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 LlmUsageSummary,
|
||||||
type UsageByModelRow,
|
type UsageByModelRow,
|
||||||
} from "~/lib/arcadia/llm-configs"
|
} from "~/lib/arcadia/llm-configs"
|
||||||
|
import { listSecrets, type Secret } from "~/lib/arcadia/secrets"
|
||||||
|
|
||||||
const PROVIDERS: LlmProvider[] = ["openai", "anthropic", "deepseek", "qwen", "lmstudio"]
|
const PROVIDERS: LlmProvider[] = ["openai", "anthropic", "deepseek", "qwen", "lmstudio"]
|
||||||
const LOCAL_SETTINGS_KEY = "crema.llm-providers.settings"
|
const LOCAL_SETTINGS_KEY = "crema.llm-providers.settings"
|
||||||
@@ -80,6 +81,7 @@ export function LlmConfigurationsPanel() {
|
|||||||
const [catalog, setCatalog] = useState<CatalogEntry[]>([])
|
const [catalog, setCatalog] = useState<CatalogEntry[]>([])
|
||||||
const [usage, setUsage] = useState<LlmUsageSummary | null>(null)
|
const [usage, setUsage] = useState<LlmUsageSummary | null>(null)
|
||||||
const [usageByModel, setUsageByModel] = useState<UsageByModelRow[]>([])
|
const [usageByModel, setUsageByModel] = useState<UsageByModelRow[]>([])
|
||||||
|
const [secrets, setSecrets] = useState<Secret[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [active, setActive] = useState<LLMProvidersSettings | null>(null)
|
const [active, setActive] = useState<LLMProvidersSettings | null>(null)
|
||||||
@@ -88,16 +90,18 @@ export function LlmConfigurationsPanel() {
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [list, cat, sum, byModel] = await Promise.all([
|
const [list, cat, sum, byModel, secs] = await Promise.all([
|
||||||
listConfigurations(arcadia),
|
listConfigurations(arcadia),
|
||||||
getCatalog(arcadia).catch(() => [] as CatalogEntry[]),
|
getCatalog(arcadia).catch(() => [] as CatalogEntry[]),
|
||||||
getUsageSummary(arcadia, { days: 30 }).catch(() => null),
|
getUsageSummary(arcadia, { days: 30 }).catch(() => null),
|
||||||
getUsageByModel(arcadia, { days: 30 }).catch(() => [] as UsageByModelRow[]),
|
getUsageByModel(arcadia, { days: 30 }).catch(() => [] as UsageByModelRow[]),
|
||||||
|
listSecrets(arcadia).catch(() => [] as Secret[]),
|
||||||
])
|
])
|
||||||
setConfigs(list)
|
setConfigs(list)
|
||||||
setCatalog(cat)
|
setCatalog(cat)
|
||||||
setUsage(sum)
|
setUsage(sum)
|
||||||
setUsageByModel(byModel)
|
setUsageByModel(byModel)
|
||||||
|
setSecrets(secs)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Failed to load configurations.")
|
setError(e instanceof Error ? e.message : "Failed to load configurations.")
|
||||||
} finally {
|
} finally {
|
||||||
@@ -293,6 +297,7 @@ export function LlmConfigurationsPanel() {
|
|||||||
<ConfigDialog
|
<ConfigDialog
|
||||||
existing={editing === "new" ? null : editing}
|
existing={editing === "new" ? null : editing}
|
||||||
catalog={catalog}
|
catalog={catalog}
|
||||||
|
secrets={secrets}
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
@@ -453,11 +458,13 @@ function ConfigRow({
|
|||||||
function ConfigDialog({
|
function ConfigDialog({
|
||||||
existing,
|
existing,
|
||||||
catalog,
|
catalog,
|
||||||
|
secrets,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
existing: LlmConfiguration | null
|
existing: LlmConfiguration | null
|
||||||
catalog: CatalogEntry[]
|
catalog: CatalogEntry[]
|
||||||
|
secrets: Secret[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (
|
onSave: (
|
||||||
input: LlmConfigurationInput,
|
input: LlmConfigurationInput,
|
||||||
@@ -560,11 +567,11 @@ function ConfigDialog({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Vault secret name (optional)" className="sm:col-span-2">
|
<Field label="Vault secret (optional)" className="sm:col-span-2">
|
||||||
<Input
|
<SecretPicker
|
||||||
value={draft.secret_name ?? ""}
|
value={draft.secret_name ?? null}
|
||||||
onChange={(e) => setDraft({ ...draft, secret_name: e.target.value || null })}
|
secrets={secrets}
|
||||||
placeholder="llm-openai-key"
|
onChange={(name) => setDraft({ ...draft, secret_name: name })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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({
|
function Field({
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
|
|||||||
Reference in New Issue
Block a user