import { afterEach, describe, expect, it, vi } from 'vitest'; import { createLlmClient, DEFAULT_MAX_TOKENS } from './llm'; function json(body: unknown) { return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body), } as unknown as Response; } function httpError(status: number, text = 'upstream down') { return { ok: false, status, text: async () => text, json: async () => ({}), } as unknown as Response; } afterEach(() => { vi.restoreAllMocks(); }); describe('createLlmClient (injectable transport)', () => { it('chat posts model+messages to {base}/chat/completions and returns content', async () => { const sent: { url: string; init?: RequestInit }[] = []; const transport = async (url: string, init?: RequestInit) => { sent.push({ url, init }); return json({ choices: [{ message: { content: 'hello back' } }] }); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1/', model: 'm1' }); const out = await llm.chat([{ role: 'user', content: 'hi' }], { maxTokens: 10, temperature: 0.5 }); expect(out).toBe('hello back'); expect(sent[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized const body = JSON.parse(sent[0].init!.body as string); // The wire key is `max_tokens` (snake_case), never camelCase `maxTokens`. expect(body).toEqual({ model: 'm1', messages: [{ role: 'user', content: 'hi' }], temperature: 0.5, max_tokens: 10, reasoning_effort: 'low', }); // No auth header: the live vLLM endpoint requires no API key. expect(sent[0].init!.headers).toEqual({ 'content-type': 'application/json' }); }); it('chat uses a generous default max_tokens when the caller omits it', async () => { const sent: { url: string; init?: RequestInit }[] = []; const transport = async (url: string, init?: RequestInit) => { sent.push({ url, init }); return json({ choices: [{ message: { content: 'ok' } }] }); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' }); await llm.chat([{ role: 'user', content: 'hi' }]); const body = JSON.parse(sent[0].init!.body as string); expect(body.max_tokens).toBe(DEFAULT_MAX_TOKENS); expect(body.max_tokens).toBe(4000); }); it('chat sends reasoning_effort "low" by default', async () => { const sent: { url: string; init?: RequestInit }[] = []; const transport = async (url: string, init?: RequestInit) => { sent.push({ url, init }); return json({ choices: [{ message: { content: 'ok' } }] }); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' }); await llm.chat([{ role: 'user', content: 'hi' }]); const body = JSON.parse(sent[0].init!.body as string); expect(body.reasoning_effort).toBe('low'); }); it('chat omits reasoning_effort when it is "off" or empty', async () => { const sent: { url: string; init?: RequestInit }[] = []; const transport = async (url: string, init?: RequestInit) => { sent.push({ url, init }); return json({ choices: [{ message: { content: 'ok' } }] }); }; for (const effort of ['off', 'OFF', '']) { sent.length = 0; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1', reasoningEffort: effort, }); await llm.chat([{ role: 'user', content: 'hi' }]); const body = JSON.parse(sent[0].init!.body as string); expect(body.reasoning_effort, `effort=${JSON.stringify(effort)}`).toBeUndefined(); } }); it('listModels returns data[].id from GET {base}/models', async () => { const transport = async (url: string) => { expect(url).toBe('http://x/v1/models'); return json({ data: [{ id: 'a' }, { id: 'b' }] }); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' }); expect(await llm.listModels()).toEqual(['a', 'b']); }); it('chat retries 5xx and succeeds once the transport recovers (500, 500 → 200)', async () => { const sent: { url: string; init?: RequestInit }[] = []; const transport = async (url: string, init?: RequestInit) => { sent.push({ url, init }); if (sent.length < 3) return httpError(500); return json({ choices: [{ message: { content: 'recovered' } }] }); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 }); const out = await llm.chat([{ role: 'user', content: 'hi' }]); expect(out).toBe('recovered'); expect(sent).toHaveLength(3); // two failures, then success on the third attempt }); it('chat gives up after exactly 3 attempts when the transport always returns 5xx', async () => { const sent: { url: string; init?: RequestInit }[] = []; const transport = async (url: string, init?: RequestInit) => { sent.push({ url, init }); return httpError(500, 'still down'); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 }); await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 500/); expect(sent).toHaveLength(3); }); it('chat does not retry on 4xx (deterministic client error → first attempt only)', async () => { const sent: { url: string; init?: RequestInit }[] = []; const transport = async (url: string, init?: RequestInit) => { sent.push({ url, init }); return httpError(400, 'bad request'); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 }); await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 400/); expect(sent).toHaveLength(1); }); it('chat retries a null content (truncation) and then succeeds (null → content)', async () => { const sent: { url: string; init?: RequestInit }[] = []; const transport = async (url: string, init?: RequestInit) => { sent.push({ url, init }); if (sent.length === 1) return json({ choices: [{ message: { content: null } }] }); return json({ choices: [{ message: { content: 'real content' } }] }); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 }); const out = await llm.chat([{ role: 'user', content: 'hi' }]); expect(out).toBe('real content'); expect(sent).toHaveLength(2); }); it('chat rejects with the HTTP status after 3 attempts when 5xx never recovers', async () => { const sent: { url: string; init?: RequestInit }[] = []; const transport = async (url: string, init?: RequestInit) => { sent.push({ url, init }); return httpError(503); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 }); await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 503/); expect(sent).toHaveLength(3); }); it('chat rejects with a content error after 3 attempts when content is never present', async () => { const sent: { url: string; init?: RequestInit }[] = []; const transport = async (url: string, init?: RequestInit) => { sent.push({ url, init }); return json({ choices: [] }); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 }); await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/no choices/); expect(sent).toHaveLength(3); }); it('chat logs one line on success and one on final failure', async () => { const logSpy = vi.spyOn(console, 'log'); const errorSpy = vi.spyOn(console, 'error'); const okTransport = async () => json({ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }] }); const okLlm = createLlmClient({ transport: okTransport, apiBase: 'http://x/v1', model: 'm' }); await okLlm.chat([{ role: 'user', content: 'hi' }]); expect(logSpy).toHaveBeenCalledWith( '[llm] chat ok', expect.objectContaining({ ms: expect.any(Number), finishReason: 'stop' }), ); const failSent: { url: string; init?: RequestInit }[] = []; const failTransport = async (url: string, init?: RequestInit) => { failSent.push({ url, init }); return httpError(502, 'bad gateway'); }; const failLlm = createLlmClient({ transport: failTransport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1, }); await expect(failLlm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 502/); expect(failSent).toHaveLength(3); expect(errorSpy).toHaveBeenCalledWith( '[llm] chat failed', expect.objectContaining({ attempts: 3, lastStatus: 502, message: expect.stringContaining('HTTP 502'), totalMs: expect.any(Number), }), ); }); }); describe('createLlmClient.chatWithTools (function-calling loop, v0.21 #16 P2)', () => { const tool = { type: 'function' as const, function: { name: 'getNeighbours', description: 'list bordering region keys', parameters: { type: 'object' }, }, }; function toolCallResponse(id: string, name: string, args: string) { return json({ choices: [ { finish_reason: 'tool_calls', message: { content: null, tool_calls: [{ id, type: 'function', function: { name, arguments: args } }], }, }, ], }); } function contentResponse(content: string) { return json({ choices: [{ finish_reason: 'stop', message: { content } }] }); } it('sends tools in the first request; echoes the assistant tool_calls + tool result; the executor gets parsed args', async () => { const sent: string[] = []; const transport = async (_url: string, init?: RequestInit) => { sent.push(String(init?.body ?? '')); if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}'); return contentResponse('final answer'); }; const executorCalls: Array<{ name: string; args: Record }> = []; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' }); const out = await llm.chatWithTools!( [{ role: 'user', content: 'hi' }], [tool], (name, args) => { executorCalls.push({ name, args }); return JSON.stringify({ neighbours: ['b'] }); }, ); expect(out).toBe('final answer'); expect(sent).toHaveLength(2); // request 1: the tools array is present, history is just the initial message const body1 = JSON.parse(sent[0]); expect(body1.tools).toEqual([tool]); expect(body1.messages).toEqual([{ role: 'user', content: 'hi' }]); // request 2: the assistant tool-call and the tool result are echoed back const body2 = JSON.parse(sent[1]); expect(body2.tools).toEqual([tool]); expect(body2.messages).toEqual([ { role: 'user', content: 'hi' }, { role: 'assistant', content: null, tool_calls: [ { id: 'call_1', type: 'function', function: { name: 'getNeighbours', arguments: '{"regionKeys":["a"]}' }, }, ], }, { role: 'tool', tool_call_id: 'call_1', content: JSON.stringify({ neighbours: ['b'] }) }, ]); // the executor received the parsed (object) arguments, not the raw string expect(executorCalls).toEqual([{ name: 'getNeighbours', args: { regionKeys: ['a'] } }]); }); it('a content-only response ends in one round (the executor is never called)', async () => { const sent: string[] = []; const transport = async (_url: string, init?: RequestInit) => { sent.push(String(init?.body ?? '')); return contentResponse('done'); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' }); const out = await llm.chatWithTools!( [{ role: 'user', content: 'hi' }], [tool], () => { throw new Error('the executor must not be called'); }, ); expect(out).toBe('done'); expect(sent).toHaveLength(1); }); it('bounds the tool rounds: maxRounds tool-call rounds, then one final no-tools request', async () => { const sent: string[] = []; let n = 0; const transport = async (_url: string, init?: RequestInit) => { sent.push(String(init?.body ?? '')); n += 1; if (n <= 2) return toolCallResponse(`call_${n}`, 'getNeighbours', '{}'); return contentResponse('committed'); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' }); const out = await llm.chatWithTools!( [{ role: 'user', content: 'hi' }], [tool], () => JSON.stringify({ neighbours: [] }), { maxRounds: 2 }, ); expect(out).toBe('committed'); expect(sent).toHaveLength(3); // 2 tool rounds + the forced final request expect(JSON.parse(sent[2]).tools).toBeUndefined(); // the final request has no tools }); it('malformed tool arguments produce an "invalid tool arguments" tool message (no throw)', async () => { const sent: string[] = []; let n = 0; const transport = async (_url: string, init?: RequestInit) => { sent.push(String(init?.body ?? '')); n += 1; if (n === 1) return toolCallResponse('call_1', 'getNeighbours', '{not valid json'); return contentResponse('recovered'); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' }); const out = await llm.chatWithTools!( [{ role: 'user', content: 'hi' }], [tool], () => { throw new Error('the executor must not be called on malformed args'); }, ); expect(out).toBe('recovered'); const body2 = JSON.parse(sent[1]); const toolMsg = body2.messages.find((m: { role?: string }) => m.role === 'tool'); expect(toolMsg).toBeDefined(); expect(toolMsg.content).toMatch(/invalid tool arguments/); }); it('retries a tool-call round on transient failure and still resolves the tool (500 → tool_calls → content)', async () => { const sent: string[] = []; let n = 0; const transport = async (_url: string, init?: RequestInit) => { sent.push(String(init?.body ?? '')); n += 1; if (n === 1) return httpError(500); if (n === 2) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}'); return contentResponse('ok'); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1, }); const out = await llm.chatWithTools!( [{ role: 'user', content: 'hi' }], [tool], () => JSON.stringify({ neighbours: [] }), ); expect(out).toBe('ok'); expect(sent).toHaveLength(3); }); it('a tool-call response is never treated as a truncation failure (content null is fine)', async () => { const sent: string[] = []; const transport = async (_url: string, init?: RequestInit) => { sent.push(String(init?.body ?? '')); if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{}'); return contentResponse('ok'); }; const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' }); const out = await llm.chatWithTools!( [{ role: 'user', content: 'hi' }], [tool], () => JSON.stringify({ neighbours: [] }), ); expect(out).toBe('ok'); expect(sent).toHaveLength(2); // no retry of the first (successful) round }); });