mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
521 lines
17 KiB
TypeScript
521 lines
17 KiB
TypeScript
/**
|
|
* Messages -> Chat Completions bridge (TDD).
|
|
*
|
|
* Pins the wire translation that lets Claude Code drive Kimi through
|
|
* Cloudflare's OpenAI-compatible endpoint using the Anthropic schema it speaks.
|
|
*
|
|
* Run just this file: pnpm vitest run tests/chat-bridge.test.ts
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
anthropicMessagesToOpenAIChat,
|
|
openAIChatToAnthropicMessage,
|
|
openAIChatStreamToAnthropic,
|
|
chatCompletionsUrl,
|
|
} from '../src/core/messages-chat-bridge.js';
|
|
|
|
const enc = (obj: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(obj));
|
|
const dec = (b: Uint8Array): any => JSON.parse(new TextDecoder().decode(b));
|
|
|
|
/** Convert an Anthropic request object to the Chat Completions object. */
|
|
function toChat(req: unknown): any {
|
|
return dec(anthropicMessagesToOpenAIChat(enc(req)));
|
|
}
|
|
|
|
describe('chatCompletionsUrl — accepts bare host, /v1 base, or full URL', () => {
|
|
it('uses a full /chat/completions URL verbatim (Cloudflare Workers AI shape)', () => {
|
|
const full =
|
|
'https://api.cloudflare.com/client/v4/accounts/abc/ai/v1/chat/completions';
|
|
expect(chatCompletionsUrl(full)).toBe(full);
|
|
// Trailing slashes are trimmed, not double-suffixed.
|
|
expect(chatCompletionsUrl(full + '/')).toBe(full);
|
|
});
|
|
|
|
it('appends /chat/completions to a /vN base', () => {
|
|
expect(chatCompletionsUrl('https://api.moonshot.ai/v1')).toBe(
|
|
'https://api.moonshot.ai/v1/chat/completions',
|
|
);
|
|
});
|
|
|
|
it('appends /v1/chat/completions to a bare host', () => {
|
|
expect(chatCompletionsUrl('https://example.test')).toBe(
|
|
'https://example.test/v1/chat/completions',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('anthropicMessagesToOpenAIChat — request translation', () => {
|
|
it('carries model, hoists system to a system message, and preserves user text', () => {
|
|
const out = toChat({
|
|
model: 'moonshotai/kimi-k3',
|
|
system: 'You are terse.',
|
|
max_tokens: 256,
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
});
|
|
expect(out.model).toBe('moonshotai/kimi-k3');
|
|
expect(out.max_tokens).toBe(256);
|
|
expect(out.messages[0]).toEqual({ role: 'system', content: 'You are terse.' });
|
|
expect(out.messages[1]).toEqual({ role: 'user', content: 'hi' });
|
|
});
|
|
|
|
it('joins an array-form system prompt into a single string', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
system: [
|
|
{ type: 'text', text: 'line one' },
|
|
{ type: 'text', text: 'line two' },
|
|
],
|
|
messages: [{ role: 'user', content: 'x' }],
|
|
});
|
|
expect(out.messages[0]).toEqual({ role: 'system', content: 'line one\nline two' });
|
|
});
|
|
|
|
it('collapses a lone text block to a plain string', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [{ role: 'user', content: [{ type: 'text', text: 'just text' }] }],
|
|
});
|
|
expect(out.messages[0]).toEqual({ role: 'user', content: 'just text' });
|
|
});
|
|
|
|
it('maps an image block to image_url with a data URL', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: 'look' },
|
|
{
|
|
type: 'image',
|
|
source: { type: 'base64', media_type: 'image/png', data: 'QUJD' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(out.messages[0].content).toEqual([
|
|
{ type: 'text', text: 'look' },
|
|
{ type: 'image_url', image_url: { url: 'data:image/png;base64,QUJD' } },
|
|
]);
|
|
});
|
|
|
|
it('lifts assistant tool_use into OpenAI tool_calls with stringified arguments', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'text', text: 'calling' },
|
|
{ type: 'tool_use', id: 'toolu_1', name: 'get_weather', input: { city: 'SF' } },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
const msg = out.messages[0];
|
|
expect(msg.role).toBe('assistant');
|
|
expect(msg.content).toBe('calling');
|
|
expect(msg.tool_calls).toEqual([
|
|
{
|
|
id: 'toolu_1',
|
|
type: 'function',
|
|
function: { name: 'get_weather', arguments: JSON.stringify({ city: 'SF' }) },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('omits Anthropic thinking blocks from assistant history', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'thinking', thinking: 'private reasoning', signature: 'sig' },
|
|
{ type: 'redacted_thinking', data: 'opaque' },
|
|
{ type: 'text', text: 'visible answer' },
|
|
{ type: 'tool_use', id: 'toolu_1', name: 'lookup', input: { q: 'x' } },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(out.messages).toEqual([
|
|
{
|
|
role: 'assistant',
|
|
content: 'visible answer',
|
|
tool_calls: [
|
|
{
|
|
id: 'toolu_1',
|
|
type: 'function',
|
|
function: { name: 'lookup', arguments: JSON.stringify({ q: 'x' }) },
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('turns a user tool_result into a standalone tool message', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'tool_result', tool_use_id: 'toolu_1', content: '72F and sunny' },
|
|
{ type: 'text', text: 'thanks' },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(out.messages[0]).toEqual({
|
|
role: 'tool',
|
|
tool_call_id: 'toolu_1',
|
|
content: '72F and sunny',
|
|
});
|
|
expect(out.messages[1]).toEqual({ role: 'user', content: 'thanks' });
|
|
});
|
|
|
|
it('prefixes a failed tool_result so the model sees the error', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'tool_result', tool_use_id: 't', content: 'boom', is_error: true },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(out.messages[0].content).toBe('[Tool execution failed]\nboom');
|
|
});
|
|
|
|
it('forwards tool_result images in a following multimodal user message', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'toolu_1',
|
|
content: [
|
|
{ type: 'text', text: 'Rendered image:' },
|
|
{
|
|
type: 'image',
|
|
source: { type: 'base64', media_type: 'image/png', data: 'QUJD' },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(out.messages).toEqual([
|
|
{ role: 'tool', tool_call_id: 'toolu_1', content: 'Rendered image:' },
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,QUJD' } }],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('places returned images before the follow-up text for vision providers', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'toolu_1',
|
|
content: [
|
|
{ type: 'text', text: 'Image read successfully.' },
|
|
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } },
|
|
],
|
|
},
|
|
{ type: 'text', text: 'What is in the image?' },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(out.messages[1].content).toEqual([
|
|
{ type: 'image_url', image_url: { url: 'data:image/png;base64,QUJD' } },
|
|
{ type: 'text', text: 'What is in the image?' },
|
|
]);
|
|
});
|
|
|
|
it('keeps parallel tool responses contiguous before returned images', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'toolu_1',
|
|
content: [{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'QQ==' } }],
|
|
},
|
|
{ type: 'tool_result', tool_use_id: 'toolu_2', content: 'second result' },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
expect(out.messages.map((message: any) => message.role)).toEqual(['tool', 'tool', 'user']);
|
|
expect(out.messages[0]).toMatchObject({ tool_call_id: 'toolu_1', content: '' });
|
|
expect(out.messages[1]).toMatchObject({ tool_call_id: 'toolu_2', content: 'second result' });
|
|
expect(out.messages[2].content).toEqual([
|
|
{ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,QQ==' } },
|
|
]);
|
|
});
|
|
|
|
it('translates tools and tool_choice into the OpenAI function schema', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [{ role: 'user', content: 'x' }],
|
|
tool_choice: { type: 'tool', name: 'lookup' },
|
|
tools: [
|
|
{
|
|
name: 'lookup',
|
|
description: 'find a thing',
|
|
input_schema: { type: 'object', properties: { q: { type: 'string' } } },
|
|
},
|
|
],
|
|
});
|
|
expect(out.tool_choice).toEqual({ type: 'function', function: { name: 'lookup' } });
|
|
expect(out.tools).toEqual([
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'lookup',
|
|
description: 'find a thing',
|
|
parameters: { type: 'object', properties: { q: { type: 'string' } } },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('maps tool_choice:any to required and passes stop_sequences as stop', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [{ role: 'user', content: 'x' }],
|
|
tool_choice: { type: 'any' },
|
|
stop_sequences: ['STOP'],
|
|
});
|
|
expect(out.tool_choice).toBe('required');
|
|
expect(out.stop).toEqual(['STOP']);
|
|
});
|
|
|
|
it('requests stream usage so a streamed turn reports token counts', () => {
|
|
const streamed = toChat({ model: 'm', messages: [{ role: 'user', content: 'x' }], stream: true });
|
|
expect(streamed.stream).toBe(true);
|
|
expect(streamed.stream_options).toEqual({ include_usage: true });
|
|
// Non-streaming turns must not carry stream_options.
|
|
const buffered = toChat({ model: 'm', messages: [{ role: 'user', content: 'x' }] });
|
|
expect(buffered.stream_options).toBeUndefined();
|
|
});
|
|
|
|
it('maps enabled thinking budgets to a reasoning_effort bucket', () => {
|
|
const base = { model: 'm', messages: [{ role: 'user', content: 'x' }] };
|
|
const low = toChat({ ...base, thinking: { type: 'enabled', budget_tokens: 4096 } });
|
|
const medium = toChat({ ...base, thinking: { type: 'enabled', budget_tokens: 16000 } });
|
|
const high = toChat({ ...base, thinking: { type: 'enabled', budget_tokens: 32000 } });
|
|
expect(low.reasoning_effort).toBe('low');
|
|
expect(medium.reasoning_effort).toBe('medium');
|
|
expect(high.reasoning_effort).toBe('high');
|
|
// Disabled or absent thinking leaves reasoning_effort unset.
|
|
expect(toChat({ ...base, thinking: { type: 'disabled' } }).reasoning_effort).toBeUndefined();
|
|
expect(toChat(base).reasoning_effort).toBeUndefined();
|
|
});
|
|
|
|
it('rejects a non-array messages field as an invalid request', () => {
|
|
expect(() => anthropicMessagesToOpenAIChat(enc({ model: 'm', messages: 'nope' }))).toThrow(
|
|
/messages must be an array/,
|
|
);
|
|
});
|
|
|
|
it('maps in-conversation system-role messages (new Claude Code shape) to chat system messages', () => {
|
|
// Newer Claude Code builds inject system reminders as role:"system" entries
|
|
// inside `messages`, alongside user/assistant turns.
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
|
{ role: 'system', content: [{ type: 'text', text: 'Available agent types: cdp' }] },
|
|
],
|
|
});
|
|
expect(out.messages.map((m: any) => m.role)).toEqual(['user', 'system']);
|
|
expect(out.messages[1]).toEqual({ role: 'system', content: 'Available agent types: cdp' });
|
|
});
|
|
|
|
it('drops empty system-role messages and rejects unknown roles', () => {
|
|
const out = toChat({
|
|
model: 'm',
|
|
messages: [
|
|
{ role: 'system', content: [] },
|
|
{ role: 'user', content: 'q' },
|
|
],
|
|
});
|
|
expect(out.messages.map((m: any) => m.role)).toEqual(['user']);
|
|
expect(() =>
|
|
anthropicMessagesToOpenAIChat(enc({ model: 'm', messages: [{ role: 'tool', content: 'x' }] })),
|
|
).toThrow(/user, assistant, or system role/);
|
|
});
|
|
});
|
|
|
|
describe('openAIChatToAnthropicMessage — buffered response translation', () => {
|
|
it('wraps assistant text and normalizes id/usage/stop_reason', () => {
|
|
const msg = openAIChatToAnthropicMessage(
|
|
{
|
|
id: 'chatcmpl-abc',
|
|
model: 'moonshotai/kimi-k3',
|
|
choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: 'hello' } }],
|
|
usage: { prompt_tokens: 10, completion_tokens: 3 },
|
|
},
|
|
'fallback',
|
|
);
|
|
expect(msg.id).toBe('msg_abc');
|
|
expect(msg.type).toBe('message');
|
|
expect(msg.role).toBe('assistant');
|
|
expect(msg.model).toBe('moonshotai/kimi-k3');
|
|
expect(msg.content).toEqual([{ type: 'text', text: 'hello' }]);
|
|
expect(msg.stop_reason).toBe('end_turn');
|
|
expect(msg.usage).toEqual({
|
|
input_tokens: 10,
|
|
output_tokens: 3,
|
|
cache_creation_input_tokens: 0,
|
|
cache_read_input_tokens: 0,
|
|
});
|
|
});
|
|
|
|
it('subtracts cached tokens from input and reports them as cache_read', () => {
|
|
const msg = openAIChatToAnthropicMessage(
|
|
{
|
|
choices: [{ finish_reason: 'stop', message: { content: 'x' } }],
|
|
usage: { prompt_tokens: 100, completion_tokens: 5, prompt_tokens_details: { cached_tokens: 40 } },
|
|
},
|
|
'fallback',
|
|
);
|
|
expect(msg.usage).toMatchObject({ input_tokens: 60, cache_read_input_tokens: 40 });
|
|
});
|
|
|
|
it('converts tool_calls into tool_use and sets stop_reason tool_use', () => {
|
|
const msg = openAIChatToAnthropicMessage(
|
|
{
|
|
choices: [
|
|
{
|
|
finish_reason: 'tool_calls',
|
|
message: {
|
|
content: null,
|
|
tool_calls: [
|
|
{ id: 'call_9', function: { name: 'search', arguments: '{"q":"cats"}' } },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
'fallback',
|
|
);
|
|
expect(msg.stop_reason).toBe('tool_use');
|
|
expect(msg.content).toEqual([
|
|
{ type: 'tool_use', id: 'call_9', name: 'search', input: { q: 'cats' } },
|
|
]);
|
|
});
|
|
|
|
it('maps finish_reason length to max_tokens and falls back to the model name', () => {
|
|
const msg = openAIChatToAnthropicMessage(
|
|
{ choices: [{ finish_reason: 'length', message: { content: 'truncated' } }] },
|
|
'moonshotai/kimi-k3',
|
|
);
|
|
expect(msg.stop_reason).toBe('max_tokens');
|
|
expect(msg.model).toBe('moonshotai/kimi-k3');
|
|
expect(msg.id).toBe('msg_pxpipe');
|
|
});
|
|
|
|
it('emits an empty text block when the assistant returns no content', () => {
|
|
const msg = openAIChatToAnthropicMessage(
|
|
{ choices: [{ finish_reason: 'stop', message: { content: '' } }] },
|
|
'fallback',
|
|
);
|
|
expect(msg.content).toEqual([{ type: 'text', text: '' }]);
|
|
});
|
|
});
|
|
|
|
describe('openAIChatStreamToAnthropic — SSE translation', () => {
|
|
async function collect(chunks: string[]): Promise<string> {
|
|
const src = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
const e = new TextEncoder();
|
|
for (const c of chunks) controller.enqueue(e.encode(c));
|
|
controller.close();
|
|
},
|
|
});
|
|
const out = openAIChatStreamToAnthropic(src, 'moonshotai/kimi-k3');
|
|
const reader = out.getReader();
|
|
const d = new TextDecoder();
|
|
let text = '';
|
|
for (;;) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
text += d.decode(value, { stream: true });
|
|
}
|
|
return text;
|
|
}
|
|
|
|
it('renders a text stream as Anthropic content-block events', async () => {
|
|
const text = await collect([
|
|
'data: {"id":"chatcmpl-1","model":"moonshotai/kimi-k3","choices":[{"delta":{"role":"assistant","content":"Hel"}}]}\n\n',
|
|
'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n',
|
|
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
|
'data: [DONE]\n\n',
|
|
]);
|
|
expect(text).toContain('event: message_start');
|
|
expect(text).toContain('"id":"msg_1"');
|
|
expect(text).toContain('event: content_block_start');
|
|
expect(text).toContain('event: content_block_delta');
|
|
expect(text).toContain('Hel');
|
|
expect(text).toContain('lo');
|
|
expect(text).toContain('event: content_block_stop');
|
|
expect(text).toContain('event: message_delta');
|
|
expect(text).toContain('event: message_stop');
|
|
});
|
|
|
|
it('surfaces a malformed upstream event as an Anthropic error event', async () => {
|
|
const text = await collect(['data: {not json}\n\n']);
|
|
expect(text).toContain('event: error');
|
|
expect(text).toContain('api_error');
|
|
});
|
|
});
|
|
|
|
describe('anthropicMessagesToOpenAIChat — model override', () => {
|
|
it('stamps the override model id, replacing the client-sent claude-* id', () => {
|
|
const out = dec(
|
|
anthropicMessagesToOpenAIChat(
|
|
enc({ model: 'claude-opus-4-6', messages: [{ role: 'user', content: 'hi' }] }),
|
|
'@cf/moonshotai/kimi-k2-instruct',
|
|
),
|
|
);
|
|
expect(out.model).toBe('@cf/moonshotai/kimi-k2-instruct');
|
|
});
|
|
|
|
it('preserves the client-sent model id when no override is supplied', () => {
|
|
expect(toChat({ model: 'kimi-k3', messages: [{ role: 'user', content: 'hi' }] }).model).toBe(
|
|
'kimi-k3',
|
|
);
|
|
});
|
|
|
|
it('ignores an empty-string override (treated as absent)', () => {
|
|
const out = dec(
|
|
anthropicMessagesToOpenAIChat(
|
|
enc({ model: 'kimi-k3', messages: [{ role: 'user', content: 'hi' }] }),
|
|
'',
|
|
),
|
|
);
|
|
expect(out.model).toBe('kimi-k3');
|
|
});
|
|
});
|