Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// swiftlint:disable file_length
import Foundation

public enum CostUsageError: LocalizedError, Sendable {
Expand Down Expand Up @@ -435,6 +436,19 @@ public struct CostUsageFetcher: Sendable {
return remoteSnapshot
}

// Provider-specific by design: Cursor local CSV is an offline fallback for the remote dashboard API.
if provider == .cursor, let local = await self.loadCursorLocalSnapshot(
now: now, historyDays: clampedHistoryDays)
{
return local

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back to the Cursor cache when the remote fetch throws

On macOS, an offline request, expired session, or missing Cursor cookie makes the preceding try await loadRemoteTokenSnapshot throw rather than return nil, so execution never reaches this advertised local fallback even when valid tokscale CSV files exist. Catch the remote Cursor failure, try the local snapshot, and rethrow only if no usable local data is available.

Useful? React with 👍 / 👎.

}
// Provider-specific by design: Antigravity local cache (tokscale) supplements the quota-only probe.
if provider == .antigravity, let local = await self.loadAntigravityLocalSnapshot(
now: now, historyDays: clampedHistoryDays)
{
return local
}

var options = Self.resolvedScannerOptions(
overrideScannerOptions,
provider: provider,
Expand Down Expand Up @@ -1120,6 +1134,46 @@ public struct CostUsageFetcher: Sendable {
}
#endif

private static func loadCursorLocalSnapshot(now: Date, historyDays: Int) async -> CostUsageTokenSnapshot? {
let paths = CursorLocalCSVReader.cachedCSVPaths()
guard !paths.isEmpty else { return nil }
var allRows: [CursorLocalCSVReader.Row] = []
for url in paths {
allRows.append(contentsOf: CursorLocalCSVReader.parseFile(at: url))
}
guard !allRows.isEmpty else { return nil }
let full = CursorLocalCSVReader.makeDailyReport(from: allRows, now: now)
let cal = Calendar.current

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Thread the pinned bucket calendar through local readers

When the pinned cost-usage timezone differs from Calendar.current—for example, after the user travels—both new local fallbacks ignore the calendar supplied through CostUsageFetcher and instead parse, group, filter, and resolve the current session using the process timezone. Midnight-adjacent Cursor or Antigravity events can therefore move to another day or fall across the requested-window boundary, violating the pinned-bucket behavior; pass the configured scanner calendar through both readers, filtering, and tokenSnapshot.

Useful? React with 👍 / 👎.

let since = cal.date(byAdding: .day, value: -(historyDays - 1), to: cal.startOfDay(for: now)) ?? now
let sinceKey = CostUsageLocalDay.key(from: since, calendar: cal)
let nowKey = CostUsageLocalDay.key(from: now, calendar: cal)
let filtered = full.data.filter { $0.date >= sinceKey && $0.date <= nowKey }
let daily = CostUsageDailyReport(data: filtered, summary: full.summary)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recompute totals after filtering local history

When the local cache contains rows older than the requested window, filtered correctly limits daily.data, but retaining full.summary makes tokenSnapshot prefer the all-time token and cost totals over those filtered entries. Consequently a 30-day snapshot can report months of spend while exposing only 30 days of rows; recompute the summary from filtered here and in the mirrored Antigravity loader.

Useful? React with 👍 / 👎.

return Self.tokenSnapshot(
from: daily,
now: now,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the cache's actual freshness

When the tokscale cache has not synced recently, passing refresh-time now here and leaving historyCoverageIsEstablished at its default makes stale local data appear freshly updated with complete coverage. If every cached Cursor row predates the requested window, this returns an established empty snapshot and reports zero current spend instead of indicating stale/unavailable data; the mirrored Antigravity return has the same problem. Derive updatedAt and coverage from the cache files or decline the fallback when it cannot establish the requested window.

Useful? React with 👍 / 👎.

historyDays: historyDays,
useCurrentLocalDayForSession: true,
costProvenance: .listPriceEstimate)
Comment on lines +1182 to +1187

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass the fallback calendar into tokenSnapshot

When the configured scanner calendar differs from Calendar.current near a day boundary, this call builds daily keys with the configured calendar but lets tokenSnapshot search for today's entry using its default calendar, so current-session tokens and cost can incorrectly become zero. Fresh evidence after the earlier calendar comment is that the loader now receives and uses fallbackCalendar for parsing and filtering, but this final call—and the mirrored Antigravity call—still omits calendar: calendar.

Useful? React with 👍 / 👎.

}

private static func loadAntigravityLocalSnapshot(now: Date, historyDays: Int) async -> CostUsageTokenSnapshot? {
let report = AntigravityLocalReader.makeDailyReport()
guard !report.data.isEmpty else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep a missing Antigravity cache unavailable

When no tokscale cache exists or every row is unreadable, returning nil here falls through to CostUsageScanner, whose default branch returns an empty report; loadLocalTokenScanResult then marks every non-Codex report as having established coverage, so tokenSnapshot publishes zero tokens and $0 spend. This makes the optional external cache appear to confirm no usage for Antigravity users who never installed tokscale; stop the Antigravity path or preserve unestablished coverage when the cache is absent.

Useful? React with 👍 / 👎.

let cal = Calendar.current
let since = cal.date(byAdding: .day, value: -(historyDays - 1), to: cal.startOfDay(for: now)) ?? now
let sinceKey = CostUsageLocalDay.key(from: since, calendar: cal)
let nowKey = CostUsageLocalDay.key(from: now, calendar: cal)
let filtered = report.data.filter { $0.date >= sinceKey && $0.date <= nowKey }
let daily = CostUsageDailyReport(data: filtered, summary: report.summary)
return Self.tokenSnapshot(
from: daily,
now: now,
historyDays: historyDays,
useCurrentLocalDayForSession: true,
costProvenance: .listPriceEstimate)
}

static func tokenSnapshot(
from daily: CostUsageDailyReport,
now: Date,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import Foundation

// MARK: - Antigravity Local Reader (tokscale compatible)

enum AntigravityLocalReader {
static func tokiCachePaths(home: URL? = nil) -> [URL] {
let base: URL = if let home {
home.appendingPathComponent(".config/tokscale/antigravity-cache/sessions", isDirectory: true)
} else if let dir = ProcessInfo.processInfo.environment["TOKSCALE_CONFIG_DIR"] {
URL(fileURLWithPath: dir).appendingPathComponent("antigravity-cache/sessions", isDirectory: true)
} else {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/tokscale/antigravity-cache/sessions", isDirectory: true)
}
guard let contents = try? FileManager.default.contentsOfDirectory(
at: base, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { return [] }
return contents.filter { $0.pathExtension == "jsonl" }.sorted { $0.lastPathComponent < $1.lastPathComponent }
}

static func cliDBPaths(home: URL? = nil) -> [URL] {
let base: URL = if let env = ProcessInfo.processInfo.environment["GEMINI_CLI_HOME"], !env.isEmpty {
URL(fileURLWithPath: env).appendingPathComponent("antigravity-cli/conversations", isDirectory: true)
} else if let home {
home.appendingPathComponent(".gemini/antigravity-cli/conversations", isDirectory: true)
} else {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".gemini/antigravity-cli/conversations", isDirectory: true)
}
guard let c = try? FileManager.default.contentsOfDirectory(
at: base, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { return [] }
return c
.filter {
$0.pathExtension == "db" && !$0.lastPathComponent.hasSuffix("-wal") && !$0.lastPathComponent
.hasSuffix("-shm")
}
.sorted { $0.lastPathComponent < $1.lastPathComponent }
}

static func parseJSONLCache(paths: [URL]? = nil) -> [CostUsageDailyReport.Entry] {
var entries: [CostUsageDailyReport.Entry] = []
for url in paths ?? self.tokiCachePaths() {
guard let data = try? Data(contentsOf: url),
let text = String(data: data, encoding: .utf8) else { continue }
var sessionModel: String?
for line in text.components(separatedBy: .newlines) {
let t = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !t.isEmpty, let d = t.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: d) as? [String: Any] else { continue }
let type = json["type"] as? String
if type == "session_meta" {
sessionModel = json["modelId"] as? String ?? json["model_id"] as? String
continue
}
if type == "usage" || json["input"] != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deduplicate Antigravity usage by response ID

When the synced cache contains the same response in multiple session JSONLs, this loop counts every copy because it ignores the format's responseId identity and only merges by day. The duplicated response inflates token totals, request counts, and model breakdowns on every refresh; track seen nonempty response IDs across all cache files before aggregating usage records.

Useful? React with 👍 / 👎.

let modelId =
(json["modelId"] as? String ?? json["model_id"] as? String ?? sessionModel ?? "unknown")
let input = (json["input"] as? Int) ?? 0
let output = (json["output"] as? Int) ?? 0
let read = (json["cacheRead"] as? Int) ?? (json["cache_read"] as? Int) ?? 0
let write = (json["cacheWrite"] as? Int) ?? (json["cache_write"] as? Int) ?? 0
let reason = (json["reasoning"] as? Int) ?? 0
let ts = (json["timestamp"] as? Int64) ?? (json["timestamp"] as? Int).map(Int64.init) ?? 0
let date = self.timestampToDayKey(ts)
let total = input + output + read + write
if total == 0 { continue }
if let idx = entries.firstIndex(where: { $0.date == date }) {
var e = entries[idx]
let ne = CostUsageDailyReport.Entry(
date: e.date,
inputTokens: (e.inputTokens ?? 0) + input,
outputTokens: (e.outputTokens ?? 0) + output,
cacheReadTokens: (e.cacheReadTokens ?? 0) + read,
cacheCreationTokens: (e.cacheCreationTokens ?? 0) + write,
reasoningTokens: (e.reasoningTokens ?? 0) + reason,
totalTokens: (e.totalTokens ?? 0) + total,
requestCount: (e.requestCount ?? 0) + 1,
costUSD: e.costUSD ?? 0,
modelsUsed: nil,
modelBreakdowns: self.mergeBreakdown(e.modelBreakdowns, model: modelId, tokens: total))
entries[idx] = ne
} else {
let e = CostUsageDailyReport.Entry(
date: date,
inputTokens: input,
outputTokens: output,
cacheReadTokens: read,
cacheCreationTokens: write,
reasoningTokens: reason,
totalTokens: total,
requestCount: 1,
costUSD: 0,
modelsUsed: nil,
modelBreakdowns: [
CostUsageDailyReport.ModelBreakdown(
modelName: modelId,
costUSD: 0,
totalTokens: total,
requestCount: 1),
])
entries.append(e)
}
}
}
}
return entries.sorted { $0.date < $1.date }
}

static func parseCLIDBs() -> [CostUsageDailyReport.Entry] {
[]
}

static func makeDailyReport() -> CostUsageDailyReport {
var merged: [String: CostUsageDailyReport.Entry] = [:]
for e in self.parseJSONLCache() + self.parseCLIDBs() {
if var ex = merged[e.date] {
let ne = CostUsageDailyReport.Entry(
date: ex.date,
inputTokens: (ex.inputTokens ?? 0) + (e.inputTokens ?? 0),
outputTokens: (ex.outputTokens ?? 0) + (e.outputTokens ?? 0),
cacheReadTokens: (ex.cacheReadTokens ?? 0) + (e.cacheReadTokens ?? 0),
cacheCreationTokens: (ex.cacheCreationTokens ?? 0) + (e.cacheCreationTokens ?? 0),
reasoningTokens: (ex.reasoningTokens ?? 0) + (e.reasoningTokens ?? 0),
totalTokens: (ex.totalTokens ?? 0) + (e.totalTokens ?? 0),
requestCount: (ex.requestCount ?? 0) + (e.requestCount ?? 0),
costUSD: (ex.costUSD ?? 0) + (e.costUSD ?? 0),
modelsUsed: nil,
modelBreakdowns: self.mergeBreakdowns(ex.modelBreakdowns, e.modelBreakdowns))
merged[e.date] = ne
} else {
merged[e.date] = e
}
}
let sorted = merged.values.sorted { $0.date < $1.date }
let totalCost = sorted.compactMap(\.costUSD).reduce(0, +)
let totalTokens = sorted.compactMap(\.totalTokens).reduce(0, +)
let summary: CostUsageDailyReport.Summary? = sorted.isEmpty ? nil : .init(
totalInputTokens: nil,
totalOutputTokens: nil,
totalTokens: totalTokens,
totalCostUSD: totalCost)
return CostUsageDailyReport(data: sorted, summary: summary)
}

private static func timestampToDayKey(_ ms: Int64) -> String {
let sec = Double(ms) / 1000.0
let date = Date(timeIntervalSince1970: sec)
let c = Calendar.current
let comps = c.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", comps.year ?? 1970, comps.month ?? 1, comps.day ?? 1)
}

private static func mergeBreakdown(
_ ex: [CostUsageDailyReport.ModelBreakdown]?,
model: String,
tokens: Int) -> [CostUsageDailyReport.ModelBreakdown]
{
var arr = ex ?? []
if let i = arr.firstIndex(where: { $0.modelName == model }) {
let b = arr[i]
arr[i] = CostUsageDailyReport.ModelBreakdown(
modelName: b.modelName,
costUSD: b.costUSD ?? 0,
totalTokens: (b.totalTokens ?? 0) + tokens,
requestCount: (b.requestCount ?? 0) + 1)
} else {
arr.append(CostUsageDailyReport.ModelBreakdown(
modelName: model,
costUSD: 0,
totalTokens: tokens,
requestCount: 1))
}
return arr
}

private static func mergeBreakdowns(
_ a: [CostUsageDailyReport.ModelBreakdown]?,
_ b: [CostUsageDailyReport.ModelBreakdown]?) -> [CostUsageDailyReport.ModelBreakdown]?
{
var d: [String: CostUsageDailyReport.ModelBreakdown] = [:]
for m in (a ?? []) + (b ?? []) {
if var ex = d[m.modelName] {
ex = CostUsageDailyReport.ModelBreakdown(
modelName: ex.modelName,
costUSD: (ex.costUSD ?? 0) + (m.costUSD ?? 0),
totalTokens: (ex.totalTokens ?? 0) + (m.totalTokens ?? 0),
requestCount: (ex.requestCount ?? 0) + (m.requestCount ?? 0))
d[m.modelName] = ex
} else {
d[m.modelName] = m
}
}
return d.isEmpty ? nil : Array(d.values)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ public enum AntigravityProviderDescriptor {
ProviderColor(hex: 0xFBBC04),
]),
tokenCost: ProviderTokenCostConfig(
supportsTokenCost: false,
noDataMessage: { "Antigravity cost summary is not supported." }),
supportsTokenCost: true,
noDataMessage: { "Antigravity cost summary is not supported." },
supportsTokenSnapshot: true),
Comment on lines +52 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include Antigravity in the capability registry test

Enabling supportsTokenSnapshot here makes ProviderArchitectureGatekeeperTests.small provider capabilities preserve legacy registries fail on both macOS and Linux because its expected provider sets still omit .antigravity. Update that registry assertion alongside this capability change so the test suite can pass and the new behavior is covered.

AGENTS.md reference: AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add Antigravity to the Linux capability expectation

On Linux this unconditional capability makes small provider capabilities preserve legacy registries fail because its #else expectation still omits .antigravity. Fresh evidence after the earlier review comment is that this commit updated only the macOS expectation while leaving the Linux set unchanged, even though the descriptor is not platform-gated.

Useful? React with 👍 / 👎.

pace: ProviderPaceCapability(
sessionPaceWindowRule: .custom { window, _ in
window.windowMinutes == nil || window.windowMinutes == 300
Expand Down
Loading
Loading