diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index 91346776..701a451e 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -1,14 +1,18 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { api } from "@/lib/api"; -import { getKeyLabel } from "@/lib/deploy-preflight"; +import { + getKeyLabel, + getRuntimeProviders, + type ProviderChoice, +} from "@/lib/deploy-preflight"; interface Props { open: boolean; missingKeys: string[]; runtime: string; - /** Called when user adds all keys and wants to proceed with deploy. */ + /** Called when user adds all required keys and wants to proceed with deploy. */ onKeysAdded: () => void; /** Called when user cancels the deploy. */ onCancel: () => void; @@ -27,6 +31,24 @@ interface KeyEntry { error: string | null; } +/** + * MissingKeysModal + * ---------------- + * Two rendering modes, picked automatically from the runtime: + * + * 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. + * + * 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. + */ export function MissingKeysModal({ open, missingKeys, @@ -35,12 +57,291 @@ export function MissingKeysModal({ 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; + + if (pickerMode) { + return ( + + ); + } + + return ( + + ); +} + +// ----------------------------------------------------------------------------- +// Provider-picker mode — one-of-N providers, save one, deploy. +// ----------------------------------------------------------------------------- + +function ProviderPickerModal({ + open, + providers, + runtime, + onKeysAdded, + onCancel, + onOpenSettings, + workspaceId, +}: { + open: boolean; + providers: ProviderChoice[]; + runtime: string; + onKeysAdded: () => void; + onCancel: () => void; + onOpenSettings?: () => void; + workspaceId?: string; +}) { + const [selectedId, setSelectedId] = useState(providers[0].id); + const [value, setValue] = useState(""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + const firstInputRef = useRef(null); + + useEffect(() => { + if (!open) return; + setSelectedId(providers[0].id); + setValue(""); + setSaving(false); + setSaved(false); + setError(null); + }, [open, providers]); + + useEffect(() => { + if (!open) return; + const raf = requestAnimationFrame(() => firstInputRef.current?.focus()); + return () => cancelAnimationFrame(raf); + }, [open, selectedId]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onCancel]); + + const selected = providers.find((p) => p.id === selectedId) ?? providers[0]; + + 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(), + }); + } + setSaved(true); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save"); + } finally { + setSaving(false); + } + }, [selected, value, workspaceId]); + + if (!open) return null; + + const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + + return ( + + + + + + + + + + + + + + + Missing API Keys + + + + The {runtimeLabel} runtime + supports multiple providers. Pick one and paste its API key. + + + + + + + Provider + + {providers.map((p) => ( + + { + setSelectedId(p.id); + setValue(""); + setSaved(false); + setError(null); + }} + className="mt-0.5 accent-blue-500" + /> + + {p.label} + {p.envVar} + {p.note && ( + {p.note} + )} + + + ))} + + + + + + + {getKeyLabel(selected.envVar)} + + {selected.envVar} + + {saved && ( + + + + + Saved + + )} + + + {!saved && ( + + setValue(e.target.value.trimStart())} + placeholder={selected.envVar.includes("API_KEY") ? "sk-..." : "Enter value"} + type="password" + ref={firstInputRef} + onKeyDown={(e) => { + if (e.key === "Enter" && value.trim()) { + handleSave(); + } + }} + className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors" + /> + + {saving ? "..." : "Save"} + + + )} + + {error && {error}} + + + + + + {onOpenSettings && ( + + Open Settings Panel + + )} + + + + Cancel Deploy + + + {saved ? "Deploy" : "Add Key"} + + + + + + ); +} + +// ----------------------------------------------------------------------------- +// Legacy all-keys mode — every missingKey rendered as its own input, +// all must save before deploy. Kept for single-provider runtimes + +// callers that pass unrelated-key lists (old contract). +// ----------------------------------------------------------------------------- + +function AllKeysModal({ + open, + missingKeys, + runtime, + onKeysAdded, + onCancel, + onOpenSettings, + workspaceId, }: Props) { const [entries, setEntries] = useState([]); const [globalError, setGlobalError] = useState(null); const firstInputRef = useRef(null); - // Initialize entries when modal opens or missingKeys change useEffect(() => { if (!open) return; setEntries( @@ -56,14 +357,12 @@ export function MissingKeysModal({ setGlobalError(null); }, [open, missingKeys]); - // Focus first input when modal opens useEffect(() => { if (!open) return; - const raf = requestAnimationFrame(() => { - firstInputRef.current?.focus(); - }); + const raf = requestAnimationFrame(() => firstInputRef.current?.focus()); return () => cancelAnimationFrame(raf); }, [open]); + useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { @@ -90,7 +389,6 @@ export function MissingKeysModal({ updateEntry(index, { saving: true, error: null }); try { - // Save to global scope by default (available to all workspaces) if (workspaceId) { await api.put(`/workspaces/${workspaceId}/secrets`, { key: entry.key, @@ -135,31 +433,19 @@ export function MissingKeysModal({ return ( - {/* Backdrop */} - + - {/* Dialog */} - {/* Header */} - + @@ -174,7 +460,6 @@ export function MissingKeysModal({ - {/* Body — key list */} {entries.map((entry, index) => ( - - {entry.label} - - - {entry.key} - + {entry.label} + {entry.key} {entry.saved && ( @@ -225,9 +506,7 @@ export function MissingKeysModal({ )} - {entry.error && ( - {entry.error} - )} + {entry.error && {entry.error}} ))} @@ -238,7 +517,6 @@ export function MissingKeysModal({ )} - {/* Footer */} {onOpenSettings && ( diff --git a/canvas/src/components/__tests__/MissingKeysModal.a11y.test.tsx b/canvas/src/components/__tests__/MissingKeysModal.a11y.test.tsx index fbe9c421..3ec240de 100644 --- a/canvas/src/components/__tests__/MissingKeysModal.a11y.test.tsx +++ b/canvas/src/components/__tests__/MissingKeysModal.a11y.test.tsx @@ -27,6 +27,10 @@ vi.mock("@/lib/deploy-preflight", () => ({ }; return labels[key] ?? key; }, + // These tests use unknown runtimes ("test" / "openai") — let the + // modal fall back to synthesising providers from the missingKeys + // prop. Real runtimes look this up from RUNTIME_PROVIDERS. + getRuntimeProviders: () => [], })); // ── Import after mocks ──────────────────────────────────────────────────────── diff --git a/canvas/src/components/__tests__/MissingKeysModal.component.test.tsx b/canvas/src/components/__tests__/MissingKeysModal.component.test.tsx index f7557605..80adae67 100644 --- a/canvas/src/components/__tests__/MissingKeysModal.component.test.tsx +++ b/canvas/src/components/__tests__/MissingKeysModal.component.test.tsx @@ -36,6 +36,11 @@ vi.mock("@/lib/deploy-preflight", () => ({ }; return labels[key] ?? key; }, + // Runtime names here ("test" / "openai") aren't in the real + // RUNTIME_PROVIDERS map; return [] so the modal falls back to + // synthesising providers from the missingKeys prop. That preserves + // the single-key-per-runtime semantics these tests were written for. + getRuntimeProviders: () => [], })); // ── Suite 1: Visibility and ARIA ──────────────────────────────────────────── @@ -265,7 +270,7 @@ describe("MissingKeysModal — save flow", () => { onCancel={vi.fn()} /> ); - const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? ""))!; + const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? "")) as HTMLButtonElement; expect(saveBtn.disabled).toBe(true); }); @@ -284,7 +289,7 @@ describe("MissingKeysModal — save flow", () => { act(() => { fireEvent.change(input, { target: { value: "sk-123" } }); }); - const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? ""))!; + const saveBtn = screen.getAllByRole("button").find(b => /save/i.test(b.textContent ?? "")) as HTMLButtonElement; expect(saveBtn.disabled).toBe(false); }); diff --git a/canvas/src/components/__tests__/MissingKeysModal.test.tsx b/canvas/src/components/__tests__/MissingKeysModal.test.tsx index 1a10f4cb..d3991abb 100644 --- a/canvas/src/components/__tests__/MissingKeysModal.test.tsx +++ b/canvas/src/components/__tests__/MissingKeysModal.test.tsx @@ -49,7 +49,13 @@ describe("MissingKeysModal preflight logic", () => { const result = await checkDeploySecrets("langgraph"); expect(result.ok).toBe(false); - expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]); + // langgraph accepts OpenAI, Anthropic, or OpenRouter — when none are + // configured we surface all three so the picker modal can offer a choice. + expect(result.missingKeys).toEqual([ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + ]); expect(result.runtime).toBe("langgraph"); }); diff --git a/canvas/src/lib/__tests__/deploy-preflight.test.ts b/canvas/src/lib/__tests__/deploy-preflight.test.ts index 010a7981..1b22699b 100644 --- a/canvas/src/lib/__tests__/deploy-preflight.test.ts +++ b/canvas/src/lib/__tests__/deploy-preflight.test.ts @@ -141,7 +141,13 @@ describe("checkDeploySecrets", () => { const result = await checkDeploySecrets("langgraph"); expect(result.ok).toBe(false); - expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]); + // langgraph supports any of three providers — when none are configured, + // surface all alternatives so the modal can offer a picker. + expect(result.missingKeys).toEqual([ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + ]); }); it("returns ok=false when secret exists but has_value is false", async () => { @@ -155,7 +161,11 @@ describe("checkDeploySecrets", () => { const result = await checkDeploySecrets("langgraph"); expect(result.ok).toBe(false); - expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]); + expect(result.missingKeys).toEqual([ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + ]); }); it("returns ok=true for runtimes with no required keys", async () => { @@ -203,6 +213,10 @@ describe("checkDeploySecrets", () => { const result = await checkDeploySecrets("langgraph"); expect(result.ok).toBe(false); - expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]); + expect(result.missingKeys).toEqual([ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + ]); }); }); diff --git a/canvas/src/lib/deploy-preflight.ts b/canvas/src/lib/deploy-preflight.ts index 055ce3de..1dc1b59f 100644 --- a/canvas/src/lib/deploy-preflight.ts +++ b/canvas/src/lib/deploy-preflight.ts @@ -8,19 +8,73 @@ import { api } from "./api"; -/* ---------- Required keys per runtime ---------- */ +/* ---------- Required keys per runtime ---------- + * + * A runtime may accept ANY of several provider keys (Hermes speaks + * OpenRouter or OpenAI or its native Nous API; LangGraph speaks + * OpenAI or Anthropic; …). Represent that as a list of provider + * choices — the UI renders a picker when length > 1, and the + * preflight check treats the runtime as satisfied if *any one* of + * the listed keys is configured. + * + * The first entry is the default / recommended provider for that + * runtime. + */ -export const RUNTIME_REQUIRED_KEYS: Record = { - langgraph: ["OPENAI_API_KEY"], - "claude-code": ["ANTHROPIC_API_KEY"], - openclaw: ["OPENAI_API_KEY"], - deepagents: ["OPENAI_API_KEY"], - crewai: ["OPENAI_API_KEY"], - autogen: ["OPENAI_API_KEY"], - hermes: ["OPENROUTER_API_KEY"], - "gemini-cli": ["GOOGLE_API_KEY"], +export interface ProviderChoice { + /** Stable id for the provider. Used as React key + picker value. */ + id: string; + /** Human label shown in the provider picker. */ + label: string; + /** Env var name the workspace container reads at runtime. */ + envVar: string; + /** Short rationale shown under the picker option, optional. */ + note?: string; +} + +export const RUNTIME_PROVIDERS: Record = { + langgraph: [ + { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" }, + { id: "anthropic", label: "Anthropic", envVar: "ANTHROPIC_API_KEY" }, + { id: "openrouter", label: "OpenRouter (proxy — any model)", envVar: "OPENROUTER_API_KEY", note: "Broadest model coverage incl. Minimax, DeepSeek, Groq" }, + ], + "claude-code": [ + { id: "anthropic", label: "Anthropic", envVar: "ANTHROPIC_API_KEY" }, + ], + openclaw: [ + { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" }, + { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" }, + ], + deepagents: [ + { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" }, + { id: "anthropic", label: "Anthropic", envVar: "ANTHROPIC_API_KEY" }, + { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" }, + ], + crewai: [ + { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" }, + { id: "anthropic", label: "Anthropic", envVar: "ANTHROPIC_API_KEY" }, + ], + autogen: [ + { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" }, + { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" }, + ], + hermes: [ + { id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY", note: "Recommended — widest model coverage (Minimax, DeepSeek, Llama, …)" }, + { id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" }, + { id: "hermes-native", label: "Nous Research (Hermes native)", envVar: "HERMES_API_KEY" }, + ], + "gemini-cli": [ + { id: "google", label: "Google AI", envVar: "GOOGLE_API_KEY" }, + ], }; +/** Back-compat: flat list of the DEFAULT (first) env var per runtime. + * Preserved so existing callers keep working; the richer provider- + * aware UX consumes RUNTIME_PROVIDERS directly. */ +export const RUNTIME_REQUIRED_KEYS: Record = Object.fromEntries( + Object.entries(RUNTIME_PROVIDERS).map(([rt, choices]) => [rt, [choices[0].envVar]]), +); + /** Human-readable labels for common secret keys */ export const KEY_LABELS: Record = { OPENAI_API_KEY: "OpenAI API Key", @@ -32,6 +86,24 @@ export const KEY_LABELS: Record = { DEEPSEEK_API_KEY: "DeepSeek API Key", }; +/** Get the provider choices for a runtime. Returns [] for unknown runtimes. */ +export function getRuntimeProviders(runtime: string): ProviderChoice[] { + return RUNTIME_PROVIDERS[runtime] ?? []; +} + +/** Returns the first provider choice whose env var is in `configured`, + * or null if none are set. Used to auto-skip the picker when the + * user has already wired up a supported provider. */ +export function findConfiguredProvider( + runtime: string, + configured: Set, +): ProviderChoice | null { + for (const p of getRuntimeProviders(runtime)) { + if (configured.has(p.envVar)) return p; + } + return null; +} + /* ---------- Types ---------- */ export interface SecretEntry { @@ -81,8 +153,9 @@ export async function checkDeploySecrets( runtime: string, workspaceId?: string, ): Promise { - const requiredKeys = getRequiredKeys(runtime); - if (requiredKeys.length === 0) { + const providers = getRuntimeProviders(runtime); + if (providers.length === 0) { + // Unknown runtime — nothing to preflight. return { ok: true, missingKeys: [], runtime }; } @@ -95,12 +168,25 @@ export async function checkDeploySecrets( secrets.filter((s) => s.has_value).map((s) => s.key), ); - const missingKeys = findMissingKeys(runtime, configuredKeys); - return { ok: missingKeys.length === 0, missingKeys, runtime }; + // If ANY supported provider's key is already set we're satisfied — + // the picker is only for "none yet" cases. + if (findConfiguredProvider(runtime, configuredKeys)) { + return { ok: true, missingKeys: [], runtime }; + } + + // Nothing configured — surface every supported provider so the + // modal can render a picker. The default (first) still renders at + // the top. + const missingKeys = providers.map((p) => p.envVar); + return { ok: false, missingKeys, runtime }; } catch (error) { // Log the error before falling back — aids debugging when the API is down. console.error("[deploy-preflight] Failed to check secrets, assuming all missing:", error); // If we can't reach the secrets API, assume missing — safer to prompt the user. - return { ok: false, missingKeys: requiredKeys, runtime }; + return { + ok: false, + missingKeys: providers.map((p) => p.envVar), + runtime, + }; } }
+ The {runtimeLabel} runtime + supports multiple providers. Pick one and paste its API key. +