diff --git a/messages/de.json b/messages/de.json index 39cb35622..f069f25de 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3257,6 +3257,7 @@ "testProvider": "Aktiven Provider testen", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test fehlgeschlagen: {message}", + "testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen — unerwartete Antwort vom Server.", "disableCoach": { "title": "Coach ausblenden", "description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.", diff --git a/messages/en.json b/messages/en.json index e0b030cfd..1160ba975 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3257,6 +3257,7 @@ "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", + "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", "disableCoach": { "title": "Hide Coach", "description": "Hides the Coach button and drawer everywhere.", diff --git a/messages/es.json b/messages/es.json index 80d51e25d..8c0b10aaf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3257,6 +3257,7 @@ "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", + "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", "disableCoach": { "title": "Ocultar el Coach", "description": "Oculta el botón del Coach y el panel en todas las páginas.", diff --git a/messages/fr.json b/messages/fr.json index 98d3ff977..b0f7ed8ca 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3257,6 +3257,7 @@ "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", + "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", "disableCoach": { "title": "Masquer le Coach", "description": "Masque le bouton et le panneau du Coach sur toutes les pages.", diff --git a/messages/it.json b/messages/it.json index cd63d35b7..39c72189e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3257,6 +3257,7 @@ "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", + "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", "disableCoach": { "title": "Nascondi il Coach", "description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.", diff --git a/messages/pl.json b/messages/pl.json index 3b1a05642..f2b500f4c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3257,6 +3257,7 @@ "testProvider": "Test active provider", "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", + "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", "disableCoach": { "title": "Ukryj Coacha", "description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.", diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 4326312ea..eb1902b66 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -1736,7 +1736,33 @@ function RuntimeActionsRow({ setTestMsg(null); try { const res = await fetch("/api/ai/test", { method: "POST" }); - const json = await res.json(); + // Guard against a non-JSON body. A reverse proxy / Cloudflare can + // rewrite an origin error to its own HTML page; parsing that as JSON + // throws `Unexpected token '<'` (Safari: "did not match the + // expected pattern"). Show a clean message instead of leaking that. + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + setTestOk(false); + setTestMsg(t("settings.ai.testUnexpectedResponse")); + return; + } + let json: { + data?: { + ok?: boolean; + providerType?: string; + model?: string; + reason?: string; + } | null; + error?: string | null; + }; + try { + json = await res.json(); + } catch { + setTestOk(false); + setTestMsg(t("settings.ai.testUnexpectedResponse")); + return; + } + // 4xx config errors still arrive via the error envelope. if (!res.ok) { setTestOk(false); setTestMsg( @@ -1746,11 +1772,22 @@ function RuntimeActionsRow({ ); return; } + // Provider-call failures now arrive as 200 + { ok:false, reason } + // so the body is never rewritten by a proxy. Show the reason + // verbatim — it is a categorised, secret-free string. + if (json.data && json.data.ok === false) { + setTestOk(false); + setTestMsg( + json.data.reason ?? + t("settings.ai.testFailedShort", { message: `HTTP ${res.status}` }), + ); + return; + } setTestOk(true); setTestMsg( t("settings.ai.testSuccess", { - provider: json.data.providerType, - model: json.data.model, + provider: json.data?.providerType ?? "", + model: json.data?.model ?? "", }), ); } catch (e) { @@ -1774,7 +1811,27 @@ function RuntimeActionsRow({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ force: true }), }); - const json = await res.json(); + // Same defensive guard as the connection test: a proxy can rewrite + // a 5xx (e.g. all-providers-failed) to an HTML page, which would + // crash `res.json()` with `Unexpected token '<'`. + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + setRegenOk(false); + setRegenMsg( + res.status === 429 + ? t("settings.regenerateRateLimit") + : t("settings.ai.testUnexpectedResponse"), + ); + return; + } + let json: { error?: string | null }; + try { + json = await res.json(); + } catch { + setRegenOk(false); + setRegenMsg(t("settings.ai.testUnexpectedResponse")); + return; + } if (!res.ok) { setRegenOk(false); setRegenMsg(