From e08ea7b5ba82322a9494b6a312e8828d61fbf67d Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Wed, 22 Apr 2026 18:59:49 -0700 Subject: [PATCH] fix(canvas): require hermes model at create + send to CP (fixes silent Anthropic 401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the hermes 401 "Invalid API key" on SaaS workspaces: 1. CreateWorkspaceDialog never sent `model` in the /workspaces POST 2. Tenant/CP plumbed through a valid (provider, API key) but empty MODEL 3. Workspace install.sh ran with HERMES_DEFAULT_MODEL unset 4. derive-provider.sh saw no slug → PROVIDER="auto" 5. Hermes fell back to its compiled-in default (Anthropic via OpenAI-compat adapter) 6. User's MINIMAX_API_KEY was present but irrelevant — hermes tried Anthropic with it → 401 Fix: - Extend HERMES_PROVIDERS with `defaultModel` + `models` (suggestion list). Each provider ships with a known-good default so the trap is physically impossible to hit with the new form. - Add a required Model input to the Hermes panel, auto-populated from the provider's defaultModel when the provider changes (only if the user hasn't typed their own slug yet). - Datalist surfaces additional model suggestions per provider so users can pick a different size (e.g. M2.7-highspeed) without typing the whole slug. - handleCreate validates hermesModel is non-empty, sends as `model` in the POST body alongside the secrets block. - useEffect guard avoids clobbering a user-typed custom slug when they toggle providers back and forth. Existing 19 a11y tests still pass (non-SaaS path unchanged, four-tier picker still renders, arrow-key nav still wraps). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/CreateWorkspaceDialog.tsx | 107 +++++++++++++++--- 1 file changed, 90 insertions(+), 17 deletions(-) diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index b6c71960..9471a2d8 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -15,25 +15,33 @@ interface HermesProvider { id: string; label: string; envVar: string; + defaultModel: string; + models: string[]; } -// All providers supported by Hermes runtime via providers.resolve_provider() +// 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" }, - { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" }, - { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" }, - { id: "xai", label: "xAI (Grok)", envVar: "XAI_API_KEY" }, - { id: "gemini", label: "Google Gemini", envVar: "GEMINI_API_KEY" }, - { id: "qwen", label: "Qwen (Alibaba)", envVar: "QWEN_API_KEY" }, - { id: "glm", label: "GLM (Zhipu AI)", envVar: "GLM_API_KEY" }, - { id: "kimi", label: "Kimi (Moonshot)", envVar: "KIMI_API_KEY" }, - { id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY" }, - { id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY" }, - { id: "groq", label: "Groq", envVar: "GROQ_API_KEY" }, - { id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY" }, - { id: "together", label: "Together AI", envVar: "TOGETHER_API_KEY" }, - { id: "fireworks", label: "Fireworks AI", envVar: "FIREWORKS_API_KEY" }, - { id: "hermes", label: "Hermes / Nous (legacy)", envVar: "HERMES_API_KEY" }, + { 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() { @@ -50,6 +58,14 @@ export function CreateWorkspaceButton() { // 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(""); // Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access // by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and @@ -100,6 +116,22 @@ export function CreateWorkspaceButton() { const isHermes = template.trim().toLowerCase() === "hermes"; + // Auto-fill hermesModel with the provider's defaultModel whenever the + // provider changes, but only if the user hasn't already typed their own + // slug. Prevents the empty-model → "auto" → Anthropic-default 401 trap. + useEffect(() => { + if (!isHermes) return; + const p = HERMES_PROVIDERS.find((x) => x.id === hermesProvider); + if (!p) return; + // Replace model only if current value matches another provider's + // default (user hasn't customized it) OR is empty. + const isUntouched = + hermesModel === "" || + HERMES_PROVIDERS.some((x) => x.defaultModel === hermesModel); + if (isUntouched) setHermesModel(p.defaultModel); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hermesProvider, isHermes]); + // Reset form and load workspaces whenever dialog opens useEffect(() => { if (!open) return; @@ -112,6 +144,7 @@ export function CreateWorkspaceButton() { setError(null); setHermesProvider("anthropic"); setHermesApiKey(""); + setHermesModel(""); api .get("/workspaces") .then((ws) => setWorkspaces(ws)) @@ -130,6 +163,10 @@ export function CreateWorkspaceButton() { setError("API key is required for Hermes workspaces"); return; } + if (isHermes && !hermesModel.trim()) { + setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix"); + return; + } setCreating(true); setError(null); @@ -151,7 +188,10 @@ export function CreateWorkspaceButton() { budget_limit: parsedBudget, canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 }, ...(isHermes && provider - ? { secrets: { [provider.envVar]: hermesApiKey.trim() } } + ? { + secrets: { [provider.envVar]: hermesApiKey.trim() }, + model: hermesModel.trim(), + } : {}), }); setOpen(false); @@ -340,6 +380,39 @@ export function CreateWorkspaceButton() { className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 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-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 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. +

+
)}