Repository navigation
fix(sse): keep mid-conversation system turns in place on anthropic-compatible relays - #16236
Merged
diegosouzapw merged 12 commits intoOct 18, 2026
Conversation
…mpatible relays The claude -> claude passthrough for a plain `anthropic-compatible-*` provider ran normalizeClaudeUpstreamMessages(), which hoists every `role: "system"` turn into the top-level `system`. Claude Code (claude CLI) appends system turns as the session goes on (environment updates, background notices, `<total_tokens>` budget notices), so `system` -- the head of the prompt -- grew on every request and the cached history behind it never matched again: long claude CLI sessions through such a relay re-wrote ~105K tokens of history per turn while reading only ~27K from cache. On anthropic-compatible-* connections, regardless of the client's anthropic-beta: - only the leading system run before the first real turn is hoisted and directive-only messages are relocated off messages[0]; later system turns stay in place - bodies carrying OpenAI `developer` turns keep the full legacy hoist - the client's `mid-conversation-system-2026-04-07` token is forwarded when it sent one and a mid-conversation system turn survived into the body; OmniRoute never invents it (the generic allowlist dropped it before) - the global system prompt on claude targets always goes to body.system and never onto a conversation system turn Native `claude` and the CC bridge (`anthropic-compatible-cc-*`) are unchanged.
…-file budgets No behaviour change. CI's file-size and complexity ratchets flagged the PR's own growth in two frozen god-files and one over-long function: - open-sse/executors/default.ts is back to its base content: forwarding the client's `mid-conversation-system-2026-04-07` token moved into applyClientAnthropicBeta (config/anthropicHeaders.ts), the helper that already handles client betas for anthropic-compatible-* only, gated on a system turn still being in the body - open-sse/handlers/chatCore.ts changes a single line: the relay policy is resolved where the system roles are lifted (liftSystemRoleMessages in chatCore/claudeSystemRole.ts) from the provider/source format it now receives - normalizeClaudeUpstreamMessages stays under max-lines-per-function Tests cover the client beta end to end (case/spacing, look-alike token).
…er-function The pre-commit prettier pass split two long lines in the file, pushing the function back over the 80-line ratchet. Extract the empty-text-block predicate and drop the reassigned messages binding; no behaviour change.
Collaborator
|
Hi @MikeTuev — closing this pull request to clear the open queue. The branch |
Collaborator
|
Hi @MikeTuev — reopening this pull request. The close was a mistake, and it is back in the merge queue. The branch |
…t off) for cheaper-inference#16236 Opt-in switch for keeping mid-conversation role:system turns inside messages[] on plain anthropic-compatible-* relays. Definition, the FEATURE_FLAGS.md catalog row, the flag-count assertion and the description key translated in every locale.
…opt-in flag and the client beta resolveClaudeMidConversationSystemPolicy kept role:system turns inside messages[] for every anthropic-compatible-* relay regardless of what the client negotiated, so relays without mid-conversation-system-2026-04-07 (GLM/Kimi/MiniMax Anthropic endpoints) would get role:system messages and 400. The in-place shape now requires ANTHROPIC_COMPATIBLE_MID_CONVERSATION_SYSTEM (read in chatCore and injected, fail closed) AND the client's own beta, mirroring native claude's shouldUseMidConversationSystem gating. The global-prompt placement change is scoped to anthropic-compatible relays so native claude keeps its existing placement; the beta constant moves above the FORWARDABLE_CLIENT_BETAS docblock it had split.
# Conflicts: # docs/reference/FEATURE_FLAGS.md # tests/unit/feature-flags-settings.test.ts # tests/unit/server-owned-tool-loop-flag.test.ts
# Conflicts: # docs/reference/FEATURE_FLAGS.md # src/i18n/messages/am.json # src/i18n/messages/ar.json # src/i18n/messages/az.json # src/i18n/messages/bg.json # src/i18n/messages/bn.json # src/i18n/messages/bs.json # src/i18n/messages/cs.json # src/i18n/messages/da.json # src/i18n/messages/de.json # src/i18n/messages/el.json # src/i18n/messages/en.json # src/i18n/messages/es.json # src/i18n/messages/et.json # src/i18n/messages/fa.json # src/i18n/messages/fi.json # src/i18n/messages/fr.json # src/i18n/messages/ga.json # src/i18n/messages/gu.json # src/i18n/messages/ha.json # src/i18n/messages/he.json # src/i18n/messages/hi.json # src/i18n/messages/hr.json # src/i18n/messages/hu.json # src/i18n/messages/hy.json # src/i18n/messages/id.json # src/i18n/messages/ig.json # src/i18n/messages/it.json # src/i18n/messages/ja.json # src/i18n/messages/ka.json # src/i18n/messages/km.json # src/i18n/messages/kn.json # src/i18n/messages/ko.json # src/i18n/messages/lt.json # src/i18n/messages/lv.json # src/i18n/messages/ml.json # src/i18n/messages/mr.json # src/i18n/messages/ms.json # src/i18n/messages/mt.json # src/i18n/messages/my.json # src/i18n/messages/ne.json # src/i18n/messages/nl.json # src/i18n/messages/no.json # src/i18n/messages/or.json # src/i18n/messages/pa.json # src/i18n/messages/phi.json # src/i18n/messages/pl.json # src/i18n/messages/pt-BR.json # src/i18n/messages/pt.json # src/i18n/messages/ro.json # src/i18n/messages/ru.json # src/i18n/messages/si.json # src/i18n/messages/sk.json # src/i18n/messages/sl.json # src/i18n/messages/sr.json # src/i18n/messages/sv.json # src/i18n/messages/sw.json # src/i18n/messages/ta.json # src/i18n/messages/te.json # src/i18n/messages/th.json # src/i18n/messages/tr.json # src/i18n/messages/uk-UA.json # src/i18n/messages/ur.json # src/i18n/messages/uz.json # src/i18n/messages/vi.json # src/i18n/messages/yo.json # src/i18n/messages/zh-CN.json # src/i18n/messages/zh-TW.json # tests/unit/feature-flags-settings.test.ts # tests/unit/server-owned-tool-loop-flag.test.ts
diegosouzapw
merged commit Oct 18, 2026
cae17fc
into
cheaper-inference:release/v3.8.52
5 of 7 checks passed
Merged
8 tasks done
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.
Summary
This broke prompt caching for Claude Code (
claudeCLI) sessions routed through a plainanthropic-compatible-*connection. Every turn re-wrote almost the whole conversation to the cache instead of reading it.What the client sends
During a session, Claude Code appends system-role turns to
messages[], which come after the first user turn. They are not part of the top-levelsystem. Examples from real traffic:{ "role": "system", "content": "<total_tokens>14967640 tokens left</total_tokens>" } { "role": "system", "content": "The TodoWrite tool hasn't been used recently. If you're working on tasks that would benefit from tracking progress, consider using the TodoWrite tool ..." } { "role": "system", "content": "… While auto mode is active …" }(The last two are abbreviated.)
The
<total_tokens>…</total_tokens>budget notice is appended as a new system turn after each tool round, and older ones stay in the history. A 2-message request carries 1 such turn, an 89-message request carries 30, and so on.What OmniRoute did with it
For any provider other than native
claude, the claude → claude passthrough rannormalizeClaudeUpstreamMessages()→extractSystemRoleMessages(). That function moves everyrole: "system"turn out ofmessages[]and appends its text to the top-levelsystemarray:systemcomes beforemessages, so each turn inserted one more block ahead of the entire conversation history. Anthropic prompt caching matches prefixes. The cached history therefore never matched again, and only the stable head of the prompt (tools + original system) was reused.Measured impact (one real claude CLI session,
claude-opus-5-5, single connection, no account rotation, gaps < 4 min)<total_tokens>block in the cached prefix.Fix
Plain
anthropic-compatible-*passthrough no longer hoists mid-conversation system turns:hoistLeadingTextSystemMessages). Anthropic rejects a text system turn atmessages[0]. Directive-only turns (content: []+output_config) are moved offmessages[0](relocateDirectiveOnlyMessages). Every later system turn keeps its position, sosystemstays byte-stable and history is only appended.anthropic-beta, so cache stability does not depend on the exact beta set a client version negotiates.mid-conversation-system-2026-04-07is forwarded when the client sent it and a mid-conversation system turn survived into the body. Before this change, the generic beta allowlist dropped it. OmniRoute never adds this beta on its own.developerturn keeps the full legacy hoist. Acceptingsystemturns does not imply acceptingdeveloperturns.injectSystemPromptPostTranslation) on claude targets now always goes tobody.system. Before, once a system turn was present inmessages[], the suffix was written onto the last system turn, which moves every turn and would break the prefix in the same way.New helper:
open-sse/services/claudeMidConversationSystem.ts. Call sites: the claude passthrough branch inchatCore.ts(non-semantic path) andDefaultExecutor.buildHeaders().Why native
claudeand the CC bridge are not changed hereclaudealready keeps these turns for Opus/Fable full-agent requests (shouldUseMidConversationSystem, fix(sse): preserve Claude Code cache breakpoints #8934 / fix(sse): preserve Fable mid-conversation cache prefixes #13173, together with the beta fromselectBetaFlags). For other models (e.g. Sonnet), it still hoists them. Anthropic validates the position of these turns per model ([BUG] Opus 5 rejects cache-safe memory system message after assistant text #11290, fix(claude): 3.8.50 Claude Code passthrough — mid-conversation system output_config 400 (claude-opus), web_search interception sends OpenAI tool shape to Claude #15118), so widening that gate needs live verification per model family. That belongs in a separate PR.anthropic-compatible-cc-*) builds its own body (prepareClaudeCodeCompatibleSemanticBody/extractCustomSystemBlocks) and hoists there as well. It has the same cache problem in principle. However, it sits behind the CC wire image/signing and relay-specific beta handling, and that path is out of scope for this change.Both are candidates for follow-ups. I can open issues for them if useful.
Related Issues
claudeprovider only (fix(sse): preserve Claude Code cache breakpoints #8934).anthropic-compatible-*kept the unconditional hoist.Target Branch
release/v3.8.52branch, notmainProof Of Fix (required)
release/v3.8.52without the fix and passes with it.tests/unit/claude-mid-conversation-system-compatible-passthrough.test.ts(11 tests). The test driveshandleChatCore()with a synthetic Claude Code-shaped body (env system turn + one<total_tokens>system turn per tool round, thinking blocks, tool_use/tool_result, 1h cache markers) and asserts on the final body and headers handed tofetch, not on the helper alone.tests/unit/global-prompt-single-injection.test.tsfor the global prompt with a mid-conversation system turn.Scope
Validation
combos-auto-skipped-variants.test.ts(6) andcombo-test-auto-route.test.ts(2) and fail the same way on cleanrelease/v3.8.52(5 pass / 8 fail there).npm run typecheck:coreclean; ESLint clean on changed files; pre-commit hooks passReviewer Notes
role: "system"insidemessages[]will now return 400 for bodies that carry such turns. Before, these were silently flattened intosystemat the cost of the cache. Nativeclaudealready sends these turns to Anthropic for Opus/Fable, but I have not verified every third-party relay.