forked from molecule-ai/molecule-core
Merge pull request #2540 from Molecule-AI/fix/wire-provider-model-selector
fix(canvas): wire ProviderModelSelector into MissingKeysModal + ConfigTab
This commit is contained in:
commit
2eea2b1315
@ -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<SelectorValue>(() => {
|
||||
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<SelectorValue>(initial);
|
||||
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const [model, setModel] = useState(initialModel ?? "");
|
||||
const firstInputRef = useRef<HTMLInputElement>(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({
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
{showModelInput && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="provider-picker-model-input"
|
||||
className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5 block"
|
||||
>
|
||||
Model{" "}
|
||||
<span aria-hidden="true" className="text-red-400">*</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
<input
|
||||
id="provider-picker-model-input"
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<datalist id="provider-picker-model-suggestions">
|
||||
{modelSuggestions?.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
<p className="text-[9px] text-zinc-500 mt-1 leading-relaxed">
|
||||
Slug determines provider routing at install time.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5">
|
||||
Provider
|
||||
</legend>
|
||||
{providers.map((p) => (
|
||||
<label
|
||||
key={p.id}
|
||||
className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
|
||||
selectedId === p.id
|
||||
? "bg-blue-600/15 border-blue-500/50"
|
||||
: "bg-zinc-800/40 border-zinc-700/50 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="provider"
|
||||
value={p.id}
|
||||
checked={selectedId === p.id}
|
||||
onChange={() => setSelectedId(p.id)}
|
||||
className="mt-0.5 accent-blue-500"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] text-zinc-100 font-medium">{p.label}</div>
|
||||
<div className="text-[10px] font-mono text-zinc-500">
|
||||
{p.envVars.join(", ")}
|
||||
</div>
|
||||
{p.note && (
|
||||
<div className="text-[10px] text-zinc-500 mt-1 leading-relaxed">
|
||||
{p.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
{/* 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). */}
|
||||
<ProviderModelSelector
|
||||
models={selectorModels}
|
||||
value={selectorValue}
|
||||
onChange={setSelectorValue}
|
||||
variant="stack"
|
||||
idPrefix="provider-picker"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{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"
|
||||
|
||||
509
canvas/src/components/ProviderModelSelector.tsx
Normal file
509
canvas/src/components/ProviderModelSelector.tsx
Normal file
@ -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 <option value>. `${vendor}|${sortedEnv}`. */
|
||||
id: string;
|
||||
/** Inferred vendor key (e.g. "minimax", "anthropic-oauth"). */
|
||||
vendor: string;
|
||||
/** Human label shown in the dropdown. */
|
||||
label: string;
|
||||
/** Env vars required by every model in this provider. */
|
||||
envVars: string[];
|
||||
/** Models bucketed under this provider. */
|
||||
models: SelectorModel[];
|
||||
/** True when ANY model id contains "*" — UI shows free-text model input. */
|
||||
wildcard: boolean;
|
||||
/** Optional tooltip text (rendered as native title=). */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export interface SelectorValue {
|
||||
/** ProviderEntry.id of the selected provider. Empty string = nothing
|
||||
* picked yet (parent should treat as invalid for save). */
|
||||
providerId: string;
|
||||
/** Selected model slug. For wildcard providers this is whatever the
|
||||
* user typed in the free-text input. */
|
||||
model: string;
|
||||
/** Snapshot of envVars from the selected provider. Re-emitted on every
|
||||
* change so consumers can re-render credential fields without
|
||||
* re-inferring from the model. */
|
||||
envVars: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
models: SelectorModel[];
|
||||
value: SelectorValue;
|
||||
onChange: (next: SelectorValue) => void;
|
||||
/** Display variant. "grid" = label+control side-by-side (used in ConfigTab
|
||||
* Runtime section). "stack" = vertical (used in MissingKeysModal). */
|
||||
variant?: "grid" | "stack";
|
||||
/** When true, parent caller is opting in to power-user free-text. Adds a
|
||||
* "Custom (type model id)..." escape-hatch entry as a model option even
|
||||
* when the chosen provider isn't wildcard. ConfigTab uses this; the
|
||||
* deploy modal does not. */
|
||||
allowCustomModelEscape?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Optional id-prefix for label↔control wiring (WCAG 1.3.1). Default
|
||||
* uses useId(). */
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Vendor detection — id-prefix heuristic + bare-name patterns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Vendor keys → human label. Add new vendors here when templates pick
|
||||
* up new model families. */
|
||||
const VENDOR_LABELS: Record<string, string> = {
|
||||
"anthropic-oauth": "Claude Code subscription",
|
||||
anthropic: "Anthropic API",
|
||||
minimax: "MiniMax",
|
||||
zai: "Z.ai (GLM)",
|
||||
moonshot: "Moonshot (Kimi)",
|
||||
deepseek: "DeepSeek",
|
||||
"xiaomi-mimo": "Xiaomi MiMo",
|
||||
openai: "OpenAI",
|
||||
google: "Google Gemini",
|
||||
alibaba: "Alibaba Qwen (DashScope)",
|
||||
nousresearch: "Nous Research (Hermes)",
|
||||
openrouter: "OpenRouter (any model)",
|
||||
huggingface: "Hugging Face Inference",
|
||||
"ai-gateway": "Vercel AI Gateway",
|
||||
"opencode-zen": "OpenCode Zen",
|
||||
"opencode-go": "OpenCode Go",
|
||||
kilocode: "Kilo Code",
|
||||
"kimi-coding": "Moonshot Kimi (coding-tuned)",
|
||||
"minimax-cn": "MiniMax China",
|
||||
"ollama-cloud": "Ollama Cloud",
|
||||
ollama: "Ollama (self-hosted)",
|
||||
nvidia: "NVIDIA NIM",
|
||||
arcee: "Arcee",
|
||||
xiaomi: "Xiaomi MiMo",
|
||||
gemini: "Google Gemini",
|
||||
custom: "Custom OpenAI-compat endpoint",
|
||||
};
|
||||
|
||||
/** Optional per-vendor tooltip shown on hover. */
|
||||
const VENDOR_TOOLTIPS: Record<string, string> = {
|
||||
"anthropic-oauth":
|
||||
"Use your Claude.ai (Pro/Max/Team) subscription via OAuth. Run `claude login` in the workspace terminal to mint the token, then paste it here. No API spend.",
|
||||
anthropic:
|
||||
"Pay-per-token via the Anthropic API (Console). Provide an API key starting with sk-ant-…",
|
||||
minimax:
|
||||
"MiniMax models served through their Anthropic-API-compatible endpoint. Get a key at platform.minimax.io.",
|
||||
zai:
|
||||
"Zhipu AI / z.ai GLM models through the Anthropic-compatible gateway. Get a key at docs.z.ai.",
|
||||
moonshot:
|
||||
"Moonshot Kimi K2-series via Anthropic-API-compatible endpoint. Get a key at platform.kimi.ai.",
|
||||
deepseek:
|
||||
"DeepSeek V4 via Anthropic-API-compatible endpoint. Get a key at api-docs.deepseek.com.",
|
||||
openrouter:
|
||||
"OpenRouter routes to 200+ models behind one API. Use any openrouter/<model> id. Get a key at openrouter.ai.",
|
||||
huggingface:
|
||||
"Any model hosted on Hugging Face Inference. Type the full model id (e.g. mistralai/Mistral-7B-Instruct-v0.3).",
|
||||
custom:
|
||||
"Self-hosted OpenAI-compatible endpoint (LM Studio, Ollama local, vLLM, llama.cpp). Configure base_url in the workspace's runtime config. No API key required.",
|
||||
};
|
||||
|
||||
/** Sentinel value used in the model <select> for the free-text escape hatch
|
||||
* added by `allowCustomModelEscape`. The component swaps to a text input
|
||||
* when this is selected. */
|
||||
const CUSTOM_MODEL_SENTINEL = "__custom__";
|
||||
|
||||
/** Bare-id vendor patterns (no slash separator). Order matters — first
|
||||
* match wins. */
|
||||
const BARE_VENDOR_PATTERNS: Array<{ test: (id: string) => boolean; vendor: string }> = [
|
||||
{ test: (id) => /^minimax-/i.test(id) || /^MiniMax-/.test(id), vendor: "minimax" },
|
||||
{ test: (id) => /^GLM-/i.test(id), vendor: "zai" },
|
||||
{ test: (id) => /^kimi-/i.test(id), vendor: "moonshot" },
|
||||
{ test: (id) => /^deepseek-/i.test(id), vendor: "deepseek" },
|
||||
{ test: (id) => /^mimo-/i.test(id), vendor: "xiaomi-mimo" },
|
||||
{ test: (id) => /^claude-/i.test(id), vendor: "anthropic" },
|
||||
{ test: (id) => /^gpt-/i.test(id), vendor: "openai" },
|
||||
{ test: (id) => /^gemini-/i.test(id), vendor: "google" },
|
||||
{ test: (id) => /^qwen-/i.test(id), vendor: "alibaba" },
|
||||
// Claude-Code OAuth aliases — bare "sonnet"/"opus"/"haiku" + CLAUDE_CODE_OAUTH_TOKEN
|
||||
// is the strongest signal that this is a subscription model. We also
|
||||
// gate on env in inferVendor() below to avoid mis-tagging non-OAuth
|
||||
// models that happen to be named "sonnet".
|
||||
{ test: (id) => /^(sonnet|opus|haiku)$/i.test(id), vendor: "anthropic-oauth" },
|
||||
];
|
||||
|
||||
/** Infer a vendor key from a model spec. Combines id-prefix and env
|
||||
* signals. Exported for tests. */
|
||||
export function inferVendor(model: SelectorModel): string {
|
||||
const id = model.id || "";
|
||||
const envSet = new Set(model.required_env ?? []);
|
||||
|
||||
// 1. Explicit slash-separated prefix wins (e.g. nousresearch/hermes-4-70b).
|
||||
const slashIdx = id.indexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
return id.slice(0, slashIdx).toLowerCase();
|
||||
}
|
||||
|
||||
// 2. Bare-id pattern. Special-case the OAuth aliases — they only count
|
||||
// when the env actually demands the OAuth token. Otherwise (e.g.
|
||||
// a hypothetical "sonnet" alias against ANTHROPIC_API_KEY) fall
|
||||
// through and let the env-based fallback bucket it under
|
||||
// "anthropic".
|
||||
for (const p of BARE_VENDOR_PATTERNS) {
|
||||
if (!p.test(id)) continue;
|
||||
if (p.vendor === "anthropic-oauth" && !envSet.has("CLAUDE_CODE_OAUTH_TOKEN")) {
|
||||
continue;
|
||||
}
|
||||
return p.vendor;
|
||||
}
|
||||
|
||||
// 3. Env-tuple fallback. Pick the first env's "namespace" as the
|
||||
// vendor — e.g. OPENROUTER_API_KEY → "openrouter".
|
||||
const env = model.required_env?.[0];
|
||||
if (env) {
|
||||
const ns = env.replace(/_API_KEY$|_TOKEN$|_KEY$/i, "").toLowerCase();
|
||||
return ns || "unknown";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Build the provider catalog from the template's models[]. Models are
|
||||
* bucketed by `(vendor, sortedEnv)` so two distinct env-tuples for the
|
||||
* same vendor (rare but possible) become two separate entries. */
|
||||
export function buildProviderCatalog(models: SelectorModel[]): ProviderEntry[] {
|
||||
const buckets = new Map<string, ProviderEntry>();
|
||||
|
||||
for (const m of models) {
|
||||
const envs = m.required_env ?? [];
|
||||
const sortedEnv = [...envs].sort().join("|");
|
||||
const vendor = inferVendor(m);
|
||||
const id = `${vendor}|${sortedEnv}`;
|
||||
const wildcard = m.id.includes("*");
|
||||
|
||||
let entry = buckets.get(id);
|
||||
if (!entry) {
|
||||
const baseLabel = VENDOR_LABELS[vendor] ?? vendor;
|
||||
entry = {
|
||||
id,
|
||||
vendor,
|
||||
label: baseLabel,
|
||||
envVars: envs,
|
||||
models: [],
|
||||
wildcard,
|
||||
tooltip: VENDOR_TOOLTIPS[vendor],
|
||||
};
|
||||
buckets.set(id, entry);
|
||||
}
|
||||
entry.models.push(m);
|
||||
// Wildcard sticks if any model in the bucket is a wildcard — same
|
||||
// bucket can't mix wildcard and concrete because they'd typically
|
||||
// share required_env but rarely the same vendor. Defensive OR.
|
||||
entry.wildcard = entry.wildcard || wildcard;
|
||||
}
|
||||
|
||||
// Decorate label with model-count when ≥2 concrete models share the
|
||||
// bucket. Helps the user understand "Anthropic API (5 models)" vs
|
||||
// "MiniMax (3 models)".
|
||||
for (const e of buckets.values()) {
|
||||
if (!e.wildcard && e.models.length > 1) {
|
||||
e.label = `${e.label} (${e.models.length} models)`;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(buckets.values());
|
||||
}
|
||||
|
||||
/** Find the provider entry that contains a given model id. Used by
|
||||
* callers to back-derive the provider when only the model is known
|
||||
* (e.g. ConfigTab loading from saved state). */
|
||||
export function findProviderForModel(
|
||||
catalog: ProviderEntry[],
|
||||
modelId: string,
|
||||
): ProviderEntry | null {
|
||||
if (!modelId) return null;
|
||||
for (const p of catalog) {
|
||||
if (p.models.some((m) => m.id === modelId)) return p;
|
||||
// Wildcard match — entry has model id ending in "*" and the typed
|
||||
// id starts with the wildcard's prefix (e.g. "openrouter/anthropic/
|
||||
// claude-3.5-sonnet" matches the "openrouter/*" bucket).
|
||||
if (p.wildcard) {
|
||||
for (const m of p.models) {
|
||||
if (!m.id.endsWith("*")) continue;
|
||||
const prefix = m.id.slice(0, -1);
|
||||
if (modelId.startsWith(prefix)) return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Component
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function ProviderModelSelector({
|
||||
models,
|
||||
value,
|
||||
onChange,
|
||||
variant = "stack",
|
||||
allowCustomModelEscape = false,
|
||||
disabled = false,
|
||||
idPrefix,
|
||||
}: Props) {
|
||||
const generatedId = useId();
|
||||
const baseId = idPrefix ?? generatedId;
|
||||
const providerSelectId = `${baseId}-provider`;
|
||||
const modelSelectId = `${baseId}-model`;
|
||||
|
||||
const catalog = useMemo(() => buildProviderCatalog(models), [models]);
|
||||
const selected = useMemo(
|
||||
() => catalog.find((p) => p.id === value.providerId) ?? null,
|
||||
[catalog, value.providerId],
|
||||
);
|
||||
|
||||
// True when the user picked the "Custom (type model id)..." escape entry
|
||||
// in the model dropdown — switches to free-text. Wildcard providers
|
||||
// ALWAYS use free-text, so this flag is for the escape hatch on
|
||||
// non-wildcard providers.
|
||||
const userPickedCustom = value.model === CUSTOM_MODEL_SENTINEL || (
|
||||
!!selected &&
|
||||
!selected.wildcard &&
|
||||
!!value.model &&
|
||||
!selected.models.some((m) => m.id === value.model)
|
||||
);
|
||||
const useTextInput = (selected?.wildcard ?? false) || userPickedCustom;
|
||||
|
||||
const handleProviderChange = (nextProviderId: string) => {
|
||||
const next = catalog.find((p) => p.id === nextProviderId) ?? null;
|
||||
if (!next) {
|
||||
onChange({ providerId: "", model: "", envVars: [] });
|
||||
return;
|
||||
}
|
||||
// When switching providers, default the model to the first concrete
|
||||
// entry in that provider (or empty if wildcard). Avoids showing a
|
||||
// stale model id from the previous provider.
|
||||
const defaultModel = next.wildcard
|
||||
? ""
|
||||
: next.models[0]?.id ?? "";
|
||||
onChange({
|
||||
providerId: next.id,
|
||||
model: defaultModel,
|
||||
envVars: next.envVars,
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelChange = (nextModel: string) => {
|
||||
if (!selected) {
|
||||
onChange({ ...value, model: nextModel });
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
providerId: selected.id,
|
||||
model: nextModel,
|
||||
envVars: selected.envVars,
|
||||
});
|
||||
};
|
||||
|
||||
const containerClass = variant === "grid" ? "grid grid-cols-2 gap-3" : "space-y-3";
|
||||
|
||||
return (
|
||||
<div className={containerClass} data-testid="provider-model-selector">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={providerSelectId}
|
||||
className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5 block"
|
||||
>
|
||||
Provider <span aria-hidden="true" className="text-red-400">*</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
<select
|
||||
id={providerSelectId}
|
||||
value={value.providerId}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
disabled={disabled || catalog.length === 0}
|
||||
aria-describedby={selected?.tooltip ? `${providerSelectId}-help` : undefined}
|
||||
data-testid="provider-select"
|
||||
className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>
|
||||
— select provider —
|
||||
</option>
|
||||
{catalog.map((p) => (
|
||||
<option key={p.id} value={p.id} title={p.tooltip}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selected?.tooltip && (
|
||||
<p
|
||||
id={`${providerSelectId}-help`}
|
||||
className="text-[9px] text-zinc-500 mt-1 leading-relaxed"
|
||||
>
|
||||
{selected.tooltip}
|
||||
</p>
|
||||
)}
|
||||
{selected && selected.envVars.length > 0 && (
|
||||
<p className="text-[9px] text-zinc-600 mt-0.5 font-mono">
|
||||
requires: {selected.envVars.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={modelSelectId}
|
||||
className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5 block"
|
||||
>
|
||||
Model <span aria-hidden="true" className="text-red-400">*</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
{useTextInput ? (
|
||||
<>
|
||||
<input
|
||||
id={modelSelectId}
|
||||
type="text"
|
||||
value={
|
||||
value.model === CUSTOM_MODEL_SENTINEL ? "" : value.model
|
||||
}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-[9px] text-zinc-500 mt-1 leading-relaxed">
|
||||
{selected?.wildcard
|
||||
? wildcardHelpText(selected)
|
||||
: "Free-text model id. Make sure the provider can resolve it."}
|
||||
</p>
|
||||
{!selected?.wildcard && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Switch back to dropdown by setting model to first
|
||||
// concrete option.
|
||||
if (selected) {
|
||||
handleModelChange(selected.models[0]?.id ?? "");
|
||||
}
|
||||
}}
|
||||
className="text-[9px] text-blue-400 hover:text-blue-300 mt-0.5"
|
||||
>
|
||||
← back to model list
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
id={modelSelectId}
|
||||
value={
|
||||
value.model && selected?.models.some((m) => m.id === value.model)
|
||||
? value.model
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM_MODEL_SENTINEL) {
|
||||
handleModelChange(CUSTOM_MODEL_SENTINEL);
|
||||
} else {
|
||||
handleModelChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || !selected || selected.models.length === 0}
|
||||
data-testid="model-select"
|
||||
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"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{selected ? "— select model —" : "— select provider first —"}
|
||||
</option>
|
||||
{selected?.models
|
||||
.filter((m) => !m.id.includes("*"))
|
||||
.map((m) => (
|
||||
<option
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
title={m.name ?? m.id}
|
||||
>
|
||||
{m.name ?? m.id}
|
||||
</option>
|
||||
))}
|
||||
{allowCustomModelEscape && selected && (
|
||||
<option value={CUSTOM_MODEL_SENTINEL}>
|
||||
Custom (type model id)…
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}<model-id>`;
|
||||
}
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
<MissingKeysModal
|
||||
open
|
||||
@ -138,28 +135,22 @@ describe("ProviderPickerModal — model→provider cascade", () => {
|
||||
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(
|
||||
<MissingKeysModal
|
||||
open
|
||||
@ -173,60 +164,21 @@ describe("ProviderPickerModal — model→provider cascade", () => {
|
||||
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(
|
||||
<MissingKeysModal
|
||||
open
|
||||
missingKeys={["ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY"]}
|
||||
providers={HERMES_PROVIDERS}
|
||||
runtime="hermes"
|
||||
modelSuggestions={HERMES_MODELS.map((m) => 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(
|
||||
<MissingKeysModal
|
||||
open
|
||||
@ -235,35 +187,33 @@ describe("ProviderPickerModal — model→provider cascade", () => {
|
||||
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(
|
||||
<MissingKeysModal
|
||||
open
|
||||
missingKeys={["ANTHROPIC_API_KEY", "MINIMAX_API_KEY", "OPENROUTER_API_KEY"]}
|
||||
providers={HERMES_PROVIDERS}
|
||||
runtime="hermes"
|
||||
// User has Anthropic globally. Without the cascade, radio
|
||||
// would snap to Anthropic. WITH the cascade, the typed
|
||||
// User has Anthropic globally. Without back-derivation,
|
||||
// selector would land on Anthropic. WITH it, the typed
|
||||
// MiniMax model wins.
|
||||
configuredKeys={new Set(["ANTHROPIC_API_KEY"])}
|
||||
modelSuggestions={HERMES_MODELS.map((m) => 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"));
|
||||
});
|
||||
});
|
||||
|
||||
269
canvas/src/components/__tests__/ProviderModelSelector.test.tsx
Normal file
269
canvas/src/components/__tests__/ProviderModelSelector.test.tsx
Normal file
@ -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(
|
||||
<ProviderModelSelector
|
||||
models={overrides?.models ?? CLAUDE_CODE_MODELS}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
describe("<ProviderModelSelector>", () => {
|
||||
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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 = <K extends keyof ConfigData>(key: K, value: ConfigData[K]) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
@ -551,125 +599,148 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
</Section>
|
||||
|
||||
<Section title="Runtime">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor={runtimeId} className="text-[10px] text-zinc-500 block mb-1">Runtime</label>
|
||||
<select
|
||||
id={runtimeId}
|
||||
value={config.runtime || ""}
|
||||
onChange={(e) => update("runtime", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{runtimeOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">
|
||||
Model
|
||||
{availableModels.length > 0 && (
|
||||
<span className="ml-1 text-zinc-600">({availableModels.length} suggested)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
list={availableModels.length > 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 && (
|
||||
<datalist id={`${runtimeId}-models`}>
|
||||
{availableModels.map((m, i) => (
|
||||
<option key={`${m.id}-${i}`} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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"). */}
|
||||
<div>
|
||||
<label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-zinc-500 block mb-1">
|
||||
Provider
|
||||
<span className="ml-1 text-zinc-600">
|
||||
(override — leave empty to auto-derive from model slug)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`${runtimeId}-provider`}
|
||||
type="text"
|
||||
list={providerSuggestions.length > 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 && (
|
||||
<datalist id={`${runtimeId}-providers`}>
|
||||
{providerSuggestions.map((p) => (
|
||||
<option key={p} value={p} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
{provider && provider !== originalProvider && (
|
||||
<p className="text-[10px] text-amber-500 mt-1">
|
||||
Provider change → workspace will auto-restart on Save.
|
||||
</p>
|
||||
)}
|
||||
<label htmlFor={runtimeId} className="text-[10px] text-zinc-500 block mb-1">Runtime</label>
|
||||
<select
|
||||
id={runtimeId}
|
||||
value={config.runtime || ""}
|
||||
onChange={(e) => update("runtime", e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{runtimeOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* 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 ? (
|
||||
<ProviderModelSelector
|
||||
models={availableModels}
|
||||
value={selectorValue}
|
||||
onChange={(next) => {
|
||||
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.
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-zinc-500 block mb-1">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentModelId}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-zinc-500 block mb-1">
|
||||
Provider
|
||||
<span className="ml-1 text-zinc-600">
|
||||
(override — leave empty to auto-derive from model slug)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id={`${runtimeId}-provider`}
|
||||
type="text"
|
||||
list={
|
||||
providerSuggestionsList.length > 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 && (
|
||||
<datalist id={`${runtimeId}-providers`}>
|
||||
{providerSuggestionsList.map((p) => (
|
||||
<option key={p} value={p} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{provider && provider !== originalProvider && (
|
||||
<p className="text-[10px] text-amber-500 mt-1">
|
||||
Provider change → workspace will auto-restart on Save.
|
||||
</p>
|
||||
)}
|
||||
<TagList
|
||||
label={
|
||||
currentModelSpec?.required_env?.length &&
|
||||
|
||||
@ -215,12 +215,27 @@ describe("ConfigTab — Save persists model under runtime_config.model (2026-04-
|
||||
).toBe("hermes"),
|
||||
);
|
||||
|
||||
// The model input is a free-text input wired to a datalist of suggestions.
|
||||
const modelInput = (await waitFor(() =>
|
||||
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" },
|
||||
});
|
||||
|
||||
|
||||
@ -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(<ConfigTab workspaceId="ws-test" />);
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user