Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 111 additions & 2 deletions packages/web/src/components/settings/provider-section.test.tsx
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down Expand Up @@ -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<string[]> {
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(<ProviderSection />);
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(<ProviderSection />);

const model = await screen.findByLabelText<HTMLInputElement>("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(<ProviderSection />);

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(<ProviderSection />);

const model = await screen.findByLabelText<HTMLInputElement>("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(<ProviderSection />);

await waitFor(() => expect(mocks.getProviders).toHaveBeenCalledOnce());
expect(screen.getByLabelText<HTMLInputElement>("Model").placeholder).toBe(
"gemini-3.5-flash-lite",
);
});
});
57 changes: 50 additions & 7 deletions packages/web/src/components/settings/provider-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
gemini: "gemini-3.5-flash-lite",
openai: "gpt-5.6-luna",
Expand All @@ -48,6 +60,11 @@ const PROVIDER_ENV_VARS: Record<string, { vars: string[]; installHint: string }>
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" },
};

Expand Down Expand Up @@ -75,6 +92,8 @@ export function ProviderSection() {
const [model, setModel] = useState("");
const [embedder, setEmbedder] = useState("mock");
const [serverProvider, setServerProvider] = useState<string | null>(null);
const [providers, setProviders] = useState<readonly string[]>(FALLBACK_PROVIDERS);
const [catalogModels, setCatalogModels] = useState<Record<string, string>>({});
const [saveState, setSaveState] = useState<SaveState>("idle");

const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All @@ -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);
Expand Down Expand Up @@ -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] ?? [];

Expand All @@ -146,11 +186,11 @@ export function ProviderSection() {
>
<div className="space-y-2">
<Select value={provider} onValueChange={handleProviderChange}>
<SelectTrigger className="w-full sm:w-64">
<SelectTrigger aria-label="Provider" className="w-full sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map((p) => (
{providerOptions.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
Expand All @@ -168,7 +208,10 @@ export function ProviderSection() {
>
<Input
id="model"
placeholder={MODEL_PLACEHOLDERS[provider] ?? "model name"}
// Catalog first: it is what the server will actually default to.
// The local table is the cold-load stand-in, and a stale entry in
// it winning would reintroduce the drift this catalog read fixes.
placeholder={catalogModels[provider] ?? MODEL_PLACEHOLDERS[provider] ?? "model name"}
value={model}
onChange={(e) => setModel(e.target.value)}
onBlur={handleModelBlur}
Expand All @@ -182,7 +225,7 @@ export function ProviderSection() {
>
<div className="space-y-2">
<Select value={embedder} onValueChange={handleEmbedderChange}>
<SelectTrigger className="w-full sm:w-64">
<SelectTrigger aria-label="Embedder" className="w-full sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
Expand Down
Loading