diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 618dbbafa..a07f937dc 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -5,6 +5,13 @@ import * as Dialog from "@radix-ui/react-dialog"; import { api } from "@/lib/api"; import { isSaaSTenant } from "@/lib/tenant"; import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal"; +import { + ProviderModelSelector, + buildProviderCatalog, + findProviderForModel, + type SelectorModel, + type SelectorValue, +} from "./ProviderModelSelector"; interface WorkspaceOption { id: string; @@ -22,6 +29,8 @@ interface TemplateSpec { id: string; name?: string; runtime?: string; + model?: string; + models?: SelectorModel[]; providers?: string[]; } @@ -33,50 +42,12 @@ interface HermesProvider { models: string[]; } -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_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_HEADLESS_INSTANCE_TYPE = "t3.medium"; const DEFAULT_HEADLESS_ROOT_GB = 30; @@ -149,9 +120,11 @@ 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 [llmSelection, setLLMSelection] = useState({ + providerId: "platform|", + model: "moonshot/kimi-k2.6", + envVars: [], + }); const [llmSecret, setLLMSecret] = useState(""); // Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access @@ -208,32 +181,23 @@ 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, - // name, or runtime so any of those work. Lower-cased compare keeps - // "Hermes" / "hermes" / "HERMES" interchangeable. + // The user picks a runtime/template preset from a dropdown; the value + // remains the template id because that is the backend create contract. const selectedTemplateSpec = useMemo(() => { - const t = template.trim().toLowerCase(); - if (!t) return null; - return ( - templateSpecs.find( - (s) => - (s.id || "").toLowerCase() === t || - (s.name || "").toLowerCase() === t || - (s.runtime || "").toLowerCase() === t, - ) ?? null - ); + if (!template) return null; + return templateSpecs.find((s) => s.id === template) ?? null; }, [template, templateSpecs]); + const isHermes = (selectedTemplateSpec?.runtime ?? "").trim().toLowerCase() === "hermes"; + const llmModels = useMemo( + () => selectedTemplateSpec?.models?.length ? selectedTemplateSpec.models : DEFAULT_LLM_MODELS, + [selectedTemplateSpec], + ); + const llmCatalog = useMemo(() => buildProviderCatalog(llmModels), [llmModels]); + const selectedLLMProvider = useMemo( + () => llmCatalog.find((p) => p.id === llmSelection.providerId) ?? llmCatalog[0], + [llmCatalog, llmSelection.providerId], + ); // Filter HERMES_PROVIDERS by what the template declares it supports. // Empty/missing declared list → fall back to the full catalog so @@ -265,20 +229,21 @@ export function CreateWorkspaceButton() { }, [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]); + if (isHermes || llmCatalog.length === 0) return; + const templateDefault = selectedTemplateSpec?.model?.trim(); + const matched = templateDefault ? findProviderForModel(llmCatalog, templateDefault) : null; + const next = matched ?? llmCatalog[0]; + setLLMSelection({ + providerId: next.id, + model: matched && templateDefault + ? templateDefault + : next.wildcard + ? "" + : next.models[0]?.id ?? "", + envVars: next.envVars, + }); + setLLMSecret(""); + }, [isHermes, llmCatalog, selectedTemplateSpec?.model]); // Auto-fill hermesModel with the provider's defaultModel whenever the // provider changes, but only if the user hasn't already typed their own @@ -314,9 +279,7 @@ export function CreateWorkspaceButton() { setExternalRuntime("external"); setHermesApiKey(""); setHermesModel(""); - setLLMAuthMode("platform"); - setLLMProvider("minimax"); - setLLMModel("MiniMax-M2.7"); + setLLMSelection({ providerId: "platform|", model: "moonshot/kimi-k2.6", envVars: [] }); setLLMSecret(""); api .get("/workspaces") @@ -344,12 +307,12 @@ export function CreateWorkspaceButton() { setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix"); return; } - if (!isExternal && !isHermes && !llmModel.trim()) { + if (!isExternal && !isHermes && !llmSelection.model.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"); + if (!isExternal && !isHermes && selectedLLMProvider?.envVars.length && !llmSecret.trim()) { + setError("Provider credential is required"); return; } setCreating(true); @@ -358,7 +321,7 @@ export function CreateWorkspaceButton() { const provider = isHermes ? HERMES_PROVIDERS.find((p) => p.id === hermesProvider) : undefined; - const nativeProvider = !isHermes ? selectedNativeProvider : undefined; + const nativeProvider = !isHermes ? selectedLLMProvider : undefined; try { const parsedBudget = budgetLimit.trim() @@ -384,10 +347,10 @@ export function CreateWorkspaceButton() { budget_limit: parsedBudget, ...(!isExternal && !isHermes && nativeProvider ? { - model: llmModel.trim(), - llm_provider: nativeProvider.id, - ...(llmAuthMode !== "platform" && nativeProvider.envVar - ? { secrets: { [nativeProvider.envVar]: llmSecret.trim() } } + model: llmSelection.model.trim(), + llm_provider: nativeProvider.vendor, + ...(nativeProvider.envVars.length > 0 + ? { secrets: { [nativeProvider.envVars[0]]: llmSecret.trim() } } : {}), } : {}), @@ -533,77 +496,46 @@ export function CreateWorkspaceButton() { )} {!isExternal && ( - +
+ + +
)} - {!isExternal && !isHermes && selectedNativeProvider && ( + {!isExternal && !isHermes && selectedLLMProvider && (
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" && ( + { + setLLMSelection(next); + setLLMSecret(""); + }} + idPrefix="create-workspace-llm" + variant="stack" + /> + {selectedLLMProvider.envVars.length > 0 && (
{ vi.clearAllMocks(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - mockGet.mockResolvedValue(SAMPLE_WORKSPACES as any); + mockGet.mockImplementation(async (url: string) => { + if (url === "/templates") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return SAMPLE_TEMPLATES as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return SAMPLE_WORKSPACES as any; + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any mockPost.mockResolvedValue({} as any); }); @@ -42,7 +66,7 @@ async function openDialog() { async function setTemplate(value: string) { fireEvent.change( - screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"), + screen.getByLabelText("Runtime"), { target: { value } } ); } @@ -139,8 +163,8 @@ describe("CreateWorkspaceDialog", () => { volume: { root_gb: 30 }, display: { mode: "none" }, }); - expect(body.model).toBe("MiniMax-M2.7"); - expect(body.llm_provider).toBe("minimax"); + expect(body.model).toBe("moonshot/kimi-k2.6"); + expect(body.llm_provider).toBe("platform"); expect(body.secrets).toBeUndefined(); }); @@ -172,8 +196,8 @@ describe("CreateWorkspaceDialog", () => { await waitFor(() => expect(mockPost).toHaveBeenCalled()); const body = mockPost.mock.calls[0][1] as Record; - expect(body.model).toBe("MiniMax-M2.7"); - expect(body.llm_provider).toBe("minimax"); + expect(body.model).toBe("moonshot/kimi-k2.6"); + expect(body.llm_provider).toBe("platform"); expect(body.compute).toEqual({ instance_type: "t3.xlarge", volume: { root_gb: 80 }, @@ -191,8 +215,8 @@ describe("CreateWorkspaceDialog", () => { fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { target: { value: "BYOK Agent" }, }); - fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, { - target: { value: "api_key" }, + fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, { + target: { value: "minimax|MINIMAX_API_KEY" }, }); fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, { target: { value: "sk-minimax-test" }, @@ -213,8 +237,8 @@ describe("CreateWorkspaceDialog", () => { fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { target: { value: "OAuth Agent" }, }); - fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, { - target: { value: "oauth" }, + fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, { + target: { value: "anthropic-oauth|CLAUDE_CODE_OAUTH_TOKEN" }, }); fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, { target: { value: "oauth-token" }, @@ -262,9 +286,9 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { ); }); - it("shows hermes provider section for template 'HERMES' (case-insensitive)", async () => { + it("shows hermes provider section for the Hermes runtime preset", async () => { await openDialog(); - await setTemplate("HERMES"); + await setTemplate("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() );