Skip to content
↑↓Navigate↵SelectescClose

AWS Bedrock Connections

Wiring Nexus to AWS Bedrock foundation models via the Converse API

How an operator wires Nexus to AWS Bedrock. The gateway routes POST /v1/chat/completions to the Bedrock runtime Converse operation, and a request with stream: true to ConverseStream, translating the OpenAI-shaped request into Converse and the Converse reply back into an OpenAI chat.completion or OpenAI-shaped SSE. Tool calls translate in both directions on both paths. Authentication is AWS Signature Version 4 over the exact bytes sent, so the credential never travels as a header value.

Two provider ids share this adapter, one per AWS partition: bedrock for the commercial partition and bedrock-gov for AWS GovCloud. They are separate providers with separate credentials, separate registry rows, and separate prices; everything below applies to both except where the partition is called out.

POST /v1/responses and embeddings are not dispatched to this provider; each returns a structured unsupported error.

  1. Registry endpoints first. A credential (platform or BYOK) for bedrock routes nothing until a registry endpoint row exists for the model.
  2. Platform connection for Wallet (pay-as-you-go) traffic, or a tenant BYOK connection per project — or both.

The credential needs two Bedrock actions, scoped to the models it may serve. InvokeModel covers Converse and InvokeModelWithResponseStream covers ConverseStream; without the second, a stream: true request is refused with AccessDeniedException, which the gateway surfaces as a 502:

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "NexusBedrockInvoke",
"Effect": "Allow",
"Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
"Resource": "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-haiku-20241022-v1:0"
}
]
}

Widen Resource to the model ids the connection serves. Drop bedrock:InvokeModelWithResponseStream to serve buffered completions only; a stream: true request then fails rather than falling back to a buffered answer. The account must also have model access granted for those models in the Bedrock console; without it Bedrock answers AccessDeniedException, which the gateway surfaces as a 502 and records on the request’s audit event.

Both credential tables carry the same non-secret metadata object, validated at create time:

{
"region": "us-east-1"
}
  • region (required) — an operator-supplied SigV4 region label. It selects the default runtime host https://bedrock-runtime.{region}.amazonaws.com and remains the signing region when a custom base URL is set. Nexus requires a non-empty value without URL delimiters or control characters, but does not classify it into a public-cloud partition; custom and isolated labels are accepted on either provider lane.

Unknown metadata fields are rejected by name. To send traffic somewhere other than the regional host — a FIPS endpoint, a VPC interface endpoint, or a gateway in front of Bedrock — set the lane’s base URL rather than a metadata field; see Where a lane’s upstream comes from.

The secret stored with the connection is the AWS access key pair, packed as one JSON object:

{ "access_key_id": "AKIA...", "secret_access_key": "..." }

Both fields are required and non-empty; unknown fields are rejected. The same validator runs on the platform-key set path and on BYOK create, so a malformed credential is refused before it is encrypted. Never place credential material in the metadata.

Two routes seed Bedrock endpoints: bedrock-csv collects a whole region’s catalog with prices, and endpoints upsert writes one row by hand.

nexus-registry-generator bedrock-csv lists the foundation models one account can see in one region and writes the operator catalog and rates CSV pair that from-csv converts into an artifact:

Terminal window
just registry-bedrock-csv dist/bedrock --prices \
--region us-east-1 --context-length 200000 --max-output-tokens 8192
just registry-from-csv bedrock \
dist/bedrock/catalog.csv dist/bedrock/rates.csv dist/bedrock/bedrock.json
just registry-import dist/bedrock/bedrock.json

The region and credential come from AWS_BEDROCK_COMMERCIAL_REGION, AWS_BEDROCK_COMMERCIAL_ACCESS_KEY_ID, and AWS_BEDROCK_COMMERCIAL_SECRET_ACCESS_KEY; --region, --access-key-id-env, and --secret-access-key-env override them. Add --catalog-only to emit every row disabled for staged review, and --force to replace existing CSVs.

The credential needs two read actions, which are not the invoke action above:

Action Effect when missing
bedrock:ListFoundationModels the command fails; nothing can be collected
bedrock:ListInferenceProfiles models invocable only through a profile are reported and emitted disabled; the run continues

A model becomes a routable row only when its modelLifecycle.status is ACTIVE and it is invocable: inferenceTypesSupported contains ON_DEMAND, in which case provider_model_id is the model id, or it contains INFERENCE_PROFILE and exactly one listed profile routes to it, in which case provider_model_id is that profile id. A model that is neither, and a LEGACY model — which Bedrock refuses at invoke time with ResourceNotFoundException — is emitted disabled and non-routable with the reason printed. A model that serves no text output produces no row, also with the reason printed. Two model ids that reduce to one canonical model id produce no row either, because a registry endpoint is one row per model.

