From 854090a0b0656afeb8ea16f38f5e94101665b08c Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:41:53 +0800 Subject: [PATCH 01/22] feat(core): generalized usage/spend scanning + pricing foundation Split out the data layer from the Usage & Spend work so UI modules can land as small follow-up PRs on top of it. No UI / App-target changes. - Per-tool local session scanners: Antigravity, Gemini CLI, Kimi Code, MiniMax, OpenCode, Qwen Code (WAL-aware SQLite, incremental source fingerprints, Gregorian-day bucketing). - Pricing: models.dev catalog + Google/ThirdParty tiers, routing-prefix stripping, overflow-safe thresholds, provider-reported vs estimated cost. - Core plumbing: cost-usage scanner/cache helpers, subagent rollout shape, parser hash, branding/descriptor updates. Foundation for the dashboard UI; behavior additive and backward compatible. Co-authored-by: Cursor --- Sources/CodexBarCore/CostUsageFetcher.swift | 8 +- Sources/CodexBarCore/CostUsageModels.swift | 162 +++++ .../CodexBarCore/CostUsageScanExecutor.swift | 9 +- .../Generated/CodexParserHash.generated.swift | 2 +- .../CodexBarCore/PiSessionCostScanner.swift | 9 +- .../AntigravityProviderDescriptor.swift | 1 + .../AntigravitySessionScanner.swift | 596 ++++++++++++++++ .../Gemini/GeminiProviderDescriptor.swift | 1 + .../Gemini/GeminiSessionScanner.swift | 524 +++++++++++++++ .../Groq/GroqConsoleUsageSnapshot.swift | 1 + .../Groq/GroqProviderDescriptor.swift | 2 +- .../Kimi/KimiCodeSessionScanner.swift | 339 ++++++++++ .../Kimi/KimiProviderDescriptor.swift | 1 + .../Providers/Kimi/KimiSettingsReader.swift | 4 +- .../MiniMax/MiniMaxProviderDescriptor.swift | 1 + .../MiniMax/MiniMaxSessionScanner.swift | 435 ++++++++++++ .../Providers/Mistral/MistralModels.swift | 1 + .../OpenAI/OpenAIAPIUsageSnapshot.swift | 1 + .../OpenCode/OpenCodeProviderDescriptor.swift | 1 + .../OpenCode/OpenCodeSessionScanner.swift | 634 ++++++++++++++++++ .../OpenCodeGo/OpenCodeGoUsageSnapshot.swift | 3 +- .../Providers/ProviderBranding.swift | 10 + .../Providers/ProviderDescriptor.swift | 55 +- .../QwenCloudProviderDescriptor.swift | 1 + .../QwenCloud/QwenCodeSessionScanner.swift | 348 ++++++++++ .../CostUsage/CodexSubagentRolloutShape.swift | 4 +- .../Vendored/CostUsage/CostUsageCache.swift | 3 + .../CostUsage/CostUsagePricing+Google.swift | 152 +++++ .../CostUsagePricing+ThirdParty.swift | 169 +++++ .../Vendored/CostUsage/CostUsagePricing.swift | 74 +- .../CostUsage/CostUsagePricingKey.swift | 13 +- .../CostUsageScanner+CacheHelpers.swift | 48 +- .../CostUsage/CostUsageScanner+Claude.swift | 59 +- .../CostUsage/CostUsageScanner+Projects.swift | 40 +- .../Vendored/CostUsage/CostUsageScanner.swift | 48 +- .../CostUsageSourceFingerprint.swift | 141 ++++ .../AntigravitySessionScannerTests.swift | 244 +++++++ .../CostUsageDailyReportMergeTests.swift | 65 ++ .../CodexBarTests/CostUsageFetcherTests.swift | 6 +- .../CostUsagePerformanceGateTests.swift | 83 ++- .../CodexBarTests/CostUsagePricingTests.swift | 101 +++ .../CostUsageScannerBreakdownTests.swift | 106 ++- .../CostUsageScannerPriorityTests.swift | 4 +- .../GeminiSessionScannerTests.swift | 330 +++++++++ .../KimiCodeSessionScannerTests.swift | 161 +++++ .../MiniMaxSessionScannerTests.swift | 185 +++++ .../CodexBarTests/ModelsDevPricingTests.swift | 23 + .../OpenCodeSessionScannerTests.swift | 399 +++++++++++ .../QwenCodeSessionScannerTests.swift | 86 +++ 49 files changed, 5617 insertions(+), 76 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift create mode 100644 Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift create mode 100644 Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift create mode 100644 Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift create mode 100644 Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift create mode 100644 Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift create mode 100644 Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift create mode 100644 Tests/CodexBarTests/AntigravitySessionScannerTests.swift create mode 100644 Tests/CodexBarTests/GeminiSessionScannerTests.swift create mode 100644 Tests/CodexBarTests/KimiCodeSessionScannerTests.swift create mode 100644 Tests/CodexBarTests/MiniMaxSessionScannerTests.swift create mode 100644 Tests/CodexBarTests/OpenCodeSessionScannerTests.swift create mode 100644 Tests/CodexBarTests/QwenCodeSessionScannerTests.swift diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 226d061364..9f8fc8d9cd 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -740,6 +740,8 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, useCurrentLocalDayForSession: true, meteredCostUSD: report.meteredCostUSD, + // The Cursor dashboard API returns the account's actual billed usage events. + costSource: .providerReported, credentialScopeFingerprint: report.credentialScopeFingerprint) } #endif @@ -751,6 +753,7 @@ public struct CostUsageFetcher: Sendable { useCurrentLocalDayForSession: Bool = true, calendar: Calendar = .current, meteredCostUSD: Double? = nil, + costSource: CostUsageCostSource = .estimated, credentialScopeFingerprint: String? = nil, historyLabel: String? = nil, projects: [CostUsageProjectBreakdown] = [], @@ -791,6 +794,7 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, historyLabel: historyLabel, meteredCostUSD: meteredCostUSD, + costSource: costSource, credentialScopeFingerprint: credentialScopeFingerprint, daily: daily.data, projects: projects, @@ -1030,7 +1034,9 @@ extension CostUsageFetcher { from: daily, now: now, historyDays: historyDays, - useCurrentLocalDayForSession: false) + useCurrentLocalDayForSession: false, + // Bedrock Cost Explorer reports actual billed spend, not a rate-card estimate. + costSource: .providerReported) } #if os(macOS) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 88f2760bbb..5286e711b4 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -1,5 +1,50 @@ import Foundation +/// Extracts billing ownership only from an explicit provider namespace retained in a model id. +/// +/// A plain family label such as `MiniMax-M3` is not evidence because a subscription can bundle +/// that model. A namespaced id such as `minimax/MiniMax-M3` is routing evidence and can safely be +/// carried into the dashboard without tool-specific heuristics. +public enum CostUsageBillingProvider { + public static func providerID(fromNamespacedModel model: String) -> String? { + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + let components = trimmed + .split(separator: "/", omittingEmptySubsequences: true) + .dropLast() + .map { $0.lowercased() } + guard !components.isEmpty else { + return nil + } + let aliases: [String: String] = [ + "alibaba": UsageProvider.qwencloud.rawValue, + "alibabacloud": UsageProvider.qwencloud.rawValue, + "anthropic": UsageProvider.claude.rawValue, + "google": UsageProvider.gemini.rawValue, + "minimax-cn": UsageProvider.minimax.rawValue, + "moonshot": UsageProvider.moonshot.rawValue, + "moonshotai": UsageProvider.moonshot.rawValue, + "openai": UsageProvider.openai.rawValue, + "qwen": UsageProvider.qwencloud.rawValue, + "z.ai": UsageProvider.zai.rawValue, + ] + for namespace in components.reversed() { + if let alias = aliases[namespace] { return alias } + if let provider = UsageProvider(rawValue: namespace) { return provider.rawValue } + } + return nil + } +} + +/// Where a snapshot's `costUSD` figures come from. Local scanners price token counts against +/// models.dev rate cards, so their cost is an API-rate *estimate* of the real bill; provider +/// dashboards/APIs (Cursor usage events, Bedrock Cost Explorer) report the actual billed amount. +public enum CostUsageCostSource: String, Sendable, Equatable { + /// Billed spend as reported by the provider itself. + case providerReported + /// Locally estimated spend (token counts priced against public rate cards). + case estimated +} + public struct CostUsageWindowSummary: Sendable, Equatable { public let days: Int public let totalTokens: Int? @@ -77,6 +122,9 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { /// actually deducts, as opposed to the API-rate estimate. Only some providers (e.g. Cursor) /// report this; `nil` when unknown. public let meteredCostUSD: Double? + /// Origin of the cost figures in this snapshot. Defaults to `.estimated` because most + /// snapshots are priced locally; provider-billed sources opt into `.providerReported`. + public let costSource: CostUsageCostSource /// Internal credential scope used to prevent cross-account cache publication. This is a /// non-reversible fingerprint, not account identity, and is not emitted by CLI payloads. public let credentialScopeFingerprint: String? @@ -97,6 +145,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { historyCoverageIsEstablished: Bool = true, historyLabel: String? = nil, meteredCostUSD: Double? = nil, + costSource: CostUsageCostSource = .estimated, credentialScopeFingerprint: String? = nil, daily: [CostUsageDailyReport.Entry], projects: [CostUsageProjectBreakdown] = [], @@ -115,6 +164,7 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.historyCoverageIsEstablished = historyCoverageIsEstablished self.historyLabel = historyLabel self.meteredCostUSD = meteredCostUSD + self.costSource = costSource self.credentialScopeFingerprint = credentialScopeFingerprint self.daily = daily self.projects = projects @@ -275,8 +325,21 @@ public struct CostUsageProjectSourceBreakdown: Sendable, Equatable { public struct CostUsageDailyReport: Sendable, Decodable { public struct ModelBreakdown: Sendable, Decodable, Equatable { public let modelName: String + /// Provider/endpoint identity reported by the source record. This is + /// billing evidence, unlike a model-name guess. It is intentionally + /// optional because many historical formats do not retain routing. + public let billingProviderID: String? public let costUSD: Double? public let totalTokens: Int? + public let inputTokens: Int? + public let cacheReadTokens: Int? + public let cacheCreationTokens: Int? + public let outputTokens: Int? + /// Reasoning ("thinking") tokens, when the source format reports them separately + /// (Codex `reasoning_output_tokens`, Gemini `thoughts`, OpenCode `reasoning`). + /// Reasoning is always a sub-bucket of `outputTokens` — output stays billing-inclusive, + /// so reasoning must never be added on top of output when summing buckets. + public let reasoningTokens: Int? public let requestCount: Int? public let standardCostUSD: Double? public let priorityCostUSD: Double? @@ -285,9 +348,19 @@ public struct CostUsageDailyReport: Sendable, Decodable { private enum CodingKeys: String, CodingKey { case modelName + case billingProviderID + case providerID case costUSD case cost case totalTokens + case inputTokens + case cacheReadTokens + case cacheCreationTokens + case cacheReadInputTokens + case cacheCreationInputTokens + case outputTokens + case reasoningTokens + case reasoningOutputTokens = "reasoning_output_tokens" case requestCount case requests case standardCostUSD @@ -299,10 +372,24 @@ public struct CostUsageDailyReport: Sendable, Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.modelName = try container.decode(String.self, forKey: .modelName) + self.billingProviderID = + try container.decodeIfPresent(String.self, forKey: .billingProviderID) + ?? container.decodeIfPresent(String.self, forKey: .providerID) self.costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .cost) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) + self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens) + self.cacheReadTokens = + try container.decodeIfPresent(Int.self, forKey: .cacheReadTokens) + ?? container.decodeIfPresent(Int.self, forKey: .cacheReadInputTokens) + self.cacheCreationTokens = + try container.decodeIfPresent(Int.self, forKey: .cacheCreationTokens) + ?? container.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens) + self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) + self.reasoningTokens = + try container.decodeIfPresent(Int.self, forKey: .reasoningTokens) + ?? container.decodeIfPresent(Int.self, forKey: .reasoningOutputTokens) self.requestCount = try container.decodeIfPresent(Int.self, forKey: .requestCount) ?? container.decodeIfPresent(Int.self, forKey: .requests) @@ -314,8 +401,14 @@ public struct CostUsageDailyReport: Sendable, Decodable { public init( modelName: String, + billingProviderID: String? = nil, costUSD: Double?, totalTokens: Int? = nil, + inputTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + outputTokens: Int? = nil, + reasoningTokens: Int? = nil, requestCount: Int? = nil, standardCostUSD: Double? = nil, priorityCostUSD: Double? = nil, @@ -323,8 +416,18 @@ public struct CostUsageDailyReport: Sendable, Decodable { priorityTokens: Int? = nil) { self.modelName = modelName + let normalizedProviderID = billingProviderID? + .trimmingCharacters(in: .whitespacesAndNewlines) + self.billingProviderID = normalizedProviderID?.isEmpty == false + ? normalizedProviderID + : nil self.costUSD = costUSD self.totalTokens = totalTokens + self.inputTokens = inputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens self.requestCount = requestCount self.standardCostUSD = standardCostUSD self.priorityCostUSD = priorityCostUSD @@ -530,6 +633,21 @@ extension CostUsageDailyReport { private struct BreakdownAccumulator { var totalTokens: Int = 0 var sawTotalTokens = false + var inputTokens: Int = 0 + var sawInputTokens = false + var missingInputTokens = false + var cacheReadTokens: Int = 0 + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var cacheCreationTokens: Int = 0 + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var outputTokens: Int = 0 + var sawOutputTokens = false + var missingOutputTokens = false + var reasoningTokens: Int = 0 + var sawReasoningTokens = false + var missingReasoningTokens = false var costUSD: Double = 0 var sawCost = false var standardCostUSD: Double = 0 @@ -546,6 +664,36 @@ extension CostUsageDailyReport { self.totalTokens += totalTokens self.sawTotalTokens = true } + if let inputTokens = breakdown.inputTokens { + self.inputTokens += inputTokens + self.sawInputTokens = true + } else { + self.missingInputTokens = true + } + if let cacheReadTokens = breakdown.cacheReadTokens { + self.cacheReadTokens += cacheReadTokens + self.sawCacheReadTokens = true + } else { + self.missingCacheReadTokens = true + } + if let cacheCreationTokens = breakdown.cacheCreationTokens { + self.cacheCreationTokens += cacheCreationTokens + self.sawCacheCreationTokens = true + } else { + self.missingCacheCreationTokens = true + } + if let outputTokens = breakdown.outputTokens { + self.outputTokens += outputTokens + self.sawOutputTokens = true + } else { + self.missingOutputTokens = true + } + if let reasoningTokens = breakdown.reasoningTokens { + self.reasoningTokens += reasoningTokens + self.sawReasoningTokens = true + } else { + self.missingReasoningTokens = true + } if let costUSD = breakdown.costUSD { self.costUSD += costUSD self.sawCost = true @@ -573,6 +721,13 @@ extension CostUsageDailyReport { modelName: modelName, costUSD: self.sawCost ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil, + inputTokens: self.sawInputTokens && !self.missingInputTokens ? self.inputTokens : nil, + cacheReadTokens: self.sawCacheReadTokens && !self.missingCacheReadTokens ? self.cacheReadTokens : nil, + cacheCreationTokens: self.sawCacheCreationTokens && !self.missingCacheCreationTokens + ? self.cacheCreationTokens + : nil, + outputTokens: self.sawOutputTokens && !self.missingOutputTokens ? self.outputTokens : nil, + reasoningTokens: self.sawReasoningTokens && !self.missingReasoningTokens ? self.reasoningTokens : nil, standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, priorityCostUSD: self.sawPriorityCost ? self.priorityCostUSD : nil, standardTokens: self.sawStandardTokens ? self.standardTokens : nil, @@ -1064,6 +1219,13 @@ enum CostUsageBucketInterval { } enum CostUsageLocalDay { + static func gregorianCalendar(preserving calendar: Calendar) -> Calendar { + var normalized = Calendar(identifier: .gregorian) + normalized.timeZone = calendar.timeZone + normalized.locale = calendar.locale + return normalized + } + static func gregorianCalendar(matching calendar: Calendar = .current) -> Calendar { var gregorian = Calendar(identifier: .gregorian) gregorian.timeZone = calendar.timeZone diff --git a/Sources/CodexBarCore/CostUsageScanExecutor.swift b/Sources/CodexBarCore/CostUsageScanExecutor.swift index aef8b2b78d..3aec3ada70 100644 --- a/Sources/CodexBarCore/CostUsageScanExecutor.swift +++ b/Sources/CodexBarCore/CostUsageScanExecutor.swift @@ -120,7 +120,14 @@ public enum CostUsageScanExecutor { guard state.install(continuation) else { return } queue.async { guard state.begin() else { return } - state.complete(with: Result { try work(checkCancellation) }) + #if canImport(ObjectiveC) + let result = autoreleasepool { + Result { try work(checkCancellation) } + } + #else + let result = Result { try work(checkCancellation) } + #endif + state.complete(with: result) } } } onCancel: { diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index ca353e24bb..11191f29b9 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "3aa49b47f4b78e13" + static let value = "c76ca2e79b9ed330" } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 4ecdbd3d63..30217f1a59 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -254,8 +254,7 @@ enum PiSessionCostScanner { pricingKey: CostUsagePricingKey.codex( modelsDevArtifact: modelsDevArtifact, formulaVersion: Self.costFormulaVersion, - parserHash: CodexParserHash.value, - modelsDevProviderIDs: ["anthropic", "openai"])) + parserHash: CodexParserHash.value)) } private static func requestedWindowExpandsCache( @@ -942,7 +941,11 @@ extension PiSessionCostScanner { breakdown.append(CostUsageDailyReport.ModelBreakdown( modelName: modelName, costUSD: costNanos.map { Double($0) / Self.costScale }, - totalTokens: modelTotalTokens > 0 ? modelTotalTokens : nil)) + totalTokens: modelTotalTokens > 0 ? modelTotalTokens : nil, + inputTokens: packed.inputTokens, + cacheReadTokens: packed.cacheReadTokens, + cacheCreationTokens: packed.cacheWriteTokens, + outputTokens: packed.outputTokens)) dayInput += packed.inputTokens dayOutput += packed.outputTokens dayCacheRead += packed.cacheReadTokens diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift index db210b6069..d8929f27ba 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityProviderDescriptor.swift @@ -35,6 +35,7 @@ public enum AntigravityProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.antigravity], noDataMessage: { "Antigravity cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .cli, .oauth], diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift new file mode 100644 index 0000000000..d8a0ba9c63 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift @@ -0,0 +1,596 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// Reads Antigravity (Google) local assistant-turn token usage from the conversation databases +// (`~/.gemini/antigravity/conversations/*.db`) and folds it into a `CostUsageTokenSnapshot` for the +// Usage & Spend dashboard. Same output shape as `MiniMaxSessionScanner`. +// +// Each `gen_metadata` row is one generation encoded as a `GeneratorMetadata` protobuf (the same +// message the IDE returns over `GetCascadeTrajectoryGeneratorMetadata`). There is no shipped +// `.proto`, so — mirroring tokscale's antigravity-cli parser — this scanner ships a tiny +// dependency-free protobuf wire-format reader and pulls only the fields it needs: +// +// gen_metadata.#1 → chatModel message +// #19 (string) → responseModel (e.g. `gemini-3.6-flash`) +// #9.#4 = {#1 sec, #2 ns} → per-generation wall-clock Timestamp +// #4 → usage message +// #1 (varint, const) → fixed system-prompt tokens (≈1132), billable input +// #2 (varint) → newly-processed (non-cached) input tokens +// #5 (varint) → cacheRead tokens +// #9 (varint) → output (text) tokens +// #10 (varint) → thinking / reasoning tokens +// #11 (string) → responseId (dedup key) +// trajectory_metadata_blob.#2 = {#1 sec, #2 ns} → session-created fallback Timestamp +// +// `reasoning` is part of billing output, so it stays folded into `outputTokens` and is additionally +// surfaced as the `reasoningTokens` sub-bucket (never added on top). Costs are priced from the +// token buckets at the vendor's models.dev rate (Google provider), matching how Codex/Claude spend +// is estimated; when no official rate is known the day stays token-only (`costUSD: nil`). +#if canImport(SQLite3) || canImport(CSQLite3) +public enum AntigravitySessionScanner { + public static let defaultHistoryDays = 30 + + /// Environment override for the Antigravity conversations directory, resolved directly here so + /// this scanner stays self-contained (mirrors `MiniMaxSessionScanner.MINIMAX_HOME`). + public static let homeEnvironmentKey = "ANTIGRAVITY_HOME" + + private static let claudePricingAliases: [String: String] = [ + "claude-opus-4-6-thinking": "claude-opus-4-6", + "claude-sonnet-4-6-thinking": "claude-sonnet-4-6", + "claude-haiku-4-6-thinking": "claude-haiku-4-6", + ] + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var cacheRead = 0 + var output = 0 + var reasoning = 0 + var requests = 0 + var cost = 0.0 + var sawCost = false + + mutating func add(_ row: UsageRow) -> Bool { + guard let nextInput = Self.adding(self.input, row.input), + let nextCacheRead = Self.adding(self.cacheRead, row.cacheRead), + let nextOutput = Self.adding(self.output, row.output), + let nextReasoning = Self.adding(self.reasoning, row.reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + if let cost = row.costUSD, cost.isFinite { + let nextCost = self.cost + cost + if nextCost.isFinite { + self.cost = nextCost + self.sawCost = true + } + } + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextOutput = Self.adding(self.output, other.output), + let nextReasoning = Self.adding(self.reasoning, other.reasoning), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + if other.sawCost { + let nextCost = self.cost + other.cost + if other.cost.isFinite, nextCost.isFinite { + self.cost = nextCost + self.sawCost = true + } + } + return true + } + + var total: Int? { + guard let withCacheRead = Self.adding(self.input, self.cacheRead) else { return nil } + return Self.adding(withCacheRead, self.output) + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + private struct UsageRow { + let model: String + let createdMs: Int64 + let input: Int + let cacheRead: Int + let output: Int + let reasoning: Int + let responseID: String? + let costUSD: Double? + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let conversationsURL = self.conversationsURL(environment: environment) + let databaseURLs = self.conversationDatabases(under: conversationsURL) + guard !databaseURLs.isEmpty else { return nil } + + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var values: [DayModelKey: TokenAccumulator] = [:] + var seenResponseIDs: Set = [] + for databaseURL in databaseURLs { + try checkCancellation() + try self.readRows( + databaseURL: databaseURL, + pricing: (catalog: modelsDevCatalog, cacheRoot: modelsDevCacheRoot), + seenResponseIDs: &seenResponseIDs, + checkCancellation: checkCancellation) + { row in + let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { return } + let key = DayModelKey( + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: row.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(row) else { return } + values[key] = value + } + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + var dayCost = 0.0 + var daySawCost = false + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + costUSD: value.sawCost ? value.cost : nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: nil, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + if value.sawCost { + dayCost += value.cost + daySawCost = true + } + } + guard let totalTokens = total.total else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: nil, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: daySawCost ? dayCost : nil, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + let totalCost = daily.compactMap(\.costUSD).reduce(0, +) + let sawCost = daily.contains { $0.costUSD != nil } + guard let totalTokens, let totalRequests else { return nil } + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: sawCost ? totalCost : nil, + last30DaysRequests: totalRequests, + currencyCode: sawCost ? "USD" : "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "Antigravity", + costSource: .estimated, + daily: daily, + updatedAt: now) + } + + // MARK: - Paths + + public static func antigravityHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + .appendingPathComponent("antigravity", isDirectory: true) + } + + public static func conversationsURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.antigravityHomeURL(environment: environment) + .appendingPathComponent("conversations", isDirectory: true) + } + + private static func conversationDatabases(under directory: URL) -> [URL] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + else { + return [] + } + return contents + .filter { $0.pathExtension == "db" } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + + // MARK: - SQLite + + private static func readRows( + databaseURL: URL, + pricing: (catalog: ModelsDevCatalog?, cacheRoot: URL?), + seenResponseIDs: inout Set, + checkCancellation: () throws -> Void, + onRow: (UsageRow) -> Void) throws + { + var db: OpaquePointer? + // Observe committed WAL frames. `immutable=1` is unsafe for a live provider database + // because SQLite may assume the WAL can never change and return stale history. + let encodedPath = databaseURL.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + ?? databaseURL.path + let uri = "file:\(encodedPath)?mode=ro" + guard sqlite3_open_v2( + uri, + &db, + SQLITE_OPEN_READONLY | SQLITE_OPEN_URI | SQLITE_OPEN_FULLMUTEX, + nil) == SQLITE_OK + else { + sqlite3_close(db) + return + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + guard self.hasTable(named: "gen_metadata", db: db) else { return } + let sessionTimestampMs = self.sessionCreatedMs(db: db) ?? self.fileModifiedMs(databaseURL) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT data FROM gen_metadata ORDER BY idx", -1, &stmt, nil) == SQLITE_OK + else { return } + defer { sqlite3_finalize(stmt) } + + while true { + try checkCancellation() + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { return } + guard let blob = self.columnBlob(stmt, 0) else { continue } + if let row = self.parseGeneration( + blob, + sessionTimestampMs: sessionTimestampMs, + catalog: pricing.catalog, + cacheRoot: pricing.cacheRoot, + seenResponseIDs: &seenResponseIDs) + { + onRow(row) + } + } + } + + private static func parseGeneration( + _ blob: [UInt8], + sessionTimestampMs: Int64, + catalog: ModelsDevCatalog?, + cacheRoot: URL?, + seenResponseIDs: inout Set) -> UsageRow? + { + guard let chatModel = WireReader.messageField(blob, 1), + let usage = WireReader.messageField(chatModel, 4) + else { + return nil + } + + // Per-generation wall-clock time; fall back to the session-created stamp. + let timestampMs = WireReader.messageField(chatModel, 9) + .flatMap { WireReader.messageField($0, 4) } + .flatMap { WireReader.timestampMs($0) } + .flatMap { $0 > 0 ? $0 : nil } + ?? sessionTimestampMs + + let inputPart1 = Self.clampedInt(WireReader.varintField(usage, 1)) + let inputPart2 = Self.clampedInt(WireReader.varintField(usage, 2)) + let inputAddition = inputPart1.addingReportingOverflow(inputPart2) + guard !inputAddition.overflow else { return nil } + let input = inputAddition.partialValue + let cacheRead = Self.clampedInt(WireReader.varintField(usage, 5)) + let visibleOutput = Self.clampedInt(WireReader.varintField(usage, 9)) + let reasoning = Self.clampedInt(WireReader.varintField(usage, 10)) + let outputAddition = visibleOutput.addingReportingOverflow(reasoning) + guard !outputAddition.overflow else { return nil } + let output = outputAddition.partialValue + guard input > 0 || output > 0 || cacheRead > 0 || reasoning > 0 else { return nil } + + if let responseID = WireReader.stringField(usage, 11)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !responseID.isEmpty + { + guard seenResponseIDs.insert(responseID).inserted else { return nil } + } + + let modelRaw = WireReader.stringField(chatModel, 19)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let model = modelRaw.isEmpty ? "gemini" : modelRaw + + let costUSD = self.costUSD( + model: model, + tokens: TokenBuckets(input: input, cacheRead: cacheRead, output: output), + catalog: catalog, + cacheRoot: cacheRoot) + + return UsageRow( + model: model, + createdMs: timestampMs, + input: input, + cacheRead: cacheRead, + output: output, + reasoning: reasoning, + responseID: nil, + costUSD: costUSD) + } + + // MARK: - Pricing + + private struct TokenBuckets { + let input: Int + let cacheRead: Int + let output: Int + } + + /// Prices a generation against its actual model provider. Known Gemini models use CodexBar's + /// official Google pricing snapshot first and models.dev for newly released ids; Claude uses + /// the same catalog-plus-bundled fallback as Claude Code. The raw display name is never changed. + private static func costUSD( + model: String, + tokens: TokenBuckets, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> Double? + { + let lowered = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if lowered.hasPrefix("claude-") { + let pricingModel = self.claudePricingAliases[lowered] ?? lowered + return CostUsagePricing.claudeCostUSD( + model: pricingModel, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + cacheCreationInputTokens: 0, + outputTokens: tokens.output, + modelsDevCatalog: catalog, + modelsDevCacheRoot: cacheRoot) + } + return CostUsagePricing.googleCostUSD( + model: lowered, + inputTokens: tokens.input, + cacheReadInputTokens: tokens.cacheRead, + outputTokens: tokens.output, + modelsDevCatalog: catalog, + modelsDevCacheRoot: cacheRoot) + } + + // MARK: - SQLite helpers + + private static func sessionCreatedMs(db: OpaquePointer?) -> Int64? { + guard self.hasTable(named: "trajectory_metadata_blob", db: db) else { return nil } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "SELECT data FROM trajectory_metadata_blob LIMIT 1", -1, &stmt, nil) == SQLITE_OK + else { return nil } + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW, + let blob = self.columnBlob(stmt, 0), + let tsMessage = WireReader.messageField(blob, 2) + else { + return nil + } + return WireReader.timestampMs(tsMessage).flatMap { $0 > 0 ? $0 : nil } + } + + private static func fileModifiedMs(_ url: URL) -> Int64 { + guard let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), + let modified = values.contentModificationDate + else { + return 0 + } + return Int64(modified.timeIntervalSince1970 * 1000) + } + + private static func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static func columnBlob(_ stmt: OpaquePointer?, _ index: Int32) -> [UInt8]? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } + let count = Int(sqlite3_column_bytes(stmt, index)) + guard count > 0, let pointer = sqlite3_column_blob(stmt, index) else { return nil } + let buffer = UnsafeRawBufferPointer(start: pointer, count: count) + return Array(buffer) + } + + private static func clampedInt(_ value: UInt64?) -> Int { + guard let value else { return 0 } + return Int(clamping: value) + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} + +// MARK: - Protobuf wire-format reader + +/// Minimal dependency-free protobuf wire-format reader, mirroring tokscale's antigravity-cli +/// parser. Malformed data degrades to `nil`, never traps. Group wire types (3/4) are deprecated and +/// unsupported; encountering one stops the scan rather than risking desync. +private enum WireReader { + enum Value { + case varint(UInt64) + case length([UInt8]) + } + + static func messageField(_ buffer: [UInt8], _ field: UInt64) -> [UInt8]? { + for (number, value) in self.fields(buffer) where number == field { + if case let .length(bytes) = value { return bytes } + } + return nil + } + + static func varintField(_ buffer: [UInt8], _ field: UInt64) -> UInt64? { + for (number, value) in self.fields(buffer) where number == field { + if case let .varint(raw) = value { return raw } + } + return nil + } + + static func stringField(_ buffer: [UInt8], _ field: UInt64) -> String? { + guard let bytes = self.messageField(buffer, field) else { return nil } + return String(bytes: bytes, encoding: .utf8) + } + + /// Decode a `{#1: seconds, #2: nanos}` Timestamp to epoch milliseconds. Out-of-range nanos mark + /// the stamp malformed; checked arithmetic keeps corrupt seconds from overflowing. + static func timestampMs(_ buffer: [UInt8]) -> Int64? { + guard let secondsRaw = self.varintField(buffer, 1), + let seconds = Int64(exactly: secondsRaw) + else { + return nil + } + let nanosRaw = self.varintField(buffer, 2) ?? 0 + guard let nanos = Int64(exactly: nanosRaw), (0...999_999_999).contains(nanos) else { return nil } + let millis = seconds.multipliedReportingOverflow(by: 1000) + guard !millis.overflow else { return nil } + let total = millis.partialValue.addingReportingOverflow(nanos / 1_000_000) + return total.overflow ? nil : total.partialValue + } + + private static func fields(_ buffer: [UInt8]) -> [(UInt64, Value)] { + var result: [(UInt64, Value)] = [] + var position = 0 + while position < buffer.count { + guard let tag = self.readVarint(buffer, &position) else { break } + let field = tag >> 3 + switch tag & 0x7 { + case 0: + guard let value = self.readVarint(buffer, &position) else { return result } + result.append((field, .varint(value))) + case 1: + guard position + 8 <= buffer.count else { return result } + position += 8 + case 2: + guard let length = self.readVarint(buffer, &position), + let count = Int(exactly: length), + position + count <= buffer.count + else { + return result + } + result.append((field, .length(Array(buffer[position..<(position + count)])))) + position += count + case 5: + guard position + 4 <= buffer.count else { return result } + position += 4 + default: + return result + } + } + return result + } + + private static func readVarint(_ buffer: [UInt8], _ position: inout Int) -> UInt64? { + var result: UInt64 = 0 + var shift: UInt64 = 0 + while position < buffer.count { + let byte = buffer[position] + position += 1 + result |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { return result } + shift += 7 + if shift >= 64 { return nil } + } + return nil + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift index 4cb101eb3f..68cd067d40 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiProviderDescriptor.swift @@ -36,6 +36,7 @@ public enum GeminiProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.geminiCLI], noDataMessage: { "Gemini cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift new file mode 100644 index 0000000000..184f66f5a0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiSessionScanner.swift @@ -0,0 +1,524 @@ +import Foundation + +/// Scans local Gemini CLI session transcripts and folds their per-turn token usage into a +/// token-only `CostUsageTokenSnapshot` for the Usage & Spend dashboard. Same shape as +/// `KimiCodeSessionScanner`; the on-disk semantics mirror tokscale's `sessions/gemini.rs`: +/// +/// - Root: `$GEMINI_CLI_HOME/tmp` (default `~/.gemini/tmp`), one `/` dir per project. +/// - Chat recordings: `/chats/session-*.json` (plus legacy `session-*.json` anywhere +/// under `tmp`) holding a `messages` array. Model turns carry `model` and a `tokens` object with +/// `input`/`prompt`, `output`/`candidates`, `cached`, `thoughts`, `tool`, `total` (plus the +/// snake_case/camelCase aliases below) and an RFC 3339 `timestamp` (file mtime is the fallback). +/// - Chat streams: any `*.jsonl` under `tmp`, one message object per line; `init` lines seed the +/// current model for later token lines, and a line whose `id` repeats replaces the earlier one. +/// +/// Normalization (mirrors tokscale): when `total` equals input+output+thoughts+tool but not that +/// sum plus cached, the prompt count was cache-inclusive, so the cached overlap is subtracted from +/// input. `tool` tokens fold into input because `CostUsageDailyReport.ModelBreakdown` has no tool +/// bucket. `thoughts` stays folded into billing `outputTokens` (reasoning is part of output for +/// billing) and is additionally surfaced as the `reasoningTokens` sub-bucket. Deliberate deviations: +/// headless `stats` blobs are not parsed, string-typed numbers are rejected, and negative values +/// mark the record corrupt (skipped) rather than clamped — both matching this repo's stricter +/// `KimiCodeSessionScanner` robustness style. +public enum GeminiSessionScanner { + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + public static let maximumFileBytes = 16 * 1024 * 1024 + + /// Environment override for the Gemini CLI home directory, resolved directly here (the same + /// way `KimiSettingsReader` honors `KIMI_CODE_HOME`) so this scanner stays self-contained. + public static let cliHomeEnvironmentKey = "GEMINI_CLI_HOME" + + // MARK: - Wire models + + private struct WireTokens: Decodable { + let input: Int + let output: Int + let cached: Int + let thoughts: Int + let tool: Int + let total: Int? + + private enum CodingKeys: String, CodingKey { + case input + case prompt + case inputTokens = "input_tokens" + case promptTokens = "prompt_tokens" + case promptTokenCount + case output + case candidates + case outputTokens = "output_tokens" + case completionTokens = "completion_tokens" + case candidatesTokenCount + case cached + case cachedTokens = "cached_tokens" + case cachedContentTokenCount + case thoughts + case reasoning + case thoughtsTokens = "thoughts_tokens" + case tool + case toolTokens = "tool_tokens" + case total + case totalTokenCount + case totalTokens = "total_tokens" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.input = Self.first(container, [.input, .prompt, .inputTokens, .promptTokens, .promptTokenCount]) ?? 0 + self.output = Self + .first(container, [.output, .candidates, .outputTokens, .completionTokens, .candidatesTokenCount]) ?? 0 + self.cached = Self.first(container, [.cached, .cachedTokens, .cachedContentTokenCount]) ?? 0 + self.thoughts = Self.first(container, [.thoughts, .reasoning, .thoughtsTokens]) ?? 0 + self.tool = Self.first(container, [.tool, .toolTokens]) ?? 0 + self.total = Self.first(container, [.total, .totalTokenCount, .totalTokens]) + } + + /// First present, integer-typed alias wins; a missing or mistyped alias falls through. + private static func first( + _ container: KeyedDecodingContainer, + _ keys: [CodingKeys]) -> Int? + { + for key in keys { + if let value = try? container.decode(Int.self, forKey: key) { + return value + } + } + return nil + } + } + + private struct WireMessage: Decodable { + let id: String? + let type: String? + let model: String? + let tokens: WireTokens? + let timestamp: Date? + + private enum CodingKeys: String, CodingKey { + case id + case type + case model + case tokens + case timestamp + case createdAt = "created_at" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try? container.decode(String.self, forKey: .id) + self.type = try? container.decode(String.self, forKey: .type) + self.model = try? container.decode(String.self, forKey: .model) + self.tokens = try? container.decode(WireTokens.self, forKey: .tokens) + self.timestamp = Self.timestamp(from: container) + } + + /// Mirrors tokscale: the first *present* key wins; if it is present but unparseable the + /// caller falls back to the file mtime instead of trying the next key. + private static func timestamp(from container: KeyedDecodingContainer) -> Date? { + for key in [CodingKeys.timestamp, .createdAt] { + guard container.contains(key) else { continue } + if let text = try? container.decode(String.self, forKey: key) { + return GeminiSessionScanner.timestampTextDate(text) + } + if let number = try? container.decode(Double.self, forKey: key), + number.isFinite, number > 0 + { + // Milliseconds when the magnitude says so, otherwise seconds (tokscale rule). + return Date(timeIntervalSince1970: number >= 1_000_000_000_000 ? number / 1000 : number) + } + return nil + } + return nil + } + } + + private struct WireChatRecording: Decodable { + let messages: [WireMessage] + } + + // MARK: - Aggregation + + private struct NormalizedUsage { + let input: Int + let output: Int + let cacheRead: Int + /// `thoughts` tokens; already included in `output` (billing-inclusive), tracked separately. + let reasoning: Int + } + + private struct ResolvedTurn { + let date: Date + let model: String + let usage: NormalizedUsage + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var output = 0 + var cacheRead = 0 + var reasoning = 0 + var requests = 0 + + mutating func add(_ usage: NormalizedUsage) -> Bool { + guard let nextInput = Self.adding(self.input, usage.input), + let nextOutput = Self.adding(self.output, usage.output), + let nextCacheRead = Self.adding(self.cacheRead, usage.cacheRead), + let nextReasoning = Self.adding(self.reasoning, usage.reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.output = nextOutput + self.cacheRead = nextCacheRead + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextOutput = Self.adding(self.output, other.output), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextReasoning = Self.adding(self.reasoning, other.reasoning), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.output = nextOutput + self.cacheRead = nextCacheRead + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + var total: Int? { + guard let inputAndOutput = Self.adding(self.input, self.output) else { return nil } + return Self.adding(inputAndOutput, self.cacheRead) + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + // MARK: - Scanning + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let tmp = self.geminiTmpURL(environment: environment) + guard let enumerator = fileManager.enumerator( + at: tmp, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var values: [DayModelKey: TokenAccumulator] = [:] + let decoder = JSONDecoder() + var visitedFiles = 0 + var visitedBytes = 0 + + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + let pathExtension = url.pathExtension.lowercased() + guard pathExtension == "json" || pathExtension == "jsonl", + self.isChatTranscript(url, under: tmp) + else { + continue + } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + let modificationDate = resourceValues?.contentModificationDate + if let modificationDate, modificationDate < start { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumFileBytes, + size <= self.maximumBytes - visitedBytes + else { + continue + } + visitedFiles += 1 + visitedBytes += size + guard let data = try? Data(contentsOf: url) else { continue } + // Messages without a usable timestamp fall back to the file mtime (tokscale rule); + // epoch zero simply lands outside the window when even the mtime is unavailable. + let fallbackDate = modificationDate ?? Date(timeIntervalSince1970: 0) + let turns = pathExtension == "jsonl" + ? self.parseStream(data: data, fallbackDate: fallbackDate, decoder: decoder) + : self.parseChatRecording(data: data, fallbackDate: fallbackDate, decoder: decoder) + for turn in turns { + try checkCancellation() + let day = calendar.startOfDay(for: turn.date) + guard day >= start, day <= end else { continue } + let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: turn.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(turn.usage) else { continue } + values[key] = value + } + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + costUSD: nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: nil, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + } + guard let totalTokens = total.total else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: nil, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: nil, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + guard let totalTokens, let totalRequests else { return nil } + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: nil, + last30DaysRequests: totalRequests, + currencyCode: "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "Gemini CLI", + daily: daily, + updatedAt: now) + } + + // MARK: - Parsing + + private static func parseChatRecording( + data: Data, + fallbackDate: Date, + decoder: JSONDecoder) -> [ResolvedTurn] + { + guard let recording = try? decoder.decode(WireChatRecording.self, from: data) else { return [] } + return recording.messages.compactMap { message in + guard let model = self.cleaned(message.model), + let tokens = message.tokens, + let usage = self.normalize(tokens) + else { + return nil + } + return ResolvedTurn(date: message.timestamp ?? fallbackDate, model: model, usage: usage) + } + } + + private static func parseStream( + data: Data, + fallbackDate: Date, + decoder: JSONDecoder) -> [ResolvedTurn] + { + var turns: [ResolvedTurn] = [] + var indicesByID: [String: Int] = [:] + var currentModel: String? + for line in data.split(separator: 0x0A, omittingEmptySubsequences: true) { + guard let message = try? decoder.decode(WireMessage.self, from: Data(line)) else { continue } + if message.type == "init" { + if let model = self.cleaned(message.model) { currentModel = model } + continue + } + guard message.type == "gemini" || message.tokens != nil else { continue } + if let model = self.cleaned(message.model) { currentModel = model } + guard let model = currentModel, + let tokens = message.tokens, + let usage = self.normalize(tokens) + else { + continue + } + // A repeated `id` is a streaming rewrite of the same turn: the later line wins. + let turn = ResolvedTurn(date: message.timestamp ?? fallbackDate, model: model, usage: usage) + if let id = self.cleaned(message.id) { + if let index = indicesByID[id] { + turns[index] = turn + } else { + indicesByID[id] = turns.count + turns.append(turn) + } + } else { + turns.append(turn) + } + } + return turns + } + + private static func normalize(_ tokens: WireTokens) -> NormalizedUsage? { + guard tokens.input >= 0, tokens.output >= 0, tokens.cached >= 0, + tokens.thoughts >= 0, tokens.tool >= 0, tokens.total ?? 0 >= 0 + else { + return nil + } + + var input = tokens.input + if let total = tokens.total, tokens.cached > 0, + let inclusive = self.sum([tokens.input, tokens.output, tokens.thoughts, tokens.tool]), + inclusive == total + { + // `total` leaving out the cached count proves the input count was cache-inclusive. + let excludesCache = self.adding(inclusive, tokens.cached).map { $0 != total } ?? true + if excludesCache { + input -= min(tokens.cached, input) + } + } + + guard let finalInput = self.adding(input, tokens.tool), + let finalOutput = self.adding(tokens.output, tokens.thoughts) + else { + return nil + } + return NormalizedUsage( + input: finalInput, + output: finalOutput, + cacheRead: tokens.cached, + reasoning: tokens.thoughts) + } + + private static func timestampTextDate(_ text: String) -> Date? { + guard let trimmed = self.cleaned(text) else { return nil } + if let date = CostUsageDateParser.parse(trimmed) { return date } + // Timezone-less ISO-8601 datetimes carry no offset; interpret them as UTC (tokscale rule). + for format in [ + "yyyy-MM-dd'T'HH:mm:ss.SSSSSS", + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd HH:mm:ss", + ] { + if let date = self.utcFormatter(format: format).date(from: trimmed) { return date } + } + return nil + } + + private static func utcFormatter(format: String) -> DateFormatter { + let key = "GeminiSessionScanner.utcFormatter.\(format)" + let threadDictionary = Thread.current.threadDictionary + if let cached = threadDictionary[key] as? DateFormatter { return cached } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = format + formatter.isLenient = false + threadDictionary[key] = formatter + return formatter + } + + // MARK: - Paths + + public static func geminiTmpURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = self.cleaned(environment[self.cliHomeEnvironmentKey]) { + return URL(fileURLWithPath: override, isDirectory: true) + .appendingPathComponent("tmp", isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + .appendingPathComponent("tmp", isDirectory: true) + } + + /// Mirrors tokscale's layout filter: every `.jsonl` under `tmp` is a candidate chat stream, + /// while `.json` files need the legacy `session-` prefix or the `tmp//chats/` layout. + private static func isChatTranscript(_ url: URL, under tmp: URL) -> Bool { + if url.pathExtension.lowercased() == "jsonl" { return true } + if url.lastPathComponent.hasPrefix("session-") { return true } + guard let components = self.relativeComponents(of: url, under: tmp) else { return false } + return components.count == 3 && components[1] == "chats" + } + + private static func relativeComponents(of url: URL, under base: URL) -> [String]? { + let baseComponents = base.standardizedFileURL.pathComponents + let urlComponents = url.standardizedFileURL.pathComponents + guard urlComponents.count > baseComponents.count, + urlComponents.prefix(baseComponents.count).elementsEqual(baseComponents) + else { + return nil + } + return Array(urlComponents.dropFirst(baseComponents.count)) + } + + private static func cleaned(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} diff --git a/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift index 8581becc5c..44ad21d426 100644 --- a/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Groq/GroqConsoleUsageSnapshot.swift @@ -161,6 +161,7 @@ public struct GroqConsoleUsageSnapshot: Codable, Equatable, Sendable { last30DaysCostUSD: total.costUSD, last30DaysRequests: total.requests, historyDays: self.historyDays, + costSource: .providerReported, daily: daily, updatedAt: self.updatedAt) } diff --git a/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift index fbc6dace91..4b91547b7b 100644 --- a/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift @@ -34,7 +34,7 @@ public enum GroqProviderDescriptor { ProviderColor(hex: 0x97FCA7), ]), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, + supportsTokenCost: true, noDataMessage: { "Sign in at console.groq.com to show Groq spend and token usage." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web, .api], diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift new file mode 100644 index 0000000000..ecf8b2898c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift @@ -0,0 +1,339 @@ +import Foundation + +public enum KimiCodeSessionScanner { + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + + private struct WireEvent: Decodable { + struct Usage: Decodable { + let inputOther: Int? + let inputCacheRead: Int? + let inputCacheCreation: Int? + let output: Int? + } + + let type: String + let time: Double? + let model: String? + let usage: Usage? + let usageScope: String? + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var cacheRead = 0 + var cacheCreation = 0 + var output = 0 + var requests = 0 + var cost = 0.0 + var sawCost = false + + mutating func add( + _ usage: WireEvent.Usage, + model: String, + pricingDate: Date, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Bool + { + guard let input = Self.valid(usage.inputOther), + let cacheRead = Self.valid(usage.inputCacheRead), + let cacheCreation = Self.valid(usage.inputCacheCreation), + let output = Self.valid(usage.output), + let nextInput = Self.adding(self.input, input), + let nextCacheRead = Self.adding(self.cacheRead, cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, cacheCreation), + let nextOutput = Self.adding(self.output, output), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.requests = nextRequests + if let cost = Self.estimatedCost( + model: model, + usage: usage, + pricingDate: pricingDate, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot), + cost.isFinite + { + let nextCost = self.cost + cost + if nextCost.isFinite { + self.cost = nextCost + self.sawCost = true + } + } + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, other.cacheCreation), + let nextOutput = Self.adding(self.output, other.output), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.requests = nextRequests + if other.sawCost { + let nextCost = self.cost + other.cost + if other.cost.isFinite, nextCost.isFinite { + self.cost = nextCost + self.sawCost = true + } + } + return true + } + + var total: Int? { + guard let inputAndCacheRead = Self.adding(self.input, self.cacheRead), + let withCacheCreation = Self.adding(inputAndCacheRead, self.cacheCreation) + else { + return nil + } + return Self.adding(withCacheCreation, self.output) + } + + private static func valid(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + + private static func estimatedCost( + model: String, + usage: WireEvent.Usage, + pricingDate: Date, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + guard let input = self.valid(usage.inputOther), + let cacheRead = self.valid(usage.inputCacheRead), + let cacheCreation = self.valid(usage.inputCacheCreation), + let output = self.valid(usage.output) + else { + return nil + } + return CostUsagePricing.claudeCostUSD( + model: self.pricingModelID(model), + inputTokens: input, + cacheReadInputTokens: cacheRead, + cacheCreationInputTokens: cacheCreation, + outputTokens: output, + pricingDate: pricingDate, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + private static func pricingModelID(_ model: String) -> String { + let bare = model.split(separator: "/", omittingEmptySubsequences: true).last + .map(String.init) ?? model + switch bare.lowercased() { + case "k3", "k3-256k": + return "kimi-k3" + default: + return bare + } + } + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let home = KimiSettingsReader.kimiCodeHomeURL(environment: environment) + let sessions = home.appendingPathComponent("sessions", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: sessions, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + var values: [DayModelKey: TokenAccumulator] = [:] + let decoder = JSONDecoder() + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + var visitedFiles = 0 + var visitedBytes = 0 + + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + guard url.lastPathComponent == "wire.jsonl", + url.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent == "agents" + else { + continue + } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + if let modificationDate = resourceValues?.contentModificationDate, + modificationDate < start + { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumBytes - visitedBytes else { break } + visitedFiles += 1 + visitedBytes += size + do { + try CostUsageJsonl.scan( + fileURL: url, + maxLineBytes: 1024 * 1024, + prefixBytes: 1024 * 1024, + checkCancellation: checkCancellation) + { line in + guard !line.wasTruncated, + let event = try? decoder.decode(WireEvent.self, from: line.bytes), + event.type == "usage.record", + event.usageScope == nil || event.usageScope == "turn", + let time = event.time, + time.isFinite, + let rawModel = event.model?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawModel.isEmpty, + let usage = event.usage + else { + return + } + let date = Date(timeIntervalSince1970: time / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { return } + let key = DayModelKey( + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: rawModel) + var value = values[key] ?? TokenAccumulator() + guard value.add( + usage, + model: rawModel, + pricingDate: date, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { return } + values[key] = value + } + } catch is CancellationError { + throw CancellationError() + } catch { + continue + } + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + var dayCost = 0.0 + var daySawCost = false + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + billingProviderID: UsageProvider.kimi.rawValue, + costUSD: value.sawCost ? value.cost : nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: value.cacheCreation, + outputTokens: value.output, + requestCount: value.requests)) + if value.sawCost { + dayCost += value.cost + daySawCost = true + } + } + guard let totalTokens = total.total else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: total.cacheCreation, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: daySawCost ? dayCost : nil, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + let totalCost = daily.compactMap(\.costUSD).reduce(0, +) + let sawCost = daily.contains { $0.costUSD != nil } + guard let totalTokens, let totalRequests else { return nil } + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: sawCost ? totalCost : nil, + last30DaysRequests: totalRequests, + currencyCode: sawCost ? "USD" : "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "Kimi Code CLI", + costSource: .estimated, + daily: daily, + updatedAt: now) + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift index c2c82758b7..a7cef89717 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiProviderDescriptor.swift @@ -36,6 +36,7 @@ public enum KimiProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.kimiCode], noDataMessage: { "Kimi cost summary is not supported." }), pace: ProviderPaceCapability( resetWindowPace: .windowDuration(minutes: self.weeklyWindowMinutes)), diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift b/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift index e4c676f16e..24014545a9 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiSettingsReader.swift @@ -120,7 +120,9 @@ public enum KimiSettingsReader { return deviceID } - private static func kimiCodeHomeURL(environment: [String: String]) -> URL { + public static func kimiCodeHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { if let override = self.cleaned(environment[self.codeHomeEnvironmentKey]) { return URL(fileURLWithPath: override, isDirectory: true) } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift index 4d02782100..e085b332a3 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift @@ -34,6 +34,7 @@ public enum MiniMaxProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.miniMax], noDataMessage: { "MiniMax cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web, .api], diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift new file mode 100644 index 0000000000..c3a27b394e --- /dev/null +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift @@ -0,0 +1,435 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// Reads MiniMax local assistant-turn token usage from the MiniMax desktop runtime database +// (`~/.minimax/v2/sqlite/runtime-state.sqlite`, table `local_runtime_token_usage`) and folds it +// into a token-only `CostUsageTokenSnapshot` for the Usage & Spend dashboard. Same output shape as +// `KimiCodeSessionScanner`, but sourced from SQLite rather than JSONL. +// +// The runtime table already stores one row per billable turn with explicit buckets: +// `input_tokens`, `output_tokens`, `reasoning_tokens`, `cache_read_tokens`, `cache_write_tokens`, +// plus `model` (e.g. `minimax/MiniMax-M3`) and `ts` (epoch milliseconds). `reasoning` is part of +// billing output, so it stays folded into `outputTokens` and is additionally surfaced as the +// `reasoningTokens` sub-bucket (never added on top). `cache_write` maps to `cacheCreationTokens`. +// `cost_usd` is reported by the provider, but MiniMax coding plans run on a flat subscription and +// report `0` there. To stay consistent with how Codex/Claude spend is estimated, we price each turn +// at the vendor's official models.dev rate (via `CostUsagePricing.claudeCostUSD`, which routes +// MiniMax through the third-party lookup) instead of trusting the zeroed provider figure. A turn is +// priced from its token buckets; only when no official rate is known does the snapshot fall back to +// the provider's `cost_usd`, and failing that stays token-only (`costUSD: nil`). +#if canImport(SQLite3) || canImport(CSQLite3) +public enum MiniMaxSessionScanner { + public static let defaultHistoryDays = 30 + + /// Environment override for the MiniMax home directory, resolved directly here so this scanner + /// stays self-contained (mirrors how `KimiCodeSessionScanner` honors `KIMI_CODE_HOME`). + public static let homeEnvironmentKey = "MINIMAX_HOME" + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var cacheRead = 0 + var cacheCreation = 0 + var output = 0 + var reasoning = 0 + var requests = 0 + var cost = 0.0 + var sawCost = false + + mutating func add( + _ row: UsageRow, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Bool + { + guard let nextInput = Self.adding(self.input, row.input), + let nextCacheRead = Self.adding(self.cacheRead, row.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, row.cacheCreation), + let nextOutput = Self.adding(self.output, row.output), + let nextReasoning = Self.adding(self.reasoning, row.reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + if let cost = row.estimatedCost( + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot), + cost.isFinite + { + let nextCost = self.cost + cost + if nextCost.isFinite { + self.cost = nextCost + self.sawCost = true + } + } + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, other.cacheCreation), + let nextOutput = Self.adding(self.output, other.output), + let nextReasoning = Self.adding(self.reasoning, other.reasoning), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + if other.sawCost { + let nextCost = self.cost + other.cost + if other.cost.isFinite, nextCost.isFinite { + self.cost = nextCost + self.sawCost = true + } + } + return true + } + + var total: Int? { + guard let inputAndCacheRead = Self.adding(self.input, self.cacheRead), + let withCacheCreation = Self.adding(inputAndCacheRead, self.cacheCreation) + else { + return nil + } + return Self.adding(withCacheCreation, self.output) + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + private struct UsageRow { + let model: String + let createdMs: Int64 + let input: Int + let output: Int + let reasoning: Int + let cacheRead: Int + let cacheCreation: Int + let cost: Double? + + /// Prices the turn at the vendor's official models.dev rate (mirroring how Codex/Claude + /// spend is estimated). The stored `model` is namespaced as `minimax/MiniMax-M3`, but the + /// third-party lookup keys on the bare model id, so the provider prefix is stripped first. + /// `reasoning` is already folded into `output`, so it is not priced twice. Falls back to the + /// provider-reported `cost_usd` only when it carries a real (non-zero) figure and no + /// official rate is known. + func estimatedCost( + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + let bareModel = Self.bareModelID(self.model) + if let priced = CostUsagePricing.claudeCostUSD( + model: bareModel, + inputTokens: self.input, + cacheReadInputTokens: self.cacheRead, + cacheCreationInputTokens: self.cacheCreation, + outputTokens: self.output, + pricingDate: Date(timeIntervalSince1970: TimeInterval(self.createdMs) / 1000), + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + return priced + } + if let cost = self.cost, cost > 0 { return cost } + return nil + } + + private static func bareModelID(_ model: String) -> String { + guard let slash = model.lastIndex(of: "/") else { return model } + return String(model[model.index(after: slash)...]) + } + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let databaseURL = self.runtimeDatabaseURL(environment: environment) + guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil } + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let until = calendar.date(byAdding: .day, value: 1, to: end) ?? now + guard let rows = try self.readRows( + databaseURL: databaseURL, + sinceMs: Int64(start.timeIntervalSince1970 * 1000), + untilMs: Int64(until.timeIntervalSince1970 * 1000), + checkCancellation: checkCancellation), + !rows.isEmpty + else { + return nil + } + + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + var values: [DayModelKey: TokenAccumulator] = [:] + + for row in rows { + try checkCancellation() + let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: row.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(row, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } + values[key] = value + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + var dayCost = 0.0 + var daySawCost = false + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + billingProviderID: UsageProvider.minimax.rawValue, + costUSD: value.sawCost ? value.cost : nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: value.cacheCreation, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + if value.sawCost { + dayCost += value.cost + daySawCost = true + } + } + guard let totalTokens = total.total else { return nil } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: total.cacheCreation, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: daySawCost ? dayCost : nil, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + let totalCost = daily.compactMap(\.costUSD).reduce(0, +) + let sawCost = daily.contains { $0.costUSD != nil } + guard let totalTokens, let totalRequests else { return nil } + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: sawCost ? totalCost : nil, + last30DaysRequests: totalRequests, + currencyCode: sawCost ? "USD" : "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "MiniMax", + costSource: .estimated, + daily: daily, + updatedAt: now) + } + + // MARK: - Paths + + public static func minimaxHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".minimax", isDirectory: true) + } + + public static func runtimeDatabaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.minimaxHomeURL(environment: environment) + .appendingPathComponent("v2", isDirectory: true) + .appendingPathComponent("sqlite", isDirectory: true) + .appendingPathComponent("runtime-state.sqlite", isDirectory: false) + } + + // MARK: - SQLite + + private static func readRows( + databaseURL: URL, + sinceMs: Int64, + untilMs: Int64, + checkCancellation: () throws -> Void) throws -> [UsageRow]? + { + var db: OpaquePointer? + // The runtime database is WAL-journaled. `immutable=1` must not be used here: it tells + // SQLite the database cannot change and can therefore ignore a live WAL, which made recent + // MiniMax turns disappear until a checkpoint. A normal URI read-only connection observes + // committed WAL frames while still preventing CodexBar from modifying the provider DB. + let encodedPath = databaseURL.path.addingPercentEncoding( + withAllowedCharacters: .urlPathAllowed) ?? databaseURL.path + let uri = "file:\(encodedPath)?mode=ro" + guard sqlite3_open_v2( + uri, + &db, + SQLITE_OPEN_READONLY | SQLITE_OPEN_URI | SQLITE_OPEN_FULLMUTEX, + nil) == SQLITE_OK + else { + sqlite3_close(db) + return nil + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + guard self.hasTable(named: "local_runtime_token_usage", db: db) else { return nil } + + let sql = """ + SELECT + COALESCE(model, ''), + ts, + input_tokens, + output_tokens, + reasoning_tokens, + cache_read_tokens, + cache_write_tokens, + cost_usd + FROM local_runtime_token_usage + WHERE ts >= ? AND ts < ? + ORDER BY ts + """ + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int64(stmt, 1, sinceMs) + sqlite3_bind_int64(stmt, 2, untilMs) + + var rows: [UsageRow] = [] + while true { + try checkCancellation() + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { return nil } + + let model = self.columnText(stmt, 0) ?? "" + let createdMs = sqlite3_column_int64(stmt, 1) + let input = Int(sqlite3_column_int64(stmt, 2)) + let output = Int(sqlite3_column_int64(stmt, 3)) + let reasoning = Int(sqlite3_column_int64(stmt, 4)) + let cacheRead = Int(sqlite3_column_int64(stmt, 5)) + let cacheCreation = Int(sqlite3_column_int64(stmt, 6)) + let cost: Double? = sqlite3_column_type(stmt, 7) == SQLITE_NULL + ? nil + : sqlite3_column_double(stmt, 7) + + guard createdMs > 0, + input >= 0, output >= 0, reasoning >= 0, cacheRead >= 0, cacheCreation >= 0 + else { + continue + } + rows.append(UsageRow( + model: model.isEmpty ? "minimax" : model, + createdMs: createdMs, + input: input, + output: output, + reasoning: reasoning, + cacheRead: cacheRead, + cacheCreation: cacheCreation, + cost: cost)) + } + return rows + } + + private static func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static func columnText(_ stmt: OpaquePointer?, _ index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { + return nil + } + return String(cString: cString) + } + + // MARK: - Helpers + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index db653f1a84..ea9e0b8df4 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -291,6 +291,7 @@ public struct MistralUsageSnapshot: Codable, Sendable { historyDays: window.coveredDays, historyCoverageIsEstablished: window.coverageIsEstablished, historyLabel: window.isMonthToDate ? "This month" : nil, + costSource: .providerReported, daily: entries, updatedAt: window.observationEnd) } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift index 5b4030eca8..f1bf02bcb2 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -268,6 +268,7 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { last30DaysCostUSD: total.costUSD, last30DaysRequests: total.requests, historyDays: self.historyDays, + costSource: .providerReported, daily: daily, updatedAt: self.updatedAt) } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift index 54883af5d4..ec5f1f219f 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift @@ -34,6 +34,7 @@ public enum OpenCodeProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.openCode], noDataMessage: { "OpenCode cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift new file mode 100644 index 0000000000..edfe04234f --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift @@ -0,0 +1,634 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +/// Scans local OpenCode message storage and folds assistant-turn token usage into a token-only +/// `CostUsageTokenSnapshot` for the Usage & Spend dashboard. Same shape as +/// `KimiCodeSessionScanner`; the on-disk semantics mirror tokscale's `sessions/opencode.rs`: +/// +/// - Root: `$XDG_DATA_HOME/opencode` (default `~/.local/share/opencode`). +/// - Messages: `storage/message//*.json`, one message per file, with `role`, +/// `modelID`/`providerID` (v2 payloads may nest the model under `model.id`), a required +/// `tokens {input, output, reasoning?, cache {read, write}}` object, and `time.created` / +/// `time.completed` as epoch milliseconds (float allowed). +/// +/// Only `role == "assistant"` files carry billable usage; role-less files are skipped on purpose +/// (the missing-role shortcut in tokscale applies solely to its type-filtered SQLite query). +/// `cache.write` maps to `cacheCreationTokens`. `reasoning` stays folded into billing +/// `outputTokens` (reasoning is part of output for billing) and is additionally surfaced as the +/// `reasoningTokens` sub-bucket. Negative values mark the record corrupt (skipped) +/// rather than clamped, matching this repo's `KimiCodeSessionScanner` robustness style. +/// +/// Deduplication mirrors tokscale: a message's dedup key is its embedded `id`, falling back to +/// the file stem, and repeated keys collapse into one record — this absorbs forked-session +/// copies, which keep the same message id (and usually the same filename) in a second session +/// directory. +/// +/// Future work — SQLite storage: OpenCode 1.2+ also keeps messages in `opencode.db` (`message` +/// table, role inside the JSON `data` column) and newer channel builds in +/// `opencode-.db` (`session_message` table, model nested under `$.model`). The package +/// already links the SQLite3 C module (see `OpenCodeGoLocalUsageReader`), so support can be +/// added without new dependencies. When it lands, the same dedup keys must be shared with this +/// JSON pass so messages present in both stores collapse (tokscale dedups JSON vs DB the same +/// way); DB rows additionally collapse on a full-field fingerprint — created/completed +/// timestamps, model/provider, token counts, cost, agent — whenever the embedded ids do not +/// conflict. +public enum OpenCodeSessionScanner { + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + public static let maximumFileBytes = 16 * 1024 * 1024 + + /// Environment override for the XDG data home, resolved directly here (the same way + /// `KimiSettingsReader` honors `KIMI_CODE_HOME`) so this scanner stays self-contained. + public static let dataHomeEnvironmentKey = "XDG_DATA_HOME" + + // MARK: - Wire models + + private struct WireMessage: Decodable { + struct Tokens: Decodable { + struct Cache: Decodable { + let read: Int + let write: Int + } + + let input: Int + let output: Int + let reasoning: Int? + let cache: Cache + } + + struct Model: Decodable { + let id: String? + } + + struct Time: Decodable { + let created: Double + } + + let id: String? + let role: String? + let modelID: String? + let model: Model? + let tokens: Tokens? + let time: Time + } + + // MARK: - Aggregation + + private struct NormalizedUsage { + let input: Int + let output: Int + let cacheRead: Int + let cacheCreation: Int + /// `reasoning` tokens; already included in `output` (billing-inclusive), tracked separately. + let reasoning: Int + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + /// One assistant-turn usage record, normalized from either a JSON message file or an + /// `opencode.db` row so both storage passes share the aggregation path. + private struct UsageRecord { + let dedupKey: String + let day: String + let model: String + let usage: NormalizedUsage + /// Provider-reported cost (only present for `opencode.db` rows; JSON files carry none). + let cost: Double? + /// Full-field fingerprint used to collapse JSON-vs-DB duplicates when ids don't conflict. + let fingerprint: String + } + + private struct TokenAccumulator { + var input = 0 + var cacheRead = 0 + var cacheCreation = 0 + var output = 0 + var reasoning = 0 + var requests = 0 + + mutating func add(_ usage: NormalizedUsage) -> Bool { + guard let nextInput = Self.adding(self.input, usage.input), + let nextCacheRead = Self.adding(self.cacheRead, usage.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, usage.cacheCreation), + let nextOutput = Self.adding(self.output, usage.output), + let nextReasoning = Self.adding(self.reasoning, usage.reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + mutating func merge(_ other: TokenAccumulator) -> Bool { + guard let nextInput = Self.adding(self.input, other.input), + let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), + let nextCacheCreation = Self.adding(self.cacheCreation, other.cacheCreation), + let nextOutput = Self.adding(self.output, other.output), + let nextReasoning = Self.adding(self.reasoning, other.reasoning), + let nextRequests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = nextInput + self.cacheRead = nextCacheRead + self.cacheCreation = nextCacheCreation + self.output = nextOutput + self.reasoning = nextReasoning + self.requests = nextRequests + return true + } + + var total: Int? { + guard let inputAndCacheRead = Self.adding(self.input, self.cacheRead), + let withCacheCreation = Self.adding(inputAndCacheRead, self.cacheCreation) + else { + return nil + } + return Self.adding(withCacheCreation, self.output) + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + // MARK: - Scanning + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + + var context = ScanContext(start: start, end: end, calendar: calendar) + var records: [UsageRecord] = [] + // The SQLite row is the richer source after an OpenCode migration because it carries + // provider-reported cost. Register it before legacy JSON so duplicates retain pricing. + try records.append(contentsOf: self.scanDatabaseMessages( + environment: environment, + context: &context, + checkCancellation: checkCancellation)) + try records.append(contentsOf: self.scanJSONMessages( + environment: environment, + fileManager: fileManager, + context: &context, + checkCancellation: checkCancellation)) + + var values: [DayModelKey: TokenAccumulator] = [:] + var costs: [DayModelKey: Double] = [:] + // Keys that include at least one billable record with no provider-reported cost (legacy JSON + // rows). Their day's cost must be withheld so a priced DB subtotal is not read as complete. + var partiallyPricedKeys: Set = [] + for record in records { + try checkCancellation() + let key = DayModelKey(day: record.day, model: record.model) + var value = values[key] ?? TokenAccumulator() + guard value.add(record.usage) else { continue } + values[key] = value + if let cost = record.cost, cost.isFinite, cost >= 0 { + costs[key] = (costs[key] ?? 0) + cost + } else { + partiallyPricedKeys.insert(key) + } + } + + guard !values.isEmpty else { return nil } + let byDay = Dictionary(grouping: values, by: \.key.day) + let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in + let models = (byDay[day] ?? []).sorted { lhs, rhs in + lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var dayCost = 0.0 + var dayCostSeen = false + var dayHasUnpricedUsage = false + var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + for (key, value) in models { + guard let modelTotal = value.total else { return nil } + guard total.merge(value) else { return nil } + // A model whose records are only partially priced reports no subtotal: pairing the + // combined token count with a priced-only cost would read as a complete figure. + let modelPriced = costs[key] != nil && !partiallyPricedKeys.contains(key) + let modelCost = modelPriced ? costs[key] : nil + if let modelCost { dayCost += modelCost; dayCostSeen = true } + if !modelPriced { dayHasUnpricedUsage = true } + modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + costUSD: modelCost, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: value.cacheCreation, + outputTokens: value.output, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, + requestCount: value.requests)) + } + guard let totalTokens = total.total else { return nil } + let dayCostUSD = dayCostSeen && !dayHasUnpricedUsage ? dayCost : nil + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: total.cacheCreation, + totalTokens: totalTokens, + requestCount: total.requests, + costUSD: dayCostUSD, + modelsUsed: modelBreakdowns.map(\.modelName), + modelBreakdowns: modelBreakdowns) + } + let totalTokens = self.sum(daily.compactMap(\.totalTokens)) + let totalRequests = self.sum(daily.compactMap(\.requestCount)) + guard let totalTokens, let totalRequests else { return nil } + // Cost only exists for `opencode.db` rows (JSON files carry none). When nothing was priced, + // stay token-only ("XXX"/nil) so the dashboard does not show a phantom zero spend. + let totalCost = self.sum(daily.compactMap(\.costUSD)) + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + sessionRequests: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: totalCost, + last30DaysRequests: totalRequests, + currencyCode: totalCost != nil ? "USD" : "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "OpenCode", + daily: daily, + updatedAt: now) + } + + /// JSON message files (OpenCode < 1.2). Only assistant turns bill tokens. + /// Shared scan window and cross-source dedup state threaded through the JSON and SQLite scanners. + private struct ScanContext { + let start: Date + let end: Date + let calendar: Calendar + var seenMessageIDs: Set = [] + var seenFingerprints: Set = [] + } + + private static func scanJSONMessages( + environment: [String: String], + fileManager: FileManager, + context: inout ScanContext, + checkCancellation: () throws -> Void) throws -> [UsageRecord] + { + let start = context.start + let end = context.end + let calendar = context.calendar + let storage = self.opencodeMessageStorageURL(environment: environment) + guard let enumerator = fileManager.enumerator( + at: storage, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return [] + } + + let decoder = JSONDecoder() + var records: [UsageRecord] = [] + var visitedFiles = 0 + var visitedBytes = 0 + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + guard url.pathExtension.lowercased() == "json" else { continue } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + if let modificationDate = resourceValues?.contentModificationDate, + modificationDate < start + { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumFileBytes, + size <= self.maximumBytes - visitedBytes + else { + continue + } + visitedFiles += 1 + visitedBytes += size + guard let data = try? Data(contentsOf: url), + let message = try? decoder.decode(WireMessage.self, from: data) + else { + continue + } + guard message.role == "assistant", + let model = self.cleaned(message.modelID ?? message.model?.id), + let tokens = message.tokens, + let usage = self.normalize(tokens), + message.time.created.isFinite + else { + continue + } + let date = Date(timeIntervalSince1970: message.time.created / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + let dedupKey = self.cleaned(message.id) ?? url.deletingPathExtension().lastPathComponent + let fingerprint = self.fingerprint( + createdMs: Int64(message.time.created.rounded()), + model: model, + usage: usage, + cost: nil) + guard context.seenMessageIDs.insert(dedupKey).inserted, + context.seenFingerprints.insert(fingerprint).inserted + else { continue } + records.append(UsageRecord( + dedupKey: dedupKey, + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: model, + usage: usage, + cost: nil, + fingerprint: fingerprint)) + } + return records + } + + /// `opencode.db` (OpenCode 1.2+). The `message` table keeps each message as a JSON `data` + /// blob; assistant rows carry `modelID`/`model.id`, `tokens{input,output,reasoning,cache{read,write}}`, + /// `cost` (provider-reported USD), and `time.created` (epoch ms). `json_extract` reads them in + /// SQL so we never materialize the blob. + private static func scanDatabaseMessages( + environment: [String: String], + context: inout ScanContext, + checkCancellation: () throws -> Void) throws -> [UsageRecord] + { + let start = context.start + let end = context.end + let calendar = context.calendar + let dbURL = self.opencodeDatabaseURL(environment: environment) + guard FileManager.default.fileExists(atPath: dbURL.path) else { return [] } + + var db: OpaquePointer? + // Open the live database read-only without `immutable=1`; immutable mode can ignore + // committed WAL frames and publish stale history. + let encodedPath = dbURL.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) + ?? dbURL.path + let uri = "file:\(encodedPath)?mode=ro" + guard sqlite3_open_v2( + uri, + &db, + SQLITE_OPEN_READONLY | SQLITE_OPEN_URI | SQLITE_OPEN_FULLMUTEX, + nil) == SQLITE_OK + else { + sqlite3_close(db) + return [] + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + guard self.hasTable(named: "message", db: db) else { return [] } + + let sql = """ + SELECT + COALESCE(NULLIF(json_extract(data, '$.id'), ''), ''), + COALESCE( + NULLIF(json_extract(data, '$.modelID'), ''), + json_extract(data, '$.model.id')), + json_extract(data, '$.time.created'), + json_extract(data, '$.tokens.input'), + json_extract(data, '$.tokens.output'), + json_extract(data, '$.tokens.reasoning'), + json_extract(data, '$.tokens.cache.read'), + json_extract(data, '$.tokens.cache.write'), + json_extract(data, '$.cost') + FROM message + WHERE json_extract(data, '$.role') = 'assistant' + AND json_extract(data, '$.time.created') >= ? + AND json_extract(data, '$.time.created') < ? + """ + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + let until = calendar.date(byAdding: .day, value: 1, to: end) ?? end + sqlite3_bind_int64(stmt, 1, Int64(start.timeIntervalSince1970 * 1000)) + sqlite3_bind_int64(stmt, 2, Int64(until.timeIntervalSince1970 * 1000)) + + var records: [UsageRecord] = [] + while true { + try checkCancellation() + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { return [] } + + let messageID = self.columnText(stmt, 0) + guard let model = self.cleaned(self.columnText(stmt, 1)) else { continue } + let createdMs = sqlite3_column_type(stmt, 2) == SQLITE_NULL + ? 0 + : Int64(sqlite3_column_double(stmt, 2)) + guard createdMs > 0 else { continue } + let input = Int(sqlite3_column_int64(stmt, 3)) + let output = Int(sqlite3_column_int64(stmt, 4)) + let reasoning = sqlite3_column_type(stmt, 5) == SQLITE_NULL ? 0 : Int(sqlite3_column_int64(stmt, 5)) + let cacheRead = sqlite3_column_type(stmt, 6) == SQLITE_NULL ? 0 : Int(sqlite3_column_int64(stmt, 6)) + let cacheCreation = sqlite3_column_type(stmt, 7) == SQLITE_NULL ? 0 : Int(sqlite3_column_int64(stmt, 7)) + let cost: Double? = sqlite3_column_type(stmt, 8) == SQLITE_NULL + ? nil + : sqlite3_column_double(stmt, 8) + + guard input >= 0, output >= 0, reasoning >= 0, cacheRead >= 0, cacheCreation >= 0, + let foldedOutput = self.adding(output, reasoning) + else { + continue + } + let usage = NormalizedUsage( + input: input, + output: foldedOutput, + cacheRead: cacheRead, + cacheCreation: cacheCreation, + reasoning: reasoning) + + let date = Date(timeIntervalSince1970: Double(createdMs) / 1000) + let day = calendar.startOfDay(for: date) + guard day >= start, day <= end else { continue } + + let fingerprint = self.fingerprint(createdMs: createdMs, model: model, usage: usage, cost: cost) + // tokscale dedups JSON vs DB by embedded id, then by full-field fingerprint when ids + // don't conflict — so a message present in both stores collapses to one record. + if let messageID, !messageID.isEmpty { + guard context.seenMessageIDs.insert(messageID).inserted else { continue } + } + guard context.seenFingerprints.insert(fingerprint).inserted else { continue } + + records.append(UsageRecord( + dedupKey: messageID ?? fingerprint, + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: model, + usage: usage, + cost: cost, + fingerprint: fingerprint)) + } + return records + } + + // MARK: - Paths + + public static func opencodeMessageStorageURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.opencodeDataRootURL(environment: environment) + .appendingPathComponent("storage", isDirectory: true) + .appendingPathComponent("message", isDirectory: true) + } + + public static func opencodeDatabaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + self.opencodeDataRootURL(environment: environment) + .appendingPathComponent("opencode.db", isDirectory: false) + } + + private static func opencodeDataRootURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + let dataHome = if let override = self.cleaned(environment[self.dataHomeEnvironmentKey]) { + URL(fileURLWithPath: override, isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + return dataHome.appendingPathComponent("opencode", isDirectory: true) + } + + // MARK: - Helpers + + private static func normalize(_ tokens: WireMessage.Tokens) -> NormalizedUsage? { + let reasoning = tokens.reasoning ?? 0 + guard tokens.input >= 0, tokens.output >= 0, reasoning >= 0, + tokens.cache.read >= 0, tokens.cache.write >= 0 + else { + return nil + } + // Reasoning is part of billing output, so it stays folded into `output`; the + // `reasoning` bucket surfaces the same count separately (never add it on top). + guard let output = self.adding(tokens.output, reasoning) else { return nil } + return NormalizedUsage( + input: tokens.input, + output: output, + cacheRead: tokens.cache.read, + cacheCreation: tokens.cache.write, + reasoning: reasoning) + } + + private static func cleaned(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } + + private static func sum(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + var result = 0.0 + for value in values { + result += value + guard result.isFinite else { return nil } + } + return result + } + + /// Cross-store fingerprint for one logical message. Cost is deliberately excluded because + /// migrated database rows can enrich an otherwise identical legacy JSON record with pricing. + private static func fingerprint( + createdMs: Int64, + model: String, + usage: NormalizedUsage, + cost _: Double?) -> String + { + [ + String(createdMs), + model, + String(usage.input), + String(usage.output), + String(usage.cacheRead), + String(usage.cacheCreation), + String(usage.reasoning), + ].joined(separator: "|") + } + + // MARK: - SQLite helpers + + private static func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static func columnText(_ stmt: OpaquePointer?, _ index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { + return nil + } + return String(cString: cString) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift index 6f0adc4ebe..90dae68ff1 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -175,6 +175,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable { CostUsageFetcher.tokenSnapshot( from: CostUsageDailyReport(data: self.daily, summary: nil), now: self.updatedAt, - historyDays: historyDays) + historyDays: historyDays, + costSource: .providerReported) } } diff --git a/Sources/CodexBarCore/Providers/ProviderBranding.swift b/Sources/CodexBarCore/Providers/ProviderBranding.swift index 9d76dafedf..4ad5ba4e72 100644 --- a/Sources/CodexBarCore/Providers/ProviderBranding.swift +++ b/Sources/CodexBarCore/Providers/ProviderBranding.swift @@ -19,9 +19,17 @@ public struct ProviderColor: Sendable, Equatable { } } +public enum ProviderIconRenderingMode: Sendable, Equatable { + /// A monochrome official mark that follows the surrounding foreground color. + case template + /// An official asset whose embedded colors must be preserved. + case original +} + public struct ProviderBranding: Sendable { public let iconStyle: IconStyle public let iconResourceName: String + public let iconRenderingMode: ProviderIconRenderingMode public let color: ProviderColor public let confettiPalette: [ProviderColor] @@ -39,12 +47,14 @@ public struct ProviderBranding: Sendable { public init( iconStyle: IconStyle, iconResourceName: String, + iconRenderingMode: ProviderIconRenderingMode = .template, color: ProviderColor, confettiPalette: [ProviderColor]) { precondition((2...3).contains(confettiPalette.count), "Provider confetti palettes require 2–3 colors.") self.iconStyle = iconStyle self.iconResourceName = iconResourceName + self.iconRenderingMode = iconRenderingMode self.color = color self.confettiPalette = confettiPalette } diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6e24825e55..d2cecf4421 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -1,13 +1,59 @@ import Foundation +/// Stable identifier for a local usage-history adapter. +/// +/// This is intentionally an open value type instead of an enum. Provider +/// descriptors can opt into new adapters without expanding a central switch, +/// which keeps the dashboard extensible as tools add or change local formats. +public struct ProviderLocalHistorySource: RawRepresentable, Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + precondition(!rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + self.rawValue = rawValue + } + + public static let antigravity = Self(rawValue: "antigravity") + public static let geminiCLI = Self(rawValue: "geminiCLI") + public static let kimiCode = Self(rawValue: "kimiCode") + public static let miniMax = Self(rawValue: "miniMax") + public static let openCode = Self(rawValue: "openCode") + public static let qwenCode = Self(rawValue: "qwenCode") + + public static let builtIn: [Self] = [ + .antigravity, + .geminiCLI, + .kimiCode, + .miniMax, + .openCode, + .qwenCode, + ] + + /// Built-in adapter identifiers shipped by this build. Custom identifiers + /// remain valid even though they are not present in this inventory. + public static var allCases: [Self] { + self.builtIn + } +} + public struct ProviderTokenCostConfig: Sendable { public let supportsTokenCost: Bool + public let localHistorySources: [ProviderLocalHistorySource] public let noDataMessage: @Sendable () -> String - public init(supportsTokenCost: Bool, noDataMessage: @escaping @Sendable () -> String) { + public init( + supportsTokenCost: Bool, + localHistorySources: [ProviderLocalHistorySource] = [], + noDataMessage: @escaping @Sendable () -> String) + { self.supportsTokenCost = supportsTokenCost + self.localHistorySources = localHistorySources self.noDataMessage = noDataMessage } + + public var supportsDashboardHistory: Bool { + self.supportsTokenCost || !self.localHistorySources.isEmpty + } } public enum ProviderPaceWindowRule: Sendable { @@ -207,7 +253,9 @@ public enum ProviderDescriptorRegistry { public static func register(_ descriptor: ProviderDescriptor) -> ProviderDescriptor { self.lock.lock() defer { self.lock.unlock() } - if self.store.byID[descriptor.id] == nil { + if let index = self.store.ordered.firstIndex(where: { $0.id == descriptor.id }) { + self.store.ordered[index] = descriptor + } else { self.store.ordered.append(descriptor) } self.store.byID[descriptor.id] = descriptor @@ -227,8 +275,9 @@ public enum ProviderDescriptorRegistry { public static func descriptor(for id: UsageProvider) -> ProviderDescriptor { self.ensureBootstrapped() + self.lock.lock() + defer { self.lock.unlock() } if let found = self.store.byID[id] { return found } - if let found = self.all.first(where: { $0.id == id }) { return found } fatalError("Missing ProviderDescriptor for \(id.rawValue)") } diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift index ffd88045b4..55e4e61f37 100644 --- a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift @@ -45,6 +45,7 @@ public enum QwenCloudProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.qwenCode], noDataMessage: { "Qwen Cloud cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift new file mode 100644 index 0000000000..6a6ded35a4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift @@ -0,0 +1,348 @@ +import Foundation + +/// Reads Qwen Code's local JSONL history. +/// +/// Qwen stores assistant messages under `~/.qwen/projects/*/chats/*.jsonl`. +/// The format is also consumed by tokscale. The scanner is deliberately +/// bounded and cancellable so enabling a long history cannot turn dashboard +/// refresh into an unbounded filesystem crawl. +public enum QwenCodeSessionScanner { + public static let homeEnvironmentKey = "QWEN_HOME" + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + + private struct WireMessage: Decodable { + struct Usage: Decodable { + let promptTokenCount: Int? + let candidatesTokenCount: Int? + let thoughtsTokenCount: Int? + let cachedContentTokenCount: Int? + } + + let type: String? + let model: String? + let timestamp: String? + let usageMetadata: Usage? + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var output = 0 + var cacheRead = 0 + var reasoning = 0 + var requests = 0 + var cost = 0.0 + var sawCost = false + /// Set when at least one message in this accumulator carried billable usage but had no + /// resolvable price. The merged day/entry cost must then stay nil so the dashboard does not + /// present a partial subtotal as the complete day total. + var sawUnpricedUsage = false + + mutating func add( + usage: WireMessage.Usage, + model: String, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Bool + { + // Gemini-style `promptTokenCount` already includes the cached prefix + // (`cachedContentTokenCount` is a subset of it). Passing the full prompt as input AND the + // cached portion again as cache-read would bill the cached tokens twice, so split the + // prompt into its uncached remainder. + guard let promptTotal = Self.valid(usage.promptTokenCount), + let candidates = Self.valid(usage.candidatesTokenCount), + let reasoning = Self.valid(usage.thoughtsTokenCount), + let cacheRead = Self.valid(usage.cachedContentTokenCount), + let billingOutput = Self.adding(candidates, reasoning) + else { + return false + } + let uncachedInput = max(0, promptTotal - cacheRead) + guard let nextInput = Self.adding(self.input, uncachedInput), + let nextOutput = Self.adding(self.output, billingOutput), + let nextCacheRead = Self.adding(self.cacheRead, cacheRead), + let nextReasoning = Self.adding(self.reasoning, reasoning), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + guard promptTotal > 0 || billingOutput > 0 || cacheRead > 0 else { return false } + self.input = nextInput + self.output = nextOutput + self.cacheRead = nextCacheRead + self.reasoning = nextReasoning + self.requests = nextRequests + if let cost = CostUsagePricing.modelsDevCostUSD( + request: .init( + providerIDs: ["alibaba", "alibaba-cn"], + model: model, + inputTokens: uncachedInput, + cacheReadInputTokens: cacheRead, + outputTokens: billingOutput), + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot), + cost.isFinite + { + let nextCost = self.cost + cost + if nextCost.isFinite { + self.cost = nextCost + self.sawCost = true + } + } else { + self.sawUnpricedUsage = true + } + return true + } + + mutating func merge(_ other: Self) -> Bool { + guard let input = Self.adding(self.input, other.input), + let output = Self.adding(self.output, other.output), + let cacheRead = Self.adding(self.cacheRead, other.cacheRead), + let reasoning = Self.adding(self.reasoning, other.reasoning), + let requests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = input + self.output = output + self.cacheRead = cacheRead + self.reasoning = reasoning + self.requests = requests + if other.sawCost { + let nextCost = self.cost + other.cost + if other.cost.isFinite, nextCost.isFinite { + self.cost = nextCost + self.sawCost = true + } + } + self.sawUnpricedUsage = self.sawUnpricedUsage || other.sawUnpricedUsage + return true + } + + var total: Int? { + guard let inputAndCache = Self.adding(self.input, self.cacheRead) else { return nil } + return Self.adding(inputAndCache, self.output) + } + + private static func valid(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return 0 } + return value + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let home = self.homeURL(environment: environment) + let root = home.appendingPathComponent("projects", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let decoder = JSONDecoder() + // Qwen emits both whole-second and fractional-second RFC 3339 timestamps. The default + // formatter rejects fractional seconds and would silently collapse those messages onto the + // file's modification day, so try a fractional-seconds formatter first. + let iso8601Fractional = ISO8601DateFormatter() + iso8601Fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let iso8601 = ISO8601DateFormatter() + let parseTimestamp: (String) -> Date? = { raw in + iso8601Fractional.date(from: raw) ?? iso8601.date(from: raw) + } + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + var values: [DayModelKey: TokenAccumulator] = [:] + var visitedFiles = 0 + var visitedBytes = 0 + + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + guard url.pathExtension.lowercased() == "jsonl", + url.deletingLastPathComponent().lastPathComponent == "chats" + else { + continue + } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + if let modified = resourceValues?.contentModificationDate, modified < start { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumBytes - visitedBytes else { break } + visitedFiles += 1 + visitedBytes += size + + do { + try CostUsageJsonl.scan( + fileURL: url, + maxLineBytes: 1024 * 1024, + prefixBytes: 1024 * 1024, + checkCancellation: checkCancellation) + { line in + guard !line.wasTruncated, + let message = try? decoder.decode(WireMessage.self, from: line.bytes), + message.type == "assistant", + let usage = message.usageMetadata + else { + return + } + let eventDate = message.timestamp.flatMap(parseTimestamp) + ?? resourceValues?.contentModificationDate + ?? now + let eventDay = calendar.startOfDay(for: eventDate) + guard eventDay >= start, eventDay <= end else { return } + let model = message.model? + .trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedModel = model?.isEmpty == false ? model! : "unknown" + let key = DayModelKey( + day: CostUsageLocalDay.key(from: eventDay, calendar: calendar), + model: normalizedModel) + var accumulator = values[key] ?? TokenAccumulator() + guard accumulator.add( + usage: usage, + model: normalizedModel, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { + return + } + values[key] = accumulator + } + } catch is CancellationError { + throw CancellationError() + } catch { + continue + } + } + + guard !values.isEmpty else { return nil } + let daily = self.makeDaily(values: values) + guard let totalTokens = self.sum(daily.compactMap(\.totalTokens)), + let requests = self.sum(daily.compactMap(\.requestCount)) + else { + return nil + } + let costs = daily.compactMap(\.costUSD) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), + last30DaysRequests: requests, + currencyCode: costs.isEmpty ? "XXX" : "USD", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "Qwen Code CLI", + costSource: .estimated, + daily: daily, + updatedAt: now) + } + + public static func homeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".qwen", isDirectory: true) + } + + private static func makeDaily(values: [DayModelKey: TokenAccumulator]) + -> [CostUsageDailyReport.Entry] + { + let byDay = Dictionary(grouping: values, by: \.key.day) + return byDay.keys.sorted().compactMap { day in + let models = (byDay[day] ?? []).sorted { + $0.key.model.localizedCaseInsensitiveCompare($1.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + for (key, value) in models { + guard let modelTotal = value.total, total.merge(value) else { return nil } + breakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + billingProviderID: UsageProvider.qwencloud.rawValue, + costUSD: value.sawCost ? value.cost : nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: 0, + outputTokens: value.output, + reasoningTokens: value.reasoning, + requestCount: value.requests)) + } + guard let dayTotal = total.total else { return nil } + // Withhold the day cost whenever any contributing model could not be priced, so the + // dashboard treats the day as partially priced instead of a confident complete total. + let dayCost = total.sawCost && !total.sawUnpricedUsage ? total.cost : nil + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: 0, + totalTokens: dayTotal, + requestCount: total.requests, + costUSD: dayCost, + modelsUsed: breakdowns.map(\.modelName), + modelBreakdowns: breakdowns) + } + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift b/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift index 56f5608a56..bccb49327a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CodexSubagentRolloutShape.swift @@ -187,14 +187,16 @@ extension CostUsageScanner { private static func totalsEqual(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { lhs.input == rhs.input && lhs.cached == rhs.cached && lhs.output == rhs.output + && lhs.reasoning == rhs.reasoning } private static func totalsAtLeast(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { lhs.input >= rhs.input && lhs.cached >= rhs.cached && lhs.output >= rhs.output + && (lhs.reasoning ?? 0) >= (rhs.reasoning ?? 0) } private static func totalsContainUsage(_ totals: CostUsageCodexTotals) -> Bool { - totals.input > 0 || totals.cached > 0 || totals.output > 0 + totals.input > 0 || totals.cached > 0 || totals.output > 0 || (totals.reasoning ?? 0) > 0 } private static func normalizedSessionID(_ value: String?) -> String? { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index e13e0bcd3b..85a56219e0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -137,6 +137,9 @@ struct CostUsageCache: Codable { struct CostUsageFileUsage: Codable { var mtimeUnixMs: Int64 var size: Int64 + /// Sampling fingerprint recorded at scan time; nil on cache entries written before + /// bounded content-fingerprint validation was introduced. + var fingerprint: CostUsageSourceFingerprint? var days: [String: [String: [Int]]] var parsedBytes: Int64? var lastModel: String? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift new file mode 100644 index 0000000000..4855bd6f59 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift @@ -0,0 +1,152 @@ +import Foundation + +extension CostUsagePricing { + private struct GooglePricing { + let inputCostPerToken: Double + let outputCostPerToken: Double + let cacheReadInputCostPerToken: Double + let thresholdTokens: Int? + let inputCostPerTokenAboveThreshold: Double? + let outputCostPerTokenAboveThreshold: Double? + let cacheReadInputCostPerTokenAboveThreshold: Double? + } + + /// Official Google Gemini Standard rates, kept as an offline fallback for model ids that can + /// appear in Antigravity before the cached models.dev catalog catches up. + /// + /// Source (accessed 2026-07-28): + /// https://ai.google.dev/gemini-api/docs/pricing + private static let google: [String: GooglePricing] = [ + "gemini-3.6-flash": GooglePricing( + inputCostPerToken: 1.50 / 1_000_000, + outputCostPerToken: 7.50 / 1_000_000, + cacheReadInputCostPerToken: 0.15 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + "gemini-3.5-flash": GooglePricing( + inputCostPerToken: 1.50 / 1_000_000, + outputCostPerToken: 9.00 / 1_000_000, + cacheReadInputCostPerToken: 0.15 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + "gemini-3.1-pro-preview": GooglePricing( + inputCostPerToken: 2.00 / 1_000_000, + outputCostPerToken: 12.00 / 1_000_000, + cacheReadInputCostPerToken: 0.20 / 1_000_000, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 4.00 / 1_000_000, + outputCostPerTokenAboveThreshold: 18.00 / 1_000_000, + cacheReadInputCostPerTokenAboveThreshold: 0.40 / 1_000_000), + "gemini-3-flash-preview": GooglePricing( + inputCostPerToken: 0.50 / 1_000_000, + outputCostPerToken: 3.00 / 1_000_000, + cacheReadInputCostPerToken: 0.05 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), + ] + + private static let googleAliases: [String: String] = [ + "gemini-pro-default": "gemini-3.1-pro-preview", + "gemini-pro-agent": "gemini-3.1-pro-preview", + "gemini-3.1-pro": "gemini-3.1-pro-preview", + "gemini-3.1-pro-high": "gemini-3.1-pro-preview", + "gemini-3.1-pro-low": "gemini-3.1-pro-preview", + "gemini-3-flash": "gemini-3-flash-preview", + "gemini-3-flash-c": "gemini-3-flash-preview", + "gemini-default": "gemini-3-flash-preview", + "gemini-3-flash-a": "gemini-3.5-flash", + "gemini-3-flash-agent": "gemini-3.5-flash", + "gemini-3-flash-b": "gemini-3.5-flash", + "gemini-3.5-flash-high": "gemini-3.5-flash", + "gemini-3.5-flash-medium": "gemini-3.5-flash", + "gemini-3.5-flash-low": "gemini-3.5-flash", + "gemini-3.5-flash-extra-low": "gemini-3.5-flash", + ] + + static func googleCostUSD( + model: String, + inputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? + { + let canonicalModel = self.googlePricingModelID(model) + if let pricing = self.google[canonicalModel] { + return self.googleCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + outputTokens: outputTokens) + } + + guard let lookup = self.modelsDevLookup( + providerID: "google", + model: canonicalModel, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + else { + return nil + } + let pricing = lookup.pricing + let safeInput = max(0, inputTokens) + let safeCacheRead = max(0, cacheReadInputTokens) + let totalInput = safeInput.addingReportingOverflow(safeCacheRead) + let usesLongContextRates = pricing.thresholdTokens.map { + totalInput.overflow || totalInput.partialValue > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold + ?? pricing.cacheReadInputCostPerToken + ?? inputRate + : pricing.cacheReadInputCostPerToken ?? inputRate + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + let cost = Double(safeInput) * inputRate + + Double(safeCacheRead) * cacheReadRate + + Double(max(0, outputTokens)) * outputRate + return cost.isFinite ? cost : nil + } + + static func googlePricingModelID(_ model: String) -> String { + let normalized = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.googleAliases[normalized] ?? normalized + } + + private static func googleCostUSD( + pricing: GooglePricing, + inputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int) -> Double + { + let input = max(0, inputTokens) + let cacheRead = max(0, cacheReadInputTokens) + let output = max(0, outputTokens) + let totalInput = input.addingReportingOverflow(cacheRead) + let usesLongContextRates = pricing.thresholdTokens.map { + totalInput.overflow || totalInput.partialValue > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? pricing.cacheReadInputCostPerToken + : pricing.cacheReadInputCostPerToken + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + return Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(output) * outputRate + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift new file mode 100644 index 0000000000..1771567505 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift @@ -0,0 +1,169 @@ +import Foundation + +extension CostUsagePricing { + struct ModelsDevCostRequest { + let providerIDs: [String] + let model: String + let inputTokens: Int + let cacheReadInputTokens: Int + let outputTokens: Int + } + + /// Prices a model against an explicit ordered list of models.dev provider IDs. + /// + /// Callers must supply provider ownership from structured source evidence. This helper never + /// guesses a vendor from the model name, so a harness cannot silently bill usage to the wrong + /// subscription. + static func modelsDevCostUSD( + request: ModelsDevCostRequest, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> Double? + { + let lookup = request.providerIDs.lazy.compactMap { + self.modelsDevLookup( + providerID: $0, + model: request.model, + catalog: catalog, + cacheRoot: cacheRoot) + }.first + guard let pricing = lookup?.pricing else { return nil } + + let input = max(0, request.inputTokens) + let cacheRead = max(0, request.cacheReadInputTokens) + let output = max(0, request.outputTokens) + let context = input.addingReportingOverflow(cacheRead) + let usesLongContextRates = pricing.thresholdTokens.map { + context.overflow || context.partialValue > $0 + } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cacheReadRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold + ?? pricing.cacheReadInputCostPerToken + ?? inputRate + : pricing.cacheReadInputCostPerToken ?? inputRate + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + let cost = Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(output) * outputRate + return cost.isFinite ? cost : nil + } + + /// Resolves pricing for third-party models that are routed through the Claude-compatible + /// endpoint (DeepSeek, Kimi/Moonshot, MiniMax) via the models.dev catalog. + static func thirdPartyClaudeLookup( + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ClaudePricing? + { + let routedModel = model + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "/", omittingEmptySubsequences: true) + .last + .map(String.init) ?? model + let trimmed = routedModel.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + + let candidates: [(providerID: String, modelID: String)] + if lower.hasPrefix("deepseek-") { + candidates = [("deepseek", trimmed)] + } else if lower == "k3" || lower == "k3-256k" || lower == "kimi-k3" { + candidates = [ + ("moonshotai", "kimi-k3"), + ("moonshotai-cn", "kimi-k3"), + ] + } else if lower == "kimi-for-coding" { + candidates = [ + ("kimi-for-coding", trimmed), + ("moonshotai", "kimi-k2.6"), + ("moonshotai-cn", "kimi-k2.6"), + ] + } else if lower.hasPrefix("kimi-") { + candidates = [("moonshotai", trimmed), ("moonshotai-cn", trimmed)] + } else if lower.hasPrefix("minimax-") { + candidates = [("minimax", trimmed), ("minimax-cn", trimmed)] + } else { + return nil + } + + for candidate in candidates { + if let lookup = self.modelsDevLookup( + providerID: candidate.providerID, + model: candidate.modelID, + catalog: catalog, + cacheRoot: cacheRoot) + { + return ClaudePricing( + inputCostPerToken: lookup.pricing.inputCostPerToken, + outputCostPerToken: lookup.pricing.outputCostPerToken, + cacheCreationInputCostPerToken: lookup.pricing.cacheCreationInputCostPerToken + ?? lookup.pricing.inputCostPerToken, + cacheReadInputCostPerToken: lookup.pricing.cacheReadInputCostPerToken + ?? lookup.pricing.inputCostPerToken, + thresholdTokens: lookup.pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: lookup.pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: lookup.pricing.outputCostPerTokenAboveThreshold, + cacheCreationInputCostPerTokenAboveThreshold: lookup.pricing + .cacheCreationInputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: lookup.pricing + .cacheReadInputCostPerTokenAboveThreshold) + } + } + + // Kimi's official API price on 2026-07-26 is $3/M uncached input, $0.30/M cached + // input, and $15/M output for kimi-k3. Keep a fallback because newly released models can + // precede the models.dev catalog; the catalog remains authoritative as soon as it contains + // the model. Source: https://www.kimi.com/help/kimi-api/api-pricing + if lower == "k3" || lower == "k3-256k" || lower == "kimi-k3" { + return ClaudePricing( + inputCostPerToken: 3 / 1_000_000, + outputCostPerToken: 15 / 1_000_000, + cacheCreationInputCostPerToken: 3 / 1_000_000, + cacheReadInputCostPerToken: 0.30 / 1_000_000, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil) + } + + // MiniMax-M3 launched before every cached pricing catalog carried its pay-as-you-go row. + // Keep the official standard-tier API price as a fallback so local Token Plan usage stays + // priceable while offline or during catalog refresh. For requests whose input context + // (including cache hits) exceeds 512K, MiniMax doubles input, output, and cache-read rates. + // Source (accessed 2026-07-26): + // https://platform.minimax.io/subscribe/token-plan?tab=api-enterprise + if lower == "minimax-m3" { + return ClaudePricing( + inputCostPerToken: 0.30 / 1_000_000, + outputCostPerToken: 1.20 / 1_000_000, + cacheCreationInputCostPerToken: 0.30 / 1_000_000, + cacheReadInputCostPerToken: 0.06 / 1_000_000, + thresholdTokens: 512_000, + inputCostPerTokenAboveThreshold: 0.60 / 1_000_000, + outputCostPerTokenAboveThreshold: 2.40 / 1_000_000, + cacheCreationInputCostPerTokenAboveThreshold: 0.60 / 1_000_000, + cacheReadInputCostPerTokenAboveThreshold: 0.12 / 1_000_000) + } + return nil + } + + static func modelsDevLookup( + providerID: String, + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ModelsDevPricingLookup? + { + if let catalog { + return catalog.pricing(providerID: providerID, modelID: model) + } + + return ModelsDevPricingPipeline.lookup( + providerID: providerID, + modelID: model, + cacheRoot: cacheRoot) + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index 3727bf2ce2..ed2df0007f 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -148,6 +148,15 @@ enum CostUsagePricing { outputCostPerToken: 0, cacheReadInputCostPerToken: 0, displayLabel: "Research Preview"), + // Auto-review turns are an internal Codex feature billed within the subscription, not per + // token, so they carry no price. Pricing them at zero (like spark above) keeps those days + // "proven zero-cost" instead of unpriced, which would otherwise void the whole provider's + // cost history via the all-or-nothing completeness check. + "codex-auto-review": CodexPricing( + inputCostPerToken: 0, + outputCostPerToken: 0, + cacheReadInputCostPerToken: 0, + displayLabel: "Included"), "gpt-5.4": CodexPricing( inputCostPerToken: 2.5e-6, outputCostPerToken: 1.5e-5, @@ -576,13 +585,34 @@ enum CostUsagePricing { outputTokens: outputTokens) } - guard let pricing = self.codex[key] else { return nil } - return self.codexCostUSD( - pricing: pricing, - inputTokens: inputTokens, - cachedInputTokens: cachedInputTokens, - cacheWriteInputTokens: cacheWriteInputTokens, - outputTokens: outputTokens) + if let pricing = self.codex[key] { + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + cacheWriteInputTokens: cacheWriteInputTokens, + outputTokens: outputTokens) + } + + // Non-OpenAI models routed through a Codex-compatible endpoint (e.g. MiniMax / DeepSeek / + // Kimi). Price them at the vendor's official per-token rates so a single third-party day + // does not void the provider's whole cost history via the all-or-nothing check. + if let thirdParty = self.thirdPartyClaudeLookup( + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.claudeCostUSD( + pricing: thirdParty, + tokens: ClaudeCostTokens( + input: inputTokens, + cacheRead: cachedInputTokens, + cacheCreation: cacheWriteInputTokens, + cacheCreation1h: 0, + output: outputTokens)) + } + + return nil } static func codexPriorityCostUSD( @@ -733,12 +763,26 @@ enum CostUsagePricing { tokens: tokens) } + // Non-Anthropic models routed through a Claude-compatible endpoint (Kimi / DeepSeek / + // MiniMax coding plans): price them at the vendor's official per-token rates so the spend + // dashboard shows an equivalent-cost estimate instead of "unavailable". + if let thirdParty = self.thirdPartyClaudeLookup( + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.claudeCostUSD(pricing: thirdParty, tokens: tokens) + } + guard let pricing = self.claude[key] else { return nil } return self.claudeCostUSD( pricing: pricing, tokens: tokens) } + /// Maps a non-Anthropic model seen on the Claude endpoint to its vendor's official models.dev + /// pricing. `kimi-for-coding` is a subscription alias; it is priced as Moonshot's kimi-k2.6, + /// the current default coding model. Returns nil when no official rate is known. private static func claudeCostUSD( pricing: ClaudePricing, tokens: ClaudeCostTokens) -> Double @@ -792,20 +836,4 @@ enum CostUsagePricing { static func modelsDevCatalog(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCatalog? { ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog } - - private static func modelsDevLookup( - providerID: String, - model: String, - catalog: ModelsDevCatalog?, - cacheRoot: URL?) -> ModelsDevPricingLookup? - { - if let catalog { - return catalog.pricing(providerID: providerID, modelID: model) - } - - return ModelsDevPricingPipeline.lookup( - providerID: providerID, - modelID: model, - cacheRoot: cacheRoot) - } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift index 1e3e8d565e..b35867b03a 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricingKey.swift @@ -10,7 +10,18 @@ enum CostUsagePricingKey { modelsDevArtifact: ModelsDevCacheArtifact?, formulaVersion: Int, parserHash: String? = nil, - modelsDevProviderIDs: Set = ["openai"]) -> String + modelsDevProviderIDs: Set = [ + "anthropic", + "openai", + // Third-party models routed through a Codex-compatible endpoint are priced via + // `thirdPartyClaudeLookup`, so their catalog entries must also bust the cost cache. + "deepseek", + "minimax", + "minimax-cn", + "moonshotai", + "moonshotai-cn", + "kimi-for-coding", + ]) -> String { var parts = [ "costFormulaVersion=\(formulaVersion)", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index d6c5882db9..2a1ae5b4ad 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -276,6 +276,7 @@ extension CostUsageScanner { static func makeFileUsage( mtimeUnixMs: Int64, size: Int64, + fingerprint: CostUsageSourceFingerprint? = nil, days: [String: [String: [Int]]], parsedBytes: Int64?, lastModel: String? = nil, @@ -312,6 +313,7 @@ extension CostUsageScanner { CostUsageFileUsage( mtimeUnixMs: mtimeUnixMs, size: size, + fingerprint: fingerprint, days: days, parsedBytes: parsedBytes, lastModel: lastModel, @@ -730,9 +732,13 @@ extension CostUsageScanner { var days: [String: [String: [Int]]] = [:] for row in rows { let packed = days[row.day]?[row.model] ?? [] + var delta = [row.input, row.cached, row.output] + if let reasoning = row.reasoning { + delta.append(reasoning) + } days[row.day, default: [:]][row.model] = Self.addPacked( a: packed, - b: [row.input, row.cached, row.output], + b: delta, sign: 1) } return days @@ -916,6 +922,20 @@ extension CostUsageScanner { fileId: "\(info.st_dev):\(info.st_ino)") } + /// Builds the bounded sampling fingerprint for a Codex session file. `mtimeUnixNs` is derived + /// from the metadata's millisecond mtime so it round-trips with the value used at freshness + /// check time. Returns nil when the file is unreadable; the caller then stores no fingerprint + /// and the next scan fails safe to a reparse. + static func codexContentFingerprint( + metadata: CodexFileMetadata, + fileURL: URL) -> CostUsageSourceFingerprint? + { + CostUsageSourceFingerprint.make( + fileURL: fileURL, + size: metadata.size, + mtimeUnixNs: metadata.mtimeUnixMs * 1_000_000) + } + static func dropCachedCodexFile( path: String, cached: CostUsageFileUsage?, @@ -965,6 +985,24 @@ extension CostUsageScanner { !context.forceFullScan else { return false } + // Sampling-fingerprint freshness (tokscale-style): an exact size+mtime match is NOT + // sufficient on its own — a rewrite that restores the same byte length and mtime would + // otherwise be invisible (see the in-place same-size rewrite regression test). Recompute + // the bounded content samples and only treat the entry as fresh when they still match. + // A nil cached fingerprint (entries written before bounded validation) fails safe to a + // reparse so the entry is upgraded. + switch CostUsageSourceFingerprint.check( + fileURL: input.fileURL, + size: input.metadata.size, + mtimeUnixNs: input.metadata.mtimeUnixMs * 1_000_000, + cached: cached.fingerprint) + { + case .unchanged, .touched: + break + case .changed: + return false + } + guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } let sessionAlreadyContributed = cached.sessionId.map { state.contributingSessionIds.contains($0) } ?? false @@ -1172,6 +1210,7 @@ extension CostUsageScanner { cache.files[input.metadata.path] = Self.makeFileUsage( mtimeUnixMs: input.metadata.mtimeUnixMs, size: input.metadata.size, + fingerprint: Self.codexContentFingerprint(metadata: input.metadata, fileURL: input.fileURL), days: mergedDays, parsedBytes: delta.parsedBytes, lastModel: delta.lastModel, @@ -1296,6 +1335,7 @@ extension CostUsageScanner { cache.files[input.metadata.path] = Self.makeFileUsage( mtimeUnixMs: input.metadata.mtimeUnixMs, size: input.metadata.size, + fingerprint: Self.codexContentFingerprint(metadata: input.metadata, fileURL: input.fileURL), days: usageDays, parsedBytes: parsed.parsedBytes, lastModel: parsed.lastModel, @@ -1536,6 +1576,7 @@ extension CostUsageScanner { let input = packed[safe: 0] ?? 0 let cached = packed[safe: 1] ?? 0 let output = packed[safe: 2] ?? 0 + let reasoning = packed[safe: 3] let totalTokens = input + output dayInput += input @@ -1577,8 +1618,13 @@ extension CostUsageScanner { breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, + billingProviderID: CostUsageBillingProvider.providerID(fromNamespacedModel: model), costUSD: cost, totalTokens: totalTokens, + inputTokens: input, + cacheReadTokens: cached, + outputTokens: output, + reasoningTokens: reasoning, standardCostUSD: hasModeSplit ? standardCost : nil, priorityCostUSD: hasModeSplit ? priorityCost : nil, standardTokens: hasModeSplit ? cachedStandardTokens : nil, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index e862bb349e..8e8006c511 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -370,12 +370,14 @@ extension CostUsageScanner { private static func makeClaudeFileUsage( mtimeMs: Int64, size: Int64, + fingerprint: CostUsageSourceFingerprint? = nil, rows: [ClaudeUsageRow], parsedBytes: Int64?) -> CostUsageFileUsage { makeFileUsage( mtimeUnixMs: mtimeMs, size: size, + fingerprint: fingerprint, days: [:], parsedBytes: parsedBytes, claudeRows: rows) @@ -537,25 +539,47 @@ extension CostUsageScanner { url: URL, size: Int64, mtimeMs: Int64, + mtimeUnixNs: Int64, state: ClaudeScanState) throws { try state.checkCancellation?() let path = url.path state.touched.insert(path) - if let cached = state.cache.files[path], - cached.mtimeUnixMs == mtimeMs, - cached.size == size, - !state.forceFullScan - { - return - } - + // Reused by the incremental/full parse paths so a changed file is hashed only once. + var freshFingerprint: CostUsageSourceFingerprint? if let cached = state.cache.files[path], !state.forceFullScan { + // Sampling fingerprint freshness (tokscale-style): an exact size+mtime+samples match + // is a hit; a metadata-only touch (same whole-file hash) is a hit with a refreshed + // fingerprint; anything else falls through to incremental/full parsing. + switch CostUsageSourceFingerprint.check( + fileURL: url, + size: size, + mtimeUnixNs: mtimeUnixNs, + cached: cached.fingerprint) + { + case .unchanged: + return + case let .touched(fresh): + var updated = cached + updated.fingerprint = fresh + updated.mtimeUnixMs = mtimeMs + state.cache.files[path] = updated + return + case let .changed(fresh): + freshFingerprint = fresh + } + let startOffset = cached.parsedBytes ?? cached.size let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size && cached.claudeRows != nil if canIncremental { + // Fingerprint the post-append content before parsing the delta so a concurrent + // rewrite invalidates the entry on the next scan. + let fingerprint = freshFingerprint ?? CostUsageSourceFingerprint.make( + fileURL: url, + size: size, + mtimeUnixNs: mtimeUnixNs) let delta = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, @@ -568,12 +592,19 @@ extension CostUsageScanner { state.cache.files[path] = Self.makeClaudeFileUsage( mtimeMs: mtimeMs, size: size, + fingerprint: fingerprint, rows: mergedRows, parsedBytes: delta.parsedBytes) return } } + // Fingerprint before the full parse: any concurrent rewrite then invalidates the entry + // on the next scan instead of leaving a stale fingerprint behind. + let fingerprint = freshFingerprint ?? CostUsageSourceFingerprint.make( + fileURL: url, + size: size, + mtimeUnixNs: mtimeUnixNs) let parsed = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, @@ -584,6 +615,7 @@ extension CostUsageScanner { let usage = Self.makeClaudeFileUsage( mtimeMs: mtimeMs, size: size, + fingerprint: fingerprint, rows: parsed.rows, parsedBytes: parsed.parsedBytes) state.cache.files[path] = usage @@ -640,10 +672,14 @@ extension CostUsageScanner { let mtime = values.contentModificationDate?.timeIntervalSince1970 ?? 0 let mtimeMs = Int64(mtime * 1000) + // Derived from the same resource value on every scan, so the conversion is + // deterministic for unchanged files even though Date carries sub-ms precision. + let mtimeNs = Int64((mtime * 1_000_000_000).rounded()) try Self.processClaudeFile( url: url, size: size, mtimeMs: mtimeMs, + mtimeUnixNs: mtimeNs, state: state) } @@ -823,8 +859,13 @@ extension CostUsageScanner { breakdown.append( CostUsageDailyReport.ModelBreakdown( modelName: model, + billingProviderID: CostUsageBillingProvider.providerID(fromNamespacedModel: model), costUSD: cost, - totalTokens: totalTokens)) + totalTokens: totalTokens, + inputTokens: input, + cacheReadTokens: cacheRead, + cacheCreationTokens: cacheCreate, + outputTokens: output)) if let cost { dayCost += cost dayCostSeen = true diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift index 5e79e86c07..67b10000bf 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Projects.swift @@ -202,37 +202,57 @@ extension CostUsageScanner { var sawStandardTokens = false var priorityTokens = 0 var sawPriorityTokens = false + var billingProviderIDs: Set = [] mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) { if let totalTokens = breakdown.totalTokens { - self.totalTokens += totalTokens - self.sawTotalTokens = true + let addition = self.totalTokens.addingReportingOverflow(totalTokens) + if !addition.overflow { + self.totalTokens = addition.partialValue + self.sawTotalTokens = true + } } - if let costUSD = breakdown.costUSD { + if let costUSD = breakdown.costUSD, costUSD.isFinite { self.costUSD += costUSD self.sawCost = true } - if let standardCostUSD = breakdown.standardCostUSD { + if let standardCostUSD = breakdown.standardCostUSD, standardCostUSD.isFinite { self.standardCostUSD += standardCostUSD self.sawStandardCost = true } - if let priorityCostUSD = breakdown.priorityCostUSD { + if let priorityCostUSD = breakdown.priorityCostUSD, priorityCostUSD.isFinite { self.priorityCostUSD += priorityCostUSD self.sawPriorityCost = true } if let standardTokens = breakdown.standardTokens { - self.standardTokens += standardTokens - self.sawStandardTokens = true + let addition = self.standardTokens.addingReportingOverflow(standardTokens) + if !addition.overflow { + self.standardTokens = addition.partialValue + self.sawStandardTokens = true + } } if let priorityTokens = breakdown.priorityTokens { - self.priorityTokens += priorityTokens - self.sawPriorityTokens = true + let addition = self.priorityTokens.addingReportingOverflow(priorityTokens) + if !addition.overflow { + self.priorityTokens = addition.partialValue + self.sawPriorityTokens = true + } + } + if let billingProviderID = breakdown.billingProviderID? + .trimmingCharacters(in: .whitespacesAndNewlines), + !billingProviderID.isEmpty + { + self.billingProviderIDs.insert(billingProviderID) } } func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { - CostUsageDailyReport.ModelBreakdown( + let billingProviderID = self.billingProviderIDs.count == 1 + ? self.billingProviderIDs.first + : CostUsageBillingProvider.providerID(fromNamespacedModel: modelName) + return CostUsageDailyReport.ModelBreakdown( modelName: modelName, + billingProviderID: billingProviderID, costUSD: self.sawCost ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil, standardCostUSD: self.sawStandardCost ? self.standardCostUSD : nil, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index fe2677734a..522fd98236 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -2352,6 +2352,27 @@ enum CostUsageScanner { bufferedSubagentLines: nil) } + /// Test-only parser instrumentation. It remains nil in production, so normal scans do not + /// retain file paths or allocate a tracking set. + private(set) nonisolated(unsafe) static var _test_codexParsedFilePaths: Set? + private static let testParsedFilePathsLock = NSLock() + + static func _test_resetCodexParsedFilePaths() { + self.testParsedFilePathsLock.lock() + defer { self.testParsedFilePathsLock.unlock() } + self._test_codexParsedFilePaths = [] + } + + private static func recordTestParsedFilePath(_ path: String) { + self.testParsedFilePathsLock.lock() + defer { self.testParsedFilePathsLock.unlock() } + var normalized = URL(fileURLWithPath: path).standardizedFileURL.path + if normalized.hasPrefix("/private/var/") { + normalized = String(normalized.dropFirst("/private".count)) + } + self._test_codexParsedFilePaths?.insert(normalized) + } + // swiftlint:disable:next cyclomatic_complexity function_body_length static func parseCodexFileCancellable( fileURL: URL, @@ -2372,6 +2393,7 @@ enum CostUsageScanner { inheritedTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, checkCancellation: CancellationCheck? = nil) throws -> CodexParseResult { + self.recordTestParsedFilePath(fileURL.path) var currentModel = initialModel var previousTotals = initialTotals var sessionId: String? @@ -2410,16 +2432,26 @@ enum CostUsageScanner { var days: [String: [String: [Int]]] = [:] var rows: [CodexUsageRow] = [] - func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { + func add( + dayKey: String, + model: String, + usage: (input: Int, cached: Int, output: Int, reasoning: Int?)) + { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) else { return } let normModel = CostUsagePricing.normalizeCodexModel(model) var dayModels = days[dayKey] ?? [:] var packed = dayModels[normModel] ?? [0, 0, 0] - packed[0] = (packed[safe: 0] ?? 0) + input - packed[1] = (packed[safe: 1] ?? 0) + cached - packed[2] = (packed[safe: 2] ?? 0) + output + packed[0] = (packed[safe: 0] ?? 0) + usage.input + packed[1] = (packed[safe: 1] ?? 0) + usage.cached + packed[2] = (packed[safe: 2] ?? 0) + usage.output + if let reasoning = usage.reasoning { + while packed.count < 4 { + packed.append(0) + } + packed[3] = (packed[safe: 3] ?? 0) + reasoning + } dayModels[normModel] = packed days[dayKey] = dayModels } @@ -2735,9 +2767,11 @@ enum CostUsageScanner { add( dayKey: dayKey, model: normModel, - input: deltaInput, - cached: deltaCached, - output: deltaOutput) + usage: ( + input: deltaInput, + cached: deltaCached, + output: deltaOutput, + reasoning: deltaReasoning)) if CostUsageDayRange.isInRange( dayKey: dayKey, since: range.scanSinceKey, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift new file mode 100644 index 0000000000..24eeb04990 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageSourceFingerprint.swift @@ -0,0 +1,141 @@ +import Foundation + +/// One bounded content sample: FNV-1a (64-bit) over up to `sampleBytes` read at a fixed offset. +struct CostUsageFileSampleHash: Codable, Equatable { + var offset: Int64 + var length: Int64 + var fnv1a: UInt64 +} + +/// tokscale-style sampling fingerprint for per-file cache freshness (mirrors `SourceFingerprint` +/// in tokscale-core's message_cache.rs): size + nanosecond mtime + FNV-1a over 5 fixed sample +/// points x 4 KiB. Metadata changes are treated as content changes instead of hashing the whole +/// file: active JSONL logs grow frequently, and an extra full read before parsing doubles disk I/O. +struct CostUsageSourceFingerprint: Codable, Equatable { + static let sampleBytes: Int64 = 4096 + static let samplePoints = 5 + + var size: Int64 + var mtimeUnixNs: Int64 + var samples: [CostUsageFileSampleHash] + /// Legacy cache field retained for decoding caches written by older builds. New fingerprints + /// intentionally leave it nil; bounded samples are the only content reads. + var contentSHA256: String? + + enum Freshness: Equatable { + /// Size, mtime and all bounded samples match; validation read at most + /// `samplePoints x sampleBytes` and no full-file hash was computed. + case unchanged + /// Legacy state retained for callers that can read older cache entries. + /// Carries the refreshed fingerprint the caller should store. + case touched(CostUsageSourceFingerprint) + /// Real content change, an unreadable file, or no usable cached fingerprint. + /// Carries the fresh fingerprint describing the current file. + case changed(CostUsageSourceFingerprint) + + /// The freshly computed fingerprint carried by touched/changed results, reusable by + /// rescan paths so a changed file is hashed only once per scan. + var freshFingerprint: CostUsageSourceFingerprint? { + switch self { + case .unchanged: + nil + case let .touched(fresh), let .changed(fresh): + fresh + } + } + } + + // MARK: - Validation + + /// Cheap metadata first: when size + mtime match, only the bounded samples are recomputed + /// (<=20 KiB). When metadata moved, return changed immediately with a new bounded fingerprint. + static func check( + fileURL: URL, + size: Int64, + mtimeUnixNs: Int64, + cached: CostUsageSourceFingerprint?) -> Freshness + { + if let cached, + cached.size == size, + cached.mtimeUnixNs == mtimeUnixNs, + let samples = self.sampleHashes(fileURL: fileURL, size: size), + samples == cached.samples + { + return .unchanged + } + + guard let fresh = self.make(fileURL: fileURL, size: size, mtimeUnixNs: mtimeUnixNs) else { + return .changed(CostUsageSourceFingerprint( + size: size, + mtimeUnixNs: mtimeUnixNs, + samples: [], + contentSHA256: nil)) + } + return .changed(fresh) + } + + /// Builds a bounded fingerprint for the current file content. + static func make( + fileURL: URL, + size: Int64, + mtimeUnixNs: Int64) -> CostUsageSourceFingerprint? + { + guard let samples = self.sampleHashes(fileURL: fileURL, size: size) else { return nil } + return CostUsageSourceFingerprint( + size: size, + mtimeUnixNs: mtimeUnixNs, + samples: samples, + contentSHA256: nil) + } + + // MARK: - Sampling + + /// Fixed sample offsets matching tokscale: start / quarter / half / three-quarter / final + /// window, sorted and deduplicated, capped at `samplePoints`. + static func sampleOffsets(size: Int64) -> [(offset: Int64, length: Int64)] { + let sampleLength = min(size, self.sampleBytes) + guard sampleLength > 0 else { return [] } + let maxOffset = size - sampleLength + let raw: [Int64] = maxOffset == 0 + ? [0] + : [0, maxOffset / 4, maxOffset / 2, maxOffset / 4 * 3, maxOffset] + var seen: Set = [] + var offsets: [Int64] = [] + for offset in raw.sorted() where !seen.contains(offset) { + seen.insert(offset) + offsets.append(offset) + } + return offsets.prefix(self.samplePoints).map { (offset: $0, length: sampleLength) } + } + + static func sampleHashes(fileURL: URL, size: Int64) -> [CostUsageFileSampleHash]? { + let offsets = self.sampleOffsets(size: size) + guard !offsets.isEmpty else { return [] } + guard let handle = try? FileHandle(forReadingFrom: fileURL) else { return nil } + defer { try? handle.close() } + var samples: [CostUsageFileSampleHash] = [] + for (offset, length) in offsets { + do { + try handle.seek(toOffset: UInt64(offset)) + guard let data = try handle.read(upToCount: Int(length)), data.count == length + else { return nil } + samples.append(CostUsageFileSampleHash( + offset: offset, + length: length, + fnv1a: self.fnv1a(data))) + } catch { + return nil + } + } + return samples + } + + static func fnv1a(_ data: Data) -> UInt64 { + var hash: UInt64 = 0xCBF2_9CE4_8422_2325 + for byte in data { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 + } + return hash + } +} diff --git a/Tests/CodexBarTests/AntigravitySessionScannerTests.swift b/Tests/CodexBarTests/AntigravitySessionScannerTests.swift new file mode 100644 index 0000000000..05e02743bc --- /dev/null +++ b/Tests/CodexBarTests/AntigravitySessionScannerTests.swift @@ -0,0 +1,244 @@ +import Foundation +import SQLite3 +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct AntigravitySessionScannerTests { + enum SQLiteTestError: Error { + case open + case exec(String) + } + + @Test + func `scans token usage from conversation database`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.createDatabase(at: env.databaseURL) + try Self.insertGeneration( + databaseURL: env.databaseURL, + meta: GenerationMeta( + index: 0, + model: "gemini-3.6-flash", + responseID: "resp-1", + timestampMs: Self.ms("2026-07-20T10:00:00.000Z")), + tokens: Tokens(newlyProcessed: 500, cacheRead: 16000, output: 300, reasoning: 40)) + try Self.insertGeneration( + databaseURL: env.databaseURL, + meta: GenerationMeta( + index: 1, + model: "gemini-3.6-flash", + responseID: "resp-2", + timestampMs: Self.ms("2026-07-20T11:00:00.000Z")), + tokens: Tokens(newlyProcessed: 100, cacheRead: 0, output: 50, reasoning: 0)) + + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-07-20T12:00:00.000Z")) / 1000) + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: now) + + let report = try #require(snapshot) + #expect(report.last30DaysTokens == 1132 + 500 + 16000 + 300 + 40 + 1132 + 100 + 50) + #expect(report.last30DaysRequests == 2) + #expect(report.daily.count == 1) + let entry = try #require(report.daily.first) + #expect(entry.requestCount == 2) + #expect(entry.inputTokens == 1132 + 500 + 1132 + 100) + #expect(entry.cacheReadTokens == 16000) + #expect(entry.outputTokens == 390) + #expect(entry.modelsUsed == ["gemini-3.6-flash"]) + #expect(entry.modelBreakdowns?.first?.reasoningTokens == 40) + let expectedCost = (2864.0 * 1.5e-6) + (16000.0 * 0.15e-6) + (390.0 * 7.5e-6) + #expect(abs((entry.costUSD ?? 0) - expectedCost) < 1e-12) + } + + @Test + func `dedupes generations by response id`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.createDatabase(at: env.databaseURL) + for index in 0..<3 { + try Self.insertGeneration( + databaseURL: env.databaseURL, + meta: GenerationMeta( + index: index, + model: "gemini-3.6-flash", + responseID: "same-response", + timestampMs: Self.ms("2026-07-20T10:00:00.000Z")), + tokens: Tokens(newlyProcessed: 500, cacheRead: 0, output: 300, reasoning: 0)) + } + + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-07-20T12:00:00.000Z")) / 1000) + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: now) + + #expect(snapshot?.last30DaysRequests == 1) + } + + @Test + func `empty conversations directory yields no snapshot`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: Date()) + + #expect(snapshot == nil) + } + + @Test + func `database without gen_metadata table is skipped`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + var db: OpaquePointer? + guard sqlite3_open(env.databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + sqlite3_close(db) + + let snapshot = AntigravitySessionScanner.scan( + environment: [AntigravitySessionScanner.homeEnvironmentKey: env.homeURL.path], + historyDays: 30, + now: Date()) + + #expect(snapshot == nil) + } + + // MARK: - Environment + + private static func makeEnvironment() throws -> (root: URL, homeURL: URL, databaseURL: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("AntigravitySessionScannerTests-\(UUID().uuidString)", isDirectory: true) + let homeURL = root.appendingPathComponent("antigravity", isDirectory: true) + let conversationsURL = homeURL.appendingPathComponent("conversations", isDirectory: true) + try FileManager.default.createDirectory(at: conversationsURL, withIntermediateDirectories: true) + return (root, homeURL, conversationsURL.appendingPathComponent("session-1.db", isDirectory: false)) + } + + private static func createDatabase(at url: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try self.exec(db: db, sql: "CREATE TABLE gen_metadata (idx INTEGER PRIMARY KEY, data BLOB);") + } + + struct Tokens { + /// Fixed system-prompt tokens the scanner adds on top of `newlyProcessed` for billable input. + var systemPrompt: UInt64 = 1132 + var newlyProcessed: UInt64 + var cacheRead: UInt64 + var output: UInt64 + var reasoning: UInt64 + } + + struct GenerationMeta { + var index: Int + var model: String + var responseID: String? + var timestampMs: Int64 + } + + private static func insertGeneration( + databaseURL: URL, + meta: GenerationMeta, + tokens: Tokens) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let blob = Self.buildGeneration( + model: meta.model, + tokens: tokens, + responseID: meta.responseID, + timestampMs: meta.timestampMs) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "INSERT INTO gen_metadata (idx, data) VALUES (?, ?)", -1, &stmt, nil) == SQLITE_OK + else { throw SQLiteTestError.exec("prepare") } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int(stmt, 1, Int32(meta.index)) + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + _ = blob.withUnsafeBytes { buffer in + sqlite3_bind_blob(stmt, 2, buffer.baseAddress, Int32(buffer.count), transient) + } + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.exec("insert") } + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var error: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &error) == SQLITE_OK else { + let message = error.map { String(cString: $0) } ?? "unknown" + sqlite3_free(error) + throw SQLiteTestError.exec(message) + } + } + + private static func ms(_ iso: String) -> Int64 { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = formatter.date(from: iso) ?? Date(timeIntervalSince1970: 0) + return Int64(date.timeIntervalSince1970 * 1000) + } + + // MARK: - Protobuf encoding + + /// Builds a `gen_metadata` blob: `{#1: chatModel}` where chatModel carries `#4: usage`, + /// `#9: {#4: Timestamp}` and `#19: responseModel`. + private static func buildGeneration( + model: String, + tokens: Tokens, + responseID: String?, + timestampMs: Int64) -> [UInt8] + { + var usage = self.encVarint(field: 1, value: tokens.systemPrompt) + usage += self.encVarint(field: 2, value: tokens.newlyProcessed) + usage += self.encVarint(field: 5, value: tokens.cacheRead) + usage += self.encVarint(field: 9, value: tokens.output) + usage += self.encVarint(field: 10, value: tokens.reasoning) + if let responseID { + usage += self.encLen(field: 11, payload: Array(responseID.utf8)) + } + + var generation: [UInt8] = [] + generation += self.encLen(field: 4, payload: self.encTimestamp(seconds: timestampMs / 1000, nanos: 0)) + + var chatModel = self.encLen(field: 4, payload: usage) + chatModel += self.encLen(field: 9, payload: generation) + chatModel += self.encLen(field: 19, payload: Array(model.utf8)) + + return self.encLen(field: 1, payload: chatModel) + } + + private static func encTimestamp(seconds: Int64, nanos: Int64) -> [UInt8] { + var out = self.encVarint(field: 1, value: UInt64(bitPattern: seconds)) + out += self.encVarint(field: 2, value: UInt64(bitPattern: nanos)) + return out + } + + private static func encVarint(field: UInt64, value: UInt64) -> [UInt8] { + self.encodeVarint(field << 3) + self.encodeVarint(value) + } + + private static func encLen(field: UInt64, payload: [UInt8]) -> [UInt8] { + self.encodeVarint((field << 3) | 2) + self.encodeVarint(UInt64(payload.count)) + payload + } + + private static func encodeVarint(_ value: UInt64) -> [UInt8] { + var value = value + var out: [UInt8] = [] + while true { + var byte = UInt8(value & 0x7F) + value >>= 7 + if value != 0 { byte |= 0x80 } + out.append(byte) + if value == 0 { return out } + } + } +} diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index e7a42293c9..37af3ce96f 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -21,6 +21,9 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 1.25, totalTokens: 130, + inputTokens: 100, + cacheReadTokens: 10, + outputTokens: 20, standardCostUSD: 0.75, priorityCostUSD: 0.50, standardTokens: 80, @@ -50,6 +53,10 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 0.75, totalTokens: 67, + inputTokens: 50, + cacheReadTokens: 5, + cacheCreationTokens: 2, + outputTokens: 10, standardCostUSD: 0.25, priorityCostUSD: 0.50, standardTokens: 20, @@ -77,6 +84,9 @@ struct CostUsageDailyReportMergeTests { modelName: "gpt-5.4", costUSD: 2.0, totalTokens: 197, + inputTokens: 150, + cacheReadTokens: 15, + outputTokens: 30, standardCostUSD: 1.0, priorityCostUSD: 1.0, standardTokens: 100, @@ -163,4 +173,59 @@ struct CostUsageDailyReportMergeTests { #expect(merged.summary?.totalTokens == 120) #expect(abs((merged.data.first?.costUSD ?? 0) - 1.25) < 0.000001) } + + @Test + func `merged report sums reasoning tokens and drops the bucket when any source misses it`() { + func report(day: String, reasoning: Int?) -> CostUsageDailyReport { + CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: day, + inputTokens: 100, + outputTokens: 30, + totalTokens: 130, + costUSD: 1.0, + modelsUsed: ["gpt-5.4"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.4", + costUSD: 1.0, + totalTokens: 130, + inputTokens: 100, + outputTokens: 30, + reasoningTokens: reasoning), + ]), + ], + summary: nil) + } + + let both = report(day: "2026-04-04", reasoning: 12) + .merged(with: report(day: "2026-04-04", reasoning: 8)) + #expect(both.data.first?.modelBreakdowns?.first?.outputTokens == 60) + #expect(both.data.first?.modelBreakdowns?.first?.reasoningTokens == 20) + + // Reasoning is a sub-bucket of output: merging must never change the output total. + let missing = report(day: "2026-04-04", reasoning: 12) + .merged(with: report(day: "2026-04-04", reasoning: nil)) + #expect(missing.data.first?.modelBreakdowns?.first?.outputTokens == 60) + #expect(missing.data.first?.modelBreakdowns?.first?.reasoningTokens == nil) + } + + @Test + func `model breakdown decodes reasoning tokens from camel and snake case keys`() throws { + let camel = try JSONDecoder().decode( + CostUsageDailyReport.ModelBreakdown.self, + from: Data(#"{"modelName":"gpt-5.4","reasoningTokens":7}"#.utf8)) + #expect(camel.reasoningTokens == 7) + + let snake = try JSONDecoder().decode( + CostUsageDailyReport.ModelBreakdown.self, + from: Data(#"{"modelName":"gpt-5.4","reasoning_output_tokens":9}"#.utf8)) + #expect(snake.reasoningTokens == 9) + + let absent = try JSONDecoder().decode( + CostUsageDailyReport.ModelBreakdown.self, + from: Data(#"{"modelName":"gpt-5.4"}"#.utf8)) + #expect(absent.reasoningTokens == nil) + } } diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 5db8108693..c8a878f073 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -705,7 +705,11 @@ extension CostUsageFetcherTests { CostUsageDailyReport.ModelBreakdown( modelName: "claude-sonnet-4-6", costUSD: nativeCost + piCost, - totalTokens: 205), + totalTokens: 205, + inputTokens: 150, + cacheReadTokens: 9, + cacheCreationTokens: 16, + outputTokens: 30), ]) } diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift index c202d20157..c7d6e02e35 100644 --- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -29,20 +29,57 @@ struct CostUsagePerformanceGateTests { until: day, now: day, options: options) + CostUsageScanner._test_resetCodexParsedFilePaths() + let warm = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + // Other suites may parse their own fixtures concurrently, so assert on this corpus. + let corpusPaths = fileURLs.map(\.standardizedFileURL.path) + #expect((CostUsageScanner._test_codexParsedFilePaths ?? []).isDisjoint(with: corpusPaths)) + #expect(cold.data.count == 1) + #expect(warm.data.first?.totalTokens == cold.data.first?.totalTokens) + } + + @Test + func `in-place same-size session rewrite invalidates the sampling fingerprint cache`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let fileURLs = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 2, turnsPerFile: 4) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let cold = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + // Rewrite one file in place: identical byte length, original mtime restored. Only the + // sampling fingerprint can see this edit, and it must invalidate exactly that file. let changedFile = try #require(fileURLs.first) - let originalAttributes = try FileManager.default.attributesOfItem(atPath: changedFile.path) - let originalModificationDate = try #require(originalAttributes[.modificationDate] as? Date) + let originalModificationDateNs = Self.statModificationUnixNs(of: changedFile) let original = try String(contentsOf: changedFile, encoding: .utf8) let modified = original.replacingOccurrences( - of: #""input_tokens":100,"#, + of: #""input_tokens":400,"#, with: #""input_tokens":900,"#) #expect(modified != original) #expect(modified.utf8.count == original.utf8.count) try modified.write(to: changedFile, atomically: false, encoding: .utf8) - try FileManager.default.setAttributes( - [.modificationDate: originalModificationDate], - ofItemAtPath: changedFile.path) + try Self.setModificationUnixNs(originalModificationDateNs, of: changedFile) + #expect(Self.statModificationUnixNs(of: changedFile) == originalModificationDateNs) + CostUsageScanner._test_resetCodexParsedFilePaths() let warm = CostUsageScanner.loadDailyReport( provider: .codex, @@ -51,8 +88,14 @@ struct CostUsagePerformanceGateTests { now: day, options: options) - #expect(cold.data.count == 1) - #expect(warm.data.first?.totalTokens == cold.data.first?.totalTokens) + // Only the rewritten file is re-parsed; the edited final turn (input 400 -> 900) lifts + // that file's cumulative total from 440 to 940 tokens. Other suites may parse their own + // fixtures concurrently, so assert on this corpus only. + let parsedCorpusPaths = (CostUsageScanner._test_codexParsedFilePaths ?? []) + .intersection(fileURLs.map(\.standardizedFileURL.path)) + #expect(parsedCorpusPaths == [changedFile.standardizedFileURL.path]) + #expect(cold.data.first?.totalTokens == 880) + #expect(warm.data.first?.totalTokens == 1380) } @Test @@ -693,6 +736,30 @@ struct CostUsagePerformanceGateTests { return fileURLs } + /// stat-level mtime in nanoseconds. `FileManager.attributesOfItem` round-trips through + /// `Date` (Double seconds, µs granularity beyond ~±128 years), silently truncating the + /// nanosecond tail — which would make an in-place rewrite fixture look mtime-changed even + /// after a "restore". These helpers keep the whole round-trip in integer nanoseconds. + private static func statModificationUnixNs(of fileURL: URL) -> Int64 { + var info = stat() + guard fileURL.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { return 0 } + #if os(Linux) + return Int64(info.st_mtim.tv_sec) * 1_000_000_000 + Int64(info.st_mtim.tv_nsec) + #else + return Int64(info.st_mtimespec.tv_sec) * 1_000_000_000 + Int64(info.st_mtimespec.tv_nsec) + #endif + } + + private static func setModificationUnixNs(_ ns: Int64, of fileURL: URL) throws { + var times = [ + timespec(tv_sec: 0, tv_nsec: Int(UTIME_OMIT)), + timespec(tv_sec: Int(ns / 1_000_000_000), tv_nsec: Int(ns % 1_000_000_000)), + ] + guard fileURL.path.withCString({ utimensat(AT_FDCWD, $0, ×, 0) }) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + } + private static func replaceTraceBody(dbURL: URL, rowID: Int64, body: String) throws { var db: OpaquePointer? guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 7c5340418c..87ea4f5f5d 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -3,6 +3,53 @@ import Testing @testable import CodexBarCore struct CostUsagePricingTests { + @Test + func `generic catalog pricing stays scoped to explicit provider ownership`() throws { + let catalog = try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(""" + { + "alibaba": { + "id": "alibaba", + "models": { + "qwen3-coder-plus": { + "id": "qwen3-coder-plus", + "cost": { "input": 1, "output": 4, "cache_read": 0.2 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "qwen3-coder-plus": { + "id": "qwen3-coder-plus", + "cost": { "input": 99, "output": 199 } + } + } + } + } + """.utf8)) + + let cost = CostUsagePricing.modelsDevCostUSD( + request: .init( + providerIDs: ["alibaba", "alibaba-cn"], + model: "qwen3-coder-plus", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000), + catalog: catalog, + cacheRoot: nil) + + #expect(cost == 5.2) + #expect(CostUsagePricing.modelsDevCostUSD( + request: .init( + providerIDs: ["missing-provider"], + model: "qwen3-coder-plus", + inputTokens: 1_000_000, + cacheReadInputTokens: 0, + outputTokens: 0), + catalog: catalog, + cacheRoot: nil) == nil) + } + @Test func `normalizes codex model variants exactly`() { #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5-codex") == "gpt-5-codex") @@ -24,6 +71,60 @@ struct CostUsagePricingTests { #expect(CostUsagePricing.normalizeCodexModel("openai/gpt-5.6-terra-2099-01-01") == "gpt-5.6-terra") } + @Test + func `google pricing normalizes antigravity aliases and stays available offline`() throws { + let emptyCacheRoot = try Self.cacheRoot() + + let flash36 = CostUsagePricing.googleCostUSD( + model: "gemini-3.6-flash", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + let flash35Alias = CostUsagePricing.googleCostUSD( + model: "gemini-3-flash-agent", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + let flash3Alias = CostUsagePricing.googleCostUSD( + model: "gemini-default", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + + #expect(flash36 == 1.50 + 0.15 + 7.50) + #expect(flash35Alias == 1.50 + 0.15 + 9.00) + #expect(flash3Alias == 0.50 + 0.05 + 3.00) + for alias in [ + "gemini-3.5-flash-medium", + "gemini-3.5-flash-low", + "gemini-3.5-flash-extra-low", + ] { + let value = CostUsagePricing.googleCostUSD( + model: alias, + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + modelsDevCacheRoot: emptyCacheRoot) + #expect(value == flash35Alias) + } + } + + @Test + func `google pricing applies gemini31 pro long context rates`() throws { + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.googleCostUSD( + model: "gemini-3.1-pro-high", + inputTokens: 200_001, + cacheReadInputTokens: 10, + outputTokens: 20, + modelsDevCacheRoot: emptyCacheRoot) + + #expect(cost == (200_001.0 * 4e-6) + (10.0 * 0.4e-6) + (20.0 * 18e-6)) + } + @Test func `unattributed codex usage stays unpriced despite a catalog collision`() throws { let root = try Self.seedModelsDevCache(""" diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 547cf7fc0e..d9d042f12f 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -204,7 +204,10 @@ struct CostUsageScannerBreakdownTests { CostUsageDailyReport.ModelBreakdown( modelName: "gpt-5.2-codex", costUSD: first.data[0].costUSD, - totalTokens: 110), + totalTokens: 110, + inputTokens: 100, + cacheReadTokens: 20, + outputTokens: 10), ]) #expect(first.data[0].totalTokens == 110) #expect((first.data[0].costUSD ?? 0) > 0) @@ -241,6 +244,101 @@ struct CostUsageScannerBreakdownTests { #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } + @Test + func `codex daily report surfaces reasoning output tokens without changing billing output`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 11) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let model = "openai/gpt-5.2-codex" + + func tokenCount( + timestamp: String, + total: (input: Int, cached: Int, output: Int, reasoning: Int), + last: (input: Int, cached: Int, output: Int, reasoning: Int)) -> [String: Any] + { + func usage(_ value: (input: Int, cached: Int, output: Int, reasoning: Int)) -> [String: Any] { + [ + "input_tokens": value.input, + "cached_input_tokens": value.cached, + "output_tokens": value.output, + "reasoning_output_tokens": value.reasoning, + ] + } + return [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "model": model, + "total_token_usage": usage(total), + "last_token_usage": usage(last), + ], + ], + ] + } + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: iso0, model: model), + tokenCount( + timestamp: iso1, + total: (input: 100, cached: 20, output: 10, reasoning: 6), + last: (input: 100, cached: 20, output: 10, reasoning: 6)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let firstBreakdown = try #require(first.data.first?.modelBreakdowns?.first) + // Reasoning is a sub-bucket of billing output: output and totals stay as before. + #expect(firstBreakdown.outputTokens == 10) + #expect(firstBreakdown.reasoningTokens == 6) + #expect(firstBreakdown.totalTokens == 110) + #expect(first.data.first?.totalTokens == 110) + + // The incremental rescan rides the cached row path; reasoning must survive the roundtrip. + try env.jsonl([ + self.codexTurnContext(timestamp: iso0, model: model), + tokenCount( + timestamp: iso1, + total: (input: 100, cached: 20, output: 10, reasoning: 6), + last: (input: 100, cached: 20, output: 10, reasoning: 6)), + tokenCount( + timestamp: iso2, + total: (input: 160, cached: 40, output: 16, reasoning: 9), + last: (input: 60, cached: 20, output: 6, reasoning: 3)), + ]) + .write(to: fileURL, atomically: true, encoding: .utf8) + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let secondBreakdown = try #require(second.data.first?.modelBreakdowns?.first) + #expect(secondBreakdown.outputTokens == 16) + #expect(secondBreakdown.reasoningTokens == 9) + #expect(secondBreakdown.totalTokens == 176) + } + @Test func `codex project breakdowns group by cwd and preserve daily totals`() throws { let env = try CostUsageTestEnvironment() @@ -6540,7 +6638,11 @@ struct CostUsageScannerBreakdownTests { CostUsageDailyReport.ModelBreakdown( modelName: "claude-sonnet-4-20250514", costUSD: report.data[0].costUSD, - totalTokens: 355), + totalTokens: 355, + inputTokens: 200, + cacheReadTokens: 25, + cacheCreationTokens: 50, + outputTokens: 80), ]) #expect((report.data[0].costUSD ?? 0) > 0) } diff --git a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift index 2db80641cd..8e7db813c3 100644 --- a/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerPriorityTests.swift @@ -406,7 +406,9 @@ struct CostUsageScannerPriorityTests { until: day, now: day, options: options) - #expect(first.summary?.totalCostUSD == nil) + // The auto-review harness is intentionally zero-priced until the completed response + // identifies the real model. Preserve that known zero instead of reporting unavailable. + #expect(first.summary?.totalCostUSD == 0) try CostUsageScannerCodexPriorityTests.insertTestLog( dbURL: dbURL, diff --git a/Tests/CodexBarTests/GeminiSessionScannerTests.swift b/Tests/CodexBarTests/GeminiSessionScannerTests.swift new file mode 100644 index 0000000000..6a760e56be --- /dev/null +++ b/Tests/CodexBarTests/GeminiSessionScannerTests.swift @@ -0,0 +1,330 @@ +import CodexBarCore +import Foundation +import Testing + +struct GeminiSessionScannerTests { + @Test + func `scanner aggregates chat recordings across projects layouts and models`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chatsA = try Self.makeChatsDir(root, "hash-a") + let chatsB = try Self.makeChatsDir(root, "hash-b") + + try Self.write(Self.chatRecording(messages: [ + #"{"id":"m1","timestamp":"2026-07-10T09:00:00Z","type":"user"}"#, + Self.modelTurn( + id: "m2", + timestamp: "2026-07-10T09:01:00Z", + tokens: #"{"input":100,"output":20,"cached":10,"thoughts":5,"tool":3,"total":138}"#), + Self.modelTurn( + id: "m3", + timestamp: "2026-07-10T09:02:00Z", + tokens: #"{"input":15,"output":20,"cached":5,"thoughts":2,"tool":0,"total":37}"#), + ]), to: chatsA.appendingPathComponent("session-one.json")) + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-11T10:00:00Z", + model: "gemini-2.0-flash", + tokens: #"{"input":8,"output":9,"total":17}"#), + ]), to: chatsB.appendingPathComponent("session-two.json")) + // Legacy layout: `session-` prefix accepted anywhere under `tmp`. + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-11T11:00:00Z", + model: "gemini-2.0-flash", + tokens: #"{"input":1,"output":2,"total":3}"#), + ]), to: root.appendingPathComponent("tmp/session-legacy.json")) + // Decoys: wrong extension, and a `.json` outside the expected layouts. + try Self.write("not json", to: chatsB.appendingPathComponent("notes.txt")) + try Self.write( + #"{"messages":[{"id":"x","model":"gemini-2.0-flash","tokens":{"input":99,"output":99}}]}"#, + to: root.appendingPathComponent("tmp/hash-b/other.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.historyLabel == "Gemini CLI") + #expect(snapshot.historyDays == 30) + #expect(snapshot.last30DaysTokens == 195) + #expect(snapshot.last30DaysRequests == 4) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.sessionTokens == nil) + #expect(snapshot.daily.map(\.date) == ["2026-07-10", "2026-07-11"]) + + let first = snapshot.daily[0] + #expect(first.inputTokens == 113) + #expect(first.outputTokens == 47) + #expect(first.cacheReadTokens == 15) + #expect(first.cacheCreationTokens == nil) + #expect(first.totalTokens == 175) + #expect(first.requestCount == 2) + #expect(first.costUSD == nil) + #expect(first.modelsUsed == ["gemini-2.5-pro"]) + let firstBreakdown = try #require(first.modelBreakdowns?.first) + #expect(firstBreakdown.modelName == "gemini-2.5-pro") + #expect(firstBreakdown.inputTokens == 113) + #expect(firstBreakdown.outputTokens == 47) + #expect(firstBreakdown.cacheReadTokens == 15) + #expect(firstBreakdown.cacheCreationTokens == nil) + // `thoughts` stays folded into billing output and is also surfaced as the reasoning bucket. + #expect(firstBreakdown.reasoningTokens == 7) + #expect(firstBreakdown.totalTokens == 175) + #expect(firstBreakdown.requestCount == 2) + #expect(firstBreakdown.costUSD == nil) + + let second = snapshot.daily[1] + #expect(second.inputTokens == 9) + #expect(second.outputTokens == 11) + #expect(second.cacheReadTokens == 0) + #expect(second.totalTokens == 20) + #expect(second.requestCount == 2) + #expect(second.modelsUsed == ["gemini-2.0-flash"]) + #expect(second.modelBreakdowns?.first?.reasoningTokens == nil) + } + + @Test + func `scanner buckets usage by local day across midnight`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + try Self.write(Self.chatRecording(messages: [ + // 23:30 at GMT+8 on 2026-07-10. + Self.modelTurn(id: "m1", timestamp: "2026-07-10T15:30:00Z", tokens: #"{"input":1,"output":2}"#), + // 00:30 at GMT+8 on 2026-07-11. + Self.modelTurn(id: "m2", timestamp: "2026-07-10T16:30:00Z", tokens: #"{"input":3,"output":4}"#), + ]), to: chats.appendingPathComponent("session-one.json")) + + let snapshot = try #require(Self.scan( + root: root, + now: Self.date("2026-07-12T12:00:00Z"), + calendar: Self.gmtPlus8Calendar)) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10", "2026-07-11"]) + #expect(snapshot.daily.map(\.requestCount) == [1, 1]) + #expect(snapshot.daily.map(\.totalTokens) == [3, 7]) + } + + @Test + func `scanner prefilters by file mtime and falls back to mtime for missing timestamps`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + + // mtime older than the window: skipped without parsing, even for in-window timestamps. + let oldFile = chats.appendingPathComponent("session-old.json") + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-12T09:00:00Z", + tokens: #"{"input":100,"output":100}"#), + ]), to: oldFile) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-06-02T12:00:00Z")], + ofItemAtPath: oldFile.path) + // Fresh mtime but a record older than the window: dropped by the day filter. + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-06-02T09:00:00Z", + tokens: #"{"input":200,"output":200}"#), + ]), to: chats.appendingPathComponent("session-stale.json")) + // No timestamp at all: bucketed by the file mtime, pinned inside the window here. + let fallbackFile = chats.appendingPathComponent("session-fallback.json") + try Self.write(Self.chatRecording(messages: [ + #"{"id":"m1","type":"gemini","model":"gemini-2.5-pro","tokens":{"input":5,"output":6}}"#, + ]), to: fallbackFile) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-07-12T09:00:00Z")], + ofItemAtPath: fallbackFile.path) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-12"]) + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 11) + #expect(snapshot.daily.first?.inputTokens == 5) + #expect(snapshot.daily.first?.outputTokens == 6) + } + + @Test + func `scanner skips corrupt files records and stream lines`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + + try Self.write("this is not json", to: chats.appendingPathComponent("session-garbage.json")) + try Self.write(#"{"foo":1}"#, to: chats.appendingPathComponent("session-shape.json")) + try Self.write(Self.chatRecording(messages: [ + #"{"id":"m1","timestamp":"2026-07-10T09:00:00Z","type":"gemini","tokens":{"input":1,"output":1}}"#, + #"{"id":"m2","timestamp":"2026-07-10T09:01:00Z","type":"gemini","model":"gemini-2.5-pro"}"#, + ]), to: chats.appendingPathComponent("session-incomplete.json")) + try Self.write("", to: chats.appendingPathComponent("session-empty.jsonl")) + try Self.write([ + "not json at all", + #"{"type":"init","model":"gemini-2.5-pro","session_id":"s1"}"#, + #"{"type":"gemini","id":"x1","timestamp":"2026-07-10T09:00:00.000Z","tokens":{"input":10,"output":5}}"#, + ].joined(separator: "\n"), to: chats.appendingPathComponent("session-mixed.jsonl")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + #expect(snapshot.daily.map(\.date) == ["2026-07-10"]) + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 15) + #expect(snapshot.daily.first?.modelsUsed == ["gemini-2.5-pro"]) + } + + @Test + func `scanner skips negative token values and overflowing accumulations`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn( + id: "m1", + timestamp: "2026-07-10T09:00:00Z", + tokens: #"{"input":-1,"output":2,"total":1}"#), + Self.modelTurn( + id: "m2", + timestamp: "2026-07-10T09:01:00Z", + tokens: #"{"input":1,"output":2,"total":-3}"#), + Self.modelTurn( + id: "m3", + timestamp: "2026-07-10T09:02:00Z", + tokens: #"{"input":9223372036854775807,"output":0}"#), + Self.modelTurn( + id: "m4", + timestamp: "2026-07-10T09:03:00Z", + tokens: #"{"input":10,"output":0}"#), + ]), to: chats.appendingPathComponent("session-one.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + // Only the Int.max record survives; the follow-up record overflows the accumulator and + // is dropped without poisoning the bucket. + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == Int.max) + #expect(snapshot.daily.first?.inputTokens == Int.max) + } + + @Test + func `scanner parses chat streams with model hints and replaces duplicate message ids`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let chats = try Self.makeChatsDir(root, "hash-a") + try Self.write([ + // swiftlint:disable:next line_length + #"{"sessionId":"s1","projectHash":"hash-a","startTime":"2026-07-10T00:00:00.000Z","lastUpdated":"2026-07-10T00:01:00.000Z"}"#, + #"{"type":"init","model":"gemini-2.5-pro","session_id":"s1"}"#, + // swiftlint:disable:next line_length + #"{"id":"m1","timestamp":"2026-07-10T09:00:00.000Z","type":"gemini","tokens":{"input":10,"output":1,"total":11}}"#, + // swiftlint:disable:next line_length + #"{"id":"m1","timestamp":"2026-07-10T09:01:00.000Z","type":"gemini","tokens":{"input":20,"output":2,"cached":5,"thoughts":3,"total":25}}"#, + #"{"timestamp":"2026-07-10T09:02:00.000Z","type":"gemini","tokens":{"input":7,"output":8}}"#, + #"{"timestamp":"2026-07-10T09:03:00.000Z","type":"user"}"#, + ].joined(separator: "\n"), to: chats.appendingPathComponent("session-stream.jsonl")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + // The second "m1" line replaces the first (cache-inclusive input 20 normalizes to 15, + // thoughts fold into output), and the id-less line inherits the `init` model. + #expect(snapshot.daily.map(\.date) == ["2026-07-10"]) + #expect(snapshot.last30DaysRequests == 2) + #expect(snapshot.last30DaysTokens == 40) + let entry = snapshot.daily[0] + #expect(entry.inputTokens == 22) + #expect(entry.outputTokens == 13) + #expect(entry.cacheReadTokens == 5) + #expect(entry.modelsUsed == ["gemini-2.5-pro"]) + #expect(entry.modelBreakdowns?.first?.reasoningTokens == 3) + } + + @Test + func `scanner honors GEMINI_CLI_HOME override and returns nil for empty directories`() throws { + let emptyRoot = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: emptyRoot) } + + #expect(Self.scan(root: emptyRoot, now: Self.date("2026-07-12T12:00:00Z")) == nil) + + let tmp = emptyRoot.appendingPathComponent("tmp", isDirectory: true) + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + #expect(Self.scan(root: emptyRoot, now: Self.date("2026-07-12T12:00:00Z")) == nil) + + let fixtureRoot = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: fixtureRoot) } + let chats = try Self.makeChatsDir(fixtureRoot, "hash-a") + try Self.write(Self.chatRecording(messages: [ + Self.modelTurn(id: "m1", timestamp: "2026-07-10T09:00:00Z", tokens: #"{"input":1,"output":2}"#), + ]), to: chats.appendingPathComponent("session-one.json")) + + let snapshot = try #require(Self.scan(root: fixtureRoot, now: Self.date("2026-07-12T12:00:00Z"))) + #expect(snapshot.last30DaysTokens == 3) + #expect(snapshot.historyLabel == "Gemini CLI") + } + + // MARK: - Fixtures + + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static let gmtPlus8Calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 8 * 3600)! + return calendar + }() + + private static func scan( + root: URL, + now: Date, + calendar: Calendar = Self.utcCalendar) -> CostUsageTokenSnapshot? + { + GeminiSessionScanner.scan( + environment: [GeminiSessionScanner.cliHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: calendar) + } + + private static func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func makeChatsDir(_ root: URL, _ projectHash: String) throws -> URL { + let dir = root.appendingPathComponent("tmp/\(projectHash)/chats", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private static func chatRecording(messages: [String]) -> String { + """ + {"sessionId":"ses","projectHash":"hash","startTime":"2026-07-10T09:00:00Z",\ + "lastUpdated":"2026-07-10T09:05:00Z","messages":[\(messages.joined(separator: ","))]} + """ + } + + private static func modelTurn( + id: String, + timestamp: String, + model: String = "gemini-2.5-pro", + tokens: String) -> String + { + #"{"id":"\#(id)","timestamp":"\#(timestamp)","type":"gemini","model":"\#(model)","tokens":\#(tokens)}"# + } + + private static func date(_ iso: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: iso) ?? Date(timeIntervalSince1970: 0) + } + + private static func write(_ content: String, to url: URL) throws { + try (content + "\n").write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift new file mode 100644 index 0000000000..190205a7a2 --- /dev/null +++ b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift @@ -0,0 +1,161 @@ +import CodexBarCore +import Foundation +import Testing + +struct KimiCodeSessionScannerTests { + @Test + func `scanner aggregates turn usage across main and subagents without reading other events`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let main = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + let child = root + .appendingPathComponent("sessions/workspace/session-a/agents/agent-0", isDirectory: true) + try FileManager.default.createDirectory(at: main, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: child, withIntermediateDirectories: true) + + let now = Date(timeIntervalSince1970: 1_784_347_200) + try Self.write([ + Self.usage(time: 1_784_257_200_000, model: "kimi-code/k3", input: 10, cacheRead: 20, output: 3), + #"{"type":"assistant.message","time":1784257200000,"content":"must not be parsed"}"#, + Self.usage( + time: 1_784_257_300_000, + model: "kimi-code/k3", + input: 4, + cacheRead: 5, + cacheCreation: 6, + output: 7), + Self.usage( + time: 1_784_257_400_000, + model: "kimi-code/k3", + scope: "session", + input: 999, + cacheRead: 999, + output: 999), + ], to: main.appendingPathComponent("wire.jsonl")) + try Self.write([ + Self.usage( + time: 1_784_343_600_000, + model: "kimi-code/kimi-for-coding", + input: 8, + cacheRead: 9, + output: 10), + ], to: child.appendingPathComponent("wire.jsonl")) + + let snapshot = try #require(KimiCodeSessionScanner.scan( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: Self.calendar)) + + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.last30DaysTokens == 82) + #expect(snapshot.last30DaysRequests == 3) + #expect(snapshot.last30DaysCostUSD != nil) + #expect(snapshot.costSource == .estimated) + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.modelName) == [ + "kimi-code/k3", + "kimi-code/kimi-for-coding", + ]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.totalTokens) == [55, 27]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.inputTokens) == [14, 8]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheReadTokens) == [25, 9]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheCreationTokens) == [6, 0]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.outputTokens) == [10, 10]) + let breakdowns = snapshot.daily.flatMap { $0.modelBreakdowns ?? [] } + #expect(abs((breakdowns.first?.costUSD ?? 0) - 0.0002175) < 0.000000001) + // Kimi wire.jsonl carries no reasoning field, so the reasoning bucket stays nil. + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.reasoningTokens) == [nil, nil]) + } + + @Test + func `scanner ignores malformed negative and out of range records`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let agent = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + try FileManager.default.createDirectory(at: agent, withIntermediateDirectories: true) + try Self.write([ + Self.usage( + time: 1_784_257_200_000, + model: "kimi-code/k3", + input: -1, + cacheRead: 2, + output: 3), + Self.usage( + time: 1_770_000_000_000, + model: "kimi-code/k3", + input: 10, + cacheRead: 20, + output: 30), + #"{"type":"usage.record","time":"bad","model":"kimi-code/k3","usageScope":"turn","usage":{}}"#, + ], to: agent.appendingPathComponent("wire.jsonl")) + + #expect(KimiCodeSessionScanner.scan( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_784_347_200), + calendar: Self.calendar) == nil) + } + + @Test + func `streaming scanner cancels without loading the remaining transcript`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let agent = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + try FileManager.default.createDirectory(at: agent, withIntermediateDirectories: true) + let line = Self.usage( + time: 1_784_257_200_000, + model: "kimi-code/k3", + input: 10, + cacheRead: 20, + output: 3) + try Self.write(Array(repeating: line, count: 20000), to: agent.appendingPathComponent("wire.jsonl")) + + var checks = 0 + #expect(throws: CancellationError.self) { + _ = try KimiCodeSessionScanner.scanCancellable( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_784_347_200), + calendar: Self.calendar, + checkCancellation: { + checks += 1 + if checks >= 4 { + throw CancellationError() + } + }) + } + #expect(checks >= 4) + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static func usage( + time: Int64, + model: String, + scope: String = "turn", + input: Int, + cacheRead: Int, + cacheCreation: Int = 0, + output: Int) -> String + { + """ + {"type":"usage.record","time":\(time),"model":"\(model)","usageScope":"\(scope)","usage":{"inputOther":\( + input),"inputCacheRead":\(cacheRead),"inputCacheCreation":\(cacheCreation),"output":\(output)}} + """ + } + + private static func write(_ lines: [String], to url: URL) throws { + try (lines.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift b/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift new file mode 100644 index 0000000000..ff551d7244 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxSessionScannerTests.swift @@ -0,0 +1,185 @@ +import CodexBarCore +import Foundation +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif +import Testing + +#if canImport(SQLite3) || canImport(CSQLite3) +struct MiniMaxSessionScannerTests { + @Test + func `scanner observes committed rows still present in the live WAL`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("MiniMaxSessionScannerWALTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let databaseURL = root.appendingPathComponent("v2/sqlite/runtime-state.sqlite") + try FileManager.default.createDirectory( + at: databaseURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + + var writer: OpaquePointer? + guard sqlite3_open(databaseURL.path, &writer) == SQLITE_OK, let writer else { + throw SQLiteFixtureError.open + } + defer { sqlite3_close(writer) } + guard sqlite3_exec(writer, "PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0;", nil, nil, nil) + == SQLITE_OK + else { + throw SQLiteFixtureError.schema + } + try Self.createSchema(in: writer) + guard sqlite3_wal_checkpoint_v2(writer, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil) == SQLITE_OK else { + throw SQLiteFixtureError.schema + } + try Self.insertCurrentRow(in: writer) + + let snapshot = try #require(MiniMaxSessionScanner.scan( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_033_600), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("empty-pricing", isDirectory: true))) + + #expect(snapshot.last30DaysRequests == 1) + #expect(snapshot.last30DaysTokens == 350) + } + + @Test + func `scanner prices MiniMax M3 even when the models catalog is empty`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("MiniMaxSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("v2/sqlite/runtime-state.sqlite") + try FileManager.default.createDirectory( + at: database.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Self.createDatabase(at: database) + + let now = Date(timeIntervalSince1970: 1_785_033_600) // 2026-07-26 UTC + let cacheRoot = root.appendingPathComponent("empty-model-pricing-cache", isDirectory: true) + let snapshot = try #require(MiniMaxSessionScanner.scan( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: Self.calendar, + modelsDevCacheRoot: cacheRoot)) + + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.costSource == .estimated) + #expect(snapshot.last30DaysTokens == 350) + #expect(snapshot.last30DaysRequests == 1) + #expect(abs((snapshot.last30DaysCostUSD ?? 0) - 0.000_102) < 0.000_000_001) + let breakdown = try #require(snapshot.daily.first?.modelBreakdowns?.first) + #expect(breakdown.modelName == "minimax/MiniMax-M3") + #expect(breakdown.reasoningTokens == 20) + #expect(abs((breakdown.costUSD ?? 0) - 0.000_102) < 0.000_000_001) + } + + @Test + func `scanner filters old database rows before decoding`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("MiniMaxSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("v2/sqlite/runtime-state.sqlite") + try FileManager.default.createDirectory( + at: database.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Self.createDatabase(at: database, oldRowCount: 200) + + var checks = 0 + let snapshot = try MiniMaxSessionScanner.scanCancellable( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_033_600), + calendar: Self.calendar, + checkCancellation: { + checks += 1 + if checks > 10 { + throw CancellationError() + } + }) + + #expect(snapshot?.last30DaysRequests == 1) + #expect(checks <= 10) + } + + private static func createDatabase(at url: URL, oldRowCount: Int = 0) throws { + var database: OpaquePointer? + guard sqlite3_open(url.path, &database) == SQLITE_OK, let database else { + throw SQLiteFixtureError.open + } + defer { sqlite3_close(database) } + try Self.createSchema(in: database) + try Self.insertCurrentRow(in: database) + guard oldRowCount > 0 else { return } + for index in 0.. CostUsageTokenSnapshot? + { + OpenCodeSessionScanner.scan( + environment: [OpenCodeSessionScanner.dataHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: calendar) + } + + private static func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func makeSessionDir(_ root: URL, _ sessionID: String) throws -> URL { + let dir = root.appendingPathComponent("opencode/storage/message/\(sessionID)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private static func assistantMessage( + id: String?, + modelID: String?, + nestedModelID: String? = nil, + created: String, + tokens: String, + role: String?? = "assistant", + completed: Bool = false) -> String + { + var fields: [String] = [] + if let id { fields.append(#""id":"\#(id)""#) } + fields.append(#""sessionID":"ses""#) + if let role = role.flatMap(\.self) { fields.append(#""role":"\#(role)""#) } + if let modelID { fields.append(#""modelID":"\#(modelID)""#) } + if let nestedModelID { fields.append(#""model":{"id":"\#(nestedModelID)","providerID":"test"}"#) } + fields.append(#""tokens":"# + tokens) + var time = #""created":"# + "\(Self.ms(created))" + if completed { time += #","completed":"# + "\(Self.ms(created) + 1000)" } + fields.append(#""time":{"# + time + "}") + return "{" + fields.joined(separator: ",") + "}" + } + + private static func ms(_ iso: String) -> Int { + Int(self.date(iso).timeIntervalSince1970 * 1000) + } + + private static func date(_ iso: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: iso) ?? Date(timeIntervalSince1970: 0) + } + + private static func write(_ content: String, to url: URL) throws { + try (content + "\n").write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/QwenCodeSessionScannerTests.swift b/Tests/CodexBarTests/QwenCodeSessionScannerTests.swift new file mode 100644 index 0000000000..0f9b7ab85e --- /dev/null +++ b/Tests/CodexBarTests/QwenCodeSessionScannerTests.swift @@ -0,0 +1,86 @@ +import CodexBarCore +import Foundation +import Testing + +struct QwenCodeSessionScannerTests { + @Test + func `scanner reads Qwen Code assistant usage and preserves billing owner`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("QwenCodeSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let chats = root + .appendingPathComponent("projects/project-a/chats", isDirectory: true) + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + // promptTokenCount includes the cached prefix, so prompt must be >= cachedContentTokenCount. + let assistantOne = #"{"type":"assistant","model":"qwen3-coder-plus","# + + #""timestamp":"2026-07-28T08:01:00Z","usageMetadata":{"promptTokenCount":300,"# + + #""candidatesTokenCount":30,"thoughtsTokenCount":20,"cachedContentTokenCount":200}}"# + let assistantTwo = #"{"type":"assistant","model":"qwen3-coder-plus","# + + #""timestamp":"2026-07-28T08:02:00Z","usageMetadata":{"promptTokenCount":30,"# + + #""candidatesTokenCount":5,"thoughtsTokenCount":2,"cachedContentTokenCount":20}}"# + let jsonl = [ + #"{"type":"user","timestamp":"2026-07-28T08:00:00Z"}"#, + assistantOne, + assistantTwo, + ].joined(separator: "\n") + try Data(jsonl.utf8).write(to: chats.appendingPathComponent("session.jsonl")) + + let snapshot = try #require(QwenCodeSessionScanner.scan( + environment: [QwenCodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true))) + + #expect(snapshot.historyLabel == "Qwen Code CLI") + #expect(snapshot.last30DaysRequests == 2) + #expect(snapshot.last30DaysTokens == 387) + let entry = try #require(snapshot.daily.first) + #expect(entry.inputTokens == 110) + #expect(entry.outputTokens == 57) + #expect(entry.cacheReadTokens == 220) + #expect(entry.totalTokens == 387) + let model = try #require(entry.modelBreakdowns?.first) + #expect(model.modelName == "qwen3-coder-plus") + #expect(model.billingProviderID == UsageProvider.qwencloud.rawValue) + #expect(model.reasoningTokens == 22) + } + + @Test + func `scanner ignores other layouts and cooperatively cancels`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("QwenCodeSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let chats = root.appendingPathComponent("projects/project-a/chats", isDirectory: true) + try FileManager.default.createDirectory(at: chats, withIntermediateDirectories: true) + let realEntry = #"{"type":"assistant","model":"qwen3-coder","# + + #""timestamp":"2026-07-28T08:01:00Z","usageMetadata":{"promptTokenCount":1,"# + + #""candidatesTokenCount":1}}"# + try Data(realEntry.utf8).write(to: chats.appendingPathComponent("session.jsonl")) + let decoy = root.appendingPathComponent("projects/project-a/other", isDirectory: true) + try FileManager.default.createDirectory(at: decoy, withIntermediateDirectories: true) + let decoyEntry = #"{"type":"assistant","model":"decoy","# + + #""timestamp":"2026-07-28T08:01:00Z","usageMetadata":{"promptTokenCount":999,"# + + #""candidatesTokenCount":999}}"# + try Data(decoyEntry.utf8).write(to: decoy.appendingPathComponent("session.jsonl")) + + var checks = 0 + #expect(throws: CancellationError.self) { + _ = try QwenCodeSessionScanner.scanCancellable( + environment: [QwenCodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + checkCancellation: { + checks += 1 + if checks >= 2 { throw CancellationError() } + }) + } + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() +} From c338e8fc7a94666244ee6e03af19be7513e0f9d0 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:37:15 +0800 Subject: [PATCH 02/22] Keep Groq out of the cost-capable set in the foundation layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling `supportsTokenCost` for Groq changes which providers surface in the descriptor-driven generic Cost row — that is dashboard behavior whose expectations live in the App-layer tests (SpendDashboardModelTests, GroqMenuCardModelTests). Those App tests are not part of this data-layer split, so flipping the flag here breaks CI. Defer the flag to the UI follow-up that carries the matching test updates; the `costSource: .providerReported` tagging stays. Co-authored-by: Cursor --- .../CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift index 4b91547b7b..fbc6dace91 100644 --- a/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Groq/GroqProviderDescriptor.swift @@ -34,7 +34,7 @@ public enum GroqProviderDescriptor { ProviderColor(hex: 0x97FCA7), ]), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: true, + supportsTokenCost: false, noDataMessage: { "Sign in at console.groq.com to show Groq spend and token usage." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web, .api], From c565404c833b83ee16b6172f5cdf0b9922ec499a Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:39:30 +0800 Subject: [PATCH 03/22] feat(core): registerable local-history scanner registry + zcode/trae/cursor adapters Generalize the Usage & Spend data layer into a registerable scanner framework modeled on tokscale's define_clients!, so adding support for a mainstream tool means registering one adapter instead of editing central switches. - LocalHistoryScanning: per-tool protocol (source id, display name, home resolution, scan) with a bundled LocalHistoryScanContext. - LocalHistoryScannerRegistry: source-keyed, order-preserving registry with a process-wide `shared` instance pre-populated from LocalHistoryBuiltInScanners. - Wrap the six existing scanners (Kimi/Gemini/OpenCode/MiniMax/Antigravity/ Qwen) as built-in registrations; no App-layer behavior change. - ZcodeSessionScanner: reads ~/.zcode/cli/rollout/model-io-sess_*.jsonl and normalizes ZCode's cache-inclusive input (cross-checked against totalTokens) so cached prefixes are never billed twice; priced at the official Z.ai rate. - Cursor/Trae degraded scanners: read the local state DBs to surface real per-day model activity, with all token/cost fields left nil because neither tool mirrors token usage locally (billing is server-side). Tests cover the registry, zcode cache normalization, and the degraded sources. Co-authored-by: Cursor --- .../Cursor/CursorLocalActivityScanner.swift | 148 +++++++ .../LocalHistoryBuiltInScanners.swift | 221 ++++++++++ .../LocalHistoryScannerRegistry.swift | 55 +++ .../LocalHistory/LocalHistoryScanning.swift | 74 ++++ .../Providers/ProviderDescriptor.swift | 8 + .../Trae/TraeLocalActivityScanner.swift | 194 +++++++++ .../Providers/Zcode/ZcodeSessionScanner.swift | 381 ++++++++++++++++++ .../CursorLocalActivityScannerTests.swift | 98 +++++ .../LocalHistoryScannerRegistryTests.swift | 59 +++ .../TraeLocalActivityScannerTests.swift | 102 +++++ .../ZcodeSessionScannerTests.swift | 121 ++++++ 11 files changed, 1461 insertions(+) create mode 100644 Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift create mode 100644 Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift create mode 100644 Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScannerRegistry.swift create mode 100644 Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScanning.swift create mode 100644 Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift create mode 100644 Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift create mode 100644 Tests/CodexBarTests/CursorLocalActivityScannerTests.swift create mode 100644 Tests/CodexBarTests/LocalHistoryScannerRegistryTests.swift create mode 100644 Tests/CodexBarTests/TraeLocalActivityScannerTests.swift create mode 100644 Tests/CodexBarTests/ZcodeSessionScannerTests.swift diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift b/Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift new file mode 100644 index 0000000000..87c2371f96 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift @@ -0,0 +1,148 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// Reads Cursor's local AI activity database to surface which models were used and on which +// days, without reporting token counts. +// +// Cursor stores code-generation records in `~/.cursor/ai-tracking/ai-code-tracking.db`. The +// `ai_code_hashes` table has one row per recorded AI edit with a `model` column and an +// epoch-millisecond `timestamp`, but — unlike the CLI scanners — it stores no per-request token +// usage: token billing for Cursor happens server-side and is not mirrored locally. We therefore +// treat this as a *degraded* source: the scanner aggregates the per-day set of models that were +// active and emits a snapshot whose token and cost fields are all `nil`, so the dashboard can +// show "this tool was used with these models" without fabricating token numbers. `requestCount` +// is also withheld because a row is a code-hash record, not an LLM request. +#if canImport(SQLite3) || canImport(CSQLite3) +public enum CursorLocalActivityScanner { + public static let defaultHistoryDays = 30 + + /// Environment override for the Cursor home directory (the folder that contains + /// `ai-tracking/ai-code-tracking.db`), so tests can point at a fixture. + public static let homeEnvironmentKey = "CURSOR_HOME" + + private static let maximumRows = 500_000 + + public static func databaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + let home: URL = if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + URL(fileURLWithPath: override, isDirectory: true) + } else { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".cursor", isDirectory: true) + } + return home + .appendingPathComponent("ai-tracking", isDirectory: true) + .appendingPathComponent("ai-code-tracking.db", isDirectory: false) + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + historyDays: historyDays, + now: now, + calendar: calendar) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let databaseURL = self.databaseURL(environment: environment) + guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let startMs = Int64(start.timeIntervalSince1970 * 1000) + let endMs = Int64(end.addingTimeInterval(24 * 60 * 60).timeIntervalSince1970 * 1000) + + var db: OpaquePointer? + guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + if let db { sqlite3_close(db) } + return nil + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + // One row per (day, model) with the number of activity records. We bound the scan and + // ignore rows with no usable model name. + let query = """ + SELECT (timestamp / 1000) AS seconds, model + FROM ai_code_hashes + WHERE timestamp >= ? AND timestamp < ? AND model IS NOT NULL AND model != '' + LIMIT \(self.maximumRows); + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int64(stmt, 1, startMs) + sqlite3_bind_int64(stmt, 2, endMs) + + var modelsByDay: [String: Set] = [:] + while true { + try checkCancellation() + let stepResult = sqlite3_step(stmt) + if stepResult == SQLITE_DONE { break } + guard stepResult == SQLITE_ROW else { break } + let seconds = sqlite3_column_int64(stmt, 0) + guard let modelC = sqlite3_column_text(stmt, 1) else { continue } + let model = String(cString: modelC) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !model.isEmpty else { continue } + let day = calendar.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(seconds))) + let dayKey = CostUsageLocalDay.key(from: day, calendar: calendar) + modelsByDay[dayKey, default: []].insert(model) + } + + guard !modelsByDay.isEmpty else { return nil } + let daily = modelsByDay.keys.sorted().map { dayKey in + let models = (modelsByDay[dayKey] ?? []).sorted { + $0.localizedCaseInsensitiveCompare($1) == .orderedAscending + } + return CostUsageDailyReport.Entry( + date: dayKey, + inputTokens: nil, + outputTokens: nil, + cacheReadTokens: nil, + cacheCreationTokens: nil, + totalTokens: nil, + requestCount: nil, + costUSD: nil, + modelsUsed: models, + modelBreakdowns: nil) + } + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + last30DaysRequests: nil, + currencyCode: "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "Cursor", + costSource: .estimated, + daily: daily, + updatedAt: now) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift new file mode 100644 index 0000000000..e7baf37a50 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift @@ -0,0 +1,221 @@ +import Foundation + +/// Built-in `LocalHistoryScanning` conformances that wrap the existing per-tool +/// `…SessionScanner.scanCancellable` entry points. Each wrapper is a stateless value; registering +/// one is all that is required to make a tool discoverable through +/// `LocalHistoryScannerRegistry.shared`. +/// +/// When adding a new mainstream tool, define a wrapper here (or in its own provider folder) and +/// append it to `LocalHistoryBuiltInScanners.all` — no controller or switch edits needed. +public enum LocalHistoryBuiltInScanners { + /// All built-in scanners, in stable registration order. + public static let all: [any LocalHistoryScanning] = { + var scanners: [any LocalHistoryScanning] = [ + KimiCodeLocalHistoryScanner(), + GeminiCLILocalHistoryScanner(), + OpenCodeLocalHistoryScanner(), + MiniMaxLocalHistoryScanner(), + AntigravityLocalHistoryScanner(), + QwenCodeLocalHistoryScanner(), + ZcodeLocalHistoryScanner(), + ] + #if canImport(SQLite3) || canImport(CSQLite3) + scanners.append(CursorLocalHistoryScanner()) + scanners.append(TraeLocalHistoryScanner()) + #endif + return scanners + }() +} + +public struct KimiCodeLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .kimiCode + public let displayName = "Kimi Code CLI" + public let homeEnvironmentKey: String? = "KIMI_CODE_HOME" + + public func homeURL(environment: [String: String]) -> URL? { + KimiSettingsReader.kimiCodeHomeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try KimiCodeSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +public struct GeminiCLILocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .geminiCLI + public let displayName = "Gemini CLI" + public let homeEnvironmentKey: String? = GeminiSessionScanner.cliHomeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + GeminiSessionScanner.geminiTmpURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try GeminiSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + checkCancellation: context.checkCancellation) + } +} + +public struct OpenCodeLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .openCode + public let displayName = "OpenCode" + public let homeEnvironmentKey: String? = OpenCodeSessionScanner.dataHomeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + OpenCodeSessionScanner.opencodeDatabaseURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try OpenCodeSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + checkCancellation: context.checkCancellation) + } +} + +public struct MiniMaxLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .miniMax + public let displayName = "MiniMax Code" + public let homeEnvironmentKey: String? = MiniMaxSessionScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + MiniMaxSessionScanner.minimaxHomeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try MiniMaxSessionScanner.scanCancellable( + environment: context.environment, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +public struct AntigravityLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .antigravity + public let displayName = "Antigravity" + public let homeEnvironmentKey: String? = AntigravitySessionScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + AntigravitySessionScanner.antigravityHomeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try AntigravitySessionScanner.scanCancellable( + environment: context.environment, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +public struct QwenCodeLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .qwenCode + public let displayName = "Qwen Code CLI" + public let homeEnvironmentKey: String? = QwenCodeSessionScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + QwenCodeSessionScanner.homeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try QwenCodeSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +public struct ZcodeLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .zcode + public let displayName = "ZCode" + public let homeEnvironmentKey: String? = ZcodeSessionScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + ZcodeSessionScanner.homeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try ZcodeSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + +#if canImport(SQLite3) || canImport(CSQLite3) +public struct CursorLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .cursorLocal + public let displayName = "Cursor" + public let homeEnvironmentKey: String? = CursorLocalActivityScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + CursorLocalActivityScanner.databaseURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try CursorLocalActivityScanner.scanCancellable( + environment: context.environment, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + checkCancellation: context.checkCancellation) + } +} + +public struct TraeLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .traeLocal + public let displayName = "Trae" + public let homeEnvironmentKey: String? = TraeLocalActivityScanner.databaseEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + TraeLocalActivityScanner.databaseURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try TraeLocalActivityScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + checkCancellation: context.checkCancellation) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScannerRegistry.swift b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScannerRegistry.swift new file mode 100644 index 0000000000..f87e746c00 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScannerRegistry.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Registry of local usage-history scanners, keyed by their stable source identifier. +/// +/// This replaces per-call-site hardcoding of "which scanner handles which tool". Provider +/// descriptors opt into a source ID via `localHistorySources`; the dashboard resolves that ID to +/// a concrete scanner through this registry. Third-party or future built-in tools register an +/// additional conformance instead of editing the controller. +/// +/// The registry is a value type holding type-erased scanners. The process-wide `shared` instance +/// is pre-populated with the built-in scanners; tests can build isolated registries to register +/// fakes. +public struct LocalHistoryScannerRegistry: Sendable { + private var scannersBySource: [ProviderLocalHistorySource: any LocalHistoryScanning] + private var insertionOrder: [ProviderLocalHistorySource] + + public init(scanners: [any LocalHistoryScanning] = []) { + self.scannersBySource = [:] + self.insertionOrder = [] + for scanner in scanners { + self.register(scanner) + } + } + + /// Registers (or replaces) the scanner for its `source`. Registration order is preserved for + /// deterministic iteration. + public mutating func register(_ scanner: any LocalHistoryScanning) { + if self.scannersBySource[scanner.source] == nil { + self.insertionOrder.append(scanner.source) + } + self.scannersBySource[scanner.source] = scanner + } + + /// Returns the registered scanner for a source, or `nil` when none is registered. + public func scanner(for source: ProviderLocalHistorySource) -> (any LocalHistoryScanning)? { + self.scannersBySource[source] + } + + /// All registered scanners in registration order. + public var all: [any LocalHistoryScanning] { + self.insertionOrder.compactMap { self.scannersBySource[$0] } + } + + /// Registered source identifiers in registration order. + public var registeredSources: [ProviderLocalHistorySource] { + self.insertionOrder + } +} + +extension LocalHistoryScannerRegistry { + /// Process-wide registry pre-populated with every built-in scanner. The dashboard reads from + /// this; tests should construct isolated registries instead of mutating shared state. + public static let shared = LocalHistoryScannerRegistry( + scanners: LocalHistoryBuiltInScanners.all) +} diff --git a/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScanning.swift b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScanning.swift new file mode 100644 index 0000000000..c4eb03d215 --- /dev/null +++ b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryScanning.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Shared parameters for a local history scan, bundled so scanner signatures stay small and so +/// new scan-wide options can be added without changing every conformance. +/// +/// `FileManager` is a reference type that Foundation documents as safe to share across threads +/// for the operations these scanners perform, so the bundle is marked `@unchecked Sendable`. +public struct LocalHistoryScanContext: @unchecked Sendable { + public var environment: [String: String] + public var fileManager: FileManager + public var historyDays: Int + public var now: Date + public var calendar: Calendar + public var modelsDevCacheRoot: URL? + public var checkCancellation: @Sendable () throws -> Void + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = 30, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping @Sendable () throws -> Void = {}) + { + self.environment = environment + self.fileManager = fileManager + self.historyDays = historyDays + self.now = now + self.calendar = calendar + self.modelsDevCacheRoot = modelsDevCacheRoot + self.checkCancellation = checkCancellation + } +} + +/// A local usage-history scanner for a single tool (CLI/desktop client). +/// +/// This is the registerable extension point for the Usage & Spend data layer, modeled on +/// tokscale's `define_clients!` macro: adding support for a new mainstream tool means +/// implementing one `LocalHistoryScanning` conformance and registering it, without editing any +/// central switch or the dashboard controller. A scanner knows how to locate its tool's home +/// directory and how to fold the local files it finds there into a `CostUsageTokenSnapshot`. +/// +/// Scanners are value types (usually enums with no cases) that wrap an existing +/// `…SessionScanner.scanCancellable` entry point. They are `Sendable` so the registry can vend +/// them across actor boundaries. +public protocol LocalHistoryScanning: Sendable { + /// Stable identifier matching the provider descriptor's `localHistorySources` entry. + var source: ProviderLocalHistorySource { get } + + /// Human-facing tool name shown in the dashboard (e.g. "Kimi Code CLI"). + var displayName: String { get } + + /// Environment variable that overrides the tool's home directory (e.g. `KIMI_CODE_HOME`). + /// `nil` when the tool has a fixed, non-overridable home. + var homeEnvironmentKey: String? { get } + + /// Resolves the tool's home directory from the environment, or `nil` when the tool is not + /// installed / has no local history on this machine. + func homeURL(environment: [String: String]) -> URL? + + /// Scans the tool's local history and returns a dashboard-ready snapshot, or `nil` when no + /// usable history exists. Implementations must honor cancellation via the context's + /// `checkCancellation`. + func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? +} + +extension LocalHistoryScanning { + /// Convenience for the common case where a scanner simply forwards to an existing + /// `…SessionScanner.scanCancellable` static method. + public func scan(environment: [String: String]) throws -> CostUsageTokenSnapshot? { + try self.scan(context: LocalHistoryScanContext(environment: environment)) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index d2cecf4421..a2941cb35d 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -19,6 +19,11 @@ public struct ProviderLocalHistorySource: RawRepresentable, Hashable, Sendable { public static let miniMax = Self(rawValue: "miniMax") public static let openCode = Self(rawValue: "openCode") public static let qwenCode = Self(rawValue: "qwenCode") + public static let zcode = Self(rawValue: "zcode") + /// Degraded sources: the tool is recognized and model activity is reported, but no per-request + /// token usage is available locally (billing is server-side). + public static let cursorLocal = Self(rawValue: "cursorLocal") + public static let traeLocal = Self(rawValue: "traeLocal") public static let builtIn: [Self] = [ .antigravity, @@ -27,6 +32,9 @@ public struct ProviderLocalHistorySource: RawRepresentable, Hashable, Sendable { .miniMax, .openCode, .qwenCode, + .zcode, + .cursorLocal, + .traeLocal, ] /// Built-in adapter identifiers shipped by this build. Custom identifiers diff --git a/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift b/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift new file mode 100644 index 0000000000..ee8334d9b1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift @@ -0,0 +1,194 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// Reads Trae's local state to surface which model the agent was last configured to use, without +// reporting token counts. +// +// Trae (a VS Code fork) keeps its state in +// `~/Library/Application Support/Trae*/User/globalStorage/state.vscdb`, a VS Code `ItemTable` +// key-value store. The keys relevant here are the per-agent selected-model map +// (`…_ai-chat:sessionRelation:globalModelMap`, values like `1_-_glm-5.2`) and the telemetry +// last-session date. Trae stores no per-request token usage locally — billing is server-side — +// so this is a *degraded* source: the scanner reports the models the user actually selected and +// the last active day, with every token and cost field left `nil` so nothing is fabricated. +#if canImport(SQLite3) || canImport(CSQLite3) +public enum TraeLocalActivityScanner { + public static let defaultHistoryDays = 30 + + /// Environment override pointing at the Trae `state.vscdb` file, so tests can use a fixture. + public static let databaseEnvironmentKey = "TRAE_STATE_VSCDB" + + /// Candidate `Application Support` bundle folder names, newest branding first. + private static let applicationSupportFolders = ["Trae CN", "Trae"] + + public static func databaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> URL? + { + if let override = environment[self.databaseEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: false) + } + let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + guard let appSupport else { return nil } + for folder in self.applicationSupportFolders { + let candidate = appSupport + .appendingPathComponent(folder, isDirectory: true) + .appendingPathComponent("User/globalStorage/state.vscdb", isDirectory: false) + if fileManager.fileExists(atPath: candidate.path) { + return candidate + } + } + return nil + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + guard let databaseURL = self.databaseURL(environment: environment, fileManager: fileManager), + fileManager.fileExists(atPath: databaseURL.path) + else { + return nil + } + + var db: OpaquePointer? + guard sqlite3_open_v2(databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + if let db { sqlite3_close(db) } + return nil + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let models = self.selectedModels(db: db) + let lastActive = self.lastSessionDate(db: db) + guard !models.isEmpty || lastActive != nil else { return nil } + + // Anchor the (single) record on the last active day when known, otherwise today. Trae + // exposes no per-day token history, so there is exactly one degraded entry. + let anchor = lastActive ?? now + let anchorDay = calendar.startOfDay(for: anchor) + let dayKey = CostUsageLocalDay.key(from: anchorDay, calendar: calendar) + let entry = CostUsageDailyReport.Entry( + date: dayKey, + inputTokens: nil, + outputTokens: nil, + cacheReadTokens: nil, + cacheCreationTokens: nil, + totalTokens: nil, + requestCount: nil, + costUSD: nil, + modelsUsed: models.isEmpty ? nil : models, + modelBreakdowns: nil) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + last30DaysRequests: nil, + currencyCode: "XXX", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "Trae", + costSource: .estimated, + daily: [entry], + updatedAt: now) + } + + /// Reads the per-agent selected-model map and returns the de-duplicated, cleaned model names + /// the user actually chose (e.g. `glm-5.2` from `1_-_glm-5.2`). + private static func selectedModels(db: OpaquePointer?) -> [String] { + guard let raw = self.value(db: db, keySuffix: "ai-chat:sessionRelation:globalModelMap"), + let data = raw.data(using: .utf8), + let map = try? JSONDecoder().decode([String: String].self, from: data) + else { + return [] + } + var seen: Set = [] + var models: [String] = [] + for value in map.values { + let cleaned = self.cleanModelName(value) + guard !cleaned.isEmpty, !seen.contains(cleaned.lowercased()) else { continue } + seen.insert(cleaned.lowercased()) + models.append(cleaned) + } + return models.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + /// Strips Trae's internal `N_-_` selection-id prefix from a stored model reference. + private static func cleanModelName(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if let range = value.range(of: "_-_") { + value = String(value[range.upperBound...]) + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Parses the HTTP-date telemetry last-session timestamp (`Sat, 27 Jun 2026 15:51:03 GMT`). + private static func lastSessionDate(db: OpaquePointer?) -> Date? { + guard let raw = self.value(db: db, exactKey: "telemetry.lastSessionDate") else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + return formatter.date(from: raw.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + /// Looks up a value whose key either matches exactly or ends with the given suffix (Trae + /// prefixes many keys with a numeric install id). + private static func value(db: OpaquePointer?, exactKey: String? = nil, keySuffix: String? = nil) -> String? { + let query = if exactKey != nil { + "SELECT value FROM ItemTable WHERE key = ? LIMIT 1;" + } else { + "SELECT value FROM ItemTable WHERE key LIKE ? LIMIT 1;" + } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + let bind = exactKey ?? "%\(keySuffix ?? "")" + sqlite3_bind_text(stmt, 1, bind, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + guard sqlite3_step(stmt) == SQLITE_ROW else { return nil } + switch sqlite3_column_type(stmt, 0) { + case SQLITE_TEXT: + guard let c = sqlite3_column_text(stmt, 0) else { return nil } + return String(cString: c) + case SQLITE_BLOB: + guard let bytes = sqlite3_column_blob(stmt, 0) else { return nil } + let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, 0))) + return String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .utf16LittleEndian) + default: + return nil + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift new file mode 100644 index 0000000000..151f6cc972 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift @@ -0,0 +1,381 @@ +import Foundation + +/// Reads ZCode's local JSONL rollout history. +/// +/// ZCode stores one file per session under `~/.zcode/cli/rollout/model-io-sess_*.jsonl`. Each +/// line is a single billable request with `completedAt` (RFC 3339), `model.modelId` (e.g. +/// `GLM-5.2`), `model.providerId` (e.g. `builtin:bigmodel-start-plan`, the Zhipu/BigModel coding +/// plan), and `response.usage` carrying `inputTokens`, `outputTokens`, `totalTokens`, +/// `cacheReadTokens`, and `cacheWriteTokens`. +/// +/// ZCode's `inputTokens` already includes the cached prefix, mirroring tokscale's zcode handling: +/// `inputTokens + outputTokens == totalTokens` even when `cacheReadTokens > 0`. We therefore +/// cross-check against `totalTokens` and split the prompt into its uncached remainder so cached +/// tokens are never billed twice. Usage is priced at the vendor's official Z.ai API rate (the +/// coding-plan catalog entries are zero-priced because the plan is a flat subscription); only +/// when no official rate is known does the day stay partially priced. +public enum ZcodeSessionScanner { + public static let homeEnvironmentKey = "ZCODE_HOME" + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + + private struct WireMessage: Decodable { + struct Model: Decodable { + let modelId: String? + let providerId: String? + } + + struct Usage: Decodable { + let inputTokens: Int? + let outputTokens: Int? + let totalTokens: Int? + let cacheReadTokens: Int? + let cacheWriteTokens: Int? + } + + struct Response: Decodable { + let usage: Usage? + } + + let completedAt: String? + let model: Model? + let response: Response? + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + private struct TokenAccumulator { + var input = 0 + var output = 0 + var cacheRead = 0 + var cacheWrite = 0 + var requests = 0 + var cost = 0.0 + var sawCost = false + /// Set when at least one request in this accumulator carried billable usage but had no + /// resolvable price. The merged day/entry cost then stays nil so the dashboard does not + /// present a partial subtotal as the complete day total. + var sawUnpricedUsage = false + + mutating func add( + usage: WireMessage.Usage, + model: String, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Bool + { + guard let rawInput = Self.valid(usage.inputTokens), + let output = Self.valid(usage.outputTokens), + let cacheRead = Self.valid(usage.cacheReadTokens), + let cacheWrite = Self.valid(usage.cacheWriteTokens), + let total = Self.valid(usage.totalTokens) + else { + return false + } + // Normalize the cached-prefix double count. When `input + output == total` the input + // already includes the cached prefix, so the billable uncached input is + // `input - cacheRead`. When `input + cacheRead + output == total` the input is already + // uncached. Fall back to subtracting the cache when total is inconsistent. + let uncachedInput: Int = if let inputPlusOutput = Self.adding(rawInput, output), inputPlusOutput == total { + max(0, rawInput - cacheRead) + } else if let withCache = Self.adding(rawInput, cacheRead), + let withCacheAndOutput = Self.adding(withCache, output), + withCacheAndOutput == total + { + rawInput + } else { + max(0, rawInput - cacheRead) + } + guard let nextInput = Self.adding(self.input, uncachedInput), + let nextOutput = Self.adding(self.output, output), + let nextCacheRead = Self.adding(self.cacheRead, cacheRead), + let nextCacheWrite = Self.adding(self.cacheWrite, cacheWrite), + let nextRequests = Self.adding(self.requests, 1) + else { + return false + } + guard uncachedInput > 0 || output > 0 || cacheRead > 0 else { return false } + self.input = nextInput + self.output = nextOutput + self.cacheRead = nextCacheRead + self.cacheWrite = nextCacheWrite + self.requests = nextRequests + // Price at the official Z.ai API rate (zhipuai is the same vendor's alias). The + // `zai-coding-plan`/`zhipuai-coding-plan` catalogs are intentionally excluded: they are + // zero-priced flat subscriptions, and we report the API-equivalent value to stay + // consistent with how other subscription tools are estimated. + if let cost = CostUsagePricing.modelsDevCostUSD( + request: .init( + providerIDs: ["zai", "zhipuai"], + model: model, + inputTokens: uncachedInput, + cacheReadInputTokens: cacheRead, + outputTokens: output), + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot), + cost.isFinite + { + let nextCost = self.cost + cost + if nextCost.isFinite { + self.cost = nextCost + self.sawCost = true + } + } else { + self.sawUnpricedUsage = true + } + return true + } + + mutating func merge(_ other: Self) -> Bool { + guard let input = Self.adding(self.input, other.input), + let output = Self.adding(self.output, other.output), + let cacheRead = Self.adding(self.cacheRead, other.cacheRead), + let cacheWrite = Self.adding(self.cacheWrite, other.cacheWrite), + let requests = Self.adding(self.requests, other.requests) + else { + return false + } + self.input = input + self.output = output + self.cacheRead = cacheRead + self.cacheWrite = cacheWrite + self.requests = requests + if other.sawCost { + let nextCost = self.cost + other.cost + if other.cost.isFinite, nextCost.isFinite { + self.cost = nextCost + self.sawCost = true + } + } + self.sawUnpricedUsage = self.sawUnpricedUsage || other.sawUnpricedUsage + return true + } + + var total: Int? { + guard let inputAndCache = Self.adding(self.input, self.cacheRead) else { return nil } + return Self.adding(inputAndCache, self.output) + } + + private static func valid(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return 0 } + return value + } + + private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let root = self.homeURL(environment: environment) + .appendingPathComponent("cli", isDirectory: true) + .appendingPathComponent("rollout", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let decoder = JSONDecoder() + // ZCode emits RFC 3339 timestamps with fractional seconds (`2026-06-14T17:23:26.382Z`). + // The whole-second formatter rejects them, so try the fractional variant first. + let iso8601Fractional = ISO8601DateFormatter() + iso8601Fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let iso8601 = ISO8601DateFormatter() + let parseTimestamp: (String) -> Date? = { raw in + iso8601Fractional.date(from: raw) ?? iso8601.date(from: raw) + } + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + var values: [DayModelKey: TokenAccumulator] = [:] + var visitedFiles = 0 + var visitedBytes = 0 + + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + guard url.pathExtension.lowercased() == "jsonl", + url.lastPathComponent.hasPrefix("model-io-sess") + else { + continue + } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + if let modified = resourceValues?.contentModificationDate, modified < start { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumBytes - visitedBytes else { break } + visitedFiles += 1 + visitedBytes += size + + do { + try CostUsageJsonl.scan( + fileURL: url, + maxLineBytes: 1024 * 1024, + prefixBytes: 1024 * 1024, + checkCancellation: checkCancellation) + { line in + guard !line.wasTruncated, + let message = try? decoder.decode(WireMessage.self, from: line.bytes), + let usage = message.response?.usage + else { + return + } + let eventDate = message.completedAt.flatMap(parseTimestamp) + ?? resourceValues?.contentModificationDate + ?? now + let eventDay = calendar.startOfDay(for: eventDate) + guard eventDay >= start, eventDay <= end else { return } + // models.dev keys the Z.ai catalog by the lowercase model id (`glm-5.2`), while + // ZCode records the display casing (`GLM-5.2`). + let model = message.model?.modelId? + .trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedModel = model?.isEmpty == false + ? model!.lowercased() + : "unknown" + let key = DayModelKey( + day: CostUsageLocalDay.key(from: eventDay, calendar: calendar), + model: normalizedModel) + var accumulator = values[key] ?? TokenAccumulator() + guard accumulator.add( + usage: usage, + model: normalizedModel, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { + return + } + values[key] = accumulator + } + } catch is CancellationError { + throw CancellationError() + } catch { + continue + } + } + + guard !values.isEmpty else { return nil } + let daily = self.makeDaily(values: values) + guard let totalTokens = self.sum(daily.compactMap(\.totalTokens)), + let requests = self.sum(daily.compactMap(\.requestCount)) + else { + return nil + } + let costs = daily.compactMap(\.costUSD) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), + last30DaysRequests: requests, + currencyCode: costs.isEmpty ? "XXX" : "USD", + historyDays: days, + historyCoverageIsEstablished: true, + historyLabel: "ZCode", + costSource: .estimated, + daily: daily, + updatedAt: now) + } + + public static func homeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".zcode", isDirectory: true) + } + + private static func makeDaily(values: [DayModelKey: TokenAccumulator]) + -> [CostUsageDailyReport.Entry] + { + let byDay = Dictionary(grouping: values, by: \.key.day) + return byDay.keys.sorted().compactMap { day in + let models = (byDay[day] ?? []).sorted { + $0.key.model.localizedCaseInsensitiveCompare($1.key.model) == .orderedAscending + } + var total = TokenAccumulator() + var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + for (key, value) in models { + guard let modelTotal = value.total, total.merge(value) else { return nil } + breakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + billingProviderID: UsageProvider.zai.rawValue, + costUSD: value.sawCost ? value.cost : nil, + totalTokens: modelTotal, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: value.cacheWrite, + outputTokens: value.output, + reasoningTokens: 0, + requestCount: value.requests)) + } + guard let dayTotal = total.total else { return nil } + // Withhold the day cost whenever any contributing model could not be priced, so the + // dashboard treats the day as partially priced instead of a confident complete total. + let dayCost = total.sawCost && !total.sawUnpricedUsage ? total.cost : nil + return CostUsageDailyReport.Entry( + date: day, + inputTokens: total.input, + outputTokens: total.output, + cacheReadTokens: total.cacheRead, + cacheCreationTokens: total.cacheWrite, + totalTokens: dayTotal, + requestCount: total.requests, + costUSD: dayCost, + modelsUsed: breakdowns.map(\.modelName), + modelBreakdowns: breakdowns) + } + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} diff --git a/Tests/CodexBarTests/CursorLocalActivityScannerTests.swift b/Tests/CodexBarTests/CursorLocalActivityScannerTests.swift new file mode 100644 index 0000000000..6552e34a92 --- /dev/null +++ b/Tests/CodexBarTests/CursorLocalActivityScannerTests.swift @@ -0,0 +1,98 @@ +import CodexBarCore +import Foundation +import Testing + +#if canImport(SQLite3) || canImport(CSQLite3) +#if canImport(SQLite3) +import SQLite3 +#else +import CSQLite3 +#endif + +struct CursorLocalActivityScannerTests { + @Test + func `scanner aggregates per-day model activity without token counts`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CursorLocalActivityScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let tracking = root.appendingPathComponent("ai-tracking", isDirectory: true) + try FileManager.default.createDirectory(at: tracking, withIntermediateDirectories: true) + let database = tracking.appendingPathComponent("ai-code-tracking.db") + // 2026-07-28 and 2026-07-29 (epoch ms), each with two models and a blank model to skip. + try Self.createDatabase(at: database, rows: [ + (1_785_283_200_000, "Kimi K3"), // 2026-07-28T00:00:00Z + (1_785_283_800_000, "default"), + (1_785_284_000_000, ""), // skipped: empty model + (1_785_369_600_000, "MiniMax-M3"), // 2026-07-29T00:00:00Z + (1_785_369_900_000, "Kimi K3"), + ]) + + let snapshot = try #require(CursorLocalActivityScanner.scan( + environment: [CursorLocalActivityScanner.homeEnvironmentKey: root.path], + historyDays: 60, + now: Date(timeIntervalSince1970: 1_785_456_000), // 2026-07-30T00:00:00Z + calendar: Self.calendar)) + + #expect(snapshot.historyLabel == "Cursor") + #expect(snapshot.last30DaysTokens == nil) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.last30DaysRequests == nil) + #expect(snapshot.daily.count == 2) + let first = try #require(snapshot.daily.first) + #expect(first.totalTokens == nil) + #expect(first.costUSD == nil) + #expect(first.requestCount == nil) + #expect(first.modelsUsed == ["default", "Kimi K3"]) + let second = snapshot.daily[1] + #expect(second.modelsUsed == ["Kimi K3", "MiniMax-M3"]) + } + + @Test + func `scanner returns nil when the database is missing`() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("CursorLocalActivityScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let snapshot = CursorLocalActivityScanner.scan( + environment: [CursorLocalActivityScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_456_000), + calendar: Self.calendar) + #expect(snapshot == nil) + } + + private static func createDatabase(at url: URL, rows: [(Int64, String)]) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK, let db else { + throw TestFailure.dbOpen + } + defer { sqlite3_close(db) } + let schema = """ + CREATE TABLE ai_code_hashes ( + hash TEXT, source TEXT, fileExtension TEXT, fileName TEXT, + requestId TEXT, conversationId TEXT, timestamp INTEGER, + model TEXT, createdAt INTEGER + ); + """ + guard sqlite3_exec(db, schema, nil, nil, nil) == SQLITE_OK else { + throw TestFailure.dbWrite + } + for (timestamp, model) in rows { + let insert = "INSERT INTO ai_code_hashes (timestamp, model) VALUES (\(timestamp), '\(model)');" + guard sqlite3_exec(db, insert, nil, nil, nil) == SQLITE_OK else { + throw TestFailure.dbWrite + } + } + } + + private enum TestFailure: Error { + case dbOpen + case dbWrite + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() +} +#endif diff --git a/Tests/CodexBarTests/LocalHistoryScannerRegistryTests.swift b/Tests/CodexBarTests/LocalHistoryScannerRegistryTests.swift new file mode 100644 index 0000000000..1dcd328f4a --- /dev/null +++ b/Tests/CodexBarTests/LocalHistoryScannerRegistryTests.swift @@ -0,0 +1,59 @@ +import CodexBarCore +import Foundation +import Testing + +struct LocalHistoryScannerRegistryTests { + private struct FakeScanner: LocalHistoryScanning { + let source: ProviderLocalHistorySource + let displayName: String + let homeEnvironmentKey: String? = nil + + func homeURL(environment: [String: String]) -> URL? { + nil + } + + func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + nil + } + } + + @Test + func `shared registry registers every built-in source`() { + let registry = LocalHistoryScannerRegistry.shared + for source in ProviderLocalHistorySource.builtIn { + #expect(registry.scanner(for: source) != nil, "missing built-in scanner for \(source.rawValue)") + } + #expect(registry.registeredSources.count == ProviderLocalHistorySource.builtIn.count) + } + + @Test + func `register adds a new scanner and preserves registration order`() { + var registry = LocalHistoryScannerRegistry() + let custom = ProviderLocalHistorySource(rawValue: "customTool") + #expect(registry.scanner(for: custom) == nil) + + registry.register(FakeScanner(source: custom, displayName: "Custom Tool")) + #expect(registry.scanner(for: custom)?.displayName == "Custom Tool") + #expect(registry.registeredSources == [custom]) + } + + @Test + func `re-registering a source replaces it without duplicating order`() { + var registry = LocalHistoryScannerRegistry() + let source = ProviderLocalHistorySource(rawValue: "replaceable") + registry.register(FakeScanner(source: source, displayName: "First")) + registry.register(FakeScanner(source: source, displayName: "Second")) + + #expect(registry.scanner(for: source)?.displayName == "Second") + #expect(registry.registeredSources == [source]) + #expect(registry.all.count == 1) + } + + @Test + func `zcode built-in scanner is discoverable with its display name`() { + let registry = LocalHistoryScannerRegistry.shared + let scanner = registry.scanner(for: .zcode) + #expect(scanner?.displayName == "ZCode") + #expect(scanner?.homeEnvironmentKey == ZcodeSessionScanner.homeEnvironmentKey) + } +} diff --git a/Tests/CodexBarTests/TraeLocalActivityScannerTests.swift b/Tests/CodexBarTests/TraeLocalActivityScannerTests.swift new file mode 100644 index 0000000000..5a1361224e --- /dev/null +++ b/Tests/CodexBarTests/TraeLocalActivityScannerTests.swift @@ -0,0 +1,102 @@ +import CodexBarCore +import Foundation +import Testing + +#if canImport(SQLite3) || canImport(CSQLite3) +#if canImport(SQLite3) +import SQLite3 +#else +import CSQLite3 +#endif + +struct TraeLocalActivityScannerTests { + @Test + func `scanner surfaces the selected model and last active day without token counts`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("TraeLocalActivityScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("state.vscdb") + let modelMap = #"{"solo_coder":"1_-_glm-5.2","builder":"0_-_kimi-k2.7-code"}"# + try Self.createDatabase(at: database, values: [ + "4484236971871945_ai-chat:sessionRelation:globalModelMap": modelMap, + "telemetry.lastSessionDate": "Tue, 28 Jul 2026 08:30:00 GMT", + ]) + + let snapshot = try #require(TraeLocalActivityScanner.scan( + environment: [TraeLocalActivityScanner.databaseEnvironmentKey: database.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_456_000), // 2026-07-30T00:00:00Z + calendar: Self.calendar)) + + #expect(snapshot.historyLabel == "Trae") + #expect(snapshot.last30DaysTokens == nil) + let entry = try #require(snapshot.daily.first) + #expect(entry.totalTokens == nil) + #expect(entry.costUSD == nil) + // Selection-id prefixes stripped, de-duplicated, sorted case-insensitively. + #expect(entry.modelsUsed == ["glm-5.2", "kimi-k2.7-code"]) + // Anchored on the telemetry last-session day. + #expect(entry.date == "2026-07-28") + } + + @Test + func `scanner returns nil when nothing useful is stored`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("TraeLocalActivityScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("state.vscdb") + try Self.createDatabase(at: database, values: [:]) + let snapshot = TraeLocalActivityScanner.scan( + environment: [TraeLocalActivityScanner.databaseEnvironmentKey: database.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_456_000), + calendar: Self.calendar) + #expect(snapshot == nil) + } + + private static func createDatabase(at url: URL, values: [String: String]) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK, let db else { + throw TestFailure.dbOpen + } + defer { sqlite3_close(db) } + guard sqlite3_exec( + db, + "CREATE TABLE ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);", + nil, + nil, + nil) == SQLITE_OK + else { + throw TestFailure.dbWrite + } + for (key, value) in values { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, "INSERT INTO ItemTable (key, value) VALUES (?, ?);", -1, &stmt, nil) == + SQLITE_OK + else { + throw TestFailure.dbWrite + } + sqlite3_bind_text(stmt, 1, key, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + sqlite3_bind_text(stmt, 2, value, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self)) + guard sqlite3_step(stmt) == SQLITE_DONE else { + sqlite3_finalize(stmt) + throw TestFailure.dbWrite + } + sqlite3_finalize(stmt) + } + } + + private enum TestFailure: Error { + case dbOpen + case dbWrite + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() +} +#endif diff --git a/Tests/CodexBarTests/ZcodeSessionScannerTests.swift b/Tests/CodexBarTests/ZcodeSessionScannerTests.swift new file mode 100644 index 0000000000..b19086b168 --- /dev/null +++ b/Tests/CodexBarTests/ZcodeSessionScannerTests.swift @@ -0,0 +1,121 @@ +import CodexBarCore +import Foundation +import Testing + +struct ZcodeSessionScannerTests { + @Test + func `scanner reads ZCode rollout usage and normalizes the cached prefix`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ZcodeSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let rollout = root.appendingPathComponent("cli/rollout", isDirectory: true) + try FileManager.default.createDirectory(at: rollout, withIntermediateDirectories: true) + // inputTokens includes the cached prefix: input + output == total even with cacheRead > 0. + // 300 input (of which 200 cached) + 30 output == 330 total → 100 uncached input. + let inclusive = #"{"completedAt":"2026-07-28T08:01:00.000Z","# + + #""model":{"modelId":"GLM-5.2","providerId":"builtin:bigmodel-start-plan"},"# + + #""response":{"usage":{"inputTokens":300,"outputTokens":30,"totalTokens":330,"# + + #""cacheReadTokens":200,"cacheWriteTokens":0}}}"# + // A second request on the same day, uncached: input + output == total with no cache. + let second = #"{"completedAt":"2026-07-28T08:02:00.000Z","# + + #""model":{"modelId":"GLM-5.2","providerId":"builtin:bigmodel-start-plan"},"# + + #""response":{"usage":{"inputTokens":30,"outputTokens":7,"totalTokens":37,"# + + #""cacheReadTokens":0,"cacheWriteTokens":0}}}"# + let jsonl = [inclusive, second].joined(separator: "\n") + try Data(jsonl.utf8).write(to: rollout.appendingPathComponent("model-io-sess_abc.jsonl")) + + let snapshot = try #require(ZcodeSessionScanner.scan( + environment: [ZcodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true))) + + #expect(snapshot.historyLabel == "ZCode") + #expect(snapshot.last30DaysRequests == 2) + // Uncached input 100 + 30, cache read 200, output 30 + 7 → total 367. + #expect(snapshot.last30DaysTokens == 367) + let entry = try #require(snapshot.daily.first) + #expect(entry.inputTokens == 130) + #expect(entry.outputTokens == 37) + #expect(entry.cacheReadTokens == 200) + #expect(entry.totalTokens == 367) + let model = try #require(entry.modelBreakdowns?.first) + // models.dev keys the Z.ai catalog by the lowercase model id. + #expect(model.modelName == "glm-5.2") + #expect(model.billingProviderID == UsageProvider.zai.rawValue) + } + + @Test + func `scanner treats cache-exclusive input without double counting`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ZcodeSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let rollout = root.appendingPathComponent("cli/rollout", isDirectory: true) + try FileManager.default.createDirectory(at: rollout, withIntermediateDirectories: true) + // Defensive: if a build ever reports cache-exclusive input, input + cacheRead + output + // equals total, so the input is already uncached and must not be reduced again. + // 100 input + 200 cache + 30 output == 330 total → keep 100 input. + let exclusive = #"{"completedAt":"2026-07-28T08:01:00.000Z","# + + #""model":{"modelId":"GLM-5.2","providerId":"builtin:bigmodel-start-plan"},"# + + #""response":{"usage":{"inputTokens":100,"outputTokens":30,"totalTokens":330,"# + + #""cacheReadTokens":200,"cacheWriteTokens":0}}}"# + try Data(exclusive.utf8).write(to: rollout.appendingPathComponent("model-io-sess_def.jsonl")) + + let snapshot = try #require(ZcodeSessionScanner.scan( + environment: [ZcodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true))) + + let entry = try #require(snapshot.daily.first) + #expect(entry.inputTokens == 100) + #expect(entry.cacheReadTokens == 200) + #expect(entry.totalTokens == 330) + } + + @Test + func `scanner ignores non-rollout files and cooperatively cancels`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ZcodeSessionScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let rollout = root.appendingPathComponent("cli/rollout", isDirectory: true) + try FileManager.default.createDirectory(at: rollout, withIntermediateDirectories: true) + let real = #"{"completedAt":"2026-07-28T08:01:00.000Z","# + + #""model":{"modelId":"GLM-5.2"},"# + + #""response":{"usage":{"inputTokens":1,"outputTokens":1,"totalTokens":2}}}"# + try Data(real.utf8).write(to: rollout.appendingPathComponent("model-io-sess_abc.jsonl")) + // A decoy that does not match the model-io-sess prefix must be skipped. + let decoy = #"{"completedAt":"2026-07-28T08:01:00.000Z","# + + #""response":{"usage":{"inputTokens":999,"outputTokens":999,"totalTokens":1998}}}"# + try Data(decoy.utf8).write(to: rollout.appendingPathComponent("other.jsonl")) + + var checks = 0 + #expect(throws: CancellationError.self) { + _ = try ZcodeSessionScanner.scanCancellable( + environment: [ZcodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + checkCancellation: { + checks += 1 + if checks >= 2 { throw CancellationError() } + }) + } + + // Without cancellation, only the rollout file is read. + let snapshot = try #require(ZcodeSessionScanner.scan( + environment: [ZcodeSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar)) + #expect(snapshot.last30DaysRequests == 1) + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() +} From 1bba0c574487c8c2b4b0a5b651f596de17c3ecde Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:59:04 +0800 Subject: [PATCH 04/22] feat(core): unified usage-event layer + shared aggregator, add Copilot Refactor the local-history framework from "each scanner builds its own snapshot" to a two-layer design modeled on tokscale's UnifiedMessage: - UnifiedUsageEvent: a single normalized record (full token detail, or degraded model-only) that every thin tool parser emits. Per-event billingProviderID carries source evidence for harness tools. - UsageEventAggregator: the one place that does day/model bucketing, cached-prefix normalization (total cross-check, fallback subtract), models.dev pricing, provider-reported-cost passthrough, partial-pricing nil handling, and snapshot construction. Previously this logic was re-implemented (and occasionally dropped) in every scanner. Migrate Zcode, Qwen, Cursor, and Trae to thin parsers over the engine (Zcode 381->200 lines; Qwen -237). Engine output is byte-identical to the prior hand-rolled accumulators, verified by the existing suites. Add CopilotSessionScanner: reads GitHub Copilot CLI's session-state/*/events.jsonl session.shutdown modelMetrics rollup, normalizes model ids to pricing keys, and traces each model to the real billing vendor (claude-* -> anthropic, gpt-* -> openai, gemini-* -> google) so a harness is priced at the model source, not the tool. New mainstream tools now only need a thin parse->events adapter plus one registration line; aggregation/normalization/pricing are reused and cannot be forgotten. Engine covered by 7 focused tests; Copilot by 3. Co-authored-by: Cursor --- .../Copilot/CopilotSessionScanner.swift | 218 +++++++++++++ .../Cursor/CursorLocalActivityScanner.swift | 48 +-- .../LocalHistoryBuiltInScanners.swift | 23 ++ .../LocalHistory/UnifiedUsageEvent.swift | 80 +++++ .../LocalHistory/UsageEventAggregator.swift | 298 ++++++++++++++++++ .../Providers/ProviderDescriptor.swift | 2 + .../QwenCloud/QwenCodeSessionScanner.swift | 237 ++------------ .../Trae/TraeLocalActivityScanner.swift | 32 +- .../Providers/Zcode/ZcodeSessionScanner.swift | 244 ++------------ .../CopilotSessionScannerTests.swift | 117 +++++++ .../UsageEventAggregatorTests.swift | 136 ++++++++ 11 files changed, 956 insertions(+), 479 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift create mode 100644 Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift create mode 100644 Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift create mode 100644 Tests/CodexBarTests/CopilotSessionScannerTests.swift create mode 100644 Tests/CodexBarTests/UsageEventAggregatorTests.swift diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift new file mode 100644 index 0000000000..05ff03e28c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift @@ -0,0 +1,218 @@ +import Foundation + +/// Reads GitHub Copilot CLI's local session history as a thin parser over the shared aggregation +/// engine. +/// +/// Copilot stores one directory per session under `~/.copilot/session-state//events.jsonl`. +/// Unlike most tools it does not log per-request token rows; instead the terminal +/// `session.shutdown` event carries a per-model rollup under `data.modelMetrics..usage` +/// with `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, and +/// `reasoningTokens`. This scanner reads that rollup (falling back to the last event that carries +/// `modelMetrics` for sessions still in progress) and maps each model bucket to a +/// `UnifiedUsageEvent`, anchoring the day on the event `timestamp`. +/// +/// Cached-prefix handling (Copilot's `inputTokens` includes the cached prefix, and there is no +/// separate total, so the engine subtracts it), models.dev pricing, day bucketing, and snapshot +/// construction are all handled once by `UsageEventAggregator`. +public enum CopilotSessionScanner { + public static let homeEnvironmentKey = "COPILOT_HOME" + public static let defaultHistoryDays = 30 + public static let maximumFiles = 20000 + public static let maximumBytes = 512 * 1024 * 1024 + + private struct WireEvent: Decodable { + struct ModelUsage: Decodable { + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheWriteTokens: Int? + let reasoningTokens: Int? + } + + struct ModelBucket: Decodable { + let usage: ModelUsage? + } + + struct Data: Decodable { + let modelMetrics: [String: ModelBucket]? + } + + let type: String? + let timestamp: String? + let data: Data? + } + + public static func scan( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil) -> CostUsageTokenSnapshot? + { + try? self.scanCancellable( + environment: environment, + fileManager: fileManager, + historyDays: historyDays, + now: now, + calendar: calendar, + modelsDevCacheRoot: modelsDevCacheRoot) + } + + public static func scanCancellable( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default, + historyDays: Int = defaultHistoryDays, + now: Date = Date(), + calendar: Calendar = .current, + modelsDevCacheRoot: URL? = nil, + checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + { + try checkCancellation() + let days = max(1, historyDays) + let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let root = self.homeURL(environment: environment) + .appendingPathComponent("session-state", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles]) + else { + return nil + } + + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + let decoder = JSONDecoder() + let iso8601Fractional = ISO8601DateFormatter() + iso8601Fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let iso8601 = ISO8601DateFormatter() + let parseTimestamp: (String) -> Date? = { raw in + iso8601Fractional.date(from: raw) ?? iso8601.date(from: raw) + } + + var events: [UnifiedUsageEvent] = [] + var visitedFiles = 0 + var visitedBytes = 0 + + while let url = enumerator.nextObject() as? URL { + try checkCancellation() + guard url.pathExtension.lowercased() == "jsonl", + url.lastPathComponent == "events.jsonl" + else { + continue + } + guard visitedFiles < self.maximumFiles else { break } + let resourceValues = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) + guard resourceValues?.isRegularFile == true else { continue } + if let modified = resourceValues?.contentModificationDate, modified < start { + continue + } + let size = max(0, resourceValues?.fileSize ?? 0) + guard size <= self.maximumBytes - visitedBytes else { break } + visitedFiles += 1 + visitedBytes += size + + do { + // Keep only the latest event carrying a modelMetrics rollup (the shutdown summary, + // or the most recent in-progress snapshot), then fold each model bucket once. + var latest: WireEvent? + try CostUsageJsonl.scan( + fileURL: url, + maxLineBytes: 1024 * 1024, + prefixBytes: 1024 * 1024, + checkCancellation: checkCancellation) + { line in + guard !line.wasTruncated, + let event = try? decoder.decode(WireEvent.self, from: line.bytes), + let metrics = event.data?.modelMetrics, + !metrics.isEmpty + else { + return + } + latest = event + } + + guard let event = latest, let metrics = event.data?.modelMetrics else { continue } + let eventDate = event.timestamp.flatMap(parseTimestamp) + ?? resourceValues?.contentModificationDate + ?? now + let eventDay = calendar.startOfDay(for: eventDate) + guard eventDay >= start, eventDay <= end else { continue } + let dayKey = CostUsageLocalDay.key(from: eventDay, calendar: calendar) + for (rawModel, bucket) in metrics { + guard let usage = bucket.usage else { continue } + let model = Self.normalizeModel(rawModel) + events.append(UnifiedUsageEvent( + day: dayKey, + model: model, + billingProviderID: Self.billingProvider(for: model), + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: nil, + cacheReadTokens: usage.cacheReadTokens, + cacheCreationTokens: usage.cacheWriteTokens, + reasoningTokens: usage.reasoningTokens, + pricingProviderIDs: Self.pricingProviderIDs(for: model))) + } + } catch is CancellationError { + throw CancellationError() + } catch { + continue + } + } + + return UsageEventAggregator.aggregate( + events: events, + historyDays: days, + now: now, + options: .init(historyLabel: "GitHub Copilot", modelsDevCacheRoot: modelsDevCacheRoot)) + } + + public static func homeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[self.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".copilot", isDirectory: true) + } + + /// Traces the model to the real vendor that bills it. Copilot is a harness: the model name is + /// the billing evidence, so a Claude model is priced at Anthropic's rate, a GPT model at + /// OpenAI's, and so on. + private static func pricingProviderIDs(for model: String) -> [String] { + if model.hasPrefix("claude-") { return ["anthropic"] } + if model.hasPrefix("gpt-") || model.hasPrefix("o1") || model.hasPrefix("o3") || model.hasPrefix("o4") { + return ["openai"] + } + if model.hasPrefix("gemini-") { return ["google"] } + return [] + } + + private static func billingProvider(for model: String) -> String? { + if model.hasPrefix("claude-") { return UsageProvider.claude.rawValue } + if model.hasPrefix("gpt-") || model.hasPrefix("o1") || model.hasPrefix("o3") || model.hasPrefix("o4") { + return UsageProvider.openai.rawValue + } + if model.hasPrefix("gemini-") { return UsageProvider.gemini.rawValue } + return UsageProvider.copilot.rawValue + } + + /// Normalizes Copilot's model ids to the pricing keys used by the built-in tables and + /// models.dev (e.g. `claude-haiku-4.5` -> `claude-haiku-4-5`). + private static func normalizeModel(_ raw: String) -> String { + var model = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if model.isEmpty { return "unknown" } + // Version separators in Claude ids are dots in Copilot but dashes in the pricing tables. + if model.hasPrefix("claude-") { + model = model.replacingOccurrences(of: ".", with: "-") + } + return model + } +} diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift b/Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift index 87c2371f96..d4f03fec99 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorLocalActivityScanner.swift @@ -97,7 +97,11 @@ public enum CursorLocalActivityScanner { sqlite3_bind_int64(stmt, 1, startMs) sqlite3_bind_int64(stmt, 2, endMs) - var modelsByDay: [String: Set] = [:] + // Degraded source: emit one model-only event per distinct (day, model). The aggregator + // surfaces these as token-less entries; we de-duplicate here so a busy day does not turn + // into tens of thousands of identical events. + var seen: Set = [] + var events: [UnifiedUsageEvent] = [] while true { try checkCancellation() let stepResult = sqlite3_step(stmt) @@ -110,39 +114,21 @@ public enum CursorLocalActivityScanner { guard !model.isEmpty else { continue } let day = calendar.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(seconds))) let dayKey = CostUsageLocalDay.key(from: day, calendar: calendar) - modelsByDay[dayKey, default: []].insert(model) + let dedupKey = "\(dayKey)\u{1f}\(model)" + guard seen.insert(dedupKey).inserted else { continue } + events.append(UnifiedUsageEvent( + day: dayKey, + model: model, + billingProviderID: UsageProvider.cursor.rawValue)) } - guard !modelsByDay.isEmpty else { return nil } - let daily = modelsByDay.keys.sorted().map { dayKey in - let models = (modelsByDay[dayKey] ?? []).sorted { - $0.localizedCaseInsensitiveCompare($1) == .orderedAscending - } - return CostUsageDailyReport.Entry( - date: dayKey, - inputTokens: nil, - outputTokens: nil, - cacheReadTokens: nil, - cacheCreationTokens: nil, - totalTokens: nil, - requestCount: nil, - costUSD: nil, - modelsUsed: models, - modelBreakdowns: nil) - } - return CostUsageTokenSnapshot( - sessionTokens: nil, - sessionCostUSD: nil, - last30DaysTokens: nil, - last30DaysCostUSD: nil, - last30DaysRequests: nil, - currencyCode: "XXX", + return UsageEventAggregator.aggregate( + events: events, historyDays: days, - historyCoverageIsEstablished: true, - historyLabel: "Cursor", - costSource: .estimated, - daily: daily, - updatedAt: now) + now: now, + options: .init( + historyLabel: "Cursor", + defaultBillingProviderID: UsageProvider.cursor.rawValue)) } } #endif diff --git a/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift index e7baf37a50..a4bafccfb2 100644 --- a/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift +++ b/Sources/CodexBarCore/Providers/LocalHistory/LocalHistoryBuiltInScanners.swift @@ -18,6 +18,7 @@ public enum LocalHistoryBuiltInScanners { AntigravityLocalHistoryScanner(), QwenCodeLocalHistoryScanner(), ZcodeLocalHistoryScanner(), + CopilotLocalHistoryScanner(), ] #if canImport(SQLite3) || canImport(CSQLite3) scanners.append(CursorLocalHistoryScanner()) @@ -177,6 +178,28 @@ public struct ZcodeLocalHistoryScanner: LocalHistoryScanning { } } +public struct CopilotLocalHistoryScanner: LocalHistoryScanning { + public init() {} + public let source: ProviderLocalHistorySource = .copilot + public let displayName = "GitHub Copilot" + public let homeEnvironmentKey: String? = CopilotSessionScanner.homeEnvironmentKey + + public func homeURL(environment: [String: String]) -> URL? { + CopilotSessionScanner.homeURL(environment: environment) + } + + public func scan(context: LocalHistoryScanContext) throws -> CostUsageTokenSnapshot? { + try CopilotSessionScanner.scanCancellable( + environment: context.environment, + fileManager: context.fileManager, + historyDays: context.historyDays, + now: context.now, + calendar: context.calendar, + modelsDevCacheRoot: context.modelsDevCacheRoot, + checkCancellation: context.checkCancellation) + } +} + #if canImport(SQLite3) || canImport(CSQLite3) public struct CursorLocalHistoryScanner: LocalHistoryScanning { public init() {} diff --git a/Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift b/Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift new file mode 100644 index 0000000000..46c0ff2d5f --- /dev/null +++ b/Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift @@ -0,0 +1,80 @@ +import Foundation + +/// A single normalized usage record produced by a thin per-tool parser. +/// +/// This is the seam that makes the local-history framework cheap to extend, modeled on +/// tokscale's `UnifiedMessage`: a tool adapter's only job is to turn its own files into a stream +/// of `UnifiedUsageEvent`s. Aggregation, cached-prefix normalization, pricing, and snapshot +/// construction all live in one shared engine (`UsageEventAggregator`), so a new tool never +/// re-implements that logic — and can't forget it. +/// +/// An event represents one billable unit (usually one request/message) on a single local day for +/// a single model. Token fields are optional because some tools (Cursor, Trae) do not mirror +/// token usage locally; a parser for such a tool emits *degraded* events with only a model and a +/// day, and the engine surfaces them as model-only entries without fabricating numbers. +public struct UnifiedUsageEvent: Sendable, Equatable { + /// Local day the usage occurred on, in `yyyy-MM-dd` form (see `CostUsageLocalDay`). + public var day: String + /// Model identifier as recorded by the tool (already normalized to the pricing key, e.g. + /// lowercase for models.dev). `"unknown"` when the tool did not record one. + public var model: String + /// Billing ownership evidence reported by the source record (e.g. Z.ai, `bigmodel`). Optional + /// because many formats do not retain routing. Never guessed from the model name. + public var billingProviderID: String? + + /// Raw input tokens as the tool reports them. May or may not include the cached prefix — + /// see `totalTokens` for how the engine disambiguates. + public var inputTokens: Int? + public var outputTokens: Int? + /// Total tokens as the tool reports them, used to detect whether `inputTokens` already + /// includes the cached prefix (input + output == total) or excludes it. + public var totalTokens: Int? + public var cacheReadTokens: Int? + public var cacheCreationTokens: Int? + /// Reasoning ("thinking") tokens. Always a sub-bucket of `outputTokens`, never added on top. + public var reasoningTokens: Int? + + /// Cost already reported by the provider for this unit, when the source retains it. Most + /// local formats do not, so pricing normally happens in the engine against models.dev. + public var providerCostUSD: Double? + + /// Ordered models.dev provider IDs to price this event against, from structured source + /// evidence (never inferred from the model name). Empty means "do not price" (e.g. degraded + /// sources or tools whose plan is flat and reported elsewhere). + public var pricingProviderIDs: [String] + + public init( + day: String, + model: String, + billingProviderID: String? = nil, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + totalTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, + providerCostUSD: Double? = nil, + pricingProviderIDs: [String] = []) + { + self.day = day + self.model = model + self.billingProviderID = billingProviderID + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens + self.providerCostUSD = providerCostUSD + self.pricingProviderIDs = pricingProviderIDs + } + + /// Whether this event carries any billable token information. Degraded (model-only) events + /// return false and are surfaced without token totals. + public var hasTokenUsage: Bool { + (self.inputTokens ?? 0) > 0 + || (self.outputTokens ?? 0) > 0 + || (self.cacheReadTokens ?? 0) > 0 + || (self.totalTokens ?? 0) > 0 + } +} diff --git a/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift b/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift new file mode 100644 index 0000000000..7f3080fd7c --- /dev/null +++ b/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift @@ -0,0 +1,298 @@ +import Foundation + +/// Shared aggregation engine for the local-history framework. +/// +/// Every thin tool parser emits `UnifiedUsageEvent`s; this engine is the single place that turns +/// them into a dashboard-ready `CostUsageTokenSnapshot`. Centralizing the logic here is what +/// keeps per-tool adapters small and correct — the cached-prefix normalization, day/model +/// bucketing, models.dev pricing, and partial-pricing handling each exist exactly once instead of +/// being copied (and occasionally dropped) across scanners. +public enum UsageEventAggregator { + /// Accumulates one (day, model) bucket. + private struct Bucket { + var input = 0 + var output = 0 + var cacheRead = 0 + var cacheCreation = 0 + var reasoning = 0 + var requests = 0 + var cost = 0.0 + var sawCost = false + /// Billing ownership evidence from the source record (per-model). Optional; falls back to + /// the tool-level default when the source did not retain one. + var billingProviderID: String? + /// Set when a token-carrying event in this bucket could not be priced, so the merged day + /// cost stays nil rather than presenting a partial subtotal as the complete total. + var sawUnpricedUsage = false + /// Set when at least one event in this bucket carried token usage. Buckets built only + /// from degraded (model-only) events stay token-less. + var sawTokenUsage = false + + var total: Int? { + guard self.sawTokenUsage else { return nil } + guard let inputAndCache = Self.adding(self.input, self.cacheRead) else { return nil } + return Self.adding(inputAndCache, self.output) + } + + fileprivate static func adding(_ lhs: Int, _ rhs: Int) -> Int? { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } + } + + private struct DayModelKey: Hashable { + let day: String + let model: String + } + + /// Options that vary per tool rather than per event. + public struct Options: Sendable { + /// Display label for the snapshot (`historyLabel`), e.g. "ZCode". + public var historyLabel: String + /// Fallback billing owner written onto each model breakdown when an event did not carry + /// its own `billingProviderID`. + public var defaultBillingProviderID: String? + /// `costSource` for the produced snapshot. + public var costSource: CostUsageCostSource + /// Root used to resolve the models.dev pricing catalog. Nil disables pricing. + public var modelsDevCacheRoot: URL? + + public init( + historyLabel: String, + defaultBillingProviderID: String? = nil, + costSource: CostUsageCostSource = .estimated, + modelsDevCacheRoot: URL? = nil) + { + self.historyLabel = historyLabel + self.defaultBillingProviderID = defaultBillingProviderID + self.costSource = costSource + self.modelsDevCacheRoot = modelsDevCacheRoot + } + } + + /// Aggregates a stream of events into a snapshot, or returns nil when there is nothing to + /// report. `historyDays` is recorded on the snapshot; day-window filtering is the parser's + /// responsibility (it knows each file's timestamps). + /// + /// Internal because it prices against the module-internal `ModelsDevCatalog`; tool scanners + /// live in the same module, so this is the intended call surface. + static func aggregate( + events: [UnifiedUsageEvent], + historyDays: Int, + now: Date, + options: Options) -> CostUsageTokenSnapshot? + { + let modelsDevCacheRoot = options.modelsDevCacheRoot + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) + var buckets: [DayModelKey: Bucket] = [:] + var order: [DayModelKey] = [] + for event in events { + let key = DayModelKey(day: event.day, model: event.model) + if buckets[key] == nil { + buckets[key] = Bucket() + order.append(key) + } + guard var bucket = buckets[key] else { continue } + Self.add(event, to: &bucket, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) + buckets[key] = bucket + } + guard !buckets.isEmpty else { return nil } + + let daily = Self.makeDaily(buckets: buckets, order: order, options: options) + guard !daily.isEmpty else { return nil } + + let tokenEntries = daily.filter { $0.totalTokens != nil } + let totalTokens = tokenEntries.isEmpty ? nil : Self.sum(tokenEntries.compactMap(\.totalTokens)) + let requests = tokenEntries.isEmpty ? nil : Self.sum(tokenEntries.compactMap(\.requestCount)) + let costs = daily.compactMap(\.costUSD) + let totalCost = costs.isEmpty ? nil : costs.reduce(0, +) + + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: totalCost, + last30DaysRequests: requests, + currencyCode: costs.isEmpty ? "XXX" : "USD", + historyDays: historyDays, + historyCoverageIsEstablished: true, + historyLabel: options.historyLabel, + costSource: options.costSource, + daily: daily, + updatedAt: now) + } + + /// Folds one event into its bucket, applying cached-prefix normalization and pricing. + private static func add( + _ event: UnifiedUsageEvent, + to bucket: inout Bucket, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) + { + guard event.hasTokenUsage else { return } + bucket.sawTokenUsage = true + if bucket.billingProviderID == nil, let evidence = event.billingProviderID { + bucket.billingProviderID = evidence + } + + let rawInput = Self.valid(event.inputTokens) + let output = Self.valid(event.outputTokens) + let cacheRead = Self.valid(event.cacheReadTokens) + let cacheCreation = Self.valid(event.cacheCreationTokens) + let reasoning = Self.valid(event.reasoningTokens) + // Reasoning is billed as output but is a sub-bucket of it; only add it when the source + // reports output and reasoning separately (e.g. Gemini thoughts) rather than folded in. + let billingOutput = output + let uncachedInput = Self.uncachedInput( + rawInput: rawInput, + output: billingOutput, + cacheRead: cacheRead, + total: event.totalTokens) + + guard let nextInput = Bucket.adding(bucket.input, uncachedInput), + let nextOutput = Bucket.adding(bucket.output, billingOutput), + let nextCacheRead = Bucket.adding(bucket.cacheRead, cacheRead), + let nextCacheCreation = Bucket.adding(bucket.cacheCreation, cacheCreation), + let nextReasoning = Bucket.adding(bucket.reasoning, reasoning), + let nextRequests = Bucket.adding(bucket.requests, 1) + else { + return + } + bucket.input = nextInput + bucket.output = nextOutput + bucket.cacheRead = nextCacheRead + bucket.cacheCreation = nextCacheCreation + bucket.reasoning = nextReasoning + bucket.requests = nextRequests + + if let providerCost = event.providerCostUSD, providerCost.isFinite { + // The provider already priced this unit; trust it and do not re-price. + let next = bucket.cost + providerCost + if next.isFinite { + bucket.cost = next + bucket.sawCost = true + } + return + } + + guard !event.pricingProviderIDs.isEmpty else { + bucket.sawUnpricedUsage = true + return + } + if let cost = CostUsagePricing.modelsDevCostUSD( + request: .init( + providerIDs: event.pricingProviderIDs, + model: event.model, + inputTokens: uncachedInput, + cacheReadInputTokens: cacheRead, + outputTokens: billingOutput), + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot), + cost.isFinite + { + let next = bucket.cost + cost + if next.isFinite { + bucket.cost = next + bucket.sawCost = true + } + } else { + bucket.sawUnpricedUsage = true + } + } + + /// Splits a tool's reported input into its uncached remainder, cross-checking against the + /// reported total the same way tokscale does: when `input + output == total` the input already + /// includes the cached prefix; when `input + cacheRead + output == total` it is already + /// uncached. Falls back to subtracting the cache when the total is missing or inconsistent. + private static func uncachedInput(rawInput: Int, output: Int, cacheRead: Int, total: Int?) -> Int { + guard let total else { return max(0, rawInput - cacheRead) } + if rawInput + output == total { + return max(0, rawInput - cacheRead) + } + if rawInput + cacheRead + output == total { + return rawInput + } + return max(0, rawInput - cacheRead) + } + + private static func makeDaily( + buckets: [DayModelKey: Bucket], + order: [DayModelKey], + options: Options) -> [CostUsageDailyReport.Entry] + { + let byDay = Dictionary(grouping: order, by: \.day) + return byDay.keys.sorted().map { day in + let dayKeys = (byDay[day] ?? []).sorted { + $0.model.localizedCaseInsensitiveCompare($1.model) == .orderedAscending + } + var total = Bucket() + var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] + var modelsUsed: [String] = [] + for key in dayKeys { + guard let value = buckets[key] else { continue } + modelsUsed.append(key.model) + if value.sawTokenUsage { + breakdowns.append(CostUsageDailyReport.ModelBreakdown( + modelName: key.model, + billingProviderID: value.billingProviderID ?? options.defaultBillingProviderID, + costUSD: value.sawCost ? value.cost : nil, + totalTokens: value.total, + inputTokens: value.input, + cacheReadTokens: value.cacheRead, + cacheCreationTokens: value.cacheCreation, + outputTokens: value.output, + reasoningTokens: value.reasoning, + requestCount: value.requests)) + } + Self.merge(value, into: &total) + } + let dayCost = total.sawCost && !total.sawUnpricedUsage ? total.cost : nil + let hasTokens = total.sawTokenUsage + return CostUsageDailyReport.Entry( + date: day, + inputTokens: hasTokens ? total.input : nil, + outputTokens: hasTokens ? total.output : nil, + cacheReadTokens: hasTokens ? total.cacheRead : nil, + cacheCreationTokens: hasTokens ? total.cacheCreation : nil, + totalTokens: hasTokens ? total.total : nil, + requestCount: hasTokens ? total.requests : nil, + costUSD: dayCost, + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: breakdowns.isEmpty ? nil : breakdowns) + } + } + + private static func merge(_ other: Bucket, into bucket: inout Bucket) { + guard other.sawTokenUsage else { return } + bucket.sawTokenUsage = true + if let v = Bucket.adding(bucket.input, other.input) { bucket.input = v } + if let v = Bucket.adding(bucket.output, other.output) { bucket.output = v } + if let v = Bucket.adding(bucket.cacheRead, other.cacheRead) { bucket.cacheRead = v } + if let v = Bucket.adding(bucket.cacheCreation, other.cacheCreation) { bucket.cacheCreation = v } + if let v = Bucket.adding(bucket.reasoning, other.reasoning) { bucket.reasoning = v } + if let v = Bucket.adding(bucket.requests, other.requests) { bucket.requests = v } + if other.sawCost, other.cost.isFinite { + let next = bucket.cost + other.cost + if next.isFinite { + bucket.cost = next + bucket.sawCost = true + } + } + bucket.sawUnpricedUsage = bucket.sawUnpricedUsage || other.sawUnpricedUsage + } + + private static func valid(_ value: Int?) -> Int { + guard let value, value >= 0 else { return 0 } + return value + } + + private static func sum(_ values: [Int]) -> Int? { + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index a2941cb35d..3c1fdd3f54 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -20,6 +20,7 @@ public struct ProviderLocalHistorySource: RawRepresentable, Hashable, Sendable { public static let openCode = Self(rawValue: "openCode") public static let qwenCode = Self(rawValue: "qwenCode") public static let zcode = Self(rawValue: "zcode") + public static let copilot = Self(rawValue: "copilot") /// Degraded sources: the tool is recognized and model activity is reported, but no per-request /// token usage is available locally (billing is server-side). public static let cursorLocal = Self(rawValue: "cursorLocal") @@ -33,6 +34,7 @@ public struct ProviderLocalHistorySource: RawRepresentable, Hashable, Sendable { .openCode, .qwenCode, .zcode, + .copilot, .cursorLocal, .traeLocal, ] diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift index 6a6ded35a4..7a1e0ce827 100644 --- a/Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCodeSessionScanner.swift @@ -1,11 +1,15 @@ import Foundation -/// Reads Qwen Code's local JSONL history. +/// Reads Qwen Code's local JSONL history as a thin parser over the shared aggregation engine. /// -/// Qwen stores assistant messages under `~/.qwen/projects/*/chats/*.jsonl`. -/// The format is also consumed by tokscale. The scanner is deliberately -/// bounded and cancellable so enabling a long history cannot turn dashboard -/// refresh into an unbounded filesystem crawl. +/// Qwen stores assistant messages under `~/.qwen/projects/*/chats/*.jsonl`. The format is also +/// consumed by tokscale. The scanner is deliberately bounded and cancellable so enabling a long +/// history cannot turn dashboard refresh into an unbounded filesystem crawl. +/// +/// This scanner only walks files and maps each assistant message to a `UnifiedUsageEvent`. The +/// cached-prefix split (Qwen's `promptTokenCount` already includes `cachedContentTokenCount`), +/// models.dev pricing, reasoning/output accounting, day bucketing, and snapshot construction are +/// all handled once by `UsageEventAggregator`. public enum QwenCodeSessionScanner { public static let homeEnvironmentKey = "QWEN_HOME" public static let defaultHistoryDays = 30 @@ -26,120 +30,6 @@ public enum QwenCodeSessionScanner { let usageMetadata: Usage? } - private struct DayModelKey: Hashable { - let day: String - let model: String - } - - private struct TokenAccumulator { - var input = 0 - var output = 0 - var cacheRead = 0 - var reasoning = 0 - var requests = 0 - var cost = 0.0 - var sawCost = false - /// Set when at least one message in this accumulator carried billable usage but had no - /// resolvable price. The merged day/entry cost must then stay nil so the dashboard does not - /// present a partial subtotal as the complete day total. - var sawUnpricedUsage = false - - mutating func add( - usage: WireMessage.Usage, - model: String, - modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Bool - { - // Gemini-style `promptTokenCount` already includes the cached prefix - // (`cachedContentTokenCount` is a subset of it). Passing the full prompt as input AND the - // cached portion again as cache-read would bill the cached tokens twice, so split the - // prompt into its uncached remainder. - guard let promptTotal = Self.valid(usage.promptTokenCount), - let candidates = Self.valid(usage.candidatesTokenCount), - let reasoning = Self.valid(usage.thoughtsTokenCount), - let cacheRead = Self.valid(usage.cachedContentTokenCount), - let billingOutput = Self.adding(candidates, reasoning) - else { - return false - } - let uncachedInput = max(0, promptTotal - cacheRead) - guard let nextInput = Self.adding(self.input, uncachedInput), - let nextOutput = Self.adding(self.output, billingOutput), - let nextCacheRead = Self.adding(self.cacheRead, cacheRead), - let nextReasoning = Self.adding(self.reasoning, reasoning), - let nextRequests = Self.adding(self.requests, 1) - else { - return false - } - guard promptTotal > 0 || billingOutput > 0 || cacheRead > 0 else { return false } - self.input = nextInput - self.output = nextOutput - self.cacheRead = nextCacheRead - self.reasoning = nextReasoning - self.requests = nextRequests - if let cost = CostUsagePricing.modelsDevCostUSD( - request: .init( - providerIDs: ["alibaba", "alibaba-cn"], - model: model, - inputTokens: uncachedInput, - cacheReadInputTokens: cacheRead, - outputTokens: billingOutput), - catalog: modelsDevCatalog, - cacheRoot: modelsDevCacheRoot), - cost.isFinite - { - let nextCost = self.cost + cost - if nextCost.isFinite { - self.cost = nextCost - self.sawCost = true - } - } else { - self.sawUnpricedUsage = true - } - return true - } - - mutating func merge(_ other: Self) -> Bool { - guard let input = Self.adding(self.input, other.input), - let output = Self.adding(self.output, other.output), - let cacheRead = Self.adding(self.cacheRead, other.cacheRead), - let reasoning = Self.adding(self.reasoning, other.reasoning), - let requests = Self.adding(self.requests, other.requests) - else { - return false - } - self.input = input - self.output = output - self.cacheRead = cacheRead - self.reasoning = reasoning - self.requests = requests - if other.sawCost { - let nextCost = self.cost + other.cost - if other.cost.isFinite, nextCost.isFinite { - self.cost = nextCost - self.sawCost = true - } - } - self.sawUnpricedUsage = self.sawUnpricedUsage || other.sawUnpricedUsage - return true - } - - var total: Int? { - guard let inputAndCache = Self.adding(self.input, self.cacheRead) else { return nil } - return Self.adding(inputAndCache, self.output) - } - - private static func valid(_ value: Int?) -> Int? { - guard let value, value >= 0 else { return 0 } - return value - } - - private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { - let result = lhs.addingReportingOverflow(rhs) - return result.overflow ? nil : result.partialValue - } - } - public static func scan( environment: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default, @@ -191,8 +81,8 @@ public enum QwenCodeSessionScanner { let parseTimestamp: (String) -> Date? = { raw in iso8601Fractional.date(from: raw) ?? iso8601.date(from: raw) } - let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) - var values: [DayModelKey: TokenAccumulator] = [:] + + var events: [UnifiedUsageEvent] = [] var visitedFiles = 0 var visitedBytes = 0 @@ -237,19 +127,23 @@ public enum QwenCodeSessionScanner { let model = message.model? .trimmingCharacters(in: .whitespacesAndNewlines) let normalizedModel = model?.isEmpty == false ? model! : "unknown" - let key = DayModelKey( + // Reasoning ("thoughts") is billed as output. Fold it into the event's output so + // the engine prices output correctly, and also pass it through as the reasoning + // sub-bucket for display. `promptTokenCount` carries no separate total, so the + // engine falls back to subtracting the cached prefix. + let candidates = max(0, usage.candidatesTokenCount ?? 0) + let thoughts = max(0, usage.thoughtsTokenCount ?? 0) + events.append(UnifiedUsageEvent( day: CostUsageLocalDay.key(from: eventDay, calendar: calendar), - model: normalizedModel) - var accumulator = values[key] ?? TokenAccumulator() - guard accumulator.add( - usage: usage, model: normalizedModel, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - else { - return - } - values[key] = accumulator + billingProviderID: UsageProvider.qwencloud.rawValue, + inputTokens: usage.promptTokenCount, + outputTokens: candidates + thoughts, + totalTokens: nil, + cacheReadTokens: usage.cachedContentTokenCount, + cacheCreationTokens: nil, + reasoningTokens: thoughts, + pricingProviderIDs: ["alibaba", "alibaba-cn"])) } } catch is CancellationError { throw CancellationError() @@ -258,27 +152,14 @@ public enum QwenCodeSessionScanner { } } - guard !values.isEmpty else { return nil } - let daily = self.makeDaily(values: values) - guard let totalTokens = self.sum(daily.compactMap(\.totalTokens)), - let requests = self.sum(daily.compactMap(\.requestCount)) - else { - return nil - } - let costs = daily.compactMap(\.costUSD) - return CostUsageTokenSnapshot( - sessionTokens: nil, - sessionCostUSD: nil, - last30DaysTokens: totalTokens, - last30DaysCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), - last30DaysRequests: requests, - currencyCode: costs.isEmpty ? "XXX" : "USD", + return UsageEventAggregator.aggregate( + events: events, historyDays: days, - historyCoverageIsEstablished: true, - historyLabel: "Qwen Code CLI", - costSource: .estimated, - daily: daily, - updatedAt: now) + now: now, + options: .init( + historyLabel: "Qwen Code CLI", + defaultBillingProviderID: UsageProvider.qwencloud.rawValue, + modelsDevCacheRoot: modelsDevCacheRoot)) } public static func homeURL( @@ -293,56 +174,4 @@ public enum QwenCodeSessionScanner { return FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".qwen", isDirectory: true) } - - private static func makeDaily(values: [DayModelKey: TokenAccumulator]) - -> [CostUsageDailyReport.Entry] - { - let byDay = Dictionary(grouping: values, by: \.key.day) - return byDay.keys.sorted().compactMap { day in - let models = (byDay[day] ?? []).sorted { - $0.key.model.localizedCaseInsensitiveCompare($1.key.model) == .orderedAscending - } - var total = TokenAccumulator() - var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] - for (key, value) in models { - guard let modelTotal = value.total, total.merge(value) else { return nil } - breakdowns.append(CostUsageDailyReport.ModelBreakdown( - modelName: key.model, - billingProviderID: UsageProvider.qwencloud.rawValue, - costUSD: value.sawCost ? value.cost : nil, - totalTokens: modelTotal, - inputTokens: value.input, - cacheReadTokens: value.cacheRead, - cacheCreationTokens: 0, - outputTokens: value.output, - reasoningTokens: value.reasoning, - requestCount: value.requests)) - } - guard let dayTotal = total.total else { return nil } - // Withhold the day cost whenever any contributing model could not be priced, so the - // dashboard treats the day as partially priced instead of a confident complete total. - let dayCost = total.sawCost && !total.sawUnpricedUsage ? total.cost : nil - return CostUsageDailyReport.Entry( - date: day, - inputTokens: total.input, - outputTokens: total.output, - cacheReadTokens: total.cacheRead, - cacheCreationTokens: 0, - totalTokens: dayTotal, - requestCount: total.requests, - costUSD: dayCost, - modelsUsed: breakdowns.map(\.modelName), - modelBreakdowns: breakdowns) - } - } - - private static func sum(_ values: [Int]) -> Int? { - var result = 0 - for value in values { - let addition = result.addingReportingOverflow(value) - guard !addition.overflow else { return nil } - result = addition.partialValue - } - return result - } } diff --git a/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift b/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift index ee8334d9b1..0c336e3826 100644 --- a/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift +++ b/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift @@ -94,34 +94,18 @@ public enum TraeLocalActivityScanner { guard !models.isEmpty || lastActive != nil else { return nil } // Anchor the (single) record on the last active day when known, otherwise today. Trae - // exposes no per-day token history, so there is exactly one degraded entry. + // exposes no per-day token history, so there is exactly one degraded entry per model. let anchor = lastActive ?? now let anchorDay = calendar.startOfDay(for: anchor) let dayKey = CostUsageLocalDay.key(from: anchorDay, calendar: calendar) - let entry = CostUsageDailyReport.Entry( - date: dayKey, - inputTokens: nil, - outputTokens: nil, - cacheReadTokens: nil, - cacheCreationTokens: nil, - totalTokens: nil, - requestCount: nil, - costUSD: nil, - modelsUsed: models.isEmpty ? nil : models, - modelBreakdowns: nil) - return CostUsageTokenSnapshot( - sessionTokens: nil, - sessionCostUSD: nil, - last30DaysTokens: nil, - last30DaysCostUSD: nil, - last30DaysRequests: nil, - currencyCode: "XXX", + let events = models.map { model in + UnifiedUsageEvent(day: dayKey, model: model) + } + return UsageEventAggregator.aggregate( + events: events, historyDays: days, - historyCoverageIsEstablished: true, - historyLabel: "Trae", - costSource: .estimated, - daily: [entry], - updatedAt: now) + now: now, + options: .init(historyLabel: "Trae")) } /// Reads the per-agent selected-model map and returns the de-duplicated, cleaned model names diff --git a/Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift index 151f6cc972..cb3a60b87d 100644 --- a/Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Zcode/ZcodeSessionScanner.swift @@ -1,6 +1,6 @@ import Foundation -/// Reads ZCode's local JSONL rollout history. +/// Reads ZCode's local JSONL rollout history as a thin parser over the shared aggregation engine. /// /// ZCode stores one file per session under `~/.zcode/cli/rollout/model-io-sess_*.jsonl`. Each /// line is a single billable request with `completedAt` (RFC 3339), `model.modelId` (e.g. @@ -8,12 +8,10 @@ import Foundation /// plan), and `response.usage` carrying `inputTokens`, `outputTokens`, `totalTokens`, /// `cacheReadTokens`, and `cacheWriteTokens`. /// -/// ZCode's `inputTokens` already includes the cached prefix, mirroring tokscale's zcode handling: -/// `inputTokens + outputTokens == totalTokens` even when `cacheReadTokens > 0`. We therefore -/// cross-check against `totalTokens` and split the prompt into its uncached remainder so cached -/// tokens are never billed twice. Usage is priced at the vendor's official Z.ai API rate (the -/// coding-plan catalog entries are zero-priced because the plan is a flat subscription); only -/// when no official rate is known does the day stay partially priced. +/// This scanner only walks files and maps each request to a `UnifiedUsageEvent`. The cached-prefix +/// double-count (ZCode's `inputTokens` already includes the cached prefix), models.dev pricing at +/// the official Z.ai rate, day bucketing, and snapshot construction are all handled once by +/// `UsageEventAggregator` — nothing here re-implements them. public enum ZcodeSessionScanner { public static let homeEnvironmentKey = "ZCODE_HOME" public static let defaultHistoryDays = 30 @@ -43,133 +41,6 @@ public enum ZcodeSessionScanner { let response: Response? } - private struct DayModelKey: Hashable { - let day: String - let model: String - } - - private struct TokenAccumulator { - var input = 0 - var output = 0 - var cacheRead = 0 - var cacheWrite = 0 - var requests = 0 - var cost = 0.0 - var sawCost = false - /// Set when at least one request in this accumulator carried billable usage but had no - /// resolvable price. The merged day/entry cost then stays nil so the dashboard does not - /// present a partial subtotal as the complete day total. - var sawUnpricedUsage = false - - mutating func add( - usage: WireMessage.Usage, - model: String, - modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Bool - { - guard let rawInput = Self.valid(usage.inputTokens), - let output = Self.valid(usage.outputTokens), - let cacheRead = Self.valid(usage.cacheReadTokens), - let cacheWrite = Self.valid(usage.cacheWriteTokens), - let total = Self.valid(usage.totalTokens) - else { - return false - } - // Normalize the cached-prefix double count. When `input + output == total` the input - // already includes the cached prefix, so the billable uncached input is - // `input - cacheRead`. When `input + cacheRead + output == total` the input is already - // uncached. Fall back to subtracting the cache when total is inconsistent. - let uncachedInput: Int = if let inputPlusOutput = Self.adding(rawInput, output), inputPlusOutput == total { - max(0, rawInput - cacheRead) - } else if let withCache = Self.adding(rawInput, cacheRead), - let withCacheAndOutput = Self.adding(withCache, output), - withCacheAndOutput == total - { - rawInput - } else { - max(0, rawInput - cacheRead) - } - guard let nextInput = Self.adding(self.input, uncachedInput), - let nextOutput = Self.adding(self.output, output), - let nextCacheRead = Self.adding(self.cacheRead, cacheRead), - let nextCacheWrite = Self.adding(self.cacheWrite, cacheWrite), - let nextRequests = Self.adding(self.requests, 1) - else { - return false - } - guard uncachedInput > 0 || output > 0 || cacheRead > 0 else { return false } - self.input = nextInput - self.output = nextOutput - self.cacheRead = nextCacheRead - self.cacheWrite = nextCacheWrite - self.requests = nextRequests - // Price at the official Z.ai API rate (zhipuai is the same vendor's alias). The - // `zai-coding-plan`/`zhipuai-coding-plan` catalogs are intentionally excluded: they are - // zero-priced flat subscriptions, and we report the API-equivalent value to stay - // consistent with how other subscription tools are estimated. - if let cost = CostUsagePricing.modelsDevCostUSD( - request: .init( - providerIDs: ["zai", "zhipuai"], - model: model, - inputTokens: uncachedInput, - cacheReadInputTokens: cacheRead, - outputTokens: output), - catalog: modelsDevCatalog, - cacheRoot: modelsDevCacheRoot), - cost.isFinite - { - let nextCost = self.cost + cost - if nextCost.isFinite { - self.cost = nextCost - self.sawCost = true - } - } else { - self.sawUnpricedUsage = true - } - return true - } - - mutating func merge(_ other: Self) -> Bool { - guard let input = Self.adding(self.input, other.input), - let output = Self.adding(self.output, other.output), - let cacheRead = Self.adding(self.cacheRead, other.cacheRead), - let cacheWrite = Self.adding(self.cacheWrite, other.cacheWrite), - let requests = Self.adding(self.requests, other.requests) - else { - return false - } - self.input = input - self.output = output - self.cacheRead = cacheRead - self.cacheWrite = cacheWrite - self.requests = requests - if other.sawCost { - let nextCost = self.cost + other.cost - if other.cost.isFinite, nextCost.isFinite { - self.cost = nextCost - self.sawCost = true - } - } - self.sawUnpricedUsage = self.sawUnpricedUsage || other.sawUnpricedUsage - return true - } - - var total: Int? { - guard let inputAndCache = Self.adding(self.input, self.cacheRead) else { return nil } - return Self.adding(inputAndCache, self.output) - } - - private static func valid(_ value: Int?) -> Int? { - guard let value, value >= 0 else { return 0 } - return value - } - - private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { - let result = lhs.addingReportingOverflow(rhs) - return result.overflow ? nil : result.partialValue - } - } - public static func scan( environment: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default, @@ -214,15 +85,14 @@ public enum ZcodeSessionScanner { let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end let decoder = JSONDecoder() // ZCode emits RFC 3339 timestamps with fractional seconds (`2026-06-14T17:23:26.382Z`). - // The whole-second formatter rejects them, so try the fractional variant first. let iso8601Fractional = ISO8601DateFormatter() iso8601Fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] let iso8601 = ISO8601DateFormatter() let parseTimestamp: (String) -> Date? = { raw in iso8601Fractional.date(from: raw) ?? iso8601.date(from: raw) } - let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) - var values: [DayModelKey: TokenAccumulator] = [:] + + var events: [UnifiedUsageEvent] = [] var visitedFiles = 0 var visitedBytes = 0 @@ -270,19 +140,18 @@ public enum ZcodeSessionScanner { let normalizedModel = model?.isEmpty == false ? model!.lowercased() : "unknown" - let key = DayModelKey( + events.append(UnifiedUsageEvent( day: CostUsageLocalDay.key(from: eventDay, calendar: calendar), - model: normalizedModel) - var accumulator = values[key] ?? TokenAccumulator() - guard accumulator.add( - usage: usage, model: normalizedModel, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - else { - return - } - values[key] = accumulator + billingProviderID: UsageProvider.zai.rawValue, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + cacheReadTokens: usage.cacheReadTokens, + cacheCreationTokens: usage.cacheWriteTokens, + // Price at the official Z.ai API rate; the zero-priced coding-plan catalogs + // are intentionally excluded so we report the API-equivalent value. + pricingProviderIDs: ["zai", "zhipuai"])) } } catch is CancellationError { throw CancellationError() @@ -291,27 +160,14 @@ public enum ZcodeSessionScanner { } } - guard !values.isEmpty else { return nil } - let daily = self.makeDaily(values: values) - guard let totalTokens = self.sum(daily.compactMap(\.totalTokens)), - let requests = self.sum(daily.compactMap(\.requestCount)) - else { - return nil - } - let costs = daily.compactMap(\.costUSD) - return CostUsageTokenSnapshot( - sessionTokens: nil, - sessionCostUSD: nil, - last30DaysTokens: totalTokens, - last30DaysCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), - last30DaysRequests: requests, - currencyCode: costs.isEmpty ? "XXX" : "USD", + return UsageEventAggregator.aggregate( + events: events, historyDays: days, - historyCoverageIsEstablished: true, - historyLabel: "ZCode", - costSource: .estimated, - daily: daily, - updatedAt: now) + now: now, + options: .init( + historyLabel: "ZCode", + defaultBillingProviderID: UsageProvider.zai.rawValue, + modelsDevCacheRoot: modelsDevCacheRoot)) } public static func homeURL( @@ -326,56 +182,4 @@ public enum ZcodeSessionScanner { return FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".zcode", isDirectory: true) } - - private static func makeDaily(values: [DayModelKey: TokenAccumulator]) - -> [CostUsageDailyReport.Entry] - { - let byDay = Dictionary(grouping: values, by: \.key.day) - return byDay.keys.sorted().compactMap { day in - let models = (byDay[day] ?? []).sorted { - $0.key.model.localizedCaseInsensitiveCompare($1.key.model) == .orderedAscending - } - var total = TokenAccumulator() - var breakdowns: [CostUsageDailyReport.ModelBreakdown] = [] - for (key, value) in models { - guard let modelTotal = value.total, total.merge(value) else { return nil } - breakdowns.append(CostUsageDailyReport.ModelBreakdown( - modelName: key.model, - billingProviderID: UsageProvider.zai.rawValue, - costUSD: value.sawCost ? value.cost : nil, - totalTokens: modelTotal, - inputTokens: value.input, - cacheReadTokens: value.cacheRead, - cacheCreationTokens: value.cacheWrite, - outputTokens: value.output, - reasoningTokens: 0, - requestCount: value.requests)) - } - guard let dayTotal = total.total else { return nil } - // Withhold the day cost whenever any contributing model could not be priced, so the - // dashboard treats the day as partially priced instead of a confident complete total. - let dayCost = total.sawCost && !total.sawUnpricedUsage ? total.cost : nil - return CostUsageDailyReport.Entry( - date: day, - inputTokens: total.input, - outputTokens: total.output, - cacheReadTokens: total.cacheRead, - cacheCreationTokens: total.cacheWrite, - totalTokens: dayTotal, - requestCount: total.requests, - costUSD: dayCost, - modelsUsed: breakdowns.map(\.modelName), - modelBreakdowns: breakdowns) - } - } - - private static func sum(_ values: [Int]) -> Int? { - var result = 0 - for value in values { - let addition = result.addingReportingOverflow(value) - guard !addition.overflow else { return nil } - result = addition.partialValue - } - return result - } } diff --git a/Tests/CodexBarTests/CopilotSessionScannerTests.swift b/Tests/CodexBarTests/CopilotSessionScannerTests.swift new file mode 100644 index 0000000000..bd272d618d --- /dev/null +++ b/Tests/CodexBarTests/CopilotSessionScannerTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing + +@testable import CodexBarCore + +@Suite +struct CopilotSessionScannerTests { + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + return calendar + }() + + @Test + func `scanner reads the session.shutdown model metrics rollup`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let sessionDir = root + .appendingPathComponent("session-state", isDirectory: true) + .appendingPathComponent("sess-1", isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + + // Mirrors the real Copilot layout: per-model usage lives only on the terminal + // session.shutdown event's data.modelMetrics map. + let shutdownUsage = #""usage":{"inputTokens":73595,"outputTokens":1196,"# + + #""cacheReadTokens":48228,"cacheWriteTokens":0,"reasoningTokens":0}"# + let shutdown = """ + {"type":"session.shutdown","timestamp":"2026-07-28T10:00:00.000Z",\ + "data":{"modelMetrics":{"claude-haiku-4.5":{"requests":{"count":3},\(shutdownUsage)}}}} + """ + let lines = [ + #"{"type":"session.start","timestamp":"2026-07-28T09:00:00.000Z"}"#, + shutdown, + ] + try lines.joined(separator: "\n").write( + to: sessionDir.appendingPathComponent("events.jsonl"), + atomically: true, + encoding: .utf8) + + let snapshot = try #require(CopilotSessionScanner.scan( + environment: [CopilotSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true))) + + #expect(snapshot.historyLabel == "GitHub Copilot") + let entry = try #require(snapshot.daily.first) + #expect(entry.totalTokens == 73595 + 1196) + #expect(entry.inputTokens == 73595 - 48228) + #expect(entry.cacheReadTokens == 48228) + let model = try #require(entry.modelBreakdowns?.first) + // Model name is traced to the real vendor: Copilot's claude-haiku-4.5 is billed at + // Anthropic's rate and normalized to the pricing key. + #expect(model.modelName == "claude-haiku-4-5") + #expect(model.billingProviderID == UsageProvider.claude.rawValue) + } + + @Test + func `scanner falls back to the latest in-progress metrics event`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let sessionDir = root + .appendingPathComponent("session-state", isDirectory: true) + .appendingPathComponent("sess-2", isDirectory: true) + try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true) + + // No shutdown event: a still-running session. The most recent modelMetrics snapshot is + // used instead. + let metricsEvent: (String, Int, Int, Int) -> String = { ts, input, output, cache in + """ + {"type":"session.model_metrics","timestamp":"\(ts)",\ + "data":{"modelMetrics":{"gpt-5":{"usage":{"inputTokens":\(input),\ + "outputTokens":\(output),"cacheReadTokens":\(cache),"cacheWriteTokens":0,"reasoningTokens":0}}}}} + """ + } + let lines = [ + metricsEvent("2026-07-28T09:00:00.000Z", 100, 50, 20), + metricsEvent("2026-07-28T09:30:00.000Z", 300, 120, 60), + ] + try lines.joined(separator: "\n").write( + to: sessionDir.appendingPathComponent("events.jsonl"), + atomically: true, + encoding: .utf8) + + let snapshot = try #require(CopilotSessionScanner.scan( + environment: [CopilotSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true))) + + let entry = try #require(snapshot.daily.first) + // Latest snapshot wins (300+120), not the sum of both. + #expect(entry.totalTokens == 300 + 120) + #expect(entry.inputTokens == 300 - 60) + let model = try #require(entry.modelBreakdowns?.first) + #expect(model.billingProviderID == UsageProvider.openai.rawValue) + } + + @Test + func `scanner returns nil when no metrics exist`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let snapshot = CopilotSessionScanner.scan( + environment: [CopilotSessionScanner.homeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_283_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("pricing-cache", isDirectory: true)) + #expect(snapshot == nil) + } +} diff --git a/Tests/CodexBarTests/UsageEventAggregatorTests.swift b/Tests/CodexBarTests/UsageEventAggregatorTests.swift new file mode 100644 index 0000000000..0f52cde835 --- /dev/null +++ b/Tests/CodexBarTests/UsageEventAggregatorTests.swift @@ -0,0 +1,136 @@ +import Foundation +import Testing + +@testable import CodexBarCore + +@Suite +struct UsageEventAggregatorTests { + private let now = Date(timeIntervalSince1970: 1_785_283_200) + + @Test + func `cache-inclusive input is split using the total cross-check`() { + // input(1100) + output(500) == total(1600), so the cached prefix is already inside input. + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "glm-5.2", + inputTokens: 1100, + outputTokens: 500, + totalTokens: 1600, + cacheReadTokens: 900, + pricingProviderIDs: [])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + let entry = snapshot?.daily.first + #expect(entry?.inputTokens == 200) + #expect(entry?.cacheReadTokens == 900) + #expect(entry?.outputTokens == 500) + #expect(entry?.totalTokens == 1600) + } + + @Test + func `cache-exclusive input is kept without double counting`() { + // input(200) + cacheRead(900) + output(500) == total(1600): input is already uncached. + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "glm-5.2", + inputTokens: 200, + outputTokens: 500, + totalTokens: 1600, + cacheReadTokens: 900, + pricingProviderIDs: [])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot?.daily.first?.inputTokens == 200) + #expect(snapshot?.daily.first?.totalTokens == 1600) + } + + @Test + func `missing total falls back to subtracting the cached prefix`() { + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "qwen3-coder-plus", + inputTokens: 330, + outputTokens: 57, + totalTokens: nil, + cacheReadTokens: 220, + pricingProviderIDs: [])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot?.daily.first?.inputTokens == 110) + #expect(snapshot?.daily.first?.totalTokens == 387) + } + + @Test + func `provider-reported cost is trusted and not re-priced`() { + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "glm-5.2", + inputTokens: 100, + outputTokens: 50, + providerCostUSD: 0.5, + pricingProviderIDs: ["zai"])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot?.daily.first?.costUSD == 0.5) + } + + @Test + func `unpriced token usage keeps the day cost nil rather than a partial total`() { + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "unknown-model", + inputTokens: 100, + outputTokens: 50, + pricingProviderIDs: ["no-such-provider"])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + // Tokens are real but unpriced, so the cost must stay nil, not a fabricated partial sum. + #expect(snapshot?.daily.first?.totalTokens == 150) + #expect(snapshot?.daily.first?.costUSD == nil) + } + + @Test + func `degraded model-only events surface without token totals`() { + let snapshot = UsageEventAggregator.aggregate( + events: [ + UnifiedUsageEvent(day: "2026-07-28", model: "claude-opus-4-8"), + UnifiedUsageEvent(day: "2026-07-28", model: "gpt-5"), + ], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + let entry = snapshot?.daily.first + #expect(entry?.totalTokens == nil) + #expect(entry?.costUSD == nil) + #expect(entry?.modelsUsed == ["claude-opus-4-8", "gpt-5"]) + #expect(entry?.modelBreakdowns == nil) + // A fully degraded snapshot carries no headline token total. + #expect(snapshot?.last30DaysTokens == nil) + } + + @Test + func `per-event billing evidence wins over the tool default`() { + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "claude-haiku-4-5", + billingProviderID: "claude", + inputTokens: 100, + outputTokens: 50, + pricingProviderIDs: [])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test", defaultBillingProviderID: "copilot")) + #expect(snapshot?.daily.first?.modelBreakdowns?.first?.billingProviderID == "claude") + } +} From 3eb62c26055fccc424da26aad8c2ef3a126aaa7b Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:13:39 +0800 Subject: [PATCH 05/22] style(tests): apply SwiftFormat to new engine/copilot suites Fixes CI swift-test-macos shard failures: blankLinesBetweenImports, redundantSwiftTestingSuite, indent, redundantThrows. No logic change; both suites still pass. Co-authored-by: Cursor --- Tests/CodexBarTests/CopilotSessionScannerTests.swift | 10 ++++------ Tests/CodexBarTests/UsageEventAggregatorTests.swift | 2 -- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Tests/CodexBarTests/CopilotSessionScannerTests.swift b/Tests/CodexBarTests/CopilotSessionScannerTests.swift index bd272d618d..91c00f0c10 100644 --- a/Tests/CodexBarTests/CopilotSessionScannerTests.swift +++ b/Tests/CodexBarTests/CopilotSessionScannerTests.swift @@ -1,9 +1,7 @@ import Foundation import Testing - @testable import CodexBarCore -@Suite struct CopilotSessionScannerTests { private static let calendar: Calendar = { var calendar = Calendar(identifier: .gregorian) @@ -26,9 +24,9 @@ struct CopilotSessionScannerTests { let shutdownUsage = #""usage":{"inputTokens":73595,"outputTokens":1196,"# + #""cacheReadTokens":48228,"cacheWriteTokens":0,"reasoningTokens":0}"# let shutdown = """ - {"type":"session.shutdown","timestamp":"2026-07-28T10:00:00.000Z",\ - "data":{"modelMetrics":{"claude-haiku-4.5":{"requests":{"count":3},\(shutdownUsage)}}}} - """ + {"type":"session.shutdown","timestamp":"2026-07-28T10:00:00.000Z",\ + "data":{"modelMetrics":{"claude-haiku-4.5":{"requests":{"count":3},\(shutdownUsage)}}}} + """ let lines = [ #"{"type":"session.start","timestamp":"2026-07-28T09:00:00.000Z"}"#, shutdown, @@ -101,7 +99,7 @@ struct CopilotSessionScannerTests { } @Test - func `scanner returns nil when no metrics exist`() throws { + func `scanner returns nil when no metrics exist`() { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } diff --git a/Tests/CodexBarTests/UsageEventAggregatorTests.swift b/Tests/CodexBarTests/UsageEventAggregatorTests.swift index 0f52cde835..1fc447596e 100644 --- a/Tests/CodexBarTests/UsageEventAggregatorTests.swift +++ b/Tests/CodexBarTests/UsageEventAggregatorTests.swift @@ -1,9 +1,7 @@ import Foundation import Testing - @testable import CodexBarCore -@Suite struct UsageEventAggregatorTests { private let now = Date(timeIntervalSince1970: 1_785_283_200) From 6cfcb0c6d23d4c9bc1ce7d1e6a0736c983f3ad8e Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:30:45 +0800 Subject: [PATCH 06/22] feat(core): full-history manual rescan + live scan progress Manual refresh reconciles the entire local history in one pass instead of creeping across refreshes: - CostUsageFetcher: forceRefresh forces a rescan and lifts the 512MB per-refresh byte budget (configureFullRescan), so users with gigabytes of session history see the true total on a single manual refresh rather than watching token counts climb over several clicks. - CostUsageScanner: per-file progress callback (progressHandler) threaded through the scan loop. - SpendDashboardController: CodexScanProgressStore bounces scan-queue progress onto the main actor for live UI updates. - PreferencesSpendDashboardPane: show "Scanning history X/Y" beside the refresh spinner during a full rescan (21 locales). Co-authored-by: Cursor --- .../PreferencesSpendDashboardPane.swift | 10 +++- .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fa.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/gl.lproj/Localizable.strings | 1 + .../Resources/id.lproj/Localizable.strings | 1 + .../Resources/it.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../Resources/nl.lproj/Localizable.strings | 1 + .../Resources/pl.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/ru.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../Resources/th.lproj/Localizable.strings | 1 + .../Resources/tr.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + .../Resources/vi.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../CodexBar/SpendDashboardController.swift | 57 ++++++++++++++++--- Sources/CodexBarCore/CostUsageFetcher.swift | 53 +++++++++++++---- .../Generated/CodexParserHash.generated.swift | 2 +- .../Vendored/CostUsage/CostUsageScanner.swift | 12 +++- 28 files changed, 135 insertions(+), 22 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 83e7f31038..5dcd413a0a 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -115,7 +115,15 @@ struct SpendDashboardPane: View { self.controller.refresh() } label: { if self.controller.isRefreshing { - ProgressView().controlSize(.small) + HStack(spacing: 6) { + ProgressView().controlSize(.small) + if let progress = self.controller.codexScanProgress, progress.total > 0 { + Text(L("Scanning history %d/%d", progress.scanned, progress.total)) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + } } else { Label(L("Refresh"), systemImage: "arrow.clockwise") } diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 656e84539a..7287eeaed5 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "لا يوجد سجل تكاليف محلي بعد"; "Turn on cost tracking or refresh after using a supported provider." = "فعّل تتبّع التكاليف أو حدّث بعد استخدام مزوّد مدعوم."; "Refresh failures" = "حالات فشل التحديث"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "تبقى العملات الأصلية منفصلة؛ تستبعد صفوف حساب Codex سجل جلسات Pi."; "Spend unavailable" = "الإنفاق غير متاح"; "Model breakdown unavailable" = "تفصيل الإنفاق حسب النموذج غير متاح"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 1921f5a94d..b292c649e3 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1265,6 +1265,7 @@ "No local cost history yet" = "Encara no hi ha historial local de costos"; "Turn on cost tracking or refresh after using a supported provider." = "Activa el seguiment de costos o actualitza després d’utilitzar un proveïdor compatible."; "Refresh failures" = "Actualitzacions fallides"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Les monedes originals es mantenen separades; les files de comptes de Codex exclouen l’historial de sessions de Pi."; "Spend unavailable" = "Despesa no disponible"; "Model breakdown unavailable" = "Desglossament per model no disponible"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 2986c311d4..c9e291b722 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1263,6 +1263,7 @@ "No local cost history yet" = "Noch kein lokaler Kostenverlauf"; "Turn on cost tracking or refresh after using a supported provider." = "Aktivieren Sie die Kostenverfolgung oder aktualisieren Sie nach der Nutzung eines unterstützten Anbieters."; "Refresh failures" = "Fehlgeschlagene Aktualisierungen"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Originalwährungen bleiben getrennt; Codex-Kontozeilen schließen den Pi-Sitzungsverlauf aus."; "Spend unavailable" = "Ausgaben nicht verfügbar"; "Model breakdown unavailable" = "Modellaufschlüsselung nicht verfügbar"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 18b42e8801..5f417ecd8c 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1267,6 +1267,7 @@ "No local cost history yet" = "No local cost history yet"; "Turn on cost tracking or refresh after using a supported provider." = "Turn on cost tracking or refresh after using a supported provider."; "Refresh failures" = "Refresh failures"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Native currencies stay separate; Codex account rows exclude Pi session history."; "Spend unavailable" = "Spend unavailable"; "Model breakdown unavailable" = "Model breakdown unavailable"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 656ac7975c..31225e1532 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1261,6 +1261,7 @@ "No local cost history yet" = "Aún no hay historial local de costes"; "Turn on cost tracking or refresh after using a supported provider." = "Activa el seguimiento local de costes o actualiza después de usar un proveedor compatible."; "Refresh failures" = "Actualizaciones fallidas"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Las divisas originales se mantienen separadas; las filas de cuentas de Codex excluyen el historial de sesiones de Pi."; "Spend unavailable" = "Gasto no disponible"; "Model breakdown unavailable" = "Desglose por modelo no disponible"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index cdea6d030b..db2fd21c6b 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "هنوز تاریخچه هزینه محلی وجود ندارد"; "Turn on cost tracking or refresh after using a supported provider." = "پیگیری هزینه را روشن کنید یا پس از استفاده از یک ارائه‌دهنده پشتیبانی‌شده تازه‌سازی کنید."; "Refresh failures" = "خطاهای تازه‌سازی"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "ارزهای اصلی جدا نگه داشته می‌شوند؛ ردیف‌های حساب Codex تاریخچه نشست‌های Pi را دربر نمی‌گیرند."; "Spend unavailable" = "هزینه در دسترس نیست"; "Model breakdown unavailable" = "تفکیک مدل در دسترس نیست"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index ff583450d9..06d68c7200 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1262,6 +1262,7 @@ "No local cost history yet" = "Aucun historique local des coûts pour l’instant"; "Turn on cost tracking or refresh after using a supported provider." = "Activez le suivi local des coûts ou actualisez après avoir utilisé un fournisseur pris en charge."; "Refresh failures" = "Échecs d’actualisation"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Les devises d’origine restent séparées ; les lignes de compte Codex excluent l’historique des sessions Pi."; "Spend unavailable" = "Dépenses indisponibles"; "Model breakdown unavailable" = "Répartition par modèle indisponible"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 43bd695894..5b069b2b66 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1262,6 +1262,7 @@ "No local cost history yet" = "Aínda non hai historial local de custos"; "Turn on cost tracking or refresh after using a supported provider." = "Activa o seguimento de custos ou actualiza despois de usar un provedor compatible."; "Refresh failures" = "Actualizacións falladas"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "As moedas orixinais mantéñense separadas; as filas das contas de Codex exclúen o historial de sesións de Pi."; "Spend unavailable" = "Gasto non dispoñible"; "Model breakdown unavailable" = "Desglose por modelo non dispoñible"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 275d7b769e..8035b65dd0 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "Belum ada riwayat biaya lokal"; "Turn on cost tracking or refresh after using a supported provider." = "Aktifkan pelacakan biaya atau segarkan setelah menggunakan penyedia yang didukung."; "Refresh failures" = "Kegagalan penyegaran"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Mata uang asli tetap dipisahkan; baris akun Codex tidak menyertakan riwayat sesi Pi."; "Spend unavailable" = "Data pengeluaran tidak tersedia"; "Model breakdown unavailable" = "Rincian per model tidak tersedia"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 6ce92c2a94..829b575228 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "Ancora nessuna cronologia locale dei costi"; "Turn on cost tracking or refresh after using a supported provider." = "Attiva il monitoraggio dei costi o aggiorna dopo aver usato un provider supportato."; "Refresh failures" = "Aggiornamenti non riusciti"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Le valute originali rimangono separate; le righe degli account Codex escludono la cronologia delle sessioni Pi."; "Spend unavailable" = "Spesa non disponibile"; "Model breakdown unavailable" = "Ripartizione per modello non disponibile"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 7b6911423d..d0f4379d51 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1263,6 +1263,7 @@ "No local cost history yet" = "ローカルのコスト履歴はまだありません"; "Turn on cost tracking or refresh after using a supported provider." = "コスト追跡をオンにするか、対応プロバイダの使用後に更新してください。"; "Refresh failures" = "更新失敗"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "各通貨は別々に扱われ、Codex アカウント行には Pi セッション履歴を含めません。"; "Spend unavailable" = "支出を取得できません"; "Model breakdown unavailable" = "モデル別の内訳を取得できません"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 7458d95078..872125e1ae 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1230,6 +1230,7 @@ "No local cost history yet" = "아직 로컬 비용 내역이 없습니다"; "Turn on cost tracking or refresh after using a supported provider." = "비용 추적을 켜거나 지원되는 공급자를 사용한 후 새로 고치세요."; "Refresh failures" = "새로 고침 실패"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "각 통화는 별도로 유지되며 Codex 계정 행에서는 Pi 세션 기록이 제외됩니다."; "Spend unavailable" = "지출 정보 없음"; "Model breakdown unavailable" = "모델별 내역을 사용할 수 없습니다"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index cbe4af8954..89c53057b2 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1262,6 +1262,7 @@ "No local cost history yet" = "Nog geen lokale kostengeschiedenis"; "Turn on cost tracking or refresh after using a supported provider." = "Schakel kostenregistratie in of vernieuw nadat je een ondersteunde aanbieder hebt gebruikt."; "Refresh failures" = "Mislukte vernieuwingen"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Oorspronkelijke valuta’s blijven gescheiden; rijen met Codex-accounts sluiten Pi-sessiegeschiedenis uit."; "Spend unavailable" = "Uitgaven niet beschikbaar"; "Model breakdown unavailable" = "Uitsplitsing per model niet beschikbaar"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 9958e32f9e..41c173203e 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "Brak lokalnej historii kosztów"; "Turn on cost tracking or refresh after using a supported provider." = "Włącz śledzenie kosztów lub odśwież po użyciu obsługiwanego dostawcy."; "Refresh failures" = "Błędy odświeżania"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Waluty źródłowe pozostają rozdzielone; wiersze kont Codex nie obejmują historii sesji Pi."; "Spend unavailable" = "Wydatki niedostępne"; "Model breakdown unavailable" = "Podział według modeli jest niedostępny"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 434ba9148c..d83ae85ee2 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1263,6 +1263,7 @@ "No local cost history yet" = "Ainda não há histórico local de custos"; "Turn on cost tracking or refresh after using a supported provider." = "Ative o acompanhamento de custos ou atualize após usar um provedor compatível."; "Refresh failures" = "Falhas de atualização"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "As moedas originais permanecem separadas; as linhas de contas do Codex excluem o histórico de sessões do Pi."; "Spend unavailable" = "Gastos indisponíveis"; "Model breakdown unavailable" = "Detalhamento por modelo indisponível"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index a1015d75f2..771c9ccd55 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1264,6 +1264,7 @@ "No local cost history yet" = "Локальной истории расходов пока нет"; "Turn on cost tracking or refresh after using a supported provider." = "Включите отслеживание расходов или обновите данные после использования поддерживаемого провайдера."; "Refresh failures" = "Ошибки обновления"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Исходные валюты остаются раздельными; строки учётных записей Codex не включают историю сеансов Pi."; "Spend unavailable" = "Расходы недоступны"; "Model breakdown unavailable" = "Разбивка по моделям недоступна"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 541451b974..4c547d8127 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1261,6 +1261,7 @@ "No local cost history yet" = "Ingen lokal kostnadshistorik än"; "Turn on cost tracking or refresh after using a supported provider." = "Aktivera kostnadsspårning eller uppdatera efter att ha använt en leverantör som stöds."; "Refresh failures" = "Misslyckade uppdateringar"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Ursprungliga valutor hålls åtskilda; rader för Codex-konton utesluter Pi-sessionshistorik."; "Spend unavailable" = "Utgifter ej tillgängliga"; "Model breakdown unavailable" = "Modellfördelning ej tillgänglig"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 4e0f2ff782..b7b211615a 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1266,6 +1266,7 @@ "No local cost history yet" = "ยังไม่มีประวัติค่าใช้จ่ายในเครื่อง"; "Turn on cost tracking or refresh after using a supported provider." = "เปิดการติดตามค่าใช้จ่ายหรือรีเฟรชหลังจากใช้ผู้ให้บริการที่รองรับ"; "Refresh failures" = "การรีเฟรชที่ล้มเหลว"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "สกุลเงินต้นทางแยกจากกัน แถวบัญชี Codex ไม่รวมประวัติเซสชัน Pi"; "Spend unavailable" = "ไม่มีข้อมูลค่าใช้จ่าย"; "Model breakdown unavailable" = "ไม่มีรายละเอียดแยกตามโมเดล"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 48a5096e45..543fffe482 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1264,6 +1264,7 @@ "No local cost history yet" = "Henüz yerel maliyet geçmişi yok"; "Turn on cost tracking or refresh after using a supported provider." = "Maliyet takibini açın veya desteklenen bir sağlayıcıyı kullandıktan sonra yenileyin."; "Refresh failures" = "Yenileme hataları"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Kaynak para birimleri ayrı tutulur; Codex hesap satırlarına Pi oturum geçmişi dahil edilmez."; "Spend unavailable" = "Harcama verisi kullanılamıyor"; "Model breakdown unavailable" = "Model dökümü kullanılamıyor"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 1915ea7bf9..1ced315e41 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1262,6 +1262,7 @@ "No local cost history yet" = "Локальної історії витрат ще немає"; "Turn on cost tracking or refresh after using a supported provider." = "Увімкніть відстеження витрат або оновіть дані після використання підтримуваного провайдера."; "Refresh failures" = "Помилки оновлення"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Вихідні валюти залишаються розділеними; рядки облікових записів Codex не включають історію сеансів Pi."; "Spend unavailable" = "Витрати недоступні"; "Model breakdown unavailable" = "Розподіл за моделями недоступний"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 02c9b8fdf3..c4037e105c 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1263,6 +1263,7 @@ "No local cost history yet" = "Chưa có lịch sử chi phí cục bộ"; "Turn on cost tracking or refresh after using a supported provider." = "Bật tính năng theo dõi chi phí hoặc làm mới sau khi sử dụng nhà cung cấp được hỗ trợ."; "Refresh failures" = "Lần làm mới thất bại"; +"Scanning history %d/%d" = "Scanning history %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Đơn vị tiền tệ gốc được giữ riêng biệt; các hàng tài khoản Codex không bao gồm lịch sử phiên Pi."; "Spend unavailable" = "Không có dữ liệu chi tiêu"; "Model breakdown unavailable" = "Phân tích theo mô hình không khả dụng"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 06fc0d4755..a7f9ff59c4 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1238,6 +1238,7 @@ "No local cost history yet" = "暂无本地费用历史"; "Turn on cost tracking or refresh after using a supported provider." = "启用费用跟踪,或在使用受支持的提供商后刷新。"; "Refresh failures" = "刷新失败"; +"Scanning history %d/%d" = "正在扫描历史 %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "各原始币种保持分开;Codex 帐户行不包含 Pi 会话历史。"; "Spend unavailable" = "支出数据不可用"; "Model breakdown unavailable" = "模型明细不可用"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index ec01ed18f3..fcf0b558e8 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1293,6 +1293,7 @@ "No local cost history yet" = "尚無本機費用歷史"; "Turn on cost tracking or refresh after using a supported provider." = "開啟費用追蹤,或在使用支援的提供者後重新整理。"; "Refresh failures" = "重新整理失敗"; +"Scanning history %d/%d" = "正在掃描歷史 %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "各原始幣別保持分開;Codex 帳號列不包含 Pi 工作階段歷史。"; "Spend unavailable" = "無法取得支出資料"; "Model breakdown unavailable" = "無法取得模型明細"; diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 8b7de621e3..3a2c920ae9 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -115,6 +115,8 @@ struct CodexSpendSnapshotLoadContext: Sendable { let historyDays: Int let refreshPricingInBackground: Bool let includePiSessions: Bool + /// Reports Codex full-rescan progress (filesScanned, totalFiles) from the scan queue. + var progress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? } enum SpendDashboardSource { @@ -249,14 +251,22 @@ enum SpendDashboardSource { force: mode.forcesLoader) } - static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { - await self.load(request, codexSnapshotLoader: { context in - try await self.loadCodexSnapshot(context) - }) + static func load( + _ request: SpendDashboardLoadRequest, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) async + -> SpendDashboardLoadResult + { + await self.load( + request, + codexProgress: codexProgress, + codexSnapshotLoader: { context in + try await self.loadCodexSnapshot(context) + }) } static func load( _ request: SpendDashboardLoadRequest, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil, codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs @@ -280,7 +290,8 @@ enum SpendDashboardSource { force: request.force, historyDays: Self.scanDays, refreshPricingInBackground: false, - includePiSessions: false)) + includePiSessions: false, + progress: codexProgress)) try Task.checkCancellation() guard self.currentAuthFingerprint(for: account) == account.authFingerprint else { failedSourceIDs.insert(sourceID) @@ -328,7 +339,8 @@ enum SpendDashboardSource { codexHomePath: context.account.homePath, historyDays: context.historyDays, refreshPricingInBackground: context.refreshPricingInBackground, - includePiSessions: context.includePiSessions) + includePiSessions: context.includePiSessions, + codexProgress: context.progress) } @MainActor @@ -558,6 +570,14 @@ private struct SpendDashboardSnapshotRevisionEncoder { } } +/// Main-actor store for Codex full-rescan progress. The scan queue reports file progress off the +/// main thread; the loader bounces each update here so `@Observable` views re-render live. +@MainActor +@Observable +final class CodexScanProgressStore { + var value: (scanned: Int, total: Int)? +} + @MainActor @Observable final class SpendDashboardController { @@ -653,6 +673,12 @@ final class SpendDashboardController { private(set) var model = SpendDashboardModel(requestedDays: 30, groups: []) private(set) var isRefreshing = false + /// Codex full-rescan progress during a manual refresh: (filesScanned, totalFiles). Nil when + /// no progress has been reported yet (incremental refreshes and non-Codex sources). + var codexScanProgress: (scanned: Int, total: Int)? { + self.progressStore.value + } + private(set) var failedSourceCount = 0 private(set) var generation: UInt64 = 0 private(set) var configuration: SpendDashboardConfiguration? @@ -663,6 +689,7 @@ final class SpendDashboardController { private let requestBuilder: RequestBuilder private let loader: Loader private let nowProvider: @Sendable () -> Date + private let progressStore = CodexScanProgressStore() private var loadTask: Task? private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] private var loadedAt = Date() @@ -672,14 +699,25 @@ final class SpendDashboardController { init( userDefaults: UserDefaults = .standard, requestBuilder: @escaping RequestBuilder, - loader: @escaping Loader = SpendDashboardSource.load, + loader: Loader? = nil, nowProvider: @escaping @Sendable () -> Date = { Date() }) { self.userDefaults = userDefaults self.requestBuilder = requestBuilder - self.loader = loader self.nowProvider = nowProvider self.selectedDays = Self.normalizedDays(userDefaults.integer(forKey: Self.daysDefaultsKey)) + if let loader { + self.loader = loader + } else { + // Capture only the progress store (already initialized above), not `self`, so the + // default loader can bounce scan-queue progress onto the main actor. + let progressStore = self.progressStore + self.loader = { request in + await SpendDashboardSource.load(request) { scanned, total in + Task { @MainActor in progressStore.value = (scanned, total) } + } + } + } } func update(configuration: SpendDashboardConfiguration, force: Bool = false) { @@ -736,6 +774,7 @@ final class SpendDashboardController { } self.isRefreshing = true + self.progressStore.value = nil self.loadTask = Task { [weak self] in guard let self else { return } let request = await self.requestBuilder(phase.buildMode) @@ -890,6 +929,7 @@ final class SpendDashboardController { self.lastSuccessfulConfiguration = request.configuration self.failedSourceCount = result.failedSourceCount self.isRefreshing = false + self.progressStore.value = nil self.phase = .ordinary self.loadTask = nil self.rebuildModel() @@ -961,6 +1001,7 @@ final class SpendDashboardController { self.loadTask = nil self.configuration = nil self.isRefreshing = false + self.progressStore.value = nil self.phase = .ordinary } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 9f8fc8d9cd..fe2b7c7a4a 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -117,7 +117,9 @@ public struct CostUsageFetcher: Sendable { cursorCookieHeaderOverride: String? = nil, allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, - includePiSessions: Bool = true) async throws -> CostUsageTokenSnapshot + includePiSessions: Bool = true, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) async throws + -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, @@ -132,7 +134,8 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: false, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptionsOverride(), + codexProgress: codexProgress) } package func loadTokenSnapshot( @@ -147,7 +150,9 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, - bypassScannerDebounce: Bool) async throws -> CostUsageTokenSnapshot + bypassScannerDebounce: Bool, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) async throws + -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, @@ -162,7 +167,8 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: bypassScannerDebounce, - scannerOptions: self.scannerOptionsOverride()) + scannerOptions: self.scannerOptionsOverride(), + codexProgress: codexProgress) } @available(*, deprecated, message: "Codex token-cost scans are uncapped; this limit is ignored.") @@ -194,6 +200,32 @@ public struct CostUsageFetcher: Sendable { self.scannerOptions } + /// Configures a manual full rescan: reconcile the *entire* history in one pass. The default + /// per-refresh byte budget (512MB) defers older session files to later refreshes, so a user + /// with gigabytes of history sees token counts creep up over several manual refreshes instead + /// of settling at the true total. Lifting the budget and forcing a rescan makes one manual + /// refresh converge on the full corpus. + private static func configureFullRescan( + _ options: inout CostUsageScanner.Options, + progress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)?) + { + options.forceRescan = true + options.maxCodexScanBytesPerRefresh = 0 + options.progressHandler = progress + } + + private static func applyClaudeLogFilter( + _ options: inout CostUsageScanner.Options, + provider: UsageProvider, + allowVertexClaudeFallback: Bool) + { + if provider == .vertexai { + options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly + } else if provider == .claude { + options.claudeLogProviderFilter = .excludeVertexAI + } + } + private static func resolvedScannerOptions( _ override: CostUsageScanner.Options?, provider: UsageProvider, @@ -227,7 +259,9 @@ public struct CostUsageFetcher: Sendable { piScannerOptions overridePiScannerOptions: PiSessionCostScanner .Options? = nil, modelsDevClient: ModelsDevClient = ModelsDevClient(), - retryUnknownPricing: Bool = true) async throws -> CostUsageTokenSnapshot + retryUnknownPricing: Bool = true, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) async throws + -> CostUsageTokenSnapshot { guard self.supportsTokenSnapshot(provider) else { throw CostUsageError.unsupportedProvider(provider) @@ -263,14 +297,13 @@ public struct CostUsageFetcher: Sendable { cacheRoot: options.cacheRoot, client: modelsDevClient) - if provider == .vertexai { - options.claudeLogProviderFilter = allowVertexClaudeFallback ? .all : .vertexAIOnly - } else if provider == .claude { - options.claudeLogProviderFilter = .excludeVertexAI - } + Self.applyClaudeLogFilter(&options, provider: provider, allowVertexClaudeFallback: allowVertexClaudeFallback) if forceRefresh || bypassScannerDebounce { options.refreshMinIntervalSeconds = 0 } + if forceRefresh { + Self.configureFullRescan(&options, progress: codexProgress) + } var resolvedPiOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options() if resolvedPiOptions.cacheRoot == nil { resolvedPiOptions.cacheRoot = options.cacheRoot diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 11191f29b9..aff01aa148 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "c76ca2e79b9ed330" + static let value = "4e4c7de1db121b34" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 522fd98236..e801efc3fc 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -42,6 +42,9 @@ enum CostUsageScanner { var maxCodexScanBytesPerRefresh: Int64 = 512 * 1024 * 1024 /// Prefer newest session files first so recent usage lands before catch-up work. var preferNewestCodexSessionsFirst: Bool = true + /// Called on the scan queue with (filesScanned, totalFiles) as each Codex session file + /// is parsed. Lets a manual full rescan surface progress instead of appearing stalled. + var progressHandler: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? init( codexSessionsRoot: URL? = nil, @@ -53,7 +56,8 @@ enum CostUsageScanner { forceRescan: Bool = false, maxCodexSessionFileBytes: Int64 = 256 * 1024 * 1024, maxCodexScanBytesPerRefresh: Int64 = 512 * 1024 * 1024, - preferNewestCodexSessionsFirst: Bool = true) + preferNewestCodexSessionsFirst: Bool = true, + progressHandler: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots @@ -65,6 +69,7 @@ enum CostUsageScanner { self.maxCodexSessionFileBytes = max(0, maxCodexSessionFileBytes) self.maxCodexScanBytesPerRefresh = max(0, maxCodexScanBytesPerRefresh) self.preferNewestCodexSessionsFirst = preferNewestCodexSessionsFirst + self.progressHandler = progressHandler } } @@ -3516,12 +3521,15 @@ enum CostUsageScanner { resources: resources, checkCancellation: checkCancellation, scanBudget: scanBudget) - for fileURL in files { + let totalFiles = files.count + options.progressHandler?(0, totalFiles) + for (index, fileURL) in files.enumerated() { try Self.scanCodexFile( fileURL: fileURL, context: scanContext, cache: &cache, state: &scanState) + options.progressHandler?(index + 1, totalFiles) } if scanBudget.resumedPartialFileCount > 0 || scanBudget.deferredByBudgetFileCount > 0 { Self.log.info( From 979259584c1da1ab7ab6de5cd93527ffb88f6c7a Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:16:48 +0800 Subject: [PATCH 07/22] fix(i18n): translate "Scanning history" into Italian The Italian catalog test requires every key whose value still equals English to be on an explicit intentionallyUnchanged allowlist. The new progress string was added with an English placeholder, breaking that guard. Provide the real translation ("Scansione cronologia %d/%d") so the catalog stays fully localized. Co-authored-by: Cursor --- Sources/CodexBar/Resources/it.lproj/Localizable.strings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 829b575228..86bf398e2c 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1266,7 +1266,7 @@ "No local cost history yet" = "Ancora nessuna cronologia locale dei costi"; "Turn on cost tracking or refresh after using a supported provider." = "Attiva il monitoraggio dei costi o aggiorna dopo aver usato un provider supportato."; "Refresh failures" = "Aggiornamenti non riusciti"; -"Scanning history %d/%d" = "Scanning history %d/%d"; +"Scanning history %d/%d" = "Scansione cronologia %d/%d"; "Native currencies stay separate; Codex account rows exclude Pi session history." = "Le valute originali rimangono separate; le righe degli account Codex escludono la cronologia delle sessioni Pi."; "Spend unavailable" = "Spesa non disponibile"; "Model breakdown unavailable" = "Ripartizione per modello non disponibile"; From 5c2ae861ae4ffb028691dd368640ad39200eaec1 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:43:43 +0800 Subject: [PATCH 08/22] fix(core): provider ownership + complete-cost + cache-write pricing Addresses ClawSweeper review findings on the local-history layer: - Copilot ownership: local Copilot events carry no explicit billing-provider field, so attributing claude-*/gpt-*/gemini-* models to other providers mixed Copilot activity into those providers' rows by name alone. Ownership now stays with Copilot; the vendor id is used only as the rate-lookup key (pricingProviderIDs), never to re-attribute. - Headline cost completeness: the aggregator dropped unpriced days via compactMap and still published the remaining priced subtotal as the 30-day total. It now withholds the headline cost whenever any token-bearing day is unpriced, so a partial subtotal never masquerades as a complete total. - Cache-write pricing: the shared models.dev pricing path received only uncached input, cache reads, and output, so catalogs with a distinct cache-write rate mispriced records carrying writes. The request now carries cacheCreationInputTokens, priced at the catalog cache-write rate with an input-rate fallback (matching cache reads). Tests: Copilot ownership stays Copilot; headline cost withheld when any token day is unpriced; cache-write priced at its own rate and via fallback. Co-authored-by: Cursor --- .../Generated/CodexParserHash.generated.swift | 2 +- .../Copilot/CopilotSessionScanner.swift | 20 +++--- .../LocalHistory/UsageEventAggregator.swift | 11 +++- .../CostUsagePricing+ThirdParty.swift | 11 ++++ .../CopilotSessionScannerTests.swift | 9 +-- .../CodexBarTests/ModelsDevPricingTests.swift | 61 +++++++++++++++++++ .../UsageEventAggregatorTests.swift | 31 ++++++++++ 7 files changed, 127 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index aff01aa148..064f6afee0 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "4e4c7de1db121b34" + static let value = "8c56d1cda8c0daa8" } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift index 05ff03e28c..a47bf8201d 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift @@ -183,9 +183,10 @@ public enum CopilotSessionScanner { .appendingPathComponent(".copilot", isDirectory: true) } - /// Traces the model to the real vendor that bills it. Copilot is a harness: the model name is - /// the billing evidence, so a Claude model is priced at Anthropic's rate, a GPT model at - /// OpenAI's, and so on. + /// Vendor used *only* to look up the rate for a model. Copilot is a harness with no explicit + /// billing-provider field on its local events, so the model name is the only rate evidence: + /// a Claude model is priced at Anthropic's rate, a GPT model at OpenAI's, and so on. This + /// never changes ownership — the usage stays attributed to Copilot (see `billingProvider`). private static func pricingProviderIDs(for model: String) -> [String] { if model.hasPrefix("claude-") { return ["anthropic"] } if model.hasPrefix("gpt-") || model.hasPrefix("o1") || model.hasPrefix("o3") || model.hasPrefix("o4") { @@ -195,13 +196,12 @@ public enum CopilotSessionScanner { return [] } - private static func billingProvider(for model: String) -> String? { - if model.hasPrefix("claude-") { return UsageProvider.claude.rawValue } - if model.hasPrefix("gpt-") || model.hasPrefix("o1") || model.hasPrefix("o3") || model.hasPrefix("o4") { - return UsageProvider.openai.rawValue - } - if model.hasPrefix("gemini-") { return UsageProvider.gemini.rawValue } - return UsageProvider.copilot.rawValue + /// Billing ownership always stays with Copilot. The local Copilot event carries no explicit + /// routing or billing-provider field, so attributing a `claude-*`/`gpt-*`/`gemini-*` model to + /// another provider would mix Copilot activity into that provider's rows based only on a name. + /// The vendor is used solely as the rate-lookup key in `pricingProviderIDs`. + private static func billingProvider(for _: String) -> String? { + UsageProvider.copilot.rawValue } /// Normalizes Copilot's model ids to the pricing keys used by the built-in tables and diff --git a/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift b/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift index 7f3080fd7c..2547ad900c 100644 --- a/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift +++ b/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift @@ -104,8 +104,12 @@ public enum UsageEventAggregator { let tokenEntries = daily.filter { $0.totalTokens != nil } let totalTokens = tokenEntries.isEmpty ? nil : Self.sum(tokenEntries.compactMap(\.totalTokens)) let requests = tokenEntries.isEmpty ? nil : Self.sum(tokenEntries.compactMap(\.requestCount)) + // Publish the headline cost only when every token-bearing day is fully priced. A day whose + // cost was withheld (mixed priced/unpriced usage) must not be silently dropped via + // compactMap, or the remaining priced subtotal would masquerade as the complete total. + let hasUnpricedTokenDay = daily.contains { $0.totalTokens != nil && $0.costUSD == nil } let costs = daily.compactMap(\.costUSD) - let totalCost = costs.isEmpty ? nil : costs.reduce(0, +) + let totalCost = (costs.isEmpty || hasUnpricedTokenDay) ? nil : costs.reduce(0, +) return CostUsageTokenSnapshot( sessionTokens: nil, @@ -113,7 +117,7 @@ public enum UsageEventAggregator { last30DaysTokens: totalTokens, last30DaysCostUSD: totalCost, last30DaysRequests: requests, - currencyCode: costs.isEmpty ? "XXX" : "USD", + currencyCode: totalCost == nil ? "XXX" : "USD", historyDays: historyDays, historyCoverageIsEstablished: true, historyLabel: options.historyLabel, @@ -185,7 +189,8 @@ public enum UsageEventAggregator { model: event.model, inputTokens: uncachedInput, cacheReadInputTokens: cacheRead, - outputTokens: billingOutput), + outputTokens: billingOutput, + cacheCreationInputTokens: cacheCreation), catalog: modelsDevCatalog, cacheRoot: modelsDevCacheRoot), cost.isFinite diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift index 1771567505..235e1841a3 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+ThirdParty.swift @@ -7,6 +7,10 @@ extension CostUsagePricing { let inputTokens: Int let cacheReadInputTokens: Int let outputTokens: Int + /// Cache-write (cache-creation) tokens. Defaults to 0 so callers that do not track them + /// price unchanged; catalogs without a distinct cache-write rate fall back to the input + /// rate, matching how cache reads are handled. + var cacheCreationInputTokens: Int = 0 } /// Prices a model against an explicit ordered list of models.dev provider IDs. @@ -30,6 +34,7 @@ extension CostUsagePricing { let input = max(0, request.inputTokens) let cacheRead = max(0, request.cacheReadInputTokens) + let cacheCreation = max(0, request.cacheCreationInputTokens) let output = max(0, request.outputTokens) let context = input.addingReportingOverflow(cacheRead) let usesLongContextRates = pricing.thresholdTokens.map { @@ -43,11 +48,17 @@ extension CostUsagePricing { ?? pricing.cacheReadInputCostPerToken ?? inputRate : pricing.cacheReadInputCostPerToken ?? inputRate + let cacheCreationRate = usesLongContextRates + ? pricing.cacheCreationInputCostPerTokenAboveThreshold + ?? pricing.cacheCreationInputCostPerToken + ?? inputRate + : pricing.cacheCreationInputCostPerToken ?? inputRate let outputRate = usesLongContextRates ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken : pricing.outputCostPerToken let cost = Double(input) * inputRate + Double(cacheRead) * cacheReadRate + + Double(cacheCreation) * cacheCreationRate + Double(output) * outputRate return cost.isFinite ? cost : nil } diff --git a/Tests/CodexBarTests/CopilotSessionScannerTests.swift b/Tests/CodexBarTests/CopilotSessionScannerTests.swift index 91c00f0c10..34dfc4d8d2 100644 --- a/Tests/CodexBarTests/CopilotSessionScannerTests.swift +++ b/Tests/CodexBarTests/CopilotSessionScannerTests.swift @@ -49,10 +49,11 @@ struct CopilotSessionScannerTests { #expect(entry.inputTokens == 73595 - 48228) #expect(entry.cacheReadTokens == 48228) let model = try #require(entry.modelBreakdowns?.first) - // Model name is traced to the real vendor: Copilot's claude-haiku-4.5 is billed at - // Anthropic's rate and normalized to the pricing key. + // Model name is normalized to the pricing key, but ownership stays with Copilot: the + // local event has no explicit billing-provider field, so the vendor (Anthropic) is used + // only as the rate-lookup key, never to re-attribute the usage. #expect(model.modelName == "claude-haiku-4-5") - #expect(model.billingProviderID == UsageProvider.claude.rawValue) + #expect(model.billingProviderID == UsageProvider.copilot.rawValue) } @Test @@ -95,7 +96,7 @@ struct CopilotSessionScannerTests { #expect(entry.totalTokens == 300 + 120) #expect(entry.inputTokens == 300 - 60) let model = try #require(entry.modelBreakdowns?.first) - #expect(model.billingProviderID == UsageProvider.openai.rawValue) + #expect(model.billingProviderID == UsageProvider.copilot.rawValue) } @Test diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift index c064e7d29a..8d735b4ded 100644 --- a/Tests/CodexBarTests/ModelsDevPricingTests.swift +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -1318,6 +1318,67 @@ extension ModelsDevPricingTests { return try Data(contentsOf: url) } + @Test + func `models dev cost includes cache-write tokens at their own rate`() throws { + // input=1/MTok, cache_read=0.5/MTok, cache_write=2/MTok, output=4/MTok. Cache-write is + // priced distinctly so the test fails if cache-creation tokens are dropped from the cost. + let catalog = try Self.catalog(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-test": { + "id": "claude-test", + "cost": { "input": 1, "output": 4, "cache_read": 0.5, "cache_write": 2 } + } + } + } + } + """) + let request = CostUsagePricing.ModelsDevCostRequest( + providerIDs: ["anthropic"], + model: "claude-test", + inputTokens: 1_000_000, + cacheReadInputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheCreationInputTokens: 1_000_000) + let cost = try #require(CostUsagePricing.modelsDevCostUSD( + request: request, + catalog: catalog, + cacheRoot: nil)) + // 1*1 + 1*0.5 + 1*2 + 1*4 = 7.5 + #expect(abs(cost - 7.5) < 0.000_000_001) + } + + @Test + func `models dev cost omits cache-write when the catalog has no such rate`() throws { + // No cache_write rate: cache-creation tokens fall back to the input rate, same as cache + // reads, so records carrying writes still price instead of erroring. + let catalog = try Self.catalog(""" + { + "anthropic": { + "id": "anthropic", + "models": { + "claude-test": { "id": "claude-test", "cost": { "input": 1, "output": 4 } } + } + } + } + """) + let request = CostUsagePricing.ModelsDevCostRequest( + providerIDs: ["anthropic"], + model: "claude-test", + inputTokens: 0, + cacheReadInputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 1_000_000) + let cost = try #require(CostUsagePricing.modelsDevCostUSD( + request: request, + catalog: catalog, + cacheRoot: nil)) + // 1M cache-write tokens at the 1/MTok input-rate fallback = 1.0 + #expect(abs(cost - 1.0) < 0.000_000_001) + } + private static func fixtureCatalog() throws -> ModelsDevCatalog { try JSONDecoder().decode(ModelsDevCatalog.self, from: self.fixtureData()) } diff --git a/Tests/CodexBarTests/UsageEventAggregatorTests.swift b/Tests/CodexBarTests/UsageEventAggregatorTests.swift index 1fc447596e..e2cb1a89c0 100644 --- a/Tests/CodexBarTests/UsageEventAggregatorTests.swift +++ b/Tests/CodexBarTests/UsageEventAggregatorTests.swift @@ -97,6 +97,37 @@ struct UsageEventAggregatorTests { #expect(snapshot?.daily.first?.costUSD == nil) } + @Test + func `headline cost is withheld when any token day is unpriced`() { + // Day 1 is fully priced via a provider-reported cost; day 2 has real tokens but no price. + // The priced day alone must not be published as the 30-day total. + let snapshot = UsageEventAggregator.aggregate( + events: [ + UnifiedUsageEvent( + day: "2026-07-27", + model: "glm-5.2", + inputTokens: 100, + outputTokens: 50, + providerCostUSD: 0.5, + pricingProviderIDs: ["zai"]), + UnifiedUsageEvent( + day: "2026-07-28", + model: "unknown-model", + inputTokens: 100, + outputTokens: 50, + pricingProviderIDs: ["no-such-provider"]), + ], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot?.daily.count == 2) + #expect(snapshot?.daily.first?.costUSD == 0.5) + #expect(snapshot?.daily.last?.costUSD == nil) + // Tokens still aggregate, but the headline cost is withheld as incomplete. + #expect(snapshot?.last30DaysTokens == 300) + #expect(snapshot?.last30DaysCostUSD == nil) + } + @Test func `degraded model-only events surface without token totals`() { let snapshot = UsageEventAggregator.aggregate( From 2d25dabad1e28beb3076497ee8cb86888859c726 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:49:33 +0800 Subject: [PATCH 09/22] fix(core): prefer refreshed catalog rates over embedded Gemini table The embedded Google rate table was consulted first and returned before the models.dev lookup, so a refreshed catalog correction or price change for these models was never used. The catalog is now consulted first; the embedded table remains the offline fallback for model ids the catalog has not caught up with yet, matching the documented intent. Co-authored-by: Cursor --- .../Generated/CodexParserHash.generated.swift | 2 +- .../CostUsage/CostUsagePricing+Google.swift | 59 +++++++++++-------- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 064f6afee0..ef78e6be7c 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "8c56d1cda8c0daa8" + static let value = "5ff079ec70f99f5b" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift index 4855bd6f59..d8606ad147 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing+Google.swift @@ -78,26 +78,45 @@ extension CostUsagePricing { modelsDevCacheRoot: URL? = nil) -> Double? { let canonicalModel = self.googlePricingModelID(model) - if let pricing = self.google[canonicalModel] { + // Prefer the refreshed models.dev catalog so a corrected or updated rate is actually used; + // the embedded table below is the offline fallback for ids the catalog has not caught up + // with yet, not the first source consulted. + if let lookup = self.modelsDevLookup( + providerID: "google", + model: canonicalModel, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { return self.googleCostUSD( - pricing: pricing, + pricing: lookup.pricing, inputTokens: inputTokens, cacheReadInputTokens: cacheReadInputTokens, outputTokens: outputTokens) } - guard let lookup = self.modelsDevLookup( - providerID: "google", - model: canonicalModel, - catalog: modelsDevCatalog, - cacheRoot: modelsDevCacheRoot) - else { - return nil - } - let pricing = lookup.pricing - let safeInput = max(0, inputTokens) - let safeCacheRead = max(0, cacheReadInputTokens) - let totalInput = safeInput.addingReportingOverflow(safeCacheRead) + guard let pricing = self.google[canonicalModel] else { return nil } + return self.googleCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + outputTokens: outputTokens) + } + + static func googlePricingModelID(_ model: String) -> String { + let normalized = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return self.googleAliases[normalized] ?? normalized + } + + private static func googleCostUSD( + pricing: ModelsDevPricingInfo, + inputTokens: Int, + cacheReadInputTokens: Int, + outputTokens: Int) -> Double + { + let input = max(0, inputTokens) + let cacheRead = max(0, cacheReadInputTokens) + let output = max(0, outputTokens) + let totalInput = input.addingReportingOverflow(cacheRead) let usesLongContextRates = pricing.thresholdTokens.map { totalInput.overflow || totalInput.partialValue > $0 } ?? false @@ -112,15 +131,9 @@ extension CostUsagePricing { let outputRate = usesLongContextRates ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken : pricing.outputCostPerToken - let cost = Double(safeInput) * inputRate - + Double(safeCacheRead) * cacheReadRate - + Double(max(0, outputTokens)) * outputRate - return cost.isFinite ? cost : nil - } - - static func googlePricingModelID(_ model: String) -> String { - let normalized = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return self.googleAliases[normalized] ?? normalized + return Double(input) * inputRate + + Double(cacheRead) * cacheReadRate + + Double(output) * outputRate } private static func googleCostUSD( From d5f438597202aed0494b90f1fe8d8f13788ce559 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:53:22 +0800 Subject: [PATCH 10/22] fix(core): fall back to OpenCode's time_created column The OpenCode database query filtered exclusively on data.time.created, but some databases populate only the message.time_created column (the established OpenCode Go reader falls back to it via COALESCE). Those rows were silently excluded. Apply the same COALESCE fallback in both the projection and the window filter. Co-authored-by: Cursor --- .../Providers/OpenCode/OpenCodeSessionScanner.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift index edfe04234f..298ec522d4 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift @@ -427,7 +427,7 @@ public enum OpenCodeSessionScanner { COALESCE( NULLIF(json_extract(data, '$.modelID'), ''), json_extract(data, '$.model.id')), - json_extract(data, '$.time.created'), + COALESCE(json_extract(data, '$.time.created'), time_created), json_extract(data, '$.tokens.input'), json_extract(data, '$.tokens.output'), json_extract(data, '$.tokens.reasoning'), @@ -436,8 +436,8 @@ public enum OpenCodeSessionScanner { json_extract(data, '$.cost') FROM message WHERE json_extract(data, '$.role') = 'assistant' - AND json_extract(data, '$.time.created') >= ? - AND json_extract(data, '$.time.created') < ? + AND COALESCE(json_extract(data, '$.time.created'), time_created) >= ? + AND COALESCE(json_extract(data, '$.time.created'), time_created) < ? """ var stmt: OpaquePointer? From 0adc8a195f5ac9dc06640dca307e632a753c876d Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:38:32 +0800 Subject: [PATCH 11/22] feat(core): wire local history scanners into the dashboard loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the local-history scanners (Antigravity, Gemini CLI, Kimi, MiniMax, OpenCode, Qwen, Copilot) were registered but never reached the Usage & Spend dashboard: `costCapableProviders` filtered on `supportsTokenCost`, which is false for these providers, so they never appeared; and `CostUsageFetcher.supportsTokenSnapshot` had no path for them. - `costCapableProviders` now filters on `supportsDashboardHistory` (supportsTokenCost OR has localHistorySources) so these providers enter the dashboard pipeline. - `CostUsageFetcher.supportsTokenSnapshot` returns true for providers that opted into `localHistorySources`. - `loadTokenSnapshot` short-circuits local-history providers to the registered scanner via `LocalHistoryScannerRegistry`, returning an empty snapshot (historyCoverageIsEstablished=false) when the tool is not installed or has no usage in the window. This keeps the change entirely in CodexBarCore — the existing UsageStore/dashboard `refreshTokenUsageNow` path picks up local history automatically. - Copilot descriptor now declares `localHistorySources: [.copilot]` so the registered Copilot scanner is reachable. - Extract `runCorpusScan`/`resolvedPiScannerOptions`/`configureScannerRefresh` helpers to keep `loadTokenSnapshot` within the function-length budget. Cursor/Trae stay on their existing remote/degraded paths (Cursor is server-billed; Trae has no local token source). ZCode has no UsageProvider/descriptor yet, so it is out of scope here. Co-authored-by: Cursor --- .../CodexBar/SpendDashboardController.swift | 2 +- Sources/CodexBarCore/CostUsageFetcher.swift | 298 +++++++++++++----- .../Copilot/CopilotProviderDescriptor.swift | 1 + .../CodexBarTests/CostUsageFetcherTests.swift | 15 + 4 files changed, 242 insertions(+), 74 deletions(-) diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 3a2c920ae9..3e063c20d1 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -346,7 +346,7 @@ enum SpendDashboardSource { @MainActor static func costCapableProviders(store: UsageStore) -> [UsageProvider] { store.enabledProvidersForDisplay().filter { - ProviderDescriptorRegistry.descriptor(for: $0).tokenCost.supportsTokenCost + ProviderDescriptorRegistry.descriptor(for: $0).tokenCost.supportsDashboardHistory } } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index fe2b7c7a4a..f613904cb8 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -242,85 +242,75 @@ public struct CostUsageFetcher: Sendable { return options } - static func loadTokenSnapshot( - provider: UsageProvider, - environment: [String: String] = ProcessInfo.processInfo.environment, - now: Date = Date(), - forceRefresh: Bool = false, - allowVertexClaudeFallback: Bool = false, - codexHomePath: String? = nil, - historyDays: Int = 30, - cursorCookieHeaderOverride: String? = nil, - allowPricingRefresh: Bool = true, - refreshPricingInBackground: Bool = true, - includePiSessions: Bool = true, - bypassScannerDebounce: Bool = false, - scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, - piScannerOptions overridePiScannerOptions: PiSessionCostScanner - .Options? = nil, - modelsDevClient: ModelsDevClient = ModelsDevClient(), - retryUnknownPricing: Bool = true, - codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) async throws - -> CostUsageTokenSnapshot + private static func resolvedPiScannerOptions( + _ override: PiSessionCostScanner.Options?, + base: CostUsageScanner.Options, + forceRefresh: Bool, + bypassScannerDebounce: Bool) -> PiSessionCostScanner.Options { - guard self.supportsTokenSnapshot(provider) else { - throw CostUsageError.unsupportedProvider(provider) + var resolved = override ?? PiSessionCostScanner.Options() + if resolved.cacheRoot == nil { + resolved.cacheRoot = base.cacheRoot } - - let clampedHistoryDays = max(1, min(365, historyDays)) - - if let remoteSnapshot = try await self.loadRemoteTokenSnapshot( - provider: provider, - environment: environment, - now: now, - historyDays: clampedHistoryDays, - cursorCookieHeaderOverride: cursorCookieHeaderOverride) - { - return remoteSnapshot + resolved.calendar = base.calendar + if forceRefresh || bypassScannerDebounce { + resolved.refreshMinIntervalSeconds = 0 } + return resolved + } - var options = Self.resolvedScannerOptions( - overrideScannerOptions, - provider: provider, - codexHomePath: codexHomePath) - // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. - let since = options.calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now - let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) - let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false - await Self.refreshPricingIfAllowed( - options: PricingRefreshOptions( - provider: provider, - isAllowed: allowPricingRefresh, - retryUnknown: retryUnknownPricing, - inBackground: refreshPricingInBackground), - now: now, - cacheRoot: options.cacheRoot, - client: modelsDevClient) - - Self.applyClaudeLogFilter(&options, provider: provider, allowVertexClaudeFallback: allowVertexClaudeFallback) + private static func configureScannerRefresh( + _ options: inout CostUsageScanner.Options, + forceRefresh: Bool, + bypassScannerDebounce: Bool, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)?) + { if forceRefresh || bypassScannerDebounce { options.refreshMinIntervalSeconds = 0 } if forceRefresh { Self.configureFullRescan(&options, progress: codexProgress) } - var resolvedPiOptions = overridePiScannerOptions ?? PiSessionCostScanner.Options() - if resolvedPiOptions.cacheRoot == nil { - resolvedPiOptions.cacheRoot = options.cacheRoot - } - resolvedPiOptions.calendar = options.calendar - if forceRefresh || bypassScannerDebounce { - resolvedPiOptions.refreshMinIntervalSeconds = 0 - } - let piOptions = resolvedPiOptions + } - try Task.checkCancellation() - // The corpus scans below are synchronous and can run for minutes on large session - // archives. They execute on the dedicated scan queue so they never occupy a cooperative - // pool thread; CostUsageScanExecutor bridges this task's cancellation into the - // scanner-level checks. - let scanOptions = options - let scanResult = try await CostUsageScanExecutor.run { checkCancellation in + private static func pricingRefreshOptions( + provider: UsageProvider, + allowPricingRefresh: Bool, + retryUnknownPricing: Bool, + refreshPricingInBackground: Bool) -> PricingRefreshOptions + { + PricingRefreshOptions( + provider: provider, + isAllowed: allowPricingRefresh, + retryUnknown: retryUnknownPricing, + inBackground: refreshPricingInBackground) + } + + /// Inputs for the synchronous Codex/Claude corpus scan, grouped to keep `runCorpusScan` + /// readable. + private struct CorpusScanInput { + let provider: UsageProvider + let since: Date + let now: Date + let scanOptions: CostUsageScanner.Options + let piOptions: PiSessionCostScanner.Options + let allowVertexClaudeFallback: Bool + let includePiSessions: Bool + let shouldMergePiUsage: Bool + } + + /// Runs the synchronous corpus scans on the dedicated scan queue so they never occupy a + /// cooperative pool thread; `CostUsageScanExecutor` bridges task cancellation into the + /// scanner-level checks. + private static func runCorpusScan(_ input: CorpusScanInput) async throws + -> (daily: CostUsageDailyReport, projects: [CostUsageProjectBreakdown], sessions: [CostUsageSessionBreakdown]) + { + let provider = input.provider + let since = input.since + let now = input.now + let scanOptions = input.scanOptions + let piOptions = input.piOptions + return try await CostUsageScanExecutor.run { checkCancellation in var daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, @@ -331,7 +321,7 @@ public struct CostUsageFetcher: Sendable { try checkCancellation() if provider == .vertexai, - !allowVertexClaudeFallback, + !input.allowVertexClaudeFallback, scanOptions.claudeLogProviderFilter == .vertexAIOnly, daily.data.isEmpty { @@ -367,7 +357,7 @@ public struct CostUsageFetcher: Sendable { modelsDevCacheRoot: scanOptions.cacheRoot, sessionRoots: roots) } - if includePiSessions, provider == .claude || (provider == .codex && shouldMergePiUsage) { + if input.includePiSessions, provider == .claude || (provider == .codex && input.shouldMergePiUsage) { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, since: since, @@ -390,6 +380,99 @@ public struct CostUsageFetcher: Sendable { } return (daily: daily, projects: projects, sessions: sessions) } + } + + static func loadTokenSnapshot( + provider: UsageProvider, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + forceRefresh: Bool = false, + allowVertexClaudeFallback: Bool = false, + codexHomePath: String? = nil, + historyDays: Int = 30, + cursorCookieHeaderOverride: String? = nil, + allowPricingRefresh: Bool = true, + refreshPricingInBackground: Bool = true, + includePiSessions: Bool = true, + bypassScannerDebounce: Bool = false, + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, + piScannerOptions overridePiScannerOptions: PiSessionCostScanner + .Options? = nil, + modelsDevClient: ModelsDevClient = ModelsDevClient(), + retryUnknownPricing: Bool = true, + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil) async throws + -> CostUsageTokenSnapshot + { + guard self.supportsTokenSnapshot(provider) else { + throw CostUsageError.unsupportedProvider(provider) + } + + let clampedHistoryDays = max(1, min(365, historyDays)) + + // Local-history providers short-circuit here: their snapshot comes from the registered + // scanner, not a remote path or the Codex corpus scan below. + if Self.usesLocalHistorySnapshot(provider) { + return try await self.loadLocalHistorySnapshotOrEmpty( + provider: provider, + environment: environment, + now: now, + historyDays: clampedHistoryDays) + } + + if let remoteSnapshot = try await self.loadRemoteTokenSnapshot( + provider: provider, + environment: environment, + now: now, + historyDays: clampedHistoryDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride) + { + return remoteSnapshot + } + + var options = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: provider, + codexHomePath: codexHomePath) + // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. + let since = options.calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now + let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false + await Self.refreshPricingIfAllowed( + options: Self.pricingRefreshOptions( + provider: provider, + allowPricingRefresh: allowPricingRefresh, + retryUnknownPricing: retryUnknownPricing, + refreshPricingInBackground: refreshPricingInBackground), + now: now, + cacheRoot: options.cacheRoot, + client: modelsDevClient) + Self.applyClaudeLogFilter(&options, provider: provider, allowVertexClaudeFallback: allowVertexClaudeFallback) + Self.configureScannerRefresh( + &options, + forceRefresh: forceRefresh, + bypassScannerDebounce: bypassScannerDebounce, + codexProgress: codexProgress) + let piOptions = Self.resolvedPiScannerOptions( + overridePiScannerOptions, + base: options, + forceRefresh: forceRefresh, + bypassScannerDebounce: bypassScannerDebounce) + + try Task.checkCancellation() + // The corpus scans below are synchronous and can run for minutes on large session + // archives. They execute on the dedicated scan queue so they never occupy a cooperative + // pool thread; CostUsageScanExecutor bridges this task's cancellation into the + // scanner-level checks. + let scanInput = CorpusScanInput( + provider: provider, + since: since, + now: now, + scanOptions: options, + piOptions: piOptions, + allowVertexClaudeFallback: allowVertexClaudeFallback, + includePiSessions: includePiSessions, + shouldMergePiUsage: shouldMergePiUsage) + let scanResult = try await self.runCorpusScan(scanInput) if allowPricingRefresh, retryUnknownPricing, @@ -423,7 +506,7 @@ public struct CostUsageFetcher: Sendable { from: scanResult.daily, now: now, historyDays: clampedHistoryDays, - calendar: scanOptions.calendar, + calendar: options.calendar, projects: scanResult.projects, sessions: scanResult.sessions) } @@ -639,7 +722,9 @@ public struct CostUsageFetcher: Sendable { } /// Providers whose token-cost snapshot `loadTokenSnapshot` can produce. Cursor is - /// macOS-only because it reuses the macOS Cursor session resolution. + /// macOS-only because it reuses the macOS Cursor session resolution. A provider that opts + /// into `localHistorySources` is also supported: its snapshot comes from the registered local + /// history scanner rather than a remote/web path. static func supportsTokenSnapshot(_ provider: UsageProvider) -> Bool { switch provider { case .codex, .claude, .vertexai, .bedrock: @@ -651,7 +736,8 @@ public struct CostUsageFetcher: Sendable { return false #endif default: - return false + return !ProviderDescriptorRegistry.descriptor(for: provider) + .tokenCost.localHistorySources.isEmpty } } @@ -1083,4 +1169,70 @@ extension CostUsageFetcher { #endif return nil } + + /// Produces a snapshot from a provider's registered local history scanners, when the provider + /// opted into `localHistorySources`. Returns nil when no scanner yields usable history on this + /// machine (tool not installed, or no usage in the window). + fileprivate static func loadLocalHistoryTokenSnapshot( + provider: UsageProvider, + environment: [String: String], + now: Date, + historyDays: Int) async throws -> CostUsageTokenSnapshot? + { + let sources = ProviderDescriptorRegistry.descriptor(for: provider) + .tokenCost.localHistorySources + guard !sources.isEmpty else { return nil } + let context = LocalHistoryScanContext( + environment: environment, + historyDays: historyDays, + now: now) + for source in sources { + guard let scanner = LocalHistoryScannerRegistry.shared.scanner(for: source) else { + continue + } + if let snapshot = try scanner.scan(context: context) { + return snapshot + } + } + return nil + } + + /// Whether the provider's token snapshot comes from local history scanners rather than a + /// remote/web path or the Codex corpus scanner. Such providers short-circuit `loadTokenSnapshot` + /// before the Codex scan path. + fileprivate static func usesLocalHistorySnapshot(_ provider: UsageProvider) -> Bool { + !ProviderDescriptorRegistry.descriptor(for: provider) + .tokenCost.localHistorySources.isEmpty + } + + /// Loads the local-history snapshot, or an empty snapshot when the tool is not installed or + /// has no usage in the window. `historyCoverageIsEstablished` is false so the dashboard can + /// distinguish "no data" from a real zero. + fileprivate static func loadLocalHistorySnapshotOrEmpty( + provider: UsageProvider, + environment: [String: String], + now: Date, + historyDays: Int) async throws -> CostUsageTokenSnapshot + { + if let snapshot = try await self.loadLocalHistoryTokenSnapshot( + provider: provider, + environment: environment, + now: now, + historyDays: historyDays) + { + return snapshot + } + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "XXX", + historyDays: historyDays, + historyCoverageIsEstablished: false, + historyLabel: ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName, + costSource: .estimated, + daily: [], + updatedAt: now) + } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift index 13db4ed67c..912f6c539b 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotProviderDescriptor.swift @@ -34,6 +34,7 @@ public enum CopilotProviderDescriptor { ]), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, + localHistorySources: [.copilot], noDataMessage: { "Copilot cost summary is not supported." }), pace: ProviderPaceCapability( resetWindowPace: .resetDatePresent, diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index c8a878f073..088f829b45 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -4,6 +4,21 @@ import Testing @Suite(.serialized) struct CostUsageFetcherTests { + @Test + func `local history providers support token snapshot`() { + // Providers that opt into `localHistorySources` (Copilot, Antigravity, Kimi, MiniMax, + // OpenCode, Qwen) get their snapshot from a registered local scanner, so the fetcher must + // report them as snapshot-capable even though `supportsTokenCost` is false. + #expect(CostUsageFetcher.supportsTokenSnapshot(.copilot)) + #expect(CostUsageFetcher.supportsTokenSnapshot(.antigravity)) + #expect(CostUsageFetcher.supportsTokenSnapshot(.kimi)) + #expect(CostUsageFetcher.supportsTokenSnapshot(.minimax)) + #expect(CostUsageFetcher.supportsTokenSnapshot(.opencode)) + #expect(CostUsageFetcher.supportsTokenSnapshot(.qwencloud)) + // A provider with neither a remote path nor local history sources stays unsupported. + #expect(!CostUsageFetcher.supportsTokenSnapshot(.grok)) + } + @Test func `fetcher scopes codex history to selected codex home`() async throws { let env = try CostUsageTestEnvironment() From 1d68546d321807e81cc62abe5773249bab6073fd Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:07:14 +0800 Subject: [PATCH 12/22] refactor(core): migrate Kimi/MiniMax/Antigravity scanners to the shared engine These three hand-written aggregations each carried a copy of day/model bucketing, cached-prefix handling, and pricing, and each published a partial subtotal as the complete headline cost when a day contained both priced and unpriced usage. Migrating them to UsageEventAggregator collapses that duplicated logic into the single engine and makes the unpriced-day withholding (previously fixed for the engine in r5) apply to them too. - Each scanner now walks its source files and emits UnifiedUsageEvent records; the engine owns bucketing, normalization, pricing, and snapshot construction. - Per-turn estimated cost is resolved in the parser (Kimi via the Moonshot third-party lookup with kimi-k3 fallback; MiniMax via the MiniMax third-party lookup falling back to provider-reported cost_usd; Antigravity already resolves it during the DB read) and carried as providerCostUSD so the engine trusts it without re-pricing. An unresolvable cost now surfaces as an unpriced day, not a partial subtotal. - UsageEventAggregator now emits reasoningTokens as nil when the bucket has none, matching the pre-migration output of these scanners (and the MiniMax/Antigravity convention that reasoning folds into output). - Consumption totals are unified on the engine's input+cacheRead+output shape: Kimi/MiniMax previously also counted cache-write tokens in the total, which is now reported separately via cacheCreationTokens and priced at its own rate. Tests: Kimi aggregate/malformed/mixed-pricing tests updated for the unified total and unpriced-day semantics; all three scanner suites, the engine suite, and ZCode/Copilot suites pass. Co-authored-by: Cursor --- .../AntigravitySessionScanner.swift | 170 ++--------- .../Kimi/KimiCodeSessionScanner.swift | 278 +++++------------- .../LocalHistory/UsageEventAggregator.swift | 2 +- .../MiniMax/MiniMaxSessionScanner.swift | 196 ++---------- .../KimiCodeSessionScannerTests.swift | 47 ++- 5 files changed, 166 insertions(+), 527 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift index d8a0ba9c63..08820254f5 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravitySessionScanner.swift @@ -45,79 +45,6 @@ public enum AntigravitySessionScanner { "claude-haiku-4-6-thinking": "claude-haiku-4-6", ] - private struct DayModelKey: Hashable { - let day: String - let model: String - } - - private struct TokenAccumulator { - var input = 0 - var cacheRead = 0 - var output = 0 - var reasoning = 0 - var requests = 0 - var cost = 0.0 - var sawCost = false - - mutating func add(_ row: UsageRow) -> Bool { - guard let nextInput = Self.adding(self.input, row.input), - let nextCacheRead = Self.adding(self.cacheRead, row.cacheRead), - let nextOutput = Self.adding(self.output, row.output), - let nextReasoning = Self.adding(self.reasoning, row.reasoning), - let nextRequests = Self.adding(self.requests, 1) - else { - return false - } - self.input = nextInput - self.cacheRead = nextCacheRead - self.output = nextOutput - self.reasoning = nextReasoning - self.requests = nextRequests - if let cost = row.costUSD, cost.isFinite { - let nextCost = self.cost + cost - if nextCost.isFinite { - self.cost = nextCost - self.sawCost = true - } - } - return true - } - - mutating func merge(_ other: TokenAccumulator) -> Bool { - guard let nextInput = Self.adding(self.input, other.input), - let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), - let nextOutput = Self.adding(self.output, other.output), - let nextReasoning = Self.adding(self.reasoning, other.reasoning), - let nextRequests = Self.adding(self.requests, other.requests) - else { - return false - } - self.input = nextInput - self.cacheRead = nextCacheRead - self.output = nextOutput - self.reasoning = nextReasoning - self.requests = nextRequests - if other.sawCost { - let nextCost = self.cost + other.cost - if other.cost.isFinite, nextCost.isFinite { - self.cost = nextCost - self.sawCost = true - } - } - return true - } - - var total: Int? { - guard let withCacheRead = Self.adding(self.input, self.cacheRead) else { return nil } - return Self.adding(withCacheRead, self.output) - } - - private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { - let result = lhs.addingReportingOverflow(rhs) - return result.overflow ? nil : result.partialValue - } - } - private struct UsageRow { let model: String let createdMs: Int64 @@ -162,7 +89,7 @@ public enum AntigravitySessionScanner { let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end - var values: [DayModelKey: TokenAccumulator] = [:] + var events: [UnifiedUsageEvent] = [] var seenResponseIDs: Set = [] for databaseURL in databaseURLs { try checkCancellation() @@ -175,75 +102,32 @@ public enum AntigravitySessionScanner { let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) let day = calendar.startOfDay(for: date) guard day >= start, day <= end else { return } - let key = DayModelKey( + // Antigravity's `input` is newly-processed (non-cached), and its consumption total + // is input+cacheRead+output — the same shape the shared engine produces — so the + // event carries that total verbatim. The per-row estimated cost (Google models.dev + // rate, resolved during the DB read) is carried as `providerCostUSD`; an + // unresolvable cost surfaces as an unpriced day rather than a partial subtotal. + events.append(UnifiedUsageEvent( day: CostUsageLocalDay.key(from: day, calendar: calendar), - model: row.model) - var value = values[key] ?? TokenAccumulator() - guard value.add(row) else { return } - values[key] = value + model: row.model, + billingProviderID: UsageProvider.antigravity.rawValue, + inputTokens: row.input, + outputTokens: row.output, + totalTokens: row.input + row.cacheRead + row.output, + cacheReadTokens: row.cacheRead, + reasoningTokens: row.reasoning > 0 ? row.reasoning : nil, + providerCostUSD: row.costUSD)) } } - guard !values.isEmpty else { return nil } - let byDay = Dictionary(grouping: values, by: \.key.day) - let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in - let models = (byDay[day] ?? []).sorted { lhs, rhs in - lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending - } - var total = TokenAccumulator() - var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] - var dayCost = 0.0 - var daySawCost = false - for (key, value) in models { - guard let modelTotal = value.total else { return nil } - guard total.merge(value) else { return nil } - modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( - modelName: key.model, - costUSD: value.sawCost ? value.cost : nil, - totalTokens: modelTotal, - inputTokens: value.input, - cacheReadTokens: value.cacheRead, - cacheCreationTokens: nil, - outputTokens: value.output, - reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, - requestCount: value.requests)) - if value.sawCost { - dayCost += value.cost - daySawCost = true - } - } - guard let totalTokens = total.total else { return nil } - return CostUsageDailyReport.Entry( - date: day, - inputTokens: total.input, - outputTokens: total.output, - cacheReadTokens: total.cacheRead, - cacheCreationTokens: nil, - totalTokens: totalTokens, - requestCount: total.requests, - costUSD: daySawCost ? dayCost : nil, - modelsUsed: modelBreakdowns.map(\.modelName), - modelBreakdowns: modelBreakdowns) - } - let totalTokens = self.sum(daily.compactMap(\.totalTokens)) - let totalRequests = self.sum(daily.compactMap(\.requestCount)) - let totalCost = daily.compactMap(\.costUSD).reduce(0, +) - let sawCost = daily.contains { $0.costUSD != nil } - guard let totalTokens, let totalRequests else { return nil } - - return CostUsageTokenSnapshot( - sessionTokens: nil, - sessionCostUSD: nil, - last30DaysTokens: totalTokens, - last30DaysCostUSD: sawCost ? totalCost : nil, - last30DaysRequests: totalRequests, - currencyCode: sawCost ? "USD" : "XXX", + return UsageEventAggregator.aggregate( + events: events, historyDays: days, - historyCoverageIsEstablished: true, - historyLabel: "Antigravity", - costSource: .estimated, - daily: daily, - updatedAt: now) + now: now, + options: .init( + historyLabel: "Antigravity", + defaultBillingProviderID: UsageProvider.antigravity.rawValue, + modelsDevCacheRoot: modelsDevCacheRoot)) } // MARK: - Paths @@ -489,16 +373,6 @@ public enum AntigravitySessionScanner { guard let value else { return 0 } return Int(clamping: value) } - - private static func sum(_ values: [Int]) -> Int? { - var result = 0 - for value in values { - let addition = result.addingReportingOverflow(value) - guard !addition.overflow else { return nil } - result = addition.partialValue - } - return result - } } // MARK: - Protobuf wire-format reader diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift index ecf8b2898c..f92cceb681 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift @@ -20,138 +20,46 @@ public enum KimiCodeSessionScanner { let usageScope: String? } - private struct DayModelKey: Hashable { - let day: String - let model: String + /// Returns the value when it is a valid non-negative token count, or nil otherwise. A + /// malformed (negative) count rejects the whole record, matching the pre-migration scanner. + private static func validOrNil(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value } - private struct TokenAccumulator { - var input = 0 - var cacheRead = 0 - var cacheCreation = 0 - var output = 0 - var requests = 0 - var cost = 0.0 - var sawCost = false - - mutating func add( - _ usage: WireEvent.Usage, - model: String, - pricingDate: Date, - modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Bool - { - guard let input = Self.valid(usage.inputOther), - let cacheRead = Self.valid(usage.inputCacheRead), - let cacheCreation = Self.valid(usage.inputCacheCreation), - let output = Self.valid(usage.output), - let nextInput = Self.adding(self.input, input), - let nextCacheRead = Self.adding(self.cacheRead, cacheRead), - let nextCacheCreation = Self.adding(self.cacheCreation, cacheCreation), - let nextOutput = Self.adding(self.output, output), - let nextRequests = Self.adding(self.requests, 1) - else { - return false - } - self.input = nextInput - self.cacheRead = nextCacheRead - self.cacheCreation = nextCacheCreation - self.output = nextOutput - self.requests = nextRequests - if let cost = Self.estimatedCost( - model: model, - usage: usage, - pricingDate: pricingDate, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot), - cost.isFinite - { - let nextCost = self.cost + cost - if nextCost.isFinite { - self.cost = nextCost - self.sawCost = true - } - } - return true - } - - mutating func merge(_ other: TokenAccumulator) -> Bool { - guard let nextInput = Self.adding(self.input, other.input), - let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), - let nextCacheCreation = Self.adding(self.cacheCreation, other.cacheCreation), - let nextOutput = Self.adding(self.output, other.output), - let nextRequests = Self.adding(self.requests, other.requests) - else { - return false - } - self.input = nextInput - self.cacheRead = nextCacheRead - self.cacheCreation = nextCacheCreation - self.output = nextOutput - self.requests = nextRequests - if other.sawCost { - let nextCost = self.cost + other.cost - if other.cost.isFinite, nextCost.isFinite { - self.cost = nextCost - self.sawCost = true - } - } - return true - } - - var total: Int? { - guard let inputAndCacheRead = Self.adding(self.input, self.cacheRead), - let withCacheCreation = Self.adding(inputAndCacheRead, self.cacheCreation) - else { - return nil - } - return Self.adding(withCacheCreation, self.output) - } - - private static func valid(_ value: Int?) -> Int? { - guard let value, value >= 0 else { return nil } - return value - } - - private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { - let result = lhs.addingReportingOverflow(rhs) - return result.overflow ? nil : result.partialValue - } - - private static func estimatedCost( - model: String, - usage: WireEvent.Usage, - pricingDate: Date, - modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Double? - { - guard let input = self.valid(usage.inputOther), - let cacheRead = self.valid(usage.inputCacheRead), - let cacheCreation = self.valid(usage.inputCacheCreation), - let output = self.valid(usage.output) - else { - return nil - } - return CostUsagePricing.claudeCostUSD( - model: self.pricingModelID(model), - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreation, - outputTokens: output, - pricingDate: pricingDate, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) + private static func estimatedCost( + model: String, + usage: WireEvent.Usage, + pricingDate: Date, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + guard let input = self.validOrNil(usage.inputOther), + let cacheRead = self.validOrNil(usage.inputCacheRead), + let cacheCreation = self.validOrNil(usage.inputCacheCreation), + let output = self.validOrNil(usage.output) + else { + return nil } + return CostUsagePricing.claudeCostUSD( + model: self.pricingModelID(model), + inputTokens: input, + cacheReadInputTokens: cacheRead, + cacheCreationInputTokens: cacheCreation, + outputTokens: output, + pricingDate: pricingDate, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } - private static func pricingModelID(_ model: String) -> String { - let bare = model.split(separator: "/", omittingEmptySubsequences: true).last - .map(String.init) ?? model - switch bare.lowercased() { - case "k3", "k3-256k": - return "kimi-k3" - default: - return bare - } + private static func pricingModelID(_ model: String) -> String { + let bare = model.split(separator: "/", omittingEmptySubsequences: true).last + .map(String.init) ?? model + switch bare.lowercased() { + case "k3", "k3-256k": + return "kimi-k3" + default: + return bare } } @@ -196,7 +104,7 @@ public enum KimiCodeSessionScanner { let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end - var values: [DayModelKey: TokenAccumulator] = [:] + var events: [UnifiedUsageEvent] = [] let decoder = JSONDecoder() let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) var visitedFiles = 0 @@ -244,18 +152,34 @@ public enum KimiCodeSessionScanner { let date = Date(timeIntervalSince1970: time / 1000) let day = calendar.startOfDay(for: date) guard day >= start, day <= end else { return } - let key = DayModelKey( + // Kimi's `inputOther` is already uncached. Passing totalTokens as + // input+cacheRead+output (cache-write excluded) hits the engine's + // "input is already uncached" branch so it is priced as-is. The estimated cost + // is resolved here (official Moonshot rate via the third-party lookup, with a + // kimi-k3 fallback) and carried as `providerCostUSD` so the engine trusts it; + // an unresolvable cost surfaces as an unpriced day, not a partial subtotal. + guard let input = Self.validOrNil(usage.inputOther), + let cacheRead = Self.validOrNil(usage.inputCacheRead), + let cacheCreation = Self.validOrNil(usage.inputCacheCreation), + let output = Self.validOrNil(usage.output) + else { + return + } + events.append(UnifiedUsageEvent( day: CostUsageLocalDay.key(from: day, calendar: calendar), - model: rawModel) - var value = values[key] ?? TokenAccumulator() - guard value.add( - usage, model: rawModel, - pricingDate: date, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - else { return } - values[key] = value + billingProviderID: UsageProvider.kimi.rawValue, + inputTokens: input, + outputTokens: output, + totalTokens: input + cacheRead + output, + cacheReadTokens: cacheRead, + cacheCreationTokens: cacheCreation, + providerCostUSD: Self.estimatedCost( + model: rawModel, + usage: usage, + pricingDate: date, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot))) } } catch is CancellationError { throw CancellationError() @@ -264,76 +188,14 @@ public enum KimiCodeSessionScanner { } } - guard !values.isEmpty else { return nil } - let byDay = Dictionary(grouping: values, by: \.key.day) - let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in - let models = (byDay[day] ?? []).sorted { lhs, rhs in - lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending - } - var total = TokenAccumulator() - var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] - var dayCost = 0.0 - var daySawCost = false - for (key, value) in models { - guard let modelTotal = value.total else { return nil } - guard total.merge(value) else { return nil } - modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( - modelName: key.model, - billingProviderID: UsageProvider.kimi.rawValue, - costUSD: value.sawCost ? value.cost : nil, - totalTokens: modelTotal, - inputTokens: value.input, - cacheReadTokens: value.cacheRead, - cacheCreationTokens: value.cacheCreation, - outputTokens: value.output, - requestCount: value.requests)) - if value.sawCost { - dayCost += value.cost - daySawCost = true - } - } - guard let totalTokens = total.total else { return nil } - return CostUsageDailyReport.Entry( - date: day, - inputTokens: total.input, - outputTokens: total.output, - cacheReadTokens: total.cacheRead, - cacheCreationTokens: total.cacheCreation, - totalTokens: totalTokens, - requestCount: total.requests, - costUSD: daySawCost ? dayCost : nil, - modelsUsed: modelBreakdowns.map(\.modelName), - modelBreakdowns: modelBreakdowns) - } - let totalTokens = self.sum(daily.compactMap(\.totalTokens)) - let totalRequests = self.sum(daily.compactMap(\.requestCount)) - let totalCost = daily.compactMap(\.costUSD).reduce(0, +) - let sawCost = daily.contains { $0.costUSD != nil } - guard let totalTokens, let totalRequests else { return nil } - - return CostUsageTokenSnapshot( - sessionTokens: nil, - sessionCostUSD: nil, - sessionRequests: nil, - last30DaysTokens: totalTokens, - last30DaysCostUSD: sawCost ? totalCost : nil, - last30DaysRequests: totalRequests, - currencyCode: sawCost ? "USD" : "XXX", + return UsageEventAggregator.aggregate( + events: events, historyDays: days, - historyCoverageIsEstablished: true, - historyLabel: "Kimi Code CLI", - costSource: .estimated, - daily: daily, - updatedAt: now) + now: now, + options: .init( + historyLabel: "Kimi Code CLI", + defaultBillingProviderID: UsageProvider.kimi.rawValue, + modelsDevCacheRoot: modelsDevCacheRoot)) } - private static func sum(_ values: [Int]) -> Int? { - var result = 0 - for value in values { - let addition = result.addingReportingOverflow(value) - guard !addition.overflow else { return nil } - result = addition.partialValue - } - return result - } } diff --git a/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift b/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift index 2547ad900c..6bc4877729 100644 --- a/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift +++ b/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift @@ -246,7 +246,7 @@ public enum UsageEventAggregator { cacheReadTokens: value.cacheRead, cacheCreationTokens: value.cacheCreation, outputTokens: value.output, - reasoningTokens: value.reasoning, + reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, requestCount: value.requests)) } Self.merge(value, into: &total) diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift index c3a27b394e..4dae76a7c6 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift @@ -30,96 +30,6 @@ public enum MiniMaxSessionScanner { /// stays self-contained (mirrors how `KimiCodeSessionScanner` honors `KIMI_CODE_HOME`). public static let homeEnvironmentKey = "MINIMAX_HOME" - private struct DayModelKey: Hashable { - let day: String - let model: String - } - - private struct TokenAccumulator { - var input = 0 - var cacheRead = 0 - var cacheCreation = 0 - var output = 0 - var reasoning = 0 - var requests = 0 - var cost = 0.0 - var sawCost = false - - mutating func add( - _ row: UsageRow, - modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) -> Bool - { - guard let nextInput = Self.adding(self.input, row.input), - let nextCacheRead = Self.adding(self.cacheRead, row.cacheRead), - let nextCacheCreation = Self.adding(self.cacheCreation, row.cacheCreation), - let nextOutput = Self.adding(self.output, row.output), - let nextReasoning = Self.adding(self.reasoning, row.reasoning), - let nextRequests = Self.adding(self.requests, 1) - else { - return false - } - self.input = nextInput - self.cacheRead = nextCacheRead - self.cacheCreation = nextCacheCreation - self.output = nextOutput - self.reasoning = nextReasoning - self.requests = nextRequests - if let cost = row.estimatedCost( - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot), - cost.isFinite - { - let nextCost = self.cost + cost - if nextCost.isFinite { - self.cost = nextCost - self.sawCost = true - } - } - return true - } - - mutating func merge(_ other: TokenAccumulator) -> Bool { - guard let nextInput = Self.adding(self.input, other.input), - let nextCacheRead = Self.adding(self.cacheRead, other.cacheRead), - let nextCacheCreation = Self.adding(self.cacheCreation, other.cacheCreation), - let nextOutput = Self.adding(self.output, other.output), - let nextReasoning = Self.adding(self.reasoning, other.reasoning), - let nextRequests = Self.adding(self.requests, other.requests) - else { - return false - } - self.input = nextInput - self.cacheRead = nextCacheRead - self.cacheCreation = nextCacheCreation - self.output = nextOutput - self.reasoning = nextReasoning - self.requests = nextRequests - if other.sawCost { - let nextCost = self.cost + other.cost - if other.cost.isFinite, nextCost.isFinite { - self.cost = nextCost - self.sawCost = true - } - } - return true - } - - var total: Int? { - guard let inputAndCacheRead = Self.adding(self.input, self.cacheRead), - let withCacheCreation = Self.adding(inputAndCacheRead, self.cacheCreation) - else { - return nil - } - return Self.adding(withCacheCreation, self.output) - } - - private static func adding(_ lhs: Int, _ rhs: Int) -> Int? { - let result = lhs.addingReportingOverflow(rhs) - return result.overflow ? nil : result.partialValue - } - } - private struct UsageRow { let model: String let createdMs: Int64 @@ -205,82 +115,43 @@ public enum MiniMaxSessionScanner { } let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: modelsDevCacheRoot) - var values: [DayModelKey: TokenAccumulator] = [:] + var events: [UnifiedUsageEvent] = [] for row in rows { try checkCancellation() let date = Date(timeIntervalSince1970: TimeInterval(row.createdMs) / 1000) let day = calendar.startOfDay(for: date) guard day >= start, day <= end else { continue } - let key = DayModelKey(day: CostUsageLocalDay.key(from: day, calendar: calendar), model: row.model) - var value = values[key] ?? TokenAccumulator() - guard value.add(row, modelsDevCatalog: modelsDevCatalog, modelsDevCacheRoot: modelsDevCacheRoot) - else { continue } - values[key] = value + // MiniMax reports `input` already uncached. The engine's uncached-input disambiguation + // keys off `totalTokens`: passing input+cacheRead+output (cache-write excluded) hits the + // "input is already uncached" branch so it is priced as-is, while cache-write is priced + // separately via `cacheCreationTokens`. The estimated cost is resolved here (official + // models.dev rate, falling back to a real provider-reported `cost_usd`) and carried as + // `providerCostUSD` so the engine trusts it and never re-prices; an unresolvable cost + // surfaces as an unpriced day instead of a partial subtotal. + events.append(UnifiedUsageEvent( + day: CostUsageLocalDay.key(from: day, calendar: calendar), + model: row.model, + billingProviderID: UsageProvider.minimax.rawValue, + inputTokens: row.input, + outputTokens: row.output, + totalTokens: row.input + row.cacheRead + row.output, + cacheReadTokens: row.cacheRead, + cacheCreationTokens: row.cacheCreation, + reasoningTokens: row.reasoning > 0 ? row.reasoning : nil, + providerCostUSD: row.estimatedCost( + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot))) } - guard !values.isEmpty else { return nil } - let byDay = Dictionary(grouping: values, by: \.key.day) - let daily = byDay.keys.sorted().compactMap { day -> CostUsageDailyReport.Entry? in - let models = (byDay[day] ?? []).sorted { lhs, rhs in - lhs.key.model.localizedCaseInsensitiveCompare(rhs.key.model) == .orderedAscending - } - var total = TokenAccumulator() - var modelBreakdowns: [CostUsageDailyReport.ModelBreakdown] = [] - var dayCost = 0.0 - var daySawCost = false - for (key, value) in models { - guard let modelTotal = value.total else { return nil } - guard total.merge(value) else { return nil } - modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( - modelName: key.model, - billingProviderID: UsageProvider.minimax.rawValue, - costUSD: value.sawCost ? value.cost : nil, - totalTokens: modelTotal, - inputTokens: value.input, - cacheReadTokens: value.cacheRead, - cacheCreationTokens: value.cacheCreation, - outputTokens: value.output, - reasoningTokens: value.reasoning > 0 ? value.reasoning : nil, - requestCount: value.requests)) - if value.sawCost { - dayCost += value.cost - daySawCost = true - } - } - guard let totalTokens = total.total else { return nil } - return CostUsageDailyReport.Entry( - date: day, - inputTokens: total.input, - outputTokens: total.output, - cacheReadTokens: total.cacheRead, - cacheCreationTokens: total.cacheCreation, - totalTokens: totalTokens, - requestCount: total.requests, - costUSD: daySawCost ? dayCost : nil, - modelsUsed: modelBreakdowns.map(\.modelName), - modelBreakdowns: modelBreakdowns) - } - let totalTokens = self.sum(daily.compactMap(\.totalTokens)) - let totalRequests = self.sum(daily.compactMap(\.requestCount)) - let totalCost = daily.compactMap(\.costUSD).reduce(0, +) - let sawCost = daily.contains { $0.costUSD != nil } - guard let totalTokens, let totalRequests else { return nil } - - return CostUsageTokenSnapshot( - sessionTokens: nil, - sessionCostUSD: nil, - sessionRequests: nil, - last30DaysTokens: totalTokens, - last30DaysCostUSD: sawCost ? totalCost : nil, - last30DaysRequests: totalRequests, - currencyCode: sawCost ? "USD" : "XXX", + return UsageEventAggregator.aggregate( + events: events, historyDays: days, - historyCoverageIsEstablished: true, - historyLabel: "MiniMax", - costSource: .estimated, - daily: daily, - updatedAt: now) + now: now, + options: .init( + historyLabel: "MiniMax", + defaultBillingProviderID: UsageProvider.minimax.rawValue, + modelsDevCacheRoot: modelsDevCacheRoot)) } // MARK: - Paths @@ -420,16 +291,5 @@ public enum MiniMaxSessionScanner { return String(cString: cString) } - // MARK: - Helpers - - private static func sum(_ values: [Int]) -> Int? { - var result = 0 - for value in values { - let addition = result.addingReportingOverflow(value) - guard !addition.overflow else { return nil } - result = addition.partialValue - } - return result - } } #endif diff --git a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift index 190205a7a2..b0120d8066 100644 --- a/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift +++ b/Tests/CodexBarTests/KimiCodeSessionScannerTests.swift @@ -50,7 +50,9 @@ struct KimiCodeSessionScannerTests { calendar: Self.calendar)) #expect(snapshot.currencyCode == "USD") - #expect(snapshot.last30DaysTokens == 82) + // Shared-engine total is input+cacheRead+output; cache-write is priced and reported + // separately (cacheCreationTokens) but not folded into the consumption total. + #expect(snapshot.last30DaysTokens == 76) #expect(snapshot.last30DaysRequests == 3) #expect(snapshot.last30DaysCostUSD != nil) #expect(snapshot.costSource == .estimated) @@ -59,7 +61,7 @@ struct KimiCodeSessionScannerTests { "kimi-code/k3", "kimi-code/kimi-for-coding", ]) - #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.totalTokens) == [55, 27]) + #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.totalTokens) == [49, 27]) #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.inputTokens) == [14, 8]) #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheReadTokens) == [25, 9]) #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.cacheCreationTokens) == [6, 0]) @@ -70,6 +72,47 @@ struct KimiCodeSessionScannerTests { #expect(snapshot.daily.flatMap { $0.modelBreakdowns ?? [] }.map(\.reasoningTokens) == [nil, nil]) } + @Test + func `mixed pricing day withholds the day cost instead of a partial total`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let agent = root + .appendingPathComponent("sessions/workspace/session-a/agents/main", isDirectory: true) + try FileManager.default.createDirectory(at: agent, withIntermediateDirectories: true) + try Self.write([ + // kimi-code/k3 is priced via the third-party fallback. + Self.usage( + time: 1_784_257_200_000, + model: "kimi-code/k3", + input: 10, + cacheRead: 20, + output: 3), + // An unresolvable model (no third-party mapping, empty catalog) stays unpriced. + Self.usage( + time: 1_784_257_300_000, + model: "kimi-code/unknown-model", + input: 4, + cacheRead: 5, + output: 7), + ], to: agent.appendingPathComponent("wire.jsonl")) + + let snapshot = try #require(KimiCodeSessionScanner.scan( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: root.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_784_347_200), + calendar: Self.calendar, + modelsDevCacheRoot: root.appendingPathComponent("empty-pricing", isDirectory: true))) + + // Tokens are still counted; the mixed-priced day withholds its cost so the partial + // subtotal never masquerades as a complete total. k3(10+20+3) + unknown(4+5+7) = 49. + #expect(snapshot.last30DaysTokens == 49) + #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.daily.first?.totalTokens == 49) + #expect(snapshot.daily.first?.costUSD == nil) + } + @Test func `scanner ignores malformed negative and out of range records`() throws { let root = FileManager.default.temporaryDirectory From b7110f477224099be108611bb59d6f30ac9d5567 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:13:22 +0800 Subject: [PATCH 13/22] feat(core): retain OpenCode billing ownership evidence end to end The scanner dropped OpenCode's recorded provider on the floor: neither the JSON nor the opencode.db path parsed it, so per-model breakdowns carried no ownership evidence and cross-store dedup could not tell two otherwise identical records routed through different providers apart. - WireMessage and the opencode.db SQL now read `providerID` / `model.provider` (JSON) and `$.providerID` / `$.model.provider` (DB). - UsageRecord carries billingProviderID, the aggregation keeps the first sourced value per (day, model), and ModelBreakdown surfaces it. - The cross-store fingerprint now includes the provider, so records whose routing differs no longer collapse; legacy records without evidence use an empty slot and never get one guessed from the model name. Co-authored-by: Cursor --- .../OpenCode/OpenCodeSessionScanner.swift | 35 +++++++++++++- .../OpenCodeSessionScannerTests.swift | 48 ++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift index 298ec522d4..d94953dca4 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift @@ -64,6 +64,7 @@ public enum OpenCodeSessionScanner { struct Model: Decodable { let id: String? + let provider: String? } struct Time: Decodable { @@ -74,6 +75,9 @@ public enum OpenCodeSessionScanner { let role: String? let modelID: String? let model: Model? + /// Billing ownership evidence recorded by OpenCode (e.g. `openai`, `anthropic`). + /// Optional because legacy JSON files predate it; never guessed from the model name. + let providerID: String? let tokens: Tokens? let time: Time } @@ -100,6 +104,10 @@ public enum OpenCodeSessionScanner { let dedupKey: String let day: String let model: String + /// Billing ownership evidence retained from the source record (JSON `providerID` / + /// `model.provider`, or the DB row's equivalents). Optional because legacy records predate + /// it; never inferred from the model name. + let billingProviderID: String? let usage: NormalizedUsage /// Provider-reported cost (only present for `opencode.db` rows; JSON files carry none). let cost: Double? @@ -215,6 +223,9 @@ public enum OpenCodeSessionScanner { var values: [DayModelKey: TokenAccumulator] = [:] var costs: [DayModelKey: Double] = [:] + // First retained ownership evidence per (day, model); records that lack one do not + // overwrite an earlier, sourced value. + var billingProviderIDs: [DayModelKey: String] = [:] // Keys that include at least one billable record with no provider-reported cost (legacy JSON // rows). Their day's cost must be withheld so a priced DB subtotal is not read as complete. var partiallyPricedKeys: Set = [] @@ -224,6 +235,9 @@ public enum OpenCodeSessionScanner { var value = values[key] ?? TokenAccumulator() guard value.add(record.usage) else { continue } values[key] = value + if billingProviderIDs[key] == nil, let billingProviderID = record.billingProviderID { + billingProviderIDs[key] = billingProviderID + } if let cost = record.cost, cost.isFinite, cost >= 0 { costs[key] = (costs[key] ?? 0) + cost } else { @@ -253,6 +267,7 @@ public enum OpenCodeSessionScanner { if !modelPriced { dayHasUnpricedUsage = true } modelBreakdowns.append(CostUsageDailyReport.ModelBreakdown( modelName: key.model, + billingProviderID: billingProviderIDs[key], costUSD: modelCost, totalTokens: modelTotal, inputTokens: value.input, @@ -367,9 +382,11 @@ public enum OpenCodeSessionScanner { let day = calendar.startOfDay(for: date) guard day >= start, day <= end else { continue } let dedupKey = self.cleaned(message.id) ?? url.deletingPathExtension().lastPathComponent + let billingProviderID = self.cleaned(message.providerID ?? message.model?.provider) let fingerprint = self.fingerprint( createdMs: Int64(message.time.created.rounded()), model: model, + billingProviderID: billingProviderID, usage: usage, cost: nil) guard context.seenMessageIDs.insert(dedupKey).inserted, @@ -379,6 +396,7 @@ public enum OpenCodeSessionScanner { dedupKey: dedupKey, day: CostUsageLocalDay.key(from: day, calendar: calendar), model: model, + billingProviderID: billingProviderID, usage: usage, cost: nil, fingerprint: fingerprint)) @@ -433,7 +451,10 @@ public enum OpenCodeSessionScanner { json_extract(data, '$.tokens.reasoning'), json_extract(data, '$.tokens.cache.read'), json_extract(data, '$.tokens.cache.write'), - json_extract(data, '$.cost') + json_extract(data, '$.cost'), + COALESCE( + NULLIF(json_extract(data, '$.providerID'), ''), + json_extract(data, '$.model.provider')) FROM message WHERE json_extract(data, '$.role') = 'assistant' AND COALESCE(json_extract(data, '$.time.created'), time_created) >= ? @@ -468,6 +489,7 @@ public enum OpenCodeSessionScanner { let cost: Double? = sqlite3_column_type(stmt, 8) == SQLITE_NULL ? nil : sqlite3_column_double(stmt, 8) + let billingProviderID = self.cleaned(self.columnText(stmt, 9)) guard input >= 0, output >= 0, reasoning >= 0, cacheRead >= 0, cacheCreation >= 0, let foldedOutput = self.adding(output, reasoning) @@ -485,7 +507,12 @@ public enum OpenCodeSessionScanner { let day = calendar.startOfDay(for: date) guard day >= start, day <= end else { continue } - let fingerprint = self.fingerprint(createdMs: createdMs, model: model, usage: usage, cost: cost) + let fingerprint = self.fingerprint( + createdMs: createdMs, + model: model, + billingProviderID: billingProviderID, + usage: usage, + cost: cost) // tokscale dedups JSON vs DB by embedded id, then by full-field fingerprint when ids // don't conflict — so a message present in both stores collapses to one record. if let messageID, !messageID.isEmpty { @@ -497,6 +524,7 @@ public enum OpenCodeSessionScanner { dedupKey: messageID ?? fingerprint, day: CostUsageLocalDay.key(from: day, calendar: calendar), model: model, + billingProviderID: billingProviderID, usage: usage, cost: cost, fingerprint: fingerprint)) @@ -587,15 +615,18 @@ public enum OpenCodeSessionScanner { /// Cross-store fingerprint for one logical message. Cost is deliberately excluded because /// migrated database rows can enrich an otherwise identical legacy JSON record with pricing. + /// Ownership evidence is included so records whose routing differs are not collapsed. private static func fingerprint( createdMs: Int64, model: String, + billingProviderID: String?, usage: NormalizedUsage, cost _: Double?) -> String { [ String(createdMs), model, + billingProviderID ?? "", String(usage.input), String(usage.output), String(usage.cacheRead), diff --git a/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift index 01981c6a6a..36c1b4ae3f 100644 --- a/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift +++ b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift @@ -76,6 +76,44 @@ struct OpenCodeSessionScannerTests { #expect(second.modelBreakdowns?.first?.reasoningTokens == nil) } + @Test + func `scanner retains billing ownership evidence from json and nested model`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let sessionA = try Self.makeSessionDir(root, "ses_a") + let sessionB = try Self.makeSessionDir(root, "ses_b") + try Self.write( + Self.assistantMessage( + id: "msg_001", + modelID: "claude-sonnet-4", + providerID: "anthropic", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":100,"output":50,"cache":{"read":20,"write":5}}"#), + to: sessionA.appendingPathComponent("msg_001.json")) + try Self.write( + Self.assistantMessage( + id: "msg_002", + modelID: "gpt-5", + nestedModelID: "gpt-5", + nestedProviderID: "openai", + created: "2026-07-11T10:00:00Z", + tokens: #"{"input":3,"output":4,"cache":{"read":1,"write":2}}"#), + to: sessionA.appendingPathComponent("msg_002.json")) + // Legacy record without ownership evidence stays nil. + try Self.write( + Self.assistantMessage( + id: "msg_003", + modelID: "gemini-2.5-pro", + created: "2026-07-10T11:00:00Z", + tokens: #"{"input":7,"output":8,"cache":{"read":0,"write":0}}"#), + to: sessionB.appendingPathComponent("msg_003.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) + + let breakdowns = snapshot.daily.flatMap { $0.modelBreakdowns ?? [] } + #expect(breakdowns.map(\.billingProviderID) == ["anthropic", nil, "openai"]) + } + @Test func `scanner buckets usage by local day across midnight`() throws { let root = try Self.makeRoot() @@ -365,6 +403,8 @@ struct OpenCodeSessionScannerTests { id: String?, modelID: String?, nestedModelID: String? = nil, + nestedProviderID: String? = nil, + providerID: String? = nil, created: String, tokens: String, role: String?? = "assistant", @@ -375,7 +415,13 @@ struct OpenCodeSessionScannerTests { fields.append(#""sessionID":"ses""#) if let role = role.flatMap(\.self) { fields.append(#""role":"\#(role)""#) } if let modelID { fields.append(#""modelID":"\#(modelID)""#) } - if let nestedModelID { fields.append(#""model":{"id":"\#(nestedModelID)","providerID":"test"}"#) } + if let nestedModelID { + var nested = #""model":{"id":"\#(nestedModelID)""# + if let nestedProviderID { nested += #","provider":"\#(nestedProviderID)""# } + nested += "}" + fields.append(nested) + } + if let providerID { fields.append(#""providerID":"\#(providerID)""#) } fields.append(#""tokens":"# + tokens) var time = #""created":"# + "\(Self.ms(created))" if completed { time += #","completed":"# + "\(Self.ms(created) + 1000)" } From f719adfddc8351c58ef070234dadf393ec2cb6df Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:25:30 +0800 Subject: [PATCH 14/22] style: satisfy swift-format lint-macos gate - CostUsageFetcher: static helper call uses `self.` rather than `Self.` - Kimi/MiniMax scanners: drop trailing blank line at scope end Co-authored-by: Cursor --- Sources/CodexBarCore/CostUsageFetcher.swift | 2 +- .../CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift | 1 - .../CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index f613904cb8..1627a0ecfa 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -269,7 +269,7 @@ public struct CostUsageFetcher: Sendable { options.refreshMinIntervalSeconds = 0 } if forceRefresh { - Self.configureFullRescan(&options, progress: codexProgress) + self.configureFullRescan(&options, progress: codexProgress) } } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift index f92cceb681..eaf95f5ff0 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiCodeSessionScanner.swift @@ -197,5 +197,4 @@ public enum KimiCodeSessionScanner { defaultBillingProviderID: UsageProvider.kimi.rawValue, modelsDevCacheRoot: modelsDevCacheRoot)) } - } diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift index 4dae76a7c6..1990535239 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxSessionScanner.swift @@ -290,6 +290,5 @@ public enum MiniMaxSessionScanner { } return String(cString: cString) } - } #endif From 3a22838234f55dac1b731d22da39cab8e91c498f Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:36:13 +0800 Subject: [PATCH 15/22] fix(core): wire local-history providers into the token refresh pipeline --- .../SettingsStore+MenuPreferences.swift | 2 +- Sources/CodexBar/UsageStore.swift | 2 +- .../UsageStoreManualTokenRefreshTests.swift | 32 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index 7f28ffa86f..4221c4b42e 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -333,7 +333,7 @@ extension SettingsStore { func isCostUsageEffectivelyEnabled(for provider: UsageProvider) -> Bool { let isEnabled = self.costUsageEnabled || (provider == .codex && self.codexLocalSessionCostLedgerEnabled) - return isEnabled && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost + return isEnabled && ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsDashboardHistory } var resetTimeDisplayStyle: ResetTimeDisplayStyle { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 19c9488717..deb4531905 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1357,7 +1357,7 @@ extension UsageStore { } func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { - guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsDashboardHistory else { self.resetTokenUsageState(for: provider) return } diff --git a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift index eb28ffd4fd..53f8d0c941 100644 --- a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift +++ b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift @@ -94,6 +94,38 @@ private actor TokenRefreshRecorder { @MainActor @Suite(.serialized) struct UsageStoreManualTokenRefreshTests { + @Test + func `local history provider refresh publishes token snapshot instead of resetting`() async { + let store = Self.makeStore(enabledProviders: [.codex, .gemini]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 150, + sessionCostUSD: 0.5, + last30DaysTokens: 150, + last30DaysCostUSD: 0.5, + currencyCode: "USD", + historyDays: 30, + historyLabel: "Gemini CLI", + costSource: .estimated, + daily: [CostUsageDailyReport.Entry( + date: "2026-07-28", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 0.5, + modelsUsed: ["gemini-2.5-pro"], + modelBreakdowns: nil)], + updatedAt: Date(timeIntervalSince1970: 1_785_283_200)) + store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in + snapshot + } + + await store.refreshTokenUsage(.gemini, force: true) + + // Gemini opts into local history but not supportsTokenCost; the refresh must run the + // snapshot pipeline instead of resetting the provider's token state. + #expect(store.tokenSnapshot(for: .gemini)?.historyLabel == "Gemini CLI") + } + @Test func `manual refresh waits for token-cost refresh before completing`() async { let store = Self.makeStore() From 204f1ebaf09868d1c99bbb527dc6d212db44a4ee Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:36:13 +0800 Subject: [PATCH 16/22] fix(core): preserve billing ownership across merged daily reports --- Sources/CodexBarCore/CostUsageModels.swift | 12 ++++++ .../CostUsageDailyReportMergeTests.swift | 41 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 5286e711b4..534cb34531 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -650,6 +650,10 @@ extension CostUsageDailyReport { var missingReasoningTokens = false var costUSD: Double = 0 var sawCost = false + /// Billing ownership evidence retained across merged sources. Dropped when sources + /// disagree: a merged breakdown must never claim an ownership the sources do not share. + var billingProviderID: String? + var sawConflictingBillingProviderID = false var standardCostUSD: Double = 0 var sawStandardCost = false var priorityCostUSD: Double = 0 @@ -698,6 +702,13 @@ extension CostUsageDailyReport { self.costUSD += costUSD self.sawCost = true } + if let billingProviderID = breakdown.billingProviderID { + if let existing = self.billingProviderID, existing != billingProviderID { + self.sawConflictingBillingProviderID = true + } else if self.billingProviderID == nil { + self.billingProviderID = billingProviderID + } + } if let standardCostUSD = breakdown.standardCostUSD { self.standardCostUSD += standardCostUSD self.sawStandardCost = true @@ -719,6 +730,7 @@ extension CostUsageDailyReport { func build(modelName: String) -> ModelBreakdown { ModelBreakdown( modelName: modelName, + billingProviderID: self.sawConflictingBillingProviderID ? nil : self.billingProviderID, costUSD: self.sawCost ? self.costUSD : nil, totalTokens: self.sawTotalTokens ? self.totalTokens : nil, inputTokens: self.sawInputTokens && !self.missingInputTokens ? self.inputTokens : nil, diff --git a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift index 37af3ce96f..86f7bbd0cf 100644 --- a/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift +++ b/Tests/CodexBarTests/CostUsageDailyReportMergeTests.swift @@ -211,6 +211,47 @@ struct CostUsageDailyReportMergeTests { #expect(missing.data.first?.modelBreakdowns?.first?.reasoningTokens == nil) } + @Test + func `merged report preserves billing ownership when sources agree and drops it on conflict`() { + func report(billingProviderID: String?) -> CostUsageDailyReport { + CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-04-04", + inputTokens: 100, + outputTokens: 30, + totalTokens: 130, + costUSD: 1.0, + modelsUsed: ["claude-haiku-4-5"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "claude-haiku-4-5", + billingProviderID: billingProviderID, + costUSD: 1.0, + totalTokens: 130, + inputTokens: 100, + outputTokens: 30), + ]), + ], + summary: nil) + } + + let agreed = report(billingProviderID: "anthropic") + .merged(with: report(billingProviderID: "anthropic")) + #expect(agreed.data.first?.modelBreakdowns?.first?.billingProviderID == "anthropic") + + // Two sources disagree about who owns the usage; the merged breakdown must not claim + // either side's ownership as the merged truth. + let conflicted = report(billingProviderID: "anthropic") + .merged(with: report(billingProviderID: "openai")) + #expect(conflicted.data.first?.modelBreakdowns?.first?.billingProviderID == nil) + + // A source without evidence does not erase evidence another source retained. + let oneSided = report(billingProviderID: "anthropic") + .merged(with: report(billingProviderID: nil)) + #expect(oneSided.data.first?.modelBreakdowns?.first?.billingProviderID == "anthropic") + } + @Test func `model breakdown decodes reasoning tokens from camel and snake case keys`() throws { let camel = try JSONDecoder().decode( From 65af90db2b1f4ae9838dd3d16d0447fab5f2bbb4 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:36:13 +0800 Subject: [PATCH 17/22] fix(core): withhold OpenCode headline cost on unpriced days and mark DB costs provider-reported --- .../OpenCode/OpenCodeSessionScanner.swift | 9 +- .../OpenCodeSessionScannerTests.swift | 103 ++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift index d94953dca4..bb28a8660e 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift @@ -294,9 +294,11 @@ public enum OpenCodeSessionScanner { let totalTokens = self.sum(daily.compactMap(\.totalTokens)) let totalRequests = self.sum(daily.compactMap(\.requestCount)) guard let totalTokens, let totalRequests else { return nil } - // Cost only exists for `opencode.db` rows (JSON files carry none). When nothing was priced, - // stay token-only ("XXX"/nil) so the dashboard does not show a phantom zero spend. - let totalCost = self.sum(daily.compactMap(\.costUSD)) + // Cost only exists for `opencode.db` rows (JSON files carry none). A day with any + // unpriced usage withholds the headline cost: publishing the priced subtotal alone would + // read as the complete history total. + let hasUnpricedTokenDay = daily.contains { $0.totalTokens != nil && $0.costUSD == nil } + let totalCost = hasUnpricedTokenDay ? nil : self.sum(daily.compactMap(\.costUSD)) return CostUsageTokenSnapshot( sessionTokens: nil, @@ -309,6 +311,7 @@ public enum OpenCodeSessionScanner { historyDays: days, historyCoverageIsEstablished: true, historyLabel: "OpenCode", + costSource: totalCost != nil ? .providerReported : .estimated, daily: daily, updatedAt: now) } diff --git a/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift index 36c1b4ae3f..455eef2e12 100644 --- a/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift +++ b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift @@ -1,6 +1,11 @@ import CodexBarCore import Foundation import Testing +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif struct OpenCodeSessionScannerTests { @Test @@ -360,6 +365,54 @@ struct OpenCodeSessionScannerTests { #expect(snapshot.historyLabel == "OpenCode") } + @Test + func `db priced day plus unpriced json day withholds the headline cost`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeDatabase(root, rows: [ + ( + id: "db-1", + created: "2026-07-27T10:00:00Z", + tokens: #"{"input":100,"output":50,"reasoning":0,"cache":{"read":0,"write":0}}"#, + cost: 0.01), + ]) + let session = try Self.makeSessionDir(root, "ses_legacy") + try Self.write( + Self.assistantMessage( + id: "msg_001", + modelID: "gpt-5", + created: "2026-07-28T10:00:00Z", + tokens: #"{"input":3,"output":4,"cache":{"read":0,"write":0}}"#), + to: session.appendingPathComponent("msg_001.json")) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-28T12:00:00Z"))) + // The priced DB day stays visible, but the unpriced JSON day withholds the headline: + // publishing 0.01 as the 30-day total would read as complete history. + #expect(snapshot.daily.first?.costUSD == 0.01) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.currencyCode == "XXX") + #expect(snapshot.costSource == .estimated) + } + + @Test + func `db priced snapshot reports provider-reported cost source`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try Self.makeDatabase(root, rows: [ + ( + id: "db-1", + created: "2026-07-27T10:00:00Z", + tokens: #"{"input":100,"output":50,"reasoning":0,"cache":{"read":0,"write":0}}"#, + cost: 0.01), + ]) + + let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-28T12:00:00Z"))) + #expect(snapshot.last30DaysCostUSD == 0.01) + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.costSource == .providerReported) + #expect(snapshot.daily.first?.modelBreakdowns?.first?.billingProviderID == "anthropic") + } + // MARK: - Fixtures private static let utcCalendar: Calendar = { @@ -442,4 +495,54 @@ struct OpenCodeSessionScannerTests { private static func write(_ content: String, to url: URL) throws { try (content + "\n").write(to: url, atomically: true, encoding: .utf8) } + + private static func makeDatabase( + _ root: URL, + rows: [(id: String, created: String, tokens: String, cost: Double?)]) throws + { + let opencodeDir = root.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: opencodeDir, withIntermediateDirectories: true) + var db: OpaquePointer? + guard sqlite3_open(opencodeDir.appendingPathComponent("opencode.db").path, &db) == SQLITE_OK, + let db + else { + throw TestFailure.dbOpen + } + defer { sqlite3_close(db) } + guard sqlite3_exec(db, "CREATE TABLE message (data TEXT, time_created INTEGER);", nil, nil, nil) == + SQLITE_OK + else { + throw TestFailure.dbWrite + } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + for row in rows { + var data = #"{"id":"\#(row.id)","role":"assistant","modelID":"claude-sonnet-4","tokens":\#(row.tokens),"# + if let cost = row.cost { + data += #""cost":\#(cost),"# + } + data += #""providerID":"anthropic","time":{"created":\#(Self.ms(row.created))}}"# + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO message (data, time_created) VALUES (?, ?);", + -1, + &stmt, + nil) == SQLITE_OK + else { + throw TestFailure.dbWrite + } + sqlite3_bind_text(stmt, 1, data, -1, transient) + sqlite3_bind_int64(stmt, 2, Int64(Self.ms(row.created))) + guard sqlite3_step(stmt) == SQLITE_DONE else { + sqlite3_finalize(stmt) + throw TestFailure.dbWrite + } + sqlite3_finalize(stmt) + } + } + + private enum TestFailure: Error { + case dbOpen + case dbWrite + } } From 10fb613b42e92bc16633155221e15e3516705c5c Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:36:13 +0800 Subject: [PATCH 18/22] fix(core): omit Trae activity outside the requested history window --- .../Trae/TraeLocalActivityScanner.swift | 7 +++++++ .../TraeLocalActivityScannerTests.swift | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift b/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift index 0c336e3826..334b3eb8dd 100644 --- a/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift +++ b/Sources/CodexBarCore/Providers/Trae/TraeLocalActivityScanner.swift @@ -75,6 +75,8 @@ public enum TraeLocalActivityScanner { try checkCancellation() let days = max(1, historyDays) let calendar = CostUsageLocalDay.gregorianCalendar(preserving: calendar) + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end guard let databaseURL = self.databaseURL(environment: environment, fileManager: fileManager), fileManager.fileExists(atPath: databaseURL.path) else { @@ -97,6 +99,11 @@ public enum TraeLocalActivityScanner { // exposes no per-day token history, so there is exactly one degraded entry per model. let anchor = lastActive ?? now let anchorDay = calendar.startOfDay(for: anchor) + // A last-session date older than the requested window must not surface as recent + // activity; the scanner reports nothing rather than an out-of-window record. + if let lastActive, anchorDay < start || anchorDay > end { + return nil + } let dayKey = CostUsageLocalDay.key(from: anchorDay, calendar: calendar) let events = models.map { model in UnifiedUsageEvent(day: dayKey, model: model) diff --git a/Tests/CodexBarTests/TraeLocalActivityScannerTests.swift b/Tests/CodexBarTests/TraeLocalActivityScannerTests.swift index 5a1361224e..1cf46d26a7 100644 --- a/Tests/CodexBarTests/TraeLocalActivityScannerTests.swift +++ b/Tests/CodexBarTests/TraeLocalActivityScannerTests.swift @@ -54,6 +54,27 @@ struct TraeLocalActivityScannerTests { #expect(snapshot == nil) } + @Test + func `scanner omits activity when the last session predates the history window`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("TraeLocalActivityScannerTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let database = root.appendingPathComponent("state.vscdb") + let modelMap = #"{"solo_coder":"1_-_glm-5.2"}"# + try Self.createDatabase(at: database, values: [ + "4484236971871945_ai-chat:sessionRelation:globalModelMap": modelMap, + // 2026-05-01 is months before the 30-day window ending 2026-07-30. + "telemetry.lastSessionDate": "Fri, 01 May 2026 12:00:00 GMT", + ]) + + let snapshot = TraeLocalActivityScanner.scan( + environment: [TraeLocalActivityScanner.databaseEnvironmentKey: database.path], + historyDays: 30, + now: Date(timeIntervalSince1970: 1_785_456_000), // 2026-07-30T00:00:00Z + calendar: Self.calendar) + #expect(snapshot == nil) + } + private static func createDatabase(at url: URL, values: [String: String]) throws { try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) From 7ef493522b64a207f8958f74a6f13d21930e5455 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:36:13 +0800 Subject: [PATCH 19/22] fix(core): lift the per-file cap for full Codex rescans and bridge cancellation into local-history scans --- Sources/CodexBarCore/CostUsageFetcher.swift | 33 ++++++++++++------- .../CodexBarTests/CostUsageFetcherTests.swift | 11 +++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 1627a0ecfa..c6639a9b63 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -205,12 +205,16 @@ public struct CostUsageFetcher: Sendable { /// with gigabytes of history sees token counts creep up over several manual refreshes instead /// of settling at the true total. Lifting the budget and forcing a rescan makes one manual /// refresh converge on the full corpus. - private static func configureFullRescan( + static func configureFullRescan( _ options: inout CostUsageScanner.Options, progress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)?) { options.forceRescan = true options.maxCodexScanBytesPerRefresh = 0 + // The aggregate budget is disabled above, so the per-file cap must go too: a session + // file larger than 256 MiB would otherwise be partially parsed on every full rescan and + // the manual result would never converge. + options.maxCodexSessionFileBytes = 0 options.progressHandler = progress } @@ -1182,19 +1186,24 @@ extension CostUsageFetcher { let sources = ProviderDescriptorRegistry.descriptor(for: provider) .tokenCost.localHistorySources guard !sources.isEmpty else { return nil } - let context = LocalHistoryScanContext( - environment: environment, - historyDays: historyDays, - now: now) - for source in sources { - guard let scanner = LocalHistoryScannerRegistry.shared.scanner(for: source) else { - continue - } - if let snapshot = try scanner.scan(context: context) { - return snapshot + // Scanners are synchronous and can walk large file trees, so they run on the dedicated + // scan queue with the awaiting task's cancellation bridged into the context. + return try await CostUsageScanExecutor.run { checkCancellation in + let context = LocalHistoryScanContext( + environment: environment, + historyDays: historyDays, + now: now, + checkCancellation: checkCancellation) + for source in sources { + guard let scanner = LocalHistoryScannerRegistry.shared.scanner(for: source) else { + continue + } + if let snapshot = try scanner.scan(context: context) { + return snapshot + } } + return nil } - return nil } /// Whether the provider's token snapshot comes from local history scanners rather than a diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 088f829b45..24e6893d5a 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -19,6 +19,17 @@ struct CostUsageFetcherTests { #expect(!CostUsageFetcher.supportsTokenSnapshot(.grok)) } + @Test + func `full rescan also lifts the per-file byte cap`() { + var options = CostUsageScanner.Options() + CostUsageFetcher.configureFullRescan(&options, progress: nil) + #expect(options.forceRescan) + #expect(options.maxCodexScanBytesPerRefresh == 0) + // A session file larger than the 256 MiB default cap must not be partially parsed on a + // full rescan, or the manual result can never converge for that file. + #expect(options.maxCodexSessionFileBytes == 0) + } + @Test func `fetcher scopes codex history to selected codex home`() async throws { let env = try CostUsageTestEnvironment() From b366ef851dbcc47e160c7159b25489d6ea985658 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:36:14 +0800 Subject: [PATCH 20/22] fix(core): overflow-safe total checks and Copilot rollup request counts --- .../Copilot/CopilotSessionScanner.swift | 6 +++ .../LocalHistory/UnifiedUsageEvent.swift | 6 +++ .../LocalHistory/UsageEventAggregator.swift | 15 +++++-- .../CopilotSessionScannerTests.swift | 3 ++ .../UsageEventAggregatorTests.swift | 45 +++++++++++++++++++ 5 files changed, 72 insertions(+), 3 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift index a47bf8201d..3bdd1036a1 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotSessionScanner.swift @@ -30,7 +30,12 @@ public enum CopilotSessionScanner { } struct ModelBucket: Decodable { + struct Requests: Decodable { + let count: Int? + } + let usage: ModelUsage? + let requests: Requests? } struct Data: Decodable { @@ -154,6 +159,7 @@ public enum CopilotSessionScanner { cacheReadTokens: usage.cacheReadTokens, cacheCreationTokens: usage.cacheWriteTokens, reasoningTokens: usage.reasoningTokens, + requestCount: bucket.requests?.count, pricingProviderIDs: Self.pricingProviderIDs(for: model))) } } catch is CancellationError { diff --git a/Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift b/Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift index 46c0ff2d5f..dce901744a 100644 --- a/Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift +++ b/Sources/CodexBarCore/Providers/LocalHistory/UnifiedUsageEvent.swift @@ -38,6 +38,10 @@ public struct UnifiedUsageEvent: Sendable, Equatable { /// local formats do not, so pricing normally happens in the engine against models.dev. public var providerCostUSD: Double? + /// Number of requests this event represents. Rollup sources (e.g. Copilot's shutdown + /// metrics) summarize several requests in one record; nil means one request. + public var requestCount: Int? + /// Ordered models.dev provider IDs to price this event against, from structured source /// evidence (never inferred from the model name). Empty means "do not price" (e.g. degraded /// sources or tools whose plan is flat and reported elsewhere). @@ -54,6 +58,7 @@ public struct UnifiedUsageEvent: Sendable, Equatable { cacheCreationTokens: Int? = nil, reasoningTokens: Int? = nil, providerCostUSD: Double? = nil, + requestCount: Int? = nil, pricingProviderIDs: [String] = []) { self.day = day @@ -66,6 +71,7 @@ public struct UnifiedUsageEvent: Sendable, Equatable { self.cacheCreationTokens = cacheCreationTokens self.reasoningTokens = reasoningTokens self.providerCostUSD = providerCostUSD + self.requestCount = requestCount self.pricingProviderIDs = pricingProviderIDs } diff --git a/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift b/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift index 6bc4877729..96f63069fc 100644 --- a/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift +++ b/Sources/CodexBarCore/Providers/LocalHistory/UsageEventAggregator.swift @@ -158,7 +158,7 @@ public enum UsageEventAggregator { let nextCacheRead = Bucket.adding(bucket.cacheRead, cacheRead), let nextCacheCreation = Bucket.adding(bucket.cacheCreation, cacheCreation), let nextReasoning = Bucket.adding(bucket.reasoning, reasoning), - let nextRequests = Bucket.adding(bucket.requests, 1) + let nextRequests = Bucket.adding(bucket.requests, max(1, event.requestCount ?? 1)) else { return } @@ -211,10 +211,19 @@ public enum UsageEventAggregator { /// uncached. Falls back to subtracting the cache when the total is missing or inconsistent. private static func uncachedInput(rawInput: Int, output: Int, cacheRead: Int, total: Int?) -> Int { guard let total else { return max(0, rawInput - cacheRead) } - if rawInput + output == total { + let inputPlusOutput = rawInput.addingReportingOverflow(output) + let inputPlusCache = rawInput.addingReportingOverflow(cacheRead) + let inputCachePlusOutput = inputPlusCache.overflow + ? nil + : inputPlusCache.partialValue.addingReportingOverflow(output) + // Malformed records can carry counts near Int.max; overflow-reporting arithmetic lets the + // aggregator reject the record instead of trapping inside these comparisons. + if !inputPlusOutput.overflow, inputPlusOutput.partialValue == total { return max(0, rawInput - cacheRead) } - if rawInput + cacheRead + output == total { + if let inputCachePlusOutput, !inputCachePlusOutput.overflow, + inputCachePlusOutput.partialValue == total + { return rawInput } return max(0, rawInput - cacheRead) diff --git a/Tests/CodexBarTests/CopilotSessionScannerTests.swift b/Tests/CodexBarTests/CopilotSessionScannerTests.swift index 34dfc4d8d2..3dc4cfdd60 100644 --- a/Tests/CodexBarTests/CopilotSessionScannerTests.swift +++ b/Tests/CodexBarTests/CopilotSessionScannerTests.swift @@ -48,12 +48,15 @@ struct CopilotSessionScannerTests { #expect(entry.totalTokens == 73595 + 1196) #expect(entry.inputTokens == 73595 - 48228) #expect(entry.cacheReadTokens == 48228) + // The shutdown rollup carries requests.count: 3, and all three must be reported. + #expect(entry.requestCount == 3) let model = try #require(entry.modelBreakdowns?.first) // Model name is normalized to the pricing key, but ownership stays with Copilot: the // local event has no explicit billing-provider field, so the vendor (Anthropic) is used // only as the rate-lookup key, never to re-attribute the usage. #expect(model.modelName == "claude-haiku-4-5") #expect(model.billingProviderID == UsageProvider.copilot.rawValue) + #expect(model.requestCount == 3) } @Test diff --git a/Tests/CodexBarTests/UsageEventAggregatorTests.swift b/Tests/CodexBarTests/UsageEventAggregatorTests.swift index e2cb1a89c0..18b246886a 100644 --- a/Tests/CodexBarTests/UsageEventAggregatorTests.swift +++ b/Tests/CodexBarTests/UsageEventAggregatorTests.swift @@ -162,4 +162,49 @@ struct UsageEventAggregatorTests { options: .init(historyLabel: "Test", defaultBillingProviderID: "copilot")) #expect(snapshot?.daily.first?.modelBreakdowns?.first?.billingProviderID == "claude") } + + @Test + func `rollup request counts aggregate instead of counting one event`() { + let snapshot = UsageEventAggregator.aggregate( + events: [ + UnifiedUsageEvent( + day: "2026-07-28", + model: "claude-haiku-4-5", + inputTokens: 100, + outputTokens: 50, + requestCount: 3, + pricingProviderIDs: []), + UnifiedUsageEvent( + day: "2026-07-28", + model: "claude-haiku-4-5", + inputTokens: 100, + outputTokens: 50, + pricingProviderIDs: []), + ], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot?.daily.first?.requestCount == 4) + #expect(snapshot?.daily.first?.modelBreakdowns?.first?.requestCount == 4) + } + + @Test + func `overflowing totals are rejected instead of trapping in total comparisons`() { + // Malformed local records can carry counts near Int.max. The old comparison + // `rawInput + output == total` would trap; the engine must reject the record safely. + let snapshot = UsageEventAggregator.aggregate( + events: [UnifiedUsageEvent( + day: "2026-07-28", + model: "qwen3-coder-plus", + inputTokens: Int.max, + outputTokens: Int.max, + totalTokens: Int.max, + pricingProviderIDs: [])], + historyDays: 30, + now: self.now, + options: .init(historyLabel: "Test")) + #expect(snapshot != nil) + #expect(snapshot?.daily.first?.totalTokens == nil) + #expect(snapshot?.last30DaysTokens == nil) + } } From 20c872500fcaa2152c37c04ef03724398d9eb798 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:02:14 +0800 Subject: [PATCH 21/22] fix(scanner): prefer recent OpenCode files before the scan cap Session directories enumerate in arbitrary order, so an unsorted first-N pass can fill the 20000-file cap with old messages and omit recent usage. Sort candidates by modification date before enforcing the cap; add a fileLimit override for tests and a recency regression test. --- .../OpenCode/OpenCodeSessionScanner.swift | 34 ++++++--- .../OpenCodeSessionScannerTests.swift | 74 ++++++++++++++++++- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift index bb28a8660e..5ade76f943 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeSessionScanner.swift @@ -199,7 +199,8 @@ public enum OpenCodeSessionScanner { historyDays: Int = defaultHistoryDays, now: Date = Date(), calendar: Calendar = .current, - checkCancellation: @escaping () throws -> Void = {}) throws -> CostUsageTokenSnapshot? + checkCancellation: @escaping () throws -> Void = {}, + fileLimit: Int? = nil) throws -> CostUsageTokenSnapshot? { try checkCancellation() let days = max(1, historyDays) @@ -219,7 +220,8 @@ public enum OpenCodeSessionScanner { environment: environment, fileManager: fileManager, context: &context, - checkCancellation: checkCancellation)) + checkCancellation: checkCancellation, + fileLimit: fileLimit ?? self.maximumFiles)) var values: [DayModelKey: TokenAccumulator] = [:] var costs: [DayModelKey: Double] = [:] @@ -330,7 +332,8 @@ public enum OpenCodeSessionScanner { environment: [String: String], fileManager: FileManager, context: inout ScanContext, - checkCancellation: () throws -> Void) throws -> [UsageRecord] + checkCancellation: () throws -> Void, + fileLimit: Int) throws -> [UsageRecord] { let start = context.start let end = context.end @@ -346,12 +349,10 @@ public enum OpenCodeSessionScanner { let decoder = JSONDecoder() var records: [UsageRecord] = [] - var visitedFiles = 0 - var visitedBytes = 0 + var candidates: [(url: URL, modificationDate: Date?, size: Int)] = [] while let url = enumerator.nextObject() as? URL { try checkCancellation() guard url.pathExtension.lowercased() == "json" else { continue } - guard visitedFiles < self.maximumFiles else { break } let resourceValues = try? url.resourceValues( forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]) guard resourceValues?.isRegularFile == true else { continue } @@ -361,12 +362,21 @@ public enum OpenCodeSessionScanner { continue } let size = max(0, resourceValues?.fileSize ?? 0) - guard size <= self.maximumFileBytes, - size <= self.maximumBytes - visitedBytes - else { - continue - } - visitedFiles += 1 + guard size <= self.maximumFileBytes else { continue } + candidates.append((url, resourceValues?.contentModificationDate, size)) + } + // The cap must prefer the most recent messages: session directories are enumerated in + // arbitrary order, so an unsorted first-N pass can fill the cap with old sessions and + // omit the recent usage the dashboard actually needs. + let cappedCandidates = candidates + .sorted { ($0.modificationDate ?? .distantPast) > ($1.modificationDate ?? .distantPast) } + .prefix(fileLimit) + var visitedBytes = 0 + for candidate in cappedCandidates { + try checkCancellation() + let url = candidate.url + let size = candidate.size + guard size <= self.maximumBytes - visitedBytes else { continue } visitedBytes += size guard let data = try? Data(contentsOf: url), let message = try? decoder.decode(WireMessage.self, from: data) diff --git a/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift index 455eef2e12..9b2f3e6c32 100644 --- a/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift +++ b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift @@ -273,14 +273,80 @@ struct OpenCodeSessionScannerTests { created: "2026-07-10T09:03:00Z", tokens: #"{"input":10,"output":0,"cache":{"read":0,"write":0}}"#), to: session.appendingPathComponent("msg_small.json")) + for (fileName, modified) in [ + ("msg_neg.json", "2026-07-10T09:00:00Z"), + ("msg_neg_reasoning.json", "2026-07-10T09:01:00Z"), + ("msg_huge.json", "2026-07-10T09:02:00Z"), + ("msg_small.json", "2026-07-10T09:03:00Z"), + ] { + try FileManager.default.setAttributes( + [.modificationDate: Self.date(modified)], + ofItemAtPath: session.appendingPathComponent(fileName).path) + } let snapshot = try #require(Self.scan(root: root, now: Self.date("2026-07-12T12:00:00Z"))) - // Only the Int.max record survives; the follow-up record overflows the accumulator and - // is dropped without poisoning the bucket. + // The newest record survives; the Int.max record overflows the accumulator and is dropped + // without poisoning the bucket. #expect(snapshot.last30DaysRequests == 1) - #expect(snapshot.last30DaysTokens == Int.max) - #expect(snapshot.daily.first?.inputTokens == Int.max) + #expect(snapshot.last30DaysTokens == 10) + #expect(snapshot.daily.first?.inputTokens == 10) + } + + @Test + func `file cap prefers the most recently modified messages`() throws { + let root = try Self.makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let oldSession = try Self.makeSessionDir(root, "ses_old") + let midSession = try Self.makeSessionDir(root, "ses_mid") + let newSession = try Self.makeSessionDir(root, "ses_new") + let now = Self.date("2026-07-12T12:00:00Z") + + let oldURL = oldSession.appendingPathComponent("msg_old.json") + let midURL = midSession.appendingPathComponent("msg_mid.json") + let newURL = newSession.appendingPathComponent("msg_new.json") + try Self.write( + Self.assistantMessage( + id: "msg_old", + modelID: "claude-sonnet-4", + created: "2026-07-05T09:00:00Z", + tokens: #"{"input":1000,"output":0,"cache":{"read":0,"write":0}}"#), + to: oldURL) + try Self.write( + Self.assistantMessage( + id: "msg_mid", + modelID: "claude-sonnet-4", + created: "2026-07-08T09:00:00Z", + tokens: #"{"input":100,"output":0,"cache":{"read":0,"write":0}}"#), + to: midURL) + try Self.write( + Self.assistantMessage( + id: "msg_new", + modelID: "claude-sonnet-4", + created: "2026-07-10T09:00:00Z", + tokens: #"{"input":10,"output":0,"cache":{"read":0,"write":0}}"#), + to: newURL) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-07-05T09:00:00Z")], + ofItemAtPath: oldURL.path) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-07-08T09:00:00Z")], + ofItemAtPath: midURL.path) + try FileManager.default.setAttributes( + [.modificationDate: Self.date("2026-07-10T09:00:00Z")], + ofItemAtPath: newURL.path) + + let snapshot = try #require(try OpenCodeSessionScanner.scanCancellable( + environment: [OpenCodeSessionScanner.dataHomeEnvironmentKey: root.path], + historyDays: 30, + now: now, + calendar: Self.utcCalendar, + fileLimit: 2)) + + // The two newest files win; the old session is omitted even though enumeration order + // could surface it first. + #expect(snapshot.last30DaysTokens == 110) + #expect(snapshot.last30DaysRequests == 2) } @Test From 40949c04df6c1de286e7b695e731b919fdb5f50c Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:02:51 +0800 Subject: [PATCH 22/22] fix(core): keep outer billing route and withhold partial window costs - Walk model namespaces from the outside in so nested routes such as openrouter/anthropic/claude-* keep the routing provider as the billing owner instead of resolving to the inner vendor. - summary(forLastDays:) now returns nil cost whenever any token-bearing entry in the window is unpriced, so 7/30/90-day comparisons never present a priced subtotal as complete. --- Sources/CodexBarCore/CostUsageModels.swift | 10 +++++++-- .../CostUsageBillingProviderTests.swift | 22 +++++++++++++++++++ .../CostUsageWindowSummaryTests.swift | 21 ++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 Tests/CodexBarTests/CostUsageBillingProviderTests.swift diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 534cb34531..7e0ef31184 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -27,7 +27,10 @@ public enum CostUsageBillingProvider { "qwen": UsageProvider.qwencloud.rawValue, "z.ai": UsageProvider.zai.rawValue, ] - for namespace in components.reversed() { + // Nested routes bill through the outermost explicit provider (e.g. + // `openrouter/anthropic/claude-*` is owned by OpenRouter, not Anthropic), so walk the + // chain from the outside in and stop at the first recognizable route. + for namespace in components { if let alias = aliases[namespace] { return alias } if let provider = UsageProvider(rawValue: namespace) { return provider.rawValue } } @@ -189,10 +192,13 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { let costs = entries.compactMap(\.costUSD) let tokens = entries.compactMap(\.totalTokens) let requests = entries.compactMap(\.requestCount) + // A token-bearing day without a price must withhold the whole window total: summing the + // priced remainder would present a partial estimate as the complete comparison figure. + let hasUnpricedTokenDay = entries.contains { $0.totalTokens != nil && $0.costUSD == nil } return CostUsageWindowSummary( days: days, totalTokens: tokens.isEmpty ? nil : tokens.reduce(0, +), - totalCostUSD: costs.isEmpty ? nil : costs.reduce(0, +), + totalCostUSD: hasUnpricedTokenDay ? nil : (costs.isEmpty ? nil : costs.reduce(0, +)), totalRequests: requests.isEmpty ? nil : requests.reduce(0, +), entryCount: entries.count) } diff --git a/Tests/CodexBarTests/CostUsageBillingProviderTests.swift b/Tests/CodexBarTests/CostUsageBillingProviderTests.swift new file mode 100644 index 0000000000..975a88b79e --- /dev/null +++ b/Tests/CodexBarTests/CostUsageBillingProviderTests.swift @@ -0,0 +1,22 @@ +import CodexBarCore +import Testing + +struct CostUsageBillingProviderTests { + @Test + func `outer explicit route owns nested billing chains`() { + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "openrouter/anthropic/claude-sonnet-4") + == UsageProvider.openrouter.rawValue) + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "gateway/team/moonshot/kimi-k2") + == UsageProvider.moonshot.rawValue) + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "gateway/team/moonshotai/kimi-k2") + == UsageProvider.moonshot.rawValue) + } + + @Test + func `plain family labels are not billing evidence`() { + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "MiniMax-M3") == nil) + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "claude-sonnet-4") == nil) + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "minimax/MiniMax-M3") + == UsageProvider.minimax.rawValue) + } +} diff --git a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift index e7b150ec9b..756671a635 100644 --- a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift +++ b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift @@ -42,6 +42,27 @@ struct CostUsageWindowSummaryTests { #expect(summary.totalRequests == nil) } + @Test + func `window with an unpriced token day withholds the cost total`() { + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + historyDays: 30, + daily: [ + Self.entry(day: "2026-06-29", cost: 5, tokens: 500, requests: 5), + Self.entry(day: "2026-07-01", cost: nil, tokens: 100, requests: 1), + ], + updatedAt: Self.now) + + let summary = snapshot.summary(forLastDays: 7, calendar: Self.utcCalendar) + #expect(summary.entryCount == 2) + #expect(summary.totalTokens == 600) + #expect(summary.totalRequests == 6) + #expect(summary.totalCostUSD == nil) + } + @Test func `comparison summaries keep Gregorian entries under a Buddhist calendar`() throws { let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok"))