fix: cache usage cost summary

This commit is contained in:
Peter Steinberger
2026-01-22 08:51:14 +00:00
parent 54e0fc342e
commit 0e17e55be9
3 changed files with 136 additions and 5 deletions

View File

@@ -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<void>, 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 <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")

View File

@@ -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<CostUsageSummary>;
};
const costUsageCache = new Map<number, CostUsageCacheEntry>();
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<typeof loadConfig>;
}): Promise<CostUsageSummary> {
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);
},
};

View File

@@ -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({