## Description The native Bedrock path (Phase D) compresses + signs Anthropic-on-Bedrock requests, but two real-world cases slipped through, and the native binary that powers it was never shipped. This PR closes those gaps as a focused set of give-backs. Aligns with the Rust migration plan (see below). ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - **Cross-region inference-profile detection** via a new `bedrock::vendor` module (`canonical_vendor()`), following the design proposed in #953: strip a known geo prefix (`eu.`/`us.`/`apac.`/`global.`) then match the canonical vendor. Geo-prefixed Anthropic profiles (`eu.anthropic.…`) now get live-zone compression instead of being silently skipped; geo-prefixed non-Anthropic vendors stay correctly excluded. - **Converse-body compression (two parts)**: 1. `run_anthropic_compression` no longer bails to passthrough when the body lacks an InvokeModel `anthropic_version` envelope; envelope re-emit stays gated on successful parse. 2. The **live-zone dispatcher now recognizes Bedrock Converse content blocks**. Converse blocks carry no `type` discriminator (the variant is the key: `{"text": …}` vs Anthropic's `{"type":"text","text":…}`), so real Converse user-message text was still passing through uncompressed. A typeless block whose `text` is a JSON string now routes through the same surgical text path. Anthropic blocks always carry `type`, so the Anthropic path is byte-for-byte unchanged; non-text Converse blocks (`{"image":…}`, `{"toolUse":…}`) stay unrecognized and no-op. - **Correct `/converse` upstream routing**: the non-streaming handler resolved the upstream action from a hard-coded `"invoke"`, so `/converse` requests were forwarded to Bedrock's `/invoke` endpoint. It now resolves the action from the inbound path (`extract_invoke_action`), mirroring the streaming handler's `extract_streaming_action`. SigV4 signs the same URL it forwards, so the signature stays consistent. - **`aws-config` `sso` feature**: SSO profiles now resolve through the default credential chain for SigV4 — the credential chain in `docs/bedrock.md` already promised SSO; this makes the code match. - **Ship the `headroom-proxy` binary in published images** (`Dockerfile`): built in the builder stage (`--locked`, with the cargo registry cache mounted at `CARGO_HOME`) and copied into both the debian and distroless runtime images. - **Docs** (`docs/bedrock.md`): document cross-region inference profiles and a "Running the proxy" section. AWS credentials mount at `/home/nonroot/.aws` (the default nonroot image home) where the SDK looks for `~/.aws`, with a note on the root-image alternative. ## Related issues - Closes #976 — ship the `headroom-proxy` binary in published images (this PR implements the exact fix proposed there). - Addresses the **cross-region inference-profile** half of #953 via its proposed `canonical_vendor()` design. Non-Anthropic vendor compression parity (Nova/GLM/MiniMax/ Kimi) is the natural follow-up — `bedrock::vendor` is the shared resolver it can build on. - Extends the native Bedrock InvokeModel compression requested in #734 (the Bedrock slice of #510) to cross-region profiles and Converse bodies. - Partially enables #181 (native, Python-free packaging): the native binary now ships in the images, though full Python-free distribution remains out of scope. ## Alignment with the Rust migration plan Per `docs/spec/022-rust-migration.md`, the migration is **proxy-first**: `headroom-proxy` is the deployable Rust artifact, native routes replace Python passthroughs one at a time (Stage 4 = provider expansion, Bedrock included), and the binary is meant to be "built, tested, and **released together with the Python package**." Two ways this PR advances that: - The binary-in-images change makes the codebase do what the spec already states (ship the artifact) — closing the gap that forced downstreams to build from source. - Hardening the native Bedrock route (cross-region, Converse routing + body compression) is exactly the Stage-4 provider-expansion work, keeping the native path at parity with real traffic so it can be the default rather than a passthrough. ## Testing - [x] Unit tests pass (`cargo test -p headroom-core -p headroom-proxy` — full suites, 0 failures) - [x] Linting passes (`cargo clippy -p headroom-core -p headroom-proxy --all-targets -- -D warnings`) - [x] Formatting passes (`cargo fmt -- --check`) - [x] New tests added — `bedrock::vendor` (foundation + inference-profile matching), `extract_invoke_action` + converse upstream URL, and live-zone Converse text-block routing (`block_has_string_text_field`, converse-vs-anthropic dispatch equivalence). - [x] Manual testing performed ### Test Output ```text $ cargo test -p headroom-core -p headroom-proxy # all suites: ok, 0 failed $ cargo clippy -p headroom-core -p headroom-proxy --all-targets -- -D warnings # Finished, no warnings $ cargo fmt -- --check # clean # image validation (local, proxy/code extras): $ docker build --target runtime ... # debian: /usr/local/bin/headroom-proxy, --help OK $ docker build --target runtime-slim ... # distroless: binary links + --help OK ``` ## Real Behavior Proof - Environment: native Bedrock proxy against `bedrock-runtime.eu-west-2`, SSO profile, model `eu.anthropic.claude-haiku-4-5-20251001-v1:0`. - Exact command / steps: POST a large multi-turn Converse body to `/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/converse`; separately build the `runtime` + `runtime-slim` targets and run `/usr/local/bin/headroom-proxy --help`. - Observed result: before — `bedrock_compression_skipped` (geo-prefixed id not recognized), forwarded uncompressed to the wrong `/invoke` upstream; after — geo-prefixed id recognized, `/converse` forwarded to the `/converse` upstream, live-zone dispatcher compresses the Converse user-message text, measurable token savings. Images contain a runnable `headroom-proxy` in both variants. - Not tested: non-Anthropic vendor compression parity (#953 follow-up); Converse `toolResult` nested-text compression (follow-up — only top-level Converse text blocks compress today). ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I have updated the CHANGELOG.md if applicable ## Additional Notes - An earlier revision flipped the EventStream `Accept` default (`*/*`/absent → passthrough); **dropped** — `*/*` is what most clients (incl. reqwest and the proxy's own metrics tests) send while expecting SSE, so forcing passthrough breaks the standard SSE path. - The binary build adds the native-proxy compile to the image build; happy to gate it behind a build arg if maintainers prefer it opt-in. - Addressed a Copilot review round: corrected the `/converse` upstream routing, the stale `run_anthropic_compression` comment, the Dockerfile cargo cache mount + `--locked`, and the nonroot AWS-credentials docs example.
13 KiB
AWS Bedrock — Operator Guide
Headroom's Rust proxy ships a native AWS Bedrock InvokeModel surface. After Phase D (PRs D1–D3), Anthropic-on-Bedrock requests are signed, compressed, and observed by the proxy directly — no LiteLLM Python shim on the request path.
This document covers how to deploy the Bedrock-native surface, how compression policy is applied, and how to read the Prometheus metrics the proxy exports.
What's in scope
| Capability | Status |
|---|---|
POST /model/{model}/invoke |
PR-D1 — native Rust handler |
POST /model/{model}/converse |
PR-D1 — same handler (Bedrock accepts both paths for the Anthropic envelope) |
POST /model/{model}/invoke-with-response-stream |
PR-D2 — binary EventStream parsed and translated to SSE |
| AWS SigV4 signing (post-compression) | PR-D1 |
AuthMode::OAuth classification |
PR-D3 — Bedrock IAM is OAuth-equivalent under the policy matrix |
| Per-model + per-region Prometheus metrics | PR-D3 — exposed at GET /metrics |
| OAuth compression policy gates (no auto cache_control, lossless-only) | Phase F PR-F2/F3 (gates the marker D3 wires) |
Running the proxy
The native surface lives in the headroom-proxy binary, which ships in the published
container images (every proxy-extra tag) at /usr/local/bin/headroom-proxy. You can run
it directly from any published image — no separate build:
docker run --rm -p 8787:8787 \
-v "$HOME/.aws:/home/nonroot/.aws:ro" \
-e HEADROOM_PROXY_AWS_PROFILE=my-profile \
--entrypoint headroom-proxy \
ghcr.io/chopratejas/headroom:latest \
--listen 0.0.0.0:8787 \
--upstream https://bedrock-runtime.us-east-1.amazonaws.com \
--bedrock-region us-east-1
The published images default to the nonroot user (home /home/nonroot), so AWS
credentials are mounted at /home/nonroot/.aws — that is where the SDK looks for
~/.aws. For a root-based image (RUNTIME_USER=root build), mount to /root/.aws
instead, or pass --user root.
Then point the AWS SDK / CLI at the proxy:
AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://localhost:8787 \
aws bedrock-runtime invoke-model --model-id anthropic.claude-3-haiku-20240307-v1:0 ...
The proxy can also drop in front of the Python proxy (--upstream http://127.0.0.1:8788)
so non-Bedrock traffic is forwarded while Bedrock requests are signed + compressed
natively. The default --enable-bedrock-native=true mounts the Bedrock routes; everything
else is passed through to --upstream.
AWS credential configuration
The proxy uses the aws-config default credential chain, resolved once at startup.
The chain searches in this order, stopping at the first source that yields valid credentials:
- Environment variables —
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, optionalAWS_SESSION_TOKEN. Useful for ECS task roles that inject creds via env or foraws sts assume-roleshells. - Shared credentials file —
~/.aws/credentials. Profile selected by--aws-profile(orAWS_PROFILE). Falls back to[default]. - IAM instance profile / IMDS — when running on EC2.
- ECS task role / EKS pod identity — when running on the AWS-managed compute platforms.
- AWS SSO —
~/.aws/sso/cache/...whenaws sso loginhas been run.
If the chain does NOT resolve any credentials at startup, the proxy logs event=bedrock_credentials_unavailable at WARN and continues to start. Bedrock invoke routes will then return 500 with event=bedrock_credentials_missing per request — there is no silent fallback to unsigned requests, by design.
Required IAM permissions
The proxy needs:
bedrock:InvokeModelfor non-streamingbedrock:InvokeModelWithResponseStreamfor streaming
Scope these to the specific model ARNs you intend to use. Example IAM policy snippet:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": [
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0",
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0"
]
}
]
}
Region configuration
headroom-proxy \
--upstream http://unused-when-bedrock-only \
--bedrock-region us-east-1
| Flag | Env var | Default | Notes |
|---|---|---|---|
--bedrock-region |
HEADROOM_PROXY_BEDROCK_REGION (or AWS_REGION) |
us-east-1 |
Drives both the SigV4 region and the derived endpoint hostname. |
--bedrock-endpoint |
HEADROOM_PROXY_BEDROCK_ENDPOINT |
derived from region | Override for FIPS endpoints (bedrock-runtime-fips.{region}.amazonaws.com), VPC endpoints, or local mock servers. |
--aws-profile |
HEADROOM_PROXY_AWS_PROFILE |
unset | Selects the named profile from the shared credentials file. |
--enable-bedrock-native |
HEADROOM_PROXY_ENABLE_BEDROCK_NATIVE |
true |
Set to false to mount no Bedrock routes at all (Bedrock requests will then fall through to the catch-all and fail without SigV4). |
Supported model IDs
The proxy classifies model IDs by literal vendor match — no regexes. It strips a known cross-region inference-profile geo prefix (eu., us., apac., global.) if present, then takes the leading dot-segment as the vendor. A model is treated as Anthropic-shape when that canonical vendor is anthropic — so both the bare anthropic.… foundation models and the geo-prefixed inference profiles (eu.anthropic.…, us.anthropic.…, apac.anthropic.…, global.anthropic.…) qualify. For those, the live-zone compression dispatcher runs over the body, the envelope is re-emitted with anthropic_version preserved as the first key, and the request is signed with SigV4.
Examples that hit the Anthropic compression path:
anthropic.claude-3-haiku-20240307-v1:0(foundation model)anthropic.claude-3-5-sonnet-20241022-v2:0eu.anthropic.claude-haiku-4-5-20251001-v1:0(EU cross-region inference profile)us.anthropic.claude-3-5-sonnet-20241022-v2:0(US inference profile)global.anthropic.claude-haiku-4-5-20251001-v1:0
Other Bedrock vendors (amazon.titan-..., meta.llama3-..., cohere.command-..., ai21.j2-..., stability.stable-diffusion-..., and their geo-prefixed inference profiles such as eu.amazon.nova-...) are signed and forwarded without compression — the proxy does not yet understand their body shapes and would risk corrupting them. These model IDs log event=bedrock_compression_skipped, reason=non_anthropic_vendor per request. Full Anthropic envelopes only.
The contract: any new model ID that AWS adds under the anthropic. vendor (as a bare prefix or behind a cross-region geo prefix) automatically picks up the full compression + signing pipeline. No code change in the proxy is needed for new versions of Claude on Bedrock.
Compression behaviour
Bedrock requests are subject to the same live-zone compression rules as direct Anthropic (/v1/messages):
- Only the live-zone messages (latest user turn, latest tool/output blocks) are eligible for compression.
- The cache hot zone (older messages, system prompt, tools list) is byte-faithful passthrough.
- The dispatcher only mutates body bytes when at least one block compressed. The byte-equality invariant for unchanged blocks is enforced at
debug_assert!granularity.
OAuth policy (PR-D3 → PR-F2/F3)
The Bedrock auth-mode middleware classifies every Bedrock request as AuthMode::OAuth. Even when the inbound request has no Authorization header (the common case where the AWS SDK signs after our proxy), the middleware coerces to OAuth and emits event=bedrock_auth_mode_unexpected at WARN if F1's classifier disagreed — so the divergence is loud, not silent.
Under the OAuth policy matrix (see docs/auth-modes.md):
- No auto-
cache_controlinjection. OAuth subscriptions pin the cache scope to(account, model, session); auto-injecting markers can void cache hits. - No auto-
prompt_cache_key. Same reasoning. - Lossless-only compressors. Lossy compressors (text rewriting, summarisation) are gated off for OAuth.
PR-D3 lands the classification + the resulting AuthMode in request.extensions(). PR-F2 and PR-F3 wire the actual policy gates that read it. Until those PRs land, the Bedrock route uses the existing dispatcher (which is a no-op in compression_mode=off); the OAuth contract above is the documented forward direction.
Cache safety
The bytes signed by SigV4 are exactly the bytes Bedrock receives — the signer hashes the post-compression body. There is no "sign before compress" shortcut that would produce a signature mismatched to the wire payload. Compression mutates the body once, then the signer runs once, then the bytes are forwarded once.
Prometheus metrics
The proxy exposes a GET /metrics endpoint that serves the standard Prometheus text-format scrape. Three Bedrock-specific metric families are exported:
| Metric | Type | Labels | Source |
|---|---|---|---|
bedrock_invoke_count_total |
Counter | model, region, auth_mode |
One increment per /model/.../invoke (and /converse and /invoke-with-response-stream) request. |
bedrock_invoke_latency_seconds |
Histogram | model, region |
Observed at request completion (success or failure). |
bedrock_eventstream_message_count_total |
Counter | model, region, event_type |
One increment per parsed binary EventStream message in the streaming path. event_type is the :event-type header (chunk, metadata, internalServerException, etc.). |
All labels are bounded by infrastructure config (region from --bedrock-region, auth_mode from the 3-variant enum) or by the path parameter (model, supplied by the axum extractor — never by user-controlled body bytes). Cardinality is bounded by deployment fan-out, not by traffic volume.
Sample PromQL queries
p99 latency by model:
histogram_quantile(
0.99,
sum by (model, le) (rate(bedrock_invoke_latency_seconds_bucket[5m]))
)
Request rate by region (RPS):
sum by (region) (rate(bedrock_invoke_count_total[1m]))
EventStream message rate by event type (debugging the streaming path):
sum by (event_type) (rate(bedrock_eventstream_message_count_total[1m]))
Error breakdown by HTTP status (cross-references the structured logs event=bedrock_upstream_error):
sum by (model) (rate(bedrock_invoke_count_total{auth_mode="oauth"}[5m]))
/ sum by (model) (rate(bedrock_invoke_latency_seconds_count[5m]))
(The denominator is the total observed latency samples — useful for sanity-checking that every counted invoke also got a histogram observation. They should be equal.)
Structured-log correlation
Every metric increment in the Bedrock path is paired with a tracing::debug! log line carrying:
event = "metric_recorded"metric = "bedrock_invoke_count_total" | "bedrock_invoke_latency_seconds" | "bedrock_eventstream_message_count_total"- the same labels as the metric
Enable with RUST_LOG=headroom_proxy::observability=debug for incident correlation. In normal operation keep this at the default info level — debug volume per request is bounded by the same cardinality the metric uses.
Live cloud validation
The PR-D1, D2, D3 implementations are exercised end-to-end against a wiremock upstream (crates/headroom-proxy/tests/integration_bedrock_*.rs). The wiremock-based tests are the canonical correctness gate.
A real Bedrock smoke test (aws bedrock-runtime invoke-model ... through the proxy) requires bedrock:InvokeModel permissions in the developer's AWS account. Set the proxy upstream to the proxy URL (http://localhost:8787) via the AWS SDK's AWS_ENDPOINT_URL_BEDROCK_RUNTIME env var:
AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://localhost:8787 \
aws bedrock-runtime invoke-model \
--model-id anthropic.claude-3-haiku-20240307-v1:0 \
--body '{"anthropic_version":"bedrock-2023-05-31","max_tokens":32,"messages":[{"role":"user","content":"hi"}]}' \
/tmp/out.json
If the SDK signs the request before sending to the proxy, the proxy will see a SigV4 Authorization header and classify as OAuth via the standard rule. If the SDK is configured to sign downstream of the proxy (some IAM-instance-profile setups), the proxy still classifies as OAuth via the middleware's coerce-and-log fallback.
Rollback
Set --enable-bedrock-native=false to unmount all Bedrock routes; the catch-all proxy then forwards Bedrock requests unchanged to --upstream. This is an emergency rollback only — without SigV4 re-signing, the catch-all path will fail closed unless the upstream is itself a Bedrock-aware proxy (e.g., the Python LiteLLM converter on a different port).