From 613d6619df3a86a1fcfe56a141eb931c9b353e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 00:34:31 +0200 Subject: [PATCH] feat(settings): minimal display of active AI provider + configured chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AI section now renders a small read-only summary of the user's fallback chain: "Active provider: ChatGPT (Codex)" plus "Configured chain: ChatGPT (Codex), OpenAI (your key), Admin OpenAI". When the in-process cache reroutes to a fallback (the primary 401'd in the last hour), the line gains "(cached: OpenAI)" so the user can see the runner has shifted without digging through the wide-event log. The full management UX (drag-to-reorder, on/off toggles, add/remove provider entries) is owned by phase B2 — this surface is deliberately tight so the two phases don't collide. The card hides itself when no provider is configured at all so accounts that haven't set up AI don't see an empty header. EN + DE i18n keys added under settings.ai.providerChain.*; provider-type labels are localised so "Local model" reads "Lokales Modell" in German. The new GET /api/insights/provider-chain endpoint is the data source. Returns the resolved chain in priority order plus the cached active provider (when set), wrapped in the standard apiSuccess envelope. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 17 ++- messages/en.json | 17 ++- .../provider-chain/__tests__/route.test.ts | 100 ++++++++++++++++++ src/app/api/insights/provider-chain/route.ts | 49 +++++++++ src/components/settings/ai-section.tsx | 67 ++++++++++++ 5 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 src/app/api/insights/provider-chain/__tests__/route.test.ts create mode 100644 src/app/api/insights/provider-chain/route.ts 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")} +

+
+ ); +}