From b720231077267e0fcf281ef77b6c188340f6a3b6 Mon Sep 17 00:00:00 2001 From: "sn4p.dev" Date: Thu, 6 Aug 2026 11:26:37 +0200 Subject: [PATCH 1/3] 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. --- src/proxy/transformers/request-transformer.ts | 45 ++++++++++++++++++- .../request-transformer-regressions.test.ts | 28 ++++++++++++ .../transformers/request-transformer.test.ts | 10 +++-- 3 files changed, 79 insertions(+), 4 deletions(-) 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?' }, ]); }); From c621241a2e308b6910b556696da40f1820ca9a1d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 8 Aug 2026 20:59:37 -0400 Subject: [PATCH 2/3] test(proxy): harden system message ordering Cover supported late-system and tool-result ordering invariants. Refs #1687 --- src/proxy/transformers/request-transformer.ts | 11 +-- .../request-transformer-regressions.test.ts | 72 ++++++++++++++++++- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index a87c4dff..bde13890 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -672,7 +672,7 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { } /** - * Hoist every `role: "system"` message to a single leading system message. + * Hoist every accepted `role: "system"` message to one 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 @@ -687,9 +687,12 @@ function transformMessages(messagesValue: unknown): OpenAIMessage[] { * 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. + * Inline system messages may appear between complete turns, including after + * tool results. They may not interrupt a pending assistant tool-call/result + * sequence; `transformMessages` rejects that ambiguous placement before this + * pass. Accepted system messages are extracted in encounter order, joined with + * a blank line, and reinserted as the sole leading message. Everything else + * keeps its relative order before normal same-role coalescing. */ function hoistSystemMessages(messages: OpenAIMessage[]): OpenAIMessage[] { const systemParts: string[] = []; diff --git a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts index bbeffec3..2497b2f4 100644 --- a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -332,11 +332,12 @@ describe('ProxyRequestTransformer regressions', () => { const result = new ProxyRequestTransformer().transform({ system: [{ type: 'text', text: 'You are Claude Code, a CLI tool.' }], messages: [ + { role: 'user', content: 'ping' }, { role: 'system', content: 'The following skills are available for use with the Skill tool:\n- foo', }, - { role: 'user', content: 'ping' }, + { role: 'user', content: 'pong' }, ], }); @@ -347,6 +348,73 @@ describe('ProxyRequestTransformer regressions', () => { 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' }); + expect(result.messages[1]).toEqual({ role: 'user', content: 'ping\npong' }); + }); + + it('hoists a late system message after complete parallel tool results without disturbing tool order', () => { + const result = new ProxyRequestTransformer().transform({ + system: 'base instructions', + messages: [ + { role: 'user', content: 'inspect both files' }, + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_1', name: 'read', input: { path: 'a.ts' } }, + { type: 'tool_use', id: 'toolu_2', name: 'read', input: { path: 'b.ts' } }, + ], + }, + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'a contents' }, + { type: 'tool_result', tool_use_id: 'toolu_2', content: 'b contents' }, + ], + }, + { role: 'system', content: 'late instructions' }, + { role: 'user', content: 'compare them' }, + ], + }); + + expect(result.messages).toEqual([ + { role: 'system', content: 'base instructions\n\nlate instructions' }, + { role: 'user', content: 'inspect both files' }, + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'toolu_1', + type: 'function', + function: { name: 'read', arguments: '{"path":"a.ts"}' }, + }, + { + id: 'toolu_2', + type: 'function', + function: { name: 'read', arguments: '{"path":"b.ts"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'a contents' }, + { role: 'tool', tool_call_id: 'toolu_2', content: 'b contents' }, + { role: 'user', content: 'compare them' }, + ]); + }); + + it('rejects a system message inserted before pending tool results', () => { + expect(() => + new ProxyRequestTransformer().transform({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'read', input: { path: 'a.ts' } }], + }, + { role: 'system', content: 'interrupting instructions' }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'a contents' }], + }, + ], + }) + ).toThrow('role must be "user" with tool_result blocks after assistant tool_use'); }); }); From 3e7f27fe9a56247e97527d8689f26c5eba4e961e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 9 Aug 2026 01:07:37 +0000 Subject: [PATCH 3/3] chore(release): 8.8.1-dev.17 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4bfbfe9c..134de238 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.8.1-dev.16", + "version": "8.8.1-dev.17", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",