From 5cadbdf9805353f3cef265d32f2fc4176fe3437a Mon Sep 17 00:00:00 2001 From: hongming Date: Tue, 26 May 2026 13:47:45 -0700 Subject: [PATCH] fix(canvas): derive create-dialog LLM catalog from templates --- .../src/components/CreateWorkspaceDialog.tsx | 293 ++---------------- .../__tests__/CreateWorkspaceDialog.test.tsx | 275 ++++++---------- 2 files changed, 123 insertions(+), 445 deletions(-) diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 69a5b6f94..5528d9750 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -34,22 +34,6 @@ interface TemplateSpec { providers?: string[]; } -interface HermesProvider { - id: string; - label: string; - envVar: string; - defaultModel: string; - models: string[]; -} - -const DEFAULT_LLM_MODELS: SelectorModel[] = [ - { id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] }, - { id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["MINIMAX_API_KEY"] }, - { id: "kimi-k2-turbo-preview", name: "Kimi K2 Turbo Preview", required_env: ["KIMI_API_KEY"] }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", required_env: ["ANTHROPIC_API_KEY"] }, - { id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, -]; -const DEFAULT_PLATFORM_MODEL = DEFAULT_LLM_MODELS[0]; const DEFAULT_RUNTIME = "claude-code"; const RUNTIME_OPTIONS = [ { value: "claude-code", label: "Claude Code" }, @@ -63,31 +47,6 @@ const DEFAULT_HEADLESS_ROOT_GB = 30; const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge"; const DEFAULT_DISPLAY_ROOT_GB = 80; -// All providers supported by Hermes runtime via providers.resolve_provider(). -// `defaultModel` is the slug injected into the workspace provision request -// when the user picks this provider — template-hermes's derive-provider.sh -// maps the prefix back to the provider name at install time, so this is -// the canonical handshake. `models` are additional suggestions surfaced in -// the datalist so the user can pick a different size without typing the -// whole slug. -export const HERMES_PROVIDERS: HermesProvider[] = [ - { id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY", defaultModel: "anthropic/claude-sonnet-4-5", models: ["anthropic/claude-opus-4-5", "anthropic/claude-sonnet-4-5", "anthropic/claude-haiku-4-5"] }, - { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY", defaultModel: "openai/gpt-4o", models: ["openai/gpt-4o", "openai/gpt-4o-mini", "openai/o3-mini"] }, - { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY", defaultModel: "openrouter/auto", models: ["openrouter/auto", "openrouter/anthropic/claude-sonnet-4", "openrouter/meta-llama/llama-3.3-70b"] }, - { id: "xai", label: "xAI (Grok)", envVar: "XAI_API_KEY", defaultModel: "xai/grok-4", models: ["xai/grok-4", "xai/grok-4-mini"] }, - { id: "gemini", label: "Google Gemini", envVar: "GEMINI_API_KEY", defaultModel: "gemini/gemini-2.5-pro", models: ["gemini/gemini-2.5-pro", "gemini/gemini-2.5-flash"] }, - { id: "qwen", label: "Qwen (Alibaba)", envVar: "QWEN_API_KEY", defaultModel: "alibaba/qwen3-max", models: ["alibaba/qwen3-max", "alibaba/qwen3-coder"] }, - { id: "glm", label: "GLM (Zhipu AI)", envVar: "GLM_API_KEY", defaultModel: "zai/glm-4.6", models: ["zai/glm-4.6", "zai/glm-4.5-air"] }, - { id: "kimi", label: "Kimi (Moonshot)", envVar: "KIMI_API_KEY", defaultModel: "kimi-coding/kimi-k2", models: ["kimi-coding/kimi-k2", "kimi-coding/kimi-k1.5"] }, - { id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY", defaultModel: "minimax/MiniMax-M2.7", models: ["minimax/MiniMax-M2.7", "minimax/MiniMax-M2.7-highspeed", "minimax/MiniMax-M1"] }, - { id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY", defaultModel: "deepseek/deepseek-chat", models: ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"] }, - { id: "groq", label: "Groq", envVar: "GROQ_API_KEY", defaultModel: "openrouter/groq/llama-3.3-70b", models: ["openrouter/groq/llama-3.3-70b"] }, - { id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY", defaultModel: "openrouter/mistralai/mistral-large", models: ["openrouter/mistralai/mistral-large"] }, - { id: "together", label: "Together AI", envVar: "TOGETHER_API_KEY", defaultModel: "openrouter/meta-llama/llama-3.3-70b", models: ["openrouter/meta-llama/llama-3.3-70b"] }, - { id: "fireworks", label: "Fireworks AI", envVar: "FIREWORKS_API_KEY", defaultModel: "openrouter/meta-llama/llama-3.3-70b", models: ["openrouter/meta-llama/llama-3.3-70b"] }, - { id: "hermes", label: "Hermes / Nous (legacy)", envVar: "HERMES_API_KEY", defaultModel: "nousresearch/Hermes-3-Llama-3.1-405B", models: ["nousresearch/Hermes-3-Llama-3.1-405B", "nousresearch/Hermes-4-14B"] }, -]; - export function CreateWorkspaceButton() { const [open, setOpen] = useState(false); const [name, setName] = useState(""); @@ -107,32 +66,20 @@ export function CreateWorkspaceButton() { // filter below. Same data source ConfigTab uses (PR #2454). When the // selected template declares `runtime_config.providers` in its // config.yaml, the modal surfaces only those providers in the - // . Provider/model options are derived from template models. const [templateSpecs, setTemplateSpecs] = useState([]); // External-runtime path: skip docker provision, mint a workspace_auth_token, // and surface the connection snippet in a modal after create. When - // isExternal is true the template / model / hermes-provider fields are - // hidden (they're meaningless for BYO-compute agents). + // isExternal is true the template and model fields are hidden (they're + // meaningless for BYO-compute agents). const [isExternal, setIsExternal] = useState(false); const [externalRuntime, setExternalRuntime] = useState("external"); const [externalConnection, setExternalConnection] = useState(null); - // Hermes-specific state - const [hermesProvider, setHermesProvider] = useState("anthropic"); - const [hermesApiKey, setHermesApiKey] = useState(""); - // Model slug is sent to CP as `model` and plumbed to the workspace EC2 - // as HERMES_DEFAULT_MODEL env var. template-hermes's derive-provider.sh - // reads the prefix (`minimax/…`, `anthropic/…`) to set - // HERMES_INFERENCE_PROVIDER at install time. Missing model → provider - // falls back to "auto" and hermes picks its compiled-in default - // (Anthropic), which 401s if the user's key is for a different - // provider. Hence: require model when template=hermes. - const [hermesModel, setHermesModel] = useState(""); const [llmSelection, setLLMSelection] = useState({ - providerId: "platform|", - model: "moonshot/kimi-k2.6", + providerId: "", + model: "", envVars: [], }); const [llmSecret, setLLMSecret] = useState(""); @@ -194,10 +141,7 @@ export function CreateWorkspaceButton() { const handleRuntimeChange = useCallback((nextRuntime: string) => { setRuntime(nextRuntime); setTemplate(""); - setHermesProvider("anthropic"); - setHermesApiKey(""); - setHermesModel(""); - setLLMSelection({ providerId: "platform|", model: DEFAULT_PLATFORM_MODEL.id, envVars: [] }); + setLLMSelection({ providerId: "", model: "", envVars: [] }); setLLMSecret(""); }, []); @@ -209,9 +153,12 @@ export function CreateWorkspaceButton() { return templateSpecs.find((s) => s.id === template) ?? null; }, [template, templateSpecs]); const selectedRuntimeTemplateSpec = useMemo(() => ( - templateSpecs.find((s) => s.id === runtime && BASE_RUNTIME_TEMPLATE_IDS.has(s.id)) ?? null + templateSpecs.find((s) => { + if (!BASE_RUNTIME_TEMPLATE_IDS.has(s.id)) return false; + const specRuntime = (s.runtime ?? s.id).trim().toLowerCase(); + return s.id === runtime || specRuntime === runtime; + }) ?? null ), [runtime, templateSpecs]); - const isHermes = runtime === "hermes"; const visibleTemplateSpecs = useMemo( () => templateSpecs.filter((spec) => { if (BASE_RUNTIME_TEMPLATE_IDS.has(spec.id)) return false; @@ -222,28 +169,11 @@ export function CreateWorkspaceButton() { ); const llmModels = useMemo( () => { - if (!selectedTemplateSpec?.models?.length) return DEFAULT_LLM_MODELS; - if (isHermes) { - return selectedTemplateSpec.models; - } - if (selectedTemplateSpec.models.some((model) => model.provider === "platform")) { - return selectedTemplateSpec.models; - } - const templateDefault = selectedTemplateSpec.model?.trim(); - const defaultModelSpec = templateDefault - ? selectedTemplateSpec.models.find((model) => model.id === templateDefault) - : undefined; - return [ - { - id: templateDefault || DEFAULT_PLATFORM_MODEL.id, - name: defaultModelSpec?.name ?? DEFAULT_PLATFORM_MODEL.name, - provider: "platform", - required_env: [], - }, - ...selectedTemplateSpec.models, - ]; + const sourceSpec = selectedTemplateSpec ?? selectedRuntimeTemplateSpec; + if (!sourceSpec?.models?.length) return []; + return sourceSpec.models; }, - [isHermes, selectedTemplateSpec], + [selectedRuntimeTemplateSpec, selectedTemplateSpec], ); const llmCatalog = useMemo(() => buildProviderCatalog(llmModels), [llmModels]); const selectedLLMProvider = useMemo( @@ -251,67 +181,22 @@ export function CreateWorkspaceButton() { [llmCatalog, llmSelection.providerId], ); - // Filter HERMES_PROVIDERS by what the template declares it supports. - // Empty/missing declared list → fall back to the full catalog so - // templates that haven't migrated to the explicit `providers:` field - // (and self-hosted setups without /templates) keep working unchanged. - const availableProviders = useMemo(() => { - const declared = selectedTemplateSpec?.providers ?? selectedRuntimeTemplateSpec?.providers; - if (!declared || declared.length === 0) return HERMES_PROVIDERS; - const allowed = new Set(declared.map((p) => p.toLowerCase())); - const filtered = HERMES_PROVIDERS.filter((p) => allowed.has(p.id.toLowerCase())); - // Defensive: if the template's declared list doesn't match anything - // in our static catalog (e.g. brand-new provider id we don't have - // metadata for yet), fall back to the full list rather than render - // an empty setHermesProvider(e.target.value)} - aria-label="Hermes provider" - className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors" - > - {availableProviders.map((p) => ( - - ))} - - - -
- - setHermesApiKey(e.target.value)} - placeholder="sk-…" - aria-label="Hermes API key" - autoComplete="off" - className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono" - /> -
- -
- - setHermesModel(e.target.value)} - placeholder="e.g. minimax/MiniMax-M2.7" - aria-label="Hermes model slug" - autoComplete="off" - spellCheck={false} - list="hermes-model-suggestions" - className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono" - /> - - {HERMES_PROVIDERS.find((p) => p.id === hermesProvider)?.models.map( - (m) => -

