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 { gatewayStatusCommand } from "../../commands/gateway-status.js";
|
||||||
import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js";
|
import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js";
|
||||||
import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.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 { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { formatDocsLink } from "../../terminal/links.js";
|
import { formatDocsLink } from "../../terminal/links.js";
|
||||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||||
|
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
|
||||||
import { withProgress } from "../progress.js";
|
import { withProgress } from "../progress.js";
|
||||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||||
import {
|
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) {
|
export function registerGatewayCli(program: Command) {
|
||||||
const gateway = addGatewayRunCommand(
|
const gateway = addGatewayRunCommand(
|
||||||
program
|
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(
|
gatewayCallOpts(
|
||||||
gateway
|
gateway
|
||||||
.command("health")
|
.command("health")
|
||||||
|
|||||||
@@ -1,16 +1,78 @@
|
|||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
|
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||||
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
|
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||||
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
|
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.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 = {
|
export const usageHandlers: GatewayRequestHandlers = {
|
||||||
"usage.status": async ({ respond }) => {
|
"usage.status": async ({ respond }) => {
|
||||||
const summary = await loadProviderUsageSummary();
|
const summary = await loadProviderUsageSummary();
|
||||||
respond(true, summary, undefined);
|
respond(true, summary, undefined);
|
||||||
},
|
},
|
||||||
"usage.cost": async ({ respond }) => {
|
"usage.cost": async ({ respond, params }) => {
|
||||||
const config = loadConfig();
|
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);
|
respond(true, summary, undefined);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -184,9 +184,19 @@ export async function loadCostUsageSummary(params?: {
|
|||||||
|
|
||||||
const sessionsDir = resolveSessionTranscriptsDirForAgent(params?.agentId);
|
const sessionsDir = resolveSessionTranscriptsDirForAgent(params?.agentId);
|
||||||
const entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
|
const entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
|
||||||
const files = entries
|
const files = (
|
||||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
await Promise.all(
|
||||||
.map((entry) => path.join(sessionsDir, entry.name));
|
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) {
|
for (const filePath of files) {
|
||||||
await scanUsageFile({
|
await scanUsageFile({
|
||||||
|
|||||||
Reference in New Issue
Block a user