From dc50a1c775be88cf3c708e5e9304bf1877b163d4 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Thu, 23 Apr 2026 17:07:15 -0700 Subject: [PATCH] refactor(canvas): data-drive provider picker from template config.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MissingKeysModal's provider list was hardcoded in deploy-preflight.ts as RUNTIME_PROVIDERS — a per-runtime map that duplicated what each template repo already declares in its config.yaml. That meant adding a new provider required changes in two places, and the UI could drift out of sync with the actual template (e.g. when a template adds a MiniMax or Kimi model, the picker wouldn't know). The single source of truth for "which env vars does this workspace need" is each template's config.yaml: * `runtime_config.models[].required_env` — per-model key list * `runtime_config.required_env` — runtime-level AND list Go /templates already returned `models`. This change: * Adds `required_env` alongside `models` on templateSummary so the canvas receives the full picture. * Rewrites deploy-preflight.ts to derive ProviderChoice[] from a template object via `providersFromTemplate(template)`: - groups `models[]` by unique required_env tuple - falls back to runtime_config.required_env when models is empty - decorates labels with model counts (e.g. "OpenRouter (14 models)") * `checkDeploySecrets(template, workspaceId?)` now takes a template object instead of a runtime string. Any-provider satisfaction still short-circuits preflight to ok=true. * MissingKeysModal receives `providers` directly; no more lookups. * TemplatePalette threads `template.models` + `template.required_env` into the preflight. Side effects: * Claude Code's dual-auth (OAuth token OR Anthropic API key) now surfaces as two picker options — its config.yaml already declared both, the UI just wasn't reading them. * Hermes picker now shows 8 provider options (Nous, OpenRouter, Anthropic, Gemini, DeepSeek, GLM, Kimi, Kilocode) instead of the hand-picked 3, matching its 35-model reality. Removed the legacy RUNTIME_PROVIDERS / RUNTIME_REQUIRED_KEYS / getRequiredKeys / findMissingKeys exports; MissingKeysModal.test.tsx deleted (its coverage is subsumed by the new template-driven deploy-preflight.test.ts). 58 modal-adjacent tests pass; full canvas suite 919 pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/MissingKeysModal.tsx | 321 ++++++++++------- canvas/src/components/TemplatePalette.tsx | 18 +- .../__tests__/MissingKeysModal.a11y.test.tsx | 6 +- .../MissingKeysModal.component.test.tsx | 8 +- .../__tests__/MissingKeysModal.test.tsx | 89 ----- .../lib/__tests__/deploy-preflight.test.ts | 292 +++++++++------- canvas/src/lib/deploy-preflight.ts | 326 ++++++++++-------- .../internal/handlers/templates.go | 16 +- 8 files changed, 560 insertions(+), 516 deletions(-) delete mode 100644 canvas/src/components/__tests__/MissingKeysModal.test.tsx diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index 701a451e..2c2a648e 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -2,29 +2,31 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { api } from "@/lib/api"; -import { - getKeyLabel, - getRuntimeProviders, - type ProviderChoice, -} from "@/lib/deploy-preflight"; +import { getKeyLabel, type ProviderChoice } from "@/lib/deploy-preflight"; interface Props { open: boolean; + /** Flat list of every candidate env var. Used as the fallback input + * set when `providers` is empty (or length 1). */ missingKeys: string[]; + /** Grouped provider options derived from the template's models[] / + * required_env. When length ≥ 2 the modal shows a radio picker. */ + providers?: ProviderChoice[]; + /** Runtime slug — used only for the "The runtime …" + * headline; behavior is driven by providers/missingKeys. */ runtime: string; - /** Called when user adds all required keys and wants to proceed with deploy. */ + /** Called when all required keys for the chosen provider are saved. */ onKeysAdded: () => void; - /** Called when user cancels the deploy. */ + /** Called when the user cancels the deploy. */ onCancel: () => void; - /** Called when user wants to open the Settings Panel (Config tab → Secrets). */ + /** Optional — open the Settings Panel (Config tab → Secrets). */ onOpenSettings?: () => void; - /** Optional workspace ID — if provided, secrets are saved at workspace scope. */ + /** If provided, secrets save at workspace scope instead of global. */ workspaceId?: string; } interface KeyEntry { key: string; - label: string; value: string; saved: boolean; saving: boolean; @@ -34,45 +36,38 @@ interface KeyEntry { /** * MissingKeysModal * ---------------- - * Two rendering modes, picked automatically from the runtime: + * Dispatches between two modes based on what the template declares: * - * 1. PROVIDER-PICKER mode — when `getRuntimeProviders(runtime)` returns - * ≥2 alternatives. The modal shows a radio list of supported - * providers first ("Hermes supports OpenRouter / OpenAI / Nous - * native — pick one") and only the chosen provider's env input - * below. Saving that one key satisfies the deploy. + * 1. PROVIDER PICKER — when the preflight returned ≥2 `providers` (e.g. + * a Hermes template whose models[].required_env enumerate OpenRouter, + * Anthropic, Nous-native, etc.). Radio list of options, saving the + * chosen option's env vars satisfies the deploy. * - * 2. LEGACY all-keys mode — when the runtime has <2 provider - * alternatives, or the caller supplied multiple unrelated keys. - * Renders one input per `missingKeys` entry; all must be saved - * before deploy. Preserves the pre-provider-picker contract so - * callers that pass unrelated-key lists (e.g. a workspace that - * needs an LLM key AND a separate tool key) keep working. + * 2. ALL-KEYS — every entry in `missingKeys` rendered as its own input, + * all must save before Deploy. Used when the template has a single + * provider option or no declared alternatives. + * + * The modal never hardcodes per-runtime provider lists; the upstream + * preflight derives that from the template config.yaml. */ export function MissingKeysModal({ open, missingKeys, + providers, runtime, onKeysAdded, onCancel, onOpenSettings, workspaceId, }: Props) { - const providers: ProviderChoice[] = useMemo( - () => getRuntimeProviders(runtime), - [runtime], - ); - - // Picker mode activates only when we have a real provider list with - // genuine alternatives. If the runtime is unknown (providers=[]) or - // has a single forced provider, fall back to the legacy all-keys UX. - const pickerMode = providers.length > 1; + const pickerProviders = providers ?? []; + const pickerMode = pickerProviders.length > 1; if (pickerMode) { return ( (null); + const [entries, setEntries] = useState([]); const firstInputRef = useRef(null); + const selected = useMemo( + () => providers.find((p) => p.id === selectedId) ?? providers[0], + [providers, selectedId], + ); + useEffect(() => { if (!open) return; setSelectedId(providers[0].id); - setValue(""); - setSaving(false); - setSaved(false); - setError(null); }, [open, providers]); + useEffect(() => { + if (!open) return; + setEntries( + selected.envVars.map((key) => ({ + key, + value: "", + saved: false, + saving: false, + error: null, + })), + ); + }, [open, selected]); + useEffect(() => { if (!open) return; const raf = requestAnimationFrame(() => firstInputRef.current?.focus()); @@ -147,39 +158,58 @@ function ProviderPickerModal({ return () => window.removeEventListener("keydown", handler); }, [open, onCancel]); - const selected = providers.find((p) => p.id === selectedId) ?? providers[0]; + const updateEntry = useCallback( + (index: number, updates: Partial) => { + setEntries((prev) => + prev.map((e, i) => (i === index ? { ...e, ...updates } : e)), + ); + }, + [], + ); - const handleSave = useCallback(async () => { - if (!value.trim()) return; - setSaving(true); - setError(null); - try { - if (workspaceId) { - await api.put(`/workspaces/${workspaceId}/secrets`, { - key: selected.envVar, - value: value.trim(), - }); - } else { - await api.put("/settings/secrets", { - key: selected.envVar, - value: value.trim(), + const handleSaveKey = useCallback( + async (index: number) => { + const entry = entries[index]; + if (!entry.value.trim()) return; + updateEntry(index, { saving: true, error: null }); + try { + if (workspaceId) { + await api.put(`/workspaces/${workspaceId}/secrets`, { + key: entry.key, + value: entry.value.trim(), + }); + } else { + await api.put("/settings/secrets", { + key: entry.key, + value: entry.value.trim(), + }); + } + updateEntry(index, { saved: true, saving: false }); + } catch (e) { + updateEntry(index, { + saving: false, + error: e instanceof Error ? e.message : "Failed to save", }); } - setSaved(true); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save"); - } finally { - setSaving(false); - } - }, [selected, value, workspaceId]); + }, + [entries, updateEntry, workspaceId], + ); if (!open) return null; - const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + const allSaved = entries.length > 0 && entries.every((e) => e.saved); + const anySaving = entries.some((e) => e.saving); + const runtimeLabel = runtime + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); return (
-