From c2a5b625214a78cc278452d9025e7cc0d0d871ed Mon Sep 17 00:00:00 2001 From: claude-ceo-assistant Date: Sun, 24 May 2026 20:31:30 -0700 Subject: [PATCH] Wire native LLM auth selection --- .../src/components/CreateWorkspaceDialog.tsx | 176 +++++++++++++++++- .../__tests__/CreateWorkspaceDialog.test.tsx | 65 ++++++- .../__tests__/useTemplateDeploy.test.tsx | 92 ++++----- canvas/src/hooks/useTemplateDeploy.tsx | 34 +++- .../internal/handlers/workspace.go | 6 +- .../internal/handlers/workspace_provision.go | 37 +++- .../workspace_provision_shared_test.go | 75 +++++++- workspace-server/internal/models/workspace.go | 10 +- 8 files changed, 417 insertions(+), 78 deletions(-) diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index b62c03ae8..618dbbafa 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -33,7 +33,51 @@ interface HermesProvider { models: string[]; } -const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7"; +type LLMAuthMode = "platform" | "api_key" | "oauth"; + +interface NativeLLMProvider { + id: string; + label: string; + envVar?: string; + defaultModel: string; + models: string[]; + authModes: LLMAuthMode[]; +} + +export const NATIVE_LLM_PROVIDERS: NativeLLMProvider[] = [ + { + id: "minimax", + label: "MiniMax", + envVar: "MINIMAX_API_KEY", + defaultModel: "MiniMax-M2.7", + models: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"], + authModes: ["platform", "api_key"], + }, + { + id: "kimi-coding", + label: "Kimi", + envVar: "KIMI_API_KEY", + defaultModel: "kimi-for-coding", + models: ["kimi-for-coding", "kimi-k2.5", "kimi-k2"], + authModes: ["platform", "api_key"], + }, + { + id: "anthropic", + label: "Anthropic", + envVar: "ANTHROPIC_API_KEY", + defaultModel: "claude-sonnet-4-6", + models: ["claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5"], + authModes: ["platform", "api_key"], + }, + { + id: "anthropic-oauth", + label: "Claude OAuth", + envVar: "CLAUDE_CODE_OAUTH_TOKEN", + defaultModel: "sonnet", + models: ["sonnet", "opus", "haiku"], + authModes: ["oauth"], + }, +]; const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium"; const DEFAULT_HEADLESS_ROOT_GB = 30; const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge"; @@ -105,6 +149,10 @@ export function CreateWorkspaceButton() { // (Anthropic), which 401s if the user's key is for a different // provider. Hence: require model when template=hermes. const [hermesModel, setHermesModel] = useState(""); + const [llmAuthMode, setLLMAuthMode] = useState("platform"); + const [llmProvider, setLLMProvider] = useState("minimax"); + const [llmModel, setLLMModel] = useState("MiniMax-M2.7"); + const [llmSecret, setLLMSecret] = 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 @@ -161,6 +209,14 @@ export function CreateWorkspaceButton() { ); const isHermes = template.trim().toLowerCase() === "hermes"; + const nativeLLMProviders = useMemo( + () => NATIVE_LLM_PROVIDERS.filter((p) => p.authModes.includes(llmAuthMode)), + [llmAuthMode], + ); + const selectedNativeProvider = useMemo( + () => nativeLLMProviders.find((p) => p.id === llmProvider) ?? nativeLLMProviders[0], + [llmProvider, nativeLLMProviders], + ); // Resolve the selected template's spec from the /templates response. // The `template` input is free-text; templates can be matched by id, @@ -208,6 +264,22 @@ export function CreateWorkspaceButton() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [availableProviders, isHermes]); + useEffect(() => { + if (isHermes) return; + if (nativeLLMProviders.length === 0) return; + if (!nativeLLMProviders.some((p) => p.id === llmProvider)) { + setLLMProvider(nativeLLMProviders[0].id); + setLLMModel(nativeLLMProviders[0].defaultModel); + } + }, [isHermes, llmProvider, nativeLLMProviders]); + + useEffect(() => { + if (isHermes || !selectedNativeProvider) return; + if (!selectedNativeProvider.models.includes(llmModel)) { + setLLMModel(selectedNativeProvider.defaultModel); + } + }, [isHermes, llmModel, selectedNativeProvider]); + // 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. @@ -242,6 +314,10 @@ export function CreateWorkspaceButton() { setExternalRuntime("external"); setHermesApiKey(""); setHermesModel(""); + setLLMAuthMode("platform"); + setLLMProvider("minimax"); + setLLMModel("MiniMax-M2.7"); + setLLMSecret(""); api .get("/workspaces") .then((ws) => setWorkspaces(ws)) @@ -268,12 +344,21 @@ export function CreateWorkspaceButton() { setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix"); return; } + if (!isExternal && !isHermes && !llmModel.trim()) { + setError("Model is required"); + return; + } + if (!isExternal && !isHermes && llmAuthMode !== "platform" && !llmSecret.trim()) { + setError(llmAuthMode === "oauth" ? "Claude OAuth token is required" : "API key is required"); + return; + } setCreating(true); setError(null); const provider = isHermes ? HERMES_PROVIDERS.find((p) => p.id === hermesProvider) : undefined; + const nativeProvider = !isHermes ? selectedNativeProvider : undefined; try { const parsedBudget = budgetLimit.trim() @@ -297,7 +382,15 @@ export function CreateWorkspaceButton() { tier, parent_id: parentId || undefined, budget_limit: parsedBudget, - ...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}), + ...(!isExternal && !isHermes && nativeProvider + ? { + model: llmModel.trim(), + llm_provider: nativeProvider.id, + ...(llmAuthMode !== "platform" && nativeProvider.envVar + ? { secrets: { [nativeProvider.envVar]: llmSecret.trim() } } + : {}), + } + : {}), ...(!isExternal ? { compute: displayEnabled @@ -449,6 +542,82 @@ export function CreateWorkspaceButton() { /> )} + {!isExternal && !isHermes && selectedNativeProvider && ( +
+
+ LLM +
+
+ + +
+
+ + +
+
+ + setLLMModel(e.target.value)} + list="llm-model-suggestions" + spellCheck={false} + 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-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono" + /> + + {selectedNativeProvider.models.map((m) => +
+ {llmAuthMode !== "platform" && ( +
+ + setLLMSecret(e.target.value)} + 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-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono" + /> +
+ )} +
+ )} +
-