From 3ce1ee84ace49d5c9ef44b9e1a093e640090cfef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 19 Jan 2026 00:04:58 +0000 Subject: [PATCH] Usage: add cost summaries to /usage + mac menu --- CHANGELOG.md | 5 + .../Sources/Clawdbot/CostUsageMenuView.swift | 99 +++++++ .../Clawdbot/MenuSessionsInjector.swift | 83 ++++++ .../Sources/Clawdbot/UsageCostData.swift | 60 ++++ docs/concepts/usage-tracking.md | 1 + docs/token-use.md | 1 + docs/tools/slash-commands.md | 4 +- src/auto-reply/commands-registry.args.test.ts | 8 +- src/auto-reply/commands-registry.data.ts | 6 +- src/auto-reply/reply/commands-session.ts | 42 ++- src/gateway/server-methods-list.ts | 1 + src/gateway/server-methods/usage.ts | 7 + src/infra/session-cost-usage.test.ts | 142 ++++++++++ src/infra/session-cost-usage.ts | 257 ++++++++++++++++++ 14 files changed, 706 insertions(+), 10 deletions(-) create mode 100644 apps/macos/Sources/Clawdbot/CostUsageMenuView.swift create mode 100644 apps/macos/Sources/Clawdbot/UsageCostData.swift create mode 100644 src/infra/session-cost-usage.test.ts create mode 100644 src/infra/session-cost-usage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ec7f446..a48418313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Docs: https://docs.clawd.bot +## 2026.1.19-1 + +### Changes +- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. + ## 2026.1.18-5 ### Changes diff --git a/apps/macos/Sources/Clawdbot/CostUsageMenuView.swift b/apps/macos/Sources/Clawdbot/CostUsageMenuView.swift new file mode 100644 index 000000000..c94a4de35 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/CostUsageMenuView.swift @@ -0,0 +1,99 @@ +import Charts +import SwiftUI + +struct CostUsageHistoryMenuView: View { + let summary: GatewayCostUsageSummary + let width: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + self.header + self.chart + self.footer + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(width: max(1, self.width), alignment: .leading) + } + + private var header: some View { + let todayKey = CostUsageMenuDateParser.format(Date()) + let todayEntry = self.summary.daily.first { $0.date == todayKey } + let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a" + let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a" + + return HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Today") + .font(.caption2) + .foregroundStyle(.secondary) + Text(todayCost) + .font(.system(size: 14, weight: .semibold)) + } + VStack(alignment: .leading, spacing: 2) { + Text("Last \(self.summary.days)d") + .font(.caption2) + .foregroundStyle(.secondary) + Text(totalCost) + .font(.system(size: 14, weight: .semibold)) + } + Spacer() + } + } + + private var chart: some View { + let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in + guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil } + return (date, entry.totalCost) + } + + return Chart(entries, id: \.0) { entry in + BarMark( + x: .value("Day", entry.0), + y: .value("Cost", entry.1)) + .foregroundStyle(Color.accentColor) + .cornerRadius(3) + } + .chartXAxis { + AxisMarks(values: .stride(by: .day, count: 7)) { + AxisGridLine().foregroundStyle(.clear) + AxisValueLabel(format: .dateTime.month().day()) + } + } + .chartYAxis { + AxisMarks(position: .leading) { + AxisGridLine() + AxisValueLabel() + } + } + .frame(height: 110) + } + + private var footer: some View { + if self.summary.totals.missingCostEntries == 0 { + return AnyView(EmptyView()) + } + return AnyView( + Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost") + .font(.caption2) + .foregroundStyle(.secondary)) + } +} + +private enum CostUsageMenuDateParser { + static let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + return formatter + }() + + static func parse(_ value: String) -> Date? { + self.formatter.date(from: value) + } + + static func format(_ date: Date) -> String { + self.formatter.string(from: date) + } +} diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index fd1727c00..26a8a2d38 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -27,6 +27,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { private var cachedUsageErrorText: String? private var usageCacheUpdatedAt: Date? private let usageRefreshIntervalSeconds: TimeInterval = 30 + private var cachedCostSummary: GatewayCostUsageSummary? + private var cachedCostErrorText: String? + private var costCacheUpdatedAt: Date? + private let costRefreshIntervalSeconds: TimeInterval = 45 private let nodesStore = NodesStore.shared #if DEBUG private var testControlChannelConnected: Bool? @@ -64,6 +68,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { guard let self else { return } await self.refreshCache(force: forceRefresh) await self.refreshUsageCache(force: forceRefresh) + await self.refreshCostUsageCache(force: forceRefresh) await MainActor.run { guard self.isMenuOpen else { return } self.inject(into: menu) @@ -200,6 +205,7 @@ extension MenuSessionsInjector { } cursor = self.insertUsageSection(into: menu, at: cursor, width: width) + cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width) DispatchQueue.main.async { [weak self, weak headerView] in guard let self, let headerView else { return } @@ -344,6 +350,28 @@ extension MenuSessionsInjector { return cursor } + private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { + guard self.isControlChannelConnected else { return cursor } + guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor } + var cursor = cursor + + if cursor > 0, !menu.items[cursor - 1].isSeparatorItem { + let separator = NSMenuItem.separator() + separator.tag = self.tag + menu.insertItem(separator, at: cursor) + cursor += 1 + } + + let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "") + item.tag = self.tag + item.isEnabled = true + item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil) + item.submenu = submenu + menu.insertItem(item, at: cursor) + cursor += 1 + return cursor + } + private var selectedUsageProviderId: String? { guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil } let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) @@ -393,6 +421,36 @@ extension MenuSessionsInjector { } } + private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? { + if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil { + let menu = NSMenu() + let item = NSMenuItem(title: error, action: nil, keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + return menu + } + + guard let summary = self.cachedCostSummary else { return nil } + guard !summary.daily.isEmpty else { return nil } + + let menu = NSMenu() + menu.delegate = self + + let chartView = CostUsageHistoryMenuView(summary: summary, width: width) + let hosting = NSHostingView(rootView: AnyView(chartView)) + let controller = NSHostingController(rootView: AnyView(chartView)) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = "costUsageChart" + menu.addItem(chartItem) + + return menu + } + private func gatewayEntry() -> NodeInfo? { let mode = AppStateStore.shared.connectionMode let isConnected = self.isControlChannelConnected @@ -581,6 +639,31 @@ extension MenuSessionsInjector { self.usageCacheUpdatedAt = Date() } + private func refreshCostUsageCache(force: Bool) async { + if !force, + let updated = self.costCacheUpdatedAt, + Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds + { + return + } + + guard self.isControlChannelConnected else { + self.cachedCostSummary = nil + self.cachedCostErrorText = nil + self.costCacheUpdatedAt = Date() + return + } + + do { + self.cachedCostSummary = try await CostUsageLoader.loadSummary() + self.cachedCostErrorText = nil + } catch { + self.cachedCostSummary = nil + self.cachedCostErrorText = self.compactUsageError(error) + } + self.costCacheUpdatedAt = Date() + } + private func compactUsageError(_ error: Error) -> String { let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) if message.isEmpty { return "Usage unavailable" } diff --git a/apps/macos/Sources/Clawdbot/UsageCostData.swift b/apps/macos/Sources/Clawdbot/UsageCostData.swift new file mode 100644 index 000000000..ca1fb5cc3 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/UsageCostData.swift @@ -0,0 +1,60 @@ +import Foundation + +struct GatewayCostUsageTotals: Codable { + let input: Int + let output: Int + let cacheRead: Int + let cacheWrite: Int + let totalTokens: Int + let totalCost: Double + let missingCostEntries: Int +} + +struct GatewayCostUsageDay: Codable { + let date: String + let input: Int + let output: Int + let cacheRead: Int + let cacheWrite: Int + let totalTokens: Int + let totalCost: Double + let missingCostEntries: Int +} + +struct GatewayCostUsageSummary: Codable { + let updatedAt: Double + let days: Int + let daily: [GatewayCostUsageDay] + let totals: GatewayCostUsageTotals +} + +enum CostUsageFormatting { + static func formatUsd(_ value: Double?) -> String? { + guard let value, value.isFinite else { return nil } + if value >= 1 { return String(format: "$%.2f", value) } + if value >= 0.01 { return String(format: "$%.2f", value) } + return String(format: "$%.4f", value) + } + + static func formatTokenCount(_ value: Int?) -> String? { + guard let value else { return nil } + let safe = max(0, value) + if safe >= 1_000_000 { return String(format: "%.1fm", Double(safe) / 1_000_000.0) } + if safe >= 1000 { return safe >= 10000 + ? String(format: "%.0fk", Double(safe) / 1000.0) + : String(format: "%.1fk", Double(safe) / 1000.0) + } + return String(safe) + } +} + +@MainActor +enum CostUsageLoader { + static func loadSummary() async throws -> GatewayCostUsageSummary { + let data = try await ControlChannel.shared.request( + method: "usage.cost", + params: nil, + timeoutMs: 7000) + return try JSONDecoder().decode(GatewayCostUsageSummary.self, from: data) + } +} diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 93d52983f..4a91b8085 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -13,6 +13,7 @@ read_when: ## Where it shows up - `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available. - `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only). +- `/usage cost` in chats: local cost summary aggregated from Clawdbot session logs. - CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - macOS menu bar: “Usage” section under Context (only if available). diff --git a/docs/token-use.md b/docs/token-use.md index c5d1a8f92..c27412ff7 100644 --- a/docs/token-use.md +++ b/docs/token-use.md @@ -45,6 +45,7 @@ Use these in chat: - `/usage off|tokens|full` → appends a **per-response usage footer** to every reply. - Persists per session (stored as `responseUsage`). - OAuth auth **hides cost** (tokens only). +- `/usage cost` → shows a local cost summary from Clawdbot session logs. Other surfaces: diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 0678c32c6..8e64de65c 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -64,7 +64,7 @@ Text + native (when enabled): - `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session) - `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`) - `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`) -- `/usage off|tokens|full` (per-response usage footer) +- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary) - `/stop` - `/restart` - `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram) @@ -91,7 +91,7 @@ Text-only: Notes: - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). - For full provider usage breakdown, use `clawdbot status --usage`. -- `/usage` controls the per-response usage footer. It only shows dollar cost when the model uses an API key (OAuth hides cost). +- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs. - `/restart` is disabled by default; set `commands.restart: true` to enable it. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. diff --git a/src/auto-reply/commands-registry.args.test.ts b/src/auto-reply/commands-registry.args.test.ts index 8dda3a706..cee8cf5f3 100644 --- a/src/auto-reply/commands-registry.args.test.ts +++ b/src/auto-reply/commands-registry.args.test.ts @@ -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"], }, ], }; diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 6f7c5a2f6..547f92e88 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -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", diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 724228dad..84118a0a0 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -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" }, }; } diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 27ba19e53..85b427fc0 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -7,6 +7,7 @@ const BASE_METHODS = [ "channels.logout", "status", "usage.status", + "usage.cost", "config.get", "config.set", "config.apply", diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index b6a2a9bc0..e6d9b3722 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -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); + }, }; diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts new file mode 100644 index 000000000..961312d93 --- /dev/null +++ b/src/infra/session-cost-usage.test.ts @@ -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); + }); +}); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts new file mode 100644 index 000000000..f47e6f95d --- /dev/null +++ b/src/infra/session-cost-usage.ts @@ -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; + const cost = record.cost as Record | undefined; + const total = toFiniteNumber(cost?.total); + if (total === undefined) return undefined; + if (total < 0) return undefined; + return total; +}; + +const parseTimestamp = (entry: Record): 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 | 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): ParsedUsageEntry | null => { + const message = entry.message as Record | 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 { + 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; + 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 { + 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(); + 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 { + 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, + }; +}