diff --git a/packages/web/src/components/settings/provider-section.test.tsx b/packages/web/src/components/settings/provider-section.test.tsx index 0ad882986..9f9444e06 100644 --- a/packages/web/src/components/settings/provider-section.test.tsx +++ b/packages/web/src/components/settings/provider-section.test.tsx @@ -1,17 +1,18 @@ // @vitest-environment jsdom import React from "react"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ getProviders: vi.fn(), + getProvider: vi.fn(() => "gemini"), })); vi.mock("@/lib/api/providers", () => ({ getProviders: mocks.getProviders })); vi.mock("@/lib/config", () => ({ config: { - getProvider: () => "gemini", + getProvider: mocks.getProvider, getModel: () => "", getEmbedder: () => "mock", setProvider: vi.fn(), @@ -64,3 +65,111 @@ describe("ProviderSection server provider", () => { ).toBeTruthy(); }); }); + +describe("ProviderSection provider catalog", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getProvider.mockReturnValue("gemini"); + // Radix needs these to open its listbox; jsdom implements none of them. + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.releasePointerCapture = vi.fn(); + Element.prototype.scrollIntoView = vi.fn(); + }); + + /** Opens the provider listbox and returns the option labels it offers. */ + async function openProviderOptions(): Promise { + fireEvent.keyDown(screen.getByRole("combobox", { name: "Provider" }), { + key: "Enter", + code: "Enter", + }); + await waitFor(() => expect(screen.queryAllByRole("option").length).toBeGreaterThan(0)); + return screen.queryAllByRole("option").map((option) => option.textContent ?? ""); + } + + it("still offers a flag-only provider the catalog leaves out", async () => { + // `mock` is registerable and keyless but never appears in the server + // catalog, so rendering the catalog verbatim took away a choice the page + // has always offered -- silently, since the user is on gemini and the + // trigger still looks right. + mocks.getProviders.mockResolvedValue({ + active: { provider: "gemini", model: null }, + providers: [ + { id: "gemini", name: "Google Gemini", default_model: "gemini-3.5-flash-lite" }, + { id: "codex_cli", name: "Codex CLI", default_model: "codex_cli/gpt-5.6-luna" }, + ], + }); + + render(); + await waitFor(() => expect(mocks.getProviders).toHaveBeenCalledOnce()); + + expect(await openProviderOptions()).toEqual(["gemini", "codex_cli", "mock"]); + }); + + it("takes the model placeholder from a provider it has no local entry for", async () => { + // codex_cli is the real case: it reached the server catalog and was never + // added to this file's tables, so before the catalog was read here it was + // unselectable and had no placeholder. + mocks.getProvider.mockReturnValue("codex_cli"); + mocks.getProviders.mockResolvedValue({ + active: { provider: "codex_cli", model: null }, + providers: [ + { id: "codex_cli", name: "Codex CLI", default_model: "codex_cli/gpt-5.6-luna" }, + { id: "openrouter", name: "OpenRouter", default_model: "openrouter/auto" }, + ], + }); + + render(); + + const model = await screen.findByLabelText("Model"); + await waitFor(() => expect(model.placeholder).toBe("codex_cli/gpt-5.6-luna")); + }); + + it("keeps the selected provider selectable when the catalog omits it", async () => { + // `mock` is flag-only and is deliberately absent from the server catalog, + // so rendering the catalog verbatim would leave a user who has it saved + // staring at a picker with no matching option and a blank trigger. + mocks.getProvider.mockReturnValue("mock"); + mocks.getProviders.mockResolvedValue({ + active: { provider: "gemini", model: null }, + providers: [ + { id: "gemini", name: "Google Gemini", default_model: "gemini-3.5-flash-lite" }, + { id: "codex_cli", name: "Codex CLI", default_model: "codex_cli/gpt-5.6-luna" }, + ], + }); + + render(); + + await waitFor(() => expect(mocks.getProviders).toHaveBeenCalledOnce()); + await waitFor(() => + expect(screen.getByRole("combobox", { name: /provider/i }).textContent).toContain("mock"), + ); + }); + + it("lets the server's default model beat a stale built-in placeholder", async () => { + // The local table is a hardcoded guess that has already drifted once. When + // the server reports a different default, the server is right. + mocks.getProviders.mockResolvedValue({ + active: { provider: "gemini", model: null }, + providers: [{ id: "gemini", name: "Google Gemini", default_model: "gemini-4-pro" }], + }); + + render(); + + const model = await screen.findByLabelText("Model"); + await waitFor(() => expect(model.placeholder).toBe("gemini-4-pro")); + }); + + it("falls back to the built-in placeholder when the catalog has no default", async () => { + mocks.getProviders.mockResolvedValue({ + active: { provider: "gemini", model: null }, + providers: [{ id: "gemini", name: "Google Gemini", default_model: null }], + }); + + render(); + + await waitFor(() => expect(mocks.getProviders).toHaveBeenCalledOnce()); + expect(screen.getByLabelText("Model").placeholder).toBe( + "gemini-3.5-flash-lite", + ); + }); +}); diff --git a/packages/web/src/components/settings/provider-section.tsx b/packages/web/src/components/settings/provider-section.tsx index c883d98af..dea994101 100644 --- a/packages/web/src/components/settings/provider-section.tsx +++ b/packages/web/src/components/settings/provider-section.tsx @@ -20,9 +20,21 @@ import { type SaveState, } from "@repowise-dev/ui/settings"; -const PROVIDERS = ["gemini", "openai", "anthropic", "deepseek", "kimi", "edenai", "claude_cli", "opencode", "ollama", "litellm", "mock"] as const; +/** + * Fallback only, for a cold load and for an API that never answers. The server + * owns the catalog; this is not a second source of truth. It had already + * drifted -- `codex_cli` and `openrouter` are in the server catalog and were + * never added here, so neither could be picked from this page. + */ +const FALLBACK_PROVIDERS = ["gemini", "openai", "anthropic", "deepseek", "kimi", "edenai", "claude_cli", "opencode", "ollama", "litellm", "mock"] as const; const EMBEDDERS = ["mock", "gemini", "openai", "openrouter", "edenai", "ollama"] as const; +// Real, registerable providers the server catalog deliberately leaves out. +// `mock` is a keyless test provider (`KEYLESS_PROVIDERS` in the registry) that +// this page has always offered; it is flag-only, so it is absent from +// PROVIDER_CATALOG and would otherwise vanish the moment the catalog loads. +const FLAG_ONLY_PROVIDERS = ["mock"] as const; + const MODEL_PLACEHOLDERS: Record = { gemini: "gemini-3.5-flash-lite", openai: "gpt-5.6-luna", @@ -48,6 +60,11 @@ const PROVIDER_ENV_VARS: Record litellm: { vars: ["LITELLM_*"], installHint: "pip install litellm" }, claude_cli: { vars: [], installHint: "https://claude.com/claude-code, then: claude login" }, opencode: { vars: [], installHint: "curl -fsSL https://opencode.ai/install | bash" }, + codex_cli: { + vars: [], + installHint: "npm install -g @openai/codex, then: codex login", + }, + openrouter: { vars: ["OPENROUTER_API_KEY"], installHint: "pip install openai" }, mock: { vars: [], installHint: "No key needed" }, }; @@ -75,6 +92,8 @@ export function ProviderSection() { const [model, setModel] = useState(""); const [embedder, setEmbedder] = useState("mock"); const [serverProvider, setServerProvider] = useState(null); + const [providers, setProviders] = useState(FALLBACK_PROVIDERS); + const [catalogModels, setCatalogModels] = useState>({}); const [saveState, setSaveState] = useState("idle"); const savedTimer = useRef | null>(null); @@ -85,8 +104,23 @@ export function ProviderSection() { setEmbedder(config.getEmbedder()); let cancelled = false; void getProviders() - .then(({ active }) => { - if (!cancelled) setServerProvider(active.provider); + .then(({ active, providers: catalog }) => { + if (cancelled) return; + setServerProvider(active.provider); + // Same response already carries the catalog the server resolves + // against, so the picker can render it instead of a second copy + // compiled into this file. That copy is how `codex_cli` and + // `openrouter` came to be selectable everywhere except here. + const entries = catalog ?? []; + const ids = entries.map((entry) => entry.id).filter(Boolean); + if (ids.length) setProviders(ids); + setCatalogModels( + Object.fromEntries( + entries.flatMap((entry) => + entry.default_model ? [[entry.id, entry.default_model]] : [], + ), + ), + ); }) .catch((error: unknown) => { console.warn("[settings] Could not load the active server provider", error); @@ -126,6 +160,12 @@ export function ProviderSection() { flashSaved(); } + // The catalog is not a superset of what can be selected, so rendering it + // verbatim silently takes options away: the flag-only providers never + // appear in it, and a saved provider the server has since stopped + // advertising would leave a blank trigger with nothing to recover with. + const providerOptions = [...new Set([...providers, ...FLAG_ONLY_PROVIDERS, provider])]; + const providerInfo = PROVIDER_ENV_VARS[provider]; const embedderVars = EMBEDDER_ENV_VARS[embedder] ?? []; @@ -146,11 +186,11 @@ export function ProviderSection() { >
setModel(e.target.value)} onBlur={handleModelBlur} @@ -182,7 +225,7 @@ export function ProviderSection() { >