Settings/LLM: unified panel with per-row active toggle, edit, spend

Reworks the LLM settings surface based on UX feedback. Drops the
separate "Active LLM (this session)" card — its functionality is now
inline on each saved config as a star toggle (writes the same
localStorage key the Assistant reads via @crema/llm-providers-ui's
saveSettings, so the existing assistant code picks the change up
without any plumbing).

Per-row controls now include:
- Star: make this config active for the current browser
- Switch: enable/disable server-side
- Pencil: edit (modal, not inline-expand)
- Trash: delete (with confirm)
- Spend (30d): cost + request count, sourced from
  /api/v1/ai/llm/usage/by-model and matched on (provider, model)

Other improvements:
- Add wizard moved to a Dialog modal instead of pushing the list
  around. Same form handles edit.
- Empty state: "Seed from catalog" button creates a curated starter
  set (GPT-4o mini/4o, Sonnet 4.6, Haiku 4.5, DeepSeek V4 Flash, LM
  Studio) so first-time operators don't face a blank panel.
- Catalog dropdown picks now auto-fill input/output costs as you
  switch models, so the rates always reflect the chosen model unless
  manually overridden.
- The lib's full settings card (system prompt, transport, context
  budget) is still reachable for advanced cases — collapsed into a
  <details> below the panel.

Adds llm-configs.ts: getUsageByModel + findSpend helper for the
per-row spend lookup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-05-02 18:47:45 +10:00
parent bfe61c220a
commit 5dfceeff94
3 changed files with 510 additions and 177 deletions

View File

