diff --git a/lib/ai/__tests__/local-client.test.ts b/lib/ai/__tests__/local-client.test.ts new file mode 100644 index 00000000..b1d631a7 --- /dev/null +++ b/lib/ai/__tests__/local-client.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { chatPublic } from '../local-client'; + +describe('chatPublic', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('turns a network/CORS-level failure into an actionable message naming the Base URL', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))); + + await expect( + chatPublic('https://platform.deepseek.com/', 'sk-test', 'deepseek-chat', [ + { role: 'user', content: 'hi' }, + ]), + ).rejects.toThrow(/Could not reach https:\/\/platform\.deepseek\.com\/chat\/completions/); + }); + + it('still reports the provider-returned status when the request completes', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }), + ); + + await expect( + chatPublic('https://api.deepseek.com', 'sk-bad', 'deepseek-chat', [ + { role: 'user', content: 'hi' }, + ]), + ).rejects.toThrow('Provider returned 401'); + }); + + it('returns the message content on success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ choices: [{ message: { content: 'hello there' } }] }), + }), + ); + + await expect( + chatPublic('https://api.deepseek.com', 'sk-good', 'deepseek-chat', [ + { role: 'user', content: 'hi' }, + ]), + ).resolves.toBe('hello there'); + }); +}); diff --git a/lib/ai/local-client.ts b/lib/ai/local-client.ts index 37420720..5dc41d25 100644 --- a/lib/ai/local-client.ts +++ b/lib/ai/local-client.ts @@ -135,14 +135,33 @@ export async function chatPublic( model: string, messages: ChatMessage[], ): Promise { - const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ model, messages }), - }); + const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`; + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, messages }), + }); + } catch { + // A request that never got a response (DNS failure, TLS failure, or - + // by far the most common cause in practice - a CORS preflight the + // target rejected) surfaces to fetch() as a bare, undifferentiated + // "TypeError: Failed to fetch" with no status code to inspect. Verified + // live: a Base URL pointing at a provider's website instead of its API + // (platform.deepseek.com vs api.deepseek.com) fails exactly this way, + // the preflight OPTIONS getting a 403 with no Access-Control-* headers + // at all. Naming the Base URL is the one actionable thing this error + // can tell the user, since the browser gives back nothing else. + throw new Error( + `Could not reach ${url} — check the Base URL is the provider's API endpoint, not its ` + + 'website or console (e.g. api.deepseek.com, not platform.deepseek.com), and that it ' + + 'allows being called directly from a browser.', + ); + } if (!res.ok) throw new Error(`Provider returned ${res.status}`); const body = (await res.json()) as OpenAiChatResponse; const content = body.choices?.[0]?.message?.content;