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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user