feat(settings): minimal display of active AI provider + configured chain

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:34:39 +02:00
1 parent cc0f3436ff
commit 613d6619df
5 files changed
+248 -2

No files matched your search

+16 -1
View File
@@ -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.",
+16 -1
View File
@@ -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.",
@@ -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: <T extends (...args: unknown[]) => 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<Response>)();
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<Response>)();
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<Response>)();
const body = (await res.json()) as Envelope;
expect(body.data?.activeProvider).toBeNull();
expect(body.data?.configuredChain).toEqual([]);
});
});
@@ -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,
})),
});
});
+67
View File
@@ -534,6 +534,8 @@ function InsightsSettingsCard({
</Button>
)}
<ProviderChainSummary />
<UserAIProviderSubsection />
</div>
</div>
@@ -970,3 +972,68 @@ function UserAIProviderSubsection() {
</div>
);
}
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 (
<div
className="bg-muted/50 rounded-lg p-4 text-xs"
data-slot="ai-provider-chain-summary"
>
<p className="text-sm font-medium">
{t("settings.ai.providerChain.heading")}
</p>
<p className="text-muted-foreground mt-1">
{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}`,
),
})}`
: ""}
</p>
<p className="text-muted-foreground mt-1">
{t("settings.ai.providerChain.chainLine", { chain: chainLabels })}
</p>
<p className="text-muted-foreground/80 mt-1 italic">
{t("settings.ai.providerChain.managementHint")}
</p>
</div>
);
}