mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
d8f623aea3
commit
9bdf76ea11
9 files changed
+101
-15
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user