Capability columns state what the adapter dispatches for the row, not everything the model can do, so GET /v1/models agrees with what a request actually gets: chat and tools are true, streaming is the listing’s responseStreamingSupported for that model, and responses, embeddings, images, and moderations are false.

Capability columns are claims, and tools is an assumption

Section titled “Capability columns are claims, and tools is an assumption”

Review every capability column before importing a generated pair. from-csv validates the shape of a row, never the truth of it, and it reads a blank boolean cell as false — so a column asserts something whether or not a source supplied it. The two ways of getting one wrong fail asymmetrically:

  • Understated, the capability disappears in silence. The gateway advertises it as absent in GET /v1/models, every client that reads the catalog stops sending the field, and the feature is gone with no error raised anywhere.
  • Overstated, every request that uses it fails at call time. Bedrock rejects it upstream and the caller receives a provider error it cannot act on.

Of the columns bedrock-csv writes, only chat and streaming come from AWS: chat from the listing’s modality lists and streaming from its responseStreamingSupported. tools is an assumption. No AWS source reports whether a model implements Converse tool use, and the collector writes true on every text row because the adapter translates tools and tool_choice into a Converse toolConfig for all of them. A model that does not implement tool use answers ValidationException, which reaches the client as a 400, the same way a model that does not implement Converse at all does — so the assumption is paid for by the caller at call time rather than hidden in the catalog, but it is still an assumption, and a row known to be wrong should be corrected in the CSV before import. The collector names it as an assumption in the capability review it prints at the end of every run.

reasoning, the input and output modalities, context_length, and max_output_tokens are reported by nothing and asserted anyway: reasoning is false and the modalities are text-in/text-out on every row, and the two token limits are whatever --context-length and --max-output-tokens were given, repeated across every row.

--prices reads the public AWS Price List bulk API, which needs no credential. Only the standard on-demand token meters are used: a product must publish feature On-demand Inference and inferenceType exactly Input tokens or Output tokens, so the priority, flex, batch, and provisioned meters AWS publishes under the same model name are counted and dropped. A published 1K tokens amount is divided by one thousand exactly and written to the rates CSV in the per-million-token denomination that column carries. A price is filled in only where the model’s providerName and modelName equal a published product’s provider and model exactly — a near name is left blank and reported, never substituted — and so is a model whose meter has more than one published amount. Every price observed, with the source URL, price-list version, publication date, and the SHA-256 of the bytes parsed, is written to pricing-evidence.json beside the CSVs.

Blank prices and blank context lengths are refused by from-csv, quoting the line and column, so an incomplete pair cannot become an artifact.

AWS does not publish a price for every model it serves, so a whole-region collection normally contains some models the pass could not price. Rather than editing them out by hand, pass --skip-unpriced to from-csv: it imports the models priced for both the input and output token meters, and names every model it dropped and why.

Terminal window
just registry-from-csv bedrock \
dist/bedrock/catalog.csv dist/bedrock/rates.csv dist/bedrock.json --skip-unpriced

The skipped models stay out of the registry entirely rather than arriving at a guessed rate. To serve one of them, fill its price in the rates CSV from the model’s AWS pricing page and re-run without the flag, or write the single row with endpoints upsert.

nexus registry models endpoints upsert writes the model row, a rate card, and its activation, marking the endpoint managed_by = 'operator' and pricing_pinned = true; artifact import’s retirement pass skips exactly those rows, so a hand-seeded Bedrock row survives imports for other providers.

Terminal window
nexus registry models endpoints upsert \
--model claude-3-5-haiku \
--provider bedrock \
--provider-model-id 'anthropic.claude-3-5-haiku-20241022-v1:0' \
--base-url https://unused.invalid \
--context-length 200000 \
--max-completion-tokens 8192 \
--prompt-token 0.0000008 \
--completion-token 0.000004 \
--effective-from 2026-07-01T00:00:00Z

provider_model_id carries the Bedrock model id exactly as Bedrock spells it, including the :0 version suffix; it becomes the {modelId} path segment. base_url is required by the registry schema and the adapter reads it as the lane default, below a connection override and above the regional host. Write https://unused.invalid when the lane declares no default; the adapter treats that value as unset. A real host here redirects every request on the lane that has no connection override. Prices are per token, not per million.

Seed bedrock-gov rows the same way, with --provider bedrock-gov and that partition’s own prices. The two partitions are separate providers, so a GovCloud row never inherits a commercial price and retiring one partition’s rows leaves the other’s untouched.

A provider lane represents one upstream environment. Its origin resolves in three steps:

  1. the connection base_url column, a per-scope override;
  2. the lane default from the imported artifact’s catalog[].base_url;
  3. the regional host, https://bedrock-runtime.{region}.amazonaws.com.

