- Reorganize sidenav into collapsible groups (Tenancy, Data, Integrations, Communications, Observability, AI & Search) with Overview/Settings pinned at top/bottom. Group open/close persists in localStorage; the group containing the active route auto-opens. Icon-only collapsed rail flattens to a single icon column. Sub-items inside groups drop their per-item icons and indent under the header. - Fix mobile sheet scroll — the nav couldn't reach items past viewport height. SheetContent is now flex-col h-svh, header shrink-0, nav flex-1 min-h-0 overflow-y-auto. - Settings page mobile fixes: section nav wraps instead of horizontal scroll, top padding clears the floating actions pill, LLM config card header and rows stack on narrow widths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
906 lines
28 KiB
TypeScript
906 lines
28 KiB
TypeScript
// LLM configurations panel.
|
|
//
|
|
// 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 { 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 {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "~/components/ui/select"
|
|
import { Switch } from "~/components/ui/switch"
|
|
import {
|
|
createConfiguration,
|
|
deleteConfiguration,
|
|
findSpend,
|
|
formatCost,
|
|
getCatalog,
|
|
getUsageByModel,
|
|
getUsageSummary,
|
|
listConfigurations,
|
|
REASONING_EFFORTS,
|
|
saveActiveReasoning,
|
|
updateConfiguration,
|
|
type CatalogEntry,
|
|
type LlmConfiguration,
|
|
type LlmConfigurationInput,
|
|
type LlmProvider,
|
|
type LlmUsageSummary,
|
|
type ReasoningEffort,
|
|
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"
|
|
|
|
// 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 [secrets, setSecrets] = useState<Secret[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
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, 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 {
|
|
setLoading(false)
|
|
}
|
|
}, [arcadia])
|
|
|
|
useEffect(() => {
|
|
void refresh()
|
|
if (typeof window !== "undefined") setActive(loadActiveSettings())
|
|
}, [refresh])
|
|
|
|
const isActive = useCallback(
|
|
(c: LlmConfiguration) =>
|
|
!!active &&
|
|
active.providerId === c.provider &&
|
|
active.model === c.model &&
|
|
(active.secretName || "") === (c.secret_name || ""),
|
|
[active],
|
|
)
|
|
|
|
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,
|
|
})
|
|
// Inherit this config's reasoning default. The /ai composer chip
|
|
// listens for this and updates live; if the operator already
|
|
// override it via the chip, the next save propagates here.
|
|
saveActiveReasoning(c.reasoning_effort ?? "off")
|
|
setActive(loadActiveSettings())
|
|
}
|
|
|
|
const onToggleEnabled = async (c: LlmConfiguration) => {
|
|
setError(null)
|
|
try {
|
|
await updateConfiguration(arcadia, c.id, { enabled: !c.enabled })
|
|
await refresh()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Update failed.")
|
|
}
|
|
}
|
|
|
|
const onDelete = async (c: LlmConfiguration) => {
|
|
setError(null)
|
|
if (!window.confirm(`Delete "${c.name}"? Historical usage rows are preserved.`)) return
|
|
try {
|
|
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
|
|
if (!raw) {
|
|
setError("No local settings found to import.")
|
|
return
|
|
}
|
|
try {
|
|
const local = JSON.parse(raw) as LLMProvidersSettings
|
|
if (!local.providerId || !local.model) {
|
|
setError("Local settings are incomplete.")
|
|
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,
|
|
})
|
|
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-col items-stretch justify-between gap-4 sm:flex-row sm:items-start">
|
|
<div className="flex-1">
|
|
<CardTitle>LLM configurations</CardTitle>
|
|
<CardDescription>
|
|
Server-persisted provider/model/secret/cost settings. Toggle the star to
|
|
pick which one the Assistant uses on the next message.
|
|
</CardDescription>
|
|
</div>
|
|
{usage ? (
|
|
<div className="flex shrink-0 flex-col items-start rounded-md border bg-muted/40 px-3 py-2 text-left sm:items-end sm: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 flex-wrap gap-2">
|
|
{hasLocalSettings && configs.length === 0 ? (
|
|
<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={() => setEditing("new")}
|
|
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}
|
|
|
|
{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}
|
|
secrets={secrets}
|
|
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 flex-col items-stretch justify-between gap-3 px-1 py-2.5 sm:flex-row sm:items-center">
|
|
<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
|
|
{c.reasoning_effort && c.reasoning_effort !== "off" ? (
|
|
<>
|
|
{" "}
|
|
· <span className="uppercase tracking-wider">think</span>{" "}
|
|
<span className="text-[var(--console-amber,oklch(0.78_0.15_60))]">
|
|
{c.reasoning_effort}
|
|
</span>
|
|
</>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center justify-end gap-3 pl-7 sm:pl-0">
|
|
{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}`}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onEdit}
|
|
data-action={`llm-config-edit-${c.id}`}
|
|
aria-label="Edit"
|
|
title={c.tenant_id == null ? "Edit platform default (visible to all tenants)" : "Edit"}
|
|
>
|
|
<Pencil className="size-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onDelete}
|
|
data-action={`llm-config-delete-${c.id}`}
|
|
aria-label="Delete"
|
|
title={c.tenant_id == null ? "Delete platform default" : "Delete"}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
// --- Add/Edit modal ------------------------------------------------------
|
|
|
|
function ConfigDialog({
|
|
existing,
|
|
catalog,
|
|
secrets,
|
|
onClose,
|
|
onSave,
|
|
}: {
|
|
existing: LlmConfiguration | null
|
|
catalog: CatalogEntry[]
|
|
secrets: Secret[]
|
|
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,
|
|
reasoning_effort: existing.reasoning_effort,
|
|
}
|
|
: 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}
|
|
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) => {
|
|
// 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 (optional)" className="sm:col-span-2">
|
|
<SecretPicker
|
|
value={draft.secret_name ?? null}
|
|
secrets={secrets}
|
|
onChange={(name) => setDraft({ ...draft, secret_name: name })}
|
|
/>
|
|
</Field>
|
|
|
|
<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="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>
|
|
|
|
<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>
|
|
|
|
<Field label="Reasoning effort (thinking models)" className="sm:col-span-2">
|
|
<Select
|
|
value={draft.reasoning_effort ?? "off"}
|
|
onValueChange={(v) =>
|
|
setDraft({
|
|
...draft,
|
|
reasoning_effort: (v === "off" ? null : v) as ReasoningEffort | null,
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{REASONING_EFFORTS.map((e) => (
|
|
<SelectItem key={e} value={e}>
|
|
<span className="flex items-center justify-between gap-3">
|
|
<span className="capitalize">{e}</span>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{reasoningHint(e)}
|
|
</span>
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</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={onSubmit}
|
|
disabled={!valid || saving}
|
|
data-action="llm-config-save"
|
|
>
|
|
{saving ? "Saving…" : existing ? "Save changes" : "Create"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// --- Model picker (Select + Custom… escape) -------------------------------
|
|
|
|
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)
|
|
|
|
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…" : "Type a model id"} />
|
|
</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>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
className = "",
|
|
}: {
|
|
label: string
|
|
children: React.ReactNode
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<div className={`flex flex-col gap-1 ${className}`}>
|
|
<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)}`
|
|
}
|
|
|
|
function reasoningHint(e: ReasoningEffort): string {
|
|
switch (e) {
|
|
case "off":
|
|
return "no thinking"
|
|
case "low":
|
|
return "~2k thinking tokens"
|
|
case "medium":
|
|
return "~8k thinking tokens"
|
|
case "high":
|
|
return "~24k thinking tokens"
|
|
case "max":
|
|
return "~64k — slowest, most thorough"
|
|
}
|
|
}
|