import { describe, it, expect, vi, beforeEach } from 'vitest'; import { OpenAICompatProvider } from '../../providers/openai-compat.js'; describe('OpenAICompatProvider', () => { let provider: OpenAICompatProvider; beforeEach(() => { provider = new OpenAICompatProvider({ platform: 'groq', name: 'TestProvider', baseUrl: 'https://api.test.com/v1', extraHeaders: { 'X-Custom': 'test' }, }); }); it('should set platform and name from config', () => { expect(provider.platform).toBe('groq'); expect(provider.name).toBe('TestProvider'); }); it('should call API with correct URL and headers', async () => { let capturedUrl = ''; let capturedHeaders: Record = {}; let capturedBody: any = null; vi.spyOn(global, 'fetch').mockImplementation(async (url, init) => { capturedUrl = url as string; capturedHeaders = (init as any).headers; capturedBody = JSON.parse((init as any).body); return { ok: true, json: () => Promise.resolve({ id: 'test-id', object: 'chat.completion', created: 123, model: 'test-model', choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), } as any; }); await provider.chatCompletion('my-key', [{ role: 'user', content: 'test' }], 'test-model'); expect(capturedUrl).toBe('https://api.test.com/v1/chat/completions'); expect(capturedHeaders['Authorization']).toBe('Bearer my-key'); expect(capturedHeaders['X-Custom']).toBe('test'); expect(capturedBody.messages[0].role).toBe('user'); }); it('should pass tool-calling params through untouched', async () => { let capturedBody: any = null; vi.spyOn(global, 'fetch').mockImplementation(async (_url, init) => { capturedBody = JSON.parse((init as any).body); return { ok: true, json: () => Promise.resolve({ id: 'test-id', object: 'chat.completion', created: 123, model: 'test-model', choices: [{ index: 0, message: { role: 'assistant', content: null, tool_calls: [] }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), } as any; }); await provider.chatCompletion( 'my-key', [{ role: 'user', content: 'what is weather?' }], 'test-model', { tools: [{ type: 'function', function: { name: 'get_weather', description: 'Get weather', parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], }, }, }], tool_choice: 'required', parallel_tool_calls: true, }, ); expect(capturedBody.tools).toHaveLength(1); expect(capturedBody.tool_choice).toBe('required'); expect(capturedBody.parallel_tool_calls).toBe(true); }); describe('forceSingleToolCall (NVIDIA NIM single-tool-call 400 — issue #255)', () => { const nim = () => new OpenAICompatProvider({ platform: 'nvidia', name: 'NVIDIA NIM', baseUrl: 'https://integrate.api.nvidia.com/v1', forceSingleToolCall: true, }); const okResponse = { ok: true, json: () => Promise.resolve({ id: 'x', object: 'chat.completion', created: 1, model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), } as any; const tools = [{ type: 'function' as const, function: { name: 'f', description: 'd', parameters: { type: 'object', properties: {} } } }]; it('pins parallel_tool_calls to false when tools are present, even if the caller asked for true', async () => { let body: any = null; vi.spyOn(global, 'fetch').mockImplementation(async (_u, init) => { body = JSON.parse((init as any).body); return okResponse; }); await nim().chatCompletion('k', [{ role: 'user', content: 'hi' }], 'm', { tools, parallel_tool_calls: true }); expect(body.parallel_tool_calls).toBe(false); }); it('leaves parallel_tool_calls untouched when there are no tools', async () => { let body: any = null; vi.spyOn(global, 'fetch').mockImplementation(async (_u, init) => { body = JSON.parse((init as any).body); return okResponse; }); await nim().chatCompletion('k', [{ role: 'user', content: 'hi' }], 'm', {}); expect(body.parallel_tool_calls).toBeUndefined(); }); it('does not affect providers without the flag (parallel_tool_calls passes through)', async () => { let body: any = null; vi.spyOn(global, 'fetch').mockImplementation(async (_u, init) => { body = JSON.parse((init as any).body); return okResponse; }); await provider.chatCompletion('k', [{ role: 'user', content: 'hi' }], 'm', { tools, parallel_tool_calls: true }); expect(body.parallel_tool_calls).toBe(true); }); }); it('should throw on error response', async () => { vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: false, status: 429, statusText: 'Rate Limited', json: () => Promise.resolve({ error: { message: 'Too many requests' } }), } as any); await expect( provider.chatCompletion('key', [{ role: 'user', content: 'hi' }], 'model') ).rejects.toThrow(/Too many requests/); }); it('explains a non-JSON 200 body instead of surfacing the raw parse error (#189)', async () => { // e.g. a custom base URL pointing at Ollama's native NDJSON /api endpoint: // real fetch's res.json() rejects with "Unexpected non-whitespace character // after JSON at position …", which is useless to the user. vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true, status: 200, json: () => Promise.reject(new SyntaxError('Unexpected non-whitespace character after JSON at position 583 (line 27 column 2)')), } as any); await expect( provider.chatCompletion('key', [{ role: 'user', content: 'hi' }], 'model') ).rejects.toThrow(/not OpenAI-compatible/); }); it('should validate key using models endpoint', async () => { vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true, status: 200 } as any); expect(await provider.validateKey('valid')).toBe(true); }); it('validateKey returns false on confirmed 401', async () => { vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: false, status: 401 } as any); expect(await provider.validateKey('bad')).toBe(false); }); it('validateKey propagates transport errors instead of swallowing', async () => { vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('ECONNREFUSED')); await expect(provider.validateKey('any')).rejects.toThrow(/ECONNREFUSED/); }); it('folds reasoning_content into content when content is empty (Z.ai glm-4.5-flash style)', async () => { vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'id', object: 'chat.completion', created: 1, model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: '', reasoning_content: 'the actual answer' }, finish_reason: 'stop', }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), } as any); const result = await provider.chatCompletion('k', [{ role: 'user', content: 'hi' }], 'm'); expect(result.choices[0].message.content).toBe('the actual answer'); }); it('flattens array content into a string (Mistral magistral style)', async () => { vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'id', object: 'chat.completion', created: 1, model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: [{ type: 'text', text: 'part one ' }, { type: 'text', text: 'part two' }] }, finish_reason: 'stop', }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), } as any); const result = await provider.chatCompletion('k', [{ role: 'user', content: 'hi' }], 'm'); expect(result.choices[0].message.content).toBe('part one part two'); }); it('folds reasoning into content when content is empty (Ollama style — bare `reasoning` field)', async () => { vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'id', object: 'chat.completion', created: 1, model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: '', reasoning: 'ollama answer' }, finish_reason: 'stop', }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), } as any); const result = await provider.chatCompletion('k', [{ role: 'user', content: 'hi' }], 'm'); expect(result.choices[0].message.content).toBe('ollama answer'); }); it('prefers reasoning_content over reasoning when both are present', async () => { vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'id', object: 'chat.completion', created: 1, model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: '', reasoning_content: 'preferred', reasoning: 'fallback' }, finish_reason: 'stop', }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), } as any); const result = await provider.chatCompletion('k', [{ role: 'user', content: 'hi' }], 'm'); expect(result.choices[0].message.content).toBe('preferred'); }); it('does NOT fold reasoning_content when tool_calls are present', async () => { vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'id', object: 'chat.completion', created: 1, model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: null, reasoning_content: 'I am thinking about the tool', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'get_weather', arguments: '{}' } }], }, finish_reason: 'tool_calls', }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), } as any); const result = await provider.chatCompletion('k', [{ role: 'user', content: 'hi' }], 'm'); expect(result.choices[0].message.content).toBeNull(); expect(result.choices[0].message.tool_calls?.[0].function.name).toBe('get_weather'); }); it('leaves real string content untouched', async () => { vi.spyOn(global, 'fetch').mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'id', object: 'chat.completion', created: 1, model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: 'normal answer', reasoning_content: 'should not override' }, finish_reason: 'stop', }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, }), } as any); const result = await provider.chatCompletion('k', [{ role: 'user', content: 'hi' }], 'm'); expect(result.choices[0].message.content).toBe('normal answer'); }); }); describe('OpenAICompatProvider - platform instances', () => { // Mirrors the actual registrations in server/src/providers/index.ts. // Update both when adding/removing a platform. const platforms = [ { platform: 'groq', name: 'Groq', baseUrl: 'https://api.groq.com/openai/v1' }, { platform: 'cerebras', name: 'Cerebras', baseUrl: 'https://api.cerebras.ai/v1' }, { platform: 'nvidia', name: 'NVIDIA NIM', baseUrl: 'https://integrate.api.nvidia.com/v1' }, { platform: 'mistral', name: 'Mistral', baseUrl: 'https://api.mistral.ai/v1' }, { platform: 'openrouter', name: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1' }, { platform: 'github', name: 'GitHub Models', baseUrl: 'https://models.github.ai/inference' }, { platform: 'zhipu', name: 'Zhipu AI', baseUrl: 'https://open.bigmodel.cn/api/paas/v4' }, { platform: 'opencode', name: 'OpenCode Zen', baseUrl: 'https://opencode.ai/zen/v1' }, ] as const; for (const p of platforms) { it(`${p.name} provider should make requests to ${p.baseUrl}`, async () => { const provider = new OpenAICompatProvider(p as any); let capturedUrl = ''; vi.spyOn(global, 'fetch').mockImplementation(async (url) => { capturedUrl = url as string; return { ok: true, json: () => Promise.resolve({ id: 'id', object: 'chat.completion', created: 1, model: 'm', choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, }), } as any; }); const result = await provider.chatCompletion('key', [{ role: 'user', content: 'hi' }], 'model'); expect(capturedUrl).toContain(p.baseUrl); expect(result._routed_via?.platform).toBe(p.platform); }); } });