Usage: add cost summaries to /usage + mac menu
This commit is contained in:
@@ -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
|
||||
|
||||
99
apps/macos/Sources/Clawdbot/CostUsageMenuView.swift
Normal file
99
apps/macos/Sources/Clawdbot/CostUsageMenuView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
60
apps/macos/Sources/Clawdbot/UsageCostData.swift
Normal file
60
apps/macos/Sources/Clawdbot/UsageCostData.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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