From 8ff7f7daf01d21a7e838a0f9a9271908d4e0c6ab Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Sun, 16 Aug 2026 00:51:40 +0800 Subject: [PATCH] fix(sse): relocate directive-only messages off messages[0] (#10457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream Messages API rejects directive-style messages (empty content array with a message-level output_config) when they sit at messages[0] — the initial system prompt position — while accepting the form at any other position. Measured in production: 122x 400 on the offical-claude combo in one hour. The mid-conversation-system passthrough (official provider + 1M-context beta models) keeps system-role messages inside messages[], so a directive that arrived first went upstream unchanged. relocateDirectiveOnlyMessages() moves the whole leading run of empty system messages: directive-only ones past the first real turn, plain empties dropped. extractSystemRoleMessages() now folds a directive's output_config into the top-level parameter instead of silently discarding it. Signed-off-by: Minxi Hou --- open-sse/handlers/chatCore.ts | 10 +- .../handlers/chatCore/claudeSystemRole.ts | 97 ++++++ ...aude-directive-midconv-passthrough.test.ts | 120 ++++++++ .../claude-directive-only-relocation.test.ts | 291 ++++++++++++++++++ 4 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 tests/unit/claude-directive-midconv-passthrough.test.ts create mode 100644 tests/unit/claude-directive-only-relocation.test.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8976a4272b..8fbb425eda 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -7,8 +7,8 @@ import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts"; import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts"; import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts"; import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts"; -import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; -export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts"; +import { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts"; +export { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts"; import { checkIdempotencyCache } from "./chatCore/idempotency.ts"; import { checkSemanticCache } from "./chatCore/semanticCache.ts"; import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts"; @@ -2132,6 +2132,12 @@ export async function handleChatCore({ !shouldUseMidConversationSystem(translatedBody, effectiveModel) ) { extractSystemRoleMessages(translatedBody); + } else { + // The mid-conversation-system path keeps system-role messages inside + // 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. + relocateDirectiveOnlyMessages(translatedBody); } if (Array.isArray(translatedBody.messages)) { translatedBody.messages = splitMisplacedToolResults( diff --git a/open-sse/handlers/chatCore/claudeSystemRole.ts b/open-sse/handlers/chatCore/claudeSystemRole.ts index a661847f29..106d521ad0 100644 --- a/open-sse/handlers/chatCore/claudeSystemRole.ts +++ b/open-sse/handlers/chatCore/claudeSystemRole.ts @@ -135,6 +135,21 @@ export function extractSystemRoleMessages(payload: Record): voi } } } + // Directive payload (message-level output_config, as emitted by Claude + // Code clients): the message itself is lifted away, so fold its output + // configuration into the top-level parameter instead of silently dropping + // it — whatever shape the content had. An explicit top-level output_config + // wins, and among several directive messages the first one wins. + if (payload.output_config == null) { + const directive = sm as Record; + if ( + directive.output_config != null && + typeof directive.output_config === "object" && + !Array.isArray(directive.output_config) + ) { + payload.output_config = directive.output_config; + } + } } if (extraBlocks.length > 0) { const existingSystem = payload.system; @@ -148,3 +163,85 @@ export function extractSystemRoleMessages(payload: Record): voi } payload.messages = messages.filter((m) => !isSystemRole(m.role)); } + +/** + * Moves a directive-only system message (empty content array + message-level + * `output_config`, the shape Claude Code clients emit) off `messages[0]`. + * + * Anthropic treats `messages[0]` as the initial system prompt position and + * rejects the directive-only form there ("use the top-level 'system' parameter + * for the initial system prompt"), while accepting it at any other position. + * The mid-conversation-system passthrough (provider `claude` + 1M-context beta + * models) deliberately keeps system-role messages inside `messages[]`, so a + * directive that arrived first would go upstream unchanged and 400. Relocate it + * past the first real turn instead; when the conversation has no real turn at + * all, fold the `output_config` into the top-level parameter (which wins when + * already present) and drop the now-empty message. + */ +export function relocateDirectiveOnlyMessages(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 isEmptySystem = (m: Record): boolean => + m != null && + typeof m === "object" && + isSystemRole(m.role) && + Array.isArray(m.content) && + m.content.length === 0; + const isDirectiveOnly = (m: Record): boolean => + isEmptySystem(m) && + m.output_config != null && + typeof m.output_config === "object" && + !Array.isArray(m.output_config); + + if (!isEmptySystem(messages[0])) { + return; + } + + // Collect the whole leading run of empty system messages so consecutive + // directives are all relocated in one pass (handling only messages[0] would + // leave the second directive at the rejected position). + let runEnd = 0; + while (runEnd < messages.length && isEmptySystem(messages[runEnd])) { + runEnd++; + } + const lead = messages.slice(0, runEnd); + const directives = lead.filter(isDirectiveOnly); + + // First real (user/assistant) turn after the run. System messages with text + // content are not safe insertion anchors — keep walking past them, and past + // any non-object entries a malformed body may carry. + let insertAfter = -1; + for (let i = runEnd; i < messages.length; i++) { + const candidate = messages[i]; + if ( + candidate != null && + typeof candidate === "object" && + !isSystemRole(candidate.role) + ) { + insertAfter = i; + break; + } + } + + if (insertAfter === -1) { + // No real turn to relocate after: fold the first directive's + // output_config into the top-level parameter (an explicit top-level value + // wins) and drop the whole run. + if (payload.output_config == null && directives.length > 0) { + payload.output_config = directives[0].output_config; + } + payload.messages = messages.slice(runEnd); + return; + } + + // Move the directives (in order) past the first real turn; plain empty + // system messages carry nothing and are dropped. + payload.messages = [ + ...messages.slice(runEnd, insertAfter + 1), + ...directives, + ...messages.slice(insertAfter + 1), + ]; +} diff --git a/tests/unit/claude-directive-midconv-passthrough.test.ts b/tests/unit/claude-directive-midconv-passthrough.test.ts new file mode 100644 index 0000000000..ca1a95684f --- /dev/null +++ b/tests/unit/claude-directive-midconv-passthrough.test.ts @@ -0,0 +1,120 @@ +// @ts-nocheck +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-directive-midconv-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); + +const originalFetch = globalThis.fetch; + +function noopLog() { + return { + debug() {}, + info() {}, + warn() {}, + error() {}, + }; +} + +async function flushAsyncSideEffects() { + for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve)); +} + +test.afterEach(async () => { + globalThis.fetch = originalFetch; + await flushAsyncSideEffects(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("claude mid-conversation-system passthrough relocates a directive-only messages[0]", async () => { + let captured = null; + + globalThis.fetch = async (url, init = {}) => { + captured = { + url: String(url), + method: init.method ?? "GET", + headers: new Headers(init.headers), + body: JSON.parse(String(init.body || "{}")), + }; + return new Response( + JSON.stringify({ + id: "msg_test", + type: "message", + role: "assistant", + model: "claude-opus-5", + content: [{ type: "text", text: "OK" }], + usage: { input_tokens: 4, output_tokens: 1 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }; + + const body = { + model: "claude-opus-5", + max_tokens: 64, + system: [{ type: "text", text: "You are Claude." }], + tools: [{ name: "Bash", description: "Run a command", input_schema: { type: "object" } }], + messages: [ + { role: "system", content: [], output_config: { effort: "medium" } }, + { role: "user", content: "hello" }, + ], + stream: false, + }; + + const result = await handleChatCore({ + body: structuredClone(body), + modelInfo: { provider: "claude", model: "claude-opus-5", extendedContext: false }, + credentials: { apiKey: "test-claude-key", providerSpecificData: {} }, + log: noopLog(), + clientRawRequest: { + endpoint: "/v1/messages", + body: structuredClone(body), + headers: new Headers({ + accept: "application/json", + "content-type": "application/json", + "user-agent": "claude-code/2.1.154", + }), + }, + userAgent: "claude-code/2.1.154", + }); + + assert.ok(captured, "fetch was not called"); + assert.ok(captured.url.startsWith("https://api.anthropic.com/v1/messages")); + assert.equal(captured.method, "POST"); + assert.ok(captured.headers.get("x-api-key"), "x-api-key header missing"); + assert.ok(captured.headers.get("anthropic-version"), "anthropic-version header missing"); + assert.equal(result.success, true); + // The directive-only message must not sit at messages[0] when it reaches upstream. + const upstreamMessages = captured.body.messages; + assert.equal(upstreamMessages[0].role, "user"); + assert.equal(upstreamMessages[1].role, "system"); + assert.deepEqual(upstreamMessages[1].output_config, { effort: "medium" }); + // The relocation must not disturb anything else the client sent. + assert.deepEqual(upstreamMessages[1].content, []); + assert.equal(upstreamMessages[0].content, "hello"); + // The claude identity layer prepends its own blocks; assert the client's + // block survived rather than an exact count. + assert.ok( + captured.body.system.some( + (block) => block.type === "text" && block.text === "You are Claude." + ) + ); + assert.equal(captured.body.tools.length, 1); + // The directive stays message-level; the top level (if set) is the base + // executor's own default injection, not the hoisted directive value. + assert.notDeepEqual(captured.body.output_config, { effort: "medium" }); +}); diff --git a/tests/unit/claude-directive-only-relocation.test.ts b/tests/unit/claude-directive-only-relocation.test.ts new file mode 100644 index 0000000000..bfa82d4533 --- /dev/null +++ b/tests/unit/claude-directive-only-relocation.test.ts @@ -0,0 +1,291 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + extractSystemRoleMessages, + relocateDirectiveOnlyMessages, +} from "../../open-sse/handlers/chatCore.ts"; + +// Claude Code 2.1.154+ clients send directives as system-role messages with an +// empty content array and a message-level output_config. Anthropic rejects the +// directive-only form when it lands at messages[0] (the initial system prompt +// position) while accepting it at any other position. Upstream error text: +// 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 +// Measured in production: 122x 400 in one hour on the offical-claude combo. + +test("relocateDirectiveOnlyMessages moves a directive-only messages[0] past the first real turn", () => { + const payload = { + messages: [ + { role: "system", content: [], output_config: { effort: "high" } }, + { role: "user", content: "hello" }, + { role: "assistant", content: "hi" }, + ], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 3); + assert.equal(payload.messages[0].role, "user"); + assert.equal(payload.messages[1].role, "system"); + assert.deepEqual(payload.messages[1].output_config, { effort: "high" }); + assert.equal(payload.messages[2].role, "assistant"); +}); + +test("relocateDirectiveOnlyMessages skips consecutive system messages to find the real turn", () => { + const payload = { + messages: [ + { role: "system", content: [], output_config: { effort: "high" } }, + { role: "system", content: "mid-conversation context" }, + { role: "user", content: "hello" }, + ], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 3); + assert.equal(payload.messages[0].role, "system"); + assert.equal(payload.messages[0].content, "mid-conversation context"); + assert.equal(payload.messages[1].role, "user"); + assert.equal(payload.messages[2].role, "system"); + assert.deepEqual(payload.messages[2].output_config, { effort: "high" }); +}); + +test("relocateDirectiveOnlyMessages drops an empty system message without output_config at messages[0]", () => { + const payload = { + messages: [ + { role: "system", content: [] }, + { role: "user", content: "hello" }, + ], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 1); + assert.equal(payload.messages[0].role, "user"); +}); + +test("relocateDirectiveOnlyMessages folds output_config to top level when no real turn exists", () => { + const payload = { + messages: [{ role: "system", content: [], output_config: { effort: "xhigh" } }], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 0); + assert.deepEqual(payload.output_config, { effort: "xhigh" }); +}); + +test("relocateDirectiveOnlyMessages keeps an existing top-level output_config untouched", () => { + const payload = { + output_config: { effort: "low" }, + messages: [{ role: "system", content: [], output_config: { effort: "xhigh" } }], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 0); + assert.deepEqual(payload.output_config, { effort: "low" }); +}); + +test("relocateDirectiveOnlyMessages is a no-op for a normal user first message", () => { + const payload = { + messages: [ + { role: "user", content: "hello" }, + { role: "system", content: [], output_config: { effort: "high" } }, + ], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 2); + assert.equal(payload.messages[0].role, "user"); + assert.equal(payload.messages[1].role, "system"); + assert.equal(payload.messages[1].content.length, 0); + assert.deepEqual(payload.messages[1].output_config, { effort: "high" }); +}); + +test("relocateDirectiveOnlyMessages relocates a directive after an empty system message", () => { + const payload = { + messages: [ + { role: "system", content: [] }, + { role: "system", content: [], output_config: { effort: "high" } }, + { role: "user", content: "hello" }, + ], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 2); + assert.equal(payload.messages[0].role, "user"); + assert.equal(payload.messages[1].role, "system"); + assert.deepEqual(payload.messages[1].output_config, { effort: "high" }); +}); + +test("relocateDirectiveOnlyMessages relocates consecutive directives in order", () => { + const payload = { + messages: [ + { role: "system", content: [], output_config: { effort: "high" } }, + { role: "system", content: [], output_config: { effort: "low" } }, + { role: "user", content: "hello" }, + ], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 3); + assert.equal(payload.messages[0].role, "user"); + assert.equal(payload.messages[1].role, "system"); + assert.deepEqual(payload.messages[1].output_config, { effort: "high" }); + assert.equal(payload.messages[2].role, "system"); + assert.deepEqual(payload.messages[2].output_config, { effort: "low" }); +}); + +test("relocateDirectiveOnlyMessages walks past a text system message to find the anchor", () => { + const payload = { + messages: [ + { role: "system", content: [], output_config: { effort: "high" } }, + { role: "system", content: "real system prompt" }, + { role: "user", content: "hello" }, + ], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 3); + assert.equal(payload.messages[0].content, "real system prompt"); + assert.equal(payload.messages[1].role, "user"); + assert.equal(payload.messages[2].role, "system"); + assert.deepEqual(payload.messages[2].output_config, { effort: "high" }); +}); + +test("relocateDirectiveOnlyMessages is a no-op for a system message with text content", () => { + const payload = { + messages: [ + { role: "system", content: "real system prompt" }, + { role: "user", content: "hello" }, + ], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 2); + assert.equal(payload.messages[0].content, "real system prompt"); +}); + +test("relocateDirectiveOnlyMessages handles a non-array messages field", () => { + const payload = { messages: "not-an-array" }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages, "not-an-array"); +}); + +test("relocateDirectiveOnlyMessages handles an empty messages array", () => { + const payload = { messages: [] }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 0); +}); + +test("relocateDirectiveOnlyMessages handles developer-role directives too", () => { + const payload = { + messages: [ + { role: "developer", content: [], output_config: { format: { type: "json_schema" } } }, + { role: "user", content: "hello" }, + ], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 2); + assert.equal(payload.messages[0].role, "user"); + assert.equal(payload.messages[1].role, "developer"); + assert.deepEqual(payload.messages[1].output_config, { + format: { type: "json_schema" }, + }); +}); + +test("extractSystemRoleMessages preserves the output_config of directive-only messages", () => { + const payload = { + messages: [ + { role: "system", content: "Memory context: foo" }, + { role: "system", content: [], output_config: { effort: "high" } }, + { role: "user", content: "hello" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 1); + assert.equal(payload.messages[0].role, "user"); + assert.deepEqual(payload.system, [{ type: "text", text: "Memory context: foo" }]); + assert.deepEqual(payload.output_config, { effort: "high" }); +}); + +test("extractSystemRoleMessages keeps an existing top-level output_config", () => { + const payload = { + output_config: { effort: "low" }, + messages: [ + { role: "system", content: [], output_config: { effort: "high" } }, + { role: "user", content: "hello" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 1); + assert.deepEqual(payload.output_config, { effort: "low" }); +}); + +test("extractSystemRoleMessages folds output_config even when the message also has text", () => { + const payload = { + messages: [ + { + role: "system", + content: [{ type: "text", text: "Text + directive" }], + output_config: { effort: "high" }, + }, + { role: "user", content: "hello" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 1); + assert.deepEqual(payload.system, [{ type: "text", text: "Text + directive" }]); + assert.deepEqual(payload.output_config, { effort: "high" }); +}); + +test("extractSystemRoleMessages keeps the first directive output_config among several", () => { + const payload = { + messages: [ + { role: "system", content: [], output_config: { effort: "high" } }, + { role: "system", content: [], output_config: { effort: "low" } }, + { role: "user", content: "hello" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 1); + assert.deepEqual(payload.output_config, { effort: "high" }); +}); + +test("extractSystemRoleMessages folds output_config for string-content messages too", () => { + const payload = { + messages: [ + { role: "system", content: "String content", output_config: { effort: "high" } }, + { role: "user", content: "hello" }, + ], + }; + extractSystemRoleMessages(payload); + assert.equal(payload.messages.length, 1); + assert.deepEqual(payload.system, [{ type: "text", text: "String content" }]); + assert.deepEqual(payload.output_config, { effort: "high" }); +}); + +test("relocateDirectiveOnlyMessages does not throw on a null first message", () => { + const payload = { + messages: [null, { role: "user", content: "hello" }], + }; + assert.doesNotThrow(() => relocateDirectiveOnlyMessages(payload)); + assert.equal(payload.messages.length, 2); +}); + +test("relocateDirectiveOnlyMessages does not throw on a null anchor candidate", () => { + const payload = { + messages: [ + { role: "system", content: [], output_config: { effort: "high" } }, + null, + { role: "user", content: "hello" }, + ], + }; + assert.doesNotThrow(() => relocateDirectiveOnlyMessages(payload)); + // The null entry stays where it was; the directive lands after the real turn. + assert.equal(payload.messages.length, 3); + assert.equal(payload.messages[0], null); + assert.equal(payload.messages[1].role, "user"); + assert.equal(payload.messages[2].role, "system"); + assert.deepEqual(payload.messages[2].output_config, { effort: "high" }); +}); + +test("relocateDirectiveOnlyMessages drops plain empties but keeps text system messages with no real turn", () => { + const payload = { + messages: [ + { role: "system", content: [] }, + { role: "system", content: "keep me" }, + ], + }; + relocateDirectiveOnlyMessages(payload); + assert.equal(payload.messages.length, 1); + assert.equal(payload.messages[0].content, "keep me"); + assert.equal(payload.output_config, undefined); +});