diff --git a/app/components/settings/llm-configurations-panel.tsx b/app/components/settings/llm-configurations-panel.tsx index 8220cbc..028e2a4 100644 --- a/app/components/settings/llm-configurations-panel.tsx +++ b/app/components/settings/llm-configurations-panel.tsx @@ -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([]) const [catalog, setCatalog] = useState([]) const [usage, setUsage] = useState(null) + const [usageByModel, setUsageByModel] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [creating, setCreating] = useState(false) - const [draft, setDraft] = useState(emptyDraft()) + const [active, setActive] = useState(null) + const [editing, setEditing] = useState(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() {
- Saved configurations + LLM configurations - 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.
{usage ? ( @@ -167,7 +238,7 @@ export function LlmConfigurationsPanel() { ) : null}
- {hasLocalSettings ? ( + {hasLocalSettings && configs.length === 0 ? (
+ {error ? (

@@ -195,143 +267,373 @@ export function LlmConfigurationsPanel() {

) : null} - {creating ? ( -
- - setDraft({ ...draft, name: e.target.value })} - placeholder="Production GPT-4o-mini" - /> - - - - - - setDraft({ ...draft, model })} - /> - - - setDraft({ ...draft, secret_name: e.target.value || null })} - placeholder="llm-openai-key" - /> - - - setDraft({ ...draft, base_url: e.target.value || null })} - placeholder="leave blank for provider default" - /> - - -

- Auto-filled from the curated catalog when you save. Override later if you - have a negotiated rate. -

-
-
- - -
-
- ) : null} - {loading ? (

Loading…

) : configs.length === 0 ? ( -

- No configurations yet. Add one to start tracking cost. -

+ ) : (
    {configs.map((c) => ( -
  • -
    - - {c.name} - {c.tenant_id == null ? ( - - platform - - ) : null} - {!c.enabled ? ( - disabled - ) : null} - - - {c.provider} · {c.model} - {c.secret_name ? ( - <> - {" "} - · secret {c.secret_name} - - ) : null} - - - {formatRate(c.input_cost_per_million)}/1M in ·{" "} - {formatRate(c.output_cost_per_million)}/1M out - -
    - {c.tenant_id != null ? ( - - ) : null} -
  • + onMakeActive(c)} + onToggleEnabled={() => onToggleEnabled(c)} + onEdit={() => setEditing(c)} + onDelete={() => onDelete(c)} + /> ))}
)}
+ + {editing ? ( + setEditing(null)} + onSave={onSave} + /> + ) : null}
) } -/** - * 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. - */ +// --- Empty state --------------------------------------------------------- + +function EmptyState({ + onSeed, + onImport, +}: { + onSeed: () => void + onImport: (() => void) | null +}) { + return ( +
+ +
+

No configurations yet

+

+ Seed a starter set from the curated catalog (GPT-4o, Claude, DeepSeek, LM Studio) + and tweak from there. +

+
+
+ + {onImport ? ( + + ) : null} +
+
+ ) +} + +// --- 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 ( +
  • +
    + + +
    + + {c.name} + {c.tenant_id == null ? ( + + platform + + ) : null} + {!c.enabled ? ( + disabled + ) : null} + + + {c.provider} · {c.model} + {c.secret_name ? ( + <> + {" "} + · secret {c.secret_name} + + ) : null} + + + {formatRate(c.input_cost_per_million)}/1M in ·{" "} + {formatRate(c.output_cost_per_million)}/1M out + +
    +
    + +
    + {spend && spend.cost_cents > 0 ? ( +
    + + {formatCost(spend.cost_cents)} + + + 30d · {spend.requests.toLocaleString()} req + +
    + ) : null} + +
    + + {c.tenant_id != null ? ( + <> + + + + ) : null} +
    +
    +
  • + ) +} + +// --- Add/Edit modal ------------------------------------------------------ + +function ConfigDialog({ + existing, + catalog, + onClose, + onSave, +}: { + existing: LlmConfiguration | null + catalog: CatalogEntry[] + onClose: () => void + onSave: ( + input: LlmConfigurationInput, + existing: LlmConfiguration | null, + ) => Promise +}) { + const [draft, setDraft] = useState( + 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(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 ( + !open && onClose()}> + + + {existing ? "Edit configuration" : "New configuration"} + + Costs auto-fill from the curated catalog when you pick a known model. + Override below if you have a negotiated rate. + + + +
    + + setDraft({ ...draft, name: e.target.value })} + placeholder="Production GPT-4o-mini" + autoFocus={!existing} + /> + + + + + + + + { + // 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, + }), + }) + }} + /> + + + + setDraft({ ...draft, secret_name: e.target.value || null })} + placeholder="llm-openai-key" + /> + + + + setDraft({ ...draft, base_url: e.target.value || null })} + placeholder="leave blank for provider default" + /> + + + + + setDraft({ + ...draft, + input_cost_per_million: e.target.value === "" ? null : Number(e.target.value), + }) + } + placeholder="0.15" + /> + + + + + setDraft({ + ...draft, + output_cost_per_million: e.target.value === "" ? null : Number(e.target.value), + }) + } + placeholder="0.60" + /> + +
    + + {err ? ( +

    + {err} +

    + ) : null} + + + + + +
    +
    + ) +} + +// --- 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({ }} > - + {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 ( -
    +
    {children}
    diff --git a/app/lib/arcadia/llm-configs.ts b/app/lib/arcadia/llm-configs.ts index 884c72e..4f7f98b 100644 --- a/app/lib/arcadia/llm-configs.ts +++ b/app/lib/arcadia/llm-configs.ts @@ -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 { + const params: Record = {} + 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, +): UsageByModelRow | undefined { + return rows.find((r) => r.provider === config.provider && r.model === config.model) +} diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index ca1e1e7..173617f 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -215,22 +215,18 @@ export default function SettingsRoute() {
    - - - Active LLM (this session) - - The Assistant uses this provider/model on the next message. For - persistent setups shared across operators, use the Saved - configurations card above. - - - +
    + + Advanced: tweak the active session settings (transport, system prompt, + context budget) directly + +
    - - +
    +