fix(sse): hoist leading text system messages on the Claude mid-conversation-system passthrough (#13072)

On the Claude Code semantic passthrough with a 1M-context model (system + tools
present), system-role messages are deliberately kept inside messages[] and only
directive-only messages (content: [] + output_config) are relocated off
messages[0]. Anthropic also rejects a text-bearing system message at messages[0]:

    messages.0: use the top-level 'system' parameter for the initial system
    prompt; the directive-only form (content: [] with output_config) is
    accepted at any position

That is exactly where the Output Styles injection lands
(open-sse/services/compression/outputStyles/apply.ts unshifts a system message),
so every Claude Code turn with an output style active on such a model fails
with 400.

Add hoistLeadingTextSystemMessages(): move only the leading run of text-bearing
system-role messages (everything before the first user/assistant turn) into the
top-level system parameter, and call it before relocateDirectiveOnlyMessages()
on that path. Genuine mid-conversation system turns keep their position and
cache prefix; directive-only messages in the run are left for the existing
relocation. Unit tests cover the injected-style case, the string top-level
system case, mixed directive/text runs, and the no-op case.

Repro: POST /v1/messages with Claude Code client headers (user-agent
claude-cli/..., x-app: cli), model claude-opus-5, a top-level system, one tool,
and messages[0] = {role: "system", content: "[OmniRoute Output Styles] ..."}.
Before: 400 from Anthropic. After: 200.

Co-authored-by: ai-stack <ops@ai-stack.local>
This commit is contained in:
JasonBroderick
2026-09-10 00:59:05 +01:00
committed by GitHub
parent c0b2253f21
commit a3ca33fa64
3 changed files with 138 additions and 0 deletions

View File

@@ -9,10 +9,12 @@ import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts";
import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts";
import {
extractSystemRoleMessages,
hoistLeadingTextSystemMessages,
relocateDirectiveOnlyMessages,
} from "./chatCore/claudeSystemRole.ts";
export {
extractSystemRoleMessages,
hoistLeadingTextSystemMessages,
relocateDirectiveOnlyMessages,
} from "./chatCore/claudeSystemRole.ts";
import { checkIdempotencyCache } from "./chatCore/idempotency.ts";
@@ -2312,6 +2314,10 @@ export async function handleChatCore({
// messages[], but a directive-only message (content: [] +
// output_config) at messages[0] is rejected by Anthropic. Move it past
// the first real turn; Anthropic accepts the form at any other position.
// A text-bearing system message at messages[0] (e.g. the Output Styles
// injection) is rejected there too: hoist the leading run into the
// top-level `system` parameter first.
hoistLeadingTextSystemMessages(translatedBody);
relocateDirectiveOnlyMessages(translatedBody);
}
if (Array.isArray(translatedBody.messages)) {

View File

@@ -164,6 +164,61 @@ export function extractSystemRoleMessages(payload: Record<string, unknown>): voi
payload.messages = messages.filter((m) => !isSystemRole(m.role));
}
/**
* Hoists the leading run of text-bearing system-role messages (everything
* before the first real user/assistant turn) into the top-level `system`
* parameter. Anthropic treats `messages[0]` as the initial system prompt
* position and rejects any non-directive system-role message there ("use the
* top-level 'system' parameter for the initial system prompt"), which is
* exactly where the Output Styles injection lands on the mid-conversation
* system passthrough (provider `claude` + 1M-context models). Only the leading
* run is hoisted so genuine mid-conversation system turns keep their position
* and cache prefix; empty (directive-only) messages in the run are left in
* place for relocateDirectiveOnlyMessages to handle.
*/
export function hoistLeadingTextSystemMessages(payload: Record<string, unknown>): void {
if (!Array.isArray(payload.messages) || payload.messages.length === 0) return;
const messages = payload.messages as Array<Record<string, unknown>>;
const isSystemRole = (role: unknown): boolean =>
typeof role === "string" &&
(role.toLowerCase() === "system" || role.toLowerCase() === "developer");
const blocks: Array<Record<string, unknown>> = [];
const kept: Array<Record<string, unknown>> = [];
let i = 0;
for (; i < messages.length; i++) {
const m = messages[i];
if (m == null || typeof m !== "object" || !isSystemRole(m.role)) break;
if (typeof m.content === "string") {
if (m.content.length > 0) blocks.push({ type: "text", text: m.content });
continue;
}
if (Array.isArray(m.content) && m.content.length > 0) {
let hoisted = false;
for (const block of m.content as Array<Record<string, unknown>>) {
if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) {
blocks.push({ type: "text", text: block.text });
hoisted = true;
}
}
if (!hoisted) kept.push(m);
continue;
}
kept.push(m);
}
if (blocks.length === 0) return;
const existing = payload.system;
if (typeof existing === "string" && existing.length > 0) {
payload.system = [{ type: "text", text: existing }, ...blocks];
} else if (Array.isArray(existing)) {
payload.system = [...(existing as Array<Record<string, unknown>>), ...blocks];
} else {
payload.system = blocks;
}
payload.messages = [...kept, ...messages.slice(i)];
}
/**
* Moves a directive-only system message (empty content array + message-level
* `output_config`, the shape Claude Code clients emit) off `messages[0]`.

View File

@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
hoistLeadingTextSystemMessages,
relocateDirectiveOnlyMessages,
} from "../../open-sse/handlers/chatCore/claudeSystemRole.ts";
// Reproduction of the production 400 (2026-09-03/04): Output Styles injects a
// text system message at messages[0]; on the mid-conversation-system passthrough
// Anthropic rejects it ("use the top-level 'system' parameter for the initial
// system prompt").
test("hoistLeadingTextSystemMessages moves a text system messages[0] into top-level system", () => {
const payload: Record<string, unknown> = {
system: [{ type: "text", text: "You are Claude." }],
output_config: { effort: "medium" },
messages: [
{ role: "system", content: "[OmniRoute Output Styles]\nRespond terse." },
{ role: "user", content: "Reply exactly: MIDCONV_TOPLEVEL_OK" },
],
};
hoistLeadingTextSystemMessages(payload);
assert.deepEqual(payload.system, [
{ type: "text", text: "You are Claude." },
{ type: "text", text: "[OmniRoute Output Styles]\nRespond terse." },
]);
assert.equal((payload.messages as Array<{ role: string }>)[0].role, "user");
assert.equal((payload.messages as unknown[]).length, 1);
});
test("hoistLeadingTextSystemMessages converts a string top-level system and keeps mid-conversation system turns", () => {
const payload: Record<string, unknown> = {
system: "base",
messages: [
{ role: "system", content: [{ type: "text", text: "style" }] },
{ role: "user", content: "hello" },
{ role: "system", content: "mid-conversation context" },
{ role: "assistant", content: "hi" },
],
};
hoistLeadingTextSystemMessages(payload);
assert.deepEqual(payload.system, [
{ type: "text", text: "base" },
{ type: "text", text: "style" },
]);
const roles = (payload.messages as Array<{ role: string }>).map((m) => m.role);
assert.deepEqual(roles, ["user", "system", "assistant"]);
});
test("hoistLeadingTextSystemMessages leaves directive-only messages for relocateDirectiveOnlyMessages", () => {
const payload: Record<string, unknown> = {
messages: [
{ role: "system", content: [], output_config: { effort: "high" } },
{ role: "system", content: "style" },
{ role: "user", content: "hello" },
{ role: "assistant", content: "hi" },
],
};
hoistLeadingTextSystemMessages(payload);
assert.deepEqual(payload.system, [{ type: "text", text: "style" }]);
relocateDirectiveOnlyMessages(payload);
const msgs = payload.messages as Array<Record<string, unknown>>;
assert.equal(msgs[0].role, "user");
assert.equal(msgs[1].role, "system");
assert.deepEqual(msgs[1].output_config, { effort: "high" });
assert.equal(msgs[2].role, "assistant");
});
test("hoistLeadingTextSystemMessages is a no-op for a normal user first message", () => {
const payload: Record<string, unknown> = {
system: "base",
messages: [{ role: "user", content: "hello" }],
};
hoistLeadingTextSystemMessages(payload);
assert.equal(payload.system, "base");
assert.equal((payload.messages as unknown[]).length, 1);
});