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:
@@ -1,13 +1,20 @@
|
||||
// Saved LLM configurations: server-side CRUD + one-shot import of any
|
||||
// localStorage settings the user had before this surface existed.
|
||||
// LLM configurations panel.
|
||||
//
|
||||
// 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.
|
||||
// One unified surface for everything LLM-config-related: server-persisted
|
||||
// configurations, the per-operator "active" choice (which one the assistant
|
||||
// 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 { Plus, Trash2, Upload } from "lucide-react"
|
||||
import { Pencil, Plus, Sparkles, Star, Trash2, Upload } from "lucide-react"
|
||||
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 {
|
||||
@@ -17,6 +24,14 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
import { Input } from "~/components/ui/input"
|
||||
import { Label } from "~/components/ui/label"
|
||||
import {
|
||||
@@ -26,45 +41,63 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select"
|
||||
import { Switch } from "~/components/ui/switch"
|
||||
import {
|
||||
createConfiguration,
|
||||
deleteConfiguration,
|
||||
findSpend,
|
||||
formatCost,
|
||||
getCatalog,
|
||||
getUsageByModel,
|
||||
getUsageSummary,
|
||||
listConfigurations,
|
||||
updateConfiguration,
|
||||
type CatalogEntry,
|
||||
type LlmConfiguration,
|
||||
type LlmConfigurationInput,
|
||||
type LlmProvider,
|
||||
type LlmUsageSummary,
|
||||
type UsageByModelRow,
|
||||
} from "~/lib/arcadia/llm-configs"
|
||||
|
||||
const PROVIDERS: LlmProvider[] = ["openai", "anthropic", "deepseek", "qwen", "lmstudio"]
|
||||
|
||||
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() {
|
||||
const arcadia = useArcadiaClient()
|
||||
const [configs, setConfigs] = useState<LlmConfiguration[]>([])
|
||||
const [catalog, setCatalog] = useState<CatalogEntry[]>([])
|
||||
const [usage, setUsage] = useState<LlmUsageSummary | null>(null)
|
||||
const [usageByModel, setUsageByModel] = useState<UsageByModelRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [draft, setDraft] = useState<LlmConfigurationInput>(emptyDraft())
|
||||
const [active, setActive] = useState<LLMProvidersSettings | null>(null)
|
||||
const [editing, setEditing] = useState<LlmConfiguration | "new" | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
const [list, cat, sum] = await Promise.all([
|
||||
const [list, cat, sum, byModel] = await Promise.all([
|
||||
listConfigurations(arcadia),
|
||||
getCatalog(arcadia).catch(() => [] as CatalogEntry[]),
|
||||
getUsageSummary(arcadia, { days: 30 }).catch(() => null),
|
||||
getUsageByModel(arcadia, { days: 30 }).catch(() => [] as UsageByModelRow[]),
|
||||
])
|
||||
setConfigs(list)
|
||||
setCatalog(cat)
|
||||
setUsage(sum)
|
||||
setUsageByModel(byModel)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load configurations.")
|
||||
} finally {
|
||||
@@ -74,35 +107,83 @@ export function LlmConfigurationsPanel() {
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
if (typeof window !== "undefined") setActive(loadActiveSettings())
|
||||
}, [refresh])
|
||||
|
||||
const modelsForProvider = useMemo(
|
||||
() => catalog.filter((c) => c.provider === draft.provider && c.model !== "*"),
|
||||
[catalog, draft.provider],
|
||||
const isActive = useCallback(
|
||||
(c: LlmConfiguration) =>
|
||||
!!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)
|
||||
try {
|
||||
await createConfiguration(arcadia, draft)
|
||||
setDraft(emptyDraft())
|
||||
setCreating(false)
|
||||
await updateConfiguration(arcadia, c.id, { enabled: !c.enabled })
|
||||
await refresh()
|
||||
} 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)
|
||||
if (!window.confirm(`Delete "${c.name}"? Historical usage rows are preserved.`)) return
|
||||
try {
|
||||
await deleteConfiguration(arcadia, id)
|
||||
await deleteConfiguration(arcadia, c.id)
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
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 () => {
|
||||
setError(null)
|
||||
const raw = typeof window !== "undefined" ? localStorage.getItem(LOCAL_SETTINGS_KEY) : null
|
||||
@@ -111,14 +192,9 @@ export function LlmConfigurationsPanel() {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const local = JSON.parse(raw) as {
|
||||
providerId?: string
|
||||
model?: string
|
||||
baseURL?: string
|
||||
secretName?: string
|
||||
}
|
||||
const local = JSON.parse(raw) as LLMProvidersSettings
|
||||
if (!local.providerId || !local.model) {
|
||||
setError("Local settings are incomplete (missing provider or model).")
|
||||
setError("Local settings are incomplete.")
|
||||
return
|
||||
}
|
||||
await createConfiguration(arcadia, {
|
||||
@@ -128,10 +204,6 @@ export function LlmConfigurationsPanel() {
|
||||
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.")
|
||||
@@ -145,11 +217,10 @@ export function LlmConfigurationsPanel() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<CardTitle>Saved configurations</CardTitle>
|
||||
<CardTitle>LLM 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.
|
||||
Server-persisted provider/model/secret/cost settings. Toggle the star to
|
||||
pick which one the Assistant uses on the next message.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{usage ? (
|
||||
@@ -167,7 +238,7 @@ export function LlmConfigurationsPanel() {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{hasLocalSettings ? (
|
||||
{hasLocalSettings && configs.length === 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -180,7 +251,7 @@ export function LlmConfigurationsPanel() {
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreating((v) => !v)}
|
||||
onClick={() => setEditing("new")}
|
||||
data-action="llm-config-add"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
@@ -188,6 +259,7 @@ export function LlmConfigurationsPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
@@ -195,15 +267,260 @@ export function LlmConfigurationsPanel() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{creating ? (
|
||||
<div className="grid gap-3 rounded-md border bg-muted/30 p-3 sm:grid-cols-2">
|
||||
<Field label="Name">
|
||||
{loading ? (
|
||||
<p className="py-4 text-sm text-muted-foreground">Loading…</p>
|
||||
) : 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
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="Production GPT-4o-mini"
|
||||
autoFocus={!existing}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Provider">
|
||||
<Select
|
||||
value={draft.provider}
|
||||
@@ -223,115 +540,100 @@ export function LlmConfigurationsPanel() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Model">
|
||||
<ModelPicker
|
||||
value={draft.model}
|
||||
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 label="Vault secret name (optional)">
|
||||
|
||||
<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>
|
||||
<Field label="Base URL (optional)">
|
||||
|
||||
<Field label="Base URL (optional)" className="sm:col-span-2">
|
||||
<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 label="Input cost (USD per 1M tokens)">
|
||||
<Input
|
||||
type="number"
|
||||
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>
|
||||
<div className="sm:col-span-2 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setCreating(false)
|
||||
setDraft(emptyDraft())
|
||||
}}
|
||||
>
|
||||
|
||||
<Field label="Output cost (USD per 1M tokens)">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCreate}
|
||||
disabled={!draft.name || !draft.model}
|
||||
onClick={onSubmit}
|
||||
disabled={!valid || saving}
|
||||
data-action="llm-config-save"
|
||||
>
|
||||
Save
|
||||
{saving ? "Saving…" : existing ? "Save changes" : "Create"}
|
||||
</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>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// --- Model picker (Select + Custom… escape) -------------------------------
|
||||
|
||||
function ModelPicker({
|
||||
value,
|
||||
models,
|
||||
@@ -345,8 +647,6 @@ function ModelPicker({
|
||||
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])
|
||||
@@ -388,7 +688,7 @@ function ModelPicker({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<SelectContent>
|
||||
{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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className={`flex flex-col gap-1 ${className}`}>
|
||||
<Label className="text-xs">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -153,3 +153,32 @@ export async function getUsageSummary(
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -215,22 +215,18 @@ export default function SettingsRoute() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<LlmConfigurationsPanel />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active LLM (this session)</CardTitle>
|
||||
<CardDescription>
|
||||
The Assistant uses this provider/model on the next message. For
|
||||
persistent setups shared across operators, use the Saved
|
||||
configurations card above.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<details className="rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
Advanced: tweak the active session settings (transport, system prompt,
|
||||
context budget) directly
|
||||
</summary>
|
||||
<div className="pt-3">
|
||||
<LLMProvidersSettingsCard
|
||||
onTest={testConnection}
|
||||
hideTransportToggle={false}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user