import { describe, it, expect, vi } from 'vitest'; import { OpenAIClient } from './openai.js'; const { mockCreate, mockOpenAIConstructor } = vi.hoisted(() => { const mockCreate = vi.fn().mockResolvedValue({ choices: [{ message: { content: 'Hello from GPT!' }, finish_reason: 'stop' }], usage: { prompt_tokens: 10, completion_tokens: 5 }, }); const mockOpenAIConstructor = vi.fn().mockImplementation(() => ({ chat: { completions: { create: mockCreate, }, }, })); return { mockCreate, mockOpenAIConstructor }; }); vi.mock('openai', () => ({ default: mockOpenAIConstructor, })); describe('OpenAIClient', () => { it('sets request timeout and disables SDK retries', () => { new OpenAIClient({ apiKey: 'test-key', model: 'gpt-4o', }); expect(mockOpenAIConstructor).toHaveBeenCalledWith(expect.objectContaining({ timeout: 20_000, maxRetries: 0, })); }); it('sends messages and returns response', async () => { const client = new OpenAIClient({ apiKey: 'test-key', model: 'gpt-4o', }); const response = await client.chat({ messages: [{ role: 'user', content: 'Hello' }], }); expect(response.content).toBe('Hello from GPT!'); expect(response.stopReason).toBe('end_turn'); expect(response.usage.inputTokens).toBe(10); expect(response.usage.outputTokens).toBe(5); }); }); describe('OpenAIClient tool use', () => { it('passes tools to API and parses tool_calls response', async () => { mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: null, tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'shell.exec', arguments: '{"command":"ls"}' }, }], }, finish_reason: 'tool_calls', }], usage: { prompt_tokens: 20, completion_tokens: 15 }, }); const client = new OpenAIClient({ apiKey: 'test-key', model: 'gpt-4o', }); const response = await client.chat({ messages: [{ role: 'user', content: 'list files' }], tools: [{ name: 'shell.exec', description: 'Run shell command', input_schema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] }, }], }); expect(response.stopReason).toBe('tool_use'); expect(response.toolCalls).toHaveLength(1); expect(response.toolCalls![0]).toEqual({ id: 'call_1', name: 'shell.exec', args: { command: 'ls' }, }); }); it('maps finish_reason "tool_calls" with empty tool_calls to end_turn', async () => { mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: 'I tried to call a tool but none matched.', tool_calls: [], }, finish_reason: 'tool_calls', }], usage: { prompt_tokens: 15, completion_tokens: 10 }, }); const client = new OpenAIClient({ apiKey: 'test-key', model: 'gpt-4o', }); const response = await client.chat({ messages: [{ role: 'user', content: 'do something' }], }); expect(response.stopReason).toBe('end_turn'); expect(response.toolCalls).toBeUndefined(); }); it('maps finish_reason "length" to max_tokens', async () => { mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: 'Truncated output...' }, finish_reason: 'length', }], usage: { prompt_tokens: 100, completion_tokens: 4096 }, }); const client = new OpenAIClient({ apiKey: 'test-key', model: 'gpt-4o', }); const response = await client.chat({ messages: [{ role: 'user', content: 'write a long essay' }], }); expect(response.stopReason).toBe('max_tokens'); }); });