The lane default means a hand-edited artifact defines where its lane lives, without a connection override. A catalog row carrying the placeholder https://unused.invalid (what the generator writes) counts as “no lane default”, so step 3 applies. Rows within a lane are expected to agree; the first usable value wins.

In every case SigV4 signs with metadata.region, never with the host actually dialled, so pointing a lane at a FIPS endpoint, a VPC interface endpoint, or a proxy keeps the credential scope correct:

Terminal window
# A FIPS endpoint as a lane of its own, or as a connection override.
--base-url https://bedrock-runtime-fips.us-gov-west-1.amazonaws.com \
--metadata '{"region":"us-gov-west-1"}'

A second environment for the same backend is a second lane, not an override: import an artifact whose name mints the lane and whose provider selects this adapter, for example {"name": "bedrock-gov-fips", "provider": "bedrock-gov"}. That gives the environment its own credential, prices, and import history.

The connection override remains for the cases a lane cannot express — one org behind its own egress proxy, or a loopback origin for offline checks. Because it silently outranks the catalog, credentials platform-keys set and get print a note on stderr when the two differ.

The adapter requires an origin with no path, query, or fragment. Non-loopback HTTP is refused; loopback HTTP is accepted so a connection can point at a local upstream for offline checks. --base-url is a column, not a metadata field, and a base_url key inside metadata is rejected.

This gateway’s own x-nexus-request-id header is sent upstream but deliberately excluded from the SigV4 signed-header set, so an intermediary that strips or rewrites it cannot turn a correlation header into InvalidSignatureException.

Terminal window
printf '{"access_key_id":"%s","secret_access_key":"%s"}' \
"$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" \
| nexus credentials platform-keys set \
--provider bedrock \
--metadata '{"region":"us-east-1"}' \
--secret-stdin

Scope flags (--workspace, --org, --project) narrow the key as with any platform key. platform-keys list and get print the metadata column, so the region a key serves is visible without decrypting anything. platform-keys validate decrypts the secret and checks that it parses as the access key pair; there is no live Bedrock probe, so a --live run reports healthy (no live probe) on a well-formed credential and failed on one that does not decrypt or does not carry both fields.

Via the console: Settings → Provider keys → Register provider key, choose Amazon Bedrock, and fill in the region, the optional endpoint URL, and the access key pair. Via REST:

Terminal window
curl -X POST "$CONTROL_PLANE/api/provider-keys" \
-H 'content-type: application/json' \
-d '{
"org_id": "org_...",
"project_id": "proj_...",
"provider": "bedrock",
"name": "primary",
"secret": "{\"access_key_id\":\"AKIA...\",\"secret_access_key\":\"...\"}",
"metadata": {"region": "us-east-1"}
}'

The gateway resolves the primary connection; other names are stored but nothing routes to them until a selector targets them (the console flags such connections as Not routed).

A chat completion pinned to bedrock/claude-3-5-haiku posts to:

{origin}/model/{provider_model_id}/converse
{origin}/model/{provider_model_id}/converse-stream

— the second when the request carries stream: true — with Authorization: AWS4-HMAC-SHA256 … and x-amz-date produced by signing the request with the connection’s key pair against the service name bedrock and the connection’s region. Both operations take the same request body, so translation is identical; only the response framing differs.

The request body is the Converse shape: system messages become system text blocks, user and assistant messages become messages[].content[].text, and max_tokens, temperature, and top_p become inferenceConfig.maxTokens, .temperature, and .topP. A message with no text content, or a role Bedrock chat completions do not accept, fails as a client error before any upstream call.

The reply’s content blocks become the assistant message, stopReason becomes finish_reason (max_tokens → length, content_filtered and guardrail_intervened → content_filter, tool_use → tool_calls, everything else → stop), and usage.inputTokens / usage.outputTokens become the prompt and completion token counts on the request analytics row, priced from the operator-pinned rate card.

tools becomes Converse toolConfig.tools[].toolSpec: the function name, its description when it has one, and parameters as inputSchema.json (an empty object schema when the function declares no parameters). tool_choice becomes toolConfig.toolChoice:

tool_choice Converse
absent no toolChoice
"auto" {"auto": {}}
"required" {"any": {}}
"none" no toolConfig at all
{"type": "function", "function": {"name": "…"}} {"tool": {"name": "…"}}

"none" forbids a tool call while leaving the declarations in the request; Converse has no such setting, and a tool set the model may not use is observably a request with no tool set, so the whole block is omitted and no tool call can come back. A tool_choice with no tools to choose from, a named function tools does not declare, and a tool that is not a function tool are each refused as a 400 naming the problem rather than dispatched without the part Converse cannot carry.

