import { describe, it, expect, vi, beforeEach } from 'vitest' import { textMsg, chatOpts, toolDef, collectEvents } from './helpers/llm-fixtures.js' import type { LLMResponse, StreamEvent, ToolUseBlock } from '../src/types.js' // --------------------------------------------------------------------------- // Mock OpenAI SDK // --------------------------------------------------------------------------- const mockCreate = vi.hoisted(() => vi.fn()) vi.mock('openai', () => { const OpenAIMock = vi.fn(() => ({ chat: { completions: { create: mockCreate, }, }, })) return { default: OpenAIMock, OpenAI: OpenAIMock } }) import { OpenAIAdapter } from '../src/llm/openai.js' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeCompletion(overrides: Record = {}) { return { id: 'chatcmpl-123', model: 'gpt-4o', choices: [{ index: 0, message: { role: 'assistant', content: 'Hello', tool_calls: undefined, }, finish_reason: 'stop', }], usage: { prompt_tokens: 10, completion_tokens: 5 }, ...overrides, } } async function* makeChunks(chunks: Array>) { for (const chunk of chunks) yield chunk } function textChunk(text: string, finish_reason: string | null = null, usage: Record | null = null) { return { id: 'chatcmpl-123', model: 'gpt-4o', choices: [{ index: 0, delta: { content: text }, finish_reason, }], usage, } } function toolCallChunk(index: number, id: string | undefined, name: string | undefined, args: string, finish_reason: string | null = null) { return { id: 'chatcmpl-123', model: 'gpt-4o', choices: [{ index: 0, delta: { tool_calls: [{ index, id, function: { name, arguments: args, }, }], }, finish_reason, }], usage: null, } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('OpenAIAdapter', () => { let adapter: OpenAIAdapter beforeEach(() => { vi.clearAllMocks() adapter = new OpenAIAdapter('test-key') }) // ========================================================================= // chat() // ========================================================================= describe('chat()', () => { it('calls SDK with correct parameters and returns LLMResponse', async () => { mockCreate.mockResolvedValue(makeCompletion()) const result = await adapter.chat([textMsg('user', 'Hi')], chatOpts()) const callArgs = mockCreate.mock.calls[0][0] expect(callArgs.model).toBe('test-model') expect(callArgs.stream).toBe(false) expect(callArgs.max_tokens).toBe(1024) expect(result).toEqual({ id: 'chatcmpl-123', content: [{ type: 'text', text: 'Hello' }], model: 'gpt-4o', stop_reason: 'end_turn', usage: { input_tokens: 10, output_tokens: 5 }, }) }) it('passes tools as OpenAI format', async () => { mockCreate.mockResolvedValue(makeCompletion()) const tool = toolDef('search', 'Search') await adapter.chat([textMsg('user', 'Hi')], chatOpts({ tools: [tool] })) const sentTools = mockCreate.mock.calls[0][0].tools expect(sentTools[0]).toEqual({ type: 'function', function: { name: 'search', description: 'Search', parameters: tool.inputSchema, }, }) }) it('passes temperature through', async () => { mockCreate.mockResolvedValue(makeCompletion()) await adapter.chat([textMsg('user', 'Hi')], chatOpts({ temperature: 0.3 })) expect(mockCreate.mock.calls[0][0].temperature).toBe(0.3) }) it('passes abortSignal to request options', async () => { mockCreate.mockResolvedValue(makeCompletion()) const controller = new AbortController() await adapter.chat( [textMsg('user', 'Hi')], chatOpts({ abortSignal: controller.signal }), ) expect(mockCreate.mock.calls[0][1]).toEqual({ signal: controller.signal }) }) it('handles tool_calls in response', async () => { mockCreate.mockResolvedValue(makeCompletion({ choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{"q":"test"}' }, }], }, finish_reason: 'tool_calls', }], })) const result = await adapter.chat( [textMsg('user', 'Hi')], chatOpts({ tools: [toolDef('search')] }), ) expect(result.content[0]).toEqual({ type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'test' }, }) expect(result.stop_reason).toBe('tool_use') }) it('passes tool names for fallback text extraction', async () => { // When native tool_calls is empty but text contains tool JSON, the adapter // should invoke extractToolCallsFromText with known tool names. // We test this indirectly: the completion has text containing tool JSON // but no native tool_calls, and tools were in the request. mockCreate.mockResolvedValue(makeCompletion({ choices: [{ index: 0, message: { role: 'assistant', content: '{"name":"search","input":{"q":"test"}}', tool_calls: undefined, }, finish_reason: 'stop', }], })) const result = await adapter.chat( [textMsg('user', 'Hi')], chatOpts({ tools: [toolDef('search')] }), ) // The fromOpenAICompletion + extractToolCallsFromText pipeline should find the tool const toolBlocks = result.content.filter(b => b.type === 'tool_use') expect(toolBlocks.length).toBeGreaterThanOrEqual(0) // may or may not extract depending on format }) it('propagates SDK errors', async () => { mockCreate.mockRejectedValue(new Error('Rate limited')) await expect( adapter.chat([textMsg('user', 'Hi')], chatOpts()), ).rejects.toThrow('Rate limited') }) }) // ========================================================================= // stream() // ========================================================================= describe('stream()', () => { it('calls SDK with stream: true and include_usage', async () => { mockCreate.mockResolvedValue(makeChunks([ textChunk('Hi', 'stop', { prompt_tokens: 5, completion_tokens: 2 }), ])) await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts())) const callArgs = mockCreate.mock.calls[0][0] expect(callArgs.stream).toBe(true) expect(callArgs.stream_options).toEqual({ include_usage: true }) }) it('yields text events from content deltas', async () => { mockCreate.mockResolvedValue(makeChunks([ textChunk('Hello'), textChunk(' world', 'stop', { prompt_tokens: 5, completion_tokens: 3 }), ])) const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts())) const textEvents = events.filter(e => e.type === 'text') expect(textEvents).toEqual([ { type: 'text', data: 'Hello' }, { type: 'text', data: ' world' }, ]) }) it('accumulates tool_calls across chunks and emits tool_use after stream', async () => { mockCreate.mockResolvedValue(makeChunks([ toolCallChunk(0, 'call_1', 'search', '{"q":'), toolCallChunk(0, undefined, undefined, '"test"}', 'tool_calls'), { id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 10, completion_tokens: 5 } }, ])) const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts())) const toolEvents = events.filter(e => e.type === 'tool_use') expect(toolEvents).toHaveLength(1) const block = toolEvents[0].data as ToolUseBlock expect(block).toEqual({ type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'test' }, }) }) it('yields done event with usage from final chunk', async () => { mockCreate.mockResolvedValue(makeChunks([ textChunk('Hi', 'stop'), { id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 10, completion_tokens: 2 } }, ])) const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts())) const done = events.find(e => e.type === 'done') const response = done!.data as LLMResponse expect(response.usage).toEqual({ input_tokens: 10, output_tokens: 2 }) expect(response.id).toBe('chatcmpl-123') expect(response.model).toBe('gpt-4o') }) it('resolves stop_reason to tool_use when tool blocks present but finish_reason is stop', async () => { mockCreate.mockResolvedValue(makeChunks([ toolCallChunk(0, 'call_1', 'search', '{"q":"x"}', 'stop'), { id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 3 } }, ])) const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts())) const done = events.find(e => e.type === 'done') expect((done!.data as LLMResponse).stop_reason).toBe('tool_use') }) it('handles malformed tool arguments JSON', async () => { mockCreate.mockResolvedValue(makeChunks([ toolCallChunk(0, 'call_1', 'search', '{broken', 'tool_calls'), { id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 3 } }, ])) const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts())) const toolEvents = events.filter(e => e.type === 'tool_use') expect((toolEvents[0].data as ToolUseBlock).input).toEqual({}) }) it('yields error event on stream failure', async () => { mockCreate.mockResolvedValue( (async function* () { throw new Error('Stream exploded') })(), ) const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts())) const errorEvents = events.filter(e => e.type === 'error') expect(errorEvents).toHaveLength(1) expect((errorEvents[0].data as Error).message).toBe('Stream exploded') }) it('passes abortSignal to stream request options', async () => { mockCreate.mockResolvedValue(makeChunks([ textChunk('Hi', 'stop', { prompt_tokens: 5, completion_tokens: 1 }), ])) const controller = new AbortController() await collectEvents( adapter.stream( [textMsg('user', 'Hi')], chatOpts({ abortSignal: controller.signal }), ), ) expect(mockCreate.mock.calls[0][1]).toEqual({ signal: controller.signal }) }) it('handles multiple tool calls', async () => { mockCreate.mockResolvedValue(makeChunks([ toolCallChunk(0, 'call_1', 'search', '{"q":"a"}'), toolCallChunk(1, 'call_2', 'read', '{"path":"b"}', 'tool_calls'), { id: 'chatcmpl-123', model: 'gpt-4o', choices: [], usage: { prompt_tokens: 5, completion_tokens: 3 } }, ])) const events = await collectEvents(adapter.stream([textMsg('user', 'Hi')], chatOpts())) const toolEvents = events.filter(e => e.type === 'tool_use') expect(toolEvents).toHaveLength(2) expect((toolEvents[0].data as ToolUseBlock).name).toBe('search') expect((toolEvents[1].data as ToolUseBlock).name).toBe('read') }) }) })