feat(ai): durable provider-health ledger and credential-expired surfacing

Promote the volatile per-worker last-working cache to a Postgres
provider-health ledger so the fallback chain shares one health signal
across every instance, modelled on the atomic-upsert rate limiter.

The ledger holds a durable negative cache: an auth-class failure
(401/403) benches that provider for a cooldown instead of re-burning the
dead round-trip on every generation; a sustained hard failure backs off
briefly. The local model is never benched — it stays the guaranteed
floor of the chain. Every ledger interaction fails open, so a DB error
falls back to today's chain walk exactly.

When the user's primary provider fails auth-class, the Coach emits a
distinct credential_expired frame that deep-links to reconnect rather
than a generic "try again later".

Adds the provider_health table (migration 0112) and the
errorCredentialExpired copy across all locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 00:50:13 +02:00
1 parent 6db3bef413
commit fbd45f3044
15 files changed
+949 -3

No files matched your search

@@ -543,6 +543,14 @@ describe("errorCodeToI18nKey", () => {
);
});
// v1.11.0 W1 — a dead primary credential gets the reconnect copy, not
// the generic provider-unavailable copy.
it("maps coach.provider.credential_expired to the reconnect key", () => {
expect(errorCodeToI18nKey("coach.provider.credential_expired")).toBe(
"insights.coach.errorCredentialExpired",
);
});
it("forward-compats unknown codes with the namespaced key", () => {
expect(errorCodeToI18nKey("errorProvider")).toBe(
"insights.coach.errorProvider",
@@ -88,6 +88,11 @@ export function errorCodeToI18nKey(code: string): string {
// than a provider failure ("try again in a moment"). Split out
// so the user sees the actionable copy in the offline branch.
return "insights.coach.errorNetwork";
case "coach.provider.credential_expired":
// v1.11.0 W1 — the user's primary AI provider credential is dead
// (auth-class failure). The next action is "reconnect", not "try
// again later", so it carries its own copy pointing at Settings.
return "insights.coach.errorCredentialExpired";
case "coach.provider.unavailable":
case "coach.provider.empty":
case "coach.provider.none":