forked from molecule-ai/molecule-core
feat(canvas): provider-picker MissingKeysModal for multi-provider runtimes
Runtimes like Hermes and LangGraph accept any one of several LLM
provider keys (OpenRouter OR OpenAI OR Anthropic OR Nous-native).
Before this change, the missing-keys modal treated all supported
providers as simultaneously required — a fresh user on Hermes was
asked for three parallel API keys when any one suffices.
Introduces RUNTIME_PROVIDERS in deploy-preflight.ts as the canonical
per-runtime provider list (label, envVar, note). checkDeploySecrets
now returns all alternatives as missingKeys when nothing is
configured, so the modal can offer a picker.
MissingKeysModal dispatches between two render paths:
* ProviderPickerModal — radio list of supported providers, a single
env input for the chosen one. Saving that one key satisfies the
preflight. Activated whenever the runtime has ≥2 provider choices.
* AllKeysModal — legacy parallel-inputs UX, all keys must be saved
before deploy. Kept for single-provider runtimes (claude-code,
gemini-cli) and callers that pass unrelated-key lists.
Dual-mode preserves the pre-existing contract for every caller while
fixing the multi-provider UX. All 930 canvas vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
03b56fa5af
commit
baa7e1531f
@ -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 (
|
||||
<ProviderPickerModal
|
||||
open={open}
|
||||
providers={providers}
|
||||
runtime={runtime}
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={onCancel}
|
||||
onOpenSettings={onOpenSettings}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AllKeysModal
|
||||
open={open}
|
||||
missingKeys={missingKeys}
|
||||
runtime={runtime}
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={onCancel}
|
||||
onOpenSettings={onOpenSettings}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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<string | null>(null);
|
||||
const firstInputRef = useRef<HTMLInputElement>(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 (
|
||||
<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
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="missing-keys-title"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
<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" />
|
||||
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-zinc-100">
|
||||
Missing API Keys
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5">
|
||||
Provider
|
||||
</legend>
|
||||
{providers.map((p) => (
|
||||
<label
|
||||
key={p.id}
|
||||
className={`flex items-start gap-2.5 rounded-lg border px-3 py-2 cursor-pointer transition-colors ${
|
||||
selectedId === p.id
|
||||
? "bg-blue-600/15 border-blue-500/50"
|
||||
: "bg-zinc-800/40 border-zinc-700/50 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="provider"
|
||||
value={p.id}
|
||||
checked={selectedId === p.id}
|
||||
onChange={() => {
|
||||
setSelectedId(p.id);
|
||||
setValue("");
|
||||
setSaved(false);
|
||||
setError(null);
|
||||
}}
|
||||
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>
|
||||
{p.note && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{error && <div className="mt-1.5 text-[10px] text-red-400">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Open Settings Panel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel Deploy
|
||||
</button>
|
||||
<button
|
||||
onClick={onKeysAdded}
|
||||
disabled={!saved || saving}
|
||||
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"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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<KeyEntry[]>([]);
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
const firstInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<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} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="missing-keys-title"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<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">
|
||||
<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 1L11 10H1L6 1Z" stroke="#fbbf24" strokeWidth="1.2" strokeLinejoin="round" />
|
||||
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
|
||||
</svg>
|
||||
@ -174,7 +460,6 @@ export function MissingKeysModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body — key list */}
|
||||
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
@ -183,12 +468,8 @@ export function MissingKeysModal({
|
||||
>
|
||||
<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-[9px] font-mono text-zinc-500">
|
||||
{entry.key}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-300 font-medium">{entry.label}</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">
|
||||
@ -225,9 +506,7 @@ export function MissingKeysModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && (
|
||||
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
|
||||
)}
|
||||
{entry.error && <div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -238,7 +517,6 @@ export function MissingKeysModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{onOpenSettings && (
|
||||
|
||||
@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, string[]> = {
|
||||
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<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",
|
||||
@ -32,6 +86,24 @@ export const KEY_LABELS: Record<string, string> = {
|
||||
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<string>,
|
||||
): 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<PreflightResult> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user