diff --git a/messages/de.json b/messages/de.json index 40ecad1f3..304174e73 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1200,7 +1200,22 @@ "saved": "Gespeichert", "saveFailed": "Speichern fehlgeschlagen", "testFailed": "Test fehlgeschlagen", - "errorGeneric": "Fehler" + "errorGeneric": "Fehler", + "providerChain": { + "heading": "KI-Provider-Fallback-Kette", + "activeLine": "Aktiver Provider: {provider}", + "cachedSuffix": "(zwischengespeichert: {provider})", + "chainLine": "Konfigurierte Kette: {chain}", + "managementHint": "Reihenfolge und Ein/Aus-Schalter kommen mit dem nächsten Release.", + "none": "keiner konfiguriert", + "types": { + "codex": "ChatGPT (Codex)", + "openai": "OpenAI (eigener Key)", + "anthropic": "Anthropic (Claude)", + "local": "Lokales Modell", + "admin-openai": "Admin-OpenAI" + } + } }, "withings": "Withings", "withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.", diff --git a/messages/en.json b/messages/en.json index 270de4ec3..a91159b0d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1200,7 +1200,22 @@ "saved": "Saved", "saveFailed": "Save failed", "testFailed": "Test failed", - "errorGeneric": "Error" + "errorGeneric": "Error", + "providerChain": { + "heading": "AI provider fallback chain", + "activeLine": "Active provider: {provider}", + "cachedSuffix": "(cached: {provider})", + "chainLine": "Configured chain: {chain}", + "managementHint": "Reordering and on/off toggles arrive in the next release.", + "none": "none configured", + "types": { + "codex": "ChatGPT (Codex)", + "openai": "OpenAI (your key)", + "anthropic": "Anthropic (Claude)", + "local": "Local model", + "admin-openai": "Admin OpenAI" + } + } }, "withings": "Withings", "withingsDescription": "Connect your Withings scale and blood pressure monitors.", diff --git a/src/app/api/insights/provider-chain/__tests__/route.test.ts b/src/app/api/insights/provider-chain/__tests__/route.test.ts new file mode 100644 index 000000000..40d60752c --- /dev/null +++ b/src/app/api/insights/provider-chain/__tests__/route.test.ts @@ -0,0 +1,100 @@ +/** + * v1.4.16 phase B5b — read-only chain summary surfaced under + * `/settings/ai`. The full management UX is owned by B2 (provider + * settings cleanup); this endpoint just lets the existing AI section + * render "Active provider: codex" + "Configured: codex, openai" so the + * user can see the cascade order without clicking into a dedicated + * page. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ + user: { id: "u-1" }, + session: { id: "s-1" }, + })), +})); + +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: vi.fn(), +})); + +vi.mock("@/lib/ai/provider-runner", () => ({ + getLastWorkingProvider: vi.fn(), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { GET } from "../route"; +import { resolveProviderChain } from "@/lib/ai/provider"; +import { getLastWorkingProvider } from "@/lib/ai/provider-runner"; + +interface Envelope { + data?: { + activeProvider: string | null; + cachedActiveProvider: string | null; + configuredChain: { providerType: string; available: boolean }[]; + }; + error?: string; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/insights/provider-chain", () => { + it("returns the resolved chain in priority order", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([ + { + providerType: "codex", + instance: { type: "codex" } as never, + }, + { + providerType: "admin-openai", + instance: { type: "admin-key" } as never, + }, + ]); + vi.mocked(getLastWorkingProvider).mockReturnValue(null); + + const res = await (GET as () => Promise)(); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.data?.activeProvider).toBe("codex"); + expect(body.data?.configuredChain).toEqual([ + { providerType: "codex", available: true }, + { providerType: "admin-openai", available: true }, + ]); + }); + + it("surfaces the cached last-working provider when available", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([ + { providerType: "codex", instance: { type: "codex" } as never }, + { + providerType: "openai", + instance: { type: "admin-key" } as never, + }, + ]); + vi.mocked(getLastWorkingProvider).mockReturnValue("openai"); + + const res = await (GET as () => Promise)(); + const body = (await res.json()) as Envelope; + expect(body.data?.cachedActiveProvider).toBe("openai"); + // The chain still reports codex first (chain priority is the truth); + // only the cache hint is updated. The UI reads both — primary line + // is "Active provider: codex (cached: openai)". + expect(body.data?.activeProvider).toBe("codex"); + }); + + it("returns an empty chain + null active when nothing is configured", async () => { + vi.mocked(resolveProviderChain).mockResolvedValue([]); + vi.mocked(getLastWorkingProvider).mockReturnValue(null); + + const res = await (GET as () => Promise)(); + const body = (await res.json()) as Envelope; + expect(body.data?.activeProvider).toBeNull(); + expect(body.data?.configuredChain).toEqual([]); + }); +}); diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts new file mode 100644 index 000000000..15dc7f63f --- /dev/null +++ b/src/app/api/insights/provider-chain/route.ts @@ -0,0 +1,49 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { resolveProviderChain } from "@/lib/ai/provider"; +import { getLastWorkingProvider } from "@/lib/ai/provider-runner"; + +/** + * v1.4.16 phase B5b — read-only chain summary for the Settings → AI + * section. Returns: + * - `activeProvider`: first entry in the resolved chain (priority + * order). Null when the user has no providers configured at all. + * - `cachedActiveProvider`: the last working provider from the + * in-process cache, when one is set. Surfaces "Active: codex + * (cached: openai)" so a user can see the runner has rerouted to + * a fallback without digging through logs. + * - `configuredChain`: ordered list of `{providerType, available}` + * entries. `available` is always true here since the chain + * resolver only returns instantiable entries; the field is wired + * for the v1.4.17 UX where unconfigured slots should still render + * in the list with a "needs setup" pill. + * + * The full management UX (drag-to-reorder, enable/disable, add/remove + * provider entries) is owned by B2 (AI provider settings cleanup) — we + * intentionally keep this endpoint and the matching panel small so + * the two phases don't collide. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const chain = await resolveProviderChain(user.id); + const cached = getLastWorkingProvider(user.id); + + annotate({ + action: { name: "insights.provider_chain.get" }, + meta: { + chain_length: chain.length, + chain_active: chain[0]?.providerType ?? null, + chain_cached_active: cached, + }, + }); + + return apiSuccess({ + activeProvider: chain[0]?.providerType ?? null, + cachedActiveProvider: cached, + configuredChain: chain.map((entry) => ({ + providerType: entry.providerType, + available: true, + })), + }); +}); diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 5902274e3..c237db45e 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -534,6 +534,8 @@ function InsightsSettingsCard({ )} + + @@ -970,3 +972,68 @@ function UserAIProviderSubsection() { ); } + +interface ProviderChainSummaryData { + activeProvider: string | null; + cachedActiveProvider: string | null; + configuredChain: { providerType: string; available: boolean }[]; +} + +/** + * v1.4.16 phase B5b — read-only summary of the user's AI provider + * fallback chain. Surfaces "Active provider: codex" + "Configured: + * codex, openai" so the user can see the cascade order without going + * through admin logs. Full management UX (drag-to-reorder, add/remove) + * is owned by phase B2 (AI provider settings cleanup); this panel is + * deliberately tight so the two phases don't collide. + */ +function ProviderChainSummary() { + const { t } = useTranslations(); + const { data } = useQuery({ + queryKey: ["insights", "provider-chain"], + queryFn: async () => { + const res = await fetch("/api/insights/provider-chain"); + if (!res.ok) return null; + const json = await res.json(); + return json.data as ProviderChainSummaryData; + }, + }); + + if (!data) return null; + if (data.configuredChain.length === 0) return null; + + const activeLabel = data.activeProvider + ? t(`settings.ai.providerChain.types.${data.activeProvider}`) + : t("settings.ai.providerChain.none"); + const chainLabels = data.configuredChain + .map((entry) => t(`settings.ai.providerChain.types.${entry.providerType}`)) + .join(", "); + + return ( +
+

+ {t("settings.ai.providerChain.heading")} +

+

+ {t("settings.ai.providerChain.activeLine", { provider: activeLabel })} + {data.cachedActiveProvider && + data.cachedActiveProvider !== data.activeProvider + ? ` ${t("settings.ai.providerChain.cachedSuffix", { + provider: t( + `settings.ai.providerChain.types.${data.cachedActiveProvider}`, + ), + })}` + : ""} +

+

+ {t("settings.ai.providerChain.chainLine", { chain: chainLabels })} +

+

+ {t("settings.ai.providerChain.managementHint")} +

+
+ ); +}