forked from molecule-ai/molecule-core
feat(canvas): always prompt for provider+model on multi-provider template deploy
Clicking a hermes template tile silently deployed when global env covered the API key, producing "No LLM provider configured" 500 because the workspace booted with no explicit model slug — the adapter fell back to its compiled-in default which 401s on the user's actual provider key. Fix: in useTemplateDeploy, open the picker whenever the template declares ≥2 provider options, even when preflight.ok=true. The modal renders pre-saved keys as Saved (with an Override link) and adds a model input pre-filled from the template's default. Single- provider templates (claude-code, langgraph) still skip the picker since there's nothing to choose. POST /workspaces now includes the picker's model slug so hermes- style routing reads the prefix at install time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
141ecc1c16
commit
0608e15ab3
@ -16,14 +16,35 @@ interface Props {
|
||||
/** Runtime slug — used only for the "The <runtime> runtime …"
|
||||
* headline; behavior is driven by providers/missingKeys. */
|
||||
runtime: string;
|
||||
/** Called when all required keys for the chosen provider are saved. */
|
||||
onKeysAdded: () => void;
|
||||
/** Called when all required keys for the chosen provider are saved.
|
||||
* Receives the model slug if the modal collected one (template-deploy
|
||||
* flow); legacy callers ignore it. */
|
||||
onKeysAdded: (model?: string) => void;
|
||||
/** Called when the user cancels the deploy. */
|
||||
onCancel: () => void;
|
||||
/** Optional — open the Settings Panel (Config tab → Secrets). */
|
||||
onOpenSettings?: () => void;
|
||||
/** If provided, secrets save at workspace scope instead of global. */
|
||||
workspaceId?: string;
|
||||
/** Set of env var names already configured in the relevant scope
|
||||
* (global or workspace). When provided, entries whose key is already
|
||||
* in this set start as `saved: true` so the user can confirm without
|
||||
* re-entering. Used by the template-deploy "always ask" flow so a
|
||||
* user can pick a different provider even when global env covers
|
||||
* the default one. */
|
||||
configuredKeys?: Set<string>;
|
||||
/** Model slug suggestions (datalist) — populated from the template's
|
||||
* models[]. When non-empty the picker renders a model input above
|
||||
* the API-key fields. The picker passes the entered slug back via
|
||||
* onKeysAdded. */
|
||||
modelSuggestions?: string[];
|
||||
/** Pre-fill the model input. */
|
||||
initialModel?: string;
|
||||
/** Override the modal's title + description copy. The default
|
||||
* "Missing API Keys" title misreads when the modal is opened to
|
||||
* pick provider/model with keys already configured. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface KeyEntry {
|
||||
@ -60,6 +81,11 @@ export function MissingKeysModal({
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
initialModel,
|
||||
title,
|
||||
description,
|
||||
}: Props) {
|
||||
const pickerProviders = providers ?? [];
|
||||
const pickerMode = pickerProviders.length > 1;
|
||||
@ -74,6 +100,11 @@ export function MissingKeysModal({
|
||||
onCancel={onCancel}
|
||||
onOpenSettings={onOpenSettings}
|
||||
workspaceId={workspaceId}
|
||||
configuredKeys={configuredKeys}
|
||||
modelSuggestions={modelSuggestions}
|
||||
initialModel={initialModel}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -108,17 +139,41 @@ function ProviderPickerModal({
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
initialModel,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
open: boolean;
|
||||
providers: ProviderChoice[];
|
||||
runtime: string;
|
||||
onKeysAdded: () => void;
|
||||
onKeysAdded: (model?: string) => void;
|
||||
onCancel: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
workspaceId?: string;
|
||||
configuredKeys?: Set<string>;
|
||||
modelSuggestions?: string[];
|
||||
initialModel?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(providers[0].id);
|
||||
// Prefer the first provider whose env vars are already satisfied by
|
||||
// the configured set — pre-selecting "the option the user already has
|
||||
// keys for" matches expected UX. Falls back to providers[0] otherwise.
|
||||
const initialSelected = useMemo(() => {
|
||||
if (configuredKeys) {
|
||||
const satisfied = providers.find((p) =>
|
||||
p.envVars.every((k) => configuredKeys.has(k)),
|
||||
);
|
||||
if (satisfied) return satisfied.id;
|
||||
}
|
||||
return providers[0].id;
|
||||
}, [providers, configuredKeys]);
|
||||
|
||||
const [selectedId, setSelectedId] = useState(initialSelected);
|
||||
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const [model, setModel] = useState(initialModel ?? "");
|
||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selected = useMemo(
|
||||
@ -126,10 +181,13 @@ function ProviderPickerModal({
|
||||
[providers, selectedId],
|
||||
);
|
||||
|
||||
const showModelInput = (modelSuggestions?.length ?? 0) > 0 || initialModel !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectedId(providers[0].id);
|
||||
}, [open, providers]);
|
||||
setSelectedId(initialSelected);
|
||||
setModel(initialModel ?? "");
|
||||
}, [open, initialSelected, initialModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@ -137,12 +195,15 @@ function ProviderPickerModal({
|
||||
selected.envVars.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
saved: false,
|
||||
// Pre-mark as saved when the key is already in the configured
|
||||
// set (global or workspace scope). Lets the user click Deploy
|
||||
// without re-entering a key the platform already holds.
|
||||
saved: configuredKeys?.has(key) ?? false,
|
||||
saving: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}, [open, selected]);
|
||||
}, [open, selected, configuredKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@ -243,16 +304,52 @@ function ProviderPickerModal({
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-zinc-100">
|
||||
Missing API Keys
|
||||
{title ?? "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.
|
||||
{description ?? (
|
||||
<>
|
||||
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">
|
||||
{showModelInput && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="provider-picker-model-input"
|
||||
className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5 block"
|
||||
>
|
||||
Model{" "}
|
||||
<span aria-hidden="true" className="text-red-400">*</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
<input
|
||||
id="provider-picker-model-input"
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="e.g. minimax/MiniMax-M2.7"
|
||||
aria-label="Model slug"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
list="provider-picker-model-suggestions"
|
||||
className="w-full 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"
|
||||
/>
|
||||
<datalist id="provider-picker-model-suggestions">
|
||||
{modelSuggestions?.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
<p className="text-[9px] text-zinc-500 mt-1 leading-relaxed">
|
||||
Slug determines provider routing at install time.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-[10px] uppercase tracking-wide text-zinc-500 font-semibold mb-1.5">
|
||||
Provider
|
||||
@ -303,12 +400,28 @@ function ProviderPickerModal({
|
||||
<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 className="flex items-center gap-1.5">
|
||||
<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>
|
||||
{/* Allow override when the saved state came from a
|
||||
pre-configured global secret — the user may want
|
||||
to use a different key for this workspace. */}
|
||||
{configuredKeys?.has(entry.key) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateEntry(index, { saved: false, value: "" })
|
||||
}
|
||||
className="text-[9px] text-zinc-400 hover:text-zinc-200 underline"
|
||||
>
|
||||
Override
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -364,8 +477,12 @@ function ProviderPickerModal({
|
||||
Cancel Deploy
|
||||
</button>
|
||||
<button
|
||||
onClick={onKeysAdded}
|
||||
disabled={!allSaved || anySaving}
|
||||
onClick={() => onKeysAdded(showModelInput ? model.trim() : undefined)}
|
||||
disabled={
|
||||
!allSaved ||
|
||||
anySaving ||
|
||||
(showModelInput && model.trim() === "")
|
||||
}
|
||||
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"
|
||||
>
|
||||
{allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
|
||||
|
||||
@ -27,16 +27,16 @@ import { renderHook } from "@testing-library/react";
|
||||
import type { Template } from "@/lib/deploy-preflight";
|
||||
|
||||
// ── Hoisted mocks ────────────────────────────────────────────────────────────
|
||||
const { mockApiPost, mockCheckDeploySecrets, mockResolveRuntime } = vi.hoisted(
|
||||
() => ({
|
||||
const { mockApiPost, mockApiGet, mockCheckDeploySecrets, mockResolveRuntime } =
|
||||
vi.hoisted(() => ({
|
||||
mockApiPost: vi.fn(),
|
||||
mockApiGet: vi.fn(),
|
||||
mockCheckDeploySecrets: vi.fn(),
|
||||
mockResolveRuntime: vi.fn(),
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { post: mockApiPost },
|
||||
api: { post: mockApiPost, get: mockApiGet },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/deploy-preflight", async () => {
|
||||
@ -51,20 +51,44 @@ vi.mock("@/lib/deploy-preflight", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// MissingKeysModal: render a minimal stand-in that exposes the two
|
||||
// callbacks the hook wires up. The real modal pulls in radix + the
|
||||
// secrets store, neither of which is relevant to this hook's behavior.
|
||||
// MissingKeysModal: render a minimal stand-in that exposes the
|
||||
// callbacks the hook wires up + dumps the new template-deploy props
|
||||
// (configuredKeys size, modelSuggestions, initialModel) into the
|
||||
// DOM so tests can assert on them. The real modal pulls in radix +
|
||||
// the secrets store, neither of which is relevant to this hook's
|
||||
// behavior.
|
||||
vi.mock("@/components/MissingKeysModal", () => ({
|
||||
MissingKeysModal: (props: {
|
||||
open: boolean;
|
||||
onKeysAdded: () => void;
|
||||
onKeysAdded: (model?: string) => void;
|
||||
onCancel: () => void;
|
||||
configuredKeys?: Set<string>;
|
||||
modelSuggestions?: string[];
|
||||
initialModel?: string;
|
||||
title?: string;
|
||||
}) =>
|
||||
props.open ? (
|
||||
<div data-testid="missing-keys-modal">
|
||||
<button data-testid="modal-keys-added" onClick={props.onKeysAdded}>
|
||||
<span data-testid="modal-configured-size">
|
||||
{props.configuredKeys?.size ?? 0}
|
||||
</span>
|
||||
<span data-testid="modal-model-suggestions">
|
||||
{(props.modelSuggestions ?? []).join(",")}
|
||||
</span>
|
||||
<span data-testid="modal-initial-model">{props.initialModel ?? ""}</span>
|
||||
<span data-testid="modal-title">{props.title ?? ""}</span>
|
||||
<button
|
||||
data-testid="modal-keys-added"
|
||||
onClick={() => props.onKeysAdded()}
|
||||
>
|
||||
keys added
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-keys-added-with-model"
|
||||
onClick={() => props.onKeysAdded("minimax/MiniMax-M2.7")}
|
||||
>
|
||||
keys added with model
|
||||
</button>
|
||||
<button data-testid="modal-cancel" onClick={props.onCancel}>
|
||||
cancel
|
||||
</button>
|
||||
@ -95,6 +119,7 @@ function makeTemplate(over: Partial<Template> = {}): Template {
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiPost.mockReset();
|
||||
mockApiGet.mockReset();
|
||||
mockCheckDeploySecrets.mockReset();
|
||||
mockResolveRuntime.mockReset();
|
||||
// Default: identity-mapped runtime, preflight passes.
|
||||
@ -106,6 +131,9 @@ beforeEach(() => {
|
||||
runtime: "claude-code",
|
||||
});
|
||||
mockApiPost.mockResolvedValue({ id: "ws-new" });
|
||||
// Default: secrets endpoint returns nothing so the picker
|
||||
// renders every entry as input. Multi-provider tests override.
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -287,6 +315,166 @@ describe("useTemplateDeploy — modal lifecycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
// The user-reported bug: clicking a hermes template (which has
|
||||
// multiple provider options) deployed silently when global env
|
||||
// covered the API key, producing "No LLM provider configured" 500
|
||||
// because the workspace booted with no explicit model. Fix:
|
||||
// always open the picker for multi-provider templates so the
|
||||
// user picks provider + model per workspace, even when keys are
|
||||
// already saved.
|
||||
function multiProviderTemplate(): Template {
|
||||
return makeTemplate({
|
||||
id: "hermes-template",
|
||||
name: "Hermes",
|
||||
runtime: "hermes",
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
models: [
|
||||
{ id: "minimax/MiniMax-M2.7", required_env: ["MINIMAX_API_KEY"] },
|
||||
{ id: "anthropic/claude-sonnet-4-5", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("opens picker even when preflight.ok=true (≥2 providers)", async () => {
|
||||
mockCheckDeploySecrets.mockResolvedValueOnce({
|
||||
ok: true, // every key is in global env
|
||||
missingKeys: [],
|
||||
providers: [
|
||||
{ id: "MINIMAX_API_KEY", label: "MiniMax", envVars: ["MINIMAX_API_KEY"] },
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
});
|
||||
mockApiGet.mockResolvedValueOnce([
|
||||
{ key: "MINIMAX_API_KEY", has_value: true, created_at: "", updated_at: "" },
|
||||
{ key: "ANTHROPIC_API_KEY", has_value: true, created_at: "", updated_at: "" },
|
||||
]);
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deploy(multiProviderTemplate());
|
||||
});
|
||||
|
||||
rerender();
|
||||
render(<>{result.current.modal}</>);
|
||||
|
||||
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
|
||||
// Both global keys flowed into the modal as `configuredKeys` so
|
||||
// entries can render as Saved without re-prompting.
|
||||
expect(screen.getByTestId("modal-configured-size").textContent).toBe("2");
|
||||
// Confirm POST has NOT fired yet — the user must explicitly
|
||||
// confirm in the picker even though preflight passed.
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
// Title shifts to "Configure Workspace" since keys aren't missing.
|
||||
expect(screen.getByTestId("modal-title").textContent).toBe(
|
||||
"Configure Workspace",
|
||||
);
|
||||
});
|
||||
|
||||
it("threads template.models[].id as model suggestions + template.model as initial value", async () => {
|
||||
mockCheckDeploySecrets.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
missingKeys: [],
|
||||
providers: [
|
||||
{ id: "MINIMAX_API_KEY", label: "MiniMax", envVars: ["MINIMAX_API_KEY"] },
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deploy(multiProviderTemplate());
|
||||
});
|
||||
|
||||
rerender();
|
||||
render(<>{result.current.modal}</>);
|
||||
|
||||
expect(screen.getByTestId("modal-model-suggestions").textContent).toBe(
|
||||
"minimax/MiniMax-M2.7,anthropic/claude-sonnet-4-5",
|
||||
);
|
||||
expect(screen.getByTestId("modal-initial-model").textContent).toBe(
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /workspaces includes model when picker confirms with one", async () => {
|
||||
mockCheckDeploySecrets.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
missingKeys: [],
|
||||
providers: [
|
||||
{ id: "MINIMAX_API_KEY", label: "MiniMax", envVars: ["MINIMAX_API_KEY"] },
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deploy(multiProviderTemplate());
|
||||
});
|
||||
|
||||
rerender();
|
||||
render(<>{result.current.modal}</>);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("modal-keys-added-with-model"));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledWith(
|
||||
"/workspaces",
|
||||
expect.objectContaining({
|
||||
template: "hermes-template",
|
||||
model: "minimax/MiniMax-M2.7",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("single-provider template still skips picker when preflight.ok", async () => {
|
||||
// Default preflight mock: ok=true, providers=[]. claude-code is
|
||||
// single-provider — there's nothing to choose, so the picker
|
||||
// SHOULD remain hidden. Regression guard: don't accidentally
|
||||
// make every deploy require a click-through.
|
||||
const onDeployed = vi.fn();
|
||||
const { result } = renderHook(() => useTemplateDeploy({ onDeployed }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate());
|
||||
});
|
||||
|
||||
expect(mockApiPost).toHaveBeenCalledTimes(1);
|
||||
expect(onDeployed).toHaveBeenCalledWith("ws-new");
|
||||
});
|
||||
|
||||
it("secrets fetch failure still opens picker (empty configuredKeys)", async () => {
|
||||
mockCheckDeploySecrets.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
missingKeys: [],
|
||||
providers: [
|
||||
{ id: "MINIMAX_API_KEY", label: "MiniMax", envVars: ["MINIMAX_API_KEY"] },
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
});
|
||||
mockApiGet.mockRejectedValueOnce(new Error("secrets fetch down"));
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deploy(multiProviderTemplate());
|
||||
});
|
||||
|
||||
rerender();
|
||||
render(<>{result.current.modal}</>);
|
||||
|
||||
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
|
||||
expect(screen.getByTestId("modal-configured-size").textContent).toBe("0");
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTemplateDeploy — POST failure", () => {
|
||||
it("POST rejection sets error and clears deploying", async () => {
|
||||
mockApiPost.mockRejectedValueOnce(new Error("server 500"));
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
checkDeploySecrets,
|
||||
resolveRuntime,
|
||||
type PreflightResult,
|
||||
type SecretEntry,
|
||||
type Template,
|
||||
} from "@/lib/deploy-preflight";
|
||||
import { MissingKeysModal } from "@/components/MissingKeysModal";
|
||||
@ -44,10 +45,15 @@ export interface UseTemplateDeployOptions {
|
||||
/** Paired template + preflight result carried through the "user
|
||||
* clicked deploy → modal opens → keys saved → retry" loop. Named
|
||||
* so the `useState` generic and any future signature change have
|
||||
* a single place to track. */
|
||||
* a single place to track. `configuredKeys` lets the modal mark
|
||||
* pre-saved entries (global secrets) without re-prompting — the
|
||||
* template-deploy "always ask" flow surfaces the picker even when
|
||||
* preflight.ok is true so the user can pick a different provider
|
||||
* per workspace. */
|
||||
interface MissingKeysInfo {
|
||||
template: Template;
|
||||
preflight: PreflightResult;
|
||||
configuredKeys: Set<string>;
|
||||
}
|
||||
|
||||
export interface UseTemplateDeployResult {
|
||||
@ -81,9 +87,14 @@ export function useTemplateDeploy(
|
||||
|
||||
/** Actually execute the POST /workspaces call. Split from `deploy`
|
||||
* so the "modal → keys added → retry" path can reuse it without
|
||||
* re-running preflight (the user just proved the keys are now set). */
|
||||
* re-running preflight (the user just proved the keys are now set).
|
||||
*
|
||||
* `model` (optional) is the user-picked model slug from the picker
|
||||
* modal. When the template is multi-provider, hermes-style routing
|
||||
* reads the slug prefix at install time to pick the upstream
|
||||
* endpoint, so the slug must reach the workspace verbatim. */
|
||||
const executeDeploy = useCallback(
|
||||
async (template: Template) => {
|
||||
async (template: Template, model?: string) => {
|
||||
setDeploying(template.id);
|
||||
setError(null);
|
||||
try {
|
||||
@ -98,6 +109,7 @@ export function useTemplateDeploy(
|
||||
template: template.id,
|
||||
tier: template.tier,
|
||||
canvas: coords,
|
||||
...(model ? { model } : {}),
|
||||
});
|
||||
onDeployed?.(ws.id);
|
||||
} catch (e) {
|
||||
@ -133,8 +145,33 @@ export function useTemplateDeploy(
|
||||
setDeploying(null);
|
||||
return;
|
||||
}
|
||||
if (!preflight.ok) {
|
||||
setMissingKeysInfo({ template, preflight });
|
||||
// Open the picker modal whenever preflight failed OR the
|
||||
// template offers ≥2 provider options. Multi-provider
|
||||
// templates (e.g. hermes) need an explicit per-workspace
|
||||
// pick — silently inheriting whichever global key matches
|
||||
// produces the "No LLM provider configured" 500 because the
|
||||
// adapter falls back to its compiled-in default model when
|
||||
// the user hasn't asserted a slug. The "always ask" rule
|
||||
// skips claude-code / langgraph-style single-provider
|
||||
// templates where there's nothing to choose between.
|
||||
const shouldShowPicker =
|
||||
!preflight.ok || preflight.providers.length >= 2;
|
||||
if (shouldShowPicker) {
|
||||
// Read the secret set the modal needs to mark pre-saved
|
||||
// entries. We did this inside checkDeploySecrets too but
|
||||
// didn't surface it; pull it again so a slow secrets
|
||||
// endpoint failing here doesn't block the modal — empty
|
||||
// set just means everything renders as input.
|
||||
let configuredKeys = new Set<string>();
|
||||
try {
|
||||
const secrets = await api.get<SecretEntry[]>("/settings/secrets");
|
||||
configuredKeys = new Set(
|
||||
secrets.filter((s) => s.has_value).map((s) => s.key),
|
||||
);
|
||||
} catch {
|
||||
// Empty set — modal will render every entry as input.
|
||||
}
|
||||
setMissingKeysInfo({ template, preflight, configuredKeys });
|
||||
setDeploying(null);
|
||||
return;
|
||||
}
|
||||
@ -147,19 +184,43 @@ export function useTemplateDeploy(
|
||||
// (it's placed inline in JSX), and useCallback's deps would
|
||||
// invalidate on every state change, making the memoisation a wash.
|
||||
// Plain ReactNode is simpler and equally performant.
|
||||
const isMultiProvider = (missingKeysInfo?.preflight.providers.length ?? 0) >= 2;
|
||||
// Suggestions for the model field — pull declared model ids from the
|
||||
// template. Templates without `models` declared (e.g. claude-code)
|
||||
// pass [] which suppresses the model field entirely.
|
||||
const modelSuggestions =
|
||||
missingKeysInfo?.template.models?.map((m) => m.id) ?? [];
|
||||
// Pre-fill the model input with the template's default `model` so
|
||||
// confirming without changing it preserves today's behaviour.
|
||||
const initialModel = missingKeysInfo?.template.model;
|
||||
// When the user has keys configured (preflight.ok) we re-purpose the
|
||||
// modal as a "confirm provider/model" prompt — adjust copy
|
||||
// accordingly so it doesn't claim keys are missing.
|
||||
const allConfigured = missingKeysInfo?.preflight.ok ?? false;
|
||||
const modalTitle = allConfigured
|
||||
? "Configure Workspace"
|
||||
: undefined;
|
||||
const modalDescription = allConfigured
|
||||
? "Pick the provider and model for this workspace. Saved API keys are reused — click Override to use a different one."
|
||||
: undefined;
|
||||
const modal: ReactNode = (
|
||||
<MissingKeysModal
|
||||
open={!!missingKeysInfo}
|
||||
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
|
||||
providers={missingKeysInfo?.preflight.providers ?? []}
|
||||
runtime={missingKeysInfo?.preflight.runtime ?? ""}
|
||||
onKeysAdded={() => {
|
||||
configuredKeys={missingKeysInfo?.configuredKeys}
|
||||
modelSuggestions={isMultiProvider ? modelSuggestions : undefined}
|
||||
initialModel={isMultiProvider ? initialModel : undefined}
|
||||
title={modalTitle}
|
||||
description={modalDescription}
|
||||
onKeysAdded={(model?: string) => {
|
||||
if (missingKeysInfo) {
|
||||
const template = missingKeysInfo.template;
|
||||
setMissingKeysInfo(null);
|
||||
// Intentional fire-and-forget — executeDeploy manages
|
||||
// its own error state via setError.
|
||||
void executeDeploy(template);
|
||||
void executeDeploy(template, model);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setMissingKeysInfo(null)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user