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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user