fix(sse): relocate directive-only messages off messages[0] (#10457)

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 <houminxi@gmail.com>
This commit is contained in:
Bob.Hou
2026-08-16 00:51:40 +08:00
committed by GitHub
parent e168b2347e
commit 8ff7f7daf0
4 changed files with 516 additions and 2 deletions

View File

@@ -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(

View File

@@ -135,6 +135,21 @@ export function extractSystemRoleMessages(payload: Record<string, unknown>): 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<string, unknown>;
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<string, unknown>): 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<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 isEmptySystem = (m: Record<string, unknown>): boolean =>
m != null &&
typeof m === "object" &&
isSystemRole(m.role) &&
Array.isArray(m.content) &&
m.content.length === 0;
const isDirectiveOnly = (m: Record<string, unknown>): 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),
];
}

View File

@@ -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" });
});

View File

@@ -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);
});