diff --git a/CHANGELOG.md b/CHANGELOG.md index 8631fdb2e..8396ad62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - Docs: add ClawdHub guide and hubs link for browsing, install, and sync workflows. - Docs: add FAQ for PNPM/Bun lockfile migration warning; link AgentSkills spec + ClawdHub guide (`/clawdhub`) from skills docs. - Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312. +- Status: add provider usage snapshots to `/status`, `clawdbot status --usage`, and the macOS menu bar. - Build: fix macOS packaging QR smoke test for the bun-compiled relay. Thanks @dbhurley for PR #358. - Browser: fix `browser snapshot`/`browser act` timeouts under Bun by patching Playwright’s CDP WebSocket selection. Thanks @azade-c for PR #307. - Browser: add `--browser-profile` flag and honor profile in tabs routes + browser tool. Thanks @jamesgroat for PR #324. diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index 347c56bcc..c7fdcb545 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -22,6 +22,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { private var cachedErrorText: String? private var cacheUpdatedAt: Date? private let refreshIntervalSeconds: TimeInterval = 12 + private var cachedUsageSummary: GatewayUsageSummary? + private var cachedUsageErrorText: String? + private var usageCacheUpdatedAt: Date? + private let usageRefreshIntervalSeconds: TimeInterval = 30 private let nodesStore = NodesStore.shared #if DEBUG private var testControlChannelConnected: Bool? @@ -58,6 +62,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { self.loadTask = Task { [weak self] in guard let self else { return } await self.refreshCache(force: forceRefresh) + await self.refreshUsageCache(force: forceRefresh) await MainActor.run { guard self.isMenuOpen else { return } self.inject(into: menu) @@ -108,66 +113,70 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { guard self.isControlChannelConnected else { return } - guard let snapshot = self.cachedSnapshot else { + var cursor = insertIndex + var headerView: NSView? + + if let snapshot = self.cachedSnapshot { + let now = Date() + let rows = snapshot.rows.filter { row in + if row.key == "main" { return true } + guard let updatedAt = row.updatedAt else { return false } + return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds + }.sorted { lhs, rhs in + if lhs.key == "main" { return true } + if rhs.key == "main" { return false } + return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) + } + let headerItem = NSMenuItem() headerItem.tag = self.tag headerItem.isEnabled = false - headerItem.view = self.makeHostedView( + let hosted = self.makeHostedView( + rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)), + width: width, + highlighted: false) + headerItem.view = hosted + headerView = hosted + menu.insertItem(headerItem, at: cursor) + cursor += 1 + + if rows.isEmpty { + menu.insertItem( + self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width), + at: cursor) + cursor += 1 + } else { + for row in rows { + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = true + item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath) + item.view = self.makeHostedView( + rootView: AnyView(SessionMenuLabelView(row: row, width: width)), + width: width, + highlighted: true) + menu.insertItem(item, at: cursor) + cursor += 1 + } + } + } else { + let headerItem = NSMenuItem() + headerItem.tag = self.tag + headerItem.isEnabled = false + let hosted = self.makeHostedView( rootView: AnyView(MenuSessionsHeaderView( count: 0, statusText: self.cachedErrorText ?? "Loading sessions…")), width: width, highlighted: false) - menu.insertItem(headerItem, at: insertIndex) - DispatchQueue.main.async { [weak self, weak view = headerItem.view] in - guard let self, let view else { return } - self.captureMenuWidthIfAvailable(from: view) - } - return - } - - let now = Date() - let rows = snapshot.rows.filter { row in - if row.key == "main" { return true } - guard let updatedAt = row.updatedAt else { return false } - return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds - }.sorted { lhs, rhs in - if lhs.key == "main" { return true } - if rhs.key == "main" { return false } - return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) - } - - let headerItem = NSMenuItem() - headerItem.tag = self.tag - headerItem.isEnabled = false - let headerView = self.makeHostedView( - rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)), - width: width, - highlighted: false) - headerItem.view = headerView - menu.insertItem(headerItem, at: insertIndex) - - var cursor = insertIndex + 1 - if rows.isEmpty { - menu.insertItem( - self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width), - at: cursor) - return - } - - for row in rows { - let item = NSMenuItem() - item.tag = self.tag - item.isEnabled = true - item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath) - item.view = self.makeHostedView( - rootView: AnyView(SessionMenuLabelView(row: row, width: width)), - width: width, - highlighted: true) - menu.insertItem(item, at: cursor) + headerItem.view = hosted + headerView = hosted + menu.insertItem(headerItem, at: cursor) cursor += 1 } + cursor = self.insertUsageSection(into: menu, at: cursor, width: width) + DispatchQueue.main.async { [weak self, weak headerView] in guard let self, let headerView else { return } self.captureMenuWidthIfAvailable(from: headerView) @@ -240,6 +249,55 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { _ = cursor } + private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { + let rows = self.usageRows + let errorText = self.cachedUsageErrorText + + if rows.isEmpty && errorText == nil { + return cursor + } + + var cursor = cursor + let headerItem = NSMenuItem() + headerItem.tag = self.tag + headerItem.isEnabled = false + headerItem.view = self.makeHostedView( + rootView: AnyView(MenuUsageHeaderView( + count: rows.count, + statusText: errorText)), + width: width, + highlighted: false) + menu.insertItem(headerItem, at: cursor) + cursor += 1 + + if rows.isEmpty { + menu.insertItem( + self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width), + at: cursor) + cursor += 1 + return cursor + } + + for row in rows { + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = false + item.view = self.makeHostedView( + rootView: AnyView(UsageMenuLabelView(row: row, width: width)), + width: width, + highlighted: false) + menu.insertItem(item, at: cursor) + cursor += 1 + } + + return cursor + } + + private var usageRows: [UsageRow] { + guard let summary = self.cachedUsageSummary else { return [] } + return summary.primaryRows() + } + private var isControlChannelConnected: Bool { #if DEBUG if let override = self.testControlChannelConnected { return override } @@ -364,6 +422,40 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { } } + private func refreshUsageCache(force: Bool) async { + if !force, + let updated = self.usageCacheUpdatedAt, + Date().timeIntervalSince(updated) < self.usageRefreshIntervalSeconds + { + return + } + + guard self.isControlChannelConnected else { + self.cachedUsageSummary = nil + self.cachedUsageErrorText = nil + self.usageCacheUpdatedAt = Date() + return + } + + do { + self.cachedUsageSummary = try await UsageLoader.loadSummary() + self.cachedUsageErrorText = nil + self.usageCacheUpdatedAt = Date() + } catch { + if self.cachedUsageSummary == nil { + self.cachedUsageErrorText = self.compactUsageError(error) + } + self.usageCacheUpdatedAt = Date() + } + } + + private func compactUsageError(_ error: Error) -> String { + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + if message.isEmpty { return "Usage unavailable" } + if message.count > 90 { return "\(message.prefix(87))…" } + return message + } + private func compactError(_ error: Error) -> String { if let loadError = error as? SessionLoadError { switch loadError { diff --git a/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift b/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift new file mode 100644 index 000000000..199b01cf1 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct MenuUsageHeaderView: View { + let count: Int + let statusText: String? + + private let paddingTop: CGFloat = 8 + private let paddingBottom: CGFloat = 6 + private let paddingTrailing: CGFloat = 10 + private let paddingLeading: CGFloat = 20 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text("Usage") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 10) + Text(self.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let statusText, !statusText.isEmpty { + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + .padding(.top, self.paddingTop) + .padding(.bottom, self.paddingBottom) + .padding(.leading, self.paddingLeading) + .padding(.trailing, self.paddingTrailing) + .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) + .transaction { txn in txn.animation = nil } + } + + private var subtitle: String { + if self.count == 1 { return "1 provider" } + return "\(self.count) providers" + } +} + diff --git a/apps/macos/Sources/Clawdbot/UsageData.swift b/apps/macos/Sources/Clawdbot/UsageData.swift new file mode 100644 index 000000000..0db492938 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/UsageData.swift @@ -0,0 +1,110 @@ +import Foundation + +struct GatewayUsageWindow: Codable { + let label: String + let usedPercent: Double + let resetAt: Double? +} + +struct GatewayUsageProvider: Codable { + let provider: String + let displayName: String + let windows: [GatewayUsageWindow] + let plan: String? + let error: String? +} + +struct GatewayUsageSummary: Codable { + let updatedAt: Double + let providers: [GatewayUsageProvider] +} + +struct UsageRow: Identifiable { + let id: String + let displayName: String + let plan: String? + let windowLabel: String? + let usedPercent: Double? + let resetAt: Date? + let error: String? + + var titleText: String { + if let plan, !plan.isEmpty { return "\(displayName) (\(plan))" } + return displayName + } + + var remainingPercent: Int? { + guard let usedPercent, usedPercent.isFinite else { return nil } + let remaining = max(0, min(100, Int(round(100 - usedPercent)))) + return remaining + } + + func detailText(now: Date = .init()) -> String { + if let error, !error.isEmpty { return error } + guard let remaining = self.remainingPercent else { return "No data" } + var parts = ["\(remaining)% left"] + if let windowLabel, !windowLabel.isEmpty { parts.append(windowLabel) } + if let resetAt { + let reset = UsageRow.formatResetRemaining(target: resetAt, now: now) + if let reset { parts.append("⏱\(reset)") } + } + return parts.joined(separator: " · ") + } + + private static func formatResetRemaining(target: Date, now: Date) -> String? { + let diff = target.timeIntervalSince(now) + if diff <= 0 { return "now" } + let minutes = Int(floor(diff / 60)) + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + let mins = minutes % 60 + if hours < 24 { return mins > 0 ? "\(hours)h \(mins)m" : "\(hours)h" } + let days = hours / 24 + if days < 7 { return "\(days)d \(hours % 24)h" } + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + return formatter.string(from: target) + } +} + +extension GatewayUsageSummary { + func primaryRows() -> [UsageRow] { + self.providers.compactMap { provider in + if let error = provider.error, provider.windows.isEmpty { + return UsageRow( + id: provider.provider, + displayName: provider.displayName, + plan: provider.plan, + windowLabel: nil, + usedPercent: nil, + resetAt: nil, + error: error) + } + + guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else { + return nil + } + + return UsageRow( + id: "\(provider.provider)-\(window.label)", + displayName: provider.displayName, + plan: provider.plan, + windowLabel: window.label, + usedPercent: window.usedPercent, + resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) }, + error: provider.error) + } + } +} + +@MainActor +enum UsageLoader { + static func loadSummary() async throws -> GatewayUsageSummary { + let data = try await ControlChannel.shared.request( + method: "usage.status", + params: nil, + timeoutMs: 5000) + return try JSONDecoder().decode(GatewayUsageSummary.self, from: data) + } +} + diff --git a/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift b/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift new file mode 100644 index 000000000..c5514a53d --- /dev/null +++ b/apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct UsageMenuLabelView: View { + let row: UsageRow + let width: CGFloat + private let paddingLeading: CGFloat = 22 + private let paddingTrailing: CGFloat = 14 + private let barHeight: CGFloat = 6 + + private var primaryTextColor: Color { .primary } + private var secondaryTextColor: Color { .secondary } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let used = row.usedPercent { + ContextUsageBar( + usedTokens: Int(round(used)), + contextTokens: 100, + width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)), + height: self.barHeight) + } + + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(row.titleText) + .font(.caption.weight(.semibold)) + .foregroundStyle(self.primaryTextColor) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) + + Spacer(minLength: 4) + + Text(row.detailText()) + .font(.caption.monospacedDigit()) + .foregroundStyle(self.secondaryTextColor) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + } + } + .padding(.vertical, 10) + .padding(.leading, self.paddingLeading) + .padding(.trailing, self.paddingTrailing) + } +} + diff --git a/docs/cli/index.md b/docs/cli/index.md index 33ea246ed..a12dbcf2c 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -305,9 +305,24 @@ Show linked session health and recent recipients. Options: - `--json` - `--deep` (probe providers) +- `--usage` (show provider usage/quota) - `--timeout ` - `--verbose` +### Usage tracking +Clawdbot can surface provider usage/quota when OAuth/API creds are available. + +Surfaces: +- `/status` (adds a short usage line when available) +- `clawdbot status --usage` (prints full provider breakdown) +- macOS menu bar (Usage section under Context) + +Notes: +- Data comes directly from provider usage endpoints (no estimates). +- Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured. +- If no matching credentials exist, usage is hidden. +- Details: see [Usage tracking](/concepts/usage-tracking). + ### `health` Fetch health from the running Gateway. diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md new file mode 100644 index 000000000..2c8e6531c --- /dev/null +++ b/docs/concepts/usage-tracking.md @@ -0,0 +1,27 @@ +--- +summary: "Usage tracking surfaces and credential requirements" +read_when: + - You are wiring provider usage/quota surfaces + - You need to explain usage tracking behavior or auth requirements +--- +# Usage tracking + +## What it is +- Pulls provider usage/quota directly from their usage endpoints. +- No estimated costs; only the provider-reported windows. + +## Where it shows up +- `/status` in chats: adds a short “Usage” line (only if available). +- CLI: `clawdbot status --usage` prints a full per-provider breakdown. +- macOS menu bar: “Usage” section under Context (only if available). + +## Providers + credentials +- **Anthropic (Claude)**: OAuth tokens in auth profiles. +- **GitHub Copilot**: OAuth tokens in auth profiles. +- **Gemini CLI**: OAuth tokens in auth profiles. +- **Antigravity**: OAuth tokens in auth profiles. +- **OpenAI Codex**: OAuth tokens in auth profiles (accountId used when present). +- **z.ai**: API key via env/config/auth store. + +Usage is hidden if no matching OAuth/API credentials exist. + diff --git a/docs/platforms/mac/menu-bar.md b/docs/platforms/mac/menu-bar.md index 39ce441c7..c1979722e 100644 --- a/docs/platforms/mac/menu-bar.md +++ b/docs/platforms/mac/menu-bar.md @@ -9,6 +9,7 @@ read_when: - We surface the current agent work state in the menu bar icon and in the first status row of the menu. - Health status is hidden while work is active; it returns when all sessions are idle. - The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries. +- A “Usage” section appears under Context when provider usage snapshots are available. ## State model - Sessions: events arrive with `runId` (per-run) plus `sessionKey` in the payload. The “main” session is the key `main`; if absent, we fall back to the most recently updated session. diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 722ee4132..54514a35e 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -38,6 +38,10 @@ import { formatContextUsageShort, formatTokenCount, } from "../status.js"; +import { + formatUsageSummaryLine, + loadProviderUsageSummary, +} from "../../infra/provider-usage.js"; import type { MsgContext } from "../templating.js"; import type { ElevatedLevel, @@ -383,6 +387,15 @@ export async function handleCommands(params: { ); return { shouldContinue: false }; } + let usageLine: string | null = null; + try { + const usageSummary = await loadProviderUsageSummary({ + timeoutMs: 3500, + }); + usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() }); + } catch { + usageLine = null; + } const queueSettings = resolveQueueSettings({ cfg, provider: command.provider, @@ -421,6 +434,7 @@ export async function handleCommands(params: { resolvedReasoning: resolvedReasoningLevel, resolvedElevated: resolvedElevatedLevel, modelAuth: resolveModelAuthLabel(provider, cfg), + usageLine: usageLine ?? undefined, queue: { mode: queueSettings.mode, depth: queueDepth, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index f958b5402..97bafe16e 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -95,6 +95,22 @@ describe("buildStatusMessage", () => { ); }); + it("inserts usage summary beneath context line", () => { + const text = buildStatusMessage({ + agent: { model: "anthropic/claude-opus-4-5", contextTokens: 32_000 }, + sessionEntry: { sessionId: "u1", updatedAt: 0, totalTokens: 1000 }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { mode: "collect", depth: 0 }, + usageLine: "📊 Usage: Claude 80% left (5h)", + }); + + const lines = text.split("\n"); + const contextIndex = lines.findIndex((line) => line.startsWith("📚 ")); + expect(contextIndex).toBeGreaterThan(-1); + expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)"); + }); + it("prefers cached prompt tokens from the session log", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-status-")); const previousHome = process.env.HOME; diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 69379ed8e..da125ece8 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -50,6 +50,7 @@ type StatusArgs = { resolvedReasoning?: ReasoningLevel; resolvedElevated?: ElevatedLevel; modelAuth?: string; + usageLine?: string; queue?: QueueStatus; includeTranscriptUsage?: boolean; now?: number; @@ -356,6 +357,7 @@ export function buildStatusMessage(args: StatusArgs): string { versionLine, modelLine, `📚 ${contextLine}`, + args.usageLine, `🧵 ${sessionLine}`, `⚙️ ${optionsLine}`, activationLine, diff --git a/src/cli/program.ts b/src/cli/program.ts index 46d8eeddf..3ff9dbc73 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -639,6 +639,7 @@ Examples: .command("status") .description("Show web session health and recent session recipients") .option("--json", "Output JSON instead of text", false) + .option("--usage", "Show provider usage/quota snapshots", false) .option( "--deep", "Probe providers (WhatsApp Web + Telegram + Discord + Slack + Signal)", @@ -652,6 +653,7 @@ Examples: Examples: clawdbot status # show linked account + session store summary clawdbot status --json # machine-readable output + clawdbot status --usage # show provider usage/quota snapshots clawdbot status --deep # run provider probes (WA + Telegram + Discord + Slack + Signal) clawdbot status --deep --timeout 5000 # tighten probe timeout`, ) @@ -672,6 +674,7 @@ Examples: { json: Boolean(opts.json), deep: Boolean(opts.deep), + usage: Boolean(opts.usage), timeoutMs: timeout, }, defaultRuntime, diff --git a/src/commands/status.ts b/src/commands/status.ts index 911d5f438..2c546c2ae 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -11,6 +11,10 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; +import { + formatUsageReportLines, + loadProviderUsageSummary, +} from "../infra/provider-usage.js"; import { callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import { buildProviderSummary } from "../infra/provider-summary.js"; @@ -218,10 +222,13 @@ const buildFlags = (entry: SessionEntry): string[] => { }; export async function statusCommand( - opts: { json?: boolean; deep?: boolean; timeoutMs?: number }, + opts: { json?: boolean; deep?: boolean; usage?: boolean; timeoutMs?: number }, runtime: RuntimeEnv, ) { const summary = await getStatusSummary(); + const usage = opts.usage + ? await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }) + : undefined; const health: HealthSummary | undefined = opts.deep ? await callGateway({ method: "health", @@ -231,7 +238,11 @@ export async function statusCommand( if (opts.json) { runtime.log( - JSON.stringify(health ? { ...summary, health } : summary, null, 2), + JSON.stringify( + health || usage ? { ...summary, health, usage } : summary, + null, + 2, + ), ); return; } @@ -302,4 +313,10 @@ export async function statusCommand( } else { runtime.log("No session activity yet."); } + + if (usage) { + for (const line of formatUsageReportLines(usage)) { + runtime.log(line); + } + } } diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 16b9fe364..210bbbc0b 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -17,6 +17,7 @@ import type { GatewayRequestHandlers, GatewayRequestOptions, } from "./server-methods/types.js"; +import { usageHandlers } from "./server-methods/usage.js"; import { voicewakeHandlers } from "./server-methods/voicewake.js"; import { webHandlers } from "./server-methods/web.js"; import { wizardHandlers } from "./server-methods/wizard.js"; @@ -38,6 +39,7 @@ const handlers: GatewayRequestHandlers = { ...systemHandlers, ...nodeHandlers, ...sendHandlers, + ...usageHandlers, ...agentHandlers, }; diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts new file mode 100644 index 000000000..696180025 --- /dev/null +++ b/src/gateway/server-methods/usage.ts @@ -0,0 +1,10 @@ +import { loadProviderUsageSummary } from "../../infra/provider-usage.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +export const usageHandlers: GatewayRequestHandlers = { + "usage.status": async ({ respond }) => { + const summary = await loadProviderUsageSummary(); + respond(true, summary, undefined); + }, +}; + diff --git a/src/gateway/server.ts b/src/gateway/server.ts index d198f999b..c06a8941d 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -205,6 +205,7 @@ const METHODS = [ "health", "providers.status", "status", + "usage.status", "config.get", "config.set", "config.schema", diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts new file mode 100644 index 000000000..cda3a6832 --- /dev/null +++ b/src/infra/provider-usage.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { + formatUsageReportLines, + formatUsageSummaryLine, + loadProviderUsageSummary, + type UsageSummary, +} from "./provider-usage.js"; + +describe("provider usage formatting", () => { + it("returns null when no usage is available", () => { + const summary: UsageSummary = { updatedAt: 0, providers: [] }; + expect(formatUsageSummaryLine(summary)).toBeNull(); + }); + + it("picks the most-used window for summary line", () => { + const summary: UsageSummary = { + updatedAt: 0, + providers: [ + { + provider: "anthropic", + displayName: "Claude", + windows: [ + { label: "5h", usedPercent: 10 }, + { label: "Week", usedPercent: 60 }, + ], + }, + ], + }; + const line = formatUsageSummaryLine(summary, { now: 0 }); + expect(line).toContain("Claude"); + expect(line).toContain("40% left"); + expect(line).toContain("(Week"); + }); + + it("prints provider errors in report output", () => { + const summary: UsageSummary = { + updatedAt: 0, + providers: [ + { + provider: "openai-codex", + displayName: "Codex", + windows: [], + error: "Token expired", + }, + ], + }; + const lines = formatUsageReportLines(summary); + expect(lines.join("\n")).toContain("Codex: Token expired"); + }); + + it("includes reset countdowns in report lines", () => { + const now = Date.UTC(2026, 0, 7, 0, 0, 0); + const summary: UsageSummary = { + updatedAt: now, + providers: [ + { + provider: "anthropic", + displayName: "Claude", + windows: [ + { label: "5h", usedPercent: 20, resetAt: now + 60_000 }, + ], + }, + ], + }; + const lines = formatUsageReportLines(summary, { now }); + expect(lines.join("\n")).toContain("resets 1m"); + }); +}); + +describe("provider usage loading", () => { + it("loads usage snapshots with injected auth", async () => { + const makeResponse = (status: number, body: unknown) => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }) as any; + + const mockFetch = vi.fn(async (input: any) => { + const url = String(input); + if (url.includes("api.anthropic.com")) { + return makeResponse(200, { + five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, + }); + } + if (url.includes("api.z.ai")) { + return makeResponse(200, { + success: true, + code: 200, + data: { + planName: "Pro", + limits: [ + { + type: "TOKENS_LIMIT", + percentage: 25, + unit: 3, + number: 6, + nextResetTime: "2026-01-07T06:00:00Z", + }, + ], + }, + }); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadProviderUsageSummary({ + now: Date.UTC(2026, 0, 7, 0, 0, 0), + auth: [ + { provider: "anthropic", token: "token-1" }, + { provider: "zai", token: "token-2" }, + ], + fetch: mockFetch, + }); + + expect(summary.providers).toHaveLength(2); + const claude = summary.providers.find((p) => p.provider === "anthropic"); + const zai = summary.providers.find((p) => p.provider === "zai"); + expect(claude?.windows[0]?.label).toBe("5h"); + expect(zai?.plan).toBe("Pro"); + expect(mockFetch).toHaveBeenCalled(); + }); +}); diff --git a/src/infra/provider-usage.ts b/src/infra/provider-usage.ts new file mode 100644 index 000000000..095ec5bc9 --- /dev/null +++ b/src/infra/provider-usage.ts @@ -0,0 +1,757 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { loadConfig } from "../config/config.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, + resolveApiKeyForProfile, + resolveAuthProfileOrder, +} from "../agents/auth-profiles.js"; +import { + getCustomProviderApiKey, + resolveEnvApiKey, +} from "../agents/model-auth.js"; +import { normalizeProviderId } from "../agents/model-selection.js"; + +export type UsageWindow = { + label: string; + usedPercent: number; + resetAt?: number; +}; + +export type ProviderUsageSnapshot = { + provider: UsageProviderId; + displayName: string; + windows: UsageWindow[]; + plan?: string; + error?: string; +}; + +export type UsageSummary = { + updatedAt: number; + providers: ProviderUsageSnapshot[]; +}; + +export type UsageProviderId = + | "anthropic" + | "github-copilot" + | "google-gemini-cli" + | "google-antigravity" + | "openai-codex" + | "zai"; + +type ProviderAuth = { + provider: UsageProviderId; + token: string; + accountId?: string; +}; + +type UsageSummaryOptions = { + now?: number; + timeoutMs?: number; + providers?: UsageProviderId[]; + auth?: ProviderAuth[]; + fetch?: typeof fetch; +}; + +const DEFAULT_TIMEOUT_MS = 5000; + +const PROVIDER_LABELS: Record = { + anthropic: "Claude", + "github-copilot": "Copilot", + "google-gemini-cli": "Gemini", + "google-antigravity": "Antigravity", + "openai-codex": "Codex", + zai: "z.ai", +}; + +const usageProviders: UsageProviderId[] = [ + "anthropic", + "github-copilot", + "google-gemini-cli", + "google-antigravity", + "openai-codex", + "zai", +]; + +const ignoredErrors = new Set([ + "No credentials", + "No token", + "No API key", + "Not logged in", + "No auth", +]); + +const clampPercent = (value: number) => + Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0)); + +const withTimeout = async ( + work: Promise, + ms: number, + fallback: T, +): Promise => { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + work, + new Promise((resolve) => { + timeout = setTimeout(() => resolve(fallback), ms); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +}; + +function formatResetRemaining(targetMs?: number, now?: number): string | null { + if (!targetMs) return null; + const base = now ?? Date.now(); + const diffMs = targetMs - base; + if (diffMs <= 0) return "now"; + + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 60) return `${diffMins}m`; + + const hours = Math.floor(diffMins / 60); + const mins = diffMins % 60; + if (hours < 24) return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ${hours % 24}h`; + + return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }) + .format(new Date(targetMs)); +} + +function pickPrimaryWindow(windows: UsageWindow[]): UsageWindow | undefined { + if (windows.length === 0) return undefined; + return windows.reduce((best, next) => + next.usedPercent > best.usedPercent ? next : best, + ); +} + +function formatWindowShort(window: UsageWindow, now?: number): string { + const remaining = clampPercent(100 - window.usedPercent); + const reset = formatResetRemaining(window.resetAt, now); + const resetSuffix = reset ? ` ⏱${reset}` : ""; + return `${remaining.toFixed(0)}% left (${window.label}${resetSuffix})`; +} + +export function formatUsageSummaryLine( + summary: UsageSummary, + opts?: { now?: number; maxProviders?: number }, +): string | null { + const providers = summary.providers + .filter((entry) => entry.windows.length > 0 && !entry.error) + .slice(0, opts?.maxProviders ?? summary.providers.length); + if (providers.length === 0) return null; + + const parts = providers.map((entry) => { + const window = pickPrimaryWindow(entry.windows); + if (!window) return null; + return `${entry.displayName} ${formatWindowShort(window, opts?.now)}`; + }).filter(Boolean) as string[]; + + if (parts.length === 0) return null; + return `📊 Usage: ${parts.join(" · ")}`; +} + +export function formatUsageReportLines( + summary: UsageSummary, + opts?: { now?: number }, +): string[] { + if (summary.providers.length === 0) { + return ["Usage: no provider usage available."]; + } + + const lines: string[] = ["Usage:"]; + for (const entry of summary.providers) { + const planSuffix = entry.plan ? ` (${entry.plan})` : ""; + if (entry.error) { + lines.push(` ${entry.displayName}${planSuffix}: ${entry.error}`); + continue; + } + if (entry.windows.length === 0) { + lines.push(` ${entry.displayName}${planSuffix}: no data`); + continue; + } + lines.push(` ${entry.displayName}${planSuffix}`); + for (const window of entry.windows) { + const remaining = clampPercent(100 - window.usedPercent); + const reset = formatResetRemaining(window.resetAt, opts?.now); + const resetSuffix = reset ? ` · resets ${reset}` : ""; + lines.push( + ` ${window.label}: ${remaining.toFixed(0)}% left${resetSuffix}`, + ); + } + } + return lines; +} + +function parseGoogleToken(apiKey: string): { token: string } | null { + if (!apiKey) return null; + try { + const parsed = JSON.parse(apiKey) as { token?: unknown }; + if (parsed && typeof parsed.token === "string") { + return { token: parsed.token }; + } + } catch { + // ignore + } + return null; +} + +async function fetchJson( + url: string, + init: RequestInit, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchFn(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function fetchClaudeUsage( + token: string, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + const res = await fetchJson( + "https://api.anthropic.com/api/oauth/usage", + { + headers: { + Authorization: `Bearer ${token}`, + "anthropic-beta": "oauth-2025-04-20", + }, + }, + timeoutMs, + fetchFn, + ); + + if (!res.ok) { + return { + provider: "anthropic", + displayName: PROVIDER_LABELS.anthropic, + windows: [], + error: `HTTP ${res.status}`, + }; + } + + const data = (await res.json()) as any; + const windows: UsageWindow[] = []; + + if (data.five_hour?.utilization !== undefined) { + windows.push({ + label: "5h", + usedPercent: clampPercent(data.five_hour.utilization), + resetAt: data.five_hour.resets_at + ? new Date(data.five_hour.resets_at).getTime() + : undefined, + }); + } + + if (data.seven_day?.utilization !== undefined) { + windows.push({ + label: "Week", + usedPercent: clampPercent(data.seven_day.utilization), + resetAt: data.seven_day.resets_at + ? new Date(data.seven_day.resets_at).getTime() + : undefined, + }); + } + + const modelWindow = data.seven_day_sonnet || data.seven_day_opus; + if (modelWindow?.utilization !== undefined) { + windows.push({ + label: data.seven_day_sonnet ? "Sonnet" : "Opus", + usedPercent: clampPercent(modelWindow.utilization), + }); + } + + return { + provider: "anthropic", + displayName: PROVIDER_LABELS.anthropic, + windows, + }; +} + +async function fetchCopilotUsage( + token: string, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + const res = await fetchJson( + "https://api.github.com/copilot_internal/user", + { + headers: { + Authorization: `token ${token}`, + "Editor-Version": "vscode/1.96.2", + "User-Agent": "GitHubCopilotChat/0.26.7", + "X-Github-Api-Version": "2025-04-01", + }, + }, + timeoutMs, + fetchFn, + ); + + if (!res.ok) { + return { + provider: "github-copilot", + displayName: PROVIDER_LABELS["github-copilot"], + windows: [], + error: `HTTP ${res.status}`, + }; + } + + const data = (await res.json()) as any; + const windows: UsageWindow[] = []; + + if (data.quota_snapshots?.premium_interactions) { + const remaining = data.quota_snapshots.premium_interactions + .percent_remaining; + windows.push({ + label: "Premium", + usedPercent: clampPercent(100 - (remaining ?? 0)), + }); + } + + if (data.quota_snapshots?.chat) { + const remaining = data.quota_snapshots.chat.percent_remaining; + windows.push({ + label: "Chat", + usedPercent: clampPercent(100 - (remaining ?? 0)), + }); + } + + return { + provider: "github-copilot", + displayName: PROVIDER_LABELS["github-copilot"], + windows, + plan: data.copilot_plan, + }; +} + +async function fetchGeminiUsage( + token: string, + timeoutMs: number, + fetchFn: typeof fetch, + provider: UsageProviderId, +): Promise { + const res = await fetchJson( + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: "{}", + }, + timeoutMs, + fetchFn, + ); + + if (!res.ok) { + return { + provider, + displayName: PROVIDER_LABELS[provider], + windows: [], + error: `HTTP ${res.status}`, + }; + } + + const data = (await res.json()) as any; + const quotas: Record = {}; + + for (const bucket of data.buckets || []) { + const model = bucket.modelId || "unknown"; + const frac = bucket.remainingFraction ?? 1; + if (!quotas[model] || frac < quotas[model]) quotas[model] = frac; + } + + const windows: UsageWindow[] = []; + let proMin = 1; + let flashMin = 1; + let hasPro = false; + let hasFlash = false; + + for (const [model, frac] of Object.entries(quotas)) { + const lower = model.toLowerCase(); + if (lower.includes("pro")) { + hasPro = true; + if (frac < proMin) proMin = frac; + } + if (lower.includes("flash")) { + hasFlash = true; + if (frac < flashMin) flashMin = frac; + } + } + + if (hasPro) { + windows.push({ label: "Pro", usedPercent: clampPercent((1 - proMin) * 100) }); + } + if (hasFlash) { + windows.push({ + label: "Flash", + usedPercent: clampPercent((1 - flashMin) * 100), + }); + } + + return { provider, displayName: PROVIDER_LABELS[provider], windows }; +} + +async function fetchCodexUsage( + token: string, + accountId: string | undefined, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + const headers: Record = { + Authorization: `Bearer ${token}`, + "User-Agent": "CodexBar", + Accept: "application/json", + }; + if (accountId) headers["ChatGPT-Account-Id"] = accountId; + + const res = await fetchJson( + "https://chatgpt.com/backend-api/wham/usage", + { method: "GET", headers }, + timeoutMs, + fetchFn, + ); + + if (res.status === 401 || res.status === 403) { + return { + provider: "openai-codex", + displayName: PROVIDER_LABELS["openai-codex"], + windows: [], + error: "Token expired", + }; + } + + if (!res.ok) { + return { + provider: "openai-codex", + displayName: PROVIDER_LABELS["openai-codex"], + windows: [], + error: `HTTP ${res.status}`, + }; + } + + const data = (await res.json()) as any; + const windows: UsageWindow[] = []; + + if (data.rate_limit?.primary_window) { + const pw = data.rate_limit.primary_window; + const windowHours = Math.round((pw.limit_window_seconds || 10800) / 3600); + windows.push({ + label: `${windowHours}h`, + usedPercent: clampPercent(pw.used_percent || 0), + resetAt: pw.reset_at ? pw.reset_at * 1000 : undefined, + }); + } + + if (data.rate_limit?.secondary_window) { + const sw = data.rate_limit.secondary_window; + const windowHours = Math.round((sw.limit_window_seconds || 86400) / 3600); + const label = windowHours >= 24 ? "Day" : `${windowHours}h`; + windows.push({ + label, + usedPercent: clampPercent(sw.used_percent || 0), + resetAt: sw.reset_at ? sw.reset_at * 1000 : undefined, + }); + } + + let plan = data.plan_type; + if (data.credits?.balance !== undefined && data.credits.balance !== null) { + const balance = + typeof data.credits.balance === "number" + ? data.credits.balance + : parseFloat(data.credits.balance) || 0; + plan = plan ? `${plan} ($${balance.toFixed(2)})` : `$${balance.toFixed(2)}`; + } + + return { + provider: "openai-codex", + displayName: PROVIDER_LABELS["openai-codex"], + windows, + plan, + }; +} + +async function fetchZaiUsage( + apiKey: string, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + const res = await fetchJson( + "https://api.z.ai/api/monitor/usage/quota/limit", + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, + timeoutMs, + fetchFn, + ); + + if (!res.ok) { + return { + provider: "zai", + displayName: PROVIDER_LABELS.zai, + windows: [], + error: `HTTP ${res.status}`, + }; + } + + const data = (await res.json()) as any; + if (!data.success || data.code !== 200) { + return { + provider: "zai", + displayName: PROVIDER_LABELS.zai, + windows: [], + error: data.msg || "API error", + }; + } + + const windows: UsageWindow[] = []; + const limits = data.data?.limits || []; + + for (const limit of limits) { + const percent = clampPercent(limit.percentage || 0); + const nextReset = limit.nextResetTime + ? new Date(limit.nextResetTime).getTime() + : undefined; + let windowLabel = "Limit"; + if (limit.unit === 1) windowLabel = `${limit.number}d`; + else if (limit.unit === 3) windowLabel = `${limit.number}h`; + else if (limit.unit === 5) windowLabel = `${limit.number}m`; + + if (limit.type === "TOKENS_LIMIT") { + windows.push({ + label: `Tokens (${windowLabel})`, + usedPercent: percent, + resetAt: nextReset, + }); + } else if (limit.type === "TIME_LIMIT") { + windows.push({ + label: "Monthly", + usedPercent: percent, + resetAt: nextReset, + }); + } + } + + const planName = data.data?.planName || data.data?.plan || undefined; + return { + provider: "zai", + displayName: PROVIDER_LABELS.zai, + windows, + plan: planName, + }; +} + +function resolveZaiApiKey(): string | undefined { + const envDirect = + process.env.ZAI_API_KEY?.trim() || + process.env.Z_AI_API_KEY?.trim(); + if (envDirect) return envDirect; + + const envResolved = resolveEnvApiKey("zai"); + if (envResolved?.apiKey) return envResolved.apiKey; + + const cfg = loadConfig(); + const key = + getCustomProviderApiKey(cfg, "zai") || + getCustomProviderApiKey(cfg, "z-ai"); + if (key) return key; + + const store = ensureAuthProfileStore(); + const apiProfile = [ + ...listProfilesForProvider(store, "zai"), + ...listProfilesForProvider(store, "z-ai"), + ].find((id) => store.profiles[id]?.type === "api_key"); + if (apiProfile) { + const cred = store.profiles[apiProfile]; + if (cred?.type === "api_key" && cred.key?.trim()) { + return cred.key.trim(); + } + } + + try { + const authPath = path.join(os.homedir(), ".pi", "agent", "auth.json"); + if (!fs.existsSync(authPath)) return undefined; + const data = JSON.parse(fs.readFileSync(authPath, "utf-8")) as Record< + string, + { access?: string } + >; + return data["z-ai"]?.access || data.zai?.access; + } catch { + return undefined; + } +} + +async function resolveOAuthToken(params: { + provider: UsageProviderId; +}): Promise { + const cfg = loadConfig(); + const store = ensureAuthProfileStore(); + const order = resolveAuthProfileOrder({ + cfg, + store, + provider: params.provider, + }); + + for (const profileId of order) { + const cred = store.profiles[profileId]; + if (!cred || cred.type !== "oauth") continue; + try { + const resolved = await resolveApiKeyForProfile({ + cfg, + store, + profileId, + }); + if (!resolved?.apiKey) continue; + let token = resolved.apiKey; + if ( + params.provider === "google-gemini-cli" || + params.provider === "google-antigravity" + ) { + const parsed = parseGoogleToken(resolved.apiKey); + token = parsed?.token ?? resolved.apiKey; + } + return { + provider: params.provider, + token, + accountId: + cred.type === "oauth" && "accountId" in cred + ? (cred as { accountId?: string }).accountId + : undefined, + }; + } catch { + continue; + } + } + + return null; +} + +function resolveOAuthProviders(): UsageProviderId[] { + const store = ensureAuthProfileStore(); + const cfg = loadConfig(); + const providers = usageProviders.filter((provider) => + provider !== "zai", + ); + return providers.filter((provider) => { + const profiles = listProfilesForProvider(store, provider).filter((id) => { + const cred = store.profiles[id]; + return cred?.type === "oauth"; + }); + if (profiles.length > 0) return true; + const normalized = normalizeProviderId(provider); + const configuredProfiles = Object.entries(cfg.auth?.profiles ?? {}) + .filter(([, profile]) => normalizeProviderId(profile.provider) === normalized) + .map(([id]) => id) + .filter((id) => store.profiles[id]?.type === "oauth"); + return configuredProfiles.length > 0; + }); +} + +async function resolveProviderAuths( + opts: UsageSummaryOptions, +): Promise { + if (opts.auth) return opts.auth; + + const targetProviders = opts.providers ?? usageProviders; + const oauthProviders = resolveOAuthProviders(); + const auths: ProviderAuth[] = []; + + for (const provider of targetProviders) { + if (provider === "zai") { + const apiKey = resolveZaiApiKey(); + if (apiKey) auths.push({ provider, token: apiKey }); + continue; + } + + if (!oauthProviders.includes(provider)) continue; + const auth = await resolveOAuthToken({ provider }); + if (auth) auths.push(auth); + } + + return auths; +} + +export async function loadProviderUsageSummary( + opts: UsageSummaryOptions = {}, +): Promise { + const now = opts.now ?? Date.now(); + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const fetchFn = opts.fetch ?? fetch; + + const auths = await resolveProviderAuths(opts); + if (auths.length === 0) { + return { updatedAt: now, providers: [] }; + } + + const tasks = auths.map((auth) => + withTimeout( + (async (): Promise => { + switch (auth.provider) { + case "anthropic": + return await fetchClaudeUsage(auth.token, timeoutMs, fetchFn); + case "github-copilot": + return await fetchCopilotUsage(auth.token, timeoutMs, fetchFn); + case "google-gemini-cli": + case "google-antigravity": + return await fetchGeminiUsage( + auth.token, + timeoutMs, + fetchFn, + auth.provider, + ); + case "openai-codex": + return await fetchCodexUsage( + auth.token, + auth.accountId, + timeoutMs, + fetchFn, + ); + case "zai": + return await fetchZaiUsage(auth.token, timeoutMs, fetchFn); + default: + return { + provider: auth.provider, + displayName: PROVIDER_LABELS[auth.provider], + windows: [], + error: "Unsupported provider", + }; + } + })(), + timeoutMs + 1000, + { + provider: auth.provider, + displayName: PROVIDER_LABELS[auth.provider], + windows: [], + error: "Timeout", + }, + ), + ); + + const snapshots = await Promise.all(tasks); + const providers = snapshots.filter((entry) => { + if (entry.windows.length > 0) return true; + if (!entry.error) return true; + return !ignoredErrors.has(entry.error); + }); + + return { updatedAt: now, providers }; +}