feat: add provider usage tracking
This commit is contained in:
@@ -38,6 +38,7 @@
|
|||||||
- Docs: add ClawdHub guide and hubs link for browsing, install, and sync workflows.
|
- 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.
|
- 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.
|
- 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.
|
- 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: 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.
|
- Browser: add `--browser-profile` flag and honor profile in tabs routes + browser tool. Thanks @jamesgroat for PR #324.
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private var cachedErrorText: String?
|
private var cachedErrorText: String?
|
||||||
private var cacheUpdatedAt: Date?
|
private var cacheUpdatedAt: Date?
|
||||||
private let refreshIntervalSeconds: TimeInterval = 12
|
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
|
private let nodesStore = NodesStore.shared
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private var testControlChannelConnected: Bool?
|
private var testControlChannelConnected: Bool?
|
||||||
@@ -58,6 +62,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.loadTask = Task { [weak self] in
|
self.loadTask = Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await self.refreshCache(force: forceRefresh)
|
await self.refreshCache(force: forceRefresh)
|
||||||
|
await self.refreshUsageCache(force: forceRefresh)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
guard self.isMenuOpen else { return }
|
guard self.isMenuOpen else { return }
|
||||||
self.inject(into: menu)
|
self.inject(into: menu)
|
||||||
@@ -108,66 +113,70 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
|
|
||||||
guard self.isControlChannelConnected else { return }
|
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()
|
let headerItem = NSMenuItem()
|
||||||
headerItem.tag = self.tag
|
headerItem.tag = self.tag
|
||||||
headerItem.isEnabled = false
|
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(
|
rootView: AnyView(MenuSessionsHeaderView(
|
||||||
count: 0,
|
count: 0,
|
||||||
statusText: self.cachedErrorText ?? "Loading sessions…")),
|
statusText: self.cachedErrorText ?? "Loading sessions…")),
|
||||||
width: width,
|
width: width,
|
||||||
highlighted: false)
|
highlighted: false)
|
||||||
menu.insertItem(headerItem, at: insertIndex)
|
headerItem.view = hosted
|
||||||
DispatchQueue.main.async { [weak self, weak view = headerItem.view] in
|
headerView = hosted
|
||||||
guard let self, let view else { return }
|
menu.insertItem(headerItem, at: cursor)
|
||||||
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)
|
|
||||||
cursor += 1
|
cursor += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self, weak headerView] in
|
DispatchQueue.main.async { [weak self, weak headerView] in
|
||||||
guard let self, let headerView else { return }
|
guard let self, let headerView else { return }
|
||||||
self.captureMenuWidthIfAvailable(from: headerView)
|
self.captureMenuWidthIfAvailable(from: headerView)
|
||||||
@@ -240,6 +249,55 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
_ = cursor
|
_ = 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 {
|
private var isControlChannelConnected: Bool {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if let override = self.testControlChannelConnected { return override }
|
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 {
|
private func compactError(_ error: Error) -> String {
|
||||||
if let loadError = error as? SessionLoadError {
|
if let loadError = error as? SessionLoadError {
|
||||||
switch loadError {
|
switch loadError {
|
||||||
|
|||||||
45
apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift
Normal file
45
apps/macos/Sources/Clawdbot/MenuUsageHeaderView.swift
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
110
apps/macos/Sources/Clawdbot/UsageData.swift
Normal file
110
apps/macos/Sources/Clawdbot/UsageData.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
46
apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift
Normal file
46
apps/macos/Sources/Clawdbot/UsageMenuLabelView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -305,9 +305,24 @@ Show linked session health and recent recipients.
|
|||||||
Options:
|
Options:
|
||||||
- `--json`
|
- `--json`
|
||||||
- `--deep` (probe providers)
|
- `--deep` (probe providers)
|
||||||
|
- `--usage` (show provider usage/quota)
|
||||||
- `--timeout <ms>`
|
- `--timeout <ms>`
|
||||||
- `--verbose`
|
- `--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`
|
### `health`
|
||||||
Fetch health from the running Gateway.
|
Fetch health from the running Gateway.
|
||||||
|
|
||||||
|
|||||||
27
docs/concepts/usage-tracking.md
Normal file
27
docs/concepts/usage-tracking.md
Normal file
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ import {
|
|||||||
formatContextUsageShort,
|
formatContextUsageShort,
|
||||||
formatTokenCount,
|
formatTokenCount,
|
||||||
} from "../status.js";
|
} from "../status.js";
|
||||||
|
import {
|
||||||
|
formatUsageSummaryLine,
|
||||||
|
loadProviderUsageSummary,
|
||||||
|
} from "../../infra/provider-usage.js";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
import type {
|
import type {
|
||||||
ElevatedLevel,
|
ElevatedLevel,
|
||||||
@@ -383,6 +387,15 @@ export async function handleCommands(params: {
|
|||||||
);
|
);
|
||||||
return { shouldContinue: false };
|
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({
|
const queueSettings = resolveQueueSettings({
|
||||||
cfg,
|
cfg,
|
||||||
provider: command.provider,
|
provider: command.provider,
|
||||||
@@ -421,6 +434,7 @@ export async function handleCommands(params: {
|
|||||||
resolvedReasoning: resolvedReasoningLevel,
|
resolvedReasoning: resolvedReasoningLevel,
|
||||||
resolvedElevated: resolvedElevatedLevel,
|
resolvedElevated: resolvedElevatedLevel,
|
||||||
modelAuth: resolveModelAuthLabel(provider, cfg),
|
modelAuth: resolveModelAuthLabel(provider, cfg),
|
||||||
|
usageLine: usageLine ?? undefined,
|
||||||
queue: {
|
queue: {
|
||||||
mode: queueSettings.mode,
|
mode: queueSettings.mode,
|
||||||
depth: queueDepth,
|
depth: queueDepth,
|
||||||
|
|||||||
@@ -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 () => {
|
it("prefers cached prompt tokens from the session log", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-status-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-status-"));
|
||||||
const previousHome = process.env.HOME;
|
const previousHome = process.env.HOME;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type StatusArgs = {
|
|||||||
resolvedReasoning?: ReasoningLevel;
|
resolvedReasoning?: ReasoningLevel;
|
||||||
resolvedElevated?: ElevatedLevel;
|
resolvedElevated?: ElevatedLevel;
|
||||||
modelAuth?: string;
|
modelAuth?: string;
|
||||||
|
usageLine?: string;
|
||||||
queue?: QueueStatus;
|
queue?: QueueStatus;
|
||||||
includeTranscriptUsage?: boolean;
|
includeTranscriptUsage?: boolean;
|
||||||
now?: number;
|
now?: number;
|
||||||
@@ -356,6 +357,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
|||||||
versionLine,
|
versionLine,
|
||||||
modelLine,
|
modelLine,
|
||||||
`📚 ${contextLine}`,
|
`📚 ${contextLine}`,
|
||||||
|
args.usageLine,
|
||||||
`🧵 ${sessionLine}`,
|
`🧵 ${sessionLine}`,
|
||||||
`⚙️ ${optionsLine}`,
|
`⚙️ ${optionsLine}`,
|
||||||
activationLine,
|
activationLine,
|
||||||
|
|||||||
@@ -639,6 +639,7 @@ Examples:
|
|||||||
.command("status")
|
.command("status")
|
||||||
.description("Show web session health and recent session recipients")
|
.description("Show web session health and recent session recipients")
|
||||||
.option("--json", "Output JSON instead of text", false)
|
.option("--json", "Output JSON instead of text", false)
|
||||||
|
.option("--usage", "Show provider usage/quota snapshots", false)
|
||||||
.option(
|
.option(
|
||||||
"--deep",
|
"--deep",
|
||||||
"Probe providers (WhatsApp Web + Telegram + Discord + Slack + Signal)",
|
"Probe providers (WhatsApp Web + Telegram + Discord + Slack + Signal)",
|
||||||
@@ -652,6 +653,7 @@ Examples:
|
|||||||
Examples:
|
Examples:
|
||||||
clawdbot status # show linked account + session store summary
|
clawdbot status # show linked account + session store summary
|
||||||
clawdbot status --json # machine-readable output
|
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 # run provider probes (WA + Telegram + Discord + Slack + Signal)
|
||||||
clawdbot status --deep --timeout 5000 # tighten probe timeout`,
|
clawdbot status --deep --timeout 5000 # tighten probe timeout`,
|
||||||
)
|
)
|
||||||
@@ -672,6 +674,7 @@ Examples:
|
|||||||
{
|
{
|
||||||
json: Boolean(opts.json),
|
json: Boolean(opts.json),
|
||||||
deep: Boolean(opts.deep),
|
deep: Boolean(opts.deep),
|
||||||
|
usage: Boolean(opts.usage),
|
||||||
timeoutMs: timeout,
|
timeoutMs: timeout,
|
||||||
},
|
},
|
||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
|
import {
|
||||||
|
formatUsageReportLines,
|
||||||
|
loadProviderUsageSummary,
|
||||||
|
} from "../infra/provider-usage.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
import { info } from "../globals.js";
|
import { info } from "../globals.js";
|
||||||
import { buildProviderSummary } from "../infra/provider-summary.js";
|
import { buildProviderSummary } from "../infra/provider-summary.js";
|
||||||
@@ -218,10 +222,13 @@ const buildFlags = (entry: SessionEntry): string[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function statusCommand(
|
export async function statusCommand(
|
||||||
opts: { json?: boolean; deep?: boolean; timeoutMs?: number },
|
opts: { json?: boolean; deep?: boolean; usage?: boolean; timeoutMs?: number },
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
) {
|
) {
|
||||||
const summary = await getStatusSummary();
|
const summary = await getStatusSummary();
|
||||||
|
const usage = opts.usage
|
||||||
|
? await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs })
|
||||||
|
: undefined;
|
||||||
const health: HealthSummary | undefined = opts.deep
|
const health: HealthSummary | undefined = opts.deep
|
||||||
? await callGateway<HealthSummary>({
|
? await callGateway<HealthSummary>({
|
||||||
method: "health",
|
method: "health",
|
||||||
@@ -231,7 +238,11 @@ export async function statusCommand(
|
|||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(health ? { ...summary, health } : summary, null, 2),
|
JSON.stringify(
|
||||||
|
health || usage ? { ...summary, health, usage } : summary,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -302,4 +313,10 @@ export async function statusCommand(
|
|||||||
} else {
|
} else {
|
||||||
runtime.log("No session activity yet.");
|
runtime.log("No session activity yet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (usage) {
|
||||||
|
for (const line of formatUsageReportLines(usage)) {
|
||||||
|
runtime.log(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type {
|
|||||||
GatewayRequestHandlers,
|
GatewayRequestHandlers,
|
||||||
GatewayRequestOptions,
|
GatewayRequestOptions,
|
||||||
} from "./server-methods/types.js";
|
} from "./server-methods/types.js";
|
||||||
|
import { usageHandlers } from "./server-methods/usage.js";
|
||||||
import { voicewakeHandlers } from "./server-methods/voicewake.js";
|
import { voicewakeHandlers } from "./server-methods/voicewake.js";
|
||||||
import { webHandlers } from "./server-methods/web.js";
|
import { webHandlers } from "./server-methods/web.js";
|
||||||
import { wizardHandlers } from "./server-methods/wizard.js";
|
import { wizardHandlers } from "./server-methods/wizard.js";
|
||||||
@@ -38,6 +39,7 @@ const handlers: GatewayRequestHandlers = {
|
|||||||
...systemHandlers,
|
...systemHandlers,
|
||||||
...nodeHandlers,
|
...nodeHandlers,
|
||||||
...sendHandlers,
|
...sendHandlers,
|
||||||
|
...usageHandlers,
|
||||||
...agentHandlers,
|
...agentHandlers,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
10
src/gateway/server-methods/usage.ts
Normal file
10
src/gateway/server-methods/usage.ts
Normal file
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -205,6 +205,7 @@ const METHODS = [
|
|||||||
"health",
|
"health",
|
||||||
"providers.status",
|
"providers.status",
|
||||||
"status",
|
"status",
|
||||||
|
"usage.status",
|
||||||
"config.get",
|
"config.get",
|
||||||
"config.set",
|
"config.set",
|
||||||
"config.schema",
|
"config.schema",
|
||||||
|
|||||||
123
src/infra/provider-usage.test.ts
Normal file
123
src/infra/provider-usage.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
757
src/infra/provider-usage.ts
Normal file
757
src/infra/provider-usage.ts
Normal file
@@ -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<UsageProviderId, string> = {
|
||||||
|
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 <T>(
|
||||||
|
work: Promise<T>,
|
||||||
|
ms: number,
|
||||||
|
fallback: T,
|
||||||
|
): Promise<T> => {
|
||||||
|
let timeout: NodeJS.Timeout | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
work,
|
||||||
|
new Promise<T>((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<Response> {
|
||||||
|
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<ProviderUsageSnapshot> {
|
||||||
|
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<ProviderUsageSnapshot> {
|
||||||
|
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<ProviderUsageSnapshot> {
|
||||||
|
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<string, number> = {};
|
||||||
|
|
||||||
|
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<ProviderUsageSnapshot> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
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<ProviderUsageSnapshot> {
|
||||||
|
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<ProviderAuth | null> {
|
||||||
|
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<ProviderAuth[]> {
|
||||||
|
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<UsageSummary> {
|
||||||
|
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<ProviderUsageSnapshot> => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user