Prompt for template provider env config #1846

Merged
hongming merged 1 commits from fix/seo-template-provider-env-prompt into main 2026-05-25 13:50:30 +00:00
8 changed files with 361 additions and 22 deletions
+240 -18
View File
@@ -23,6 +23,8 @@ interface Props {
/** Grouped provider options derived from the template's models[] /
* required_env. When length ≥ 2 the modal shows a radio picker. */
providers?: ProviderChoice[];
/** Optional keys to offer in the deploy modal without blocking Deploy. */
optionalKeys?: string[];
/** Runtime slug — used only for the "The <runtime> runtime …"
* headline; behavior is driven by providers/missingKeys. */
runtime: string;
@@ -94,13 +96,13 @@ export function MissingKeysModal({
open,
missingKeys,
providers,
optionalKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
modelSuggestions,
models,
initialModel,
title,
@@ -114,13 +116,13 @@ export function MissingKeysModal({
<ProviderPickerModal
open={open}
providers={pickerProviders}
optionalKeys={optionalKeys ?? []}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
configuredKeys={configuredKeys}
modelSuggestions={modelSuggestions}
models={models}
initialModel={initialModel}
title={title}
@@ -138,11 +140,15 @@ export function MissingKeysModal({
<AllKeysModal
open={open}
missingKeys={keys}
optionalKeys={optionalKeys ?? []}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
configuredKeys={configuredKeys}
title={title}
description={description}
/>
);
}
@@ -170,13 +176,13 @@ export function providerIdForModel(
function ProviderPickerModal({
open,
providers,
optionalKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
modelSuggestions,
models,
initialModel,
title,
@@ -184,13 +190,13 @@ function ProviderPickerModal({
}: {
open: boolean;
providers: ProviderChoice[];
optionalKeys: string[];
runtime: string;
onKeysAdded: (model?: string) => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
configuredKeys?: Set<string>;
modelSuggestions?: string[];
models?: ModelSpec[];
initialModel?: string;
title?: string;
@@ -250,16 +256,9 @@ function ProviderPickerModal({
const [selectorValue, setSelectorValue] = useState<SelectorValue>(initial);
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
const firstInputRef = useRef<HTMLInputElement>(null);
// Legacy compat: map the selector value back into the old `selected`/
// `model` shape for the rest of the modal body (footer copy, etc.).
const selected = useMemo(
() =>
providers.find((p) => p.id === selectorValue.providerId) ??
providers[0],
[providers, selectorValue.providerId],
);
const model = selectorValue.model;
const showModelInput = catalog.length > 0;
@@ -282,7 +281,18 @@ function ProviderPickerModal({
error: null,
})),
);
}, [open, selectorValue.envVars, configuredKeys]);
setOptionalEntries(
optionalKeys
.filter((key) => !selectorValue.envVars.includes(key))
.map((key) => ({
key,
value: "",
saved: configuredKeys?.has(key) ?? false,
saving: false,
error: null,
})),
);
}, [open, selectorValue.envVars, configuredKeys, optionalKeys]);
useEffect(() => {
if (!open) return;
@@ -336,6 +346,43 @@ function ProviderPickerModal({
[entries, updateEntry, workspaceId],
);
const updateOptionalEntry = useCallback(
(index: number, updates: Partial<KeyEntry>) => {
setOptionalEntries((prev) =>
prev.map((e, i) => (i === index ? { ...e, ...updates } : e)),
);
},
[],
);
const handleSaveOptionalKey = useCallback(
async (index: number) => {
const entry = optionalEntries[index];
if (!entry.value.trim()) return;
updateOptionalEntry(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(),
});
}
updateOptionalEntry(index, { saved: true, saving: false });
} catch (e) {
updateOptionalEntry(index, {
saving: false,
error: e instanceof Error ? e.message : "Failed to save",
});
}
},
[optionalEntries, updateOptionalEntry, workspaceId],
);
if (!open) return null;
// Portal to document.body for the same reason as
// OrgImportPreflightModal — several callers (TemplatePalette,
@@ -465,6 +512,62 @@ function ProviderPickerModal({
</div>
))}
</div>
{optionalEntries.length > 0 && (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold">
Optional
</div>
{optionalEntries.map((entry, index) => (
<div
key={entry.key}
className="bg-surface-card/30 rounded-lg px-3 py-2.5 border border-line/40"
>
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
Saved
</span>
)}
</div>
{!entry.saved && (
<div className="flex gap-2 mt-2">
<input
value={entry.value}
onChange={(e) => updateOptionalEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
aria-label={`Optional value for ${entry.key}`}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
handleSaveOptionalKey(index);
}
}}
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
<button
type="button"
onClick={() => handleSaveOptionalKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card/80 text-[11px] rounded text-ink border border-line disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && (
<div role="alert" aria-live="assertive" className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
)}
</div>
))}
</div>
)}
</div>
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
@@ -512,21 +615,30 @@ function ProviderPickerModal({
function AllKeysModal({
open,
missingKeys,
optionalKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
title,
description,
}: {
open: boolean;
missingKeys: string[];
optionalKeys: string[];
runtime: string;
onKeysAdded: () => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
configuredKeys?: Set<string>;
title?: string;
description?: string;
}) {
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
useEffect(() => {
@@ -535,13 +647,24 @@ function AllKeysModal({
missingKeys.map((key) => ({
key,
value: "",
saved: false,
saved: configuredKeys?.has(key) ?? false,
saving: false,
error: null,
})),
);
setOptionalEntries(
optionalKeys
.filter((key) => !missingKeys.includes(key))
.map((key) => ({
key,
value: "",
saved: configuredKeys?.has(key) ?? false,
saving: false,
error: null,
})),
);
setGlobalError(null);
}, [open, missingKeys]);
}, [open, missingKeys, optionalKeys, configuredKeys]);
useEffect(() => {
if (!open) return;
@@ -591,6 +714,45 @@ function AllKeysModal({
[entries, updateEntry, workspaceId],
);
const updateOptionalEntry = useCallback(
(index: number, updates: Partial<KeyEntry>) => {
setOptionalEntries((prev) =>
prev.map((entry, i) => (i === index ? { ...entry, ...updates } : entry)),
);
},
[],
);
const handleSaveOptionalKey = useCallback(
async (index: number) => {
const entry = optionalEntries[index];
if (!entry.value.trim()) return;
updateOptionalEntry(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(),
});
}
updateOptionalEntry(index, { saved: true, saving: false });
} catch (e) {
updateOptionalEntry(index, {
saving: false,
error: e instanceof Error ? e.message : "Failed to save",
});
}
},
[optionalEntries, updateOptionalEntry, workspaceId],
);
const handleAddKeysAndDeploy = useCallback(() => {
const anySaving = entries.some((e) => e.saving);
if (anySaving) {
@@ -656,12 +818,16 @@ function AllKeysModal({
</svg>
</div>
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
Missing API Keys
{title ?? "Missing API Keys"}
</h3>
</div>
<p className="text-[12px] text-ink-mid leading-relaxed">
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
runtime requires the following keys to be configured before deploying.
{description ?? (
<>
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
runtime requires the following keys to be configured before deploying.
</>
)}
</p>
</div>
@@ -719,6 +885,62 @@ function AllKeysModal({
</div>
))}
{optionalEntries.length > 0 && (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold">
Optional
</div>
{optionalEntries.map((entry, index) => (
<div
key={entry.key}
className="bg-surface-card/30 rounded-lg px-3 py-2.5 border border-line/40"
>
<div className="flex items-center justify-between mb-1">
<div>
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded">
Saved
</span>
)}
</div>
{!entry.saved && (
<div className="flex gap-2 mt-2">
<input
value={entry.value}
onChange={(e) => updateOptionalEntry(index, { value: e.target.value.trimStart() })}
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
type="password"
aria-label={`Optional value for ${entry.key}`}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
handleSaveOptionalKey(index);
}
}}
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
<button
type="button"
onClick={() => handleSaveOptionalKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card/80 text-[11px] rounded text-ink border border-line disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && <div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>}
</div>
))}
</div>
)}
{globalError && (
<div role="alert" aria-live="assertive" className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
{globalError}
@@ -402,6 +402,31 @@ describe("MissingKeysModal — add keys and deploy", () => {
expect(onKeysAdded).toHaveBeenCalled();
});
it("shows optional keys without blocking deploy", () => {
const onKeysAdded = vi.fn();
render(
<MissingKeysModal
open={true}
missingKeys={[]}
optionalKeys={["GOOGLE_GSC_SITE"]}
runtime="claude-code"
title="Configure Workspace"
onKeysAdded={onKeysAdded}
onCancel={vi.fn()}
/>
);
expect(screen.getByText("Optional")).toBeTruthy();
expect(screen.getAllByText("GOOGLE_GSC_SITE").length).toBeGreaterThan(0);
const deployBtn = Array.from(document.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Deploy",
);
expect(deployBtn).toBeTruthy();
expect(deployBtn!.disabled).toBe(false);
act(() => { fireEvent.click(deployBtn!); });
expect(onKeysAdded).toHaveBeenCalled();
});
it("shows global error when not all keys saved", async () => {
const onKeysAdded = vi.fn();
render(
@@ -529,4 +554,4 @@ describe("MissingKeysModal — cancel and settings", () => {
);
expect(screen.queryByRole("button", { name: /open settings/i })).toBeNull();
});
});
});
@@ -63,6 +63,7 @@ vi.mock("@/components/MissingKeysModal", () => ({
onKeysAdded: (model?: string) => void;
onCancel: () => void;
configuredKeys?: Set<string>;
optionalKeys?: string[];
modelSuggestions?: string[];
initialModel?: string;
title?: string;
@@ -77,6 +78,9 @@ vi.mock("@/components/MissingKeysModal", () => ({
</span>
<span data-testid="modal-initial-model">{props.initialModel ?? ""}</span>
<span data-testid="modal-title">{props.title ?? ""}</span>
<span data-testid="modal-optional-keys">
{(props.optionalKeys ?? []).join(",")}
</span>
<button
data-testid="modal-keys-added"
onClick={() => props.onKeysAdded()}
@@ -113,6 +117,7 @@ function makeTemplate(over: Partial<Template> = {}): Template {
runtime: "claude-code",
models: [],
required_env: [],
recommended_env: [],
...over,
};
}
@@ -129,6 +134,7 @@ beforeEach(() => {
missingKeys: [],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
mockApiPost.mockResolvedValue({ id: "ws-new" });
@@ -243,6 +249,7 @@ describe("useTemplateDeploy — preflight failure modes", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const onDeployed = vi.fn();
@@ -271,6 +278,7 @@ describe("useTemplateDeploy — modal lifecycle", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const onDeployed = vi.fn();
@@ -306,6 +314,7 @@ describe("useTemplateDeploy — modal lifecycle", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -359,6 +368,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(["MINIMAX_API_KEY", "ANTHROPIC_API_KEY"]),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -392,6 +402,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -420,6 +431,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -484,6 +496,7 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
],
runtime: "hermes",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -499,6 +512,35 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
expect(screen.getByTestId("modal-configured-size").textContent).toBe("0");
expect(mockApiPost).not.toHaveBeenCalled();
});
it("opens configure modal for optional env prompts even when no required provider key is missing", async () => {
mockCheckDeploySecrets.mockResolvedValueOnce({
ok: true,
missingKeys: [],
providers: [],
runtime: "claude-code",
optionalKeys: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate({
id: "seo-agent",
name: "SEO Agent",
recommended_env: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
}));
});
rerender();
render(<>{result.current.modal}</>);
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
expect(screen.getByTestId("modal-optional-keys").textContent).toBe(
"GOOGLE_GSC_SITE,GOOGLE_GA4_PROPERTY_ID",
);
expect(mockApiPost).not.toHaveBeenCalled();
});
});
describe("useTemplateDeploy — POST failure", () => {
+7 -1
View File
@@ -152,6 +152,7 @@ export function useTemplateDeploy(
runtime,
models: template.models,
required_env: template.required_env,
recommended_env: template.recommended_env,
});
} catch (e) {
// Preflight network failure used to strand `deploying` — the
@@ -165,7 +166,11 @@ export function useTemplateDeploy(
setDeploying(null);
return;
}
if (preflight.ok && preflight.providers.length === 0) {
if (
preflight.ok &&
preflight.providers.length === 0 &&
preflight.optionalKeys.length === 0
) {
await executeDeploy(template);
return;
}
@@ -220,6 +225,7 @@ export function useTemplateDeploy(
<MissingKeysModal
open={!!missingKeysInfo}
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
optionalKeys={missingKeysInfo?.preflight.optionalKeys ?? []}
providers={missingKeysInfo?.preflight.providers ?? []}
runtime={missingKeysInfo?.preflight.runtime ?? ""}
configuredKeys={missingKeysInfo?.preflight.configuredKeys}
@@ -37,6 +37,11 @@ const CLAUDE_CODE: TemplateLike = {
required_env: ["OPENAI_API_KEY"],
};
const OPTIONAL_ONLY: TemplateLike = {
runtime: "claude-code",
recommended_env: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
};
const UNKNOWN: TemplateLike = { runtime: "nothing-declared" };
// -----------------------------------------------------------------------------
@@ -154,6 +159,7 @@ describe("checkDeploySecrets", () => {
const result = await checkDeploySecrets(CLAUDE_CODE);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(result.optionalKeys).toEqual([]);
expect(result.runtime).toBe("claude-code");
});
@@ -184,6 +190,7 @@ describe("checkDeploySecrets", () => {
);
// Grouped providers preserved for the picker.
expect(result.providers).toHaveLength(3);
expect(result.optionalKeys).toEqual([]);
});
it("treats has_value=false as not-configured", async () => {
@@ -207,6 +214,22 @@ describe("checkDeploySecrets", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it("prompts optional-only env without treating it as missing", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const result = await checkDeploySecrets(OPTIONAL_ONLY);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(result.optionalKeys).toEqual([
"GOOGLE_GSC_SITE",
"GOOGLE_GA4_PROPERTY_ID",
]);
expect(global.fetch).toHaveBeenCalled();
});
it("uses the workspace-scoped endpoint when workspaceId is provided", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
@@ -244,6 +267,7 @@ describe("checkDeploySecrets", () => {
const result = await checkDeploySecrets(CLAUDE_CODE);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
expect(result.optionalKeys).toEqual([]);
// Empty Set on fetch failure — useTemplateDeploy relies on this
// so the picker still opens with every entry rendered as input.
expect(result.configuredKeys).toEqual(new Set());
+10 -2
View File
@@ -31,6 +31,8 @@ export interface TemplateLike {
models?: ModelSpec[];
/** AND-required env vars declared at runtime_config level. */
required_env?: string[];
/** Optional env vars declared at runtime_config level. */
recommended_env?: string[];
}
/** Full /templates response shape shared by TemplatePalette (sidebar)
@@ -95,6 +97,8 @@ export interface PreflightResult {
/** 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[];
/** Optional env vars to offer in the modal without blocking deploy. */
optionalKeys: 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). */
@@ -247,12 +251,14 @@ export async function checkDeploySecrets(
): Promise<PreflightResult> {
const providers = providersFromTemplate(template);
const runtime = template.runtime;
const optionalKeys = Array.from(new Set(template.recommended_env ?? []));
if (providers.length === 0) {
if (providers.length === 0 && optionalKeys.length === 0) {
// Template declares no env requirements — nothing to preflight.
return {
ok: true,
missingKeys: [],
optionalKeys: [],
providers: [],
runtime,
configuredKeys: new Set(),
@@ -274,10 +280,11 @@ export async function checkDeploySecrets(
configured = new Set();
}
if (findSatisfiedProvider(providers, configured)) {
if (providers.length === 0 || findSatisfiedProvider(providers, configured)) {
return {
ok: true,
missingKeys: [],
optionalKeys,
providers,
runtime,
configuredKeys: configured,
@@ -292,6 +299,7 @@ export async function checkDeploySecrets(
return {
ok: false,
missingKeys,
optionalKeys,
providers,
runtime,
configuredKeys: configured,
@@ -116,6 +116,10 @@ type templateSummary struct {
// 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"`
// RecommendedEnv mirrors runtime_config.recommended_env from the
// template's config.yaml. Canvas prompts for these as non-blocking
// optional secrets during template deploy.
RecommendedEnv []string `json:"recommended_env,omitempty"`
// Providers is the runtime's own list of supported provider slugs,
// sourced from runtime_config.providers in the template's config.yaml.
// The canvas Config tab surfaces this as the Provider override
@@ -188,6 +192,7 @@ func (h *TemplatesHandler) List(c *gin.Context) {
Model string `yaml:"model"`
Models []modelSpec `yaml:"models"`
RequiredEnv []string `yaml:"required_env"`
RecommendedEnv []string `yaml:"recommended_env"`
Providers []string `yaml:"providers"`
ProvisionTimeoutSeconds int `yaml:"provision_timeout_seconds"`
} `yaml:"runtime_config"`
@@ -229,6 +234,7 @@ func (h *TemplatesHandler) List(c *gin.Context) {
Model: model,
Models: raw.RuntimeConfig.Models,
RequiredEnv: raw.RuntimeConfig.RequiredEnv,
RecommendedEnv: raw.RuntimeConfig.RecommendedEnv,
Providers: raw.RuntimeConfig.Providers,
ProviderRegistry: raw.Providers,
Skills: raw.Skills,
@@ -148,6 +148,7 @@ tier: 2
runtime: hermes
runtime_config:
model: nous-hermes-3-70b
recommended_env: [GOOGLE_GSC_SITE, GOOGLE_GA4_PROPERTY_ID]
models:
- id: nous-hermes-3-70b
name: Nous Hermes 3 70B
@@ -199,6 +200,11 @@ skills: []
if len(got.Models[1].RequiredEnv) != 1 || got.Models[1].RequiredEnv[0] != "OPENROUTER_API_KEY" {
t.Errorf("Models[1] required_env: want [OPENROUTER_API_KEY], got %+v", got.Models[1].RequiredEnv)
}
if len(got.RecommendedEnv) != 2 ||
got.RecommendedEnv[0] != "GOOGLE_GSC_SITE" ||
got.RecommendedEnv[1] != "GOOGLE_GA4_PROPERTY_ID" {
t.Errorf("RecommendedEnv: want [GOOGLE_GSC_SITE GOOGLE_GA4_PROPERTY_ID], got %+v", got.RecommendedEnv)
}
}
// TestTemplatesList_SurfacesProviders pins the Option B PR-5 wiring: