feat(llm): Amazon Bedrock provider — Converse codec, SigV4 + API-key auth (#459)

Adds **Amazon Bedrock** as an opt-in built-in provider, over Bedrock's
unified **Converse / ConverseStream** API. One codec serves every
Converse-capable family — Claude, Amazon Nova, Meta Llama, Mistral,
DeepSeek, Moonshot Kimi, Z.AI GLM, MiniMax, NVIDIA Nemotron, and OpenAI
gpt-oss — because AWS translates the envelope to each model's native
dialect server-side. Auth is either **AWS SigV4** (the default
credential chain — env / profile / IMDS / IRSA / SSO, resolved per
request so sessions refresh) or a **Bedrock API key**
(`AWS_BEARER_TOKEN_BEDROCK`, bearer). Disabled by default (the Ollama /
OpenRouter opt-in pattern).

This is the redo of #459's original Claude-only `InvokeModel` adapter,
rebuilt on the gateway-refactor seams (#481–#497). @depopry's SigV4
signer, AWS event-stream frame decoder, `BedrockAuth`, the `aws_sigv4`
credential grammar, `AdapterKind::Bedrock`, region-from-base_url, and
the lean-deps decision are preserved and authored by him on the first
two commits; the per-family `BedrockCodec` trait he wrote turned out to
be the crate-wide `Codec` seam in miniature, so the refactor promoted
exactly that shape. The original Claude-only description is preserved in
a comment below.

## What's here

- **`AdapterKind::Bedrock` × `CodecKind::BedrockConverse`** on the
route, plus the `aws_sigv4` credential source (no static secret — the
adapter signs at request time; `fabro-auth` stays AWS-free).
*(@depopry)*
- **SigV4 signer + AWS event-stream `FrameDecoder`** on the lean AWS
stack (no `aws-sdk-bedrockruntime`; transport stays on `fabro-http`).
Re-targeted at Converse's direct-JSON stream frames; the signer resolves
credentials per request. *(@depopry)*
- **`bedrock_converse` codec** — Converse envelope (`system[]`, typed
content blocks, `inferenceConfig`, `toolConfig`), prompt caching via
`cachePoint`, thinking-signature round-trip through `reasoningContent`,
usage mapped onto the disjoint `TokenCounts` buckets,
`provider_options.bedrock` passthrough. Plus the adapter shell and an
event-stream byte loop beside the transport's shared SSE loop.
- **Catalog**: `bedrock.toml` (Claude incl. Fable 5, Nova 2, Llama 4,
Mistral, DeepSeek, Kimi, GLM, MiniMax, Nemotron, gpt-oss — cross-region
inference-profile ids, per-model `billing_policy` so Claude bills
Anthropic-style) and a companion **`bedrock-openai`** provider for
GPT-5.5/5.4 over the `bedrock-mantle` Responses endpoint (pure config
over the existing `openai_responses` codec, zero new code).
- Secrets registry (`AWS_BEARER_TOKEN_BEDROCK`), gitleaks rules for both
Bedrock key formats, the `docs/integrations/bedrock` guide, and live e2e
tests.

## Live verification (confirmed end-to-end against a real AWS account)

Verified on a real Bedrock account (us-east-2, SigV4 + bearer):

- **SigV4 + Converse** — multiple families (Claude, Nova, DeepSeek, …)
via the full settings → catalog → route → adapter → codec path.
- **ConverseStream** — streaming deltas through the workflow engine.
- **Multi-turn tool use** — agent loop with tool calls round-tripping
(no-arg tools included).
- **Multi-model routing** — Claude + DeepSeek pinned in one run through
the single Converse codec.
- **mantle Responses** — `openai.gpt-5.5` answered via the
`bedrock-openai` provider (bearer auth).

The exercise caught and fixed several issues that unit tests (static
creds, mocked transports) could not — see the follow-up commits below.

## Follow-up fixes from live testing (commits on top of the foundation)

1. **Worker AWS env** — the workflow worker scrubs its env to an
allowlist, so SigV4 (which re-resolves from the ambient chain per
request) couldn't work through `fabro run`. The AWS credential-chain
inputs now cross into the worker.
2. **Vault bearer key** — Bedrock was the only key-based provider
missing a `vault:` credential ref, so `fabro secret set
AWS_BEARER_TOKEN_BEDROCK` silently didn't feed it. Now resolves env →
vault → SigV4.
3. **Converse tool-encoding hardening** — a no-arg tool call's
`toolUse.input` is now a `{}` object (Bedrock rejects null), and every
tool `inputSchema` gets a top-level `type: "object"` (strict families
like DeepSeek reject a typeless schema Claude tolerates).
4. **Nova output cap** — `amazon.nova-2-lite` max_output 65536 → 65535
(Bedrock's per-request limit).

Earlier fixes already folded into the foundation commits: the
`aws-config` sleep-impl (default chain panicked) and AWS error-body
decoding (top-level `message`/`Message`/`__type` → proper messages
instead of "Unknown error").

## Manual testing & setup

See `docs/integrations/bedrock` — now documents the non-obvious account
setup that live testing surfaced: the per-Region Anthropic use-case
approval, `aws-marketplace:Subscribe` for third-party models, the Fable
5 / Mythos-class data-sharing opt-in, and the bearer-vs-SigV4 precedence
override for running Converse + mantle side by side.

## Open decision / discussion

- **Model-id naming** — Bedrock rows use dotted ids mirroring Bedrock's
native inference-profile ids (`us.anthropic.claude-sonnet-4-6`,
`openai.gpt-5.5`), which also makes them the wire `api_id`. Third scheme
alongside bare ids and OpenRouter's `vendor/model` slashes. No collision
risk (enforced at catalog build). Open to a uniform scheme if preferred.
- **`BEDROCK_API_KEY` alias** — see the comment thread; the AWS console
hands some users `export BEDROCK_API_KEY=` while the SDK-standard var is
`AWS_BEARER_TOKEN_BEDROCK`. Question of whether to accept both.

## Deferred (named follow-ups)

- **`qwen.qwen3-coder-next`** — omitted pending a verified Bedrock
model/inference-profile id (its fabro id isn't a valid Bedrock
identifier; needs an explicit `api_id`). Re-add once confirmed via `aws
bedrock list-inference-profiles`.
- **Claude Mythos 5** — Anthropic-Messages-only on `bedrock-mantle`
(limited preview).
- **Converse structured output** (`response_format` rejected with a
clear error).
- **`reasoning_effort` on Converse rows** via
`additionalModelRequestFields` (the `bedrock-openai` GPT rows already
accept effort levels).
- **CountTokens** route (`count_input_tokens` returns `None`).

## Verification

`cargo nextest run --workspace`: green except the pre-existing
environment-dependent fabro-workflow failures (identical on main).
clippy `-D warnings` + pinned-nightly fmt clean. Codec unit tests +
adapter httpmock tests + frame-decoder/signer locks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Scott Werner <scott@sublayer.com>
Co-authored-by: Scott Werner <stwerner@vt.edu>
This commit is contained in:
Ryan Neal 2026-06-16 16:46:49 +01:00 committed by GitHub
parent dbf4829b47
commit d5b2220ed3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 4457 additions and 140 deletions

661
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -33,6 +33,22 @@ fs2 = "0.4"
base64 = "0.22"
bytes = "1"
tokio-util = "0.7"
# AWS building blocks for the native Bedrock adapter. Lean stack: request
# signing + credential chain + event-stream decode only. Transport for the
# actual Bedrock inference calls stays on fabro-http; the full
# aws-sdk-bedrockruntime (and its parallel hyper stack) is not pulled in.
# aws-config keeps its DEFAULT features on purpose: `rt-tokio` supplies the
# TokioSleep impl the credential chain's retry requires (without it,
# resolving the default chain panics with "an async sleep implementation is
# required"), and `sso`/`credentials-process` make from_default_chain's
# documented SSO/credential-process support real. `rustls` pins the TLS
# backend for credential-resolution HTTP.
aws-config = { version = "1", features = ["behavior-version-latest", "rustls"] }
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
aws-sigv4 = "1"
aws-smithy-eventstream = "0.60"
aws-smithy-runtime-api = "1"
aws-smithy-types = "1"
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4"
jsonschema = { version = "0.42", default-features = false }

View file

@ -141,6 +141,16 @@ Fabro ships an [OpenRouter](/integrations/openrouter) provider definition with a
enabled = true
```
### Amazon Bedrock
Fabro ships an [Amazon Bedrock](/integrations/bedrock) provider definition with a curated multi-vendor catalog over Bedrock's Converse API, disabled by default. Enable it and authenticate with a Bedrock API key or AWS SigV4 credentials:
```toml title="settings.toml"
[llm.providers.bedrock]
enabled = true
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
```
### Ollama
Fabro ships an Ollama provider definition that is disabled by default. Enable it in settings when you want Fabro to route through a local Ollama server:

View file

@ -95,6 +95,7 @@
"integrations/github",
"integrations/daytona",
"integrations/litellm",
"integrations/bedrock",
"integrations/openrouter",
"integrations/slack",
"integrations/brave-search"

View file

@ -0,0 +1,162 @@
---
title: "Amazon Bedrock"
description: "Route Fabro models through Amazon Bedrock with SigV4 or API-key auth"
---
[Amazon Bedrock](https://aws.amazon.com/bedrock/) hosts Anthropic, Amazon, Meta, Mistral, DeepSeek, Qwen, Moonshot, Z.AI, MiniMax, NVIDIA, and OpenAI open-weight models behind one AWS endpoint. Fabro ships a disabled `bedrock` provider entry with a curated model catalog over Bedrock's unified Converse API, so you can opt in from `settings.toml` without changing Fabro code.
## Prerequisites
- An AWS account with [Bedrock model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) granted for the models you want (see [Model access and approvals](#model-access-and-approvals))
- Either a [Bedrock API key](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) or working AWS credentials (environment keys, profile, IMDS, IRSA, SSO)
## Model access and approvals
Access is granted per Region and varies by model family — enabling the provider in Fabro is necessary but not sufficient.
- **IAM.** Converse and ConverseStream have no dedicated IAM actions; they're authorized by `bedrock:InvokeModel` and `bedrock:InvokeModelWithResponseStream`. A Bedrock API key additionally needs `bedrock:CallWithBearerToken`.
- **Anthropic (Claude) models** require a one-time use-case submission in the Bedrock console (**Model access**) before first use, and the grant is **per Region** — approval in `us-east-1` does not cover `us-east-2`. An un-approved Region returns `AccessDeniedException`.
- **Third-party models** (OpenAI gpt-oss, DeepSeek, Qwen, Moonshot, Z.AI, MiniMax, NVIDIA) auto-enable on first call, which needs `aws-marketplace:Subscribe` and `aws-marketplace:ViewSubscriptions` on the calling principal. The first call may take a moment while the subscription activates.
- **Claude Fable 5 / Mythos-class** models additionally require opting into data sharing via the [Data Retention API](https://docs.aws.amazon.com/bedrock/latest/userguide/data-retention.html) (`provider_data_share`, 30-day retention) **before** they can be invoked. With the account/project on the `default` retention mode, Converse rejects the request with *"data retention mode 'default' is not available for this model."*
## Enable the provider
Add the provider override to `~/.fabro/settings.toml`:
```toml title="settings.toml"
_version = 1
[llm.providers.bedrock]
enabled = true
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
```
The SigV4 signing region is derived from `base_url` — change it to your Region's endpoint (`https://bedrock-runtime.<region>.amazonaws.com`, FIPS and China endpoints included).
## Configure credentials
Two auth modes, tried in order:
**Bedrock API key** (simplest): store the key and Fabro sends it as a bearer token. The key is read from either `AWS_BEARER_TOKEN_BEDROCK` (AWS's canonical name, also honored by the AWS SDKs and CLI) or `BEDROCK_API_KEY` (Fabro's `<PROVIDER>_API_KEY` convention) — use whichever you prefer.
```bash
fabro secret set AWS_BEARER_TOKEN_BEDROCK bedrock-api-key-...
# or, equivalently
fabro secret set BEDROCK_API_KEY bedrock-api-key-...
# or for standalone local runs
export AWS_BEARER_TOKEN_BEDROCK=bedrock-api-key-...
```
**AWS SigV4** (IAM-scoped): with no API key configured, Fabro signs each request using the AWS default credential chain — environment keys, shared profile, EC2/ECS instance roles, IRSA/web identity, SSO. Expiring session credentials refresh automatically. The catalog declares this as the `aws_sigv4` credential source:
```toml
[llm.providers.bedrock.auth]
credentials = ["env:AWS_BEARER_TOKEN_BEDROCK", "env:BEDROCK_API_KEY", "vault:AWS_BEARER_TOKEN_BEDROCK", "vault:BEDROCK_API_KEY", "aws_sigv4"]
```
The key resolves from the process environment first (either name), then the server vault (`fabro secret set`), then falls back to SigV4 — so on a server, prefer `secret set`. To select a non-default AWS profile for SigV4, set `AWS_PROFILE` (it, and the rest of the AWS credential-chain variables, are passed through to workflow workers).
<Warning>
**Bearer-vs-SigV4 precedence.** Because the bearer key is tried before SigV4, setting `AWS_BEARER_TOKEN_BEDROCK` makes the `bedrock` (Converse) provider authenticate with that key too — not just the `bedrock-openai` mantle provider below. If your key is valid only for mantle (it lacks `bedrock:InvokeModel*` on the runtime), every Converse model then fails with *"Authentication failed."* To run Converse models on SigV4 while using a mantle-only bearer key for GPT-5.x, pin the Converse provider to SigV4 explicitly:
```toml
[llm.providers.bedrock.auth]
credentials = ["aws_sigv4"]
```
</Warning>
## Included models
The built-in catalog curates Converse-capable models, using cross-region inference profile ids (`us.`/`global.` prefixes) where on-demand access requires them:
| Fabro model ID | Notes |
| --- | --- |
| `us.anthropic.claude-sonnet-4-6` | Provider default; Anthropic cache billing |
| `us.anthropic.claude-opus-4-8` | Anthropic cache billing |
| `us.anthropic.claude-haiku-4-5` | Provider small default |
| `us.anthropic.claude-fable-5` | Frontier; sampling params pinned by Bedrock (Fabro drops `temperature`/`top_p` automatically); requires the account-level `provider_data_share` data-sharing opt-in (see [Model access](#model-access-and-approvals)) |
| `openai.gpt-oss-120b`, `openai.gpt-oss-20b` | OpenAI open-weights |
| `amazon.nova-2-lite` | Vision |
| `meta.llama4-maverick` | Vision |
| `mistral.mistral-large-3`, `mistral.devstral-2` | |
| `deepseek.v3-2` | |
| `moonshotai.kimi-k2.5`, `zai.glm-5` | |
| `minimax.minimax-m2.5`, `nvidia.nemotron-3-super` | |
Any other Converse-capable Bedrock model can be added as a settings model entry with `provider = "bedrock"` and the Bedrock model or inference-profile id as `api_id`.
Not included on this provider: Claude Mythos 5 (Anthropic-Messages-only on `bedrock-mantle`, limited preview). OpenAI's frontier models live on the companion `bedrock-openai` provider below.
## OpenAI frontier models (GPT-5.5 / GPT-5.4)
GPT-5.5 and GPT-5.4 on Bedrock are served only by the `bedrock-mantle` endpoint's OpenAI Responses API — a different surface than Converse. Fabro ships a companion `bedrock-openai` provider for them: the same AWS account and `AWS_BEARER_TOKEN_BEDROCK` key, pointed at the mantle endpoint over the OpenAI dialect.
```toml title="settings.toml"
[llm.providers.bedrock-openai]
enabled = true
# regional: change to https://bedrock-mantle.<region>.api.aws/openai/v1
```
```bash
fabro model test --model openai.gpt-5.5
```
Auth on this provider is Bedrock-API-key only (mantle SigV4 uses a different signing name than the runtime endpoint). Fabro always sends `store: false`, so nothing is retained under mantle's default 30-day response storage.
## Use Bedrock models
```bash
fabro model list --provider bedrock
fabro model test --model us.anthropic.claude-sonnet-4-6
fabro run workflow.fabro --model deepseek.v3-2
```
## Prompt caching
Claude models cache automatically when the catalog row declares `prompt_cache`: Fabro places Converse `cachePoint` blocks after the system prompt, the tool definitions, and the conversation prefix — the same placement as the direct Anthropic provider. Cache reads and writes price Anthropic-style via the per-model `billing_policy`.
## Converse extensions
Bedrock-specific request fields pass through verbatim via `provider_options.bedrock` on API/SDK requests — the keys merge into the top level of the Converse envelope:
```json
{
"model": "us.anthropic.claude-sonnet-4-6",
"provider_options": {
"bedrock": {
"additionalModelRequestFields": { "top_k": 200 },
"guardrailConfig": { "guardrailIdentifier": "gr-abc", "guardrailVersion": "1" },
"serviceTier": { "type": "flex" }
}
}
}
```
## Troubleshooting
**"no AWS credentials provider found"** — Neither an API key nor any AWS chain source resolved. Set `AWS_BEARER_TOKEN_BEDROCK` (or `BEDROCK_API_KEY`), or configure standard AWS credentials.
**`AccessDeniedException` / 403** — The IAM principal lacks `bedrock:InvokeModel*` for the model, or model access has not been granted in the Bedrock console for your Region (Claude needs the per-Region use-case approval; third-party models need `aws-marketplace:Subscribe`).
**"Authentication failed: Please make sure your API Key is valid."** — A Bedrock API key was sent but rejected by the runtime. Common cause: a mantle-scoped key used against Converse — see the bearer-vs-SigV4 [warning above](#configure-credentials). Verify the key is valid for `bedrock-runtime` in this Region, or pin Converse to `aws_sigv4`.
**"data retention mode 'default' is not available for this model"** — Fable 5 / Mythos-class models require opting into data sharing first; see [Model access and approvals](#model-access-and-approvals).
**"The provided model identifier is invalid"** — The wire id sent to Bedrock isn't a recognized model or inference-profile id. Set an explicit `api_id` (from `aws bedrock list-inference-profiles`) on the model entry.
**`ValidationException` mentioning on-demand throughput** — The model requires an inference-profile id; use the `us.`/`global.`-prefixed id from the catalog rather than the bare model id.
**`ValidationException` mentioning maximum tokens** — The requested `max_tokens` exceeds the model's per-request output cap; lower the model's `max_output` to the documented limit.
**`ThrottlingException`** — Account-level Bedrock quota; consider cross-region inference profiles or a quota increase.
## Further reading
<Columns cols={2}>
<Card title="Models" icon="microchip" href="/core-concepts/models">
How Fabro routes model IDs, providers, and fallbacks.
</Card>
<Card title="Settings Configuration" icon="gear" href="/reference/user-configuration">
Full reference for `[llm.providers.<id>]` and `[llm.models.<id>]`.
</Card>
</Columns>

View file

@ -198,7 +198,7 @@ x-team-secret = { vault = "gateway_team_secret" }
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | derived from `adapter` | Provider-owned billing algorithm for usage estimates. Override for exceptional providers such as local no-billing runtimes. |
| `base_url` | string | built-in value or adapter runtime default | Provider API base URL. Required for most custom OpenAI-compatible providers. |
| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>` and `env:<NAME>`. Literal secret strings are rejected. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>`, `env:<NAME>`, and `aws_sigv4` (sign requests from the AWS default credential chain — Bedrock). Literal secret strings are rejected. |
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ vault = "NAME" }`. |
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |

View file

@ -1016,8 +1016,8 @@ mod tests {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("bedrock".to_string(), ProviderCatalogSettings {
display_name: Some("Bedrock".to_string()),
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::OpenAi),
@ -1026,7 +1026,7 @@ mod tests {
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let args = AgentArgs {
prompt: "test".to_string(),
provider: Some("bedrock".to_string()),
provider: Some("acme-aws".to_string()),
model: None,
permissions: None,
auto_approve: false,
@ -1037,7 +1037,7 @@ mod tests {
};
let provider_id = parse_provider(&args).unwrap();
assert_eq!(provider_id, ProviderId::new("bedrock"));
assert_eq!(provider_id, ProviderId::new("acme-aws"));
assert_eq!(
profile_kind_for_provider(&catalog, &provider_id, None).unwrap(),
AgentProfileKind::OpenAi
@ -1049,8 +1049,8 @@ mod tests {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("bedrock".to_string(), ProviderCatalogSettings {
display_name: Some("Bedrock".to_string()),
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::OpenAi),
@ -1058,9 +1058,9 @@ mod tests {
});
settings
.models
.insert("bedrock-claude".to_string(), ModelCatalogSettings {
provider: Some("bedrock".to_string()),
display_name: Some("Bedrock Claude".to_string()),
.insert("acme-aws-claude".to_string(), ModelCatalogSettings {
provider: Some("acme-aws".to_string()),
display_name: Some("Acme AWS Claude".to_string()),
family: Some("claude".to_string()),
default: Some(true),
limits: Some(SettingsModelLimits {
@ -1081,7 +1081,7 @@ mod tests {
let args = AgentArgs {
prompt: "test".to_string(),
provider: None,
model: Some("bedrock-claude".to_string()),
model: Some("acme-aws-claude".to_string()),
permissions: None,
auto_approve: false,
debug: false,
@ -1092,7 +1092,7 @@ mod tests {
assert_eq!(
resolve_provider_id(&catalog, &args).unwrap(),
ProviderId::new("bedrock")
ProviderId::new("acme-aws")
);
}
@ -1101,8 +1101,8 @@ mod tests {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("bedrock".to_string(), ProviderCatalogSettings {
display_name: Some("Bedrock".to_string()),
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::OpenAi),
@ -1124,7 +1124,7 @@ mod tests {
assert_eq!(
resolve_provider_id(&catalog, &args).unwrap(),
ProviderId::new("bedrock")
ProviderId::new("acme-aws")
);
}
@ -1133,8 +1133,8 @@ mod tests {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("bedrock".to_string(), ProviderCatalogSettings {
display_name: Some("Bedrock".to_string()),
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::OpenAi),
@ -1142,9 +1142,9 @@ mod tests {
});
settings
.models
.insert("bedrock-claude".to_string(), ModelCatalogSettings {
provider: Some("bedrock".to_string()),
display_name: Some("Bedrock Claude".to_string()),
.insert("acme-aws-claude".to_string(), ModelCatalogSettings {
provider: Some("acme-aws".to_string()),
display_name: Some("Acme AWS Claude".to_string()),
family: Some("claude".to_string()),
default: Some(true),
agent_profile: Some(AgentProfileKind::Anthropic),
@ -1167,8 +1167,8 @@ mod tests {
assert_eq!(
profile_kind_for_provider(
&catalog,
&ProviderId::new("bedrock"),
Some("bedrock-claude")
&ProviderId::new("acme-aws"),
Some("acme-aws-claude")
)
.unwrap(),
AgentProfileKind::Anthropic
@ -1180,20 +1180,20 @@ mod tests {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("bedrock".to_string(), ProviderCatalogSettings {
display_name: Some("Bedrock".to_string()),
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::OpenAi),
..ProviderCatalogSettings::default()
});
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let provider_id = ProviderId::new("bedrock");
let provider_id = ProviderId::new("acme-aws");
let model_id = summarizer_model_id(&provider_id, &catalog, "bedrock-claude-sonnet-4-6");
let model_id = summarizer_model_id(&provider_id, &catalog, "acme-aws-claude-sonnet-4-6");
assert_eq!(model_id.provider(), &provider_id);
assert_eq!(model_id.model_id(), "bedrock-claude-sonnet-4-6");
assert_eq!(model_id.model_id(), "acme-aws-claude-sonnet-4-6");
}
#[test]
@ -1201,20 +1201,20 @@ mod tests {
let mut settings = LlmCatalogSettings::default();
settings
.providers
.insert("bedrock".to_string(), ProviderCatalogSettings {
display_name: Some("Bedrock".to_string()),
.insert("acme-aws".to_string(), ProviderCatalogSettings {
display_name: Some("Acme AWS".to_string()),
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://example.invalid/v1".to_string()),
agent_profile: Some(AgentProfileKind::Anthropic),
..ProviderCatalogSettings::default()
});
let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap();
let provider_id = ProviderId::new("bedrock");
let provider_id = ProviderId::new("acme-aws");
let model_id = summarizer_model_id(&provider_id, &catalog, "bedrock-claude-sonnet-4-6");
let model_id = summarizer_model_id(&provider_id, &catalog, "acme-aws-claude-sonnet-4-6");
assert_eq!(model_id.provider(), &provider_id);
assert_eq!(model_id.model_id(), "bedrock-claude-sonnet-4-6");
assert_eq!(model_id.model_id(), "acme-aws-claude-sonnet-4-6");
}
// subagent tool registration tests

View file

@ -45,7 +45,13 @@ pub struct OAuthConfig {
#[derive(Clone, PartialEq, Eq)]
pub enum ApiKeyHeader {
Bearer(String),
Custom { name: String, value: String },
Custom {
name: String,
value: String,
},
/// No static header: the request is authenticated by AWS SigV4 signing,
/// with credentials resolved from the AWS default chain at request time.
AwsSigv4,
}
fn redact_for_debug(value: &str) -> String {
@ -69,6 +75,7 @@ impl std::fmt::Debug for ApiKeyHeader {
.field("name", name)
.field("value", &redact_for_debug(value))
.finish(),
Self::AwsSigv4 => f.write_str("AwsSigv4"),
}
}
}

View file

@ -28,6 +28,9 @@ pub(crate) enum ResolvedSecret {
credential: Box<OAuthCredential>,
vault_name: String,
},
/// Opaque AWS SigV4 source: no static secret; the adapter signs requests
/// using the AWS default credential chain.
AwsSigv4,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -333,6 +336,9 @@ impl CredentialResolver {
Err(err) => Err(vault_lookup_error(provider, name, err)),
},
CredentialRef::Env(name) => Ok((self.env_lookup)(name).map(ResolvedSecret::ApiKey)),
// AWS SigV4 is an opaque source: it always "resolves" (the adapter
// signs at request time from the AWS chain), no vault/env lookup.
CredentialRef::AwsSigv4 => Ok(Some(ResolvedSecret::AwsSigv4)),
}
}
@ -379,23 +385,39 @@ impl CredentialResolver {
) -> Result<ApiCredential, ResolveError> {
let base_url = Self::provider_base_url_for_catalog(provider_id, catalog);
match secret {
// Opaque AWS SigV4 source: carry the marker so the adapter signs
// with the AWS chain; no static secret resolved here.
ResolvedSecret::AwsSigv4 => Ok(ApiCredential {
provider: provider_id.clone(),
auth_header: Some(ApiKeyHeader::AwsSigv4),
extra_headers: self.resolved_extra_headers_for_catalog(
vault,
provider_id,
catalog,
)?,
base_url,
codex_mode: false,
org_id: None,
project_id: None,
}),
ResolvedSecret::ApiKey(key) => {
let provider = catalog
.provider(provider_id)
.ok_or_else(|| ResolveError::NotConfigured(provider_id.clone()))?;
let auth_header = auth_header_for_catalog_provider(provider, key.clone())?;
let mut cred = ApiCredential {
provider: provider_id.clone(),
auth_header: Some(auth_header),
extra_headers: HashMap::new(),
base_url: None,
codex_mode: false,
org_id: None,
project_id: None,
provider: provider_id.clone(),
auth_header: Some(auth_header),
extra_headers: self.resolved_extra_headers_for_catalog(
vault,
provider_id,
catalog,
)?,
base_url,
codex_mode: false,
org_id: None,
project_id: None,
};
cred.base_url = base_url;
cred.extra_headers =
self.resolved_extra_headers_for_catalog(vault, provider_id, catalog)?;
if provider_id == &ProviderId::openai() {
apply_openai_api_env_context(&mut cred, &*self.env_lookup);
}
@ -579,6 +601,38 @@ mod tests {
);
}
#[tokio::test]
async fn sigv4_provider_resolves_to_aws_sigv4_credential() {
let dir = tempfile::tempdir().unwrap();
let vault = Vault::load(dir.path().join("secrets.json")).unwrap();
// No env credentials configured: SigV4 must still resolve.
let resolver = test_resolver(vault, Arc::new(|_| None));
let catalog = catalog_with(
r#"
[providers.bedrock]
adapter = "bedrock"
enabled = true
base_url = "https://bedrock-runtime.eu-west-1.amazonaws.com"
[providers.bedrock.auth]
credentials = ["aws_sigv4"]
"#,
);
let resolved = resolver
.resolve(
ProviderId::from("bedrock"),
CredentialUsage::ApiRequest,
&catalog,
)
.await
.unwrap();
let ResolvedCredential::Api(api) = resolved;
assert_eq!(api.provider, ProviderId::from("bedrock"));
assert_eq!(api.auth_header, Some(ApiKeyHeader::AwsSigv4));
}
#[tokio::test]
async fn resolve_returns_not_configured_for_missing_provider() {
let dir = tempfile::tempdir().unwrap();

View file

@ -23,7 +23,7 @@ impl ApiKeyStrategy {
.iter()
.filter_map(|credential_ref| match credential_ref {
CredentialRef::Env(name) => Some(name.clone()),
CredentialRef::Vault(_) => None,
CredentialRef::Vault(_) | CredentialRef::AwsSigv4 => None,
})
.collect()
})

View file

@ -102,7 +102,7 @@ fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String {
.iter()
.filter_map(|credential| match credential {
CredentialRef::Env(name) => Some(name.as_str()),
CredentialRef::Vault(_) => None,
CredentialRef::Vault(_) | CredentialRef::AwsSigv4 => None,
})
.collect::<Vec<_>>()
.join(" / ")

View file

@ -333,7 +333,7 @@ fn exec_accepts_configured_custom_provider_from_settings() {
let context = test_context!();
context.write_home(
".fabro/settings.toml",
"_version = 1\n\n[llm.providers.bedrock]\nadapter = \"openai_compatible\"\nagent_profile = \"openai\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[llm.providers.bedrock.auth]\ncredentials = [\"env:BEDROCK_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"bedrock\"\nname = \"bedrock-claude-sonnet-4-6\"\n",
"_version = 1\n\n[llm.providers.acme-aws]\nadapter = \"openai_compatible\"\nagent_profile = \"openai\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[llm.providers.acme-aws.auth]\ncredentials = [\"env:ACME_AWS_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
);
let mut cmd = context.exec_cmd();
@ -350,7 +350,7 @@ fn exec_accepts_configured_custom_provider_from_settings() {
exit_code: 1
----- stdout -----
----- stderr -----
× LLM credentials not configured for provider 'bedrock'
× LLM credentials not configured for provider 'acme-aws'
");
}
@ -398,7 +398,7 @@ fn exec_server_target_accepts_configured_custom_provider_from_settings() {
let context = test_context!();
context.write_home(
".fabro/settings.toml",
"_version = 1\n\n[llm.providers.bedrock]\nadapter = \"openai_compatible\"\nagent_profile = \"openai\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[llm.providers.bedrock.auth]\ncredentials = [\"env:BEDROCK_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"bedrock\"\nname = \"bedrock-claude-sonnet-4-6\"\n",
"_version = 1\n\n[llm.providers.acme-aws]\nadapter = \"openai_compatible\"\nagent_profile = \"openai\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[llm.providers.acme-aws.auth]\ncredentials = [\"env:ACME_AWS_API_KEY\"]\n\n[cli.exec.model]\nprovider = \"acme-aws\"\nname = \"acme-claude-sonnet-4-6\"\n",
);
let server = MockServer::start();
server.mock(|when, then| {
@ -425,7 +425,7 @@ fn exec_server_target_accepts_configured_custom_provider_from_settings() {
"expected remote server failure marker, got: {stderr}"
);
assert!(
!stderr.contains("unknown provider: bedrock"),
!stderr.contains("unknown provider: acme-aws"),
"exec should resolve custom providers from settings for remote transport: {stderr}"
);
}

View file

@ -244,7 +244,7 @@ x-team-secret = { vault = "gateway_team_secret" }
| `billing_policy` | `"openai"` \| `"anthropic"` \| `"gemini"` \| `"none"` | derived from `adapter` | Provider-owned billing algorithm for usage estimates. Override for exceptional providers such as local no-billing runtimes. |
| `base_url` | string | built-in value or adapter runtime default | Provider API base URL. Required for most custom OpenAI-compatible providers. |
| `auth` | table | omitted | API-key auth config. Omit the table entirely for providers that need no API key; any `extra_headers` are still attached. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>` and `env:<NAME>`. Literal secret strings are rejected. |
| `auth.credentials` | array<string> | required when `auth` present | Ordered credential refs. Accepted forms are `vault:<NAME>`, `env:<NAME>`, and `aws_sigv4` (sign requests from the AWS default credential chain Bedrock). Literal secret strings are rejected. |
| `auth.header` | `"bearer"` or `{ custom = "Header-Name" }` | `"bearer"` | Primary API-key header policy. Omit when the provider uses a standard bearer token. |
| `extra_headers` | table | `{}` | Additional headers attached to provider requests. Values must be typed refs: `{ literal = "..." }`, `{ env = "NAME" }`, or `{ vault = "NAME" }`. |
| `priority` | integer | `0` | Higher-priority configured providers win default selection; ties use canonical provider ID. |

View file

@ -32,6 +32,12 @@ base64.workspace = true
bytes.workspace = true
tokio-util.workspace = true
tracing.workspace = true
aws-config.workspace = true
aws-credential-types.workspace = true
aws-sigv4.workspace = true
aws-smithy-eventstream.workspace = true
aws-smithy-runtime-api.workspace = true
aws-smithy-types.workspace = true
fabro-http.workspace = true
fabro-auth = { path = "../fabro-auth" }
fabro-model = { path = "../fabro-model" }

View file

@ -90,7 +90,9 @@ fn apply_primary_auth_header(
extra_headers.insert(name, value);
None
}
None => None,
// SigV4 is not a static header; only the Bedrock adapter consumes
// the marker (it signs at request time).
Some(ApiKeyHeader::AwsSigv4) | None => None,
}
}
@ -213,6 +215,7 @@ pub fn factory_for(adapter_kind: AdapterKind) -> AdapterFactory {
AdapterKind::OpenAi => build_openai,
AdapterKind::Gemini => build_gemini,
AdapterKind::OpenAiCompatible => build_openai_compatible,
AdapterKind::Bedrock => providers::bedrock::build,
}
}

View file

@ -7,7 +7,7 @@
use super::SYNTHETIC_TOOL_NAME;
use super::decode::{convert_synthetic_tool_to_text, map_finish_reason, refusal_error};
use crate::codec::{RawEvent, StreamDecoder};
use crate::codec::{RawEvent, StreamDecoder, parse_tool_arguments_or_empty};
use crate::error::{Error, ProviderErrorDetail, ProviderErrorKind};
use crate::types::{
ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData,
@ -253,8 +253,7 @@ impl SseAccumulator {
}
Some(ContentBlockKind::ToolUse { id, name }) => {
let raw_args = std::mem::take(&mut self.current_tool_args);
let arguments =
serde_json::from_str(&raw_args).unwrap_or_else(|_| serde_json::json!({}));
let arguments = parse_tool_arguments_or_empty(&raw_args);
let mut tool_call = ToolCall::new(id, name, arguments);
tool_call.raw_arguments = Some(raw_args);
self.content_parts

View file

@ -0,0 +1,296 @@
//! Response decoding: Converse body → canonical `Response`.
use serde_json::Value;
use crate::codec::CodecCtx;
use crate::error::{Error, error_from_status_code};
use crate::types::{
ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, ThinkingData, TokenCounts,
ToolCall,
};
/// Map a non-2xx Bedrock runtime response to an `Error`, pulling the human
/// reason out of AWS's error envelope. Bedrock uses several shapes for the
/// same field — top-level `message` (SigV4 path) and `Message` (API-key
/// path), occasionally nested `error.message` — and tags the type in
/// `__type`. The generic codec parser only reads `error.message`, so without
/// this these surface as "Unknown error".
pub(super) fn bedrock_error(
status: u16,
body: &str,
provider: &str,
retry_after: Option<f64>,
) -> Error {
let raw: Option<Value> = serde_json::from_str(body).ok();
let message = raw
.as_ref()
.and_then(extract_error_message)
.unwrap_or_else(|| {
if body.trim().is_empty() {
"Unknown error".to_string()
} else {
body.to_string()
}
});
// `__type` is often an ARN-ish `prefix#ThrottlingException`; keep the tail.
let code = raw
.as_ref()
.and_then(|v| {
v.get("__type")
.or_else(|| v.get("code"))
.and_then(Value::as_str)
})
.map(|t| t.rsplit('#').next().unwrap_or(t).to_string());
error_from_status_code(
status,
message,
provider.to_string(),
code,
raw,
retry_after,
)
}
fn extract_error_message(v: &Value) -> Option<String> {
v.get("message")
.and_then(Value::as_str)
.or_else(|| v.get("Message").and_then(Value::as_str))
.or_else(|| {
v.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
})
.map(String::from)
}
pub(super) fn decode_response(
body: &str,
ctx: &CodecCtx<'_>,
rate_limit: Option<RateLimitInfo>,
) -> Result<Response, Error> {
let raw: Value = serde_json::from_str(body)
.map_err(|e| Error::network(format!("failed to parse converse response: {e}"), e))?;
let content_parts = raw
.pointer("/output/message/content")
.and_then(Value::as_array)
.map(|blocks| blocks.iter().filter_map(decode_content_block).collect())
.unwrap_or_default();
let finish_reason = map_stop_reason(raw.get("stopReason").and_then(Value::as_str));
let usage = token_counts_from_usage(raw.get("usage"));
Ok(Response {
// Converse responses carry no id; synthesize one like the gemini
// codec does so downstream consumers always see a non-empty id.
id: uuid::Uuid::new_v4().to_string(),
model: ctx.request.model.clone(),
provider: ctx.provider_name.to_string(),
message: Message {
role: Role::Assistant,
content: content_parts,
name: None,
tool_call_id: None,
},
finish_reason,
usage,
raw: Some(raw),
warnings: vec![],
rate_limit,
cost_usd: None,
cost_source: None,
})
}
/// Decode one Converse content block into a canonical part. Unknown block
/// kinds are skipped (the union grows: `citationsContent`, `searchResult`,
/// `video`, ...).
pub(super) fn decode_content_block(block: &Value) -> Option<ContentPart> {
if let Some(text) = block.get("text").and_then(Value::as_str) {
if text.is_empty() {
return None;
}
return Some(ContentPart::text(text));
}
if let Some(tool_use) = block.get("toolUse") {
let id = tool_use.get("toolUseId").and_then(Value::as_str)?;
let name = tool_use.get("name").and_then(Value::as_str)?;
// A no-argument tool call is canonically `{}`, not null (so it
// re-encodes to a valid Converse `toolUse.input` object).
let input = match tool_use.get("input") {
Some(Value::Null) | None => Value::Object(serde_json::Map::new()),
Some(value) => value.clone(),
};
return Some(ContentPart::ToolCall(ToolCall::new(id, name, input)));
}
if let Some(reasoning) = block.get("reasoningContent") {
if let Some(text_block) = reasoning.get("reasoningText") {
return Some(ContentPart::Thinking(ThinkingData {
text: text_block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
signature: text_block
.get("signature")
.and_then(Value::as_str)
.map(str::to_string),
redacted: false,
}));
}
if let Some(redacted) = reasoning.get("redactedContent").and_then(Value::as_str) {
return Some(ContentPart::Thinking(ThinkingData {
text: redacted.to_string(),
signature: None,
redacted: true,
}));
}
}
None
}
/// Map a Converse `stopReason` onto the canonical finish vocabulary.
pub(super) fn map_stop_reason(reason: Option<&str>) -> FinishReason {
match reason {
None | Some("end_turn" | "stop_sequence") => FinishReason::Stop,
Some("max_tokens" | "model_context_window_exceeded") => FinishReason::Length,
Some("tool_use") => FinishReason::ToolCalls,
// `refusal` is the Claude 5 blocking-classifier stop, passed through
// by Bedrock for Fable-class models.
Some("guardrail_intervened" | "content_filtered" | "refusal") => {
FinishReason::ContentFilter
}
Some(other) => FinishReason::Other(other.to_string()),
}
}
/// Converse usage maps directly onto the disjoint buckets: `inputTokens`
/// already excludes cached tokens (documented), so no subtraction applies.
pub(super) fn token_counts_from_usage(usage: Option<&Value>) -> TokenCounts {
let Some(usage) = usage else {
return TokenCounts::default();
};
let count = |key: &str| usage.get(key).and_then(Value::as_i64).unwrap_or(0);
TokenCounts {
input_tokens: count("inputTokens"),
output_tokens: count("outputTokens"),
reasoning_tokens: 0,
cache_read_tokens: count("cacheReadInputTokens"),
cache_write_tokens: count("cacheWriteInputTokens"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stop_reasons_map_to_canonical_vocabulary() {
assert_eq!(map_stop_reason(Some("end_turn")), FinishReason::Stop);
assert_eq!(map_stop_reason(Some("stop_sequence")), FinishReason::Stop);
assert_eq!(map_stop_reason(Some("max_tokens")), FinishReason::Length);
assert_eq!(
map_stop_reason(Some("model_context_window_exceeded")),
FinishReason::Length
);
assert_eq!(map_stop_reason(Some("tool_use")), FinishReason::ToolCalls);
assert_eq!(
map_stop_reason(Some("guardrail_intervened")),
FinishReason::ContentFilter
);
assert_eq!(
map_stop_reason(Some("content_filtered")),
FinishReason::ContentFilter
);
assert_eq!(
map_stop_reason(Some("refusal")),
FinishReason::ContentFilter
);
assert_eq!(
map_stop_reason(Some("malformed_tool_use")),
FinishReason::Other("malformed_tool_use".to_string())
);
assert_eq!(map_stop_reason(None), FinishReason::Stop);
}
#[test]
fn usage_maps_without_subtraction() {
let usage = serde_json::json!({
"inputTokens": 30,
"outputTokens": 628,
"totalTokens": 658,
"cacheReadInputTokens": 1024,
"cacheWriteInputTokens": 512,
});
let counts = token_counts_from_usage(Some(&usage));
assert_eq!(counts.input_tokens, 30);
assert_eq!(counts.output_tokens, 628);
assert_eq!(counts.cache_read_tokens, 1024);
assert_eq!(counts.cache_write_tokens, 512);
assert_eq!(counts.reasoning_tokens, 0);
}
#[test]
fn bedrock_error_extracts_aws_message_shapes() {
// SigV4 path: top-level lowercase `message`.
let sigv4 = bedrock_error(
403,
r#"{"message":"Model access is denied due to IAM ..."}"#,
"bedrock",
None,
);
assert!(
sigv4.to_string().contains("Model access is denied"),
"{sigv4}"
);
// API-key path: top-level capitalized `Message`.
let api_key = bedrock_error(
403,
r#"{"Message":"Authentication failed: Please make sure your API Key is valid."}"#,
"bedrock",
None,
);
assert!(
api_key.to_string().contains("Authentication failed"),
"{api_key}"
);
// `__type` becomes the error code (tail after `#`).
let typed = bedrock_error(
429,
r#"{"__type":"com.amazon.coral.service#ThrottlingException","message":"slow down"}"#,
"bedrock",
None,
);
let Error::Provider { detail, .. } = &typed else {
panic!("expected provider error: {typed}");
};
assert_eq!(detail.error_code.as_deref(), Some("ThrottlingException"));
// Garbage body falls back rather than panicking.
let opaque = bedrock_error(500, "not json", "bedrock", None);
assert!(opaque.to_string().contains("not json"), "{opaque}");
}
#[test]
fn unknown_content_blocks_are_skipped() {
assert!(decode_content_block(&serde_json::json!({"citationsContent": {}})).is_none());
assert!(decode_content_block(&serde_json::json!({"text": ""})).is_none());
}
#[test]
fn reasoning_text_block_round_trips_signature() {
let block = serde_json::json!({
"reasoningContent": {
"reasoningText": { "text": "thinking...", "signature": "sig-1" }
}
});
let Some(ContentPart::Thinking(thinking)) = decode_content_block(&block) else {
panic!("expected thinking part");
};
assert_eq!(thinking.text, "thinking...");
assert_eq!(thinking.signature.as_deref(), Some("sig-1"));
assert!(!thinking.redacted);
}
}

View file

@ -0,0 +1,643 @@
//! Request encoding: canonical `Request` → Converse envelope.
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use serde_json::{Map, Value, json};
use crate::codec::{CodecCtx, EncodedRequest, extract_system_prompt, merge_named_provider_options};
use crate::error::Error;
use crate::types::{ContentPart, Message, Request, Role, ToolChoice};
pub(super) fn encode(ctx: &CodecCtx<'_>, stream: bool) -> Result<EncodedRequest, Error> {
let request = ctx.request;
if request.response_format.is_some() {
return Err(Error::Configuration {
message: format!(
"provider '{}' does not support response_format yet (Bedrock Converse \
structured output is a named follow-up)",
ctx.provider_name
),
source: None,
});
}
let caching = supports_prompt_cache(ctx);
let (system, conversation) = extract_system_prompt(&request.messages);
let mut body = Map::new();
if let Some(system) = system {
let mut blocks = vec![json!({ "text": system })];
if caching {
blocks.push(cache_point());
}
body.insert("system".to_string(), Value::Array(blocks));
}
let mut messages = Vec::new();
for message in conversation {
if let Some(value) = encode_message(message) {
messages.push(value);
}
}
if caching {
apply_cache_point_to_conversation_prefix(&mut messages);
}
body.insert("messages".to_string(), Value::Array(messages));
// Models with `sampling_params = false` reject classic sampling knobs
// (Claude Fable 5 pins temperature on Bedrock too).
let (temperature, top_p) = if ctx
.model
.is_none_or(fabro_model::Model::supports_sampling_params)
{
(request.temperature, request.top_p)
} else {
(None, None)
};
let mut inference = Map::new();
if let Some(max_tokens) = request.max_tokens {
inference.insert("maxTokens".to_string(), json!(max_tokens));
}
if let Some(temperature) = temperature {
inference.insert("temperature".to_string(), json!(temperature));
}
if let Some(top_p) = top_p {
inference.insert("topP".to_string(), json!(top_p));
}
if let Some(stop) = &request.stop_sequences {
if !stop.is_empty() {
inference.insert("stopSequences".to_string(), json!(stop));
}
}
if !inference.is_empty() {
body.insert("inferenceConfig".to_string(), Value::Object(inference));
}
if let Some(tool_config) = encode_tool_config(request, caching) {
body.insert("toolConfig".to_string(), tool_config);
}
let mut body = Value::Object(body);
merge_provider_options(
&mut body,
request.provider_options.as_ref(),
ctx.provider_name,
);
let action = if stream {
"converse-stream"
} else {
"converse"
};
Ok(EncodedRequest {
body,
endpoint: format!("/model/{}/{action}", ctx.deployment_id),
headers: Vec::new(),
})
}
fn supports_prompt_cache(ctx: &CodecCtx<'_>) -> bool {
ctx.model.is_some_and(|m| m.features.prompt_cache)
}
fn cache_point() -> Value {
json!({ "cachePoint": { "type": "default" } })
}
/// Encode one conversation message. Tool-role messages carry their results in
/// user-role messages (Converse has no tool role). Returns `None` when no
/// block survives translation.
fn encode_message(message: &Message) -> Option<Value> {
let role = match message.role {
Role::Assistant => "assistant",
// Tool results ride in user messages on the Converse wire.
_ => "user",
};
let mut blocks: Vec<Value> = message
.content
.iter()
.filter_map(encode_content_part)
.collect();
// Tool-role messages whose result lives on the message rather than in a
// ToolResult part.
if blocks.is_empty() && message.role == Role::Tool {
if let Some(tool_call_id) = &message.tool_call_id {
let text = message.text();
blocks.push(json!({
"toolResult": {
"toolUseId": tool_call_id,
"content": [{ "text": text }],
}
}));
}
}
if blocks.is_empty() {
return None;
}
Some(json!({ "role": role, "content": blocks }))
}
fn encode_content_part(part: &ContentPart) -> Option<Value> {
match part {
ContentPart::Text(text) => {
if text.is_empty() {
None
} else {
Some(json!({ "text": text }))
}
}
// Converse has no URL sources; the adapter's attachment resolution
// inlines file-backed parts ahead of encoding, and URL-only parts are
// dropped (the established drop-don't-fail attachment contract).
ContentPart::Image(image) => {
let bytes = image.data.as_ref()?;
Some(json!({
"image": {
"format": media_format(image.media_type.as_deref(), "png"),
"source": { "bytes": BASE64.encode(bytes) },
}
}))
}
ContentPart::Document(document) => {
let bytes = document.data.as_ref()?;
Some(json!({
"document": {
"format": media_format(document.media_type.as_deref(), "pdf"),
"name": document.file_name.as_deref().unwrap_or("document"),
"source": { "bytes": BASE64.encode(bytes) },
}
}))
}
ContentPart::ToolCall(tool_call) => {
// Converse requires `toolUse.input` to be a JSON object document.
// A no-argument tool call carries `Null` (the stream decoder gets
// no input fragments to parse), which Bedrock rejects as
// "toolUse.input is empty". Coerce any non-object to `{}` so the
// wire is always valid, regardless of where the call originated.
let input = match &tool_call.arguments {
Value::Object(_) => tool_call.arguments.clone(),
_ => json!({}),
};
Some(json!({
"toolUse": {
"toolUseId": tool_call.id,
"name": tool_call.name,
"input": input,
}
}))
}
ContentPart::ToolResult(result) => {
let content = match &result.content {
Value::String(text) => json!([{ "text": text }]),
other => json!([{ "json": other }]),
};
let mut block = Map::new();
block.insert("toolUseId".to_string(), json!(result.tool_call_id));
block.insert("content".to_string(), content);
if result.is_error {
block.insert("status".to_string(), json!("error"));
}
Some(json!({ "toolResult": Value::Object(block) }))
}
ContentPart::Thinking(thinking) => {
if thinking.redacted {
Some(json!({
"reasoningContent": { "redactedContent": thinking.text }
}))
} else {
let mut text_block = Map::new();
text_block.insert("text".to_string(), json!(thinking.text));
if let Some(signature) = &thinking.signature {
// Echoed back unmodified — Bedrock validates it.
text_block.insert("signature".to_string(), json!(signature));
}
Some(json!({
"reasoningContent": { "reasoningText": Value::Object(text_block) }
}))
}
}
// Audio input and opaque foreign parts have no Converse encoding.
ContentPart::Audio(_) | ContentPart::Other { .. } => None,
}
}
/// Convert common MIME types into Bedrock's media `format` enum values.
fn media_format<'a>(media_type: Option<&str>, default: &'a str) -> &'a str {
match media_type {
Some("image/png") => "png",
Some("image/jpeg" | "image/jpg") => "jpeg",
Some("image/gif") => "gif",
Some("image/webp") => "webp",
Some("application/pdf") => "pdf",
Some("text/plain") => "txt",
Some("text/markdown") => "md",
Some("text/html") => "html",
Some("text/csv") => "csv",
Some(
"application/msword"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
) => "docx",
Some(
"application/vnd.ms-excel"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
) => "xlsx",
_ => default,
}
}
fn encode_tool_config(request: &Request, caching: bool) -> Option<Value> {
let tools = request.tools.as_ref()?;
if tools.is_empty() {
return None;
}
// `tool_choice: none` is rejected at the adapter's validate_request;
// defensively drop the toolConfig if it slips through.
if request.tool_choice == Some(ToolChoice::None) {
return None;
}
let mut entries: Vec<Value> = tools
.iter()
.map(|tool| {
json!({
"toolSpec": {
"name": tool.name,
"description": tool.description,
"inputSchema": { "json": tool_input_schema(&tool.parameters) },
}
})
})
.collect();
if caching {
entries.push(cache_point());
}
let mut config = Map::new();
config.insert("tools".to_string(), Value::Array(entries));
match &request.tool_choice {
Some(ToolChoice::Required) => {
config.insert("toolChoice".to_string(), json!({ "any": {} }));
}
Some(ToolChoice::Named { tool_name }) => {
config.insert(
"toolChoice".to_string(),
json!({ "tool": { "name": tool_name } }),
);
}
// Auto is the wire default; ToolChoice::None dropped the config above.
Some(ToolChoice::Auto | ToolChoice::None) | None => {}
}
Some(Value::Object(config))
}
/// Normalize a tool's JSON-Schema for Bedrock's `toolSpec.inputSchema.json`.
/// Converse strictly validates the schema and requires a top-level `type`;
/// some model families (e.g. DeepSeek) reject a typeless schema that Claude
/// tolerates. Tools may arrive with a loose schema (no top-level `type`, or a
/// bare `{}` for a no-argument tool), so default the type to `object`.
fn tool_input_schema(parameters: &Value) -> Value {
match parameters {
Value::Object(map) => {
let mut map = map.clone();
map.entry("type").or_insert_with(|| json!("object"));
Value::Object(map)
}
// A non-object schema is not a valid tool input schema; substitute the
// empty-object schema Bedrock accepts.
_ => json!({ "type": "object", "properties": {} }),
}
}
/// Mirror the anthropic codec's conversation-prefix cache placement: a
/// `cachePoint` at the end of the second-to-last user message, so the prior
/// turns stay cached while the newest turn streams.
fn apply_cache_point_to_conversation_prefix(messages: &mut [Value]) {
let mut previous_user = None;
let mut last_user = None;
for (index, message) in messages.iter().enumerate() {
if message.get("role").and_then(Value::as_str) == Some("user") {
previous_user = last_user;
last_user = Some(index);
}
}
let Some(target) = previous_user else {
return;
};
if let Some(content) = messages[target]
.get_mut("content")
.and_then(Value::as_array_mut)
{
content.push(cache_point());
}
}
/// Merge `provider_options.<provider_name>` keys into the top level of the
/// body (the same adapter-name-keyed contract as the openai_compatible
/// codec). This is the passthrough for `additionalModelRequestFields`,
/// `guardrailConfig`, `serviceTier`, and other Converse extensions.
fn merge_provider_options(body: &mut Value, provider_options: Option<&Value>, provider_name: &str) {
merge_named_provider_options(body, provider_options, provider_name);
}
#[cfg(test)]
mod tests {
use fabro_model::Catalog;
use fabro_model::catalog::LlmCatalogSettings;
use serde_json::json;
use super::*;
use crate::codec::CodecParams;
use crate::types::{
ResponseFormat, ResponseFormatType, ThinkingData, ToolCall, ToolDefinition, ToolResult,
};
fn base_request(model: &str) -> Request {
Request {
model: model.to_string(),
messages: vec![Message::user("Hello")],
provider: Some("bedrock".to_string()),
tools: None,
tool_choice: None,
response_format: None,
temperature: Some(0.5),
top_p: None,
max_tokens: Some(256),
stop_sequences: None,
reasoning_effort: None,
speed: None,
metadata: None,
provider_options: None,
}
}
fn encode_with(request: &Request) -> EncodedRequest {
let params = CodecParams::default();
let ctx = CodecCtx {
request,
provider_name: "bedrock",
deployment_id: "us.anthropic.claude-sonnet-4-6",
model: None,
params: &params,
};
encode(&ctx, false).unwrap()
}
#[test]
fn endpoint_carries_model_and_action() {
let request = base_request("claude");
let params = CodecParams::default();
let ctx = CodecCtx {
request: &request,
provider_name: "bedrock",
deployment_id: "us.anthropic.claude-sonnet-4-6",
model: None,
params: &params,
};
assert_eq!(
encode(&ctx, false).unwrap().endpoint,
"/model/us.anthropic.claude-sonnet-4-6/converse"
);
assert_eq!(
encode(&ctx, true).unwrap().endpoint,
"/model/us.anthropic.claude-sonnet-4-6/converse-stream"
);
}
#[test]
fn system_messages_become_top_level_system_blocks() {
let mut request = base_request("claude");
request.messages = vec![Message::system("Be brief"), Message::user("Hi")];
let encoded = encode_with(&request);
assert_eq!(encoded.body["system"][0]["text"], "Be brief");
assert_eq!(encoded.body["messages"][0]["role"], "user");
assert_eq!(encoded.body["messages"][0]["content"][0]["text"], "Hi");
}
#[test]
fn inference_config_uses_camel_case() {
let encoded = encode_with(&base_request("claude"));
assert_eq!(encoded.body["inferenceConfig"]["maxTokens"], 256);
assert_eq!(encoded.body["inferenceConfig"]["temperature"], 0.5);
}
#[test]
fn tools_encode_as_tool_specs_with_choice() {
let mut request = base_request("claude");
request.tools = Some(vec![ToolDefinition::function(
"search",
"Search things",
json!({"type": "object"}),
)]);
request.tool_choice = Some(ToolChoice::named("search"));
let encoded = encode_with(&request);
let spec = &encoded.body["toolConfig"]["tools"][0]["toolSpec"];
assert_eq!(spec["name"], "search");
assert_eq!(spec["inputSchema"]["json"]["type"], "object");
assert_eq!(
encoded.body["toolConfig"]["toolChoice"]["tool"]["name"],
"search"
);
}
#[test]
fn typeless_tool_schema_gains_object_type() {
// Bedrock rejects a tool inputSchema without a top-level `type` (some
// model families validate strictly); the encoder must default it.
let mut request = base_request("claude");
request.tools = Some(vec![
ToolDefinition::function("no_type", "schema without a type", json!({})),
ToolDefinition::function(
"props_only",
"properties but no top-level type",
json!({"properties": {"q": {"type": "string"}}}),
),
]);
let encoded = encode_with(&request);
let tools = &encoded.body["toolConfig"]["tools"];
assert_eq!(
tools[0]["toolSpec"]["inputSchema"]["json"]["type"],
"object"
);
assert_eq!(
tools[1]["toolSpec"]["inputSchema"]["json"]["type"],
"object"
);
// An existing nested schema is preserved, not clobbered.
assert_eq!(
tools[1]["toolSpec"]["inputSchema"]["json"]["properties"]["q"]["type"],
"string"
);
}
#[test]
fn tool_results_ride_in_user_messages() {
let mut request = base_request("claude");
request.messages = vec![Message {
role: Role::Tool,
content: vec![ContentPart::ToolResult(ToolResult {
tool_call_id: "tool-1".to_string(),
content: json!("42"),
is_error: false,
image_data: None,
image_media_type: None,
})],
name: None,
tool_call_id: Some("tool-1".to_string()),
}];
let encoded = encode_with(&request);
let message = &encoded.body["messages"][0];
assert_eq!(message["role"], "user");
assert_eq!(message["content"][0]["toolResult"]["toolUseId"], "tool-1");
assert_eq!(
message["content"][0]["toolResult"]["content"][0]["text"],
"42"
);
}
#[test]
fn no_argument_tool_call_encodes_empty_object_input() {
// A no-arg tool call decodes to `Null` arguments; Bedrock rejects a
// null/empty `toolUse.input`, so the encoder must emit `{}`.
let mut request = base_request("claude");
request.messages = vec![Message {
role: Role::Assistant,
content: vec![ContentPart::ToolCall(ToolCall::new(
"tool-1",
"TaskList",
Value::Null,
))],
name: None,
tool_call_id: None,
}];
let encoded = encode_with(&request);
let tool_use = &encoded.body["messages"][0]["content"][0]["toolUse"];
assert_eq!(tool_use["toolUseId"], "tool-1");
assert_eq!(tool_use["name"], "TaskList");
assert_eq!(tool_use["input"], json!({}));
}
#[test]
fn thinking_parts_restructure_into_reasoning_text_blocks() {
let mut request = base_request("claude");
request.messages = vec![Message {
role: Role::Assistant,
content: vec![ContentPart::Thinking(ThinkingData {
text: "prior thoughts".to_string(),
signature: Some("sig-1".to_string()),
redacted: false,
})],
name: None,
tool_call_id: None,
}];
let encoded = encode_with(&request);
let block = &encoded.body["messages"][0]["content"][0]["reasoningContent"]["reasoningText"];
assert_eq!(block["text"], "prior thoughts");
assert_eq!(block["signature"], "sig-1");
}
#[test]
fn media_format_maps_common_mime_types_to_bedrock_formats() {
assert_eq!(media_format(Some("image/jpeg"), "png"), "jpeg");
assert_eq!(media_format(Some("text/plain"), "pdf"), "txt");
assert_eq!(media_format(Some("text/markdown"), "pdf"), "md");
assert_eq!(media_format(Some("application/octet-stream"), "pdf"), "pdf");
}
#[test]
fn provider_options_merge_top_level() {
let mut request = base_request("claude");
request.provider_options = Some(json!({
"bedrock": {
"additionalModelRequestFields": {"top_k": 200},
"serviceTier": {"type": "flex"}
}
}));
let encoded = encode_with(&request);
assert_eq!(encoded.body["additionalModelRequestFields"]["top_k"], 200);
assert_eq!(encoded.body["serviceTier"]["type"], "flex");
}
#[test]
fn response_format_is_rejected() {
let mut request = base_request("claude");
request.response_format = Some(ResponseFormat {
kind: ResponseFormatType::JsonSchema,
json_schema: Some(json!({"type": "object"})),
strict: false,
});
let params = CodecParams::default();
let ctx = CodecCtx {
request: &request,
provider_name: "bedrock",
deployment_id: "m",
model: None,
params: &params,
};
assert!(encode(&ctx, false).is_err());
}
#[test]
fn sampling_params_false_drops_temperature_and_top_p() {
let settings: LlmCatalogSettings = toml::from_str(
r#"
[providers.bedrock]
adapter = "bedrock"
enabled = true
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
[models."pinned-model"]
provider = "bedrock"
display_name = "Pinned"
family = "claude-5"
default = true
[models."pinned-model".limits]
context_window = 100000
[models."pinned-model".features]
tools = true
vision = false
reasoning = true
sampling_params = false
"#,
)
.unwrap();
let catalog = Catalog::from_settings(&settings).unwrap();
let mut request = base_request("pinned-model");
request.top_p = Some(0.9);
let params = CodecParams::default();
let ctx = CodecCtx {
request: &request,
provider_name: "bedrock",
deployment_id: "pinned-model",
model: catalog.get("pinned-model"),
params: &params,
};
let encoded = encode(&ctx, false).unwrap();
let inference = &encoded.body["inferenceConfig"];
assert!(inference.get("temperature").is_none());
assert!(inference.get("topP").is_none());
assert_eq!(inference["maxTokens"], 256);
}
#[test]
fn cache_points_follow_the_anthropic_placement() {
let mut messages = vec![
json!({"role": "user", "content": [{"text": "turn 1"}]}),
json!({"role": "assistant", "content": [{"text": "reply 1"}]}),
json!({"role": "user", "content": [{"text": "turn 2"}]}),
];
apply_cache_point_to_conversation_prefix(&mut messages);
// Second-to-last user message gains the cachePoint.
assert!(messages[0]["content"][1].get("cachePoint").is_some());
assert_eq!(messages[2]["content"].as_array().unwrap().len(), 1);
}
}

View file

@ -0,0 +1,58 @@
//! The Amazon Bedrock Converse codec.
//!
//! Pure translation: no HTTP, auth, signing, or event-stream framing — the
//! Bedrock adapter shell owns those. Converse is Bedrock's model-agnostic
//! envelope (AWS translates it to each hosted family's native dialect
//! server-side), which is what makes this one codec serve Claude, Nova,
//! Llama, Mistral, DeepSeek, Qwen, Kimi, GLM, MiniMax, Nemotron, and
//! gpt-oss alike. The codec fully forms its endpoints (model-in-path,
//! `/converse` vs `/converse-stream`), mirrors the anthropic codec's prompt
//! cache placement with `cachePoint` blocks, and round-trips
//! `reasoningContent` thinking signatures unmodified.
mod decode;
mod encode;
mod stream;
use crate::codec::{Codec, CodecCtx, EncodedRequest, StreamDecoder};
use crate::error::Error;
use crate::types::{RateLimitInfo, Response};
/// Codec for the Bedrock Converse wire dialect.
pub(crate) struct BedrockConverse;
impl Codec for BedrockConverse {
fn encode(&self, ctx: &CodecCtx<'_>, stream: bool) -> Result<EncodedRequest, Error> {
encode::encode(ctx, stream)
}
fn decode_response(
&self,
body: &str,
ctx: &CodecCtx<'_>,
rate_limit: Option<RateLimitInfo>,
) -> Result<Response, Error> {
decode::decode_response(body, ctx, rate_limit)
}
fn stream_decoder(
&self,
ctx: &CodecCtx<'_>,
rate_limit: Option<RateLimitInfo>,
) -> Box<dyn StreamDecoder> {
Box::new(stream::ConverseStreamDecoder::new(ctx, rate_limit))
}
/// Bedrock error bodies are AWS-shaped (top-level `message`/`Message`,
/// `__type`), which the default parser misses — extract them so failures
/// surface the real reason instead of "Unknown error".
fn decode_error(
&self,
status: u16,
body: &str,
ctx: &CodecCtx<'_>,
retry_after: Option<f64>,
) -> Error {
decode::bedrock_error(status, body, ctx.provider_name, retry_after)
}
}

View file

@ -0,0 +1,512 @@
//! Streaming decoder: ConverseStream events → canonical `StreamEvent`s.
//!
//! Event names arrive in the transport's `RawEvent::event` (the frame's
//! `:event-type` header); payloads are the event JSON. The documented
//! sequence is `messageStart` → per content block (`contentBlockStart`
//! [tool use only] → `contentBlockDelta`* → `contentBlockStop`) →
//! `messageStop{stopReason}` → `metadata{usage}`. Usage arrives ONLY in the
//! terminal `metadata` event, which is also where the final `Finish` is
//! synthesized.
use std::collections::BTreeMap;
use serde_json::Value;
use super::decode::{map_stop_reason, token_counts_from_usage};
use crate::codec::{CodecCtx, RawEvent, StreamDecoder, parse_tool_arguments_or_empty};
use crate::error::Error;
use crate::types::{
ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, StreamEvent, ThinkingData,
TokenCounts, ToolCall,
};
/// Per-content-block accumulation state, keyed by `contentBlockIndex`.
enum BlockState {
Text(String),
Reasoning {
text: String,
signature: Option<String>,
redacted: Option<String>,
},
ToolUse {
id: String,
name: String,
input: String,
},
}
/// Accumulated state while decoding one ConverseStream response.
pub(super) struct ConverseStreamDecoder {
provider_name: String,
model: String,
blocks: BTreeMap<u64, BlockState>,
/// Completed blocks in arrival order, for the final response message.
parts: Vec<ContentPart>,
finish_reason: FinishReason,
usage: TokenCounts,
text_started: bool,
finished: bool,
rate_limit: Option<RateLimitInfo>,
}
impl ConverseStreamDecoder {
pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option<RateLimitInfo>) -> Self {
Self {
provider_name: ctx.provider_name.to_string(),
model: ctx.request.model.clone(),
blocks: BTreeMap::new(),
parts: Vec::new(),
finish_reason: FinishReason::Stop,
usage: TokenCounts::default(),
text_started: false,
finished: false,
rate_limit,
}
}
fn block_index(payload: &Value) -> u64 {
payload
.get("contentBlockIndex")
.and_then(Value::as_u64)
.unwrap_or(0)
}
fn on_block_start(&mut self, payload: &Value) -> Vec<StreamEvent> {
let index = Self::block_index(payload);
if let Some(tool_use) = payload.pointer("/start/toolUse") {
let id = tool_use
.get("toolUseId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let name = tool_use
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let started = ToolCall::new(&id, &name, Value::Null);
self.blocks.insert(index, BlockState::ToolUse {
id,
name,
input: String::new(),
});
return vec![StreamEvent::ToolCallStart { tool_call: started }];
}
Vec::new()
}
fn on_block_delta(&mut self, payload: &Value) -> Vec<StreamEvent> {
let index = Self::block_index(payload);
let Some(delta) = payload.get("delta") else {
return Vec::new();
};
if let Some(text) = delta.get("text").and_then(Value::as_str) {
if text.is_empty() {
return Vec::new();
}
let mut events = Vec::new();
if !self.text_started {
self.text_started = true;
events.push(StreamEvent::TextStart { text_id: None });
}
match self
.blocks
.entry(index)
.or_insert_with(|| BlockState::Text(String::new()))
{
BlockState::Text(buffer) => buffer.push_str(text),
// A text delta against a non-text block: tolerate by ignoring
// the mismatch rather than corrupting tool/reasoning state.
_ => return events,
}
events.push(StreamEvent::text_delta(text, None));
return events;
}
if let Some(input) = delta.pointer("/toolUse/input").and_then(Value::as_str) {
if let Some(BlockState::ToolUse {
id,
name,
input: buffer,
}) = self.blocks.get_mut(&index)
{
buffer.push_str(input);
let partial = ToolCall::new(id.as_str(), name.as_str(), Value::Null);
return vec![StreamEvent::ToolCallDelta { tool_call: partial }];
}
return Vec::new();
}
if let Some(reasoning) = delta.get("reasoningContent") {
let entry = self
.blocks
.entry(index)
.or_insert_with(|| BlockState::Reasoning {
text: String::new(),
signature: None,
redacted: None,
});
let BlockState::Reasoning {
text,
signature,
redacted,
} = entry
else {
return Vec::new();
};
let mut events = Vec::new();
if text.is_empty() && signature.is_none() && redacted.is_none() {
events.push(StreamEvent::ReasoningStart);
}
// Streaming reasoning deltas carry text/signature as FLAT union
// members (unlike the nested request-side reasoningText block).
if let Some(fragment) = reasoning.get("text").and_then(Value::as_str) {
text.push_str(fragment);
events.push(StreamEvent::ReasoningDelta {
delta: fragment.to_string(),
});
}
if let Some(sig) = reasoning.get("signature").and_then(Value::as_str) {
*signature = Some(sig.to_string());
}
if let Some(blob) = reasoning.get("redactedContent").and_then(Value::as_str) {
*redacted = Some(blob.to_string());
}
return events;
}
Vec::new()
}
fn on_block_stop(&mut self, payload: &Value) -> Vec<StreamEvent> {
let index = Self::block_index(payload);
let Some(block) = self.blocks.remove(&index) else {
return Vec::new();
};
match block {
BlockState::Text(text) => {
let mut events = Vec::new();
if self.text_started {
self.text_started = false;
events.push(StreamEvent::TextEnd { text_id: None });
}
if !text.is_empty() {
self.parts.push(ContentPart::text(&text));
}
events
}
BlockState::Reasoning {
text,
signature,
redacted,
} => {
let part = if let Some(blob) = redacted {
ThinkingData {
text: blob,
signature: None,
redacted: true,
}
} else {
ThinkingData {
text,
signature,
redacted: false,
}
};
self.parts.push(ContentPart::Thinking(part));
vec![StreamEvent::ReasoningEnd]
}
BlockState::ToolUse { id, name, input } => {
// A no-argument tool call streams no input fragments, leaving
// the buffer empty; canonically that is an empty object, not
// null (matching the anthropic/openai codecs, and what Bedrock
// wants back on re-encode).
let arguments = parse_tool_arguments_or_empty(&input);
let mut tool_call = ToolCall::new(&id, &name, arguments);
tool_call.raw_arguments = Some(input);
self.parts.push(ContentPart::ToolCall(tool_call.clone()));
vec![StreamEvent::ToolCallEnd { tool_call }]
}
}
}
/// Build the final `Finish` from accumulated state.
fn finish_event(&mut self) -> StreamEvent {
self.finished = true;
// Flush any blocks that never saw a contentBlockStop.
let dangling: Vec<u64> = self.blocks.keys().copied().collect();
for index in dangling {
let _ = self.on_block_stop(&serde_json::json!({ "contentBlockIndex": index }));
}
let response = Response {
id: uuid::Uuid::new_v4().to_string(),
model: self.model.clone(),
provider: self.provider_name.clone(),
message: Message {
role: Role::Assistant,
content: std::mem::take(&mut self.parts),
name: None,
tool_call_id: None,
},
finish_reason: self.finish_reason.clone(),
usage: self.usage.clone(),
raw: None,
warnings: vec![],
rate_limit: self.rate_limit.clone(),
cost_usd: None,
cost_source: None,
};
StreamEvent::finish(self.finish_reason.clone(), self.usage.clone(), response)
}
}
impl StreamDecoder for ConverseStreamDecoder {
fn on_event(&mut self, ev: RawEvent<'_>) -> Result<Vec<StreamEvent>, Error> {
let Some(event_type) = ev.event else {
return Ok(Vec::new());
};
let payload: Value = serde_json::from_str(ev.data)
.map_err(|e| Error::stream_error(format!("converse stream event json: {e}"), e))?;
Ok(match event_type {
"messageStart" => vec![StreamEvent::StreamStart],
"contentBlockStart" => self.on_block_start(&payload),
"contentBlockDelta" => self.on_block_delta(&payload),
"contentBlockStop" => self.on_block_stop(&payload),
"messageStop" => {
self.finish_reason =
map_stop_reason(payload.get("stopReason").and_then(Value::as_str));
Vec::new()
}
"metadata" => {
self.usage = token_counts_from_usage(payload.get("usage"));
vec![self.finish_event()]
}
// Tolerate unknown event types — the union grows.
_ => Vec::new(),
})
}
/// Byte-stream end: `metadata` is the documented terminus, but if the
/// stream ends without one, synthesize the `Finish` from accumulated
/// state so callers still receive a response (mirrors the gemini
/// decoder's unconditional synthesis).
fn finish(&mut self) -> Vec<StreamEvent> {
if self.finished {
return Vec::new();
}
vec![self.finish_event()]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::CodecParams;
use crate::types::{Message as RequestMessage, Request};
fn decoder() -> ConverseStreamDecoder {
let request = Request {
model: "us.anthropic.claude-sonnet-4-6".to_string(),
messages: vec![RequestMessage::user("hi")],
provider: Some("bedrock".to_string()),
tools: None,
tool_choice: None,
response_format: None,
temperature: None,
top_p: None,
max_tokens: None,
stop_sequences: None,
reasoning_effort: None,
speed: None,
metadata: None,
provider_options: None,
};
let params = CodecParams::default();
let ctx = CodecCtx {
request: &request,
provider_name: "bedrock",
deployment_id: "us.anthropic.claude-sonnet-4-6",
model: None,
params: &params,
};
ConverseStreamDecoder::new(&ctx, None)
}
fn feed(decoder: &mut ConverseStreamDecoder, event: &str, data: &str) -> Vec<StreamEvent> {
decoder
.on_event(RawEvent {
event: Some(event),
data,
})
.unwrap()
}
#[test]
fn text_happy_path_finishes_on_metadata() {
let mut d = decoder();
assert!(matches!(
feed(&mut d, "messageStart", r#"{"role":"assistant"}"#)[0],
StreamEvent::StreamStart
));
let events = feed(
&mut d,
"contentBlockDelta",
r#"{"delta":{"text":"Hel"},"contentBlockIndex":0}"#,
);
assert!(matches!(events[0], StreamEvent::TextStart { .. }));
assert!(matches!(events[1], StreamEvent::TextDelta { .. }));
feed(
&mut d,
"contentBlockDelta",
r#"{"delta":{"text":"lo"},"contentBlockIndex":0}"#,
);
let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#);
assert!(matches!(stop[0], StreamEvent::TextEnd { .. }));
assert!(feed(&mut d, "messageStop", r#"{"stopReason":"end_turn"}"#).is_empty());
let finish = feed(
&mut d,
"metadata",
r#"{"usage":{"inputTokens":12,"outputTokens":5,"totalTokens":17}}"#,
);
let StreamEvent::Finish {
finish_reason,
usage,
response,
} = &finish[0]
else {
panic!("expected Finish");
};
assert_eq!(*finish_reason, FinishReason::Stop);
assert_eq!(usage.input_tokens, 12);
assert_eq!(response.text(), "Hello");
assert_eq!(response.provider, "bedrock");
// Byte-stream end after metadata adds nothing.
assert!(d.finish().is_empty());
}
#[test]
fn no_argument_tool_call_decodes_empty_object_not_null() {
// A no-arg tool call (e.g. TaskList) streams no input fragments; the
// arguments must be `{}` so it re-encodes to a valid Converse input.
let mut d = decoder();
feed(&mut d, "messageStart", r#"{"role":"assistant"}"#);
feed(
&mut d,
"contentBlockStart",
r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"TaskList"}},"contentBlockIndex":0}"#,
);
let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#);
let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else {
panic!("expected ToolCallEnd");
};
assert_eq!(tool_call.arguments, serde_json::json!({}));
assert!(!tool_call.arguments.is_null());
}
#[test]
fn tool_use_accumulates_string_input_fragments() {
let mut d = decoder();
feed(&mut d, "messageStart", r#"{"role":"assistant"}"#);
let start = feed(
&mut d,
"contentBlockStart",
r#"{"start":{"toolUse":{"toolUseId":"tool-1","name":"search"}},"contentBlockIndex":0}"#,
);
assert!(matches!(start[0], StreamEvent::ToolCallStart { .. }));
feed(
&mut d,
"contentBlockDelta",
r#"{"delta":{"toolUse":{"input":"{\"que"}},"contentBlockIndex":0}"#,
);
feed(
&mut d,
"contentBlockDelta",
r#"{"delta":{"toolUse":{"input":"ry\":\"foo\"}"}},"contentBlockIndex":0}"#,
);
let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#);
let StreamEvent::ToolCallEnd { tool_call } = &stop[0] else {
panic!("expected ToolCallEnd");
};
assert_eq!(tool_call.id, "tool-1");
assert_eq!(tool_call.arguments["query"], "foo");
feed(&mut d, "messageStop", r#"{"stopReason":"tool_use"}"#);
let finish = feed(
&mut d,
"metadata",
r#"{"usage":{"inputTokens":1,"outputTokens":1}}"#,
);
let StreamEvent::Finish { finish_reason, .. } = &finish[0] else {
panic!("expected Finish");
};
assert_eq!(*finish_reason, FinishReason::ToolCalls);
}
#[test]
fn reasoning_deltas_round_trip_signature() {
let mut d = decoder();
let events = feed(
&mut d,
"contentBlockDelta",
r#"{"delta":{"reasoningContent":{"text":"thinking"}},"contentBlockIndex":0}"#,
);
assert!(matches!(events[0], StreamEvent::ReasoningStart));
assert!(matches!(events[1], StreamEvent::ReasoningDelta { .. }));
feed(
&mut d,
"contentBlockDelta",
r#"{"delta":{"reasoningContent":{"signature":"sig-9"}},"contentBlockIndex":0}"#,
);
let stop = feed(&mut d, "contentBlockStop", r#"{"contentBlockIndex":0}"#);
assert!(matches!(stop[0], StreamEvent::ReasoningEnd));
let finish = feed(
&mut d,
"metadata",
r#"{"usage":{"inputTokens":1,"outputTokens":1}}"#,
);
let StreamEvent::Finish { response, .. } = &finish[0] else {
panic!("expected Finish");
};
let ContentPart::Thinking(thinking) = &response.message.content[0] else {
panic!("expected thinking part");
};
assert_eq!(thinking.text, "thinking");
assert_eq!(thinking.signature.as_deref(), Some("sig-9"));
}
#[test]
fn stream_end_without_metadata_synthesizes_finish() {
let mut d = decoder();
feed(
&mut d,
"contentBlockDelta",
r#"{"delta":{"text":"partial"},"contentBlockIndex":0}"#,
);
let events = d.finish();
let StreamEvent::Finish { response, .. } = &events[0] else {
panic!("expected synthesized Finish");
};
assert_eq!(response.text(), "partial");
// Synthesis happens once.
assert!(d.finish().is_empty());
}
#[test]
fn unknown_events_are_tolerated() {
let mut d = decoder();
assert!(feed(&mut d, "futureEventKind", r#"{"anything":1}"#).is_empty());
assert!(
d.on_event(RawEvent {
event: None,
data: "{}",
})
.unwrap()
.is_empty()
);
}
}

View file

@ -11,6 +11,7 @@
//! methods, never extend the contract.
pub(crate) mod anthropic_messages;
pub(crate) mod bedrock_converse;
pub(crate) mod gemini_generate;
pub(crate) mod openai_compatible;
pub(crate) mod openai_responses;
@ -20,6 +21,35 @@ use fabro_model::Model;
use crate::error::{Error, error_from_status_code};
use crate::types::{Message, RateLimitInfo, Request, Response, Role, StreamEvent};
/// Parse a streamed/generated tool-argument JSON string, defaulting malformed
/// or absent arguments to the canonical no-argument object.
pub(crate) fn parse_tool_arguments_or_empty(raw_arguments: &str) -> serde_json::Value {
serde_json::from_str(raw_arguments).unwrap_or_else(|_| serde_json::json!({}))
}
/// Merge `provider_options.<provider_name>` fields into an encoded request
/// body. Used by codecs whose provider-options namespace is adapter-name keyed
/// rather than a single fixed provider.
pub(crate) fn merge_named_provider_options(
body: &mut serde_json::Value,
provider_options: Option<&serde_json::Value>,
provider_name: &str,
) {
let Some(opts) = provider_options.and_then(|opts| opts.get(provider_name)) else {
return;
};
let Some(body_map) = body.as_object_mut() else {
return;
};
let Some(opts_map) = opts.as_object() else {
return;
};
for (key, value) in opts_map {
body_map.insert(key.clone(), value.clone());
}
}
/// Per-request context. Borrowed — the codec reads what it needs and returns.
pub(crate) struct CodecCtx<'a> {
/// The canonical request being translated. Decoders read it too

View file

@ -2,7 +2,7 @@
use super::translate;
use super::wire::ApiRequest;
use crate::codec::{CodecCtx, EncodedRequest};
use crate::codec::{CodecCtx, EncodedRequest, merge_named_provider_options};
/// Build the Chat Completions request for `ctx.request`. `stream` toggles the
/// `stream` body field. The body is assembled as a `serde_json::Value` so
@ -63,19 +63,7 @@ pub(super) fn merge_provider_options(
provider_options: Option<&serde_json::Value>,
provider_name: &str,
) {
let Some(opts) = provider_options.and_then(|opts| opts.get(provider_name)) else {
return;
};
let Some(body_map) = body.as_object_mut() else {
return;
};
let Some(opts_map) = opts.as_object() else {
return;
};
for (key, value) in opts_map {
body_map.insert(key.clone(), value.clone());
}
merge_named_provider_options(body, provider_options, provider_name);
}
#[cfg(test)]

View file

@ -3,7 +3,7 @@
use serde::Deserialize;
use super::wire::{ApiResponse, ApiUsage, InputTokensResponse};
use crate::codec::CodecCtx;
use crate::codec::{CodecCtx, parse_tool_arguments_or_empty};
use crate::error::Error;
use crate::types::{
ContentPart, FinishReason, Message, RateLimitInfo, Response, Role, TokenCounts, ToolCall,
@ -75,7 +75,7 @@ pub(super) fn tool_call_from_item(item: &serde_json::Value, custom: bool) -> Too
.get("arguments")
.and_then(serde_json::Value::as_str)
.unwrap_or("{}");
let arguments = serde_json::from_str(args_str).unwrap_or_else(|_| serde_json::json!({}));
let arguments = parse_tool_arguments_or_empty(args_str);
let mut tc = ToolCall::new(call_id, name, arguments);
tc.raw_arguments = Some(args_str.to_string());
tc

View file

@ -0,0 +1,219 @@
//! Decoder for Bedrock's `application/vnd.amazon.eventstream` streaming
//! responses.
//!
//! ConverseStream wraps each event in a binary event-stream frame: the event
//! name (`messageStart`, `contentBlockDelta`, `metadata`, ...) travels in the
//! frame's `:event-type` header and the payload is that event's JSON
//! directly. (The base64 `{"bytes": ...}` wrapping belongs to
//! `InvokeModelWithResponseStream`'s `PayloadPart` and does not apply here.)
//! Exception and error frames are surfaced as stream errors.
use aws_smithy_eventstream::frame::{DecodedFrame, MessageFrameDecoder};
use aws_smithy_types::event_stream::Message;
use aws_smithy_types::str_bytes::StrBytes;
use bytes::BytesMut;
use crate::error::Error;
/// One decoded ConverseStream event: the `:event-type` header value plus the
/// frame's JSON payload, ready to feed a stream decoder.
#[derive(Debug)]
pub(crate) struct DecodedEvent {
pub event_type: String,
pub payload: String,
}
/// Incremental decoder over event-stream bytes.
pub(crate) struct FrameDecoder {
inner: MessageFrameDecoder,
buffer: BytesMut,
}
impl FrameDecoder {
pub(crate) fn new() -> Self {
Self {
inner: MessageFrameDecoder::new(),
buffer: BytesMut::new(),
}
}
/// Feed newly received bytes and return any complete events decoded from
/// them. Bedrock exception and error frames are surfaced as errors.
pub(crate) fn push(&mut self, bytes: &[u8]) -> Result<Vec<DecodedEvent>, Error> {
self.buffer.extend_from_slice(bytes);
let mut events = Vec::new();
loop {
// `decode_frame` advances `self.buffer` and retains partial-frame
// state internally, so repeated calls over a growing buffer work.
let frame = self.inner.decode_frame(&mut self.buffer).map_err(|e| {
Error::stream_error(
format!("bedrock event-stream decode: {e}"),
std::io::Error::other(e.to_string()),
)
})?;
match frame {
DecodedFrame::Complete(message) => {
if let Some(event) = Self::message_to_event(&message)? {
events.push(event);
}
}
DecodedFrame::Incomplete => break,
}
}
Ok(events)
}
/// Classify one event-stream message.
///
/// `event` frames yield their `:event-type` name and JSON payload;
/// `exception` frames (modeled AWS errors such as `throttlingException`,
/// arriving in-band after HTTP 200) and `error` frames (unmodeled) are
/// turned into errors. Frames without an event type are skipped.
fn message_to_event(message: &Message) -> Result<Option<DecodedEvent>, Error> {
match header_str(message, ":message-type") {
Some("exception") => {
let kind = header_str(message, ":exception-type").unwrap_or("unknown");
let body = String::from_utf8_lossy(message.payload());
Err(Error::stream_error(
format!("bedrock stream exception ({kind}): {body}"),
std::io::Error::other("bedrock event-stream exception frame"),
))
}
Some("error") => {
let code = header_str(message, ":error-code").unwrap_or("unknown");
let detail = header_str(message, ":error-message").unwrap_or("");
Err(Error::stream_error(
format!("bedrock stream error ({code}): {detail}"),
std::io::Error::other("bedrock event-stream error frame"),
))
}
_ => {
let Some(event_type) = header_str(message, ":event-type") else {
return Ok(None);
};
Ok(Some(DecodedEvent {
event_type: event_type.to_string(),
payload: String::from_utf8_lossy(message.payload()).into_owned(),
}))
}
}
}
}
/// Read a string-valued event-stream header by name.
fn header_str<'a>(message: &'a Message, name: &str) -> Option<&'a str> {
message
.headers()
.iter()
.find(|header| header.name().as_str() == name)
.and_then(|header| header.value().as_string().ok())
.map(StrBytes::as_str)
}
#[cfg(test)]
pub(crate) mod tests {
use aws_smithy_eventstream::frame::write_message_to;
use aws_smithy_types::event_stream::{Header, HeaderValue, Message};
use super::*;
/// Build one ConverseStream event frame: event name in `:event-type`,
/// payload = the event JSON directly.
fn encode_event_frame(event_type: &str, payload_json: &str) -> Vec<u8> {
let message = Message::new(payload_json.as_bytes().to_vec())
.add_header(Header::new(
":message-type",
HeaderValue::String("event".into()),
))
.add_header(Header::new(
":event-type",
HeaderValue::String(event_type.to_string().into()),
))
.add_header(Header::new(
":content-type",
HeaderValue::String("application/json".into()),
));
let mut buf = Vec::new();
write_message_to(&message, &mut buf).unwrap();
buf
}
/// Build a full streaming body from `(event_type, payload_json)` pairs.
pub(crate) fn build_stream_body(events: &[(&str, &str)]) -> Vec<u8> {
let mut body = Vec::new();
for (event_type, payload) in events {
body.extend_from_slice(&encode_event_frame(event_type, payload));
}
body
}
#[test]
fn decodes_event_frame_to_typed_payload() {
let frame = encode_event_frame(
"contentBlockDelta",
r#"{"delta":{"text":"hi"},"contentBlockIndex":0}"#,
);
let mut decoder = FrameDecoder::new();
let events = decoder.push(&frame).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, "contentBlockDelta");
let payload: serde_json::Value = serde_json::from_str(&events[0].payload).unwrap();
assert_eq!(payload["delta"]["text"], "hi");
}
#[test]
fn reassembles_frame_split_across_pushes() {
let frame = encode_event_frame("messageStop", r#"{"stopReason":"end_turn"}"#);
let split = frame.len() / 2;
let mut decoder = FrameDecoder::new();
assert!(decoder.push(&frame[..split]).unwrap().is_empty());
let events = decoder.push(&frame[split..]).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_type, "messageStop");
}
#[test]
fn exception_frame_surfaces_as_error() {
let message = Message::new(br#"{"message":"Too many requests"}"#.to_vec())
.add_header(Header::new(
":message-type",
HeaderValue::String("exception".into()),
))
.add_header(Header::new(
":exception-type",
HeaderValue::String("throttlingException".into()),
));
let mut buf = Vec::new();
write_message_to(&message, &mut buf).unwrap();
let mut decoder = FrameDecoder::new();
let err = decoder.push(&buf).unwrap_err();
let rendered = err.to_string();
assert!(rendered.contains("throttlingException"), "{rendered}");
assert!(rendered.contains("Too many requests"), "{rendered}");
}
#[test]
fn unmodeled_error_frame_surfaces_as_error() {
let message = Message::new(Vec::new())
.add_header(Header::new(
":message-type",
HeaderValue::String("error".into()),
))
.add_header(Header::new(
":error-code",
HeaderValue::String("InternalError".into()),
))
.add_header(Header::new(
":error-message",
HeaderValue::String("stream broke".into()),
));
let mut buf = Vec::new();
write_message_to(&message, &mut buf).unwrap();
let mut decoder = FrameDecoder::new();
let err = decoder.push(&buf).unwrap_err();
let rendered = err.to_string();
assert!(rendered.contains("InternalError"), "{rendered}");
}
}

View file

@ -0,0 +1,691 @@
//! Provider adapter for Amazon Bedrock (Converse/ConverseStream).
//!
//! A thin transport shell over the `bedrock_converse` codec: it owns auth
//! (SigV4 signing or a bearer Bedrock API key), the region derivation, and
//! the AWS event-stream byte loop. All wire translation lives in the codec;
//! one codec serves every Converse-capable family because AWS translates the
//! envelope server-side.
pub(crate) mod eventstream;
pub(crate) mod sigv4;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::Duration;
use eventstream::FrameDecoder;
use fabro_auth::ApiKeyHeader;
use fabro_model::Catalog;
use futures::stream;
use sigv4::Sigv4Signer;
use tokio::sync::OnceCell;
use tokio::time;
use crate::adapter_registry::AdapterConfig;
#[cfg(test)]
use crate::adapter_registry::AdapterKindOptions;
use crate::attachments::{self, AttachmentPolicy};
use crate::codec::bedrock_converse::BedrockConverse;
use crate::codec::{Codec, CodecCtx, CodecParams, EncodedRequest, RawEvent, StreamDecoder};
use crate::error::Error;
use crate::provider::{self, ProviderAdapter, StreamEventStream};
use crate::providers::common::{self as common};
use crate::transport::{self, HttpTransport};
use crate::types::{AdapterTimeout, Request, Response, StreamEvent};
/// How the adapter authenticates to Bedrock.
pub(crate) enum BedrockAuth {
/// Bedrock API key, sent as an `Authorization: Bearer` token.
ApiKey(String),
/// SigV4 signing. The signer (holding the AWS default credential chain)
/// is resolved on first use and cached; the chain itself re-resolves
/// expiring credentials per request. Tests pre-seed the cell with a
/// static signer.
Sigv4(OnceCell<Sigv4Signer>),
}
/// Build a boxed Bedrock adapter from a resolved [`AdapterConfig`].
///
/// Kept in this module (rather than the generic adapter registry) so that
/// Bedrock-specific construction stays encapsulated here. The auth mode is
/// implied by the resolved credential: an `aws_sigv4` credential signs with
/// the AWS chain; a static token is sent as a bearer API key.
pub(crate) fn build(config: AdapterConfig) -> Result<Arc<dyn ProviderAdapter>, Error> {
let base_url = config
.base_url
.clone()
.ok_or_else(|| Error::Configuration {
message: format!(
"bedrock provider '{}' requires a base_url (the Bedrock runtime endpoint)",
config.provider_id
),
source: None,
})?;
let adapter = match config.auth_header {
Some(ApiKeyHeader::AwsSigv4) => Adapter::new_sigv4(base_url)?,
Some(ApiKeyHeader::Bearer(token)) => Adapter::new_api_key(token, base_url)?,
Some(ApiKeyHeader::Custom { name, .. }) => {
return Err(Error::Configuration {
message: format!(
"bedrock provider '{}' does not support custom auth header '{}' (use bearer \
credentials or aws_sigv4)",
config.provider_id, name
),
source: None,
});
}
None => {
return Err(Error::Configuration {
message: format!(
"bedrock provider '{}' has no resolved credential (configure `aws_sigv4` or \
an API key)",
config.provider_id
),
source: None,
});
}
};
let mut adapter = adapter.with_name(config.provider_id);
if !config.extra_headers.is_empty() {
adapter = adapter.with_default_headers(config.extra_headers);
}
if let Some(catalog) = config.catalog {
adapter = adapter.with_catalog(catalog);
}
Ok(Arc::new(adapter))
}
/// Provider adapter for Amazon Bedrock.
pub struct Adapter {
pub(crate) http: HttpTransport,
provider_name: String,
region: String,
auth: BedrockAuth,
catalog: Option<Arc<Catalog>>,
}
impl Adapter {
/// Construct an adapter that authenticates with a Bedrock API key.
/// `base_url` is the Bedrock runtime endpoint; the signing region is
/// parsed from it.
pub fn new_api_key(
token: impl Into<String>,
base_url: impl Into<String>,
) -> Result<Self, Error> {
Self::with_auth(base_url, BedrockAuth::ApiKey(token.into()))
}
/// Construct a SigV4 adapter. Credentials resolve lazily from the AWS
/// default chain on the first request, so construction stays synchronous.
pub fn new_sigv4(base_url: impl Into<String>) -> Result<Self, Error> {
Self::with_auth(base_url, BedrockAuth::Sigv4(OnceCell::new()))
}
fn with_auth(base_url: impl Into<String>, auth: BedrockAuth) -> Result<Self, Error> {
let base_url = base_url.into();
let region = region_from_base_url(&base_url)?;
Ok(Self {
http: HttpTransport::new_optional(None, base_url),
provider_name: "bedrock".to_string(),
region,
auth,
catalog: None,
})
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.provider_name = name.into();
self
}
#[must_use]
pub fn with_catalog(mut self, catalog: Arc<Catalog>) -> Self {
self.catalog = Some(catalog);
self
}
#[must_use]
pub fn with_default_headers(mut self, headers: HashMap<String, String>) -> Self {
self.http = self.http.with_default_headers(headers);
self
}
#[must_use]
pub fn with_timeout(self, timeout: AdapterTimeout) -> Self {
Self {
http: self.http.with_timeout(timeout),
..self
}
}
fn codec_ctx<'a>(
&'a self,
request: &'a Request,
deployment_id: &'a str,
params: &'a CodecParams,
) -> CodecCtx<'a> {
CodecCtx {
request,
provider_name: &self.provider_name,
deployment_id,
model: common::catalog_model(self.catalog.as_deref(), &request.model),
params,
}
}
/// Resolve file-backed attachments to inline data first: Converse takes
/// inline image and document bytes (no URL sources).
async fn resolve_request<'a>(&self, request: &'a Request) -> std::borrow::Cow<'a, Request> {
let policy = AttachmentPolicy {
images: true,
documents: true,
audio: false,
};
attachments::resolve(request, policy).await
}
/// Build the signed/bearer HTTP request for an encoded Converse call.
async fn build_http_request(
&self,
encoded: &EncodedRequest,
stream: bool,
) -> Result<fabro_http::RequestBuilder, Error> {
let url = format!("{}{}", self.http.base_url, encoded.endpoint);
let body = serde_json::to_vec(&encoded.body).map_err(|e| Error::Configuration {
message: format!("failed to serialize converse request: {e}"),
source: None,
})?;
let mut req = self.http.client.post(&url);
for (key, value) in &self.http.default_headers {
req = req.header(key, value);
}
for (key, value) in &encoded.headers {
req = req.header(key, value);
}
req = match &self.auth {
BedrockAuth::ApiKey(token) => req.bearer_auth(token).body(body),
BedrockAuth::Sigv4(cell) => {
let signer = cell
.get_or_try_init(Sigv4Signer::from_default_chain)
.await?;
signer.sign_post(req, &self.region, &url, body).await?
}
};
req = req.header("content-type", "application/json");
if stream {
req = req.header("accept", "application/vnd.amazon.eventstream");
}
if let Some(t) = self.http.request_timeout {
if !stream {
req = req.timeout(t);
}
}
Ok(req)
}
}
#[async_trait::async_trait]
impl ProviderAdapter for Adapter {
fn name(&self) -> &str {
&self.provider_name
}
async fn complete(&self, request: &Request) -> Result<Response, Error> {
self.validate_request(request)?;
let resolved = self.resolve_request(request).await;
let codec = BedrockConverse;
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
let params = CodecParams::default();
let ctx = self.codec_ctx(&resolved, &deployment_id, &params);
let encoded = codec.encode(&ctx, false)?;
let req = self.build_http_request(&encoded, false).await?;
transport::complete_via_http(req, &codec, &ctx).await
}
async fn stream(&self, request: &Request) -> Result<StreamEventStream, Error> {
self.validate_request(request)?;
let resolved = self.resolve_request(request).await;
let codec = BedrockConverse;
let deployment_id = common::api_model_id(self.catalog.as_deref(), &resolved.model);
let params = CodecParams::default();
let ctx = self.codec_ctx(&resolved, &deployment_id, &params);
let encoded = codec.encode(&ctx, true)?;
let req = self.build_http_request(&encoded, true).await?;
let http_resp = req
.send()
.await
.map_err(|e| Error::network(e.to_string(), e))?;
let status = http_resp.status();
if !status.is_success() {
let retry_after = transport::parse_retry_after(http_resp.headers());
let body = http_resp
.text()
.await
.map_err(|e| Error::network(e.to_string(), e))?;
return Err(codec.decode_error(status.as_u16(), &body, &ctx, retry_after));
}
let rate_limit = transport::parse_rate_limit_headers(http_resp.headers());
let decoder = codec.stream_decoder(&ctx, rate_limit);
Ok(decode_eventstream(
http_resp,
decoder,
self.http.stream_read_timeout,
))
}
fn supports_tool_choice(&self, mode: &str) -> bool {
// Converse has no `none` tool choice on the wire.
matches!(mode, "auto" | "required" | "named")
}
fn validate_request(&self, request: &Request) -> Result<(), Error> {
if let Some(tool_choice) = &request.tool_choice {
provider::validate_tool_choice(self, tool_choice)?;
}
Ok(())
}
}
/// State driving the event-stream byte loop: the codec's decoder plus the
/// frame decoder, with a buffer that flattens batched events.
struct EventStreamLoop {
response: fabro_http::Response,
frames: FrameDecoder,
decoder: Box<dyn StreamDecoder>,
pending: VecDeque<StreamEvent>,
done: bool,
/// `finish()` already drained.
finished: bool,
timeout: Option<Duration>,
}
/// Drive `decoder` over the AWS event-stream byte stream of `response`: the
/// event-stream sibling of the transport's shared SSE loop, anticipated by
/// the transport consolidation notes.
fn decode_eventstream(
response: fabro_http::Response,
decoder: Box<dyn StreamDecoder>,
timeout: Option<Duration>,
) -> StreamEventStream {
let out = stream::unfold(
EventStreamLoop {
response,
frames: FrameDecoder::new(),
decoder,
pending: VecDeque::new(),
done: false,
finished: false,
timeout,
},
move |mut state| async move {
loop {
if let Some(event) = state.pending.pop_front() {
return Some((Ok(event), state));
}
if state.done {
if state.finished {
return None;
}
state.finished = true;
state.pending.extend(state.decoder.finish());
if state.pending.is_empty() {
return None;
}
continue;
}
let chunk_result = match state.timeout {
Some(timeout) => time::timeout(timeout, state.response.chunk()).await,
None => Ok(state.response.chunk().await),
};
match chunk_result {
Ok(Ok(Some(bytes))) => {
let frames = match state.frames.push(&bytes) {
Ok(frames) => frames,
Err(e) => return Some((Err(e), state)),
};
for frame in frames {
let raw = RawEvent {
event: Some(frame.event_type.as_str()),
data: frame.payload.as_str(),
};
match state.decoder.on_event(raw) {
Ok(events) => state.pending.extend(events),
Err(e) => return Some((Err(e), state)),
}
}
}
Ok(Ok(None)) => state.done = true,
Ok(Err(e)) => {
return Some((Err(Error::stream_error(e.to_string(), e)), state));
}
Err(_) => {
return Some((
Err(Error::Stream {
message: "stream read timed out waiting for next event".to_string(),
source: None,
}),
state,
));
}
}
}
},
);
Box::pin(out)
}
/// Derive the AWS region from a Bedrock runtime endpoint URL.
///
/// The region is a SigV4 signing parameter, so it is parsed from the
/// configured base URL rather than carried as a separate AWS-specific config
/// field. It is validated as `[a-z0-9-]` since it ultimately appears in a
/// signed request.
fn region_from_base_url(base_url: &str) -> Result<String, Error> {
let invalid = || Error::Configuration {
message: format!(
"bedrock base_url '{base_url}' is not a recognized Bedrock runtime endpoint \
(expected https://bedrock-runtime[-fips].<region>.amazonaws.com[.cn])"
),
source: None,
};
#[expect(
clippy::disallowed_types,
reason = "Bedrock region derivation needs URL host parsing; the raw URL is not logged or rendered."
)]
let parsed = fabro_http::Url::parse(base_url).map_err(|_| invalid())?;
let host = parsed.host_str().ok_or_else(invalid)?;
let rest = host
.strip_prefix("bedrock-runtime-fips.")
.or_else(|| host.strip_prefix("bedrock-runtime."))
.ok_or_else(invalid)?;
let region = rest
.strip_suffix(".amazonaws.com.cn")
.or_else(|| rest.strip_suffix(".amazonaws.com"))
.ok_or_else(invalid)?;
let valid = !region.is_empty()
&& region
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
if valid {
Ok(region.to_string())
} else {
Err(invalid())
}
}
#[cfg(test)]
mod tests {
use futures::StreamExt;
use httpmock::prelude::*;
use super::*;
use crate::types::{FinishReason, Message};
fn make_request(model: &str) -> Request {
Request {
model: model.to_string(),
messages: vec![Message::user("Hello")],
provider: Some("bedrock".to_string()),
tools: None,
tool_choice: None,
response_format: None,
temperature: None,
top_p: None,
max_tokens: Some(64),
stop_sequences: None,
reasoning_effort: None,
speed: None,
metadata: None,
provider_options: None,
}
}
/// Adapter pointed at httpmock: region parsing only applies to real
/// bedrock-runtime URLs, so the test constructor sets the region field
/// directly.
fn test_adapter(server: &MockServer) -> Adapter {
Adapter {
http: HttpTransport::new_optional(None, server.base_url()),
provider_name: "bedrock".to_string(),
region: "us-east-1".to_string(),
auth: BedrockAuth::ApiKey("test-bedrock-key".to_string()),
catalog: None,
}
}
#[test]
fn region_parses_from_standard_endpoint() {
assert_eq!(
region_from_base_url("https://bedrock-runtime.eu-west-1.amazonaws.com").unwrap(),
"eu-west-1"
);
}
#[test]
fn region_parses_from_fips_endpoint() {
assert_eq!(
region_from_base_url("https://bedrock-runtime-fips.us-gov-west-1.amazonaws.com")
.unwrap(),
"us-gov-west-1"
);
}
#[test]
fn region_parses_from_china_endpoint() {
assert_eq!(
region_from_base_url("https://bedrock-runtime.cn-north-1.amazonaws.com.cn").unwrap(),
"cn-north-1"
);
}
#[test]
fn region_rejects_non_bedrock_hosts() {
for url in [
"https://example.com",
"https://bedrock.us-east-1.amazonaws.com",
"https://bedrock-runtime.amazonaws.com",
] {
assert!(region_from_base_url(url).is_err(), "{url}");
}
}
#[test]
fn region_normalizes_hostname_case() {
assert_eq!(
region_from_base_url("https://bedrock-runtime.US-EAST-1.amazonaws.com").unwrap(),
"us-east-1"
);
}
#[tokio::test]
async fn complete_posts_converse_body_with_bearer_auth() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST)
.path("/model/us.anthropic.claude-sonnet-4-6/converse")
.header("authorization", "Bearer test-bedrock-key")
.json_body_includes(
r#"{"messages":[{"role":"user","content":[{"text":"Hello"}]}],"inferenceConfig":{"maxTokens":64}}"#,
);
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"output": {"message": {"role": "assistant", "content": [{"text": "Hi!"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 8, "outputTokens": 2, "totalTokens": 10}
}));
});
let adapter = test_adapter(&server);
let response = adapter
.complete(&make_request("us.anthropic.claude-sonnet-4-6"))
.await
.unwrap();
mock.assert();
assert_eq!(response.text(), "Hi!");
assert_eq!(response.finish_reason, FinishReason::Stop);
assert_eq!(response.usage.input_tokens, 8);
assert_eq!(response.provider, "bedrock");
}
#[tokio::test]
async fn complete_applies_default_headers() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST)
.path("/model/m/converse")
.header("x-fabro-test", "present");
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2}
}));
});
let adapter = test_adapter(&server).with_default_headers(HashMap::from([(
"x-fabro-test".to_string(),
"present".to_string(),
)]));
let response = adapter.complete(&make_request("m")).await.unwrap();
mock.assert();
assert_eq!(response.text(), "ok");
}
#[test]
fn factory_rejects_custom_auth_header() {
let result = build(AdapterConfig {
provider_id: "bedrock".to_string(),
auth_header: Some(ApiKeyHeader::Custom {
name: "x-api-key".to_string(),
value: "secret".to_string(),
}),
base_url: Some("https://bedrock-runtime.us-east-1.amazonaws.com".to_string()),
extra_headers: HashMap::new(),
kind_options: AdapterKindOptions::None,
catalog: None,
});
let Err(err) = result else {
panic!("expected custom auth header to be rejected");
};
assert!(
err.to_string()
.contains("does not support custom auth header")
);
}
#[tokio::test]
async fn complete_signs_with_sigv4_when_configured() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(POST)
.path("/model/m/converse")
.header_exists("authorization")
.header_exists("x-amz-date");
then.status(200)
.header("content-type", "application/json")
.json_body(serde_json::json!({
"output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}},
"stopReason": "end_turn",
"usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2}
}));
});
let mut adapter = test_adapter(&server);
let cell = OnceCell::new();
cell.set(Sigv4Signer::from_static("AKIDEXAMPLE", "secret", None))
.ok();
adapter.auth = BedrockAuth::Sigv4(cell);
let response = adapter.complete(&make_request("m")).await.unwrap();
mock.assert();
assert_eq!(response.text(), "ok");
}
#[tokio::test]
async fn stream_decodes_eventstream_frames() {
let server = MockServer::start();
let body = eventstream::tests::build_stream_body(&[
("messageStart", r#"{"role":"assistant"}"#),
(
"contentBlockDelta",
r#"{"delta":{"text":"Hel"},"contentBlockIndex":0}"#,
),
(
"contentBlockDelta",
r#"{"delta":{"text":"lo"},"contentBlockIndex":0}"#,
),
("contentBlockStop", r#"{"contentBlockIndex":0}"#),
("messageStop", r#"{"stopReason":"end_turn"}"#),
(
"metadata",
r#"{"usage":{"inputTokens":9,"outputTokens":3,"totalTokens":12}}"#,
),
]);
server.mock(|when, then| {
when.method(POST)
.path("/model/m/converse-stream")
.header("accept", "application/vnd.amazon.eventstream");
then.status(200)
.header("content-type", "application/vnd.amazon.eventstream")
.body(body);
});
let adapter = test_adapter(&server);
let mut stream = adapter.stream(&make_request("m")).await.unwrap();
let mut text = String::new();
let mut finish: Option<Response> = None;
while let Some(event) = stream.next().await {
match event.unwrap() {
StreamEvent::TextDelta { delta, .. } => text.push_str(&delta),
StreamEvent::Finish { response, .. } => finish = Some(*response),
_ => {}
}
}
assert_eq!(text, "Hello");
let response = finish.expect("stream should finish");
assert_eq!(response.text(), "Hello");
assert_eq!(response.usage.input_tokens, 9);
}
#[tokio::test]
async fn stream_surfaces_http_error_before_bytes() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/model/m/converse-stream");
then.status(429)
.json_body(serde_json::json!({"message": "Too many requests"}));
});
let adapter = test_adapter(&server);
let Err(err) = adapter.stream(&make_request("m")).await else {
panic!("expected an HTTP error before any stream bytes");
};
assert_eq!(err.status_code(), Some(429));
}
#[test]
fn tool_choice_none_is_rejected() {
let server = MockServer::start();
let adapter = test_adapter(&server);
assert!(!adapter.supports_tool_choice("none"));
assert!(adapter.supports_tool_choice("auto"));
}
}

