diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 11b2b405..a2c8dff1 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -12,6 +12,19 @@ interface WorkspaceOption { tier: number; } +// Subset of the /templates row used here. Mirrors the shape ConfigTab +// reads. `providers` is the per-template declarative list of supported +// LLM providers — sourced from the template's +// runtime_config.providers (config.yaml). When present, it filters +// the modal's provider . Empty/missing list falls back to the full HERMES_PROVIDERS + // catalog so older templates without the field keep working. + 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 @@ -130,6 +150,52 @@ export function CreateWorkspaceButton() { const isHermes = template.trim().toLowerCase() === "hermes"; + // 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. + 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 + ); + }, [template, templateSpecs]); + + // 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; + 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 — the + // operator can't pick a provider, the form locks. Component falls + // back to the full catalog so the user can still proceed. + it("hermes provider dropdown falls back to all providers when template declares only unknown providers", async () => { + mockGet.mockImplementation(async (url: string) => { + if (url === "/templates") { + return [ + { id: "hermes", name: "Hermes", runtime: "hermes", providers: ["totally-new-provider-2030"] }, + // 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; + }); + + await openDialog(); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement; + // Stays at full catalog length — no flapping to 0 then back. + expect(providerSelect.options.length).toBe(HERMES_PROVIDERS.length); + }); + it("hermes API key field is a password input (masked)", async () => { await openDialog(); await setTemplate("hermes");