diff --git a/app/components/settings/llm-configurations-panel.tsx b/app/components/settings/llm-configurations-panel.tsx index 028e2a4..892b5cc 100644 --- a/app/components/settings/llm-configurations-panel.tsx +++ b/app/components/settings/llm-configurations-panel.tsx @@ -59,6 +59,7 @@ import { type LlmUsageSummary, type UsageByModelRow, } from "~/lib/arcadia/llm-configs" +import { listSecrets, type Secret } from "~/lib/arcadia/secrets" const PROVIDERS: LlmProvider[] = ["openai", "anthropic", "deepseek", "qwen", "lmstudio"] const LOCAL_SETTINGS_KEY = "crema.llm-providers.settings" @@ -80,6 +81,7 @@ export function LlmConfigurationsPanel() { const [catalog, setCatalog] = useState([]) const [usage, setUsage] = useState(null) const [usageByModel, setUsageByModel] = useState([]) + const [secrets, setSecrets] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [active, setActive] = useState(null) @@ -88,16 +90,18 @@ export function LlmConfigurationsPanel() { const refresh = useCallback(async () => { setError(null) try { - const [list, cat, sum, byModel] = await Promise.all([ + const [list, cat, sum, byModel, secs] = await Promise.all([ listConfigurations(arcadia), getCatalog(arcadia).catch(() => [] as CatalogEntry[]), getUsageSummary(arcadia, { days: 30 }).catch(() => null), getUsageByModel(arcadia, { days: 30 }).catch(() => [] as UsageByModelRow[]), + listSecrets(arcadia).catch(() => [] as Secret[]), ]) setConfigs(list) setCatalog(cat) setUsage(sum) setUsageByModel(byModel) + setSecrets(secs) } catch (e) { setError(e instanceof Error ? e.message : "Failed to load configurations.") } finally { @@ -293,6 +297,7 @@ export function LlmConfigurationsPanel() { setEditing(null)} onSave={onSave} /> @@ -453,11 +458,13 @@ function ConfigRow({ function ConfigDialog({ existing, catalog, + secrets, onClose, onSave, }: { existing: LlmConfiguration | null catalog: CatalogEntry[] + secrets: Secret[] onClose: () => void onSave: ( input: LlmConfigurationInput, @@ -560,11 +567,11 @@ function ConfigDialog({ /> - - setDraft({ ...draft, secret_name: e.target.value || null })} - placeholder="llm-openai-key" + + setDraft({ ...draft, secret_name: name })} /> @@ -709,6 +716,103 @@ function ModelPicker({ ) } +/** + * Secret picker: Select populated from /api/v1/admin/secrets, filtered to + * api_key category (LLM keys live there). Includes a "(none)" option for + * keyless providers (lmstudio) and "Type a name…" for secrets that haven't + * been created yet — the latter switches to free-text and the user can + * type any name; the proxy will fail loudly at request time if it's wrong. + */ +function SecretPicker({ + value, + secrets, + onChange, +}: { + value: string | null + secrets: Secret[] + onChange: (name: string | null) => void +}) { + const apiKeys = useMemo( + () => + secrets + .filter((s) => s.category === "api_key" && s.enabled) + .sort((a, b) => a.name.localeCompare(b.name)), + [secrets], + ) + + const known = useMemo(() => new Set(apiKeys.map((s) => s.name)), [apiKeys]) + const isCustom = value != null && value !== "" && !known.has(value) + const [customMode, setCustomMode] = useState(isCustom) + + useEffect(() => { + if (!isCustom) setCustomMode(false) + }, [secrets, isCustom]) + + if (customMode) { + return ( +
+ onChange(e.target.value || null)} + placeholder="secret-name-not-yet-in-vault" + autoFocus + /> + +
+ ) + } + + // Encode null as the empty string for the Select — Radix/base-ui can't + // bind an actual null/undefined value cleanly. + const NONE = "__none__" + const CUSTOM = "__custom__" + + return ( + + ) +} + function Field({ label, children,