LLM configs: vault secret picker (Select from api_key secrets)

Replaces the free-text secret name input with a Select populated from
/api/v1/admin/secrets, filtered to category=api_key + enabled. Each
option shows the secret name plus its description for context.

Includes "(none — keyless / local)" for lmstudio-style configs and a
"Type a name…" escape hatch for secrets that don't exist in the vault
yet (the proxy will fail loudly at request time if the name is wrong,
which is the right behaviour — better than silently saving a config
that can't authenticate).

Secrets are fetched once on panel load alongside configs/catalog/usage,
not per modal-open, so the dialog opens instantly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-02 18:49:47 +10:00
parent 5dfceeff94
commit b397bbcb9e

View File

@@ -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<CatalogEntry[]>([])
const [usage, setUsage] = useState<LlmUsageSummary | null>(null)
const [usageByModel, setUsageByModel] = useState<UsageByModelRow[]>([])
const [secrets, setSecrets] = useState<Secret[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [active, setActive] = useState<LLMProvidersSettings | null>(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() {
<ConfigDialog
existing={editing === "new" ? null : editing}
catalog={catalog}
secrets={secrets}
onClose={() => 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({
/>
</Field>
<Field label="Vault secret name (optional)" className="sm:col-span-2">
<Input
value={draft.secret_name ?? ""}
onChange={(e) => setDraft({ ...draft, secret_name: e.target.value || null })}
placeholder="llm-openai-key"
<Field label="Vault secret (optional)" className="sm:col-span-2">
<SecretPicker
value={draft.secret_name ?? null}
secrets={secrets}
onChange={(name) => setDraft({ ...draft, secret_name: name })}
/>
</Field>
@@ -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 (
<div className="flex gap-2">
<Input
value={value ?? ""}
onChange={(e) => onChange(e.target.value || null)}
placeholder="secret-name-not-yet-in-vault"
autoFocus
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCustomMode(false)
onChange(null)
}}
>
Pick
</Button>
</div>
)
}
// 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 (
<Select
value={value == null ? NONE : known.has(value) ? value : ""}
onValueChange={(v) => {
if (v === NONE) onChange(null)
else if (v === CUSTOM) {
setCustomMode(true)
onChange("")
} else onChange(v)
}}
>
<SelectTrigger>
<SelectValue placeholder={apiKeys.length ? "Pick a secret…" : "No api_key secrets yet"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>
<span className="text-muted-foreground">(none keyless / local)</span>
</SelectItem>
{apiKeys.map((s) => (
<SelectItem key={s.id} value={s.name}>
<span className="flex flex-col items-start">
<span className="font-mono text-xs">{s.name}</span>
{s.description ? (
<span className="text-[10px] text-muted-foreground">{s.description}</span>
) : null}
</span>
</SelectItem>
))}
<SelectItem value={CUSTOM}>
<span className="text-muted-foreground">Type a name</span>
</SelectItem>
</SelectContent>
</Select>
)
}
function Field({
label,
children,