fix(ai): turn a bare 'Failed to fetch' into an actionable BYOK error
chatPublic() calls the provider's /chat/completions directly from the renderer. Verified live: a saved profile pointing at platform.deepseek.com (DeepSeek's console) instead of api.deepseek.com (their actual API) fails the CORS preflight outright - 403, no Access-Control-* headers - which surfaces to fetch() as an undifferentiated "Failed to fetch" with no status to inspect. Confirmed the real API and OpenRouter both support being called directly from a browser fine, so the architecture is sound; only the error message was useless. Now names the URL and the likely cause instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
395fcc27a8
commit
1aa0a4686b
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+27
-8
@@ -135,14 +135,33 @@ export async function chatPublic(
|
|||||||
model: string,
|
model: string,
|
||||||
messages: ChatMessage[],
|
messages: ChatMessage[],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
||||||
method: 'POST',
|
let res: Response;
|
||||||
headers: {
|
try {
|
||||||
'Content-Type': 'application/json',
|
res = await fetch(url, {
|
||||||
Authorization: `Bearer ${apiKey}`,
|
method: 'POST',
|
||||||
},
|
headers: {
|
||||||
body: JSON.stringify({ model, messages }),
|
'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}`);
|
if (!res.ok) throw new Error(`Provider returned ${res.status}`);
|
||||||
const body = (await res.json()) as OpenAiChatResponse;
|
const body = (await res.json()) as OpenAiChatResponse;
|
||||||
const content = body.choices?.[0]?.message?.content;
|
const content = body.choices?.[0]?.message?.content;
|
||||||
|
|||||||
Reference in New Issue
Block a user