mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
2029 lines
67 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|