diff --git a/messages/de.json b/messages/de.json index f35254e57..b02ed86da 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6454,6 +6454,25 @@ "saved": "KI-Server-Schlüssel gespeichert", "error": "Speichern des KI-Server-Schlüssels fehlgeschlagen" }, + "providerHealth": { + "title": "Zustand der KI-Provider", + "description": "Zustellstatus jedes KI-Providers, zusammengefasst aus dem Wiederholungsregister pro Nutzer.", + "loading": "Provider-Zustand wird geladen…", + "loadError": "Der Provider-Zustand konnte nicht geladen werden.", + "empty": "Es wurden noch keine KI-Aufrufe aufgezeichnet.", + "trackedUsers": "{count} Nutzer erfasst", + "lastOk": "letzter Erfolg {at}", + "lastFailure": "letzter Fehlschlag {at}", + "statusHealthy": "In Ordnung", + "statusFailing": "{failing} von {tracked} scheitern, {run} in Folge", + "typeAdminOpenai": "Server-Schlüssel (Betreiber)", + "typeAdminCodex": "Zentrales ChatGPT (Betreiber)", + "typeOpenai": "OpenAI-Schlüssel (persönlich)", + "typeAnthropic": "Anthropic-Schlüssel (persönlich)", + "typeCodex": "ChatGPT-Anmeldung (persönlich)", + "typeLocal": "Lokales Modell (persönlich)", + "typeGateway": "OpenAI-kompatibles Gateway (persönlich)" + }, "aiQuality": { "title": "Auswertungs-Qualität", "loading": "Feedback-Zusammenfassung wird geladen…", diff --git a/messages/en.json b/messages/en.json index 1853215aa..a7472a8de 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6454,6 +6454,25 @@ "saved": "AI server key settings saved", "error": "Saving the AI server key failed" }, + "providerHealth": { + "title": "AI provider health", + "description": "Delivery state of every AI provider, folded from the per-user retry ledger.", + "loading": "Loading provider health…", + "loadError": "Provider health could not be loaded.", + "empty": "No AI calls have been recorded yet.", + "trackedUsers": "{count} users tracked", + "lastOk": "last success {at}", + "lastFailure": "last failure {at}", + "statusHealthy": "Healthy", + "statusFailing": "{failing} of {tracked} failing, {run} in a row", + "typeAdminOpenai": "Server key (operator)", + "typeAdminCodex": "Central ChatGPT (operator)", + "typeOpenai": "OpenAI key (personal)", + "typeAnthropic": "Anthropic key (personal)", + "typeCodex": "ChatGPT sign-in (personal)", + "typeLocal": "Local model (personal)", + "typeGateway": "OpenAI-compatible gateway (personal)" + }, "aiQuality": { "title": "Insights Quality", "loading": "Loading feedback summary…", diff --git a/messages/es.json b/messages/es.json index 68910cac6..5cbaeb9f8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6454,6 +6454,25 @@ "saved": "Ajustes de la clave de IA del servidor guardados", "error": "No se pudo guardar la clave de IA del servidor" }, + "providerHealth": { + "title": "Estado de los proveedores de IA", + "description": "Estado de entrega de cada proveedor de IA, agregado del registro de reintentos por usuario.", + "loading": "Cargando el estado de los proveedores…", + "loadError": "No se pudo cargar el estado de los proveedores.", + "empty": "Aún no se han registrado llamadas de IA.", + "trackedUsers": "{count} usuarios registrados", + "lastOk": "último éxito {at}", + "lastFailure": "último fallo {at}", + "statusHealthy": "Correcto", + "statusFailing": "{failing} de {tracked} fallan, {run} seguidos", + "typeAdminOpenai": "Clave del servidor (operador)", + "typeAdminCodex": "ChatGPT central (operador)", + "typeOpenai": "Clave de OpenAI (personal)", + "typeAnthropic": "Clave de Anthropic (personal)", + "typeCodex": "Inicio de sesión de ChatGPT (personal)", + "typeLocal": "Modelo local (personal)", + "typeGateway": "Pasarela compatible con OpenAI (personal)" + }, "aiQuality": { "title": "Calidad de los análisis", "loading": "Cargando resumen de comentarios...", diff --git a/messages/fr.json b/messages/fr.json index 227a035c7..c05cc7694 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6454,6 +6454,25 @@ "saved": "Paramètres de la clé IA du serveur enregistrés", "error": "Échec de l'enregistrement de la clé IA du serveur" }, + "providerHealth": { + "title": "État des fournisseurs d'IA", + "description": "État de livraison de chaque fournisseur d'IA, agrégé depuis le registre de reprise par utilisateur.", + "loading": "Chargement de l'état des fournisseurs…", + "loadError": "Impossible de charger l'état des fournisseurs.", + "empty": "Aucun appel d'IA n'a encore été enregistré.", + "trackedUsers": "{count} utilisateurs suivis", + "lastOk": "dernier succès {at}", + "lastFailure": "dernier échec {at}", + "statusHealthy": "En bon état", + "statusFailing": "{failing} sur {tracked} en échec, {run} d'affilée", + "typeAdminOpenai": "Clé du serveur (opérateur)", + "typeAdminCodex": "ChatGPT central (opérateur)", + "typeOpenai": "Clé OpenAI (personnelle)", + "typeAnthropic": "Clé Anthropic (personnelle)", + "typeCodex": "Connexion ChatGPT (personnelle)", + "typeLocal": "Modèle local (personnel)", + "typeGateway": "Passerelle compatible OpenAI (personnelle)" + }, "aiQuality": { "title": "Qualité des analyses", "loading": "Chargement du résumé des retours...", diff --git a/messages/it.json b/messages/it.json index 8dd448591..3238984e3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6454,6 +6454,25 @@ "saved": "Impostazioni della chiave IA del server salvate", "error": "Salvataggio della chiave IA del server non riuscito" }, + "providerHealth": { + "title": "Stato dei provider IA", + "description": "Stato di consegna di ogni provider IA, aggregato dal registro dei tentativi per utente.", + "loading": "Caricamento dello stato dei provider…", + "loadError": "Impossibile caricare lo stato dei provider.", + "empty": "Non è stata ancora registrata alcuna chiamata IA.", + "trackedUsers": "{count} utenti tracciati", + "lastOk": "ultimo successo {at}", + "lastFailure": "ultimo errore {at}", + "statusHealthy": "Regolare", + "statusFailing": "{failing} su {tracked} in errore, {run} di fila", + "typeAdminOpenai": "Chiave del server (operatore)", + "typeAdminCodex": "ChatGPT centrale (operatore)", + "typeOpenai": "Chiave OpenAI (personale)", + "typeAnthropic": "Chiave Anthropic (personale)", + "typeCodex": "Accesso ChatGPT (personale)", + "typeLocal": "Modello locale (personale)", + "typeGateway": "Gateway compatibile OpenAI (personale)" + }, "aiQuality": { "title": "Qualità delle analisi", "loading": "Caricamento riepilogo feedback...", diff --git a/messages/pl.json b/messages/pl.json index 5e1ea3491..444d9899d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6454,6 +6454,25 @@ "saved": "Zapisano ustawienia serwerowego klucza AI", "error": "Nie udało się zapisać serwerowego klucza AI" }, + "providerHealth": { + "title": "Stan dostawców AI", + "description": "Stan dostarczania każdego dostawcy AI, zebrany z rejestru ponowień na użytkownika.", + "loading": "Ładowanie stanu dostawców…", + "loadError": "Nie udało się załadować stanu dostawców.", + "empty": "Nie zarejestrowano jeszcze żadnych wywołań AI.", + "trackedUsers": "Śledzeni użytkownicy: {count}", + "lastOk": "ostatni sukces {at}", + "lastFailure": "ostatnia awaria {at}", + "statusHealthy": "Sprawny", + "statusFailing": "{failing} z {tracked} zawodzi, {run} z rzędu", + "typeAdminOpenai": "Klucz serwera (operator)", + "typeAdminCodex": "Centralny ChatGPT (operator)", + "typeOpenai": "Klucz OpenAI (osobisty)", + "typeAnthropic": "Klucz Anthropic (osobisty)", + "typeCodex": "Logowanie ChatGPT (osobiste)", + "typeLocal": "Model lokalny (osobisty)", + "typeGateway": "Brama zgodna z OpenAI (osobista)" + }, "aiQuality": { "title": "Jakość analiz", "loading": "Ładowanie podsumowania opinii...", diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index f1b015563..7e6e2b0a2 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -258,6 +258,7 @@ const UNPUBLISHED: Readonly> = { kind: "adminConsole", methods: ["POST"], }, + "/api/admin/provider-health": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/notifications/health": { kind: "adminConsole", methods: ["GET"] }, "/api/admin/notifications/reminder-check": { kind: "adminConsole", diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index f1af619a4..f9548687e 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -18,6 +18,7 @@ import { useAuth } from "@/hooks/use-auth"; import { AboutSection } from "@/components/settings/about-section"; import { AiQualitySection } from "@/components/admin/ai-quality-section"; import { AiServerKeySection } from "@/components/admin/ai-server-key-section"; +import { ProviderHealthSection } from "@/components/admin/provider-health-section"; import { CentralCodexSection } from "@/components/admin/central-codex-section"; import { AssistantSection } from "@/components/admin/assistant-section"; import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section"; @@ -89,6 +90,7 @@ export function AdminSectionRenderer({ + diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts new file mode 100644 index 000000000..0ce584510 --- /dev/null +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -0,0 +1,147 @@ +/** + * v1.37.31 — contract for the operator provider-health readout + * (`/api/admin/provider-health`). The per-user retry ledger existed for a + * long time with no operator surface; this pins the fold: one row per + * provider type, failing counted from the LAST result only, the worst + * uninterrupted failure run taken across failing users, central types + * sorted first, and no per-user data in the response. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + providerHealth: { + groupBy: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin } from "@/lib/api-handler"; + +const groupBy = vi.mocked(prisma.providerHealth.groupBy); + +function group( + providerType: string, + lastResult: string, + count: number, + max: { + consecutiveFailures?: number; + lastFailureAt?: Date | null; + lastOkAt?: Date | null; + } = {}, +) { + return { + providerType, + lastResult, + _count: { _all: count }, + _max: { + consecutiveFailures: max.consecutiveFailures ?? 0, + lastFailureAt: max.lastFailureAt ?? null, + lastOkAt: max.lastOkAt ?? null, + }, + }; +} + +describe("GET /api/admin/provider-health", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue({ + user: { id: "admin1" }, + } as never); + }); + + it("is admin-gated before it touches the ledger", async () => { + vi.mocked(requireAdmin).mockRejectedValue(new Error("forbidden")); + + await expect(GET()).rejects.toThrow("forbidden"); + expect(groupBy).not.toHaveBeenCalled(); + }); + + it("folds ok and failing groups of one type into a single row", async () => { + groupBy.mockResolvedValue([ + group("admin-openai", "ok", 3, { + lastOkAt: new Date("2026-08-27T07:00:00Z"), + }), + group("admin-openai", "hard_failed", 2, { + consecutiveFailures: 3334, + lastFailureAt: new Date("2026-08-27T06:00:00Z"), + }), + ] as never); + + const res = await GET(); + const body = await res.json(); + expect(body.data.providers).toEqual([ + { + providerType: "admin-openai", + tracked: 5, + failing: 2, + maxConsecutiveFailures: 3334, + lastOkAt: "2026-08-27T07:00:00.000Z", + lastFailureAt: "2026-08-27T06:00:00.000Z", + }, + ]); + }); + + it("counts auth_failed as failing and keeps an all-ok type at zero", async () => { + groupBy.mockResolvedValue([ + group("codex", "auth_failed", 1, { consecutiveFailures: 7 }), + group("local", "ok", 4), + ] as never); + + const res = await GET(); + const body = await res.json(); + const byType = Object.fromEntries( + body.data.providers.map( + (p: { providerType: string; failing: number }) => [ + p.providerType, + p.failing, + ], + ), + ); + expect(byType).toEqual({ codex: 1, local: 0 }); + }); + + it("sorts the operator-managed types first, the rest alphabetically", async () => { + groupBy.mockResolvedValue([ + group("local", "ok", 1), + group("anthropic", "ok", 1), + group("admin-codex", "ok", 1), + group("admin-openai", "ok", 1), + ] as never); + + const res = await GET(); + const body = await res.json(); + expect( + body.data.providers.map((p: { providerType: string }) => p.providerType), + ).toEqual(["admin-openai", "admin-codex", "anthropic", "local"]); + }); + + it("answers an empty ledger with an empty list, not an error", async () => { + groupBy.mockResolvedValue([] as never); + + const res = await GET(); + const body = await res.json(); + expect(body.data.providers).toEqual([]); + }); +}); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts new file mode 100644 index 000000000..49b4660c7 --- /dev/null +++ b/src/app/api/admin/provider-health/route.ts @@ -0,0 +1,89 @@ +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { prisma } from "@/lib/db"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +export const dynamic = "force-dynamic"; + +/** + * v1.37.31 — per-provider delivery health for the operator. + * + * The per-user retry ledger (`provider_health`) has recorded every AI + * delivery outcome for a long time, but no surface ever read it for the + * operator: a central provider (`admin-openai` / `admin-codex`) could fail + * on every call for weeks and the admin console said nothing. This endpoint + * folds the ledger into one row per provider type so the console can show + * it. Read-only, admin-cookie-only, and it exposes counts and timestamps + * only — never which user a row belongs to. + */ + +interface ProviderHealthSummary { + providerType: string; + /** Users whose chain has recorded at least one outcome for this type. */ + tracked: number; + /** Users whose LAST outcome for this type was a failure. */ + failing: number; + /** Highest uninterrupted failure run across those users. */ + maxConsecutiveFailures: number; + lastOkAt: string | null; + lastFailureAt: string | null; +} + +/** The two operator-managed tags sort first — they affect every user on the chain. */ +const CENTRAL_TYPES = ["admin-openai", "admin-codex"]; + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.provider-health.get" } }); + + const groups = await prisma.providerHealth.groupBy({ + by: ["providerType", "lastResult"], + _count: { _all: true }, + _max: { + consecutiveFailures: true, + lastFailureAt: true, + lastOkAt: true, + }, + }); + + const byType = new Map(); + for (const g of groups) { + const row = byType.get(g.providerType) ?? { + providerType: g.providerType, + tracked: 0, + failing: 0, + maxConsecutiveFailures: 0, + lastOkAt: null, + lastFailureAt: null, + }; + row.tracked += g._count._all; + if (g.lastResult !== "ok") { + row.failing += g._count._all; + row.maxConsecutiveFailures = Math.max( + row.maxConsecutiveFailures, + g._max.consecutiveFailures ?? 0, + ); + } + const ok = g._max.lastOkAt?.toISOString() ?? null; + if (ok && (!row.lastOkAt || ok > row.lastOkAt)) row.lastOkAt = ok; + const fail = g._max.lastFailureAt?.toISOString() ?? null; + if (fail && (!row.lastFailureAt || fail > row.lastFailureAt)) { + row.lastFailureAt = fail; + } + byType.set(g.providerType, row); + } + + const providers = [...byType.values()].sort((a, b) => { + const ca = CENTRAL_TYPES.indexOf(a.providerType); + const cb = CENTRAL_TYPES.indexOf(b.providerType); + if (ca !== -1 || cb !== -1) { + return ( + (ca === -1 ? CENTRAL_TYPES.length : ca) - + (cb === -1 ? CENTRAL_TYPES.length : cb) + ); + } + return a.providerType.localeCompare(b.providerType); + }); + + return apiSuccess({ providers }); +}); diff --git a/src/components/admin/provider-health-section.tsx b/src/components/admin/provider-health-section.tsx new file mode 100644 index 000000000..70e27328c --- /dev/null +++ b/src/components/admin/provider-health-section.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Activity } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { formatDateTime } from "@/lib/format"; + +/** + * v1.37.31 — the delivery-health readout the `provider_health` ledger never + * had. The ledger records every AI call outcome per user and provider tag; + * before this card, a failing central provider was invisible until someone + * read the database. The card shows one row per provider type: how many + * users' chains touch it, how many of them are currently failing, the worst + * uninterrupted failure run, and the last success / failure instants. + */ + +interface ProviderHealthRow { + providerType: string; + tracked: number; + failing: number; + maxConsecutiveFailures: number; + lastOkAt: string | null; + lastFailureAt: string | null; +} + +export function ProviderHealthSection() { + const { t } = useTranslations(); + + const { data, isPending, isError } = useQuery({ + queryKey: queryKeys.adminProviderHealth(), + queryFn: () => + apiGet<{ providers: ProviderHealthRow[] }>("/api/admin/provider-health"), + }); + + const typeLabel = (type: string): string => { + switch (type) { + case "admin-openai": + return t("admin.providerHealth.typeAdminOpenai"); + case "admin-codex": + return t("admin.providerHealth.typeAdminCodex"); + case "openai": + return t("admin.providerHealth.typeOpenai"); + case "anthropic": + return t("admin.providerHealth.typeAnthropic"); + case "codex": + return t("admin.providerHealth.typeCodex"); + case "local": + return t("admin.providerHealth.typeLocal"); + case "openai-compatible": + return t("admin.providerHealth.typeGateway"); + default: + return type; + } + }; + + const providers = data?.providers ?? []; + + return ( + + + {isPending ? ( +

+ {t("admin.providerHealth.loading")} +

+ ) : isError ? ( +

+ {t("admin.providerHealth.loadError")} +

+ ) : providers.length === 0 ? ( +

+ {t("admin.providerHealth.empty")} +

+ ) : ( +
    + {providers.map((p) => ( +
  • +
    + + {typeLabel(p.providerType)} + + + {t("admin.providerHealth.trackedUsers", { + count: p.tracked, + })} + {p.lastOkAt + ? ` · ${t("admin.providerHealth.lastOk", { + at: formatDateTime(p.lastOkAt), + })}` + : ""} + {p.failing > 0 && p.lastFailureAt + ? ` · ${t("admin.providerHealth.lastFailure", { + at: formatDateTime(p.lastFailureAt), + })}` + : ""} + +
    + {p.failing === 0 ? ( + + {t("admin.providerHealth.statusHealthy")} + + ) : ( + + {t("admin.providerHealth.statusFailing", { + failing: p.failing, + tracked: p.tracked, + run: p.maxConsecutiveFailures, + })} + + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index d016eadf3..18c1720c1 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -16,6 +16,11 @@ export const adminKeys = { * `insightsSettings.hasAdminKey` flag mirrors the configured state. */ adminAiServerKey: () => ["admin", "ai-server-key"] as const, + /** + * v1.37.31 — per-provider delivery health folded from the per-user + * retry ledger. Read via `/api/admin/provider-health`. + */ + adminProviderHealth: () => ["admin", "provider-health"] as const, /** * Operator-shared central Codex (ChatGPT subscription) connection status. * Read/write via `/api/admin/central-codex`; the user-facing