import { describe, it, expect, vi } from 'vitest'; import { OpenAiPassthroughAdapter } from '../src/services/llm/adapters/openai-passthrough.js'; import { AnthropicAdapter } from '../src/services/llm/adapters/anthropic.js'; import { LlmAdapterRegistry, UnsupportedProviderError } from '../src/services/llm/dispatcher.js'; import type { InferContext } from '../src/services/llm/types.js'; function mockFetch(responses: Array<{ match: RegExp; status: number; body?: unknown; text?: string }>): ReturnType { return vi.fn(async (input: string | URL, _init?: RequestInit) => { const url = String(input); const match = responses.find((r) => r.match.test(url)); if (!match) throw new Error(`unexpected fetch: ${url}`); const body = match.body !== undefined ? JSON.stringify(match.body) : (match.text ?? ''); return new Response(body, { status: match.status, headers: { 'Content-Type': 'application/json' } }); }); } function makeCtx(overrides: Partial = {}): InferContext { return { body: { model: '', messages: [{ role: 'user', content: 'hello' }] }, modelOverride: 'default-model', apiKey: 'test-key', url: '', extraConfig: {}, ...overrides, }; } // Helper to build a streaming Response from SSE lines. function sseResponse(events: string[]): Response { const body = events.join('\n\n') + '\n\n'; const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(body)); controller.close(); }, }); return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); } describe('OpenAiPassthroughAdapter', () => { it('infer: POSTs to /v1/chat/completions with Authorization + body', async () => { const fetchFn = mockFetch([{ match: /\/v1\/chat\/completions$/, status: 200, body: { id: 'x', choices: [{ message: { role: 'assistant', content: 'hi' } }] }, }]); const adapter = new OpenAiPassthroughAdapter('openai', { fetch: fetchFn as unknown as typeof fetch }); const ctx = makeCtx({ url: 'https://api.example.com' }); const res = await adapter.infer(ctx); expect(res.status).toBe(200); const [url, init] = fetchFn.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://api.example.com/v1/chat/completions'); expect(init.method).toBe('POST'); const headers = init.headers as Record; expect(headers['Authorization']).toBe('Bearer test-key'); const sent = JSON.parse(init.body as string) as { model: string; stream: boolean }; expect(sent.model).toBe('default-model'); // filled from modelOverride expect(sent.stream).toBe(false); }); it('infer: uses default URL for openai when url is empty', async () => { const fetchFn = mockFetch([{ match: /api\.openai\.com/, status: 200, body: {} }]); const adapter = new OpenAiPassthroughAdapter('openai', { fetch: fetchFn as unknown as typeof fetch }); await adapter.infer(makeCtx()); const [url] = fetchFn.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://api.openai.com/v1/chat/completions'); }); it('infer: throws for vllm when url is empty (no default)', async () => { const adapter = new OpenAiPassthroughAdapter('vllm', { fetch: vi.fn() as unknown as typeof fetch }); await expect(adapter.infer(makeCtx())).rejects.toThrow(/no default endpoint/); }); it('infer: omits Authorization when apiKey is empty', async () => { const fetchFn = mockFetch([{ match: /ollama/, status: 200, body: {} }]); const adapter = new OpenAiPassthroughAdapter('ollama', { fetch: fetchFn as unknown as typeof fetch }); await adapter.infer(makeCtx({ url: 'http://ollama:11434', apiKey: '' })); const [, init] = fetchFn.mock.calls[0] as [string, RequestInit]; const headers = init.headers as Record; expect(headers['Authorization']).toBeUndefined(); }); it('stream: forwards SSE chunks and emits terminal [DONE]', async () => { const fetchFn = vi.fn(async () => sseResponse([ 'data: {"choices":[{"delta":{"content":"hi"}}]}', 'data: {"choices":[{"delta":{"content":"!"}}]}', 'data: [DONE]', ])); const adapter = new OpenAiPassthroughAdapter('openai', { fetch: fetchFn as unknown as typeof fetch }); const ctx = makeCtx({ url: 'http://example', body: { model: '', messages: [], stream: true } }); const chunks: { data: string; done?: boolean }[] = []; for await (const c of adapter.stream(ctx)) chunks.push(c); expect(chunks).toHaveLength(3); expect(chunks[2]?.done).toBe(true); }); }); describe('AnthropicAdapter', () => { it('infer: translates system+user messages, posts to /v1/messages', async () => { const fetchFn = mockFetch([{ match: /\/v1\/messages$/, status: 200, body: { id: 'msg_01', model: 'claude-3-5-sonnet-20241022', role: 'assistant', content: [{ type: 'text', text: 'howdy' }], stop_reason: 'end_turn', usage: { input_tokens: 5, output_tokens: 2 }, }, }]); const adapter = new AnthropicAdapter({ fetch: fetchFn as unknown as typeof fetch }); const ctx = makeCtx({ body: { model: '', messages: [ { role: 'system', content: 'be nice' }, { role: 'user', content: 'hi' }, ], }, modelOverride: 'claude-3-5-sonnet-20241022', }); const res = await adapter.infer(ctx); expect(res.status).toBe(200); const [url, init] = fetchFn.mock.calls[0] as [string, RequestInit]; expect(url).toBe('https://api.anthropic.com/v1/messages'); const headers = init.headers as Record; expect(headers['x-api-key']).toBe('test-key'); expect(headers['anthropic-version']).toBeDefined(); const sent = JSON.parse(init.body as string) as { model: string; system: string; messages: Array<{ role: string; content: string }>; max_tokens: number; }; expect(sent.model).toBe('claude-3-5-sonnet-20241022'); expect(sent.system).toBe('be nice'); expect(sent.messages).toEqual([{ role: 'user', content: 'hi' }]); expect(sent.max_tokens).toBe(1024); // default // Response shape: OpenAI chat.completion const body = res.body as { choices: Array<{ message: { content: string }; finish_reason: string }>; usage: { total_tokens: number } }; expect(body.choices[0]!.message.content).toBe('howdy'); expect(body.choices[0]!.finish_reason).toBe('stop'); expect(body.usage.total_tokens).toBe(7); }); it('infer: returns a synthetic error body on non-2xx', async () => { const fetchFn = vi.fn(async () => new Response('boom', { status: 500 })); const adapter = new AnthropicAdapter({ fetch: fetchFn as unknown as typeof fetch }); const res = await adapter.infer(makeCtx({ body: { model: '', messages: [{ role: 'user', content: 'x' }] } })); expect(res.status).toBe(500); const body = res.body as { error: { message: string } }; expect(body.error.message).toMatch(/HTTP 500/); }); it('stream: translates anthropic event stream into OpenAI chunks', async () => { const events = [ 'event: message_start\ndata: {"type":"message_start","message":{"id":"m","content":[]}}', 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"he"}}', 'event: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"llo"}}', 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}', 'event: message_stop\ndata: {"type":"message_stop"}', ]; const fetchFn = vi.fn(async () => sseResponse(events)); const adapter = new AnthropicAdapter({ fetch: fetchFn as unknown as typeof fetch }); const ctx = makeCtx({ body: { model: '', messages: [{ role: 'user', content: 'hi' }], stream: true } }); const chunks: { data: string; done?: boolean }[] = []; for await (const c of adapter.stream(ctx)) chunks.push(c); // Expect: role-prime, two text deltas, finish-reason, [DONE] expect(chunks[chunks.length - 1]?.data).toBe('[DONE]'); expect(chunks[chunks.length - 1]?.done).toBe(true); // First chunk is the role-prime (role: assistant, content: ''). const first = JSON.parse(chunks[0]!.data) as { choices: [{ delta: { role: string; content: string } }] }; expect(first.choices[0]!.delta.role).toBe('assistant'); // Next two chunks carry the text. const d1 = JSON.parse(chunks[1]!.data) as { choices: [{ delta: { content: string } }] }; const d2 = JSON.parse(chunks[2]!.data) as { choices: [{ delta: { content: string } }] }; expect(d1.choices[0]!.delta.content).toBe('he'); expect(d2.choices[0]!.delta.content).toBe('llo'); // Finish-reason chunk. const stopped = JSON.parse(chunks[3]!.data) as { choices: [{ finish_reason: string }] }; expect(stopped.choices[0]!.finish_reason).toBe('stop'); }); }); describe('LlmAdapterRegistry', () => { it('returns the right adapter kind for each type', () => { const reg = new LlmAdapterRegistry(); expect(reg.get('openai').kind).toBe('openai'); expect(reg.get('vllm').kind).toBe('vllm'); expect(reg.get('deepseek').kind).toBe('deepseek'); expect(reg.get('ollama').kind).toBe('ollama'); expect(reg.get('anthropic').kind).toBe('anthropic'); }); it('caches adapters between calls', () => { const reg = new LlmAdapterRegistry(); const a = reg.get('openai'); const b = reg.get('openai'); expect(a).toBe(b); }); it('rejects unsupported providers (gemini-cli is deferred)', () => { const reg = new LlmAdapterRegistry(); expect(() => reg.get('gemini-cli')).toThrow(UnsupportedProviderError); expect(() => reg.get('bogus')).toThrow(UnsupportedProviderError); }); }); describe('verifyAuth — registration-time probe', () => { it('OpenAI passthrough: 200 from /v1/models → ok', async () => { const fetchImpl = mockFetch([ { match: /\/v1\/models$/, status: 200, body: { data: [{ id: 'gpt-4o-mini' }] } }, ]); const adapter = new OpenAiPassthroughAdapter('openai', { fetch: fetchImpl as unknown as typeof fetch }); const result = await adapter.verifyAuth(makeCtx({ url: 'http://lite:4000', apiKey: 'sk-good' })); expect(result).toEqual({ ok: true }); expect(fetchImpl).toHaveBeenCalledWith('http://lite:4000/v1/models', expect.objectContaining({ method: 'GET' })); const callInit = fetchImpl.mock.calls[0][1] as RequestInit; expect((callInit.headers as Record)['Authorization']).toBe('Bearer sk-good'); }); it('OpenAI passthrough: 401 → reason=auth (caller throws)', async () => { const fetchImpl = mockFetch([ { match: /\/v1\/models$/, status: 401, text: '{"error":"invalid_api_key"}' }, ]); const adapter = new OpenAiPassthroughAdapter('openai', { fetch: fetchImpl as unknown as typeof fetch }); const result = await adapter.verifyAuth(makeCtx({ url: 'http://lite:4000', apiKey: 'sk-bad' })); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe('auth'); if (result.reason === 'auth') { expect(result.status).toBe(401); expect(result.body).toContain('invalid_api_key'); } } }); it('OpenAI passthrough: 403 → reason=auth', async () => { const fetchImpl = mockFetch([ { match: /\/v1\/models$/, status: 403, text: 'forbidden' }, ]); const adapter = new OpenAiPassthroughAdapter('openai', { fetch: fetchImpl as unknown as typeof fetch }); const result = await adapter.verifyAuth(makeCtx({ url: 'http://lite:4000', apiKey: 'k' })); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe('auth'); }); it('OpenAI passthrough: 404 (proxy without /v1/models) → reason=unexpected (warn-only)', async () => { const fetchImpl = mockFetch([ { match: /\/v1\/models$/, status: 404, text: 'not found' }, ]); const adapter = new OpenAiPassthroughAdapter('openai', { fetch: fetchImpl as unknown as typeof fetch }); const result = await adapter.verifyAuth(makeCtx({ url: 'http://lite:4000', apiKey: 'k' })); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe('unexpected'); }); it('OpenAI passthrough: network error → reason=unreachable (warn-only)', async () => { const fetchImpl = vi.fn(async () => { throw new Error('ECONNREFUSED 127.0.0.1:9999'); }); const adapter = new OpenAiPassthroughAdapter('openai', { fetch: fetchImpl as unknown as typeof fetch }); const result = await adapter.verifyAuth(makeCtx({ url: 'http://localhost:9999', apiKey: 'k' })); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe('unreachable'); if (result.reason === 'unreachable') { expect(result.error).toContain('ECONNREFUSED'); } } }); it('Anthropic: 200 from /v1/messages probe → ok', async () => { const fetchImpl = mockFetch([ { match: /\/v1\/messages$/, status: 200, body: { id: 'msg_x', content: [{ type: 'text', text: 'pong' }] } }, ]); const adapter = new AnthropicAdapter({ fetch: fetchImpl as unknown as typeof fetch }); const result = await adapter.verifyAuth(makeCtx({ url: 'https://api.anthropic.com', apiKey: 'sk-ant-good' })); expect(result.ok).toBe(true); const callInit = fetchImpl.mock.calls[0][1] as RequestInit; expect((callInit.headers as Record)['x-api-key']).toBe('sk-ant-good'); const reqBody = JSON.parse(callInit.body as string) as { max_tokens: number }; expect(reqBody.max_tokens).toBe(1); }); it('Anthropic: 401 → reason=auth', async () => { const fetchImpl = mockFetch([ { match: /\/v1\/messages$/, status: 401, text: '{"type":"authentication_error"}' }, ]); const adapter = new AnthropicAdapter({ fetch: fetchImpl as unknown as typeof fetch }); const result = await adapter.verifyAuth(makeCtx({ apiKey: 'bad' })); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe('auth'); }); it('Anthropic: 400 (typo\'d model) → reason=unexpected, NOT auth', async () => { // Auth was fine; the request was rejected for a different reason. We // don't want to block registration on bad model names — that error // surfaces at chat time when the user actually picks a model. const fetchImpl = mockFetch([ { match: /\/v1\/messages$/, status: 400, text: '{"error":"model not found"}' }, ]); const adapter = new AnthropicAdapter({ fetch: fetchImpl as unknown as typeof fetch }); const result = await adapter.verifyAuth(makeCtx({ apiKey: 'sk-ant-x', modelOverride: 'claude-fake' })); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe('unexpected'); }); });