fix(proxy): PR-D1 native Bedrock InvokeModel route + SigV4

Phase D PR-D1 lands the first native Rust path for AWS Bedrock,
replacing the lossy LiteLLM Python converter for Anthropic-on-Bedrock
non-streaming requests. Eliminates part of P4-37 and P4-39.

What landed
-----------

- New crates/headroom-proxy/src/bedrock/ module:
  - envelope.rs: parses the {"anthropic_version": "...", ...}
    Bedrock body shape; re-emits with anthropic_version preserved
    as the first key (relies on serde_json preserve_order).
  - sigv4.rs: AWS SigV4 signer wrapping the aws-sigv4 crate.
    Forces PayloadChecksumKind::XAmzSha256 so x-amz-content-sha256
    is in the canonical request, hashed over the post-compression
    body bytes (the bytes that actually hit Bedrock). No silent
    fallback: signing failures return 5xx with
    event=bedrock_sigv4_failed.
  - invoke.rs: POST handler for /model/{model_id}/invoke
    (and /converse - same wire shape for anthropic.claude-*).
    Detects Anthropic vendor via literal starts_with("anthropic.")
    (no regex per project rule), routes Anthropic-shape bodies
    through the existing compress_anthropic_request live-zone
    dispatcher, then signs and forwards to the configured Bedrock
    endpoint.

- Modified:
  - proxy.rs: routes /model/:model_id/invoke and
    /model/:model_id/converse when enable_bedrock_native is on
    (default). Adds bedrock_credentials: Option<Arc<Credentials>>
    to AppState.
  - config.rs: new flags --bedrock-region (default us-east-1,
    env HEADROOM_PROXY_BEDROCK_REGION), --bedrock-endpoint
    (operator override for FIPS/VPC/test setups),
    --enable-bedrock-native (default true), --aws-profile.
  - main.rs: resolves AWS credentials at startup via
    aws_config::defaults(BehaviorVersion::latest()). Failure logs
    event=bedrock_credentials_unavailable at WARN; the handler
    refuses to forward unsigned (event=bedrock_credentials_missing).
  - Cargo.toml: workspace deps aws-sigv4, aws-config,
    aws-credential-types, aws-smithy-runtime-api.

Tests
-----

8 integration tests under
crates/headroom-proxy/tests/integration_bedrock_invoke.rs:

1. native_envelope_round_trip_byte_equal
2. sigv4_signed_correctly_after_compression - confirms
   authorization is SigV4-shape and x-amz-content-sha256
   matches sha256(body received by upstream).
3. thinking_block_preserved_through_bedrock
4. redacted_thinking_preserved
5. document_block_preserved
6. tool_result_array_with_image_preserved
7. stop_sequence_null_only_when_present - pins that the proxy
   does NOT inject stop_sequence: null (P4-37 hardcode).
8. tool_use_input_byte_equal_preserves_key_order

All eight pass. Full workspace test run is green; clippy + fmt
clean. make ci-precheck (rust + python + commitlint) passes
locally.

Build constraints honoured
--------------------------

- No silent fallbacks: missing creds / signing failures return
  5xx with structured event=... log; no path ever forwards
  unsigned.
- No hardcodes: region, endpoint, profile, enable-flag all
  configurable via CLI + env.
- No regexes: vendor detection is str::starts_with.
- Comprehensive structured logs: event=bedrock_invoke_received,
  bedrock_envelope_parsed, bedrock_compression_skipped,
  bedrock_credentials_missing, sigv4_signed,
  bedrock_invoke_forwarded, etc.
- Performant: body buffered once, passed by &[u8] to signer
  (zero-copy), Bytes::clone only for ownership transfer to
  reqwest. Sign exactly once per request.
- Elegant: 4 small focused modules mirror handlers/ + sse/.
- Tests use realistic Anthropic block content (real thinking,
  redacted_thinking, document, base64 image fixtures).

Streaming (PR-D2) and observability (PR-D3) follow.
This commit is contained in:
chopratejas 2026-05-03 16:22:32 -07:00
parent 1352f621fc
commit f2d4fe39cb
13 changed files with 2509 additions and 31 deletions

588
Cargo.lock generated
View file

