From 0e17e55be9025a341ff18c8fdcb9c79c86a333d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 08:51:14 +0000 Subject: [PATCH] fix: cache usage cost summary --- src/cli/gateway-cli/register.ts | 59 ++++++++++++++++++++++++++ src/gateway/server-methods/usage.ts | 66 ++++++++++++++++++++++++++++- src/infra/session-cost-usage.ts | 16 +++++-- 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 1f094699e..8334cc6f7 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -2,10 +2,12 @@ import type { Command } from "commander"; import { gatewayStatusCommand } from "../../commands/gateway-status.js"; import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js"; import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js"; +import type { CostUsageSummary } from "../../infra/session-cost-usage.js"; import { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js"; import { defaultRuntime } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; +import { formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import { withProgress } from "../progress.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { @@ -58,6 +60,41 @@ function runGatewayCommand(action: () => Promise, label?: string) { }); } +function parseDaysOption(raw: unknown, fallback = 30): number { + if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(1, Math.floor(raw)); + if (typeof raw === "string" && raw.trim() !== "") { + const parsed = Number(raw); + if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed)); + } + return fallback; +} + +function renderCostUsageSummary(summary: CostUsageSummary, days: number, rich: boolean): string[] { + const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00"; + const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0"; + const lines = [ + colorize(rich, theme.heading, `Usage cost (${days} days)`), + `${colorize(rich, theme.muted, "Total:")} ${totalCost} · ${totalTokens} tokens`, + ]; + + if (summary.totals.missingCostEntries > 0) { + lines.push( + `${colorize(rich, theme.muted, "Missing entries:")} ${summary.totals.missingCostEntries}`, + ); + } + + const latest = summary.daily.at(-1); + if (latest) { + const latestCost = formatUsd(latest.totalCost) ?? "$0.00"; + const latestTokens = formatTokenCount(latest.totalTokens) ?? "0"; + lines.push( + `${colorize(rich, theme.muted, "Latest day:")} ${latest.date} · ${latestCost} · ${latestTokens} tokens`, + ); + } + + return lines; +} + export function registerGatewayCli(program: Command) { const gateway = addGatewayRunCommand( program @@ -160,6 +197,28 @@ export function registerGatewayCli(program: Command) { }), ); + gatewayCallOpts( + gateway + .command("usage-cost") + .description("Fetch usage cost summary from session logs") + .option("--days ", "Number of days to include", "30") + .action(async (opts) => { + await runGatewayCommand(async () => { + const days = parseDaysOption(opts.days); + const result = await callGatewayCli("usage.cost", opts, { days }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + const rich = isRich(); + const summary = result as CostUsageSummary; + for (const line of renderCostUsageSummary(summary, days, rich)) { + defaultRuntime.log(line); + } + }, "Gateway usage cost failed"); + }), + ); + gatewayCallOpts( gateway .command("health") diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index e6d9b3722..dcdd89742 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -1,16 +1,78 @@ import { loadConfig } from "../../config/config.js"; +import type { CostUsageSummary } from "../../infra/session-cost-usage.js"; import { loadCostUsageSummary } from "../../infra/session-cost-usage.js"; import { loadProviderUsageSummary } from "../../infra/provider-usage.js"; import type { GatewayRequestHandlers } from "./types.js"; +const COST_USAGE_CACHE_TTL_MS = 30_000; + +type CostUsageCacheEntry = { + summary?: CostUsageSummary; + updatedAt?: number; + inFlight?: Promise; +}; + +const costUsageCache = new Map(); + +const parseDays = (raw: unknown): number => { + if (typeof raw === "number" && Number.isFinite(raw)) return Math.floor(raw); + if (typeof raw === "string" && raw.trim() !== "") { + const parsed = Number(raw); + if (Number.isFinite(parsed)) return Math.floor(parsed); + } + return 30; +}; + +async function loadCostUsageSummaryCached(params: { + days: number; + config: ReturnType; +}): Promise { + const days = Math.max(1, params.days); + const now = Date.now(); + const cached = costUsageCache.get(days); + if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) { + return cached.summary; + } + + if (cached?.inFlight) { + if (cached.summary) return cached.summary; + return await cached.inFlight; + } + + const entry: CostUsageCacheEntry = cached ?? {}; + const inFlight = loadCostUsageSummary({ days, config: params.config }) + .then((summary) => { + costUsageCache.set(days, { summary, updatedAt: Date.now() }); + return summary; + }) + .catch((err) => { + if (entry.summary) return entry.summary; + throw err; + }) + .finally(() => { + const current = costUsageCache.get(days); + if (current?.inFlight === inFlight) { + current.inFlight = undefined; + costUsageCache.set(days, current); + } + }); + + entry.inFlight = inFlight; + costUsageCache.set(days, entry); + + if (entry.summary) return entry.summary; + return await inFlight; +} + export const usageHandlers: GatewayRequestHandlers = { "usage.status": async ({ respond }) => { const summary = await loadProviderUsageSummary(); respond(true, summary, undefined); }, - "usage.cost": async ({ respond }) => { + "usage.cost": async ({ respond, params }) => { const config = loadConfig(); - const summary = await loadCostUsageSummary({ days: 30, config }); + const days = parseDays(params?.days); + const summary = await loadCostUsageSummaryCached({ days, config }); respond(true, summary, undefined); }, }; diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 9778f09bb..2b35d637e 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -184,9 +184,19 @@ export async function loadCostUsageSummary(params?: { const sessionsDir = resolveSessionTranscriptsDirForAgent(params?.agentId); const entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true }).catch(() => []); - const files = entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) - .map((entry) => path.join(sessionsDir, entry.name)); + const files = ( + await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map(async (entry) => { + const filePath = path.join(sessionsDir, entry.name); + const stats = await fs.promises.stat(filePath).catch(() => null); + if (!stats) return null; + if (stats.mtimeMs < sinceTime) return null; + return filePath; + }), + ) + ).filter((filePath): filePath is string => Boolean(filePath)); for (const filePath of files) { await scanUsageFile({