feat(settings): AI provider section with single dropdown driving form below

Replaces the v1.4.15-vintage two-card layout (Codex connect at the top,
personal-provider form at the bottom) with the dropdown-driven UX
called out in feedback_settings_no_split.md: one Pulldown picks the
active provider, the form below adapts to that choice, and the
fallback chain at the bottom reorders / toggles entries with arrow
controls.

Five provider config forms live in this file (Codex, OpenAI, Anthropic,
Local, Admin OpenAI) and share a single card frame so transitions feel
like updating one concept rather than swapping pages. The Codex form
keeps the device-code flow + OAuth callback handling unchanged. OpenAI
gets a model dropdown plus a collapsed Base URL override (advanced).
Anthropic and Local mirror that shape with the right credentials. The
fallback chain calls the new PUT /api/insights/provider-chain to
persist reorders.

The Select is URL-synced via ?provider=… so deep links work, the SSR
test can drive each branch deterministically, and a refresh keeps
the user on the form they were editing. Native <select> elements
keep the SSR-only render-to-static-markup test happy without bringing
a portal-based Radix tree into play. No new dependency — dnd-kit is
not in package.json so reorder uses up/down arrow buttons.

EN + DE i18n keys land alongside; the i18n parity guard test sees
both locales and the existing settings.ai.providerChain.types.* tree
keeps the per-provider labels stable across the chain summary and
the new chain-management UI.

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 01:09:07 +02:00
1 parent 3e037b4d42
commit 23f4ee5b1e
5 files changed
+1725 -732

No files matched your search