Coming back, each toolUse content block becomes one OpenAI tool_calls entry — toolUseId as id, type function, and the input object serialized into the arguments JSON string — and stopReason: "tool_use" becomes finish_reason: "tool_calls".

A follow-up turn round-trips. An assistant message carrying tool_calls (with content null, as OpenAI sends it) becomes an assistant turn of toolUse blocks, and each subsequent tool-role message becomes a toolResult block matched to its call by tool_call_id. Several results for one assistant turn ride in a single user turn, because Converse rejects two user turns in a row. A tool call without an id, a tool message without a tool_call_id, and arguments that are not JSON are client errors raised before any upstream call.

ConverseStream answers with AWS event-stream binary framing (application/vnd.amazon.eventstream), not SSE: a prelude carrying the total and headers lengths with a CRC over it, the frame headers, the payload, and a CRC over the whole message. The gateway decodes that framing and translates each Converse event into the same OpenAI-shaped chat.completion.chunk frames every other streaming provider produces:

Converse event Emitted frame
messageStart delta.role
contentBlockDelta with text delta.content
contentBlockStart with toolUse delta.tool_calls[] with the id, name, and empty arguments
contentBlockDelta with toolUse delta.tool_calls[].function.arguments fragment
messageStop finish_reason
metadata terminal usage chunk, then data: [DONE]

Converse numbers every content block in one sequence, text included, while OpenAI numbers only the tool calls, so a tool block that follows text is still tool_calls[0]. A tool-use block streams its input as JSON text split across deltas, reassembled by the client exactly as with a native OpenAI stream.

metadata.usage feeds the same projection and parser as the buffered reply, so a streamed request meters and prices identically to a buffered one for the same token counts. stream_options.include_usage is honoured by that terminal chunk; any other member of stream_options is refused by name.

An exception frame mid-stream — a throttle, a model stream error, an internal error — ends the stream with the gateway’s terminal error frame (error.type = upstream_stream_error, carrying the exception name and Bedrock’s message) followed by data: [DONE], and so does framing the decoder cannot read. The response status is already committed at that point, so the attempt is not replayed and no other provider is tried; the request is recorded as a failed stream. A client that disconnects mid-stream is finalized on the same path, so the partial usage is still priced.

Bedrock’s exception envelope is normalized into the OpenAI error shape, with the exception name preserved in error.code:

Bedrock exception Status error.type
ThrottlingException, TooManyRequestsException, ServiceQuotaExceededException 429 rate_limit_error
ValidationException 400 invalid_request_error
AccessDeniedException, UnrecognizedClientException, InvalidSignatureException, ExpiredTokenException 502 upstream_error
anything else upstream status upstream_error

A 429 from ThrottlingException or TooManyRequestsException is retryable and fallback-eligible, so nexus-retries and comma-separated model routes both apply to a throttled Bedrock attempt. ServiceQuotaExceededException also returns 429, but it is classified non-retryable — replaying the identical request against an exhausted quota only spends budget before failing the same way — so it falls forward to the next candidate without consuming a retry. ValidationException and the four credential rejections are non-retryable for the same reason. See 0026 - Shared per-request retry budget with full-jitter backoff and per-try timeout re-arming.

Point a connection at a local mock with a loopback base URL. The region still has to be a real region name, because it is part of the signature:

Terminal window
printf '{"access_key_id":"AKIDEXAMPLE","secret_access_key":"test-secret"}' \
| nexus credentials platform-keys set --provider bedrock --secret-stdin \
--metadata '{"region":"us-east-1"}' \
--base-url 'http://127.0.0.1:18081'

Then route traffic and confirm the metering used the seeded rate card:

Terminal window
curl -sS "$NEXUS_BASE_URL/chat/completions" \
-H "authorization: Bearer $NEXUS_API_KEY" -H 'content-type: application/json' \
-d '{"model":"bedrock/claude-3-5-haiku","messages":[{"role":"user","content":"Reply with: ok"}],"max_tokens":24}'
curl -sSN "$NEXUS_BASE_URL/chat/completions" \
-H "authorization: Bearer $NEXUS_API_KEY" -H 'content-type: application/json' \
-d '{"model":"bedrock/claude-3-5-haiku","messages":[{"role":"user","content":"Reply with: ok"}],"max_tokens":24,"stream":true}'
$COMPOSE exec -T clickhouse clickhouse-client -q "
SELECT model, billing_status, prompt_tokens, completion_tokens, total_cost_usd
FROM vortex.requests WHERE provider = 'bedrock' ORDER BY request_id"

billing_status = priced with total_cost_usd equal to tokens times the seeded per-token price is the signal that the registry row, the connection, and the meter agree.