mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
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:
@@ -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)) {
|
||||
|
||||
@@ -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]`.
|
||||
|
||||
77
tests/unit/claude-leading-text-system-hoist.test.ts
Normal file
77
tests/unit/claude-leading-text-system-hoist.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user