@@ -1,13 +1,20 @@
// Saved LLM configurations: server-side CRUD + one-shot import of any // LLM configurations panel.
// localStorage settings the user had before this surface existed.
// //
// Lists configurations (own + platform-default), exposes Add/Delete, and // One unified surface for everything LLM-config-related: server-persisted
// surfaces published costs from the curated catalog so the operator can // configurations, the per-operator "active" choice (which one the assistant
// see at a glance what each config costs per 1M tokens. // uses on the next message), and 30-day spend per row. The "active" toggle
// writes to the same localStorage key @crema/llm-providers-ui reads via
// loadSettings/saveSettings, so the existing assistant code picks it up
// without any plumbing changes.
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { Plus, Trash2, Upload } from "lucide-react" import { Pencil, Plus, Sparkles, Star, Trash2, Upload } from "lucide-react"
import { useArcadiaClient } from "@crema/arcadia-client" import { useArcadiaClient } from "@crema/arcadia-client"
import {
loadSettings as loadActiveSettings,
saveSettings as saveActiveSettings,
type LLMProvidersSettings,
} from "@crema/llm-providers-ui"
import { Button } from "~/components/ui/button" import { Button } from "~/components/ui/button"
import { import {
@@ -17,6 +24,14 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "~/components/ui/card" } from "~/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
import { Input } from "~/components/ui/input" import { Input } from "~/components/ui/input"
import { Label } from "~/components/ui/label" import { Label } from "~/components/ui/label"
import { import {
@@ -26,45 +41,63 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "~/components/ui/select" } from "~/components/ui/select"
import { Switch } from "~/components/ui/switch"
import { import {
createConfiguration, createConfiguration,
deleteConfiguration, deleteConfiguration,
findSpend,
formatCost, formatCost,
getCatalog, getCatalog,
getUsageByModel,
getUsageSummary, getUsageSummary,
listConfigurations, listConfigurations,
updateConfiguration,
type CatalogEntry, type CatalogEntry,
type LlmConfiguration, type LlmConfiguration,
type LlmConfigurationInput, type LlmConfigurationInput,
type LlmProvider, type LlmProvider,
type LlmUsageSummary, type LlmUsageSummary,
type UsageByModelRow,
} from "~/lib/arcadia/llm-configs" } from "~/lib/arcadia/llm-configs"
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"
// Curated picks for the "Seed catalog" empty-state action — operators get
// a sensible starting set instead of a blank panel and 19 manual creates.
const SEED_PICKS: Array<{ name: string; provider: LlmProvider; model: string }> = [
{ name: "GPT-4o mini (cheap default)", provider: "openai", model: "gpt-4o-mini" },
{ name: "GPT-4o", provider: "openai", model: "gpt-4o" },
{ name: "Claude Sonnet 4.6", provider: "anthropic", model: "claude-sonnet-4-6" },
{ name: "Claude Haiku 4.5", provider: "anthropic", model: "claude-haiku-4-5" },
{ name: "DeepSeek V4 Flash", provider: "deepseek", model: "deepseek-v4-flash" },
{ name: "LM Studio (local)", provider: "lmstudio", model: "local-model" },
]
export function LlmConfigurationsPanel() { export function LlmConfigurationsPanel() {
const arcadia = useArcadiaClient() const arcadia = useArcadiaClient()
const [configs, setConfigs] = useState<LlmConfiguration[]>([]) const [configs, setConfigs] = useState<LlmConfiguration[]>([])
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 [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false) const [active, setActive] = useState<LLMProvidersSettings | null>(null)
const [draft, setDraft] = useState<LlmConfigurationInput>(emptyDraft()) const [editing, setEditing] = useState<LlmConfiguration | "new" | null>(null)
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setError(null) setError(null)
try { try {
const [list, cat, sum] = await Promise.all([ const [list, cat, sum, byModel] = 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[]),
]) ])
setConfigs(list) setConfigs(list)
setCatalog(cat) setCatalog(cat)
setUsage(sum) setUsage(sum)
setUsageByModel(byModel)
} 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 {
@@ -74,35 +107,83 @@ export function LlmConfigurationsPanel() {
useEffect(() => { useEffect(() => {
void refresh() void refresh()
if (typeof window !== "undefined") setActive(loadActiveSettings())
}, [refresh]) }, [refresh])
const modelsForProvider = useMemo( const isActive = useCallback(
() => catalog.filter((c) => c.provider === draft.provider && c.model !== "*"), (c: LlmConfiguration) =>
[catalog, draft.provider], !!active &&
active.providerId === c.provider &&
active.model === c.model &&
(active.secretName || "") === (c.secret_name || ""),
[active],
) )
const onCreate = async () => { const onMakeActive = (c: LlmConfiguration) => {
const current = loadActiveSettings()
saveActiveSettings({
...current,
providerId: c.provider as LLMProvidersSettings["providerId"],
model: c.model,
baseURL: c.base_url || undefined,
secretName: c.secret_name || undefined,
})
setActive(loadActiveSettings())
}
const onToggleEnabled = async (c: LlmConfiguration) => {
setError(null) setError(null)
try { try {
await createConfiguration(arcadia, draft) await updateConfiguration(arcadia, c.id, { enabled: !c.enabled })
setDraft(emptyDraft())
setCreating(false)
await refresh() await refresh()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Create failed.") setError(e instanceof Error ? e.message : "Update failed.")
} }
} }
const onDelete = async (id: string) => { const onDelete = async (c: LlmConfiguration) => {
setError(null) setError(null)
if (!window.confirm(`Delete "${c.name}"? Historical usage rows are preserved.`)) return
try { try {
await deleteConfiguration(arcadia, id) await deleteConfiguration(arcadia, c.id)
await refresh() await refresh()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Delete failed.") setError(e instanceof Error ? e.message : "Delete failed.")
} }
} }
const onSave = async (input: LlmConfigurationInput, existing: LlmConfiguration | null) => {
setError(null)
try {
if (existing) {
await updateConfiguration(arcadia, existing.id, input)
} else {
await createConfiguration(arcadia, input)
}
setEditing(null)
await refresh()
} catch (e) {
throw e instanceof Error ? e : new Error(String(e))
}
}
const onSeed = async () => {
setError(null)
try {
// Seed sequentially to surface conflicts cleanly.
for (const pick of SEED_PICKS) {
try {
await createConfiguration(arcadia, pick)
} catch {
// skip dupes — they're benign on a re-seed
}
}
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : "Seed failed.")
}
}
const onImportFromLocal = async () => { const onImportFromLocal = async () => {
setError(null) setError(null)
const raw = typeof window !== "undefined" ? localStorage.getItem(LOCAL_SETTINGS_KEY) : null const raw = typeof window !== "undefined" ? localStorage.getItem(LOCAL_SETTINGS_KEY) : null
@@ -111,14 +192,9 @@ export function LlmConfigurationsPanel() {
return return
} }
try { try {
const local = JSON.parse(raw) as { const local = JSON.parse(raw) as LLMProvidersSettings
providerId?: string
model?: string
baseURL?: string
secretName?: string
}
if (!local.providerId || !local.model) { if (!local.providerId || !local.model) {
setError("Local settings are incomplete (missing provider or model).") setError("Local settings are incomplete.")
return return
} }
await createConfiguration(arcadia, { await createConfiguration(arcadia, {
@@ -128,10 +204,6 @@ export function LlmConfigurationsPanel() {
base_url: local.baseURL || null, base_url: local.baseURL || null,
secret_name: local.secretName || 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() await refresh()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Import failed.") setError(e instanceof Error ? e.message : "Import failed.")
@@ -145,11 +217,10 @@ export function LlmConfigurationsPanel() {
<Card> <Card>
<CardHeader className="flex flex-row items-start justify-between gap-4"> <CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="flex-1"> <div className="flex-1">
<CardTitle>Saved configurations</CardTitle> <CardTitle>LLM configurations</CardTitle>
<CardDescription> <CardDescription>
Persisted server-side. Each config bundles a provider, model, vault secret, Server-persisted provider/model/secret/cost settings. Toggle the star to
and per-1M-token costs (auto-filled from the catalog). Used by the proxy to pick which one the Assistant uses on the next message.
attribute cost on every completion.
</CardDescription> </CardDescription>
</div> </div>
{usage ? ( {usage ? (
@@ -167,7 +238,7 @@ export function LlmConfigurationsPanel() {
</div> </div>
) : null} ) : null}
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
{hasLocalSettings ? ( {hasLocalSettings && configs.length === 0 ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -180,7 +251,7 @@ export function LlmConfigurationsPanel() {
) : null} ) : null}
<Button <Button
size="sm" size="sm"
onClick={() => setCreating((v) => !v)} onClick={() => setEditing("new")}
data-action="llm-config-add" data-action="llm-config-add"
> >
<Plus className="size-4" /> <Plus className="size-4" />
@@ -188,6 +259,7 @@ export function LlmConfigurationsPanel() {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
{error ? ( {error ? (
<p className="text-sm text-destructive" role="alert"> <p className="text-sm text-destructive" role="alert">
@@ -195,15 +267,260 @@ export function LlmConfigurationsPanel() {
</p> </p>
) : null} ) : null}
{creating ? ( {loading ? (
<div className="grid gap-3 rounded-md border bg-muted/30 p-3 sm:grid-cols-2"> <p className="py-4 text-sm text-muted-foreground">Loading</p>
<Field label="Name"> ) : configs.length === 0 ? (
<EmptyState onSeed={onSeed} onImport={hasLocalSettings ? onImportFromLocal : null} />
) : (
<ul className="divide-y border-y">
{configs.map((c) => (
<ConfigRow
key={c.id}
config={c}
spend={findSpend(usageByModel, c)}
isActive={isActive(c)}
onMakeActive={() => onMakeActive(c)}
onToggleEnabled={() => onToggleEnabled(c)}
onEdit={() => setEditing(c)}
onDelete={() => onDelete(c)}
/>
))}
</ul>
)}
</CardContent>
{editing ? (
<ConfigDialog
existing={editing === "new" ? null : editing}
catalog={catalog}
onClose={() => setEditing(null)}
onSave={onSave}
/>
) : null}
</Card>
)
}
// --- Empty state ---------------------------------------------------------
function EmptyState({
onSeed,
onImport,
}: {
onSeed: () => void
onImport: (() => void) | null
}) {
return (
<div className="flex flex-col items-center gap-3 rounded-md border border-dashed bg-muted/20 px-6 py-10 text-center">
<Sparkles className="size-6 text-muted-foreground" />
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">No configurations yet</p>
<p className="text-xs text-muted-foreground">
Seed a starter set from the curated catalog (GPT-4o, Claude, DeepSeek, LM Studio)
and tweak from there.
</p>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={onSeed} data-action="llm-config-seed">
<Sparkles className="size-4" />
Seed from catalog
</Button>
{onImport ? (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="size-4" />
Import local
</Button>
) : null}
</div>
</div>
)
}
// --- Single row ----------------------------------------------------------
function ConfigRow({
config: c,
spend,
isActive,
onMakeActive,
onToggleEnabled,
onEdit,
onDelete,
}: {
config: LlmConfiguration
spend: UsageByModelRow | undefined
isActive: boolean
onMakeActive: () => void
onToggleEnabled: () => void
onEdit: () => void
onDelete: () => void
}) {
return (
<li className="flex items-center justify-between gap-3 px-1 py-2.5">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={onMakeActive}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={isActive ? "Active configuration" : "Make active"}
data-action={`llm-config-activate-${c.id}`}
title={isActive ? "Currently active for this browser" : "Make active for this browser"}
>
<Star
className={`size-4 ${isActive ? "fill-amber-400 text-amber-400" : ""}`}
aria-hidden
/>
</button>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-2 text-sm font-medium">
<span className="truncate">{c.name}</span>
{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="truncate 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>
</div>
<div className="flex shrink-0 items-center gap-3">
{spend && spend.cost_cents > 0 ? (
<div className="flex flex-col items-end text-right">
<span className="font-mono text-sm tabular-nums">
{formatCost(spend.cost_cents)}
</span>
<span className="text-[10px] text-muted-foreground">
30d · {spend.requests.toLocaleString()} req
</span>
</div>
) : null}
<div className="flex items-center gap-1">
<Switch
checked={c.enabled}
onCheckedChange={onToggleEnabled}
size="sm"
aria-label={c.enabled ? "Disable" : "Enable"}
data-action={`llm-config-enabled-${c.id}`}
/>
{c.tenant_id != null ? (
<>
<Button
variant="ghost"
size="sm"
onClick={onEdit}
data-action={`llm-config-edit-${c.id}`}
aria-label="Edit"
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
data-action={`llm-config-delete-${c.id}`}
aria-label="Delete"
>
<Trash2 className="size-4" />
</Button>
</>
) : null}
</div>
</div>
</li>
)
}
// --- Add/Edit modal ------------------------------------------------------
function ConfigDialog({
existing,
catalog,
onClose,
onSave,
}: {
existing: LlmConfiguration | null
catalog: CatalogEntry[]
onClose: () => void
onSave: (
input: LlmConfigurationInput,
existing: LlmConfiguration | null,
) => Promise<void>
}) {
const [draft, setDraft] = useState<LlmConfigurationInput>(
existing
? {
name: existing.name,
provider: existing.provider,
model: existing.model,
base_url: existing.base_url,
secret_name: existing.secret_name,
input_cost_per_million: existing.input_cost_per_million,
output_cost_per_million: existing.output_cost_per_million,
enabled: existing.enabled,
}
: emptyDraft(),
)
const [saving, setSaving] = useState(false)
const [err, setErr] = useState<string | null>(null)
const modelsForProvider = useMemo(
() => catalog.filter((c) => c.provider === draft.provider && c.model !== "*"),
[catalog, draft.provider],
)
const onSubmit = async () => {
setSaving(true)
setErr(null)
try {
await onSave(draft, existing)
} catch (e) {
setErr(e instanceof Error ? e.message : "Save failed.")
} finally {
setSaving(false)
}
}
const valid = draft.name.trim() !== "" && draft.model.trim() !== ""
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>{existing ? "Edit configuration" : "New configuration"}</DialogTitle>
<DialogDescription>
Costs auto-fill from the curated catalog when you pick a known model.
Override below if you have a negotiated rate.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2 sm:grid-cols-2">
<Field label="Name" className="sm:col-span-2">
<Input <Input
value={draft.name} value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })} onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="Production GPT-4o-mini" placeholder="Production GPT-4o-mini"
autoFocus={!existing}
/> />
</Field> </Field>
<Field label="Provider"> <Field label="Provider">
<Select <Select
value={draft.provider} value={draft.provider}
@@ -223,115 +540,100 @@ export function LlmConfigurationsPanel() {
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
<Field label="Model"> <Field label="Model">
<ModelPicker <ModelPicker
value={draft.model} value={draft.model}
models={modelsForProvider} models={modelsForProvider}
onChange={(model) => setDraft({ ...draft, model })} onChange={(model) => {
// Auto-fill costs when picking a catalog model.
const entry = modelsForProvider.find((m) => m.model === model)
setDraft({
...draft,
model,
...(entry && {
input_cost_per_million: entry.input_cost_per_million,
output_cost_per_million: entry.output_cost_per_million,
}),
})
}}
/> />
</Field> </Field>
<Field label="Vault secret name (optional)">
<Field label="Vault secret name (optional)" className="sm:col-span-2">
<Input <Input
value={draft.secret_name ?? ""} value={draft.secret_name ?? ""}
onChange={(e) => setDraft({ ...draft, secret_name: e.target.value || null })} onChange={(e) => setDraft({ ...draft, secret_name: e.target.value || null })}
placeholder="llm-openai-key" placeholder="llm-openai-key"
/> />
</Field> </Field>
<Field label="Base URL (optional)">
<Field label="Base URL (optional)" className="sm:col-span-2">
<Input <Input
value={draft.base_url ?? ""} value={draft.base_url ?? ""}
onChange={(e) => setDraft({ ...draft, base_url: e.target.value || null })} onChange={(e) => setDraft({ ...draft, base_url: e.target.value || null })}
placeholder="leave blank for provider default" placeholder="leave blank for provider default"
/> />
</Field> </Field>
<Field label="Costs">
<p className="text-xs text-muted-foreground"> <Field label="Input cost (USD per 1M tokens)">
Auto-filled from the curated catalog when you save. Override later if you <Input
have a negotiated rate. type="number"
</p> step="0.01"
min={0}
value={draft.input_cost_per_million ?? ""}
onChange={(e) =>
setDraft({
...draft,
input_cost_per_million: e.target.value === "" ? null : Number(e.target.value),
})
}
placeholder="0.15"
/>
</Field> </Field>
<div className="sm:col-span-2 flex justify-end gap-2">
<Button <Field label="Output cost (USD per 1M tokens)">
variant="ghost" <Input
onClick={() => { type="number"
setCreating(false) step="0.01"
setDraft(emptyDraft()) min={0}
}} value={draft.output_cost_per_million ?? ""}
> onChange={(e) =>
setDraft({
...draft,
output_cost_per_million: e.target.value === "" ? null : Number(e.target.value),
})
}
placeholder="0.60"
/>
</Field>
</div>
{err ? (
<p className="text-sm text-destructive" role="alert">
{err}
</p>
) : null}
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={onCreate} onClick={onSubmit}
disabled={!draft.name || !draft.model} disabled={!valid || saving}
data-action="llm-config-save" data-action="llm-config-save"
> >
Save {saving ? "Saving…" : existing ? "Save changes" : "Create"}
</Button> </Button>
</div> </DialogFooter>
</div> </DialogContent>
) : null} </Dialog>
{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 (Select + Custom… escape) -------------------------------
* 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({ function ModelPicker({
value, value,
models, models,
@@ -345,8 +647,6 @@ function ModelPicker({
const isCustom = value !== "" && !known.has(value) const isCustom = value !== "" && !known.has(value)
const [customMode, setCustomMode] = useState(isCustom) 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(() => { useEffect(() => {
if (!isCustom) setCustomMode(false) if (!isCustom) setCustomMode(false)
}, [models, isCustom]) }, [models, isCustom])
@@ -388,7 +688,7 @@ function ModelPicker({
}} }}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={models.length ? "Pick a model…" : "No models for this provider"} /> <SelectValue placeholder={models.length ? "Pick a model…" : "Type a model id"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{models.map((m) => ( {models.map((m) => (
@@ -409,9 +709,17 @@ function ModelPicker({
) )
} }
function Field({ label, children }: { label: string; children: React.ReactNode }) { function Field({
label,
children,
className = "",
}: {
label: string
children: React.ReactNode
className?: string
}) {
return ( return (
<div className="flex flex-col gap-1"> <div className={`flex flex-col gap-1 ${className}`}>
<Label className="text-xs">{label}</Label> <Label className="text-xs">{label}</Label>
{children} {children}
</div> </div>

View File

@@ -153,3 +153,32 @@ export async function getUsageSummary(
) )
return "data" in (res as object) ? (res as { data: LlmUsageSummary }).data : (res as LlmUsageSummary) return "data" in (res as object) ? (res as { data: LlmUsageSummary }).data : (res as LlmUsageSummary)
} }
export interface UsageByModelRow {
provider: string
model: string
requests: number
total_tokens: number
cost_cents: number
}
export async function getUsageByModel(
arcadia: ArcadiaClient,
opts: { days?: number } = {},
): Promise<UsageByModelRow[]> {
const params: Record<string, string | number | boolean | null | undefined> = {}
if (opts.days != null) params.days = opts.days
const res = await arcadia.GET<{ data: UsageByModelRow[] } | UsageByModelRow[]>(
"/api/v1/ai/llm/usage/by-model",
{ params },
)
return "data" in (res as object) ? (res as { data: UsageByModelRow[] }).data : (res as UsageByModelRow[])
}
/** Find the spend row matching a given config's (provider, model). */
export function findSpend(
rows: UsageByModelRow[],
config: Pick<LlmConfiguration, "provider" | "model">,
): UsageByModelRow | undefined {
return rows.find((r) => r.provider === config.provider && r.model === config.model)
}

View File

@@ -215,22 +215,18 @@ export default function SettingsRoute() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<LlmConfigurationsPanel /> <LlmConfigurationsPanel />
<Card> <details className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
<CardHeader> <summary className="cursor-pointer text-muted-foreground">
<CardTitle>Active LLM (this session)</CardTitle> Advanced: tweak the active session settings (transport, system prompt,
<CardDescription> context budget) directly
The Assistant uses this provider/model on the next message. For </summary>
persistent setups shared across operators, use the Saved <div className="pt-3">
configurations card above.
</CardDescription>
</CardHeader>
<CardContent>
<LLMProvidersSettingsCard <LLMProvidersSettingsCard
onTest={testConnection} onTest={testConnection}
hideTransportToggle={false} hideTransportToggle={false}
/> />
</CardContent> </div>
</Card> </details>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button