+57 -2
View File
@@ -1217,8 +1217,63 @@
"anthropic": "Anthropic (Claude)",
"local": "Lokales Modell",
"admin-openai": "Admin-OpenAI"
}
}
},
"title": "Fallback-Kette",
"description": "Wenn der primäre Provider ausfällt, läuft HealthLog die Kette der Reihe nach durch. Reihenfolge per Pfeile ändern, Schalter deaktiviert einen Eintrag ohne ihn zu entfernen.",
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"removeFromChain": "Aus Kette entfernen",
"addProvider": "Provider hinzufügen",
"addNoneAvailable": "Alle Provider sind bereits in der Kette",
"saveOrder": "Reihenfolge speichern",
"saved": "Kette gespeichert",
"saveFailed": "Speichern der Kette fehlgeschlagen",
"resetDefaults": "Zurücksetzen",
"resetConfirmTitle": "Kette auf Standard zurücksetzen?",
"resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten."
},
"activeProviderHeading": "Aktiver KI-Provider",
"activeProviderBody": "Wähle den Provider, der zuerst genutzt wird. Das Formular darunter konfiguriert ausschließlich den ausgewählten Provider; die Fallback-Kette unten regelt, was passiert, wenn er ausfällt.",
"activeProviderLabel": "Primärer Provider",
"providerConfigTitle": "Provider-Konfiguration",
"providerSelect": {
"codex": "ChatGPT-Account (Codex)",
"openai": "OpenAI (eigener API-Key)",
"anthropic": "Anthropic (Claude)",
"local": "Lokales Modell (OpenAI-kompatibel)",
"admin-openai": "Admin-OpenAI"
},
"openai": {
"modelSelect": "Modell",
"modelOptionCustom": "Eigener Slug…",
"modelCustomLabel": "Eigener Modell-Slug",
"modelCustomPlaceholder": "gpt-5",
"baseUrlLabel": "Base URL (erweitert)",
"baseUrlPlaceholder": "https://api.openai.com/v1",
"baseUrlHelp": "Nur überschreiben, wenn ein OpenAI-kompatibles Gateway genutzt wird. Für OpenAI selbst leer lassen.",
"showAdvanced": "Erweitert anzeigen",
"hideAdvanced": "Erweitert ausblenden",
"apiKey": "API-Key",
"apiKeyPlaceholder": "sk-…"
},
"codex": {
"statusConnected": "Verbunden",
"statusDisconnected": "Nicht verbunden",
"statusExpired": "Verbindung abgelaufen",
"connectButton": "Mit ChatGPT verbinden",
"disconnectButton": "Trennen",
"modelSlugLabel": "Modell-Slug",
"modelSlugBody": "Codex nutzt das Modell, auf das dein ChatGPT-Abo routet. Die Umgebungsvariable CODEX_MODEL kann das instanzweit überschreiben.",
"lastInsight": "Letzte Analyse: {when}"
},
"adminOpenai": {
"title": "Admin-OpenAI",
"body": "Vom Betreiber bereitgestellter OpenAI-Key. Wird als letzter Fallback genutzt, wenn kein persönlicher Provider konfiguriert ist. Hier ist nichts einzustellen — die Anzeige bedeutet, dass der Betreiber ihn eingerichtet hat.",
"notConfigured": "Der Betreiber hat keinen geteilten OpenAI-Key auf dieser Instanz konfiguriert."
},
"testProvider": "Aktiven Provider testen",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test fehlgeschlagen: {message}"
},
"withings": "Withings",
"withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.",
+57 -2
View File
@@ -1217,8 +1217,63 @@
"anthropic": "Anthropic (Claude)",
"local": "Local model",
"admin-openai": "Admin OpenAI"
}
}
},
"title": "Fallback chain",
"description": "If the primary provider fails, HealthLog walks the chain in order. Drag the rows to reorder, toggle the switch to disable a provider without removing it.",
"moveUp": "Move up",
"moveDown": "Move down",
"removeFromChain": "Remove from chain",
"addProvider": "Add provider",
"addNoneAvailable": "All providers already in chain",
"saveOrder": "Save chain order",
"saved": "Chain saved",
"saveFailed": "Saving the chain failed",
"resetDefaults": "Reset to defaults",
"resetConfirmTitle": "Reset chain to defaults?",
"resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched."
},
"activeProviderHeading": "Active AI provider",
"activeProviderBody": "Pick the provider you want to use first. The form below configures only the selected provider; the fallback chain at the bottom decides what happens if it fails.",
"activeProviderLabel": "Primary provider",
"providerConfigTitle": "Provider configuration",
"providerSelect": {
"codex": "ChatGPT account (Codex)",
"openai": "OpenAI (your API key)",
"anthropic": "Anthropic (Claude)",
"local": "Local model (OpenAI-compatible)",
"admin-openai": "Admin-provided OpenAI"
},
"openai": {
"modelSelect": "Model",
"modelOptionCustom": "Custom slug…",
"modelCustomLabel": "Custom model slug",
"modelCustomPlaceholder": "gpt-5",
"baseUrlLabel": "Base URL (advanced)",
"baseUrlPlaceholder": "https://api.openai.com/v1",
"baseUrlHelp": "Override only when using an OpenAI-compatible gateway. Leave blank for OpenAI itself.",
"showAdvanced": "Show advanced",
"hideAdvanced": "Hide advanced",
"apiKey": "API key",
"apiKeyPlaceholder": "sk-…"
},
"codex": {
"statusConnected": "Connected",
"statusDisconnected": "Not connected",
"statusExpired": "Connection expired",
"connectButton": "Connect with ChatGPT",
"disconnectButton": "Disconnect",
"modelSlugLabel": "Model slug",
"modelSlugBody": "Codex uses the model your ChatGPT subscription routes to. The CODEX_MODEL environment variable lets ops override this on the instance.",
"lastInsight": "Last insight: {when}"
},
"adminOpenai": {
"title": "Admin OpenAI",
"body": "Operator-provided OpenAI key. Used as a last-ditch fallback when no personal provider is configured. There is nothing to configure on this row — visibility means the operator has set it up.",
"notConfigured": "The operator has not configured a shared OpenAI key on this instance."
},
"testProvider": "Test active provider",
"testSuccess": "OK — {provider} ({model})",
"testFailedShort": "Test failed: {message}"
},
"withings": "Withings",
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
@@ -0,0 +1,194 @@
/**
* v1.4.16 phase B2 — AI provider settings UX (dropdown-driven).
*
* The new section replaces the v1.4.15-vintage top/bottom split (Marc's
* `feedback_settings_no_split.md`): a single Select picks the active
* provider; the form below is switch-rendered for that provider; the
* fallback chain at the bottom reorders / toggles entries with arrow
* controls (no new dependency — dnd-kit is not in package.json).
*
* Tests cover:
* 1. Codex selected → Codex form (connect button) renders, OpenAI
* form does NOT.
* 2. OpenAI selected → API-key field + model select render, Codex
* connect button does NOT.
* 3. Fallback-chain rows surface in priority order with the right
* i18n labels.
*
* Each case feeds the section a different `?provider=…` URL parameter
* so the SSR render lands on the desired branch — the production
* component reads the same parameter via `useSearchParams()`. This
* keeps the test SSR-only (no jsdom dependency, no testing-library)
* and matches the existing settings-section test style.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
let activeProviderQuery: string | null = null;
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/ai",
useSearchParams: () =>
new URLSearchParams(
activeProviderQuery ? `provider=${activeProviderQuery}` : "",
),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER" },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
// Stable mocks for every TanStack Query the section reads. The chain
// fixture mirrors the default order (codex first) so the rows render in
// a known order; insights settings + ai-provider GET return enough to
// keep the form branches content (status, hasOpenaiKey etc.).
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
const key = Array.isArray(queryKey) ? queryKey.join("/") : "";
if (key === "insights/settings") {
return {
data: {
codexStatus: "disconnected",
codexConnectedAt: null,
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: null,
},
isLoading: false,
};
}
if (key === "user/ai-provider") {
return {
data: {
provider: null,
model: null,
baseUrl: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
},
isLoading: false,
};
}
if (key === "insights/provider-chain") {
return {
data: {
activeProvider: "codex",
cachedActiveProvider: null,
configuredChain: [
{ providerType: "codex", available: true },
{ providerType: "openai", available: true },
{ providerType: "anthropic", available: true },
],
},
isLoading: false,
};
}
return { data: null, isLoading: false };
},
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { AiSection } from "../ai-section";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<AiSection />
</I18nProvider>,
);
}
beforeEach(() => {
activeProviderQuery = null;
});
describe("<AiSection> — dropdown-driven UX (B2)", () => {
it("renders the active-provider Select with the configured options", () => {
activeProviderQuery = "codex";
const html = render();
expect(html).toContain('data-testid="ai-active-provider-select"');
// The four selectable options resolve via the new
// `settings.ai.providerSelect.*` keys.
expect(html).toContain("ChatGPT account (Codex)");
expect(html).toContain("OpenAI (your API key)");
expect(html).toContain("Anthropic (Claude)");
expect(html).toContain("Local model (OpenAI-compatible)");
});
it("Codex selected → Codex provider form, no OpenAI form", () => {
activeProviderQuery = "codex";
const html = render();
// Provider-config card surfaces.
expect(html).toContain('data-testid="ai-provider-config-codex"');
// OpenAI's API-key field is NOT rendered when Codex is active.
expect(html).not.toContain('data-testid="ai-provider-config-openai"');
// Connect-with-ChatGPT CTA is the focal action.
expect(html).toContain("Connect with ChatGPT");
});
it("OpenAI selected → API-key field + model select, no Codex form", () => {
activeProviderQuery = "openai";
const html = render();
expect(html).toContain('data-testid="ai-provider-config-openai"');
expect(html).not.toContain('data-testid="ai-provider-config-codex"');
// OpenAI form surfaces the API-key input + model select.
expect(html).toContain('data-testid="ai-openai-api-key"');
expect(html).toContain('data-testid="ai-openai-model"');
});
it("Anthropic selected → Anthropic API-key form", () => {
activeProviderQuery = "anthropic";
const html = render();
expect(html).toContain('data-testid="ai-provider-config-anthropic"');
expect(html).not.toContain('data-testid="ai-provider-config-openai"');
});
it("Local selected → Local base-URL + key form", () => {
activeProviderQuery = "local";
const html = render();
expect(html).toContain('data-testid="ai-provider-config-local"');
expect(html).not.toContain('data-testid="ai-provider-config-codex"');
});
it("renders the fallback-chain card with rows in priority order", () => {
activeProviderQuery = "codex";
const html = render();
expect(html).toContain('data-testid="ai-fallback-chain"');
// Each row carries a stable `data-chain-row="<providerType>"`
// marker so the e2e suite can target without leaning on i18n
// labels (which differ per locale).
expect(html).toContain('data-chain-row="codex"');
expect(html).toContain('data-chain-row="openai"');
expect(html).toContain('data-chain-row="anthropic"');
});
it("exposes a Test active provider button", () => {
activeProviderQuery = "codex";
const html = render();
expect(html).toContain('data-testid="ai-test-active-provider"');
expect(html).toContain("Test active provider");
});
it("renders the German labels when locale=de", () => {
activeProviderQuery = "codex";
const html = render("de");
expect(html).toContain("Mit ChatGPT verbinden");
expect(html).toContain("ChatGPT-Account (Codex)");
// Heading uses the locale's full string ("Aktiver KI-Provider"); a
// shorter substring would also match the "Aktiver Provider" line
// in the chain summary.
expect(html).toContain("Aktiver KI-Provider");
});
});
@@ -3,10 +3,13 @@ import { renderToStaticMarkup } from "react-dom/server";
// Stub out next/navigation. The settings sections that read it (Account)
// only call `useRouter().push()` from inside an effect after mount, so a
// no-op stub is enough for SSR smoke rendering.
// no-op stub is enough for SSR smoke rendering. v1.4.16 phase B2:
// `<AiSection>` reads `?provider=…` via `useSearchParams()` to pick the
// dropdown branch, so the mock has to expose that hook too.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/account",
useSearchParams: () => new URLSearchParams(),
}));
// Stub TanStack Query — every section reads from it but we only need the
File diff suppressed because it is too large. Load diff