mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-04 14:13:13 +00:00
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.
155 lines
5.2 KiB
TypeScript
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();
|
|
});
|
|
});
|