@ -240,6 +240,321 @@ dependencies = [
"arrayvec",
]
[[package]]
name = "aws-config"
version = "1.8.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d"
dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-sdk-sts",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand",
"http 1.4.0",
"time",
"tokio",
"tracing",
"url",
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "aws-runtime"
version = "1.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864"
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-sts"
version = "1.103.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b"
dependencies = [
"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.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a"
dependencies = [
"aws-credential-types",
"aws-smithy-http",
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
"form_urlencoded",
"hex",
"hmac",
"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-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.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769"
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.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a"
dependencies = [
"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.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-http-client",
"aws-smithy-observability",
"aws-smithy-runtime-api",
"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.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e"
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",
]
[[package]]
name = "aws-smithy-types"
version = "1.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c"
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.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-types",
"rustc_version",
"tracing",
]
[[package]]
name = "axum"
version = "0.7.9"
@ -252,8 +567,8 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures-util",
"http",
"http-body",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper",
"hyper-util",
@ -287,8 +602,8 @@ dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
@ -322,6 +637,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 = "base64ct"
version = "1.8.3"
@ -387,6 +712,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bstr"
version = "1.12.1"
@ -434,6 +768,16 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[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 = "bytesize"
version = "1.3.3"
@ -546,6 +890,21 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
[[package]]
name = "color_quant"
version = "1.1.0"
@ -608,6 +967,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 = "constant_time_eq"
version = "0.4.2"
@ -773,6 +1138,24 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
dependencies = [
"hybrid-array",
]
[[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.20.11"
@ -911,8 +1294,20 @@ 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",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"const-oid",
"crypto-common 0.2.1",
"ctutils",
]
[[package]]
@ -956,6 +1351,12 @@ dependencies = [
"litrs",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.15.0"
@ -1170,6 +1571,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.32"
@ -1329,7 +1736,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
"http",
"http 1.4.0",
"indexmap",
"slab",
"tokio",
@ -1415,7 +1822,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"tempfile",
"thiserror 1.0.69",
"tiktoken-rs",
@ -1441,6 +1848,10 @@ dependencies = [
name = "headroom-proxy"
version = "0.1.0"
dependencies = [
"aws-config",
"aws-credential-types",
"aws-sigv4",
"aws-smithy-runtime-api",
"axum",
"bytes",
"bytesize",
@ -1448,7 +1859,7 @@ dependencies = [
"futures",
"futures-util",
"headroom-core",
"http",
"http 1.4.0",
"http-body-util",
"humantime",
"hyper",
@ -1458,7 +1869,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
@ -1494,6 +1905,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hf-hub"
version = "0.4.3"
@ -1501,7 +1918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97"
dependencies = [
"dirs",
"http",
"http 1.4.0",
"indicatif 0.17.11",
"libc",
"log",
@ -1521,7 +1938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213"
dependencies = [
"dirs",
"http",
"http 1.4.0",
"indicatif 0.18.4",
"libc",
"log",
@ -1535,12 +1952,32 @@ dependencies = [
"windows-sys 0.61.2",
]
[[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]]
name = "hmac-sha256"
version = "1.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
[[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"
@ -1551,6 +1988,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"
@ -1558,7 +2006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
"http 1.4.0",
]
[[package]]
@ -1569,8 +2017,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http 1.4.0",
"http-body 1.0.1",
"pin-project-lite",
]
@ -1592,6 +2040,15 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "hybrid-array"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.9.0"
@ -1603,8 +2060,8 @@ dependencies = [
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"http 1.4.0",
"http-body 1.0.1",
"httparse",
"httpdate",
"itoa",
@ -1620,10 +2077,11 @@ version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"http 1.4.0",
"hyper",
"hyper-util",
"rustls",
"rustls-native-certs",
"tokio",
"tokio-rustls",
"tower-service",
@ -1656,8 +2114,8 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"http 1.4.0",
"http-body 1.0.1",
"hyper",
"ipnet",
"libc",
@ -2150,7 +2608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
"digest 0.10.7",
]
[[package]]
@ -2509,6 +2967,12 @@ dependencies = [
"ureq 3.3.0",
]
[[package]]
name = "outref"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "parking_lot_core"
version = "0.9.12"
@ -2555,6 +3019,12 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.33"
@ -3078,6 +3548,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"
@ -3096,8 +3572,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",
@ -3177,6 +3653,15 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
@ -3196,6 +3681,7 @@ version = "0.23.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
@ -3205,6 +3691,18 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
@ -3221,6 +3719,7 @@ version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@ -3398,7 +3897,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@ -3409,7 +3908,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"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]]
@ -3923,8 +4433,8 @@ dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"http 1.4.0",
"http-body 1.0.1",
"iri-string",
"pin-project-lite",
"tower",
@ -4036,7 +4546,7 @@ dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"http 1.4.0",
"httparse",
"log",
"rand 0.8.6",
@ -4176,7 +4686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64 0.22.1",
"http",
"http 1.4.0",
"httparse",
"log",
]
@ -4193,6 +4703,12 @@ dependencies = [
"serde",
]
[[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"
@ -4257,6 +4773,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vsimd"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
[[package]]
name = "wait-timeout"
version = "0.2.1"
@ -4714,7 +5236,7 @@ dependencies = [
"base64 0.22.1",
"deadpool",
"futures",
"http",
"http 1.4.0",
"http-body-util",
"hyper",
"hyper-util",
@ -4827,6 +5349,12 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "xmlparser"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
[[package]]
name = "y4m"
version = "0.8.0"

View file

@ -56,3 +56,15 @@ axum = "0.7"
tower = "0.5"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
pyo3 = "0.22"
# Phase D PR-D1: AWS SigV4 signing for native Bedrock InvokeModel route.
# `aws-sigv4` provides the canonical-request + signing-key implementation;
# `aws-config` resolves credentials from the standard provider chain
# (env vars, profiles, IMDS, ECS task role, etc); `aws-credential-types`
# exposes `Credentials` so the signer accepts whatever the chain returned.
aws-sigv4 = { version = "1", default-features = false, features = ["sign-http", "http1"] }
aws-config = { version = "1", default-features = false, features = ["behavior-version-latest", "rustls", "rt-tokio"] }
aws-credential-types = { version = "1", default-features = false }
# `Identity` lives in aws-smithy-runtime-api; the SigV4 builder
# accepts `&Identity`. Pinning the version explicitly avoids a
# silent semver bump from the transitive dep tree.
aws-smithy-runtime-api = { version = "1", default-features = false, features = ["client"] }

View file

@ -41,6 +41,12 @@ humantime = "2"
bytesize = "1"
tokio-util = { version = "0.7" }
headroom-core = { path = "../headroom-core" }
# Phase D PR-D1: native Bedrock InvokeModel route. SigV4 + AWS
# default credential chain.
aws-sigv4 = { workspace = true }
aws-config = { workspace = true }
aws-credential-types = { workspace = true }
aws-smithy-runtime-api = { workspace = true }
[dev-dependencies]
tower = { workspace = true, features = ["util"] }

View file

@ -0,0 +1,239 @@
//! Bedrock envelope: parse the body shape Bedrock expects, hand the
//! Anthropic-shape sub-body to the compressor, and re-emit with
//! `anthropic_version` preserved as the FIRST key.
//!
//! # Wire shape
//!
//! Bedrock InvokeModel for any `anthropic.claude-*` model expects:
//!
//! ```json
//! {
//! "anthropic_version": "bedrock-2023-05-31",
//! "messages": [...],
//! "max_tokens": 1024,
//! ...rest_of_anthropic_body
//! }
//! ```
//!
//! `model` is in the URL path (`/model/{model}/invoke`), NOT the body
//! — the opposite of direct-Anthropic `/v1/messages`. `anthropic_version`
//! is REQUIRED and must be a literal Bedrock-recognized version
//! string (e.g. `"bedrock-2023-05-31"`).
//!
//! # Why key order matters
//!
//! Bedrock's request validator does NOT depend on key order, but our
//! cache-safety contract (`I1` in REALIGNMENT/02-architecture.md) is
//! that *unmodified* bytes round-trip byte-equal. If the compressor
//! returns `NoChange` we forward the original buffered bytes
//! verbatim — we never re-serialize. If the compressor returns
//! `Modified`, the byte slice it produced already preserves
//! key order from the input via the `preserve_order` feature on
//! `serde_json` (the workspace turns this on by default).
//!
//! This module's [`BedrockEnvelope`] is used ONLY for re-emitting
//! the envelope shape after compression. It calls the compressor
//! against the body bytes directly — there is no decode-then-encode
//! round trip on the no-change path.
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
/// The literal `anthropic_version` field name. Bedrock-strict.
const ANTHROPIC_VERSION_KEY: &str = "anthropic_version";
/// Parsed Bedrock InvokeModel envelope. Holds the literal
/// `anthropic_version` string plus the rest of the body as a parsed
/// `serde_json::Value` (so callers can inspect / route it). The
/// original byte-equal body is also stashed for the no-change
/// passthrough path.
#[derive(Debug, Clone)]
pub struct BedrockEnvelope {
/// The literal `anthropic_version` string, e.g.
/// `"bedrock-2023-05-31"`. Required.
pub anthropic_version: String,
/// The full parsed body, including `anthropic_version`. Useful
/// for tests + logs; the compressor takes raw bytes.
pub body: Value,
}
/// Errors surfaced when parsing a Bedrock envelope.
#[derive(Debug, Error)]
pub enum EnvelopeError {
/// Body was not valid JSON.
#[error("body is not valid JSON: {0}")]
NotJson(serde_json::Error),
/// Body was JSON but not a top-level object.
#[error("body is not a JSON object")]
NotObject,
/// `anthropic_version` field missing.
#[error("missing required `anthropic_version` field")]
MissingAnthropicVersion,
/// `anthropic_version` was present but not a string.
#[error("`anthropic_version` is not a string")]
AnthropicVersionNotString,
}
impl BedrockEnvelope {
/// Parse a Bedrock envelope from raw JSON bytes.
///
/// This does NOT mutate the bytes. Compression dispatch happens
/// elsewhere (the handler hands the same byte slice to
/// `compress_anthropic_request`).
pub fn parse(body: &[u8]) -> Result<Self, EnvelopeError> {
let value: Value = serde_json::from_slice(body).map_err(EnvelopeError::NotJson)?;
let obj = value.as_object().ok_or(EnvelopeError::NotObject)?;
let av = obj
.get(ANTHROPIC_VERSION_KEY)
.ok_or(EnvelopeError::MissingAnthropicVersion)?;
let av_str = av
.as_str()
.ok_or(EnvelopeError::AnthropicVersionNotString)?
.to_string();
Ok(Self {
anthropic_version: av_str,
body: value,
})
}
/// Re-emit the envelope as JSON bytes with `anthropic_version`
/// preserved as the FIRST key.
///
/// Used only after the compressor returns `Modified` and we need
/// to reassemble the body. With `serde_json`'s `preserve_order`
/// feature (workspace default), the parsed object already preserves
/// insertion order; we just need to make sure `anthropic_version`
/// is the first key. If the compressor's output already has it
/// first (which it will — the compressor only mutates the
/// `messages` content slot, not key ordering), we hand the bytes
/// back unchanged.
///
/// `body` is the (possibly-compressed) JSON bytes returned by the
/// compression dispatcher.
///
/// Returns `Ok(bytes)` on success. On any structural error, returns
/// the input bytes unchanged so the byte-fidelity contract is
/// preserved (the caller will surface the error).
pub fn ensure_anthropic_version_first(body: &[u8]) -> Result<Bytes, EnvelopeError> {
// Parse with preserve_order (workspace serde_json default).
let mut value: Value = serde_json::from_slice(body).map_err(EnvelopeError::NotJson)?;
let map = value.as_object_mut().ok_or(EnvelopeError::NotObject)?;
// If `anthropic_version` is missing, that's a logical error
// for the Bedrock surface — surface it loudly.
if !map.contains_key(ANTHROPIC_VERSION_KEY) {
return Err(EnvelopeError::MissingAnthropicVersion);
}
// Already first? Then `body` round-trips byte-equal — no work.
// Note: serde_json::Map iteration order, with preserve_order,
// is insertion order. We check the first key directly.
if map
.keys()
.next()
.map(|k| k.as_str() == ANTHROPIC_VERSION_KEY)
.unwrap_or(false)
{
return Ok(Bytes::copy_from_slice(body));
}
// Reorder: pull anthropic_version, drain rest, rebuild with
// anthropic_version first. This only runs when the compressor
// moved the field (it shouldn't, but if it ever does we
// reassert the invariant rather than ship a Bedrock-rejecting
// body).
let av = map
.remove(ANTHROPIC_VERSION_KEY)
.expect("contains_key guard above");
let mut new_map = serde_json::Map::with_capacity(map.len() + 1);
new_map.insert(ANTHROPIC_VERSION_KEY.to_string(), av);
for (k, v) in std::mem::take(map) {
new_map.insert(k, v);
}
let rebuilt = Value::Object(new_map);
let bytes = serde_json::to_vec(&rebuilt).map_err(EnvelopeError::NotJson)?;
Ok(Bytes::from(bytes))
}
}
/// Helper for reading just the model id from a Bedrock URL path.
///
/// Bedrock paths look like `/model/{model_id}/invoke`. The `{model_id}`
/// segment can contain dots (`anthropic.claude-3-haiku-20240307-v1:0`),
/// hyphens, colons, and digits — all of which are allowed in URL path
/// segments. We use Axum's `:model_id` capture; this helper exists for
/// callers who only have the raw path.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelPath {
pub model_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_minimal_envelope() {
let body = json!({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 16,
"messages": [{"role": "user", "content": "hi"}]
});
let bytes = serde_json::to_vec(&body).unwrap();
let env = BedrockEnvelope::parse(&bytes).expect("parses");
assert_eq!(env.anthropic_version, "bedrock-2023-05-31");
}
#[test]
fn missing_anthropic_version_errors() {
let body = json!({"max_tokens": 16, "messages": []});
let bytes = serde_json::to_vec(&body).unwrap();
let err = BedrockEnvelope::parse(&bytes).expect_err("must error");
assert!(matches!(err, EnvelopeError::MissingAnthropicVersion));
}
#[test]
fn anthropic_version_not_string_errors() {
let body = json!({"anthropic_version": 123, "max_tokens": 16});
let bytes = serde_json::to_vec(&body).unwrap();
let err = BedrockEnvelope::parse(&bytes).expect_err("must error");
assert!(matches!(err, EnvelopeError::AnthropicVersionNotString));
}
#[test]
fn not_an_object_errors() {
let bytes = b"[1,2,3]";
let err = BedrockEnvelope::parse(bytes).expect_err("must error");
assert!(matches!(err, EnvelopeError::NotObject));
}
#[test]
fn invalid_json_errors() {
let bytes = b"not json";
let err = BedrockEnvelope::parse(bytes).expect_err("must error");
assert!(matches!(err, EnvelopeError::NotJson(_)));
}
#[test]
fn ensure_first_no_op_when_already_first() {
let body = br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":16}"#;
let out = BedrockEnvelope::ensure_anthropic_version_first(body).unwrap();
assert_eq!(&out[..], &body[..]);
}
#[test]
fn ensure_first_reorders_when_not_first() {
// anthropic_version comes second.
let body = br#"{"max_tokens":16,"anthropic_version":"bedrock-2023-05-31"}"#;
let out = BedrockEnvelope::ensure_anthropic_version_first(body).unwrap();
let out_str = std::str::from_utf8(&out).unwrap();
// First key after `{"` must be `anthropic_version`.
assert!(
out_str.starts_with(r#"{"anthropic_version":"bedrock-2023-05-31""#),
"got {out_str}"
);
}
}

View file

@ -0,0 +1,555 @@
//! POST `/model/{model_id}/invoke` handler — Phase D PR-D1.
//!
//! # Pipeline
//!
//! 1. Extract `{model_id}` from the path. The Bedrock convention is
//! `anthropic.claude-3-haiku-20240307-v1:0` — dot-separated
//! `<vendor>.<model>-<date>-<rev>`.
//! 2. If the vendor is `anthropic`, parse the body as a Bedrock
//! envelope (`{"anthropic_version": "...", ...rest}`) and run the
//! live-zone Anthropic compression dispatcher over the body bytes.
//! The dispatcher is the SAME one `/v1/messages` uses; Bedrock's
//! body shape is just Anthropic-without-the-`model`-field.
//! 3. Re-emit the (possibly compressed) body with `anthropic_version`
//! preserved as the first key.
//! 4. Build the upstream URL (`https://bedrock-runtime.{region}.amazonaws.com/model/{model}/invoke`
//! or operator override).
//! 5. Sign the (post-compression) body bytes with AWS SigV4. Sign
//! over `host`, `x-amz-date`, `x-amz-content-sha256` plus any
//! extra headers (`content-type`, `accept`).
//! 6. Forward to Bedrock; stream the response back to the client.
//!
//! # Failure modes
//!
//! - **Missing credentials**: log
//! `event=bedrock_credentials_missing` at WARN, return `500` with
//! a JSON error body. NEVER forwards an unsigned request.
//! - **Envelope parse failure**: log `event=bedrock_envelope_parse_error`,
//! pass the bytes through unchanged. Bedrock will reject anyway,
//! but the failure is the customer's, not ours — we just route.
//! This matches the Anthropic compression path's
//! `Outcome::Passthrough` behaviour.
//! - **Non-anthropic model**: skip compression, but still sign +
//! forward. Other vendors (Amazon Titan, Cohere, AI21, Meta) have
//! different body shapes that the proxy doesn't yet understand;
//! we pass them through opaquely.
//! - **SigV4 signing failure**: log
//! `event=bedrock_sigv4_failed`, return `500`. NEVER forwards
//! unsigned.
use std::net::SocketAddr;
use std::time::SystemTime;
use axum::body::Body;
use axum::extract::{ConnectInfo, Path, State};
use axum::http::{HeaderMap, Method, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use futures_util::StreamExt as _;
use http::HeaderName;
use url::Url;
use crate::bedrock::envelope::BedrockEnvelope;
use crate::bedrock::sigv4::{sign_request, SigningInputs};
use crate::compression::{
compress_anthropic_request, Outcome as AnthropicOutcome, PassthroughReason,
};
use crate::headers::filter_response_headers;
use crate::proxy::AppState;
/// Anthropic vendor prefix as encoded in Bedrock model ids
/// (`anthropic.claude-3-haiku-...`). Literal-match per project rule
/// "no regexes for parsing the model ID".
const ANTHROPIC_VENDOR_PREFIX: &str = "anthropic.";
/// AWS Bedrock Runtime DNS template. The `{}` placeholder is the
/// region. Only used when `Config::bedrock_endpoint` is `None`.
const BEDROCK_RUNTIME_HOST_TEMPLATE: &str = "bedrock-runtime.{region}.amazonaws.com";
/// Axum POST handler for `/model/{model_id}/invoke`.
///
/// Buffers the body so the live-zone compressor + SigV4 signer can
/// inspect it. Both are required to be applied to the SAME byte slice
/// — the signer hashes whatever the forwarder will actually send.
pub async fn handle_invoke(
State(state): State<AppState>,
ConnectInfo(client_addr): ConnectInfo<SocketAddr>,
Path(model_id): Path<String>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> Response {
let _ = client_addr; // accepted for ConnectInfo extractor; not used directly today
let request_id = headers
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
tracing::info!(
event = "bedrock_invoke_received",
request_id = %request_id,
method = %method,
model_id = %model_id,
body_bytes = body.len(),
"bedrock invoke route received request"
);
let is_anthropic = model_id.starts_with(ANTHROPIC_VENDOR_PREFIX);
let outbound_body: Bytes = if is_anthropic {
run_anthropic_compression(&body, &state, &request_id)
} else {
tracing::info!(
event = "bedrock_compression_skipped",
request_id = %request_id,
model_id = %model_id,
reason = "non_anthropic_vendor",
"bedrock invoke: skipping live-zone compression for non-anthropic vendor"
);
body.clone()
};
// Build the upstream URL based on configured endpoint or
// region-derived default.
let upstream_url = match build_bedrock_upstream(&state, &model_id, &uri, "invoke") {
Ok(u) => u,
Err(msg) => {
tracing::error!(
event = "bedrock_endpoint_invalid",
request_id = %request_id,
error = %msg,
"bedrock invoke: failed to construct upstream URL"
);
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"bedrock_endpoint_invalid",
&msg,
);
}
};
// Resolve credentials. No silent fallback: missing creds → 5xx.
let creds = match state.bedrock_credentials.as_ref() {
Some(c) => c.clone(),
None => {
tracing::warn!(
event = "bedrock_credentials_missing",
request_id = %request_id,
model_id = %model_id,
"bedrock invoke: refusing to forward without AWS credentials"
);
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"bedrock_credentials_missing",
"AWS credentials not configured; refusing to forward unsigned",
);
}
};
// Build the headers we sign + forward. Start from the inbound
// headers, drop the ones the upstream client manages, then sign.
let extra_signed: Vec<(String, String)> = collect_signed_headers(&headers, &upstream_url);
let extra_signed_refs: Vec<(&str, &str)> = extra_signed
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let sign_inputs = SigningInputs {
method: method.as_str(),
url: &upstream_url,
region: &state.config.bedrock_region,
credentials: creds.as_ref(),
body: &outbound_body,
extra_signed_headers: &extra_signed_refs,
time: SystemTime::now(),
};
let signed = match sign_request(&sign_inputs) {
Ok(s) => s,
Err(e) => {
tracing::error!(
event = "bedrock_sigv4_failed",
request_id = %request_id,
model_id = %model_id,
error = %e,
"bedrock invoke: SigV4 signing failed; refusing to forward"
);
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"bedrock_sigv4_failed",
&e.to_string(),
);
}
};
// Compose the outgoing header map. Start with the headers we'll
// forward (filter out hop-by-hop / Host / Content-Length;
// reqwest sets those itself), then layer the SigV4 outputs on
// top — they replace any pre-existing copies of the same name.
let mut outbound_headers = HeaderMap::new();
for (name, value) in extra_signed.iter() {
if let (Ok(n), Ok(v)) = (
HeaderName::from_bytes(name.as_bytes()),
http::HeaderValue::from_str(value),
) {
outbound_headers.insert(n, v);
}
}
for (name, value) in signed.entries.iter() {
if let (Ok(n), Ok(v)) = (
HeaderName::from_bytes(name.as_bytes()),
http::HeaderValue::from_str(value),
) {
outbound_headers.insert(n, v);
}
}
// Forward. We surface upstream errors as 502; the byte path
// streams the response back to the client.
let reqwest_method = match reqwest::Method::from_bytes(method.as_str().as_bytes()) {
Ok(m) => m,
Err(e) => {
tracing::error!(
event = "bedrock_invalid_method",
request_id = %request_id,
error = %e,
"bedrock invoke: invalid HTTP method"
);
return error_response(
StatusCode::BAD_REQUEST,
"bedrock_invalid_method",
&e.to_string(),
);
}
};
let upstream_resp = state
.client
.request(reqwest_method, upstream_url.clone())
.headers(outbound_headers)
.body(outbound_body.clone())
.send()
.await;
let upstream_resp = match upstream_resp {
Ok(r) => r,
Err(e) => {
tracing::warn!(
event = "bedrock_upstream_error",
request_id = %request_id,
error = %e,
"bedrock invoke: upstream request failed"
);
let status = if e.is_timeout() {
StatusCode::GATEWAY_TIMEOUT
} else {
StatusCode::BAD_GATEWAY
};
return error_response(status, "bedrock_upstream_error", &e.to_string());
}
};
let status =
StatusCode::from_u16(upstream_resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let resp_headers = filter_response_headers(upstream_resp.headers());
tracing::info!(
event = "bedrock_invoke_forwarded",
request_id = %request_id,
model_id = %model_id,
upstream_status = status.as_u16(),
upstream_url = %upstream_url,
"bedrock invoke: response forwarded"
);
// Stream the response body back without buffering.
let stream = upstream_resp
.bytes_stream()
.map(|r| r.map_err(std::io::Error::other));
let body_out = Body::from_stream(stream);
let mut builder = Response::builder().status(status);
if let Some(h) = builder.headers_mut() {
h.extend(resp_headers);
if let Ok(v) = http::HeaderValue::from_str(&request_id) {
h.insert(HeaderName::from_static("x-request-id"), v);
}
}
builder.body(body_out).unwrap_or_else(|e| {
tracing::error!(
event = "bedrock_response_build_failed",
request_id = %request_id,
error = %e,
"bedrock invoke: failed to build response"
);
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("internal handler error"))
.expect("static response")
})
}
/// Run the live-zone Anthropic compressor over a Bedrock-shape body.
///
/// The compressor only inspects `messages` — it doesn't care that the
/// Bedrock body has `anthropic_version` instead of `model`. The
/// `Outcome::Compressed` body bytes still preserve key order via
/// `serde_json`'s `preserve_order` feature, so the caller's
/// re-emission step (`ensure_anthropic_version_first`) almost always
/// no-ops. We still call it as a defence-in-depth assertion that
/// the byte order is correct before signing.
fn run_anthropic_compression(body: &Bytes, state: &AppState, request_id: &str) -> Bytes {
// Validate envelope shape. If the body isn't a valid Bedrock
// envelope we still forward verbatim — the compressor would have
// refused too — but log loudly.
if let Err(e) = BedrockEnvelope::parse(body) {
tracing::warn!(
event = "bedrock_envelope_parse_error",
request_id = %request_id,
error = %e,
"bedrock invoke: envelope parse failed; passing body through unchanged"
);
return body.clone();
}
tracing::info!(
event = "bedrock_envelope_parsed",
request_id = %request_id,
body_bytes = body.len(),
"bedrock invoke: envelope validated; dispatching to live-zone compressor"
);
let outcome = compress_anthropic_request(
body,
state.config.compression_mode,
state.config.cache_control_auto_frozen,
request_id,
);
match outcome {
AnthropicOutcome::NoCompression => body.clone(),
AnthropicOutcome::Passthrough { reason } => {
tracing::info!(
event = "bedrock_compression_passthrough",
request_id = %request_id,
reason = ?reason,
"bedrock invoke: live-zone dispatcher fell through to passthrough"
);
// The compressor's passthrough variants all leave bytes
// unchanged. Forward the original.
let _ = (PassthroughReason::ModeOff, PassthroughReason::NoMessages); // pin types
body.clone()
}
AnthropicOutcome::Compressed { body: new_body, .. } => {
// Defence-in-depth: re-emit so anthropic_version is the
// first key. With preserve_order this is a no-op on the
// happy path.
match BedrockEnvelope::ensure_anthropic_version_first(&new_body) {
Ok(b) => b,
Err(e) => {
tracing::error!(
event = "bedrock_envelope_reemit_failed",
request_id = %request_id,
error = %e,
"bedrock invoke: failed to re-emit envelope; falling back to original body"
);
body.clone()
}
}
}
}
}
/// Build the upstream URL for the Bedrock route. Honours the
/// operator-supplied `bedrock_endpoint` first, falling back to the
/// region-derived default. The path/query portion is taken from the
/// original URI verbatim — Bedrock's path schema (`/model/{id}/{action}`)
/// is identical to the proxy's external path.
fn build_bedrock_upstream(
state: &AppState,
model_id: &str,
uri: &Uri,
action: &str,
) -> Result<Url, String> {
let base = match state.config.bedrock_endpoint.as_ref() {
Some(u) => u.clone(),
None => {
let host =
BEDROCK_RUNTIME_HOST_TEMPLATE.replace("{region}", &state.config.bedrock_region);
Url::parse(&format!("https://{host}/"))
.map_err(|e| format!("bedrock derived base URL parse error: {e}"))?
}
};
// Compose the path. We trust the captured `model_id` (Axum
// already URL-decoded it) and append `/{action}`.
let path = format!(
"/model/{model_id}/{action}",
model_id = model_id,
action = action,
);
let mut joined = base;
joined.set_path(&path);
if let Some(q) = uri.query() {
joined.set_query(Some(q));
}
Ok(joined)
}
/// Build the list of headers to sign + forward. Drops hop-by-hop,
/// `host`, `content-length` (reqwest manages those), `authorization`
/// (we replace it with the SigV4 output). Lower-cases names for
/// canonical-request consistency.
fn collect_signed_headers(headers: &HeaderMap, upstream_url: &Url) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::with_capacity(headers.len() + 1);
for (name, value) in headers.iter() {
let n = name.as_str().to_ascii_lowercase();
if matches!(
n.as_str(),
"host"
| "content-length"
| "connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailers"
| "transfer-encoding"
| "upgrade"
| "authorization"
| "x-amz-date"
| "x-amz-content-sha256"
) {
// Drop client-managed + signer-managed headers.
continue;
}
if n.starts_with("x-headroom-") {
// Internal headers are stripped from upstream traffic
// (PR-A5). The Bedrock route inherits the same default.
continue;
}
if let Ok(v) = value.to_str() {
out.push((n, v.to_string()));
}
}
// Signer requires `host` in the canonical request. Add it
// explicitly from the upstream URL — the inbound `host` header
// (the proxy's listening hostname) is wrong for the canonical
// request.
if let Some(host) = upstream_url.host_str() {
let host_value = match upstream_url.port() {
Some(p) => format!("{host}:{p}"),
None => host.to_string(),
};
out.push(("host".to_string(), host_value));
}
out
}
fn error_response(status: StatusCode, event: &str, msg: &str) -> Response {
let body = serde_json::json!({
"error": {
"type": event,
"message": msg,
}
})
.to_string();
let _ = event;
let mut resp = Response::builder()
.status(status)
.body(Body::from(body))
.expect("static error response");
resp.headers_mut().insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("application/json"),
);
resp.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anthropic_vendor_prefix_match() {
assert!("anthropic.claude-3-haiku-20240307-v1:0".starts_with(ANTHROPIC_VENDOR_PREFIX));
assert!("anthropic.claude-3-5-sonnet-20241022-v2:0".starts_with(ANTHROPIC_VENDOR_PREFIX));
assert!(!"amazon.titan-text-express-v1".starts_with(ANTHROPIC_VENDOR_PREFIX));
assert!(!"meta.llama3-70b-instruct-v1:0".starts_with(ANTHROPIC_VENDOR_PREFIX));
}
#[test]
fn build_upstream_uses_region_default() {
use crate::config::Config;
let mut config = Config::for_test(Url::parse("http://up:8080").unwrap());
config.bedrock_region = "us-west-2".to_string();
let state = AppState {
config: std::sync::Arc::new(config),
client: reqwest::Client::new(),
bedrock_credentials: None,
};
let uri: Uri = "/model/anthropic.claude-3-haiku-20240307-v1:0/invoke"
.parse()
.unwrap();
let url = build_bedrock_upstream(
&state,
"anthropic.claude-3-haiku-20240307-v1:0",
&uri,
"invoke",
)
.unwrap();
assert_eq!(
url.as_str(),
"https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke"
);
}
#[test]
fn build_upstream_honors_explicit_endpoint() {
use crate::config::Config;
let mut config = Config::for_test(Url::parse("http://up:8080").unwrap());
config.bedrock_endpoint = Some(Url::parse("http://127.0.0.1:9999").unwrap());
let state = AppState {
config: std::sync::Arc::new(config),
client: reqwest::Client::new(),
bedrock_credentials: None,
};
let uri: Uri = "/model/anthropic.claude-3-haiku-20240307-v1:0/invoke"
.parse()
.unwrap();
let url = build_bedrock_upstream(
&state,
"anthropic.claude-3-haiku-20240307-v1:0",
&uri,
"invoke",
)
.unwrap();
assert_eq!(
url.as_str(),
"http://127.0.0.1:9999/model/anthropic.claude-3-haiku-20240307-v1:0/invoke"
);
}
#[test]
fn collect_signed_headers_strips_client_managed() {
let mut headers = HeaderMap::new();
headers.insert("content-type", "application/json".parse().unwrap());
headers.insert("host", "proxy.example".parse().unwrap());
headers.insert("authorization", "Bearer x".parse().unwrap());
headers.insert("x-headroom-mode", "live".parse().unwrap());
headers.insert("accept", "application/json".parse().unwrap());
let upstream =
Url::parse("https://bedrock-runtime.us-east-1.amazonaws.com/model/x/invoke").unwrap();
let out = collect_signed_headers(&headers, &upstream);
let names: Vec<&str> = out.iter().map(|(k, _)| k.as_str()).collect();
assert!(names.contains(&"content-type"));
assert!(names.contains(&"accept"));
assert!(names.contains(&"host"));
assert!(!names.contains(&"authorization"));
assert!(!names.contains(&"x-headroom-mode"));
// host must be the upstream host, not the proxy host.
let host = out
.iter()
.find(|(k, _)| k == "host")
.map(|(_, v)| v.as_str())
.unwrap();
assert_eq!(host, "bedrock-runtime.us-east-1.amazonaws.com");
}
}

View file

@ -0,0 +1,52 @@
//! Native AWS Bedrock InvokeModel route — Phase D PR-D1.
//!
//! # Why a separate module?
//!
//! The Python proxy currently routes Anthropic-on-Bedrock through the
//! `litellm` shim (`headroom/backends/litellm.py`). That shim
//! lossy-converts every request and response between Anthropic and
//! OpenAI shapes, dropping `thinking`, `redacted_thinking`,
//! `document`, `search_result`, `image`, `server_tool_use`, and
//! `mcp_tool_use` blocks (P4-37). It also hardcodes
//! `stop_sequence: null` (§11.1 violation) and re-wraps
//! `function_call.arguments` as a parsed JSON object (§4.4 — P4-43).
//!
//! Phase D rebuilds the Bedrock surface natively in Rust. PR-D1
//! handles the **non-streaming** `POST /model/{model}/invoke` route:
//!
//! 1. Parse the Bedrock envelope (`{"anthropic_version": "...",
//! ...rest_of_anthropic_body}`).
//! 2. Route Anthropic-shape bodies through the live-zone compression
//! path (the same one `/v1/messages` uses).
//! 3. Re-emit the envelope with `anthropic_version` preserved as the
//! first key — Bedrock is strict about schema validation.
//! 4. Sign the **outgoing** body bytes with AWS SigV4 (after
//! compression) and forward to the configured Bedrock endpoint.
//!
//! # Cache safety
//!
//! The signed bytes are exactly the bytes Bedrock receives. If the
//! compressor mutated the body, the SigV4 signature is computed
//! against the post-compression bytes; the upstream verifier will
//! accept them. There is no "sign before compress" path — that would
//! produce a signature that doesn't match the wire payload.
//!
//! # Module layout
//!
//! - [`envelope`] — `BedrockEnvelope` parse + emit (preserves
//! `anthropic_version` ordering byte-equal).
//! - [`sigv4`] — AWS SigV4 signing helper. Wraps the `aws-sigv4`
//! crate with the project's no-fallback / structured-logging
//! policy.
//! - [`invoke`] — POST handler for `/model/{model}/invoke`.
//!
//! Streaming (`/model/{model}/invoke-with-response-stream`) is
//! Phase D PR-D2.
pub mod envelope;
pub mod invoke;
pub mod sigv4;
pub use envelope::{BedrockEnvelope, EnvelopeError};
pub use invoke::handle_invoke;
pub use sigv4::{sign_request, SigV4Error, SigningInputs};

View file

@ -0,0 +1,324 @@
//! AWS SigV4 request signing for the Bedrock InvokeModel route.
//!
//! # What this module does
//!
//! Wraps the `aws-sigv4` crate with the project's policies:
//!
//! - **No silent fallbacks.** A failed sign-attempt returns a
//! structured error; the handler surfaces 5xx and logs an event.
//! We NEVER forward an unsigned request to Bedrock, because the
//! AWS endpoint will reject anyway and the user would see an
//! opaque 403.
//! - **Body bytes are hashed AFTER compression.** The signing
//! inputs include a `&[u8]` that is the EXACT byte slice the
//! forwarder will send upstream. There is no separate "hash the
//! pre-compression body" code path.
//! - **Structured logs at every decision point.** Every code path
//! emits `tracing::info!`/`warn!` with an `event = ...` field so
//! operators can confirm signing happened.
//!
//! # What this module deliberately does NOT do
//!
//! - It does not resolve credentials — that's `aws-config`'s job and
//! happens at app-startup time so per-request signing is cheap.
//! The handler holds the resolved [`aws_credential_types::Credentials`]
//! in `AppState` and passes them in.
//! - It does not buffer the body — callers buffer the body in the
//! compression gate (it's the same byte slice).
//! - It does not handle SigV4a (the cross-region variant). Bedrock
//! uses standard SigV4 per region.
use std::time::SystemTime;
use aws_credential_types::Credentials;
use aws_sigv4::http_request::{
sign, PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings,
};
use aws_sigv4::sign::v4;
use aws_smithy_runtime_api::client::identity::Identity;
use thiserror::Error;
use url::Url;
/// AWS service name used in the SigV4 string-to-sign for Bedrock.
/// Documented at
/// <https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam.html>
pub const BEDROCK_SERVICE_NAME: &str = "bedrock";
/// Inputs needed to sign a Bedrock request. Borrows the body so we
/// avoid a copy on the hot path.
#[derive(Debug)]
pub struct SigningInputs<'a> {
/// HTTP method (always `"POST"` for InvokeModel today, but we
/// keep this explicit so a future GET-shaped surface doesn't
/// require redoing the call site).
pub method: &'a str,
/// Fully-qualified upstream URL (scheme + host + path + query).
/// Used for canonical-request URI normalization.
pub url: &'a Url,
/// AWS region the upstream endpoint lives in.
pub region: &'a str,
/// AWS credentials (resolved from the `aws-config` default chain
/// at app-startup time).
pub credentials: &'a Credentials,
/// Body bytes to sign. MUST be the exact bytes the forwarder
/// will send to Bedrock (post-compression).
pub body: &'a [u8],
/// Extra headers the canonical request must include in the
/// signed-headers list. The signer always includes `host`,
/// `x-amz-date`, and `x-amz-content-sha256`; callers add
/// anything else they want covered (e.g. `accept-encoding`,
/// `content-type`).
pub extra_signed_headers: &'a [(&'a str, &'a str)],
/// Time to use in the signature. Production uses
/// `SystemTime::now()`; tests pin a known time to make the
/// canonical request deterministic.
pub time: SystemTime,
}
/// Headers that the signer will write into the outbound request.
/// The handler must add every entry to the upstream-bound HeaderMap
/// before sending — Bedrock validates each header against the
/// canonical request.
#[derive(Debug, Clone)]
pub struct SignedHeaders {
pub entries: Vec<(String, String)>,
/// Lowercase hex SHA-256 of the body. Surfaced for tests + logs.
pub signature: String,
}
/// Errors surfaced by the signing path.
#[derive(Debug, Error)]
pub enum SigV4Error {
/// `aws-sigv4` rejected the request (URL parse, malformed header,
/// etc).
#[error("sigv4 signing failed: {0}")]
Sign(String),
/// The signing-params builder rejected the inputs (e.g. missing
/// region — should never happen because we validate at startup).
#[error("sigv4 builder error: {0}")]
Builder(String),
}
/// Sign a Bedrock request and return the headers the handler must add
/// to the outbound request.
///
/// # Cache safety
///
/// The body bytes passed in MUST be the bytes the proxy is about to
/// send upstream. If the compressor mutated the body, those mutated
/// bytes are what get signed — Bedrock will accept because the
/// signature covers the wire payload, not the original.
///
/// # Errors
///
/// Returns [`SigV4Error::Sign`] when `aws-sigv4` rejects the request
/// (malformed URL, etc). Returns [`SigV4Error::Builder`] when the
/// signing-params builder rejects the inputs.
pub fn sign_request(inputs: &SigningInputs<'_>) -> Result<SignedHeaders, SigV4Error> {
// Build the identity wrapper around the credentials. Identity is
// the type the SigV4 signer accepts; it can in principle hold
// alternative auth schemes (Bearer, etc) but we only ever use it
// for AWS creds.
let identity: Identity = Identity::new(inputs.credentials.clone(), None);
// Default settings + force `x-amz-content-sha256` into the
// canonical request. Bedrock validates the content hash to
// catch any in-flight body mutation; with `NoHeader` (the
// crate-level default) the signer would skip the header,
// which means a downstream gateway that DOES check it would
// 403.
let mut settings = SigningSettings::default();
settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
let signing_params = v4::SigningParams::builder()
.identity(&identity)
.region(inputs.region)
.name(BEDROCK_SERVICE_NAME)
.time(inputs.time)
.settings(settings)
.build()
.map_err(|e| SigV4Error::Builder(e.to_string()))?
.into();
// The signer needs the URL as a string; Url's Display impl is
// canonical RFC 3986 form which is what aws-sigv4 expects.
let url_string = inputs.url.to_string();
let signable = SignableRequest::new(
inputs.method,
url_string,
inputs.extra_signed_headers.iter().copied(),
SignableBody::Bytes(inputs.body),
)
.map_err(|e| SigV4Error::Sign(e.to_string()))?;
let signing_output =
sign(signable, &signing_params).map_err(|e| SigV4Error::Sign(e.to_string()))?;
let signature = signing_output.signature().to_string();
let (instructions, _signature) = signing_output.into_parts();
let (header_entries, _query_params) = instructions.into_parts();
let entries = header_entries
.into_iter()
.map(|h| (h.name().to_string(), h.value().to_string()))
.collect::<Vec<_>>();
tracing::info!(
event = "sigv4_signed",
forwarder = "rust_proxy",
region = inputs.region,
service = BEDROCK_SERVICE_NAME,
method = inputs.method,
host = inputs.url.host_str().unwrap_or(""),
body_bytes = inputs.body.len(),
headers_added = entries.len(),
"bedrock request signed with sigv4"
);
Ok(SignedHeaders { entries, signature })
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn fixed_time() -> SystemTime {
// 2026-05-03T12:00:00Z — pinned so canonical request is
// deterministic across runs.
SystemTime::UNIX_EPOCH + Duration::from_secs(1_777_910_400)
}
fn fixture_credentials() -> Credentials {
Credentials::new("AKIA_TEST", "secret_test", None, None, "test")
}
#[test]
fn signs_minimal_request_produces_expected_headers() {
let url = Url::parse(
"https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke",
)
.unwrap();
let creds = fixture_credentials();
let body = br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":16,"messages":[]}"#;
let inputs = SigningInputs {
method: "POST",
url: &url,
region: "us-east-1",
credentials: &creds,
body,
extra_signed_headers: &[("content-type", "application/json")],
time: fixed_time(),
};
let signed = sign_request(&inputs).expect("sigv4 sign");
// The signer always emits `authorization`, `x-amz-date`, and
// `x-amz-content-sha256`. Confirm all three are present.
let names: Vec<String> = signed
.entries
.iter()
.map(|(k, _)| k.to_ascii_lowercase())
.collect();
assert!(
names.iter().any(|n| n == "authorization"),
"must add authorization; got {names:?}"
);
assert!(
names.iter().any(|n| n == "x-amz-date"),
"must add x-amz-date"
);
assert!(
names.iter().any(|n| n == "x-amz-content-sha256"),
"must add x-amz-content-sha256"
);
assert_eq!(signed.signature.len(), 64, "sigv4 signature is 32-byte hex");
}
#[test]
fn signature_is_deterministic_for_fixed_inputs() {
let url = Url::parse(
"https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke",
)
.unwrap();
let creds = fixture_credentials();
let body = br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":16}"#;
let mk = || SigningInputs {
method: "POST",
url: &url,
region: "us-east-1",
credentials: &creds,
body,
extra_signed_headers: &[("content-type", "application/json")],
time: fixed_time(),
};
let a = sign_request(&mk()).unwrap();
let b = sign_request(&mk()).unwrap();
assert_eq!(a.signature, b.signature);
}
#[test]
fn changing_body_changes_signature() {
let url = Url::parse(
"https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke",
)
.unwrap();
let creds = fixture_credentials();
let inputs_a = SigningInputs {
method: "POST",
url: &url,
region: "us-east-1",
credentials: &creds,
body: br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":16}"#,
extra_signed_headers: &[("content-type", "application/json")],
time: fixed_time(),
};
let inputs_b = SigningInputs {
body: br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":32}"#,
..SigningInputs {
method: "POST",
url: &url,
region: "us-east-1",
credentials: &creds,
body: &[],
extra_signed_headers: &[("content-type", "application/json")],
time: fixed_time(),
}
};
let a = sign_request(&inputs_a).unwrap();
let b = sign_request(&inputs_b).unwrap();
assert_ne!(
a.signature, b.signature,
"different body bytes must yield different signatures"
);
}
#[test]
fn changing_region_changes_signature() {
let url = Url::parse(
"https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/invoke",
)
.unwrap();
let creds = fixture_credentials();
let body = br#"{"anthropic_version":"bedrock-2023-05-31"}"#;
let east = SigningInputs {
method: "POST",
url: &url,
region: "us-east-1",
credentials: &creds,
body,
extra_signed_headers: &[],
time: fixed_time(),
};
let west = SigningInputs {
method: "POST",
url: &url,
region: "us-west-2",
credentials: &creds,
body,
extra_signed_headers: &[],
time: fixed_time(),
};
let a = sign_request(&east).unwrap();
let b = sign_request(&west).unwrap();
assert_ne!(a.signature, b.signature);
}
}

View file

@ -296,6 +296,61 @@ pub struct CliArgs {
action = clap::ArgAction::Set,
)]
pub enable_conversations_passthrough: bool,
/// Phase D PR-D1: enable the native Bedrock InvokeModel route.
/// When `true` (default), `POST /model/{model_id}/invoke` is
/// handled by the Rust `bedrock::invoke` handler — Anthropic-shape
/// bodies run through the live-zone compression path and the
/// proxy re-signs the request with SigV4 before forwarding to
/// the configured Bedrock endpoint. When `false`, the routes are
/// not mounted and requests fall through to the catch-all
/// (which forwards to `--upstream` byte-equal but does NOT
/// re-sign — operators MUST run an unsigned upstream that
/// happens to know what to do, otherwise this fails closed).
///
/// Source priority: CLI flag → `HEADROOM_PROXY_ENABLE_BEDROCK_NATIVE`
/// env var → default (`true`).
#[arg(
long = "enable-bedrock-native",
env = "HEADROOM_PROXY_ENABLE_BEDROCK_NATIVE",
default_value_t = true,
action = clap::ArgAction::Set,
)]
pub enable_bedrock_native: bool,
/// AWS region to use when signing Bedrock requests. Default
/// `us-east-1`. The Bedrock endpoint URL derived from this
/// region is `https://bedrock-runtime.{region}.amazonaws.com`
/// (override via `--bedrock-endpoint` for FIPS or VPC endpoints).
///
/// Source priority: CLI flag → `HEADROOM_PROXY_BEDROCK_REGION`
/// env var → `AWS_REGION` env var → default (`us-east-1`).
#[arg(
long = "bedrock-region",
env = "HEADROOM_PROXY_BEDROCK_REGION",
default_value = "us-east-1"
)]
pub bedrock_region: String,
/// Bedrock endpoint base URL. When unset (the common case), the
/// proxy derives `https://bedrock-runtime.{bedrock_region}.amazonaws.com`
/// from the configured region. Override for FIPS endpoints
/// (`bedrock-runtime-fips.{region}.amazonaws.com`), VPC endpoints,
/// or local-mock test setups.
///
/// Source priority: CLI flag → `HEADROOM_PROXY_BEDROCK_ENDPOINT`
/// env var → derived-from-region.
#[arg(long = "bedrock-endpoint", env = "HEADROOM_PROXY_BEDROCK_ENDPOINT")]
pub bedrock_endpoint: Option<Url>,
/// AWS profile name passed to the `aws-config` default credential
/// chain. When unset, the chain uses the default behaviour
/// (env vars → `[default]` profile → IMDS / ECS task role).
///
/// Source priority: CLI flag → `HEADROOM_PROXY_AWS_PROFILE`
/// env var → `AWS_PROFILE` env var → default chain.
#[arg(long = "aws-profile", env = "HEADROOM_PROXY_AWS_PROFILE")]
pub aws_profile: Option<String>,
}
fn parse_duration(s: &str) -> Result<Duration, String> {
@ -350,6 +405,20 @@ pub struct Config {
/// NOT gate compression of conversation items (that's
/// C5+/B-phase territory).
pub enable_conversations_passthrough: bool,
/// PR-D1: enable the native Bedrock InvokeModel route. Default
/// `true`. When disabled, the explicit Rust handlers are not
/// mounted; operators relying on the Python LiteLLM converter
/// keep their existing path.
pub enable_bedrock_native: bool,
/// PR-D1: AWS region used to sign Bedrock requests + (when no
/// explicit endpoint is set) derive the Bedrock endpoint URL.
pub bedrock_region: String,
/// PR-D1: Bedrock endpoint base URL. `None` means
/// "derive from region" (`https://bedrock-runtime.{region}.amazonaws.com`).
pub bedrock_endpoint: Option<Url>,
/// PR-D1: optional AWS profile name. When `None`, the default
/// credential chain (env → `[default]` profile → IMDS) is used.
pub aws_profile: Option<String>,
}
impl Config {
@ -378,6 +447,10 @@ impl Config {
strip_internal_headers: args.strip_internal_headers,
enable_responses_streaming: args.enable_responses_streaming,
enable_conversations_passthrough: args.enable_conversations_passthrough,
enable_bedrock_native: args.enable_bedrock_native,
bedrock_region: args.bedrock_region,
bedrock_endpoint: args.bedrock_endpoint,
aws_profile: args.aws_profile,
}
}
@ -408,6 +481,14 @@ impl Config {
// production traffic will hit.
enable_responses_streaming: true,
enable_conversations_passthrough: true,
// PR-D1: bedrock route default-on so tests exercise
// it without per-test opt-in. Tests that set
// `bedrock_endpoint` to a wiremock URL get the full
// sign-and-forward path.
enable_bedrock_native: true,
bedrock_region: "us-east-1".to_string(),
bedrock_endpoint: None,
aws_profile: None,
}
}
}

