Files
arcadia-admin/app/components/settings/llm-configurations-panel.tsx
jules 8e07f4b9c0 Settings: real model dropdown with cost hints + Custom escape hatch
The previous datalist-on-input approach was fragile — Safari hid the
suggestions, and there was no visual cue that a dropdown existed.
Replace with a proper Select populated from the catalog. Each option
shows the per-1M-token rates inline so operators see cost while
choosing. "Custom…" switches to free-text for models the catalog
doesn't know about, with a "Catalog" button to flip back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:25:49 +10:00

436 lines
14 KiB
TypeScript

// Saved LLM configurations: server-side CRUD + one-shot import of any
// localStorage settings the user had before this surface existed.
//
// Lists configurations (own + platform-default), exposes Add/Delete, and
// surfaces published costs from the curated catalog so the operator can
// see at a glance what each config costs per 1M tokens.
import { useCallback, useEffect, useMemo, useState } from "react"
import { Plus, Trash2, Upload } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-client"
import { Button } from "~/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card"
import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import {
createConfiguration,
deleteConfiguration,
formatCost,
getCatalog,
getUsageSummary,
listConfigurations,
type CatalogEntry,
type LlmConfiguration,
type LlmConfigurationInput,
type LlmProvider,
type LlmUsageSummary,
} from "~/lib/arcadia/llm-configs"
const PROVIDERS: LlmProvider[] = ["openai", "anthropic", "deepseek", "qwen", "lmstudio"]
const LOCAL_SETTINGS_KEY = "crema.llm-providers.settings"
export function LlmConfigurationsPanel() {
const arcadia = useArcadiaClient()
const [configs, setConfigs] = useState<LlmConfiguration[]>([])
const [catalog, setCatalog] = useState<CatalogEntry[]>([])
const [usage, setUsage] = useState<LlmUsageSummary | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [draft, setDraft] = useState<LlmConfigurationInput>(emptyDraft())
const refresh = useCallback(async () => {
setError(null)
try {
const [list, cat, sum] = await Promise.all([
listConfigurations(arcadia),
getCatalog(arcadia).catch(() => [] as CatalogEntry[]),
getUsageSummary(arcadia, { days: 30 }).catch(() => null),
])
setConfigs(list)
setCatalog(cat)
setUsage(sum)
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load configurations.")
} finally {
setLoading(false)
}
}, [arcadia])
useEffect(() => {
void refresh()
}, [refresh])
const modelsForProvider = useMemo(
() => catalog.filter((c) => c.provider === draft.provider && c.model !== "*"),
[catalog, draft.provider],
)
const onCreate = async () => {
setError(null)
try {
await createConfiguration(arcadia, draft)
setDraft(emptyDraft())
setCreating(false)
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : "Create failed.")
}
}
const onDelete = async (id: string) => {
setError(null)
try {
await deleteConfiguration(arcadia, id)
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : "Delete failed.")
}
}
const onImportFromLocal = async () => {
setError(null)
const raw = typeof window !== "undefined" ? localStorage.getItem(LOCAL_SETTINGS_KEY) : null
if (!raw) {
setError("No local settings found to import.")
return
}
try {
const local = JSON.parse(raw) as {
providerId?: string
model?: string
baseURL?: string
secretName?: string
}
if (!local.providerId || !local.model) {
setError("Local settings are incomplete (missing provider or model).")
return
}
await createConfiguration(arcadia, {
name: `Imported (${local.providerId})`,
provider: local.providerId as LlmProvider,
model: local.model,
base_url: local.baseURL || null,
secret_name: local.secretName || null,
})
// Keep the local store as a fallback in case import fails on a later
// browser; clearing only happens after a successful refresh confirms
// the row exists server-side.
localStorage.removeItem(LOCAL_SETTINGS_KEY)
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : "Import failed.")
}
}
const hasLocalSettings =
typeof window !== "undefined" && !!localStorage.getItem(LOCAL_SETTINGS_KEY)
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="flex-1">
<CardTitle>Saved configurations</CardTitle>
<CardDescription>
Persisted server-side. Each config bundles a provider, model, vault secret,
and per-1M-token costs (auto-filled from the catalog). Used by the proxy to
attribute cost on every completion.
</CardDescription>
</div>
{usage ? (
<div className="flex shrink-0 flex-col items-end rounded-md border bg-muted/40 px-3 py-2 text-right">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
Spend (30d)
</span>
<span className="font-mono text-base font-semibold tabular-nums">
{formatCost(usage.total_cost_cents ?? 0)}
</span>
<span className="text-[10px] text-muted-foreground">
{(usage.total_requests ?? 0).toLocaleString()} req ·{" "}
{(usage.total_tokens ?? 0).toLocaleString()} tok
</span>
</div>
) : null}
<div className="flex shrink-0 gap-2">
{hasLocalSettings ? (
<Button
variant="outline"
size="sm"
onClick={onImportFromLocal}
data-action="llm-config-import-local"
>
<Upload className="size-4" />
Import local
</Button>
) : null}
<Button
size="sm"
onClick={() => setCreating((v) => !v)}
data-action="llm-config-add"
>
<Plus className="size-4" />
Add
</Button>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
{creating ? (
<div className="grid gap-3 rounded-md border bg-muted/30 p-3 sm:grid-cols-2">
<Field label="Name">
<Input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Production GPT-4o-mini"
/>
</Field>
<Field label="Provider">
<Select
value={draft.provider}
onValueChange={(v) =>
setDraft({ ...draft, provider: v as LlmProvider, model: "" })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map((p) => (
<SelectItem key={p} value={p}>
{p}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Model">
<ModelPicker
value={draft.model}
models={modelsForProvider}
onChange={(model) => setDraft({ ...draft, model })}
/>
</Field>
<Field label="Vault secret name (optional)">
<Input
value={draft.secret_name ?? ""}
onChange={(e) => setDraft({ ...draft, secret_name: e.target.value || null })}
placeholder="llm-openai-key"
/>
</Field>
<Field label="Base URL (optional)">
<Input
value={draft.base_url ?? ""}
onChange={(e) => setDraft({ ...draft, base_url: e.target.value || null })}
placeholder="leave blank for provider default"
/>
</Field>
<Field label="Costs">
<p className="text-xs text-muted-foreground">
Auto-filled from the curated catalog when you save. Override later if you
have a negotiated rate.
</p>
</Field>
<div className="sm:col-span-2 flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => {
setCreating(false)
setDraft(emptyDraft())
}}
>
Cancel
</Button>
<Button
onClick={onCreate}
disabled={!draft.name || !draft.model}
data-action="llm-config-save"
>
Save
</Button>
</div>
</div>
) : null}
{loading ? (
<p className="py-4 text-sm text-muted-foreground">Loading</p>
) : configs.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">
No configurations yet. Add one to start tracking cost.
</p>
) : (
<ul className="divide-y border-y">
{configs.map((c) => (
<li key={c.id} className="flex items-center justify-between gap-3 px-1 py-2">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-2 text-sm font-medium">
{c.name}
{c.tenant_id == null ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
platform
</span>
) : null}
{!c.enabled ? (
<span className="text-[11px] text-muted-foreground">disabled</span>
) : null}
</span>
<span className="text-xs text-muted-foreground">
{c.provider} · <code className="font-mono">{c.model}</code>
{c.secret_name ? (
<>
{" "}
· secret <code className="font-mono">{c.secret_name}</code>
</>
) : null}
</span>
<span className="text-[11px] text-muted-foreground">
{formatRate(c.input_cost_per_million)}/1M in ·{" "}
{formatRate(c.output_cost_per_million)}/1M out
</span>
</div>
{c.tenant_id != null ? (
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(c.id)}
data-action={`llm-config-delete-${c.id}`}
>
<Trash2 className="size-4" />
</Button>
) : null}
</li>
))}
</ul>
)}
</CardContent>
</Card>
)
}
/**
* Model picker: dropdown of catalog entries for the selected provider, plus
* a "Custom…" escape hatch that switches to free-text for models the
* catalog doesn't know about. The custom branch is sticky — once the user
* types a custom name, switching providers resets to the dropdown.
*/
function ModelPicker({
value,
models,
onChange,
}: {
value: string
models: CatalogEntry[]
onChange: (model: string) => void
}) {
const known = useMemo(() => new Set(models.map((m) => m.model)), [models])
const isCustom = value !== "" && !known.has(value)
const [customMode, setCustomMode] = useState(isCustom)
// Reset to dropdown if the available models change (e.g. provider switched)
// and we're not actively in custom mode.
useEffect(() => {
if (!isCustom) setCustomMode(false)
}, [models, isCustom])
if (customMode) {
return (
<div className="flex gap-2">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="custom-model-id"
autoFocus
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setCustomMode(false)
onChange("")
}}
>
Catalog
</Button>
</div>
)
}
return (
<Select
value={known.has(value) ? value : ""}
onValueChange={(v) => {
if (v === "__custom__") {
setCustomMode(true)
onChange("")
} else {
onChange(v)
}
}}
>
<SelectTrigger>
<SelectValue placeholder={models.length ? "Pick a model…" : "No models for this provider"} />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.model} value={m.model}>
<span className="flex items-center justify-between gap-3">
<span>{m.model}</span>
<span className="text-[10px] text-muted-foreground">
${m.input_cost_per_million.toFixed(2)} / ${m.output_cost_per_million.toFixed(2)} per 1M
</span>
</span>
</SelectItem>
))}
<SelectItem value="__custom__">
<span className="text-muted-foreground">Custom</span>
</SelectItem>
</SelectContent>
</Select>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<Label className="text-xs">{label}</Label>
{children}
</div>
)
}
function emptyDraft(): LlmConfigurationInput {
return {
name: "",
provider: "openai",
model: "",
base_url: null,
secret_name: null,
}
}
function formatRate(rate: number | null): string {
if (rate == null) return "—"
if (rate === 0) return "free"
return `$${rate.toFixed(2)}`
}