From d5b2220ed3f73eaffc22a6d1fee67f163d2bf00f Mon Sep 17 00:00:00 2001 From: Ryan Neal <123962789+depopry@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:46:49 +0100 Subject: [PATCH] =?UTF-8?q?feat(llm):=20Amazon=20Bedrock=20provider=20?= =?UTF-8?q?=E2=80=94=20Converse=20codec,=20SigV4=20+=20API-key=20auth=20(#?= =?UTF-8?q?459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Scott Werner Co-authored-by: Scott Werner --- Cargo.lock | 661 +++++++++++++++-- Cargo.toml | 16 + docs/public/core-concepts/models.mdx | 10 + docs/public/docs.json | 1 + docs/public/integrations/bedrock.mdx | 162 ++++ docs/public/reference/user-configuration.mdx | 2 +- lib/crates/fabro-agent/src/cli.rs | 62 +- lib/crates/fabro-auth/src/credential.rs | 9 +- lib/crates/fabro-auth/src/resolve.rs | 74 +- .../fabro-auth/src/strategies/api_key.rs | 2 +- lib/crates/fabro-cli/src/commands/install.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/exec.rs | 8 +- .../src/commands/docs_options_reference.rs | 2 +- lib/crates/fabro-llm/Cargo.toml | 6 + lib/crates/fabro-llm/src/adapter_registry.rs | 5 +- .../src/codec/anthropic_messages/stream.rs | 5 +- .../src/codec/bedrock_converse/decode.rs | 296 ++++++++ .../src/codec/bedrock_converse/encode.rs | 643 ++++++++++++++++ .../src/codec/bedrock_converse/mod.rs | 58 ++ .../src/codec/bedrock_converse/stream.rs | 512 +++++++++++++ lib/crates/fabro-llm/src/codec/mod.rs | 30 + .../src/codec/openai_compatible/request.rs | 16 +- .../src/codec/openai_responses/decode.rs | 4 +- .../src/providers/bedrock/eventstream.rs | 219 ++++++ .../fabro-llm/src/providers/bedrock/mod.rs | 691 ++++++++++++++++++ .../fabro-llm/src/providers/bedrock/sigv4.rs | 249 +++++++ lib/crates/fabro-llm/src/providers/mod.rs | 2 + lib/crates/fabro-llm/tests/integration.rs | 65 +- lib/crates/fabro-model/src/adapter.rs | 11 + lib/crates/fabro-model/src/catalog.rs | 223 +++++- .../src/catalog/providers/bedrock-openai.toml | 75 ++ .../src/catalog/providers/bedrock.toml | 388 ++++++++++ lib/crates/fabro-model/src/codec.rs | 7 + lib/crates/fabro-redact/data/gitleaks.toml | 14 + lib/crates/fabro-server/src/spawn_env.rs | 53 ++ lib/crates/fabro-static/src/env_vars.rs | 10 + .../fabro-static/src/secret_registry.rs | 4 + 37 files changed, 4457 insertions(+), 140 deletions(-) create mode 100644 docs/public/integrations/bedrock.mdx create mode 100644 lib/crates/fabro-llm/src/codec/bedrock_converse/decode.rs create mode 100644 lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs create mode 100644 lib/crates/fabro-llm/src/codec/bedrock_converse/mod.rs create mode 100644 lib/crates/fabro-llm/src/codec/bedrock_converse/stream.rs create mode 100644 lib/crates/fabro-llm/src/providers/bedrock/eventstream.rs create mode 100644 lib/crates/fabro-llm/src/providers/bedrock/mod.rs create mode 100644 lib/crates/fabro-llm/src/providers/bedrock/sigv4.rs create mode 100644 lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml create mode 100644 lib/crates/fabro-model/src/catalog/providers/bedrock.toml diff --git a/Cargo.lock b/Cargo.lock index 4ad6efbd3..8c3f2d2cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,7 +29,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -41,7 +41,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -408,6 +408,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.16.3" @@ -431,6 +474,339 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-runtime" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ed8e8c52d2dc2390ad9f15647fe663f71e9780b4262c190fbb823a32721566" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b647baea49ff551960b904f905681e9b4765a6c4ea08631e89dc52d8bd3f5896" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ae401c65ff288aa7873117fe535cd32b7b1bb0bc43751d28901a1d5f20636b9" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.106.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c80de7bb7d03e9ca8c9fd7b489f20f3948d3f3be91a7953591347d238115408" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.4.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.8" @@ -443,8 +819,8 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-util", @@ -476,8 +852,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -499,8 +875,8 @@ dependencies = [ "cookie", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -565,6 +941,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bincode" version = "1.3.3" @@ -604,6 +990,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -625,7 +1020,7 @@ dependencies = [ "futures-core", "futures-util", "hex", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-named-pipe", @@ -710,6 +1105,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cc" version = "1.2.56" @@ -760,7 +1165,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -832,6 +1237,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cmsketch" version = "0.2.4" @@ -889,6 +1300,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -907,10 +1324,10 @@ dependencies = [ "aes-gcm", "base64", "hkdf", - "hmac", + "hmac 0.12.1", "percent-encoding", "rand 0.8.6", - "sha2", + "sha2 0.10.9", "subtle", "time", "version_check", @@ -987,6 +1404,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1137,6 +1563,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csv" version = "1.4.0" @@ -1167,6 +1602,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.14.4" @@ -1467,11 +1911,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1707,7 +2163,7 @@ dependencies = [ "paste", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "shell-escape", "strum 0.28.0", "tempfile", @@ -1773,7 +2229,7 @@ dependencies = [ "croner", "hex", "serde", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -1885,7 +2341,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2", + "sha2 0.10.9", "shlex", "temp-env", "tempfile", @@ -2017,7 +2473,7 @@ dependencies = [ "fabro-types", "hex", "serde", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -2090,7 +2546,7 @@ name = "fabro-http" version = "0.260.0-nightly.0" dependencies = [ "fabro-static", - "http", + "http 1.4.0", "reqwest 0.13.2", "thiserror 2.0.18", ] @@ -2132,6 +2588,12 @@ version = "0.260.0-nightly.0" dependencies = [ "anyhow", "async-trait", + "aws-config", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", "base64", "bytes", "fabro-auth", @@ -2144,7 +2606,7 @@ dependencies = [ "fabro-types", "fabro-util", "futures", - "http", + "http 1.4.0", "httpmock", "insta", "rand 0.9.4", @@ -2242,7 +2704,7 @@ name = "fabro-model" version = "0.260.0-nightly.0" dependencies = [ "fabro-static", - "http", + "http 1.4.0", "insta", "rust-embed", "serde", @@ -2270,7 +2732,7 @@ dependencies = [ "rand 0.9.4", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tokio", "tracing", ] @@ -2333,14 +2795,14 @@ dependencies = [ "git2", "glob", "hex", - "hmac", + "hmac 0.12.1", "httpmock", "rand 0.9.4", "reqwest-middleware", "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "shlex", "strum 0.28.0", "tar", @@ -2406,7 +2868,7 @@ dependencies = [ "globset", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "http-body-util", "httpmock", "jsonwebtoken", @@ -2421,7 +2883,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2", + "sha2 0.10.9", "strum 0.28.0", "sysinfo", "tempfile", @@ -2615,7 +3077,7 @@ dependencies = [ "hex", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "strum 0.28.0", "tempfile", "toml 0.8.23", @@ -2736,7 +3198,7 @@ dependencies = [ "scopeguard", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "shlex", "tempfile", "thiserror 2.0.18", @@ -3312,7 +3774,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap 2.13.0", "slab", "tokio", @@ -3372,7 +3834,7 @@ dependencies = [ "base64", "bytes", "headers-core", - "http", + "http 1.4.0", "httpdate", "mime", "sha1", @@ -3384,7 +3846,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http", + "http 1.4.0", ] [[package]] @@ -3411,7 +3873,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -3420,7 +3882,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -3456,6 +3927,17 @@ dependencies = [ "match_token", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -3466,6 +3948,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -3473,7 +3966,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -3484,8 +3977,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -3517,7 +4010,7 @@ dependencies = [ "futures-timer", "futures-util", "headers", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-util", @@ -3541,6 +4034,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -3552,8 +4054,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -3585,7 +4087,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -3607,8 +4109,8 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "ipnet", "libc", @@ -4338,7 +4840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -4479,7 +4981,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.4.0", "httparse", "memchr", "mime", @@ -4854,7 +5356,7 @@ dependencies = [ "chrono", "form_urlencoded", "futures", - "http", + "http 1.4.0", "http-body-util", "humantime", "hyper", @@ -5280,7 +5782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -5452,7 +5954,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de362a0477182f45accdbad4d43cd89a95a1db0a518a7c1ddf3e525e6896f0f0" dependencies = [ "heck 0.5.0", - "http", + "http 1.4.0", "indexmap 2.13.0", "openapiv3", "proc-macro2", @@ -5755,6 +6257,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -5783,8 +6291,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -5830,8 +6338,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-rustls", @@ -5871,7 +6379,7 @@ checksum = "199dda04a536b532d0cc04d7979e39b1c763ea749bf91507017069c00b96056f" dependencies = [ "anyhow", "async-trait", - "http", + "http 1.4.0", "reqwest 0.13.2", "serde", "thiserror 2.0.18", @@ -5908,7 +6416,7 @@ dependencies = [ "base64", "chrono", "futures", - "http", + "http 1.4.0", "pastey", "pin-project-lite", "process-wrap", @@ -5970,7 +6478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "globset", - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -6516,8 +7024,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -6527,8 +7035,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -6735,7 +7254,7 @@ checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" dependencies = [ "bytes", "futures-util", - "http-body", + "http-body 1.0.1", "http-body-util", "pin-project-lite", ] @@ -7355,8 +7874,8 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -7465,7 +7984,7 @@ checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -7484,7 +8003,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.4", @@ -7522,7 +8041,7 @@ dependencies = [ "fabro-http", "fabro-static", "futures-util", - "http", + "http 1.4.0", "serde", "serde_json", "tokio", @@ -7682,7 +8201,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -7732,6 +8251,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -8647,6 +9172,12 @@ dependencies = [ "markup5ever", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 764649446..9528a14d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index a8c3df629..34e104034 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -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: diff --git a/docs/public/docs.json b/docs/public/docs.json index b2f737ba1..5dc4a9f8d 100644 --- a/docs/public/docs.json +++ b/docs/public/docs.json @@ -95,6 +95,7 @@ "integrations/github", "integrations/daytona", "integrations/litellm", + "integrations/bedrock", "integrations/openrouter", "integrations/slack", "integrations/brave-search" diff --git a/docs/public/integrations/bedrock.mdx b/docs/public/integrations/bedrock.mdx new file mode 100644 index 000000000..66a03c91c --- /dev/null +++ b/docs/public/integrations/bedrock.mdx @@ -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..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 `_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). + + +**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"] +``` + + +## 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..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 + + + + How Fabro routes model IDs, providers, and fallbacks. + + + Full reference for `[llm.providers.]` and `[llm.models.]`. + + diff --git a/docs/public/reference/user-configuration.mdx b/docs/public/reference/user-configuration.mdx index 8f4e0c38e..1cfba22b9 100644 --- a/docs/public/reference/user-configuration.mdx +++ b/docs/public/reference/user-configuration.mdx @@ -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 | required when `auth` present | Ordered credential refs. Accepted forms are `vault:` and `env:`. Literal secret strings are rejected. | +| `auth.credentials` | array | required when `auth` present | Ordered credential refs. Accepted forms are `vault:`, `env:`, 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. | diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index ee1f05dc0..866c64733 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -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 diff --git a/lib/crates/fabro-auth/src/credential.rs b/lib/crates/fabro-auth/src/credential.rs index c8b59ed8c..d4ec91262 100644 --- a/lib/crates/fabro-auth/src/credential.rs +++ b/lib/crates/fabro-auth/src/credential.rs @@ -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"), } } } diff --git a/lib/crates/fabro-auth/src/resolve.rs b/lib/crates/fabro-auth/src/resolve.rs index 6daa97d28..62e140331 100644 --- a/lib/crates/fabro-auth/src/resolve.rs +++ b/lib/crates/fabro-auth/src/resolve.rs @@ -28,6 +28,9 @@ pub(crate) enum ResolvedSecret { credential: Box, 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 { 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(); diff --git a/lib/crates/fabro-auth/src/strategies/api_key.rs b/lib/crates/fabro-auth/src/strategies/api_key.rs index 6a815d1c6..0d594110a 100644 --- a/lib/crates/fabro-auth/src/strategies/api_key.rs +++ b/lib/crates/fabro-auth/src/strategies/api_key.rs @@ -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() }) diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 281ba1ec7..4c40e3dad 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -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::>() .join(" / ") diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index e2a6209fc..860fb65a4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -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}" ); } diff --git a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs b/lib/crates/fabro-dev/src/commands/docs_options_reference.rs index d5a52370f..d04926cb1 100644 --- a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs +++ b/lib/crates/fabro-dev/src/commands/docs_options_reference.rs @@ -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 | required when `auth` present | Ordered credential refs. Accepted forms are `vault:` and `env:`. Literal secret strings are rejected. | +| `auth.credentials` | array | required when `auth` present | Ordered credential refs. Accepted forms are `vault:`, `env:`, 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. | diff --git a/lib/crates/fabro-llm/Cargo.toml b/lib/crates/fabro-llm/Cargo.toml index 482447b17..9146976b6 100644 --- a/lib/crates/fabro-llm/Cargo.toml +++ b/lib/crates/fabro-llm/Cargo.toml @@ -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" } diff --git a/lib/crates/fabro-llm/src/adapter_registry.rs b/lib/crates/fabro-llm/src/adapter_registry.rs index bf88770d3..f37f80fa9 100644 --- a/lib/crates/fabro-llm/src/adapter_registry.rs +++ b/lib/crates/fabro-llm/src/adapter_registry.rs @@ -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, } } diff --git a/lib/crates/fabro-llm/src/codec/anthropic_messages/stream.rs b/lib/crates/fabro-llm/src/codec/anthropic_messages/stream.rs index 9cd144337..96b4830a1 100644 --- a/lib/crates/fabro-llm/src/codec/anthropic_messages/stream.rs +++ b/lib/crates/fabro-llm/src/codec/anthropic_messages/stream.rs @@ -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 diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/decode.rs b/lib/crates/fabro-llm/src/codec/bedrock_converse/decode.rs new file mode 100644 index 000000000..36ec5878d --- /dev/null +++ b/lib/crates/fabro-llm/src/codec/bedrock_converse/decode.rs @@ -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, +) -> Error { + let raw: Option = 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 { + 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, +) -> Result { + 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 { + 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); + } +} diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs b/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs new file mode 100644 index 000000000..a328434de --- /dev/null +++ b/lib/crates/fabro-llm/src/codec/bedrock_converse/encode.rs @@ -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 { + 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 { + let role = match message.role { + Role::Assistant => "assistant", + // Tool results ride in user messages on the Converse wire. + _ => "user", + }; + + let mut blocks: Vec = 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 { + 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 { + 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 = 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.` 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: ¶ms, + }; + 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: ¶ms, + }; + 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: ¶ms, + }; + 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: ¶ms, + }; + 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); + } +} diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/mod.rs b/lib/crates/fabro-llm/src/codec/bedrock_converse/mod.rs new file mode 100644 index 000000000..12b315094 --- /dev/null +++ b/lib/crates/fabro-llm/src/codec/bedrock_converse/mod.rs @@ -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 { + encode::encode(ctx, stream) + } + + fn decode_response( + &self, + body: &str, + ctx: &CodecCtx<'_>, + rate_limit: Option, + ) -> Result { + decode::decode_response(body, ctx, rate_limit) + } + + fn stream_decoder( + &self, + ctx: &CodecCtx<'_>, + rate_limit: Option, + ) -> Box { + 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, + ) -> Error { + decode::bedrock_error(status, body, ctx.provider_name, retry_after) + } +} diff --git a/lib/crates/fabro-llm/src/codec/bedrock_converse/stream.rs b/lib/crates/fabro-llm/src/codec/bedrock_converse/stream.rs new file mode 100644 index 000000000..56c2db659 --- /dev/null +++ b/lib/crates/fabro-llm/src/codec/bedrock_converse/stream.rs @@ -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, + redacted: Option, + }, + 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, + /// Completed blocks in arrival order, for the final response message. + parts: Vec, + finish_reason: FinishReason, + usage: TokenCounts, + text_started: bool, + finished: bool, + rate_limit: Option, +} + +impl ConverseStreamDecoder { + pub(super) fn new(ctx: &CodecCtx<'_>, rate_limit: Option) -> 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 { + 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 { + 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 { + 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 = 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, 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 { + 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: ¶ms, + }; + ConverseStreamDecoder::new(&ctx, None) + } + + fn feed(decoder: &mut ConverseStreamDecoder, event: &str, data: &str) -> Vec { + 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() + ); + } +} diff --git a/lib/crates/fabro-llm/src/codec/mod.rs b/lib/crates/fabro-llm/src/codec/mod.rs index e8d8d6e2a..303d8382a 100644 --- a/lib/crates/fabro-llm/src/codec/mod.rs +++ b/lib/crates/fabro-llm/src/codec/mod.rs @@ -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.` 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 diff --git a/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs b/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs index 35a32415d..7fcb81abb 100644 --- a/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs +++ b/lib/crates/fabro-llm/src/codec/openai_compatible/request.rs @@ -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)] diff --git a/lib/crates/fabro-llm/src/codec/openai_responses/decode.rs b/lib/crates/fabro-llm/src/codec/openai_responses/decode.rs index 35f5e4761..b0eb9f77e 100644 --- a/lib/crates/fabro-llm/src/codec/openai_responses/decode.rs +++ b/lib/crates/fabro-llm/src/codec/openai_responses/decode.rs @@ -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 diff --git a/lib/crates/fabro-llm/src/providers/bedrock/eventstream.rs b/lib/crates/fabro-llm/src/providers/bedrock/eventstream.rs new file mode 100644 index 000000000..bb4015eff --- /dev/null +++ b/lib/crates/fabro-llm/src/providers/bedrock/eventstream.rs @@ -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, 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, 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 { + 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 { + 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}"); + } +} diff --git a/lib/crates/fabro-llm/src/providers/bedrock/mod.rs b/lib/crates/fabro-llm/src/providers/bedrock/mod.rs new file mode 100644 index 000000000..e2cffd49d --- /dev/null +++ b/lib/crates/fabro-llm/src/providers/bedrock/mod.rs @@ -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), +} + +/// 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, 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>, +} + +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, + base_url: impl Into, + ) -> Result { + 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) -> Result { + Self::with_auth(base_url, BedrockAuth::Sigv4(OnceCell::new())) + } + + fn with_auth(base_url: impl Into, auth: BedrockAuth) -> Result { + 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) -> Self { + self.provider_name = name.into(); + self + } + + #[must_use] + pub fn with_catalog(mut self, catalog: Arc) -> Self { + self.catalog = Some(catalog); + self + } + + #[must_use] + pub fn with_default_headers(mut self, headers: HashMap) -> 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 { + 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 { + 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, ¶ms); + + 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 { + 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, ¶ms); + + 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, + pending: VecDeque, + done: bool, + /// `finish()` already drained. + finished: bool, + timeout: Option, +} + +/// 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, + timeout: Option, +) -> 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 { + let invalid = || Error::Configuration { + message: format!( + "bedrock base_url '{base_url}' is not a recognized Bedrock runtime endpoint \ + (expected https://bedrock-runtime[-fips]..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 = 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")); + } +} diff --git a/lib/crates/fabro-llm/src/providers/bedrock/sigv4.rs b/lib/crates/fabro-llm/src/providers/bedrock/sigv4.rs new file mode 100644 index 000000000..54a31849d --- /dev/null +++ b/lib/crates/fabro-llm/src/providers/bedrock/sigv4.rs @@ -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, + ) -> 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 { + 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 { + 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, 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, + ) -> Result { + 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") + ); + } +} diff --git a/lib/crates/fabro-llm/src/providers/mod.rs b/lib/crates/fabro-llm/src/providers/mod.rs index ecc2ee96c..d0c72788d 100644 --- a/lib/crates/fabro-llm/src/providers/mod.rs +++ b/lib/crates/fabro-llm/src/providers/mod.rs @@ -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; diff --git a/lib/crates/fabro-llm/tests/integration.rs b/lib/crates/fabro-llm/tests/integration.rs index 1407f0f8c..805ff1c87 100644 --- a/lib/crates/fabro-llm/tests/integration.rs +++ b/lib/crates/fabro-llm/tests/integration.rs @@ -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 = diff --git a/lib/crates/fabro-model/src/adapter.rs b/lib/crates/fabro-model/src/adapter.rs index baa74eb7e..db67fe132 100644 --- a/lib/crates/fabro-model/src/adapter.rs +++ b/lib/crates/fabro-model/src/adapter.rs @@ -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::().unwrap(), + AdapterKind::Bedrock + ); + assert!(AdapterKind::VARIANTS.contains(&AdapterKind::Bedrock)); + } + #[test] fn agent_profile_kind_round_trips_as_settings_strings() { for (kind, expected) in [ diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 480759d58..976c7a760 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -171,11 +171,19 @@ pub struct CostRates { pub cache_input_cost_per_mtok: Option, } +/// 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 for CredentialRef { #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum CredentialRefParseError { - #[error("credential reference must be `vault:` or `env:`")] + #[error("credential reference must be `vault:`, `env:`, 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:` / `vault:`; AWS SigV4 (Bedrock) uses `aws_sigv4`, + /// which resolves opaquely from the AWS credential chain. pub credentials: Vec, #[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::().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 `_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] diff --git a/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml b/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml new file mode 100644 index 000000000..20b011b9c --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/bedrock-openai.toml @@ -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..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 diff --git a/lib/crates/fabro-model/src/catalog/providers/bedrock.toml b/lib/crates/fabro-model/src/catalog/providers/bedrock.toml new file mode 100644 index 000000000..f6f217a4f --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/bedrock.toml @@ -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 `, 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 `_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..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 = "" +# 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 diff --git a/lib/crates/fabro-model/src/codec.rs b/lib/crates/fabro-model/src/codec.rs index bc8bf8813..b9667d286 100644 --- a/lib/crates/fabro-model/src/codec.rs +++ b/lib/crates/fabro-model/src/codec.rs @@ -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); } diff --git a/lib/crates/fabro-redact/data/gitleaks.toml b/lib/crates/fabro-redact/data/gitleaks.toml index 00b7cb9ca..c61cdec2c 100644 --- a/lib/crates/fabro-redact/data/gitleaks.toml +++ b/lib/crates/fabro-redact/data/gitleaks.toml @@ -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." diff --git a/lib/crates/fabro-server/src/spawn_env.rs b/lib/crates/fabro-server/src/spawn_env.rs index 5c1ed8140..2493254dd 100644 --- a/lib/crates/fabro-server/src/spawn_env.rs +++ b/lib/crates/fabro-server/src/spawn_env.rs @@ -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), diff --git a/lib/crates/fabro-static/src/env_vars.rs b/lib/crates/fabro-static/src/env_vars.rs index 315de6477..cd16892b2 100644 --- a/lib/crates/fabro-static/src/env_vars.rs +++ b/lib/crates/fabro-static/src/env_vars.rs @@ -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, diff --git a/lib/crates/fabro-static/src/secret_registry.rs b/lib/crates/fabro-static/src/secret_registry.rs index b6770aeab..6166a1d42 100644 --- a/lib/crates/fabro-static/src/secret_registry.rs +++ b/lib/crates/fabro-static/src/secret_registry.rs @@ -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,