From 9bdf76ea117622b7a28b16a05286c65dff95eb75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 09:12:20 +0200 Subject: [PATCH] fix(settings): localise AI connection-test failure reasons Return a stable, secret-free reasonCode from /api/ai/test alongside the English reason, map it to a localised string in the AI settings panel, and fall back to the plain reason for any unmapped code. A German operator no longer sees an English failure sentence in an otherwise localised panel. --- messages/de.json | 4 ++ messages/en.json | 4 ++ messages/es.json | 4 ++ messages/fr.json | 4 ++ messages/it.json | 4 ++ messages/pl.json | 4 ++ src/app/api/ai/test/__tests__/route.test.ts | 14 ++++++- src/app/api/ai/test/route.ts | 45 ++++++++++++++++----- src/components/settings/ai-section.tsx | 33 +++++++++++++-- 9 files changed, 101 insertions(+), 15 deletions(-) diff --git a/messages/de.json b/messages/de.json index 840cfa241..10928eb92 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3260,6 +3260,10 @@ "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test fehlgeschlagen: {message}", "testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen — unerwartete Antwort vom Server.", + "testReasonCredentials": "Der Anbieter hat die Anmeldedaten abgelehnt — melde dich in den KI-Einstellungen erneut an.", + "testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt — versuche es in Kürze erneut.", + "testReasonServerError": "Der KI-Anbieter hat einen Serverfehler zurückgegeben.", + "testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.", "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 9369d1c64..84e8cfc5c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3260,6 +3260,10 @@ "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testReasonServerError": "The AI provider returned a server error.", + "testReasonUnreachable": "Could not reach the AI provider.", "disableCoach": { "title": "Hide Coach", "description": "Hides the Coach button and drawer everywhere.", diff --git a/messages/es.json b/messages/es.json index 99faabece..e6741d045 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3260,6 +3260,10 @@ "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testReasonServerError": "The AI provider returned a server error.", + "testReasonUnreachable": "Could not reach the AI provider.", "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 e6fd310c5..79b4a3195 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3260,6 +3260,10 @@ "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testReasonServerError": "The AI provider returned a server error.", + "testReasonUnreachable": "Could not reach the AI provider.", "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 231752a58..be4f32660 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3260,6 +3260,10 @@ "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testReasonServerError": "The AI provider returned a server error.", + "testReasonUnreachable": "Could not reach the AI provider.", "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 f42943669..acbf89332 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3260,6 +3260,10 @@ "testSuccess": "OK — {provider} ({model})", "testFailedShort": "Test failed: {message}", "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testReasonServerError": "The AI provider returned a server error.", + "testReasonUnreachable": "Could not reach the AI provider.", "disableCoach": { "title": "Ukryj Coacha", "description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.", diff --git a/src/app/api/ai/test/__tests__/route.test.ts b/src/app/api/ai/test/__tests__/route.test.ts index 8ae65d816..054150966 100644 --- a/src/app/api/ai/test/__tests__/route.test.ts +++ b/src/app/api/ai/test/__tests__/route.test.ts @@ -66,7 +66,12 @@ function jsonRequest(body: unknown): Request { } interface TestFailureEnvelope { - data: { ok: false; providerType: string; reason: string }; + data: { + ok: false; + providerType: string; + reasonCode: "credentials" | "rate_limited" | "server_error" | "unreachable"; + reason: string; + }; error: null; } @@ -108,6 +113,9 @@ describe("POST /api/ai/test — provider error leak guard + non-5xx contract", ( expect(response.status).toBe(200); const body = (await response.json()) as TestFailureEnvelope; expect(body.data.ok).toBe(false); + expect(body.data.reasonCode).toBe("credentials"); + // The stable machine code must itself be secret-free. + expect(body.data.reasonCode).not.toMatch(/sk-/); expect(body.data.reason).not.toMatch(/sk-/); expect(body.data.reason).not.toMatch(/api\.openai\.com/); expect(body.data.reason).not.toMatch(/invalid api key/i); @@ -128,6 +136,7 @@ describe("POST /api/ai/test — provider error leak guard + non-5xx contract", ( expect(response.status).toBe(200); const body = (await response.json()) as TestFailureEnvelope; expect(body.data.ok).toBe(false); + expect(body.data.reasonCode).toBe("credentials"); expect(body.data.reason).toMatch(/re-authenticate/i); }); @@ -138,6 +147,7 @@ describe("POST /api/ai/test — provider error leak guard + non-5xx contract", ( const response = await POST(emptyRequest() as never); expect(response.status).toBe(200); const body = (await response.json()) as TestFailureEnvelope; + expect(body.data.reasonCode).toBe("rate_limited"); expect(body.data.reason).toMatch(/rate-limited/i); }); @@ -148,6 +158,7 @@ describe("POST /api/ai/test — provider error leak guard + non-5xx contract", ( const response = await POST(emptyRequest() as never); expect(response.status).toBe(200); const body = (await response.json()) as TestFailureEnvelope; + expect(body.data.reasonCode).toBe("server_error"); expect(body.data.reason).toMatch(/server error/i); }); @@ -156,6 +167,7 @@ describe("POST /api/ai/test — provider error leak guard + non-5xx contract", ( const response = await POST(emptyRequest() as never); expect(response.status).toBe(200); const body = (await response.json()) as TestFailureEnvelope; + expect(body.data.reasonCode).toBe("unreachable"); expect(body.data.reason).toMatch(/could not reach/i); }); }); diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index d152de3a2..1dbf3decb 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -99,11 +99,27 @@ export const POST = apiHandler(async (request: NextRequest) => { // operator hit re-authenticating a provider whose token came back as // an invalidated-session 500. We always reply 200 with a categorised, // secret-free `{ ok:false, reason }` the client can show verbatim. - const reason = classifyTestFailure(err); - return apiSuccess({ ok: false, providerType: provider.type, reason }); + const { reasonCode, reason } = classifyTestFailure(err); + return apiSuccess({ + ok: false, + providerType: provider.type, + reasonCode, + reason, + }); } }); +/** + * Stable, machine-readable failure categories. The client maps each to a + * localised string; the English `reason` stays as a fallback for any + * legacy / unmapped code. Secret-free by construction. + */ +type TestFailureCode = + | "credentials" + | "rate_limited" + | "server_error" + | "unreachable"; + /** * Map an upstream provider failure to a human-readable, secret-free * reason string. Distinguishes credential/auth, rate-limit, provider @@ -115,23 +131,32 @@ export const POST = apiHandler(async (request: NextRequest) => { */ function classifyTestFailure( err: Error & { httpStatus?: number; bodyExcerpt?: string }, -): string { +): { reasonCode: TestFailureCode; reason: string } { const status = err.httpStatus ?? 0; const haystack = `${err.message} ${err.bodyExcerpt ?? ""}`; const looksLikeAuth = /authentication|invalidated|expired|sign in again|api key/i.test(haystack); if (status === 401 || status === 403 || (status >= 500 && looksLikeAuth)) { - return "Provider rejected the credentials — re-authenticate in AI settings."; + return { + reasonCode: "credentials", + reason: "Provider rejected the credentials — re-authenticate in AI settings.", + }; } if (status === 429) { - return "Provider rate-limited the request — try again shortly."; + return { + reasonCode: "rate_limited", + reason: "Provider rate-limited the request — try again shortly.", + }; } if (status >= 500) { - return "The AI provider returned a server error."; + return { + reasonCode: "server_error", + reason: "The AI provider returned a server error.", + }; } - if (status === 0) { - return "Could not reach the AI provider."; - } - return "Could not reach the AI provider."; + return { + reasonCode: "unreachable", + reason: "Could not reach the AI provider.", + }; } diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index eb1902b66..cc235c30a 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -145,6 +145,29 @@ function uiToLegacyProviderEnum(p: ProviderType): string | null { } } +// Map the connection-test failure category (a stable, secret-free code from +// /api/ai/test) to a localised message. Falls back to the server's plain +// English `reason` for any unmapped / legacy code so a German operator never +// sees an untranslated sentence in the otherwise-localised Settings panel. +function localiseTestReason( + t: (key: string, params?: Record) => string, + reasonCode: string | undefined, + reason: string | undefined, +): string | undefined { + switch (reasonCode) { + case "credentials": + return t("settings.ai.testReasonCredentials"); + case "rate_limited": + return t("settings.ai.testReasonRateLimited"); + case "server_error": + return t("settings.ai.testReasonServerError"); + case "unreachable": + return t("settings.ai.testReasonUnreachable"); + default: + return reason; + } +} + export function AiSection() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); @@ -1751,6 +1774,7 @@ function RuntimeActionsRow({ ok?: boolean; providerType?: string; model?: string; + reasonCode?: string; reason?: string; } | null; error?: string | null; @@ -1772,13 +1796,14 @@ 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. + // Provider-call failures now arrive as 200 + { ok:false, reasonCode } + // so the body is never rewritten by a proxy. Map the stable code to a + // localised string; fall back to the server's plain `reason` for any + // unmapped / legacy code, then to a generic message. if (json.data && json.data.ok === false) { setTestOk(false); setTestMsg( - json.data.reason ?? + localiseTestReason(t, json.data.reasonCode, json.data.reason) ?? t("settings.ai.testFailedShort", { message: `HTTP ${res.status}` }), ); return;