fix(sse): preserve Claude Code cache breakpoints (#8934)

Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates green: static + changed tests + vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-213228-suite.log
This commit is contained in:
Aaron Scherer
2026-08-05 19:41:54 -05:00
committed by GitHub
parent 8b6dbe2a67
commit de9fe1a231
5 changed files with 191 additions and 12 deletions

View File

@@ -323,6 +323,23 @@ function isContext1mModel(model: unknown): boolean {
);
}
export function shouldUseMidConversationSystem(
body: Record<string, unknown> | null | undefined,
model?: string | null
): boolean {
const payload = body || {};
const hasSystem =
!!payload.system &&
(typeof payload.system === "string" ||
(Array.isArray(payload.system) && payload.system.length > 0));
const hasTools = Array.isArray(payload.tools) && payload.tools.length > 0;
const effectiveModel = model ?? (typeof payload.model === "string" ? payload.model : "");
return (
hasSystem && hasTools && matchesModelPrefix(effectiveModel, CONTEXT_1M_BETA_MODEL_PREFIXES)
);
}
/**
* Pick the anthropic-beta flag set that matches the request shape. Real CLI
* uses three patterns: minimal probe, structured-output, and full agent.
@@ -375,8 +392,7 @@ export function selectBetaFlags(
const isFullAgent = hasTools && hasSystem;
const effectiveModel = model ?? (typeof b.model === "string" ? b.model : "");
const isHeavyAgent = isFullAgent && isHeavyAgentModel(effectiveModel);
const isOpusAgent =
isFullAgent && matchesModelPrefix(effectiveModel, CONTEXT_1M_BETA_MODEL_PREFIXES);
const isOpusAgent = shouldUseMidConversationSystem(b, effectiveModel);
const isContext1m = isFullAgent && isContext1mModel(effectiveModel);
const flags: string[] = [];

View File

@@ -79,6 +79,7 @@ import { FORMATS } from "../translator/formats.ts";
import { collectCustomToolNamesForSourceFormat } from "../translator/request/openai-responses/additionalTools.ts";
import { sanitizeKiroTools } from "../utils/kiroSanitizer.ts";
import { splitMisplacedToolResults } from "../translator/helpers/claudeHelper.ts";
import { ensureCacheControlOnLastUserMessage } from "../services/claudeCodeConstraints.ts";
import {
createSSETransformStreamWithLogger,
createPassthroughStreamWithLogger,
@@ -119,6 +120,7 @@ import {
normalizeClaudeAdaptiveThinking,
normalizeClaudeDisabledThinkingEffort,
} from "../services/claudeAdaptiveThinking.ts";
import { shouldUseMidConversationSystem } from "../executors/claudeIdentity.ts";
import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts";
import { applyDefaultReasoningEffort } from "../services/defaultReasoningEffort.ts";
import { echoModelInObject } from "../services/responseModelEcho.ts";
@@ -2065,20 +2067,23 @@ export async function handleChatCore({
}
}
// Fix #2468: always extract role:"system" → top-level system.
// The semantic passthrough correctly skips the Claude→OpenAI→Claude
// round-trip, but even pure Claude bodies may carry system content as
// role:"system" messages rather than the top-level system field, which
// Anthropic's Messages API now rejects with a 400.
// Legacy models reject role:"system" messages. Opus accepts them behind
// its beta, and hoisting them breaks the prompt cache prefix.
if (isClaudeCodeSemanticPassthrough) {
// Only lift system/developer messages — preserves Claude Code's
// native payload structure (documents, tool chains, thinking, etc.)
extractSystemRoleMessages(translatedBody);
if (
provider !== "claude" ||
!shouldUseMidConversationSystem(translatedBody, effectiveModel)
) {
extractSystemRoleMessages(translatedBody);
}
if (Array.isArray(translatedBody.messages)) {
translatedBody.messages = splitMisplacedToolResults(
translatedBody.messages as ClaudeMessage[]
) as typeof translatedBody.messages;
}
if (provider === "claude") {
ensureCacheControlOnLastUserMessage(translatedBody);
}
} else {
normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true });
}

View File

@@ -128,6 +128,20 @@ export function ensureCacheControlOnLastUserMessage(body: Record<string, unknown
const messages = body.messages as Array<Record<string, unknown>> | undefined;
if (!Array.isArray(messages) || messages.length === 0) return;
const system = body.system as Array<Record<string, unknown>> | undefined;
const systemCacheControlCount = Array.isArray(system)
? system.filter((block) => block.cache_control).length
: 0;
for (const message of messages) {
const content = message.content as Array<Record<string, unknown>> | undefined;
if (Array.isArray(content) && content.some((block) => block.cache_control)) {
return;
}
}
if (systemCacheControlCount >= MAX_CACHE_CONTROL_BLOCKS) return;
// Find the last user message
for (let i = messages.length - 1; i >= 0; i--) {
if (String(messages[i].role) === "user") {

View File

@@ -762,6 +762,60 @@ test("chatCore normalizes native Claude Code messages for native Claude OAuth pa
assert.equal(call.body.messages[2].content[0].type, "tool_result");
});
test("chatCore preserves Opus 5 mid-conversation system cache breakpoints", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
invalidateCacheControlSettingsCache();
const { call, result } = await invokeChatCore({
provider: "claude",
model: "claude-opus-5",
endpoint: "/v1/messages",
credentials: { apiKey: "claude-key", providerSpecificData: {} },
body: {
model: "claude-opus-5",
max_tokens: 64,
system: [
{
type: "text",
text: "stable system prompt",
cache_control: { type: "ephemeral", ttl: "5m" },
},
],
messages: [
{ role: "user", content: [{ type: "text", text: "first turn" }] },
{ role: "assistant", content: [{ type: "text", text: "first response" }] },
{
role: "system",
content: [
{
type: "text",
text: "compact continuation",
cache_control: { type: "ephemeral" },
},
],
},
{ role: "user", content: [{ type: "text", text: "latest turn" }] },
],
tools: [{ name: "Bash", input_schema: { type: "object", properties: {} } }],
},
userAgent: "Claude-Code/2.1.220",
requestHeaders: { "x-app": "cli", "x-claude-code-session-id": "session-123" },
responseFormat: "claude",
});
assert.equal(result.success, true);
assert.deepEqual(
call.body.messages.map((message: { role: string }) => message.role),
["user", "assistant", "system", "user"]
);
assert.deepEqual(call.body.messages[2].content[0].cache_control, { type: "ephemeral" });
assert.equal(
call.body.system.some((block: { text?: string }) => block.text === "compact continuation"),
false
);
assert.equal(call.body.messages[3].content[0].cache_control, undefined);
});
test("chatCore keeps Claude normalization for non-Claude-Code Claude passthrough", async () => {
const { call, result } = await invokeChatCore({
provider: "claude",
@@ -937,6 +991,52 @@ test("chatCore preserves cache_control automatically for Claude Code single-mode
assert.equal(call.body.tools[0].cache_control, undefined);
});
test("chatCore supplements a missing message cache breakpoint for native Claude Code requests", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
invalidateCacheControlSettingsCache();
const { call } = await invokeChatCore({
provider: "claude",
model: "claude-sonnet-4-6",
endpoint: "/v1/messages",
credentials: { apiKey: "claude-key", providerSpecificData: {} },
body: {
model: "claude-sonnet-4-6",
max_tokens: 64,
system: [
{
type: "text",
text: "stable system prompt",
cache_control: { type: "ephemeral", ttl: "5m" },
},
{
type: "text",
text: "stable project instructions",
cache_control: { type: "ephemeral", ttl: "5m" },
},
],
messages: [
{ role: "user", content: [{ type: "text", text: "first turn" }] },
{ role: "assistant", content: [{ type: "text", text: "first response" }] },
{ role: "user", content: [{ type: "text", text: "latest turn" }] },
],
tools: [
{
name: "lookup_weather",
description: "Fetch weather",
input_schema: { type: "object" },
cache_control: { type: "ephemeral", ttl: "5m" },
},
],
},
userAgent: "Claude-Code/1.0.0",
responseFormat: "claude",
});
assert.deepEqual(call.body.messages[2].content[0].cache_control, { type: "ephemeral" });
assert.equal(call.body.tools[0].cache_control, undefined);
});
test("chatCore auto cache policy becomes false for nondeterministic combos", async () => {
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
invalidateCacheControlSettingsCache();

View File

@@ -341,15 +341,59 @@ describe("enforceCacheControlLimit", () => {
});
describe("ensureCacheControlOnLastUserMessage", () => {
it("does not throw on a valid messages array", () => {
it("adds a breakpoint to the last user message when messages have none", () => {
const body = {
system: [
{ type: "text", text: "s1", cache_control: { type: "ephemeral" } },
{ type: "text", text: "s2", cache_control: { type: "ephemeral" } },
],
messages: [
{ role: "user", content: [{ type: "text", text: "Hello" }] },
{ role: "assistant", content: [{ type: "text", text: "Hi!" }] },
{ role: "user", content: [{ type: "text", text: "Follow up" }] },
],
};
assert.doesNotThrow(() => ensureCacheControlOnLastUserMessage(body));
ensureCacheControlOnLastUserMessage(body);
assert.deepEqual(body.messages[2].content[0].cache_control, { type: "ephemeral" });
});
it("keeps an existing message breakpoint without adding another", () => {
const body = {
messages: [
{
role: "user",
content: [
{
type: "text",
text: "Hello",
cache_control: { type: "ephemeral" },
},
],
},
{ role: "user", content: [{ type: "text", text: "Follow up" }] },
],
};
ensureCacheControlOnLastUserMessage(body);
assert.equal(body.messages[1].content[0].cache_control, undefined);
});
it("does not exceed four surviving system and message breakpoints", () => {
const body = {
system: Array.from({ length: 4 }, (_, index) => ({
type: "text",
text: `s${index}`,
cache_control: { type: "ephemeral" },
})),
messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
};
ensureCacheControlOnLastUserMessage(body);
assert.equal(body.messages[0].content[0].cache_control, undefined);
});
it("handles body without messages without throwing", () => {