Why Headroom Breaks on AWS Bedrock — and How to Fix All Four Failures
You're on Bedrock because compliance won't let your data leave AWS. Your agent workflow multiplies tokens, the bill climbs, and you found Headroom — open-source compression that promises 60–95% fewer tokens. You point it at Bedrock, and it silently does almost nothing. Here's exactly why, with the error strings you're pasting into Google, and how the savings become real.
- ·Stock Headroom breaks four ways on Bedrock: 404 native routes, InvalidSignatureException, a prompt cache that reports frozen but saves 0%, and a botocore crash on STS credentials.
- ·The wins come from two levers: Bedrock prompt caching (0.1× cost on cache reads) and Headroom's compression — but on Bedrock, out of the box, both are inert.
- ·Fixed and measured: cache alone cut a 6-turn agentic session by 58.5%; cache + compression cut it 70.4%.
- ·Compact JSON (the default) compresses 0%. The same data with whitespace compresses 43%. That's why compression looks broken until you know where to look.
- ·All of this runs inside your VPC — data never leaves AWS.
Why you're on Bedrock — and why the bill climbs
Compliance keeps regulated teams on Bedrock so data never leaves AWS — and agentic workflows multiply tokens, because one user question fans out into ten times the tokens through the agent's internal loops.
If you're a platform or ML-infra engineer at a fintech or a health company, you probably didn't choose Amazon Bedrock for its ergonomics. You chose it because your security review said inference has to run inside your AWS account, against data that never crosses a third-party boundary. Bedrock delivers exactly that — and then the bill starts climbing in a way that direct API users never see.
The reason is the agent loop. A single user prompt doesn't map to a single model call. A tool-using agent reads a tool result, reasons about it, calls another tool, re-reads its own growing history, and repeats — so one question can drive on the order of ten times the tokens of a naive single-shot call. Every one of those internal turns re-sends the accumulated context. On a per-token biller like Bedrock, that compounds fast, and tool-heavy runs are where the surprise invoices come from. Independent write-ups of Bedrock economics put the "hidden" overhead well above the headline per-token rate once you account for agent multipliers and repeated context — see TrueFoundry's pricing breakdown. Treat those figures as their estimates, not ours.
Bedrock will bill you 10× less for tokens it reads from cache — cache reads are priced at 0.1× the standard input rate. On a workflow that re-sends the same system prompt and tool definitions every turn, that's most of your bill sitting behind a discount most teams never turn on. The catch: on Bedrock, through a compression proxy, the cache doesn't light up by default. The rest of this guide is about making it light up.
What Headroom is, in two paragraphs
Headroom is an open-source layer that compresses everything an agent reads — tool output, logs, files, RAG chunks, conversation history — before it reaches the model, reversibly, and aligned so the stable part of the prompt can be cached.
It ships three ways — a library, a proxy, and an MCP server — and the proxy is what matters here: you run a local server, point your client at it with ANTHROPIC_BASE_URL, and it compresses traffic on the way through without touching your application code. Compression is reversible — originals are never destroyed, so if the model needs the full version of something it can be restored. The upstream project (headroomlabs-ai/headroom, Apache 2.0) describes itself as cutting 60–95% of tokens — that's their benchmark claim, on their workloads, and a fair headline for the direct-API path.
The second half of the value is caching alignment. Compression only helps if the model still gets the same answer, and caching only helps if the compressed prefix is byte-stable across turns — one changed byte in the prefix invalidates the cache for everything after it. Headroom is built to keep that prefix stable so a cache marker can sit on it. On the direct Anthropic API, that combination works. On Bedrock, the wiring between compression, SigV4 signing, and Bedrock's cachePoint markers has four gaps. None of this is a knock on the project — the Bedrock route is just newer than the Anthropic one, and the fixes below are small and specific.
# Stock Headroom, pointed at Bedrock, wrapping Claude Code
headroom proxy --backend bedrock --region us-east-1 # local proxy on :8787
export CLAUDE_CODE_USE_BEDROCK=1
export ANTHROPIC_BASE_URL=http://localhost:8787
claude # ...and this is where the four failures startThe four ways it breaks on Bedrock
Stock Headroom on the Bedrock agentic path fails four distinct ways — a 404 on native routes, a broken SigV4 signature, a prompt cache that reports frozen but saves nothing, and a botocore crash on temporary credentials — and throws five distinct error strings. Each maps to an open upstream issue.
| Failure | What you see | Upstream |
|---|---|---|
| 404 on native routes | 404 on /model/{id}/invoke | #1589 |
| SigV4 signature breaks | InvalidSignatureException | #1220 |
| Prompt cache silently dead | savings_pct: 0.0 | #1345 |
| Too many cache markers | A maximum of 4 blocks with cache_control... | #1345 |
| Crash on STS credentials | ModuleNotFoundError: botocore | #1551 |
Failure 1 — 404 on Bedrock-native routes
Claude Code in Bedrock mode talks to native paths — /model/{id}/invoke, /invoke-with-response-stream, and GET /inference-profiles. The stock proxy only knows the Anthropic-style /v1/messages shape, so those requests come back 404. Worse, Claude Code's auto-mode classifier probes /inference-profiles to decide how to route; a 404 there makes it fail closed, so the agent degrades instead of erroring loudly. This is tracked upstream in issue #1589.
Failure 2 — InvalidSignatureException
This is the one that stops people cold. Bedrock authenticates with SigV4, and SigV4 signs a hash of the request body. Headroom's whole job is to rewrite that body — it compresses it. So the body the proxy sends no longer matches the body that was signed, and Bedrock rejects it:
botocore.exceptions.ClientError: An error occurred (InvalidSignatureException):
The request signature we calculated does not match the signature you provided.A related trap: in Bedrock mode, some clients ignore ANTHROPIC_BASE_URL entirely and sign for the real Bedrock endpoint, so even a correctly-running proxy is bypassed or mis-signed. The fix is to re-sign the request with SigV4 after compression, on the proxy's way out — which is exactly what our upstream PR #1220 does (auto-detect CLAUDE_CODE_USE_BEDROCK and re-sign).
Failure 3 — the cache reports frozen but saves savings_pct: 0.0
This is the most expensive failure because it's invisible. In --mode cache, Headroom freezes the prefix and prints prefix_frozen — so it looks like caching is on. But on Bedrock it never injects the cachePoint block that the Converse API actually needs, so no cache checkpoint is created and the report reads savings_pct: 0.0. The cache is silently dead (issue #1345; the fix that preserves the cache_control blocks through LiteLLM's message conversion is #1390).
And once you do start injecting markers, Bedrock has a hard ceiling that catches naive stacking. Claude models allow at most four cache checkpoints per request, processed in the order tools → system → messages. Put one on tools, one on system, and two on messages and add a fifth, and Bedrock returns:
ValidationException: A maximum of 4 blocks with cache_control may be provided. Found 5.Failure 4 — ModuleNotFoundError: botocore on STS credentials
Regulated environments rarely hand out static AWS keys — they use temporary STS credentials with a session token, from an assumed role or an SSO login. The stock proxy image doesn't bundle botocore, so the code path that handles the session token crashes on import:
ModuleNotFoundError: No module named 'botocore'It works on a laptop with static keys and dies the moment it hits the environment that actually needs it — a production account on assumed-role credentials (issue #1551).
The step-by-step fix
Fixing all four means keeping native Bedrock routes alive, re-signing with SigV4 after compression, injecting a real cachePoint under the 4-marker limit, and shipping botocore for STS credentials — then verifying the cache actually hits.
- 1
Pass through native routes. Proxy /model/{id}/invoke, /invoke-with-response-stream, and /inference-profiles as first-class Bedrock paths, not just /v1/messages, so Claude Code's auto-mode classifier gets a real answer.
- 2
Re-sign after compression. Compress the body first, then compute the SigV4 signature over the final bytes on the way out — never sign, then rewrite. Detect Bedrock mode from CLAUDE_CODE_USE_BEDROCK so the proxy isn't bypassed.
- 3
Inject one cachePoint, correctly. Place a single cachePoint block after the static prefix (tools + system), keep total markers ≤ 4, and confirm the prefix is byte-stable so it isn't invalidated turn to turn.
- 4
Ship botocore. Bundle botocore in the image so temporary STS credentials with a session token resolve instead of crashing on import.
Step 3 is the one worth showing concretely. A single cachePoint goes after the static prefix in a Bedrock Converse request — put it at the end of system (and one after tools if those are stable), never after the volatile last user turn:
// Bedrock Converse request — cachePoint after the static prefix
"system": [
{ "text": "<frozen system prompt + fleet context>" },
{ "cachePoint": { "type": "default" } } // <-- caches everything above
],
"messages": [
{ "role": "user", "content": [ { "text": "<this turn's question>" } ] }
] // no cachePoint here — it changes every turnSend the same prefix twice and read the Converse response. First call writes the cache; the second must read it:
// Request 1 — writes the cache
"usage": { "inputTokens": 11, "cacheWriteInputTokens": 1202, "cacheReadInputTokens": 0 }
// Request 2, same prefix — reads the cache (this is the proof)
"usage": { "inputTokens": 11, "cacheWriteInputTokens": 0, "cacheReadInputTokens": 1202 }
// total input tokens = inputTokens + cacheReadInputTokens + cacheWriteInputTokensWith stock Headroom on Bedrock, request 2 comes back inputTokens: 1213 with the cache fields empty — you pay full price every turn, roughly 9× the cost of the cached path. Surfacing these numbers in non-streaming responses is what our PR #1848 adds.
Some of these fixes are already upstream or in review — the SigV4 re-sign in #1220, the cache-token surfacing in #1848, and our notes on the cache path in #1345 / #1390. Others — like tighter token-estimate accuracy — aren't upstream yet and are packaged in our distribution while they mature.
The savings math (a formula, not a promise)
Savings stack multiplicatively: compression shrinks the input, and caching prices the repeated prefix at 0.1× on reads. The bill in token-equivalents is input + 1.25× write + 0.1× read.
No magic percentages — here's the model. Bedrock reports three usage numbers, and you pay for a weighted sum of them: uncached input at 1×, cache writes at 1.25× (the price of putting something in the cache with a 5-minute TTL), and cache reads at 0.1×. So the effective bill for a request is:
bill = input + 1.25 × write + 0.1 × read
Caching attacks the read term — a repeated prefix that would cost 1× every turn costs 0.1× instead. Compression attacks the input term — fewer tokens to send in the first place. They compound: compress the input, then cache what's stable inside it. On a realistic 6-turn agentic session (compact JSON tool-results, a 100-service fleet context), measured on Claude Sonnet 4.5 in us-east-1:
Caching alone — just making the dead cache actually work — took the session from 619,865 to 257,250 token-equivalents, a 58.5% cut. Layering compression on top brought it to 183,672 — 70.4% off the unoptimized bill. That last step is compression cutting the already-cached total by a further 28.6% — the two levers stack because they attack different terms.
Here's the trap that makes people think compression is inert on Bedrock: token count tracks whitespace. Compact JSON — what json.dumps, JSON.stringify, and boto3 emit by default — compresses 0% in stock Headroom, because there's no whitespace to strip. The same data pretty-printed with whitespace compresses 43%. If your tool results are already compact, stock compression has nothing to do — the wins have to come from structural compression and caching, not whitespace.
These are our measured numbers on a synthetic-but-realistic workload. Production numbers on a live regulated-fintech workload are being measured now, and will be published when they're real rather than modeled.
Frequently asked questions
Does Headroom work with AWS Bedrock out of the box?+
Not on the agentic Bedrock path. Stock Headroom silently fails four ways: it 404s Bedrock-native routes, breaks SigV4 signatures after compressing the body (InvalidSignatureException), never injects cachePoint markers so prompt caching reports frozen but saves 0%, and crashes on temporary STS credentials with ModuleNotFoundError: botocore. Each maps to an open upstream issue.
Why does my Bedrock prompt cache show frozen but save nothing?+
Because the prefix is frozen client-side but no cachePoint block is ever injected into the Bedrock Converse request. Headroom shows prefix_frozen and reports savings_pct: 0.0 — the cache is silently dead (upstream issue #1345). Bedrock only caches when a cachePoint marker sits after the static prefix, and the request stays under the 4-checkpoint limit.
What is the maximum number of cache_control blocks on AWS Bedrock?+
Four. Claude models on Bedrock allow a maximum of 4 cache checkpoints per request. Stacking more returns: A maximum of 4 blocks with cache_control may be provided. Found 5. Checkpoints are processed in the order tools, then system, then messages.
How do I verify Bedrock prompt caching is actually working?+
Send the same prefix twice. On the second request, the Converse response's cacheReadInputTokens must be greater than 0 (and cacheWriteInputTokens greater than 0 on the first). If both stay empty across identical-prefix requests, the cache is not being hit. Total input = inputTokens + cacheReadInputTokens + cacheWriteInputTokens.
Does running Headroom on Bedrock keep data inside my AWS account?+
Yes. That is the entire reason regulated teams are on Bedrock — inference runs against Amazon Bedrock inside your AWS account and nothing leaves. Running the compression proxy in your own VPC preserves that boundary: compressed or not, the traffic never leaves AWS.
Caprock is a managed distribution of Headroom that ships all four Bedrock fixes and runs entirely inside your VPC, so nothing leaves your AWS account — caprock.dev.