diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 3c240717ad..7a4af8eb7c 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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)) { diff --git a/open-sse/handlers/chatCore/claudeSystemRole.ts b/open-sse/handlers/chatCore/claudeSystemRole.ts index 106d521ad0..a84f3f9b72 100644 --- a/open-sse/handlers/chatCore/claudeSystemRole.ts +++ b/open-sse/handlers/chatCore/claudeSystemRole.ts @@ -164,6 +164,61 @@ export function extractSystemRoleMessages(payload: Record): 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): void { + if (!Array.isArray(payload.messages) || payload.messages.length === 0) return; + const messages = payload.messages as Array>; + const isSystemRole = (role: unknown): boolean => + typeof role === "string" && + (role.toLowerCase() === "system" || role.toLowerCase() === "developer"); + + const blocks: Array> = []; + const kept: Array> = []; + 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>) { + 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>), ...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]`. diff --git a/tests/unit/claude-leading-text-system-hoist.test.ts b/tests/unit/claude-leading-text-system-hoist.test.ts new file mode 100644 index 0000000000..95787bea7c --- /dev/null +++ b/tests/unit/claude-leading-text-system-hoist.test.ts @@ -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 = { + 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 = { + 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 = { + 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>; + 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 = { + system: "base", + messages: [{ role: "user", content: "hello" }], + }; + hoistLeadingTextSystemMessages(payload); + assert.equal(payload.system, "base"); + assert.equal((payload.messages as unknown[]).length, 1); +});