Repository navigation
fix: keep Messages system turns and client cache breakpoints; cache OpenRouter anthropic Responses - #173
Merged
Conversation
…penRouter anthropic Responses Claude Code sends mid-conversation role:system messages, and the request's message-level cache breakpoint sits on the trailing one. ClaudeEngine folded them into the system prompt and dropped them whenever the top-level system was a block array, so the model lost them and only the system prompt cached. On anthropic-messages and aws-anthropic they now stay in place with their output_config and clear_at; Converse appends them to the system blocks. prompt_cache now adds gw breakpoints only when the client placed none, which keeps a request within Anthropic's four breakpoints and the client's TTL order. OpenRouter's Responses API caches an anthropic/* model only under a top-level cache_control; prompt_cache on such a model now adds it unless the client did.
…our and TTL order A client that marks only its system prompt lost the gateway's last-user-turn breakpoint, so its conversation stopped caching. The gateway now marks the latest user turn unless the client marked a message block or sent a top-level cache_control, and the system prompt unless the client marked it or a 1h breakpoint follows it, turn first, never past four.
…onverse folds system turns A cache-marked system turn replaced a string top-level system on aws-converse, and a folded 5m breakpoint landed ahead of a 1h message breakpoint, which Bedrock rejects. Both reproduced live on Bedrock.
… Claude model 61f7b17 also folded them on every model swap, which put the variant alias route (claude-opus-5-5 to ~anthropic/claude-opus-latest) back on system-only caching. Pre-5 Claude models reject a mid-conversation system turn; 5.x and non-Claude Anthropic-wire vendors take it.
…rn's per-turn effort The pre-5 check rode on the thinking dialect, which only parses Anthropic's hyphenated ids: OpenRouter's anthropic/claude-sonnet-4.6, -opus-4.7, -haiku-4.5, -opus-4 and -sonnet-4 counted as 5.x, so a variant to one of them sent the client's system turns and came back 424. The check now reads the major version that follows "claude". A turn's output_config is shaped for the model the client named; Sonnet 5 takes a mid-conversation system turn but rejects per-turn effort, so a swap to it failed too. A swapped request now keeps 5.x system turns in place without their output_config.
…Converse Bedrock answers advisor-tool-2026-03-01, advanced-tool-use-2025-11-20 and prompt-caching-scope-2026-01-05 with a 400, and Claude Code sends the first two on every request, so each Bedrock call failed once before the client retried with its tools undeferred. Without that beta Bedrock still honors defer_loading. Converse got no betas at all, so it refused the client's context_management; the filtered list now rides in additionalModelRequestFields for Claude models, which is what Bedrock needs to accept it.
An effort-only system turn ({"content": [], "output_config": ...}) loses its
only field when the request is swapped, and Anthropic refuses the empty turn
("system content must contain at least one block"), so a swap to Sonnet 5
returned 424. Such a turn is now skipped; a client's own empty turn still
passes through as sent.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Two prompt-cache gaps, both found while running coding agents through the gateway to OpenRouter.
1.
/v1/messageslost Claude Code's mid-conversation system messages.role: "system"entries insidemessages, such as its environment block and per-turn reminders. Some carry a per-messageoutput_config.ClaudeEnginefolded these turns intosystem_parts, then threw them away whenever the top-levelsystemwas a block array, which it always is for Claude Code. So the model never saw them, and only the system prompt cached.cache_controlon user,tool_resultand tool entries was already forwarded unchanged. The loss was specific to system turns.2. OpenRouter's Responses API caches nothing for
anthropic/*models unless the request carries a top-levelcache_control. Codex sends none.Change
anthropic-messagesandaws-anthropic, system turns stay in place, verbatim. Extra per-message fields (output_config,clear_at) ride along through a newInMessage.extra→ChatMsg.extra. Onaws-converse, which has no such turn, they are appended to the system blocks instead of being dropped. They are also appended there when a fallback or variant serves a pre-5 Claude model in place of the one the client named: Claude Code sends these turns only for 5.x models, and 4.x models reject both the turn and its per-turn effort with a 400 ("role 'system' is not supported on this model", "output_config.effort requires a model that supports per-turn effort", or "Extra inputs are not permitted" on Bedrock). The swaps arefallback_models,variantsand a tenant'sfallback_model; each setsfallback_from. A swap to another 5.x model or to a non-Claude Anthropic-wire vendor keeps them in place, so a variant alias of the same model keeps its message-level caching. The chat surface is unchanged.prompt_cacheprecedence. The gateway's breakpoints are placed after the body is fully built. Client breakpoints win:tool_resultcontent included) or sent a top-levelcache_control. System or tool breakpoints alone don't suppress it, so a system-only client keeps conversation caching.prompt_cacheis on, the account's kind isopenrouterand the model isanthropic/*(including~anthropic/*aliases), the body getscache_control: {type: ephemeral}unless the client set one. This uses the existing per-model knob; there is no new config.docs/configuration.mdanddocs/examples.mdare updated.Hot-path cost
system_text()build.serde(flatten)onInMessage: buffers only unknown per-message keys. Known fields, includingcontent, are deserialized directly.prompt_cachemodels (no allocation).Tests
New tests fail on
mainand pass here:native_system_turns_stay_in_place_on_the_anthropic_wiresnative_system_turns_fold_into_the_system_prompt_on_converseprompt_cache_fills_only_the_breakpoints_the_client_left_outcovers 10 cases: system-only clients (5m and 1h) get the turn breakpoint; message,tool_resultor top-level breakpoints suppress it; a 1h message or top-level breakpoint suppresses the gateway's system breakpoint; four taken means none added; with one slot left it goes to the turn. Every case also checks that no 5m breakpoint precedes a 1h one.mainfails the 1h case (it adds a 5m system breakpoint ahead of the client's 1h one), and the first commit of this PR fails the system-only case.prompt_cache_marks_the_last_user_turn_ahead_of_a_trailing_system_turnopenrouter_anthropic_responses_carry_a_top_level_cache_controlmessages_system_turns_reach_the_anthropic_upstream_in_place, which runs the full/v1/messagespath with a Claude Code-shaped bodyGates:
cargo fmt --check,cargo clippy --workspace --all-targets -- -D warningsandcargo test --workspaceare clean. TheGW_TEST_PG_URL/GW_TEST_REDIS_URLsuites were not run; these changes don't touch the store code they cover.What Claude Code loses on main
Captured from Claude Code 2.1.310, three
-pturns, tools off, against a local recorder. Every request carries thesemessagesentries, andmaindrops all of them:role: "system"turn after the first user turn: working directory, git repo flag, platform, shell, OS version, the untrusted-download rule, model name and ID, knowledge cutoff, the<total_tokens>budget and today's date. It is 976 characters and carriesoutput_config: {effort: "medium"}.role: "system"turn after every later user turn (<total_tokens>… tokens left</total_tokens>, 49 characters). Earlier copies stay in history, and the newest one carries the request's only message-levelcache_control.maincaches only the system prompt.Token cost, measured on the live probe below: the environment message plus one reminder came to 405 prompt tokens (19,246 with them vs 18,841 without). That figure includes about 30 tokens of probe-only rule text. Each further turn adds roughly one more reminder. With tools, Claude Code may send more system turns; this capture had tools off.
Live probe (OpenRouter,
anthropic/claude-opus-5.5, direct)output_config, the trailing one withcache_control)cache_read_input_tokens19,244, i.e. the whole prefix through the trailing system turn, not just the system prompt. Cost $0.0987 → $0.0062.cache_control, twiceThe Responses pair shows that top-level
cache_controlis accepted, reads cache and writes cache. It does not isolate the flag against a no-flag baseline on a fresh prefix. That baseline (0 cached without the flag) comes from an earlier manual probe.Anthropic-wire accounts in use
The two deployed gateways have one
anthropic-messagesaccount between them, on OpenRouter. Neither has anaws-anthropicaccount or a third-party Anthropic-compatible vendor (e.g. MiniMax). The opt-in guard (keep folding for non-Anthropic providers unless the account opts in) is not added: no such account exists. Anthropic-compatible vendors configured elsewhere will now receive the client's system turns as sent.Live check on Bedrock
The gateway was run locally from this branch, authenticating with a Bedrock API key (bearer token), region us-east-1, model Opus 5.5.
output_config, the trailing one withcache_control)aws-anthropic,us.anthropic.claude-opus-5-5, in placeaws-converse,global.anthropic.claude-opus-5-5, foldedsystemplus a cache-marked system turnaws-converseBANANA … PINEAPPLE).aws-converseus.anthropic.claude-sonnet-4-6with in-place system turns (control)aws-anthropicus.anthropic.claude-sonnet-4-6aws-anthropic, foldedglobal.anthropic.claude-opus-5-5aws-anthropic, in placeThe fix keeps the top-level text ahead of the folded turns, and drops a folded turn's shorter-TTL breakpoint when a longer-TTL breakpoint follows in
messages. The later breakpoint still covers that prefix. Unit testsa_cache_marked_system_turn_keeps_the_top_level_system_text_on_converseanda_folded_system_turn_never_puts_a_5m_breakpoint_before_a_1h_one_on_conversefail without it.Swapped models: which turns fold (544236b)
Pre-5 detection. The pre-5 check came from the thinking dialect, which parses only Anthropic's hyphenated ids. OpenRouter's
anthropic/claude-sonnet-4.6,-opus-4.7,-haiku-4.5,-opus-4and-sonnet-4were all treated as 5.x. A variant fromclaude-opus-5-5to OpenRouteranthropic/claude-sonnet-4.6therefore returned 424 on de41d92:claude_before_5now reads the major version that followsclaude.Per-turn effort. Sonnet 5 takes a mid-conversation system turn but rejects its
output_config, even with the beta. A swapped request now keeps 5.x turns in place withoutoutput_config.Measured live on the Anthropic API.
output_config+per-turn-control-2026-07-01On OpenRouter,
anthropic/claude-sonnet-4.6accepts a plain turn and rejectsoutput_config.What Claude Code 2.1.310 sends (captured locally):
output_configon the system turns, plus the per-turn beta.anthropic/claude-opus-5.5: plain turns.Tests.
native_system_turns_fold_into_the_system_prompt_on_converse_or_a_swapped_modeladdsanthropic/claude-sonnet-4.6andanthropic/claude-opus-4.native_system_turns_stay_in_place_on_the_anthropic_wiresadds a swappedclaude-sonnet-5, and now expects swapped turns, the~anthropic/claude-opus-latestalias included, withoutoutput_config.E2E (e17dc86 vs main a7d6c36)
Full live matrix, every key we hold: main 182/241, PR 182/241, the same cases on both sides.
The 59 failures are vendor-side:
gpt-4.1-nanois deprecated.Rerun of the
bedrock,agents,anthropicandopenroutergroups with a working Bedrock token (us-east-1): main 74/74, this branch 74/74, identical.Claude Code 2.1.310 through the gateway. Two turns with the Write and Read tools on each route. The proxy logged every upstream request and its usage.
claude-opus-5-5output_config); 0 cache writes after the first callclaude-sonnet-5output_config, 200, 0 writesanthropic/claude-opus-5.5(direct and as a variant)~anthropic/claude-opus-latestclaude-sonnet-4-6/ OpenRouteranthropic/claude-sonnet-4.6system(3 → 7 blocks), 200us.anthropic.claude-opus-5-5(aws-anthropic)output_config, 0 writesglobal.anthropic.claude-opus-5-5(aws-converse)us.anthropic.claude-sonnet-4-6All 20 runs completed both turns.
Bedrock betas (bf1f6e1)
On main and on e17dc86, Bedrock answered Claude Code's first attempt on every request with 400. Claude Code then retried, and the retry succeeded:
aws-anthropicrejected theadvanced-tool-use-2025-11-20beta. Claude Code's retry also turned offdefer_loading, which loaded every tool schema: about 27.8k prompt tokens per request instead of 16.6k.aws-converserejectedcontext_management. Converse received no betas at all, and Claude Code's retry dropped context management.What Bedrock accepts, probed one beta at a time on InvokeModel with Opus 5.5 in us-east-1:
advisor-tool-2026-03-01,advanced-tool-use-2025-11-20andprompt-caching-scope-2026-01-05.fine-grained-tool-streaming.defer_loadingis still honored.context_managementonceanthropic_beta: ["context-management-2025-06-27"]rides inadditionalModelRequestFields.Fix.
aws-anthropicas before. It now also goes to Claude models onaws-converse, where it lands inadditionalModelRequestFields.bedrock_drops_the_betas_it_rejects_and_converse_carries_the_rest_for_claudefails on e17dc86.docs/api.md,docs/examples.mdanddocs/providers.mdstate the rule.Claude Code 2.1.310 through the gateway, e17dc86 vs bf1f6e1, with the proxy recording upstream:
us.anthropic.claude-opus-5-5global.anthropic.claude-opus-5-5(Converse)us.anthropic.claude-sonnet-4-6The
bedrock,agents,anthropicandopenroutermatrix groups went 74/74 on both builds, identical.cargo test --workspacewith real Postgres 17 and Redis 7 gives 773 passed, 0 failed, 4 ignored. fmt and clippy are clean.Gates.
cargo fmt --checkandcargo clippy --workspace --all-targets -D warningsare clean.cargo test --workspacewith real Postgres 17 and Redis 7 gives 772 passed, 0 failed, 4 ignored; it created 4 test databases and wrote 7 Redis keys.Effort-only system turns on a swap (1153e8b)
An effort-only system turn (
{"role": "system", "content": [], "output_config": {...}}) loses its only field when the request is swapped. Anthropic refuses the empty turn that is left:system content must contain at least one block. It does so on opus-5.5 as well as sonnet-5.A variant from
claude-opus-5-5toclaude-sonnet-5carrying such a turn:Such a turn is now skipped. A client's own empty turn still passes through as sent.
native_system_turns_stay_in_place_on_the_anthropic_wiresfails on bf1f6e1.Gates: fmt and clippy are clean.
cargo test --workspacewith real Postgres 17 and Redis 7: 773 passed, 0 failed, 4 ignored.Size
Against main a7d6c36:
29b8cda is a test-only cleanup, with no assertion changed and the test count unchanged at 773:
CONVERSE_OKfixture in place of five copies.system_textshelper.Known limitations
None open from review. The two Converse folding issues are fixed above, and
aws-anthropicin-place system turns are verified live.