fix(proxy): hoist duplicate system messages before coalescing

Claude Code sends the system prompt as the top-level `system` field and,
separately, sends skill/plugin listings as `role: "system"` entries inside
`messages` (#1459 made the transformer accept those). `transform()`
unconditionally prepends the top-level field, so once both are present the
OpenAI-compat payload ends up with two `system` messages that are not
adjacent. `coalesceMessages` only merges consecutive same-role messages and
explicitly skips `system`, so it cannot fix this.

Strict OpenAI-compatible backends (LiteLLM among them) reject that shape with:
  400 A 'system' message can only appear at index 0 of the messages array.

Add `hoistSystemMessages`, run before `coalesceMessages`, which extracts every
`system` message in encounter order and reinserts a single merged one at
index 0. Content-preserving, no behavior change when at most one system
message is present.
This commit is contained in:
sn4p.dev committed 2026-08-06 11:28:18 +02:00
1 parent 7fb56d62e1
commit b720231077
3 files changed
+79 -4

No files matched your search

+44 -1
View File
@@ -671,6 +671,49 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] {
return translatedMessages;
}
/**
* Hoist every `role: "system"` message to a single leading system message.
*
* Claude Code sends the system prompt as the top-level `system` field *and*,
* separately, sends skill/plugin listings as `role: "system"` entries inside
* `messages` (see #1459). `ProxyRequestTransformer.transform` prepends the
* top-level `system` field unconditionally, so once both are present the
* resulting array holds two `system` messages that are not adjacent —
* `coalesceMessages` only merges *consecutive* same-role messages, so it
* cannot fix this case even if it did coalesce `system` (which it explicitly
* excludes below).
*
* Strict OpenAI-compatible backends (LiteLLM among them) reject any request
* where a `system` message is not alone at index 0:
* `400 A 'system' message can only appear at index 0 of the messages array.`
*
* This pass extracts all `system` messages in encounter order, joins their
* content with a blank line, and reinserts the result as the sole leading
* message — content-preserving, order-preserving for everything else.
*/
function hoistSystemMessages(messages: OpenAIMessage[]): OpenAIMessage[] {
const systemParts: string[] = [];
const rest: OpenAIMessage[] = [];
for (const message of messages) {
if (message.role !== 'system') {
rest.push(message);
continue;
}
const content = message.content;
const text = typeof content === 'string' ? content : '';
if (text.trim().length > 0) {
systemParts.push(text);
}
}
if (systemParts.length === 0) {
return rest;
}
return [{ role: 'system', content: systemParts.join('\n\n') }, ...rest];
}
/**
* Coalesce consecutive messages of the same role.
* OpenAI/vLLM/Ollama/Mistral require strict user<->assistant alternation.
@@ -741,7 +784,7 @@ export class ProxyRequestTransformer {
// was billed. See:
// https://platform.openai.com/docs/api-reference/chat-streaming
...(source.stream === true ? { stream_options: { include_usage: true } } : {}),
messages: coalesceMessages(allMessages),
messages: coalesceMessages(hoistSystemMessages(allMessages)),
max_tokens: asNumber(source.max_tokens),
temperature: asNumber(source.temperature),
top_p: asNumber(source.top_p),
@@ -321,4 +321,32 @@ describe('ProxyRequestTransformer regressions', () => {
expect(result.tool_choice).toBe('auto');
});
it('merges the top-level system field with a mid-array system message into one leading system message', () => {
// Claude Code sends the main system prompt via the top-level `system`
// field AND a skill/plugin listing as a `role: "system"` message inside
// `messages` (see #1459). Prepending the top-level field unconditionally
// used to leave two non-adjacent `system` messages in the payload, which
// strict OpenAI-compatible backends (LiteLLM among them) reject with:
// `400 A 'system' message can only appear at index 0 of the messages array.`
const result = new ProxyRequestTransformer().transform({
system: [{ type: 'text', text: 'You are Claude Code, a CLI tool.' }],
messages: [
{
role: 'system',
content: 'The following skills are available for use with the Skill tool:\n- foo',
},
{ role: 'user', content: 'ping' },
],
});
const systemMessages = result.messages.filter((message) => message.role === 'system');
expect(systemMessages).toHaveLength(1);
expect(result.messages[0]).toEqual({
role: 'system',
content:
'You are Claude Code, a CLI tool.\n\nThe following skills are available for use with the Skill tool:\n- foo',
});
expect(result.messages[1]).toEqual({ role: 'user', content: 'ping' });
});
});
@@ -43,7 +43,7 @@ describe('ProxyRequestTransformer', () => {
});
});
it('accepts Claude Code system messages in the messages array', () => {
it('accepts Claude Code system messages in the messages array and hoists them to a single leading system message', () => {
const transformer = new ProxyRequestTransformer();
const result = transformer.transform({
messages: [
@@ -53,10 +53,14 @@ describe('ProxyRequestTransformer', () => {
],
});
// Strict OpenAI-compatible backends (e.g. LiteLLM) reject any payload
// where `system` is not alone at index 0, so a mid-array `system`
// message must be hoisted rather than left in place. See #1459 for why
// the message must be accepted at all, and the coalesce-duplicate-system
// fix for why it can't simply stay where it landed.
expect(result.messages).toEqual([
{ role: 'user', content: 'hello' },
{ role: 'system', content: 'answer tersely' },
{ role: 'user', content: 'which model is this?' },
{ role: 'user', content: 'hello\nwhich model is this?' },
]);
});