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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 09:17:13 +02:00
1 parent d8f623aea3
commit 9bdf76ea11
9 files changed
+101 -15

No files matched your search

+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+4
View File
@@ -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.",
+13 -1
View File
@@ -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);
});
});
+35 -10
View File
@@ -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.",
};
}
+29 -4
View File
@@ -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, string | number>) => 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;