Skip to content

fix(sse): keep mid-conversation system turns in place on anthropic-compatible relays - #16236

Merged
diegosouzapw merged 12 commits into
cheaper-inference:release/v3.8.52from
MikeTuev:fix/claude-compatible-mid-conversation-system
Oct 18, 2026
Merged

diegosouzapw merged 12 commits into
cheaper-inference:release/v3.8.52from
MikeTuev:fix/claude-compatible-mid-conversation-system

Conversation

@MikeTuev

Copy link
Copy Markdown
Contributor

Summary

This broke prompt caching for Claude Code (claude CLI) sessions routed through a plain anthropic-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-level system. 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 ran normalizeClaudeUpstreamMessages() → extractSystemRoleMessages(). That function moves every role: "system" turn out of messages[] and appends its text to the top-level system array:

client sends                          OmniRoute sent upstream
system: [BASE]                        system: [BASE, env, <total_tokens>#1, <total_tokens>#2, … , <total_tokens>#N]
messages: [user, system(env),         messages: [user, assistant, user, assistant, user, …]   (system turns removed)
           assistant, user,
           system(<total_tokens>#1),
           …]

system comes before messages, 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 input cache read cache write
turn N‑1 133,153 27,890 105,261
turn N 133,704 27,911 105,791
  • Across 54 consecutive calls, the cache-hit rate was ~29.5%. In 45 of 53 transitions cache read grew by exactly +21 tokens, which is roughly one more <total_tokens> block in the cached prefix.
  • For comparison, a call from an OpenCode client on the same connection and model, whose body has no mid-conversation system turns, hit ~99% (105,003 of 105,989 tokens read from cache).

Fix

Plain anthropic-compatible-* passthrough no longer hoists mid-conversation system turns:

  • Only the leading run of system turns before the first real user/assistant turn is hoisted (hoistLeadingTextSystemMessages). Anthropic rejects a text system turn at messages[0]. Directive-only turns (content: [] + output_config) are moved off messages[0] (relocateDirectiveOnlyMessages). Every later system turn keeps its position, so system stays byte-stable and history is only appended.
  • This applies regardless of the client's anthropic-beta, so cache stability does not depend on the exact beta set a client version negotiates.
  • mid-conversation-system-2026-04-07 is 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.
  • A body carrying an OpenAI developer turn keeps the full legacy hoist. Accepting system turns does not imply accepting developer turns.
  • The global system prompt (injectSystemPromptPostTranslation) on claude targets now always goes to body.system. Before, once a system turn was present in messages[], 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 in chatCore.ts (non-semantic path) and DefaultExecutor.buildHeaders().

Why native claude and the CC bridge are not changed here

Both are candidates for follow-ups. I can open issues for them if useful.

Related Issues

Target Branch

  • Base is the active release/v3.8.52 branch, not main

Proof Of Fix (required)

  • TDD — the new test fails on release/v3.8.52 without the fix and passes with it.
    • Test file: tests/unit/claude-mid-conversation-system-compatible-passthrough.test.ts (11 tests). The test drives handleChatCore() 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 to fetch, not on the helper alone.
    • Failing output before the fix (7 of 11 fail):
      ✖ default: mid-conversation system turns stay in place in the final body
      ✖ default: successive turns keep system stable and earlier messages a prefix
        AssertionError [ERR_ASSERTION]: system changed between turn 1 and 2
      ✖ a non-Claude Code client gets the same treatment and its beta is forwarded
      ✖ client without the beta: turns still stay in place, and OmniRoute never invents it
      ✖ client without any anthropic-beta header: turns stay in place, no beta header invented
      ✖ successive turns from a client without the beta also keep earlier messages a prefix
      ✖ leading text system turn is hoisted, directive-only one relocated
      ℹ pass 4
      ℹ fail 7
      
    • After the fix: 11/11 pass. Added a case to tests/unit/global-prompt-single-injection.test.ts for the global prompt with a mid-conversation system turn.

Scope

  • Only the files the failing tests prove need changing are touched
  • No test was weakened or deleted

Validation

  • Change type: provider / routing
  • Focused tests: Claude / chatCore / executor / system-prompt / combo slice — 3,495 tests, 3,480 pass, 7 skipped, 8 fail. All 8 are in combos-auto-skipped-variants.test.ts (6) and combo-test-auto-route.test.ts (2) and fail the same way on clean release/v3.8.52 (5 pass / 8 fail there).
  • npm run typecheck:core clean; ESLint clean on changed files; pre-commit hooks pass

Reviewer Notes

  • Behaviour change for third-party relays: a relay that rejects role: "system" inside messages[] will now return 400 for bodies that carry such turns. Before, these were silently flattened into system at the cost of the cache. Native claude already sends these turns to Anthropic for Opus/Fable, but I have not verified every third-party relay.
  • Live before/after cache numbers against a real relay are not included yet. I will add them here once this build has run on a self-hosted instance.

…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.
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Hi @MikeTuev — closing this pull request to clear the open queue. The branch fix/claude-compatible-mid-conversation-system is kept, so the commits stay. Comment here if it should be reopened.

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Hi @MikeTuev — reopening this pull request. The close was a mistake, and it is back in the merge queue. The branch fix/claude-compatible-mid-conversation-system was kept.

@diegosouzapw diegosouzapw reopened this Oct 10, 2026
…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
diegosouzapw merged commit cae17fc into cheaper-inference:release/v3.8.52 Oct 18, 2026
5 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants