fix: cache usage cost summary
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user