Usage: add cost summaries to /usage + mac menu
This commit is contained in:
@@ -59,14 +59,14 @@ describe("commands registry args", () => {
|
||||
name: "mode",
|
||||
description: "mode",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full"],
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
|
||||
expect(menu?.arg.name).toBe("mode");
|
||||
expect(menu?.choices).toEqual(["off", "tokens", "full"]);
|
||||
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
|
||||
});
|
||||
|
||||
it("does not show menus when arg already provided", () => {
|
||||
@@ -82,7 +82,7 @@ describe("commands registry args", () => {
|
||||
name: "mode",
|
||||
description: "mode",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full"],
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -141,7 +141,7 @@ describe("commands registry args", () => {
|
||||
name: "mode",
|
||||
description: "on or off",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full"],
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -233,14 +233,14 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
defineChatCommand({
|
||||
key: "usage",
|
||||
nativeName: "usage",
|
||||
description: "Toggle per-response usage line.",
|
||||
description: "Usage footer or cost summary.",
|
||||
textAlias: "/usage",
|
||||
args: [
|
||||
{
|
||||
name: "mode",
|
||||
description: "off, tokens, or full",
|
||||
description: "off, tokens, full, or cost",
|
||||
type: "string",
|
||||
choices: ["off", "tokens", "full"],
|
||||
choices: ["off", "tokens", "full", "cost"],
|
||||
},
|
||||
],
|
||||
argsMenu: "auto",
|
||||
|
||||
@@ -7,6 +7,8 @@ import { scheduleGatewaySigusr1Restart, triggerClawdbotRestart } from "../../inf
|
||||
import { parseActivationCommand } from "../group-activation.js";
|
||||
import { parseSendPolicyCommand } from "../send-policy.js";
|
||||
import { normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js";
|
||||
import { loadCostUsageSummary, loadSessionCostSummary } from "../../infra/session-cost-usage.js";
|
||||
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
|
||||
import {
|
||||
formatAbortReplyText,
|
||||
isAbortTrigger,
|
||||
@@ -141,10 +143,48 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
|
||||
|
||||
const rawArgs = normalized === "/usage" ? "" : normalized.slice("/usage".length).trim();
|
||||
const requested = rawArgs ? normalizeUsageDisplay(rawArgs) : undefined;
|
||||
if (rawArgs.toLowerCase().startsWith("cost")) {
|
||||
const sessionSummary = await loadSessionCostSummary({
|
||||
sessionId: params.sessionEntry?.sessionId,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionFile: params.sessionEntry?.sessionFile,
|
||||
config: params.cfg,
|
||||
});
|
||||
const summary = await loadCostUsageSummary({ days: 30, config: params.cfg });
|
||||
|
||||
const sessionCost = formatUsd(sessionSummary?.totalCost);
|
||||
const sessionTokens = sessionSummary?.totalTokens
|
||||
? formatTokenCount(sessionSummary.totalTokens)
|
||||
: undefined;
|
||||
const sessionMissing = sessionSummary?.missingCostEntries ?? 0;
|
||||
const sessionSuffix = sessionMissing > 0 ? " (partial)" : "";
|
||||
const sessionLine =
|
||||
sessionCost || sessionTokens
|
||||
? `Session ${sessionCost ?? "n/a"}${sessionSuffix}${sessionTokens ? ` · ${sessionTokens} tokens` : ""}`
|
||||
: "Session n/a";
|
||||
|
||||
const todayKey = new Date().toLocaleDateString("en-CA");
|
||||
const todayEntry = summary.daily.find((entry) => entry.date === todayKey);
|
||||
const todayCost = formatUsd(todayEntry?.totalCost);
|
||||
const todayMissing = todayEntry?.missingCostEntries ?? 0;
|
||||
const todaySuffix = todayMissing > 0 ? " (partial)" : "";
|
||||
const todayLine = `Today ${todayCost ?? "n/a"}${todaySuffix}`;
|
||||
|
||||
const last30Cost = formatUsd(summary.totals.totalCost);
|
||||
const last30Missing = summary.totals.missingCostEntries;
|
||||
const last30Suffix = last30Missing > 0 ? " (partial)" : "";
|
||||
const last30Line = `Last 30d ${last30Cost ?? "n/a"}${last30Suffix}`;
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `💸 Usage cost\n${sessionLine}\n${todayLine}\n${last30Line}` },
|
||||
};
|
||||
}
|
||||
|
||||
if (rawArgs && !requested) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Usage: /usage off|tokens|full" },
|
||||
reply: { text: "⚙️ Usage: /usage off|tokens|full|cost" },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const BASE_METHODS = [
|
||||
"channels.logout",
|
||||
"status",
|
||||
"usage.status",
|
||||
"usage.cost",
|
||||
"config.get",
|
||||
"config.set",
|
||||
"config.apply",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
@@ -6,4 +8,9 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
const summary = await loadProviderUsageSummary();
|
||||
respond(true, summary, undefined);
|
||||
},
|
||||
"usage.cost": async ({ respond }) => {
|
||||
const config = loadConfig();
|
||||
const summary = await loadCostUsageSummary({ days: 30, config });
|
||||
respond(true, summary, undefined);
|
||||
},
|
||||
};
|
||||
|
||||
142
src/infra/session-cost-usage.test.ts
Normal file
142
src/infra/session-cost-usage.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { loadCostUsageSummary, loadSessionCostSummary } from "./session-cost-usage.js";
|
||||
|
||||
describe("session cost usage", () => {
|
||||
it("aggregates daily totals with log cost and pricing fallback", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cost-"));
|
||||
const sessionsDir = path.join(root, "agents", "main", "sessions");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "sess-1.jsonl");
|
||||
|
||||
const now = new Date();
|
||||
const older = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const entries = [
|
||||
{
|
||||
type: "message",
|
||||
timestamp: now.toISOString(),
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 10,
|
||||
output: 20,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 30,
|
||||
cost: { total: 0.03 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: now.toISOString(),
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 10,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: older.toISOString(),
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 5,
|
||||
output: 5,
|
||||
totalTokens: 10,
|
||||
cost: { total: 0.01 },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
entries.map((entry) => JSON.stringify(entry)).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.2",
|
||||
cost: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const originalState = process.env.CLAWDBOT_STATE_DIR;
|
||||
process.env.CLAWDBOT_STATE_DIR = root;
|
||||
try {
|
||||
const summary = await loadCostUsageSummary({ days: 30, config });
|
||||
expect(summary.daily.length).toBe(1);
|
||||
expect(summary.totals.totalTokens).toBe(50);
|
||||
expect(summary.totals.totalCost).toBeCloseTo(0.03003, 5);
|
||||
} finally {
|
||||
if (originalState === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = originalState;
|
||||
}
|
||||
});
|
||||
|
||||
it("summarizes a single session file", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cost-session-"));
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
const now = new Date();
|
||||
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
timestamp: now.toISOString(),
|
||||
message: {
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 10,
|
||||
output: 20,
|
||||
totalTokens: 30,
|
||||
cost: { total: 0.03 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const summary = await loadSessionCostSummary({
|
||||
sessionFile,
|
||||
});
|
||||
expect(summary?.totalCost).toBeCloseTo(0.03, 5);
|
||||
expect(summary?.totalTokens).toBe(30);
|
||||
expect(summary?.lastActivity).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
257
src/infra/session-cost-usage.ts
Normal file
257
src/infra/session-cost-usage.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
|
||||
import type { NormalizedUsage, UsageLike } from "../agents/usage.js";
|
||||
import { normalizeUsage } from "../agents/usage.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionTranscriptsDirForAgent,
|
||||
} from "../config/sessions/paths.js";
|
||||
import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js";
|
||||
|
||||
type ParsedUsageEntry = {
|
||||
usage: NormalizedUsage;
|
||||
costTotal?: number;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
timestamp?: Date;
|
||||
};
|
||||
|
||||
export type CostUsageTotals = {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
missingCostEntries: number;
|
||||
};
|
||||
|
||||
export type CostUsageDailyEntry = CostUsageTotals & {
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type CostUsageSummary = {
|
||||
updatedAt: number;
|
||||
days: number;
|
||||
daily: CostUsageDailyEntry[];
|
||||
totals: CostUsageTotals;
|
||||
};
|
||||
|
||||
export type SessionCostSummary = CostUsageTotals & {
|
||||
sessionId?: string;
|
||||
sessionFile?: string;
|
||||
lastActivity?: number;
|
||||
};
|
||||
|
||||
const emptyTotals = (): CostUsageTotals => ({
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
totalCost: 0,
|
||||
missingCostEntries: 0,
|
||||
});
|
||||
|
||||
const toFiniteNumber = (value: unknown): number | undefined => {
|
||||
if (typeof value !== "number") return undefined;
|
||||
if (!Number.isFinite(value)) return undefined;
|
||||
return value;
|
||||
};
|
||||
|
||||
const extractCostTotal = (usageRaw?: UsageLike | null): number | undefined => {
|
||||
if (!usageRaw || typeof usageRaw !== "object") return undefined;
|
||||
const record = usageRaw as Record<string, unknown>;
|
||||
const cost = record.cost as Record<string, unknown> | undefined;
|
||||
const total = toFiniteNumber(cost?.total);
|
||||
if (total === undefined) return undefined;
|
||||
if (total < 0) return undefined;
|
||||
return total;
|
||||
};
|
||||
|
||||
const parseTimestamp = (entry: Record<string, unknown>): Date | undefined => {
|
||||
const raw = entry.timestamp;
|
||||
if (typeof raw === "string") {
|
||||
const parsed = new Date(raw);
|
||||
if (!Number.isNaN(parsed.valueOf())) return parsed;
|
||||
}
|
||||
const message = entry.message as Record<string, unknown> | undefined;
|
||||
const messageTimestamp = toFiniteNumber(message?.timestamp);
|
||||
if (messageTimestamp !== undefined) {
|
||||
const parsed = new Date(messageTimestamp);
|
||||
if (!Number.isNaN(parsed.valueOf())) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseUsageEntry = (entry: Record<string, unknown>): ParsedUsageEntry | null => {
|
||||
const message = entry.message as Record<string, unknown> | undefined;
|
||||
const role = message?.role;
|
||||
if (role !== "assistant") return null;
|
||||
|
||||
const usageRaw = (message?.usage as UsageLike | undefined) ?? (entry.usage as UsageLike | undefined);
|
||||
const usage = normalizeUsage(usageRaw);
|
||||
if (!usage) return null;
|
||||
|
||||
const provider =
|
||||
(typeof message?.provider === "string" ? message?.provider : undefined) ??
|
||||
(typeof entry.provider === "string" ? entry.provider : undefined);
|
||||
const model =
|
||||
(typeof message?.model === "string" ? message?.model : undefined) ??
|
||||
(typeof entry.model === "string" ? entry.model : undefined);
|
||||
|
||||
return {
|
||||
usage,
|
||||
costTotal: extractCostTotal(usageRaw),
|
||||
provider,
|
||||
model,
|
||||
timestamp: parseTimestamp(entry),
|
||||
};
|
||||
};
|
||||
|
||||
const formatDayKey = (date: Date): string =>
|
||||
date.toLocaleDateString("en-CA", { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone });
|
||||
|
||||
const applyUsageTotals = (totals: CostUsageTotals, usage: NormalizedUsage) => {
|
||||
totals.input += usage.input ?? 0;
|
||||
totals.output += usage.output ?? 0;
|
||||
totals.cacheRead += usage.cacheRead ?? 0;
|
||||
totals.cacheWrite += usage.cacheWrite ?? 0;
|
||||
const totalTokens =
|
||||
usage.total ??
|
||||
(usage.input ?? 0) +
|
||||
(usage.output ?? 0) +
|
||||
(usage.cacheRead ?? 0) +
|
||||
(usage.cacheWrite ?? 0);
|
||||
totals.totalTokens += totalTokens;
|
||||
};
|
||||
|
||||
const applyCostTotal = (totals: CostUsageTotals, costTotal: number | undefined) => {
|
||||
if (costTotal === undefined) {
|
||||
totals.missingCostEntries += 1;
|
||||
return;
|
||||
}
|
||||
totals.totalCost += costTotal;
|
||||
};
|
||||
|
||||
async function scanUsageFile(params: {
|
||||
filePath: string;
|
||||
config?: ClawdbotConfig;
|
||||
onEntry: (entry: ParsedUsageEntry) => void;
|
||||
}): Promise<void> {
|
||||
const fileStream = fs.createReadStream(params.filePath, { encoding: "utf-8" });
|
||||
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
||||
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
const entry = parseUsageEntry(parsed);
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.costTotal === undefined) {
|
||||
const cost = resolveModelCostConfig({
|
||||
provider: entry.provider,
|
||||
model: entry.model,
|
||||
config: params.config,
|
||||
});
|
||||
entry.costTotal = estimateUsageCost({ usage: entry.usage, cost });
|
||||
}
|
||||
|
||||
params.onEntry(entry);
|
||||
} catch {
|
||||
// Ignore malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCostUsageSummary(params?: {
|
||||
days?: number;
|
||||
config?: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
}): Promise<CostUsageSummary> {
|
||||
const days = Math.max(1, Math.floor(params?.days ?? 30));
|
||||
const now = new Date();
|
||||
const since = new Date(now);
|
||||
since.setDate(since.getDate() - (days - 1));
|
||||
const sinceTime = since.getTime();
|
||||
|
||||
const dailyMap = new Map<string, CostUsageTotals>();
|
||||
const totals = emptyTotals();
|
||||
|
||||
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));
|
||||
|
||||
for (const filePath of files) {
|
||||
await scanUsageFile({
|
||||
filePath,
|
||||
config: params?.config,
|
||||
onEntry: (entry) => {
|
||||
const ts = entry.timestamp?.getTime();
|
||||
if (!ts || ts < sinceTime) return;
|
||||
const dayKey = formatDayKey(entry.timestamp ?? now);
|
||||
const bucket = dailyMap.get(dayKey) ?? emptyTotals();
|
||||
applyUsageTotals(bucket, entry.usage);
|
||||
applyCostTotal(bucket, entry.costTotal);
|
||||
dailyMap.set(dayKey, bucket);
|
||||
|
||||
applyUsageTotals(totals, entry.usage);
|
||||
applyCostTotal(totals, entry.costTotal);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const daily = Array.from(dailyMap.entries())
|
||||
.map(([date, bucket]) => ({ date, ...bucket }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
return {
|
||||
updatedAt: Date.now(),
|
||||
days,
|
||||
daily,
|
||||
totals,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadSessionCostSummary(params: {
|
||||
sessionId?: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionFile?: string;
|
||||
config?: ClawdbotConfig;
|
||||
}): Promise<SessionCostSummary | null> {
|
||||
const sessionFile =
|
||||
params.sessionFile ??
|
||||
(params.sessionId ? resolveSessionFilePath(params.sessionId, params.sessionEntry) : undefined);
|
||||
if (!sessionFile || !fs.existsSync(sessionFile)) return null;
|
||||
|
||||
const totals = emptyTotals();
|
||||
let lastActivity: number | undefined;
|
||||
|
||||
await scanUsageFile({
|
||||
filePath: sessionFile,
|
||||
config: params.config,
|
||||
onEntry: (entry) => {
|
||||
applyUsageTotals(totals, entry.usage);
|
||||
applyCostTotal(totals, entry.costTotal);
|
||||
const ts = entry.timestamp?.getTime();
|
||||
if (ts && (!lastActivity || ts > lastActivity)) {
|
||||
lastActivity = ts;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: params.sessionId,
|
||||
sessionFile,
|
||||
lastActivity,
|
||||
...totals,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user