mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
cc0f3436ff
commit
613d6619df
5 files changed
+248
-2
No files matched your search
+16
-1
@@ -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
@@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user