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

+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.",
};
}