View file

@ -0,0 +1,249 @@
//! AWS Signature Version 4 signing for Bedrock requests.
//!
//! Wraps the `aws-sigv4` crate to compute the `Authorization`, `x-amz-date`,
//! and (for temporary credentials) `x-amz-security-token` headers for a fully
//! built request. The headers are then attached to the shared `fabro-http`
//! request builder, so signed Bedrock requests still flow through the same
//! retry/redaction/transport layers as every other adapter.
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use aws_credential_types::Credentials;
use aws_credential_types::provider::SharedCredentialsProvider;
use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign};
use aws_sigv4::sign::v4;
use aws_smithy_runtime_api::client::identity::Identity;
use crate::error::Error;
/// Service name used in the SigV4 credential scope for Bedrock runtime calls.
pub(crate) const SERVICE: &str = "bedrock";
/// Where the signer's credentials come from.
enum CredentialSource {
/// Fixed credentials (tests / explicitly supplied keys).
#[cfg(test)]
Static(Credentials),
/// The AWS default provider chain. Credentials are resolved per request
/// so expiring session credentials (STS, IRSA, instance roles) refresh
/// through the chain's identity cache instead of being snapshotted once
/// at startup.
Chain(SharedCredentialsProvider),
}
/// Signs HTTP requests for AWS services with SigV4.
pub(crate) struct Sigv4Signer {
credentials: CredentialSource,
}
impl Sigv4Signer {
/// Build a signer from static keys. Test-only: production paths resolve
/// credentials through the AWS chain.
#[cfg(test)]
pub(crate) fn from_static(
access_key_id: &str,
secret_access_key: &str,
session_token: Option<String>,
) -> Self {
Self {
credentials: CredentialSource::Static(Credentials::from_keys(
access_key_id,
secret_access_key,
session_token,
)),
}
}
/// Build a signer over the standard AWS provider chain (environment,
/// IRSA/web identity, EC2/ECS instance profile, SSO, assume-role). The
/// chain is resolved once; the credentials it yields are fetched per
/// signing call so they stay fresh over long-lived adapters.
pub(crate) async fn from_default_chain() -> Result<Self, Error> {
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.load()
.await;
let provider = config
.credentials_provider()
.ok_or_else(|| Error::Configuration {
message: "no AWS credentials provider found in the default chain".to_string(),
source: None,
})?;
Ok(Self {
credentials: CredentialSource::Chain(provider),
})
}
/// The credentials to sign the next request with.
async fn current_credentials(&self) -> Result<Credentials, Error> {
use aws_credential_types::provider::ProvideCredentials;
match &self.credentials {
#[cfg(test)]
CredentialSource::Static(credentials) => Ok(credentials.clone()),
CredentialSource::Chain(provider) => {
provider
.provide_credentials()
.await
.map_err(|e| Error::Configuration {
message: format!("failed to resolve AWS credentials: {e}"),
source: None,
})
}
}
}
/// Compute the SigV4 headers for a request: `Authorization`, `x-amz-date`,
/// and `x-amz-security-token` when the credentials carry a session token.
fn signed_headers(
credentials: &Credentials,
region: &str,
service: &str,
method: &str,
url: &str,
body: &[u8],
epoch_secs: u64,
) -> Result<Vec<(String, String)>, Error> {
let identity: Identity = credentials.clone().into();
let signing_params = v4::SigningParams::builder()
.identity(&identity)
.region(region)
.name(service)
.time(UNIX_EPOCH + Duration::from_secs(epoch_secs))
.settings(SigningSettings::default())
.build()
.map_err(|e| Error::Configuration {
message: format!("sigv4 params: {e}"),
source: None,
})?
.into();
let signable =
SignableRequest::new(method, url, std::iter::empty(), SignableBody::Bytes(body))
.map_err(|e| Error::Configuration {
message: format!("sigv4 signable request: {e}"),
source: None,
})?;
let (instructions, _signature) = sign(signable, &signing_params)
.map_err(|e| Error::Configuration {
message: format!("sigv4 signing failed: {e}"),
source: None,
})?
.into_parts();
Ok(instructions
.headers()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect())
}
/// Apply SigV4 signed headers to a `fabro-http` request builder for a
/// `POST` to `url` carrying `body`.
pub(crate) async fn sign_post(
&self,
mut req: fabro_http::RequestBuilder,
region: &str,
url: &str,
body: Vec<u8>,
) -> Result<fabro_http::RequestBuilder, Error> {
let credentials = self.current_credentials().await?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| Error::Configuration {
message: format!("system clock before epoch: {e}"),
source: None,
})?
.as_secs();
for (name, value) in
Self::signed_headers(&credentials, region, SERVICE, "POST", url, &body, now)?
{
req = req.header(name, value);
}
Ok(req.body(body))
}
}
#[cfg(test)]
mod tests {
use super::*;
// Fixed credentials + time produce a deterministic Authorization header.
// The expected value is locked below after the first green run so the test
// guards against accidental changes to the signing logic.
const ACCESS_KEY: &str = "AKIDEXAMPLE";
const SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
const FIXED_EPOCH: u64 = 1_716_960_000; // 2024-05-29T04:00:00Z
const URL: &str = "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-sonnet-4-6/converse";
fn static_credentials(signer: &Sigv4Signer) -> Credentials {
match &signer.credentials {
CredentialSource::Static(credentials) => credentials.clone(),
CredentialSource::Chain(_) => panic!("test signer should hold static credentials"),
}
}
fn auth_header(headers: &[(String, String)]) -> &str {
headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("authorization"))
.map(|(_, value)| value.as_str())
.expect("authorization header must be present")
}
fn sign_fixed(signer: &Sigv4Signer, body: &[u8]) -> Vec<(String, String)> {
Sigv4Signer::signed_headers(
&static_credentials(signer),
"us-east-1",
SERVICE,
"POST",
URL,
body,
FIXED_EPOCH,
)
.unwrap()
}
#[test]
fn produces_authorization_and_date_headers() {
let signer = Sigv4Signer::from_static(ACCESS_KEY, SECRET_KEY, None);
let headers = sign_fixed(&signer, br#"{"messages":[]}"#);
assert!(
headers
.iter()
.any(|(n, _)| n.eq_ignore_ascii_case("authorization"))
);
assert!(
headers
.iter()
.any(|(n, _)| n.eq_ignore_ascii_case("x-amz-date"))
);
let auth = auth_header(&headers);
assert!(auth.starts_with("AWS4-HMAC-SHA256 "));
assert!(auth.contains("Credential=AKIDEXAMPLE/20240529/us-east-1/bedrock/aws4_request"));
assert!(auth.contains("SignedHeaders="));
assert!(auth.contains("Signature="));
}
#[test]
fn deterministic_signature_is_stable() {
let signer = Sigv4Signer::from_static(ACCESS_KEY, SECRET_KEY, None);
// Same inputs must yield an identical signature (regression lock).
assert_eq!(
auth_header(&sign_fixed(&signer, br#"{"messages":[]}"#)),
auth_header(&sign_fixed(&signer, br#"{"messages":[]}"#)),
);
}
#[test]
fn session_token_adds_security_token_header() {
let signer =
Sigv4Signer::from_static(ACCESS_KEY, SECRET_KEY, Some("session-tok".to_string()));
let headers = sign_fixed(&signer, b"{}");
assert!(
headers
.iter()
.any(|(n, v)| n.eq_ignore_ascii_case("x-amz-security-token") && v == "session-tok")
);
}
}

View file

@ -1,4 +1,5 @@
pub mod anthropic;
pub(crate) mod bedrock;
pub mod common;
pub mod fabro_server;
pub mod gemini;
@ -6,6 +7,7 @@ pub mod openai;
pub mod openai_compatible;
pub use anthropic::Adapter as AnthropicAdapter;
pub use bedrock::Adapter as BedrockAdapter;
pub use fabro_server::Adapter as FabroServerAdapter;
pub use gemini::Adapter as GeminiAdapter;
pub use openai::Adapter as OpenAiAdapter;

View file

@ -8,7 +8,7 @@ use std::sync::Arc;
use fabro_llm::error::ProviderErrorKind;
use fabro_llm::provider::ProviderAdapter;
use fabro_llm::providers::{
AnthropicAdapter, GeminiAdapter, OpenAiAdapter, OpenAiCompatibleAdapter,
AnthropicAdapter, BedrockAdapter, GeminiAdapter, OpenAiAdapter, OpenAiCompatibleAdapter,
};
use fabro_llm::types::{CostSource, FinishReason, Message, Request};
use fabro_model::Catalog;
@ -176,6 +176,69 @@ async fn gemini_complete() {
assert_eq!(response.provider, "gemini");
}
#[fabro_macros::e2e_test(live("AWS_BEARER_TOKEN_BEDROCK"))]
async fn bedrock_complete_with_api_key() {
let token = std::env::var(EnvVars::AWS_BEARER_TOKEN_BEDROCK)
.expect("AWS_BEARER_TOKEN_BEDROCK must be set");
let adapter =
BedrockAdapter::new_api_key(token, "https://bedrock-runtime.us-east-1.amazonaws.com")
.unwrap()
.with_name("bedrock");
// Amazon Nova: first-party, no Anthropic-approval gate and no third-party
// marketplace subscription, so this runs on any Bedrock-enabled account.
let request = make_request("us.amazon.nova-2-lite-v1:0");
let response = adapter.complete(&request).await.unwrap();
assert!(
!response.text().is_empty(),
"response text should not be empty"
);
assert!(response.usage.input_tokens > 0);
assert!(response.usage.output_tokens > 0);
assert_eq!(response.provider, "bedrock");
}
#[fabro_macros::e2e_test(live("AWS_ACCESS_KEY_ID"))]
async fn bedrock_complete_with_sigv4() {
let adapter = BedrockAdapter::new_sigv4("https://bedrock-runtime.us-east-1.amazonaws.com")
.unwrap()
.with_name("bedrock");
// First-party Nova — see bedrock_complete_with_api_key for why.
let request = make_request("us.amazon.nova-2-lite-v1:0");
let response = adapter.complete(&request).await.unwrap();
assert!(
!response.text().is_empty(),
"response text should not be empty"
);
assert!(response.usage.input_tokens > 0);
assert_eq!(response.provider, "bedrock");
}
#[fabro_macros::e2e_test(live("AWS_BEARER_TOKEN_BEDROCK"))]
async fn bedrock_openai_frontier_complete() {
let token = std::env::var(EnvVars::AWS_BEARER_TOKEN_BEDROCK)
.expect("AWS_BEARER_TOKEN_BEDROCK must be set");
// GPT-5.x on Bedrock is the bedrock-mantle Responses surface: the plain
// openai adapter pointed at the mantle endpoint with the Bedrock key as
// the bearer token.
let adapter = OpenAiAdapter::new(token)
.with_base_url("https://bedrock-mantle.us-east-1.api.aws/openai/v1")
.with_name("bedrock-openai");
let request = Request {
temperature: None,
..make_request("openai.gpt-5.5")
};
let response = adapter.complete(&request).await.unwrap();
assert!(
!response.text().is_empty(),
"response text should not be empty"
);
assert!(response.usage.input_tokens > 0);
assert_eq!(response.provider, "bedrock-openai");
}
#[fabro_macros::e2e_test(live("OPENROUTER_API_KEY"))]
async fn openrouter_complete() {
let api_key =

View file

@ -32,6 +32,7 @@ pub enum AdapterKind {
#[serde(rename = "openai_compatible")]
#[strum(to_string = "openai_compatible")]
OpenAiCompatible,
Bedrock,
}
impl AdapterKind {
@ -87,6 +88,16 @@ mod tests {
}
}
#[test]
fn bedrock_adapter_kind_roundtrips() {
assert_eq!(AdapterKind::Bedrock.as_str(), "bedrock");
assert_eq!(
"bedrock".parse::<AdapterKind>().unwrap(),
AdapterKind::Bedrock
);
assert!(AdapterKind::VARIANTS.contains(&AdapterKind::Bedrock));
}
#[test]
fn agent_profile_kind_round_trips_as_settings_strings() {
for (kind, expected) in [

View file

@ -171,11 +171,19 @@ pub struct CostRates {
pub cache_input_cost_per_mtok: Option<f64>,
}
/// Where a provider's credential comes from.
///
/// `Vault`/`Env` reference a stored secret resolved to an auth header.
/// `AwsSigv4` is an opaque source: the credential comes from the AWS default
/// credential chain and the request is SigV4-signed rather than carrying a
/// static secret. It is only valid on Bedrock providers, which catalog
/// validation enforces before adapter construction.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub enum CredentialRef {
Vault(String),
Env(String),
AwsSigv4,
}
impl std::fmt::Display for CredentialRef {
@ -183,6 +191,7 @@ impl std::fmt::Display for CredentialRef {
match self {
Self::Vault(name) => write!(f, "vault:{name}"),
Self::Env(name) => write!(f, "env:{name}"),
Self::AwsSigv4 => write!(f, "aws_sigv4"),
}
}
}
@ -209,6 +218,9 @@ impl FromStr for CredentialRef {
}
return Ok(Self::Env(name.to_string()));
}
if value == "aws_sigv4" {
return Ok(Self::AwsSigv4);
}
Err(CredentialRefParseError::Invalid)
}
}
@ -223,7 +235,7 @@ impl TryFrom<String> for CredentialRef {
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum CredentialRefParseError {
#[error("credential reference must be `vault:<name>` or `env:<NAME>`")]
#[error("credential reference must be `vault:<name>`, `env:<NAME>`, or `aws_sigv4`")]
Invalid,
#[error("credential reference is missing a name after `vault:`")]
EmptyVault,
@ -234,6 +246,9 @@ pub enum CredentialRefParseError {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderAuthConfig {
/// Ordered credential sources; the first that resolves wins. Static secrets
/// use `env:<NAME>` / `vault:<NAME>`; AWS SigV4 (Bedrock) uses `aws_sigv4`,
/// which resolves opaquely from the AWS credential chain.
pub credentials: Vec<CredentialRef>,
#[serde(default)]
pub header: ApiKeyHeaderPolicy,
@ -511,7 +526,7 @@ impl CatalogProvider {
.iter()
.find_map(|credential_ref| match credential_ref {
CredentialRef::Vault(name) => Some(name.as_str()),
CredentialRef::Env(_) => None,
CredentialRef::Env(_) | CredentialRef::AwsSigv4 => None,
})
}
}
@ -600,6 +615,13 @@ pub enum CatalogBuildError {
},
#[error("provider '{provider}' API-key auth must declare at least one credential")]
EmptyApiKeyCredentials { provider: ProviderId },
#[error(
"provider '{provider}' uses aws_sigv4 credentials, but adapter '{adapter}' does not support SigV4"
)]
UnsupportedAwsSigv4Credential {
provider: ProviderId,
adapter: AdapterKind,
},
#[error("provider identifier '{identifier}' is declared by both '{first}' and '{second}'")]
DuplicateProviderIdentifier {
identifier: String,
@ -1339,7 +1361,7 @@ fn build_providers(
let codec = resolve_provider_codec(&provider_id, adapter, settings.codec)?;
let agent_profile = settings.agent_profile.unwrap_or(defaults.agent_profile);
let auth = settings.auth.clone();
validate_provider_auth(&provider_id, auth.as_ref())?;
validate_provider_auth(&provider_id, adapter, auth.as_ref())?;
providers.push(CatalogProvider {
id: provider_id,
@ -1367,7 +1389,9 @@ struct AdapterDefaults {
fn adapter_defaults(adapter: AdapterKind) -> AdapterDefaults {
match adapter {
AdapterKind::Anthropic => AdapterDefaults {
// Bedrock hosts Anthropic-family models, so it shares the Anthropic
// agent profile and billing policy by default.
AdapterKind::Anthropic | AdapterKind::Bedrock => AdapterDefaults {
agent_profile: AgentProfileKind::Anthropic,
billing_policy: BillingPolicy::Anthropic,
},
@ -1423,6 +1447,7 @@ fn resolve_model_codec(
fn validate_provider_auth(
provider: &ProviderId,
adapter: AdapterKind,
auth: Option<&ProviderAuthConfig>,
) -> Result<(), CatalogBuildError> {
match auth {
@ -1431,6 +1456,18 @@ fn validate_provider_auth(
provider: provider.clone(),
})
}
Some(auth)
if adapter != AdapterKind::Bedrock
&& auth
.credentials
.iter()
.any(|credential| matches!(credential, CredentialRef::AwsSigv4)) =>
{
Err(CatalogBuildError::UnsupportedAwsSigv4Credential {
provider: provider.clone(),
adapter,
})
}
_ => Ok(()),
}
}
@ -1832,6 +1869,56 @@ mod tests {
toml::from_str(source).expect("fixture should parse as an LLM settings layer")
}
const BEDROCK_SIGV4_LAYER: &str = r#"
[providers.bedrock]
adapter = "bedrock"
base_url = "https://bedrock-runtime.eu-west-1.amazonaws.com"
[providers.bedrock.auth]
credentials = ["aws_sigv4"]
[models."bedrock-sonnet"]
provider = "bedrock"
api_id = "anthropic.claude-sonnet-4-6"
display_name = "Bedrock Sonnet"
family = "claude-4"
default = true
[models."bedrock-sonnet".limits]
context_window = 200000
max_output = 64000
[models."bedrock-sonnet".features]
tools = true
vision = true
reasoning = true
"#;
#[test]
fn provider_parses_bedrock_base_url_and_sigv4_credential() {
let catalog = Catalog::from_settings(&minimal_settings(BEDROCK_SIGV4_LAYER)).unwrap();
let provider = catalog.provider(&ProviderId::from("bedrock")).unwrap();
assert_eq!(
provider.base_url.as_deref(),
Some("https://bedrock-runtime.eu-west-1.amazonaws.com")
);
assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![
CredentialRef::AwsSigv4
]);
// Bedrock inherits the Anthropic agent profile and billing by default.
assert_eq!(provider.agent_profile, AgentProfileKind::Anthropic);
assert_eq!(provider.billing_policy, BillingPolicy::Anthropic);
}
#[test]
fn aws_sigv4_credential_round_trips() {
assert_eq!(
"aws_sigv4".parse::<CredentialRef>().unwrap(),
CredentialRef::AwsSigv4
);
assert_eq!(CredentialRef::AwsSigv4.to_string(), "aws_sigv4");
}
// ---- Catalog struct tests ----
#[test]
@ -1914,6 +2001,117 @@ reasoning = false
assert_eq!(model.provider, ProviderId::new("acme"));
}
#[test]
fn builtin_bedrock_provider_is_opt_in() {
let bedrock = ProviderId::new("bedrock");
let builtin = Catalog::builtin();
assert!(builtin.provider(&bedrock).is_none());
assert!(builtin.list(Some(&bedrock)).is_empty());
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
r"
[providers.bedrock]
enabled = true
",
))
.expect("enabled Bedrock override should build from the built-in provider settings");
let provider = catalog
.provider(&bedrock)
.expect("enabled Bedrock provider should be present");
assert_eq!(provider.adapter, AdapterKind::Bedrock);
assert_eq!(provider.codec, CodecKind::BedrockConverse);
assert_eq!(
provider.base_url.as_deref(),
Some("https://bedrock-runtime.us-east-1.amazonaws.com")
);
// Bearer key first (env then vault, like every other provider), under
// either the AWS-canonical name or Fabro's `<PROVIDER>_API_KEY`
// convention; SigV4 chain as the fallback.
assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![
CredentialRef::Env("AWS_BEARER_TOKEN_BEDROCK".to_string()),
CredentialRef::Env("BEDROCK_API_KEY".to_string()),
CredentialRef::Vault("AWS_BEARER_TOKEN_BEDROCK".to_string()),
CredentialRef::Vault("BEDROCK_API_KEY".to_string()),
CredentialRef::AwsSigv4,
]);
// Claude rows bill Anthropic-style; open-weights rows override the
// provider's Anthropic defaults the other way.
assert_eq!(
catalog
.model_settings("us.anthropic.claude-sonnet-4-6")
.unwrap()
.billing_policy,
BillingPolicy::Anthropic
);
assert_eq!(
catalog.model_settings("zai.glm-5").unwrap().billing_policy,
BillingPolicy::OpenAi
);
assert_eq!(
catalog
.model_settings("us.anthropic.claude-haiku-4-5")
.unwrap()
.api_id,
"us.anthropic.claude-haiku-4-5-20251001-v1:0"
);
assert_eq!(
catalog
.default_for_provider(&bedrock)
.map(|model| model.id.as_str()),
Some("us.anthropic.claude-sonnet-4-6")
);
// Fable 5 ships with sampling params pinned off (the Converse
// encoder drops temperature/top_p for it).
let fable = catalog
.get("us.anthropic.claude-fable-5")
.expect("fable row should be present");
assert!(!fable.features.sampling_params);
assert_eq!(
catalog
.model_settings("us.anthropic.claude-fable-5")
.unwrap()
.billing_policy,
BillingPolicy::Anthropic
);
}
#[test]
fn builtin_bedrock_openai_provider_is_opt_in() {
let provider_id = ProviderId::new("bedrock-openai");
let builtin = Catalog::builtin();
assert!(builtin.provider(&provider_id).is_none());
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
r"
[providers.bedrock-openai]
enabled = true
",
))
.expect("enabled bedrock-openai override should build");
let provider = catalog
.provider(&provider_id)
.expect("enabled bedrock-openai provider should be present");
// OpenAI frontier on Bedrock rides the existing openai_responses
// dialect against the bedrock-mantle endpoint — pure configuration.
assert_eq!(provider.adapter, AdapterKind::OpenAi);
assert_eq!(provider.codec, CodecKind::OpenAiResponses);
assert_eq!(
provider.base_url.as_deref(),
Some("https://bedrock-mantle.us-east-1.api.aws/openai/v1")
);
assert_eq!(
catalog
.default_for_provider(&provider_id)
.map(|model| model.id.as_str()),
Some("openai.gpt-5.5")
);
}
#[test]
fn builtin_openrouter_provider_is_opt_in() {
let openrouter = ProviderId::new("openrouter");
@ -3627,6 +3825,23 @@ credentials = []
CatalogBuildError::EmptyApiKeyCredentials { provider }
if provider == ProviderId::new("test")
));
let sigv4_on_openai = minimal_settings(
r#"
[providers.test]
display_name = "Test"
adapter = "openai"
agent_profile = "openai"
[providers.test.auth]
credentials = ["aws_sigv4"]
"#,
);
assert!(matches!(
Catalog::from_settings(&sigv4_on_openai).unwrap_err(),
CatalogBuildError::UnsupportedAwsSigv4Credential { provider, adapter }
if provider == ProviderId::new("test") && adapter == AdapterKind::OpenAi
));
}
#[test]

View file

@ -0,0 +1,75 @@
[providers.bedrock-openai]
display_name = "Amazon Bedrock (OpenAI frontier)"
adapter = "openai"
api_key_url = "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html"
base_url = "https://bedrock-mantle.us-east-1.api.aws/openai/v1"
priority = 19
enabled = false
[providers.bedrock-openai.auth]
credentials = [
"env:AWS_BEARER_TOKEN_BEDROCK",
"env:BEDROCK_API_KEY",
"vault:AWS_BEARER_TOKEN_BEDROCK",
"vault:BEDROCK_API_KEY",
]
# OpenAI's frontier models on Bedrock (GPT-5.5/5.4) are served ONLY by the
# bedrock-mantle endpoint's OpenAI Responses API — they are not reachable
# through Converse or InvokeModel on bedrock-runtime. That surface speaks
# the openai_responses dialect with a Bedrock API key as the bearer token,
# so this companion provider row is pure configuration over the existing
# openai adapter: same AWS account and key as the `bedrock` provider, a
# different endpoint and wire dialect.
#
# Notes:
# - Auth is Bedrock-API-key only on this row (SigV4 on mantle uses the
# `bedrock-mantle` signing name, which the openai adapter does not do).
# - bedrock-mantle is regional (13 regions); change base_url to
# `https://bedrock-mantle.<region>.api.aws/openai/v1` as needed.
# - Responses state: Fabro always sends `store: false`, so nothing is
# retained under mantle's default 30-day Project retention.
#
# To enable, add to ~/.fabro/settings.toml:
#
# [llm.providers.bedrock-openai]
# enabled = true
[models."openai.gpt-5.5"]
provider = "bedrock-openai"
display_name = "GPT-5.5 (Bedrock)"
family = "gpt-5"
default = true
[models."openai.gpt-5.5".limits]
context_window = 272000
max_output = 128000
[models."openai.gpt-5.5".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "levels"
[models."openai.gpt-5.5".costs]
input_cost_per_mtok = 5.5
output_cost_per_mtok = 33.0
[models."openai.gpt-5.4"]
provider = "bedrock-openai"
display_name = "GPT-5.4 (Bedrock)"
family = "gpt-5"
[models."openai.gpt-5.4".limits]
context_window = 272000
max_output = 128000
[models."openai.gpt-5.4".features]
tools = true
vision = true
reasoning = true
reasoning_effort = "levels"
[models."openai.gpt-5.4".costs]
input_cost_per_mtok = 2.75
output_cost_per_mtok = 16.5

View file

@ -0,0 +1,388 @@
[providers.bedrock]
display_name = "Amazon Bedrock"
adapter = "bedrock"
api_key_url = "https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html"
base_url = "https://bedrock-runtime.us-east-1.amazonaws.com"
priority = 20
enabled = false
[providers.bedrock.auth]
# An explicit Bedrock API key wins (from the process env, or the server
# vault via `fabro secret set <name>`, matching every other provider's
# env-then-vault order); SigV4 (the AWS default credential chain, resolved
# at request time) is the fallback. `aws_sigv4` always resolves, which is
# why this provider ships disabled: enabling it is the operator's statement
# that AWS credentials are expected to work. The key is read from either
# `AWS_BEARER_TOKEN_BEDROCK` (the AWS-canonical name, also honored by the
# AWS SDKs/CLI) or `BEDROCK_API_KEY` (Fabro's `<PROVIDER>_API_KEY`
# convention); the env names are checked before the vault.
credentials = [
"env:AWS_BEARER_TOKEN_BEDROCK",
"env:BEDROCK_API_KEY",
"vault:AWS_BEARER_TOKEN_BEDROCK",
"vault:BEDROCK_API_KEY",
"aws_sigv4",
]
# To enable Bedrock, add the following to ~/.fabro/settings.toml:
#
# [llm.providers.bedrock]
# enabled = true
# base_url = "https://bedrock-runtime.<your-region>.amazonaws.com"
#
# The signing region is derived from the base_url. Authenticate with
# either a Bedrock API key (AWS_BEARER_TOKEN_BEDROCK or BEDROCK_API_KEY) or
# any AWS default credential chain source (env keys, profile, IMDS, IRSA, SSO).
#
# Model ids use cross-region inference profiles (`us.` / `global.`
# prefixes) where on-demand access requires them. Pricing rows are
# best-effort estimates from June 2026 list prices.
# ---------- Anthropic Claude ----------
#
# Claude bills Anthropic-style cache reads/writes, so these rows override
# the provider's billing default. Claude Fable 5 appears at the end of this
# file because its Bedrock deployment pins sampling parameters and requires an
# extra data-sharing opt-in.
[models."us.anthropic.claude-sonnet-4-6"]
provider = "bedrock"
display_name = "Claude Sonnet 4.6 (Bedrock)"
family = "claude-4"
billing_policy = "anthropic"
default = true
[models."us.anthropic.claude-sonnet-4-6".limits]
context_window = 1000000
max_output = 64000
[models."us.anthropic.claude-sonnet-4-6".features]
tools = true
vision = true
reasoning = true
prompt_cache = true
[models."us.anthropic.claude-sonnet-4-6".costs]
input_cost_per_mtok = 3.0
output_cost_per_mtok = 15.0
cache_input_cost_per_mtok = 0.3
[models."us.anthropic.claude-opus-4-8"]
provider = "bedrock"
display_name = "Claude Opus 4.8 (Bedrock)"
family = "claude-4"
billing_policy = "anthropic"
[models."us.anthropic.claude-opus-4-8".limits]
context_window = 1000000
max_output = 128000
[models."us.anthropic.claude-opus-4-8".features]
tools = true
vision = true
reasoning = true
prompt_cache = true
[models."us.anthropic.claude-opus-4-8".costs]
input_cost_per_mtok = 5.0
output_cost_per_mtok = 25.0
cache_input_cost_per_mtok = 0.5
[models."us.anthropic.claude-haiku-4-5"]
provider = "bedrock"
api_id = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
display_name = "Claude Haiku 4.5 (Bedrock)"
family = "claude-4"
billing_policy = "anthropic"
small_default = true
[models."us.anthropic.claude-haiku-4-5".limits]
context_window = 200000
max_output = 64000
[models."us.anthropic.claude-haiku-4-5".features]
tools = true
vision = true
reasoning = false
prompt_cache = true
[models."us.anthropic.claude-haiku-4-5".costs]
input_cost_per_mtok = 1.0
output_cost_per_mtok = 5.0
cache_input_cost_per_mtok = 0.1
# ---------- OpenAI open-weights ----------
#
# GPT-5.5/5.4 are NOT here: on Bedrock they are Responses-API-only on the
# bedrock-mantle endpoint (no Converse), a named follow-up route.
[models."openai.gpt-oss-120b"]
provider = "bedrock"
api_id = "openai.gpt-oss-120b-1:0"
display_name = "GPT-OSS 120B (Bedrock)"
family = "gpt-oss"
billing_policy = "openai"
agent_profile = "openai"
[models."openai.gpt-oss-120b".limits]
context_window = 128000
max_output = 16384
[models."openai.gpt-oss-120b".features]
tools = true
vision = false
reasoning = true
[models."openai.gpt-oss-120b".costs]
input_cost_per_mtok = 0.15
output_cost_per_mtok = 0.60
[models."openai.gpt-oss-20b"]
provider = "bedrock"
api_id = "openai.gpt-oss-20b-1:0"
display_name = "GPT-OSS 20B (Bedrock)"
family = "gpt-oss"
billing_policy = "openai"
agent_profile = "openai"
[models."openai.gpt-oss-20b".limits]
context_window = 128000
max_output = 16384
[models."openai.gpt-oss-20b".features]
tools = true
vision = false
reasoning = true
[models."openai.gpt-oss-20b".costs]
input_cost_per_mtok = 0.07
output_cost_per_mtok = 0.30
# ---------- Amazon Nova ----------
[models."amazon.nova-2-lite"]
provider = "bedrock"
api_id = "global.amazon.nova-2-lite-v1:0"
display_name = "Nova 2 Lite (Bedrock)"
family = "nova-2"
billing_policy = "openai"
agent_profile = "openai"
[models."amazon.nova-2-lite".limits]
context_window = 1000000
# Bedrock caps Nova output at 65535 (2^16 - 1); 65536 trips
# "maximum tokens exceeds the model limit of 65535" since the prompt handler
# defaults max_tokens to max_output.
max_output = 65535
[models."amazon.nova-2-lite".features]
tools = true
vision = true
reasoning = false
[models."amazon.nova-2-lite".costs]
input_cost_per_mtok = 0.30
output_cost_per_mtok = 2.50
# ---------- Open-weights ----------
[models."meta.llama4-maverick"]
provider = "bedrock"
api_id = "us.meta.llama4-maverick-17b-instruct-v1:0"
display_name = "Llama 4 Maverick (Bedrock)"
family = "llama-4"
billing_policy = "openai"
agent_profile = "openai"
[models."meta.llama4-maverick".limits]
context_window = 1000000
max_output = 8192
[models."meta.llama4-maverick".features]
tools = true
vision = true
reasoning = false
[models."mistral.mistral-large-3"]
provider = "bedrock"
api_id = "mistral.mistral-large-3-675b-instruct"
display_name = "Mistral Large 3 (Bedrock)"
family = "mistral-large"
billing_policy = "openai"
agent_profile = "openai"
[models."mistral.mistral-large-3".limits]
context_window = 256000
max_output = 32768
[models."mistral.mistral-large-3".features]
tools = true
vision = true
reasoning = false
[models."mistral.mistral-large-3".costs]
input_cost_per_mtok = 0.50
output_cost_per_mtok = 1.50
[models."mistral.devstral-2"]
provider = "bedrock"
api_id = "mistral.devstral-2-123b"
display_name = "Devstral 2 (Bedrock)"
family = "devstral"
billing_policy = "openai"
agent_profile = "openai"
[models."mistral.devstral-2".limits]
context_window = 256000
max_output = 32768
[models."mistral.devstral-2".features]
tools = true
vision = false
reasoning = false
[models."deepseek.v3-2"]
provider = "bedrock"
api_id = "deepseek.v3.2"
display_name = "DeepSeek V3.2 (Bedrock)"
family = "deepseek-v3"
billing_policy = "openai"
agent_profile = "openai"
[models."deepseek.v3-2".limits]
context_window = 164000
max_output = 8192
[models."deepseek.v3-2".features]
tools = true
vision = false
reasoning = true
[models."deepseek.v3-2".costs]
input_cost_per_mtok = 0.62
output_cost_per_mtok = 1.85
# Qwen3 Coder Next: omitted pending a verified Bedrock model/inference-profile
# id. The fabro id is not itself a valid Bedrock identifier (Converse returns
# "The provided model identifier is invalid"), so this row needs an explicit
# `api_id` confirmed against `aws bedrock list-inference-profiles` before it
# ships. Re-add with:
# [models."qwen.qwen3-coder-next"]
# provider = "bedrock"
# api_id = "<verified bedrock id>"
# display_name = "Qwen3 Coder Next (Bedrock)"
# family = "qwen3"
# billing_policy = "openai"
# agent_profile = "openai"
# [models."qwen.qwen3-coder-next".limits]
# context_window = 256000
# max_output = 16384
# [models."qwen.qwen3-coder-next".features]
# tools = true
[models."moonshotai.kimi-k2.5"]
provider = "bedrock"
display_name = "Kimi K2.5 (Bedrock)"
family = "kimi-k2"
billing_policy = "openai"
agent_profile = "openai"
[models."moonshotai.kimi-k2.5".limits]
context_window = 262144
max_output = 16384
[models."moonshotai.kimi-k2.5".features]
tools = true
vision = true
reasoning = false
[models."moonshotai.kimi-k2.5".costs]
input_cost_per_mtok = 0.60
output_cost_per_mtok = 3.00
[models."zai.glm-5"]
provider = "bedrock"
display_name = "GLM 5 (Bedrock)"
family = "glm"
billing_policy = "openai"
agent_profile = "openai"
[models."zai.glm-5".limits]
context_window = 200000
max_output = 128000
[models."zai.glm-5".features]
tools = true
vision = false
reasoning = false
[models."zai.glm-5".costs]
input_cost_per_mtok = 1.00
output_cost_per_mtok = 3.20
[models."minimax.minimax-m2.5"]
provider = "bedrock"
display_name = "MiniMax M2.5 (Bedrock)"
family = "minimax-m2"
billing_policy = "openai"
agent_profile = "openai"
[models."minimax.minimax-m2.5".limits]
context_window = 196000
max_output = 8192
[models."minimax.minimax-m2.5".features]
tools = true
vision = false
reasoning = false
[models."minimax.minimax-m2.5".costs]
input_cost_per_mtok = 0.30
output_cost_per_mtok = 1.20
[models."nvidia.nemotron-3-super"]
provider = "bedrock"
api_id = "nvidia.nemotron-super-3-120b"
display_name = "Nemotron 3 Super (Bedrock)"
family = "nemotron-3"
billing_policy = "openai"
agent_profile = "openai"
[models."nvidia.nemotron-3-super".limits]
context_window = 256000
max_output = 32768
[models."nvidia.nemotron-3-super".features]
tools = true
vision = false
reasoning = false
# Claude Fable 5: adaptive thinking is always on server-side; the row pins
# sampling_params = false so the Converse encoder drops temperature/top_p
# (Bedrock rejects them for this model). Requires the account-level
# provider_data_share opt-in in the Bedrock console. Effort-level mapping
# through additionalModelRequestFields is a named follow-up, so
# reasoning_effort stays undeclared here (requests carrying one are
# rejected up front rather than silently dropped).
[models."us.anthropic.claude-fable-5"]
provider = "bedrock"
display_name = "Claude Fable 5 (Bedrock)"
family = "claude-5"
billing_policy = "anthropic"
[models."us.anthropic.claude-fable-5".limits]
context_window = 1000000
max_output = 128000
[models."us.anthropic.claude-fable-5".features]
tools = true
vision = true
reasoning = true
prompt_cache = true
sampling_params = false
[models."us.anthropic.claude-fable-5".costs]
input_cost_per_mtok = 10.0
output_cost_per_mtok = 50.0
cache_input_cost_per_mtok = 1.0

View file

@ -40,6 +40,10 @@ pub enum CodecKind {
#[strum(to_string = "openai_compatible")]
OpenAiCompatible,
GeminiGenerate,
/// Amazon Bedrock's unified Converse/ConverseStream dialect: one
/// model-agnostic envelope AWS translates to each hosted family's
/// native format server-side.
BedrockConverse,
}
impl CodecKind {
@ -53,6 +57,7 @@ impl CodecKind {
AdapterKind::OpenAi => Self::OpenAiResponses,
AdapterKind::Gemini => Self::GeminiGenerate,
AdapterKind::OpenAiCompatible => Self::OpenAiCompatible,
AdapterKind::Bedrock => Self::BedrockConverse,
}
}
@ -90,6 +95,7 @@ mod tests {
(CodecKind::OpenAiResponses, "openai_responses"),
(CodecKind::OpenAiCompatible, "openai_compatible"),
(CodecKind::GeminiGenerate, "gemini_generate"),
(CodecKind::BedrockConverse, "bedrock_converse"),
] {
assert_eq!(kind.as_str(), expected);
assert_eq!(kind.to_string(), expected);
@ -103,6 +109,7 @@ mod tests {
(AdapterKind::OpenAi, CodecKind::OpenAiResponses),
(AdapterKind::Gemini, CodecKind::GeminiGenerate),
(AdapterKind::OpenAiCompatible, CodecKind::OpenAiCompatible),
(AdapterKind::Bedrock, CodecKind::BedrockConverse),
] {
assert_eq!(CodecKind::default_for(adapter), expected);
}

View file

@ -2546,6 +2546,20 @@ regex = '''\b(sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20})(?:['|\"|\n|\r|\s|\x60|;
entropy = 3
keywords = ["t3blbkfj"]
[[rules]]
id = "aws-bedrock-long-term-api-key"
description = "Found an AWS Bedrock long-term API key, posing a risk of unauthorized model invocation and billing."
regex = '''\b(ABSK[A-Za-z0-9+/]{109,269}={0,2})(?:[\x60'\"\s;]|\\[nr]|$)'''
entropy = 3
keywords = ["absk"]
[[rules]]
id = "aws-bedrock-short-term-api-key"
description = "Found an AWS Bedrock short-term API key, posing a risk of unauthorized model invocation."
regex = '''\b(bedrock-api-key-YmVkcm9jay5hbWF6b25hd3MuY29t[A-Za-z0-9+/=]{16,})(?:[\x60'\"\s;]|\\[nr]|$)'''
entropy = 3
keywords = ["bedrock-api-key-"]
[[rules]]
id = "openrouter-api-key"
description = "Found an OpenRouter API Key, posing a risk of unauthorized access to LLM provider routing and billing."

View file

@ -17,6 +17,32 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
EnvVars::NO_COLOR,
EnvVars::CLICOLOR,
EnvVars::CLICOLOR_FORCE,
// AWS credential-chain inputs for the Bedrock provider. Other providers'
// secrets reach the worker through the server vault (read via FABRO_HOME),
// but Bedrock SigV4 has no stored secret — it re-resolves from the ambient
// AWS chain on every request so STS/SSO/IRSA sessions can refresh, which
// means the chain's *inputs* must survive `env_clear()` in the worker, not
// a snapshot taken at launch. We pass the identity surface only (static
// keys, session token, profile/region selectors, and the web-identity/ECS
// role vars); HOME already carries the shared
// `~/.aws` config + SSO cache. Endpoint/metadata overrides
// (AWS_ENDPOINT_*, AWS_METADATA_ENDPOINT, AWS_IMDSV1_FALLBACK) are
// deliberately excluded — they belong to the server's S3 path, not to the
// worker's outbound model calls. Bedrock bearer API keys are optional LLM
// provider secrets, so server workers read them through the vault rather
// than inheriting process env.
EnvVars::AWS_ACCESS_KEY_ID,
EnvVars::AWS_SECRET_ACCESS_KEY,
EnvVars::AWS_SESSION_TOKEN,
EnvVars::AWS_PROFILE,
EnvVars::AWS_REGION,
EnvVars::AWS_DEFAULT_REGION,
EnvVars::AWS_ROLE_ARN,
EnvVars::AWS_ROLE_SESSION_NAME,
EnvVars::AWS_WEB_IDENTITY_TOKEN_FILE,
EnvVars::AWS_CONTAINER_CREDENTIALS_RELATIVE_URI,
EnvVars::AWS_CONTAINER_CREDENTIALS_FULL_URI,
EnvVars::AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE,
];
const RENDER_GRAPH_ENV_ALLOWLIST: &[&str] = &[EnvVars::PATH, EnvVars::HOME, EnvVars::TMPDIR];
@ -91,6 +117,12 @@ mod tests {
("NO_COLOR".to_string(), "1".to_string()),
("CLICOLOR".to_string(), "0".to_string()),
("CLICOLOR_FORCE".to_string(), "1".to_string()),
("AWS_ACCESS_KEY_ID".to_string(), "AKIAEXAMPLE".to_string()),
("AWS_SECRET_ACCESS_KEY".to_string(), "secret".to_string()),
("AWS_SESSION_TOKEN".to_string(), "session".to_string()),
("AWS_BEARER_TOKEN_BEDROCK".to_string(), "bearer".to_string()),
("BEDROCK_API_KEY".to_string(), "alias-bearer".to_string()),
("AWS_REGION".to_string(), "us-east-2".to_string()),
("SESSION_SECRET".to_string(), "leak".to_string()),
("FABRO_JWT_PRIVATE_KEY".to_string(), "leak".to_string()),
("FABRO_JWT_PUBLIC_KEY".to_string(), "leak".to_string()),
@ -122,6 +154,27 @@ mod tests {
assert_eq!(actual.get("NO_COLOR").map(String::as_str), Some("1"));
assert_eq!(actual.get("CLICOLOR").map(String::as_str), Some("0"));
assert_eq!(actual.get("CLICOLOR_FORCE").map(String::as_str), Some("1"));
// Bedrock SigV4 chain inputs cross into the worker so it can re-resolve
// credentials per request; a generic secret with no allowlist entry
// still does not.
assert_eq!(
actual.get("AWS_ACCESS_KEY_ID").map(String::as_str),
Some("AKIAEXAMPLE")
);
assert_eq!(
actual.get("AWS_SECRET_ACCESS_KEY").map(String::as_str),
Some("secret")
);
assert_eq!(
actual.get("AWS_SESSION_TOKEN").map(String::as_str),
Some("session")
);
assert_eq!(
actual.get("AWS_REGION").map(String::as_str),
Some("us-east-2")
);
assert!(!actual.contains_key("AWS_BEARER_TOKEN_BEDROCK"));
assert!(!actual.contains_key("BEDROCK_API_KEY"));
assert!(!actual.contains_key("FABRO_LOG_DESTINATION"));
assert_eq!(
actual.get("FABRO_DEV_TOKEN").map(String::as_str),

View file

@ -40,7 +40,9 @@ impl EnvVars {
// LLM providers and tool integrations
pub const ANTHROPIC_API_KEY: &'static str = "ANTHROPIC_API_KEY";
pub const AWS_BEARER_TOKEN_BEDROCK: &'static str = "AWS_BEARER_TOKEN_BEDROCK";
pub const ANTHROPIC_BASE_URL: &'static str = "ANTHROPIC_BASE_URL";
pub const BEDROCK_API_KEY: &'static str = "BEDROCK_API_KEY";
pub const BRAVE_SEARCH_API_KEY: &'static str = "BRAVE_SEARCH_API_KEY";
pub const CHATGPT_ACCOUNT_ID: &'static str = "CHATGPT_ACCOUNT_ID";
pub const GEMINI_API_KEY: &'static str = "GEMINI_API_KEY";
@ -81,11 +83,14 @@ impl EnvVars {
"AWS_CONTAINER_CREDENTIALS_FULL_URI";
pub const AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: &'static str =
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI";
pub const AWS_DEFAULT_REGION: &'static str = "AWS_DEFAULT_REGION";
pub const AWS_ENDPOINT: &'static str = "AWS_ENDPOINT";
pub const AWS_ENDPOINT_URL_S3: &'static str = "AWS_ENDPOINT_URL_S3";
pub const AWS_ENDPOINT_URL_STS: &'static str = "AWS_ENDPOINT_URL_STS";
pub const AWS_IMDSV1_FALLBACK: &'static str = "AWS_IMDSV1_FALLBACK";
pub const AWS_METADATA_ENDPOINT: &'static str = "AWS_METADATA_ENDPOINT";
pub const AWS_PROFILE: &'static str = "AWS_PROFILE";
pub const AWS_REGION: &'static str = "AWS_REGION";
pub const AWS_ROLE_ARN: &'static str = "AWS_ROLE_ARN";
pub const AWS_ROLE_SESSION_NAME: &'static str = "AWS_ROLE_SESSION_NAME";
pub const AWS_SECRET_ACCESS_KEY: &'static str = "AWS_SECRET_ACCESS_KEY";
@ -179,6 +184,8 @@ mod tests {
EnvVars::FABRO_WORKER_TOKEN,
EnvVars::ANTHROPIC_API_KEY,
EnvVars::ANTHROPIC_BASE_URL,
EnvVars::AWS_BEARER_TOKEN_BEDROCK,
EnvVars::BEDROCK_API_KEY,
EnvVars::BRAVE_SEARCH_API_KEY,
EnvVars::CHATGPT_ACCOUNT_ID,
EnvVars::GEMINI_API_KEY,
@ -212,11 +219,14 @@ mod tests {
EnvVars::AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE,
EnvVars::AWS_CONTAINER_CREDENTIALS_FULL_URI,
EnvVars::AWS_CONTAINER_CREDENTIALS_RELATIVE_URI,
EnvVars::AWS_DEFAULT_REGION,
EnvVars::AWS_ENDPOINT,
EnvVars::AWS_ENDPOINT_URL_S3,
EnvVars::AWS_ENDPOINT_URL_STS,
EnvVars::AWS_IMDSV1_FALLBACK,
EnvVars::AWS_METADATA_ENDPOINT,
EnvVars::AWS_PROFILE,
EnvVars::AWS_REGION,
EnvVars::AWS_ROLE_ARN,
EnvVars::AWS_ROLE_SESSION_NAME,
EnvVars::AWS_SECRET_ACCESS_KEY,

View file

@ -16,6 +16,8 @@ const BOOTSTRAP_SECRETS: &[&str] = &[
const OPTIONAL_VAULT_SECRETS: &[&str] = &[
EnvVars::ANTHROPIC_API_KEY,
EnvVars::AWS_BEARER_TOKEN_BEDROCK,
EnvVars::BEDROCK_API_KEY,
EnvVars::BRAVE_SEARCH_API_KEY,
EnvVars::FABRO_SLACK_APP_TOKEN,
EnvVars::FABRO_SLACK_BOT_TOKEN,
@ -86,6 +88,8 @@ mod tests {
EnvVars::DAYTONA_API_KEY,
EnvVars::BRAVE_SEARCH_API_KEY,
EnvVars::ANTHROPIC_API_KEY,
EnvVars::AWS_BEARER_TOKEN_BEDROCK,
EnvVars::BEDROCK_API_KEY,
EnvVars::GEMINI_API_KEY,
EnvVars::INCEPTION_API_KEY,
EnvVars::KIMI_API_KEY,