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

2029 lines
67 KiB
TypeScript

import {
APIContextOverflowError,
APIProviderRateLimitError,
APIStatusError,
ChatProviderError,
} from '#/errors';
import { generate } from '#/generate';
import type { ContentPart, Message, StreamedMessagePart, ToolCall } from '#/message';
import {
OpenAIResponsesChatProvider,
OpenAIResponsesStreamedMessage,
} from '#/providers/openai-responses';
import type { GenerateOptions } from '#/provider';
import type { Tool } from '#/tool';
import { describe, it, expect, vi } from 'vitest';
function makeResponsesAPIResponse() {
return {
id: 'resp_test123',
object: 'response',
created_at: 1234567890,
status: 'completed',
model: 'gpt-4.1',
output: [
{
type: 'message',
id: 'msg_test',
role: 'assistant',
content: [{ type: 'output_text', text: 'Hello', annotations: [] }],
},
],
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
};
}
function createProvider(): OpenAIResponsesChatProvider {
return new OpenAIResponsesChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
});
}
/** Capture the request body sent to the Responses API by mocking the client. */
async function captureRequestBody(
provider: OpenAIResponsesChatProvider,
systemPrompt: string,
tools: Tool[],
history: Message[],
options?: GenerateOptions,
): Promise<Record<string, unknown>> {
let capturedBody: Record<string, unknown> | undefined;
(provider as any)._stream = false;
((provider as any)._client.responses as unknown as Record<string, unknown>)['create'] = vi
.fn()
.mockImplementation((params: unknown) => {
capturedBody = params as Record<string, unknown>;
return Promise.resolve(makeResponsesAPIResponse());
});
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 responses.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('OpenAIResponsesChatProvider', () => {
describe('message conversion', () => {
it('sends system prompt as top-level instructions', 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['instructions']).toBe('You are helpful.');
expect(body['input']).toEqual([
{
content: [{ type: 'input_text', text: 'Hello!' }],
role: 'user',
type: 'message',
},
]);
expect(body['tools']).toEqual([]);
});
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).not.toHaveProperty('instructions');
expect(body['input']).toEqual([
{
content: [{ type: 'input_text', text: 'What is 2+2?' }],
role: 'user',
type: 'message',
},
{
content: [{ type: 'output_text', text: '2+2 equals 4.', annotations: [] }],
role: 'assistant',
type: 'message',
},
{
content: [{ type: 'input_text', text: 'And 3+3?' }],
role: 'user',
type: 'message',
},
]);
});
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['instructions']).toBe('You are a math tutor.');
expect(body['input']).toEqual([
{
content: [{ type: 'input_text', text: 'What is 2+2?' }],
role: 'user',
type: 'message',
},
{
content: [{ type: 'output_text', text: '2+2 equals 4.', annotations: [] }],
role: 'assistant',
type: 'message',
},
{
content: [{ type: 'input_text', text: 'And 3+3?' }],
role: 'user',
type: 'message',
},
]);
});
it('image url in user message is encoded as input_image', 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' } },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['input']).toEqual([
{
content: [
{ type: 'input_text', text: "What's in this image?" },
{
type: 'input_image',
detail: 'auto',
image_url: 'https://example.com/image.png',
},
],
role: 'user',
type: 'message',
},
]);
});
it('OpenAI model name with date suffix maps history system message to developer', async () => {
// gpt-4.1-2025-04-14 should be recognized as gpt-4.1 for history messages.
const provider = new OpenAIResponsesChatProvider({
model: 'gpt-4.1-2025-04-14',
apiKey: 'test-key',
});
const history: Message[] = [
{ role: 'system', content: [{ type: 'text', text: 'Remember this.' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['input']).toEqual([
{
content: [{ type: 'input_text', text: 'Remember this.' }],
role: 'developer',
type: 'message',
},
{
content: [{ type: 'input_text', text: 'hi' }],
role: 'user',
type: 'message',
},
]);
});
it('non-OpenAI model name keeps history system role unchanged', async () => {
const provider = new OpenAIResponsesChatProvider({
model: 'some-other-model',
apiKey: 'test-key',
});
const history: Message[] = [
{ role: 'system', content: [{ type: 'text', text: 'Remember this.' }], toolCalls: [] },
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<Record<string, unknown>>;
expect(input[0]).toEqual({
content: [{ type: 'input_text', text: 'Remember this.' }],
role: 'system',
type: 'message',
});
});
it('user message with audio_url data URL (mp3) is encoded as input_file with base64', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'Listen' },
{ type: 'audio_url', audioUrl: { url: 'data:audio/mp3;base64,QUJD' } },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<{ content: unknown[] }>;
expect(input[0]!.content).toEqual([
{ type: 'input_text', text: 'Listen' },
{ type: 'input_file', file_data: 'QUJD', filename: 'inline.mp3' },
]);
});
it('user message with audio_url data URL (wav) is encoded as input_file with wav extension', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [{ type: 'audio_url', audioUrl: { url: 'data:audio/wav;base64,V0FW' } }],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<{ content: unknown[] }>;
expect(input[0]!.content).toEqual([
{ type: 'input_file', file_data: 'V0FW', filename: 'inline.wav' },
]);
});
it('user message with audio_url https URL is encoded as input_file with file_url', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [{ type: 'audio_url', audioUrl: { url: 'https://example.com/speech.mp3' } }],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<{ content: unknown[] }>;
expect(input[0]!.content).toEqual([
{ type: 'input_file', file_url: 'https://example.com/speech.mp3' },
]);
});
it('user message with unsupported audio_url format degrades to placeholder text', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'Bare text' },
{ type: 'audio_url', audioUrl: { url: 'data:audio/ogg;base64,T0dH' } },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<{ content: unknown[] }>;
// The unsupported ogg audio degrades to a placeholder instead of
// silently vanishing, so the model knows an attachment existed.
expect(input[0]!.content).toEqual([
{ type: 'input_text', text: 'Bare text' },
{ type: 'input_text', text: '(audio omitted: unsupported audio format)' },
]);
});
it('multiple consecutive ThinkParts with the same encrypted value aggregate into one reasoning item with multiple summaries', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Q' }], toolCalls: [] },
{
role: 'assistant',
content: [
{ type: 'think', think: 'first thought', encrypted: 'enc_shared' },
{ type: 'think', think: 'second thought', encrypted: 'enc_shared' },
{ type: 'think', think: 'third thought', encrypted: 'enc_shared' },
{ type: 'text', text: 'answer' },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<Record<string, unknown>>;
// After the user message, expect a SINGLE reasoning item with 3 summary_text entries.
const reasoningItems = input.filter((item) => item['type'] === 'reasoning');
expect(reasoningItems).toHaveLength(1);
expect(reasoningItems[0]).toEqual({
type: 'reasoning',
encrypted_content: 'enc_shared',
summary: [
{ type: 'summary_text', text: 'first thought' },
{ type: 'summary_text', text: 'second thought' },
{ type: 'summary_text', text: 'third thought' },
],
});
});
it('consecutive ThinkParts with different encrypted values produce separate reasoning items', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Q' }], toolCalls: [] },
{
role: 'assistant',
content: [
{ type: 'think', think: 'a', encrypted: 'enc_1' },
{ type: 'think', think: 'b', encrypted: 'enc_2' },
{ type: 'text', text: 'done' },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<Record<string, unknown>>;
const reasoningItems = input.filter((item) => item['type'] === 'reasoning');
expect(reasoningItems).toHaveLength(2);
expect(reasoningItems[0]).toMatchObject({ encrypted_content: 'enc_1' });
expect(reasoningItems[1]).toMatchObject({ encrypted_content: 'enc_2' });
});
it('toolMessageConversion=extract_text flattens tool result content to a plain string', async () => {
const provider = new OpenAIResponsesChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
toolMessageConversion: 'extract_text',
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Q' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'call_x',
name: 'lookup', arguments: '{}',
},
],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'header' },
{ type: 'text', text: 'body' },
],
toolCallId: 'call_x',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<Record<string, unknown>>;
const fnOutput = input.find((item) => item['type'] === 'function_call_output');
expect(fnOutput).toMatchObject({
output: expect.stringContaining('header'),
});
expect(fnOutput).toMatchObject({
output: expect.stringContaining('body'),
});
});
it('toolMessageConversion=extract_text reattaches tool result media as a user message', async () => {
// extract_text flattens function_call_output to a plain string for
// backends that reject structured output. Media must not vanish with
// the flattening: the image-only result gets a placeholder string and
// the media items are reattached as a follow-up user message after
// the run of consecutive tool messages.
const provider = new OpenAIResponsesChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
toolMessageConversion: 'extract_text',
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Q' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [
{ type: 'function', id: 'call_shot', name: 'screenshot', arguments: '{}' },
{ type: 'function', id: 'call_read', name: 'read', arguments: '{}' },
],
},
{
role: 'tool',
content: [
{ type: 'image_url', imageUrl: { url: 'https://example.com/shot.png' } },
],
toolCallId: 'call_shot',
toolCalls: [],
},
{
role: 'tool',
content: [{ type: 'text', text: 'file body' }],
toolCallId: 'call_read',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<Record<string, unknown>>;
expect(input).toEqual([
{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Q' }] },
{
type: 'function_call',
call_id: 'call_shot',
name: 'screenshot',
arguments: '{}',
},
{ type: 'function_call', call_id: 'call_read', name: 'read', arguments: '{}' },
{
type: 'function_call_output',
call_id: 'call_shot',
output: '(see attached media)',
},
{ type: 'function_call_output', call_id: 'call_read', output: 'file body' },
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'Attached media from tool result:' },
{ type: 'input_image', image_url: 'https://example.com/shot.png' },
],
},
]);
});
it('video_url in tool result degrades to placeholder text in function_call_output', 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: 'video_url', videoUrl: { url: 'https://example.com/rec.mp4' } },
],
toolCallId: 'call_rec',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<Record<string, unknown>>;
const fnOutput = input.find((item) => item['type'] === 'function_call_output');
expect(fnOutput).toEqual({
type: 'function_call_output',
call_id: 'call_rec',
output: [
{ type: 'input_text', text: '(video omitted: not supported by this provider)' },
],
});
});
it('parallel tool calls produce multiple function_call and function_call_output items', 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:
// Responses API uses a flat `input[]` array with 6 items:
// [0] user message, [1] assistant text message,
// [2..3] function_call items (NOT bundled into assistant message),
// [4..5] function_call_output items (separate per tool result).
// function_call items carry `call_id` (not `id`).
expect(body['input']).toEqual([
{
content: [{ type: 'input_text', text: 'Calculate 2+3 and 4*5' }],
role: 'user',
type: 'message',
},
{
content: [{ type: 'output_text', text: "I'll calculate both.", annotations: [] }],
role: 'assistant',
type: 'message',
},
{
arguments: '{"a": 2, "b": 3}',
call_id: 'call_add',
name: 'add',
type: 'function_call',
},
{
arguments: '{"a": 4, "b": 5}',
call_id: 'call_mul',
name: 'multiply',
type: 'function_call',
},
{
call_id: 'call_add',
output: [
{
type: 'input_text',
text: '<system-reminder>This is a system reminder</system-reminder>',
},
{ type: 'input_text', text: '5' },
],
type: 'function_call_output',
},
{
call_id: 'call_mul',
output: [
{
type: 'input_text',
text: '<system-reminder>This is a system reminder</system-reminder>',
},
{ type: 'input_text', text: '20' },
],
type: 'function_call_output',
},
]);
expect(body['tools']).toHaveLength(2);
});
it('tool definitions include strict: false', 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',
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'],
},
strict: false,
},
{
type: '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'],
},
strict: false,
},
]);
});
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);
const input = body['input'] as unknown[];
// user message
expect(input[0]).toEqual({
content: [{ type: 'input_text', text: 'Add 2 and 3' }],
role: 'user',
type: 'message',
});
// assistant message
expect(input[1]).toEqual({
content: [
{
type: 'output_text',
text: "I'll add those numbers for you.",
annotations: [],
},
],
role: 'assistant',
type: 'message',
});
// function_call
expect(input[2]).toEqual({
arguments: '{"a": 2, "b": 3}',
call_id: 'call_abc123',
name: 'add',
type: 'function_call',
});
// function_call_output
expect(input[3]).toEqual({
call_id: 'call_abc123',
output: [{ type: 'input_text', text: '5' }],
type: 'function_call_output',
});
});
it('normalizes invalid historical tool call ids and matching function outputs', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Run bash' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: 'Bash:21',
name: 'Bash',
arguments: '{"command":"pwd"}',
},
],
},
{
role: 'tool',
content: [{ type: 'text', text: '/tmp' }],
toolCallId: 'Bash:21',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as unknown[];
expect(input[1]).toEqual({
arguments: '{"command":"pwd"}',
call_id: 'Bash_21',
name: 'Bash',
type: 'function_call',
});
expect(input[2]).toEqual({
call_id: 'Bash_21',
output: [{ type: 'input_text', text: '/tmp' }],
type: 'function_call_output',
});
});
it('assistant with reasoning (ThinkPart with encrypted)', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'What is 2+2?' }], toolCalls: [] },
{
role: 'assistant',
content: [
{ type: 'think', think: 'Thinking...', encrypted: 'enc_abc' },
{ type: 'text', text: '4.' },
],
toolCalls: [],
},
{ role: 'user', content: [{ type: 'text', text: 'Thanks!' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
// Snapshot of the expected wire format:
// 4 flat input items — ThinkPart with encrypted becomes a standalone
// `reasoning` item between the user and assistant message items.
expect(body['input']).toEqual([
{
content: [{ type: 'input_text', text: 'What is 2+2?' }],
role: 'user',
type: 'message',
},
{
summary: [{ type: 'summary_text', text: 'Thinking...' }],
type: 'reasoning',
encrypted_content: 'enc_abc',
},
{
content: [{ type: 'output_text', text: '4.', annotations: [] }],
role: 'assistant',
type: 'message',
},
{
content: [{ type: 'input_text', text: 'Thanks!' }],
role: 'user',
type: 'message',
},
]);
});
it('audio url in tool result is encoded as input_file', async () => {
const provider = createProvider();
const toolCall: ToolCall = {
type: 'function',
id: 'call_audio',
name: 'tts', arguments: '{"text":"hi"}',
};
const dataUrl = 'data:audio/mp3;base64,QUJD';
const httpsUrl = 'https://example.com/speech.wav';
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Say hi' }], toolCalls: [] },
{
role: 'assistant',
content: [],
toolCalls: [toolCall],
},
{
role: 'tool',
content: [
{ type: 'text', text: 'done' },
{ type: 'audio_url', audioUrl: { url: dataUrl } },
{ type: 'audio_url', audioUrl: { url: httpsUrl } },
] satisfies ContentPart[],
toolCallId: 'call_audio',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as unknown[];
// Locate the function_call_output item.
const functionCallOutput = input.find(
(item) => (item as Record<string, unknown>)['type'] === 'function_call_output',
) as Record<string, unknown> | undefined;
expect(functionCallOutput).toMatchObject({
output: [
{ type: 'input_text', text: 'done' },
{ type: 'input_file', file_data: 'QUJD', filename: 'inline.mp3' },
{ type: 'input_file', file_url: httpsUrl },
],
});
});
it('image url in 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' },
{ type: 'image_url', imageUrl: { url: 'https://example.com/image.png' } },
] satisfies ContentPart[],
toolCallId: 'call_abc123',
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as unknown[];
expect(input[3]).toEqual({
call_id: 'call_abc123',
output: [
{ type: 'input_text', text: '5' },
{ type: 'input_image', image_url: 'https://example.com/image.png' },
],
type: 'function_call_output',
});
});
});
describe('generation kwargs', () => {
it('applies temperature and max_output_tokens', async () => {
const provider = createProvider().withGenerationKwargs({
temperature: 0.7,
max_output_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_output_tokens']).toBe(2048);
});
it('withMaxCompletionTokens sets max_output_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_output_tokens']).toBe(1024);
expect(provider.maxCompletionTokens).toBe(1024);
});
it('maps json_schema response format to text.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['text']).toEqual({
format: {
type: 'json_schema',
name: 'contact',
schema,
strict: true,
description: undefined,
},
});
});
it('preserves existing Responses text options when applying response format', async () => {
const provider = createProvider().withGenerationKwargs({
text: { verbosity: 'low' },
});
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['text']).toEqual({
verbosity: 'low',
format: {
type: 'json_schema',
name: 'contact',
schema,
strict: true,
description: undefined,
},
});
});
});
describe('reasoning configuration', () => {
it('omits reasoning by default', async () => {
const provider = createProvider();
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['reasoning']).toBeUndefined();
expect(body['include']).toBeUndefined();
});
it('with_thinking("off") omits reasoning', async () => {
const provider = createProvider().withThinking('off');
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['reasoning']).toBeUndefined();
expect(body['include']).toBeUndefined();
});
it('with_thinking("low") sends reasoning with effort=low', async () => {
const provider = createProvider().withThinking('low');
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['reasoning']).toEqual({ effort: 'low', summary: 'auto' });
expect(body['include']).toEqual(['reasoning.encrypted_content']);
});
it('with_thinking("high") sends reasoning with effort=high', 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']).toEqual({ effort: 'high', summary: 'auto' });
expect(body['include']).toEqual(['reasoning.encrypted_content']);
});
it('with_thinking("xhigh") on gpt-5.1-codex-max passes xhigh through to the wire', async () => {
const provider = new OpenAIResponsesChatProvider({
model: 'gpt-5.1-codex-max',
apiKey: 'test-key',
}).withThinking('xhigh');
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['reasoning']).toEqual({ effort: 'xhigh', summary: 'auto' });
});
it('with_thinking("max") on gpt-5.1-codex-max clamps up to xhigh on the wire', async () => {
// Regression guard: "max" used to fall back to "high"; for OpenAI it
// must clamp up to their highest supported effort, xhigh.
const provider = new OpenAIResponsesChatProvider({
model: 'gpt-5.1-codex-max',
apiKey: 'test-key',
}).withThinking('max');
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'Think' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect((body['reasoning'] as Record<string, unknown>)['effort']).toBe('xhigh');
});
});
describe('provider properties', () => {
it('has correct name and model', () => {
const provider = createProvider();
expect(provider.name).toBe('openai-responses');
expect(provider.modelName).toBe('gpt-4.1');
});
it('thinkingEffort is null by default', () => {
const provider = createProvider();
expect(provider.thinkingEffort).toBeNull();
});
it('thinkingEffort reflects withThinking', () => {
const provider = createProvider();
expect(provider.withThinking('high').thinkingEffort).toBe('high');
expect(provider.withThinking('low').thinkingEffort).toBe('low');
});
it('withThinking returns a new instance', () => {
const provider = createProvider();
const newProvider = provider.withThinking('high');
expect(newProvider).toBeInstanceOf(OpenAIResponsesChatProvider);
expect(newProvider).not.toBe(provider);
});
it('throws a clear error when the SDK client lacks Responses API support', async () => {
const provider = createProvider();
(provider as unknown as { _client: Record<string, unknown> })._client = {};
await expect(
provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
),
).rejects.toThrow(
'OpenAI SDK version does not support Responses API. Upgrade to >=4.x with responses support.',
);
});
});
describe('response parsing', () => {
it('yields text from non-stream response', async () => {
const provider = createProvider();
(provider as any)._stream = false;
((provider as any)._client.responses as unknown as Record<string, unknown>)['create'] = vi
.fn()
.mockResolvedValue(makeResponsesAPIResponse());
const stream = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
const parts = [];
for await (const part of stream) {
parts.push(part);
}
expect(parts).toEqual([{ type: 'text', text: 'Hello' }]);
expect(stream.id).toBe('resp_test123');
expect(stream.usage).toEqual({
inputOther: 10,
output: 5,
inputCacheRead: 0,
inputCacheCreation: 0,
});
});
it('yields ToolCall from non-stream response with function_call output item', async () => {
const provider = createProvider();
(provider as any)._stream = false;
((provider as any)._client.responses as unknown as Record<string, unknown>)['create'] = vi
.fn()
.mockResolvedValue({
id: 'resp_fn',
object: 'response',
status: 'completed',
output: [
{
type: 'function_call',
call_id: 'call_xyz',
name: 'lookup',
arguments: '{"q":"hi"}',
},
],
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
});
const stream = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
const parts: StreamedMessagePart[] = [];
for await (const p of stream) parts.push(p);
expect(parts).toEqual([
{
type: 'function',
id: 'call_xyz',
name: 'lookup', arguments: '{"q":"hi"}',
},
]);
});
it('non-stream function_call generates UUID when call_id is missing', async () => {
const provider = createProvider();
(provider as any)._stream = false;
((provider as any)._client.responses as unknown as Record<string, unknown>)['create'] = vi
.fn()
.mockResolvedValue({
id: 'resp_no_call_id',
output: [
{
type: 'function_call',
name: 'lookup',
arguments: '{}',
},
],
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
});
const stream = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
const parts: StreamedMessagePart[] = [];
for await (const p of stream) parts.push(p);
const toolCall = parts.find((p) => p.type === 'function');
expect(toolCall).toMatchObject({
type: 'function',
id: expect.stringMatching(/.+/),
});
});
it('yields ThinkPart from non-stream response with reasoning output item (with encrypted_content)', async () => {
const provider = createProvider();
(provider as any)._stream = false;
((provider as any)._client.responses as unknown as Record<string, unknown>)['create'] = vi
.fn()
.mockResolvedValue({
id: 'resp_reason',
object: 'response',
status: 'completed',
output: [
{
type: 'reasoning',
encrypted_content: 'enc_token_abc',
summary: [
{ type: 'summary_text', text: 'Step 1' },
{ type: 'summary_text', text: 'Step 2' },
],
},
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'done', annotations: [] }],
},
],
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
});
const stream = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
const parts: StreamedMessagePart[] = [];
for await (const p of stream) parts.push(p);
expect(parts).toEqual([
{ type: 'think', think: 'Step 1', encrypted: 'enc_token_abc' },
{ type: 'think', think: 'Step 2', encrypted: 'enc_token_abc' },
{ type: 'text', text: 'done' },
]);
});
it('non-stream reasoning without encrypted_content yields ThinkPart without encrypted field', async () => {
const provider = createProvider();
(provider as any)._stream = false;
((provider as any)._client.responses as unknown as Record<string, unknown>)['create'] = vi
.fn()
.mockResolvedValue({
id: 'resp_reason2',
output: [
{
type: 'reasoning',
summary: [{ type: 'summary_text', text: 'Thinking...' }],
},
],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
});
const stream = await provider.generate(
'',
[],
[{ role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }],
);
const parts: StreamedMessagePart[] = [];
for await (const p of stream) parts.push(p);
expect(parts).toEqual([{ type: 'think', think: 'Thinking...' }]);
});
});
describe('provider property accessors', () => {
it('modelName returns configured model', () => {
const provider = new OpenAIResponsesChatProvider({
model: 'gpt-5-codex',
apiKey: 'test-key',
});
expect(provider.modelName).toBe('gpt-5-codex');
});
it('modelParameters returns model + baseUrl + generationKwargs', () => {
const provider = new OpenAIResponsesChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
maxOutputTokens: 2048,
});
const params = provider.modelParameters;
expect(params).toMatchObject({
model: 'gpt-4.1',
max_output_tokens: 2048,
baseUrl: expect.any(String),
});
});
it('maxOutputTokens constructor option is wired into generationKwargs', async () => {
const provider = new OpenAIResponsesChatProvider({
model: 'gpt-4.1',
apiKey: 'test-key',
maxOutputTokens: 512,
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['max_output_tokens']).toBe(512);
});
it('video_url in user content degrades to placeholder text (no video input type)', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'Watch this:' },
{ type: 'video_url', videoUrl: { url: 'https://example.com/clip.mp4' } },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<{ content: unknown[] }>;
expect(input[0]!.content).toEqual([
{ type: 'input_text', text: 'Watch this:' },
{ type: 'input_text', text: '(video omitted: not supported by this provider)' },
]);
});
it('audio_url with unsupported scheme degrades to placeholder text', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'Hear:' },
{ type: 'audio_url', audioUrl: { url: 'file:///path/to/audio.mp3' } },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<{ content: unknown[] }>;
// file:// URL cannot be encoded as input_file → placeholder
expect(input[0]!.content).toEqual([
{ type: 'input_text', text: 'Hear:' },
{ type: 'input_text', text: '(audio omitted: unsupported audio format)' },
]);
});
it('audio_url data URL with unknown subtype degrades to placeholder text', async () => {
const provider = createProvider();
const history: Message[] = [
{
role: 'user',
content: [
{ type: 'text', text: 'OGG:' },
{ type: 'audio_url', audioUrl: { url: 'data:audio/ogg;base64,T0dH' } },
],
toolCalls: [],
},
];
const body = await captureRequestBody(provider, '', [], history);
const input = body['input'] as Array<{ content: unknown[] }>;
// ogg subtype is not mp3/wav → placeholder
expect(input[0]!.content).toEqual([
{ type: 'input_text', text: 'OGG:' },
{ type: 'input_text', text: '(audio omitted: unsupported audio format)' },
]);
});
});
describe('streaming', () => {
it('generate sends stream: true and returns streaming parts', async () => {
const provider = createProvider();
// The provider has _stream = true by default; mock create to return an async iterable
const events = [
{ type: 'response.output_text.delta', delta: 'Hello' },
{ type: 'response.output_text.delta', delta: ' world' },
{
type: 'response.completed',
response: {
id: 'resp_stream_1',
usage: {
input_tokens: 20,
output_tokens: 10,
input_tokens_details: { cached_tokens: 5 },
},
},
},
];
let capturedParams: Record<string, unknown> | undefined;
((provider as any)._client.responses as unknown as Record<string, unknown>)['create'] = vi
.fn()
.mockImplementation((params: unknown) => {
capturedParams = params as Record<string, unknown>;
return Promise.resolve(makeAsyncIterable(events));
});
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);
}
// Verify stream: true was sent
expect(capturedParams!['stream']).toBe(true);
expect(parts).toEqual([
{ type: 'text', text: 'Hello' },
{ type: 'text', text: ' world' },
]);
expect(stream.usage).toEqual({
inputOther: 15,
output: 10,
inputCacheRead: 5,
inputCacheCreation: 0,
});
});
it('streams tool call with arguments delta', async () => {
const events = [
{
type: 'response.output_item.added',
item: {
id: 'item_123',
type: 'function_call',
call_id: 'call_123',
name: 'add',
arguments: '',
},
},
{ type: 'response.function_call_arguments.delta', item_id: 'item_123', delta: '{"a":' },
{
type: 'response.function_call_arguments.delta',
item_id: 'item_123',
delta: ' 2, "b": 3}',
},
{
type: 'response.function_call_arguments.done',
item_id: 'item_123',
name: 'add',
arguments: '{"a": 2, "b": 3}',
},
{
type: 'response.completed',
response: { id: 'resp_tc', usage: { input_tokens: 5, output_tokens: 3 } },
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
const parts: StreamedMessagePart[] = [];
for await (const part of stream) {
parts.push(part);
}
// The Responses adapter absorbs `function_call_arguments.done` and
// `output_item.done` for function_call items — generate.ts infers
// completion from merge boundaries and stream end.
expect(parts).toEqual([
{
type: 'function',
id: 'call_123',
name: 'add', arguments: '',
_streamIndex: 'item_123',
},
{ type: 'tool_call_part', argumentsPart: '{"a":', index: 'item_123' },
{ type: 'tool_call_part', argumentsPart: ' 2, "b": 3}', index: 'item_123' },
]);
expect(stream.usage).toEqual({
inputOther: 5,
output: 3,
inputCacheRead: 0,
inputCacheCreation: 0,
});
});
it('uses final arguments from function_call_arguments.done when no deltas are emitted', async () => {
const provider = createProvider();
const events = [
{
type: 'response.output_item.added',
item: {
id: 'item_done_only',
type: 'function_call',
call_id: 'call_done_only',
name: 'add',
arguments: '',
},
},
{
type: 'response.function_call_arguments.done',
item_id: 'item_done_only',
output_index: 0,
name: 'add',
arguments: '{"a": 2, "b": 3}',
},
{
type: 'response.completed',
response: { id: 'resp_done_only', usage: { input_tokens: 5, output_tokens: 3 } },
},
];
((provider as any)._client.responses as unknown as Record<string, unknown>)['create'] = vi
.fn()
.mockResolvedValue(makeAsyncIterable(events));
const result = await generate(
provider,
'',
[ADD_TOOL],
[{ role: 'user', content: [{ type: 'text', text: 'Add 2 and 3' }], toolCalls: [] }],
);
expect(result.message.toolCalls).toEqual([
{
type: 'function',
id: 'call_done_only',
name: 'add', arguments: '{"a": 2, "b": 3}',
extras: undefined,
},
]);
});
it('rejects function_call_arguments.done when it disagrees with streamed deltas', async () => {
const events = [
{
type: 'response.output_item.added',
item: {
id: 'item_mismatch',
type: 'function_call',
call_id: 'call_mismatch',
name: 'add',
arguments: '',
},
},
{
type: 'response.function_call_arguments.delta',
item_id: 'item_mismatch',
output_index: 0,
delta: '{"a": 1}',
},
{
type: 'response.function_call_arguments.done',
item_id: 'item_mismatch',
output_index: 0,
name: 'add',
arguments: '{"a": 2}',
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
await expect(collectStreamParts(stream)).rejects.toThrow(
/final function-call arguments.*do not match/,
);
});
it('streams reasoning with encrypted_content', async () => {
const events = [
{ type: 'response.reasoning_summary_part.added' },
{ type: 'response.reasoning_summary_text.delta', delta: 'Thinking about' },
{ type: 'response.reasoning_summary_text.delta', delta: ' the answer...' },
{
type: 'response.output_item.done',
item: {
type: 'reasoning',
id: 'rs_1',
encrypted_content: 'enc_xyz',
summary: [{ type: 'summary_text', text: 'Thinking about the answer...' }],
},
},
{ type: 'response.output_text.delta', delta: '42' },
{
type: 'response.completed',
response: { id: 'resp_r', usage: { input_tokens: 8, output_tokens: 4 } },
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
const parts: StreamedMessagePart[] = [];
for await (const part of stream) {
parts.push(part);
}
expect(parts).toEqual([
{ type: 'think', think: '' },
{ type: 'think', think: 'Thinking about' },
{ type: 'think', think: ' the answer...' },
{ type: 'think', think: '', encrypted: 'enc_xyz' },
{ type: 'text', text: '42' },
]);
});
it('stream.id is response.id, not output item id (tool call)', async () => {
// Regression: previously `output_item.added` / `output_item.done`
// overwrote `_id` with the item id (or undefined for tool-call items
// that have no `item.id`), clobbering the real `response.id`.
const events = [
{ type: 'response.created', response: { id: 'resp_001' } },
{
type: 'response.output_item.added',
item: {
type: 'function_call',
id: 'item_a',
call_id: 'call_a',
name: 'tool_a',
arguments: '',
},
},
{ type: 'response.function_call_arguments.delta', item_id: 'item_a', delta: '{}' },
{
type: 'response.output_item.done',
item: { type: 'function_call', id: 'item_a' },
},
{
type: 'response.completed',
response: { id: 'resp_001', usage: { input_tokens: 10, output_tokens: 5 } },
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
for await (const _ of stream) {
void _;
}
expect(stream.id).toBe('resp_001');
});
it('stream.id stays as response.id across multiple output items', async () => {
const events = [
{ type: 'response.in_progress', response: { id: 'resp_multi' } },
{
type: 'response.output_item.added',
item: {
type: 'function_call',
id: 'item_1',
call_id: 'call_1',
name: 'tool_1',
arguments: '',
},
},
{ type: 'response.function_call_arguments.delta', item_id: 'item_1', delta: '{}' },
{
type: 'response.output_item.done',
item: { type: 'function_call', id: 'item_1' },
},
{
type: 'response.output_item.added',
item: {
type: 'function_call',
id: 'item_2',
call_id: 'call_2',
name: 'tool_2',
arguments: '',
},
},
{ type: 'response.function_call_arguments.delta', item_id: 'item_2', delta: '{}' },
{
type: 'response.output_item.done',
item: { type: 'function_call', id: 'item_2' },
},
{
type: 'response.completed',
response: { id: 'resp_multi', usage: { input_tokens: 1, output_tokens: 1 } },
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
for await (const _ of stream) {
void _;
}
expect(stream.id).toBe('resp_multi');
});
it('stream.id is set from response.created even if tool-call items lack id', async () => {
// Some providers emit `output_item.added` without an `item.id` for
// function_call items. Before the fix, this would set `_id` to
// undefined, erasing the real response id captured earlier.
const events = [
{ type: 'response.created', response: { id: 'resp_no_item_id' } },
{
type: 'response.output_item.added',
item: {
type: 'function_call',
call_id: 'call_x',
name: 'tool_x',
arguments: '',
},
},
{ type: 'response.function_call_arguments.delta', delta: '{}' },
{
type: 'response.completed',
response: {
id: 'resp_no_item_id',
usage: { input_tokens: 2, output_tokens: 1 },
},
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
for await (const _ of stream) {
void _;
}
expect(stream.id).toBe('resp_no_item_id');
});
it('captures response.id from response.created chunk (before response.completed)', async () => {
const events = [
{
type: 'response.created',
response: { id: 'resp_created_id', status: 'in_progress' },
},
{ type: 'response.output_text.delta', delta: 'hi' },
{
type: 'response.completed',
response: { id: 'resp_final_id', usage: { input_tokens: 1, output_tokens: 1 } },
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
// Mid-stream: id should be 'resp_created_id' after the first chunk is consumed.
// We can't easily inspect mid-stream so we check the final id only:
// the response.completed event refines the id to 'resp_final_id'.
for await (const _ of stream) {
void _;
}
expect(stream.id).toBe('resp_final_id');
});
it('captures response.id from response.in_progress chunk', async () => {
const events = [
{
type: 'response.in_progress',
response: { id: 'resp_in_progress' },
},
{ type: 'response.output_text.delta', delta: 'x' },
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
for await (const _ of stream) {
void _;
}
// No response.completed → id falls back to the in_progress id.
expect(stream.id).toBe('resp_in_progress');
});
it('yields ThinkPart from response.output_item.done reasoning item (with encrypted_content)', async () => {
const events = [
{
type: 'response.output_item.done',
item: {
type: 'reasoning',
id: 'reasoning_item_1',
encrypted_content: 'enc_done',
},
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
const parts: StreamedMessagePart[] = [];
for await (const p of stream) parts.push(p);
expect(parts).toEqual([{ type: 'think', think: '', encrypted: 'enc_done' }]);
});
it('yields ThinkPart from response.output_item.done reasoning item without encrypted_content', async () => {
const events = [
{
type: 'response.output_item.done',
item: { type: 'reasoning', id: 'reasoning_item_2' },
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
const parts: StreamedMessagePart[] = [];
for await (const p of stream) parts.push(p);
expect(parts).toEqual([{ type: 'think', think: '' }]);
});
it('yields ThinkPart from response.reasoning_summary_part.added and .delta events', async () => {
const events = [
{ type: 'response.reasoning_summary_part.added' },
{ type: 'response.reasoning_summary_text.delta', delta: 'Thinking about' },
{ type: 'response.reasoning_summary_text.delta', delta: ' the answer' },
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
const parts: StreamedMessagePart[] = [];
for await (const p of stream) parts.push(p);
expect(parts).toEqual([
{ type: 'think', think: '' },
{ type: 'think', think: 'Thinking about' },
{ type: 'think', think: ' the answer' },
]);
});
it('ignores output_item.added for non-function_call items (e.g. message items)', async () => {
const events = [
{
type: 'response.output_item.added',
item: { type: 'message', id: 'msg_item_1' },
},
{ type: 'response.output_text.delta', delta: 'hi' },
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
const parts: StreamedMessagePart[] = [];
for await (const p of stream) parts.push(p);
// Only the text delta should be yielded; the message output_item.added is ignored.
expect(parts).toEqual([{ type: 'text', text: 'hi' }]);
});
it('ignores unknown stream event types and continues with known events', async () => {
const stream = new OpenAIResponsesStreamedMessage(
makeAsyncIterable([
{ type: 'response.future_event', payload: { value: 1 } },
{ type: 'response.output_text.delta', delta: 'hi' },
]),
true,
);
await expect(collectStreamParts(stream)).resolves.toEqual([{ type: 'text', text: 'hi' }]);
});
it('throws on OpenAI Responses error events after preserving prior parts', async () => {
const events = [
{ type: 'response.output_text.delta', delta: 'partial' },
{
type: 'error',
code: 'server_error',
message: 'upstream failed',
param: null,
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
const parts: StreamedMessagePart[] = [];
let caughtError: Error | undefined;
try {
for await (const part of stream) {
parts.push(part);
}
} catch (error) {
caughtError = error as Error;
}
expect(parts).toEqual([{ type: 'text', text: 'partial' }]);
expect(caughtError).toBeInstanceOf(ChatProviderError);
expect(caughtError?.message).toMatch(/server_error.*upstream failed/);
});
it('throws on response.failed events with response error details', async () => {
const events = [
{
type: 'response.failed',
response: {
id: 'resp_failed',
status: 'failed',
error: { code: 'rate_limit_exceeded', message: 'too many requests' },
},
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
let caughtError: unknown;
try {
await collectStreamParts(stream);
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(APIProviderRateLimitError);
expect((caughtError as APIProviderRateLimitError).statusCode).toBe(429);
expect((caughtError as Error).message).toMatch(/rate_limit_exceeded.*too many/);
});
it('normalizes malformed gateway error frames with nested rate-limit JSON', async () => {
const events = [
{
message:
'received error while streaming: {"type":"tokens","code":"rate_limit_exceeded","message":"Rate limit reached for gpt-5.5. Please try again in 325ms.","param":null}',
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
let caughtError: unknown;
try {
await collectStreamParts(stream);
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(APIProviderRateLimitError);
expect((caughtError as APIProviderRateLimitError).statusCode).toBe(429);
expect((caughtError as Error).message).toContain('Rate limit reached for gpt-5.5');
expect((caughtError as Error).message).not.toContain('stream event.type must be a string');
});
it('rejects malformed stream events with a non-string type even when message is present', async () => {
const events = [
{
type: 42,
message:
'received error while streaming: {"type":"tokens","code":"rate_limit_exceeded","message":"too many requests","param":null}',
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
await expect(collectStreamParts(stream)).rejects.toThrow(
'OpenAI Responses decode error: stream event.type must be a string.',
);
});
it('normalizes response.failed context overflow events', async () => {
const events = [
{
type: 'response.failed',
response: {
id: 'resp_context_overflow',
status: 'failed',
error: {
code: 'context_length_exceeded',
message:
'Your input exceeds the context window of this model. Please adjust your input and try again.',
},
},
},
];
const stream = new OpenAIResponsesStreamedMessage(makeAsyncIterable(events), true);
let caughtError: unknown;
try {
await collectStreamParts(stream);
} catch (error) {
caughtError = error;
}
expect(caughtError).toBeInstanceOf(APIContextOverflowError);
expect((caughtError as APIContextOverflowError).statusCode).toBe(400);
expect((caughtError as Error).message).toMatch(/context_length_exceeded/);
});
it('throws when a known stream event is missing a required field', async () => {
const stream = new OpenAIResponsesStreamedMessage(
makeAsyncIterable([{ type: 'response.output_text.delta' }]),
true,
);
await expect(collectStreamParts(stream)).rejects.toThrow(
/response\.output_text\.delta\.delta/,
);
});
it('converts errors during streaming', async () => {
const { APIError } = await import('openai');
async function* failingStream(): AsyncIterable<Record<string, unknown>> {
yield { type: 'response.output_text.delta', delta: 'partial' };
throw new APIError(
500,
{ message: 'Internal Server Error' },
'server error',
new Headers(),
);
}
const stream = new OpenAIResponsesStreamedMessage(failingStream(), true);
const parts: StreamedMessagePart[] = [];
let caughtError: Error | undefined;
try {
for await (const part of stream) {
parts.push(part);
}
} catch (error) {
caughtError = error as Error;
}
expect(parts).toEqual([{ type: 'text', text: 'partial' }]);
expect(caughtError).toMatchObject({ name: 'APIStatusError' });
});
});
});
async function collectStreamParts(
stream: OpenAIResponsesStreamedMessage,
): Promise<StreamedMessagePart[]> {
const parts: StreamedMessagePart[] = [];
for await (const part of stream) {
parts.push(part);
}
return parts;
}
function makeAsyncIterable(
events: Record<string, unknown>[],
): AsyncIterable<Record<string, unknown>> {
return {
[Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
let index = 0;
return {
next(): Promise<IteratorResult<Record<string, unknown>>> {
if (index < events.length) {
return Promise.resolve({ value: events[index++]!, done: false });
}
return Promise.resolve({
value: undefined as unknown as Record<string, unknown>,
done: true,
});
},
};
},
};
}