Amazon Bedrock Provider

BedrockProvider implements the AWS Bedrock ConverseStream API.

Usage

#![allow(unused)]
fn main() {
use yoagent::provider::{ApiProtocol, ModelConfig};

// Bedrock has no dedicated ModelConfig preset — build one with `custom`.
// Credentials come from the environment (see Authentication below).
let agent = Agent::from_config(ModelConfig::custom(
    ApiProtocol::BedrockConverseStream,
    "bedrock",
    "https://bedrock-runtime.us-east-1.amazonaws.com",
    "anthropic.claude-opus-4-8",
    "Claude Opus 4.8",
));

// Or pass credentials explicitly:
//   .with_api_key(bedrock_api_key)                       // Bedrock API key
//   .with_api_key("ACCESS_KEY_ID:SECRET_ACCESS_KEY")      // IAM, SigV4-signed
//   .with_api_key("ACCESS_KEY_ID:SECRET_ACCESS_KEY:SESSION_TOKEN")
}

Authentication

Two AWS mechanisms are supported:

  • Bedrock API keys — a bearer token, sent as Authorization: Bearer <key> (see AWS's Bedrock API keys guide). AWS's environment variable for it is AWS_BEARER_TOKEN_BEDROCK.
  • IAM credentials — an access key id, secret access key and optional session token. The request is signed with Signature Version 4: the secret key never leaves the process; the request carries only the access key id, an HMAC signature, x-amz-date, x-amz-content-sha256 (the hash of the exact body sent) and, with a session token, x-amz-security-token.

Which is used, first match wins:

  1. An authorization header in ModelConfig.headers (any case). It is sent as given and nothing else is added — for pre-computed auth or a signing proxy.
  2. An explicit API key (with_api_key):
    • access_key_id:secret_access_key or access_key_id:secret_access_key:session_token is IAM credentials (SigV4). An empty key id or secret is an error, and so is a temporary access key id (ASIA…) without its session token.
    • a value without : is a Bedrock API key (bearer) — unless it evidently is not one: a value with whitespace or control characters, one starting with AKIA/ASIA, one with characters outside base64 (A–Z a–z 0–9 + / = - _), or a bare 40-character secret key is refused, so IAM credentials joined with anything but : are never sent as a bearer token. A key that does not start with bedrock-api-key- (short-term keys) or ABSK (long-term keys, as observed — AWS does not document the prefix) is sent, with a one-time warning.
  3. With no API key, the environment, read each time a request is built: AWS_BEARER_TOKEN_BEDROCK (same checks), then AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (+ AWS_SESSION_TOKEN, required for ASIA… keys). One of the pair without the other, or a variable that is not valid Unicode, is an error naming the variable.

On wasm32 (Cloudflare Workers) there are no environment variables, so step 3 never finds credentials: pass them with with_api_key (a Bedrock API key, or access:secret[:token]). The SigV4 region must then come from the endpoint host, since AWS_REGION / AWS_DEFAULT_REGION cannot be set there; a proxy base_url without a region in its host cannot be SigV4-signed on wasm32 (send an authorization header and let the proxy sign).

For a BedrockConverseStream config, Agent, SubAgentTool and LlmCompaction never resolve a key themselves — whatever the config's provider string ("bedrock", "aws-bedrock", …), so a generic API_KEY or YOAGENT_API_KEY is never sent to AWS. They pass an empty key, and step 3 applies on every request: changing the variables (for example exporting refreshed STS credentials) takes effect on the next request. provider::resolve_api_key("bedrock") is still available for your own code (AWS_BEARER_TOKEN_BEDROCK first, then access:secret[:token] from the IAM variables). An explicit with_api_key always wins over the environment.

Every authentication failure — no credentials, a malformed value, a value that cannot go in an HTTP header (a newline, non-ASCII), no region — is ProviderError::Auth before anything is sent, and no error message contains a credential. Credential headers are marked sensitive, so they are redacted from Debug output.

Other headers in ModelConfig.headers are sent but not signed. With SigV4, a header the signer sets itself (host, content-type, x-amz-date, x-amz-security-token, x-amz-content-sha256) is refused: it would be sent twice and fail the signature. Credentials are never refreshed by yoagent, and there is no support for profiles, ~/.aws/credentials, SSO or instance roles — export the variables (for example with aws configure export-credentials --format env) or pass them explicitly. A 403 saying the signature expired or is not yet current means the system clock is off by more than five minutes; the error says so.

Signing proxies

Before this version yoagent never signed: without an authorization header it sent Authorization: Bearer access:secret, and a local signing proxy (such as aws-sigv4-proxy) re-signed the request. This is a behaviour change: with IAM credentials yoagent now signs itself, and with a proxy base_url (for example http://localhost:8080) there is no region in the host, so the request fails with ProviderError::Auth unless AWS_REGION is set. Either:

  • set AWS_REGION (yoagent signs for the proxy's host, and the proxy re-signs for AWS as before), or
  • put a placeholder authorization header in ModelConfig.headers, so yoagent sends no credentials at all and leaves signing to the proxy.

Region (SigV4)

The signing region is, in order:

  1. the region in the endpoint host — bedrock-runtime.<region>.amazonaws.com, bedrock-runtime-fips.<region>.amazonaws.com, the dual-stack bedrock-runtime.<region>.api.aws, the China (amazonaws.com.cn, api.amazonwebservices.com.cn) and European Sovereign Cloud (amazonaws.eu, api.amazonwebservices.eu) forms, or a VPC endpoint vpce-….bedrock-runtime.<region>.vpce.amazonaws.com (the suffixes are the partition DNS suffixes in AWS's endpoint rules for Bedrock Runtime);
  2. AWS_REGION;
  3. AWS_DEFAULT_REGION.

The host wins because a signature for any other region than the endpoint's is rejected. A custom endpoint (proxy, private DNS) needs AWS_REGION; without a region, SigV4 fails with ProviderError::Auth before sending. The signing name is bedrock (Bedrock Runtime's signing name, not the bedrock-runtime endpoint prefix). Bearer tokens need no region.

A trailing / on base_url is dropped, and the signed path is normalized the way AWS normalizes it (empty and . segments removed).

The model id is percent-encoded in the request path as the AWS SDKs send it (anthropic.claude-sonnet-5-v1:0 → anthropic.claude-sonnet-5-v1%3A0, and the / in an inference-profile ARN → %2F), and encoded once more in the signature's canonical request, as SigV4 requires for every service but S3.

The signer is checked against AWS's published SigV4 test vectors and against a mock server that recomputes each signature from the request it received. Like the rest of this provider, it has not been run against a live Bedrock endpoint.

API Details

  • Endpoint: {base_url}/model/{model}/converse-stream (model id percent-encoded)
  • Default base URL: https://bedrock-runtime.us-east-1.amazonaws.com
  • Protocol: ApiProtocol::BedrockConverseStream

Message Format

Bedrock uses its own content block format:

yoagentBedrock API
Content::Text{"text": "..."}
Content::Image{"image": {"format": "...", "source": {"bytes": "..."}}}
Content::ToolCall{"toolUse": {"toolUseId": "...", "name": "...", "input": ...}}
Message::ToolResult{"toolResult": {"toolUseId": "...", "content": [...], "status": "success"}}
System promptsystem array of text blocks
ToolstoolConfig.tools[].toolSpec
Max tokensinferenceConfig.maxTokens

Thinking

ThinkingLevel is sent as Anthropic's legacy budget-based thinking (additionalModelRequestFields.thinking.budget_tokens); budgets per level are in the ThinkingLevel table. Bedrock does not raise maxTokens above the budget, so set max_tokens higher yourself (for Max, above 30,720). Claude 4.7+ models (Opus 4.7/4.8 and the generation after; see the Anthropic provider page) reject budget-based thinking, so thinking is not usable with them on this provider yet.

Stream Events

ConverseStream answers with binary application/vnd.amazon.eventstream frames. Each frame carries a length prelude and a trailing checksum (both CRC-32 verified), binary headers and a JSON payload. The event type is the :event-type header; the payload is the event itself, with no wrapper key. The provider buffers bytes across network chunks and decodes only complete frames.

EventPayloadWhat yoagent does
messageStart{role}—
contentBlockStart{contentBlockIndex, start: {toolUse: {toolUseId, name}}}Opens a tool call (ToolCallStart)
contentBlockDelta{contentBlockIndex, delta: {text} | {toolUse: {input}} | {reasoningContent: {text} | {signature} | {redactedContent}}}Text, thinking and tool-input deltas, accumulated per block index
contentBlockStop{contentBlockIndex}Parses the tool call's accumulated input (ToolCallEnd)
messageStop{stopReason}Sets the stop reason (below)
metadata{usage: {inputTokens, outputTokens, totalTokens, cacheReadInputTokens?, cacheWriteInputTokens?}, metrics}Usage, including cache reads and writes

Stop reasons: end_turn / stop_sequence → Stop, tool_use → ToolUse, max_tokens → Length, guardrail_intervened / content_filtered → Refusal, model_context_window_exceeded → Error (detected as a context overflow), malformed_model_output / malformed_tool_use → Error.

Tool input that does not parse (cut off at the token limit) and tool blocks that never receive contentBlockStop are not run: the loop answers them with an error tool result. Server-side tool blocks and image, citation and tool-result content are not surfaced; each is dropped with a warning (once per block), as is any union member or event type added after this was written.

Reasoning is kept for replay: a signed reasoning block is sent back as reasoningText {text, signature}, and encrypted reasoning (redactedContent) is kept in Content::Thinking::redacted (with redacted_protocol set to BedrockConverseStream) and sent back as redactedContent. Encrypted reasoning from another API is not: AWS does not document redactedContent as the same bytes as the Anthropic API's redacted_thinking data, so an Anthropic redacted block is skipped here with a warning (and Bedrock's is skipped by the Anthropic provider). Reasoning without a signature is sent back as reasoningText {text} with no signature key (the signature is optional in ReasoningTextBlock, and Bedrock's non-Claude reasoning models such as gpt-oss never sign). The exception is a Claude model: Claude verifies signatures, so unsigned reasoning (which can only come from another provider after a model switch) is not replayed to it. A signature is never sent as "". Signature deltas are appended.

If the stream ends after messageStop without a metadata event, the turn reports zero tokens (as the other providers do when usage never arrives) and a warning with usage_missing = true is logged inside the llm_stream span.

Errors are never an empty successful turn:

  • An exception frame (:message-type: exception) is classified by the HTTP status AWS documents for it: throttlingException (429) and serviceUnavailableException (503) are retryable, a validationException whose message reports an over-long input is a context overflow, and the rest (modelStreamErrorException, internalServerException, …) are API errors.
  • A checksum mismatch or malformed frame is an error.
  • A dropped connection, a body that ends inside a frame, or a stream that ends without messageStop is a retryable network error — unless messageStop and metadata have both arrived, in which case the response is complete and is kept (with a warning) rather than retried and billed again.
  • A 200 whose content-type is not application/vnd.amazon.eventstream is an error carrying an excerpt of the body.

This is tested with mock frames built to AWS's documented format, not against a live Bedrock endpoint.