diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index 13da6ed0..56ebf9bf 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -8,6 +8,12 @@ import { type ModelSpec, type ProviderChoice, } from "@/lib/deploy-preflight"; +import { + ProviderModelSelector, + buildProviderCatalog, + findProviderForModel, + type SelectorValue, +} from "./ProviderModelSelector"; interface Props { open: boolean; @@ -190,63 +196,82 @@ function ProviderPickerModal({ title?: string; description?: string; }) { - // Prefer the first provider whose env vars are already satisfied by - // the configured set — pre-selecting "the option the user already has - // keys for" matches expected UX. Falls back to providers[0] otherwise. - const initialSelected = useMemo(() => { + // Single model source: `models` from caller when present, else + // synthesize a stub list from the legacy `providers` shape so older + // callers (pre-PR-2534) still drive the picker. ProviderModelSelector + // and findProviderForModel BOTH consume this list — passing the same + // shape to both keeps ids identical, so back-derivation matches the + // dropdown's option values. + const selectorModels = useMemo(() => { + if (models && models.length > 0) return models; + return providers.map((p) => ({ + id: p.id, + name: p.label, + required_env: p.envVars, + })); + }, [models, providers]); + + const catalog = useMemo(() => buildProviderCatalog(selectorModels), [selectorModels]); + + // Initial selector value: prefer back-derivation from initialModel + // (template-deploy passes the template default), then the first + // provider already satisfied by configuredKeys, then catalog[0]. + const initial = useMemo(() => { + if (initialModel) { + const matched = findProviderForModel(catalog, initialModel); + if (matched) { + return { + providerId: matched.id, + model: initialModel, + envVars: matched.envVars, + }; + } + } if (configuredKeys) { - const satisfied = providers.find((p) => + const satisfied = catalog.find((p) => p.envVars.every((k) => configuredKeys.has(k)), ); - if (satisfied) return satisfied.id; + if (satisfied) { + return { + providerId: satisfied.id, + model: satisfied.wildcard ? "" : satisfied.models[0]?.id ?? "", + envVars: satisfied.envVars, + }; + } } - return providers[0].id; - }, [providers, configuredKeys]); + const first = catalog[0]; + if (!first) return { providerId: "", model: "", envVars: [] }; + return { + providerId: first.id, + model: first.wildcard ? "" : first.models[0]?.id ?? "", + envVars: first.envVars, + }; + }, [catalog, initialModel, configuredKeys]); - const [selectedId, setSelectedId] = useState(initialSelected); + const [selectorValue, setSelectorValue] = useState(initial); const [entries, setEntries] = useState([]); - const [model, setModel] = useState(initialModel ?? ""); const firstInputRef = useRef(null); + // Legacy compat: map the selector value back into the old `selected`/ + // `model` shape for the rest of the modal body (footer copy, etc.). const selected = useMemo( - () => providers.find((p) => p.id === selectedId) ?? providers[0], - [providers, selectedId], + () => + providers.find((p) => p.id === selectorValue.providerId) ?? + providers[0], + [providers, selectorValue.providerId], ); - - const showModelInput = (modelSuggestions?.length ?? 0) > 0 || initialModel !== undefined; + const model = selectorValue.model; + const showModelInput = catalog.length > 0; useEffect(() => { if (!open) return; - setSelectedId(initialSelected); - setModel(initialModel ?? ""); - }, [open, initialSelected, initialModel]); - - // Cascade: when the model resolves to a known provider via its - // required_env, snap the radio so the env-var fields below match - // the model the user picked. Without this, picking - // "MiniMax-M2.7-highspeed" leaves the radio on whatever default - // was first (e.g. Anthropic) and surfaces ANTHROPIC_API_KEY as - // the required key — saving that and deploying produces a - // workspace with model=MiniMax + ANTHROPIC_API_KEY which then - // fails to call /registry/register and times out. Caught - // 2026-05-02 on hongming/Hermes Agent (workspace - // 95ed3ff2-… ended in WORKSPACE_PROVISION_FAILED). - // Free-text models not in `models` (or models without - // required_env) fall through and leave the radio alone. - useEffect(() => { - if (!open) return; - const targetId = providerIdForModel(model, models); - if (!targetId) return; - const matching = providers.find((p) => p.id === targetId); - if (matching && matching.id !== selectedId) { - setSelectedId(matching.id); - } - }, [open, model, models, providers, selectedId]); + setSelectorValue(initial); + }, [open, initial]); useEffect(() => { if (!open) return; setEntries( - selected.envVars.map((key) => ({ + selectorValue.envVars.map((key) => ({ key, value: "", // Pre-mark as saved when the key is already in the configured @@ -257,13 +282,13 @@ function ProviderPickerModal({ error: null, })), ); - }, [open, selected, configuredKeys]); + }, [open, selectorValue.envVars, configuredKeys]); useEffect(() => { if (!open) return; const raf = requestAnimationFrame(() => firstInputRef.current?.focus()); return () => cancelAnimationFrame(raf); - }, [open, selectedId]); + }, [open, selectorValue.providerId]); useEffect(() => { if (!open) return; @@ -372,73 +397,18 @@ function ProviderPickerModal({
- {showModelInput && ( -
- - setModel(e.target.value)} - placeholder="e.g. minimax/MiniMax-M2.7" - aria-label="Model slug" - autoComplete="off" - spellCheck={false} - list="provider-picker-model-suggestions" - className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors" - /> - - {modelSuggestions?.map((m) => ( - -

- Slug determines provider routing at install time. -

-
- )} -
- - Provider - - {providers.map((p) => ( - - ))} -
+ {/* Shared provider→model selector. Source of truth for provider + taxonomy + model filtering. Same component is used in + ConfigTab so behavior + vendor split is identical across + all 3 deploy surfaces (modal here, settings tab, template + palette flow). */} +
{entries.map((entry, index) => ( @@ -519,6 +489,7 @@ function ProviderPickerModal({ disabled={ !allSaved || anySaving || + !selectorValue.providerId || (showModelInput && model.trim() === "") } className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40" diff --git a/canvas/src/components/ProviderModelSelector.tsx b/canvas/src/components/ProviderModelSelector.tsx new file mode 100644 index 00000000..bca8cc1e --- /dev/null +++ b/canvas/src/components/ProviderModelSelector.tsx @@ -0,0 +1,509 @@ +"use client"; + +/** + * ProviderModelSelector — single source of truth for the provider→model + * dropdown chain shared across: + * 1. MissingKeysModal (template deploy / first-time onboarding modal) + * 2. ConfigTab (per-workspace settings — Runtime section) + * 3. TemplatePalette (template side panel — inherits via MissingKeysModal) + * + * The user picks Provider FIRST (Anthropic API, Claude Code subscription, + * MiniMax, Z.ai GLM, ...). The model dropdown then filters to only that + * provider's models. Wildcard providers (huggingface/*, openrouter/*, + * custom/*) reveal a free-text model input with a tooltip explaining the + * wildcard. + * + * Provider taxonomy: + * - Multiple models can share the same `required_env` (e.g. all + * ANTHROPIC_AUTH_TOKEN-routed third-party providers — MiniMax, GLM, + * Kimi, DeepSeek). Grouping ONLY by env-tuple collapses them all into + * one bucket. We split further by vendor inferred from the model id + * so the user sees "MiniMax" and "Z.ai (GLM)" as separate options. + * - Vendor is inferred via prefix rules below. Templates that ship + * explicit vendor metadata (future) should override the heuristic. + */ + +import { useId, useMemo } from "react"; + +export interface SelectorModel { + id: string; + name?: string; + required_env?: string[]; +} + +/** A provider option in the dropdown — one row corresponds to one + * vendor + env-tuple combo, holding the models that map to it. */ +export interface ProviderEntry { + /** Stable id used as the
+ +
+ + {useTextInput ? ( + <> + handleModelChange(e.target.value.trim())} + placeholder={ + selected?.wildcard + ? wildcardPlaceholder(selected) + : "type any model id" + } + disabled={disabled || !selected} + spellCheck={false} + autoComplete="off" + data-testid="model-input" + className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors disabled:opacity-50" + /> +

+ {selected?.wildcard + ? wildcardHelpText(selected) + : "Free-text model id. Make sure the provider can resolve it."} +

+ {!selected?.wildcard && ( + + )} + + ) : ( + + )} +
+
+ ); +} + +function wildcardPlaceholder(p: ProviderEntry): string { + const example = p.models.find((m) => m.id.includes("*"))?.id ?? ""; + if (!example) return "type any model id"; + // Strip trailing star — show the pattern as a hint. + const prefix = example.replace(/\*$/, ""); + switch (p.vendor) { + case "huggingface": + return `e.g. ${prefix}meta-llama/Meta-Llama-3-70B-Instruct`; + case "openrouter": + return `e.g. ${prefix}anthropic/claude-3.5-sonnet`; + case "custom": + return `e.g. ${prefix}my-local-model`; + default: + return `e.g. ${prefix}`; + } +} + +function wildcardHelpText(p: ProviderEntry): string { + switch (p.vendor) { + case "huggingface": + return "Any model hosted on Hugging Face Inference. Browse at huggingface.co/models?inference=warm."; + case "openrouter": + return "Any of OpenRouter's 200+ routed models. Browse at openrouter.ai/models."; + case "custom": + return "Self-hosted endpoint. Configure base_url in your workspace's runtime config (no API key required)."; + case "ai-gateway": + return "Vercel AI Gateway model id. See vercel.com/docs/ai-gateway."; + case "opencode-zen": + return "OpenCode Zen model id. See opencode.zen."; + default: + return "Wildcard provider — type the model id in full. Provider routes by id prefix."; + } +} diff --git a/canvas/src/components/__tests__/MissingKeysModal.cascade.test.tsx b/canvas/src/components/__tests__/MissingKeysModal.cascade.test.tsx index 32dfd62b..260efcd2 100644 --- a/canvas/src/components/__tests__/MissingKeysModal.cascade.test.tsx +++ b/canvas/src/components/__tests__/MissingKeysModal.cascade.test.tsx @@ -1,34 +1,24 @@ // @vitest-environment jsdom /** - * Provider→model cascade in the deploy modal (sibling of the ConfigTab - * cascade fix shipped in PR #2516, task #236). + * Provider→model cascade in the deploy modal. * - * The user-reported bug (2026-05-02 hongming Hermes Agent): + * Original bug (2026-05-02 hongming Hermes Agent): + * 1. Modal pre-fills MODEL with template default (e.g. MiniMax-M2.7-highspeed) + * 2. Provider radio defaults to providers[0] (Anthropic) — wrong vendor + * 3. ENV-VAR input shows ANTHROPIC_API_KEY + * 4. User pastes a key, deploys + * 5. Workspace boots with model=MiniMax + ANTHROPIC_API_KEY → adapter + * crashes before /registry/register → WORKSPACE_PROVISION_FAILED. * - * 1. User opens TemplatePalette → Deploy on a hermes template. - * 2. Modal shows MODEL field pre-filled with template default - * (e.g. "MiniMax-M2.7-highspeed") AND a list of provider radios - * (Anthropic, OpenRouter, MiniMax, …). - * 3. The provider radio defaults to whichever entry was first in - * `preflight.providers` (Anthropic in the hermes case). - * 4. The env-var input below shows ANTHROPIC_API_KEY. - * 5. User pastes whatever key they have, clicks Deploy. - * 6. Workspace is created with model=MiniMax-M2.7-highspeed + - * ANTHROPIC_API_KEY → hermes adapter tries to call Anthropic - * with a MiniMax model id → crashes before /registry/register - * → workspace ends in WORKSPACE_PROVISION_FAILED with - * "container started but never called /registry/register". - * - * Fix: when the model resolves to a known provider via its - * `required_env`, snap the radio so the env-var fields below match - * the model the user picked. Free-text models not in `models` (or - * models without required_env) leave the radio alone — the user can - * still manually pick a provider. + * Fix: pre-deploy modal back-derives provider from initialModel and pins + * the selector to the matching vendor. The dropdown UI (replacing the + * old radios in PR shipped 2026-05-02) keeps the same invariant. */ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { MissingKeysModal, providerIdForModel } from "../MissingKeysModal"; +import { buildProviderCatalog } from "../ProviderModelSelector"; import type { ModelSpec, ProviderChoice } from "@/lib/deploy-preflight"; vi.mock("@/lib/api", () => ({ @@ -73,7 +63,17 @@ const HERMES_MODELS: ModelSpec[] = [ { id: "local-llama3", required_env: [] }, ]; -describe("providerIdForModel", () => { +/** Resolve the selector option-value for a given vendor against the + * vendor-aware catalog. Catalog ids are `${vendor}|${sortedEnv}`, so + * test code shouldn't hard-code them. */ +function providerIdForVendor(vendor: string): string { + const catalog = buildProviderCatalog(HERMES_MODELS); + const entry = catalog.find((p) => p.vendor === vendor); + if (!entry) throw new Error(`vendor "${vendor}" not in catalog`); + return entry.id; +} + +describe("providerIdForModel (legacy helper, still exported for tests)", () => { it("returns the provider id (sorted+joined required_env) for a known model", () => { expect(providerIdForModel("MiniMax-M2.7-highspeed", HERMES_MODELS)).toBe( "MINIMAX_API_KEY", @@ -83,9 +83,6 @@ describe("providerIdForModel", () => { ); }); - // The id formula sorts envVars before joining. A model that needs - // two keys together (rare today, but the shape supports it) maps - // to a deterministic id regardless of the order in required_env. it("sorts required_env so the id matches providersFromTemplate's formula", () => { const models: ModelSpec[] = [ { id: "weird", required_env: ["Z_KEY", "A_KEY"] }, @@ -117,14 +114,14 @@ describe("providerIdForModel", () => { }); }); -describe("ProviderPickerModal — model→provider cascade", () => { +describe("ProviderPickerModal — model→provider cascade (dropdown UI)", () => { afterEach(() => cleanup()); // The headline bug: opening the modal with the MiniMax default - // pre-filled should NOT leave the radio on Anthropic just because - // Anthropic was first in providers[]. The cascade snaps the radio - // to MINIMAX_API_KEY on first paint. - it("snaps provider radio to MiniMax when initialModel is a MiniMax model", () => { + // pre-filled should NOT leave the selector on Anthropic just because + // Anthropic was first in providers[]. Back-derivation snaps it on + // first paint to the MiniMax vendor entry. + it("snaps provider selector to MiniMax when initialModel is a MiniMax model", () => { render( { onCancel={vi.fn()} />, ); - const minimaxRadio = screen.getByRole("radio", { - name: /MiniMax \(2 models\)/i, - }) as HTMLInputElement; - expect(minimaxRadio.checked).toBe(true); + const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement; + expect(providerSelect.value).toBe(providerIdForVendor("minimax")); // The env-var input underneath should be for MINIMAX_API_KEY, // not ANTHROPIC_API_KEY — that's the load-bearing UX win. The // entry uses a password input with a fixed "sk-..." placeholder // when the key name contains "API_KEY"; assert exactly ONE such // input exists, which proves only the selected provider's envVars - // were rendered into entries[]. (The provider-radio subtitles - // also mention each envVar name as Mono text — that's why we - // can't use getByText("MINIMAX_API_KEY") here, it would match - // both the radio label and the entry label.) + // were rendered into entries[]. const apiKeyInputs = screen.getAllByPlaceholderText("sk-..."); expect(apiKeyInputs).toHaveLength(1); }); - // Mid-flow change: user starts with the pre-filled MiniMax model, - // edits it to a Claude model, the radio re-snaps to Anthropic. This - // matches user expectation — picking a different model shouldn't - // leave the wrong env-var input showing. - it("re-snaps when the user edits the model field to a different provider's model", () => { + // Mid-flow change: user starts with the pre-filled MiniMax model and + // switches the provider dropdown to Anthropic. Env-var rows below + // re-render to show ANTHROPIC_API_KEY only. Same shape-pin as above. + it("re-renders credential entries when provider is switched", () => { render( { onCancel={vi.fn()} />, ); - const modelInput = screen.getByLabelText(/Model slug/i) as HTMLInputElement; - fireEvent.change(modelInput, { target: { value: "claude-opus-4-7" } }); - const anthropicRadio = screen.getByRole("radio", { - name: /Anthropic \(8 models\)/i, - }) as HTMLInputElement; - expect(anthropicRadio.checked).toBe(true); - // Same shape-pin as the previous test — exactly one - // password input means only the selected provider's envVars - // landed in entries[]. + const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement; + fireEvent.change(providerSelect, { + target: { value: providerIdForVendor("anthropic") }, + }); + expect(providerSelect.value).toBe(providerIdForVendor("anthropic")); + // Exactly one password input means only the selected provider's + // envVars landed in entries[]. expect(screen.getAllByPlaceholderText("sk-...")).toHaveLength(1); }); - // Free-text models (typed slug not in the registry) should NOT - // change the radio — the user may know about a model the template - // doesn't list. Falling back to the previously-selected provider - // keeps the form in a usable state. - it("leaves the radio alone when the typed model is not in the registry", () => { - render( - m.id)} - models={HERMES_MODELS} - initialModel="MiniMax-M2.7-highspeed" - onKeysAdded={vi.fn()} - onCancel={vi.fn()} - />, - ); - // Snapped to MiniMax by initial cascade. - expect( - (screen.getByRole("radio", { - name: /MiniMax \(2 models\)/i, - }) as HTMLInputElement).checked, - ).toBe(true); - - // Type something the registry doesn't know — radio stays on MiniMax. - const modelInput = screen.getByLabelText(/Model slug/i) as HTMLInputElement; - fireEvent.change(modelInput, { - target: { value: "some-future-model-not-in-registry" }, - }); - expect( - (screen.getByRole("radio", { - name: /MiniMax \(2 models\)/i, - }) as HTMLInputElement).checked, - ).toBe(true); - }); - // Backwards-compat: callers that don't pass `models` (legacy - // call sites) keep the pre-cascade behavior — radio defaults to - // providers[0] (or to a satisfied configuredKeys match). The - // cascade is purely additive. - it("falls back to providers[0] when models prop is omitted", () => { + // call sites) fall back to a synthesized catalog from `providers` + // — selector still works, but vendor split is degraded to env-tuple + // grouping (one entry per ProviderChoice). + it("falls back to providers[] when models prop is omitted", () => { render( { runtime="hermes" modelSuggestions={HERMES_MODELS.map((m) => m.id)} // models intentionally omitted — legacy caller shape. - initialModel="MiniMax-M2.7-highspeed" onKeysAdded={vi.fn()} onCancel={vi.fn()} />, ); - // Without `models`, no cascade: radio sits on providers[0] - // (Anthropic), reproducing the bug the cascade fixes. Pinned - // here so anyone removing the `models` prop sees the regression. - expect( - (screen.getByRole("radio", { - name: /Anthropic \(8 models\)/i, - }) as HTMLInputElement).checked, - ).toBe(true); + // Without `models`, no back-derivation: selector defaults to + // providers[0] (Anthropic). Dropdown still populated with all 3 + // entries — synthesized catalog uses `${vendor}|${envTuple}` ids + // (matching the selector's own catalog shape), so the value is + // "anthropic|ANTHROPIC_API_KEY", not the raw "ANTHROPIC_API_KEY". + const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement; + expect(providerSelect.value).toBe("anthropic|ANTHROPIC_API_KEY"); + expect(providerSelect.options.length).toBeGreaterThanOrEqual(4); // 3 providers + the disabled placeholder }); // configuredKeys interaction: when a provider's keys are already // saved globally, the picker pre-selects that satisfied provider. - // The model cascade should still override — the user explicitly - // picked a model that needs a different provider, that intent - // wins over "you already have this key". - it("model cascade beats configuredKeys-satisfied default", () => { + // BUT the model-derived snap still wins — the user explicitly + // picked a model, that intent overrides "you already have this key". + it("model-derived selection beats configuredKeys-satisfied default", () => { render( m.id)} @@ -273,10 +223,7 @@ describe("ProviderPickerModal — model→provider cascade", () => { onCancel={vi.fn()} />, ); - expect( - (screen.getByRole("radio", { - name: /MiniMax \(2 models\)/i, - }) as HTMLInputElement).checked, - ).toBe(true); + const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement; + expect(providerSelect.value).toBe(providerIdForVendor("minimax")); }); }); diff --git a/canvas/src/components/__tests__/ProviderModelSelector.test.tsx b/canvas/src/components/__tests__/ProviderModelSelector.test.tsx new file mode 100644 index 00000000..f5746dd4 --- /dev/null +++ b/canvas/src/components/__tests__/ProviderModelSelector.test.tsx @@ -0,0 +1,269 @@ +// @vitest-environment jsdom +/** + * ProviderModelSelector — vendor detection + dropdown cascade. + */ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; + +import { + ProviderModelSelector, + buildProviderCatalog, + inferVendor, + findProviderForModel, + type SelectorModel, + type SelectorValue, +} from "../ProviderModelSelector"; + +afterEach(() => cleanup()); + +// Fixture mirrors the real claude-code-default config.yaml — covers +// the env-collision scenario (9 models share ANTHROPIC_AUTH_TOKEN +// but represent 4 distinct vendors). +const CLAUDE_CODE_MODELS: SelectorModel[] = [ + { id: "sonnet", name: "Claude Sonnet (OAuth)", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, + { id: "opus", name: "Claude Opus (OAuth)", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, + { id: "haiku", name: "Claude Haiku (OAuth)", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (API)", required_env: ["ANTHROPIC_API_KEY"] }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7 (API)", required_env: ["ANTHROPIC_API_KEY"] }, + { id: "mimo-v2-flash", name: "Xiaomi MiMo Flash", required_env: ["ANTHROPIC_API_KEY"] }, + { id: "mimo-v2-pro", name: "Xiaomi MiMo Pro", required_env: ["ANTHROPIC_API_KEY"] }, + { id: "MiniMax-M2", name: "MiniMax M2", required_env: ["ANTHROPIC_AUTH_TOKEN"] }, + { id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["ANTHROPIC_AUTH_TOKEN"] }, + { id: "GLM-4.6", name: "Z.ai GLM-4.6", required_env: ["ANTHROPIC_AUTH_TOKEN"] }, + { id: "kimi-k2", name: "Moonshot Kimi K2", required_env: ["ANTHROPIC_AUTH_TOKEN"] }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", required_env: ["ANTHROPIC_AUTH_TOKEN"] }, +]; + +const HERMES_MODELS: SelectorModel[] = [ + { id: "nousresearch/hermes-4-70b", name: "Hermes 4 70B", required_env: ["HERMES_API_KEY"] }, + { id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet (direct)", required_env: ["ANTHROPIC_API_KEY"] }, + { id: "openai/gpt-5", name: "GPT-5 via OR", required_env: ["OPENROUTER_API_KEY"] }, + { id: "huggingface/*", name: "Any HF model", required_env: ["HF_TOKEN"] }, + { id: "openrouter/*", name: "Any OpenRouter model", required_env: ["OPENROUTER_API_KEY"] }, + { id: "custom/*", name: "Self-hosted endpoint", required_env: [] }, +]; + +describe("inferVendor", () => { + it("uses slash prefix when present", () => { + expect(inferVendor({ id: "nousresearch/hermes-4-70b", required_env: ["HERMES_API_KEY"] })) + .toBe("nousresearch"); + expect(inferVendor({ id: "anthropic/claude-sonnet-4-5", required_env: ["ANTHROPIC_API_KEY"] })) + .toBe("anthropic"); + expect(inferVendor({ id: "openai/gpt-5", required_env: ["OPENROUTER_API_KEY"] })) + .toBe("openai"); + }); + + it("infers vendor from bare-id pattern when no slash", () => { + expect(inferVendor({ id: "MiniMax-M2.7", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("minimax"); + expect(inferVendor({ id: "GLM-4.6", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("zai"); + expect(inferVendor({ id: "kimi-k2", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("moonshot"); + expect(inferVendor({ id: "deepseek-v4-pro", required_env: ["ANTHROPIC_AUTH_TOKEN"] })).toBe("deepseek"); + expect(inferVendor({ id: "mimo-v2-flash", required_env: ["ANTHROPIC_API_KEY"] })).toBe("xiaomi-mimo"); + expect(inferVendor({ id: "claude-sonnet-4-6", required_env: ["ANTHROPIC_API_KEY"] })).toBe("anthropic"); + }); + + it("treats bare sonnet/opus/haiku as anthropic-oauth ONLY when env demands OAuth", () => { + expect(inferVendor({ id: "sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] })) + .toBe("anthropic-oauth"); + expect(inferVendor({ id: "opus", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] })) + .toBe("anthropic-oauth"); + // Hypothetical sonnet alias against API key — must NOT be tagged OAuth. + expect(inferVendor({ id: "sonnet", required_env: ["ANTHROPIC_API_KEY"] })) + .toBe("anthropic"); + }); + + it("falls back to env namespace for unknown vendors", () => { + expect(inferVendor({ id: "unknown-id", required_env: ["OPENROUTER_API_KEY"] })) + .toBe("openrouter"); + expect(inferVendor({ id: "unknown-id", required_env: ["HERMES_API_KEY"] })) + .toBe("hermes"); + }); +}); + +describe("buildProviderCatalog", () => { + it("splits ANTHROPIC_AUTH_TOKEN models by vendor (not just env)", () => { + const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS); + const vendors = catalog.map((p) => p.vendor).sort(); + // The 4 third-party vendors that share ANTHROPIC_AUTH_TOKEN must + // all appear as separate entries. + expect(vendors).toContain("minimax"); + expect(vendors).toContain("zai"); + expect(vendors).toContain("moonshot"); + expect(vendors).toContain("deepseek"); + // Plus the OAuth, Anthropic API, and Xiaomi MiMo entries. + expect(vendors).toContain("anthropic-oauth"); + expect(vendors).toContain("anthropic"); + expect(vendors).toContain("xiaomi-mimo"); + }); + + it("buckets models under the correct vendor", () => { + const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS); + const minimax = catalog.find((p) => p.vendor === "minimax"); + expect(minimax).toBeDefined(); + expect(minimax!.models.map((m) => m.id).sort()).toEqual(["MiniMax-M2", "MiniMax-M2.7"]); + const oauth = catalog.find((p) => p.vendor === "anthropic-oauth"); + expect(oauth!.models.map((m) => m.id).sort()).toEqual(["haiku", "opus", "sonnet"]); + }); + + it("flags wildcard providers", () => { + const catalog = buildProviderCatalog(HERMES_MODELS); + const hf = catalog.find((p) => p.vendor === "huggingface"); + expect(hf?.wildcard).toBe(true); + const custom = catalog.find((p) => p.vendor === "custom"); + expect(custom?.wildcard).toBe(true); + const nous = catalog.find((p) => p.vendor === "nousresearch"); + expect(nous?.wildcard).toBe(false); + }); + + it("decorates label with model count when ≥2 concrete models", () => { + const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS); + const oauth = catalog.find((p) => p.vendor === "anthropic-oauth"); + expect(oauth?.label).toMatch(/3 models/); + // Wildcard buckets don't get the count suffix. + const hfCatalog = buildProviderCatalog(HERMES_MODELS); + const hf = hfCatalog.find((p) => p.vendor === "huggingface"); + expect(hf?.label).not.toMatch(/models\)/); + }); +}); + +describe("findProviderForModel", () => { + const catalog = buildProviderCatalog(HERMES_MODELS); + + it("matches concrete model ids directly", () => { + expect(findProviderForModel(catalog, "nousresearch/hermes-4-70b")?.vendor) + .toBe("nousresearch"); + expect(findProviderForModel(catalog, "openai/gpt-5")?.vendor).toBe("openai"); + }); + + it("matches wildcard providers by prefix", () => { + expect(findProviderForModel(catalog, "huggingface/meta-llama/Meta-Llama-3-70B")?.vendor) + .toBe("huggingface"); + expect(findProviderForModel(catalog, "openrouter/anthropic/claude-3.5-sonnet")?.vendor) + .toBe("openrouter"); + expect(findProviderForModel(catalog, "custom/local-vllm")?.vendor).toBe("custom"); + }); + + it("returns null on no match", () => { + expect(findProviderForModel(catalog, "")).toBeNull(); + expect(findProviderForModel(catalog, "unknown-model-xyz")).toBeNull(); + }); +}); + +// ----------------------------------------------------------------------------- +// Component behavior +// ----------------------------------------------------------------------------- + +function setup(overrides?: Partial<{ value: SelectorValue; models: SelectorModel[]; onChange: (v: SelectorValue) => void }>) { + const onChange = overrides?.onChange ?? vi.fn(); + const value: SelectorValue = overrides?.value ?? { providerId: "", model: "", envVars: [] }; + render( + , + ); + return { onChange }; +} + +describe("", () => { + it("renders provider dropdown with all vendor options", () => { + setup(); + const select = screen.getByTestId("provider-select") as HTMLSelectElement; + const optionTexts = Array.from(select.options).map((o) => o.text); + expect(optionTexts).toContain("Claude Code subscription (3 models)"); + expect(optionTexts.some((t) => t.startsWith("MiniMax"))).toBe(true); + expect(optionTexts.some((t) => t.startsWith("Z.ai"))).toBe(true); + }); + + it("model dropdown is disabled until provider is picked", () => { + setup(); + const modelSelect = screen.getByTestId("model-select") as HTMLSelectElement; + expect(modelSelect.disabled).toBe(true); + }); + + it("picking provider emits onChange with default model + envVars", () => { + const { onChange } = setup(); + const providerSelect = screen.getByTestId("provider-select"); + const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS); + const minimax = catalog.find((p) => p.vendor === "minimax")!; + fireEvent.change(providerSelect, { target: { value: minimax.id } }); + expect(onChange).toHaveBeenCalledWith({ + providerId: minimax.id, + model: "MiniMax-M2", + envVars: ["ANTHROPIC_AUTH_TOKEN"], + }); + }); + + it("picking provider then model emits combined value", () => { + const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS); + const minimax = catalog.find((p) => p.vendor === "minimax")!; + const onChange = vi.fn(); + setup({ + value: { providerId: minimax.id, model: "MiniMax-M2", envVars: ["ANTHROPIC_AUTH_TOKEN"] }, + onChange, + }); + const modelSelect = screen.getByTestId("model-select"); + fireEvent.change(modelSelect, { target: { value: "MiniMax-M2.7" } }); + expect(onChange).toHaveBeenCalledWith({ + providerId: minimax.id, + model: "MiniMax-M2.7", + envVars: ["ANTHROPIC_AUTH_TOKEN"], + }); + }); + + it("wildcard provider switches model UI to free-text input", () => { + const catalog = buildProviderCatalog(HERMES_MODELS); + const hf = catalog.find((p) => p.vendor === "huggingface")!; + setup({ + models: HERMES_MODELS, + value: { providerId: hf.id, model: "", envVars: hf.envVars }, + }); + expect(screen.queryByTestId("model-select")).toBeNull(); + expect(screen.queryByTestId("model-input")).not.toBeNull(); + }); + + it("wildcard input emits typed value as model", () => { + const catalog = buildProviderCatalog(HERMES_MODELS); + const openrouter = catalog.find((p) => p.vendor === "openrouter")!; + const onChange = vi.fn(); + setup({ + models: HERMES_MODELS, + value: { providerId: openrouter.id, model: "", envVars: openrouter.envVars }, + onChange, + }); + const input = screen.getByTestId("model-input"); + fireEvent.change(input, { target: { value: "openrouter/anthropic/claude-3.5-sonnet" } }); + expect(onChange).toHaveBeenCalledWith({ + providerId: openrouter.id, + model: "openrouter/anthropic/claude-3.5-sonnet", + envVars: ["OPENROUTER_API_KEY"], + }); + }); + + it("renders required env hint for selected provider", () => { + const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS); + const oauth = catalog.find((p) => p.vendor === "anthropic-oauth")!; + setup({ + value: { providerId: oauth.id, model: "sonnet", envVars: oauth.envVars }, + }); + expect(screen.getByText(/requires:/).textContent).toMatch(/CLAUDE_CODE_OAUTH_TOKEN/); + }); + + it("switching provider resets model to first concrete option", () => { + const catalog = buildProviderCatalog(CLAUDE_CODE_MODELS); + const oauth = catalog.find((p) => p.vendor === "anthropic-oauth")!; + const minimax = catalog.find((p) => p.vendor === "minimax")!; + const onChange = vi.fn(); + setup({ + value: { providerId: oauth.id, model: "sonnet", envVars: oauth.envVars }, + onChange, + }); + fireEvent.change(screen.getByTestId("provider-select"), { target: { value: minimax.id } }); + expect(onChange).toHaveBeenCalledWith({ + providerId: minimax.id, + model: "MiniMax-M2", + envVars: ["ANTHROPIC_AUTH_TOKEN"], + }); + }); +}); diff --git a/canvas/src/components/tabs/ConfigTab.tsx b/canvas/src/components/tabs/ConfigTab.tsx index f46ff538..f75700ed 100644 --- a/canvas/src/components/tabs/ConfigTab.tsx +++ b/canvas/src/components/tabs/ConfigTab.tsx @@ -1,11 +1,17 @@ "use client"; -import { useState, useEffect, useCallback, useRef, useId } from "react"; +import { useState, useEffect, useCallback, useRef, useId, useMemo } from "react"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; import { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./config/form-inputs"; import { parseYaml, toYaml } from "./config/yaml-utils"; import { SecretsSection } from "./config/secrets-section"; +import { + ProviderModelSelector, + buildProviderCatalog, + findProviderForModel, + type SelectorValue, +} from "../ProviderModelSelector"; interface Props { workspaceId: string; @@ -298,19 +304,61 @@ export function ConfigTab({ workspaceId }: Props) { // Models + env hints for the currently-selected runtime. const selectedRuntime = runtimeOptions.find((o) => o.value === (config.runtime || "")) ?? null; const availableModels: ModelSpec[] = selectedRuntime?.models ?? []; - // Provider suggestions: prefer the runtime's declarative providers - // list (sourced from its template config.yaml runtime_config.providers - // and surfaced via /templates), fall back to deriving from model slug - // prefixes when the template hasn't migrated to the explicit field - // yet. Either way the data flows from the adapter — no hardcoded - // canvas-side enum. - const providerSuggestions: string[] = + // Provider suggestions for the legacy free-text input fallback (used + // when /templates returned no models for this runtime, e.g. hermes + // workspaces). Prefer the runtime's declarative providers list, + // fall back to deriving from model-slug prefixes. + const providerSuggestionsList: string[] = (selectedRuntime?.providers && selectedRuntime.providers.length > 0) ? selectedRuntime.providers : deriveProvidersFromModels(availableModels); const currentModelId = config.runtime_config?.model || config.model || ""; const currentModelSpec = availableModels.find((m) => m.id === currentModelId) ?? null; + // Vendor-aware catalog shared with the selector. Memoised so the + // catalog identity is stable across renders (selector relies on it). + const providerCatalog = useMemo( + () => buildProviderCatalog(availableModels), + [availableModels], + ); + + // Derive the selector's current value from the form state. Provider + // back-derivation prefers a vendor-key match against `provider` + // (Option B explicit override), falling back to the model's vendor + // bucket when no override is set. + const selectorValue: SelectorValue = useMemo(() => { + // 1. Prefer explicit vendor match (workspace_secrets MODEL_PROVIDER). + if (provider) { + const byVendor = providerCatalog.find((p) => p.vendor === provider); + if (byVendor) { + return { + providerId: byVendor.id, + model: currentModelId, + envVars: byVendor.envVars, + }; + } + } + // 2. Back-derive from model id. + const matched = findProviderForModel(providerCatalog, currentModelId); + if (matched) { + return { + providerId: matched.id, + model: currentModelId, + envVars: matched.envVars, + }; + } + // 3. Empty — user hasn't picked yet (or template has no models). + return { providerId: "", model: currentModelId, envVars: [] }; + }, [provider, currentModelId, providerCatalog]); + const setSelectorValue = (_next: SelectorValue) => { + // Selector emits `next`; the actual writes happen in the onChange + // handler in JSX which calls setConfig + setProvider directly. + // This setter exists only to satisfy ProviderModelSelector's + // controlled-component contract (it always re-derives from props + // so the no-op identity is fine). + void _next; + }; + const update = (key: K, value: ConfigData[K]) => { setConfig((prev) => ({ ...prev, [key]: value })); }; @@ -551,125 +599,148 @@ export function ConfigTab({ workspaceId }: Props) {
-
-
- - -
-
- - 0 ? `${runtimeId}-models` : undefined} - value={currentModelId} - onChange={(e) => { - const v = e.target.value; - setConfig((prev) => { - // If the new value exactly matches a known modelSpec id, - // swap required_env to that spec's list — but only when - // the current required_env is empty or was itself - // template-driven (i.e. matches the previous modelSpec's - // required_env). User-typed envs always win. - const nextSpec = availableModels.find((m) => m.id === v) ?? null; - const prevModelId = prev.runtime_config?.model || prev.model || ""; - const prevSpec = availableModels.find((m) => m.id === prevModelId) ?? null; - const prevRequired = prev.runtime_config?.required_env ?? []; - const wasTemplateDriven = - prevRequired.length === 0 || - (prevSpec?.required_env?.length - ? prevRequired.length === prevSpec.required_env.length && - prevRequired.every((e, i) => e === prevSpec.required_env![i]) - : false); - const nextRequired = - nextSpec?.required_env?.length && wasTemplateDriven - ? nextSpec.required_env - : prevRequired; - if (prev.runtime) { - return { - ...prev, - runtime_config: { - ...prev.runtime_config, - model: v, - ...(nextSpec?.required_env?.length && wasTemplateDriven - ? { required_env: nextRequired } - : {}), - }, - }; - } - return { ...prev, model: v }; - }); - }} - placeholder="e.g. anthropic:claude-sonnet-4-6" - className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500" - /> - {availableModels.length > 0 && ( - - {availableModels.map((m, i) => ( - - ))} - - )} -
-
- {/* Provider override (Option B PR-5). Free-text combobox so - operators can use any of the 30+ slugs hermes-agent's - derive-provider.sh recognizes — the suggestion list is - a hint, not a constraint. Empty = "auto-derive from - model slug prefix" which is correct for the common case - (model "anthropic:claude-opus-4-7" → provider derived - as "anthropic"). The override is needed when the model - alias has no clean vendor prefix (e.g. hermes default - "nousresearch/hermes-4-70b" → derive returns empty → - hermes errors "No LLM provider configured"). */}
- - 0 ? `${runtimeId}-providers` : undefined} - value={provider} - onChange={(e) => setProvider(e.target.value.trim())} - placeholder={ - providerSuggestions.length > 0 - ? `e.g. ${providerSuggestions.slice(0, 3).join(", ")} (empty = auto-derive)` - : "empty = auto-derive from model slug" - } - aria-label="LLM provider override" - data-testid="provider-input" - className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500" - /> - {providerSuggestions.length > 0 && ( - - {providerSuggestions.map((p) => ( - - )} - {provider && provider !== originalProvider && ( -

- Provider change → workspace will auto-restart on Save. -

- )} + +
+ {/* Shared Provider→Model selector. Same component renders in + MissingKeysModal (deploy onboarding) so the dropdown UX is + identical across all three surfaces. Provider field maps + back into the workspace_secrets MODEL_PROVIDER override + — empty = "auto-derive from model slug" was the pre-PR-5 + behavior; selecting any provider here writes LLM_PROVIDER + and triggers an auto-restart. */} + {availableModels.length > 0 ? ( + { + setSelectorValue(next); + // Mirror selection into the config object the rest of + // the form / save handler still reads. Model lands in + // runtime_config.model when a runtime is set, else + // top-level model. required_env follows the selected + // provider's envVars when the existing required_env + // was template-driven (don't clobber user-typed envs). + setConfig((prev) => { + const v = next.model; + const prevModelId = prev.runtime_config?.model || prev.model || ""; + const prevSpec = availableModels.find((m) => m.id === prevModelId) ?? null; + const prevRequired = prev.runtime_config?.required_env ?? []; + const wasTemplateDriven = + prevRequired.length === 0 || + (prevSpec?.required_env?.length + ? prevRequired.length === prevSpec.required_env.length && + prevRequired.every((e, i) => e === prevSpec.required_env![i]) + : false); + const nextRequired = + next.envVars.length > 0 && wasTemplateDriven + ? next.envVars + : prevRequired; + if (prev.runtime) { + return { + ...prev, + runtime_config: { + ...prev.runtime_config, + model: v, + ...(next.envVars.length > 0 && wasTemplateDriven + ? { required_env: nextRequired } + : {}), + }, + }; + } + return { ...prev, model: v }; + }); + // Map vendor → workspace_secrets MODEL_PROVIDER value. + // Hermes-agent derive-provider.sh is the canonical + // recogniser, but we approximate by emitting the + // catalog vendor key (which matches our hermes + // provider taxonomy 1:1 for the slugs we ship). + if (next.providerId) { + const entry = providerCatalog.find((p) => p.id === next.providerId); + if (entry) setProvider(entry.vendor); + } else { + setProvider(""); + } + }} + variant="grid" + idPrefix={runtimeId} + allowCustomModelEscape + /> + ) : ( + // Fallback when /templates didn't surface any models for + // this runtime — e.g. hermes workspaces that manage their + // own ~/.hermes/config.yaml. Power-user free-text inputs + // for both fields. Provider here writes through to the + // workspace_secrets MODEL_PROVIDER override. +
+
+ + { + const v = e.target.value; + setConfig((prev) => + prev.runtime + ? { ...prev, runtime_config: { ...prev.runtime_config, model: v } } + : { ...prev, model: v }, + ); + }} + placeholder="e.g. anthropic:claude-sonnet-4-6" + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500" + /> +
+
+ + 0 + ? `${runtimeId}-providers` + : undefined + } + value={provider} + onChange={(e) => setProvider(e.target.value.trim())} + placeholder={ + providerSuggestionsList.length > 0 + ? `e.g. ${providerSuggestionsList.slice(0, 3).join(", ")} (empty = auto-derive)` + : "empty = auto-derive from model slug" + } + aria-label="LLM provider override" + data-testid="provider-input" + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500" + /> + {providerSuggestionsList.length > 0 && ( + + {providerSuggestionsList.map((p) => ( + + )} +
+
+ )} + {provider && provider !== originalProvider && ( +

+ Provider change → workspace will auto-restart on Save. +

+ )} - screen.getByPlaceholderText(/anthropic:claude-sonnet/i), - )) as HTMLInputElement; - - fireEvent.change(modelInput, { + // With models[] present, the new ProviderModelSelector renders a + // provider+model dropdown pair instead of free-text inputs. Pick + // the provider first (single vendor here = minimax) so the model + // dropdown appears, then pick the model. The selector emits + // {providerId, model, envVars}, ConfigTab mirrors model into + // config.runtime_config.model, and the Save handler PUTs /model. + const providerSelect = (await waitFor(() => + screen.getByTestId("provider-select"), + )) as HTMLSelectElement; + const minimaxId = Array.from(providerSelect.options).find((o) => + o.text.startsWith("MiniMax"), + )?.value; + expect(minimaxId).toBeTruthy(); + fireEvent.change(providerSelect, { target: { value: minimaxId! } }); + // After picking provider, the selector defaults model to the + // first concrete entry. We explicitly pick the same model to + // exercise the model-change path. + const modelSelect = (await waitFor(() => + screen.getByTestId("model-select"), + )) as HTMLSelectElement; + fireEvent.change(modelSelect, { target: { value: "minimax/MiniMax-M2.7-highspeed" }, }); diff --git a/canvas/src/components/tabs/__tests__/ConfigTab.provider.test.tsx b/canvas/src/components/tabs/__tests__/ConfigTab.provider.test.tsx index 14ea3891..2714cba8 100644 --- a/canvas/src/components/tabs/__tests__/ConfigTab.provider.test.tsx +++ b/canvas/src/components/tabs/__tests__/ConfigTab.provider.test.tsx @@ -262,10 +262,10 @@ describe("ConfigTab — Provider override (Option B PR-5)", () => { // prefixes. Still adapter-driven (the slugs come from the template's // `models:` list), just inferred. This keeps existing templates // working while the platform team migrates them one at a time. - it("falls back to model-slug prefixes when the runtime ships no providers list", async () => { + it("renders vendor-grouped provider dropdown when template ships models", async () => { wireApi({ workspaceRuntime: "hermes", - workspaceModel: "anthropic:claude-opus-4-7", + workspaceModel: "anthropic/claude-opus-4-7", configYamlContent: "name: ws\nruntime: hermes\n", providerValue: "", templates: [ @@ -274,28 +274,32 @@ describe("ConfigTab — Provider override (Option B PR-5)", () => { name: "Hermes", runtime: "hermes", models: [ - { id: "anthropic:claude-opus-4-7" }, - { id: "openai:gpt-4o" }, - { id: "anthropic:claude-sonnet-4-5" }, // dup vendor — must dedupe - { id: "nousresearch/hermes-4-70b" }, // "/" separator + { id: "anthropic/claude-opus-4-7", required_env: ["ANTHROPIC_API_KEY"] }, + { id: "openai/gpt-4o", required_env: ["OPENROUTER_API_KEY"] }, + { id: "anthropic/claude-sonnet-4-5", required_env: ["ANTHROPIC_API_KEY"] }, // dup vendor — must dedupe + { id: "nousresearch/hermes-4-70b", required_env: ["HERMES_API_KEY"] }, ], - // No `providers:` field → fallback derivation kicks in. + // No `providers:` field → ProviderModelSelector derives vendors + // from model id prefixes via its own buildProviderCatalog. }, ], }); render(); - const input = await screen.findByTestId("provider-input"); - const listId = (input as HTMLInputElement).getAttribute("list"); - expect(listId).toBeTruthy(); + // With models present, the new vendor-aware dropdown renders. + // Provider entries dedupe by vendor → 3 unique vendors here + // (anthropic, openai, nousresearch). + const select = await screen.findByTestId("provider-select") as HTMLSelectElement; await waitFor(() => { - const datalist = document.getElementById(listId!); - const optionValues = Array.from(datalist!.querySelectorAll("option")).map( - (o) => (o as HTMLOptionElement).value, - ); - // Order = first-appearance from models[]; dedup keeps anthropic - // once even though two model slugs use it. - expect(optionValues).toEqual(["anthropic", "openai", "nousresearch"]); + const optionTexts = Array.from(select.options) + .map((o) => o.text) + .filter((t) => !t.startsWith("—")); // strip placeholder + // Labels are vendor display names, but vendor identity is what + // matters for dedupe. Assert each expected vendor surfaces once. + expect(optionTexts.some((t) => t.startsWith("Anthropic API"))).toBe(true); + expect(optionTexts.some((t) => t.startsWith("OpenAI"))).toBe(true); + expect(optionTexts.some((t) => t.startsWith("Nous Research"))).toBe(true); + expect(optionTexts.length).toBe(3); // dedupe pin }); });