View file

@ -1,6 +1,7 @@
//! headroom-proxy library: transparent reverse proxy in front of the Python
//! Headroom proxy. Used by both `main.rs` and the integration tests.
pub mod bedrock;
pub mod compression;
pub mod config;
pub mod error;

View file

@ -31,7 +31,37 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
"headroom-proxy starting"
);
let state = AppState::new(config.clone())?;
let mut state = AppState::new(config.clone())?;
// PR-D1: resolve AWS credentials at startup via the `aws-config`
// default chain. Loaded once so per-request signing is cheap.
// Failure is NOT fatal — the proxy may run in front of a non-AWS
// upstream — but the Bedrock invoke handler refuses to forward
// unsigned requests when `bedrock_credentials` is `None`
// (see `bedrock::invoke::handle_invoke`).
if config.enable_bedrock_native {
match load_bedrock_credentials(&config).await {
Ok(creds) => {
state = state.with_bedrock_credentials(creds);
tracing::info!(
event = "bedrock_credentials_loaded",
region = %config.bedrock_region,
profile = ?config.aws_profile,
"AWS credentials resolved for Bedrock SigV4 signing"
);
}
Err(e) => {
tracing::warn!(
event = "bedrock_credentials_unavailable",
region = %config.bedrock_region,
profile = ?config.aws_profile,
error = %e,
"AWS credentials not available at startup; Bedrock invoke will 5xx until creds are configured"
);
}
}
}
let app = build_app(state).into_make_service_with_connect_info::<SocketAddr>();
let listener = tokio::net::TcpListener::bind(config.listen).await?;
@ -64,6 +94,31 @@ fn init_tracing(level: &str) {
.try_init();
}
/// PR-D1: resolve AWS credentials for Bedrock SigV4 signing.
///
/// Uses the `aws-config` default chain (env vars → shared profile
/// file → IMDS / ECS task role). Honours `Config::aws_profile` when
/// set; otherwise the chain picks up `AWS_PROFILE` from the
/// environment automatically.
async fn load_bedrock_credentials(
config: &Config,
) -> Result<aws_credential_types::Credentials, Box<dyn std::error::Error + Send + Sync>> {
use aws_config::BehaviorVersion;
use aws_credential_types::provider::ProvideCredentials;
let mut loader = aws_config::defaults(BehaviorVersion::latest())
.region(aws_config::Region::new(config.bedrock_region.clone()));
if let Some(profile) = config.aws_profile.as_deref() {
loader = loader.profile_name(profile);
}
let aws_config = loader.load().await;
let creds_provider = aws_config
.credentials_provider()
.ok_or("no credentials provider configured")?;
let creds = creds_provider.provide_credentials().await?;
Ok(creds)
}
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;

