From baf42c4cecf7383840156a8391450bc4b14110d1 Mon Sep 17 00:00:00 2001 From: jules Date: Sat, 2 May 2026 18:06:29 +1000 Subject: [PATCH] Settings: server-side LLM configurations + 30d spend roll-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the localStorage-only LLM settings with a persisted catalogue backed by /api/v1/admin/llm-configurations. The Settings → LLM screen now has two cards: - "Saved configurations" — full CRUD against the server. Each row shows provider/model/secret/published per-1M-token costs. Add wizard auto-fills costs from the curated catalog. One-click "Import local" button promotes any pre-existing localStorage settings into a server row, then clears the local store. - "Active LLM (this session)" — the existing LLMProvidersSettingsCard, scoped down to "what does the Assistant use right now" (still localStorage; per-operator). Spend (30d) tile in the configurations card header reads /api/v1/ai/llm/usage/summary and surfaces total cost / requests / tokens. First visible cost roll-up in the admin UI. New module app/lib/arcadia/llm-configs.ts: typed CRUD client, catalog lookup, computeCostCents helper (mirrors the server's LlmConfiguration.compute_cost_cents/3), and getUsageSummary. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/llm-configurations-panel.tsx | 358 ++++++++++++++++++ app/lib/arcadia/llm-configs.ts | 155 ++++++++ app/routes/settings.tsx | 10 +- 3 files changed, 520 insertions(+), 3 deletions(-) create mode 100644 app/components/settings/llm-configurations-panel.tsx create mode 100644 app/lib/arcadia/llm-configs.ts diff --git a/app/components/settings/llm-configurations-panel.tsx b/app/components/settings/llm-configurations-panel.tsx new file mode 100644 index 0000000..93e559c --- /dev/null +++ b/app/components/settings/llm-configurations-panel.tsx @@ -0,0 +1,358 @@ +// Saved LLM configurations: server-side CRUD + one-shot import of any +// localStorage settings the user had before this surface existed. +// +// Lists configurations (own + platform-default), exposes Add/Delete, and +// surfaces published costs from the curated catalog so the operator can +// see at a glance what each config costs per 1M tokens. + +import { useCallback, useEffect, useMemo, useState } from "react" +import { Plus, Trash2, Upload } from "lucide-react" +import { useArcadiaClient } from "@crema/arcadia-client" + +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { Input } from "~/components/ui/input" +import { Label } from "~/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select" +import { + createConfiguration, + deleteConfiguration, + formatCost, + getCatalog, + getUsageSummary, + listConfigurations, + type CatalogEntry, + type LlmConfiguration, + type LlmConfigurationInput, + type LlmProvider, + type LlmUsageSummary, +} from "~/lib/arcadia/llm-configs" + +const PROVIDERS: LlmProvider[] = ["openai", "anthropic", "deepseek", "qwen", "lmstudio"] + +const LOCAL_SETTINGS_KEY = "crema.llm-providers.settings" + +export function LlmConfigurationsPanel() { + const arcadia = useArcadiaClient() + const [configs, setConfigs] = useState([]) + const [catalog, setCatalog] = useState([]) + const [usage, setUsage] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [creating, setCreating] = useState(false) + const [draft, setDraft] = useState(emptyDraft()) + + const refresh = useCallback(async () => { + setError(null) + try { + const [list, cat, sum] = await Promise.all([ + listConfigurations(arcadia), + getCatalog(arcadia).catch(() => [] as CatalogEntry[]), + getUsageSummary(arcadia, { days: 30 }).catch(() => null), + ]) + setConfigs(list) + setCatalog(cat) + setUsage(sum) + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load configurations.") + } finally { + setLoading(false) + } + }, [arcadia]) + + useEffect(() => { + void refresh() + }, [refresh]) + + const modelsForProvider = useMemo( + () => catalog.filter((c) => c.provider === draft.provider && c.model !== "*"), + [catalog, draft.provider], + ) + + const onCreate = async () => { + setError(null) + try { + await createConfiguration(arcadia, draft) + setDraft(emptyDraft()) + setCreating(false) + await refresh() + } catch (e) { + setError(e instanceof Error ? e.message : "Create failed.") + } + } + + const onDelete = async (id: string) => { + setError(null) + try { + await deleteConfiguration(arcadia, id) + await refresh() + } catch (e) { + setError(e instanceof Error ? e.message : "Delete failed.") + } + } + + const onImportFromLocal = async () => { + setError(null) + const raw = typeof window !== "undefined" ? localStorage.getItem(LOCAL_SETTINGS_KEY) : null + if (!raw) { + setError("No local settings found to import.") + return + } + try { + const local = JSON.parse(raw) as { + providerId?: string + model?: string + baseURL?: string + secretName?: string + } + if (!local.providerId || !local.model) { + setError("Local settings are incomplete (missing provider or model).") + return + } + await createConfiguration(arcadia, { + name: `Imported (${local.providerId})`, + provider: local.providerId as LlmProvider, + model: local.model, + base_url: local.baseURL || null, + secret_name: local.secretName || null, + }) + // Keep the local store as a fallback in case import fails on a later + // browser; clearing only happens after a successful refresh confirms + // the row exists server-side. + localStorage.removeItem(LOCAL_SETTINGS_KEY) + await refresh() + } catch (e) { + setError(e instanceof Error ? e.message : "Import failed.") + } + } + + const hasLocalSettings = + typeof window !== "undefined" && !!localStorage.getItem(LOCAL_SETTINGS_KEY) + + return ( + + +
+ Saved 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. + +
+ {usage ? ( +
+ + Spend (30d) + + + {formatCost(usage.total_cost_cents ?? 0)} + + + {(usage.total_requests ?? 0).toLocaleString()} req ·{" "} + {(usage.total_tokens ?? 0).toLocaleString()} tok + +
+ ) : null} +
+ {hasLocalSettings ? ( + + ) : null} + +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + + {creating ? ( +
+ + setDraft({ ...draft, name: e.target.value })} + placeholder="Production GPT-4o-mini" + /> + + + + + + setDraft({ ...draft, model: e.target.value })} + list="llm-config-models" + placeholder="gpt-4o-mini" + /> + + {modelsForProvider.map((m) => ( + + + + 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} +
  • + ))} +
+ )} +
+
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +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)}` +} diff --git a/app/lib/arcadia/llm-configs.ts b/app/lib/arcadia/llm-configs.ts new file mode 100644 index 0000000..884c72e --- /dev/null +++ b/app/lib/arcadia/llm-configs.ts @@ -0,0 +1,155 @@ +// Arcadia LLM configurations API. +// +// Backed by /api/v1/admin/llm-configurations — server-side persisted +// provider/model/secret/cost settings. Replaces the localStorage-driven +// settings the admin UI used previously, so configurations and costs +// survive across browsers and operators. +// +// `tenant_id: null` configurations are platform-defaults visible to +// every tenant. Names are unique within (tenant, name). + +import type { ArcadiaClient } from "@crema/arcadia-client" + +export type LlmProvider = "openai" | "anthropic" | "deepseek" | "qwen" | "lmstudio" + +export interface LlmConfiguration { + id: string + tenant_id: string | null + name: string + provider: LlmProvider + model: string + base_url: string | null + secret_name: string | null + input_cost_per_million: number | null + output_cost_per_million: number | null + enabled: boolean + metadata: Record + inserted_at: string + updated_at: string +} + +export interface LlmConfigurationInput { + tenant_id?: string | null + name: string + provider: LlmProvider + model: string + base_url?: string | null + secret_name?: string | null + /** USD per 1M tokens. Omit to auto-fill from the catalog. */ + input_cost_per_million?: number | null + output_cost_per_million?: number | null + enabled?: boolean + metadata?: Record +} + +export interface CatalogEntry { + provider: LlmProvider + model: string + input_cost_per_million: number + output_cost_per_million: number + context_window: number | null + notes: string | null +} + +const BASE = "/api/v1/admin/llm-configurations" + +export async function listConfigurations( + arcadia: ArcadiaClient, + opts: { enabled?: boolean; tenant_id?: string } = {}, +): Promise { + const params: Record = {} + if (opts.enabled != null) params.enabled = String(opts.enabled) + if (opts.tenant_id) params.tenant_id = opts.tenant_id + const res = await arcadia.GET<{ data: LlmConfiguration[] }>(BASE, { params }) + return res.data +} + +export async function getConfiguration( + arcadia: ArcadiaClient, + id: string, +): Promise { + const res = await arcadia.GET<{ data: LlmConfiguration }>(`${BASE}/${id}`) + return res.data +} + +export async function createConfiguration( + arcadia: ArcadiaClient, + input: LlmConfigurationInput, +): Promise { + const res = await arcadia.POST<{ data: LlmConfiguration }>(BASE, { + body: { configuration: input }, + }) + return res.data +} + +export async function updateConfiguration( + arcadia: ArcadiaClient, + id: string, + input: Partial, +): Promise { + const res = await arcadia.PATCH<{ data: LlmConfiguration }>(`${BASE}/${id}`, { + body: { configuration: input }, + }) + return res.data +} + +export async function deleteConfiguration( + arcadia: ArcadiaClient, + id: string, +): Promise { + await arcadia.DELETE(`${BASE}/${id}`) +} + +export async function getCatalog(arcadia: ArcadiaClient): Promise { + const res = await arcadia.GET<{ data: CatalogEntry[] }>(`${BASE}/catalog`) + return res.data +} + +/** + * Compute cost in cents for a given input/output token count using a + * configuration's published rates. Mirrors `LlmConfiguration.compute_cost_cents/3` + * in arcadia-app — keep in sync. + */ +export function computeCostCents( + config: Pick, + inputTokens: number, + outputTokens: number, +): number { + const inRate = config.input_cost_per_million ?? 0 + const outRate = config.output_cost_per_million ?? 0 + const cents = ((inputTokens * inRate + outputTokens * outRate) / 1_000_000) * 100 + return Math.round(cents) +} + +/** Format a cost in cents as "$X.XX" or "$0.0XX" for sub-dollar amounts. */ +export function formatCost(cents: number): string { + if (cents === 0) return "$0" + if (cents < 100) return `$${(cents / 100).toFixed(2)}` + return `$${(cents / 100).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} + +// --------------------------------------------------------------------------- +// LLM usage summary (cost roll-up) +// --------------------------------------------------------------------------- + +export interface LlmUsageSummary { + total_requests: number | null + total_input_tokens: number | null + total_output_tokens: number | null + total_tokens: number | null + total_cost_cents: number | null + avg_latency_ms: number | null +} + +export async function getUsageSummary( + arcadia: ArcadiaClient, + opts: { days?: number } = {}, +): Promise { + const params: Record = {} + if (opts.days != null) params.days = opts.days + const res = await arcadia.GET<{ data: LlmUsageSummary } | LlmUsageSummary>( + "/api/v1/ai/llm/usage/summary", + { params }, + ) + return "data" in (res as object) ? (res as { data: LlmUsageSummary }).data : (res as LlmUsageSummary) +} diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index bd9b8f8..ca1e1e7 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -19,6 +19,7 @@ import { import { useArcadiaClient } from "@crema/arcadia-client" import { probeProxy, type LLMProxyProvider } from "~/lib/arcadia/llm-proxy" +import { LlmConfigurationsPanel } from "~/components/settings/llm-configurations-panel" import { AppShell } from "~/components/layout/app-shell" import { Button } from "~/components/ui/button" import { @@ -212,12 +213,15 @@ export default function SettingsRoute() {
{section === "llm" && (
+ + - LLM + Active LLM (this session) - Pick a provider, model, and the arcadia-vault secret holding the API key. Settings - auto-save as you type. The Assistant picks them up on the next message. + The Assistant uses this provider/model on the next message. For + persistent setups shared across operators, use the Saved + configurations card above.