Repository navigation
feat(spend): add tokscale-compatible local readers for Cursor and Antigravity #3113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
1d29881
ba5a4db
62341ac
667fe62
7b8abcd
aa464e6
57e4d9e
6d80e72
902c740
075eac7
60fdee7
54a99a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
|
|
@@ -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 | ||
| } | ||
| // 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, | ||
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the pinned cost-usage timezone differs from 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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the local cache contains rows older than the requested window, Useful? React with 👍 / 👎. |
||
| return Self.tokenSnapshot( | ||
| from: daily, | ||
| now: now, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the tokscale cache has not synced recently, passing refresh-time Useful? React with 👍 / 👎. |
||
| historyDays: historyDays, | ||
| useCurrentLocalDayForSession: true, | ||
| costProvenance: .listPriceEstimate) | ||
|
Comment on lines
+1182
to
+1187
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the configured scanner calendar differs from Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| private static func loadAntigravityLocalSnapshot(now: Date, historyDays: Int) async -> CostUsageTokenSnapshot? { | ||
| let report = AntigravityLocalReader.makeDailyReport() | ||
| guard !report.data.isEmpty else { return nil } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When no tokscale cache exists or every row is unreadable, returning 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, | ||
|
|
||
| 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the synced cache contains the same response in multiple session JSONLs, this loop counts every copy because it ignores the format's 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 |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Enabling AGENTS.md reference: AGENTS.md:L5-L5 Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Linux this unconditional capability makes Useful? React with 👍 / 👎. |
||
| pace: ProviderPaceCapability( | ||
| sessionPaceWindowRule: .custom { window, _ in | ||
| window.windowMinutes == nil || window.windowMinutes == 300 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On macOS, an offline request, expired session, or missing Cursor cookie makes the preceding
try await loadRemoteTokenSnapshotthrow rather than returnnil, 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 👍 / 👎.