View file

@ -34,6 +34,14 @@ use crate::websocket::ws_handler;
pub struct AppState {
pub config: Arc<Config>,
pub client: reqwest::Client,
/// PR-D1: AWS credentials resolved at startup via the
/// `aws-config` default chain. `None` when the proxy boots
/// without AWS creds available (operator running locally
/// against a non-Bedrock upstream); the Bedrock invoke handler
/// returns 5xx with a structured `event=bedrock_credentials_missing`
/// log so failures are LOUD — no silent fallback to unsigned
/// requests.
pub bedrock_credentials: Option<Arc<aws_credential_types::Credentials>>,
}
impl AppState {
@ -52,8 +60,19 @@ impl AppState {
Ok(Self {
config: Arc::new(config),
client,
bedrock_credentials: None,
})
}
/// PR-D1: attach AWS credentials resolved out-of-band (via
/// `aws-config`'s default chain at startup). Returns the
/// modified state; intended to be chained off `AppState::new`.
/// Tests that don't exercise the Bedrock route can leave
/// credentials unset (the catch-all paths never read them).
pub fn with_bedrock_credentials(mut self, creds: aws_credential_types::Credentials) -> Self {
self.bedrock_credentials = Some(Arc::new(creds));
self
}
}
/// Build the axum app. `/healthz` and `/healthz/upstream` are intercepted;
@ -84,6 +103,32 @@ pub fn build_app(state: AppState) -> Router {
post(crate::handlers::responses::handle_responses),
);
// PR-D1: native AWS Bedrock InvokeModel route. Mounts only when
// `enable_bedrock_native` is on (default). The handler runs the
// live-zone compressor over Anthropic-shape bodies, signs with
// SigV4, and forwards to the configured Bedrock endpoint. The
// `/converse` route mounts the same handler — the wire shape is
// identical for `anthropic.claude-*` model IDs (Bedrock just
// accepts both legacy `invoke` and modern `converse` paths).
if state.config.enable_bedrock_native {
router = router
.route(
"/model/:model_id/invoke",
post(crate::bedrock::invoke::handle_invoke),
)
.route(
"/model/:model_id/converse",
post(crate::bedrock::invoke::handle_invoke),
);
} else {
tracing::warn!(
event = "bedrock_native_disabled",
"Bedrock native InvokeModel route disabled by \
--enable-bedrock-native=false; Bedrock requests will fall \
through to the catch-all (no SigV4 re-signing fails closed)"
);
}
// PR-C4: Conversations API (passthrough-with-instrumentation).
// The flag is read once at app-build time so router shape
// matches the configured policy. When disabled, requests still

