import type { PolyModel } from "@/lib/client/types"; import { estimateCost, estimateTextTokens } from "@/lib/client/tokens"; /** * Live per-response metrics for the Arena. While a model streams we estimate output tokens from the * character count and price them with `estimateCost`; once the `done` frame arrives the exact usage * and cost recorded by the server replace the estimate (`exact: true`). */ export type ArenaStatus = "idle" | "waiting" | "thinking" | "streaming" | "done" | "error" | "stopped"; export interface MetricsSource { status: ArenaStatus; text: string; reasoning?: string; startedAt: number; firstTokenAt?: number; response: { ttftMs?: number | null; latencyMs?: number | null; costUsd?: number | null; usage?: { inputTokens?: number; outputTokens?: number; reasoningTokens?: number; cachedInputTokens?: number } | null; } | null; } export interface LiveMetrics { status: ArenaStatus; /** Wall-clock time since the request started (or total latency once final). */ elapsedMs: number; ttftMs: number | null; inputTokens: number | null; outputTokens: number | null; reasoningTokens: number | null; cachedTokens: number | null; tokensPerSecond: number | null; costUsd: number | null; /** True when tokens/cost come from the provider, false while estimated client-side. */ exact: boolean; } export function isFinalStatus(s: ArenaStatus): boolean { return s === "done" || s === "error" || s === "stopped"; } /** * @param inputTokensEstimate client estimate of the prompt (+system prompt, attachments) in tokens * @param now current timestamp — pass it in so renders stay pure (ticked by a timer component) */ export function liveMetrics(src: MetricsSource, model: PolyModel | null | undefined, inputTokensEstimate: number, now: number): LiveMetrics { const final = isFinalStatus(src.status); const r = src.response; if (final && r && (r.usage || typeof r.costUsd === "number" || typeof r.latencyMs === "number")) { const u = r.usage ?? null; const out = u?.outputTokens ?? null; const gen = typeof r.latencyMs === "number" ? Math.max(1, r.latencyMs - (r.ttftMs ?? 0)) : null; return { status: src.status, elapsedMs: r.latencyMs ?? Math.max(0, now - src.startedAt), ttftMs: r.ttftMs ?? null, inputTokens: u?.inputTokens ?? null, outputTokens: out, reasoningTokens: u?.reasoningTokens ?? null, cachedTokens: u?.cachedInputTokens ?? null, tokensPerSecond: out && gen ? Math.round((out / gen) * 1000) : null, costUsd: typeof r.costUsd === "number" ? r.costUsd : null, exact: true, }; } const outputTokens = src.text ? estimateTextTokens(src.text) + (src.reasoning ? estimateTextTokens(src.reasoning) : 0) : null; const ttftMs = src.firstTokenAt ? Math.max(0, src.firstTokenAt - src.startedAt) : null; const genMs = src.firstTokenAt ? Math.max(1, now - src.firstTokenAt) : null; const tokensPerSecond = outputTokens && genMs && genMs > 400 ? Math.round((outputTokens / genMs) * 1000) : null; const cost = outputTokens !== null || src.status !== "idle" ? estimateCost(model, inputTokensEstimate, outputTokens ?? 0).usd : null; return { status: src.status, elapsedMs: Math.max(0, now - src.startedAt), ttftMs, inputTokens: inputTokensEstimate || null, outputTokens, reasoningTokens: null, cachedTokens: null, tokensPerSecond, costUsd: cost, exact: false, }; } /** Signed delta formatting helper: "−$0.0031", "+120 ms", "−340 tok". */ export function formatDelta(value: number | null, kind: "usd" | "ms" | "tokens"): string { if (value === null || !Number.isFinite(value)) return "—"; const sign = value < 0 ? "−" : value > 0 ? "+" : "±"; const abs = Math.abs(value); if (kind === "usd") return `${sign}$${abs < 0.01 ? abs.toFixed(abs < 0.001 ? 5 : 4) : abs.toFixed(2)}`; if (kind === "ms") return `${sign}${abs < 1000 ? `${Math.round(abs)} ms` : `${(abs / 1000).toFixed(abs < 10_000 ? 2 : 1)} s`}`; return `${sign}${abs >= 10_000 ? `${(abs / 1000).toFixed(1)}K` : Math.round(abs)} tok`; }