Skip to content

[Bug]: Mid-conversation role: system messages in Claude inbound are aggregated into top-level instructions, destroying prompt cache prefix #4148

Description

@richardfeiliu-a11y

Client or integration

Claude Code

Area

Proxy and routing

Summary

In src/claude/inbound.ts:334-348, OpenCodex translates incoming Claude Messages by iterating through raw.messages. When it encounters msg.role === "system", it appends the message text to systemParts, which is then joined and assigned to body.instructions:

// src/claude/inbound.ts:334-348
else if (msg.role === "system") {
  const text = systemMessageText(msg.content);
  if (text.length > 0) systemParts.push(text);
}
...
if (systemParts.length > 0) body.instructions = systemParts.join("\n\n");

When clients like Claude Desktop or Claude Code inject dynamic system messages during a multi-turn conversation (e.g., style reminders, date changes, or subagent notices):

  • Turn 1: instructions = [topLevelSystem + reminder_1]
  • Turn 2: instructions = [topLevelSystem + reminder_1 + reminder_2]

Because instructions is placed at the very head of the LLM prompt sequence, mutating instructions on every turn completely invalidates the prompt cache prefix (KV cache) for all subsequent user/assistant/tool message history.

Real-world Impact & Production Evidence

In real-world long-running Claude Desktop sessions (e.g. 100+ turns), this defect causes prompt cache reuse to freeze at the initial system prompt boundary:

  • Observed historical degradation: cachedInputTokens remained strictly frozen at 24,576 tokens across dozens of turns while total input expanded from 60,000 to 137,000 tokens. The cache hit rate collapsed from 40.7% down to 19.4%, resulting in >110,000 un-cached tokens billed at full price on every single turn.
  • Controlled mitigation proof: By suppressing dynamic mid-conversation reminders on the client side (outputStyle: default and totalTokensReminder: off), instructions length growth across turns became exactly 0. In the exact same Claude Desktop 3P harness over 10 turns (70k-92k tokens), cachedInputTokens successfully grew with the conversation (51k -> 70k -> 79k), achieving 96.24% ~ 99.93% cache hit rate.

Reproduction

  1. Start OpenCodex on port 10100.
  2. Send a multi-turn Claude Messages request where Turn 1 contains messages: [{ role: "user", content: "..." }, { role: "system", content: "reminder 1" }].
  3. Send Turn 2 with messages: [{ role: "user", ... }, { role: "system", ... }, { role: "assistant", ... }, { role: "user", ... }, { role: "system", content: "reminder 2" }].
  4. Inspect the translated Responses request body.instructions:
    • Observe that Turn 1 instructions has length L1;
    • Turn 2 instructions has length L2 = L1 + delta;
    • The top-level instruction header has changed, breaking the prefix match for all preceding conversation history on upstream providers (Anthropic, OpenCode Go, DeepSeek, etc.).

Version

2.48.0

Operating system

macOS 15 (Darwin arm64)

Provider and model

All providers routed through Claude inbound (opencode-go/muse-spark-1.3-contributor, sensenova/deepseek-v4-flash, etc.)

Logs or error output

Turn 1: input=60,153, cached=0 (0.0%)
Turn 2: input=60,444, cached=24,576 (40.7%)
Turn 6: input=65,020, cached=24,576 (37.8%)
Turn 11: input=75,423, cached=24,576 (32.6%)
Turn 21: input=84,487, cached=24,576 (29.1%)
Turn 51: input=105,858, cached=24,576 (23.2%)
Turn 101: input=137,458, cached=26,624 (19.4%)
// Note: cachedInputTokens frozen at 24,576 while input expanded to 137k.

Expected Behavior & Design Consideration

Mid-conversation role: system messages should either:

  1. Be mapped into the message sequence as chronological input items (e.g. role: "developer" in Responses input) rather than hoisted into top-level instructions, preserving the immutable prefix; OR
  2. Provide a deduplication/filter policy so static/identical recurring reminders do not continuously append to instructions.

Note on downstream adapters: Care must be taken with src/adapters/openai-chat.ts:724, which currently hoists developer items back into systemParts for non-native OpenAI routes.

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions