Settings: server-side LLM configurations + 30d spend roll-up
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) <noreply@anthropic.com>
This commit is contained in:
358
app/components/settings/llm-configurations-panel.tsx
Normal file
358
app/components/settings/llm-configurations-panel.tsx
Normal file
@@ -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<LlmConfiguration[]>([])
|
||||||
|
const [catalog, setCatalog] = useState<CatalogEntry[]>([])
|
||||||
|
const [usage, setUsage] = useState<LlmUsageSummary | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [draft, setDraft] = useState<LlmConfigurationInput>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle>Saved 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.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{usage ? (
|
||||||
|
<div className="flex shrink-0 flex-col items-end rounded-md border bg-muted/40 px-3 py-2 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 gap-2">
|
||||||
|
{hasLocalSettings ? (
|
||||||
|
<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={() => setCreating((v) => !v)}
|
||||||
|
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}
|
||||||
|
|
||||||
|
{creating ? (
|
||||||
|
<div className="grid gap-3 rounded-md border bg-muted/30 p-3 sm:grid-cols-2">
|
||||||
|
<Field label="Name">
|
||||||
|
<Input
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
placeholder="Production GPT-4o-mini"
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<Input
|
||||||
|
value={draft.model}
|
||||||
|
onChange={(e) => setDraft({ ...draft, model: e.target.value })}
|
||||||
|
list="llm-config-models"
|
||||||
|
placeholder="gpt-4o-mini"
|
||||||
|
/>
|
||||||
|
<datalist id="llm-config-models">
|
||||||
|
{modelsForProvider.map((m) => (
|
||||||
|
<option key={m.model} value={m.model} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</Field>
|
||||||
|
<Field label="Vault secret name (optional)">
|
||||||
|
<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)">
|
||||||
|
<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>
|
||||||
|
<div className="sm:col-span-2 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setCreating(false)
|
||||||
|
setDraft(emptyDraft())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onCreate}
|
||||||
|
disabled={!draft.name || !draft.model}
|
||||||
|
data-action="llm-config-save"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<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)}`
|
||||||
|
}
|
||||||
155
app/lib/arcadia/llm-configs.ts
Normal file
155
app/lib/arcadia/llm-configs.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LlmConfiguration[]> {
|
||||||
|
const params: Record<string, string | number | boolean | null | undefined> = {}
|
||||||
|
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<LlmConfiguration> {
|
||||||
|
const res = await arcadia.GET<{ data: LlmConfiguration }>(`${BASE}/${id}`)
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConfiguration(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
input: LlmConfigurationInput,
|
||||||
|
): Promise<LlmConfiguration> {
|
||||||
|
const res = await arcadia.POST<{ data: LlmConfiguration }>(BASE, {
|
||||||
|
body: { configuration: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConfiguration(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
input: Partial<LlmConfigurationInput>,
|
||||||
|
): Promise<LlmConfiguration> {
|
||||||
|
const res = await arcadia.PATCH<{ data: LlmConfiguration }>(`${BASE}/${id}`, {
|
||||||
|
body: { configuration: input },
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConfiguration(
|
||||||
|
arcadia: ArcadiaClient,
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await arcadia.DELETE(`${BASE}/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCatalog(arcadia: ArcadiaClient): Promise<CatalogEntry[]> {
|
||||||
|
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<LlmConfiguration, "input_cost_per_million" | "output_cost_per_million">,
|
||||||
|
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<LlmUsageSummary> {
|
||||||
|
const params: Record<string, string | number | boolean | null | undefined> = {}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { useArcadiaClient } from "@crema/arcadia-client"
|
import { useArcadiaClient } from "@crema/arcadia-client"
|
||||||
|
|
||||||
import { probeProxy, type LLMProxyProvider } from "~/lib/arcadia/llm-proxy"
|
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 { AppShell } from "~/components/layout/app-shell"
|
||||||
import { Button } from "~/components/ui/button"
|
import { Button } from "~/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@@ -212,12 +213,15 @@ export default function SettingsRoute() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{section === "llm" && (
|
{section === "llm" && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
<LlmConfigurationsPanel />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>LLM</CardTitle>
|
<CardTitle>Active LLM (this session)</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Pick a provider, model, and the arcadia-vault secret holding the API key. Settings
|
The Assistant uses this provider/model on the next message. For
|
||||||
auto-save as you type. The Assistant picks them up on the next message.
|
persistent setups shared across operators, use the Saved
|
||||||
|
configurations card above.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user