refactor(canvas): data-drive provider picker from template config.yaml

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) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-23 17:07:15 -07:00
parent c5bcd7298c
commit dc50a1c775
8 changed files with 560 additions and 516 deletions

View File

@ -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> 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 (
<ProviderPickerModal
open={open}
providers={providers}
providers={pickerProviders}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
@ -82,10 +77,15 @@ export function MissingKeysModal({
);
}
// Prefer the (single) provider's envVars over the raw missingKeys when
// we have one — the provider list is already de-duped and ordered.
const keys =
pickerProviders.length === 1 ? pickerProviders[0].envVars : missingKeys;
return (
<AllKeysModal
open={open}
missingKeys={missingKeys}
missingKeys={keys}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
@ -96,7 +96,7 @@ export function MissingKeysModal({
}
// -----------------------------------------------------------------------------
// Provider-picker mode — one-of-N providers, save one, deploy.
// Provider-picker mode — choose one option, save its env var(s), deploy.
// -----------------------------------------------------------------------------
function ProviderPickerModal({
@ -117,21 +117,32 @@ function ProviderPickerModal({
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<string | null>(null);
const [entries, setEntries] = useState<KeyEntry[]>([]);
const firstInputRef = useRef<HTMLInputElement>(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<KeyEntry>) => {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div aria-hidden="true" className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onCancel} />
<div
aria-hidden="true"
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onCancel}
/>
<div
role="dialog"
@ -189,7 +219,10 @@ function ProviderPickerModal({
>
<div className="px-5 py-4 border-b border-zinc-800">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center" aria-hidden="true">
<div
className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center"
aria-hidden="true"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M6 1L11 10H1L6 1Z" stroke="#fbbf24" strokeWidth="1.2" strokeLinejoin="round" />
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
@ -201,8 +234,8 @@ function ProviderPickerModal({
</h3>
</div>
<p className="text-[12px] text-zinc-400 leading-relaxed">
The <span className="text-amber-300 font-medium">{runtimeLabel}</span> runtime
supports multiple providers. Pick one and paste its API key.
The <span className="text-amber-300 font-medium">{runtimeLabel}</span>{" "}
runtime supports multiple providers. Pick one and paste its API key.
</p>
</div>
@ -225,69 +258,77 @@ function ProviderPickerModal({
name="provider"
value={p.id}
checked={selectedId === p.id}
onChange={() => {
setSelectedId(p.id);
setValue("");
setSaved(false);
setError(null);
}}
onChange={() => setSelectedId(p.id)}
className="mt-0.5 accent-blue-500"
/>
<div className="min-w-0 flex-1">
<div className="text-[12px] text-zinc-100 font-medium">{p.label}</div>
<div className="text-[10px] font-mono text-zinc-500">{p.envVar}</div>
<div className="text-[10px] font-mono text-zinc-500">
{p.envVars.join(", ")}
</div>
{p.note && (
<div className="text-[10px] text-zinc-500 mt-1 leading-relaxed">{p.note}</div>
<div className="text-[10px] text-zinc-500 mt-1 leading-relaxed">
{p.note}
</div>
)}
</div>
</label>
))}
</fieldset>
<div className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50">
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-zinc-300 font-medium">
{getKeyLabel(selected.envVar)}
<div className="space-y-2">
{entries.map((entry, index) => (
<div
key={entry.key}
className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50"
>
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-zinc-300 font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-zinc-500">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Saved
</span>
)}
</div>
<div className="text-[9px] font-mono text-zinc-500">{selected.envVar}</div>
</div>
{saved && (
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Saved
</span>
)}
</div>
{!saved && (
<div className="flex gap-2 mt-2">
<input
value={value}
onChange={(e) => 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"
/>
<button
onClick={handleSave}
disabled={!value.trim() || saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{saving ? "..." : "Save"}
</button>
</div>
)}
{!entry.saved && (
<div className="flex gap-2 mt-2">
<input
value={entry.value}
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
ref={index === 0 ? firstInputRef : undefined}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
handleSaveKey(index);
}
}}
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"
/>
<button
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{error && <div className="mt-1.5 text-[10px] text-red-400">{error}</div>}
{entry.error && (
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
)}
</div>
))}
</div>
</div>
@ -311,10 +352,10 @@ function ProviderPickerModal({
</button>
<button
onClick={onKeysAdded}
disabled={!saved || saving}
disabled={!allSaved || anySaving}
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
>
{saved ? "Deploy" : "Add Key"}
{allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
</button>
</div>
</div>
@ -324,9 +365,7 @@ function ProviderPickerModal({
}
// -----------------------------------------------------------------------------
// 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).
// All-keys mode — every missingKey rendered as its own input, all required.
// -----------------------------------------------------------------------------
function AllKeysModal({
@ -337,7 +376,15 @@ function AllKeysModal({
onCancel,
onOpenSettings,
workspaceId,
}: Props) {
}: {
open: boolean;
missingKeys: string[];
runtime: string;
onKeysAdded: () => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
}) {
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
const firstInputRef = useRef<HTMLInputElement>(null);
@ -347,7 +394,6 @@ function AllKeysModal({
setEntries(
missingKeys.map((key) => ({
key,
label: getKeyLabel(key),
value: "",
saved: false,
saving: false,
@ -427,13 +473,19 @@ function AllKeysModal({
if (!open) return null;
const allSaved = entries.every((e) => e.saved);
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());
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div aria-hidden="true" className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onCancel} />
<div
aria-hidden="true"
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onCancel}
/>
<div
role="dialog"
@ -443,7 +495,10 @@ function AllKeysModal({
>
<div className="px-5 py-4 border-b border-zinc-800">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center" aria-hidden="true">
<div
className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center"
aria-hidden="true"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M6 1L11 10H1L6 1Z" stroke="#fbbf24" strokeWidth="1.2" strokeLinejoin="round" />
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
@ -455,8 +510,8 @@ function AllKeysModal({
</h3>
</div>
<p className="text-[12px] text-zinc-400 leading-relaxed">
The <span className="text-amber-300 font-medium">{runtimeLabel}</span> runtime
requires the following keys to be configured before deploying.
The <span className="text-amber-300 font-medium">{runtimeLabel}</span>{" "}
runtime requires the following keys to be configured before deploying.
</p>
</div>
@ -468,7 +523,9 @@ function AllKeysModal({
>
<div className="flex items-center justify-between mb-1">
<div>
<div className="text-[11px] text-zinc-300 font-medium">{entry.label}</div>
<div className="text-[11px] text-zinc-300 font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-zinc-500">{entry.key}</div>
</div>
{entry.saved && (

View File

@ -3,7 +3,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { checkDeploySecrets, type PreflightResult } from "@/lib/deploy-preflight";
import { checkDeploySecrets, type PreflightResult, type ModelSpec } from "@/lib/deploy-preflight";
import { MissingKeysModal } from "./MissingKeysModal";
import { ConfirmDialog } from "./ConfirmDialog";
import { Spinner } from "./Spinner";
@ -14,7 +14,11 @@ interface Template {
name: string;
description: string;
tier: number;
runtime?: string;
model: string;
models?: ModelSpec[];
/** AND-required env vars declared at runtime_config.required_env. */
required_env?: string[];
skills: string[];
skill_count: number;
}
@ -329,8 +333,15 @@ export function TemplatePalette() {
setCreating(template.id);
setError(null);
const runtime = resolveRuntime(template.id);
const preflight = await checkDeploySecrets(runtime);
// Prefer the runtime the Go /templates endpoint returned verbatim —
// resolveRuntime() is a legacy id→runtime fallback for installs whose
// template summary predates the `runtime` field.
const runtime = template.runtime ?? resolveRuntime(template.id);
const preflight = await checkDeploySecrets({
runtime,
models: template.models,
required_env: template.required_env,
});
if (!preflight.ok) {
// Missing keys — show the modal instead of deploying
@ -368,6 +379,7 @@ export function TemplatePalette() {
<MissingKeysModal
open={!!missingKeysInfo}
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
providers={missingKeysInfo?.preflight.providers ?? []}
runtime={missingKeysInfo?.preflight.runtime ?? ""}
onKeysAdded={() => {
if (missingKeysInfo) {

View File

@ -27,11 +27,9 @@ 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: () => [],
}));
// a11y tests render the modal without a `providers` prop — it falls
// back to all-keys mode driven by the `missingKeys` array.
// ── Import after mocks ────────────────────────────────────────────────────────

View File

@ -36,12 +36,10 @@ 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: () => [],
}));
// Tests render the modal without a `providers` prop — the component
// falls back to the all-keys mode using the `missingKeys` array, which
// matches the contract these tests were written for.
// ── Suite 1: Visibility and ARIA ────────────────────────────────────────────

View File

@ -1,89 +0,0 @@
// @vitest-environment node
/**
* MissingKeysModal preflight logic tests.
* Component rendering tested in MissingKeysModal.component.test.tsx.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
global.fetch = vi.fn();
import {
getRequiredKeys,
findMissingKeys,
getKeyLabel,
checkDeploySecrets,
RUNTIME_REQUIRED_KEYS,
} from "../../lib/deploy-preflight";
beforeEach(() => {
vi.clearAllMocks();
});
describe("MissingKeysModal preflight logic", () => {
it("identifies missing keys for langgraph runtime", () => {
const missing = findMissingKeys("langgraph", new Set<string>());
expect(missing).toEqual(["OPENAI_API_KEY"]);
});
it("identifies missing keys for claude-code runtime", () => {
const missing = findMissingKeys("claude-code", new Set<string>());
expect(missing).toEqual(["ANTHROPIC_API_KEY"]);
});
it("generates correct labels for modal display", () => {
const missing = findMissingKeys("langgraph", new Set<string>());
const labels = missing.map((k) => ({ key: k, label: getKeyLabel(k) }));
expect(labels).toEqual([{ key: "OPENAI_API_KEY", label: "OpenAI API Key" }]);
});
it("returns no missing keys when all are configured", () => {
const missing = findMissingKeys("langgraph", new Set(["OPENAI_API_KEY"]));
expect(missing).toEqual([]);
});
it("pre-deploy check returns ok=false and correct missing keys", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const result = await checkDeploySecrets("langgraph");
expect(result.ok).toBe(false);
// 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");
});
it("pre-deploy check returns ok=true when keys are present", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([{ key: "ANTHROPIC_API_KEY", has_value: true, created_at: "", updated_at: "" }]),
} as Response);
const result = await checkDeploySecrets("claude-code");
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
});
it("handles all runtimes correctly for modal data construction", () => {
const runtimes = Object.keys(RUNTIME_REQUIRED_KEYS);
for (const runtime of runtimes) {
const requiredKeys = getRequiredKeys(runtime);
const missing = findMissingKeys(runtime, new Set<string>());
const labels = missing.map((k) => getKeyLabel(k));
expect(requiredKeys.length).toBeGreaterThan(0);
expect(missing).toEqual(requiredKeys);
expect(labels.length).toBe(requiredKeys.length);
for (const label of labels) {
expect(label.length).toBeGreaterThan(0);
}
}
});
});

View File

@ -1,121 +1,148 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// Mock fetch globally before importing the module
global.fetch = vi.fn();
import {
getRequiredKeys,
findMissingKeys,
getKeyLabel,
checkDeploySecrets,
RUNTIME_REQUIRED_KEYS,
KEY_LABELS,
providersFromTemplate,
findSatisfiedProvider,
getKeyLabel,
getProviderLabel,
type TemplateLike,
type ModelSpec,
} from "../deploy-preflight";
beforeEach(() => {
vi.clearAllMocks();
});
/* ---------- getRequiredKeys ---------- */
// -----------------------------------------------------------------------------
// Fixtures mirroring what the Go /templates endpoint returns from each
// template repo's config.yaml. Keep these minimal — we only need the
// fields the preflight reads.
// -----------------------------------------------------------------------------
describe("getRequiredKeys", () => {
it("returns OPENAI_API_KEY for langgraph", () => {
expect(getRequiredKeys("langgraph")).toEqual(["OPENAI_API_KEY"]);
const hermesModels: ModelSpec[] = [
{ id: "nousresearch/hermes-4-70b", name: "Hermes 4 70B", required_env: ["HERMES_API_KEY"] },
{ id: "nousresearch/hermes-3-405b", name: "Hermes 3 405B", required_env: ["OPENROUTER_API_KEY"] },
{ id: "anthropic/claude-opus", name: "Claude Opus", required_env: ["ANTHROPIC_API_KEY"] },
{ id: "openai/gpt-5", name: "GPT-5 via OpenRouter", required_env: ["OPENROUTER_API_KEY"] },
{ id: "custom/local", name: "Local endpoint", required_env: [] },
];
const HERMES: TemplateLike = { runtime: "hermes", models: hermesModels };
const LANGGRAPH: TemplateLike = {
runtime: "langgraph",
required_env: ["OPENAI_API_KEY"],
};
const UNKNOWN: TemplateLike = { runtime: "nothing-declared" };
// -----------------------------------------------------------------------------
// providersFromTemplate
// -----------------------------------------------------------------------------
describe("providersFromTemplate", () => {
it("groups hermes models by unique required_env tuples", () => {
const providers = providersFromTemplate(HERMES);
// Three distinct tuples: HERMES_API_KEY, OPENROUTER_API_KEY, ANTHROPIC_API_KEY.
// The `custom/local` entry has required_env: [] and must be skipped.
expect(providers.map((p) => p.id)).toEqual([
"HERMES_API_KEY",
"OPENROUTER_API_KEY",
"ANTHROPIC_API_KEY",
]);
});
it("returns ANTHROPIC_API_KEY for claude-code", () => {
expect(getRequiredKeys("claude-code")).toEqual(["ANTHROPIC_API_KEY"]);
it("decorates labels with model counts when a provider serves multiple models", () => {
const providers = providersFromTemplate(HERMES);
const openrouter = providers.find((p) => p.id === "OPENROUTER_API_KEY");
expect(openrouter?.label).toMatch(/\(2 models\)/);
const hermes = providers.find((p) => p.id === "HERMES_API_KEY");
expect(hermes?.label).not.toMatch(/\(\d+ models\)/);
});
it("returns OPENAI_API_KEY for crewai", () => {
expect(getRequiredKeys("crewai")).toEqual(["OPENAI_API_KEY"]);
it("preserves insertion order so the template author controls defaults", () => {
const providers = providersFromTemplate(HERMES);
expect(providers[0].id).toBe("HERMES_API_KEY");
});
it("returns OPENAI_API_KEY for autogen", () => {
expect(getRequiredKeys("autogen")).toEqual(["OPENAI_API_KEY"]);
it("falls back to top-level required_env when no models[] are declared", () => {
const providers = providersFromTemplate(LANGGRAPH);
expect(providers).toHaveLength(1);
expect(providers[0].envVars).toEqual(["OPENAI_API_KEY"]);
});
it("returns OPENAI_API_KEY for openclaw", () => {
expect(getRequiredKeys("openclaw")).toEqual(["OPENAI_API_KEY"]);
it("returns [] for templates declaring no env requirements", () => {
expect(providersFromTemplate(UNKNOWN)).toEqual([]);
});
it("returns OPENAI_API_KEY for deepagents", () => {
expect(getRequiredKeys("deepagents")).toEqual(["OPENAI_API_KEY"]);
});
it("returns empty array for unknown runtimes", () => {
expect(getRequiredKeys("unknown-runtime")).toEqual([]);
expect(getRequiredKeys("")).toEqual([]);
it("supports multi-env providers (AND-semantics inside one option)", () => {
const tmpl: TemplateLike = {
runtime: "agent",
models: [
{ id: "m", required_env: ["OPENAI_API_KEY", "SERPER_API_KEY"] },
],
};
const providers = providersFromTemplate(tmpl);
expect(providers).toHaveLength(1);
expect(providers[0].envVars).toEqual(["OPENAI_API_KEY", "SERPER_API_KEY"]);
});
});
/* ---------- findMissingKeys ---------- */
// -----------------------------------------------------------------------------
// findSatisfiedProvider
// -----------------------------------------------------------------------------
describe("findMissingKeys", () => {
it("returns empty array when all keys are configured", () => {
const configured = new Set(["OPENAI_API_KEY", "OTHER_KEY"]);
expect(findMissingKeys("langgraph", configured)).toEqual([]);
describe("findSatisfiedProvider", () => {
it("returns the first provider whose envVars are all configured", () => {
const providers = providersFromTemplate(HERMES);
const satisfied = findSatisfiedProvider(
providers,
new Set(["ANTHROPIC_API_KEY"]),
);
expect(satisfied?.id).toBe("ANTHROPIC_API_KEY");
});
it("returns missing keys when not configured", () => {
const configured = new Set(["OTHER_KEY"]);
expect(findMissingKeys("langgraph", configured)).toEqual(["OPENAI_API_KEY"]);
it("returns null when no provider is fully configured", () => {
const providers = providersFromTemplate(HERMES);
expect(findSatisfiedProvider(providers, new Set())).toBeNull();
});
it("returns empty array for runtime with no required keys", () => {
const configured = new Set<string>();
expect(findMissingKeys("unknown-runtime", configured)).toEqual([]);
});
it("returns all required keys when nothing is configured", () => {
const configured = new Set<string>();
expect(findMissingKeys("claude-code", configured)).toEqual(["ANTHROPIC_API_KEY"]);
});
it("handles empty configured set for multi-key runtimes", () => {
const configured = new Set<string>();
const result = findMissingKeys("langgraph", configured);
expect(result).toEqual(["OPENAI_API_KEY"]);
it("requires ALL envVars in a multi-env provider", () => {
const providers: ReturnType<typeof providersFromTemplate> =
providersFromTemplate({
runtime: "agent",
models: [{ id: "m", required_env: ["A", "B"] }],
});
expect(findSatisfiedProvider(providers, new Set(["A"]))).toBeNull();
expect(findSatisfiedProvider(providers, new Set(["A", "B"]))?.id).toBe("A|B");
});
});
/* ---------- getKeyLabel ---------- */
// -----------------------------------------------------------------------------
// Label helpers
// -----------------------------------------------------------------------------
describe("getKeyLabel", () => {
it("returns label for known keys", () => {
describe("getKeyLabel / getProviderLabel", () => {
it("uses KEY_LABELS for well-known keys", () => {
expect(getProviderLabel("OPENAI_API_KEY")).toBe("OpenAI");
expect(getKeyLabel("OPENAI_API_KEY")).toBe("OpenAI API Key");
expect(getKeyLabel("ANTHROPIC_API_KEY")).toBe("Anthropic API Key");
});
it("returns the key itself for unknown keys", () => {
expect(getKeyLabel("CUSTOM_SECRET")).toBe("CUSTOM_SECRET");
it("humanizes unknown env vars", () => {
expect(getProviderLabel("MY_CUSTOM_API_KEY")).toBe("My Custom");
expect(getKeyLabel("MY_CUSTOM_TOKEN")).toBe("My Custom");
});
});
/* ---------- RUNTIME_REQUIRED_KEYS ---------- */
describe("RUNTIME_REQUIRED_KEYS", () => {
it("covers all six standard runtimes", () => {
const runtimes = Object.keys(RUNTIME_REQUIRED_KEYS);
expect(runtimes).toContain("langgraph");
expect(runtimes).toContain("claude-code");
expect(runtimes).toContain("openclaw");
expect(runtimes).toContain("deepagents");
expect(runtimes).toContain("crewai");
expect(runtimes).toContain("autogen");
});
it("each runtime has at least one required key", () => {
for (const [runtime, keys] of Object.entries(RUNTIME_REQUIRED_KEYS)) {
expect(keys.length).toBeGreaterThan(0);
}
});
});
/* ---------- checkDeploySecrets ---------- */
// -----------------------------------------------------------------------------
// checkDeploySecrets
// -----------------------------------------------------------------------------
describe("checkDeploySecrets", () => {
it("returns ok=true when all required keys have values", async () => {
it("returns ok=true when a single-provider template's key is configured", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
@ -124,59 +151,13 @@ describe("checkDeploySecrets", () => {
]),
} as Response);
const result = await checkDeploySecrets("langgraph");
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(result.runtime).toBe("langgraph");
});
it("returns ok=false when required keys are missing", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ key: "OTHER_KEY", has_value: true, created_at: "", updated_at: "" },
]),
} as Response);
const result = await checkDeploySecrets("langgraph");
expect(result.ok).toBe(false);
// 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 () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ key: "OPENAI_API_KEY", has_value: false, created_at: "", updated_at: "" },
]),
} as Response);
const result = await checkDeploySecrets("langgraph");
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual([
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
]);
});
it("returns ok=true for runtimes with no required keys", async () => {
const result = await checkDeploySecrets("unknown-runtime");
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
// Should not have called fetch
expect(global.fetch).not.toHaveBeenCalled();
});
it("uses workspace-specific endpoint when workspaceId is provided", async () => {
it("returns ok=true on a multi-provider template when ANY provider is configured", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
@ -185,38 +166,83 @@ describe("checkDeploySecrets", () => {
]),
} as Response);
const result = await checkDeploySecrets("claude-code", "ws-123");
const result = await checkDeploySecrets(HERMES);
expect(result.ok).toBe(true);
});
it("returns ok=false with every candidate env when nothing is configured", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const result = await checkDeploySecrets(HERMES);
expect(result.ok).toBe(false);
// De-duplicated flat list across providers.
expect(new Set(result.missingKeys)).toEqual(
new Set(["HERMES_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY"]),
);
// Grouped providers preserved for the picker.
expect(result.providers).toHaveLength(3);
});
it("treats has_value=false as not-configured", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ key: "OPENAI_API_KEY", has_value: false, created_at: "", updated_at: "" },
]),
} as Response);
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
});
it("skips the API call entirely when the template declares no env needs", async () => {
const result = await checkDeploySecrets(UNKNOWN);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(global.fetch).not.toHaveBeenCalled();
});
it("uses the workspace-scoped endpoint when workspaceId is provided", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ key: "OPENAI_API_KEY", has_value: true, created_at: "", updated_at: "" },
]),
} as Response);
await checkDeploySecrets(LANGGRAPH, "ws-123");
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/workspaces/ws-123/secrets"),
expect.any(Object),
);
});
it("uses global secrets endpoint when no workspaceId", async () => {
it("uses the global secrets endpoint when no workspaceId", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as Response);
await checkDeploySecrets("langgraph");
await checkDeploySecrets(LANGGRAPH);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/settings/secrets"),
expect.any(Object),
);
});
it("treats API failure as all keys missing (safe default)", async () => {
it("treats fetch failure as all-missing (safe default prompts the user)", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Network error"),
);
const result = await checkDeploySecrets("langgraph");
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual([
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
]);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
});
});

View File

@ -1,111 +1,38 @@
/**
* Pre-deploy secret check per runtime.
* Pre-deploy secret check driven by the template's config.yaml.
*
* Before a workspace is deployed, validates that all required secrets/env vars
* are configured for the target runtime. Each runtime defines its own set of
* required keys (derived from each runtime's config.yaml `env.required` field).
* The single source of truth for which env vars a workspace needs is
* each template repo's config.yaml the `runtime_config.models[].required_env`
* array names the key(s) required per model, and `runtime_config.required_env`
* names any AND-required keys at the runtime level. The Go `/templates`
* handler parses these and exposes them as `models` and `required_env` on
* each template summary.
*
* This module consumes that shape; it does NOT hardcode a per-runtime
* provider table. When a template declares alternative models (e.g.
* Hermes supports 35 models across 8 providers), the unique required_env
* tuples become the provider options shown in the picker modal.
*/
import { api } from "./api";
/* ---------- 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.
*/
/* ---------- Types matching the /templates response ---------- */
export interface ProviderChoice {
/** Stable id for the provider. Used as React key + picker value. */
export interface ModelSpec {
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;
name?: string;
required_env?: string[];
}
export const RUNTIME_PROVIDERS: Record<string, ProviderChoice[]> = {
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<string, string[]> = 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<string, string> = {
OPENAI_API_KEY: "OpenAI API Key",
ANTHROPIC_API_KEY: "Anthropic API Key",
GOOGLE_API_KEY: "Google AI API Key",
SERP_API_KEY: "SERP API Key",
OPENROUTER_API_KEY: "OpenRouter API Key",
HERMES_API_KEY: "Nous Research API Key",
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] ?? [];
/** Minimal template shape consumed by the preflight check. Any object
* that matches this subset of the `/templates` response works. */
export interface TemplateLike {
runtime: string;
models?: ModelSpec[];
/** AND-required env vars declared at runtime_config level. */
required_env?: string[];
}
/** 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<string>,
): ProviderChoice | null {
for (const p of getRuntimeProviders(runtime)) {
if (configured.has(p.envVar)) return p;
}
return null;
}
/* ---------- Types ---------- */
export interface SecretEntry {
key: string;
has_value: boolean;
@ -116,77 +43,184 @@ export interface SecretEntry {
export interface PreflightResult {
ok: boolean;
/** Flat list of env var names needed for the legacy modal path and
* for callers that want a single display of "what's missing". */
missingKeys: string[];
/** Grouped provider options derived from the template. When length 2
* the modal renders a picker; length 1 means exactly one provider is
* required (AllKeysModal renders the N envVars inline). */
providers: ProviderChoice[];
runtime: string;
}
/* ---------- Pure helpers (easily testable) ---------- */
/* ---------- Provider options ---------- */
/** Get required env keys for a given runtime. Returns empty array for unknown runtimes. */
export function getRequiredKeys(runtime: string): string[] {
return RUNTIME_REQUIRED_KEYS[runtime] ?? [];
/** One row in the provider picker. `envVars` is the set of keys required
* TOGETHER to satisfy this option (usually length 1 e.g. just
* OPENROUTER_API_KEY). When length 2 all must be saved. */
export interface ProviderChoice {
/** Stable id for React keys + picker value — the sorted envVars joined. */
id: string;
/** Human label, e.g. "OpenRouter" or "OpenAI + Serper". */
label: string;
/** Env vars required for this provider option. */
envVars: string[];
/** Short rationale shown under the option, optional. */
note?: string;
}
/** Given a runtime and a set of configured key names, return which keys are missing. */
export function findMissingKeys(
runtime: string,
configuredKeys: Set<string>,
): string[] {
return getRequiredKeys(runtime).filter((k) => !configuredKeys.has(k));
}
/** Human-readable labels for well-known secret keys. Anything not in
* this table falls back to a humanized form of the env var. */
export const KEY_LABELS: Record<string, string> = {
OPENAI_API_KEY: "OpenAI",
ANTHROPIC_API_KEY: "Anthropic",
GOOGLE_API_KEY: "Google AI",
GEMINI_API_KEY: "Google Gemini",
SERP_API_KEY: "SERP",
SERPER_API_KEY: "Serper",
OPENROUTER_API_KEY: "OpenRouter",
HERMES_API_KEY: "Nous Research (Hermes native)",
DEEPSEEK_API_KEY: "DeepSeek",
GLM_API_KEY: "z.ai GLM",
KIMI_API_KEY: "Moonshot Kimi",
MINIMAX_API_KEY: "MiniMax",
KILOCODE_API_KEY: "Kilo Code",
CLAUDE_CODE_OAUTH_TOKEN: "Claude Code subscription",
};
/** Get human-readable label for a key, or fall back to the key itself. */
/** Full "API Key" label used for input field headers. */
export function getKeyLabel(key: string): string {
return KEY_LABELS[key] ?? key;
const base = KEY_LABELS[key];
if (base) return `${base} API Key`;
return humanizeEnvVar(key);
}
/* ---------- API-calling preflight check ---------- */
/** Short provider name used in the picker (no trailing "API Key"). */
export function getProviderLabel(key: string): string {
return KEY_LABELS[key] ?? humanizeEnvVar(key);
}
function humanizeEnvVar(key: string): string {
return key
.replace(/_API_KEY$|_TOKEN$|_KEY$/i, "")
.split(/[_-]/)
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
}
/**
* Fetch configured secrets from the platform and check whether all required
* keys for the target runtime are present.
* Derive the provider options for a template from its declared shape.
*
* If `workspaceId` is provided, fetches the merged (global + workspace) secret
* list for that workspace. Otherwise falls back to global secrets only.
* 1. `models[].required_env` each unique (sorted) tuple becomes a
* provider option. E.g. Hermes exposes 8 options (Nous, OpenRouter,
* Anthropic, Gemini, DeepSeek, GLM, Kimi, Kilocode) even though it
* lists 35 models. Insertion order is preserved so the template's
* author controls which provider is offered first.
* 2. If `models` is empty or has no required_env, fall back to the
* top-level `required_env` as a single all-required option.
* 3. If neither is declared, return [] no preflight needed.
*
* Models with `required_env: []` (local / self-hosted endpoints) are
* skipped when computing options; they never block a deploy.
*/
export async function checkDeploySecrets(
runtime: string,
workspaceId?: string,
): Promise<PreflightResult> {
const providers = getRuntimeProviders(runtime);
if (providers.length === 0) {
// Unknown runtime — nothing to preflight.
return { ok: true, missingKeys: [], runtime };
export function providersFromTemplate(template: TemplateLike): ProviderChoice[] {
const out: ProviderChoice[] = [];
const seen = new Set<string>();
const modelCount: Record<string, number> = {};
for (const m of template.models ?? []) {
const envs = m.required_env ?? [];
if (envs.length === 0) continue;
const id = [...envs].sort().join("|");
modelCount[id] = (modelCount[id] ?? 0) + 1;
if (seen.has(id)) continue;
seen.add(id);
out.push({
id,
envVars: envs,
label: envs.map(getProviderLabel).join(" + "),
});
}
// Decorate labels with model-count hints when multiple models share
// the same provider. Gives the user context: "OpenRouter (14 models)".
for (const p of out) {
const n = modelCount[p.id];
if (n && n > 1) p.label = `${p.label} (${n} models)`;
}
if (out.length === 0 && template.required_env?.length) {
const envs = template.required_env;
out.push({
id: [...envs].sort().join("|"),
envVars: envs,
label: envs.map(getProviderLabel).join(" + "),
});
}
return out;
}
/** Helper: is any single provider option already satisfied by the set of
* configured keys? A provider is satisfied when EVERY envVar it requires
* is present. Returns the first such option or null. */
export function findSatisfiedProvider(
providers: ProviderChoice[],
configured: Set<string>,
): ProviderChoice | null {
for (const p of providers) {
if (p.envVars.every((k) => configured.has(k))) return p;
}
return null;
}
/* ---------- Preflight ---------- */
/**
* Fetch configured secrets from the platform and decide whether the
* workspace can deploy. When `workspaceId` is provided the merged
* (global + workspace) secrets are checked; otherwise only globals.
*
* Returns `ok=true` immediately if any provider option's env vars are
* already configured. Otherwise returns all candidate env vars flat in
* `missingKeys` plus the grouped `providers` list for the picker.
*/
export async function checkDeploySecrets(
template: TemplateLike,
workspaceId?: string,
): Promise<PreflightResult> {
const providers = providersFromTemplate(template);
const runtime = template.runtime;
if (providers.length === 0) {
// Template declares no env requirements — nothing to preflight.
return { ok: true, missingKeys: [], providers: [], runtime };
}
let configured: Set<string>;
try {
const secrets = workspaceId
? await api.get<SecretEntry[]>(`/workspaces/${workspaceId}/secrets`)
: await api.get<SecretEntry[]>("/settings/secrets");
const configuredKeys = new Set(
secrets.filter((s) => s.has_value).map((s) => s.key),
);
// 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 };
configured = new Set(secrets.filter((s) => s.has_value).map((s) => s.key));
} 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: providers.map((p) => p.envVar),
runtime,
};
console.error(
"[deploy-preflight] Failed to read secrets, assuming all missing:",
error,
);
// Safer to prompt the user than to silently deploy.
configured = new Set();
}
if (findSatisfiedProvider(providers, configured)) {
return { ok: true, missingKeys: [], providers, runtime };
}
// Nothing configured — surface every candidate env var so the modal
// can render the picker or the all-keys fallback.
const missingKeys = Array.from(
new Set(providers.flatMap((p) => p.envVars)),
);
return { ok: false, missingKeys, providers, runtime };
}

View File

@ -53,8 +53,14 @@ type templateSummary struct {
Runtime string `json:"runtime"`
Model string `json:"model"`
Models []modelSpec `json:"models,omitempty"`
Skills []string `json:"skills"`
SkillCount int `json:"skill_count"`
// RequiredEnv mirrors runtime_config.required_env from the template's
// config.yaml — the AND-required env vars the template declares at the
// runtime level (separate from per-model required_env). The canvas
// preflight uses this as the fallback provider when `models` is empty
// so provider picker stays data-driven instead of hardcoded in the UI.
RequiredEnv []string `json:"required_env,omitempty"`
Skills []string `json:"skills"`
SkillCount int `json:"skill_count"`
}
// resolveTemplateDir finds the template directory for a workspace on the host.
@ -100,8 +106,9 @@ func (h *TemplatesHandler) List(c *gin.Context) {
Model string `yaml:"model"`
Skills []string `yaml:"skills"`
RuntimeConfig struct {
Model string `yaml:"model"`
Models []modelSpec `yaml:"models"`
Model string `yaml:"model"`
Models []modelSpec `yaml:"models"`
RequiredEnv []string `yaml:"required_env"`
} `yaml:"runtime_config"`
}
if err := yaml.Unmarshal(data, &raw); err != nil {
@ -122,6 +129,7 @@ func (h *TemplatesHandler) List(c *gin.Context) {
Runtime: raw.Runtime,
Model: model,
Models: raw.RuntimeConfig.Models,
RequiredEnv: raw.RuntimeConfig.RequiredEnv,
Skills: raw.Skills,
SkillCount: len(raw.Skills),
})