View file

@ -44,11 +44,28 @@ pub async fn start_proxy(upstream: &str) -> ProxyHandle {
pub async fn start_proxy_with<F>(upstream: &str, customize: F) -> ProxyHandle
where
F: FnOnce(&mut Config),
{
start_proxy_with_state(upstream, customize, |s| s).await
}
/// Start a proxy with both a Config customizer and an AppState
/// post-processor. PR-D1: tests that exercise the Bedrock route
/// inject credentials via `with_bedrock_credentials` here.
#[allow(dead_code)]
pub async fn start_proxy_with_state<F, G>(
upstream: &str,
customize: F,
customize_state: G,
) -> ProxyHandle
where
F: FnOnce(&mut Config),
G: FnOnce(AppState) -> AppState,
{
let upstream_url: Url = upstream.parse().expect("valid upstream url");
let mut config = Config::for_test(upstream_url);
customize(&mut config);
let state = AppState::new(config.clone()).expect("app state");
let state = customize_state(state);
let app = build_app(state).into_make_service_with_connect_info::<SocketAddr>();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await

View file

@ -0,0 +1,563 @@
//! Integration tests for the native Bedrock InvokeModel route
//! (Phase D PR-D1).
//!
//! These tests boot the real Rust proxy in front of a wiremock
//! upstream that pretends to be the Bedrock runtime endpoint
//! (`https://bedrock-runtime.{region}.amazonaws.com`). The proxy is
//! configured with `bedrock_endpoint = wiremock_url` so SigV4-signed
//! requests are routed to the mock instead of real AWS — no live
//! AWS dependency.
//!
//! Coverage matrix (per PR-D1 spec, REALIGNMENT/06-phase-D-bedrock-vertex.md):
//!
//! 1. `native_envelope_round_trip_byte_equal` — small body,
//! compression-mode off; bytes round-trip byte-equal upstream.
//! 2. `sigv4_signed_correctly_after_compression` — confirms the
//! `authorization` header arrives at upstream and the
//! `x-amz-content-sha256` matches the (post-compression) body.
//! 3. `thinking_block_preserved_through_bedrock` — Anthropic
//! `thinking` block round-trips byte-equal with compression off.
//! Validates the live-zone dispatcher doesn't strip the block.
//! 4. `redacted_thinking_preserved` — `redacted_thinking` block
//! round-trips byte-equal.
//! 5. `document_block_preserved` — `document` block round-trips.
//! 6. `tool_result_array_with_image_preserved` — `tool_result` content
//! array containing a base64 `image` block round-trips byte-equal
//! when compression is off.
//! 7. `stop_sequence_null_only_when_present` — mock the upstream to
//! return a Bedrock-shape response that does NOT include
//! `stop_sequence`; the proxy must not inject a `null` value for
//! it. (This is an end-to-end check that Phase D doesn't regress
//! P4-37's hardcoded null.)
//! 8. `tool_use_input_byte_equal_preserves_key_order` — `tool_use.input`
//! object keys must arrive in the same order they were sent
//! (the `serde_json::preserve_order` feature backs this).
mod common;
use aws_credential_types::Credentials;
use common::start_proxy_with_state;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
use url::Url;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// What we capture from each upstream request — body + the
/// authorization-shaped headers we care about.
#[derive(Default, Clone, Debug)]
struct CapturedRequest {
body: Option<Vec<u8>>,
authorization: Option<String>,
x_amz_date: Option<String>,
x_amz_content_sha256: Option<String>,
host: Option<String>,
content_type: Option<String>,
}
type Capture = Arc<Mutex<CapturedRequest>>;
const TEST_MODEL: &str = "anthropic.claude-3-haiku-20240307-v1:0";
async fn mount_capture_invoke(upstream: &MockServer, response_body: &str) -> Capture {
let captured: Capture = Arc::new(Mutex::new(CapturedRequest::default()));
let captured_clone = captured.clone();
let response_body = response_body.to_string();
let model = TEST_MODEL.to_string();
Mock::given(method("POST"))
.and(path(format!("/model/{model}/invoke")))
.respond_with(move |req: &wiremock::Request| {
let mut c = captured_clone.lock().unwrap();
c.body = Some(req.body.clone());
c.authorization = req
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
c.x_amz_date = req
.headers
.get("x-amz-date")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
c.x_amz_content_sha256 = req
.headers
.get("x-amz-content-sha256")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
c.host = req
.headers
.get("host")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
c.content_type = req
.headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
ResponseTemplate::new(200).set_body_string(response_body.clone())
})
.mount(upstream)
.await;
captured
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hasher
.finalize()
.iter()
.fold(String::with_capacity(64), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "{b:02x}");
acc
})
}
#[track_caller]
fn assert_byte_equal_sha256(inbound: &[u8], received: &[u8]) {
let inbound_hash = sha256_hex(inbound);
let received_hash = sha256_hex(received);
assert_eq!(
inbound.len(),
received.len(),
"byte length mismatch: inbound={}, upstream-received={}",
inbound.len(),
received.len(),
);
assert_eq!(
inbound_hash, received_hash,
"SHA-256 mismatch: inbound={inbound_hash}, upstream-received={received_hash}",
);
}
fn test_credentials() -> Credentials {
Credentials::new(
"AKIAEXAMPLEAKIDFORTEST",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
None,
None,
"test",
)
}
/// Boot a proxy pointed at the wiremock upstream as the Bedrock
/// endpoint. The fake-upstream URL goes into `bedrock_endpoint`;
/// the regular `upstream` field is set to a sentinel because the
/// Bedrock route bypasses `forward_http`.
async fn bedrock_proxy(
upstream: &MockServer,
customize: impl FnOnce(&mut headroom_proxy::Config),
) -> common::ProxyHandle {
let endpoint: Url = upstream.uri().parse().unwrap();
start_proxy_with_state(
&upstream.uri(),
|c| {
c.bedrock_endpoint = Some(endpoint);
customize(c);
},
|s| s.with_bedrock_credentials(test_credentials()),
)
.await
}
#[tokio::test]
async fn native_envelope_round_trip_byte_equal() {
// Compression off → body must arrive byte-equal at upstream.
let upstream = MockServer::start().await;
let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await;
let proxy = bedrock_proxy(&upstream, |c| {
c.compression = true;
c.compression_mode = headroom_proxy::config::CompressionMode::Off;
})
.await;
let payload = json!({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 64,
"messages": [
{"role": "user", "content": "hi"}
]
});
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
.post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone();
assert_byte_equal_sha256(&body, got.body.as_deref().unwrap());
proxy.shutdown().await;
}
#[tokio::test]
async fn sigv4_signed_correctly_after_compression() {
// The signature must cover the bytes that actually hit upstream.
// We confirm: (a) `authorization` is present, (b)
// `x-amz-content-sha256` matches sha256(body received by upstream).
let upstream = MockServer::start().await;
let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await;
let proxy = bedrock_proxy(&upstream, |c| {
c.compression = true;
c.compression_mode = headroom_proxy::config::CompressionMode::LiveZone;
})
.await;
let payload = json!({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 64,
"messages": [{"role": "user", "content": "hi"}]
});
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
.post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url()))
.header("content-type", "application/json")
.body(body)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone();
let auth = got.authorization.expect("authorization header present");
assert!(
auth.starts_with("AWS4-HMAC-SHA256 "),
"authorization must be SigV4-shape; got {auth}"
);
assert!(
auth.contains("Credential=AKIAEXAMPLEAKIDFORTEST/"),
"authorization must reference the test access key id; got {auth}"
);
assert!(
auth.contains("/bedrock/aws4_request"),
"authorization scope must reference the bedrock service; got {auth}"
);
assert!(got.x_amz_date.is_some(), "x-amz-date must be present");
let body_received = got.body.expect("upstream got body");
let expected_sha = sha256_hex(&body_received);
assert_eq!(
got.x_amz_content_sha256.as_deref(),
Some(expected_sha.as_str()),
"x-amz-content-sha256 must match sha256 of bytes the upstream received"
);
proxy.shutdown().await;
}
#[tokio::test]
async fn thinking_block_preserved_through_bedrock() {
// Anthropic `thinking` block: cache hot zone item. With
// compression OFF, body round-trips byte-equal upstream. (The
// dispatcher only mutates the live zone — the latest user
// message — so even with compression on, an assistant `thinking`
// block is left alone. We pin the byte-equal contract to the
// off-mode path because that's what the litellm Python shim
// would have lost — P4-37 evidence.)
let upstream = MockServer::start().await;
let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await;
let proxy = bedrock_proxy(&upstream, |c| {
c.compression_mode = headroom_proxy::config::CompressionMode::Off;
})
.await;
let payload = json!({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "What's 2+2?"},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "The user is asking a basic arithmetic question. 2+2=4.",
"signature": "EpYBCkYIBRgCKkAhello_world_signature_payload="
},
{"type": "text", "text": "4"}
]
}
]
});
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
.post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone();
assert_byte_equal_sha256(&body, got.body.as_deref().unwrap());
let parsed: Value = serde_json::from_slice(got.body.as_deref().unwrap()).unwrap();
assert_eq!(parsed["messages"][1]["content"][0]["type"], "thinking");
assert_eq!(
parsed["messages"][1]["content"][0]["signature"],
"EpYBCkYIBRgCKkAhello_world_signature_payload="
);
proxy.shutdown().await;
}
#[tokio::test]
async fn redacted_thinking_preserved() {
// `redacted_thinking` blocks: opaque encrypted payloads from
// the model. Must round-trip BYTE-EQUAL — the proxy never
// inspects them.
let upstream = MockServer::start().await;
let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await;
let proxy = bedrock_proxy(&upstream, |c| {
c.compression_mode = headroom_proxy::config::CompressionMode::Off;
})
.await;
let payload = json!({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "Hi"},
{
"role": "assistant",
"content": [
{
"type": "redacted_thinking",
"data": "EuYBCogBAaR_o9XJEnEx_3Q9d5z9_redacted_payload"
},
{"type": "text", "text": "Hello!"}
]
}
]
});
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
.post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone();
assert_byte_equal_sha256(&body, got.body.as_deref().unwrap());
let parsed: Value = serde_json::from_slice(got.body.as_deref().unwrap()).unwrap();
assert_eq!(
parsed["messages"][1]["content"][0]["type"],
"redacted_thinking"
);
assert_eq!(
parsed["messages"][1]["content"][0]["data"],
"EuYBCogBAaR_o9XJEnEx_3Q9d5z9_redacted_payload"
);
proxy.shutdown().await;
}
#[tokio::test]
async fn document_block_preserved() {
// `document` block (PDF or text-document attachment). Has nested
// `source.media_type` + `source.data` (base64). The litellm
// Python shim drops these silently; the Rust route must NOT.
let upstream = MockServer::start().await;
let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await;
let proxy = bedrock_proxy(&upstream, |c| {
c.compression_mode = headroom_proxy::config::CompressionMode::Off;
})
.await;
let payload = json!({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 256,
"messages": [{
"role": "user",
"content": [
{
"type": "document",
"source": {
"type": "base64",
"media_type": "application/pdf",
"data": "JVBERi0xLjQKJfbk/N8KMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwo+PgplbmRvYmoK"
},
"title": "Quarterly Report",
"context": "Q4 2025 financial summary"
},
{"type": "text", "text": "Summarize."}
]
}]
});
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
.post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone();
assert_byte_equal_sha256(&body, got.body.as_deref().unwrap());
let parsed: Value = serde_json::from_slice(got.body.as_deref().unwrap()).unwrap();
let doc = &parsed["messages"][0]["content"][0];
assert_eq!(doc["type"], "document");
assert_eq!(doc["source"]["media_type"], "application/pdf");
assert_eq!(doc["title"], "Quarterly Report");
proxy.shutdown().await;
}
#[tokio::test]
async fn tool_result_array_with_image_preserved() {
// `tool_result.content` is an array; one element is an `image`
// block with base64 source. The litellm shim flattened these
// to a single string (P4-37). The Rust route must keep the
// array shape verbatim.
let upstream = MockServer::start().await;
let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await;
let proxy = bedrock_proxy(&upstream, |c| {
c.compression_mode = headroom_proxy::config::CompressionMode::Off;
})
.await;
let payload = json!({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 256,
"messages": [
{"role": "user", "content": "Take a screenshot"},
{
"role": "assistant",
"content": [{
"type": "tool_use",
"id": "toolu_xyz",
"name": "screenshot",
"input": {"region": "full"}
}]
},
{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "toolu_xyz",
"content": [
{"type": "text", "text": "Captured at 2026-05-03T12:00:00Z"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP8z8DwHwAFAQH/9zJEHwAAAABJRU5ErkJggg=="
}
}
]
}]
}
]
});
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
.post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url()))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone();
assert_byte_equal_sha256(&body, got.body.as_deref().unwrap());
let parsed: Value = serde_json::from_slice(got.body.as_deref().unwrap()).unwrap();
let tool_result = &parsed["messages"][2]["content"][0];
assert_eq!(tool_result["type"], "tool_result");
let inner = &tool_result["content"];
assert!(inner.is_array(), "tool_result.content must remain an array");
assert_eq!(inner[1]["type"], "image");
assert_eq!(inner[1]["source"]["media_type"], "image/png");
proxy.shutdown().await;
}
#[tokio::test]
async fn stop_sequence_null_only_when_present() {
// Synthesise a Bedrock-shape response that does NOT include
// `stop_sequence` and confirm the proxy doesn't add `null` for
// it. P4-37: the litellm Python shim hardcoded `stop_sequence:
// null` in the converted response shape; the Rust path must not
// do that — Bedrock's response is forwarded verbatim.
let upstream = MockServer::start().await;
let response_no_stop_sequence = r#"{"id":"msg_a","type":"message","role":"assistant","model":"claude-3-haiku-20240307","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":3,"output_tokens":1}}"#;
let _captured = mount_capture_invoke(&upstream, response_no_stop_sequence).await;
let proxy = bedrock_proxy(&upstream, |c| {
c.compression_mode = headroom_proxy::config::CompressionMode::Off;
})
.await;
let payload = json!({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 16,
"messages": [{"role": "user", "content": "hi"}]
});
let body = serde_json::to_vec(&payload).unwrap();
let resp = reqwest::Client::new()
.post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url()))
.header("content-type", "application/json")
.body(body)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let resp_text = resp.text().await.unwrap();
let resp_parsed: Value = serde_json::from_str(&resp_text).unwrap();
assert!(
resp_parsed.get("stop_sequence").is_none(),
"stop_sequence must NOT be present on the response when upstream omitted it; got {resp_text}"
);
assert_eq!(resp_parsed["stop_reason"], "end_turn");
proxy.shutdown().await;
}
#[tokio::test]
async fn tool_use_input_byte_equal_preserves_key_order() {
// `tool_use.input` is a JSON object whose key order must
// round-trip exactly. P4-43: the litellm shim parsed
// `function.arguments` into a dict and re-stringified, breaking
// key order. The Rust path uses serde_json's preserve_order
// feature throughout — confirm the bytes arrive byte-equal.
let upstream = MockServer::start().await;
let captured = mount_capture_invoke(&upstream, r#"{"id":"msg_x","content":[]}"#).await;
let proxy = bedrock_proxy(&upstream, |c| {
c.compression_mode = headroom_proxy::config::CompressionMode::Off;
})
.await;
// Hand-craft the body bytes so we can pin exact key order.
// BTreeMap-default serialization would alphabetize the keys
// (city < country < units); we send the opposite so any
// accidental re-encode shows up as a byte mismatch.
let body = br#"{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_zoom","name":"get_weather","input":{"units":"metric","country":"FR","city":"Paris"}}]}]}"#;
let resp = reqwest::Client::new()
.post(format!("{}/model/{TEST_MODEL}/invoke", proxy.url()))
.header("content-type", "application/json")
.body(body.to_vec())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let got = captured.lock().unwrap().clone();
let received = got.body.expect("upstream got body");
assert_byte_equal_sha256(body, &received);
// Defensive: sanity-check the input key order by looking at the
// raw substring.
let received_str = std::str::from_utf8(&received).unwrap();
let units_pos = received_str.find("\"units\"").expect("units present");
let country_pos = received_str.find("\"country\"").expect("country present");
let city_pos = received_str.find("\"city\"").expect("city present");
assert!(
units_pos < country_pos && country_pos < city_pos,
"tool_use.input key order must be units→country→city; got: {received_str}"
);
proxy.shutdown().await;
}