Files
multica/packages/views/runtimes/utils.ts
marovole 0e31a9ca58 fix(agent/runtimes): show Cursor Composer token usage and billing (#4135)
* fix(agent/runtimes): show Cursor Composer token usage and billing

Attribute Cursor stream-json usage to the configured runtime model when
result events omit `model`, and add Composer/Auto pricing so dashboard
cost estimates resolve for composer-2.5 runs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(views): align Cursor Composer pricing

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-15 15:52:48 +08:00

850 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AgentRuntime,
RuntimeUsage,
RuntimeUsageByAgent,
} from "@multica/core/types";
import { getCustomPricing } from "@multica/core/runtimes/custom-pricing-store";
// A live local daemon re-registers itself within seconds of a server-side
// delete (daemon self-heal, #2404), so deleting an online local runtime from
// the UI has no lasting effect. Both the detail page and the list row menu
// gate their Delete affordance on this same predicate.
export function isSelfHealingRuntime(runtime: AgentRuntime): boolean {
return runtime.runtime_mode === "local" && runtime.status === "online";
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
// Compound-unit relative timestamp ("2m 14s ago", "1d 4h ago", "6d 19h ago")
// — gives the user enough precision to tell "just lost" from "long lost"
// at a glance without forcing them to mouse-over for a full timestamp.
export function formatLastSeen(lastSeenAt: string | null): string {
if (!lastSeenAt) return "Never";
const diffMs = Date.now() - new Date(lastSeenAt).getTime();
if (diffMs < 5_000) return "Just now";
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (minutes < 1) return `${seconds}s ago`;
if (hours < 1) {
const s = seconds % 60;
return s > 0 ? `${minutes}m ${s}s ago` : `${minutes}m ago`;
}
if (days < 1) {
const m = minutes % 60;
return m > 0 ? `${hours}h ${m}m ago` : `${hours}h ago`;
}
const h = hours % 24;
return h > 0 ? `${days}d ${h}h ago` : `${days}d ago`;
}
// Turns the back-end's `device_info` string ("MacBook-Pro · darwin-amd64",
// "some-host · linux-amd64") into something humans recognise. We don't have
// hardware model or geo data on the wire today, so we settle for an OS-aware
// rewrite of the GOOS/GOARCH suffix while preserving the hostname.
export function formatDeviceInfo(raw: string | null): string | null {
if (!raw) return null;
const trimmed = raw.trim();
if (!trimmed) return null;
return trimmed
.split(" · ")
.map((part) => prettifyOsArch(part))
.join(" · ");
}
function prettifyOsArch(part: string): string {
const lower = part.toLowerCase();
// Pattern: <os>-<arch>; e.g. darwin-amd64, linux-arm64, windows-amd64.
const match = lower.match(/^(darwin|linux|windows|freebsd|openbsd|netbsd)-(amd64|arm64|386|arm)$/);
if (!match) return part;
const os = match[1] ?? "";
const arch = match[2] ?? "";
const osLabel = OS_LABEL[os] ?? os;
const archLabel = ARCH_LABEL[arch] ?? arch;
return `${osLabel} (${archLabel})`;
}
const OS_LABEL: Record<string, string> = {
darwin: "macOS",
linux: "Linux",
windows: "Windows",
freebsd: "FreeBSD",
openbsd: "OpenBSD",
netbsd: "NetBSD",
};
const ARCH_LABEL: Record<string, string> = {
amd64: "x86_64",
arm64: "arm64",
"386": "x86",
arm: "arm",
};
// Strip leading "v" from version strings — GitHub releases ship `v0.2.17`,
// daemon metadata reports `0.2.15`; normalising lets us compare both.
function stripVersionPrefix(v: string): string {
return v.replace(/^v/, "");
}
// True iff `latest` is strictly newer than `current` by dotted-numeric
// comparison. Non-numeric / missing segments compare as 0 ("0.2" < "0.2.1").
// Used by the runtime-list CLI column to decide whether to surface the ↑
// marker; same logic also lives inline in update-section.tsx for now.
export function isVersionNewer(latest: string, current: string): boolean {
const l = stripVersionPrefix(latest).split(".").map(Number);
const c = stripVersionPrefix(current).split(".").map(Number);
for (let i = 0; i < Math.max(l.length, c.length); i++) {
const lv = l[i] ?? 0;
const cv = c[i] ?? 0;
if (lv > cv) return true;
if (lv < cv) return false;
}
return false;
}
export function formatTokens(n: number): string {
if (n >= 1_000_000) {
const m = n / 1_000_000;
return m % 1 < 0.05 ? `${Math.round(m)}M` : `${m.toFixed(1)}M`;
}
if (n >= 1_000) {
const k = n / 1_000;
return k % 1 < 0.05 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
}
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Cost estimation
// ---------------------------------------------------------------------------
// Pricing per million tokens (USD). Sources, each authoritative for the
// rows tagged under it — keep in sync when providers release new models
// or adjust prices.
//
// Anthropic: https://platform.claude.com/docs/en/about-claude/pricing
// OpenAI: https://openai.com/api/pricing
// DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
// Moonshot: https://www.kimi.com/resources/kimi-k2-6-pricing
// Zhipu: https://docs.z.ai/guides/overview/pricing
//
// Anthropic's cacheWrite reflects the 5-minute cache TTL (1.25× input); the
// daemon reports cache_creation_input_tokens without TTL metadata, so 5m is
// the safest / cheapest assumption (matches the API default). OpenAI,
// DeepSeek, Moonshot and Zhipu do not bill cache writes separately (cached
// input is just discounted on subsequent reads), so cacheWrite mirrors
// input there.
//
// The resolver matches exact keys after stripping a trailing date snapshot
// (see `resolvePricing` below). It deliberately does NOT do startsWith
// fallbacks: every catalog SKU needs its own row. That keeps unfamiliar
// variants (`gpt-5.5-mini`, hypothetical `gpt-5.4-foo`) from silently
// inheriting the price of a near-named relative; they surface in the
// unmapped diagnostic instead. Mirror new entries in
// `server/pkg/agent/models.go` so the catalog and pricing stay in sync.
const MODEL_PRICING: Record<
string,
{ input: number; output: number; cacheRead: number; cacheWrite: number }
> = {
// -- Anthropic: current generation. Fable 5 is a Mythos-class SKU at 10/50;
// Opus 4.5+ stays on the lower 5/25 Opus tier. --
"claude-fable-5": { input: 10, output: 50, cacheRead: 1.00, cacheWrite: 12.50 },
"claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.10, cacheWrite: 1.25 },
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
"claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
"claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
"claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
// -- Anthropic: pre-4.5 Opus (legacy, still served at original price tier) --
"claude-opus-4-1": { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
"claude-opus-4": { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
// -- Anthropic: Sonnet 4.0 (deprecated; same price as the 4.x family) --
"claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
// -- Anthropic: older Haiku tier (defensive entry for the rare runtime still on it) --
"claude-haiku-3-5": { input: 0.80, output: 4, cacheRead: 0.08, cacheWrite: 1.00 },
// -- OpenAI: dotted-minor Codex catalog SKUs. Each generation is priced
// independently — no fallback to `gpt-5`. Entries track
// `server/pkg/agent/models.go` (Codex provider list).
"gpt-5.5": { input: 5, output: 30, cacheRead: 0.50, cacheWrite: 5 },
"gpt-5.4-mini": { input: 0.75, output: 4.50, cacheRead: 0.075, cacheWrite: 0.75 },
"gpt-5.4": { input: 2.50, output: 15, cacheRead: 0.25, cacheWrite: 2.50 },
"gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 1.75 },
// -- OpenAI: GPT-5 family (Codex CLI's default is gpt-5-codex; -codex/-mini/-nano variants priced per OpenAI tiers) --
"gpt-5-codex": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
"gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0.25 },
"gpt-5-nano": { input: 0.05, output: 0.40, cacheRead: 0.005, cacheWrite: 0.05 },
"gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
// -- OpenAI: o-series reasoning models --
"o3-mini": { input: 1.10, output: 4.40, cacheRead: 0.55, cacheWrite: 1.10 },
"o3": { input: 2, output: 8, cacheRead: 0.50, cacheWrite: 2 },
"o4-mini": { input: 1.10, output: 4.40, cacheRead: 0.275, cacheWrite: 1.10 },
// -- OpenAI: GPT-4o family (legacy, kept for runtimes still configured against it) --
"gpt-4o-mini": { input: 0.15, output: 0.60, cacheRead: 0.075, cacheWrite: 0.15 },
"gpt-4o": { input: 2.50, output: 10, cacheRead: 1.25, cacheWrite: 2.50 },
// -- DeepSeek (api-docs.deepseek.com/quick_start/pricing).
// The official catalog lists exactly two current SKUs; `deepseek-chat`
// and `deepseek-reasoner` are aliases that route to `deepseek-v4-flash`
// (non-thinking and thinking mode respectively) per the same page.
// `deepseek-v4-pro` is currently under a 75%-off promo that ends
// 2026-05-31 15:59 UTC; we price at the post-promo standard rate
// ($1.74/$3.48) so the dashboard does not jump 4× on June 1 — accept
// a brief over-estimate during the promo over a sudden cliff after it. --
"deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0.14 },
"deepseek-v4-pro": { input: 1.74, output: 3.48, cacheRead: 0.0145, cacheWrite: 1.74 },
"deepseek-chat": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0.14 },
"deepseek-reasoner": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0.14 },
// -- Moonshot Kimi (kimi.com/resources/kimi-k2-6-pricing).
// Only K2.6 is on the official price sheet today; earlier K2 variants
// are intentionally omitted until Moonshot publishes their rates. --
"kimi-k2.6": { input: 0.95, output: 4.00, cacheRead: 0.16, cacheWrite: 0.95 },
// -- Zhipu z.ai (docs.z.ai/guides/overview/pricing). Free flash tiers
// are priced at 0 so they resolve cleanly instead of falling through
// to the "unmapped" diagnostic. --
"glm-5.1": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 1.4 },
"glm-5": { input: 1.0, output: 3.2, cacheRead: 0.2, cacheWrite: 1.0 },
"glm-5-turbo": { input: 1.2, output: 4.0, cacheRead: 0.24, cacheWrite: 1.2 },
"glm-4.7": { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0.6 },
"glm-4.7-flashx": { input: 0.07, output: 0.4, cacheRead: 0.01, cacheWrite: 0.07 },
"glm-4.7-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"glm-4.6": { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0.6 },
"glm-4.5": { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0.6 },
"glm-4.5-x": { input: 2.2, output: 8.9, cacheRead: 0.45, cacheWrite: 2.2 },
"glm-4.5-air": { input: 0.2, output: 1.1, cacheRead: 0.03, cacheWrite: 0.2 },
"glm-4.5-airx": { input: 1.1, output: 4.5, cacheRead: 0.22, cacheWrite: 1.1 },
"glm-4.5-flash": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
// -- Cursor Composer / Auto (cursor.com/docs/models-and-pricing,
// cursor.com/docs/models/cursor-composer-2,
// cursor.com/docs/models/cursor-composer-2-5).
// Cursor result events often omit `model`, so the daemon falls back to
// the configured runtime model or the legacy key `cursor`.
// Cursor does not publish a cache-write rate for these rows; keep it at
// 0 so reported cache_write_tokens don't invent spend from input pricing.
"auto": { input: 1.25, output: 6, cacheRead: 0.25, cacheWrite: 0 },
"composer-2.5-fast": { input: 3, output: 15, cacheRead: 0.5, cacheWrite: 0 },
"composer-2.5": { input: 0.5, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
"composer-2-fast": { input: 1.5, output: 7.5, cacheRead: 0.35, cacheWrite: 0 },
"composer-2": { input: 0.5, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
"composer-1.5": { input: 3.5, output: 17.5, cacheRead: 0.35, cacheWrite: 0 },
"composer-1": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
// Legacy fallback bucket when neither the result event nor the runtime
// model is known — price at the current Composer 2.5 Fast default.
"cursor": { input: 3, output: 15, cacheRead: 0.5, cacheWrite: 0 },
};
// Resolve a model string to its pricing tier. Exact match, with four
// tolerances applied in order:
//
// 1. Provider-prefixed IDs (`anthropic/claude-opus-4.7` from openclaw /
// opencode) — the `<provider>/` segment is routing metadata, not part
// of the SKU, so we strip it before lookup.
// 2. Anthropic dot↔dash normalization — Claude Code reports
// `claude-opus-4-7`, GitHub Copilot reports `claude-opus-4.7`. Same
// SKU, two transports. We canonicalize `claude-*` IDs to the dashed
// form Anthropic itself publishes. Scoped to `claude-*` because for
// OpenAI the separator IS semantic (`gpt-5.4` ≠ `gpt-5-4`).
// 3. Trailing dated snapshots (`claude-sonnet-4-5-20250929`,
// `gpt-5-2025-08-07`) — the family is what we price, the date is
// volatile, so we strip a trailing date / "latest" tag.
// 4. Trailing context-window tag (`claude-opus-4-7[1m]`) — Anthropic's
// 1M-context beta is the same SKU at standard rates for prompts
// ≤200K input tokens, with a 2× surcharge above that. Aggregated
// usage rows don't carry per-request prompt sizes, so we price the
// bracketed variant at the standard tier. Slight under-estimate
// beats the previous behaviour of dropping the row entirely.
//
// Anything still unmapped falls back to the user-supplied custom pricing
// store. No startsWith fallback: variants like `gpt-5.5-mini` must have
// their own row to be priced (otherwise they'd inherit `gpt-5.5`).
function resolvePricing(model: string) {
if (!model) return undefined;
for (const candidate of canonicalCandidates(model)) {
const hit = MODEL_PRICING[candidate];
if (hit) return hit;
}
for (const candidate of canonicalCandidates(model)) {
const hit = getCustomPricing(candidate);
if (hit) return hit;
}
return undefined;
}
// Generate the lookup candidates for a model string, in priority order:
// the raw string first (preserves explicit user / catalog spellings),
// then the canonicalized forms. Deduped so we don't repeat lookups.
function canonicalCandidates(model: string): string[] {
const seen = new Set<string>();
const out: string[] = [];
const push = (s: string) => {
if (!s || seen.has(s)) return;
seen.add(s);
out.push(s);
};
const stripDate = (s: string) =>
s.replace(/-(20\d{2}-\d{2}-\d{2}|20\d{6}|latest)$/, "");
const stripProvider = (s: string) => {
const i = s.indexOf("/");
return i > 0 && /^[a-z][a-z0-9_-]*$/i.test(s.slice(0, i)) ? s.slice(i + 1) : s;
};
// Only Anthropic IDs are dot↔dash equivalent. OpenAI separators are
// semantic, so we leave `gpt-5.4` etc. alone.
const canonAnthropic = (s: string) =>
s.startsWith("claude-") ? s.replace(/\./g, "-") : s;
// Trailing context-window tag (`claude-opus-4-7[1m]`). Same family,
// same price tier — see resolver comment above for the 1M-context
// pricing trade-off.
const stripContextTag = (s: string) => s.replace(/\[[^\]]+\]$/, "");
const raw = model;
const noProvider = stripProvider(raw);
const dashed = canonAnthropic(noProvider);
const noTag = stripContextTag(dashed);
push(raw);
push(noProvider);
push(dashed);
push(noTag);
push(stripDate(raw));
push(stripDate(noProvider));
push(stripDate(dashed));
push(stripDate(noTag));
return out;
}
// Cheap predicate for the empty-state diagnostic: which model strings in a
// usage batch failed pricing resolution. Useful when the user is staring at
// "$0.00 / 2M tokens" and wants to know why.
export function isModelPriced(model: string): boolean {
return resolvePricing(model) !== undefined;
}
// Returns the unique, sorted list of model strings present in `rows` that
// don't resolve to a price. Empty when everything's priced or there are no
// rows.
export function collectUnmappedModels(rows: readonly Priceable[]): string[] {
const set = new Set<string>();
for (const r of rows) {
if (r.model && !isModelPriced(r.model)) set.add(r.model);
}
return Array.from(set).toSorted();
}
// Anything carrying per-model token totals can be priced — RuntimeUsage,
// RuntimeUsageByAgent, RuntimeUsageByHour all share this shape on purpose
// (the back-end keeps the model dimension specifically so the client can
// run this calculation for any aggregation axis).
type Priceable = Pick<
RuntimeUsage,
"model" | "input_tokens" | "output_tokens" | "cache_read_tokens" | "cache_write_tokens"
>;
export function estimateCost(usage: Priceable): number {
const pricing = resolvePricing(usage.model);
if (!pricing) return 0;
return (
(usage.input_tokens * pricing.input +
usage.output_tokens * pricing.output +
usage.cache_read_tokens * pricing.cacheRead +
usage.cache_write_tokens * pricing.cacheWrite) /
1_000_000
);
}
export interface CostBreakdown {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
export function estimateCostBreakdown(usage: Priceable): CostBreakdown {
const pricing = resolvePricing(usage.model);
if (!pricing) {
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
}
return {
input: (usage.input_tokens * pricing.input) / 1_000_000,
output: (usage.output_tokens * pricing.output) / 1_000_000,
cacheRead: (usage.cache_read_tokens * pricing.cacheRead) / 1_000_000,
cacheWrite: (usage.cache_write_tokens * pricing.cacheWrite) / 1_000_000,
};
}
// Cache savings: what cache *reads* would have cost at full input pricing
// minus what they actually cost at the discounted cache-hit rate. This is a
// reconstruction of "money the cache saved you", not real-world spend.
export function estimateCacheSavings(usage: Priceable): number {
const pricing = resolvePricing(usage.model);
if (!pricing) return 0;
const wouldHaveCost = (usage.cache_read_tokens * pricing.input) / 1_000_000;
const actualCost = (usage.cache_read_tokens * pricing.cacheRead) / 1_000_000;
return wouldHaveCost - actualCost;
}
// ---------------------------------------------------------------------------
// Data aggregation
// ---------------------------------------------------------------------------
export interface DailyTokenData {
date: string;
label: string;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
export interface DailyCostData {
date: string;
label: string;
cost: number;
}
// Stacked variant — splits the daily $ figure into the three components that
// drive billing (cache reads excluded; their cost is tracked separately as
// "savings" since they're typically dominated by the cached-input discount).
export interface DailyCostStackData {
date: string;
label: string;
input: number;
output: number;
cacheWrite: number;
total: number;
}
export interface ModelDistribution {
model: string;
tokens: number;
cost: number;
}
export interface WeeklyTokenData {
weekStart: string;
weekEnd: string;
// X-axis tick — Monday of the week, e.g. "May 12".
label: string;
// Tooltip header — inclusive range, e.g. "May 12 May 18".
rangeLabel: string;
// True when `weekEnd` is in the future (today is mid-week). Surface this
// in the chart so the bar can be drawn at reduced opacity / striped to
// signal "don't read this as a finished week".
partial: boolean;
daysCovered: number;
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
}
export interface WeeklyCostStackData {
weekStart: string;
weekEnd: string;
label: string;
rangeLabel: string;
partial: boolean;
daysCovered: number;
input: number;
output: number;
cacheWrite: number;
total: number;
}
export function aggregateByDate(usage: RuntimeUsage[]): {
dailyTokens: DailyTokenData[];
dailyCost: DailyCostData[];
dailyCostStack: DailyCostStackData[];
modelDist: ModelDistribution[];
} {
const dateMap = new Map<string, Omit<DailyTokenData, "label">>();
const costMap = new Map<string, number>();
const stackMap = new Map<
string,
{ input: number; output: number; cacheWrite: number }
>();
const modelMap = new Map<string, { tokens: number; cost: number }>();
for (const u of usage) {
const existing = dateMap.get(u.date) ?? {
date: u.date,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
existing.input += u.input_tokens;
existing.output += u.output_tokens;
existing.cacheRead += u.cache_read_tokens;
existing.cacheWrite += u.cache_write_tokens;
dateMap.set(u.date, existing);
const dayCost = (costMap.get(u.date) ?? 0) + estimateCost(u);
costMap.set(u.date, dayCost);
const breakdown = estimateCostBreakdown(u);
const stack = stackMap.get(u.date) ?? {
input: 0,
output: 0,
cacheWrite: 0,
};
stack.input += breakdown.input;
stack.output += breakdown.output;
stack.cacheWrite += breakdown.cacheWrite;
stackMap.set(u.date, stack);
const modelName = u.model || u.provider;
const m = modelMap.get(modelName) ?? { tokens: 0, cost: 0 };
m.tokens +=
u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens;
m.cost += estimateCost(u);
modelMap.set(modelName, m);
}
const formatLabel = (d: string) => {
const date = new Date(d + "T00:00:00");
return `${date.getMonth() + 1}/${date.getDate()}`;
};
const dailyTokens = Array.from(dateMap.values())
.toSorted((a, b) => a.date.localeCompare(b.date))
.map((d) => ({ ...d, label: formatLabel(d.date) }));
const dailyCost = Array.from(costMap.entries())
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([date, cost]) => ({
date,
label: formatLabel(date),
cost: Math.round(cost * 100) / 100,
}));
const dailyCostStack = Array.from(stackMap.entries())
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([date, s]) => {
const round = (n: number) => Math.round(n * 100) / 100;
const input = round(s.input);
const output = round(s.output);
const cacheWrite = round(s.cacheWrite);
return {
date,
label: formatLabel(date),
input,
output,
cacheWrite,
total: round(input + output + cacheWrite),
};
});
const modelDist = [...modelMap.entries()]
.map(([model, data]) => ({ model, ...data }))
.sort((a, b) => b.tokens - a.tokens);
return { dailyTokens, dailyCost, dailyCostStack, modelDist };
}
// Fold daily-grain rows into ISO calendar weeks (MonSun). Reuses the same
// 180-day cache the daily aggregation reads from — no extra request. The
// latest week is flagged `partial` when today (in the runtime's tz) is
// before Sunday, so the chart can render the in-progress bar at half
// opacity instead of letting the user misread "this week" as a dip.
//
// `weekCount` pins the output to exactly that many trailing calendar weeks
// ending at the week that contains today (in `tz`). Buckets are pre-zeroed,
// so sparse data — including weeks with no usage — renders as empty bars
// rather than disappearing. Rows whose week falls outside the window are
// dropped; without this guard `.slice(-weekCount)` on a sparse 180-day
// aggregate would surface old populated weeks instead of the empty
// in-range buckets the user asked for (MUL-2382 weekly window scoping).
// Accepts any row carrying `date` + token counts + the model needed for
// pricing. Both `RuntimeUsage` (runtime detail) and `DashboardUsageDaily`
// (workspace dashboard) match this shape — there's no behavioural difference,
// just slightly different surrounding fields neither aggregator cares about.
type WeeklyAggregable = Pick<
RuntimeUsage,
| "date"
| "model"
| "input_tokens"
| "output_tokens"
| "cache_read_tokens"
| "cache_write_tokens"
>;
export function aggregateByWeek(
usage: readonly WeeklyAggregable[],
tz: string,
weekCount: number,
): {
weeklyTokens: WeeklyTokenData[];
weeklyCostStack: WeeklyCostStackData[];
} {
const count = Math.max(1, Math.floor(weekCount));
const today = todayIso(tz);
const currentWeekStart = weekStartIso(today);
const firstWeekStart = addDaysIso(currentWeekStart, -(count - 1) * 7);
type TokenAgg = Omit<WeeklyTokenData, "label" | "rangeLabel" | "partial" | "daysCovered" | "weekEnd">;
const tokenMap = new Map<string, TokenAgg>();
const stackMap = new Map<string, { input: number; output: number; cacheWrite: number }>();
// Pre-seed every trailing calendar week in the window so sparse / empty
// weeks still render as zero bars instead of being dropped.
for (let i = 0; i < count; i++) {
const wkStart = addDaysIso(firstWeekStart, i * 7);
tokenMap.set(wkStart, {
weekStart: wkStart,
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
});
stackMap.set(wkStart, { input: 0, output: 0, cacheWrite: 0 });
}
for (const u of usage) {
const wkStart = weekStartIso(u.date);
if (wkStart < firstWeekStart || wkStart > currentWeekStart) continue;
const tokens = tokenMap.get(wkStart);
if (!tokens) continue;
tokens.input += u.input_tokens;
tokens.output += u.output_tokens;
tokens.cacheRead += u.cache_read_tokens;
tokens.cacheWrite += u.cache_write_tokens;
const breakdown = estimateCostBreakdown(u);
const stack = stackMap.get(wkStart);
if (!stack) continue;
stack.input += breakdown.input;
stack.output += breakdown.output;
stack.cacheWrite += breakdown.cacheWrite;
}
const decorate = (weekStart: string) => {
const weekEnd = addDaysIso(weekStart, 6);
const partial = today < weekEnd;
// Inclusive count of how many days of this week have actually elapsed.
// Sits at 7 for closed weeks, 1..6 for the current week.
const elapsedDays = Math.min(
7,
Math.max(
1,
// Day index of `today` within [weekStart, weekEnd] + 1.
diffDaysIso(weekStart, today < weekStart ? weekStart : today < weekEnd ? today : weekEnd) + 1,
),
);
return {
weekStart,
weekEnd,
label: formatShortDate(weekStart),
rangeLabel: `${formatShortDate(weekStart)} ${formatShortDate(weekEnd)}`,
partial,
daysCovered: partial ? elapsedDays : 7,
};
};
const weeklyTokens: WeeklyTokenData[] = Array.from(tokenMap.values())
.toSorted((a, b) => a.weekStart.localeCompare(b.weekStart))
.map((t) => ({ ...t, ...decorate(t.weekStart) }));
const weeklyCostStack: WeeklyCostStackData[] = Array.from(stackMap.entries())
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([weekStart, s]) => {
const round = (n: number) => Math.round(n * 100) / 100;
const input = round(s.input);
const output = round(s.output);
const cacheWrite = round(s.cacheWrite);
return {
...decorate(weekStart),
input,
output,
cacheWrite,
total: round(input + output + cacheWrite),
};
});
return { weeklyTokens, weeklyCostStack };
}
// Slice a daily-grain usage series into the user's selected window AND the
// immediately prior window of equal length. "Today" is read in the runtime's
// timezone so the cutoff lands on the same calendar boundary the backend
// used when bucketing rows — without this the browser/runtime tz gap could
// shift the boundary by a day at the edges (#MUL-2382 sliceWindow tz bug).
export function sliceWindow(
usage: readonly RuntimeUsage[],
days: number,
tz: string,
): { filtered: RuntimeUsage[]; prevFiltered: RuntimeUsage[] } {
const today = todayIso(tz);
const isoCurrent = addDaysIso(today, -days);
const isoPrev = addDaysIso(today, -days * 2);
return {
filtered: usage.filter((u) => u.date >= isoCurrent),
prevFiltered: usage.filter(
(u) => u.date >= isoPrev && u.date < isoCurrent,
),
};
}
function diffDaysIso(from: string, to: string): number {
const [y1, m1, d1] = from.split("-").map(Number);
const [y2, m2, d2] = to.split("-").map(Number);
const a = Date.UTC(y1 ?? 1970, (m1 ?? 1) - 1, d1 ?? 1);
const b = Date.UTC(y2 ?? 1970, (m2 ?? 1) - 1, d2 ?? 1);
return Math.round((b - a) / 86_400_000);
}
// ---------------------------------------------------------------------------
// Calendar helpers — all date math runs on YYYY-MM-DD strings in the
// runtime's IANA timezone. The backend already groups daily usage by
// `start-of-day in runtime tz`, so we keep the entire frontend aggregation
// on the same axis (Daily / Weekly) to avoid one-day drift when the browser
// and runtime sit in different time zones.
// ---------------------------------------------------------------------------
// Today's calendar date (YYYY-MM-DD) in the given IANA timezone. `en-CA`
// gives ISO-shaped output without us having to assemble Intl parts by hand.
export function todayIso(tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
// Pure date arithmetic on a YYYY-MM-DD string. Uses UTC under the hood so
// DST transitions never shift the result by an hour and round to a
// neighbouring day.
export function addDaysIso(iso: string, days: number): string {
const [y, m, d] = iso.split("-").map(Number);
const dt = new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1));
dt.setUTCDate(dt.getUTCDate() + days);
return dt.toISOString().slice(0, 10);
}
// Monday-of-week as YYYY-MM-DD. ISO 8601 week-start, matching the heatmap
// and the team's day-to-day "this week" mental model. Pure string math —
// no `new Date()` reads — so it's stable under any host timezone.
export function weekStartIso(iso: string): string {
const [y, m, d] = iso.split("-").map(Number);
const dt = new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1));
const day = dt.getUTCDay(); // 0 = Sun, 1 = Mon, ..., 6 = Sat
const offset = (day + 6) % 7; // distance back to Monday
dt.setUTCDate(dt.getUTCDate() - offset);
return dt.toISOString().slice(0, 10);
}
// "May 12" — short, locale-aware month/day for a YYYY-MM-DD string. Parsing
// via UTC keeps the displayed day stable regardless of the browser's tz.
export function formatShortDate(iso: string): string {
const [y, m, d] = iso.split("-").map(Number);
const dt = new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, d ?? 1));
return dt.toLocaleString("en", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
// ---------------------------------------------------------------------------
// Cost-by-X aggregations
//
// All three "Cost by …" tabs share the same shape: a sorted list of rows
// where each row carries a key (agent name, model name, or hour-of-day),
// total tokens and total cost. The chart / list components are oblivious
// to which axis they're rendering — they just see {key, tokens, cost}.
// ---------------------------------------------------------------------------
export interface CostByKey {
key: string;
tokens: number;
cost: number;
taskCount: number;
}
// Per-(agent, model) rows → per-agent totals. Cost is summed across all
// models for that agent, then the list is sorted by cost desc so the
// heaviest-spending agent appears first.
export function aggregateCostByAgent(rows: RuntimeUsageByAgent[]): CostByKey[] {
const map = new Map<string, CostByKey>();
for (const r of rows) {
const entry = map.get(r.agent_id) ?? {
key: r.agent_id,
tokens: 0,
cost: 0,
taskCount: 0,
};
entry.tokens +=
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
entry.cost += estimateCost(r);
entry.taskCount += r.task_count;
map.set(r.agent_id, entry);
}
return Array.from(map.values()).toSorted((a, b) => b.cost - a.cost);
}
// Per-(date, model) rows → per-model totals (the "By model" tab reuses the
// daily-grain data we already cache, so no extra request is needed).
export function aggregateCostByModel(rows: RuntimeUsage[]): CostByKey[] {
const map = new Map<string, CostByKey>();
for (const r of rows) {
const key = r.model || r.provider || "unknown";
const entry = map.get(key) ?? { key, tokens: 0, cost: 0, taskCount: 0 };
entry.tokens +=
r.input_tokens + r.output_tokens + r.cache_read_tokens + r.cache_write_tokens;
entry.cost += estimateCost(r);
map.set(key, entry);
}
return Array.from(map.values()).toSorted((a, b) => b.cost - a.cost);
}
// Sum of estimated cost over the trailing window
// [today offsetDays daysBack, today offsetDays).
// `offsetDays = 0, daysBack = 7` → last 7 days.
// `offsetDays = 7, daysBack = 7` → the 7 days *before* the last 7 (the
// "previous" window for the runtime-list ↑/↓ delta).
//
// "Today" is read in `tz` (the viewer's timezone) so the cutoff lands on
// the same calendar boundary the backend used when bucketing rows — the
// rows arrive bucketed in the viewer's tz, so slicing them with the JS
// engine's local tz would shift the window by a day at the edges.
//
// Walks the same daily-grain `RuntimeUsage` rows that `aggregateByDate` uses,
// so the runtime-list cost stays consistent with the runtime-detail KPIs
// (and crucially, hits the same TanStack Query cache key).
export function computeCostInWindow(
rows: readonly RuntimeUsage[],
daysBack: number,
tz: string,
offsetDays: number = 0,
): number {
const today = todayIso(tz);
const isoEnd = addDaysIso(today, -offsetDays);
const isoStart = addDaysIso(today, -offsetDays - daysBack);
let total = 0;
for (const r of rows) {
if (r.date >= isoStart && r.date < isoEnd) total += estimateCost(r);
}
return total;
}
export function pctChange(current: number, previous: number): number | null {
if (previous <= 0) return null;
return Math.round(((current - previous) / previous) * 100);
}