diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index 9083c1da..a87c4dff 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -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), diff --git a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts index 656c4e7a..bbeffec3 100644 --- a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -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' }); + }); }); diff --git a/tests/unit/proxy/transformers/request-transformer.test.ts b/tests/unit/proxy/transformers/request-transformer.test.ts index f3258f07..847f58ba 100644 --- a/tests/unit/proxy/transformers/request-transformer.test.ts +++ b/tests/unit/proxy/transformers/request-transformer.test.ts @@ -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?' }, ]); });