kimi-code/packages/kosong/test/openai-legacy.test.ts

1786 lines
59 KiB
TypeScript

import { generate } from '#/generate';
import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message';
import { OpenAILegacyChatProvider } from '#/providers/openai-legacy';
import type { GenerateOptions } from '#/provider';
import type { Tool } from '#/tool';
import { describe, it, expect, vi } from 'vitest';
function makeChatCompletionResponse(model: string = 'test-model') {
return {
id: 'chatcmpl-test123',
object: 'chat.completion',
created: 1234567890,
model,
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Hello' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
};
}
function createProvider(
options?: Partial<{
stream: boolean;
reasoningKey: string;
model: string;
}>,
): OpenAILegacyChatProvider {
return new OpenAILegacyChatProvider({
model: options?.model ?? 'gpt-4.1',
apiKey: 'test-key',
stream: options?.stream ?? false,
reasoningKey: options?.reasoningKey,
});
}
/** Capture the request body sent to OpenAI by mocking the client. */
async function captureRequestBody(
provider: OpenAILegacyChatProvider,
systemPrompt: string,
tools: Tool[],
history: Message[],
options?: GenerateOptions,
): Promise<Record<string, unknown>> {
let capturedBody: Record<string, unknown> | undefined;
(provider as any)._client.chat.completions.create = vi
.fn()
.mockImplementation((params: unknown) => {
capturedBody = params as Record<string, unknown>;
return Promise.resolve(makeChatCompletionResponse());
});
const stream = await provider.generate(systemPrompt, tools, history, options);
for await (const part of stream) {
void part;
}
if (capturedBody === undefined) {
throw new Error('Expected provider.generate() to call chat.completions.create');
}
return capturedBody;
}
const ADD_TOOL: Tool = {
name: 'add',
description: 'Add two integers.',
parameters: {
type: 'object',
properties: {
a: { type: 'integer', description: 'First number' },
b: { type: 'integer', description: 'Second number' },
},
required: ['a', 'b'],
},
};
const MUL_TOOL: Tool = {
name: 'multiply',
description: 'Multiply two integers.',
parameters: {
type: 'object',
properties: {
a: { type: 'integer', description: 'First number' },
b: { type: 'integer', description: 'Second number' },
},
required: ['a', 'b'],
},
};
describe('OpenAILegacyChatProvider', () => {
describe('message conversion (COMMON_CASES)', () => {
it('simple user message with system prompt', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hello!' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, 'You are helpful.', [], history);
expect(body['messages']).toEqual([
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'Hello!' },
]);
expect(body['tools']).toBeUndefined();
});
it('multi-turn conversation', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'What is 2+2?' }], toolCalls: [] },
{ role: 'assistant', content: [{ type: 'text', text: '2+2 equals 4.' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'And 3+3?' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['messages']).toEqual([
{ role: 'user', content: 'What is 2+2?' },
{ role: 'assistant', content: '2+2 equals 4.' },
{ role: 'user', content: 'And 3+3?' },
]);
});
it('multi-turn with system prompt', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'What is 2+2?' }], toolCalls: [] },
{ role: 'assistant', content: [{ type: 'text', text: '2+2 equals 4.' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'And 3+3?' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, 'You are a math tutor.', [], history);
expect(body['messages']).toEqual([
{ role: 'system', content: 'You are a math tutor.' },
{ role: 'user', content: 'What is 2+2?' },
{ role: 'assistant', content: '2+2 equals 4.' },
{ role: 'user', content: 'And 3+3?' },
]);
});
it('image url content', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [
{ type: 'text', text: "What's in this image?" },
{ type: 'image_url', imageUrl: { url: 'https://example.com/image.png' } },
] satisfies ContentPart[],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['messages']).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: "What's in this image?" },
{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } },
],
},
]);
});
it('tool definitions', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [ADD_TOOL, MUL_TOOL], history);
expect(body['tools']).toEqual([
{
type: 'function',
function: {
name: 'add',
description: 'Add two integers.',
parameters: {
type: 'object',
properties: {
a: { type: 'integer', description: 'First number' },
b: { type: 'integer', description: 'Second number' },
},
required: ['a', 'b'],
},
},
},
{
type: 'function',
function: {
name: 'multiply',
description: 'Multiply two integers.',
parameters: {
type: 'object',
properties: {
a: { type: 'integer', description: 'First number' },
b: { type: 'integer', description: 'Second number' },
},
required: ['a', 'b'],
},
},
},
]);
});
it('tool call and tool result', async () => {
const provider = createProvider();
const toolCall: ToolCall = {
type: 'function',
id: 'call_abc123',
name: 'add',
arguments: '{"a": 2, "b": 3}',
};
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: "I'll add those numbers for you." }],
toolCalls: [toolCall],
},
{
role: 'tool',
content: [{ type: 'text', text: '5' }],
toolCallId: 'call_abc123',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['messages']).toEqual([
{ role: 'user', content: 'Add 2 and 3' },
{
role: 'assistant',
content: "I'll add those numbers for you.",
tool_calls: [
{
type: 'function',
id: 'call_abc123',
function: { name: 'add', arguments: '{"a": 2, "b": 3}' },
},
],
},
{ role: 'tool', content: '5', tool_call_id: 'call_abc123' },
]);
});
it('normalizes invalid historical tool call ids and matching tool results', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'Bash:7',
name: 'Bash',
arguments: '{"command":"pwd"}',
},
],
},
{
role: 'tool',
content: [{ type: 'text', text: '/tmp' }],
toolCallId: 'Bash:7',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['messages']).toEqual([
{ role: 'user', content: 'Run bash' },
{
role: 'assistant',
tool_calls: [
{
type: 'function',
id: 'Bash_7',
function: { name: 'Bash', arguments: '{"command":"pwd"}' },
},
],
},
{ role: 'tool', content: '/tmp', tool_call_id: 'Bash_7' },
]);
});
it('tool call with image result keeps the tool result textual and reattaches images as user input', async () => {
// OpenAI Chat Completions `tool` messages only accept text content.
// Even when toolMessageConversion is unset, a tool result containing
// image_url / audio_url / video_url parts must not be serialized as a
// multimodal array — the API would reject the request with a 400.
// The provider is expected to force `extract_text` in that case.
const provider = createProvider();
const toolCall: ToolCall = {
type: 'function',
id: 'call_abc123',
name: 'add',
arguments: '{"a": 2, "b": 3}',
};
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: "I'll add those numbers for you." }],
toolCalls: [toolCall],
},
{
role: 'tool',
content: [
{ type: 'text', text: '5' },
{ type: 'image_url', imageUrl: { url: 'https://example.com/image.png' } },
] satisfies ContentPart[],
toolCallId: 'call_abc123',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const messages = body['messages'] as Record<string, unknown>[];
const toolMsg = messages[2]!;
expect(toolMsg['role']).toBe('tool');
expect(toolMsg['tool_call_id']).toBe('call_abc123');
// Content must be a plain string, not a content-part array.
expect(typeof toolMsg['content']).toBe('string');
// The text segment must survive; the image must not appear as a
// structured image_url part inside the tool message.
expect(toolMsg['content']).toContain('5');
expect(Array.isArray(toolMsg['content'])).toBe(false);
expect(messages[3]).toEqual({
role: 'user',
content: [
{ type: 'text', text: 'Attached media from tool result:' },
{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } },
],
});
});
it('tool call with audio result notes the omission inline without reattaching', async () => {
// Chat Completions has no url-based audio/video content part (only
// base64 input_audio), so unlike images these cannot be reattached as
// a user message — a standard OpenAI endpoint would reject the request
// with a 400. The tool message notes the omission inline instead.
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Say hi' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'call_tts', name: 'tts', arguments: '{}' }],
},
{
role: 'tool',
content: [
{ type: 'audio_url', audioUrl: { url: 'https://example.com/hi.mp3' } },
] satisfies ContentPart[],
toolCallId: 'call_tts',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const messages = body['messages'] as Record<string, unknown>[];
expect(messages[2]).toEqual({
role: 'tool',
content: '(audio omitted: not supported by this provider)',
tool_call_id: 'call_tts',
});
// No follow-up user message: audio_url is not a standard Chat
// Completions content part and must not reach the wire.
expect(messages).toHaveLength(3);
});
it('tool call with text and video result appends the omission note to the text', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Record it' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [{ type: 'function', id: 'call_rec', name: 'record', arguments: '{}' }],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'recorded 5s clip' },
{ type: 'video_url', videoUrl: { url: 'https://example.com/rec.mp4' } },
] satisfies ContentPart[],
toolCallId: 'call_rec',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const messages = body['messages'] as Record<string, unknown>[];
expect(messages[2]).toEqual({
role: 'tool',
content: 'recorded 5s clip\n(video omitted: not supported by this provider)',
tool_call_id: 'call_rec',
});
expect(messages).toHaveLength(3);
});
it('groups consecutive tool result images after all matching tool messages', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Fetch both images' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
toolCalls: [
{ type: 'function', id: 'call_first', name: 'first_image', arguments: '{}' },
{ type: 'function', id: 'call_second', name: 'second_image', arguments: '{}' },
],
},
{
role: 'tool',
content: [
{ type: 'image_url', imageUrl: { url: 'https://example.com/first.png' } },
],
toolCallId: 'call_first',
toolCalls: [],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'second' },
{ type: 'image_url', imageUrl: { url: 'https://example.com/second.png' } },
],
toolCallId: 'call_second',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['messages']).toEqual([
{ role: 'user', content: 'Fetch both images' },
{
role: 'assistant',
content: 'ok',
tool_calls: [
{
type: 'function',
id: 'call_first',
function: { name: 'first_image', arguments: '{}' },
},
{
type: 'function',
id: 'call_second',
function: { name: 'second_image', arguments: '{}' },
},
],
},
{ role: 'tool', content: '(see attached media)', tool_call_id: 'call_first' },
{ role: 'tool', content: 'second', tool_call_id: 'call_second' },
{
role: 'user',
content: [
{ type: 'text', text: 'Attached media from tool result:' },
{ type: 'image_url', image_url: { url: 'https://example.com/first.png' } },
{ type: 'image_url', image_url: { url: 'https://example.com/second.png' } },
],
},
]);
});
it('parallel tool calls', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Calculate 2+3 and 4*5' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: "I'll calculate both." }],
toolCalls: [
{
type: 'function',
id: 'call_add',
name: 'add',
arguments: '{"a": 2, "b": 3}',
},
{
type: 'function',
id: 'call_mul',
name: 'multiply',
arguments: '{"a": 4, "b": 5}',
},
],
},
{
role: 'tool',
content: [
{ type: 'text', text: '<system-reminder>This is a system reminder</system-reminder>' },
{ type: 'text', text: '5' },
],
toolCallId: 'call_add',
toolCalls: [],
},
{
role: 'tool',
content: [
{ type: 'text', text: '<system-reminder>This is a system reminder</system-reminder>' },
{ type: 'text', text: '20' },
],
toolCallId: 'call_mul',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [ADD_TOOL, MUL_TOOL], history);
// Snapshot of the expected wire format:
// - 4 messages in order: user, assistant (with 2 tool_calls), tool (call_add), tool (call_mul)
// - user / assistant use compressed string content (single-TextPart compression)
// - both tool messages preserve multi-part content arrays (not compressed)
// - tools array preserved
expect(body['messages']).toEqual([
{ role: 'user', content: 'Calculate 2+3 and 4*5' },
{
role: 'assistant',
content: "I'll calculate both.",
tool_calls: [
{
type: 'function',
id: 'call_add',
function: { name: 'add', arguments: '{"a": 2, "b": 3}' },
},
{
type: 'function',
id: 'call_mul',
function: { name: 'multiply', arguments: '{"a": 4, "b": 5}' },
},
],
},
{
role: 'tool',
content: [
{
type: 'text',
text: '<system-reminder>This is a system reminder</system-reminder>',
},
{ type: 'text', text: '5' },
],
tool_call_id: 'call_add',
},
{
role: 'tool',
content: [
{
type: 'text',
text: '<system-reminder>This is a system reminder</system-reminder>',
},
{ type: 'text', text: '20' },
],
tool_call_id: 'call_mul',
},
]);
expect(body['tools']).toHaveLength(2);
});
});
describe('reasoning content', () => {
it('converts ThinkPart to configured reasoning key', async () => {
const provider = createProvider({ reasoningKey: 'reasoning_content' });
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'What is 2+2?' }], toolCalls: [] },
{
role: 'assistant',
content: [
{ type: 'think', think: 'Thinking...' },
{ type: 'text', text: '4.' },
],
toolCalls: [],
},
{ role: 'user', content: [{ type: 'text', text: 'Thanks!' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['messages']).toEqual([
{ role: 'user', content: 'What is 2+2?' },
{
role: 'assistant',
content: '4.',
reasoning_content: 'Thinking...',
},
{ role: 'user', content: 'Thanks!' },
]);
});
});
describe('generation kwargs', () => {
it('applies temperature and max_tokens', async () => {
const provider = createProvider().withGenerationKwargs({
temperature: 0.7,
max_tokens: 2048,
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['temperature']).toBe(0.7);
expect(body['max_tokens']).toBe(2048);
});
it('maps json_schema response format to response_format', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Extract contact' }], toolCalls: [] },
];
const schema = {
type: 'object',
properties: { name: { type: 'string' } },
required: ['name'],
additionalProperties: false,
};
const body = await captureRequestBody(provider, '', [], history, {
responseFormat: {
type: 'json_schema',
jsonSchema: {
name: 'contact',
schema,
strict: true,
},
},
});
expect(body['response_format']).toEqual({
type: 'json_schema',
json_schema: {
name: 'contact',
schema,
strict: true,
description: undefined,
},
});
});
it('withMaxCompletionTokens sets max_tokens on the cloned provider', async () => {
const original = createProvider();
const provider = original.withMaxCompletionTokens(1024);
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(provider).not.toBe(original);
expect(body['max_tokens']).toBe(1024);
});
it.each(['gpt-5', 'gpt-5-codex', 'o3'])(
'withMaxCompletionTokens sets max_completion_tokens for %s',
async (model) => {
const provider = createProvider({ model }).withMaxCompletionTokens(1024);
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['max_completion_tokens']).toBe(1024);
expect(body['max_tokens']).toBeUndefined();
},
);
it('keeps max_tokens for OpenAI-compatible non-OpenAI reasoning models', async () => {
const provider = createProvider({ model: 'deepseek-reasoner' }).withMaxCompletionTokens(
1024,
);
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['max_tokens']).toBe(1024);
expect(body['max_completion_tokens']).toBeUndefined();
});
it('withMaxCompletionTokens clamps to the 128k ceiling', async () => {
const provider = createProvider().withMaxCompletionTokens(1000000, {
usedContextTokens: 30000,
maxContextTokens: 1000000,
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
// 1000000 - 30000 = 970000, clamped to 131072
expect(body['max_tokens']).toBe(131072);
// The exposed effective cap matches the ceiling-clamped wire value —
// the request trace records this field.
expect(provider.maxCompletionTokens).toBe(131072);
});
});
describe('maxTokens option', () => {
it('wires OpenAILegacyOptions.maxTokens into the request body as max_tokens', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: false,
maxTokens: 1024,
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['max_tokens']).toBe(1024);
// The constructor-level cap is on the wire without any budget
// application, so the exposed cap must reflect it too.
expect(provider.maxCompletionTokens).toBe(1024);
});
it('does not inject max_tokens when maxTokens option is omitted', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: false,
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['max_tokens']).toBeUndefined();
});
it('exposes max_tokens via modelParameters', () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
maxTokens: 2048,
});
expect(provider.modelParameters['max_tokens']).toBe(2048);
});
});
describe('toolMessageConversion option', () => {
it('flattens tool message content to a string when set to extract_text', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: false,
toolMessageConversion: 'extract_text',
});
const toolCall: ToolCall = {
type: 'function',
id: 'call_abc123',
name: 'add',
arguments: '{"a": 2, "b": 3}',
};
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: "I'll add them." }],
toolCalls: [toolCall],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'part-1' },
{ type: 'text', text: 'part-2' },
],
toolCallId: 'call_abc123',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const messages = body['messages'] as Record<string, unknown>[];
const toolMsg = messages[2]!;
expect(toolMsg['role']).toBe('tool');
// With extract_text, content must be a plain string (concatenated text),
// not an array of content parts.
expect(typeof toolMsg['content']).toBe('string');
expect(toolMsg['content']).toBe('part-1part-2');
});
it('forces string content when tool result contains audio_url even with default conversion', async () => {
const provider = createProvider();
const toolCall: ToolCall = {
type: 'function',
id: 'call_audio',
name: 'fetch_audio',
arguments: '{}',
};
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Play it' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
toolCalls: [toolCall],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'audio result' },
{ type: 'audio_url', audioUrl: { url: 'https://example.com/a.mp3' } },
] satisfies ContentPart[],
toolCallId: 'call_audio',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const toolMsg = (body['messages'] as Record<string, unknown>[])[2]!;
expect(typeof toolMsg['content']).toBe('string');
});
it('forces string content when tool result contains video_url even with default conversion', async () => {
const provider = createProvider();
const toolCall: ToolCall = {
type: 'function',
id: 'call_video',
name: 'fetch_video',
arguments: '{}',
};
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Show it' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
toolCalls: [toolCall],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'video result' },
{ type: 'video_url', videoUrl: { url: 'https://example.com/v.mp4' } },
] satisfies ContentPart[],
toolCallId: 'call_video',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const toolMsg = (body['messages'] as Record<string, unknown>[])[2]!;
expect(typeof toolMsg['content']).toBe('string');
});
it('keeps default text-only tool message as plain string', async () => {
const provider = createProvider();
const toolCall: ToolCall = {
type: 'function',
id: 'call_text',
name: 'add',
arguments: '{"a":1,"b":2}',
};
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 1 2' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
toolCalls: [toolCall],
},
{
role: 'tool',
content: [{ type: 'text', text: '3' }],
toolCallId: 'call_text',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const toolMsg = (body['messages'] as Record<string, unknown>[])[2]!;
// Single-text tool result with default conversion stays as a plain string.
expect(toolMsg['content']).toBe('3');
});
it('preserves default tool message content as array when option is omitted', async () => {
const provider = createProvider();
const toolCall: ToolCall = {
type: 'function',
id: 'call_abc123',
name: 'add',
arguments: '{"a": 2, "b": 3}',
};
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] },
{
role: 'assistant',
content: [{ type: 'text', text: "I'll add them." }],
toolCalls: [toolCall],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'part-1' },
{ type: 'text', text: 'part-2' },
],
toolCallId: 'call_abc123',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const messages = body['messages'] as Record<string, unknown>[];
const toolMsg = messages[2]!;
// Default behavior: two text parts stay as a content-part array.
expect(Array.isArray(toolMsg['content'])).toBe(true);
});
});
describe('with thinking', () => {
it('.withThinking("high") sets reasoning_effort', async () => {
const provider = createProvider().withThinking('high');
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['reasoning_effort']).toBe('high');
});
it.each(['deepseek/deepseek-v4-flash', 'gpt-5.4-pro', 'some-model'])(
'.withThinking("xhigh") passes through reasoning_effort for model %s',
async (model) => {
const provider = createProvider({ model }).withThinking('xhigh');
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['reasoning_effort']).toBe('xhigh');
expect(provider.thinkingEffort).toBe('xhigh');
},
);
it('.withThinking("max") maps to xhigh without model-specific clamping', async () => {
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
];
const openAIChatModel = await captureRequestBody(
createProvider({ model: 'gpt-5.5' }).withThinking('max'),
'',
[],
history,
);
const openAIProModel = await captureRequestBody(
createProvider({ model: 'gpt-5.5-pro' }).withThinking('max'),
'',
[],
history,
);
const deepSeekModel = await captureRequestBody(
createProvider({ model: 'deepseek/deepseek-v4-pro' }).withThinking('max'),
'',
[],
history,
);
expect(openAIChatModel['reasoning_effort']).toBe('xhigh');
expect(openAIProModel['reasoning_effort']).toBe('xhigh');
expect(deepSeekModel['reasoning_effort']).toBe('xhigh');
});
});
describe('auto reasoning_effort', () => {
it('auto-injects reasoning_effort when history has ThinkPart and reasoningKey is set', async () => {
const provider = createProvider({
model: 'kimi-k2.5',
reasoningKey: 'reasoning_content',
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hello' }], toolCalls: [] },
{
role: 'assistant',
content: [
{ type: 'think', think: 'Let me think...' },
{ type: 'text', text: 'Hi!' },
],
toolCalls: [],
},
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
// reasoning_effort should be auto-set because history contains ThinkPart
expect(body['reasoning_effort']).toBe('medium');
// reasoning_content should still be present in the message
const messages = body['messages'] as Record<string, unknown>[];
expect(messages[1]!['reasoning_content']).toBe('Let me think...');
});
it('does not auto-inject reasoning_effort when history has no ThinkPart', async () => {
const provider = createProvider({
model: 'kimi-k2.5',
reasoningKey: 'reasoning_content',
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hello' }], toolCalls: [] },
{ role: 'assistant', content: [{ type: 'text', text: 'Hi!' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['reasoning_effort']).toBeUndefined();
});
it('auto-injects reasoning_effort when history has ThinkPart even without explicit reasoningKey', async () => {
// No reasoningKey configured — the provider should still treat ThinkPart in
// history as a signal to inject reasoning_effort, so OpenAI-compatible
// gateways (One API, DeepSeek) that demand a paired reasoning_effort
// don't reject the request with 400.
const provider = createProvider({ model: 'some-model' });
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hello' }], toolCalls: [] },
{
role: 'assistant',
content: [
{ type: 'think', think: 'Thinking...' },
{ type: 'text', text: 'Hi!' },
],
toolCalls: [],
},
{ role: 'user', content: [{ type: 'text', text: 'How are you?' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['reasoning_effort']).toBe('medium');
});
it('does not overwrite reasoning_effort pinned via withGenerationKwargs', async () => {
// Auto-injection must yield to an explicit caller-set reasoning_effort,
// otherwise multi-turn requests silently downgrade a 'high' / 'low'
// setting back to 'medium' once the history contains ThinkPart.
const provider = createProvider({ model: 'some-model' }).withGenerationKwargs({
reasoning_effort: 'high',
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hello' }], toolCalls: [] },
{
role: 'assistant',
content: [
{ type: 'think', think: 'thinking' },
{ type: 'text', text: 'Hi!' },
],
toolCalls: [],
},
{ role: 'user', content: [{ type: 'text', text: 'Again?' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['reasoning_effort']).toBe('high');
});
});
describe('default reasoning protocol (no explicit reasoningKey)', () => {
it('serializes ThinkPart back to reasoning_content even without reasoningKey', async () => {
// The whole point of issue #69: a hand-written config.toml never sets
// reasoningKey, but the round-trip must still work against DeepSeek-style
// providers — otherwise the next turn sends the assistant message without
// any reasoning field and the server rejects it.
const provider = createProvider({ model: 'deepseek-reasoner' });
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'q' }], toolCalls: [] },
{
role: 'assistant',
content: [
{ type: 'think', think: 'inner monologue' },
{ type: 'text', text: 'answer' },
],
toolCalls: [],
},
{ role: 'user', content: [{ type: 'text', text: 'next' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
const messages = body['messages'] as Record<string, unknown>[];
expect(messages[1]).toEqual({
role: 'assistant',
content: 'answer',
reasoning_content: 'inner monologue',
});
});
it('explicit reasoningKey overrides the default outbound field', async () => {
const provider = createProvider({
model: 'oddball-reasoner',
reasoningKey: 'reasoning_details',
});
const history: Message[] = [
{
role: 'assistant',
content: [
{ type: 'think', think: 'thinking' },
{ type: 'text', text: 'reply' },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const messages = body['messages'] as Record<string, unknown>[];
expect(messages[0]).toEqual({
role: 'assistant',
content: 'reply',
reasoning_details: 'thinking',
});
expect(messages[0]).not.toHaveProperty('reasoning_content');
});
it('yields ThinkPart from streaming response even without explicit reasoningKey', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'deepseek-reasoner',
apiKey: 'test-key',
stream: true,
});
async function* mockedStream(): AsyncIterable<Record<string, unknown>> {
yield { id: 'c1', choices: [{ index: 0, delta: { reasoning_content: 'think 1' } }] };
yield { id: 'c1', choices: [{ index: 0, delta: { reasoning_content: ' think 2' } }] };
yield { id: 'c1', choices: [{ index: 0, delta: { content: 'final' } }] };
yield { id: 'c1', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] };
}
(provider as any)._client.chat.completions.create = vi
.fn()
.mockResolvedValue(mockedStream());
const stream = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'q' }], toolCalls: [] }],
);
const parts: StreamedMessagePart[] = [];
for await (const part of stream) parts.push(part);
expect(parts).toEqual([
{ type: 'think', think: 'think 1' },
{ type: 'think', think: ' think 2' },
{ type: 'text', text: 'final' },
]);
});
it('treats blank reasoning_key as unset so defaults still apply', async () => {
// ModelAliasSchema accepts `reasoning_key = ""` (z.string().optional()).
// A blank value must not route reads/writes through an empty property
// name — it should fall back to the default protocol behavior.
const provider = createProvider({ model: 'm', reasoningKey: '' });
const history: Message[] = [
{
role: 'assistant',
content: [
{ type: 'think', think: 'thinking' },
{ type: 'text', text: 'answer' },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const messages = body['messages'] as Record<string, unknown>[];
expect(messages[0]).toEqual({
role: 'assistant',
content: 'answer',
reasoning_content: 'thinking',
});
expect(Object.keys(messages[0] ?? {})).not.toContain('');
});
it('trims whitespace around explicit reasoning_key before use', async () => {
const provider = createProvider({
model: 'm',
reasoningKey: ' reasoning_details ',
});
const history: Message[] = [
{
role: 'assistant',
content: [
{ type: 'think', think: 'thinking' },
{ type: 'text', text: 'answer' },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const messages = body['messages'] as Record<string, unknown>[];
expect(messages[0]).toEqual({
role: 'assistant',
content: 'answer',
reasoning_details: 'thinking',
});
expect(Object.keys(messages[0] ?? {})).not.toContain(' reasoning_details ');
});
});
// argument deltas. Each delta carries `index` to identify the owning
// tool call. The provider must preserve `index` on the yielded
// ToolCallPart (and `_streamIndex` on the ToolCall header) so that
// generate() can route deltas correctly.
interface MockToolCallDelta {
index: number;
id?: string;
function?: { name?: string; arguments?: string };
}
function makeChunk(
toolCalls: MockToolCallDelta[],
opts?: { finishReason?: string; usage?: boolean },
): Record<string, unknown> {
const chunk: Record<string, unknown> = {
id: 'chatcmpl-parallel',
object: 'chat.completion.chunk',
created: 1234567890,
model: 'gpt-4.1',
choices: [
{
index: 0,
delta: { tool_calls: toolCalls },
finish_reason: opts?.finishReason ?? null,
},
],
};
if (opts?.usage) {
chunk['usage'] = { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 };
}
return chunk;
}
async function* mockStream(
chunks: Record<string, unknown>[],
): AsyncIterable<Record<string, unknown>> {
for (const c of chunks) {
yield c;
}
}
it('routes interleaved parallel tool_call arguments by streaming index', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: true,
});
// Interleave argument deltas across index 0 and 1. In the real
// pre-fix code these deltas would all pile onto whichever tool call
// was "most recently pending", corrupting both sets of arguments.
const chunks = [
makeChunk([{ index: 0, id: 'call_a', function: { name: 'read_file', arguments: '' } }]),
makeChunk([{ index: 1, id: 'call_b', function: { name: 'write_file', arguments: '' } }]),
makeChunk([{ index: 0, function: { arguments: '{"path":"' } }]),
makeChunk([{ index: 1, function: { arguments: '{"path":"' } }]),
makeChunk([{ index: 0, function: { arguments: '/a.txt"' } }]),
makeChunk([{ index: 1, function: { arguments: '/b.txt","content":"hi"' } }]),
makeChunk([{ index: 0, function: { arguments: '}' } }]),
makeChunk([{ index: 1, function: { arguments: '}' } }]),
makeChunk([], { finishReason: 'tool_calls', usage: true }),
];
(
provider as unknown as { _client: { chat: { completions: { create: unknown } } } }
)._client.chat.completions.create = vi.fn().mockResolvedValue(mockStream(chunks));
const result = await generate(
provider,
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'do it' }], toolCalls: [] }],
);
expect(result.message.toolCalls).toEqual([
{
type: 'function',
id: 'call_a',
name: 'read_file',
arguments: '{"path":"/a.txt"}',
},
{
type: 'function',
id: 'call_b',
name: 'write_file',
arguments: '{"path":"/b.txt","content":"hi"}',
},
]);
// _streamIndex must not leak into the stored ToolCall shape.
for (const tc of result.message.toolCalls) {
expect(tc).not.toHaveProperty('_streamIndex');
}
});
it('does not early-ready indexed OpenAI tool calls at merge boundaries', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: true,
});
// OpenAI Chat Completions can legally send a later delta for an earlier
// tool_call.index after another indexed tool call has started. A provider
// without an explicit done signal must not mark index 0 ready merely
// because its current arguments happen to parse at that boundary.
const chunks = [
makeChunk([{ index: 0, id: 'call_a', function: { name: 'read_file', arguments: '' } }]),
makeChunk([{ index: 0, function: { arguments: '{"path":"a.txt"}' } }]),
makeChunk([{ index: 1, id: 'call_b', function: { name: 'read_file', arguments: '' } }]),
makeChunk([{ index: 0, function: { arguments: ' ' } }]),
makeChunk([{ index: 1, function: { arguments: '{"path":"b.txt"}' } }]),
makeChunk([], { finishReason: 'tool_calls', usage: true }),
];
(
provider as unknown as { _client: { chat: { completions: { create: unknown } } } }
)._client.chat.completions.create = vi.fn().mockResolvedValue(mockStream(chunks));
const events: string[] = [];
const result = await generate(
provider,
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'do it' }], toolCalls: [] }],
{
onMessagePart(part: StreamedMessagePart): void {
if (part.type === 'tool_call_part') {
events.push(`part:${part.index}:${part.argumentsPart}`);
}
},
onToolCall(toolCall: ToolCall): void {
events.push(`ready:${toolCall.id}:${toolCall.arguments ?? ''}`);
},
},
);
// onToolCall fires after stream drains, in final order.
expect(events).toEqual([
'part:0:{"path":"a.txt"}',
'part:0: ',
'part:1:{"path":"b.txt"}',
'ready:call_a:{"path":"a.txt"} ',
'ready:call_b:{"path":"b.txt"}',
]);
expect(result.message.toolCalls).toEqual([
{
type: 'function',
id: 'call_a',
name: 'read_file',
arguments: '{"path":"a.txt"} ',
},
{
type: 'function',
id: 'call_b',
name: 'read_file',
arguments: '{"path":"b.txt"}',
},
]);
});
it('preserves index on ToolCallPart when streaming single tool call', async () => {
// Single-tool-call path: verify `index` is present on yielded parts
// so generate() can still use the map-based routing if it chooses.
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: true,
});
const chunks = [
makeChunk([{ index: 0, id: 'call_x', function: { name: 'f', arguments: '' } }]),
makeChunk([{ index: 0, function: { arguments: '{"a":' } }]),
makeChunk([{ index: 0, function: { arguments: '1}' } }]),
makeChunk([], { finishReason: 'tool_calls', usage: true }),
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(provider as any)._client.chat.completions.create = vi
.fn()
.mockResolvedValue(mockStream(chunks));
const stream = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'x' }], toolCalls: [] }],
);
const parts: StreamedMessagePart[] = [];
for await (const part of stream) {
parts.push(part);
}
const header = parts.find((p) => p.type === 'function') as
| (ToolCall & { _streamIndex?: number | string })
| undefined;
expect(header).toMatchObject({ _streamIndex: 0 });
const partDeltas = parts.filter((p) => p.type === 'tool_call_part');
expect(partDeltas.length).toBeGreaterThan(0);
for (const p of partDeltas) {
expect((p as { index?: number | string }).index).toBe(0);
}
});
it('buffers indexed argument deltas until the real tool name arrives', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: true,
});
const chunks = [
makeChunk([{ index: 0, id: 'call_delayed', function: { name: '', arguments: '' } }]),
makeChunk([{ index: 0, function: { arguments: '{"a' } }]),
makeChunk([{ index: 0, function: { name: 'foo' } }]),
makeChunk([{ index: 0, function: { arguments: '":1}' } }]),
makeChunk([], { finishReason: 'tool_calls', usage: true }),
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(provider as any)._client.chat.completions.create = vi
.fn()
.mockResolvedValue(mockStream(chunks));
const result = await generate(
provider,
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'do it' }], toolCalls: [] }],
);
expect(result.message.toolCalls).toEqual([
{
type: 'function',
id: 'call_delayed',
name: 'foo',
arguments: '{"a":1}',
},
]);
});
});
describe('OpenAILegacyChatProvider — non-stream response parsing', () => {
function makeNonStreamResponse(message: Record<string, unknown>): Record<string, unknown> {
return {
id: 'chatcmpl-test123',
object: 'chat.completion',
created: 1234567890,
model: 'gpt-4.1',
choices: [{ index: 0, message, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
};
}
async function collectFromMockedResponse(
provider: OpenAILegacyChatProvider,
response: Record<string, unknown>,
): Promise<StreamedMessagePart[]> {
(provider as any)._client.chat.completions.create = vi.fn().mockResolvedValue(response);
const stream = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }],
);
const parts: StreamedMessagePart[] = [];
for await (const part of stream) parts.push(part);
return parts;
}
it('yields ThinkPart from non-stream response when reasoningKey content is present', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'deepseek-reasoner',
apiKey: 'test-key',
stream: false,
reasoningKey: 'reasoning_content',
});
const parts = await collectFromMockedResponse(
provider,
makeNonStreamResponse({
role: 'assistant',
content: 'Final answer',
reasoning_content: 'Some thinking here.',
}),
);
expect(parts).toEqual([
{ type: 'think', think: 'Some thinking here.' },
{ type: 'text', text: 'Final answer' },
]);
});
it('non-stream response yields ToolCall parts when tool_calls present', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: false,
});
const parts = await collectFromMockedResponse(
provider,
makeNonStreamResponse({
role: 'assistant',
content: null,
tool_calls: [
{
id: 'call_x',
type: 'function',
function: { name: 'lookup', arguments: '{"q":"hi"}' },
},
],
}),
);
const toolCall = parts.find((p) => p.type === 'function');
expect(toolCall).toMatchObject({
type: 'function',
id: 'call_x',
name: 'lookup',
arguments: '{"q":"hi"}',
});
});
it('non-stream response generates a fresh ID when tool_call has no id', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: false,
});
const parts = await collectFromMockedResponse(
provider,
makeNonStreamResponse({
role: 'assistant',
content: null,
tool_calls: [
{
type: 'function',
function: { name: 'lookup', arguments: '{}' },
},
],
}),
);
const toolCall = parts.find((p) => p.type === 'function');
expect(toolCall).toMatchObject({
type: 'function',
id: expect.stringMatching(/.+/),
});
});
it('non-stream response yields reasoning_content as ThinkPart', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'deepseek-reasoner',
apiKey: 'test-key',
stream: false,
reasoningKey: 'reasoning_content',
});
const parts = await collectFromMockedResponse(
provider,
makeNonStreamResponse({
role: 'assistant',
content: 'Final answer',
reasoning_content: 'Let me think step by step',
}),
);
expect(parts).toEqual([
{ type: 'think', think: 'Let me think step by step' },
{ type: 'text', text: 'Final answer' },
]);
});
it('yields ThinkPart from non-stream response even without explicit reasoningKey', async () => {
// Hand-written config path: provider has no reasoningKey, but the server
// (DeepSeek/Qwen/One API) returns reasoning_content. We must still surface
// the ThinkPart so users see the thinking and the next-turn round-trip
// serializes it back.
const provider = new OpenAILegacyChatProvider({
model: 'deepseek-reasoner',
apiKey: 'test-key',
stream: false,
});
const parts = await collectFromMockedResponse(
provider,
makeNonStreamResponse({
role: 'assistant',
content: 'Final answer',
reasoning_content: 'walking through it',
}),
);
expect(parts).toEqual([
{ type: 'think', think: 'walking through it' },
{ type: 'text', text: 'Final answer' },
]);
});
it('reads reasoning_details when only that field is present and no reasoningKey is set', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'oddball-reasoner',
apiKey: 'test-key',
stream: false,
});
const parts = await collectFromMockedResponse(
provider,
makeNonStreamResponse({
role: 'assistant',
content: 'answer',
reasoning_details: 'detail thinking',
}),
);
expect(parts).toEqual([
{ type: 'think', think: 'detail thinking' },
{ type: 'text', text: 'answer' },
]);
});
it('explicit reasoningKey limits inbound scan to that single field', async () => {
// When the user/catalog pins reasoningKey, the provider must read only that
// field — no implicit fallback to other known field names. This is the
// escape hatch for non-standard gateways.
const provider = new OpenAILegacyChatProvider({
model: 'oddball',
apiKey: 'test-key',
stream: false,
reasoningKey: 'reasoning_details',
});
const parts = await collectFromMockedResponse(
provider,
makeNonStreamResponse({
role: 'assistant',
content: 'answer',
reasoning_content: 'should be ignored',
}),
);
expect(parts).toEqual([{ type: 'text', text: 'answer' }]);
});
});
describe('OpenAILegacyChatProvider — non-indexed streaming tool_calls', () => {
async function* mockStream(
chunks: Record<string, unknown>[],
): AsyncIterable<Record<string, unknown>> {
for (const c of chunks) yield c;
}
it('handles non-indexed tool_call delta with concrete name', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: true,
});
const chunks = [
{
id: 'chatcmpl-noidx',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
id: 'call_noidx',
function: { name: 'foo', arguments: '{"a":1}' },
// No index!
},
],
},
},
],
},
];
(
provider as unknown as { _client: { chat: { completions: { create: unknown } } } }
)._client.chat.completions.create = vi.fn().mockResolvedValue(mockStream(chunks));
const result = await generate(
provider,
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'do' }], toolCalls: [] }],
);
expect(result.message.toolCalls).toEqual([
{
type: 'function',
id: 'call_noidx',
name: 'foo',
arguments: '{"a":1}',
},
]);
});
it('handles non-indexed tool_call delta with arguments only emits tool_call_part', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: true,
});
const chunks = [
{
id: 'chatcmpl-argonly',
choices: [
{
index: 0,
delta: {
tool_calls: [{ function: { arguments: '{"x":1}' } }],
},
},
],
},
];
(
provider as unknown as { _client: { chat: { completions: { create: unknown } } } }
)._client.chat.completions.create = vi.fn().mockResolvedValue(mockStream(chunks));
const stream = await provider.generate('', [], []);
const parts: Array<Record<string, unknown>> = [];
for await (const p of stream) parts.push(p as unknown as Record<string, unknown>);
expect(parts).toEqual([{ type: 'tool_call_part', argumentsPart: '{"x":1}' }]);
});
it('handles tool_call delta with no function field (early return)', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: true,
});
const chunks = [
{
id: 'chatcmpl-nofn',
choices: [
{
index: 0,
delta: {
tool_calls: [{ index: 0 }], // no function field
},
},
],
},
];
(
provider as unknown as { _client: { chat: { completions: { create: unknown } } } }
)._client.chat.completions.create = vi.fn().mockResolvedValue(mockStream(chunks));
const stream = await provider.generate('', [], []);
const parts: Array<Record<string, unknown>> = [];
for await (const p of stream) parts.push(p as unknown as Record<string, unknown>);
// No parts yielded — the tool_call without function is silently ignored.
expect(parts).toEqual([]);
});
it('handles tool_call delta with null function field (early return)', async () => {
const provider = new OpenAILegacyChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
stream: true,
});
const chunks = [
{
id: 'chatcmpl-nullfn',
choices: [
{
index: 0,
delta: {
tool_calls: [{ index: 0, function: null }],
},
},
],
},
];
(
provider as unknown as { _client: { chat: { completions: { create: unknown } } } }
)._client.chat.completions.create = vi.fn().mockResolvedValue(mockStream(chunks));
const stream = await provider.generate('', [], []);
const parts: Array<Record<string, unknown>> = [];
for await (const p of stream) parts.push(p as unknown as Record<string, unknown>);
expect(parts).toEqual([]);
});
});