- Slug determines which provider hermes routes to at install time. -

-
- - )} - {error && (
({ api: { @@ -21,6 +21,22 @@ const SAMPLE_WORKSPACES = [ ]; const SAMPLE_TEMPLATES = [ + { + id: "claude-code-default", + name: "Claude Code Agent", + runtime: "claude-code", + model: "moonshot/kimi-k2.6", + providers: ["platform", "minimax", "kimi-coding", "anthropic", "anthropic-oauth"], + models: [ + { id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] }, + { id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["MINIMAX_API_KEY"] }, + { id: "kimi-k2-turbo-preview", name: "Kimi K2 Turbo Preview", required_env: ["KIMI_API_KEY"] }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", required_env: ["ANTHROPIC_API_KEY"] }, + { id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, + { id: "opus", name: "Claude Opus", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, + { id: "haiku", name: "Claude Haiku", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, + ], + }, { id: "seo-agent", name: "SEO Agent", @@ -33,9 +49,22 @@ const SAMPLE_TEMPLATES = [ { id: "kimi-k2-turbo-preview", name: "Kimi K2 Turbo Preview", required_env: ["KIMI_API_KEY"] }, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", required_env: ["ANTHROPIC_API_KEY"] }, { id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, + { id: "opus", name: "Claude Opus", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, + { id: "haiku", name: "Claude Haiku", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, + ], + }, + { + id: "hermes", + name: "Hermes", + runtime: "hermes", + model: "openai/gpt-4o", + providers: ["openai", "anthropic", "platform"], + models: [ + { id: "openai/gpt-4o", name: "GPT-4o", required_env: ["OPENAI_API_KEY"] }, + { id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5", required_env: ["ANTHROPIC_API_KEY"] }, + { id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] }, ], }, - { id: "hermes", name: "Hermes", runtime: "hermes" }, ]; beforeEach(() => { @@ -269,6 +298,9 @@ describe("CreateWorkspaceDialog", () => { fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, { target: { value: "anthropic-oauth|CLAUDE_CODE_OAUTH_TOKEN" }, }); + fireEvent.change(document.querySelector("[data-testid='model-select']") as HTMLSelectElement, { + target: { value: "sonnet" }, + }); fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, { target: { value: "oauth-token" }, }); @@ -283,6 +315,18 @@ describe("CreateWorkspaceDialog", () => { expect(body.secrets).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: "oauth-token" }); }); + it("lists all Claude Code subscription aliases for blank workspaces", async () => { + await openDialog(); + + fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, { + target: { value: "anthropic-oauth|CLAUDE_CODE_OAUTH_TOKEN" }, + }); + + const modelSelect = document.querySelector("[data-testid='model-select']") as HTMLSelectElement; + const optionValues = Array.from(modelSelect.options).map((option) => option.value); + expect(optionValues).toEqual(expect.arrayContaining(["sonnet", "opus", "haiku"])); + }); + it("renders gracefully when GET /workspaces fails", async () => { mockGet.mockRejectedValueOnce(new Error("Network error")); await openDialog(); @@ -297,226 +341,103 @@ describe("CreateWorkspaceDialog", () => { }); // --------------------------------------------------------------------------- -// Hermes provider picker tests +// Dynamic runtime provider picker tests // --------------------------------------------------------------------------- -describe("CreateWorkspaceDialog — Hermes provider picker", () => { - it("does NOT show hermes provider section for non-hermes templates", async () => { +describe("CreateWorkspaceDialog — dynamic runtime provider picker", () => { + it("does not render the old Hermes-only provider section", async () => { await openDialog(); - await setTemplate("seo-agent"); + await setRuntime("hermes"); expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull(); }); - it("shows hermes provider section when runtime is 'hermes'", async () => { + it("derives Hermes provider and model options from the /templates runtime row", async () => { await openDialog(); await setRuntime("hermes"); - await waitFor(() => - expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() - ); + + const providerSelect = document.querySelector("[data-testid='provider-select']") as HTMLSelectElement; + await waitFor(() => expect(providerSelect.options.length).toBe(4)); + + const providerValues = Array.from(providerSelect.options).map((option) => option.value); + expect(providerValues).toEqual(expect.arrayContaining([ + "platform|", + "openai|OPENAI_API_KEY", + "anthropic|ANTHROPIC_API_KEY", + ])); + expect(providerValues).not.toContain("gemini|GEMINI_API_KEY"); }); - it("shows hermes provider section for the Hermes runtime preset", async () => { + it("uses the template-declared default provider/model for Hermes", async () => { await openDialog(); await setRuntime("hermes"); - await waitFor(() => - expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() - ); + + await waitFor(() => { + const providerSelect = document.querySelector("[data-testid='provider-select']") as HTMLSelectElement; + expect(providerSelect.value).toBe("platform|"); + }); + const modelSelect = document.querySelector("[data-testid='model-select']") as HTMLSelectElement; + expect(modelSelect.value).toBe("moonshot/kimi-k2.6"); }); - it("hermes provider dropdown defaults to 'anthropic'", async () => { + it("prompts for the provider credential required by the selected Hermes model", async () => { await openDialog(); await setRuntime("hermes"); - await waitFor(() => - expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() - ); - const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement; - expect(providerSelect).toBeTruthy(); - expect(providerSelect.value).toBe("anthropic"); - }); - it("hermes provider dropdown lists all 15 providers", async () => { - await openDialog(); - await setRuntime("hermes"); - await waitFor(() => - expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() - ); - const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement; - expect(providerSelect.options.length).toBe(HERMES_PROVIDERS.length); - const ids = Array.from(providerSelect.options).map((o) => o.value); - expect(ids).toContain("anthropic"); - expect(ids).toContain("openai"); - expect(ids).toContain("gemini"); - expect(ids).toContain("deepseek"); - expect(ids).toContain("hermes"); - }); - - // Pins the dynamic-providers behavior: when the matched template's - // /templates row declares `providers`, the dropdown filters to that - // subset instead of showing the full HERMES_PROVIDERS catalog. Same - // data source ConfigTab uses (PR #2454) — keeps the modal and the - // settings tab honest about which providers a template supports. - it("hermes provider dropdown filters to template-declared providers when /templates ships them", async () => { - // Per-URL mock: /workspaces returns the existing fixture, /templates - // returns a hermes row that only allows anthropic + minimax + openai. - mockGet.mockImplementation(async (url: string) => { - if (url === "/templates") { - return [ - { id: "hermes", name: "Hermes", runtime: "hermes", providers: ["anthropic", "minimax", "openai"] }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ] as any; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return SAMPLE_WORKSPACES as any; + fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, { + target: { value: "openai|OPENAI_API_KEY" }, }); - await openDialog(); - await setRuntime("hermes"); - await waitFor(() => - expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() - ); - const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement; - // Filtered list arrives async after /templates fetch resolves — - // keep waiting until the dropdown shrinks below the full catalog. - await waitFor(() => expect(providerSelect.options.length).toBe(3)); - const ids = Array.from(providerSelect.options).map((o) => o.value); - expect(ids).toEqual(expect.arrayContaining(["anthropic", "minimax", "openai"])); - expect(ids).not.toContain("gemini"); - expect(ids).not.toContain("deepseek"); - }); - - // Back-compat: a template that hasn't migrated to runtime_config.providers - // (older templates, self-hosted setups without /templates server) keeps - // showing the full provider catalog. Operators picking from those - // templates can't be locked out of providers we know hermes supports. - it("hermes provider dropdown falls back to all providers when template declares no providers list", async () => { - mockGet.mockImplementation(async (url: string) => { - if (url === "/templates") { - // No `providers` field — empty/missing → fall back to full catalog. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return [{ id: "hermes", name: "Hermes", runtime: "hermes" }] as any; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return SAMPLE_WORKSPACES as any; - }); - - await openDialog(); - await setRuntime("hermes"); - await waitFor(() => - expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() - ); - const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement; - expect(providerSelect.options.length).toBe(HERMES_PROVIDERS.length); - }); - - // Defensive: a template's declared list with NO matches against our - // static catalog (e.g. a brand-new provider id we don't have label/ - // envVar metadata for yet) must not render an empty