Files
ccs/tests/unit/proxy/transformers/request-transformer.test.ts
T
sn4p.dev b720231077 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.
2026-08-06 11:28:18 +02:00

155 lines
5.2 KiB
TypeScript

import { describe, expect, it } from 'bun:test';
import { ProxyRequestTransformer } from '../../../../src/proxy/transformers/request-transformer';
describe('ProxyRequestTransformer', () => {
it('translates Anthropic messages into OpenAI-compatible chat payloads', () => {
const transformer = new ProxyRequestTransformer();
const result = transformer.transform({
model: 'claude-sonnet-4.5',
stream: true,
messages: [
{ role: 'user', content: [{ type: 'text', text: 'Find release notes' }] },
{
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'release' } }],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'v7.69.1' }],
},
],
thinking: { type: 'enabled', budget_tokens: 9000 },
max_tokens: 1024,
temperature: 0.2,
top_p: 0.9,
stop_sequences: ['STOP'],
metadata: { trace: 'abc' },
});
expect(result.stream).toBe(true);
expect(result.reasoning_effort).toBe('high');
expect(result.reasoning).toBeUndefined();
expect(result.max_tokens).toBe(1024);
expect(result.temperature).toBe(0.2);
expect(result.top_p).toBe(0.9);
expect(result.stop).toEqual(['STOP']);
expect(result.metadata).toEqual({ trace: 'abc' });
expect(result.messages[0]).toEqual({ role: 'user', content: 'Find release notes' });
expect(result.messages[1]?.tool_calls?.[0]?.function.name).toBe('search');
expect(result.messages[2]).toEqual({
role: 'tool',
tool_call_id: 'toolu_1',
content: 'v7.69.1',
});
});
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: [
{ role: 'user', content: 'hello' },
{ role: 'system', content: [{ type: 'text', text: 'answer tersely' }] },
{ role: 'user', content: 'which model is this?' },
],
});
// 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: 'system', content: 'answer tersely' },
{ role: 'user', content: 'hello\nwhich model is this?' },
]);
});
it('translates base64 image blocks into OpenAI image_url parts', () => {
const transformer = new ProxyRequestTransformer();
const result = transformer.transform({
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Describe this image' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'ZmFrZS1pbWFnZS1ieXRlcw==',
},
},
],
},
],
});
expect(result.messages).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'Describe this image' },
{
type: 'image_url',
image_url: {
url: 'data:image/png;base64,ZmFrZS1pbWFnZS1ieXRlcw==',
},
},
],
},
]);
});
it('drops malformed optional fields but preserves the translated core request', () => {
const transformer = new ProxyRequestTransformer();
const result = transformer.transform({
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 'bad',
temperature: 'bad',
top_p: 'bad',
stop_sequences: ['A', 1],
metadata: 'bad',
});
expect(result.messages).toEqual([{ role: 'user', content: 'hello' }]);
expect(result.max_tokens).toBeUndefined();
expect(result.temperature).toBeUndefined();
expect(result.top_p).toBeUndefined();
expect(result.stop).toEqual(['A']);
expect(result.metadata).toBeUndefined();
});
it('sets stream_options.include_usage for streaming requests so upstreams return token usage', () => {
const transformer = new ProxyRequestTransformer();
const result = transformer.transform({
stream: true,
messages: [{ role: 'user', content: 'ping' }],
});
expect(result.stream).toBe(true);
expect(result.stream_options).toEqual({ include_usage: true });
});
it('omits stream_options for non-streaming requests', () => {
const transformer = new ProxyRequestTransformer();
const result = transformer.transform({
stream: false,
messages: [{ role: 'user', content: 'ping' }],
});
expect(result.stream).toBe(false);
expect(result.stream_options).toBeUndefined();
});
it('omits stream_options when the upstream flag is absent', () => {
const transformer = new ProxyRequestTransformer();
const result = transformer.transform({
messages: [{ role: 'user', content: 'ping' }],
});
expect(result.stream).toBe(false);
expect(result.stream_options).toBeUndefined();
});
});