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/40] 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/40] 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/40] 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/40] 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/40] 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 2af1b1cbf009b901c4b1b906c9745c4cad1675c7 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:44:47 +0800 Subject: [PATCH 06/40] feat: add standalone token activity heatmap --- .../PreferencesSpendDashboardPane.swift | 6 + .../Resources/ar.lproj/Localizable.strings | 10 + .../Resources/ca.lproj/Localizable.strings | 10 + .../Resources/de.lproj/Localizable.strings | 10 + .../Resources/en.lproj/Localizable.strings | 10 + .../Resources/es.lproj/Localizable.strings | 10 + .../Resources/fa.lproj/Localizable.strings | 10 + .../Resources/fr.lproj/Localizable.strings | 10 + .../Resources/gl.lproj/Localizable.strings | 10 + .../Resources/id.lproj/Localizable.strings | 10 + .../Resources/it.lproj/Localizable.strings | 10 + .../Resources/ja.lproj/Localizable.strings | 10 + .../Resources/ko.lproj/Localizable.strings | 10 + .../Resources/nl.lproj/Localizable.strings | 10 + .../Resources/pl.lproj/Localizable.strings | 10 + .../Resources/pt-BR.lproj/Localizable.strings | 10 + .../Resources/ru.lproj/Localizable.strings | 10 + .../Resources/sv.lproj/Localizable.strings | 10 + .../Resources/th.lproj/Localizable.strings | 10 + .../Resources/tr.lproj/Localizable.strings | 10 + .../Resources/uk.lproj/Localizable.strings | 10 + .../Resources/vi.lproj/Localizable.strings | 10 + .../zh-Hans.lproj/Localizable.strings | 10 + .../zh-Hant.lproj/Localizable.strings | 10 + Sources/CodexBar/SpendActivityHeatmap.swift | 593 ++++++++++++++++++ .../CodexBar/SpendDashboardController.swift | 2 +- Sources/CodexBar/SpendDashboardModel.swift | 53 +- .../SpendActivityHeatmapTests.swift | 155 +++++ .../SpendDashboardControllerTests.swift | 2 +- .../SpendDashboardModelTests.swift | 2 +- 30 files changed, 1039 insertions(+), 4 deletions(-) create mode 100644 Sources/CodexBar/SpendActivityHeatmap.swift create mode 100644 Tests/CodexBarTests/SpendActivityHeatmapTests.swift diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 83e7f31038..798ec55146 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -150,6 +150,12 @@ struct SpendDashboardPane: View { } } + if self.settings.costUsageEnabled, !self.controller.model.tokenActivity.isEmpty { + SpendDashboardPanel { + SpendActivityHeatmapView(points: self.controller.model.tokenActivity) + } + } + if self.controller.failedSourceCount > 0 { Label( spendDashboardRefreshFailureText(self.controller.failedSourceCount), diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 656e84539a..acee50dc92 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1353,3 +1353,13 @@ "Cost today unavailable" = "تكلفة اليوم: غير متوفر"; "30-day cost unavailable" = "تكلفة 30 يوماً: غير متوفر"; "Resets" = "إعادات الضبط"; + +"Cumulative" = "تراكمي"; +"Token activity" = "نشاط الرموز"; +"View" = "عرض"; +"in the last year" = "في العام الماضي"; +"No activity in the last 12 months" = "لا يوجد نشاط في آخر 12 شهرًا"; +"Each column = 1 week" = "كل عمود = أسبوع واحد"; +"Running total" = "الإجمالي التراكمي"; +"Less" = "أقل"; +"More" = "أكثر"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 1921f5a94d..48ace5689d 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1352,3 +1352,13 @@ "Cost today unavailable" = "Cost d’avui: No disponible"; "30-day cost unavailable" = "Cost de 30 dies: No disponible"; "Resets" = "Reinicis"; + +"Cumulative" = "Acumulat"; +"Token activity" = "Activitat de tokens"; +"View" = "Vista"; +"in the last year" = "l'últim any"; +"No activity in the last 12 months" = "Sense activitat en els últims 12 mesos"; +"Each column = 1 week" = "Cada columna = 1 setmana"; +"Running total" = "Total acumulat"; +"Less" = "Menys"; +"More" = "Més"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 2986c311d4..f7bd44b8b0 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1350,3 +1350,13 @@ "Cost today unavailable" = "Kosten heute: Nicht verfügbar"; "30-day cost unavailable" = "Kosten 30 Tage: Nicht verfügbar"; "Resets" = "Zurücksetzungen"; + +"Cumulative" = "Kumulativ"; +"Token activity" = "Token-Aktivität"; +"View" = "Ansicht"; +"in the last year" = "im letzten Jahr"; +"No activity in the last 12 months" = "Keine Aktivität in den letzten 12 Monaten"; +"Each column = 1 week" = "Jede Spalte = 1 Woche"; +"Running total" = "Laufende Summe"; +"Less" = "Weniger"; +"More" = "Mehr"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 18b42e8801..1a1fa69d62 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1354,3 +1354,13 @@ "Cost today unavailable" = "Cost today unavailable"; "30-day cost unavailable" = "30-day cost unavailable"; "Resets" = "Resets"; + +"Cumulative" = "Cumulative"; +"Token activity" = "Token activity"; +"View" = "View"; +"in the last year" = "in the last year"; +"No activity in the last 12 months" = "No activity in the last 12 months"; +"Each column = 1 week" = "Each column = 1 week"; +"Running total" = "Running total"; +"Less" = "Less"; +"More" = "More"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 656ac7975c..041c954fa2 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1348,3 +1348,13 @@ "Cost today unavailable" = "Coste de hoy: No disponible"; "30-day cost unavailable" = "Coste de 30 días: No disponible"; "Resets" = "Reinicios"; + +"Cumulative" = "Acumulado"; +"Token activity" = "Actividad de tokens"; +"View" = "Vista"; +"in the last year" = "en el último año"; +"No activity in the last 12 months" = "Sin actividad en los últimos 12 meses"; +"Each column = 1 week" = "Cada columna = 1 semana"; +"Running total" = "Total acumulado"; +"Less" = "Menos"; +"More" = "Más"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index cdea6d030b..e58e1fe675 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1353,3 +1353,13 @@ "Cost today unavailable" = "هزینهٔ امروز: در دسترس نیست"; "30-day cost unavailable" = "هزینهٔ ۳۰ روز: در دسترس نیست"; "Resets" = "بازنشانی‌ها"; + +"Cumulative" = "تجمعی"; +"Token activity" = "فعالیت توکن"; +"View" = "نمایش"; +"in the last year" = "در سال گذشته"; +"No activity in the last 12 months" = "هیچ فعالیتی در ۱۲ ماه گذشته وجود ندارد"; +"Each column = 1 week" = "هر ستون = ۱ هفته"; +"Running total" = "مجموع تجمعی"; +"Less" = "کمتر"; +"More" = "بیشتر"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index ff583450d9..212d3ad861 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1349,3 +1349,13 @@ "Cost today unavailable" = "Coût aujourd’hui: Indisponible"; "30-day cost unavailable" = "Coût sur 30 j: Indisponible"; "Resets" = "Réinitialisations"; + +"Cumulative" = "Cumulé"; +"Token activity" = "Activité des tokens"; +"View" = "Vue"; +"in the last year" = "sur l'année écoulée"; +"No activity in the last 12 months" = "Aucune activité au cours des 12 derniers mois"; +"Each column = 1 week" = "Chaque colonne = 1 semaine"; +"Running total" = "Total cumulé"; +"Less" = "Moins"; +"More" = "Plus"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 43bd695894..ef792b70ee 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1349,3 +1349,13 @@ "Cost today unavailable" = "Custo de hoxe: Non dispoñible"; "30-day cost unavailable" = "Custo de 30 días: Non dispoñible"; "Resets" = "Reinicios"; + +"Cumulative" = "Acumulado"; +"Token activity" = "Actividade de tokens"; +"View" = "Vista"; +"in the last year" = "no último ano"; +"No activity in the last 12 months" = "Sen actividade nos últimos 12 meses"; +"Each column = 1 week" = "Cada columna = 1 semana"; +"Running total" = "Total acumulado"; +"Less" = "Menos"; +"More" = "Máis"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 275d7b769e..dedd6f199d 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1353,3 +1353,13 @@ "Cost today unavailable" = "Biaya hari ini: Tidak tersedia"; "30-day cost unavailable" = "Biaya 30 hari: Tidak tersedia"; "Resets" = "Reset"; + +"Cumulative" = "Kumulatif"; +"Token activity" = "Aktivitas token"; +"View" = "Tampilan"; +"in the last year" = "dalam setahun terakhir"; +"No activity in the last 12 months" = "Tidak ada aktivitas dalam 12 bulan terakhir"; +"Each column = 1 week" = "Setiap kolom = 1 minggu"; +"Running total" = "Total berjalan"; +"Less" = "Sedikit"; +"More" = "Banyak"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 6ce92c2a94..210f248f0e 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1353,3 +1353,13 @@ "Cost today unavailable" = "Costo oggi: Non disponibile"; "30-day cost unavailable" = "Costo 30 gg: Non disponibile"; "Resets" = "Ripristini"; + +"Cumulative" = "Cumulativo"; +"Token activity" = "Attività token"; +"View" = "Vista"; +"in the last year" = "nell'ultimo anno"; +"No activity in the last 12 months" = "Nessuna attività negli ultimi 12 mesi"; +"Each column = 1 week" = "Ogni colonna = 1 settimana"; +"Running total" = "Totale progressivo"; +"Less" = "Meno"; +"More" = "Più"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 7b6911423d..5495f8214a 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1350,3 +1350,13 @@ "Cost today unavailable" = "今日のコスト: 利用不可"; "30-day cost unavailable" = "30日間のコスト: 利用不可"; "Resets" = "リセット"; + +"Cumulative" = "累計"; +"Token activity" = "トークンアクティビティ"; +"View" = "表示"; +"in the last year" = "過去1年"; +"No activity in the last 12 months" = "過去12か月にアクティビティなし"; +"Each column = 1 week" = "各列 = 1週間"; +"Running total" = "累計"; +"Less" = "少"; +"More" = "多"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 7458d95078..0b6b169c7a 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1317,3 +1317,13 @@ "Cost today unavailable" = "오늘 비용: 사용할 수 없음"; "30-day cost unavailable" = "30일 비용: 사용할 수 없음"; "Resets" = "재설정"; + +"Cumulative" = "누적"; +"Token activity" = "토큰 활동"; +"View" = "보기"; +"in the last year" = "지난 1년"; +"No activity in the last 12 months" = "지난 12개월 동안 활동 없음"; +"Each column = 1 week" = "각 열 = 1주"; +"Running total" = "누적 합계"; +"Less" = "적음"; +"More" = "많음"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index cbe4af8954..1eab4d2cf1 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1349,3 +1349,13 @@ "Cost today unavailable" = "Kosten vandaag: Niet beschikbaar"; "30-day cost unavailable" = "Kosten 30 dagen: Niet beschikbaar"; "Resets" = "Resets"; + +"Cumulative" = "Cumulatief"; +"Token activity" = "Token-activiteit"; +"View" = "Weergave"; +"in the last year" = "in het afgelopen jaar"; +"No activity in the last 12 months" = "Geen activiteit in de afgelopen 12 maanden"; +"Each column = 1 week" = "Elke kolom = 1 week"; +"Running total" = "Lopend totaal"; +"Less" = "Minder"; +"More" = "Meer"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 9958e32f9e..2e390538da 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1353,3 +1353,13 @@ "Cost today unavailable" = "Koszt dzisiaj: Niedostępne"; "30-day cost unavailable" = "Koszt 30 dni: Niedostępne"; "Resets" = "Resety"; + +"Cumulative" = "Łącznie"; +"Token activity" = "Aktywność tokenów"; +"View" = "Widok"; +"in the last year" = "w ciągu ostatniego roku"; +"No activity in the last 12 months" = "Brak aktywności w ciągu ostatnich 12 miesięcy"; +"Each column = 1 week" = "Każda kolumna = 1 tydzień"; +"Running total" = "Suma narastająca"; +"Less" = "Mniej"; +"More" = "Więcej"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 434ba9148c..5c4d1a514a 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1350,3 +1350,13 @@ "Cost today unavailable" = "Custo hoje: Indisponível"; "30-day cost unavailable" = "Custo em 30 dias: Indisponível"; "Resets" = "Redefinições"; + +"Cumulative" = "Acumulado"; +"Token activity" = "Atividade de tokens"; +"View" = "Visualização"; +"in the last year" = "no último ano"; +"No activity in the last 12 months" = "Sem atividade nos últimos 12 meses"; +"Each column = 1 week" = "Cada coluna = 1 semana"; +"Running total" = "Total acumulado"; +"Less" = "Menos"; +"More" = "Mais"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index a1015d75f2..696a9e37fd 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1351,3 +1351,13 @@ "Cost today unavailable" = "Расход сегодня: Недоступно"; "30-day cost unavailable" = "Расход за 30 дней: Недоступно"; "Resets" = "Сбросы"; + +"Cumulative" = "Накопительно"; +"Token activity" = "Активность токенов"; +"View" = "Вид"; +"in the last year" = "за последний год"; +"No activity in the last 12 months" = "Нет активности за последние 12 месяцев"; +"Each column = 1 week" = "Каждый столбец = 1 неделя"; +"Running total" = "Накопленный итог"; +"Less" = "Меньше"; +"More" = "Больше"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 541451b974..8439c7446e 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1348,3 +1348,13 @@ "Cost today unavailable" = "Kostnad idag: Inte tillgänglig"; "30-day cost unavailable" = "Kostnad 30 dagar: Inte tillgänglig"; "Resets" = "Återställningar"; + +"Cumulative" = "Kumulativt"; +"Token activity" = "Token-aktivitet"; +"View" = "Vy"; +"in the last year" = "det senaste året"; +"No activity in the last 12 months" = "Ingen aktivitet de senaste 12 månaderna"; +"Each column = 1 week" = "Varje kolumn = 1 vecka"; +"Running total" = "Löpande summa"; +"Less" = "Mindre"; +"More" = "Mer"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 4e0f2ff782..f2d474d6fd 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1353,3 +1353,13 @@ "Cost today unavailable" = "ค่าใช้จ่ายวันนี้: ไม่พร้อมใช้งาน"; "30-day cost unavailable" = "ค่าใช้จ่าย 30 วัน: ไม่พร้อมใช้งาน"; "Resets" = "การรีเซ็ต"; + +"Cumulative" = "สะสม"; +"Token activity" = "กิจกรรมโทเทน"; +"View" = "มุมมอง"; +"in the last year" = "ในปีที่ผ่านมา"; +"No activity in the last 12 months" = "ไม่มีกิจกรรมในช่วง 12 เดือนที่ผ่านมา"; +"Each column = 1 week" = "แต่ละคอลัมน์ = 1 สัปดาห์"; +"Running total" = "ยอดรวมสะสม"; +"Less" = "น้อย"; +"More" = "มาก"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 48a5096e45..755b90aba0 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1351,3 +1351,13 @@ "Cost today unavailable" = "Bugünkü maliyet: Kullanılamıyor"; "30-day cost unavailable" = "30 günlük maliyet: Kullanılamıyor"; "Resets" = "Sıfırlamalar"; + +"Cumulative" = "Kümülatif"; +"Token activity" = "Token etkinliği"; +"View" = "Görünüm"; +"in the last year" = "son yılda"; +"No activity in the last 12 months" = "Son 12 ayda etkinlik yok"; +"Each column = 1 week" = "Her sütun = 1 hafta"; +"Running total" = "Birikimli toplam"; +"Less" = "Az"; +"More" = "Çok"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 1915ea7bf9..c4b08fe49d 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1349,3 +1349,13 @@ "Cost today unavailable" = "Вартість сьогодні: Недоступний"; "30-day cost unavailable" = "Вартість за 30 днів: Недоступний"; "Resets" = "Скидання"; + +"Cumulative" = "Наростаючим підсумком"; +"Token activity" = "Активність токенів"; +"View" = "Вигляд"; +"in the last year" = "за останній рік"; +"No activity in the last 12 months" = "Немає активності за останні 12 місяців"; +"Each column = 1 week" = "Кожен стовпець = 1 тиждень"; +"Running total" = "Накопичений підсумок"; +"Less" = "Менше"; +"More" = "Більше"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 02c9b8fdf3..c2bcb024de 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1350,3 +1350,13 @@ "Cost today unavailable" = "Chi phí hôm nay: Không có sẵn"; "30-day cost unavailable" = "Chi phí 30 ngày: Không có sẵn"; "Resets" = "Lần đặt lại"; + +"Cumulative" = "Tích lũy"; +"Token activity" = "Hoạt động token"; +"View" = "Chế độ xem"; +"in the last year" = "trong năm qua"; +"No activity in the last 12 months" = "Không có hoạt động trong 12 tháng qua"; +"Each column = 1 week" = "Mỗi cột = 1 tuần"; +"Running total" = "Tổng lũy kế"; +"Less" = "Ít"; +"More" = "Nhiều"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 06fc0d4755..e3a17b0c54 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1325,3 +1325,13 @@ "Cost today unavailable" = "今日费用: 不可用"; "30-day cost unavailable" = "30 天费用: 不可用"; "Resets" = "重置"; + +"Cumulative" = "累计"; +"Token activity" = "Token 活动"; +"View" = "视图"; +"in the last year" = "近一年"; +"No activity in the last 12 months" = "近 12 个月暂无活动"; +"Each column = 1 week" = "每列 = 1 周"; +"Running total" = "累计总量"; +"Less" = "少"; +"More" = "多"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index ec01ed18f3..cb086c42e4 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1380,3 +1380,13 @@ "Cost today unavailable" = "今日費用: 無法使用"; "30-day cost unavailable" = "30 天費用: 無法使用"; "Resets" = "重設"; + +"Cumulative" = "累計"; +"Token activity" = "Token 活動"; +"View" = "檢視"; +"in the last year" = "近一年"; +"No activity in the last 12 months" = "近 12 個月暫無活動"; +"Each column = 1 week" = "每列 = 1 週"; +"Running total" = "累計總量"; +"Less" = "少"; +"More" = "多"; diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift new file mode 100644 index 0000000000..5ee7d6d74e --- /dev/null +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -0,0 +1,593 @@ +import CodexBarCore +import Foundation +import SwiftUI + +enum SpendActivityViewMode: String, CaseIterable, Identifiable { + case daily + case weekly + case cumulative + + var id: Self { + self + } + + var title: String { + switch self { + case .daily: L("Daily") + case .weekly: L("Weekly") + case .cumulative: L("Cumulative") + } + } +} + +struct SpendActivitySeries { + static let weekCount = 52 + static let dayCount = 7 + + let daily: [Int] + let start: Date + let today: Date + let calendar: Calendar + + static func make( + from points: [SpendDashboardModel.TokenActivityPoint], + now: Date = Date(), + calendar: Calendar = .current) -> Self + { + var totals: [Date: Int] = [:] + for point in points { + let day = calendar.startOfDay(for: point.day) + totals[day] = Self.saturatingAdd(totals[day] ?? 0, max(point.totalTokens, 0)) + } + + let today = calendar.startOfDay(for: now) + let weekday = calendar.component(.weekday, from: today) + let thisWeekSunday = calendar.date(byAdding: .day, value: -(weekday - 1), to: today) ?? today + let start = calendar.date( + byAdding: .weekOfYear, + value: -(Self.weekCount - 1), + to: thisWeekSunday) ?? thisWeekSunday + let cellCount = Self.weekCount * Self.dayCount + var daily = [Int](repeating: 0, count: cellCount) + for index in 0.. Date? { + self.calendar.date(byAdding: .day, value: index, to: self.start) + } + + func weekStartDate(at week: Int) -> Date? { + self.calendar.date(byAdding: .day, value: week * Self.dayCount, to: self.start) + } + + private static func saturatingAdd(_ lhs: Int, _ rhs: Int) -> Int { + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? Int.max : result.partialValue + } +} + +enum SpendActivityLevels { + static func dailyLevels(_ values: [Int]) -> [Int] { + let maxValue = values.max() ?? 0 + return values.map { value in + guard value > 0, maxValue > 0 else { return 0 } + let ratio = Double(value) / Double(maxValue) + if ratio > 0.75 { return 4 } + if ratio > 0.5 { return 3 } + if ratio > 0.25 { return 2 } + return 1 + } + } + + static func weeklyTotals(_ daily: [Int]) -> [Int] { + stride(from: 0, to: daily.count, by: SpendActivitySeries.dayCount).map { start in + daily[start.. [Int] { + var sum = 0 + return weekly.map { value in + let result = sum.addingReportingOverflow(value) + sum = result.overflow ? Int.max : result.partialValue + return sum + } + } + + static func color(forLevel level: Int) -> Color { + switch level { + case 4: self.rgb(0x216E39) + case 3: self.rgb(0x30A14E) + case 2: self.rgb(0x40C463) + case 1: self.rgb(0x9BE9A8) + default: self.rgb(0xEBEDF0) + } + } + + static var uniformFill: Color { + self.rgb(0x40C463) + } + + private static func rgb(_ hex: UInt32) -> Color { + Color( + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255) + } +} + +struct SpendActivityGridGeometry { + static let weekdayGutterWidth: CGFloat = 40 + static let gridSpacing: CGFloat = 8 + static let tooltipInset: CGFloat = 4 + static let tooltipGap: CGFloat = 7 + static let tooltipWidth: CGFloat = 148 + static let tooltipHeight: CGFloat = 50 + + static func gridFrame(containerWidth: CGFloat, columns: Int = SpendActivitySeries.weekCount) -> CGRect { + let leading = self.weekdayGutterWidth + self.gridSpacing + let width = max(containerWidth - leading, 0) + let pitch = columns > 0 ? width / CGFloat(columns) : 0 + return CGRect(x: leading, y: 0, width: width, height: pitch * CGFloat(SpendActivitySeries.dayCount)) + } + + static func weekdayCenter(row: Int, rowPitch: CGFloat) -> CGFloat { + (CGFloat(row) + 0.5) * rowPitch + } + + static func tooltipCenterX(anchorX: CGFloat, tooltipWidth: CGFloat, gridWidth: CGFloat) -> CGFloat { + let halfWidth = tooltipWidth / 2 + let lower = min(halfWidth + self.tooltipInset, gridWidth / 2) + let upper = max(gridWidth - halfWidth - self.tooltipInset, gridWidth / 2) + return min(max(anchorX, lower), upper) + } + + static func tooltipOriginY(anchorY: CGFloat, tooltipHeight: CGFloat, gridHeight: CGFloat) -> CGFloat { + let below = anchorY + self.tooltipGap + if below + tooltipHeight <= gridHeight { + return below + } + return max(anchorY - tooltipHeight - self.tooltipGap, 0) + } +} + +enum SpendActivityWeekday { + static let labeledRows = [1, 3, 5] + + static func label(for row: Int, locale: Locale? = nil) -> String { + guard self.labeledRows.contains(row) else { return "" } + let formatter = DateFormatter() + formatter.locale = locale ?? codexBarLocalizedResourceLocale() + guard let symbols = formatter.shortStandaloneWeekdaySymbols, symbols.indices.contains(row) else { + return "" + } + return symbols[row] + } +} + +enum SpendActivityDateFormatting { + static func mediumDateString(_ date: Date, locale: Locale? = nil) -> String { + let formatter = DateFormatter() + formatter.locale = locale ?? codexBarLocalizedResourceLocale() + formatter.dateStyle = .medium + formatter.timeStyle = .none + return formatter.string(from: date) + } +} + +struct SpendActivityHeatmapView: View { + let points: [SpendDashboardModel.TokenActivityPoint] + let now: Date + + @AppStorage("spendActivityViewMode") private var mode: SpendActivityViewMode = .daily + @State private var series: SpendActivitySeries + + init(points: [SpendDashboardModel.TokenActivityPoint], now: Date = Date()) { + self.points = points + self.now = now + self._series = State(initialValue: SpendActivitySeries.make(from: points, now: now)) + } + + var body: some View { + let hasActivity = (self.series.daily.max() ?? 0) > 0 + let totalTokens = Self.saturatingTotal(self.series.daily) + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text(L("Token activity")) + .font(.headline) + if hasActivity { + Text("\(UsageFormatter.tokenCountString(totalTokens)) \(L("in the last year"))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + Picker(L("View"), selection: self.$mode) { + ForEach(SpendActivityViewMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + } + + if hasActivity { + switch self.mode { + case .daily: + SpendActivityDailyGrid(series: self.series) + self.dailyLegend + case .weekly: + SpendActivityWeekGrid( + series: self.series, + values: SpendActivityLevels.weeklyTotals(self.series.daily), + cumulative: false) + self.caption(L("Each column = 1 week")) + case .cumulative: + SpendActivityWeekGrid( + series: self.series, + values: SpendActivityLevels.cumulativeTotals( + SpendActivityLevels.weeklyTotals(self.series.daily)), + cumulative: true) + self.caption(L("Running total")) + } + } else { + Text(L("No activity in the last 12 months")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: self.points) { _, points in + self.series = SpendActivitySeries.make(from: points, now: self.now) + } + } + + private var dailyLegend: some View { + HStack(spacing: 4) { + Spacer() + Text(L("Less")) + ForEach(0...4, id: \.self) { level in + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(SpendActivityLevels.color(forLevel: level)) + .frame(width: 9, height: 9) + } + Text(L("More")) + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + private func caption(_ text: String) -> some View { + Text(text) + .font(.caption2) + .foregroundStyle(.secondary) + } + + private static func saturatingTotal(_ values: [Int]) -> Int { + values.reduce(0) { total, value in + let result = total.addingReportingOverflow(value) + return result.overflow ? Int.max : result.partialValue + } + } +} + +private struct SpendActivityDailyGrid: View { + let series: SpendActivitySeries + + @State private var hoveredIndex: Int? + + private let columns = SpendActivitySeries.weekCount + private let rows = SpendActivitySeries.dayCount + + var body: some View { + let levels = SpendActivityLevels.dailyLevels(self.series.daily) + VStack(alignment: .leading, spacing: 3) { + self.monthRow + GeometryReader { proxy in + let gridFrame = SpendActivityGridGeometry.gridFrame(containerWidth: proxy.size.width) + let pitch = gridFrame.width / CGFloat(self.columns) + let cell = max(pitch - 2, 2) + ZStack(alignment: .topLeading) { + self.weekdayLabels(rowPitch: pitch) + ZStack(alignment: .topLeading) { + Canvas { context, _ in + let corner = min(cell * 0.22, 2.5) + for index in 0..<(self.columns * self.rows) where self.isVisibleCell(index) { + let col = index / self.rows + let row = index % self.rows + let rect = CGRect( + x: CGFloat(col) * pitch + (pitch - cell) / 2, + y: CGFloat(row) * pitch + (pitch - cell) / 2, + width: cell, + height: cell) + context.fill( + RoundedRectangle(cornerRadius: corner, style: .continuous).path(in: rect), + with: .color(SpendActivityLevels.color(forLevel: levels[index]))) + } + } + self.hoverHighlight(cell: cell, pitch: pitch) + self.tooltip(size: gridFrame.size, pitch: pitch) + } + .frame(width: gridFrame.width, height: gridFrame.height) + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case let .active(location): + self.hoveredIndex = self.cellIndex(at: location, pitch: pitch) + case .ended: + self.hoveredIndex = nil + } + } + .offset(x: gridFrame.minX) + } + } + .aspectRatio(CGFloat(self.columns + 2) / CGFloat(self.rows), contentMode: .fit) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L("Token activity")) + .accessibilityValue(UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily))) + } + + private var monthRow: some View { + GeometryReader { proxy in + let gridFrame = SpendActivityGridGeometry.gridFrame(containerWidth: proxy.size.width) + let pitch = gridFrame.width / CGFloat(self.columns) + ZStack(alignment: .topLeading) { + ForEach(self.monthMarkers(pitch: pitch)) { marker in + Text(marker.label) + .font(.caption2) + .foregroundStyle(.tertiary) + .offset(x: gridFrame.minX + marker.offset) + } + } + } + .frame(height: 14) + } + + private func weekdayLabels(rowPitch: CGFloat) -> some View { + ForEach(SpendActivityWeekday.labeledRows, id: \.self) { row in + Text(SpendActivityWeekday.label(for: row)) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .frame(width: SpendActivityGridGeometry.weekdayGutterWidth, alignment: .trailing) + .position( + x: SpendActivityGridGeometry.weekdayGutterWidth / 2, + y: SpendActivityGridGeometry.weekdayCenter(row: row, rowPitch: rowPitch)) + } + } + + @ViewBuilder + private func hoverHighlight(cell: CGFloat, pitch: CGFloat) -> some View { + if let index = self.hoveredIndex { + let col = index / self.rows + let row = index % self.rows + RoundedRectangle(cornerRadius: min(cell * 0.22, 2.5), style: .continuous) + .stroke(Color.primary.opacity(0.7), lineWidth: 1.5) + .frame(width: cell, height: cell) + .position( + x: CGFloat(col) * pitch + pitch / 2, + y: CGFloat(row) * pitch + pitch / 2) + .allowsHitTesting(false) + } + } + + @ViewBuilder + private func tooltip(size: CGSize, pitch: CGFloat) -> some View { + if let index = self.hoveredIndex, let date = self.series.date(at: index) { + let col = index / self.rows + let row = index % self.rows + let anchorX = CGFloat(col) * pitch + pitch / 2 + let anchorY = CGFloat(row) * pitch + pitch / 2 + let width = min(SpendActivityGridGeometry.tooltipWidth, max(size.width - 8, 1)) + let originY = SpendActivityGridGeometry.tooltipOriginY( + anchorY: anchorY, + tooltipHeight: SpendActivityGridGeometry.tooltipHeight, + gridHeight: size.height) + SpendActivityTooltip( + title: UsageFormatter.tokenCountString(self.series.daily[index]), + subtitle: SpendActivityDateFormatting.mediumDateString(date), + width: width) + .position( + x: SpendActivityGridGeometry.tooltipCenterX( + anchorX: anchorX, + tooltipWidth: width, + gridWidth: size.width), + y: originY + SpendActivityGridGeometry.tooltipHeight / 2) + .allowsHitTesting(false) + } + } + + private func cellIndex(at location: CGPoint, pitch: CGFloat) -> Int? { + guard pitch > 0 else { return nil } + let col = Int(location.x / pitch) + let row = Int(location.y / pitch) + guard col >= 0, col < self.columns, row >= 0, row < self.rows else { return nil } + let index = col * self.rows + row + return self.isVisibleCell(index) ? index : nil + } + + private func isVisibleCell(_ index: Int) -> Bool { + guard let date = self.series.date(at: index) else { return false } + return date <= self.series.today + } + + private struct MonthMarker: Identifiable { + let id: Int + let offset: CGFloat + let label: String + } + + private func monthMarkers(pitch: CGFloat) -> [MonthMarker] { + let formatter = DateFormatter() + formatter.locale = codexBarLocalizedResourceLocale() + formatter.dateFormat = "MMM" + var markers: [MonthMarker] = [] + var lastLabel = "" + for col in 0.. Int { + values.reduce(0) { total, value in + let result = total.addingReportingOverflow(value) + return result.overflow ? Int.max : result.partialValue + } + } +} + +private struct SpendActivityWeekGrid: View { + let series: SpendActivitySeries + let values: [Int] + let cumulative: Bool + + @State private var hoverLocation: CGPoint? + + private let columns = SpendActivitySeries.weekCount + private let rows = SpendActivitySeries.dayCount + + var body: some View { + let maxValue = self.values.max() ?? 0 + GeometryReader { proxy in + let gridFrame = SpendActivityGridGeometry.gridFrame(containerWidth: proxy.size.width) + let pitch = gridFrame.width / CGFloat(self.columns) + let cell = max(pitch - 2, 2) + ZStack(alignment: .topLeading) { + Canvas { context, _ in + let corner = min(cell * 0.22, 2.5) + for col in 0.. 0 + ? Int((Double(value) / Double(maxValue) * Double(self.rows)).rounded()) + : 0 + let filled = value > 0 ? max(rawFill, 1) : 0 + for row in 0..= self.rows - filled + ? SpendActivityLevels.uniformFill + : SpendActivityLevels.color(forLevel: 0))) + } + } + } + self.tooltip(size: gridFrame.size, pitch: pitch) + } + .frame(width: gridFrame.width, height: gridFrame.height) + .contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case let .active(location): + self.hoverLocation = self.column(at: location, pitch: pitch) == nil ? nil : location + case .ended: + self.hoverLocation = nil + } + } + .offset(x: gridFrame.minX) + } + .aspectRatio(CGFloat(self.columns + 2) / CGFloat(self.rows), contentMode: .fit) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L("Token activity")) + .accessibilityValue(UsageFormatter.tokenCountString(self.accessibilityTokenTotal)) + } + + @ViewBuilder + private func tooltip(size: CGSize, pitch: CGFloat) -> some View { + if let location = self.hoverLocation, + let col = self.column(at: location, pitch: pitch), + col < self.values.count, + let weekStart = self.series.weekStartDate(at: col) + { + let width = min(SpendActivityGridGeometry.tooltipWidth, max(size.width - 8, 1)) + let originY = SpendActivityGridGeometry.tooltipOriginY( + anchorY: location.y, + tooltipHeight: SpendActivityGridGeometry.tooltipHeight, + gridHeight: size.height) + SpendActivityTooltip( + title: UsageFormatter.tokenCountString(self.values[col]), + subtitle: SpendActivityDateFormatting.mediumDateString(weekStart), + width: width) + .position( + x: SpendActivityGridGeometry.tooltipCenterX( + anchorX: CGFloat(col) * pitch + pitch / 2, + tooltipWidth: width, + gridWidth: size.width), + y: originY + SpendActivityGridGeometry.tooltipHeight / 2) + .allowsHitTesting(false) + } + } + + private func column(at location: CGPoint, pitch: CGFloat) -> Int? { + guard pitch > 0 else { return nil } + let col = Int(location.x / pitch) + guard col >= 0, col < self.columns, self.isVisible(col) else { return nil } + return col + } + + private func isVisible(_ column: Int) -> Bool { + guard let start = self.series.weekStartDate(at: column) else { return false } + return start <= self.series.today + } + + private var accessibilityTokenTotal: Int { + if self.cumulative { return self.values.last ?? 0 } + return self.values.reduce(0) { total, value in + let result = total.addingReportingOverflow(value) + return result.overflow ? Int.max : result.partialValue + } + } +} + +private struct SpendActivityTooltip: View { + let title: String + let subtitle: String + let width: CGFloat + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(self.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(self.subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.horizontal, 8) + .frame( + width: self.width, + height: SpendActivityGridGeometry.tooltipHeight, + alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor).opacity(0.3)) + } + } +} diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 8b7de621e3..00e9801a65 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -121,7 +121,7 @@ enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot - static let scanDays = 30 + static let scanDays = 365 @MainActor static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 96d3b5db9a..2fc2a9d61f 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -61,6 +61,15 @@ struct SpendDashboardModel: Equatable, Sendable { } } + struct TokenActivityPoint: Identifiable, Equatable, Sendable { + let day: Date + let totalTokens: Int + + var id: Date { + self.day + } + } + enum ModelHistoryCompleteness: Equatable, Sendable { case complete case incomplete @@ -84,6 +93,17 @@ struct SpendDashboardModel: Equatable, Sendable { let requestedDays: Int let groups: [CurrencyGroup] + let tokenActivity: [TokenActivityPoint] + + init( + requestedDays: Int, + groups: [CurrencyGroup], + tokenActivity: [TokenActivityPoint] = []) + { + self.requestedDays = requestedDays + self.groups = groups + self.tokenActivity = tokenActivity + } static func build( inputs: [ProviderInput], @@ -118,7 +138,13 @@ struct SpendDashboardModel: Equatable, Sendable { calendar: calculationCalendar) } .sorted { $0.currencyCode < $1.currencyCode } - return Self(requestedDays: days, groups: groups) + return Self( + requestedDays: days, + groups: groups, + tokenActivity: Self.tokenActivity( + inputs: inputs, + now: now, + calendar: calculationCalendar)) } private struct ClassifiedInput { @@ -542,6 +568,31 @@ struct SpendDashboardModel: Equatable, Sendable { } } + private static func tokenActivity( + inputs: [ProviderInput], + now: Date, + calendar: Calendar) -> [TokenActivityPoint] + { + let bounds = Self.bounds(days: 365, now: now, calendar: calendar) + var totals: [Date: Int] = [:] + for input in inputs { + for entry in input.snapshot.daily { + guard let day = Self.day( + entry.date, + provider: input.provider, + displayCalendar: calendar), + bounds.contains(day), + let tokens = Self.nonnegative(entry.totalTokens) + else { continue } + let addition = (totals[day] ?? 0).addingReportingOverflow(tokens) + totals[day] = addition.overflow ? Int.max : addition.partialValue + } + } + return totals.keys.sorted().map { day in + TokenActivityPoint(day: day, totalTokens: totals[day] ?? 0) + } + } + private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { let end = calendar.startOfDay(for: now) let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift new file mode 100644 index 0000000000..2f007f6d42 --- /dev/null +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -0,0 +1,155 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendActivityHeatmapTests { + @Test + func `daily levels keep exact boundaries and tolerate Int max`() { + #expect(SpendActivityLevels.dailyLevels([0, 1, 25, 26, 50, 51, 75, 76, 100]) == [ + 0, 1, 1, 2, 2, 3, 3, 4, 4, + ]) + #expect(SpendActivityLevels.dailyLevels([Int.max, Int.max / 2]) == [4, 2]) + } + + @Test + func `weekly and cumulative totals saturate instead of overflowing`() { + let daily = [Int.max, 1, 0, 0, 0, 0, 0, 2] + let weekly = SpendActivityLevels.weeklyTotals(daily) + #expect(weekly == [Int.max, 2]) + #expect(SpendActivityLevels.cumulativeTotals(weekly) == [Int.max, Int.max]) + } + + @Test + func `series uses a fixed Sunday first 52 week window`() throws { + let calendar = Self.calendar + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 5))) + let previousDay = try #require(calendar.date(byAdding: .day, value: -1, to: now)) + let futureDay = try #require(calendar.date(byAdding: .day, value: 1, to: now)) + let series = SpendActivitySeries.make( + from: [ + .init(day: previousDay, totalTokens: 10), + .init(day: now, totalTokens: 20), + .init(day: futureDay, totalTokens: 30), + ], + now: now, + calendar: calendar) + + #expect(series.daily.count == 52 * 7) + #expect(calendar.component(.weekday, from: series.start) == 1) + #expect(series.daily.reduce(0, +) == 30) + #expect(series.date(at: series.daily.count - 1)! > series.today) + } + + @Test + func `token activity spans a year without widening the spend chart`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) + let oldDay = "2025-08-01" + let first = Self.snapshot(entries: [ + Self.entry(day: oldDay, cost: 2, tokens: 40), + Self.entry(day: "2026-07-16", cost: 3, tokens: 10), + Self.entry(day: "2026-08-01", cost: 4, tokens: 100), + ]) + let second = Self.snapshot(entries: [ + Self.entry(day: oldDay, cost: 1, tokens: 60), + Self.entry(day: "2025-07-15", cost: 1, tokens: 200), + Self.entry(day: "invalid", cost: 1, tokens: 300), + Self.entry(day: "2026-07-15", cost: 1, tokens: -1), + ]) + let model = SpendDashboardModel.build( + inputs: [ + .init(id: "first", provider: .claude, displayName: "Claude", snapshot: first), + .init(id: "second", provider: .openai, displayName: "OpenAI", snapshot: second), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + + let oldDate = try #require(Self.calendar.date(from: DateComponents(year: 2025, month: 8, day: 1))) + #expect(model.tokenActivity == [ + .init(day: oldDate, totalTokens: 100), + .init(day: now, totalTokens: 10), + ]) + #expect(model.groups.first?.dailyPoints.map(\.day) == [now]) + } + + @Test + func `weekday labels and cells share the same row pitch`() { + let frame = SpendActivityGridGeometry.gridFrame(containerWidth: 1088) + let pitch = frame.width / CGFloat(SpendActivitySeries.weekCount) + + #expect(SpendActivityGridGeometry.weekdayCenter(row: 1, rowPitch: pitch) == pitch * 1.5) + #expect(SpendActivityGridGeometry.weekdayCenter(row: 3, rowPitch: pitch) == pitch * 3.5) + #expect(SpendActivityGridGeometry.weekdayCenter(row: 5, rowPitch: pitch) == pitch * 5.5) + #expect(frame.height == pitch * 7) + } + + @Test + func `tooltip stays beside the hovered cell and clamps only at the edge`() { + let gridWidth: CGFloat = 1000 + let width = SpendActivityGridGeometry.tooltipWidth + let centered = SpendActivityGridGeometry.tooltipCenterX( + anchorX: 500, + tooltipWidth: width, + gridWidth: gridWidth) + let trailing = SpendActivityGridGeometry.tooltipCenterX( + anchorX: 995, + tooltipWidth: width, + gridWidth: gridWidth) + + #expect(centered == 500) + #expect(trailing > 900) + #expect(trailing <= gridWidth - width / 2) + #expect(SpendActivityGridGeometry.tooltipOriginY( + anchorY: 10, + tooltipHeight: 50, + gridHeight: 130) > 10) + #expect(SpendActivityGridGeometry.tooltipOriginY( + anchorY: 120, + tooltipHeight: 50, + gridHeight: 130) < 70) + } + + @Test + func `weekday and date formatting follow the selected resource locale`() throws { + let date = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 8, day: 1))) + let english = Locale(identifier: "en_US") + let chinese = Locale(identifier: "zh_Hans") + + #expect(SpendActivityWeekday.label(for: 1, locale: english) == "Mon") + #expect(SpendActivityWeekday.label(for: 3, locale: english) == "Wed") + #expect(SpendActivityWeekday.label(for: 5, locale: english) == "Fri") + #expect(SpendActivityDateFormatting.mediumDateString(date, locale: english).contains("Aug")) + #expect(!SpendActivityDateFormatting.mediumDateString(date, locale: english).contains("年")) + #expect(SpendActivityDateFormatting.mediumDateString(date, locale: chinese).contains("年")) + } + + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } + + private static func snapshot(entries: [CostUsageDailyReport.Entry]) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "USD", + historyDays: 365, + daily: entries, + updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) + } + + private static func entry(day: String, cost: Double?, tokens: Int?) -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: day, + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: []) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 69aa8b4414..424eb463c7 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -48,7 +48,7 @@ struct SpendDashboardControllerTests { #expect(contexts.first?.cacheRoot.lastPathComponent == "inactive-cache") #expect(contexts.first?.now == now) #expect(contexts.first?.force == false) - #expect(contexts.first?.historyDays == 30) + #expect(contexts.first?.historyDays == 365) #expect(contexts.first?.refreshPricingInBackground == false) #expect(contexts.first?.includePiSessions == false) } diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 9223e633ca..d4088a763c 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -753,7 +753,7 @@ struct SpendDashboardModelTests { #expect(!request.authFileWasReadable) #expect(request.displayName == "Codex · #2") #expect(request.cacheIdentity.count == 64) - #expect(SpendDashboardSource.scanDays == 30) + #expect(SpendDashboardSource.scanDays == 365) #expect(SpendDashboardSource.codexRequest( account: account, homePath: "relative/path", From 4990426840d6293045749d22263d4776b8cf66d0 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:39:25 +0800 Subject: [PATCH 07/40] fix: preserve token activity coverage --- Sources/CodexBar/SpendActivityHeatmap.swift | 193 +++++++++++++++--- Sources/CodexBar/SpendDashboardModel.swift | 99 +++++++-- .../SpendActivityHeatmapTests.swift | 136 ++++++++++-- 3 files changed, 360 insertions(+), 68 deletions(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index 5ee7d6d74e..f144896bdb 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -25,6 +25,7 @@ struct SpendActivitySeries { static let dayCount = 7 let daily: [Int] + let isCovered: [Bool] let start: Date let today: Date let calendar: Calendar @@ -35,9 +36,16 @@ struct SpendActivitySeries { calendar: Calendar = .current) -> Self { var totals: [Date: Int] = [:] + var unknownDays: Set = [] for point in points { let day = calendar.startOfDay(for: point.day) - totals[day] = Self.saturatingAdd(totals[day] ?? 0, max(point.totalTokens, 0)) + guard let totalTokens = point.totalTokens else { + totals.removeValue(forKey: day) + unknownDays.insert(day) + continue + } + guard !unknownDays.contains(day) else { continue } + totals[day] = Self.saturatingAdd(totals[day] ?? 0, max(totalTokens, 0)) } let today = calendar.startOfDay(for: now) @@ -49,13 +57,16 @@ struct SpendActivitySeries { to: thisWeekSunday) ?? thisWeekSunday let cellCount = Self.weekCount * Self.dayCount var daily = [Int](repeating: 0, count: cellCount) + var isCovered = [Bool](repeating: false, count: cellCount) for index in 0.. Date? { @@ -66,12 +77,60 @@ struct SpendActivitySeries { self.calendar.date(byAdding: .day, value: week * Self.dayCount, to: self.start) } - private static func saturatingAdd(_ lhs: Int, _ rhs: Int) -> Int { + var visibleDayCount: Int { + self.daily.indices.filter(self.isVisible).count + } + + var coveredDayCount: Int { + self.daily.indices.count(where: { self.isVisible($0) && self.isCovered[$0] }) + } + + var hasUnknownCoverage: Bool { + self.coveredDayCount < self.visibleDayCount + } + + func weeklyActivity() -> SpendActivityAggregateSeries { + var values: [Int] = [] + var coverage: [Bool] = [] + for start in stride(from: 0, to: self.daily.count, by: Self.dayCount) { + let indices = start.. Bool { + guard let date = self.date(at: index) else { return false } + return date <= self.today + } + + static func saturatingAdd(_ lhs: Int, _ rhs: Int) -> Int { let result = lhs.addingReportingOverflow(rhs) return result.overflow ? Int.max : result.partialValue } } +struct SpendActivityAggregateSeries: Equatable { + let values: [Int] + let isCovered: [Bool] + + func cumulative() -> Self { + var total = 0 + var coverageIsComplete = true + var cumulativeValues: [Int] = [] + var cumulativeCoverage: [Bool] = [] + for index in self.values.indices { + total = SpendActivitySeries.saturatingAdd(total, self.values[index]) + coverageIsComplete = coverageIsComplete && self.isCovered[index] + cumulativeValues.append(total) + cumulativeCoverage.append(coverageIsComplete) + } + return Self(values: cumulativeValues, isCovered: cumulativeCoverage) + } +} + enum SpendActivityLevels { static func dailyLevels(_ values: [Int]) -> [Int] { let maxValue = values.max() ?? 0 @@ -117,6 +176,10 @@ enum SpendActivityLevels { self.rgb(0x40C463) } + static var unavailableFill: Color { + self.rgb(0xD6DCE5) + } + private static func rgb(_ hex: UInt32) -> Color { Color( red: Double((hex >> 16) & 0xFF) / 255, @@ -199,14 +262,23 @@ struct SpendActivityHeatmapView: View { var body: some View { let hasActivity = (self.series.daily.max() ?? 0) > 0 + let hasUnknownCoverage = self.series.hasUnknownCoverage let totalTokens = Self.saturatingTotal(self.series.daily) + let coverageText = spendDashboardCoverageText( + covered: self.series.coveredDayCount, + requested: self.series.visibleDayCount) + let weekly = self.series.weeklyActivity() VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 16) { VStack(alignment: .leading, spacing: 2) { Text(L("Token activity")) .font(.headline) if hasActivity { - Text("\(UsageFormatter.tokenCountString(totalTokens)) \(L("in the last year"))") + Text(self.activitySummary(totalTokens: totalTokens, coverageText: coverageText)) + .font(.caption) + .foregroundStyle(.secondary) + } else if hasUnknownCoverage { + Text("\(L("Unavailable")) · \(coverageText)") .font(.caption) .foregroundStyle(.secondary) } @@ -222,7 +294,7 @@ struct SpendActivityHeatmapView: View { .fixedSize() } - if hasActivity { + if hasActivity || hasUnknownCoverage { switch self.mode { case .daily: SpendActivityDailyGrid(series: self.series) @@ -230,16 +302,17 @@ struct SpendActivityHeatmapView: View { case .weekly: SpendActivityWeekGrid( series: self.series, - values: SpendActivityLevels.weeklyTotals(self.series.daily), + activity: weekly, cumulative: false) - self.caption(L("Each column = 1 week")) + self.caption( + L("Each column = 1 week"), + showsUnavailable: weekly.isCovered.contains(false)) case .cumulative: SpendActivityWeekGrid( series: self.series, - values: SpendActivityLevels.cumulativeTotals( - SpendActivityLevels.weeklyTotals(self.series.daily)), + activity: weekly.cumulative(), cumulative: true) - self.caption(L("Running total")) + self.caption(L("Running total"), showsUnavailable: hasUnknownCoverage) } } else { Text(L("No activity in the last 12 months")) @@ -265,15 +338,38 @@ struct SpendActivityHeatmapView: View { .frame(width: 9, height: 9) } Text(L("More")) + if self.series.hasUnknownCoverage { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(SpendActivityLevels.unavailableFill) + .frame(width: 9, height: 9) + .padding(.leading, 6) + Text(L("Unavailable")) + } } .font(.caption2) .foregroundStyle(.secondary) } - private func caption(_ text: String) -> some View { - Text(text) - .font(.caption2) - .foregroundStyle(.secondary) + private func caption(_ text: String, showsUnavailable: Bool) -> some View { + HStack(spacing: 4) { + Text(text) + Spacer() + if showsUnavailable { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(SpendActivityLevels.unavailableFill) + .frame(width: 9, height: 9) + Text(L("Unavailable")) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + + private func activitySummary(totalTokens: Int, coverageText: String) -> String { + let total = UsageFormatter.tokenCountString(totalTokens) + return self.series.hasUnknownCoverage + ? "\(total) · \(coverageText)" + : "\(total) \(L("in the last year"))" } private static func saturatingTotal(_ values: [Int]) -> Int { @@ -313,9 +409,12 @@ private struct SpendActivityDailyGrid: View { y: CGFloat(row) * pitch + (pitch - cell) / 2, width: cell, height: cell) + let fill = self.series.isCovered[index] + ? SpendActivityLevels.color(forLevel: levels[index]) + : SpendActivityLevels.unavailableFill context.fill( RoundedRectangle(cornerRadius: corner, style: .continuous).path(in: rect), - with: .color(SpendActivityLevels.color(forLevel: levels[index]))) + with: .color(fill)) } } self.hoverHighlight(cell: cell, pitch: pitch) @@ -338,7 +437,7 @@ private struct SpendActivityDailyGrid: View { } .accessibilityElement(children: .ignore) .accessibilityLabel(L("Token activity")) - .accessibilityValue(UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily))) + .accessibilityValue(self.accessibilityValue) } private var monthRow: some View { @@ -398,7 +497,9 @@ private struct SpendActivityDailyGrid: View { tooltipHeight: SpendActivityGridGeometry.tooltipHeight, gridHeight: size.height) SpendActivityTooltip( - title: UsageFormatter.tokenCountString(self.series.daily[index]), + title: self.series.isCovered[index] + ? UsageFormatter.tokenCountString(self.series.daily[index]) + : L("Unavailable"), subtitle: SpendActivityDateFormatting.mediumDateString(date), width: width) .position( @@ -455,11 +556,20 @@ private struct SpendActivityDailyGrid: View { return result.overflow ? Int.max : result.partialValue } } + + private var accessibilityValue: String { + let total = UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily)) + guard self.series.hasUnknownCoverage else { return total } + let coverage = spendDashboardCoverageText( + covered: self.series.coveredDayCount, + requested: self.series.visibleDayCount) + return "\(total) · \(coverage)" + } } private struct SpendActivityWeekGrid: View { let series: SpendActivitySeries - let values: [Int] + let activity: SpendActivityAggregateSeries let cumulative: Bool @State private var hoverLocation: CGPoint? @@ -468,7 +578,10 @@ private struct SpendActivityWeekGrid: View { private let rows = SpendActivitySeries.dayCount var body: some View { - let maxValue = self.values.max() ?? 0 + let maxValue = self.activity.values.enumerated() + .filter { self.activity.isCovered[$0.offset] } + .map(\.element) + .max() ?? 0 GeometryReader { proxy in let gridFrame = SpendActivityGridGeometry.gridFrame(containerWidth: proxy.size.width) let pitch = gridFrame.width / CGFloat(self.columns) @@ -476,13 +589,21 @@ private struct SpendActivityWeekGrid: View { ZStack(alignment: .topLeading) { Canvas { context, _ in let corner = min(cell * 0.22, 2.5) - for col in 0.. 0 ? Int((Double(value) / Double(maxValue) * Double(self.rows)).rounded()) : 0 let filled = value > 0 ? max(rawFill, 1) : 0 for row in 0..= self.rows - filled { + SpendActivityLevels.uniformFill + } else { + SpendActivityLevels.color(forLevel: 0) + } let rect = CGRect( x: CGFloat(col) * pitch + (pitch - cell) / 2, y: CGFloat(row) * pitch + (pitch - cell) / 2, @@ -490,9 +611,7 @@ private struct SpendActivityWeekGrid: View { height: cell) context.fill( RoundedRectangle(cornerRadius: corner, style: .continuous).path(in: rect), - with: .color(row >= self.rows - filled - ? SpendActivityLevels.uniformFill - : SpendActivityLevels.color(forLevel: 0))) + with: .color(fill)) } } } @@ -513,14 +632,14 @@ private struct SpendActivityWeekGrid: View { .aspectRatio(CGFloat(self.columns + 2) / CGFloat(self.rows), contentMode: .fit) .accessibilityElement(children: .ignore) .accessibilityLabel(L("Token activity")) - .accessibilityValue(UsageFormatter.tokenCountString(self.accessibilityTokenTotal)) + .accessibilityValue(self.accessibilityValue) } @ViewBuilder private func tooltip(size: CGSize, pitch: CGFloat) -> some View { if let location = self.hoverLocation, let col = self.column(at: location, pitch: pitch), - col < self.values.count, + col < self.activity.values.count, let weekStart = self.series.weekStartDate(at: col) { let width = min(SpendActivityGridGeometry.tooltipWidth, max(size.width - 8, 1)) @@ -529,7 +648,9 @@ private struct SpendActivityWeekGrid: View { tooltipHeight: SpendActivityGridGeometry.tooltipHeight, gridHeight: size.height) SpendActivityTooltip( - title: UsageFormatter.tokenCountString(self.values[col]), + title: self.activity.isCovered[col] + ? UsageFormatter.tokenCountString(self.activity.values[col]) + : L("Unavailable"), subtitle: SpendActivityDateFormatting.mediumDateString(weekStart), width: width) .position( @@ -555,12 +676,24 @@ private struct SpendActivityWeekGrid: View { } private var accessibilityTokenTotal: Int { - if self.cumulative { return self.values.last ?? 0 } - return self.values.reduce(0) { total, value in + if self.cumulative { return self.activity.values.last ?? 0 } + return self.activity.values.reduce(0) { total, value in let result = total.addingReportingOverflow(value) return result.overflow ? Int.max : result.partialValue } } + + private var accessibilityValue: String { + let total = UsageFormatter.tokenCountString(self.accessibilityTokenTotal) + let hasUnavailable = self.activity.isCovered.enumerated().contains { index, covered in + self.isVisible(index) && !covered + } + guard hasUnavailable else { return total } + let coverage = spendDashboardCoverageText( + covered: self.series.coveredDayCount, + requested: self.series.visibleDayCount) + return "\(total) · \(coverage)" + } } private struct SpendActivityTooltip: View { diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 2fc2a9d61f..00a06b1eb0 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -63,7 +63,9 @@ struct SpendDashboardModel: Equatable, Sendable { struct TokenActivityPoint: Identifiable, Equatable, Sendable { let day: Date - let totalTokens: Int + /// `nil` means at least one included source cannot establish coverage for this day. + /// This must stay distinct from a proven zero so the heatmap does not fabricate inactivity. + let totalTokens: Int? var id: Date { self.day @@ -95,6 +97,8 @@ struct SpendDashboardModel: Equatable, Sendable { let groups: [CurrencyGroup] let tokenActivity: [TokenActivityPoint] + static let tokenActivityDayCount = 365 + init( requestedDays: Int, groups: [CurrencyGroup], @@ -204,6 +208,25 @@ struct SpendDashboardModel: Equatable, Sendable { var overflowed = false } + private struct TokenActivityInputSummary { + let coveredInterval: ClosedRange? + let totalsByDay: [Date: Int] + let invalidDays: Set + let hasCompleteHistory: Bool + let isGloballyInvalid: Bool + + func tokens(on day: Date) -> Int? { + guard self.coveredInterval?.contains(day) == true, + !self.isGloballyInvalid, + !self.invalidDays.contains(day) + else { return nil } + if let tokens = self.totalsByDay[day] { + return tokens + } + return self.hasCompleteHistory ? 0 : nil + } + } + private static func buildCurrencyGroup( currencyCode: String, inputs: [ClassifiedInput], @@ -573,24 +596,68 @@ struct SpendDashboardModel: Equatable, Sendable { now: Date, calendar: Calendar) -> [TokenActivityPoint] { - let bounds = Self.bounds(days: 365, now: now, calendar: calendar) - var totals: [Date: Int] = [:] - for input in inputs { - for entry in input.snapshot.daily { - guard let day = Self.day( - entry.date, - provider: input.provider, - displayCalendar: calendar), - bounds.contains(day), - let tokens = Self.nonnegative(entry.totalTokens) - else { continue } - let addition = (totals[day] ?? 0).addingReportingOverflow(tokens) - totals[day] = addition.overflow ? Int.max : addition.partialValue + guard !inputs.isEmpty else { return [] } + let bounds = Self.bounds(days: Self.tokenActivityDayCount, now: now, calendar: calendar) + let summaries = inputs.map { + Self.tokenActivityInputSummary(input: $0, bounds: bounds, calendar: calendar) + } + return (0.., + calendar: Calendar) -> TokenActivityInputSummary + { + let coveredInterval = Self.coverageInterval( + input: input, + bounds: bounds, + displayCalendar: calendar) + let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: calendar) + var totalsByDay: [Date: Int] = [:] + var invalidDays: Set = [] + var hasUnplacedTokens = false + for entry in input.snapshot.daily { + guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: calendar) else { + hasUnplacedTokens = hasUnplacedTokens || !Self.hasProvenZeroTokens(entry) + continue + } + guard sourceCoverage.contains(day) else { continue } + guard let tokens = Self.nonnegative(entry.totalTokens) else { + invalidDays.insert(day) + continue + } + guard !invalidDays.contains(day) else { continue } + let addition = (totalsByDay[day] ?? 0).addingReportingOverflow(tokens) + if addition.overflow { + totalsByDay.removeValue(forKey: day) + invalidDays.insert(day) + } else { + totalsByDay[day] = addition.partialValue + } } + + let hasCompleteHistory = Self.hasCompleteTokenHistory(input, displayCalendar: calendar) + let aggregateIsInconsistent = input.snapshot.last30DaysTokens != nil && !hasCompleteHistory + return TokenActivityInputSummary( + coveredInterval: coveredInterval, + totalsByDay: totalsByDay, + invalidDays: invalidDays, + hasCompleteHistory: hasCompleteHistory, + isGloballyInvalid: hasUnplacedTokens || aggregateIsInconsistent) } private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 2f007f6d42..6f51c5f95e 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -36,41 +36,127 @@ struct SpendActivityHeatmapTests { calendar: calendar) #expect(series.daily.count == 52 * 7) + #expect(series.isCovered.count == 52 * 7) #expect(calendar.component(.weekday, from: series.start) == 1) #expect(series.daily.reduce(0, +) == 30) + #expect(series.coveredDayCount == 2) #expect(series.date(at: series.daily.count - 1)! > series.today) } @Test - func `token activity spans a year without widening the spend chart`() throws { + func `mixed provider activity marks days outside common coverage unavailable`() throws { let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) let oldDay = "2025-08-01" - let first = Self.snapshot(entries: [ - Self.entry(day: oldDay, cost: 2, tokens: 40), - Self.entry(day: "2026-07-16", cost: 3, tokens: 10), - Self.entry(day: "2026-08-01", cost: 4, tokens: 100), - ]) - let second = Self.snapshot(entries: [ - Self.entry(day: oldDay, cost: 1, tokens: 60), - Self.entry(day: "2025-07-15", cost: 1, tokens: 200), - Self.entry(day: "invalid", cost: 1, tokens: 300), - Self.entry(day: "2026-07-15", cost: 1, tokens: -1), - ]) + let annual = Self.snapshot( + entries: [ + Self.entry(day: oldDay, cost: 2, tokens: 40), + Self.entry(day: "2026-07-16", cost: 3, tokens: 10), + ], + historyDays: 365, + last30DaysTokens: 50) + let recent = Self.snapshot( + entries: [ + Self.entry(day: "2026-07-16", cost: 1, tokens: 60), + ], + historyDays: 30, + last30DaysTokens: 60) let model = SpendDashboardModel.build( inputs: [ - .init(id: "first", provider: .claude, displayName: "Claude", snapshot: first), - .init(id: "second", provider: .openai, displayName: "OpenAI", snapshot: second), + .init(id: "annual", provider: .claude, displayName: "Claude", snapshot: annual), + .init(id: "recent", provider: .openai, displayName: "OpenAI", snapshot: recent), ], requestedDays: 30, now: now, calendar: Self.calendar) let oldDate = try #require(Self.calendar.date(from: DateComponents(year: 2025, month: 8, day: 1))) - #expect(model.tokenActivity == [ - .init(day: oldDate, totalTokens: 100), - .init(day: now, totalTokens: 10), - ]) - #expect(model.groups.first?.dailyPoints.map(\.day) == [now]) + #expect(model.tokenActivity.count == SpendDashboardModel.tokenActivityDayCount) + #expect(model.tokenActivity.first { $0.day == oldDate }?.totalTokens == nil) + #expect(model.tokenActivity.first { $0.day == now }?.totalTokens == 70) + #expect(model.groups.first?.dailyPoints.allSatisfy { $0.day == now } == true) + } + + @Test + func `covered empty history is zero while unestablished history remains unavailable`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) + let covered = SpendDashboardModel.build( + inputs: [ + .init( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(entries: [], historyDays: 365, last30DaysTokens: 0)), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + let unavailable = SpendDashboardModel.build( + inputs: [ + .init( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + entries: [], + historyDays: 365, + last30DaysTokens: nil, + historyCoverageIsEstablished: false)), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + + #expect(covered.tokenActivity.allSatisfy { $0.totalTokens == 0 }) + #expect(unavailable.tokenActivity.allSatisfy { $0.totalTokens == nil }) + } + + @Test + func `weekly and cumulative activity preserve unavailable coverage`() throws { + let start = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 5))) + let today = try #require(Self.calendar.date(byAdding: .day, value: 13, to: start)) + var covered = [Bool](repeating: true, count: SpendActivitySeries.weekCount * 7) + covered[2] = false + let series = SpendActivitySeries( + daily: [Int](repeating: 1, count: covered.count), + isCovered: covered, + start: start, + today: today, + calendar: Self.calendar) + + let weekly = series.weeklyActivity() + #expect(weekly.values.prefix(2) == [7, 7]) + #expect(weekly.isCovered.prefix(2) == [false, true]) + #expect(weekly.cumulative().isCovered.prefix(2) == [false, false]) + } + + @Test + func `annual aggregation output stays fixed with many full year providers`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) + let formatter = DateFormatter() + formatter.calendar = Self.calendar + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + let entries = try (0.. CostUsageTokenSnapshot { + private static func snapshot( + entries: [CostUsageDailyReport.Entry], + historyDays: Int = 365, + last30DaysTokens: Int?, + historyCoverageIsEstablished: Bool = true) -> CostUsageTokenSnapshot + { CostUsageTokenSnapshot( sessionTokens: nil, sessionCostUSD: nil, - last30DaysTokens: nil, + last30DaysTokens: last30DaysTokens, last30DaysCostUSD: nil, currencyCode: "USD", - historyDays: 365, + historyDays: historyDays, + historyCoverageIsEstablished: historyCoverageIsEstablished, daily: entries, updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) } From b2114dc017e9512d6d75399d63ca93d0983217bd Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:10:09 +0800 Subject: [PATCH 08/40] fix: complete activity heatmap navigation --- Sources/CodexBar/SpendActivityHeatmap.swift | 137 ++++++++++++++++-- .../SpendActivityHeatmapTests.swift | 40 ++++- 2 files changed, 159 insertions(+), 18 deletions(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index f144896bdb..c75039682a 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -21,12 +21,14 @@ enum SpendActivityViewMode: String, CaseIterable, Identifiable { } struct SpendActivitySeries { - static let weekCount = 52 + static let weekCount = 53 static let dayCount = 7 + static let rangeDayCount = 365 let daily: [Int] let isCovered: [Bool] let start: Date + let rangeStart: Date let today: Date let calendar: Calendar @@ -49,24 +51,35 @@ struct SpendActivitySeries { } let today = calendar.startOfDay(for: now) - let weekday = calendar.component(.weekday, from: today) - let thisWeekSunday = calendar.date(byAdding: .day, value: -(weekday - 1), to: today) ?? today + let rangeStart = calendar.date( + byAdding: .day, + value: -(Self.rangeDayCount - 1), + to: today) ?? today + let rangeStartWeekday = calendar.component(.weekday, from: rangeStart) let start = calendar.date( - byAdding: .weekOfYear, - value: -(Self.weekCount - 1), - to: thisWeekSunday) ?? thisWeekSunday + byAdding: .day, + value: -(rangeStartWeekday - 1), + to: rangeStart) ?? rangeStart let cellCount = Self.weekCount * Self.dayCount var daily = [Int](repeating: 0, count: cellCount) var isCovered = [Bool](repeating: false, count: cellCount) for index in 0.. Date? { @@ -101,9 +114,9 @@ struct SpendActivitySeries { return SpendActivityAggregateSeries(values: values, isCovered: coverage) } - private func isVisible(_ index: Int) -> Bool { + func isVisible(_ index: Int) -> Bool { guard let date = self.date(at: index) else { return false } - return date <= self.today + return self.rangeStart...self.today ~= date } static func saturatingAdd(_ lhs: Int, _ rhs: Int) -> Int { @@ -223,6 +236,32 @@ struct SpendActivityGridGeometry { } } +enum SpendActivityGridMove { + case left + case right + case up + case down +} + +enum SpendActivityGridNavigation { + static func candidate(from current: Int, move: SpendActivityGridMove, rows: Int) -> Int? { + guard rows > 0 else { return nil } + let row = current % rows + return switch move { + case .left: + current - rows + case .right: + current + rows + case .up where row > 0: + current - 1 + case .down where row < rows - 1: + current + 1 + default: + nil + } + } +} + enum SpendActivityWeekday { static let labeledRows = [1, 3, 5] @@ -384,6 +423,8 @@ private struct SpendActivityDailyGrid: View { let series: SpendActivitySeries @State private var hoveredIndex: Int? + @State private var keyboardIndex: Int? + @FocusState private var isKeyboardFocused: Bool private let columns = SpendActivitySeries.weekCount private let rows = SpendActivitySeries.dayCount @@ -435,9 +476,27 @@ private struct SpendActivityDailyGrid: View { } .aspectRatio(CGFloat(self.columns + 2) / CGFloat(self.rows), contentMode: .fit) } + .focusable() + .focused(self.$isKeyboardFocused) + .onMoveCommand(perform: self.moveKeyboardSelection) + .onChange(of: self.isKeyboardFocused) { _, isFocused in + if isFocused, self.keyboardIndex == nil { + self.keyboardIndex = self.lastVisibleIndex + } + } .accessibilityElement(children: .ignore) .accessibilityLabel(L("Token activity")) .accessibilityValue(self.accessibilityValue) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: + self.moveKeyboardSelectionChronologically(by: 1) + case .decrement: + self.moveKeyboardSelectionChronologically(by: -1) + @unknown default: + break + } + } } private var monthRow: some View { @@ -471,7 +530,7 @@ private struct SpendActivityDailyGrid: View { @ViewBuilder private func hoverHighlight(cell: CGFloat, pitch: CGFloat) -> some View { - if let index = self.hoveredIndex { + if let index = self.activeIndex { let col = index / self.rows let row = index % self.rows RoundedRectangle(cornerRadius: min(cell * 0.22, 2.5), style: .continuous) @@ -486,7 +545,7 @@ private struct SpendActivityDailyGrid: View { @ViewBuilder private func tooltip(size: CGSize, pitch: CGFloat) -> some View { - if let index = self.hoveredIndex, let date = self.series.date(at: index) { + if let index = self.activeIndex, let date = self.series.date(at: index) { let col = index / self.rows let row = index % self.rows let anchorX = CGFloat(col) * pitch + pitch / 2 @@ -522,8 +581,46 @@ private struct SpendActivityDailyGrid: View { } private func isVisibleCell(_ index: Int) -> Bool { - guard let date = self.series.date(at: index) else { return false } - return date <= self.series.today + self.series.isVisible(index) + } + + private var activeIndex: Int? { + if let hoveredIndex { return hoveredIndex } + return self.keyboardIndex + } + + private var lastVisibleIndex: Int? { + self.series.daily.indices.last(where: self.series.isVisible) + } + + private func moveKeyboardSelection(_ direction: MoveCommandDirection) { + guard let current = self.keyboardIndex ?? self.lastVisibleIndex else { return } + let move: SpendActivityGridMove? = switch direction { + case .left: + .left + case .right: + .right + case .up: + .up + case .down: + .down + default: + nil + } + guard let move else { return } + let candidate = SpendActivityGridNavigation.candidate(from: current, move: move, rows: self.rows) + guard let candidate, self.isVisibleCell(candidate) else { return } + self.keyboardIndex = candidate + } + + private func moveKeyboardSelectionChronologically(by offset: Int) { + guard let current = self.keyboardIndex else { + self.keyboardIndex = self.lastVisibleIndex + return + } + let candidate = current + offset + guard self.isVisibleCell(candidate) else { return } + self.keyboardIndex = candidate } private struct MonthMarker: Identifiable { @@ -558,6 +655,12 @@ private struct SpendActivityDailyGrid: View { } private var accessibilityValue: String { + if let index = self.activeIndex, let date = self.series.date(at: index) { + let value = self.series.isCovered[index] + ? UsageFormatter.tokenCountString(self.series.daily[index]) + : L("Unavailable") + return "\(SpendActivityDateFormatting.mediumDateString(date)): \(value)" + } let total = UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily)) guard self.series.hasUnknownCoverage else { return total } let coverage = spendDashboardCoverageText( @@ -672,7 +775,11 @@ private struct SpendActivityWeekGrid: View { private func isVisible(_ column: Int) -> Bool { guard let start = self.series.weekStartDate(at: column) else { return false } - return start <= self.series.today + let end = self.series.calendar.date( + byAdding: .day, + value: SpendActivitySeries.dayCount - 1, + to: start) ?? start + return start <= self.series.today && end >= self.series.rangeStart } private var accessibilityTokenTotal: Int { diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 6f51c5f95e..7d7932cecb 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -21,7 +21,7 @@ struct SpendActivityHeatmapTests { } @Test - func `series uses a fixed Sunday first 52 week window`() throws { + func `series uses a Sunday aligned 53 week container for exactly 365 visible days`() throws { let calendar = Self.calendar let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 5))) let previousDay = try #require(calendar.date(byAdding: .day, value: -1, to: now)) @@ -35,14 +35,37 @@ struct SpendActivityHeatmapTests { now: now, calendar: calendar) - #expect(series.daily.count == 52 * 7) - #expect(series.isCovered.count == 52 * 7) + #expect(series.daily.count == 53 * 7) + #expect(series.isCovered.count == 53 * 7) #expect(calendar.component(.weekday, from: series.start) == 1) + #expect(series.visibleDayCount == 365) #expect(series.daily.reduce(0, +) == 30) #expect(series.coveredDayCount == 2) #expect(series.date(at: series.daily.count - 1)! > series.today) } + @Test(arguments: Array(2...8)) + func `oldest annual day remains visible for every ending weekday`(augustDay: Int) throws { + let calendar = Self.calendar + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: augustDay))) + let rangeStart = try #require(calendar.date( + byAdding: .day, + value: -(SpendActivitySeries.rangeDayCount - 1), + to: now)) + let points = try (0.. Date: Sat, 1 Aug 2026 21:23:26 +0800 Subject: [PATCH 09/40] fix: expose daily activity accessibility --- Sources/CodexBar/SpendActivityHeatmap.swift | 26 +++++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index c75039682a..db169a47e4 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -484,9 +484,19 @@ private struct SpendActivityDailyGrid: View { self.keyboardIndex = self.lastVisibleIndex } } - .accessibilityElement(children: .ignore) + .accessibilityElement(children: .contain) .accessibilityLabel(L("Token activity")) .accessibilityValue(self.accessibilityValue) + .accessibilityChildren { + ForEach(self.series.daily.indices.filter(self.series.isVisible), id: \.self) { index in + if let date = self.series.date(at: index) { + Color.clear + .accessibilityElement() + .accessibilityLabel(SpendActivityDateFormatting.mediumDateString(date)) + .accessibilityValue(self.accessibilityTokenValue(at: index)) + } + } + } .accessibilityAdjustableAction { direction in switch direction { case .increment: @@ -594,7 +604,9 @@ private struct SpendActivityDailyGrid: View { } private func moveKeyboardSelection(_ direction: MoveCommandDirection) { + self.hoveredIndex = nil guard let current = self.keyboardIndex ?? self.lastVisibleIndex else { return } + self.keyboardIndex = current let move: SpendActivityGridMove? = switch direction { case .left: .left @@ -614,6 +626,7 @@ private struct SpendActivityDailyGrid: View { } private func moveKeyboardSelectionChronologically(by offset: Int) { + self.hoveredIndex = nil guard let current = self.keyboardIndex else { self.keyboardIndex = self.lastVisibleIndex return @@ -656,10 +669,7 @@ private struct SpendActivityDailyGrid: View { private var accessibilityValue: String { if let index = self.activeIndex, let date = self.series.date(at: index) { - let value = self.series.isCovered[index] - ? UsageFormatter.tokenCountString(self.series.daily[index]) - : L("Unavailable") - return "\(SpendActivityDateFormatting.mediumDateString(date)): \(value)" + return "\(SpendActivityDateFormatting.mediumDateString(date)): \(self.accessibilityTokenValue(at: index))" } let total = UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily)) guard self.series.hasUnknownCoverage else { return total } @@ -668,6 +678,12 @@ private struct SpendActivityDailyGrid: View { requested: self.series.visibleDayCount) return "\(total) · \(coverage)" } + + private func accessibilityTokenValue(at index: Int) -> String { + self.series.isCovered[index] + ? UsageFormatter.tokenCountString(self.series.daily[index]) + : L("Unavailable") + } } private struct SpendActivityWeekGrid: View { 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 10/40] 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 45422937857e6c35c3138aa96c40ebf34300e2da Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:38:24 +0800 Subject: [PATCH 11/40] fix: publish daily accessibility semantics --- Sources/CodexBar/SpendActivityHeatmap.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index db169a47e4..9b07ebe42e 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -490,8 +490,7 @@ private struct SpendActivityDailyGrid: View { .accessibilityChildren { ForEach(self.series.daily.indices.filter(self.series.isVisible), id: \.self) { index in if let date = self.series.date(at: index) { - Color.clear - .accessibilityElement() + Text(self.accessibilityTokenValue(at: index)) .accessibilityLabel(SpendActivityDateFormatting.mediumDateString(date)) .accessibilityValue(self.accessibilityTokenValue(at: index)) } From ee6b5cb9333681aa382db0c95285bb613f0430b6 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:43:17 +0800 Subject: [PATCH 12/40] fix: expose dates in activity accessibility --- Sources/CodexBar/SpendActivityHeatmap.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index 9b07ebe42e..86f71ef9e4 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -490,9 +490,7 @@ private struct SpendActivityDailyGrid: View { .accessibilityChildren { ForEach(self.series.daily.indices.filter(self.series.isVisible), id: \.self) { index in if let date = self.series.date(at: index) { - Text(self.accessibilityTokenValue(at: index)) - .accessibilityLabel(SpendActivityDateFormatting.mediumDateString(date)) - .accessibilityValue(self.accessibilityTokenValue(at: index)) + Text(self.accessibilityDescription(at: index, date: date)) } } } @@ -668,7 +666,7 @@ private struct SpendActivityDailyGrid: View { private var accessibilityValue: String { if let index = self.activeIndex, let date = self.series.date(at: index) { - return "\(SpendActivityDateFormatting.mediumDateString(date)): \(self.accessibilityTokenValue(at: index))" + return self.accessibilityDescription(at: index, date: date) } let total = UsageFormatter.tokenCountString(Self.saturatingTotal(self.series.daily)) guard self.series.hasUnknownCoverage else { return total } @@ -678,6 +676,10 @@ private struct SpendActivityDailyGrid: View { return "\(total) · \(coverage)" } + private func accessibilityDescription(at index: Int, date: Date) -> String { + "\(SpendActivityDateFormatting.mediumDateString(date)): \(self.accessibilityTokenValue(at: index))" + } + private func accessibilityTokenValue(at index: Int) -> String { self.series.isCovered[index] ? UsageFormatter.tokenCountString(self.series.daily[index]) From 2f820c94c0688327e39362fc80789f1aac8017ea Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:55:59 +0800 Subject: [PATCH 13/40] fix: expose weekly activity accessibility --- Sources/CodexBar/SpendActivityHeatmap.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index 86f71ef9e4..3febded346 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -750,9 +750,16 @@ private struct SpendActivityWeekGrid: View { .offset(x: gridFrame.minX) } .aspectRatio(CGFloat(self.columns + 2) / CGFloat(self.rows), contentMode: .fit) - .accessibilityElement(children: .ignore) + .accessibilityElement(children: .contain) .accessibilityLabel(L("Token activity")) .accessibilityValue(self.accessibilityValue) + .accessibilityChildren { + ForEach(self.activity.values.indices.filter(self.isVisible), id: \.self) { index in + if let weekStart = self.series.weekStartDate(at: index) { + Text(self.accessibilityDescription(at: index, weekStart: weekStart)) + } + } + } } @ViewBuilder @@ -818,6 +825,13 @@ private struct SpendActivityWeekGrid: View { requested: self.series.visibleDayCount) return "\(total) · \(coverage)" } + + private func accessibilityDescription(at index: Int, weekStart: Date) -> String { + let value = self.activity.isCovered[index] + ? UsageFormatter.tokenCountString(self.activity.values[index]) + : L("Unavailable") + return "\(SpendActivityDateFormatting.mediumDateString(weekStart)): \(value)" + } } private struct SpendActivityTooltip: View { 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 14/40] 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 15/40] 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 16/40] 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 17/40] 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 554ef51df5522d80a436c21b0aec85c7ad550534 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:21:16 +0800 Subject: [PATCH 18/40] fix: separate spend and annual activity scan budgets Spend aggregation clamps requestedDays to 30, so scanning 365 days on every dashboard load wasted work. The Codex spend snapshot now scans 30 days, while the token activity heatmap loads its 365-day history through a dedicated actor cache keyed by cache root, account identity, and auth fingerprint. The cache coalesces in-flight loads, refreshes at most every 15 minutes, and always expires at local day rollover so the heatmap reflects the current day. Within the heatmap the recent 30-day snapshot overrides annual entries for the days it covers, keeping recent totals consistent with the spend view. A failed annual scan retains the normal spend snapshot and falls back to 30-day activity instead of failing the source. --- .../CodexBar/SpendDashboardController.swift | 110 ++++++++++- Sources/CodexBar/SpendDashboardModel.swift | 109 ++++++++--- .../SpendActivityHeatmapTests.swift | 35 ++++ .../SpendDashboardControllerTests.swift | 57 +----- ...SpendDashboardForceStateMachineTests.swift | 4 + .../SpendDashboardModelTests.swift | 1 - .../SpendDashboardScanBudgetTests.swift | 172 ++++++++++++++++++ ...SpendDashboardSourceConcurrencyTests.swift | 2 + 8 files changed, 403 insertions(+), 87 deletions(-) create mode 100644 Tests/CodexBarTests/SpendDashboardScanBudgetTests.swift diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 00e9801a65..a66727f22c 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -121,7 +121,9 @@ enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot - static let scanDays = 365 + static let scanDays = 30 + static let activityScanDays = SpendDashboardModel.tokenActivityDayCount + private static let activitySnapshotCache = SpendDashboardCodexActivitySnapshotCache() @MainActor static func configuration(settings: SettingsStore, store: UsageStore) -> SpendDashboardConfiguration { @@ -250,14 +252,32 @@ enum SpendDashboardSource { } static func load(_ request: SpendDashboardLoadRequest) async -> SpendDashboardLoadResult { - await self.load(request, codexSnapshotLoader: { context in - try await self.loadCodexSnapshot(context) - }) + await self.load( + request, + codexSnapshotLoader: { context in + try await self.loadCodexSnapshot(context) + }, + codexActivitySnapshotLoader: { context in + try await self.activitySnapshotCache.load(context) { activityContext in + try await self.loadCodexSnapshot(activityContext) + } + }) } static func load( _ request: SpendDashboardLoadRequest, codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult + { + await self.load( + request, + codexSnapshotLoader: codexSnapshotLoader, + codexActivitySnapshotLoader: codexSnapshotLoader) + } + + static func load( + _ request: SpendDashboardLoadRequest, + codexSnapshotLoader: CodexSnapshotLoader, + codexActivitySnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult { var inputs = request.capturedInputs var failedSourceIDs = request.unavailableSourceIDs @@ -287,12 +307,32 @@ enum SpendDashboardSource { invalidatedSourceIDs.insert(sourceID) continue } + var tokenActivitySnapshot = snapshot + do { + tokenActivitySnapshot = try await codexActivitySnapshotLoader(CodexSpendSnapshotLoadContext( + account: account, + cacheRoot: cacheRoot, + now: request.now, + force: false, + historyDays: Self.activityScanDays, + refreshPricingInBackground: false, + includePiSessions: false)) + try Task.checkCancellation() + } catch is CancellationError { + throw CancellationError() + } catch {} + guard self.currentAuthFingerprint(for: account) == account.authFingerprint else { + failedSourceIDs.insert(sourceID) + invalidatedSourceIDs.insert(sourceID) + continue + } inputs.append(SpendDashboardModel.ProviderInput( id: sourceID, provider: .codex, displayName: account.displayName, modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, - snapshot: snapshot)) + snapshot: snapshot, + tokenActivitySnapshot: tokenActivitySnapshot)) } catch is CancellationError { failedSourceIDs.formUnion(request.codexRequests.map { "codex:\($0.id)" }) return SpendDashboardLoadResult( @@ -500,6 +540,63 @@ enum SpendDashboardSource { } } +actor SpendDashboardCodexActivitySnapshotCache { + private struct Entry { + let snapshot: CostUsageTokenSnapshot + let expiresAt: Date + } + + private let refreshInterval: TimeInterval + private var entries: [String: Entry] = [:] + private var inFlight: [String: Task] = [:] + + init(refreshInterval: TimeInterval = 15 * 60) { + self.refreshInterval = refreshInterval + } + + func load( + _ context: CodexSpendSnapshotLoadContext, + loader: @escaping SpendDashboardSource.CodexSnapshotLoader) async throws -> CostUsageTokenSnapshot + { + let key = Self.key(for: context) + if let entry = self.entries[key], context.now < entry.expiresAt { + return entry.snapshot + } + if let task = self.inFlight[key] { + return try await task.value + } + + let task = Task { try await loader(context) } + self.inFlight[key] = task + do { + let snapshot = try await task.value + self.entries[key] = Entry( + snapshot: snapshot, + expiresAt: Self.expirationDate(from: context.now, refreshInterval: self.refreshInterval)) + self.inFlight[key] = nil + return snapshot + } catch { + self.inFlight[key] = nil + throw error + } + } + + private static func key(for context: CodexSpendSnapshotLoadContext) -> String { + [ + context.cacheRoot.standardizedFileURL.path, + context.account.cacheIdentity, + context.account.authFingerprint ?? "missing-auth", + ].joined(separator: "|") + } + + private static func expirationDate(from now: Date, refreshInterval: TimeInterval) -> Date { + let intervalExpiry = now.addingTimeInterval(refreshInterval) + let calendar = Calendar.current + let nextDay = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)) ?? intervalExpiry + return min(intervalExpiry, nextDay) + } +} + private struct SpendDashboardSnapshotRevisionEncoder { private var hasher = SHA256() @@ -998,7 +1095,8 @@ final class SpendDashboardController { provider: input.provider, displayName: displayName, modelProviderName: input.modelProviderName, - snapshot: input.snapshot) + snapshot: input.snapshot, + tokenActivitySnapshot: input.tokenActivitySnapshot) } private static func sameSourceOwnership( diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 00a06b1eb0..848c459a1f 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -8,19 +8,22 @@ struct SpendDashboardModel: Equatable, Sendable { let displayName: String let modelProviderName: String let snapshot: CostUsageTokenSnapshot + let tokenActivitySnapshot: CostUsageTokenSnapshot init( id: String? = nil, provider: UsageProvider, displayName: String, modelProviderName: String? = nil, - snapshot: CostUsageTokenSnapshot) + snapshot: CostUsageTokenSnapshot, + tokenActivitySnapshot: CostUsageTokenSnapshot? = nil) { self.id = id ?? provider.rawValue self.provider = provider self.displayName = displayName self.modelProviderName = modelProviderName ?? displayName self.snapshot = snapshot + self.tokenActivitySnapshot = tokenActivitySnapshot ?? snapshot } } @@ -208,25 +211,6 @@ struct SpendDashboardModel: Equatable, Sendable { var overflowed = false } - private struct TokenActivityInputSummary { - let coveredInterval: ClosedRange? - let totalsByDay: [Date: Int] - let invalidDays: Set - let hasCompleteHistory: Bool - let isGloballyInvalid: Bool - - func tokens(on day: Date) -> Int? { - guard self.coveredInterval?.contains(day) == true, - !self.isGloballyInvalid, - !self.invalidDays.contains(day) - else { return nil } - if let tokens = self.totalsByDay[day] { - return tokens - } - return self.hasCompleteHistory ? 0 : nil - } - } - private static func buildCurrencyGroup( currencyCode: String, inputs: [ClassifiedInput], @@ -620,12 +604,58 @@ struct SpendDashboardModel: Equatable, Sendable { private static func tokenActivityInputSummary( input: ProviderInput, bounds: ClosedRange, - calendar: Calendar) -> TokenActivityInputSummary + calendar: Calendar) -> SpendTokenActivityInputSummary + { + let annualInput = ProviderInput( + id: input.id, + provider: input.provider, + displayName: input.displayName, + modelProviderName: input.modelProviderName, + snapshot: input.tokenActivitySnapshot) + let annual = Self.tokenActivitySnapshotSummary(input: annualInput, bounds: bounds, calendar: calendar) + guard input.snapshot != input.tokenActivitySnapshot else { return annual } + + let recentInput = ProviderInput( + id: input.id, + provider: input.provider, + displayName: input.displayName, + modelProviderName: input.modelProviderName, + snapshot: input.snapshot) + let recent = Self.tokenActivitySnapshotSummary(input: recentInput, bounds: bounds, calendar: calendar) + var totalsByDay = annual.totalsByDay + var invalidDays = annual.invalidDays + var zeroKnownDays = annual.zeroKnownDays + for day in recent.coveredDays { + totalsByDay.removeValue(forKey: day) + invalidDays.remove(day) + zeroKnownDays.remove(day) + if let tokens = recent.totalsByDay[day] { + totalsByDay[day] = tokens + } + if recent.invalidDays.contains(day) { + invalidDays.insert(day) + } + if recent.zeroKnownDays.contains(day) { + zeroKnownDays.insert(day) + } + } + return SpendTokenActivityInputSummary( + coveredDays: annual.coveredDays.union(recent.coveredDays), + totalsByDay: totalsByDay, + invalidDays: invalidDays, + zeroKnownDays: zeroKnownDays) + } + + private static func tokenActivitySnapshotSummary( + input: ProviderInput, + bounds: ClosedRange, + calendar: Calendar) -> SpendTokenActivityInputSummary { let coveredInterval = Self.coverageInterval( input: input, bounds: bounds, displayCalendar: calendar) + let coveredDays = Self.days(in: coveredInterval, calendar: calendar) let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: calendar) var totalsByDay: [Date: Int] = [:] var invalidDays: Set = [] @@ -652,12 +682,26 @@ struct SpendDashboardModel: Equatable, Sendable { let hasCompleteHistory = Self.hasCompleteTokenHistory(input, displayCalendar: calendar) let aggregateIsInconsistent = input.snapshot.last30DaysTokens != nil && !hasCompleteHistory - return TokenActivityInputSummary( - coveredInterval: coveredInterval, + if hasUnplacedTokens || aggregateIsInconsistent { + invalidDays.formUnion(coveredDays) + } + return SpendTokenActivityInputSummary( + coveredDays: coveredDays, totalsByDay: totalsByDay, invalidDays: invalidDays, - hasCompleteHistory: hasCompleteHistory, - isGloballyInvalid: hasUnplacedTokens || aggregateIsInconsistent) + zeroKnownDays: hasCompleteHistory ? coveredDays : []) + } + + private static func days(in interval: ClosedRange?, calendar: Calendar) -> Set { + guard let interval else { return [] } + var result: Set = [] + var day = interval.lowerBound + while day <= interval.upperBound { + result.insert(day) + guard let next = calendar.date(byAdding: .day, value: 1, to: day), next > day else { break } + day = next + } + return result } private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { @@ -822,3 +866,18 @@ struct SpendDashboardModel: Equatable, Sendable { return result } } + +private struct SpendTokenActivityInputSummary { + let coveredDays: Set + let totalsByDay: [Date: Int] + let invalidDays: Set + let zeroKnownDays: Set + + func tokens(on day: Date) -> Int? { + guard self.coveredDays.contains(day), !self.invalidDays.contains(day) else { return nil } + if let tokens = self.totalsByDay[day] { + return tokens + } + return self.zeroKnownDays.contains(day) ? 0 : nil + } +} diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 7d7932cecb..69331fe494 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -99,6 +99,41 @@ struct SpendActivityHeatmapTests { #expect(model.groups.first?.dailyPoints.allSatisfy { $0.day == now } == true) } + @Test + func `annual activity snapshot does not widen spend aggregation`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) + let oldDay = "2025-08-01" + let spend = Self.snapshot( + entries: [Self.entry(day: "2026-07-16", cost: 2, tokens: 10)], + historyDays: 30, + last30DaysTokens: 10) + let activity = Self.snapshot( + entries: [ + Self.entry(day: oldDay, cost: nil, tokens: 40), + Self.entry(day: "2026-07-16", cost: nil, tokens: 5), + ], + historyDays: 365, + last30DaysTokens: 45) + let model = SpendDashboardModel.build( + inputs: [ + .init( + provider: .codex, + displayName: "Codex", + snapshot: spend, + tokenActivitySnapshot: activity), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + + let oldDate = try #require(Self.calendar.date(from: DateComponents(year: 2025, month: 8, day: 1))) + #expect(model.groups.first?.totalTokens == 10) + #expect(model.groups.first?.totalCost == 2) + #expect(model.groups.first?.dailyPoints.count == 1) + #expect(model.tokenActivity.first { $0.day == oldDate }?.totalTokens == 40) + #expect(model.tokenActivity.first { $0.day == now }?.totalTokens == 10) + } + @Test func `covered empty history is zero while unestablished history remains unavailable`() throws { let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index 424eb463c7..69b5783f90 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -6,53 +6,6 @@ import Testing @MainActor @Suite(.serialized) struct SpendDashboardControllerTests { - @Test - func `empty codex history loads as successful inactive source`() async { - let now = Date(timeIntervalSince1970: 1_784_179_200) - let recorder = SpendDashboardCodexLoadRecorder() - let account = CodexSpendScanRequest( - id: "inactive", - displayName: "Codex", - source: .profileHome(path: "/synthetic/codex-home"), - homePath: "/synthetic/codex-home", - authFingerprint: nil, - authFileWasReadable: false, - cacheIdentity: "inactive-cache") - let request = SpendDashboardLoadRequest( - configuration: Self.configuration(account: "inactive|inactive-cache"), - capturedInputs: [], - unavailableSourceIDs: [], - codexRequests: [account], - now: now, - force: false) - - let result = await SpendDashboardSource.load(request, codexSnapshotLoader: { context in - await recorder.record(context) - return CostUsageTokenSnapshot( - sessionTokens: nil, - sessionCostUSD: nil, - last30DaysTokens: 0, - last30DaysCostUSD: 0, - historyDays: context.historyDays, - daily: [], - updatedAt: context.now) - }) - let contexts = await recorder.contexts - - #expect(result.inputs.count == 1) - #expect(result.inputs.first?.id == "codex:inactive") - #expect(result.inputs.first?.snapshot.daily.isEmpty == true) - #expect(result.failedSourceIDs.isEmpty) - #expect(contexts.count == 1) - #expect(contexts.first?.account == account) - #expect(contexts.first?.cacheRoot.lastPathComponent == "inactive-cache") - #expect(contexts.first?.now == now) - #expect(contexts.first?.force == false) - #expect(contexts.first?.historyDays == 365) - #expect(contexts.first?.refreshPricingInBackground == false) - #expect(contexts.first?.includePiSessions == false) - } - @Test func `Codex auth rotation invalidates stale spend while retaining unrelated providers`() async throws { let home = FileManager.default.temporaryDirectory @@ -101,6 +54,8 @@ struct SpendDashboardControllerTests { controller.update(configuration: configuration) await Self.waitForCodexPendingCount(1, gate: gate) await gate.resume(at: 0, snapshot: Self.input(cost: 6).snapshot) + await Self.waitForCodexPendingCount(1, gate: gate) + await gate.resume(at: 0, snapshot: Self.input(cost: 6).snapshot) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 8) @@ -1209,14 +1164,6 @@ private actor SpendDashboardCapturedInputStore { } } -private actor SpendDashboardCodexLoadRecorder { - private(set) var contexts: [CodexSpendSnapshotLoadContext] = [] - - func record(_ context: CodexSpendSnapshotLoadContext) { - self.contexts.append(context) - } -} - private actor SpendDashboardLoadResultRecorder { private(set) var results: [SpendDashboardLoadResult] = [] diff --git a/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift b/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift index e0c1f152c6..af97fbf12b 100644 --- a/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift +++ b/Tests/CodexBarTests/SpendDashboardForceStateMachineTests.swift @@ -407,6 +407,8 @@ struct SpendDashboardForceStateMachineTests { await Self.waitForCodexGate(codexGate) controller.update(configuration: confirmedEmpty) await codexGate.resume(codexInput.snapshot) + await Self.waitForCodexGate(codexGate) + await codexGate.resume(codexInput.snapshot) await Self.waitUntil { !controller.isRefreshing } let settledGeneration = controller.generation @@ -476,6 +478,8 @@ struct SpendDashboardForceStateMachineTests { await Self.waitForCodexGate(codexGate) controller.update(configuration: unavailable) await codexGate.resume(codexInput.snapshot) + await Self.waitForCodexGate(codexGate) + await codexGate.resume(codexInput.snapshot) await Self.waitForBuildGate(captureGate) controller.update(configuration: latest) await captureGate.resume() diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index d4088a763c..1235307a99 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -753,7 +753,6 @@ struct SpendDashboardModelTests { #expect(!request.authFileWasReadable) #expect(request.displayName == "Codex · #2") #expect(request.cacheIdentity.count == 64) - #expect(SpendDashboardSource.scanDays == 365) #expect(SpendDashboardSource.codexRequest( account: account, homePath: "relative/path", diff --git a/Tests/CodexBarTests/SpendDashboardScanBudgetTests.swift b/Tests/CodexBarTests/SpendDashboardScanBudgetTests.swift new file mode 100644 index 0000000000..cb43853e1d --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardScanBudgetTests.swift @@ -0,0 +1,172 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardScanBudgetTests { + @Test + func `empty codex history loads separate spend and activity snapshots`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let recorder = SpendDashboardScanContextRecorder() + let account = Self.account(id: "inactive", cacheIdentity: "inactive-cache") + let request = Self.request(account: account, now: now, force: false) + + let result = await SpendDashboardSource.load(request, codexSnapshotLoader: { context in + await recorder.record(context) + return Self.snapshot(context: context, tokens: 0, cost: 0) + }) + let contexts = await recorder.contexts + + #expect(result.inputs.count == 1) + #expect(result.inputs.first?.id == "codex:inactive") + #expect(result.inputs.first?.snapshot.daily.isEmpty == true) + #expect(result.failedSourceIDs.isEmpty) + #expect(contexts.count == 2) + #expect(contexts.first?.account == account) + #expect(contexts.first?.cacheRoot.lastPathComponent == "inactive-cache") + #expect(contexts.first?.now == now) + #expect(contexts.first?.force == false) + #expect(contexts.first?.historyDays == 30) + #expect(contexts.first?.refreshPricingInBackground == false) + #expect(contexts.first?.includePiSessions == false) + #expect(contexts.last?.historyDays == 365) + #expect(contexts.last?.force == false) + #expect(result.inputs.first?.snapshot.historyDays == 30) + #expect(result.inputs.first?.tokenActivitySnapshot.historyDays == 365) + } + + @Test + func `forced dashboard refresh preserves the normal scan budget`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let recorder = SpendDashboardScanContextRecorder() + let account = Self.account(id: "account", cacheIdentity: "scan-budget") + + _ = await SpendDashboardSource.load( + Self.request(account: account, now: now, force: true), + codexSnapshotLoader: { context in + await recorder.record(context) + return Self.snapshot(context: context, tokens: 0, cost: 0) + }) + let contexts = await recorder.contexts + + #expect(SpendDashboardSource.scanDays == 30) + #expect(SpendDashboardSource.activityScanDays == 365) + #expect(contexts.map(\.historyDays) == [30, 365]) + #expect(contexts.map(\.force) == [true, false]) + } + + @Test + func `annual activity scan failure retains the normal spend snapshot`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let account = Self.account(id: "account", cacheIdentity: "activity-failure") + let result = await SpendDashboardSource.load( + Self.request(account: account, now: now, force: false), + codexSnapshotLoader: { context in + if context.historyDays == SpendDashboardSource.activityScanDays { + throw CocoaError(.fileReadUnknown) + } + return Self.snapshot(context: context, tokens: 10, cost: 1) + }) + + #expect(result.failedSourceIDs.isEmpty) + #expect(result.inputs.count == 1) + #expect(result.inputs.first?.snapshot.historyDays == 30) + #expect(result.inputs.first?.tokenActivitySnapshot.historyDays == 30) + } + + @Test + func `annual activity cache limits rescans and expires on schedule`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let account = Self.account(id: "account", cacheIdentity: "activity-cache") + let cache = SpendDashboardCodexActivitySnapshotCache(refreshInterval: 15 * 60) + let recorder = SpendDashboardScanContextRecorder() + let context = Self.context(account: account, now: now) + + _ = try await cache.load(context) { loadContext in + await recorder.record(loadContext) + return Self.snapshot(context: loadContext, tokens: 10, cost: 1) + } + _ = try await cache.load(context) { loadContext in + await recorder.record(loadContext) + return Self.snapshot(context: loadContext, tokens: 20, cost: 2) + } + let cachedContexts = await recorder.contexts + #expect(cachedContexts.count == 1) + + let expiredContext = Self.context(account: account, now: now.addingTimeInterval(15 * 60)) + let expired = try await cache.load(expiredContext) { loadContext in + await recorder.record(loadContext) + return Self.snapshot(context: loadContext, tokens: 30, cost: 3) + } + #expect(expired.last30DaysTokens == 30) + let refreshedContexts = await recorder.contexts + #expect(refreshedContexts.count == 2) + } + + private static func account(id: String, cacheIdentity: String) -> CodexSpendScanRequest { + CodexSpendScanRequest( + id: id, + displayName: "Codex", + source: .profileHome(path: "/synthetic/codex-home"), + homePath: "/synthetic/codex-home", + authFingerprint: nil, + authFileWasReadable: false, + cacheIdentity: cacheIdentity) + } + + private static func request( + account: CodexSpendScanRequest, + now: Date, + force: Bool) -> SpendDashboardLoadRequest + { + SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.codex.rawValue], + codexAccountIdentities: ["\(account.id)|\(account.cacheIdentity)"]), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [account], + now: now, + force: force) + } + + private static func context( + account: CodexSpendScanRequest, + now: Date) -> CodexSpendSnapshotLoadContext + { + CodexSpendSnapshotLoadContext( + account: account, + cacheRoot: URL(fileURLWithPath: "/synthetic/cache", isDirectory: true), + now: now, + force: false, + historyDays: SpendDashboardSource.activityScanDays, + refreshPricingInBackground: false, + includePiSessions: false) + } + + private nonisolated static func snapshot( + context: CodexSpendSnapshotLoadContext, + tokens: Int, + cost: Double) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: tokens, + sessionCostUSD: cost, + last30DaysTokens: tokens, + last30DaysCostUSD: cost, + historyDays: context.historyDays, + daily: [], + updatedAt: context.now) + } +} + +private actor SpendDashboardScanContextRecorder { + private(set) var contexts: [CodexSpendSnapshotLoadContext] = [] + + func record(_ context: CodexSpendSnapshotLoadContext) { + self.contexts.append(context) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift index fda8fb8878..75c0ddc09b 100644 --- a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -50,6 +50,8 @@ struct SpendDashboardSourceConcurrencyTests { to: CodexAuthFingerprint.authFileURL(homePath: failed.homePath), options: .atomic) await gate.resume(snapshot: laterSnapshot) + await Self.waitForCodexGate(gate) + await gate.resume(snapshot: laterSnapshot) let result = await loadTask.value #expect(result.inputs.map(\.id) == ["codex:later"]) 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 19/40] 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 20/40] 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 21/40] 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 22/40] 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 23/40] 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 24/40] 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 25/40] 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 26/40] 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 27/40] 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 28/40] 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 341fc5b0becbce519c9f7201087acf86b1c0a7ef Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:42:09 +0800 Subject: [PATCH 29/40] fix: treat established empty annual scans as confirmed zero activity --- Sources/CodexBar/SpendDashboardModel.swift | 8 ++++++- .../SpendActivityHeatmapTests.swift | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 848c459a1f..4602ef1d74 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -680,7 +680,13 @@ struct SpendDashboardModel: Equatable, Sendable { } } - let hasCompleteHistory = Self.hasCompleteTokenHistory(input, displayCalendar: calendar) + // A successful scan that found no sessions is confirmed zero activity: every covered day + // renders as zero instead of unavailable. Nonempty histories must still reconcile their + // aggregate against the daily entries. + let confirmedZeroHistory = input.snapshot.daily.isEmpty + && input.snapshot.last30DaysTokens == nil + let hasCompleteHistory = confirmedZeroHistory + || Self.hasCompleteTokenHistory(input, displayCalendar: calendar) let aggregateIsInconsistent = input.snapshot.last30DaysTokens != nil && !hasCompleteHistory if hasUnplacedTokens || aggregateIsInconsistent { invalidDays.formUnion(coveredDays) diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 69331fe494..8705f5ea8e 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -166,6 +166,30 @@ struct SpendActivityHeatmapTests { #expect(unavailable.tokenActivity.allSatisfy { $0.totalTokens == nil }) } + @Test + func `established empty history with nil aggregate is confirmed zero activity`() throws { + let now = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))) + // Codex's tokenSnapshot returns an empty daily array with last30DaysTokens nil for a + // successful scan of an account with no sessions in the window. + let model = SpendDashboardModel.build( + inputs: [ + .init( + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot( + entries: [], + historyDays: 365, + last30DaysTokens: nil, + historyCoverageIsEstablished: true)), + ], + requestedDays: 30, + now: now, + calendar: Self.calendar) + + #expect(!model.tokenActivity.isEmpty) + #expect(model.tokenActivity.allSatisfy { $0.totalTokens == 0 }) + } + @Test func `weekly and cumulative activity preserve unavailable coverage`() throws { let start = try #require(Self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 5))) From 0b2c2b5d244ad6f60309ed3fe07943b1e5d4ed46 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:24:35 +0800 Subject: [PATCH 30/40] feat(spend): model-centric Models view with per-tool breakdown and billing attribution Split from #2322: adds the Models section to the Usage & Spend dashboard - Models/Tools ranking views with day-detail drilldown and same-model comparison - Model/tool/provider identity resolution and billing ownership attribution - Local history adapters wiring Kimi Code, Gemini CLI, OpenCode, MiniMax, Antigravity and Qwen Code scanners into the dashboard - Groq token-cost support, provider brand icons, 23-language strings Stacked on #2527 + #2548 (integration base: main + both PR heads). --- Sources/CodexBar/CodexbarApp.swift | 40 +- .../PreferencesSpendDashboardPane.swift | 639 ++++++-- .../PreferencesSpendModelsDayDetailView.swift | 434 ++++++ .../CodexBar/PreferencesSpendModelsView.swift | 1305 ++++++++++++++++ Sources/CodexBar/PreferencesView.swift | 8 +- Sources/CodexBar/ProviderBrandIcon.swift | 15 +- .../Resources/ProviderIcon-antigravity.png | Bin 0 -> 90331 bytes .../Resources/ProviderIcon-claude.png | Bin 0 -> 2918 bytes .../Resources/ProviderIcon-gemini.png | Bin 0 -> 3845 bytes .../CodexBar/Resources/ProviderIcon-kimi.png | Bin 0 -> 1835 bytes .../Resources/ar.lproj/Localizable.strings | 53 + .../Resources/ca.lproj/Localizable.strings | 53 + .../Resources/de.lproj/Localizable.strings | 53 + .../Resources/en.lproj/Localizable.strings | 53 +- .../Resources/es.lproj/Localizable.strings | 53 + .../Resources/fa.lproj/Localizable.strings | 53 + .../Resources/fr.lproj/Localizable.strings | 53 + .../Resources/gl.lproj/Localizable.strings | 53 + .../Resources/id.lproj/Localizable.strings | 53 + .../Resources/it.lproj/Localizable.strings | 53 + .../Resources/ja.lproj/Localizable.strings | 53 + .../Resources/ko.lproj/Localizable.strings | 53 + .../Resources/nl.lproj/Localizable.strings | 53 + .../Resources/pl.lproj/Localizable.strings | 53 + .../Resources/pt-BR.lproj/Localizable.strings | 53 + .../Resources/ru.lproj/Localizable.strings | 53 + .../Resources/sv.lproj/Localizable.strings | 53 + .../Resources/th.lproj/Localizable.strings | 53 + .../Resources/tr.lproj/Localizable.strings | 53 + .../Resources/uk.lproj/Localizable.strings | 53 + .../Resources/vi.lproj/Localizable.strings | 53 + .../zh-Hans.lproj/Localizable.strings | 65 +- .../zh-Hant.lproj/Localizable.strings | 55 +- Sources/CodexBar/ShareStatsPayload.swift | 11 +- Sources/CodexBar/SpendActivityHeatmap.swift | 24 + .../CodexBar/SpendBillingAttribution.swift | 400 +++++ Sources/CodexBar/SpendClientsView.swift | 549 +++++++ .../CodexBar/SpendDashboardController.swift | 460 +++++- .../SpendDashboardModel+Aggregation.swift | 229 +++ .../SpendDashboardModel+ChartDomain.swift | 145 ++ .../SpendDashboardModel+CurrencySafety.swift | 174 +++ .../SpendDashboardModel+Evidence.swift | 79 + .../SpendDashboardModel+TokenActivity.swift | 154 ++ .../SpendDashboardModel+TokenBuckets.swift | 54 + Sources/CodexBar/SpendDashboardModel.swift | 1332 ++++++++++++----- Sources/CodexBar/SpendModelIdentity.swift | 307 ++++ Sources/CodexBar/SpendProviderIdentity.swift | 93 ++ Sources/CodexBar/SpendSubscriptionPlan.swift | 51 + Sources/CodexBar/SpendToolIdentity.swift | 81 + Sources/CodexBar/UsageStore+TokenCost.swift | 4 +- .../Groq/GroqProviderDescriptor.swift | 2 +- Tests/CodexBarTests/AppDelegateTests.swift | 3 +- .../GroqConsoleFetcherTests.swift | 1 + .../GroqMenuCardModelTests.swift | 14 +- .../ProviderIconResourcesTests.swift | 20 +- Tests/CodexBarTests/ShareStatsTests.swift | 24 +- .../SpendBillingAttributionTests.swift | 410 +++++ .../SpendChartDayHitTargetTests.swift | 111 ++ .../SpendDashboardClockRolloverTests.swift | 36 + .../SpendDashboardControllerTests.swift | 57 + .../SpendDashboardDateTruthTests.swift | 31 +- .../SpendDashboardKimiModelTests.swift | 127 ++ .../SpendDashboardLocalAdapterTests.swift | 69 + ...ndDashboardLocalHistoryRecoveryTests.swift | 117 ++ .../SpendDashboardModelTests.swift | 769 +++++++++- ...SpendDashboardSourceConcurrencyTests.swift | 281 +++- .../SpendModelIdentityTests.swift | 132 ++ .../SpendModelsPresentationTests.swift | 637 ++++++++ .../SpendToolPresentationTests.swift | 194 +++ design-qa.md | 263 ++++ docs/provider-icon-sources.md | 16 + 71 files changed, 10597 insertions(+), 538 deletions(-) create mode 100644 Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift create mode 100644 Sources/CodexBar/PreferencesSpendModelsView.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-antigravity.png create mode 100644 Sources/CodexBar/Resources/ProviderIcon-claude.png create mode 100644 Sources/CodexBar/Resources/ProviderIcon-gemini.png create mode 100644 Sources/CodexBar/Resources/ProviderIcon-kimi.png create mode 100644 Sources/CodexBar/SpendBillingAttribution.swift create mode 100644 Sources/CodexBar/SpendClientsView.swift create mode 100644 Sources/CodexBar/SpendDashboardModel+Aggregation.swift create mode 100644 Sources/CodexBar/SpendDashboardModel+ChartDomain.swift create mode 100644 Sources/CodexBar/SpendDashboardModel+CurrencySafety.swift create mode 100644 Sources/CodexBar/SpendDashboardModel+Evidence.swift create mode 100644 Sources/CodexBar/SpendDashboardModel+TokenActivity.swift create mode 100644 Sources/CodexBar/SpendDashboardModel+TokenBuckets.swift create mode 100644 Sources/CodexBar/SpendModelIdentity.swift create mode 100644 Sources/CodexBar/SpendProviderIdentity.swift create mode 100644 Sources/CodexBar/SpendSubscriptionPlan.swift create mode 100644 Sources/CodexBar/SpendToolIdentity.swift create mode 100644 Tests/CodexBarTests/SpendBillingAttributionTests.swift create mode 100644 Tests/CodexBarTests/SpendChartDayHitTargetTests.swift create mode 100644 Tests/CodexBarTests/SpendDashboardKimiModelTests.swift create mode 100644 Tests/CodexBarTests/SpendDashboardLocalAdapterTests.swift create mode 100644 Tests/CodexBarTests/SpendDashboardLocalHistoryRecoveryTests.swift create mode 100644 Tests/CodexBarTests/SpendModelIdentityTests.swift create mode 100644 Tests/CodexBarTests/SpendModelsPresentationTests.swift create mode 100644 Tests/CodexBarTests/SpendToolPresentationTests.swift create mode 100644 design-qa.md create mode 100644 docs/provider-icon-sources.md diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index c3d8e443ef..1100e8228d 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -34,6 +34,7 @@ struct CodexBarApp: App { @State private var store: UsageStore @State private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator @State private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator + @State private var spendDashboardController: SpendDashboardController private let preferencesSelection: PreferencesSelection private let account: AccountInfo @@ -78,6 +79,9 @@ struct CodexBarApp: App { let browserDetection = BrowserDetection(cacheTTL: BrowserDetection.defaultCacheTTL) let account = fetcher.loadAccountInfo() let store = UsageStore(fetcher: fetcher, browserDetection: browserDetection, settings: settings) + let spendDashboardController = SpendDashboardController(requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }) let codexAccountPromotionCoordinator = CodexAccountPromotionCoordinator( settingsStore: settings, usageStore: store, @@ -87,6 +91,7 @@ struct CodexBarApp: App { _store = State(wrappedValue: store) _managedCodexAccountCoordinator = State(wrappedValue: managedCodexAccountCoordinator) _codexAccountPromotionCoordinator = State(wrappedValue: codexAccountPromotionCoordinator) + _spendDashboardController = State(wrappedValue: spendDashboardController) self.account = account CodexBarLog.setLogLevel(settings.debugLogLevel) self.appDelegate.configure(.init( @@ -95,7 +100,8 @@ struct CodexBarApp: App { account: account, selection: preferencesSelection, managedCodexAccountCoordinator: managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: codexAccountPromotionCoordinator)) + codexAccountPromotionCoordinator: codexAccountPromotionCoordinator, + spendDashboardController: spendDashboardController)) } @SceneBuilder @@ -112,6 +118,7 @@ struct CodexBarApp: App { PreferencesView( settings: self.settings, store: self.store, + spendDashboardController: self.spendDashboardController, updater: self.appDelegate.updaterController, selection: self.preferencesSelection, managedCodexAccountCoordinator: self.managedCodexAccountCoordinator, @@ -374,6 +381,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let selection: PreferencesSelection let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator + let spendDashboardController: SpendDashboardController? } let updaterController: UpdaterProviding = makeUpdaterController() @@ -390,6 +398,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var preferencesSelection: PreferencesSelection? private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator? private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? + private var spendDashboardController: SpendDashboardController? + private var spendDashboardWarmupTask: Task? private var hasInstalledLimitResetObservers = false #if DEBUG private var debugMemoryPressureObserver: NSObjectProtocol? @@ -405,6 +415,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.preferencesSelection = dependencies.selection self.managedCodexAccountCoordinator = dependencies.managedCodexAccountCoordinator self.codexAccountPromotionCoordinator = dependencies.codexAccountPromotionCoordinator + self.spendDashboardController = dependencies.spendDashboardController } func applicationWillFinishLaunching(_ notification: Notification) { @@ -417,6 +428,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.installDebugMemoryPressureObserverIfNeeded() #endif self.ensureStatusController() + self.scheduleSpendDashboardWarmup() Task { @MainActor [weak self] in await Task.yield() guard let settings = self?.settings else { return } @@ -452,6 +464,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + self.spendDashboardWarmupTask?.cancel() + self.spendDashboardWarmupTask = nil + self.spendDashboardController?.stop() self.memoryPressureMonitor.stop() #if DEBUG self.removeDebugMemoryPressureObserver() @@ -462,6 +477,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.terminateActiveProcessesForAppShutdown() } + private func scheduleSpendDashboardWarmup() { + guard self.spendDashboardWarmupTask == nil, + let controller = self.spendDashboardController, + let settings = self.settings, + let store = self.store + else { + return + } + self.spendDashboardWarmupTask = Task(priority: .utility) { @MainActor [weak self] in + defer { self?.spendDashboardWarmupTask = nil } + do { + try await Task.sleep(for: .seconds(5)) + } catch { + return + } + guard !Task.isCancelled, settings.costUsageEnabled else { return } + controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) + } + } + func runProviderLoginFlow(_ provider: UsageProvider) async { self.ensureStatusController() guard let statusController else { return } @@ -556,9 +591,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { selection, managedCodexAccountCoordinator, codexAccountPromotionCoordinator) - if let statusController = self.statusController as? StatusItemController { - MenuSwitchFlickerProbe.startIfRequested(controller: statusController) - } return } diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index b7adb4b4a7..fae69e64b6 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -8,6 +8,7 @@ func spendDashboardDayRangeText(_ days: Int) -> String { switch days { case 7: template = L("7d") case 30: template = L("30d") + case 365: return L("Cumulative") default: return codexBarLocalizedInteger(days) } return template.replacingOccurrences( @@ -24,7 +25,8 @@ func spendDashboardRefreshFailureText(_ count: Int) -> String { } func spendDashboardCoverageText(covered: Int, requested: Int) -> String { - "\(L("Coverage")): \(codexBarLocalizedInteger(covered)) / \(codexBarLocalizedInteger(requested))" + "\(L("Coverage")): \(spendDashboardDayRangeText(covered)) · " + + "\(L("Time range")): \(spendDashboardDayRangeText(requested))" } enum SpendDashboardModelHistoryPresentation: Equatable { @@ -47,14 +49,10 @@ func spendDashboardModelHistoryPresentation( struct SpendDashboardPane: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore - @State private var controller: SpendDashboardController - - init(settings: SettingsStore, store: UsageStore) { - self.settings = settings - self.store = store - self._controller = State(initialValue: SpendDashboardController(requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - })) + @Bindable var controller: SpendDashboardController + private static let automaticReloadInterval: TimeInterval = 5 * 60 + private var selectedModelDays: Int { + self.controller.selectedDays } var body: some View { @@ -69,23 +67,21 @@ struct SpendDashboardPane: View { } .background(FocusResigningBackground()) .onAppear { - self.controller.refreshDateWindow() + self.controller.refreshDateWindow(reloadIfOlderThan: Self.automaticReloadInterval) self.controller.update(configuration: self.configuration) } .onChange(of: self.configuration) { _, configuration in self.controller.update(configuration: configuration) } - .onDisappear { - self.controller.stop() - } .onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in - self.controller.refreshDateWindow() + self.controller.refreshDateWindow(reloadIfOlderThan: nil) } .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in - self.controller.refreshDateWindow() + self.controller.refreshDateWindow(reloadIfOlderThan: nil) } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - self.controller.refreshDateWindow() + self.controller.refreshDateWindow(reloadIfOlderThan: Self.automaticReloadInterval) + self.controller.update(configuration: self.configuration) } } @@ -106,10 +102,11 @@ struct SpendDashboardPane: View { Picker(L("Time range"), selection: self.daysBinding) { Text(spendDashboardDayRangeText(7)).tag(7) Text(spendDashboardDayRangeText(30)).tag(30) + Text(spendDashboardDayRangeText(365)).tag(365) } .labelsHidden() .pickerStyle(.segmented) - .frame(width: 116) + .fixedSize() Button { self.controller.refresh() @@ -153,8 +150,22 @@ struct SpendDashboardPane: View { .frame(maxWidth: .infinity, minHeight: 220) } } else { + let modelHostGroupID = self.controller.model.groups.first?.id + let subscriptionNames = self.dashboardSubscriptionNames ForEach(self.controller.model.groups) { group in - SpendCurrencySection(group: group, requestedDays: self.controller.model.requestedDays) + SpendCurrencySection( + group: group, + requestedDays: self.controller.model.requestedDays, + subscriptionNames: subscriptionNames, + modelAnalysis: group.id == modelHostGroupID + ? self.controller.model.modelAnalysis(for: self.selectedModelDays) + : nil, + modelChartDomain: group.id == modelHostGroupID + ? self.controller.model.modelChartDomain(for: self.selectedModelDays) + : nil, + activityAnalysis: group.id == modelHostGroupID + ? self.controller.model.modelAnalysis(for: 365) + : nil) } } @@ -165,12 +176,24 @@ struct SpendDashboardPane: View { } if self.controller.failedSourceCount > 0 { - Label( - spendDashboardRefreshFailureText(self.controller.failedSourceCount), - systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.secondary) + SpendRefreshFailureNotice( + sourceNames: self.failedSourceNames, + refresh: { self.controller.refresh() }) + } + } + + private var failedSourceNames: [String] { + self.controller.failedSourceIDs.map { sourceID in + if let codexName = self.configuration.codexAccountDisplayNames[sourceID] { + return codexName + } + let providerID = sourceID.split(separator: ":", maxSplits: 1).first.map(String.init) ?? sourceID + if let provider = UsageProvider(rawValue: providerID) { + return self.store.metadata(for: provider).displayName + } + return sourceID } + .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } private var provenance: some View { @@ -178,7 +201,7 @@ struct SpendDashboardPane: View { Image(systemName: "lock.shield.fill") .foregroundStyle(.secondary) Text(L("Native currencies stay separate; Codex account rows exclude Pi session history.")) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) Spacer() Toggle(L("Track costs"), isOn: self.$settings.costUsageEnabled) @@ -203,16 +226,54 @@ struct SpendDashboardPane: View { private var sharePayload: ShareStatsPayload? { ShareStatsBuilder.make( model: self.controller.model, - subscriptionNames: self.subscriptionNames) + subscriptionNames: self.shareSubscriptionNames) } - private var subscriptionNames: [String: ShareStatsSubscriptionName] { + private var dashboardSubscriptionNames: [String: String] { + var names: [String: String] = [:] + let codexRowCount = self.controller.model.groups + .flatMap(\.providers) + .count { $0.provider == .codex } + for group in self.controller.model.groups { + for row in group.providers { + if let subscriptionName = row.subscriptionName { + names[row.id] = subscriptionName + continue + } + guard !row.id.hasPrefix("billing:") else { continue } + let snapshots: [UsageSnapshot?] = if row.provider == .codex, + row.id.hasPrefix("codex:") + { + [ + self.store.codexAccountSnapshots.first { + row.id == "codex:\($0.id)" + }?.snapshot, + codexRowCount == 1 ? self.store.snapshot(for: .codex) : nil, + ] + } else { + [self.store.snapshot(for: row.provider)] + } + if let name = snapshots.lazy.compactMap({ + SpendSubscriptionPlan.from(snapshot: $0, provider: row.provider) + }).first { + names[row.id] = name.displayName + } + } + } + return names + } + + private var shareSubscriptionNames: [String: ShareStatsSubscriptionName] { var names: [String: ShareStatsSubscriptionName] = [:] let codexRowCount = self.controller.model.groups .flatMap(\.providers) .count { $0.provider == .codex } for group in self.controller.model.groups { for row in group.providers { + // Synthetic billing rows are reconstructed from routed Codex/Claude/Cursor history, + // so the independently configured same-vendor account may belong to a different + // user. Never attach that account's plan to a routed-only row. + guard !row.id.hasPrefix("billing:") else { continue } let snapshots: [UsageSnapshot?] = if row.provider == .codex, row.id.hasPrefix("codex:") { @@ -243,16 +304,20 @@ struct SpendDashboardPane: View { private struct SpendCurrencySection: View { let group: SpendDashboardModel.CurrencyGroup let requestedDays: Int + let subscriptionNames: [String: String] + let modelAnalysis: SpendDashboardModel.ModelAnalysis? + let modelChartDomain: ClosedRange? + let activityAnalysis: SpendDashboardModel.ModelAnalysis? var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .firstTextBaseline) { - Text(self.group.currencyCode) - .font(.headline) + Text(self.group.currencyCode == "XXX" ? L("Unpriced usage") : self.group.currencyCode) + .font(SpendModelsListStyle.sectionTitleFont) Spacer() Text(self.group.totalCost.map { UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? L("Spend unavailable")) + } ?? L("Pricing unavailable")) .font(.title3.weight(.semibold)) .monospacedDigit() } @@ -262,13 +327,15 @@ private struct SpendCurrencySection: View { spendDashboardCoverageText( covered: self.group.coveredDayCount, requested: self.requestedDays)) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) SpendDashboardPanel { HStack(spacing: 24) { SpendSummaryValue( - title: L("Estimated spend"), + title: self.group.costCoverage == .partial + ? L("Known estimated spend") + : L("Estimated spend"), value: self.group.totalCost.map { UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) } ?? "—") @@ -282,9 +349,20 @@ private struct SpendCurrencySection: View { } } - SpendProviderPanel(group: self.group) - SpendModelPanel(group: self.group) + SpendProviderPanel(group: self.group, subscriptionNames: self.subscriptionNames) + if let modelAnalysis { + SpendModelsSection( + analysis: modelAnalysis, + chartDomain: self.modelChartDomain, + selectedDays: self.requestedDays, + currencyCode: self.group.currencyCode) + } SpendDailyChart(group: self.group) + if let activityAnalysis { + SpendDashboardPanel { + SpendActivityHeatmapView(analysis: activityAnalysis) + } + } } } } @@ -296,7 +374,7 @@ private struct SpendSummaryValue: View { var body: some View { VStack(alignment: .leading, spacing: 5) { Text(self.title) - .font(.caption) + .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) Text(self.value) .font(.system(.title2, design: .rounded, weight: .semibold)) @@ -307,89 +385,57 @@ private struct SpendSummaryValue: View { private struct SpendProviderPanel: View { let group: SpendDashboardModel.CurrencyGroup + let subscriptionNames: [String: String] var body: some View { SpendDashboardPanel { VStack(alignment: .leading, spacing: 0) { - Text(L("By subscription")).font(.headline).padding(.bottom, 8) + Text(L("By subscription")) + .font(SpendModelsListStyle.sectionTitleFont) + .padding(.bottom, 8) ForEach(self.group.providers) { row in if row.rank > 1 { Divider() } HStack(spacing: 10) { Text(spendDashboardRankText(row.rank)) - .font(.caption.monospacedDigit()) + .font(SpendModelsListStyle.tertiaryFont.monospacedDigit()) .foregroundStyle(.tertiary) .frame(width: 26, alignment: .leading) - SpendProviderIcon(provider: row.provider) - Text(row.displayName).lineLimit(1) - Spacer() + SpendProviderIcon(provider: row.provider, size: SpendModelsListStyle.iconSize) + .frame(width: SpendModelsListStyle.iconFrameSize) + Text(row.displayName) + .font(SpendModelsListStyle.controlFont) + .lineLimit(1) + .layoutPriority(1) + if let subscriptionName = row.subscriptionName + ?? self.subscriptionNames[row.id] + { + Text("· \(subscriptionName)") + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 12) Text(row.totalCost.map { UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? L("Spend unavailable")) + } ?? L("Pricing unavailable")) + .font(SpendModelsListStyle.controlFont) .foregroundStyle(row.totalCost == nil ? .secondary : .primary) .monospacedDigit() } - .padding(.vertical, 9) + .padding(.vertical, 5) } - } - } - } -} - -private struct SpendModelPanel: View { - let group: SpendDashboardModel.CurrencyGroup - - var body: some View { - SpendDashboardPanel { - VStack(alignment: .leading, spacing: 0) { - Text(L("Models")).font(.headline).padding(.bottom, 8) - let presentation = spendDashboardModelHistoryPresentation(self.group) - switch presentation { - case .unavailable: - Text(L("Model breakdown unavailable")) - .foregroundStyle(.secondary) - .padding(.vertical, 10) - case .empty: - Text(L("No model-level history")) + if self.group.costCoverage == .partial { + Text( + L( + "%d of %d subscriptions have pricing", + self.group.pricedProviderCount, + self.group.providers.count)) + .font(SpendModelsListStyle.tertiaryFont) .foregroundStyle(.secondary) - .padding(.vertical, 10) - case .partial, .complete: - if presentation == .partial { - Label(L("Model breakdown unavailable"), systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.bottom, 6) - } - ForEach(self.group.models.prefix(8)) { row in - if row.rank > 1 { - Divider() - } - HStack(spacing: 10) { - if presentation == .complete { - Text(spendDashboardRankText(row.rank)) - .font(.caption.monospacedDigit()) - .foregroundStyle(.tertiary) - .frame(width: 26, alignment: .leading) - } else { - Image(systemName: "circle.dashed") - .font(.caption) - .foregroundStyle(.tertiary) - .frame(width: 26, alignment: .leading) - } - SpendProviderIcon(provider: row.provider) - VStack(alignment: .leading, spacing: 2) { - Text(row.modelName).lineLimit(1) - Text(row.providerName).font(.caption).foregroundStyle(.secondary) - } - Spacer() - Text(row.totalCost.map { - UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? "—") - .monospacedDigit() - } - .padding(.vertical, 9) - } + .padding(.top, 7) } } } @@ -410,10 +456,12 @@ struct SpendDailyChartPresentation: Equatable { let content: Content let series: [Series] let dayCount: Int + let aggregateTotal: Double? init(dailyPoints: [SpendDashboardModel.DailyPoint], aggregateTotal: Double?) { - self.content = dailyPoints.isEmpty && aggregateTotal == nil ? .unavailable : .chart + self.content = dailyPoints.isEmpty ? .unavailable : .chart self.dayCount = Set(dailyPoints.map(\.day)).count + self.aggregateTotal = aggregateTotal var seenNames: Set = [] self.series = dailyPoints.compactMap { point in @@ -429,6 +477,10 @@ struct SpendDailyChartPresentation: Equatable { private struct SpendDailyChart: View { let group: SpendDashboardModel.CurrencyGroup + @State private var selectedDay: Date? + @State private var pinnedDay: Date? + @State private var cachedDays: [Date] = [] + @State private var cachedDetails: [Date: SpendDashboardModel.DailySpendDetail] = [:] var body: some View { let presentation = SpendDailyChartPresentation( @@ -436,46 +488,174 @@ private struct SpendDailyChart: View { aggregateTotal: self.group.totalCost) SpendDashboardPanel { VStack(alignment: .leading, spacing: 12) { - Text(L("Daily estimated spend")).font(.headline) + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 3) { + Text(L("Daily estimated spend")) + .font(SpendModelsListStyle.sectionTitleFont) + if presentation.content == .chart || presentation.aggregateTotal != nil { + Text( + "\(L("Active")) \(codexBarLocalizedInteger(presentation.dayCount)) · " + + "\(L("Total")) \(self.totalCostText(presentation.aggregateTotal))") + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + } + } + Spacer() + } if presentation.content == .unavailable { ContentUnavailableView(L("Spend unavailable"), systemImage: "chart.bar.xaxis") - .frame(maxWidth: .infinity, minHeight: 170) + .frame(maxWidth: .infinity, minHeight: SpendModelsListStyle.compactChartHeight) } else { - Chart(self.group.dailyPoints) { point in - BarMark( - x: .value(L("Day"), point.day, unit: .day), - yStart: .value(L("Estimated spend"), point.stackStart), - yEnd: .value(L("Estimated spend"), point.stackEnd), - width: .ratio(0.72)) - .foregroundStyle(by: .value(L("Provider"), point.providerName)) - .accessibilityLabel(Text(self.pointAccessibilityLabel(point))) - .accessibilityValue(Text(UsageFormatter.currencyString( - point.cost, - currencyCode: self.group.currencyCode))) + Chart { + if let interactionDay = self.pinnedDay ?? self.selectedDay { + RuleMark(x: .value(L("Day"), interactionDay, unit: .day)) + .foregroundStyle(Color.accentColor.opacity(0.09)) + .lineStyle(StrokeStyle(lineWidth: 18)) + } + ForEach(self.group.dailyPoints) { point in + BarMark( + x: .value(L("Day"), point.day, unit: .day), + yStart: .value(L("Estimated spend"), point.stackStart), + yEnd: .value(L("Estimated spend"), point.stackEnd), + width: .ratio(0.58)) + .foregroundStyle(by: .value(L("Provider"), point.providerName)) + .accessibilityLabel(Text(self.pointAccessibilityLabel(point))) + .accessibilityValue(Text(UsageFormatter.currencyString( + point.cost, + currencyCode: self.group.currencyCode))) + } + if let pinnedDay { + RuleMark(x: .value(L("Day"), pinnedDay, unit: .day)) + .foregroundStyle(Color.accentColor.opacity(0.72)) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + if let selectedDay { + RuleMark(x: .value(L("Day"), selectedDay, unit: .day)) + .foregroundStyle(.clear) + .annotation(position: .top, overflowResolution: .init( + x: .fit(to: .chart), + y: .fit(to: .chart))) + { + self.dayTooltip(selectedDay) + } + } } - .chartXScale(domain: self.group.chartDomain) + .chartXScale(domain: self.activeChartDomain) .chartForegroundStyleScale( domain: presentation.series.map(\.name), range: presentation.series.map { self.providerColor($0.provider) }) - .chartLegend(position: .bottom, alignment: .leading, spacing: 8) + .chartLegend(.hidden) + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 6)) { value in + AxisGridLine() + .foregroundStyle(Color.secondary.opacity(0.08)) + AxisTick() + .foregroundStyle(Color.secondary.opacity(0.35)) + AxisValueLabel { + if let date = value.as(Date.self) { + Text(date.formatted(self.axisDateFormat)) + .font(SpendModelsListStyle.secondaryFont) + } + } + } + } .chartYAxis { AxisMarks(position: .leading) { value in AxisGridLine() + .foregroundStyle(Color.secondary.opacity(0.16)) AxisValueLabel { if let amount = value.as(Double.self) { Text(UsageFormatter.compactCurrencyString( amount, currencyCode: self.group.currencyCode)) + .font(SpendModelsListStyle.secondaryFont) } } } } - .frame(height: 170) + .chartPlotStyle { plotArea in + plotArea + .background(Color.primary.opacity(0.018)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .frame(height: SpendModelsListStyle.compactChartHeight) .accessibilityLabel(L("Daily estimated spend")) .accessibilityValue(presentation.accessibilityValue) + .chartOverlay { proxy in + GeometryReader { geo in + SpendModelsChartMouseReader( + onMoved: { location in + self.updateSelectedDay(location: location, proxy: proxy, geo: geo) + }, + onClicked: { location in + self.handleChartClick(location: location, proxy: proxy, geo: geo) + }, + onDragged: { location in + self.handleChartDrag(location: location, proxy: proxy, geo: geo) + }, + onEscape: { + self.selectedDay = nil + self.pinnedDay = nil + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 156), spacing: 12, alignment: .leading)], + alignment: .leading, + spacing: 6) + { + ForEach(presentation.series, id: \.name) { series in + HStack(spacing: 6) { + SpendProviderIcon(provider: series.provider, size: 14) + .frame(width: 18, height: 18) + Text(series.name) + .font(SpendModelsListStyle.controlFont) + .lineLimit(1) + if let kind = self.toolKind(for: series.name) { + Text(kind.displayName) + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + if let detail = self.pinnedDetail { + self.dayDetail(detail) + } } } } + .onAppear { self.rebuildHoverCache() } + .onChange(of: self.group.dailySpendDetails) { _, _ in self.rebuildHoverCache() } + } + + private func totalCostText(_ aggregateTotal: Double?) -> String { + aggregateTotal.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? "—" + } + + private var activeChartDomain: ClosedRange { + guard let firstDay = self.group.dailyPoints.map(\.day).min(), + let lastDay = self.group.dailyPoints.map(\.day).max() + else { return self.group.chartDomain } + let calendar = Calendar.current + let paddedStart = calendar.date(byAdding: .day, value: -1, to: firstDay) ?? firstDay + let paddedEnd = calendar.date(byAdding: .day, value: 2, to: lastDay) ?? lastDay + let start = max(self.group.chartDomain.lowerBound, paddedStart) + let end = min(self.group.chartDomain.upperBound, max(paddedEnd, start)) + return start...end + } + + private var axisDateFormat: Date.FormatStyle { + let interval = self.activeChartDomain.upperBound.timeIntervalSince(self.activeChartDomain.lowerBound) + if interval <= 90 * 24 * 60 * 60 { + return .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()) + } + return .dateTime.year().month(.abbreviated).locale(codexBarLocalizedLocale()) } private func pointAccessibilityLabel(_ point: SpendDashboardModel.DailyPoint) -> String { @@ -488,10 +668,232 @@ private struct SpendDailyChart: View { let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color return Color(red: color.red, green: color.green, blue: color.blue) } + + private func rebuildHoverCache() { + self.cachedDays = self.group.dailySpendDetails.map(\.day).sorted() + self.cachedDetails = Dictionary(uniqueKeysWithValues: self.group.dailySpendDetails.map { ($0.day, $0) }) + } + + private func toolKind(for name: String) -> SpendToolIdentity.Kind? { + self.group.dailyPoints.first { $0.providerName == name }?.toolKind + } + + private func updateSelectedDay(location: CGPoint?, proxy: ChartProxy, geo: GeometryProxy) { + guard let location, let plotAnchor = proxy.plotFrame else { + self.selectedDay = nil + return + } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location), + let date: Date = proxy.value(atX: location.x - plotFrame.origin.x) + else { + self.selectedDay = nil + return + } + self.selectedDay = self.nearestDay(to: date) + } + + private func handleChartClick(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { + guard let day = self.chartDay(at: location, proxy: proxy, geo: geo) else { return } + self.pinnedDay = self.pinnedDay == day ? nil : day + } + + private func handleChartDrag(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { + guard let day = self.chartDay(at: location, proxy: proxy, geo: geo) else { return } + self.pinnedDay = day + } + + private func chartDay(at location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) -> Date? { + guard let plotAnchor = proxy.plotFrame else { return nil } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location) else { return nil } + return SpendChartDayHitTarget.nearestDay( + toX: location.x - plotFrame.origin.x, + days: self.cachedDays, + position: { proxy.position(forX: $0) }) + } + + private var pinnedDetail: SpendDashboardModel.DailySpendDetail? { + guard let pinnedDay else { return nil } + return self.cachedDetails[Calendar.current.startOfDay(for: pinnedDay)] + ?? self.cachedDetails[pinnedDay] + } + + private func nearestDay(to date: Date) -> Date? { + let days = self.cachedDays + guard !days.isEmpty else { return nil } + var lower = 0 + var upper = days.count + while lower < upper { + let middle = (lower + upper) / 2 + if days[middle] < date { + lower = middle + 1 + } else { + upper = middle + } + } + let nearest: Date + if lower == 0 { + nearest = days[0] + } else if lower == days.count { + nearest = days[days.count - 1] + } else { + let before = days[lower - 1] + let after = days[lower] + nearest = abs(before.timeIntervalSince(date)) <= abs(after.timeIntervalSince(date)) + ? before + : after + } + guard abs(nearest.timeIntervalSince(date)) <= 43200 else { return nil } + return nearest + } + + private func dayTooltip(_ day: Date) -> some View { + let detail = self.cachedDetails[Calendar.current.startOfDay(for: day)] + ?? self.cachedDetails[day] + return VStack(alignment: .leading, spacing: 6) { + if let detail { + Text(day.formatted( + .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()))) + .font(SpendModelsListStyle.tooltipTitleFont) + HStack(spacing: 14) { + self.tooltipSummary( + title: L("Tokens"), + value: detail.totalTokens.map(UsageFormatter.tokenCountString) ?? "—") + self.tooltipSummary( + title: L("Estimated spend"), + value: UsageFormatter.currencyString( + detail.totalCost, + currencyCode: self.group.currencyCode)) + } + } + } + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .shadow(color: .black.opacity(0.09), radius: 10, y: 3) + } + + private func tooltipSummary(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) + Text(value) + .font(SpendModelsListStyle.tooltipRowFont.weight(.medium)) + .monospacedDigit() + } + } + + private func dayDetail(_ detail: SpendDashboardModel.DailySpendDetail) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(detail.day.formatted( + .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()))) + .font(SpendModelsListStyle.primaryEmphasizedFont) + Spacer() + if let tokens = detail.totalTokens { + Text(UsageFormatter.tokenCountString(tokens)) + .font(SpendModelsListStyle.primaryFont) + .foregroundStyle(.secondary) + } + Text(UsageFormatter.currencyString(detail.totalCost, currencyCode: self.group.currencyCode)) + .font(SpendModelsListStyle.primaryEmphasizedFont) + } + .monospacedDigit() + + ForEach(detail.tools) { tool in + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 7) { + SpendProviderIcon(provider: tool.provider, size: SpendModelsListStyle.iconSize) + .frame( + width: SpendModelsListStyle.iconFrameSize, + height: SpendModelsListStyle.iconFrameSize) + Text(tool.displayName) + .font(SpendModelsListStyle.toolTitleFont) + Text(tool.kind.displayName) + .font(SpendModelsListStyle.tertiaryFont.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.12), in: Capsule()) + Spacer() + Text(UsageFormatter.currencyString(tool.cost, currencyCode: self.group.currencyCode)) + .font(SpendModelsListStyle.primaryFont) + .monospacedDigit() + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(tool.models.enumerated()), id: \.element.id) { index, model in + if index > 0 { Divider().padding(.vertical, 2) } + HStack(spacing: 8) { + SpendProviderIcon( + provider: model.modelProvider, + size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + Text(model.displayName) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + Spacer() + Text(model.cost.map { + UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) + } ?? model.tokens.map(UsageFormatter.tokenCountString) ?? "—") + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.vertical, 2) + } + } + .padding(.leading, SpendModelsListStyle.modelIndent) + } + .padding(10) + .background( + Color.secondary.opacity(0.055), + in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + } + } + .padding(11) + .background( + Color.secondary.opacity(0.05), + in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } +} + +private struct SpendRefreshFailureNotice: View { + let sourceNames: [String] + let refresh: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(spendDashboardRefreshFailureText(self.sourceNames.count)) + .font(SpendModelsListStyle.secondaryFont.weight(.semibold)) + if !self.sourceNames.isEmpty { + Text(self.sourceNames.joined(separator: " · ")) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + } + } + Spacer() + Button(L("Refresh"), action: self.refresh) + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(.orange.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(.orange.opacity(0.18)) + } + } } -private struct SpendProviderIcon: View { +struct SpendProviderIcon: View { let provider: UsageProvider + var size: CGFloat = 20 var body: some View { Group { @@ -501,12 +903,13 @@ private struct SpendProviderIcon: View { Image(systemName: "circle.dotted") } } - .frame(width: 20, height: 20) + .foregroundStyle(.primary) + .frame(width: self.size, height: self.size) .accessibilityHidden(true) } } -private struct SpendDashboardPanel: View { +struct SpendDashboardPanel: View { @ViewBuilder let content: Content var body: some View { diff --git a/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift b/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift new file mode 100644 index 0000000000..2b69b09be3 --- /dev/null +++ b/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift @@ -0,0 +1,434 @@ +import CodexBarCore +import SwiftUI + +// MARK: - Token category colors + +extension Color { + /// Token-category colors for the spend-models day detail panel, tokens.ci style. + static let spendModelsInput = Color(red: 0.29, green: 0.56, blue: 0.95) // blue + static let spendModelsOutput = Color(red: 0.20, green: 0.72, blue: 0.51) // green + static let spendModelsCacheRead = Color(red: 0.61, green: 0.47, blue: 0.90) // purple + static let spendModelsCacheWrite = Color(red: 0.93, green: 0.58, blue: 0.25) // orange + static let spendModelsReasoning = Color(red: 0.90, green: 0.42, blue: 0.60) // pink +} + +// MARK: - Day detail presentation + +struct SpendModelsDayDetailPresentation: Equatable { + enum BucketKind: String, CaseIterable { + case input + case output + case cacheRead + case cacheWrite + case reasoning + + var title: String { + switch self { + case .input: L("Input") + case .output: L("Output") + case .cacheRead: L("Cache read") + case .cacheWrite: L("Cache write") + case .reasoning: L("Reasoning") + } + } + + var color: Color { + switch self { + case .input: .spendModelsInput + case .output: .spendModelsOutput + case .cacheRead: .spendModelsCacheRead + case .cacheWrite: .spendModelsCacheWrite + case .reasoning: .spendModelsReasoning + } + } + } + + struct Bucket: Identifiable, Equatable { + let kind: BucketKind + let tokens: Int + + var id: String { + self.kind.rawValue + } + } + + struct Model: Identifiable, Equatable { + let id: String + let name: String + let modelProvider: UsageProvider + let providerNames: [String] + let totalTokens: Int? + let cost: Double? + let costIsEstimated: Bool + let buckets: [Bucket] + } + + let day: Date + let metric: SpendModelMetric + let totalTokens: Int? + let totalCost: Double? + /// Non-zero bucket totals across all models that day; empty when no bucket data exists. + let buckets: [Bucket] + /// Per-model rows sorted by tokens descending. + let models: [Model] + + var pricedModelCount: Int { + self.models.count(where: { $0.cost != nil }) + } + + init?( + analysis: SpendDashboardModel.ModelAnalysis, + day: Date, + metric: SpendModelMetric, + calendar: Calendar = .current) + { + let dailyValues = analysis.dailyValues.filter { calendar.isDate($0.day, inSameDayAs: day) } + guard !dailyValues.isEmpty else { return nil } + + self.day = day + self.metric = metric + self.totalTokens = Self.sum(dailyValues.map(\.totalTokens)) + self.totalCost = Self.sum(dailyValues.map(\.estimatedCost)) + self.buckets = Self.aggregateBuckets(dailyValues.map(Self.buckets(of:))) + + let rowsByID = Dictionary(uniqueKeysWithValues: analysis.rows.map { ($0.id, $0) }) + self.models = dailyValues.map { value in + let row = rowsByID[value.modelID] + return Model( + id: value.modelID, + name: value.modelName, + modelProvider: row?.modelProvider + ?? SpendProviderIdentity.modelProvider(rawName: value.modelName, fallback: .openai), + providerNames: row?.providerNames ?? [], + totalTokens: value.totalTokens, + cost: value.estimatedCost, + costIsEstimated: row?.costIsEstimated ?? false, + buckets: Self.aggregateBuckets([Self.buckets(of: value)])) + } + .sorted { lhs, rhs in + switch (lhs.totalTokens, rhs.totalTokens) { + case let (left?, right?) where left != right: left > right + case (_?, nil): true + case (nil, _?): false + default: lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + } + + private static func buckets( + of value: SpendDashboardModel.ModelDailyValue) -> [(kind: BucketKind, tokens: Int)] + { + [ + (.input, value.inputTokens), + (.output, value.outputTokens), + (.cacheRead, value.cacheReadTokens), + (.cacheWrite, value.cacheCreationTokens), + (.reasoning, value.reasoningTokens), + ].compactMap { kind, tokens in + tokens.map { (kind, $0) } + } + } + + /// Sums buckets across models. A bucket with only nil (unknown) values stays hidden, as do + /// zero totals. + private static func aggregateBuckets(_ bucketLists: [[(kind: BucketKind, tokens: Int)]]) -> [Bucket] { + var sums: [BucketKind: Int] = [:] + for list in bucketLists { + for (kind, tokens) in list { + let addition = sums[kind, default: 0].addingReportingOverflow(tokens) + guard !addition.overflow else { return [] } + sums[kind] = addition.partialValue + } + } + return BucketKind.allCases.compactMap { kind in + guard let total = sums[kind], total > 0 else { return nil } + return Bucket(kind: kind, tokens: total) + } + } + + private static func sum(_ values: [Int?]) -> Int? { + let present = values.compactMap(\.self) + guard !present.isEmpty, present.count == values.count else { return nil } + var total = 0 + for value in present { + let addition = total.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + total = addition.partialValue + } + return total + } + + private static func sum(_ values: [Double?]) -> Double? { + let present = values.compactMap(\.self) + guard !present.isEmpty, present.count == values.count else { return nil } + let total = present.reduce(0, +) + return total.isFinite ? total : nil + } +} + +// MARK: - Day detail text helpers + +func spendModelsDayDetailBucketText(_ bucket: SpendModelsDayDetailPresentation.Bucket) -> String { + let count = UsageFormatter.tokenCountString(bucket.tokens) + switch bucket.kind { + case .input: return L("%@ in", count) + case .output: return L("%@ out", count) + case .cacheRead: return L("%@ cache read", count) + case .cacheWrite: return L("%@ cache write", count) + case .reasoning: return L("%@ reasoning", count) + } +} + +/// Compact per-model token split ("80 in · 20 out · 15 cache read"); falls back to the bare +/// total when no bucket data exists. +func spendModelsDayDetailModelSplitText(_ model: SpendModelsDayDetailPresentation.Model) -> String { + guard !model.buckets.isEmpty else { + return model.totalTokens.map(UsageFormatter.tokenCountString) ?? "—" + } + return model.buckets.map(spendModelsDayDetailBucketText).joined(separator: " · ") +} + +func spendModelsDayDetailModelSummaryText( + _ model: SpendModelsDayDetailPresentation.Model, + metric: SpendModelMetric, + totalTokens: Int?, + totalCost: Double?, + currencyCode: String = "USD") -> String +{ + switch metric { + case .tokens: + guard let tokens = model.totalTokens else { return "—" } + let value = UsageFormatter.tokenCountString(tokens) + guard let totalTokens, totalTokens > 0 else { return value } + let share = UsageFormatter.percentString(Double(tokens) / Double(totalTokens) * 100) + return "\(value) · \(share)" + case .estimatedSpend: + guard let cost = model.cost else { + guard let tokens = model.totalTokens else { return L("Unavailable") } + return "\(UsageFormatter.tokenCountString(tokens)) · \(L("Unavailable"))" + } + let value = UsageFormatter.currencyString(cost, currencyCode: currencyCode) + guard let totalCost, totalCost > 0 else { return value } + let share = UsageFormatter.percentString(cost / totalCost * 100) + return "\(value) · \(share)" + } +} + +// MARK: - Day detail view + +struct SpendModelsDayDetailView: View { + let detail: SpendModelsDayDetailPresentation + let metric: SpendModelMetric + let currencyCode: String + @State private var expandedModelID: String? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(self.dayText) + .font(SpendModelsListStyle.primaryEmphasizedFont) + Spacer() + Text(self.totalText) + .font(SpendModelsListStyle.primaryEmphasizedFont) + .monospacedDigit() + } + if self.metric == .tokens, !self.detail.buckets.isEmpty { + self.categoryBar + self.legend + } else if self.metric == .estimatedSpend { + self.pricingCoverageBar + self.pricingCoverageLegend + } + VStack(alignment: .leading, spacing: 4) { + ForEach(self.detail.models) { model in + self.modelRow(model) + } + } + } + .padding(12) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .accessibilityElement(children: .contain) + .accessibilityLabel(Text(L("Usage details for %@", self.dayText))) + .onChange(of: self.detail.day) { _, _ in + self.expandedModelID = nil + } + .onChange(of: self.metric) { _, _ in + self.expandedModelID = nil + } + } + + // MARK: Category bar + + /// Thin rounded segmented bar. Reasoning is a subset of output, so the output segment is + /// shrunk by the reasoning share to keep the bar partitioning the day total. + private var categoryBar: some View { + GeometryReader { geo in + let total = max(1, self.barTokenTotal) + HStack(spacing: 1) { + ForEach(self.detail.buckets) { bucket in + Rectangle() + .fill(bucket.kind.color) + .frame(width: max(2, geo.size.width * CGFloat(self.barTokens(for: bucket)) / CGFloat(total))) + } + } + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .frame(height: 12) + .accessibilityHidden(true) + } + + private var legend: some View { + HStack(spacing: 12) { + ForEach(self.detail.buckets) { bucket in + HStack(spacing: 5) { + Circle() + .fill(bucket.kind.color) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + Text("\(bucket.kind.title) \(UsageFormatter.tokenCountString(bucket.tokens))") + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + } + } + } + } + + private var barTokenTotal: Int { + self.detail.buckets.reduce(0) { $0 + self.barTokens(for: $1) } + } + + private func barTokens(for bucket: SpendModelsDayDetailPresentation.Bucket) -> Int { + guard bucket.kind == .output, + let reasoning = self.detail.buckets.first(where: { $0.kind == .reasoning })?.tokens + else { + return bucket.tokens + } + return max(0, bucket.tokens - reasoning) + } + + // MARK: Pricing coverage + + private var pricingCoverageBar: some View { + GeometryReader { geo in + let total = max(1, self.detail.models.count) + let hasPriced = self.detail.pricedModelCount > 0 + let hasUnpriced = self.detail.pricedModelCount < total + let spacing: CGFloat = hasPriced && hasUnpriced ? 1 : 0 + let pricedWidth = (geo.size.width - spacing) + * CGFloat(self.detail.pricedModelCount) + / CGFloat(total) + HStack(spacing: 1) { + if hasPriced { + Rectangle() + .fill(Color.accentColor) + .frame(width: max(2, pricedWidth)) + } + if hasUnpriced { + Rectangle() + .fill(Color.secondary.opacity(0.16)) + } + } + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + .frame(height: 12) + .accessibilityHidden(true) + } + + private var pricingCoverageLegend: some View { + HStack(spacing: 5) { + Circle() + .fill(Color.accentColor) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + Text("\(L("Priced model spend")) · \(self.detail.pricedModelCount)/\(self.detail.models.count)") + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + } + } + + // MARK: Model rows + + private func modelRow(_ model: SpendModelsDayDetailPresentation.Model) -> some View { + VStack(alignment: .leading, spacing: 3) { + Button { + guard !model.buckets.isEmpty else { return } + withAnimation(.easeInOut(duration: 0.16)) { + self.expandedModelID = self.expandedModelID == model.id ? nil : model.id + } + } label: { + HStack(spacing: 9) { + SpendProviderIcon(provider: model.modelProvider, size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + .accessibilityHidden(true) + Text(model.name) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + if !model.providerNames.isEmpty { + Text(model.providerNames.joined(separator: " · ")) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + Spacer(minLength: 10) + Text(spendModelsDayDetailModelSummaryText( + model, + metric: self.metric, + totalTokens: self.detail.totalTokens, + totalCost: self.detail.totalCost)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + if !model.buckets.isEmpty { + Image(systemName: self.expandedModelID == model.id ? "chevron.up" : "chevron.down") + .font(SpendModelsListStyle.tertiaryFont.weight(.semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(model.name) + .accessibilityValue(spendModelsDayDetailModelSummaryText( + model, + metric: self.metric, + totalTokens: self.detail.totalTokens, + totalCost: self.detail.totalCost)) + .accessibilityHint(model.buckets.isEmpty + ? "" + : (self.expandedModelID == model.id ? L("Collapse") : L("Expand"))) + + if self.expandedModelID == model.id, !model.buckets.isEmpty { + Text(spendModelsDayDetailModelSplitText(model)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + .padding(.leading, SpendModelsListStyle.modelIconFrameSize + 9) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + + // MARK: Header + + private var dayText: String { + SpendModelsDateFormatter.dayText(self.detail.day) + } + + private var totalText: String { + switch self.metric { + case .tokens: self.detail.totalTokens.map(UsageFormatter.tokenCountString) ?? "—" + case .estimatedSpend: self.detail.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.currencyCode) + } ?? "—" + } + } +} diff --git a/Sources/CodexBar/PreferencesSpendModelsView.swift b/Sources/CodexBar/PreferencesSpendModelsView.swift new file mode 100644 index 0000000000..2addd2ae36 --- /dev/null +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -0,0 +1,1305 @@ +import AppKit +import Charts +import CodexBarCore +import SwiftUI + +enum SpendModelMetric: String, CaseIterable, Identifiable { + case tokens + case estimatedSpend + + var id: Self { + self + } + + var title: String { + switch self { + case .tokens: L("Tokens") + case .estimatedSpend: L("Estimated spend") + } + } +} + +enum SpendModelsViewMode: String, CaseIterable, Identifiable { + case models + case clients + + var id: Self { + self + } + + var title: String { + switch self { + case .models: L("By model") + case .clients: L("By tool") + } + } +} + +enum SpendModelsListStyle { + static let iconSize: CGFloat = 18 + static let iconFrameSize: CGFloat = 22 + static let modelIconSize: CGFloat = 16 + static let modelIconFrameSize: CGFloat = 20 + static let modelIndent: CGFloat = 48 + static let sectionTitleFont = Font.headline + static let toolTitleFont = Font.headline + static let primaryFont = Font.body + static let primaryEmphasizedFont = Font.body.weight(.semibold) + static let valueFont = Font.body.weight(.medium) + static let secondaryFont = Font.callout + static let tertiaryFont = Font.caption + static let controlFont = Font.subheadline.weight(.medium) + static let tooltipTitleFont = Font.subheadline.weight(.semibold) + static let tooltipRowFont = Font.callout + static let compactChartHeight: CGFloat = 144 +} + +enum SpendModelsDateFormatter { + static func dayText(_ day: Date) -> String { + day.formatted(.dateTime.month(.abbreviated).day().locale(self.locale)) + } + + private static var locale: Locale { + .autoupdatingCurrent + } +} + +private func spendModelsTokenValueText(_ value: Double) -> String { + guard value.isFinite, value >= 0 else { return "—" } + if value >= Double(Int.max) { + return UsageFormatter.tokenCountString(Int.max) + } + return UsageFormatter.tokenCountString(Int(value.rounded())) +} + +func spendModelsRowDetailText(_ row: SpendModelsPresentation.Row) -> String { + guard let value = row.value else { return "—" } + let providers = row.source.providerNames.joined(separator: " · ") + let metric: String = if row.source.inputTokens != nil, + row.source.outputTokens != nil, + let inputTokens = row.source.inputTokens, + let outputTokens = row.source.outputTokens + { + L( + "%@ in · %@ out", + UsageFormatter.tokenCountString(inputTokens), + UsageFormatter.tokenCountString(outputTokens)) + } else { + spendModelsTokenValueText(value) + } + return providers.isEmpty ? metric : "\(metric) · \(providers)" +} + +func spendModelsRankingValueText( + _ row: SpendModelsPresentation.Row, + metric: SpendModelMetric, + currencyCode: String = "USD") -> String +{ + switch metric { + case .tokens: + guard let tokens = row.source.totalTokens else { return "—" } + return UsageFormatter.tokenCountString(tokens) + case .estimatedSpend: + guard let cost = row.source.estimatedCost else { return "—" } + return UsageFormatter.currencyString(cost, currencyCode: currencyCode) + } +} + +func spendModelsRankingContextText( + _ row: SpendModelsPresentation.Row, + metric: SpendModelMetric, + currencyCode: String = "USD") -> String +{ + switch metric { + case .tokens: + guard let cost = row.source.estimatedCost else { return "" } + return UsageFormatter.currencyString(cost, currencyCode: currencyCode) + case .estimatedSpend: + guard let tokens = row.source.totalTokens else { return "" } + return L("%@ tokens", UsageFormatter.tokenCountString(tokens)) + } +} + +func spendModelsChartMetricText( + _ value: Double, + metric: SpendModelMetric, + currencyCode: String = "USD") -> String +{ + switch metric { + case .tokens: + spendModelsTokenValueText(value) + case .estimatedSpend: + UsageFormatter.currencyString(value, currencyCode: currencyCode) + } +} + +enum SpendModelsRanking { + static let collapsedRowLimit = 5 + + static func showsDisclosure(rowCount: Int) -> Bool { + rowCount > self.collapsedRowLimit + } + + static func visibleRows( + _ rows: [SpendModelsPresentation.Row], + showsAll: Bool) -> [SpendModelsPresentation.Row] + { + guard !showsAll, self.showsDisclosure(rowCount: rows.count) else { return rows } + return Array(rows.prefix(self.collapsedRowLimit)) + } +} + +struct SpendModelsPresentation: Equatable { + struct Row: Identifiable, Equatable { + let source: SpendDashboardModel.ModelAnalysisRow + let rank: Int + let value: Double? + let share: Double? + + var id: String { + self.source.id + } + } + + struct Series: Identifiable, Equatable { + let id: String + let name: String + let value: Double + } + + struct Point: Identifiable, Equatable { + let day: Date + let seriesID: String + let seriesName: String + let value: Double + let stackStart: Double + let stackEnd: Double + + var id: String { + "\(self.seriesID):\(Int(self.day.timeIntervalSince1970))" + } + } + + let metric: SpendModelMetric + let rows: [Row] + let series: [Series] + let points: [Point] + let coverage: SpendDashboardModel.ModelMetricCoverage + let metricTotal: Double? + + var dailyTotals: [Point] { + let pointsByDay = Dictionary(grouping: self.points, by: \.day) + return pointsByDay.keys.sorted().compactMap { day in + let total = pointsByDay[day, default: []].reduce(0.0) { $0 + $1.value } + guard total > 0 else { return nil } + return Point( + day: day, + seriesID: "daily-total", + seriesName: self.metric.title, + value: total, + stackStart: 0, + stackEnd: total) + } + } + + init( + analysis: SpendDashboardModel.ModelAnalysis, + metric: SpendModelMetric) + { + self.metric = metric + self.coverage = switch metric { + case .tokens: analysis.tokenCoverage + case .estimatedSpend: analysis.costCoverage + } + + let sortedSources = analysis.rows.sorted { lhs, rhs in + Self.compare(lhs, rhs, metric: metric) + } + let metricValues = sortedSources.compactMap { Self.value($0, metric: metric) } + let metricTotal = Self.sum(metricValues) + self.metricTotal = metricTotal + self.rows = sortedSources.enumerated().map { offset, source in + let value = Self.value(source, metric: metric) + return Row( + source: source, + rank: offset + 1, + value: value, + share: value.flatMap { value in + guard let total = metricTotal, total > 0 else { return nil } + return value / total + }) + } + + let builtSeries = self.rows.compactMap { row -> Series? in + guard let value = row.value else { return nil } + guard value > 0 else { return nil } + return Series(id: row.id, name: row.source.displayName, value: value) + } + self.series = builtSeries + + let valuesByDay = Dictionary(grouping: analysis.dailyValues, by: \.day) + self.points = valuesByDay.keys.sorted().flatMap { day in + let dailyValues = valuesByDay[day] ?? [] + var seriesValues: [String: Double] = [:] + for dailyValue in dailyValues { + guard let value = Self.value(dailyValue, metric: metric), value > 0 else { continue } + seriesValues[dailyValue.modelID, default: 0] += value + } + var cursor = 0.0 + return builtSeries.compactMap { series -> Point? in + guard let value = seriesValues[series.id], value > 0 else { return nil } + let start = cursor + cursor += value + return Point( + day: day, + seriesID: series.id, + seriesName: series.name, + value: value, + stackStart: start, + stackEnd: cursor) + } + } + } + + private init( + metric: SpendModelMetric, + rows: [Row], + series: [Series], + points: [Point], + coverage: SpendDashboardModel.ModelMetricCoverage, + metricTotal: Double?) + { + self.metric = metric + self.rows = rows + self.series = series + self.points = points + self.coverage = coverage + self.metricTotal = metricTotal + } + + // MARK: Trailing average + + static let trailingAverageWindow = 7 + + /// Returns a copy whose stacked points are smoothed with a per-series trailing moving average + /// over the visible day window (tokens.ci style). Days at the window edge average over fewer + /// samples. Rows and series stay raw, so ranking and day details are unaffected; this is + /// meant for the chart only. + func applyingTrailingAverage( + window: Int = Self.trailingAverageWindow, + calendar: Calendar = .current) -> SpendModelsPresentation + { + guard window > 1, !self.points.isEmpty else { return self } + + let observedDays = Array(Set(self.points.map(\.day))).sorted() + guard let firstDay = observedDays.first, let lastDay = observedDays.last else { return self } + var normalizedCalendar = Calendar(identifier: .gregorian) + normalizedCalendar.timeZone = calendar.timeZone + normalizedCalendar.locale = calendar.locale + let calendar = normalizedCalendar + var days: [Date] = [] + var cursor = calendar.startOfDay(for: firstDay) + let end = calendar.startOfDay(for: lastDay) + while cursor <= end { + days.append(cursor) + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor), + next > cursor + else { + break + } + cursor = next + } + let displayDayByNormalizedDay = Dictionary( + self.points.map { (calendar.startOfDay(for: $0.day), $0.day) }, + uniquingKeysWith: { first, _ in first }) + var rawValues: [String: [Date: Double]] = [:] + for point in self.points { + let normalizedDay = calendar.startOfDay(for: point.day) + rawValues[point.seriesID, default: [:]][normalizedDay, default: 0] += point.value + } + + var smoothed: [Point] = [] + for (index, day) in days.enumerated() { + let firstSample = max(0, index - window + 1) + let samples = days[firstSample...index] + var cursor = 0.0 + for series in self.series { + let total = samples.reduce(0.0) { $0 + (rawValues[series.id]?[$1] ?? 0) } + let value = total / Double(samples.count) + guard value > 0 else { continue } + let start = cursor + cursor += value + smoothed.append(Point( + day: displayDayByNormalizedDay[day] ?? day, + seriesID: series.id, + seriesName: series.name, + value: value, + stackStart: start, + stackEnd: cursor)) + } + } + + return SpendModelsPresentation( + metric: self.metric, + rows: self.rows, + series: self.series, + points: smoothed, + coverage: self.coverage, + metricTotal: self.metricTotal) + } + + // MARK: Selection + + /// Returns the charted day matching `day`, or nil when it falls outside the visible range. + func day(matching day: Date, calendar: Calendar = .current) -> Date? { + self.points.map(\.day).first { calendar.isDate($0, inSameDayAs: day) } + } + + private static func compare( + _ lhs: SpendDashboardModel.ModelAnalysisRow, + _ rhs: SpendDashboardModel.ModelAnalysisRow, + metric: SpendModelMetric) -> Bool + { + switch (self.value(lhs, metric: metric), self.value(rhs, metric: metric)) { + case let (left?, right?) where left != right: return left > right + case (_?, nil): return true + case (nil, _?): return false + default: + let otherMetric: SpendModelMetric = metric == .tokens ? .estimatedSpend : .tokens + switch (self.value(lhs, metric: otherMetric), self.value(rhs, metric: otherMetric)) { + case let (left?, right?) where left != right: return left > right + case (_?, nil): return true + case (nil, _?): return false + default: + let comparison = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + if comparison != .orderedSame { return comparison == .orderedAscending } + return lhs.id < rhs.id + } + } + } + + private static func value( + _ row: SpendDashboardModel.ModelAnalysisRow, + metric: SpendModelMetric) -> Double? + { + switch metric { + case .tokens: row.totalTokens.map(Double.init) + case .estimatedSpend: row.estimatedCost + } + } + + private static func value( + _ value: SpendDashboardModel.ModelDailyValue, + metric: SpendModelMetric) -> Double? + { + switch metric { + case .tokens: value.totalTokens.map(Double.init) + case .estimatedSpend: value.estimatedCost + } + } + + private static func sum(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + let result = values.reduce(0, +) + return result.isFinite ? result : nil + } +} + +struct SpendModelsAxisDates { + static func make( + selectedDays: Int, + dataDays: [Date], + domain: ClosedRange, + calendar: Calendar = .current) -> [Date] + { + let normalizedDataDays = Array(Set(dataDays.map { calendar.startOfDay(for: $0) })).sorted() + if selectedDays == 7 { + return normalizedDataDays + } + + let domainStart = calendar.startOfDay(for: domain.lowerBound) + if selectedDays != 365 { + return self.strideDates( + from: domainStart, + whileBefore: domain.upperBound, + step: 7, + calendar: calendar) + } + + let dataEnd = normalizedDataDays.last + ?? calendar.date(byAdding: .day, value: -1, to: domain.upperBound) + ?? domainStart + let daySpan = max( + 0, + calendar.dateComponents([.day], from: domainStart, to: dataEnd).day ?? 0) + guard daySpan > 0 else { return [domainStart] } + + // Keep the complete daily series, but limit All to roughly six readable date labels. + let step = max(14, Int(ceil(Double(daySpan) / 5))) + var dates = self.strideDates( + from: domainStart, + through: dataEnd, + step: step, + calendar: calendar) + + guard let last = dates.last, + !calendar.isDate(last, inSameDayAs: dataEnd) + else { + return dates + } + + let trailingGap = calendar.dateComponents([.day], from: last, to: dataEnd).day ?? step + if trailingGap < max(7, step / 2), dates.count > 1 { + dates[dates.count - 1] = dataEnd + } else { + dates.append(dataEnd) + } + return dates + } + + private static func strideDates( + from start: Date, + whileBefore end: Date, + step: Int, + calendar: Calendar) -> [Date] + { + var dates: [Date] = [] + var cursor = start + while cursor < end { + dates.append(cursor) + guard let next = calendar.date(byAdding: .day, value: step, to: cursor) else { break } + cursor = next + } + return dates + } + + private static func strideDates( + from start: Date, + through end: Date, + step: Int, + calendar: Calendar) -> [Date] + { + var dates: [Date] = [] + var cursor = start + while cursor <= end { + dates.append(cursor) + guard let next = calendar.date(byAdding: .day, value: step, to: cursor) else { break } + cursor = next + } + return dates + } +} + +struct SpendModelsTokenChartPresentation: Equatable { + struct Point: Identifiable, Equatable { + let day: Date + let kind: SpendModelsDayDetailPresentation.BucketKind? + let value: Double + let stackStart: Double + let stackEnd: Double + + var id: String { + "\(self.kind?.rawValue ?? "total"):\(Int(self.day.timeIntervalSince1970))" + } + } + + let points: [Point] + + var dailyTotals: [Point] { + let pointsByDay = Dictionary(grouping: self.points, by: \.day) + return pointsByDay.keys.sorted().compactMap { day in + let total = pointsByDay[day, default: []].reduce(0.0) { $0 + $1.value } + guard total > 0 else { return nil } + return Point(day: day, kind: nil, value: total, stackStart: 0, stackEnd: total) + } + } + + init(analysis: SpendDashboardModel.ModelAnalysis) { + let byDay = Dictionary(grouping: analysis.dailyValues, by: \.day) + self.points = byDay.keys.sorted().flatMap { day in + let values = byDay[day, default: []] + let buckets: [(SpendModelsDayDetailPresentation.BucketKind?, Int)] + if values.allSatisfy({ $0.inputTokens != nil && $0.outputTokens != nil }) { + let input = Self.saturatingSum(values.compactMap(\.inputTokens)) + let output = Self.saturatingSum(values.compactMap(\.outputTokens)) + let cacheRead = Self.saturatingSum(values.compactMap(\.cacheReadTokens)) + let cacheWrite = Self.saturatingSum(values.compactMap(\.cacheCreationTokens)) + let reasoning = Self.saturatingSum(values.compactMap(\.reasoningTokens)) + buckets = [ + (.input, input), + (.cacheRead, cacheRead), + (.cacheWrite, cacheWrite), + (.output, max(0, output - reasoning)), + (.reasoning, reasoning), + ] + } else { + buckets = [(nil, Self.saturatingSum(values.compactMap(\.totalTokens)))] + } + var cursor = 0.0 + return buckets.compactMap { kind, tokens -> Point? in + guard tokens > 0 else { return nil } + let value = Double(tokens) + let start = cursor + cursor += value + return Point(day: day, kind: kind, value: value, stackStart: start, stackEnd: cursor) + } + } + } + + private init(points: [Point]) { + self.points = points + } + + private static func saturatingSum(_ values: [Int]) -> Int { + values.reduce(0) { total, value in + let result = total.addingReportingOverflow(value) + return result.overflow ? Int.max : result.partialValue + } + } + + func applyingTrailingAverage(window: Int = SpendModelsPresentation.trailingAverageWindow) -> Self { + guard window > 1, !self.points.isEmpty else { return self } + let days = Array(Set(self.points.map(\.day))).sorted() + var kinds: [SpendModelsDayDetailPresentation.BucketKind?] = + SpendModelsDayDetailPresentation.BucketKind.allCases.map(\.self) + kinds.append(nil) + var raw: [String: [Date: Double]] = [:] + for point in self.points { + raw[point.kind?.rawValue ?? "total", default: [:]][point.day, default: 0] += point.value + } + var result: [Point] = [] + for (index, day) in days.enumerated() { + let first = max(0, index - window + 1) + let samples = days[first...index] + var cursor = 0.0 + for kind in kinds { + let id = kind?.rawValue ?? "total" + let value = samples.reduce(0.0) { $0 + (raw[id]?[$1] ?? 0) } / Double(samples.count) + guard value > 0 else { continue } + let start = cursor + cursor += value + result.append(Point(day: day, kind: kind, value: value, stackStart: start, stackEnd: cursor)) + } + } + return Self(points: result) + } +} + +func spendModelsActiveChartDomain( + selectedDays: Int, + dataDays: [Date], + chartDomain: ClosedRange?, + calendar: Calendar = .current) -> ClosedRange +{ + let firstDataDay = dataDays.min() ?? Date() + let lastDataDay = dataDays.max() ?? firstDataDay + let fallbackEnd = calendar.date(byAdding: .day, value: 1, to: lastDataDay) ?? lastDataDay + let fallbackDomain = firstDataDay...fallbackEnd + guard selectedDays >= 365 else { return chartDomain ?? fallbackDomain } + + // The model builder may intentionally trim a negligible legacy island from the supplied + // cumulative domain. Keep metric-specific framing inside that focused window instead of + // reintroducing the trimmed observations through the raw daily values. + let focusedDays = if let chartDomain { + dataDays.filter(chartDomain.contains) + } else { + dataDays + } + guard let first = focusedDays.min(), let last = focusedDays.max() else { + return chartDomain ?? fallbackDomain + } + + let observedDays = max( + 1, + (calendar.dateComponents([.day], from: first, to: last).day ?? 0) + 1) + let padding = min(14, max(1, Int(ceil(Double(observedDays) * 0.04)))) + let paddedStart = calendar.date(byAdding: .day, value: -padding, to: first) ?? first + let paddedLast = calendar.date(byAdding: .day, value: padding, to: last) ?? last + let paddedEnd = calendar.date(byAdding: .day, value: 1, to: paddedLast) ?? paddedLast + guard let chartDomain else { return paddedStart...paddedEnd } + + let start = max(chartDomain.lowerBound, paddedStart) + let end = min(chartDomain.upperBound, paddedEnd) + guard start <= end else { return chartDomain } + return start...end +} + +struct SpendModelsSection: View { + let analysis: SpendDashboardModel.ModelAnalysis + let chartDomain: ClosedRange? + /// Global dashboard range, passed read-only: the single top-level time-range picker drives all + /// sections now, so this block no longer renders its own 7d/30d/All selector. + let selectedDays: Int + let currencyCode: String + @AppStorage("spendModelsViewMode") private var viewMode: SpendModelsViewMode = .models + @AppStorage("spendModelsSortMetric") private var sortMetric: SpendModelMetric = .tokens + @State private var selectedDay: Date? + @State private var pinnedDay: Date? + @State private var showsAllModels = false + @State private var cachedTokenPresentation: SpendModelsPresentation? + @State private var cachedSpendPresentation: SpendModelsPresentation? + @State private var cachedTokenChartPresentation = SpendModelsTokenChartPresentation(analysis: .empty) + // Hover lookup cache: a sorted unique-day list replaces an O(points) scan with + // Calendar.isDate(inSameDayAs:) on every pointer movement. + @State private var cachedSortedDays: [Date] = [] + + /// Memoized presentation. Building this sorts and aggregates every model row; recomputing it + /// when selectedDay changes causes hover lag, so it is rebuilt only when the analysis changes. + private var presentation: SpendModelsPresentation { + if let cached = self.cachedTokenPresentation { return cached } + return SpendModelsPresentation(analysis: self.analysis, metric: .tokens) + } + + private var rankingPresentation: SpendModelsPresentation { + switch self.sortMetric { + case .tokens: + self.cachedTokenPresentation ?? SpendModelsPresentation(analysis: self.analysis, metric: .tokens) + case .estimatedSpend: + self.cachedSpendPresentation + ?? SpendModelsPresentation(analysis: self.analysis, metric: .estimatedSpend) + } + } + + private var chartPresentation: SpendModelsPresentation { + self.rankingPresentation + } + + private var hasChartData: Bool { + switch self.sortMetric { + case .tokens: + !self.cachedTokenChartPresentation.points.isEmpty + case .estimatedSpend: + !self.chartPresentation.points.isEmpty + } + } + + private func rebuildPresentations() { + let base = SpendModelsPresentation(analysis: self.analysis, metric: .tokens) + let spend = SpendModelsPresentation( + analysis: self.analysis, + metric: .estimatedSpend) + self.cachedTokenPresentation = base + self.cachedSpendPresentation = spend + self.cachedTokenChartPresentation = SpendModelsTokenChartPresentation(analysis: self.analysis) + if self.sortMetric == .tokens, + self.cachedTokenChartPresentation.points.isEmpty, + !spend.points.isEmpty + { + self.sortMetric = .estimatedSpend + } + self.syncChartInteractionDays() + } + + var body: some View { + SpendDashboardPanel { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .firstTextBaseline) { + Text(L("Models")) + .font(SpendModelsListStyle.sectionTitleFont) + Spacer() + Picker(L("View"), selection: self.$viewMode) { + ForEach(SpendModelsViewMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .font(SpendModelsListStyle.controlFont) + .controlSize(.small) + .fixedSize() + } + if self.viewMode == .clients { + SpendClientsView(analysis: self.analysis, currencyCode: self.currencyCode) + } else if !self.hasChartData { + Text(L("No model-level history")) + .foregroundStyle(.secondary) + .padding(.vertical, 10) + } else { + self.chart + if let pinnedDay = self.pinnedDay, + let detail = SpendModelsDayDetailPresentation( + analysis: self.analysis, + day: pinnedDay, + metric: self.sortMetric) + { + SpendModelsDayDetailView( + detail: detail, + metric: self.sortMetric, + currencyCode: self.currencyCode) + } + self.ranking + } + if self.presentation.coverage == .partial { + Text(L("Partial model history: incomplete source-days are excluded.")) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.tertiary) + } + if self.showsEstimatedCostFootnote { + Text(L("Estimated costs are priced from local logs and may differ from provider bills.")) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.tertiary) + } + } + } + .onAppear { + if self.cachedTokenPresentation == nil { self.rebuildPresentations() } + } + .onChange(of: self.analysis) { _, _ in + self.showsAllModels = false + self.rebuildPresentations() + } + .onChange(of: self.chartPresentation.points) { _, points in + guard let pinnedDay = self.pinnedDay else { return } + if !Set(points.map(\.day)).contains(pinnedDay) { self.pinnedDay = nil } + } + } + + private var showsEstimatedCostFootnote: Bool { + self.presentation.rows.contains { + $0.source.estimatedCost != nil && $0.source.costIsEstimated + } + } + + private var chart: some View { + Chart { + if let interactionDay = self.pinnedDay ?? self.selectedDay { + RuleMark(x: .value(L("Day"), interactionDay, unit: .day)) + .foregroundStyle(Color.accentColor.opacity(0.09)) + .lineStyle(StrokeStyle(lineWidth: 18)) + } + if self.sortMetric == .tokens { + ForEach(self.cachedTokenChartPresentation.dailyTotals) { point in + BarMark( + x: .value(L("Day"), point.day, unit: .day), + yStart: .value(L("Tokens"), point.stackStart), + yEnd: .value(L("Tokens"), point.stackEnd), + width: .ratio(0.62)) + .foregroundStyle(Color.accentColor) + .accessibilityLabel(Text("\(L("Tokens")), \(self.dayText(point.day))")) + .accessibilityValue(Text(self.metricText(point.value))) + } + } else { + ForEach(self.chartPresentation.dailyTotals) { point in + BarMark( + x: .value(L("Day"), point.day, unit: .day), + yStart: .value(L("Estimated spend"), point.stackStart), + yEnd: .value(L("Estimated spend"), point.stackEnd), + width: .ratio(0.62)) + .foregroundStyle(Color.accentColor) + .accessibilityLabel(Text( + "\(point.seriesName), \(self.dayText(point.day))")) + .accessibilityValue(Text(self.metricText(point.value))) + } + } + if let pinnedDay = self.pinnedDay { + RuleMark(x: .value(L("Day"), pinnedDay, unit: .day)) + .foregroundStyle(Color.accentColor.opacity(0.72)) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + if let selectedDay { + RuleMark(x: .value(L("Day"), selectedDay, unit: .day)) + .foregroundStyle(.clear) + .annotation(position: .top, overflowResolution: .init( + x: .fit(to: .chart), + y: .fit(to: .chart))) + { + self.tooltip(selectedDay) + } + } + } + .chartXScale( + domain: self.activeDomain, + range: .plotDimension(startPadding: 10, endPadding: 30)) + .chartLegend(.hidden) + .chartXAxis { + AxisMarks(values: self.xAxisDates) { value in + if let date = value.as(Date.self) { + AxisValueLabel(anchor: self.xAxisLabelAnchor(for: date)) { + Text(self.dayText(date)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + } + } + } + } + .chartYAxis { + AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { value in + AxisGridLine() + .foregroundStyle(Color.secondary.opacity(0.10)) + AxisValueLabel { + if let amount = value.as(Double.self) { + Text(self.axisMetricText(amount)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + } + } + } + } + .chartPlotStyle { plotArea in + plotArea + .background(Color.primary.opacity(0.018)) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + .frame(height: SpendModelsListStyle.compactChartHeight) + .accessibilityLabel(L("Models")) + .accessibilityValue(self.chartAccessibilityValue) + .chartOverlay { proxy in + GeometryReader { geo in + SpendModelsChartMouseReader( + onMoved: { location in + self.updateSelectedDay(location: location, proxy: proxy, geo: geo) + }, + onClicked: { location in + self.handleChartClick(location: location, proxy: proxy, geo: geo) + }, + onDragged: { location in + self.handleChartDrag(location: location, proxy: proxy, geo: geo) + }, + onEscape: { + self.pinnedDay = nil + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private var ranking: some View { + self.rankingContent + } + + private var rankingContent: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Spacer() + Picker(L("Models"), selection: self.$sortMetric) { + ForEach(SpendModelMetric.allCases) { metric in + Text(metric.title).tag(metric) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .font(SpendModelsListStyle.controlFont) + .controlSize(.small) + .fixedSize() + .onChange(of: self.sortMetric) { _, _ in + self.showsAllModels = false + self.syncChartInteractionDays() + } + } + ForEach(SpendModelsRanking.visibleRows( + self.rankingPresentation.rows, + showsAll: self.showsAllModels)) + { row in + HStack(spacing: 8) { + Text("#\(row.rank)") + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.tertiary) + .monospacedDigit() + .frame(width: 28, alignment: .leading) + SpendProviderIcon( + provider: row.source.modelProvider, + size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + Text(row.source.displayName) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + .layoutPriority(1) + let context = spendModelsRankingContextText( + row, + metric: self.rankingPresentation.metric, + currencyCode: self.currencyCode) + if !context.isEmpty { + Text("· \(context)") + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + } + Spacer(minLength: 12) + Text(spendModelsRankingValueText( + row, + metric: self.rankingPresentation.metric, + currencyCode: self.currencyCode)) + .font(SpendModelsListStyle.valueFont) + Text("· \(self.shareText(row.value))") + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .fixedSize(horizontal: true, vertical: false) + } + .padding(.vertical, 2) + } + if SpendModelsRanking.showsDisclosure(rowCount: self.rankingPresentation.rows.count) { + Button { + self.showsAllModels.toggle() + } label: { + HStack(spacing: 5) { + Text(self.showsAllModels + ? L("Collapse") + : L("Show all %d models", self.rankingPresentation.rows.count)) + Image(systemName: self.showsAllModels ? "chevron.up" : "chevron.down") + .font(SpendModelsListStyle.tertiaryFont.weight(.semibold)) + } + .font(SpendModelsListStyle.secondaryFont.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.secondary.opacity(0.08), in: Capsule()) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + } + + private func shareText(_ value: Double?) -> String { + guard let value else { return "—" } + guard let total = self.rankingPresentation.metricTotal, total > 0 else { return "—" } + return UsageFormatter.percentString(value / total * 100) + } + + private func tooltip(_ day: Date) -> some View { + let detail = SpendModelsDayDetailPresentation( + analysis: self.analysis, + day: day, + metric: self.sortMetric) + return VStack(alignment: .leading, spacing: 7) { + if let detail { + Text(self.dayText(day)) + .font(SpendModelsListStyle.tooltipTitleFont) + HStack(spacing: 14) { + self.tooltipSummary( + title: L("Tokens"), + value: detail.totalTokens.map(UsageFormatter.tokenCountString) ?? "—") + self.tooltipSummary( + title: L("Estimated spend"), + value: detail.totalCost.map { + UsageFormatter.currencyString($0, currencyCode: self.currencyCode) + } ?? "—") + } + } + } + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .shadow(color: .black.opacity(0.09), radius: 10, y: 3) + } + + private func tooltipSummary(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) + Text(value) + .font(SpendModelsListStyle.tooltipRowFont.weight(.medium)) + .monospacedDigit() + } + } + + private var chartAccessibilityValue: String { + let days = Set(self.chartPresentation.points.map(\.day)).count + return L("%d days of usage data across %d models", days, self.chartPresentation.series.count) + } + + /// Token and spend histories can have different priced coverage. In the + /// cumulative view, frame the chart from the active metric's observations + /// so switching metrics does not reserve months of empty space belonging + /// only to the other metric. + private var activeDomain: ClosedRange { + spendModelsActiveChartDomain( + selectedDays: self.selectedDays, + dataDays: self.activeDataDays, + chartDomain: self.chartDomain) + } + + private var activeDataDays: [Date] { + switch self.sortMetric { + case .tokens: + self.cachedTokenChartPresentation.dailyTotals.map(\.day) + case .estimatedSpend: + self.chartPresentation.dailyTotals.map(\.day) + } + } + + private var xAxisDates: [Date] { + SpendModelsAxisDates.make( + selectedDays: self.selectedDays, + dataDays: self.activeDataDays, + domain: self.activeDomain) + } + + private func xAxisLabelAnchor(for date: Date) -> UnitPoint { + if let first = self.xAxisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { + return .topLeading + } + if let last = self.xAxisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { + return .topTrailing + } + return .top + } + + private func metricText(_ value: Double) -> String { + spendModelsChartMetricText(value, metric: self.sortMetric) + } + + private func axisMetricText(_ value: Double) -> String { + self.metricText(value) + } + + private func syncChartInteractionDays() { + let days = Array(Set(self.chartPresentation.points.map(\.day))).sorted() + self.cachedSortedDays = days + if let selectedDay = self.selectedDay, !days.contains(selectedDay) { + self.selectedDay = nil + } + if let pinnedDay = self.pinnedDay, !days.contains(pinnedDay) { + self.pinnedDay = nil + } + } + + private func dayText(_ day: Date) -> String { + SpendModelsDateFormatter.dayText(day) + } + + private func updateSelectedDay(location: CGPoint?, proxy: ChartProxy, geo: GeometryProxy) { + guard let location, let plotAnchor = proxy.plotFrame else { + self.selectedDay = nil + return + } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location) else { + self.selectedDay = nil + return + } + self.selectedDay = SpendChartDayHoverResolver.resolvedDay( + toX: location.x - plotFrame.origin.x, + days: self.cachedSortedDays, + currentDay: self.selectedDay, + position: { proxy.position(forX: $0) }) + } + + /// Clicking pins the nearest visible day inside a continuous screen-space lane. Adjacent days + /// meet at their midpoint, so thin bars never leave dead strips between them. + private func handleChartClick(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { + guard let day = self.chartDay(at: location, proxy: proxy, geo: geo) else { + self.pinnedDay = nil + return + } + self.pinnedDay = self.pinnedDay == day ? nil : day + } + + private func handleChartDrag(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { + guard let day = self.chartDay(at: location, proxy: proxy, geo: geo) else { return } + self.pinnedDay = day + } + + private func chartDay(at location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) -> Date? { + guard let plotAnchor = proxy.plotFrame else { return nil } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location) else { return nil } + return SpendChartDayHitTarget.nearestDay( + toX: location.x - plotFrame.origin.x, + days: self.cachedSortedDays, + position: { proxy.position(forX: $0) }) + } +} + +enum SpendChartDayHitTarget { + static let minimumOuterExtension: CGFloat = 14 + + static func nearestDay( + toX targetX: CGFloat, + days: [Date], + position: (Date) -> CGFloat?) -> Date? + { + let positioned = days.compactMap { day in + position(day).map { (day: day, x: $0) } + } + .sorted { $0.x < $1.x } + guard !positioned.isEmpty else { return nil } + + guard let nearestIndex = positioned.indices.min(by: { + abs(positioned[$0].x - targetX) < abs(positioned[$1].x - targetX) + }) else { return nil } + let nearest = positioned[nearestIndex] + let lowerBound: CGFloat + if nearestIndex > positioned.startIndex { + let previous = positioned[positioned.index(before: nearestIndex)] + lowerBound = (previous.x + nearest.x) / 2 + } else if positioned.count > 1 { + let next = positioned[positioned.index(after: nearestIndex)] + lowerBound = nearest.x - max(self.minimumOuterExtension, (next.x - nearest.x) / 2) + } else { + lowerBound = nearest.x - self.minimumOuterExtension + } + + let upperBound: CGFloat + if nearestIndex < positioned.index(before: positioned.endIndex) { + let next = positioned[positioned.index(after: nearestIndex)] + upperBound = (nearest.x + next.x) / 2 + } else if positioned.count > 1 { + let previous = positioned[positioned.index(before: nearestIndex)] + upperBound = nearest.x + max(self.minimumOuterExtension, (nearest.x - previous.x) / 2) + } else { + upperBound = nearest.x + self.minimumOuterExtension + } + return (lowerBound...upperBound).contains(targetX) ? nearest.day : nil + } +} + +enum SpendChartDayHoverResolver { + /// The pointer must move this far into the neighboring date lane before hover changes. + /// This makes dense vertical bars feel magnetized without slowing deliberate large moves. + static let hysteresisFraction: CGFloat = 0.12 + static let minimumHysteresis: CGFloat = 1.5 + static let maximumHysteresis: CGFloat = 8 + + static func resolvedDay( + toX targetX: CGFloat, + days: [Date], + currentDay: Date?, + position: (Date) -> CGFloat?) -> Date? + { + guard let candidate = SpendChartDayHitTarget.nearestDay( + toX: targetX, + days: days, + position: position) + else { + return nil + } + guard let currentDay, currentDay != candidate else { return candidate } + + let positioned = days.compactMap { day in + position(day).map { (day: day, x: $0) } + } + .sorted { $0.x < $1.x } + guard let currentIndex = positioned.firstIndex(where: { $0.day == currentDay }), + let candidateIndex = positioned.firstIndex(where: { $0.day == candidate }) + else { + return candidate + } + + // A fast move across multiple columns should catch up immediately. Hysteresis only calms + // the ambiguous boundary between neighboring vertical date lanes. + guard abs(candidateIndex - currentIndex) == 1 else { return candidate } + let current = positioned[currentIndex] + let next = positioned[candidateIndex] + let boundary = (current.x + next.x) / 2 + let gap = abs(next.x - current.x) + let hysteresis = min( + self.maximumHysteresis, + max(self.minimumHysteresis, gap * self.hysteresisFraction)) + + if candidateIndex > currentIndex { + return targetX >= boundary + hysteresis ? candidate : currentDay + } + return targetX <= boundary - hysteresis ? candidate : currentDay + } +} + +/// Hover/click/Escape reader for the models chart. Mirrors `MouseLocationReader` (which has no +/// click support) and adds day pinning: mouse-down reports the location, and once the view holds +/// first responder, Escape clears the pinned day. +@MainActor +struct SpendModelsChartMouseReader: NSViewRepresentable { + let onMoved: (CGPoint?) -> Void + let onClicked: (CGPoint) -> Void + let onDragged: (CGPoint) -> Void + let onEscape: () -> Void + + func makeNSView(context: Context) -> TrackingView { + let view = TrackingView() + view.onMoved = self.onMoved + view.onClicked = self.onClicked + view.onDragged = self.onDragged + view.onEscape = self.onEscape + return view + } + + func updateNSView(_ nsView: TrackingView, context: Context) { + nsView.onMoved = self.onMoved + nsView.onClicked = self.onClicked + nsView.onDragged = self.onDragged + nsView.onEscape = self.onEscape + } + + final class TrackingView: NSView { + var onMoved: ((CGPoint?) -> Void)? + var onClicked: ((CGPoint) -> Void)? + var onDragged: ((CGPoint) -> Void)? + var onEscape: (() -> Void)? + private var trackingArea: NSTrackingArea? + + override var isFlipped: Bool { + true + } + + override var acceptsFirstResponder: Bool { + true + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + self.window?.acceptsMouseMovedEvents = true + self.updateTrackingAreas() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { + self.removeTrackingArea(trackingArea) + } + + let options: NSTrackingArea.Options = [ + .activeAlways, + .inVisibleRect, + .mouseEnteredAndExited, + .mouseMoved, + ] + let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil) + self.addTrackingArea(area) + self.trackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + self.onMoved?(self.convert(event.locationInWindow, from: nil)) + } + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + self.onMoved?(self.convert(event.locationInWindow, from: nil)) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + self.onMoved?(nil) + } + + override func mouseDown(with event: NSEvent) { + self.window?.makeFirstResponder(self) + self.onClicked?(self.convert(event.locationInWindow, from: nil)) + } + + override func mouseDragged(with event: NSEvent) { + let location = self.convert(event.locationInWindow, from: nil) + self.onMoved?(location) + self.onDragged?(location) + } + + override func resetCursorRects() { + super.resetCursorRects() + self.addCursorRect(self.bounds, cursor: .pointingHand) + } + + override func keyDown(with event: NSEvent) { + if event.keyCode == 53 { // Escape + self.onEscape?() + self.window?.makeFirstResponder(nil) + } else { + super.keyDown(with: event) + } + } + } +} diff --git a/Sources/CodexBar/PreferencesView.swift b/Sources/CodexBar/PreferencesView.swift index 3a398bf1ee..19e8567f0b 100644 --- a/Sources/CodexBar/PreferencesView.swift +++ b/Sources/CodexBar/PreferencesView.swift @@ -43,6 +43,7 @@ enum SettingsPane: Hashable { struct PreferencesView: View { @Bindable var settings: SettingsStore @Bindable var store: UsageStore + @Bindable var spendDashboardController: SpendDashboardController let updater: UpdaterProviding @Bindable var selection: PreferencesSelection let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator @@ -53,6 +54,7 @@ struct PreferencesView: View { init( settings: SettingsStore, store: UsageStore, + spendDashboardController: SpendDashboardController, updater: UpdaterProviding, selection: PreferencesSelection, managedCodexAccountCoordinator: ManagedCodexAccountCoordinator = ManagedCodexAccountCoordinator(), @@ -61,6 +63,7 @@ struct PreferencesView: View { { self.settings = settings self.store = store + self.spendDashboardController = spendDashboardController self.updater = updater self.selection = selection self.managedCodexAccountCoordinator = managedCodexAccountCoordinator @@ -124,7 +127,10 @@ struct PreferencesView: View { case .general: GeneralPane(settings: self.settings) case .usageSpend: - SpendDashboardPane(settings: self.settings, store: self.store) + SpendDashboardPane( + settings: self.settings, + store: self.store, + controller: self.spendDashboardController) case .notifications: NotificationsPane(settings: self.settings) case .menuBar: diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index 844e46770f..4e428e5c0d 100644 --- a/Sources/CodexBar/ProviderBrandIcon.swift +++ b/Sources/CodexBar/ProviderBrandIcon.swift @@ -26,18 +26,23 @@ enum ProviderBrandIcon { return cached } - let baseName = ProviderDescriptorRegistry.descriptor(for: provider).branding.iconResourceName + let branding = ProviderDescriptorRegistry.descriptor(for: provider).branding + let baseName = branding.iconResourceName guard let bundle = self.resourceBundle else { return nil } - guard let url = bundle.url(forResource: baseName, withExtension: "svg"), - let image = NSImage(contentsOf: url) - else { + let extensions = ["svg", "png"] + guard let url = extensions.lazy.compactMap({ + bundle.url(forResource: baseName, withExtension: $0) + }).first else { + return nil + } + guard let image = NSImage(contentsOf: url) else { return nil } image.size = self.size - image.isTemplate = true + image.isTemplate = branding.iconRenderingMode == .template self.cache[provider] = image return image } diff --git a/Sources/CodexBar/Resources/ProviderIcon-antigravity.png b/Sources/CodexBar/Resources/ProviderIcon-antigravity.png new file mode 100644 index 0000000000000000000000000000000000000000..d9c43c5d5cddbdccefed8f09fc58c5a9981dd642 GIT binary patch literal 90331 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

6;knBaFdF?ygmJ^P{oh#hZ{g5m?1)Hh#pM` zar+H;mLu!SyXqR^nStz3O)F2u-T(OvyhGmlhaJttne!6TJ?&quXEl{sXN?2mCKS*z zl85-n@!aCh0j+XOi{tiMg5!&^_q;*9osyy|614>HOy{4RI(q3XZ57tV5j#a&<+ARw zrn37E0$CbBw&{UQ!<+#y2$oo4N{4%o$5jHUtOjd1-hT4fJU{(5z$;m8=Pg9oOtZY z=y5`Q<0{2)Yk8ko zeH)hBDQ@7KF=pG3a~dev9yyHF%J9!~f=z>D5SYBEra~mhGIgW5HIYJ=WvrYl%=F^i zMBYT^S}r9*CVmFy`tYqBiebOJ{XS*+osz0F^%?}q?$7XUH7c=~c;j$59IfG(NYBD|lEs&o?TpS| z8*qw#I%#*{8j880@nx5xcV-}o`$dwQCLKGz+Hm#vjRd%kx`wyL zQVyTlv^f5Z@DEduajf#l(ilD%fE1NF{Ms|5IaSjdHjLc(6X$Gsh0*SSz2;c}4tDcV zUlSXZBPMD;Sr-%nDO&oAcl&MDaXX#9;gB^I_!;pMU$lrw?__N40gsqJ-bPNox9TFo zL(=L2ZsDZ*`zf~bA_L~_gUsXlvM2iP;rG=mju!A@2t@z4r5>+iU(h&iQ4Qz$pdE-e zHjcIktMtUW`cNa_u(lk0H*VscO0h{vRoaS9#A+fOYvZO{xdSTtxsM1n``EteC>A_HSySR583;;-;uV?w2z{r@XLxiKF z5y^!eTJQX9@QORjPn9if4mmO2=9d^g6Bd)axWVGHKgPGG;^jPsg&PBHXq1p_9sN-ljyrty_~*UE1ljI87mkbPjF4>l zef(wN>afCBxio=-QYS=wxtx1N?~zJsuTyhcN4?|x^UIbH`v;1yZC0OK5CRc;)|&!1 z#_bpesjV~GVR-_F%pR_OXJT)v|99i}3S0HJBMI|Cxe;5d6zhC&#*GvDi;43Jr1xi1hx!9ii{A{n=RIqDEdfUT0%o7Qb%u+LMwL>{ zsnj!AvRkTrc+(9aCFwr7@5vcCRjz_H2CN~0F}y{EFhk%aDai%}2hNr9IwP3oQzG}b z^TcIhvz#A z%Q~13HSY%rWE+$M$y8!zCU77L z95|__aEy-x{qn%{;)QadDP>bWO&Z>GKYc)dyIp<47y0pkPtEo(cV}Rxg-_qhjr_$e zygN7iut;v>qL&68p(e%cmyJ5tzDs8hvUeAV2f`ld=84Wb(Uf}`)Jsp>>iwczPNb!J z7ss+xD12SJp|??lskXw$0PdxG_0Wf>A_DK)5T&PoVDP`(EhT@nl{ORp=h5+Pjt~g_ zTM>;1{l&8V(kJ{r-v1dg+)rUCQovWyv2rEteA*#BSy$ z7xMIc{Vn&?se*fjm(-D_np}sP2D@>3wDZ;GNA4R7qN8%B4SUbnM)3g^$~5(17Hl`x z5<}!k>H<&B&tFr#fyy7OL`$h)dE|i~Nnhs`oZ`#Z?mt87K_t;f&p{F|FBvSR{LEjz z-f8jp7N6>id!R$KU}DIwxO(s`VKgSZX?Bc?RK%S*g+;iXVuF{cCw5~z1txa-%2X^9 z-ore&$Cfi^H;*)xFNKuo=hGzAac?sg3YgZlD|Fph1PVAIoGv$Z#K{|P^F`3kT`c7-$JXH_|NCqd9 ze5iW@8p`o&zR5oh^ZvmOV4*{Scm_b?6vaJ*1FXe3M|tcjz=fO&x>qV9H*b=lU1oUc zB&B@)6XR+XW0oyRzEXVFPvq{o{*O}ceO>b`qw9w9Tt!DL<*lEVYdb|Cq^CJ7;ZnkZ$Ib; z&u8kO*u}}4JH|Ks#!dq)Dk93e>IotsE2n(!S|eQ=z4@p{EdmR4a1A(Z{g3!5CWY^Y zK*{-9pLD8@WduW)VrCH|R|8*L!3a>6JC(A0&OHnMMjFZmuX$(q5}7}Tghm$*e^BFl z_wqqE=Vbj^g+bxrh1>8A-M$3YN_7<#d?4(Dy<}~NF5+wZ3V+v%ZrA#;I-4qVlEE-usH*3qq&{rGRXp3){@|j14`GzmLXYSa zNa^{hW+Ngk@~hKmd((N}>k=Vcl_mR$?g4M}Ve?2P%kchA=8QqIxnJjbP^P-Abj{tI0jAkUIc<^HYX_xJMTN&Tb!XncTrcdVkMY5OzOI7G)`bG2 z4QA`zAW~LF$F4&n1T=KFJ#K@HC}A-;R#rGV{*B!p6}Ge{PPl6a$huxeDZ`08H+NzU z**PD6^cDD$eVQ^5(QGx1tDW?e9rn|2c1sp4JL!-?f1E;ku;tJ=Lo^yH8?33Ig8W}e z67Il+-XsLsOF#B1!UZ^|#jF=tC|--RB+S1`i1Uyw8REva(n3bj5Cl5@{Xz5YhL)f$ zdty%D<=>kttJeX$F#))psuF-47RV z>h~of*Jg>)xH0YSmqmP#PGhVBRKGIh}x*D-? zS;K=chfUIbL{e6f_(BrzQyn+T&yU9C!yMDNs~GWmyd2~9>F9veS~DwvosX_USVcYe zWTrvS@G>XGubg|z=gi0vXwloBMcJ;n7H{hg)}&v44U(|u=K7IY{M@$Hu6A#-eT7~u zq2qP#&dzb;0&&v!WISMkSb`2rmaUW0C#8y{%NY->32?8}*1FGdr6=Y2C?r-tqVQ`I zh!X(9Hhad)-XBjY93)kX-Eet^FZN)Az;h;8fzi?6I0tL*;JsMx`as0w^?G zoJy^Bn0)7*BJx1!v*Gq5)8a&fW%c;q;|y6P`q#+CJVfT46*y2X_S}peJbB@)PmSA_ z1_(~Dv}ZOpL?_38_OvQg@Zgv#rvCBUjM~>qm#&{}YG=r<-#H50L6w*oxXT} z4kexZP-A!Fubju<{c+*kTk71xFhxyto7!`E_Lp>ZkOe~RG%8;@!jS$w#XS64j^WUN zVS%=qMrA+I&BE|NYu3~$`KqzZ#`V%hx60MV3;fqrRHp5A#qVfL#$4girpP1opXFS` zuoLHA27S6sFYv{GUI!m1@Rcdc$}dKyizL>d1}(S~Hlj`IeYREHcUYBzfiCdbp!m=@ zCj7D!Q5ArgDXpG5klRBPEm2N#bkA#K4>tg;cn1bLY}Z9M)^uac?9-B_vv}uVQB1;? z`wEiCfy`+l=3B($eI%IIPOw0U5iX}AkxplHk}Y;Jnxkg&I8$%4xP2Z?6=+h%Wg&4Z z0fd)v`4-FN+07+w8sY@-N4{+lkmdB&7S`Cze%DVD-r5l14Ce${r~PqXkjVXQIb*Fj zhn(ED1XEwyD5M(qEJ|~BnFK0`2aZVA2f;FXs?AsveqT^Omk5(%*x=Pn5hk(jYFt-U zD6E_PagqI6;M2#hI~wjsGMxr`6|1dQzJqHu?ZF=A-X7EU<8MzNB;tP-=Jtk1>4}^) zXvNXPL@oM_NGJ|83Ow=CzA6+41(-Ge(%MmQI)~8Gjom8;GadV^Lde#6AE`-vi1hcS zoZQs?^G6A$I?S6~cM2mggnb>;#CS&sB?45K2X_JA2^|SWxkX6m#EGR zULpf8GB{XM^nBAB;`gB0Ppy6Xn!^H10!ryEy*p(Ve^mO5y$)|B?0e||LJWoT3)y8IXj=#(J30edKeZv8`+pEq0mHA`#ofHxVz zg}><(fVwJscog?6Uw%vsO;gh_S%AlDp07*P!R-j}IHvN_5o2#T%UU&S}?dVXK`1-MEkx&9J4nXH1J2w=+#yG4hWn^Kio{zO+Z~rAOuaTr5j>MsTYlP| z9Bf-Zj3uV9=J=0PluOw|#qTOzHTax0?A+>khZ&FG=jecUFUn@s&3S5Q*59t=(k1%B zykX!r5>Me*?}d}HJKf##-KQuu?Pe-Hwdhu$9ZvcfR;uiMqY+qlK9FZpeXDEXb-`y7qi-r+jIss8V9!sL43v+Cmj!d0g1Vs8_#NLeb&OmD-H( zF0)&c?`X^Zb~`CGXe5tWDBLdawU>VM@nr?s8FHGcnlY)!0wJyE9G!}UQq5UPH+O>JCDcr__H5M0l2 zE^3#EM_64BmCs?$;3JXnT^}#X-%!>F?^vi_K62!9yET*S*(OQyf6c9x%};<#YzmP@ z?@m$tpn1NBaO~;JZU2%s$#`H4&=N=yE~zF!LBa zb{h*WS`s~<>O}L$T+Z8_KKtUm9c7n5rOXeJpO#L%$fOSG^W9lKSDYSJx?3w2w{JXYJ3O=RWZKh~=TyYeP(wuH21ji; z4Ft4QWZv5VSwX&NGvkq-)~)N-zUh81b9nZ!x|{iis;x81yLSe~&~@XS+jjo#zk-bV zuONp!W`O_x82a4sNdM!$<_GkZUI5kdappsVS>2?YnHa}dE%pYBjyhlrdov)a6s@D) zt;piY27ad3ra$*@m_-SS&6oUq*SxQrczR57aRy+-Qjmt|Hl5(x(82pQ_LvDi4~xT1 zx$__8zEHyIwqHp2mZa2h`f5tooGMtf!avnBEv z`0vF8);1$jJK|<(8EJN_O#G9lF9U|O6Tr&S49A0${|3fT4PW3+N1Iz%ZGgm`_acKo zw!?fa<(^`?x2Q5*s|adE=3V%|cXPU`_;M+ghaf0yWXlf=zo7bqNUrgU88dTcA<;oMjU&C%M|5#EN- z)>_j<%nun~77*UVomn@#{7e#t{$<$JK2T^Cpomr+)_;GJ$$wqw>{eJUwJ}-poppo{ z@LnhItx|KsG*O({Mp84Ro?7s$i121(kZ!d)3E@ICsg6ZtoeRkWqgvv|r_oeMtI3nW zNE;GNv{csC2lI!O58CMkhuS;tt5JM7NofhY&XSwQAc(=KbKBqp>k`K$KcRX_>TtQG9jyx}x~UE~mkf zt;B5TveH72yKGw8OnE`(G<83P@_e6C6w)<2_aJ_e?1s!+2`lydo1^pRuh{*=L5I|k z*#c}-%Tx}|c1+q?URzwoSmO0AhspBd`mv>i>U{?;AQA3GZ7`_j(V z2-~(`5#syxwr-J4bGi4EMOPH>%>t_l2Z;~oX3cpe-|#%FA!U4SmQ)l*-I!12->p_S z__&oGh~6$8-%re%{?r0~`L_lr1bmd60vHA1ApQW@ZhU@#<*fLdxEM@?6N}L^RwVR+ z1I8z0mTe7ybs`Y%Y23`;Vs)A4?|B(BD=1Xx+o}OyJyNLIX_KB-ciQPLB3#Y3G?l}x zk6_r!pASNg)6U%G`9CD4^Wb6MS4a*=hd7>p^6A_P(AT0YVuG{z)ST|pFYKg^TZM23 z#-F`XE8Y^}2%UZYa%XOI50j(=4B!^`i`d>u>!>u^Ep$h7rl{Z{Mv)=Hc>{$X%DCxJ zF@sF5Lf>bg+w}>?fq#Xc4RDctAQ$p{L@D6O`Kk9HTTbJPGirmX?w=B+-)pBm$MD$* zdZ0`74M2WGo?L{TfeZ_&UU6{7^pVG1nbZx&&%CT0hpjp-D)ut4IlqI;^9|O2@H*34 zd3Z7I&vZJkhACZ&ofWv@EUw!(tMGmEcBkji)Q8ny6ns$3r#t2T(;Y6-ZT?SP!s!Gk zbi1Br@+4={WMOcT0Uvt(YVkCgqZ2wD#nL&CNIfj-nd8X8`9epUKUe!`NSEtESCVXP zB9th4@1;l+qz7hM-J0kuG&ByZmdPnyFWthLaK=F-p|QS83fK z<&*obM5sVuNlF0q!@1lO_I~Di9_&@QzUr{W{hDTDJ!Mpk3om{!-;gs4a-yl3uKh7e z1jo|vOD`z%+sgzp87jIXe* z_d);*&PSQcOr|O`)Cje`r=ZT@EQ2E|fY%jVu;Y$TE5=6i$uVkRS#yUgxkZT7ppT}r z8NJiI*}GyA>EFd+FX=U8>@n`9ItkuE{#h`JDs0~Izytf<#@w64`aQhB^}9YRp>UXw zf5)B1vf|9@?`!Q7ldoWz-Qgdc*SXEv(Wetrdv;QLc*H}NceGaC8IkkNMyBiB6YUDb7+`4yyl=A z{589MdXnNspB6%I-}`pN-uv}G!hrelgw}h2R|GcVPBYq!z>kVy%LxzDCFuM62Vp$E zP`0Xx&nrK+DG1MVo(8XxmY_;`I1;~*%o*g*M@ihSmMIpNGY9Q@B##}E+j!>J;GW(z zJ>2P@KY(RNZ=k=ni32`4_|R|fdWaFP26QPMxlIYkiswt5Wj=b1ElS*H772tiKeZ)# zXb|<3V9Mxc=)oMYUwDDUK%#>p^V)A0A?c$%HnksHG+Fw43Fpy*9*ClB)BcZbk;W7G zY`S_ox$|r*b-(OP6N7EOj1?7OIeo%Ml`UXAuTYSGSc5(J7FBmEBMzEGm%Y-_VWgUx z?UPgq^!wfuPv2mY@eZ6pmkC)<0mDlcs;Vr;1j(qGb!I3X7!PNH`B=Gz&D?jDQ5LV;SyES9xzZr_ zq2{gC$A6;BXHA;;Dx>$0KalI{9E?)i z0+ezI>%|y|d3KQJ9g*>js|tYX)KDf~WPNgP>1>069k!%xIDjU+BBoN+2rSzOu{z+v zdN+eNQsPfLWrs-K&n_VC^`Bn<$Wj!q6`c+# z$EK0#ZM=x|;Hy4uGkoQ)-cVonzSbt^a}h%6gvR1h1}d~Oy_er35xO(#FnY{&NY)oG z&FJsLjeTGb122g#zmqRj+qu8vz9N3#IwdD&5B*ASyA2`+-zW0f*7QZQQ#?~Bx+Ht8 zm(YVs$wlJdd9<%-s<6|}*R(x(6#O+MVoGc0zRvo5A@#CqeHF(}gsaXMwW&M$;jHl>wYx)(R)O&8{#%dmm^M;OapfOn;byD2tkV2|j zL`KEQdT#H+80(?wr0nB0u091Q)O@KEI(zIx`p#ImQfjkwz^Yp?zEPE(r~E0Z_+nYu zL2p9tZypgrO#{o&+>Y^ZbH`hu5UNh26OAZaD?g!NYz3s8gLa-88L#wM?EUR(c z{a^iA5QTS#i~qdaJ(2#fm$pjfqeZ@MuSY^G4zt=4ktPFS#wodJ(b9t9xG+c*!=I)V zkEm1wyyX-}{FB?r777m?t^is+L8pD}sOXRMMrOlj@&+gS5WG>n33eZ}mP7ZNwANU4 zz*wHp<>{6EvJd!}?Xo@_+lPW!^m(6!V@6D(;ws_s{fVvgp_tB;1+E3NTZubGid)y8 zCXer1wN0;n&gQZx{ZU~1VD4zTd-Dc|j(RYyOZCN-@FazvK9U%b97su^ffv2Q0>Oxi z@~}#54h8mM9y)pYYV>rqN!%YV{awD)7#2X)TTy!FNJVAvyxkAec+lG5-TZ0{Vi z#Z+F^S*$(ev3yP2r+#1|L5$*LcEF7pTNk8bi+-}Z-I!Pf9aEV9g8k2u&HEA@RT6QU zl>_Ejl23IGqbHY2S~L?=Nd`M%3#t#G#{h$SvQb1_$x_kO32cX` z5U1!~kX@XcI0a5D)1F)XYOjX}m$3MoN>X(dmK3epS((>IRz$*jzH8I;TzA=2tN67Z$v6`;S1X z;A1avDDX+>%6Ev!&&%s@wKcK<&s{;i}Dm+EoGEnlb7QWD|FI!Q4dltn7 zb6f&%HD5GVa7O1~_S2PWg>^=c_;6{)^6pd@i^t4`nNLiR6KNTEec2VmAcfo+YrXV@ z7| zkA1I;-ni-$ukAD#P&-Ta1~yy@j7)VVnmLV~$Es>PqTx}=16X2aK~{#GTVTRVunXEE zfZuJzq{TB&?~bggH^gZ&@q@Aroo)oBC0!&3F+vch>DRcs{hxlql;@9O$3dQK<}Rdi z`zmW@2Jd{aYbNr?kb!u+$_q&!uOM5`CD0(O9^;Ywnj4}%HWU?4o>VdgXE98ifur^) zX}wLXOs?902*Btvn~WayQrBr={k?gk**Y-C3;Bt2VRg^CMhvJnXr}Q2=Yvim2KW$LjBnK%&V|6ENaW0^0o>ql zXUbKHo~h;Oph@iD2TrQ|1$E#EHxUHtN8u?+bXykK{$|hx4fYo{`CT>(zolA9p68o; zpa10Wz3BBX&Qb`d)`-UI1oZy;&CgoLPs@PhkC2(7kld}mY?0VA#X`b++OOjVMd8$a z@DOEX(nR$gJ0vla?}^2m<rV5i3D4=`CT|^ZmlH^oE*(*f`imIr=^a z=XFajk@olX#QSqjjsy*moxsl&TDLP{eXP$aUMsihOrN* z)e0mZ<>c+p3JXM@w(^g7%JOh}i_3!fzg7GQRi@Xrh?^)SkA5?uAmo027ylklp9$-B z<42j59J}2Y6>&2IBrJVUTLES?jPMT#f(TT1 z)aK1Ow%#}o)Z&f<82vp|*CW$!zr(DWab^k&jM9@59A>O19O|tLG}U#q#BAbe*X8dj zWq_WEnRURwdvkxc0o~n5Qg{E9TZ5A35s2k#D~`W?dfM>CjNT7A(n1;d4B(h6V)^%n zCyh3~orN2_40>lEY+r%hQxMIw>p>12q4MXzHOB$tOTuC#UZM12^E;(y-|dk=KC;Gu z^fNgPp^|GULM+n)_eS~L3svd|jql?Q6)moX6R1^f`+-?Lv#izdWDKlZc(~kCNkcdy z4D9=|&{05hu~qhizq3$yF=6|axcm_FFLF*xcHsDgi0`*lgSp6xQt8 zCVX}UcmUDCG94R;-a?)JQk=nqy%L$>AL#|?FAL(Q5BAxI3abL)zi#Yd-+AfMF*TmV zB}lcZI78cdzu!&%855MLYv@I6TA2v%Mr8J6lr33xzUx~@#ox8E2KgN1{V9s|oeHp( zNsbU|9hyo#vDVF|3p$*u!^q3iQ1kUwort>!OM6lpz)uu&xp1dWz}8)zP5~_!MMn9Dby4# z>0J}!MibT8o+_p=zxz67d^cTlY~m#(VQpoZzRx2F4iV~PO2w{^LoHqs`X zSJ;yY%|*bAB1nAy1l~)ybeYdwBGhFYsk<_BimkZO~a5N|6y+)vHkyg@ zSiAnjTtnAo;&k_W*&#`7)7Bwh*y+qD@ z=Xzs7S(?&N=bv$#SZ3tzL%Ll+?=oT+VZORteP3JUSVVJ zjoQ`i=t-#U{L>U;%`1?CMtIDYUW}0wY;9e@W_Tc-<(M(7wc%2I6~{Y{i!eVy3flM~ zAtRr;dZ&cs=+nBowKOipUle@{>iS%HKjc=<5Xmoi59+HsjNk@`r9Yl=_{5?mPObK> z(W{G1SRxxmm4*^IRbLwzd=ACvV?OFGgl!gaz2r)K%jV5) zn>m~rm%OO^eCAV*D$%16S-p`f&*#B76Sff!PS6wCy`ELy`1!gt`$vS%CSaq2qC&OS ztytE^Q0FmVlKg2_D>4#Bw~Hcn@Ok@7{ooFq01acM^d#3d9iMqI#^yl4(KNo$=Ah*pm>#Q^jKBdoS-#JpMzAAb@-EWq&U$E}l99G>MyB#63)<%)mU;jS~z$8Y$ zac8ejqLX~%*u8i>;23-JW{{^IF{qK;sr>HqY#g5)nz)k|bG6Q-2lM;zcCLN{*+fr?=Tmp&#wKLRR_V)Rq#`&G9bELP?T6hG5}F-uU(~ z50(G;=HnchNdN2drGEMCz5x`)EW2oU_k4~6Cd$y|zw?6TDZSb)9AL}bKLO1hm{l)7 zT%e~HoK$uzaVkCQSGMj_x=8EnwlVZI%W(Snif*SQVkR-GwblpN?QuU*9 z**rfRfLU@n@uk{{N(*&S>eszkE1F|TjHjz5T)9 zNBaSzAjxk|e$TQlrz`DEW)w{iM?7fT+zvC>ojZ4QZMM2WeRw6__b0SUW`*^wd%7*d zZ#1rRUR{LKwZov5Do@XYpgDb#Q%`)y;_H^*e^mPl$ME7iFTC7ua*N!xO-FIs+4T-{ zk@bbrITol$Z2Q1D$1D^mWP~lEPS<%l7wPGR3|0x$HtL1Ng?(F2NW$W~&ffX|x(|Jt z^?y6ugV|%#qNwN7Ny2G+Cz$^@M{eb>>tvFbylt?pI~L=;UIXblaGp!O zLh39uGGu`oxUeG}vjsd&0+-J6Mobn~Oqat%lmAfM`ec*cM4Q>pYc@f3bD9bwigP-* zB)41ecDGm+*?}o=zWW^xNYjTk%3QkKl|UT^u_G;0Iwg2z1sa{ma>yR{)4iVMA%S?x zFQRnMz^4yfvjxqs?0M^jOJhRXabj?HDdLYFJoEf-{ZI!ut8A6!q;JgDKRybj&Y%PH zH~Y=IW;O|KOOzCFCHW0Z4#gKE#gw4h5YNDF1TI^qZS|8Qvf$NbiY1W5mAX7(&-q=?x!a5o&VNB31?#E%ZWR5* zAlHiVsmf=Jweg;Kfcy8pZgt| zeCuT;ptWpHa`}Ws-x8(IrLpo;L^}wQVYSIw5#y-o*{oOR0wlv;-ttl_r;?b0eQ{SW z81J{jnoiDatAKas<{Ev&1+A1>N;(qJYZu#g%{H;KWtwj;!#ggQIfV^g!`=tEFXtNl*DGME z4?30+8}a)M8@dUxi<5f~9|>k11g?@{s5TF%o@D<`_e+oJo$omkCf-EN+J}fQBXHA6mVl)5#!TedwHDfVznzE)KI1syovFx{x+KY=6 zD4`~tUYy-v9k8*vqaA$M!t3N1OR23EAO&qAeE(Z;mpZR^*dwfpJH>_NHk{q^_XUVd zg%Uvdfkfgm^LJwiuDuh9hGvX{lWL4a+Nn)`=`mF*KFZU8StA8IFK_N_0fWh~lHA7S z)06Ql!p5zPf+Y^zcodDcPkvIGiTu8c#-`2>j~C}%{w2$W$ajkLeJ9?h1$`GAw#lCy zh=S{-MzB#2r~e5OevB~&zYFwxgelH8LTx|~mKe_UL>cR(Jt=$A_IP^PEdgJ0D`UKe zf9U_wbl%}?|KHzlRn=CTTCEWjRin0O%?6=p@v-+FwRdREh#h-xRjXE_Mo@dNsx1h$ z_l|Yz_jlcYzW;o`uGcu{d7kHSQi)+}WBT}0j^XY4sD^uiQ{aQwj_m*nJF7lduvu`! z43Zps_bSH%EDSjASJs+JDSJu}lM81N8ZBmW&ANI|0S6$R@#b&T4yiFU0_AQ* zP6(v{+=n8Uz{TdN(9e`73n9YZfJhUGLnS0#Kd-Z87uYaJx;nD;EHTCG*Snb2jWYBP z7vk5oNtG4?RQyIe&r9Ut4sQ&Kkq7;*{Fzr~gd|30j2qSMgr@7ndY@kUya#Rq#| zERb|hIN4Dnp`pW?>hP96%M*VE7*Nq-=1=N@~MUakV$j64|r z85s^fiH1(w!A)N1K4_>RXT;X8j!~0aKDC5r>$7JQ;U!tCj1KA8uNClwO}_n1zKwL_ zdR!BkzqsoOx{eH}dQ!!zg7?LLKO*6Pn-ojE0SL6!|4N5hDzlAtGvRAA4ig+2AFXqNn>j|9c5<` zN}l8&CtuyLn{Il3RILWYqRhMmdLthQe%FuVO};rXOSQspzIUvR^{IOX=THYp#N!Oc zSEp0|1ZW$^KO<`2d-P+xel24S4>HU{X6Y7YH=X!|hN3E~`F&@sGk zEH9#?F%%^?Vdq*t6UBw|B2{QKk?la16-Y$!ciyqaVE?y0W0oy zE9dn{E!AQgJhf3~u%e2auQp1FvV`Dk-H@tOX-(?JTUE&s{pagja;r+5pqWi(-`7b5 zu>~h%v|gLD<0G?z0+WjHk2i$sEM0On5_8?jC}`i_h5so&qvMUpIUN(Sd`q4%tNd*Q zXs)v;;P#XA$}jKVr>W`0jqgm4BA{spZOu^=rMU zRod%nwab6QoaOGPZvwbh{WS`Dot6j|o)GDCIvTJBV`(@teye(K85{vZ#=w=Lr5~PJ z1XtdizRO}Cwis%sOY`qGXauT=ZraR29)OuA;VEPl1qJ@vj}jv4Tt!7bv2uD`lVwA{ zU9>+M6-fC;Cj4L1IpWK-%1A}Tq2K&h&KiB-RY^U>nxoTz@RX@bQ7?#&KS$&8Q+84= z8F=RTVKLLyp5c=ROqZnd=CCKZ{Pec;FNbOSQca<5E0Ljt7e!BNW~9PJ=g~Xkk?qx#w~*5hDWqaot%XB# z?Fh3Ev^bO zLM)jDO9y$Ljy-jD=O=D%qlYm+yh-5l`R4kI)G3nmb=_vhG~#sXhqS7|b6*kL?uh50 zV_G0dp1w|4ZikJm$eE6W&eDtyM7bwugr~t4J;z+R60t76H|=;e7`7g~PCMAS0|>A* zx`-xDJedOL)X!Ct?8d~BSNA%w{$Vy+opkG;gtu#>*0pvPGkb+7g7^FT%6&hlP|;G9EC|_=j9GWKh%cWfkjYZ*g8=rr@$umR(6F%mpG1`WwaL*C6o-o3x z(hm?-04!*Y>QpS`#O@LLc!|`#*EmnrbU(z@yHo(YihmBw6P)igwA5;0BL%SP)cTw-qe=IzC z`t3-(cc)V9D6irvClB=*^xbCfHV+mlK%)3MuP0^8i_sg1Dy?m0wp&gzei&LpO8wTK z2O^L(Qo-za^gz|UewR55n~^>gO&CMdhqflnDZ%p13b_pq`o5W`MJ(+_siH zGULjmNx87D9BnuczIR@i2`MK(qx@>eZw8Rpd3aJb#`hHykb}PI<%`*X5oj*ZDDVWq z_^O%&o~j%9Gl|1}n_s^ez_gILezj%v-psM5sl#s3)~gFg#^W^`_4kLRhEM3X&8T`5h;{|5Yn4*(vi}#l0RDw8f+Z_r3HZ7Wah;kUQXLZTQ#;eVyk2@Hs0(`7 z74JGpfHWyPW$%rxzl6xSrhYaZ6`<8;C)EWges1xjN>wuWSn<$XVUCoImuv(|0U|wl zEipJ*AG$)rRLAqI*s6vP#2UHRS?>_=?>L(CUC1B`~W~zWnGEjAav-G>gm5N5O zhe77TIap|;F$nywx@I)LlB;Un-%ix_S?zp&93g*lfDn**K@4CFQ|}4ak1o=8GS#J$ zpE;4KVXU_%HscSVbO?xH6xO&taV~5IEhCDL>-D_1{PeAvA3}4mn7^0m<9}1ptq(Lv zD4Uo&WolZHHhRQs{r!&2fzBPD*kF$qVo3yMZe~0aH+BC8kz@;AHt`Af|1@S*+>4v& zDZo#FX~{IAs-TaJAe=kPoRD%N*bxAUy&slzl(Hy4E4=m*c+oFWt)%26kcQ%KtU+b& z)h6b!<~P{0m68(2a_fHmeW$l#EGak!<@NM{BP6i7cT=gXWh{To3;FdJk3qVsNGv10 z9}VH{1F9&?lYCQ-AV#1MQq$kkd9pst3&lyLbl!#LGu;#H zaQQLt;XOJxl?Q%jEem-L?&9ewNiQsGrfAo`O5P@-=Y;$3ALbqtZu{k>S1(gnIKgg% zd{10BA${X=vCP;)# zFxO567JZ{Zjm6%_Y$B1=61qQuhozVyu@U3gArH63Y)xDV9w0|-48O4_?xKvJDuwIO z8*%Z{lHHI&Cr!6sYpI=G@`DZr+3*JFyUSjLqhLhLh?e{xU_%`rg+KDfio#|`wkiwo zYd-rOz!P3*uf^Ed4s-6e;XJ|W8FDabId+TePL3r*tW@8>+~onim!9KMZ!-U?DCmmY z4=tVYRVZDh6PEQp41$=C--wMnFX*8iUNt%@AD`kv)KJCm_}%x zN`^&I5-Yp{T3%}xOb}ROHAL6ah@XzmQ!Ma5CulB=9{^jnN|8|Y7 z51Rx<_buSU`({^7Q_S)~Q$T`M8)%@^i=*P6J|?5r2ta_)H;gf(aoZG!h$rPr~XcVHmIi0!opp~ zbf)s(e?Joe-hAYxrtMG~4tZ09Eg&;={1J+`a$02W<{#~R$%SuS>R8 zp4z8*>{s5hVM9reoUeRmt@B_!7DczhJ?R-4dP1-C7~>Zuj^|Zw9QxL#z}et+sU8uu znnmX1zidog-~=e2WbZ;y%DE@vX~rmP&jok`wc=-5Lzr-pfj{}h*;wY>2R1pAM19F8 zJ38hf(5_RW@OOGtM5-!2dSXTL8&E4R3c+w5NeT=NN1P1&Di-Xunl$mxvZyqQ458xL zcn){zxc3|$1h0uru1|l>cctZj{1D5i6%kPo*q-L1P>emT^7mjBMXdgpS@*R1$4hN9 zdC9Zz?w{*bJ-6N{<^dnz+(bm%Wpe+77@2W1E&8@MXA*LEu+cdaGHijSmx0>@ZY}uX z5g=`pX|1Ex=M_tP&%Uvc1OmFSRFF4-!D3!?bHHO{D_B9^@3NDTJ}5mO32s4z%^{^TnE%^ND)1e@2-3?L9|INYRBjjAHtH?q#-0v-c%B`{vf3?*9Y>xYE5~kaJDN z_~hA_D&{l*yiSl$=rSXua#QlySxXbr`jtScd9Xpt{Q-OT76;Q%6r+UO(W_R@t~Qkf zL2;ofSjv4fNxSIf`%*qFvq&P_4X*i>AjB<{Ql{%2XDDHXWK>LjDxX!S;N?N-LSeg$ zWv8ZMQ`aP3!+5}pXE_rebOQhAP$I~=j9_H-op069qjRjhGW4YOH|W(C1^H8Zq`*^r zAIFj8you!1eobZxkDZ|6r};B(JsRQ`M=5&n2Bs8($+1ZN4+26)S1W_ws}kcU%Ov`) z!|`-Yj~|u1tWrFcRr=Og9ZBMUG#^Q9-jlu(gp&AK`Ef-O;Y@E+)sRiL7R<@;fHIJf z`pFB=jpmZ)-?9ucj&ivt%)W5};ieBa>$^TZmByH~Tf49g-!}hz<|JO#s~mW|(=y_6 z&HgX&%aOn_I{arWJ0bVJpgzw4mjjq7Ui5LDZ)BmkAFPU}t*7v$^)?k6VF4Yg>l^*% zjU=QK&L=Q^e|S3l8Zc)iTCJ?ErwD*%dl^rPs;X`CJ$RMr-^hCwZG3}uGE)LK^>&NK zo(GKU+hGx;If@ZT-8W`~1 zWCcKV*C~@8WA}WC=T7Xs&LZaaS?S}CSQ<0$+^Y7EB9YPmVH~_|stemnV~w)!QV+{A z4!2VekzDvr_l5Aoj=UH{2YG%8tX@{8s7-*bmS1qm{`e-^^Y=mDw!%%Pxc|X7S>}uH z$RAoK{BtuN;m27p*S_3kPou`;2M2cdu~FJnSkDc%4R3+oj>Aoo`kb(` zuRsYPQK%vPs?bAG4GDVAv3H`};??|46yu@$g?xy6Ei_{7(GQR{mkZV!89*4kL=AsxfUjwH}!-mVs6ULd0}cdqdy5a=?(s>%QfOq8zz+q5U6AkpyE5OOBtzd^ z5bFGkR{Gd})DCibt%@D2DK5>i+_=q>RULug1iwE;u{!|VD1#`yAc%QYIFM4vm0N@r zV=>6y?8}_TV~;b4E7QC|QbjO3GAqd zpXqZ^*A;(c@{(u-*VNf@!n8JSGjK%zsM4UkTXao(B-zjLuc&C;q4wLz%2A!17i|+z ze;nE{K9Cw!%9WCi{uENCt~B)9R+G;(h6JgQ25d;m2AoYO0r_K~9Ma}4eWb}q>V(Xm zzujV8J(&}o;AYSn%|nKc@|cJ$Iy0Wh@dI0DmzOf@nhQJFj^XFqI+kvZcx1c|={wOb z36ek&&)&!^XZU-D+Y55~U2%6vs4!xArd3=27X#zTwpMIB{oK#Ru96%xa|Qirb3CbCR)IBx%J7|&P94qz(aCx$^OAY@l1NGZs=O3~t za(JCDDiQ1zt2HCZ#BKc*GnE@nUK$#=^G}w8`BGfvD4J&+O6+oiZOX0( zX=r8|_#H{Nnys2@R(~(xH?w+!_w%B`mHR^-2SVyJk>8Oh+OP{Bg@~_mHkUJa?Zsi? z-1K=!TCni{&S_@2qRb_?80l;7K=YL&OIZ}apVLr>R?v1}+d>)Uc%|ArW?7S@I=4U| z>Feb_} zcuD#+9>x{_ynW_(7%BCO#ZXSsTb^JNol2vK;s}NdNZBu^I>I<>FN$X;r*mdPfgQLE zKDrc}{zQ+8sGMX?zm@%mlTqgr&T=Po28|Mt+MP{i=oqI9HGj`YMMyi_O*JOQ@fow$ zphTD*_z16+6-{@Aw7O=q$L9zO>R$AwVDS0yQ9>|?YA$stTtMi|iH5~!ITG{+al}WO zV=lR#=9j>n)6+@{&UqKW6nA`odZBDTrj9v1F{yVoYzGIatI6z3>`sa%%2}u!Gq9Ra zXoBi02}>h%#7DY}s8>SVtN)4p$spVK*+c>N+h-G=M$AZKNf`Q$mFHXv=!5v)T3YCD z!yaS;r5$9h&nOMt7@I(jp6?)Yzg68gUZl8Qn|o0rJ@udVjts2UNK#G2R)0k8`o_t# zFta`Gcb5HD;s9}eq<8bCxJv|eKlyRJ7`PeJ)EqsNT{6no=G`};G}0fb{bYg1h{?FS zSnK`zgyfLPyLhBRsCT#Cj{Tc(Plr;;GP3?I?MOEFHcy9zC~(g#HSLCx3O3DqqiT1S zW*{gOv76LmRS60h#p{u}%3D|nE&|)u$5RtvFGEL{z2@sN_47%@Yb`WSM%dQ$UH+a8 zdSqA~CP5bm0#C{AR;tZ``}Z&HD;(=k%tY6%E&%7Qr}e6pneVwO6*oQwH9%SL3;Zl# zIi4L?y4hc>B3lrY%Knhj0$}Nqo8ch8$l+{dB61~7Gl9~awbxJEW8*ZGZlaMq0zFe+ zqYqzmNi449Uz3A6RBy9szCLXNdJdXxb!@>_|CoU;T^+>2>hlHz0(ZBpolCg%>GhN9 zS@wN}Gr4?$N1OlsjQc%L;tvoUqb*O`i`ZLiy>RFR zv(_#398gq~N{6W7!@Wz#%(|1_c+~J-k@-wCk!&K7AdAa+(gy||%D+^LZi>t!yA1m{ z)O~I{PV<(;s1Ej`$o;0@IR3P%NAWwyp}sb|tK=Xx$>~OuY(mm=7!~!iQVTZsE404_w9K#@ID)Gu@ zlZ(3Ig0nQIGfBvl+gixk2a({foz6p*poH%9pP@`9(Hu;hVkqhF(F(MA1nq*oCfr*3 zyzva*%B+N=hj0YgbT--h;-uS*tw|wD^Na3vr$zvNPzxvAZ_YCQXpSzE)LHFYskDF} ztfyg=Cef=(!8zLds^d5*pTH`Rh)rj!_a#`Py}=yR^jw&bDFuU{tq|65NL#vu8JK=8 zmVLe`OQE!Sc#5q(H`}1Z?m6&f3sP)E4HGKzFJ{fXBpCI%DkMK3{3kJU!rD5T!lwXD z{(Gw9A`gGop-X2R{&wzgim!jZ!VX;W}hxrjFtpYQgM#TX4MFy!g}b(wIdI7bF{? z2_!HQr3&6V`--l{5BL!UTkr8csOs@|lV=?J&RKg{rn>SpwBB$Yt6^x_Nfv_b<`FY7 zlWTnZ_D53aQl%syaBbh&*_(lFDpM(`Ju)WMh@rrk*t@zYYnHF2(YA-C+2j*iU@OCW zc34qHAph%om-2vJ?gBKz{OYfczhU4X^W&$N9STFvI-uZ7J((o%0gGz#T{* z`Ndfei%~oSYIt?K(&lT!Qp(k|s`7F%uwC~w8@tf(*%kZcEmOIbGV}}9f z7q~5`nb!<|TFbyPU4LeAwX&-^qs+~}iTgt#)s?!iyxPuy)<&MNK7o_edIBvJtSS&uHM?LKYa6GPebj<^<-@F1BLPcGnWTTbuBdUgDN=GCHeV|5F zWSihUtU(kNg;s>#D4`BgFrNWH07QF3D`H9r&|~w4)+bv^_vFE#!lLH~5^gNU2EK76 z^Y!KhBcIy}Di$tK60Kr-T%Ujgblma>Pv||cZ}eU4rUbBjNnX8OqW+gGcQ;izRXt~# zQu{evIJeHy|284CZ9folmO3)47Nc71LyE@TDBN5nS&;ALO$Q3u69$2Ai-XveWBNsH zb8het z^-OwpZ!gu_AxczXoSo=$TYv7J70xGEN5N7}ekblEXs@Z0-yG!=keFFrJeEYNx&#ri zgc_|RsxN*3N5MXEe~#ZyJvASW7I@p1!yBu<1y&`QZee-mF!rJ@)X*cCfcxUGwYuEl zCZaOt?Vv5<9!3ab>1smS%BuYup3)Y7lETND=^x_5U<^ob5&e@ccbh`Pc~cmj~cy4(V_nTOka;#!^cESV8v8AP)}ibZk0l zdZ!2`eD3o!dGdq6|Mez=a4$Y~kG#sDn$B9vipX+L+vJlG(GV8@CHjafxE#a__i&ed z7mn0bTWzN-p>#h%JBro3==oeW*wD)I8s-wetW1i4S|#y)^5p^J^Sra7sA^2v0`)s^D3>Wz!}e zTI4h_X(TsJch!=~C~o#AJp34R-deNO$7qlx`1g@yCDr42ent2h)1T=+k7rDH-2#0^ z%wdRKxqML><<`gtIOD(a?#y?Y&O1wZZ)Q;BY)np>aQ@#1- zw!g=1HzeOZd7QiR8IBQ3=ym5$wZ|bkeI{b zECe{0dwq5aE6N?MR)}~4ek7y@h^F;Ug^1)X%w4SYgWzd`C|7x7|MQ{>mULyN~ zX!V3{&ztX#ltX5Ca{3!RJ-s~Z+UNjwHi$KEaH;wUxJLOZQxUTLa5*MEQAHA@OK%aa zJVK6Jk0@g|^iS$8D6U4&&EH(gF{w0tC27yNx1qseaP+ z_RG4Ir(im$aHe+SHpE}yI=F4vDc?YyNnOoq%Gd+`bc!v9rxxVku%Sps>{EeP12R{f zcVw+w{~Ec~DZd=^C8<-M^Dl0pZ@li#;zO|Gh3Tz~TfEzyt?)`ETS=&d?YQV;<$J>} zin0PppH4R?@{Y?;{NvY=7j%>GoT1gXLX;u|JV@6tWaw94ZXkP94)_9AfBP8!zp=E{ zV9Z!W9b-M4@o7<+9u6iRT|t+Wl7P-PF&Qsl7WkoPdWsv@>e^0!Z^{3(xUJL8600e} zLy||`<#ow4Kr(Q>o-C!y20iWXqTh%{lEvtK7AE!0CV|tin00!=m@Edz{1#W|{)F%d zk&Rs9MFecD{PtrqWtoQf;f|l!za_6^is)Xx@f)uX+dt8kLOqVMr(`i+8om0M)?ZNJ zI1EQCY&s(3Y0xjHsw47yu65H~tN*ho+_+r)^($Gok~iAxbFQhId7i&R4@g+d_*VU( zoCA+;sDd(vCc1K1`hX3SxK;BZkN>E<=Y{rsBsN^wo{3fM>Z%c&W-XdAZ0uOuFpZfd zk?un@j;~hbD66QrU{qrrIQ=Ff&tT-dE&>luB@nxkbQM0To-si{U$N1Z>Mt zt}bK9l*9mvd7tkX^RRr*$CK#_!irZBvN6ojgky6xD&wmw)XfO1VreX&eRgOO`Cd?| ze|y`%VHFyLaCK4kKOOU6T%K^s{QFHVo=DH(HY2fc@##Y`>T;mV!+klT0v4ZW(^<)ho%oV9Ij03S~)v$rwp zpFVkYbTuF5ds;#1d-F1T7rRD|3bKc>Di86|NdQP_)Wz?m&Y5v(JGUzxSC-3dxKv{o zO_46bVV!}?9uUm9eX-$~j!e4QT$ z2XJCk_W*c+DX=QU%p*a>huiwQ0Lb#F1nibz>9ax;xH?HM?E!uO)~U)bQ)P;lzzszIr^O>;e?uRn9E2_mgLBWoprPJDOz`t zD&wwK2=}(1e7o!4LY8r(9GYO<`k6cNZ)i?zr*5{msHLj~J>NKxC12ylyGbFn2B9yY zZ?1dgd*-FrlZqmvv&H)fjO4H%b51$v z;+fJ@Pki=0-VP)sPLgB3aOCY;8^nJR;i1r{E2N_)&CzILl*N}` zp`>T?jm$V(qRy?*5OQV9Lo3lwHe!tOIiB>12~N$~#V9RFbsQd=-*)L=7sy=K_aIec z)qb@Uy?t+0BM9K;K3R#o1&O*6X_1u8AL2mOW+g}5xIau2oN3BYjqx+` zaAxQJYN!^$)rxW`D^0UQ3jgdDjqwos{j8Q#83WK()DOl(#&bPbSqihBtsfPIPr&j5 z=G6y$w>}c}zo{AtIyx^B{DVUYl|FgFoL?M>^vCDfFF)1(C3f8@yNh$_Hou)yF4~!g ze6Kn!Z}3JzC}z^$fdr;N@WjDIF+RRnHbK}|MaKCE)-|(oMMaOUn_xRBT$`ipRkSlg6EeJ`VYCU1dtAP_XjL7akHrK%K{FPqNMve%{k>o@XC z7=^Yw=6`4S8@cz@!7W3{I2FM#tPa^bt?ml`JDN1`Q95T_@TW45v?~qmNmE)w@jkEg z5%}Oh6>^ybWqmEP-L{cI@q6FM!F$OAf<9ldd@9|KOxz`nysH`7m*@``dD1-@m$Zbmj+5qddJhI6)tcmmBv1h(;b{-<0`^ zh>x%Y>K!`8lXd^%agEF^FADQi#@vhgLD;(Le>tbUK7*WB_uKI_`QfC(ssr9!-h}2t zK?V;oWUK1it?;QNvzIY8)mkoYDjVC~K^^+!?rpl@&LQ%~D@)v~{?A0UlTQN}B}QC6A7rmS2vu~TmSc&`V@dI^NleSVt!QBnZBJOwUUPH-bG5%R)Vyft z0~=%QzE%;NVc>n<#xV>PC&wUz92)x#?5C{fp>_#7HX0AA@YgtJVk_Pk8E(eEzji<#L+*T>fml%ev3hNn zH#km7yH-#u9FTU*9Xw<$l1L#Mnk+A&^D8_hiKtin9N=;m@QO9^CEL{YfO&Pog|zX3 zR$V!9PzwjEnhy`$JLdZ6r227)?#00LxhG64Z}WJmHD&*fsJf_DKgxSM6jLU1&r}Kw zh5g@lI|!Wp_hjhd-VWd0V5X>Ws(jUR2;$OapkwX{h5P($E79(-Fm|6>&d6jsv!bIkL&DRu0N6G zjcYTrb~kIwPEq+0GCMl^S-46UlE?uvrc^dz8q#n#3TzP%;<4-+Ee}xTUKdn+t>c;b zE!+J75s^nO9$-^F`b%!GH>w;#@%bxr)3jCz51krX|A%+hRzbkrQX$u}m>|q??C^Gd zUK_+Xe4nfJL;6JdX+;$Gwg|0-<9l`hJk4 zm}>t)I*v|HL5KOIyd**iNapV3_X9@sOLeE6+Rtm%52opdcDKG5av1D8p#>jRvhZ^3zi-1&k z7_2;D@E!d{xB%kqu>p+PpXQV$9~5+eW5t)$jWdy1oY^Opj@pUE`;a z_`Zf85}&hNHTS3!?A4ZHoW@VJ!x_<2v^~$~^>aGnwo?WBw~7X8Xix$5r#;&>#0vb~b56K6F#MbVemp8{JyH6DUg-F~&fhOX#KZLRomGN}N zIUqqR$DH+bG73rO0OItB`xfkN{cB})zXM+F2Lm2?$**c)BbLL{(T;b<%}Ywd zz5!Ib0q*qZ>2V<#SrR4xra=(R+RY(D8XvX*Uw%+KU@zh-L`Q0aAal85U zKy(|1?Am9i^=Pj(9Y1pi{2>d*jjsyIOM9KEdGmY2!0u~#bJ^pxZ+UMH#n&R2E&U68 z=GglzHjezck-p>K*GuF?yyKS*>37%R?%!5;%+p`qPggu!(^ksV_sv1(Kol;sG+b+lzUuaPMt za=|G*-5%VgtzVwhIeLx#CXzTN8dKxz@d<6;Zn$s(hfmy6(|X50Vw)mWVot)&-R)ep zUQ(9-+nu*VoVKdlqB3D?K%%FjXUVT-%HK zoLG%{>9cf2Z9jVJ%c~zYP6fSgw36#jGQG$k(r5R@brwFH_2hmfqEi8R>}5Tr(_brq z3=H3DL@h*ZQ8ff*+*G61zy^nUVO0jIJ~nnTNy|%*@3TwwvCg+Epp*TLu@vQ_;5*lu zUvx}1E%lEU`T}2@@%$>FtY^h#_}PVYsnzkm0`rRcv3i6Sq@rLNA7|Y?9paiyOXgZ0 z&k7i^{L0G-iT2ihL8o#U{{@L~NSlB&J{+zAD%r{^#)Mf96m9~f{mq4P_82hzN>Aw5 zrr0RG>c>i|1uIQ-S0hq}aACo-TJQBy^}V^J*J35gY8pinVbBpb;N_K6Xnwz#4QAOZ zpRqM}jDvgq&y&eEH=>A-G}y{)g||@|2&F3X{+}WD2>xIL4RP{*y`Z#%Xku3a6GqeP z1GzU6y$@G*i_Pl!lzeZsqZ~cO8Wsc$Jzvo4_fWpq6KB=gpg#On ze>bZQ?9tew`6H^qzu?ask!_`CBd_Tk7ccS3VlbO!1E7M3+_RjI@cfjA)hO>kTIr#<3&1hg z6a9!_etuBk#t9$$Jc%r3O14v9geKkU#&;lI@!+}Npj?63vo)8IPb57pK-f(98$OYj z19=z^Mq=FnpY8Ce!V4p%6!bD%Y>cc@2AY167=ogideHvy32gFgNt<;lvx6XGf5KaxgxSDR4q{|;UXc99Xor8m)ekL z=}O!}X_p|t=mP;J?W-_a->8kZrEwI0h}$bW6Sv^m4TU2S1@^3Q|CKW?dz3*EY$$%k z?fREmMr>eu^>3Dd#32Vyb)WCbYkRZVMD*~{b+H>Ko;;KjI#B8 zYAz#ODWK1#ZS#Dh+UCaw+P@$)H6fagwub)Zw{$XRC0s`k=84bsK;Pco1v2XQ^wTnak-3;MP0P#pohAJ9B(vW7 zt4F@G>_*7!2k}FZ|I#&i1mM-sFzFFri?1~5^Z<8-OdCVPINIB-Bu*KG_*txnZHplD z)tiO9yw7D}vFH?PdlhzZVhLmRhz zzVQuN4%G|7mwq16`z_hFGi@)4MZZMP&-DTR=R9c%5a1Js zfUb%AN;6^XiO1u{-DS>0&;TjL2|Fm}GUOX$ZIf%BBXA3sVt|@Oy`I0ZlN8I@4O0A^p#Lm}9b;g2O%VMP!@yY$7@n2<`Wz zwxEu=8lQOzjmm6rFmOtA4OTjyjnmd>lpF&0=NY@M?KZtcbg6T7t2KX3>&$w`Z5^-q zlfCyt;+I;%m$RrjiuLOF!!{lgL!AeLs0QYoTC;rB&s%fspT0%siKzZ!jy8;p+{K({ z^Mm3^DjMsNKpGh!tTZ!~3jm>^puEd$GDtYUI}h{&_L<1DwDW@V$VyW$RyfLtFJ?dB z!6{>Dl&~o{a`d}pi7WZ3hHFdL-*XPPw>t**Tkq6k-~M9=1uL};{G%M+6N>}4(f4vJ zXwwp)%(=X6o+fj(mV0Gb<%VDm=F=%Uc@JmTGRj!5(cgf0`km6(Z1= zalI8O8bJ9ZMnWT8MMern$VEdqMHotnxNtxz$<5{#8|3cWD~I=XJ0*O4%rZ^k+p0E(FR%*8+|N>MVlwj#aqUn@b|eNbPir(7R)Vm>I3|4 zskKD|K63S#1jRj6UkqUaeP{gSX_@!iETVP}01|Kf7*a*lJ0 z8#m{^Eprz{5tvgk{fk3`IL7=hFR9fRjr<(@nfQZ|fE*cev{{~U{ceTKtDLRp4WaU8 z^vFc&&RPWq!-rGqyZq8hUupClo^K*;PkQX_Bcp+Cn$ngS{=m*%Jq>!9gKcv2f9doR zxtKBRX6XeG@BIo|kc|SL6XYersd_e8#+Xy<^{XPPW{Uae*^S76zSVM}#+RkXUNLIZ zWAH)MXZm?#N<6*?m1c?#Mo7LQIg48PSI&Syumh{3rYkYn2+)GfS3R`TUo}3{YIrM@ z7Exe8vA6LC;Lx=*M{Twdg)GSu!5HA6i!dRkO<9SQxA4g(+s=$41A= zH7f9!4OEF>Y|KsZ9EKk?9(*cAx;`=CqF+~dGbV|upD|M+`uPcWL3e$-V8h$5ghHDC z84=0T8-c~!)KrDHyl$h$0j?gLb%zBC^S<=jbX1urY`Wil> zSLdqlASU4a6`e2F!tUbo+;Lm@z;t%|L9R1xHbzfpthiAfB z)lUg>Y=20aU9M0dC7&Z;i0Toe;n)XGeJe}2WO^Q`YA@-c&2i-cUI6DGljo(2^$9_! z&s)1f)Y#gLTLqT9a`z2N$3xFGHqYE$8z<~(9tIlUEA}ejvzH34XVh63#D#w+#{5vk z^F}^4y=(gUBCs9sxC{OGRgwANg`!ephuiVf^3yJJ0}Nl^5Cq@U0COm8;eT3$GP6|R z8LA+=Fj0*3)TZ%1UOQ_$9W69s-il6<*rdCgjc9hBK9$;g^SsaO`Fe7}nbbbtmL1w{ zDrZj=bo8x?Bo3#?uj6K z_V7;h-+LXbuyi>x!kjgoS!euXWl4U6Cx^6X!s>qH0Nm*>1V#x^wphrRF8ZX_yg__N zySt&IgPq;mdc0uL>#YC(c)F(WN`q|~+qP}nw(U&p9ox2Tn-flKO>9lfiS69XIp4WY zd%t$~LUsMCs|@!*Q5&SC{mjlNwW+8kwA#^rzSfPKX5v_%G;ZCXVRCDCRWeF;;Eg`f z8cQ~`Cl>h+CU8@0e??`9kS>FqaAo!3RX?Ki28d5!WcQ`o`TtS>GsQt8zhgCWi~+-B zITV|S$v^J%a25AT)Vpuk`Ea50u~I4(kc?f4z$UFpS#JJL<5M54hdUQoRfvy!vB|TW zu_vUF#1|s{`04Yj-JV_vM|(7LZ$LO|lvuoS;B%5N*BKmsa4kAHBS!ANZ|U$k19@K3 zFd^q@%+06c$mrPgH)$3g)Ay&-~EWd>>&}&rA;2J z&cEDI>k-K00TOC>&5iys$6PV1jzV#sh`9g=h6x z5M5J<1tOo=#ocV&jnIZtC!M+L;eB|)=#FG-Y;zoGvLH}&W!~K4#pTAPOWF;&*MS|_ z$pojm!IG-&0WQuY>x?^FxBl*)#0YxZ?~KMBSXqdze-F6Xv=Oe)CQ1VrsLBs^EANf}y6TT}XVz2K(@XjO} zd>xVLo->MB5Gg5{wCJ5#liQDPO_;lrdvBK?t^;ZUT5~(|`1lW(gwMb0sfrRoL?TY2 zl|SUhRIgJsHz;sXrSMObz0;sC&upBN^*2-wJ6aCK2&3FmXZ}7KPJK?5>Us|aFRr3F zZxE2~CvMS8lhePxJM)c`&;A(od%oB>KlW`0PHt*PlJL7t{B1vA<9t4X%ms}pVWTMo z-g3gA*BGl^aXSGsW1h^{RQ2=WZ*8iqF=;HnwYfLW#Q8G^#2locxs;0<3aP zojFx?-${k6!^P+m7K2iuEoQxmC{YXsFyIGU7aGuPW8&YcpcY}?{gE;gIbh_MIYfPb zy!ugGiF(5r^9NIxkFP*ExAO<>_{ZVtnS#kt30var-dYyl6Q9x-dCH(!Q089{D6wg-fv$F~Oe?9|tF3(%a}!XykQ44b^{5H(l!g)FH+2;dyG#BkjL# z&={zBlZ7}R8Be7e_LlW#s(iWZ!!J-9UU0eWYS z-F6hvbN@Ay7Le7Trbkz2&E=6p$m*t8g5uVgMbmPiENta_XDm5bW9qy{Yl}&FlMSad z;ps^2{6tL^6mId-BIAqnUU6l2yPL5%>*GDME;vcsE%$BN*t(iVw*3vBIgv(W-up$J z?O`zotxjxZd)(D9AEe_?)WsdkzGoVfrfK>Byyz^&bCZhk76EA)VE300@_P|oQ63n2 zDLnqGjuA3bJPM;w9tyJy{bR742}rKzDA~XCz3U79nG`;TL3&t^TTZhEZ@#lj7K|eh z2YXbybg5kswENnO{43<*;h}1Nk!YTpZ$;(5Ya*K4vH8U=-ux5P2ksuU_8eOgG5<;1 zx3BumjrqS1UKub)``R_lkWDgw4|NfCo;p%aS3?PpXbJ}62$=p{H_nc9)ZniJYfN^! zrenxqOI-}Rm0RAh;8?b&@OwEk*Cc>X7NIck!OfFYcHjzT&iHED?!bBKYMcZl5o7dgySPI$07^P9MdhVEvWZawL6hT^buuOU+5Nh4Hn+q1Cb&tXpEO;b}W!3+0&%k(x!$Jp>($9bKY z*P?H?&-%~qBpcjyIQ|3O-$OwMb@FkS6Gv1_9NxsF8DzE~t;gjLCuPSSg}DAw{b2Pb zr|oy=z?<@W{c61pfRwxgft6rd3|}Jcj>wf&C=Q5~u>Hq%B*D*9Ql7UrLVaWeSffnUt1)DUq5780jqoBUw>yl zH#@Ag@o%%@eav(cJP7{qL!5Pe_l|&ns*9jt4?;W--8+5_!(j`Daw&LkH%S8Yr@8MN zZX_+K<0%>_MadqPx*=-Ii*=uV`bB!9(>9jJ?zurR(=hL+U^1hO+2fnM%|+Iv{nFo_52(z-+cyDe^cq*x3eIDu zD%LrWo4$}$b$^R$mrh@PYPC;u;Nd4k?gCcO4C2~akK^CbS?{NI5ih$i9@h=2Y(rH} zFe39L)y7?d66nI4TQj8{ZqS3yHE*R^sFC<(XYfAMyvE}fiP{BTkZ)twrO-u+NJ?3D zES`%-{VVHEPt+jzG5Pr+Iq}g=>F`j!?YMP+_F1pDMj^)gVgI(gnrdY=RkGT*Nc*mF z%O>QDalL4EjPdW82@QMPjiSB>BBNjJ!@t%W0d@X$hZyL~)Vl-4G|qo{Ee|&ZC70*7 zlKONPfj^Y&^@p)^JoWWXtduC`g8kziwfnP@JFS#$1z-j~8e`reft=Zx=UB_|T z+WTY-;xo-KnHoX{yZ*olpU9Bs)Myoa;otZqAee@)nYZnosh)xZ5pxy_JTtKt4Qwi9 zl73E5JWAl56lRR|CjzAFdH^Vz_eu<8;jFB=f(C9@tXj7K3TxJAJ)oku;Itb!l^M$= zzC`(W8kRBT6Bv%kPPD~3v1a1w{llmCc&J;uWL3PWr0O?jqNjscjv?~XCKu&&toL@e z92ekKt22H8oI#=gSvAlXKb~9C-7;Fx|5umU07&NJi$>V>Jmwv^{3FD$!pyi(I!2X0 zk%YCeJh3&t*a2dZG9eFmsY&6VoT;ULWUK-Fd*Gmo} z2?Bmm1xS^k6R{ge-}$OsVR~(pGwIdatc(qq%P=x27U!R85*^?wj}#c|oYT*Xrzj1% zU@zK;XEr2v(e(;)_Q~{Y2y$!+eQ#536Ws%<_jnG3oT8bx9%aEJ-8{;zMXyj@Lsxza zoa0Y_=j0U)=PlMl{@^-Q3~FZYw0+(PzPwb5{bpmo{>{z=8fQ^APkrs~e_gB!4%Wu7 z#Zei#sv6!!2rNpGE;l>azlVK@)UiIX{BW?7aVuJ{M}WcY_Dt?RRwlnd%2>H!>|{(H zYzKxBbn30WSL8o#Tw;cTDt<5vDXgztxzopTLuS}}Kki#)M9V^&`gt%`1PS2AV7Gk9 z(qXp&(S0RmpwPbt`rBT~l$s#K&O;A`k5ZMAHYaY9JrF?26(vW1AEF3*s;gLaDvA~8 z=;O=hiLH6kNjuW1;!+H{^v090oV;OTk!QUPEJdNX8a>>PO&Y+Xb2C(xvwy<8;7JC( zJB}AiEbaS4(X@Q3IUrYz80$FIlm~qa!=%b{3G2cuhHB;}YuNV}RYtL-2Uq%ayXc~L zwU@)l;4~nRK<%)su)gYNzs`@a%|Zv$sIa9pFrr54FWbkH6Sc9-GKjnmh=$RBzm*UN zUmwc@T>5w$G)xJ>x}O1~H|#aJhdfWLczZCmyN#?Eo4`R%In-=iPku(IS+kB2^q`Ui zULMcRDD=Hj{n2-r$9R5_+=t!#*v*uP<(f)fNnOEU$GcPo4nybBv+UL|PgQ;wuurpH5D+fA?5 zs(?EZup%psCIAufNDfz$*2SmuwH<+&{5kf>n8l7ZP)8Pxvzp7+eDWF=5#c+MVR|k4 z<8|k8(n>xSPRiF23~`_;sW47t2|3#9evQfRTlF(7pem^L75s6U69@Om%p5eZcM`7m)hJ9v*CzdD)k9W!o;Hqd%?IqUTGXu zyb%FwW@bee^GQ&u^$&Z};vTPH1P0C3Rlw8z{Z`=U2z*kL7Xp}YdO9rmlycM2UqPZN zW|&rDPs+RqQqD*xM0d_YvR>fwBk{ctQ8;eO(MU z0Xj2zaTsym*77}SYxB*?cHB^>pOIrcEmMcj&NZ<9f}$dSc%15JVm;&%tL3+PxP5*7 z_*+}}?<^{S>CDP111-t|H(t4G^uFwJ>dJ)=-43jY7f2TD@R}(N6s@_s!YCD)27E0k zFixQP#KJ8e(;^YH-Fm>k_l?V8ZD&Vw%Wksrk+A9AXOw0&&|cc{+g(=YwJ(hBKuFgt z3s~aP`ws1QB15@`8Nio`W`P8kC=xIa?ryXQ1EI?{cP|VCDI#PxhP0Qb%LM4NZ=EzY zsTcE@yB)H)GO8<3w`~NXa`wY;Eybk3sN1ljdSDQw9<7Cv7I;>3mji+;BoBxwzV!_6 z*TPg=#@3j-r>Wm?IiV4YLm}qYyng1=K|`T02Fql)P2BJe(yLLI)R=_*5kop>s)yEt zF#D?vdI^{~U5Hplfgp2FH5?C4ip>`=SSCN`L-VSQ2z9o)BdmxYpmga6@)sT>Zp|sh zp$FjXrz>uqak^%`Gx8IivlRaqguQ*7;s;S~S2`uwq1>%o9nCN_sBSuHKr`xtDS`or zR@?~kKIrc5HehL`Y1W%!!eIo#+VVY7Ul@ge1RFNqwoZ?K}EzS5TF zNcL0*0CDLtv*J_n^sP|Cjo=J;{cdzLp1|*B??`mP=1IWr>?@jqR`bO-VPguqbN^$Q zzT5aQZF)Vj>MPIaz^nV?=<&5(!fn(s%Js3`Vjz}q%#g7P(10GuqjwvFKy*fJAv;&;(2)n>_2Nh;@r)83PciuM$ znco*-0(lu+1(n=bu-trF|C*`{AJ1gImq9Wk%-9zE8(n$PLs9xbQj*~qshLtbz>fG8 ze-z&QZ~bUb^`4w6=!_gF>yC$cRZh@sfFJgCi`>vf&jHPakmIJu@;Vi5#)&SjFE|!o zHQxL9%`U1ute|EPy(pVg#j~8{d4#yN1tZRCf?2t(bipF!9V~c0iw=o}bF_;MxbvHk zP2tzSc?0ea5~XDn)RT#GhN6OCaEdz}_r>C+4TgkbOF8%?W; z61p3#%I^+d*LSwgChn~-+1bYyPpzZ{P&XFq`CoQ={$IRr8*ii8$+>?X3i8cA!+BoX zTx;5V(3S*UlAO~_SQL(Jt4|<(3m}dB?8Q@0$Qzos(E^o&sc03hWjm^z4;5?G7y>5l z1M%r*sW@YG!O{foaaFKo?xE8VG_>eRU$D@m0d+4HC&b*x667BiJ|&w?gSlx_RC$-G zCoLFMX)&>55*V(K2IwAr&i%}h`8g_aJ#pDM=WL8RCwM1PGbduOel>uuGQ0oeKk&%f zfA{!n?(2QVpBS^uu)@^xMoEv?o7bSOB4%)RT>5P0&&s6|U#K&~=nA60$O0|F&bd%% zd|UiobIzV53j(^LHKy*2CmPj7gj>5I@m`+t@C2QV!QHSU_)ysuh%I;Azs~dX&&Hp0YvDB*&u4wCAX1=JkrbOkaN5}m-qiz? zTDWYCxV~Ri1?#~nCF|zx$RR1D$#OddcJME;azm;5%r?yhU`DRRESVq=-^Z7)w}f( z`Z{yC_%$ED^GItW7xI6R&m7YS(+kZ#Xjy4s@j(pgn3PT1;ou3D*8xL>d+Zbw%NiX! znrEyRO3vP}n#3EA-(H~94+VlEur>`$=egU`wIz}T8m(Vc0MGUB4PmJYXU1b{6I>yk zWO)AB*D{NX6?is0tXZbozEl){5!(0{h+_Yeox(atWHunL#~;01{5qikH-?`uz*$=;>+Xc=xM6e$l@1y?B{^EfPv?{-=7i+~>@V%xT zgho}^R*u;1s?L!!?)t>wK>6qg4`;E+^u1Cm4run%f>4jMNV>qAd?rBzMqx4++J!%P003-5TSZzf1YQ5cM4n5)j`|N32288W{(UXsu>6nt<)FK6?>R6? z{-XJ~XnWX~X4G*jM5%st89)$TfsNk@2q>g*5DB-}6R+#jx2Djps*A9QCRyEMvFNE3lds*V=GE^l zs@l`noVW96hJ83o@;NW@e)W8=1+ogHZpEy2BO}^12pjivh(OKlvBc5{?2g$+nj?j( z8NS7iCrAUXuvs0BkSW?*cl!PH69Z&1pyRDB>Ad4Yqw#FE7lOSqkF<)$6BmzXwa zuzVL?a7AyFf)Tuc3MdvkHQp+%GLQN6X6Vv4X46Onkx3HRe2Ev_?XO_}mA1~E%3jO$R-55y`3&XXwoSXWcEEHR_LUP==$zi=6B%e;{Gb#O{`9WD`BY|o~qalC)tC7fMt3##*2))fI1sI4!vA4 zaI55=>cO4alz4KpzvE6YoNUsBKg!?e35|2Uw`k2$h}7o0n||B=ZTLT83ML5(wy>tE z?#DL;BX;EzL*u|fI#5QV9xu%V@l+5&w6?hrKoWfRCCWNk?&sjRNVTDs`ZhMK3+wVj zXArKDd0vCjFCl_fZ`%hr<+xFxKn+ahxuxN7sSxRxxt?T{qzTkC(s=(sE_ZrTCt^!5 zDIqcki3Qc4OMnnyZrmgO__Kum5;@aR(c+iF3f%W_vVv&y>a*zCm?p|fRr(x*!whi} znRD8Px)YPz$K@VaX$MKIREDU;#PH#!d-M?`nopgJqE@8o|AqymO|i=&B(-|#uRUc1 z$CE=a6U!D$2%%mAifhMGmLsE}p=n9{ae|laqC`$$^7Izo`!I>y%j!_ArSAO1&7Oe# z#2qU4IJn$*arHGEnTF^$T{1yf7j?RwTq@JvwWzM9G*`FSfnQ*mp%tzxw{`oTfakNW zlZFRapBO?6TM7%)*5E#EpGulB?Mu}iFVY-yO=aP$7CJytc$PCwOx-UrOEQufD-7|O z3~N5f4Ep6bo7EIVl690pGTeohdatz2n=Q$S;KWXft~6tzRG>g2hiTK4jec8i$MLPYy#HUwfVnr#WW2^pXI5GJha2 zLo9Tf6G-UwQ0#gvq9npaf;J!5VAsCr1w3{lri4QDvt@d$*!7;N``Ke5zW;1xUS#i@ zh_2*b56MbN!r%I2*(HoSh>yK=s60`z5Isw|fjB=}T+IdvFpAuh^4hobnI7M}2hlE9 zk`|w;`+d&%EBuRA!zGwOME<(GooVg)l1WePT^5YY0ah3m-)ITZQq9J`kT~b$J5*#I zW0VsIAYcGm@?G{_Rx1@{LcyUcBuTu0cHIc5Fw9J1oCy65=LTRkY6Ftr$CZ=S4%6+Chybl9>Gvue92*sVT&CU;J zQARtG-A#aByHFTL9vcE2lmQ}_nP3j19-h7>)te{WU38bzGoXk|!M-=)IMfJeAx)^T z4O7Bkebn>TBHWflNBeUqjLE{t>t`}X_Y2s?CM|Lq4E+ZQ@wy6GwCCt(xz89gx+@6T ziF~d!s5u!}iLL)veUIz=QvUZtIN`>rb85EhbO-wxbdSwMd&6Xw8D%Pe0#y~BLj_*} zUzU*lbb5hwSNnnepDfer3g%=)k;)^z!^>E=WbhDNP#>xXnQ-yoODE8x{#K+e0I@tT zn0zb|)GqN>ZDAOpkg+0S%eOU3q7hasGuDlZ#vtf51Ut@rtSv@M@=?K-GW|1qNCP{` zY|X@fjs18DXwdp!fhh&1U&&+Whp1DWF&*oQ<9pVY8V_9{Opi!ZcH2@c__a=RuYzUtmqk*W5|w2*|8H=#!*c zzhE=fnk6CZ?_t5{j)(qCgRQ0DTscMaz3G%DQQs2WCcNsI04)ss{b4Z&gqS8X#rT(U zP*7}xY?}B`rL(Y~N!j9V?a6bR+oaBH(jyg#h9~_z@WIoLC%%(JcE|X(fnCpzc(Cio zZ<|q84ZhPAt;qA)Ig9!}Di5svP`{T9n)Dikux`F$p3&odb!uS6xMT!kMty02uDnal z5z6NI^tlb)ul@Y@y+tx-PWEeVwg!*c8Di5Wf;tHMuQf%5!YKH6kUJefPGjFuWHeP6BVLD0&L5aFT;@N-=x<%Mh(rJ1OdVGdv@^ z6?w?pSyj)SL$Ck)(%*N1SiBbp zb3qN>$BnnGAMyPH;oPJ(q0(Qv{FuAW$&SM>fuAD4#@7N5?0kdcFQzdbHX+ z12?i5eJ{h%w?W|>pnbtY3|c@z@J^G9c|Agc5{cJ+Or%2%?7oN;f&$i=AsWODAN{6l zeXJ7tk`%6!YW|>jYzQ)zs@J2u-F|-mf}%1I8{ne=PQcTdb5Y&nHPo^0yopYa6~cDl zt_xI%O3Z~47%wYox|0UZ4ek(>9FNwM2Fhhg^lDPKtA5b=f%|nfm0d6B!9l2uTRSsg zez(ur_T=*EFPVJA&0}#ZH&Jpfy~Jc32?3isF0dGb3khp3CL=rh;z9%@rc2C+rgrm_ zds_5LONienhsFE^xEa6N!&QNWE}3~WD(v%}FAVDhUcPT1Ga}M_tmzu3%}%q(nQ7i7 z4I8Vep`Kg{nh`k7RYdft7?FlxWy3%{@4E^AwEpG=^VOY`zwi6_xv#!xZlRAVhZu+L z2k;k7HXx&pyF=$3;6Eu*ne6o|id3olf!mo<@tC-TE)LaDhbrK{{b*peKzW^+y)!#Z zv8>j4Ji`{q9Xoh|J#didT7J!JxdK}Si`j5lw2dR=IAMO%ql&Ypk*B2hNK~hOZ!edY zPq#BY5A!~aDr&bjYd_Zuwf4;5M}Jlr9JeY4=^XGmhw;r-iQtc(Fq?(tNUq3Jo088W zsHNO3Xln4%$#a{nuwg*KtyXX%mSEKPa1}U;gURf4&7q28Zec<~?fq&Wdk#U&oSi`O z(ThKYY-m1kvlpF`F#%qyGo)zyIp%88eSVoi*1QpqiSGeqYh(;AQ&%$~BjgtX9 zYN4@Z+V$~~pL2G0MHZJFnZ6t1oHBTOFyoBlWS-}9F{~FxaBL%@3I<74M?jE6Oz^EO zo>a9<7Z~`&_^HM*)*Pz3JdN>7Ve_kXvj?O`dbDX#@@b*Tirat8kx}F0=0FHHtQ7c8 zgv#2(LsR4qMBWe_84qSIf4sAo&!Mm&*UyEz3YC)KS@z1w;Cw$+kbhe zZ50eGjd?%|j?gnb(ZXP)lQvByD8P(09)g~Ir0!7V?+w)Nl9&_{BGpi@OrhA`v5=Zz z#K}aub>n8`r;1=hTv|$PerR}MW~V}E#S=J8h7P*BFhOlaRo3s0LIM9fV4t_Q%|AJUt+ZytAWR-u>lNp%*mT=UReld z=Z7Mlc=%v#9FwQVVJMGCOKI)m@1^@4aY*WSPxTIcuHUJ72Z#c+xCwMT1)RUhlI{jS z!eKaE4)sOMA93gVdfx~4S6o@H;_s^5xO-zla=5Llc#YTqJAvMG5m`TRCIH?|=C1?c6pdppin{t769rvDg~ z;=pGSFYI|PZ(aqu4|3fpdiD8xQuJH<6%J<>ko7Mgtab#S%LU(P<%eFwqV|@ZjNFE4 zM#(3^Iq+uG5oV?}A3h^#$?Ra5x}gmmuQS+a!rt3EB9E zT5eT5ZpVlX7o=JNT`PMm{MClnOTq%koLIF^-2+mHF1{ukF5{k6IU)cKBr*<8$-fNhT^2TV+)C z=YDTnoJXUZC0UsQ!4S(Px2z3{X`)O2@a9sa&(?*Tq?>f!<~~B@T{<@R;e$cCO3OSE z4qsi;fhT&unTKrN9GCITWrs{N-I6zVhK6Hm4=4Y#m|32+w%Iz6dBX{Q0#Gp>w28E~ zE0EjR*usdR&iEfZCTi@z(7>%qS?f(!3t3tH5{nO;PyRQx7f&viy>+O?h`Xm`r|++D zFM3BMO*6V8>QG~-tS04>q8e;m3)r~hQCdnUd(zkUb};KdkRAv0>}$0e2%#SI1#b=> zrDNsQHzmhO$Bc%jXh{fux9q8G74UEWaNJmD=CD%q5VG%m1$z61Z@X|IL|ei+r?a?U z%scj<`e*sC{)yWpTijXpdYc)}hzu1@$&6nB zK@4hJE)cd+w%5lEn}lyWJ!WIuN?|gpyxz36URi2#w^JVK_dj2$RU{? zY_c$k3|fu@Q04Zq%Olf)YrXAao{8N&R@imR~07g+^4nd3Wur(GmzegzAp+r5$ z;uY%8r$NWf1mm1+hPTx&piF_Z8FV$S>+dikLXd12SZr%rQJQ%}V4gwWeeyRVbd%JD z#1}}<(R{507D|xA9>Z!A#=a^f(X-Dq<$GxjMY>i#f-h4tZAw!z_aYN1nCg$oqgs}o zxTVDm#9VJijOgp5TW3Z<vjN5x{a|{ez4~x=+@hAv6&)+zhpUGpy^6XI)Erghy8FFR zM!a!?WD)q%-Syo(RM?ynz{nP5>Y_;5`QkWysjU~t5~!VSbj8}J8`6_1Ws!XQ6DgUb zy=TjPjjiCiK9?g~>BB(|C#Yp_;W(6rU~}+XEK5Z#UWwaixNH!DxV@_|h9nz!S}078 zH*jJI8ecZOJv@#bmk2YrxL0^}IC4-YZ?rh2s^I}cfT!g2)4X@9DTCVj{K~57Oeiovw~r=4vt6j zM$U-rZsqDh#X$@Uay8vdo7_ZUKla|q`OH*uOAJ8(*Fwn{G`-SIo3lCrkEt83)kgFx z(MF&M?hoLlsGWOQhWZ*bzx$t-^c`*i=TE6`qK*x`rM^-61NsYW&=$FWri?)4^+LsF z3w5pe77CrGGG|y(?)h7Ai1ZDs5IW>3wMOVch4zVKXu>j>_Z;_lF8aWEu-d7(6QO}1 z$E&>quqGy=VIn@WSp!1?2%^V0a}R~m+_ap96F^Z39ZnKJ_&3DUfmIkfqwoo5HfWS5 zH~nScn|r@fY?I#;*bBS|>(}RYsk)V;Sy^*f1ZwLuVx~&sDZ!@yOfGVe47PFN)w#HZO_OTaXBf2|letfB({X|;O0pFQGviS2n1|KHDV3x4 zyxdSNroT9z2y<~xP5QHRBSd-C;yB>T;0i`i{MWgbRe<7(XP~OQ6^D?<>zrG24K!?r zjin1@7m9t~)Y6DVEJ$g`!ugxt0iJ14| zX#HBh1a9e@3YN>EIJL)4oupNE=t(7H7<17+)SHM0=#Xohy!hWDmCp|5w= zDWHE0RkH6mBST0t&n`#AMDXBC%;01%I9>S6eOb_Kc6>+*igSO^PUU35S!GS*s^|Vo zFrtX3QOKtuw6t;Y$0#DKbE0>8v&&q*N7}C&hIu5q{rTN{gtZ3hB7r1! zF#<0`?lHz$Dp^x=K5VCpf2E8V1yZz+t*M(^D?!aSi$Gw+ZQ-^{V!ax&Bf9nG;H>V& zlJ`BapZOxL&F?gS>>EmYSpPfHN_BSVxqzJ66@WPydXLBe_HMwn6xkBBGi?#Rc@7=l zVZT@~+vx6-&`#m%A~hq>_#f|Qny-6~-}c_0 z^t6;?1@X-}1Z6p`zBxoLP|YnHheZKALLLkJwmT>41^+N3&c9VISpMy^a_IfgY&%dg zG{zYPzQH84cF6JCzm{{-iH?V#%9=rd!%B6T)idxa9j!?MAhokqIhTb6le%O$p zyUy50qOSvI%oTtG9-zLj5pIPB#-tGVlo3o)EJaSt64Ynz)tiG!z_S6IBh?^;L#?e2 z87|~x*DRY?AlbY`pl4U!GezPF#)wsoYWR*%yC1m)KCrlp5&tYaswin%;6<5Gckj!s4RT}v^+fhWhY!L6`hhGZ81yM zr3pT=!(s!X`#WL@1aCd;q%WA5Z=Sl~>eKg&I8>^h*00hYeK-lA?>KQWJNcVL>{T``BHRu-$GM7&<{^OgOpnWBdIo7=zR&zd7G8b`LCBxuam+026SeKxU3qHA>1l zfc~LA_?oy8Z;Tx}@+0*g7~vGTA9|teb)iAUG<{MCnZ2z`=<3mu7qR8#hE ze-rpIBEk}4X43K>xXv4XxZX}V?WCRI)2?UmbG_nyvAi0au zCoF9glMifcZ(fjlf3heA6)j_>6~!Y6|HK_9+luo(M%@x{2(F3mDLY&E8OpW)?cv3- z8;*m$W#7%Op6eFC0^nA#Mb^2M^eUVD{SVlo{kx~DQduRfp8CeBL*!(1!G9eVr0fIL zmT=Xb&`u+^t;ZpGP)+n_R(cMa!5rwCo6@~Qg8*FKu?=j!dKRRqAjRn+Svs9}UiY!i z>AvM;_?V}$tkRPkO(Jp2e>almmjE3X1|_AdX1|Hf_F?t_TogeMYb0S8>OprY5&oEz zj7W!(djnhNpik__E2jYkrzYZD#UTtpXLDvqI!%H{vVkNiHS=>fYi0*>G6+X>7KQ8Y zmO^^E4Y5P1?<|vuZ-gOK7&1uY4od*0-S1kX)R-4k|B*6^l@+2C`)*NnuZE|Q1eoe8 z7Sk80Y6H;2jeEcL`1TuEe&dmiRjw0fYq?a776-8pm-6(!fo3_hhM;7oB%mTpMB~ls zN569^wENiLmP{6*8LGois=n_+?hqAQrMeTjl%$JJg^SJ2?v_A0EQ*%A9?8V%kWvOK zFA;o!ED^F9oYLZn=1Tvfw7te2ytXu^Vg&(}jo7NV}FnOU8x5`U1U zgQh*f4kCfg-sLA~mfv$d3Vm#M_nrkB1)PX2ofb0+v}WsmeZ058;AEXrvwcrNmMhH{ zCIfiM^PomY!00K-7{#y>PO~P7t-bn@43=magA>zA>HE}jB+aVs}EFdB2K*Gv67wvsry9B|g})$_qfR5udTZ=E*z+8p=wEzNo8 z;IEM??J0Y9b@6cEVvrH{Z~_8NZ3FMxgT>G#7_fE-@@d-P3?BPx(c`t%vF}P^++$@u zpe4fWpidoXJ4v8sA<%DkfT43N1b)lvuD=?qBS(nc&qC!r4jp?vEZ#1RvO(CHz5%RM z)oXu$gE)nZ=)(o4cvQjjtC^NR{mTvvaVp5-neg2`?Xri;8H_ zkvmFBAHZ8b63yYF%!h|yLxo0x;M)Ht6J6ygOOV%KG(HSp!GY-0veva_-rkIfut&ZM zEr&YhV?PMPzxmA&^=$R*H>TvK?h-p&{bq{cYY5gv#))r)V?&fNS`~6Yf~-|4^aw!a zGSVf3gkpk`k=iB0Be%@4VMJJ316}aIAmyf>p@b&&ft1*SxWE6A+vy(!W)89qp#t=1 zZS5?`em(wgF_eFgcN%+g2kKQ1mRU^NWDa67c5E=NA~)h1qye_*V5_08!oT-^E)3q7 zr4AuxII)|1{z%sRO9TWdse913$DrP70Y9WfY~&Cw!})u%$?*mkBL6hXFBCLSRHc7N z)_a%Ri#wu2&=lTh7dG1)hx#~%X@mxphBPG!crWj!^nY3aLFx=3GOft)43I#vDBqq8 z#dZgooAk>+bbg$$48WuI2?t4$f>-2?7vPH^tAk40vpj`M(D&Qgv1)e|)}P*y`)Sdo z8%Xu6o_4ymeRc|FG6Mg!KfXl$HcI_XL}l_e zi^u-c+OKuVF_^$VyF-WM1^f%KN1RlP0ZvS}$#(zJLpl&qf+d|g1@A5d0R@tBW8YJE z7$>V!alQ|ZOF)_zwcH)_K=q!hhR>uB&w(5=;}{=H#7V+Q$lOyaa4%XkaX@67{JWLo%+XlOIZ6aJ;Y`+!DymqqU%3Vi^2r z>ap}7SfqF?ufi&C*FeVK`k?e92BCOI(uJ2>_qD05kYIBHe0*wW>1Wy$c72k(zks;b zdu6P?>G}Vy2r}+1NkF{*m{Gy8#F$mrphnoNoWj2f)3QC|~1e|D-0h!+E^=zU? zhG{57&}=Z#>g@a@fe%>NbNq0(;XaZVp6^aHV1sjEOFO`PO1c0H zyuZoy;Z_ZTJ((=`8bI|F%BQyu8-;g~AdQdfWTfnxm%u^DqQ-=eU&1^Ip5Iz%Vm3Hq z5o*|`PLNy0_5E3@e)0RT{7S7U_MYc%sHb5SLxUsVz=BW*v?($Go_zt~5MDm1f|h2? z^z^qpL`<$qZng+uYFE1K%@;;R>j1^5ys`r4d^Lq^nhKkMk6+HPP=ipMA#zsTW zEEkI!dUqO8Y%B|-HbRI$j+TyXnnyK(^3K&wJ=WNAOhBQL0wAS{$C=P^S4fi(hruZ$ zo*6wDtgofYaIU#9vW$Lon2G34VgLE`qHmJE9*1Mn8do!RY29GU+o(eAFUi&k1IqL1$DGH?--YX|~Ndvu!IgUa*) zARtsf*NGq{h(L?6pw#wAHRI@ihzHMXdpy{n2BT<2Y1`K-@@SNYq#O=Ek51LY`eB%4q)novqanM9(E4km z`*vdEO}BsgW3f+?y^rf37fg!N{!RYp>zzNAJNn4Y+z7qbOcThiJp-}7kX0DA$+5OmmVHp#c5fn4T z-b`NbBigb+6m!TSGIbI>X_F)OMa;f(Kwr!XwCRO3`;wGEK?>fc%!hz?DtHV;57AX$ zUmH-}L)F)a0v^YX_CyJTJ6)T-*$4?*AdWA;5jALB{K7QTK)NmOQ@3g~OqIWCTR(PMzqA7Kwsq*Q9J!e)Ln zOpXI|Vdji-tZ^^f5kHtcVMZNw`6{5neSKcl)e*~7oxDjc>oU0906YIe+fKQH1x8F! z2>O_%SXYy?kf0pRM8WSw8vVz05tgSyCxdSI7gL%z;o;YMC`i~Qe&okili!NmK(({ zh@t5|GBJ{!O?=N|_1x42{ugLoYnnY&rw*N9*Yh)UqYz`quu$0CA|&wRs1qM+67^wc zU}w`L^QB2TqG7EQ{5euwi%wuGpodM8nx6;(bqt=mauW-Q6;jy4+$Hm0DfKeqyMR*Y zkevl)V>Gfmn>_8R{W0}>YxG}I5f-P2LVj$u`TObq*$3la&7>j(7Uw1g2!{=LUucES z`t;t4(0HYaipmo(C?L!nR6$XbcIP&n1=i`H*dNjrVkvZ5?qW+fpNg2iB>%A=tc|1t zoOD(@mXI6wdbY0-{G|@^v7A!zK7V5fHANW+ahSWxX`^fI|V5?xMr-OWcx(UGzM~Ux_l?e zJq>33n%{QfGLP)7ccA~V0??tq&&-|gj)pn$Kvsn*up%av*HGODXEi4L{!$`HA^zZ~ z=|+p2WsvFOs^Kyq+LJfudf@6Cp>9)+5!ePSg|~?xC;r6Vx$ceN*2o)C}WB102>MwZoNh>gy54{^bq0X=NTkX zn=u4@5!RP^%;s3UJ~qA2hWr=_s7c@i8x6gguw@m~bhC4J1QZX!-QCm0+zO81U1i|E zn^fcS2%RqITl58WT?j4X=SaQ60YED6xBke&&T0~F<*zNIOnfywn;c`Xoe<$T(_c-e&D|5&;P_By*@8{3U-+iYy7vEA6V z8=H;M*f~LCG-+&~;KVi>=brE0`w!;X@80jstXXST039gP2&MRo_(Vu{up>)`TUa)d z3iW_n1p5Y*bb*mo=NvCtci))CB1pOx$Vg_F;Q{{K6aUq5R)NF;7mCiG zWEF0NbYL%osw5KRf5E>9{n@*9g;X*k(WOimGKY3~kyH@yxv#Ye@cHj>-0kXumKI4H z0S3xM33-cb4N=@|@4Ck)yhMSrxSaloLcjOt;OXd-jO)iI!MI)v+*@77`=PLf;$Cd9 zb9|FZ{Fu^P6v9D~E>4pFEp6+C2aTi`NNQ<@p9OoVK#;wF3=>+|UfuBvnxLgeo8FD- zD;hJXp_3CC|Bw@+Nosw>smBd$7?>Kd>|9wrk*COwJ$8L2T>KtuB2(=Nidiuqvwi`_HCyq^PR%e1Hr|01EqIsUw_l*Tk)F44BT zD;QQS2!;FDMQ^)0Nr{3dEuvwgx}5Le8X3~0xmEfgZCgaK?~aLcN;k-JtL>k$|3e&A zYX*Up$U-fr3(wJ7Lh)+Q5Iq}pp(ykU?r{UIw-Q_`@VZ;V0sW8d#yJgwNg9ViHbp)E zASMTQ>_Bs+aSOyETfcZWP^ZiC(sy9PmeaoaA2YpIi%}i(3-8xCJNA?4F@o+5*}ktF zoc=vV_tcOmei=O$%QShJDyS(IKm>J+(R+_9-~cWP`psctvUQsG%(V#7AR@Zuux$q7cp1_4B%0vA_W>ALMSNX@JumsojO{>{ zD_q&iMD$>zv!t=))9%cP*e_#LZ=G+ua`78ABwzCqWKkQT>D(qe>OAJ;+k4vm!0Y~> zYRjEm7uv+{^@z?5v*LZ1Rfuxt>G7)@bxu8(RopOHLM-&f55+ISp!|@%oT6}1qTXn_ z^*~tg^et$5Xj*rFu`6MJeQzlFhJ(QoSAshk>wARks`a-5D^nXHIX&XLd?I@Da+5ST zV?WzF5!4pb^$08@q6UB+~L3FO+yEKQE&UDt|Y>cO|UDc#Mvwx zN=yrkVF})zeOY;}Uh&eDeP*lF5|YjS&Qj)gIu;CZ6uP#vl8SaxErQwA|2^G9u~9Mp zt&QRQ8^95&yfctM1q19^ zlW`N!e||m=P1}Lc;3^GY25ig2a>noCrc4>^u+N-ICwafZ?YZFtjOn}yz1qp;Y^V&k`Mxg{zp80&3{%&)Hoja z&IRIsqG()udv*SN!zljg)TxmBvgH)PZlq`j~nMq5c_CMWuWHMZfv~(UZdx-*Nvge=Nwq@WhhBcOn^c zN7#mx5mtt!8>C}b8z8lTZHOWJZ0#sbwZjLL!B9-CvG3;$ri2xJz!SZ|;D!rcWO?&u zyncB;R3}L`?h_`%mS*94ANlXv05>m5WK5k5%1>4`;q2g{NlSSW6Xs04THR}*a%Zg< zX2&CRsA=ZA$1gUtZlI_pDdXh~qE4O~DF%>Ze$=-3Nkj&NuEqLBFPi82sXD{Z_Qf#^ zW*z!CM319XtbaoO4NZa+W%g^DLy{#{AeI)0+i?&+z#9m&uh8&T+J*QvQ{HGN?nMZh z0CBNTC~F+p*w;xdjRozYCY8e5Npc#gLUtZ%urhqJMC~|8NnyELx)(h z?{ZkftDS9Zw3y%YhJ|7j&9I0xJ6{~V7r^Cp`5v@Qs!lMVa)^5-% zo^U5);nKUPH!;u#7q5e?*lW>%Ykswa^SPyvv+x5d6A(m{N1l z48gJ&{%gp_rJ70qF>Eam`+8`H<^Uu=?ImcxQc8|MPUxo(`L|~GT^+VK;ZV&cHmu-v zEK?(1c~e{n%v=L~5ZoFUl65Ut3So;K*8`NK$JcFoLZhkoySWfD^35~UG9GRw!fRQq zI|n#O2r2;!qGom6(HUz9fPm$nT*3XXm0C?lAI;z7IW@Z8l8)FM{-)Y@dtDaT4yE-Q zJu>41iVa>YUNlaO^OXv((bw;N`D(B9oS7~3 z`B7H`Yv$jXkVkI>4h$~VGwRTIvblVm;M{RL^JLi)$l;e3B@i+n^?q$c@qW4ik>qXq z;(t3h(7hFj7Xz>miXt&=&4EL-Z)}+rQmf>=YCI9Ecs7!S;jA9I){vQZ!%q3Pv-h_l zyx_#;KcH+#`-|>VX&GqnJH>>g_6UFF-S;aSc5~R>1``3vT0y z&S&$-q5JDl*6-!%X*b!l>Vy%AD^~Pw)06P8BEfr`I6k-)a0PL2LAO&EYhDfTas-`( zwWKSgLn1pX*>H#HQ2y|^V0gCPA-Xmkq6MFZJi1(`fN`Uk&L`%*L7OQSjR>8Fa-L6~ zz2UV!)iB!DVSCXGgLx(!8!hDPF|Uq5NvGuIOvs+tj4*oKDdL09dCj?c^Dc(xpJG6Z zS+i4xh3Y(xP$mr1Va-w!f8OXZ1XMR&AVrG3fc1R+ek7Gx9NaGn%QEzGbs?wHr$7{P zg=u}S@N<+Twq9dqu3SQ&OTwo?yu?ceF(3Yso*vg)s{ER{GiK-jY>MLnBk4g%;3`J+ zJ3=Qf?i^NnGJ;`aemxyMp7}6(zCZW6J}e3BU2aqgo!O<;0^=2>+u~{+IxDM+Q9)Pu ze-IJcE#R!E{jc}KTU5S^e>Q)?E$;z?3&lI0;LeT4h5UL>#@%?TOL{DH3!vPyZolEd z`1Ty)WIe5;8k#MJ2`MaTPV9wO&|7}F3#=+SaTw;018bbLpoPW!x;Hnx)x=b};|azl zY*dmR(?jD#IQDbv)v7HhVDMe&Q3(f#aO1;KoTdO%16;=M4M@EZnKx8i4j$BDXdhBk z`hElgn29g4QsV0ik!uQvs5L|! zDFT%W+uth=H#FPVhR~l)KRF5q@?!EiCG!GriJ|sLNugjY`{hXtn9bTSeIU428S2mlV-?M^gYzHoo`gO@ayiE=WV4zcaBNQP~m{P?@&Z@1px# z#!a$*ucV(Y8iS{H3zqb-B#T+&5f$0@tJIN2cW`+YE6b`Lc=+$i-MHzvWm@}K4OP0f zw_U1FWe^3zkP?H(VuEheJ603^aMUy!h&6{-wvby`+;JCzc|yR@Xz5SRWPwuEnHLO= zwc00Ok8RWWBF?m1!k5Sk18O~y=rLdZEyVv{Zw*orfUljI&EgW;)nRxf=0_0dbd%E) z2x+@vT>k-*)^sA3+qcx63+WGLsjv(^N%xXw$P2{cw$s!|Q%LLqhRo4M4_vr$4cPN8 zRKN(dd!(O<&*lD$|JhX)il+wsrlc+T06Fcm8Y**-zknY*4|U>~6Nyl46^?Chz9mcO zWx4?A9Ja4q=${e2R?eD7#LFaw+#{`P{$jpw{dyYU1K@Gj zeFk6cvjFFhOW@~><8$~6%KNUVTKG1YIFQm$Q9#8?Nm_e60XeuZ9e*q5rSvEd=b5cG zO>23vaqXEt+=9lrYX}Xm)#GA&z6;#7+2goy7%(stK6n=wMg9iORt-L~4 zFA&nx)>Uu#1%F8KB8fDj4>%l^8#N-y37h* z!RM<3{X9MXn2!dKRkgx~!4negAwV9}b+)f+6yxr9RNZf-_{*yqHD|a#d)P*$xg*c8 zoCV}NgOK8;>93*JtrgnKkJTd}aLg+9DlCHi9^QX( zs3&x1AE;lJ7M*yOMre*t6iz>&=@8`=E;9CAC=0t?GJtEpIW}hWRTkt4rRO?omPF&o z4)}*Mom4Jx5&t%b|A(|`fhV7R9b?|w+#x!&no-GArVif++id@M!BCLK@I-VwR{Va6 zn_=R@QXiLq)LTc#zlA@awK!sU8lMYOji4Sogb% zRrq&xJtN@>2H@~Qiv4EmP9_<`!Uf%oAIM2wu>2&wg*^+|!BS4|6o$duwh*XuetI`? z)6Xl7tJh@TC1pOq4TZN?F!i!ISW52^BEu&6`7@HK2r`JaMm!++;oc_9@gcA~FYczN zSpI~gf{o35F$Z-2xh<_ zdb};ojPK~V64p&G6a=z9LL)>@5LBn+Jh%|AxGo7MLkyqR)QXc?A>H})sV0uyq&jMllJS63(# zlnhnO%SE4d7mB;OD?>?Sr?D>Q^qrdfH;o?JtppIZ`hHlYAz5)-q6@InZ9CNZKKw>_ zUXrQTX-+u1+Plr=4GcpsR-!qi%SE1VYhRTl(1Gl3o`ti<7rNoyYF#Ze3nLpVLlk^M zWMXg7tQPAUZUgACi(ZKb$NqhA8VcK^qMqv>-Q6U6y_kE8VYU-W<_5NiHIgD%Pjp%fr|no6=w?G4w^PMJl!y z-H(@8p!t-uH*YpsI2~DQH-6c&I@QZ1><}b;Vh~LeS^r2bF61x@{2t$c4!uwFN?!|N z4fVQ)_(mJxc8ahKY}m46h@3h1*SN9)qSPb9Am*^kqBwSJjY+pKoKMw-DUWkKvs805{(#Mo4~Xf(#eSTn{#Q0f zqnZJgxptBD)7bu+5DpkJ9#Bvr++hlGLiU9p>>xAKvvs-A00IwI2zjwt zIyrrIjaqYLx!8*sHN+>IQ|XK2<@@3Cc0h+e$xBXs-+$NleWq#B-=FV83ayxQD>f}= zNX}9m>>>j{E^-4*2{oWQ9^di#HH6BoJl6cDKdJ>9?&8Zm^7oAR3RI+op2_wwg*-b0 z5qiO3I3}7Yk0lO=}M+2#i9br?5;INGz6I z*$H=TWO}gDO68Nrkq~YRkqW8447h9_Xx5#q1B;SB3?p!z=gN?}U%uTJ+kJ{put)US zwSOGm7#>6y)c*^DBVKuZbNae*e%bcDF~`0=@7@jbRz}1s7gvIpW+6%`NGdWcE%$p* zEn#NTc7x@*o>q@k?)MW(DMP=JQXmP5?$6>Rw;y-@3~>t9CqHe(9&hxW3im@Cc-Nak z#4ws*fXFBcP)XDXvQ?pjOc1)Vbb|mEeJ^!ET>?XlnDL8DrV!gj9GEJ*SFdSSlCiJi zSXK*F7$zWy1Fg{Sori?FhXwXQML&FeOy*Ccm|qfkf88RD@0D@(cw?BQme~GK$G$~6 z(2By+VspI_&612%9FB*3!UFhN{Tu5!v&~;KUM@Hh zdNuSxNNhq!(Q?Nw%Y!}l{DoDUz6=>g|EwC8tO03QUd~I8PC9cqaTb_#{eJ#zt$0nJ_PwOb-!VZ0<%%wLo%CN2Pt783+R#MoD zZPF~=R!P-#_eElezRC;y{IN*SPLe|JR4cjtWkLL{@9_Mej8WZ`1TAseqC-pmVb-0~ zgvaiI`AIkANeSQizHVFu8*hpAhJ4FkxPX9&j_);tf!18{P-F?!Z%S`$tTF%}b*NSn9qM%LcAbdiLZd7~Aay3r7o z>juF1Uh!tZ!8vsKKL@|R=b}gCoDkZX65y>|gV3Inziv2OvKrGKIt8-ZP9)SSZ9jBm z4zn<0BVX+TO7v`!7g(7&UD+Xk$dQ>nB$@rD%~TT^OR|+zC<-);>#PGIarRyBg1^9W z-1-){trc%=kAS1qn2YcG&EnE;N*`?$Yood~E_xAz|K56yt%%vrkq8NNd6#P9r#$`9 zXgfLM)rGIqVlx>nDNvK!5FA!t1km^FWpM?7U5wKn%K!D3_CDr%o5`((XsX2)N;ViX zrm_phVH+~e!DfPaXfc7$&^SL?J1`*5ilFsfT6glFywgjd!o=z{>XZMlZ5)K+zsMPL z2&*qDjJ+9;e-X@CI;#Q~1Wd2KrIXdl%e)6bT;jI#QETh2Z#hVaKo%fXEl4Hmi?z(} zwq!N=m}}@aKj;iU47Ie;9vt$V$F^V>L~EsZLmWXlDBRkA6`xCm!h*283l9tZR-(c0 ziC5%Fay3K^<0Yzo%_gcF{ktk^2@3Vx)V^0FVyoW`NA_5lhqsV8K)GcguIJ z&U5JX6IV^WH#u?jycET;#oNt(-fg}aBkAXzh;d)FE;|IS2wGUFX>CIXv0?+LiNB z)Q3mo2)xORnN8v%dwqwZxZj#B<{$xsPI#gtHPoV1nGO%Dwqpi>Jh^!s=0=Jgn3e>X z;^uMTC!D=NXmIm!YpeSstWn!T>oZC7h$rMAi(aTaP77nS6j%sd{{~oUw@ty!@9cEL zi0Qv(j4*%N&ub6uN5mX0N6Og@ghH0H3Ms(~ z7g(&X$E#RSL##ddunq?K2=iyq`D7bTrH$!~ryA`+AoGp0?v1D<;%)Vhp$D_iDuVA> zpnPI2VTm*xu0NTapmHXHtP(h3b-9FL0wXBw)UTL zW%xvop?a35CJwXLo%jgK+5+p#N(BC?zhb&mG zC~fiq`!Gl%bTzeJfGQY!?Vw_DOJp~^DlQ|k{|@zM-b4VGRWQ8&ux?J{7awe~wOcGw zEhS6^HuqhK@f7ZQy1+_ioSmA*YZ!KMU2_2x;=h#Nuoh*6(xp+}&(n<;mP7PR2ik>O zTs``#zJKL$sU!qJYUG@SfBk6FI-gh`nk0j+6UI5WQ@U;AbkNvn-*>5L9R&)OZ$*z-L%$`D6~A@d6YBt&Y=Ie7pZ-cLKWeK+#*Fjl~5 zeX6Ov{ip$x&icGKjNZ&JRumQf;!P#ZCVmgK2V&nI%>ErvE=rpitSg6&g+%Jma5yND*h5YYEx{Cj6X{#UuzaJRZ;9QwH zXUhWndBg|j#%1qOMfCVBLmy}v38?P8wKPV{g0QDDXGrBdJv&P7sN$j zobep8LLjRyrB$`S7S<4}ukx&c!8^jdQCa=X>t=f6BGl}X@wLB8AU=&|5uLSyevhp+ z&`?$lHNi)+hDzxy3$n*T_TmF26vzv?1qzQ5Mho&n8S2YELdf;|4N)?92PZpEqYcQl zCls5=c453huG$Hh@*IK}$|e(=Tsu3`vJTRWI^vfsLPMC;ez>K_H6<8ce|y$K5troA zcKj83kDA%b@Ahff4)B;HS#@Cb`s>WMJ=xIqUya>5vmLdBN^nL(7KOqc#Y9DhR)qSM zU5Eq|9#w_t6He_fVk4%F%R8p-LIs@S`l*-FiX-EXX`(?*=N}TktvXniY{nW(h_Imu zd-DqmWOcxj3gIfqEwO5dz1CF+AVJjrXQW|N1Zo@0;iF7lOxVz}!`&*Cb_I}G3KcuU zp_toTETmW3!mvM@?l%b`WlZ`@V=#1h9DuRwLb%IJTU%8s*Xv*V=fst8uf_83uKE84 z(ivX^bEir-Z#MCr;EC`Vyb^!4c;-OYk&BOzR8Y$86*tt8opJ`shXbV}B>#RULpz6F z@OP@v@4zXc+qc{MUF<%|jP96L2y4IeZRY()WiVct39vJ@12w}1RjaN}Oa^r*oi=j{ zcRAt+4NnhR5CMFlJj`Zu;Gi6@Lr7{!8x3SIqwxcr4_zIjk;6|gw0r+@-nwzb2Jvfl z)Q(qf)41HjGEgqwQF^OhT{u?>F5vt(M6ACad%XpI9=6rqs7w}!1iuVMlOSdi;lmN- zi8pVV8iv>Ag}5(7O=D3rr9$12`Z)?;^io&iD*P4XJ`ZER=1&N-hhe!u9YA458xQVU z8RYx-4f-2&p8_;lP@}I=`QtT}&!nQ5fpvyZbU$|}@Gxqp6*cS+R9xlB_z1;}70q@+~`72dv9|NKEE9Ubs^qNDT3$sMr$ zDX{sMxDW+76S0>eRp>ezk`AFCT2mn=rZa=)UFPrcQhY++$EZa2|m^;6IXlY72>eq|0VfCTUm5H3K32|6BT2`p!-OjEdW}-0$yGiiZ zgXBvmOg=9AdRw*~RoSKLB^)|>1NsA?<5F0QTYq=ac1~(_g=%zz_dy*8AKiW^VR@(WxKQk`MBA} zgKw$12dP2<+;Xs=KwDthdcyW`>#~sJOt8ldt5Xd5v~AVE(}Tlg-Rg(^0&!`nQ7i`_ z`+*4wP#yzaJT4z@uWv*;mDl{I+c2EED>pvtAyP1IBF~z$_;RlP_WFejy^j=RBwNEiwyQtI?yQn%_kyX9Yp)B@bK@NJ{MQveUZjfE!|t=|x?0mXt}s}6FTvX{g$ zLIek7d(58M4GACY;__l{y57$!z^meZIv?sN{`d9uk0yHF$AQ6tI*@l8sd_b@FmL~^0@)E z-f%Jf3`3n&PRc{h00@FMA-sBX&SF3x{oVg2k_NliQ0<|?NC|#e*P^SQ>1}6oI*KxrGGF-w zX&xNzDuF-1-$TdCd!kP&MQc5eLxCnNx|H!-y9ES}hk9igPp%F=Z&5!1d_>cIAej@)_>Ty(6jzq^LE6a^SvhOWk|8dp0{)^a8Ww&@rD{6(; zx+O#N6;6-HbLbjg+ES=hnQh7f*ga0%dl=&YmEP<^;Rg#%5IUIim^?C$T*cWHPKEi6 zj9(~St1nhCF!6`L*Y!8h{lznv9@8Dte^6(n%5qlU(2Qxjx8KX(+oXHhaaA+Cp{S8W zao=+uL;SV|!gDPEv@!?;Djv_&bznyUv+wjxf3FwE?kATe43HBu=*{)>nGWKhYMu^u ziGt6$efqzs!HdQWm||fr+XIL3RvjGOU;Svqc!lS(NFKR#IbLg3EI-)7c3XNT9{5-4 z-GOahy1q^g_2*=ij zCXcp9*<8_fb@OB&487Wf%zUf`k*9)3$H7SGLUU^Tb_lkepY_mR__1i!)TXwWM#u~s zS=P)~F0=&_YhFW{DNwkNp5L^Pe&&K1wQDw@iia8;BEIsdRZfl9Xq-uYho*#%=&IX% zoCYHeGw6zq{ixwR4-CjWE%Y!vjX&k_S4AqQIo(r$cP&$yeq2Gw&9cD6kZX9w1EM=f z4W_;EbMV$Dc?#{UVgP6VazdOl@-s}$O*R%ur!A;^3LuFz#XLE-_uuvZ@I~733gudc2l~0YssZ~~~qh%l&v4*_X z8daxO!Szsr$yD!k&k(jAURwWET~Oa+;iat#d?yN6RB@v-fXDnH&2j?JMbx88^Bugl z6u8xDxn8iX-ME&vYtpn)iPv^w37R^%92tk`(}0j;@u*mhC>7POib)K}P1HfWfZuE*92BQEA!9wcNz_#(hEGzIJr$i^BV zm<&O4;UXa^G8zXj{1UXTqAgY4OkBHARo(XU=wzRokE;d$WV=VdSG+vhc)MoVY{BW_ zDx@cEHnqP)I-l{Vcpg|)u>QRb#Uo>;2`^_qOx0GQ)OAURpq!b~l>spl>S!+J;W}xt zkiU7EFQu*v7xpPIL{2%_fmVtjo$4GC!MA`kvyo!m97w%Ta?YhzovtgdrtPIz3^ujl zVqvPmgMpZBpJ9-(f`PlHbQU&6sycNVLsdtrLvC&9D@Lhf%mSjlJNe&-Kf}$pE*o#$ zqVOAUxbGDNK6iZ{PHS%~o~V&e#GG(Lf?X>aGX)s6+MB|e_vji*b9F3C>bMSxSl zwlz9J8~ooB&00WP3Y4SUd&Jl2byxt!y)<06w5+u=)m!Y~?1xGmW7fj85rTfPX8CFP zeG1>QV&$Ksb3VRAZ#L1V_vedW86(CWyM{YHJ1S%I6i&ZJYgHVqt@?*RHBMY!dg-W4 z;jlmSMwE>3L&b;mYtIg_&6muEvJH@~choT8-4R`y#*6fW#8dWX$dS zGVzVh5g_vLH^6ZOpE$VfPrAEYvxMoOz#5}FkEu)1K-ihoZ_AXUkD6pgXf&GFQ^kjg z(abqV+$n{ZqS>FL69GGJu-U`lb|%!M1@7zHufA3{C*)<;OmIp$;?+D@^-E^WP*q#w zM&gRaRmCQoo1|osg)BQU-51x;a5{)6C@Kp?!JzM!AEifLcKaz)u|RF3xzTpRd6&cr$W+E^!Z3>I5qr}YZzS6Dv5fy?<>;SaKMN!CIj9~ydj`YX)z zj8(!^EXA9f#sotRO?f@u-Q-h@%k2>=-JkY4YHK@Iv-!L+k~aSH0nEgThC{f059MoZ zTXlnn%xItcN&6Kt`8@_nmjk^BF4O7NHvwz34lDI$c0w7$zR?cKd%uxVBT&uEhwxYl z&ggjJq2n?e_Uo6>?W3h12w#e~qWu1ezE6L&=^b50E`W;#NRsogqK@>z;nBi)Wfszk27S;lX`(phFZumNue%yI@3ZPUP{mBUBRI{v)5 z0M(833%2phrk#-t8fXq&pEn3ROczE5Is}RceD+YPFM1T*WLB~iu%(1-J4JtPE921o z(&i(MM{p^BYAK*I)Vu>fpUJL{?ueHs;#zYd{~~*QeG6+Sz1T#mCp%NO;LH$tZKceMAthQf+R1$>Bd47b+G5ySjKyBaL4!S_vlHfzi1w05-v z`PzYe+Mhdv&To>n;@ehM`?@2Y;viatC>rYOIz>B1E`Vro0nF86$VRVU>}nmP;27X(~XWnVm3WuZ|*^iJYw1ap0CZt?--HX!i~URZGh z9z7WIf)yYzd0zOuBI2wUYWkIUVZ|3z)&&*b%uST>;@DD;K()}@VU&c8R(HUH%YtCZ zIU33M!1HIsc~Bd?%D}_e1#_2fFxR+)5Hbqc?`t8jQTxB@D{{VnW^1FD>Gk1`_!0g9 zJ9}~^<_s$rz~-dMU_L4_Zk%cSmh)7_%)XB)2SKu{P>FsFJv6s1!HHv% z_h7hkrGqm_YPi#YGK4Wa_8YtDY}I#4c{W`AC+$Vas9_p>!4>y?HnwOm3k6&wq;Ow1 z+zbCWf2b9iWcT%)Kag1cIFm5{T@Y;&(pVoDVB>@EnJV3_cLJeipw4_T9RAc6b3zfF zG=P`m$Svm|>RAN!@C!mJBQp@N-S>h1alGqkW9|3xAB=D(3ZEMnx0z%q+@4u)s#X)T zu^bL*H&opeup-fp?>2{@3*=N@0na0Ff~m31^u8-5IN~WYUZP)=!Q$g04$a|L<)8df z4eSfvJ69HA76i|2z|5bT(7&%!D-Reak`CT_o3nq6%2k$o!#Nqe3dgo(*bd)dO`I4% z6qGVC)Bkzl-BOK8Yfr$U&HgUhPe68AWF52Q=-}9QNhKq3zWUfQc>RL&-EaE8Slz!u z248jc`54H!rk>;nPR1klB+zmFgK}nLuP2Vz&!v&6{q^^2n|15C=D9HVnaUN_h)8>z z!ggtzL`s#GA}8gK%FH+hoQ?QzCgiAsZ)$fo;k9%qDdli_(^JM$5*nLzZ!LKUb)%dv zMjtfF`lD*5RdGqI=~243t5l<$T6+QGTS6krzO2`NdLx}5vhp?d;)s*`nNQc}-E(OB z@q_Q>_x>B=*3S&l_hG)ruKXaVF8pb$v{Lsoa_W4ZKe#ks@mKQ{(7ejcKh1~XY)Y92 z%XPMNs6*Bh04eZMpwu{}6@;kO%yLOgsH~*Nt9&pjQy>T4LXu6`0Y&&!wKYQl>`5|8 z^K-ju4vb}-kjBiOT|3m8mO^Luc%)%$cV>v-T3-?{N@d@>sB{N zFLrZ*!-Ewxralm;hK7Q<2;28Q;5>CCpb$920{&;YYXlYmSGOn5{yE@spuG%LLq~Bz zrFgg@iZmACf64ZO7(i}s^V@TD(F(K3x5;hOzQwrmqyEO5;1##0d(6;Ova1rKJA60w z;UoO+bohUj?G8^-(Oy9i zsmhy!tgE*MJ{j2^O~Q`-Rqg7XT%LD1y76&U5hkEsS$-)w(;Bfo~2b5qtZ zmoN^Paxi7QK@1?1-j>gqag;Iw>!e+Vxm|8jd)u4W@*0#k$lMKHLy;f?Y_zt{c9P)u z*aV1b5luC*`UfI&!oQOhoGY^l&!p{V99LH=fy8GQKE@Vjj~jq8%;>fCKz6R{=TrB^ zyA5!+TM&LqW^%mL^*V)Ujm0#iYez{p+X#s$t@nN=0(d8Q^5}Zb3LjOF1YHYa{^6I# zk|qzGcjDbY+pZF*IW>7KQZ-BKL_w3ftnraPB2C=rF^Ms3`+}x=N{S%+jGRLzK zJ{x=V5a-jykIVWB`2(b&bD3`mHDf~VXLVf>y0(BT36swY- z!!zb@s4yms@35d@!%?bl;3Yqe&~tYd3)EfX+yA7cti8e`7_f@u9{oqh-Bt3UM^EvN zq@=B^tuCEyWDRF1w1cB125(Tm1>ZCT|8uX4&FZ0UM)Ut%@Va;-|M3M(jh z`|(bVR`)e^U&?81RZdm8&$}<+-Y4o*F!_B-_4DFVrv^`d8ygZU(K#0Icvr%TC6ag6 znZPR0oSd;ERiZ6TLzs_k1FPc3&RfrvQaKE$En}8yXx3UF&2mbGYUszUX5H>F!nU#+ zVmPtmdT}&4-FoyFUUN@5W-pEE{}uLIKA1HDoH+B z2R>Us-(Jr_q&{0mKD1{&+~n~HFk~bx%8=9`ufNgm6hQ0FPyz;$dg=7ZSJ2KW z*W2_nVvk7TB2KE6_Hsp9)tDn_O5--yCP%;3XT!!fb8ooKvpZX%VqLvmiK=yfL2o@{ z(S|#U(4I`MTm8X5*(D&UcEfOWUHZ}NiwX71%||OT5_olK=#?;}iBxs+b>d3bUf9sk zpFDub?9HGgYvyaEoMx+he#$zvS#}Wdt3Il|7O?@Er(9E_jb<~4E0f9i`t0W0_xTCMRP>y%ZO~SZQriWA@0gq$smCH#aE+9h8tv2c2&#daord`B7+7 z?*GGHSnnXwIbf3*Tle?OcR25m<7QtK1Nkd}emIkmG4O6|MMeAxRqmEkI_uj`8A%xg zn?0{dr8uHg^W!h+vI-(udRqP}$#C|(EX6x$-^~MW4;}4e!*D$F^_sw%v25@DepbnK z<^JgDx3T?P_#*FO*yvji!#{XqcysCX5SPA5RToW7bmtnp6K)hfA;OjU>)Qm6a|50y zZZux}M@!GJz>gE0^b*((01xX8H4)EOf3l0JI-AYYB!G7LzvWcYc04>5tDqDOn^g&r zzcQ#rbiJp<>eI(O^mElP-c}r$E}+9JK3i~4@x1Zi^p|-eWz0y{td)^xYULlDHQm3> z4~iM*_Z`67Lr0XF2I~if?gYoREF-UHkG@wNZ%PuvnReP8pWXoPA2S}7dyQRsvh;SY zlg-yyP1MMB+6h?b$o7AnS@zbJDUG#%X=>Nm`BT)z$(KGZ(fAu{10qPkIN>2lHC~O+kEP&| zxHx*su7qAwmeDOR(h=`<-lH#3tJdTv^I1MkGwj`LNpb!X0Ag>d$CkVccJLW@_RY#7nZXqXq@u%W2B7^Og9^ zhi^3$`O=aWxPU0!LFPZHR?qjl`rZ4R&R@PHQU7IoAZ%BCVAFTpk^pp%{exQiCAvHO z9cEtOiGbnl&GSi35>$=xG^P6Z{iShw#)<^*u=V|w#oZO5Gg(+Sdtl1FfZ3ic@oQoz zc+YKXU-~n)LcR<`Lblx1pU$ngfMt=m#TI#nKh6&<41IT7jDKyUfr{kzqSFOs7CZmW ziNN6&oJ@~J1q}s>a`oY@x?z56zR7va54lHLW5s4mCcS{&*W&PcFKOtBZ@6E$VxJ8-J!6JM0crEgG>~?(*dNxIdL@?xm`uJTdF=wv=MT~1GC|p)g zg^nn$fSrnE7%jg10I%vmyl@}<>P9N)HA&-ROsFfE&Gc%5lmybmN(T8$Ug zs?6FAbi$a!tQIFKJXjPaavu$3Q4}pwBNz3RKiKTnRx6DsAgB15bP$o}72S47jOgn+ zM0;hMT_I6O5Dev^`T_$#)pqs=0v`Y6#5KyLSE-fiE~{5cMnlh+A<$HpVsV^1^VTz3 zE@38lj2JJ+B-L@fn(S%&wr|i{;0E8ZSpjjCNTessy}RE{J|(=v%Z1&c?yhboNlc`F zsPKB&T=r(^#(urem;QH3P}Mm<8ZmQTf1{V(7IU*}MKH5FTU6M1j74^{9mlNEK=3dj zFRx$mgH2PrMnvl@Mm*s$_Z@WpVDHTuzYq!mQ_mer_VIl4v13^ooZSBo{;+mB`*7SP=NuX~!or z*NW7JIBMfliS;AZ$3T{Caa3N8Nw+df!J|jb&n#o{cCC;4fLb!MUbJ-c*z)gk#^lT2 z_qEGHRg)M@A_OrAC}oiIw%Vl7IVm;Tv*c!zBn{bWH4{g)&6=iMm73;hTBR<{oMLN$ zvZ1|g*YpGKR^i^ytAU-zyiWCo?V;0!tAVzlC0p`#ECj8Z4&bk!o(VBq14fA+C^_H1 z{S!TDarM&b>3&-IEV3+h)~hEtD=Wg?0&NCl^HD2i4xgxc0&N|PXtkMr(GAbLR$gO% z;})Fh3p**TwaIFCW}Gx|wlk`YT0eNUy;=qzo%a?(DT0rj{F*wHtlKld;O2Zrm=lxk z=D4|9bnX{uoGo~dkCf0B{?`q6&Xb{R=p0iC@Zq)Li`zG?azNaAg5w*ZH^42;&8P{;IUrqRVW3rTj zs_HZ~W=wLa)oiAY1*>Y><0e&% zZ+X)t6}o#`9}1uX(Ky_(b|rM;1RFz)gUeS&e= z=rXQ@&RP89^7(M?PDLhrL}U*A3=-*HUq8<(Uf~T0F4$B-oHK0BzL7kid%>TZ6F7jt~1c>o{X03X!q?<8Ze(|GS8DIca)w*j;BtR&dnD0dPituA;T;G)wQ6sXqJsL->}`#YboP`+o^rLeH0r04dp_4=AVx$b_>4m)Nzr+c5}nbl>x=yjjG zVRmCq{9+o$tVfQxR@wJuzDP?8=x|zaj6DCvc=zMQCwPhD`&|xcQlijA7MCLRvD#19 zcK6NU_zZ>JoWGQppo3G*1`mTwS7+*0Kav$SV9>8GWXoFH@<0Bky=(tx!u$Uv74M27 zy0}&DR#wXWR&Tc`<`QDLM95{xY-%&QkVfvcMK%e!%q^F>o7_d@lDTc})ELGXW_a7&;zAZ}XWbJx7?5k+-YZtDF8*cSjV2^V{Nk?S>Ee z#E?0!`k>RKVQ!-;Snk$HTTk;J23>!l|`3}PNwn+Gn8%7%m&43?L8hKu%m7Iv_U-1I_KfPry7xgqmb{G-Fi$~RXH=U%*X zys2VY`efIT?6*2VzGZOYAfL`5o(52qWHBLJ=C0^oCSBs0gEY6jS8 z5R*i@)p=O>4SiiBEj9PtST<~0X+(&8B+oV*h)4P7jcZXA0he~qr+gxKdY0EncD7)a4}(cj!|@Pc79(%;M|SNEKLs)N z5MY{0H&qe1Qx3BAw-s{xxE5gBIiVet9((oSA?{R>Jk^@R{;UhvI+knuGV;UWHE59J zii(=v-%~c1|MH6Viu>#V+1~0b(MD{4WoiaZ;WW1sx`7kFf9iMrKqk0_QRI_H=V$;E z!z^vMDqi>L^||@|eRZ7>57*F?(S4nF;gO9oq^ZY7uY`;(X^vt>16sw z)mWxF>Jp<(%MSL^Y6oLH|N1+TzGv5q!fusK49vvu&*$U>VBgUP_zL!qfCg%s+$+Pj zK{fs}$t3Qj33ug_s&T0aXBbz|k!`wexYT?K<8$CZa`_kEoeGC5#LP0Ov8t_v!PF>| zdu&>z5YX&pDM6)2%o}>HeA*)ZLpW!Lv(_le!{w88`PFppyloF4$w)I9THtKH+dbKX zk|Et?1feR+_~Z_k$|@YW>=5+Z%!E? z&L#S7mfhWm#n)GZKf@;c9WX{#h3&P;P>L>d)Bvq`^7#SYJy+h{btEdJHd6yOZ}@Er z`u$5v!a|td064>q@>shE#`JAQBbP0f-WrSlXxOzaXo!1{nPGsEr&fUkYD ztn$?B+8(ntC{p3o4FJb$$h#yUg7My?gU{m~J&2ay`Nbj|XSkWM&Fit5tl+ z!Ww5E7|K%wf&uweMexP$o5t!NM?H?|{8=P@-E17X$9`5IwmPoK?mXrt2IMmJLB8E@ zd>Vf=_8*0qH5L2Y*@x`ul-ujO3l+> zQky70(#EF8%8`0SyyVV+!bjbi%9P6`t;UD0dmhC(#TTD*gsz{TF3vA$aqS+oZdiJ zxhiyGD37iNAFn`Z1M)8A<66t(2w7ut)#f`-SX=5Wi#-GHul0AION(RCxht7s@BN-? zZM%bEASD7>oY_Jw3I6=42)XzL(k`cm=#L|%d{Yb37FaUOmPf&SIy~R|<*Mp^k!-c@ zFHsiYj8xd)Ry%TBl^b3VnMou}VZ8p!r;Wr3m%pdvM70LzF9r&8wC1l&4Sco!Hh_>U z9fyQB8V#)_zctHsi`lmQK3uc;XTAAwJz_q(F60foG!YcJl~P6V>JFeSkScwj(>-`( z#IbM25qHS`;vpf62K933wlptmd^f%mkH8P&OKk=%$ZFtc=)x6@e7G3_>lre1aV2-~ zi(SB7%4%@$-j!~e5@+I9gQq>BO!hBj&4B;L zBPChhaavT%W>nU#izT2JhJDw@5t#ferwnMQ;}S5D8auN%mnnAbSCzr?>GsLr4!^NG zI~iSs>lUcQ1N`kqlC|ptpaAeJzkRl+r(A^){~gY@^O;19g?VHDI!{Mk*^|mH@ECC% zGvpLK%JBTyGo-7gR7Ah%>|?J=BT5vSw!2f%#=VBso0fl_{)ZPP&ha#0BlT8S6w>O` zKP-I8ZJRw$DI8X;hSB!KL|iygj%wLS$2;2sk5~hqmBhC;o&fCFu9;dId(f)A+9Rm^ zq~%3>z^ThU)Ndxnw7la2y{$+mV#W4 za|Ydv`JTLZye3gd3?k}#BHmA2+15R_*nX*=!~Mq&Dv(DsKPJ>gzd4O=i(Akn{xlLLB_S~{?`KlKlKKh|l8c za(CK&(7AC_K4h!JU&D-XS$D1cQ)2Cpj!~?nJix0s^;9VCV#Fm=-tUcqQ-8Rf}sY5qJU3akcRa*$8$dc^( z+%&q1Ej1$D#JOQP`PZpc=>5}jE5AWuz;up;H138*oYtvTd)*j$#lVvm%B~iL=_Q54 zIZdPK4`Z)1clz>k=s;@yq4)iM91UXs&f&qYNRqxL073-wkcl|J1T|fsDuI(6>^7U$ zveZE$OXUt()H27@3|oXp(68j5h#J!P+HH_7HN+m7o}1##50LAQDA{WbiDVA#D;J~- zfg5~Vr(^bLwTMB^(dc*tZ|m|G#d}Mzaofq@4kt4dDlGwb5hmUb4bI#UiUjMq7h$*j za)f4o8!=UK&(b4|=|tE}4e6{k{=#Z8YCEiH5_x6oUhnxo=$X#UeN~cADPp>M1=}^~ zPcDZV)RczkcebgBf)ar>v}&iZ(O!-EC2+fyq!bAD@V6Lwr7MExAQz|a!mF`O&Z~#B z*_5MkIL%>KCEtV-(jmexDqq-^YAIR2zSh_Fq+v9=#z&*N#K8hr>Y0F};%o{4m5l zvdZQhZAgF9t$^kFx8L)$`iuzaoXaY6H$IQ9TU9I!XYt`5Wm-WtjIYAVAzrum3u{La zrOMymf685CC{okQsh#zzPgh4>GhTD?Imxr8n8DE8{4RTe4K>Gfm*em0 z>X}^9QA00joysoPRB=mADVocc^Xt@|q8I8Ydq;_sx%lRZ!-|4Je$LDm@4xZNI4wl{ zM;htHHwLWGC-n4}o%{2aSjsIP002>|&L5P1--{`P(B6#GAy1Cjc}UoIFHcU;o=i($ z+`e!nO0btYtpE1nM|r*vvU5T&z~Kyrw0YTBCr0&~lB$AiZ=SHJ&t06y{6@>W$IN60 z!H2*8Aa@+)YGd;ot<4vh!z z(i8V~KVF~8SAK8^f(up!#`>D7y~fSE%fVUls66jc_)zE6&2+u$O*LWoq}9KJ`|t)^ z{c(yMvfscl;5xZe0~g{Z&?N4=$;3y?wRino&I{cxJQDiG@KB7uc7Em{TkxIV>LY)H zP&0=uJ);FP0o(p3J4FJ8luev5Q2tEkbJTgG>;+1bA}1y5oYV=>kg2AblaJJGX3t2y z?+Q@hF_%QS`;_Y!Cl^we;-lC*cC4J}3UvTHq(4LV-f0)MsEoCC19@MCFg@E0A&o9e zt4eLXv+yoKoqyrEdE-4n_e;x6(K3<&1r1)xIPNegcW_%r>vhfDp9;Zte$o-9I{ zqMG-I=|#Fr=}%F3u}A;d`NHaPer~N|rYnjv<#Bjg-&UOT$5%BpdYoiD5_VRrja+>H zbk}>2*&B$(*%yIq&P=_sfNp5b%p2wkW2MYv7x}?S_ZK)IY;$dyxs|`SHkKN$-k>pc zvj6ybdYbxRuOq8n5YBF%SP>vk$ahvD)}u&gxt4{W>JWC)AERh#YK~LW@w@Ei?d30S zo8~C#x@%HzHIp(HY!$P@vt$e_&4)RhV{8h0cWHyb0Sl0eJyAx5{+v1aN_aZ%v72Oo z;)#X~Hf^tVE}?B|OR#CJZ+Uj>X-Gc*PCQu(YThP-Q`jB(ccolP`xj&GqW|l1eeFLS j{_}_bhfdHi$GdlUyPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NM&`Cr=RA>d|S$lAl)fK4UZ^QfrG;9)>&s8F2>1As~-zlHKp#{?2B<<>uROlU->4==9Efd(Z2h@1AqdIrrXg zh4>$DlYbS-NvQniYpNfo3MaYyP z>HjJ48Cr;GLW@PV)zx6~lfkWJrFZJ=oI@?4BL|O_2X09wmo7am16r4tejbya<9f-I zrm`91U9=u3j3q^43;H}TMn^O~-$URkkhBaCZz@6Y1#$||d;_y`h zX&I1L{fm$Az7y_uYAs4y%VvMhO?Pl*Q@#Vo`W6>{#i%dckx1SkLg6{rxC~8^#h{hD z=uuQlc>}>pr)i=l2X^<3D<2#x?eJ=Nc7Pz-ogb{Nam$!^S}VW<^ZZ3GAP@GMN*KoU z9(lhqFu+Y`9FxD^q?1z3ChowEOK06`MZ)jENe@9^Kx@6}NO@q4!}sdbHh@bYX|)K- zY?s2pR!Dx+MT??DVu(q%q&{ro472AgpS@dxRiR;62E}FWpTC0+u8{ z? z@1oz};Fx?5kG77GYhcYZhi~>;Q)Tg*x`c2uWfBaq?0yL)4ll?PmU_FnY{ndO@P)RV zXMnpBl7_Zt(LzTq7E1hJBI*9QH$fMMcQ++ebOpG&kRZ;ZIM0zmf7H@?E*MbX zdrL5Y#S8pJ^D)_F06X2op>cuA&e?-w#YpBAR@HHgzUh*?&eu-7FJKB0bA#)=bT+pM zXQ4q-$k)L2D!U=j9~fEH@EH&9t9cs0rLrJczg`mE1qH4UiK?8Sq}bn5Q8F`%X~kjC z(_2K#IQccnp;4LcpT==DHIcB8?D>R>!y-hqu%N2$K(~0`zh2ANV-$x%M#%b!7W&~H zjLXR8f&TVKFNCsrQZp`-*&4kz)M*TJK_Ljd$KAq$omI?h_OO(AHkv$&+ugDRx7k)y1-)e?iDrSukA~H#6nxr&MY$44Dxd8$vSS}nSo7h^n1RC(z==y)}_-d{;;#>84Nt4SDqT^b7RMYY-$HGuD{Hi^Zk z4sB4>1|!?BOnCHJTT&Y~m9svdY_l(%JD0auw*CkTKSq@~skQtB^hcqt`7;Rnj6^aadpjKi4cElWn&|uQik6qV~Dv*QZ;eK&1F}IMjAt|+_em6_r0oD!aH}o#pGbF|Dm>~tM-2^jQfUuH+yD<=TBOF6 zrMFqO_yY#rnAX7Fg)e}82G6Hh<})Pe3<}a=*?#^hCl{~Ufa5D?4hcupDpV5Q+`M+! zUZ$x3f-o$AK)MyIQA#zH?<7~W5Aclm($b!3EeYGI0+V;b3LH8^LgOYVa#^mohU2sh z*_j3XeH}xUl6NDOra+!|?UB8W1FzUtawGcLX&ZovQFB@8tV<-;V4^)f5n?$Tu++OS z5#AU2Y|u5epRa3IM0MU*SiN%#2Jj>BrSC?PY>4KLgUKHUaSxu@icmc!QDO*sf>?=neHPs}V$CZk#fj~M$G$N*oYikK z95Y!ceXX*@`l7XUsT;r^k)&{>rUzW=`E%7vU1Tj~dX~2Nx;TtswAWCf{)Wo6t{W96 z&<~5jPRI4$iGpKNiD`LH)*W-0sr2Tuc*-|d%qYc1b;x7#CnSll;zsuHnBewvncCh8 z1;bbgjQX&XtobOx#f+mVTqUQY@39eS(u-L(DuK91x!!xEA}}t7r_?1i;Qb|mYp_GV zg0QHCrfUpsd_;aG;#Xtxf^~mnI*cqMA-)!qiI7mU#|F2b#pIk_`Akt&!y)iD#blx# zcJuEFTTMk;{Ub&nBDH*-PrIgEZ*Wore3|lf4D4~k0^ST~ez71}w|I1A19nfGAj?ap zAfcXMm>UxNM+H?4Z*^muOC-0C`Nt^_PnaPQqY1idKMD49!5Om$R+DN4dCq@UU&r2yaNL_Ah{;xSJzhz zuY9S)QN%|N9k$qOL%M1Dv~6X~asEECGdjmRvV7okU>t#8Mp*vd!O>g%=f{c4d0F7C zz!LJ8J${b`BQ_&Eml(1Mpf7Xl|VcI{xsy-g3QKhjT z&lNBQyx^9K8S^l;1qQFWsH&dNLGgyIKE47t^u1Q8Q&g;iC&KGz+fK^_A{Un-KhHM;68bWai1KLYTsN%kpn=TGF{Zw-Vp%4jb*qM2J6|-q%Ex#(Cy)s&@?=zttG>f z(}r*SRgX{X%*(L5;^D(MzVIa&z;yo44S9lMak&wxTQ6gML+Ken)Ln73IEfhjO0u(y zE#yS=)e+g@X#mUM3&$@J8w`U& Q`Tzg`07*qoM6N<$f+FT;dH?_b literal 0 HcmV?d00001 diff --git a/Sources/CodexBar/Resources/ProviderIcon-gemini.png b/Sources/CodexBar/Resources/ProviderIcon-gemini.png new file mode 100644 index 0000000000000000000000000000000000000000..a2ccb0ffe948d86835aa9526be47036583a3920a GIT binary patch literal 3845 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 Hu0mjfTCGpZ literal 0 HcmV?d00001 diff --git a/Sources/CodexBar/Resources/ProviderIcon-kimi.png b/Sources/CodexBar/Resources/ProviderIcon-kimi.png new file mode 100644 index 0000000000000000000000000000000000000000..213b9b83d6cd30fd57077aa18244dfc4e6fd0d14 GIT binary patch literal 1835 zcmV+`2h{k9P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91FrWhf1ONa40RR91FaQ7m0NXcg3;+NIl}SWFRA>d&T1!lnR~Y^N8HTB) zfGCjK)Jl*bY}n9ZN>fW>jKIc~3%hV*!iJU6u)t*(MnfPmftA{Xg_|al7#B?eF+h_V z(y(b-!a_ogmVhV-$N)3*^_)B0ab{p1%nT22a^`>B*ZE%e-v8clR8=CAgM?={Pz2Nh zKLFnXRX{PoyxmMeG(HDR0wcihz!>lr2!L-iVLn@+0Lc{sHNbDcDzE_r05;6Zdja;v zKG`=p3m3Yrua18`ul7sq{fEOpsK2=v%U=@FK}wY<*i?LBa8S0@Bo}r306N71ZyG@g7r)g^DRaO0L6> z-EOz!=H{B({0RgCvazut!62=fINAChj|a-N1VeTFe!pz^(S~tx>*rYF_}689>Zxo@ zkD}nVeYhz=N+~BNM^2qOCAV(f65cZ{EG$TCYpc9|{W@VABwSQfBv-Cnk+WydMhURC zwkD4sKbHIVf0p3NtoZ)=MI4t0>$9K)X8)Ge@%s|=EkHOc*w(I?lN8XrottLUKDPlot>RV)3M}7M@O}yqC(-0nULQd0j%b4-@dKu>+4%I z|LoZ_BOHZHRe=f0W!D1qdAdzkS638y`i^xQ#*Oj$6s@0te0D8B@<)#zHRKtf)@?|h z!T;pR6RoVQ)GTS7^1JS{-YKYaL5j~_q28{{){p}Dy^J$dqEm^?LY-NurC^yrb&=dRmR81w~RIvfI-~U)TErj86QbTl1%c` z7l3ZUC4qGt61R!(xzuQDYtth~j_jrt(igyzjd#UrRhO5S^}&M&T2)nLtg#E0$c_Ni zIM+K2WD<`|9Em0-CbXfULG$wRvh7lt2|)MZ0&jA1QZHP%pjWS6)mN`xMJ@U$3v{m>zOlWbZl(Q2oV_{yr(sWhK97JrY2hf zp1uIo>htH%wY^ZriHo!I<;$0ig(ANrtV=OwXsJ7Q?wId_Jr{s8F|{ABV+wNU&>^j_uQv<1*ae=A zA1dPF#fx#g96H|LcHWJ&T;OPjz**^uCWDNWPY`br_S@57<$l2^pKgEsz zpt4NBi=26eY}|*(H6Pm%)8HoekNc31!zY6Q8}J4VxF#J^QD#3Ahbo>G4*@uGU literal 0 HcmV?d00001 diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index d8ccab0264..d9237961a9 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1277,6 +1277,18 @@ "Subscriptions" = "الاشتراكات"; "By subscription" = "حسب الاشتراك"; "No model-level history" = "لا يوجد سجل على مستوى النموذج"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "رموز النماذج المتتبعة"; +"Priced model spend" = "إنفاق النماذج المسعّرة"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "لا يوجد سجل للنماذج المسعّرة"; +"Other" = "Other"; +"Show all %d models" = "عرض جميع النماذج (%d)"; +"Show top 20" = "عرض أول 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "المقياس"; +"%d days of usage data across %d models" = "%d بيانات الاستخدام عبر نماذج %d"; +"%@ in · %@ out" = "%@ داخل · %@ خارج"; "Daily estimated spend" = "الإنفاق اليومي التقديري"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d نافذة كاملة مدتها 5 ساعات متبقية من الأسبوعي · %d نافذة حتى إعادة التعيين"; "Weekly cannot run out before reset at this pace" = "لا يمكن أن ينفد الحد الأسبوعي قبل إعادة التعيين بهذه الوتيرة"; @@ -1289,6 +1301,20 @@ "Agent Plan" = "خطة الوكيل"; "Team" = "فريق"; +"7-day average" = "متوسط 7 أيام"; +"Input" = "الإدخال"; +"Output" = "الإخراج"; +"Cache write" = "كتابة ذاكرة التخزين المؤقت"; +"Reasoning" = "الاستدلال"; +"%@ in" = "%@ إدخال"; +"%@ out" = "%@ إخراج"; +"%@ cache read" = "%@ قراءة التخزين المؤقت"; +"%@ cache write" = "%@ كتابة التخزين المؤقت"; +"%@ reasoning" = "%@ استدلال"; +"estimated" = "تقديري"; +"Estimated costs are priced from local logs and may differ from provider bills." = "يتم احتساب التكاليف التقديرية من السجلات المحلية بأسعار القائمة وقد تختلف عن فواتير مزودي الخدمة."; +"Usage details for %@" = "تفاصيل الاستخدام ليوم %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "التخطيط"; "menu_bar_layout_footer" = "اسحب العناصر لترتيب شريط القوائم. انقر على عنصر لإضافته؛ حدّد عنصراً موضوعاً واضغط Delete لإزالته."; @@ -1358,9 +1384,36 @@ "Cumulative" = "تراكمي"; "Token activity" = "نشاط الرموز"; "View" = "عرض"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "حسب النموذج"; +"By tool" = "حسب الأداة"; +"7-day avg" = "متوسط 7 أيام"; "in the last year" = "في العام الماضي"; "No activity in the last 12 months" = "لا يوجد نشاط في آخر 12 شهرًا"; "Each column = 1 week" = "كل عمود = أسبوع واحد"; "Running total" = "الإجمالي التراكمي"; "Less" = "أقل"; "More" = "أكثر"; +"Mo" = "إث"; +"We" = "أر"; +"Fr" = "جم"; +"Estimated" = "تقديري"; +"No per-client model history" = "لا يوجد سجل نماذج لكل عميل"; + +"Cumulative %@ tokens as of %@" = "%@ رمزًا تراكميًا حتى %@"; +"%@ tokens in the week of %@" = "%@ رمزًا في أسبوع %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 2b00a0da57..3bc8938347 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1276,6 +1276,18 @@ "Subscriptions" = "Subscripcions"; "By subscription" = "Per subscripció"; "No model-level history" = "Sense historial per model"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tokens de models seguits"; +"Priced model spend" = "Despesa de models amb preu"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No hi ha historial de models amb preu"; +"Other" = "Other"; +"Show all %d models" = "Mostra els %d models"; +"Show top 20" = "Mostra els 20 primers"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Mètrica"; +"%d days of usage data across %d models" = "%d dies de dades d'ús en %d models"; +"%@ in · %@ out" = "%@ d'entrada · %@ de sortida"; "Daily estimated spend" = "Despesa diària estimada"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d finestres completes de 5 h de quota setmanal · %d finestres fins al reinici"; "Weekly cannot run out before reset at this pace" = "La quota setmanal no es pot esgotar abans del reinici a aquest ritme"; @@ -1288,6 +1300,20 @@ "Agent Plan" = "Pla d'agent"; "Team" = "Equip"; +"7-day average" = "Mitjana de 7 dies"; +"Input" = "Entrada"; +"Output" = "Sortida"; +"Cache write" = "Escriptura de memòria cau"; +"Reasoning" = "Raonament"; +"%@ in" = "%@ entrada"; +"%@ out" = "%@ sortida"; +"%@ cache read" = "%@ lectura de memòria cau"; +"%@ cache write" = "%@ escriptura de memòria cau"; +"%@ reasoning" = "%@ raonament"; +"estimated" = "estimat"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Els costos estimats es calculen a partir dels registres locals a preus de llista i poden diferir de les factures dels proveïdors."; +"Usage details for %@" = "Detalls d'ús per a %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Disposició"; "menu_bar_layout_footer" = "Arrossega les fitxes per ordenar la barra de menús. Fes clic en una fitxa per afegir-la; selecciona una fitxa col·locada i prem Suprimir per eliminar-la."; @@ -1357,9 +1383,36 @@ "Cumulative" = "Acumulat"; "Token activity" = "Activitat de tokens"; "View" = "Vista"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Per model"; +"By tool" = "Per eina"; +"7-day avg" = "Mitjana de 7 dies"; "in the last year" = "l'últim any"; "No activity in the last 12 months" = "Sense activitat en els últims 12 mesos"; "Each column = 1 week" = "Cada columna = 1 setmana"; "Running total" = "Total acumulat"; "Less" = "Menys"; "More" = "Més"; +"Mo" = "dl"; +"We" = "dc"; +"Fr" = "dv"; +"Estimated" = "Estimat"; +"No per-client model history" = "Sense historial de models per client"; + +"Cumulative %@ tokens as of %@" = "%@ tokens acumulats fins al %@"; +"%@ tokens in the week of %@" = "%@ tokens la setmana del %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 5e16c8f6c3..b80984f9cc 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1274,6 +1274,18 @@ "Subscriptions" = "Abonnements"; "By subscription" = "Nach Abonnement"; "No model-level history" = "Kein Verlauf auf Modellebene"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Erfasste Modell-Tokens"; +"Priced model spend" = "Bepreiste Modellausgaben"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Kein Verlauf bepreister Modelle"; +"Other" = "Other"; +"Show all %d models" = "Alle %d Modelle anzeigen"; +"Show top 20" = "Top 20 anzeigen"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metrik"; +"%d days of usage data across %d models" = "%d Tage Nutzungsdaten für %d Modelle"; +"%@ in · %@ out" = "%@ ein · %@ aus"; "Daily estimated spend" = "Geschätzte tägliche Ausgaben"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d volle 5-Std.-Fenster des Wochenlimits übrig · %d Fenster bis zum Reset"; "Weekly cannot run out before reset at this pace" = "Das Wochenlimit kann bei diesem Tempo nicht vor dem Reset aufgebraucht sein"; @@ -1286,6 +1298,20 @@ "Agent Plan" = "Agentenplan"; "Team" = "Team"; +"7-day average" = "7-Tage-Durchschnitt"; +"Input" = "Eingabe"; +"Output" = "Ausgabe"; +"Cache write" = "Cache-Schreibvorgänge"; +"Reasoning" = "Reasoning"; +"%@ in" = "%@ Eingabe"; +"%@ out" = "%@ Ausgabe"; +"%@ cache read" = "%@ Cache-Lesevorgänge"; +"%@ cache write" = "%@ Cache-Schreibvorgänge"; +"%@ reasoning" = "%@ Reasoning"; +"estimated" = "geschätzt"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Geschätzte Kosten werden aus lokalen Protokollen zu Listenpreisen berechnet und können von den Anbieterrechnungen abweichen."; +"Usage details for %@" = "Nutzungsdetails für %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Layout"; "menu_bar_layout_footer" = "Ziehe Bausteine, um die Menüleiste anzuordnen. Klicke einen Baustein zum Anhängen an; wähle einen platzierten Baustein und drücke die Löschtaste, um ihn zu entfernen."; @@ -1355,9 +1381,36 @@ "Cumulative" = "Kumulativ"; "Token activity" = "Token-Aktivität"; "View" = "Ansicht"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Nach Modell"; +"By tool" = "Nach Tool"; +"7-day avg" = "7-Tage-Ø"; "in the last year" = "im letzten Jahr"; "No activity in the last 12 months" = "Keine Aktivität in den letzten 12 Monaten"; "Each column = 1 week" = "Jede Spalte = 1 Woche"; "Running total" = "Laufende Summe"; "Less" = "Weniger"; "More" = "Mehr"; +"Mo" = "Mo"; +"We" = "Mi"; +"Fr" = "Fr"; +"Estimated" = "Geschätzt"; +"No per-client model history" = "Keinen Modellverlauf pro Client"; + +"Cumulative %@ tokens as of %@" = "Kumulativ %@ Token bis %@"; +"%@ tokens in the week of %@" = "%@ Token in der Woche vom %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 9a77d487f3..ecf0f28b9d 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1272,12 +1272,24 @@ "Spend unavailable" = "Spend unavailable"; "Model breakdown unavailable" = "Model breakdown unavailable"; "Local estimated history" = "Local estimated history"; -"Coverage" = "Coverage"; +"Coverage" = "Common complete coverage"; "Estimated spend" = "Estimated spend"; "Tracked tokens" = "Tracked tokens"; "Subscriptions" = "Subscriptions"; "By subscription" = "By subscription"; "No model-level history" = "No model-level history"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tracked model tokens"; +"Priced model spend" = "Priced model spend"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No priced model history"; +"Other" = "Other"; +"Show all %d models" = "Show all %d models"; +"Show top 20" = "Show top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metric"; +"%d days of usage data across %d models" = "%d days of usage data across %d models"; +"%@ in · %@ out" = "%@ in · %@ out"; "Daily estimated spend" = "Daily estimated spend"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d full 5h windows of weekly left · %d windows until reset"; "Weekly cannot run out before reset at this pace" = "Weekly cannot run out before reset at this pace"; @@ -1290,6 +1302,20 @@ "Agent Plan" = "Agent Plan"; "Team" = "Team"; +"7-day average" = "7-day average"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Cache write"; +"Reasoning" = "Reasoning"; +"%@ in" = "%@ in"; +"%@ out" = "%@ out"; +"%@ cache read" = "%@ cache read"; +"%@ cache write" = "%@ cache write"; +"%@ reasoning" = "%@ reasoning"; +"estimated" = "estimated"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Estimated costs are priced from local logs and may differ from provider bills."; +"Usage details for %@" = "Usage details for %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Layout"; "menu_bar_layout_footer" = "Drag tokens to arrange the menu bar. Click a token to append it; select a placed token and press Delete to remove it."; @@ -1359,9 +1385,34 @@ "Cumulative" = "Cumulative"; "Token activity" = "Token activity"; "View" = "View"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "By model"; +"By tool" = "By tool"; +"7-day avg" = "7-day avg"; "in the last year" = "in the last year"; "No activity in the last 12 months" = "No activity in the last 12 months"; "Each column = 1 week" = "Each column = 1 week"; "Running total" = "Running total"; "Less" = "Less"; "More" = "More"; +"Mo" = "Mo"; +"We" = "We"; +"Fr" = "Fr"; +"Estimated" = "Estimated"; +"No per-client model history" = "No per-client model history"; +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"%d models" = "%d models"; +"%d%% context reuse" = "%d%% context reuse"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days covered" = "%d days covered"; +"%d%% reuse" = "%d%% reuse"; +"Reuse unavailable" = "Reuse unavailable"; +"%@ per 1M tokens" = "%@ per 1M tokens"; +"%d days" = "%d days"; + +"Cumulative %@ tokens as of %@" = "Cumulative %@ tokens as of %@"; +"%@ tokens in the week of %@" = "%@ tokens in the week of %@"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index fed3d58c83..716c215310 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1272,6 +1272,18 @@ "Subscriptions" = "Suscripciones"; "By subscription" = "Por suscripción"; "No model-level history" = "No hay historial por modelo"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tokens de modelos registrados"; +"Priced model spend" = "Gasto de modelos con precio"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "No hay historial de modelos con precio"; +"Other" = "Other"; +"Show all %d models" = "Mostrar los %d modelos"; +"Show top 20" = "Mostrar los 20 primeros"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Métrica"; +"%d days of usage data across %d models" = "%d días de datos de uso en %d modelos"; +"%@ in · %@ out" = "%@ de entrada · %@ de salida"; "Daily estimated spend" = "Gasto diario estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d ventanas completas de 5 h de cuota semanal · %d ventanas hasta el reinicio"; "Weekly cannot run out before reset at this pace" = "La cuota semanal no puede agotarse antes del reinicio a este ritmo"; @@ -1284,6 +1296,20 @@ "Agent Plan" = "Plan de agente"; "Team" = "Equipo"; +"7-day average" = "Promedio de 7 días"; +"Input" = "Entrada"; +"Output" = "Salida"; +"Cache write" = "Escritura de caché"; +"Reasoning" = "Razonamiento"; +"%@ in" = "%@ entrada"; +"%@ out" = "%@ salida"; +"%@ cache read" = "%@ lectura de caché"; +"%@ cache write" = "%@ escritura de caché"; +"%@ reasoning" = "%@ razonamiento"; +"estimated" = "estimado"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Los costos estimados se calculan a partir de registros locales a precios de lista y pueden diferir de las facturas de los proveedores."; +"Usage details for %@" = "Detalles de uso del %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Disposición"; "menu_bar_layout_footer" = "Arrastra fichas para ordenar la barra de menús. Haz clic en una ficha para añadirla; selecciona una ficha colocada y pulsa Suprimir para quitarla."; @@ -1353,9 +1379,36 @@ "Cumulative" = "Acumulado"; "Token activity" = "Actividad de tokens"; "View" = "Vista"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Por modelo"; +"By tool" = "Por herramienta"; +"7-day avg" = "Prom. 7 días"; "in the last year" = "en el último año"; "No activity in the last 12 months" = "Sin actividad en los últimos 12 meses"; "Each column = 1 week" = "Cada columna = 1 semana"; "Running total" = "Total acumulado"; "Less" = "Menos"; "More" = "Más"; +"Mo" = "lu"; +"We" = "mi"; +"Fr" = "vi"; +"Estimated" = "Estimado"; +"No per-client model history" = "Sin historial de modelos por cliente"; + +"Cumulative %@ tokens as of %@" = "%@ tokens acumulados hasta el %@"; +"%@ tokens in the week of %@" = "%@ tokens en la semana del %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 345fac7e51..dff71954d7 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1277,6 +1277,18 @@ "Subscriptions" = "اشتراک‌ها"; "By subscription" = "بر اساس اشتراک"; "No model-level history" = "تاریخچه‌ای در سطح مدل وجود ندارد"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "توکن های مدل ردیابی شده"; +"Priced model spend" = "هزینه مدل های قیمت گذاری شده"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "تاریخچه مدل قیمت گذاری شده وجود ندارد"; +"Other" = "Other"; +"Show all %d models" = "نمایش هر %d مدل"; +"Show top 20" = "نمایش 20 مورد برتر"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "متریک"; +"%d days of usage data across %d models" = "%d روز داده های استفاده در مدل های %d"; +"%@ in · %@ out" = "%@ ورودی · %@ خروجی"; "Daily estimated spend" = "برآورد هزینه روزانه"; "≈%d full 5h windows of weekly left · %d windows until reset" = "حدود %d بازه کامل ۵ ساعته از سهم هفتگی مانده · %d بازه تا بازنشانی"; "Weekly cannot run out before reset at this pace" = "با این روند، سهم هفتگی پیش از بازنشانی تمام نمی‌شود"; @@ -1289,6 +1301,20 @@ "Agent Plan" = "طرح عامل"; "Team" = "تیم"; +"7-day average" = "میانگین ۷ روزه"; +"Input" = "ورودی"; +"Output" = "خروجی"; +"Cache write" = "نوشتن حافظه پنهان"; +"Reasoning" = "استدلال"; +"%@ in" = "%@ ورودی"; +"%@ out" = "%@ خروجی"; +"%@ cache read" = "%@ خواندن حافظه پنهان"; +"%@ cache write" = "%@ نوشتن حافظه پنهان"; +"%@ reasoning" = "%@ استدلال"; +"estimated" = "تخمینی"; +"Estimated costs are priced from local logs and may differ from provider bills." = "هزینه‌های تخمینی از گزارش‌های محلی با قیمت‌های فهرست محاسبه می‌شوند و ممکن است با صورت‌حساب ارائه‌دهندگان متفاوت باشند."; +"Usage details for %@" = "جزئیات استفاده برای %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "چیدمان"; "menu_bar_layout_footer" = "نشانه‌ها را برای چیدمان نوار منو بکشید. برای افزودن روی نشانه کلیک کنید؛ نشانهٔ قرارگرفته را انتخاب کنید و برای حذف Delete را بزنید."; @@ -1358,9 +1384,36 @@ "Cumulative" = "تجمعی"; "Token activity" = "فعالیت توکن"; "View" = "نمایش"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "بر اساس مدل"; +"By tool" = "بر اساس ابزار"; +"7-day avg" = "میانگین ۷ روز"; "in the last year" = "در سال گذشته"; "No activity in the last 12 months" = "هیچ فعالیتی در ۱۲ ماه گذشته وجود ندارد"; "Each column = 1 week" = "هر ستون = ۱ هفته"; "Running total" = "مجموع تجمعی"; "Less" = "کمتر"; "More" = "بیشتر"; +"Mo" = "دو"; +"We" = "چه"; +"Fr" = "جم"; +"Estimated" = "تخمینی"; +"No per-client model history" = "بدون سابقه مدل برای هر کلاینت"; + +"Cumulative %@ tokens as of %@" = "%@ توکن تجمعی تا %@"; +"%@ tokens in the week of %@" = "%@ توکن در هفته %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 330037a558..bbfa768190 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1273,6 +1273,18 @@ "Subscriptions" = "Abonnements"; "By subscription" = "Par abonnement"; "No model-level history" = "Aucun historique au niveau des modèles"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Jetons de modèles suivis"; +"Priced model spend" = "Dépenses des modèles tarifés"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Aucun historique de modèles tarifés"; +"Other" = "Other"; +"Show all %d models" = "Afficher les %d modèles"; +"Show top 20" = "Afficher le top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Métrique"; +"%d days of usage data across %d models" = "%d jours de données d'utilisation sur %d modèles"; +"%@ in · %@ out" = "%@ en entrée · %@ en sortie"; "Daily estimated spend" = "Dépenses quotidiennes estimées"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d fenêtres complètes de 5 h de quota hebdomadaire · %d fenêtres avant réinitialisation"; "Weekly cannot run out before reset at this pace" = "Le quota hebdomadaire ne peut pas être épuisé avant la réinitialisation à ce rythme"; @@ -1285,6 +1297,20 @@ "Agent Plan" = "Plan d'agent"; "Team" = "Équipe"; +"7-day average" = "Moyenne sur 7 jours"; +"Input" = "Entrée"; +"Output" = "Sortie"; +"Cache write" = "Écriture de cache"; +"Reasoning" = "Raisonnement"; +"%@ in" = "%@ entrée"; +"%@ out" = "%@ sortie"; +"%@ cache read" = "%@ lecture de cache"; +"%@ cache write" = "%@ écriture de cache"; +"%@ reasoning" = "%@ raisonnement"; +"estimated" = "estimé"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Les coûts estimés sont calculés à partir des journaux locaux aux tarifs officiels et peuvent différer des factures des fournisseurs."; +"Usage details for %@" = "Détails d'utilisation pour %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Disposition"; "menu_bar_layout_footer" = "Faites glisser les jetons pour organiser la barre des menus. Cliquez sur un jeton pour l’ajouter ; sélectionnez un jeton placé et appuyez sur Supprimer pour le retirer."; @@ -1354,9 +1380,36 @@ "Cumulative" = "Cumulé"; "Token activity" = "Activité des tokens"; "View" = "Vue"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Par modèle"; +"By tool" = "Par outil"; +"7-day avg" = "Moy. 7 j"; "in the last year" = "sur l'année écoulée"; "No activity in the last 12 months" = "Aucune activité au cours des 12 derniers mois"; "Each column = 1 week" = "Chaque colonne = 1 semaine"; "Running total" = "Total cumulé"; "Less" = "Moins"; "More" = "Plus"; +"Mo" = "lu"; +"We" = "me"; +"Fr" = "ve"; +"Estimated" = "Estimé"; +"No per-client model history" = "Aucun historique de modèle par client"; + +"Cumulative %@ tokens as of %@" = "%@ tokens cumulés au %@"; +"%@ tokens in the week of %@" = "%@ tokens sur la semaine du %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 6ba99eff34..6a2cdd0941 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1273,6 +1273,18 @@ "Subscriptions" = "Subscricións"; "By subscription" = "Por subscrición"; "No model-level history" = "Sen historial por modelo"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tokens de modelos rexistrados"; +"Priced model spend" = "Gasto de modelos con prezo"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Non hai historial de modelos con prezo"; +"Other" = "Other"; +"Show all %d models" = "Mostrar os %d modelos"; +"Show top 20" = "Mostrar os 20 primeiros"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Métrica"; +"%d days of usage data across %d models" = "%d días de datos de uso en %d modelos"; +"%@ in · %@ out" = "%@ de entrada · %@ de saída"; "Daily estimated spend" = "Gasto diario estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d xanelas completas de 5 h de cota semanal · %d xanelas ata o restablecemento"; "Weekly cannot run out before reset at this pace" = "A cota semanal non pode esgotarse antes do restablecemento a este ritmo"; @@ -1285,6 +1297,20 @@ "Agent Plan" = "Plan de axente"; "Team" = "Equipo"; +"7-day average" = "Media de 7 días"; +"Input" = "Entrada"; +"Output" = "Saída"; +"Cache write" = "Escritura de caché"; +"Reasoning" = "Razoamento"; +"%@ in" = "%@ entrada"; +"%@ out" = "%@ saída"; +"%@ cache read" = "%@ lectura de caché"; +"%@ cache write" = "%@ escritura de caché"; +"%@ reasoning" = "%@ razoamento"; +"estimated" = "estimado"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Os custos estimados calcúlanse a partir dos rexistros locais a prezos de lista e poden diferir das facturas dos provedores."; +"Usage details for %@" = "Detalles de uso de %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Disposición"; "menu_bar_layout_footer" = "Arrastra fichas para ordenar a barra de menús. Preme nunha ficha para engadila; selecciona unha ficha colocada e preme Suprimir para retirala."; @@ -1354,9 +1380,36 @@ "Cumulative" = "Acumulado"; "Token activity" = "Actividade de tokens"; "View" = "Vista"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Por modelo"; +"By tool" = "Por ferramenta"; +"7-day avg" = "Media de 7 días"; "in the last year" = "no último ano"; "No activity in the last 12 months" = "Sen actividade nos últimos 12 meses"; "Each column = 1 week" = "Cada columna = 1 semana"; "Running total" = "Total acumulado"; "Less" = "Menos"; "More" = "Máis"; +"Mo" = "lu"; +"We" = "mé"; +"Fr" = "ve"; +"Estimated" = "Estimado"; +"No per-client model history" = "Sen historial de modelos por cliente"; + +"Cumulative %@ tokens as of %@" = "%@ tokens acumulados ata %@"; +"%@ tokens in the week of %@" = "%@ tokens na semana do %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 37a8594943..2010081d5e 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1277,6 +1277,18 @@ "Subscriptions" = "Langganan"; "By subscription" = "Berdasarkan langganan"; "No model-level history" = "Tidak ada riwayat tingkat model"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Token model terlacak"; +"Priced model spend" = "Pengeluaran model berharga"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Tidak ada riwayat model berharga"; +"Other" = "Other"; +"Show all %d models" = "Tampilkan semua %d model"; +"Show top 20" = "Tampilkan 20 teratas"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metrik"; +"%d days of usage data across %d models" = "%d hari data penggunaan di %d model"; +"%@ in · %@ out" = "%@ masuk · %@ keluar"; "Daily estimated spend" = "Perkiraan pengeluaran harian"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d jendela 5 jam penuh dari kuota mingguan tersisa · %d jendela hingga reset"; "Weekly cannot run out before reset at this pace" = "Kuota mingguan tidak dapat habis sebelum reset dengan laju ini"; @@ -1289,6 +1301,20 @@ "Agent Plan" = "Paket Agen"; "Team" = "Tim"; +"7-day average" = "Rata-rata 7 hari"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Tulis cache"; +"Reasoning" = "Penalaran"; +"%@ in" = "%@ input"; +"%@ out" = "%@ output"; +"%@ cache read" = "%@ baca cache"; +"%@ cache write" = "%@ tulis cache"; +"%@ reasoning" = "%@ penalaran"; +"estimated" = "perkiraan"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Biaya perkiraan dihitung dari log lokal dengan harga daftar dan mungkin berbeda dari tagihan penyedia."; +"Usage details for %@" = "Detail penggunaan untuk %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Tata letak"; "menu_bar_layout_footer" = "Seret token untuk mengatur bar menu. Klik token untuk menambahkannya; pilih token yang sudah ditempatkan lalu tekan Delete untuk menghapusnya."; @@ -1358,9 +1384,36 @@ "Cumulative" = "Kumulatif"; "Token activity" = "Aktivitas token"; "View" = "Tampilan"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Per model"; +"By tool" = "Per alat"; +"7-day avg" = "Rata-rata 7 hari"; "in the last year" = "dalam setahun terakhir"; "No activity in the last 12 months" = "Tidak ada aktivitas dalam 12 bulan terakhir"; "Each column = 1 week" = "Setiap kolom = 1 minggu"; "Running total" = "Total berjalan"; "Less" = "Sedikit"; "More" = "Banyak"; +"Mo" = "Sen"; +"We" = "Rab"; +"Fr" = "Jum"; +"Estimated" = "Perkiraan"; +"No per-client model history" = "Tidak ada riwayat model per klien"; + +"Cumulative %@ tokens as of %@" = "Kumulatif %@ token hingga %@"; +"%@ tokens in the week of %@" = "%@ token dalam minggu %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index caaf23ccd5..fdc2ef17c4 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1277,6 +1277,18 @@ "Subscriptions" = "Abbonamenti"; "By subscription" = "Per abbonamento"; "No model-level history" = "Nessuna cronologia a livello di modello"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Token dei modelli tracciati"; +"Priced model spend" = "Spesa dei modelli con prezzo"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Nessuno storico dei modelli con prezzo"; +"Other" = "Other"; +"Show all %d models" = "Mostra tutti i %d modelli"; +"Show top 20" = "Mostra i primi 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metrica"; +"%d days of usage data across %d models" = "%d giorni di dati di utilizzo su %d modelli"; +"%@ in · %@ out" = "%@ in ingresso · %@ in uscita"; "Daily estimated spend" = "Spesa giornaliera stimata"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d finestre complete da 5 h di quota settimanale · %d finestre al reset"; "Weekly cannot run out before reset at this pace" = "La quota settimanale non può esaurirsi prima del reset a questo ritmo"; @@ -1289,6 +1301,20 @@ "Agent Plan" = "Piano agente"; "Team" = "Squadra"; +"7-day average" = "Media a 7 giorni"; +"Input" = "Input"; +"Output" = "Output"; +"Cache write" = "Scrittura cache"; +"Reasoning" = "Ragionamento"; +"%@ in" = "%@ input"; +"%@ out" = "%@ output"; +"%@ cache read" = "%@ lettura cache"; +"%@ cache write" = "%@ scrittura cache"; +"%@ reasoning" = "%@ ragionamento"; +"estimated" = "stimato"; +"Estimated costs are priced from local logs and may differ from provider bills." = "I costi stimati sono calcolati dai log locali ai prezzi di listino e possono differire dalle fatture dei provider."; +"Usage details for %@" = "Dettagli di utilizzo per %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Disposizione"; "menu_bar_layout_footer" = "Trascina i token per disporre la barra dei menu. Fai clic su un token per aggiungerlo; seleziona un token posizionato e premi Canc per rimuoverlo."; @@ -1358,9 +1384,36 @@ "Cumulative" = "Cumulativo"; "Token activity" = "Attività token"; "View" = "Vista"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Per modello"; +"By tool" = "Per strumento"; +"7-day avg" = "Media 7 gg"; "in the last year" = "nell'ultimo anno"; "No activity in the last 12 months" = "Nessuna attività negli ultimi 12 mesi"; "Each column = 1 week" = "Ogni colonna = 1 settimana"; "Running total" = "Totale progressivo"; "Less" = "Meno"; "More" = "Più"; +"Mo" = "lu"; +"We" = "me"; +"Fr" = "ve"; +"Estimated" = "Stimato"; +"No per-client model history" = "Nessuna cronologia modelli per client"; + +"Cumulative %@ tokens as of %@" = "%@ token cumulativi al %@"; +"%@ tokens in the week of %@" = "%@ token nella settimana del %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 2e33836250..b8cfb46ce5 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1274,6 +1274,18 @@ "Subscriptions" = "サブスクリプション"; "By subscription" = "サブスクリプション別"; "No model-level history" = "モデル別の履歴はありません"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "追跡されたモデルトークン"; +"Priced model spend" = "価格設定済みモデルの支出"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "価格設定済みモデルの履歴がありません"; +"Other" = "Other"; +"Show all %d models" = "すべての %d モデルを表示"; +"Show top 20" = "上位 20 件を表示"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "指標"; +"%d days of usage data across %d models" = "%2$dモデルにわたる%1$d日間の使用状況データ"; +"%@ in · %@ out" = "%@ 入力 · %@ 出力"; "Daily estimated spend" = "日別推定支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "週間枠は約%d回分の完全な5時間ウィンドウ · リセットまで%d回"; "Weekly cannot run out before reset at this pace" = "このペースではリセット前に週間枠を使い切れません"; @@ -1286,6 +1298,20 @@ "Agent Plan" = "エージェントプラン"; "Team" = "チーム"; +"7-day average" = "7日平均"; +"Input" = "入力"; +"Output" = "出力"; +"Cache write" = "キャッシュ書き込み"; +"Reasoning" = "推論"; +"%@ in" = "%@ 入力"; +"%@ out" = "%@ 出力"; +"%@ cache read" = "%@ キャッシュ読み取り"; +"%@ cache write" = "%@ キャッシュ書き込み"; +"%@ reasoning" = "%@ 推論"; +"estimated" = "推定"; +"Estimated costs are priced from local logs and may differ from provider bills." = "推定コストはローカルログから定価で計算されており、プロバイダーの請求と異なる場合があります。"; +"Usage details for %@" = "%@ の使用詳細"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "レイアウト"; "menu_bar_layout_footer" = "トークンをドラッグしてメニューバーを並べます。クリックすると追加でき、配置済みのトークンを選択して Delete キーを押すと削除できます。"; @@ -1355,9 +1381,36 @@ "Cumulative" = "累計"; "Token activity" = "トークンアクティビティ"; "View" = "表示"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "モデル別"; +"By tool" = "ツール別"; +"7-day avg" = "7日平均"; "in the last year" = "過去1年"; "No activity in the last 12 months" = "過去12か月にアクティビティなし"; "Each column = 1 week" = "各列 = 1週間"; "Running total" = "累計"; "Less" = "少"; "More" = "多"; +"Mo" = "月"; +"We" = "水"; +"Fr" = "金"; +"Estimated" = "推定"; +"No per-client model history" = "クライアント別のモデル履歴なし"; + +"Cumulative %@ tokens as of %@" = "%2$@ までの累計 %1$@ トークン"; +"%@ tokens in the week of %@" = "%2$@ の週に %1$@ トークン"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 20f60ce38d..481c0768d3 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1241,6 +1241,18 @@ "Subscriptions" = "구독"; "By subscription" = "구독별"; "No model-level history" = "모델별 내역이 없습니다"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "추적된 모델 토큰"; +"Priced model spend" = "가격이 책정된 모델 지출"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "가격이 책정된 모델 기록 없음"; +"Other" = "Other"; +"Show all %d models" = "%d개 모델 모두 표시"; +"Show top 20" = "상위 20개 표시"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "지표"; +"%d days of usage data across %d models" = "%2$d개 모델의 %1$d일간 사용량 데이터"; +"%@ in · %@ out" = "%@ 입력 · %@ 출력"; "Daily estimated spend" = "일별 예상 지출"; "≈%d full 5h windows of weekly left · %d windows until reset" = "주간 한도 약 %d개의 전체 5시간 창 남음 · 재설정까지 %d개 창"; "Weekly cannot run out before reset at this pace" = "이 속도라면 재설정 전에 주간 한도를 소진할 수 없습니다"; @@ -1253,6 +1265,20 @@ "Agent Plan" = "에이전트 요금제"; "Team" = "팀"; +"7-day average" = "7일 평균"; +"Input" = "입력"; +"Output" = "출력"; +"Cache write" = "캐시 쓰기"; +"Reasoning" = "추론"; +"%@ in" = "%@ 입력"; +"%@ out" = "%@ 출력"; +"%@ cache read" = "%@ 캐시 읽기"; +"%@ cache write" = "%@ 캐시 쓰기"; +"%@ reasoning" = "%@ 추론"; +"estimated" = "추정"; +"Estimated costs are priced from local logs and may differ from provider bills." = "추정 비용은 로컬 로그를 기준으로 정가로 계산되며, 제공업체 청구서와 다를 수 있습니다."; +"Usage details for %@" = "%@ 사용량 세부 정보"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "레이아웃"; "menu_bar_layout_footer" = "토큰을 드래그해 메뉴 막대를 배치하세요. 토큰을 클릭하면 추가되고, 배치된 토큰을 선택한 뒤 Delete 키를 누르면 제거됩니다."; @@ -1322,9 +1348,36 @@ "Cumulative" = "누적"; "Token activity" = "토큰 활동"; "View" = "보기"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "모델별"; +"By tool" = "도구별"; +"7-day avg" = "7일 평균"; "in the last year" = "지난 1년"; "No activity in the last 12 months" = "지난 12개월 동안 활동 없음"; "Each column = 1 week" = "각 열 = 1주"; "Running total" = "누적 합계"; "Less" = "적음"; "More" = "많음"; +"Mo" = "월"; +"We" = "수"; +"Fr" = "금"; +"Estimated" = "추정"; +"No per-client model history" = "클이언트별 모델 기록 없음"; + +"Cumulative %@ tokens as of %@" = "%2$@ 기준 누적 %1$@ 토큰"; +"%@ tokens in the week of %@" = "%2$@ 주에 %1$@ 토큰"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 2897dd1119..ef40336ab7 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1273,6 +1273,18 @@ "Subscriptions" = "Abonnementen"; "By subscription" = "Per abonnement"; "No model-level history" = "Geen geschiedenis op modelniveau"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Bijgehouden modeltokens"; +"Priced model spend" = "Geprijsde modeluitgaven"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Geen geschiedenis van geprijsde modellen"; +"Other" = "Other"; +"Show all %d models" = "Toon alle %d modellen"; +"Show top 20" = "Toon top 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metriek"; +"%d days of usage data across %d models" = "%d dagen aan gebruiksgegevens voor %d modellen"; +"%@ in · %@ out" = "%@ in · %@ uit"; "Daily estimated spend" = "Geschatte dagelijkse uitgaven"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d volledige vensters van 5 uur aan weeklimiet over · %d vensters tot reset"; "Weekly cannot run out before reset at this pace" = "Het weeklimiet kan bij dit tempo niet vóór de reset opraken"; @@ -1285,6 +1297,20 @@ "Agent Plan" = "Agentplan"; "Team" = "Team"; +"7-day average" = "7-daags gemiddelde"; +"Input" = "Invoer"; +"Output" = "Uitvoer"; +"Cache write" = "Cache schrijven"; +"Reasoning" = "Redenering"; +"%@ in" = "%@ invoer"; +"%@ out" = "%@ uitvoer"; +"%@ cache read" = "%@ cache lezen"; +"%@ cache write" = "%@ cache schrijven"; +"%@ reasoning" = "%@ redenering"; +"estimated" = "geschat"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Geschatte kosten worden berekend uit lokale logs tegen catalogusprijzen en kunnen afwijken van de facturen van providers."; +"Usage details for %@" = "Gebruiksdetails voor %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Indeling"; "menu_bar_layout_footer" = "Sleep tokens om de menubalk in te delen. Klik op een token om deze toe te voegen; selecteer een geplaatst token en druk op Delete om het te verwijderen."; @@ -1354,9 +1380,36 @@ "Cumulative" = "Cumulatief"; "Token activity" = "Token-activiteit"; "View" = "Weergave"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Per model"; +"By tool" = "Per tool"; +"7-day avg" = "7-daags gem."; "in the last year" = "in het afgelopen jaar"; "No activity in the last 12 months" = "Geen activiteit in de afgelopen 12 maanden"; "Each column = 1 week" = "Elke kolom = 1 week"; "Running total" = "Lopend totaal"; "Less" = "Minder"; "More" = "Meer"; +"Mo" = "ma"; +"We" = "wo"; +"Fr" = "vr"; +"Estimated" = "Geschat"; +"No per-client model history" = "Geen modelgeschiedenis per client"; + +"Cumulative %@ tokens as of %@" = "Cumulatief %@ tokens tot %@"; +"%@ tokens in the week of %@" = "%@ tokens in de week van %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index b17f3f01c5..4202be783a 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1277,6 +1277,18 @@ "Subscriptions" = "Subskrypcje"; "By subscription" = "Według subskrypcji"; "No model-level history" = "Brak historii na poziomie modeli"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Śledzone tokeny modeli"; +"Priced model spend" = "Wycenione wydatki modeli"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Brak historii wycenionych modeli"; +"Other" = "Other"; +"Show all %d models" = "Pokaż wszystkie modele (%d)"; +"Show top 20" = "Pokaż 20 najlepszych"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Metryka"; +"%d days of usage data across %d models" = "%d dni danych użycia dla %d modeli"; +"%@ in · %@ out" = "%@ wej. · %@ wyj."; "Daily estimated spend" = "Szacowane dzienne wydatki"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d pełnych 5-godz. okien limitu tygodniowego · %d okien do resetu"; "Weekly cannot run out before reset at this pace" = "Przy tym tempie limit tygodniowy nie może wyczerpać się przed resetem"; @@ -1289,6 +1301,20 @@ "Agent Plan" = "Plan agenta"; "Team" = "Zespół"; +"7-day average" = "Średnia z 7 dni"; +"Input" = "Wejście"; +"Output" = "Wyjście"; +"Cache write" = "Zapis pamięci podręcznej"; +"Reasoning" = "Wnioskowanie"; +"%@ in" = "%@ wejście"; +"%@ out" = "%@ wyjście"; +"%@ cache read" = "%@ odczyt pamięci podręcznej"; +"%@ cache write" = "%@ zapis pamięci podręcznej"; +"%@ reasoning" = "%@ wnioskowanie"; +"estimated" = "szacunkowe"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Szacunkowe koszty są obliczane na podstawie lokalnych dzienników według cen katalogowych i mogą się różnić od faktur dostawców."; +"Usage details for %@" = "Szczegóły użycia dla %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Układ"; "menu_bar_layout_footer" = "Przeciągaj elementy, aby ułożyć pasek menu. Kliknij element, aby go dodać; zaznacz umieszczony element i naciśnij Delete, aby go usunąć."; @@ -1358,9 +1384,36 @@ "Cumulative" = "Łącznie"; "Token activity" = "Aktywność tokenów"; "View" = "Widok"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Według modelu"; +"By tool" = "Według narzędzia"; +"7-day avg" = "Śr. 7-dniowa"; "in the last year" = "w ciągu ostatniego roku"; "No activity in the last 12 months" = "Brak aktywności w ciągu ostatnich 12 miesięcy"; "Each column = 1 week" = "Każda kolumna = 1 tydzień"; "Running total" = "Suma narastająca"; "Less" = "Mniej"; "More" = "Więcej"; +"Mo" = "pn"; +"We" = "śr"; +"Fr" = "pt"; +"Estimated" = "Szacunkowo"; +"No per-client model history" = "Brak historii modeli na klienta"; + +"Cumulative %@ tokens as of %@" = "Łącznie %@ tokenów do %@"; +"%@ tokens in the week of %@" = "%@ tokenów w tygodniu od %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 1281d2fcfc..7ca95b2812 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1274,6 +1274,18 @@ "Subscriptions" = "Assinaturas"; "By subscription" = "Por assinatura"; "No model-level history" = "Sem histórico por modelo"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Tokens de modelos rastreados"; +"Priced model spend" = "Gasto de modelos precificados"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Sem histórico de modelos precificados"; +"Other" = "Other"; +"Show all %d models" = "Mostrar todos os %d modelos"; +"Show top 20" = "Mostrar os 20 primeiros"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Métrica"; +"%d days of usage data across %d models" = "%d dias de dados de uso em %d modelos"; +"%@ in · %@ out" = "%@ de entrada · %@ de saída"; "Daily estimated spend" = "Gasto diário estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d janelas completas de 5 h da cota semanal · %d janelas até a renovação"; "Weekly cannot run out before reset at this pace" = "A cota semanal não pode acabar antes da renovação nesse ritmo"; @@ -1286,6 +1298,20 @@ "Agent Plan" = "Plano de agente"; "Team" = "Equipe"; +"7-day average" = "Média de 7 dias"; +"Input" = "Entrada"; +"Output" = "Saída"; +"Cache write" = "Gravação de cache"; +"Reasoning" = "Raciocínio"; +"%@ in" = "%@ entrada"; +"%@ out" = "%@ saída"; +"%@ cache read" = "%@ leitura de cache"; +"%@ cache write" = "%@ gravação de cache"; +"%@ reasoning" = "%@ raciocínio"; +"estimated" = "estimado"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Os custos estimados são calculados a partir de logs locais a preços de tabela e podem diferir das faturas dos provedores."; +"Usage details for %@" = "Detalhes de uso de %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Layout"; "menu_bar_layout_footer" = "Arraste os itens para organizar a barra de menus. Clique em um item para adicioná-lo; selecione um item posicionado e pressione Delete para removê-lo."; @@ -1355,9 +1381,36 @@ "Cumulative" = "Acumulado"; "Token activity" = "Atividade de tokens"; "View" = "Visualização"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Por modelo"; +"By tool" = "Por ferramenta"; +"7-day avg" = "Média de 7 dias"; "in the last year" = "no último ano"; "No activity in the last 12 months" = "Sem atividade nos últimos 12 meses"; "Each column = 1 week" = "Cada coluna = 1 semana"; "Running total" = "Total acumulado"; "Less" = "Menos"; "More" = "Mais"; +"Mo" = "seg"; +"We" = "qua"; +"Fr" = "sex"; +"Estimated" = "Estimado"; +"No per-client model history" = "Sem histórico de modelos por cliente"; + +"Cumulative %@ tokens as of %@" = "%@ tokens acumulados até %@"; +"%@ tokens in the week of %@" = "%@ tokens na semana de %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 048aef309a..f37877046e 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1275,6 +1275,18 @@ "Subscriptions" = "Подписки"; "By subscription" = "По подпискам"; "No model-level history" = "Нет истории по моделям"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Отслеживаемые токены моделей"; +"Priced model spend" = "Расходы по тарифицированным моделям"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Нет истории по тарифицированным моделям"; +"Other" = "Other"; +"Show all %d models" = "Показать все модели (%d)"; +"Show top 20" = "Показать первые 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Метрика"; +"%d days of usage data across %d models" = "Данные об использовании за %d дней по %d моделям"; +"%@ in · %@ out" = "%@ вх. · %@ исх."; "Daily estimated spend" = "Предполагаемые ежедневные расходы"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d полных 5-часовых окон недельного лимита · %d окон до сброса"; "Weekly cannot run out before reset at this pace" = "При таком темпе недельный лимит не может закончиться до сброса"; @@ -1287,6 +1299,20 @@ "Agent Plan" = "План агента"; "Team" = "Команда"; +"7-day average" = "Среднее за 7 дней"; +"Input" = "Входные"; +"Output" = "Выходные"; +"Cache write" = "Запись кэша"; +"Reasoning" = "Рассуждения"; +"%@ in" = "%@ входные"; +"%@ out" = "%@ выходные"; +"%@ cache read" = "%@ чтение кэша"; +"%@ cache write" = "%@ запись кэша"; +"%@ reasoning" = "%@ рассуждения"; +"estimated" = "оценка"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Оценочные затраты рассчитываются по локальным журналам по прейскурантным ценам и могут отличаться от счетов провайдеров."; +"Usage details for %@" = "Сведения об использовании за %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Компоновка"; "menu_bar_layout_footer" = "Перетаскивайте элементы, чтобы настроить строку меню. Нажмите элемент, чтобы добавить его; выберите размещённый элемент и нажмите Delete, чтобы удалить его."; @@ -1356,9 +1382,36 @@ "Cumulative" = "Накопительно"; "Token activity" = "Активность токенов"; "View" = "Вид"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "По модели"; +"By tool" = "По инструменту"; +"7-day avg" = "Ср. за 7 дн."; "in the last year" = "за последний год"; "No activity in the last 12 months" = "Нет активности за последние 12 месяцев"; "Each column = 1 week" = "Каждый столбец = 1 неделя"; "Running total" = "Накопленный итог"; "Less" = "Меньше"; "More" = "Больше"; +"Mo" = "пн"; +"We" = "ср"; +"Fr" = "пт"; +"Estimated" = "Оценочно"; +"No per-client model history" = "Нет истории моделей по клиентам"; + +"Cumulative %@ tokens as of %@" = "Всего %@ токенов на %@"; +"%@ tokens in the week of %@" = "%@ токенов за неделю от %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 591702ebb6..baa467c201 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1272,6 +1272,18 @@ "Subscriptions" = "Abonnemang"; "By subscription" = "Per abonnemang"; "No model-level history" = "Ingen historik på modellnivå"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Spårade modelltoken"; +"Priced model spend" = "Prissatta modellutgifter"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Ingen historik för prissatta modeller"; +"Other" = "Other"; +"Show all %d models" = "Visa alla %d modeller"; +"Show top 20" = "Visa topp 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Mått"; +"%d days of usage data across %d models" = "%d dagar med användningsdata för %d modeller"; +"%@ in · %@ out" = "%@ in · %@ ut"; "Daily estimated spend" = "Uppskattade dagliga utgifter"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d fulla 5-timmarsfönster av veckokvoten kvar · %d fönster till återställning"; "Weekly cannot run out before reset at this pace" = "Veckokvoten kan inte ta slut före återställningen i den här takten"; @@ -1284,6 +1296,20 @@ "Agent Plan" = "Agentplan"; "Team" = "Team"; +"7-day average" = "7-dagars medelvärde"; +"Input" = "Indata"; +"Output" = "Utdata"; +"Cache write" = "Cacheskrivning"; +"Reasoning" = "Resonemang"; +"%@ in" = "%@ indata"; +"%@ out" = "%@ utdata"; +"%@ cache read" = "%@ cacheläsning"; +"%@ cache write" = "%@ cacheskrivning"; +"%@ reasoning" = "%@ resonemang"; +"estimated" = "uppskattat"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Uppskattade kostnader beräknas från lokala loggar till listpriser och kan skilja sig från leverantörernas fakturor."; +"Usage details for %@" = "Användningsdetaljer för %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Layout"; "menu_bar_layout_footer" = "Dra brickor för att ordna menyraden. Klicka på en bricka för att lägga till den; markera en placerad bricka och tryck Delete för att ta bort den."; @@ -1353,9 +1379,36 @@ "Cumulative" = "Kumulativt"; "Token activity" = "Token-aktivitet"; "View" = "Vy"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Per modell"; +"By tool" = "Per verktyg"; +"7-day avg" = "7-dagars snitt"; "in the last year" = "det senaste året"; "No activity in the last 12 months" = "Ingen aktivitet de senaste 12 månaderna"; "Each column = 1 week" = "Varje kolumn = 1 vecka"; "Running total" = "Löpande summa"; "Less" = "Mindre"; "More" = "Mer"; +"Mo" = "må"; +"We" = "on"; +"Fr" = "fr"; +"Estimated" = "Uppskattat"; +"No per-client model history" = "Ingen modellhistorik per klient"; + +"Cumulative %@ tokens as of %@" = "Kumulativt %@ token till och med %@"; +"%@ tokens in the week of %@" = "%@ token under veckan från %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 9a868526ed..a30dc09dde 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1277,6 +1277,18 @@ "Subscriptions" = "การสมัครสมาชิก"; "By subscription" = "แยกตามการสมัครสมาชิก"; "No model-level history" = "ไม่มีประวัติระดับโมเดล"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "โทเค็นโมเดลที่ติดตาม"; +"Priced model spend" = "ค่าใช้จ่ายโมเดลที่กำหนดราคา"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "ไม่มีประวัติโมเดลที่กำหนดราคา"; +"Other" = "Other"; +"Show all %d models" = "แสดงโมเดลทั้งหมด %d รายการ"; +"Show top 20" = "แสดง 20 อันดับแรก"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "ตัวชี้วัด"; +"%d days of usage data across %d models" = "ข้อมูลการใช้งาน %d วันในโมเดล %d"; +"%@ in · %@ out" = "%@ ขาเข้า · %@ ขาออก"; "Daily estimated spend" = "ค่าใช้จ่ายรายวันโดยประมาณ"; "≈%d full 5h windows of weekly left · %d windows until reset" = "เหลือโควตารายสัปดาห์ ≈%d ช่วงเต็ม 5 ชม. · อีก %d ช่วงจนรีเซ็ต"; "Weekly cannot run out before reset at this pace" = "ด้วยอัตรานี้ โควตารายสัปดาห์จะไม่หมดก่อนรีเซ็ต"; @@ -1289,6 +1301,20 @@ "Agent Plan" = "แผนเอเจนต์"; "Team" = "ทีม"; +"7-day average" = "ค่าเฉลี่ย 7 วัน"; +"Input" = "อินพุต"; +"Output" = "เอาต์พุต"; +"Cache write" = "เขียนแคช"; +"Reasoning" = "การให้เหตุผล"; +"%@ in" = "%@ อินพุต"; +"%@ out" = "%@ เอาต์พุต"; +"%@ cache read" = "%@ อ่านแคช"; +"%@ cache write" = "%@ เขียนแคช"; +"%@ reasoning" = "%@ การให้เหตุผล"; +"estimated" = "โดยประมาณ"; +"Estimated costs are priced from local logs and may differ from provider bills." = "ค่าใช้จ่ายโดยประมาณคำนวณจากบันทึกในเครื่องตามราคาประกาศ และอาจแตกต่างจากใบแจ้งหนี้ของผู้ให้บริการ"; +"Usage details for %@" = "รายละเอียดการใช้งานสำหรับ %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "เค้าโครง"; "menu_bar_layout_footer" = "ลากโทเค็นเพื่อจัดเรียงแถบเมนู คลิกโทเค็นเพื่อเพิ่ม เลือกโทเค็นที่วางแล้วและกด Delete เพื่อลบ"; @@ -1358,9 +1384,36 @@ "Cumulative" = "สะสม"; "Token activity" = "กิจกรรมโทเทน"; "View" = "มุมมอง"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "ตามโมเดล"; +"By tool" = "ตามเครื่องมือ"; +"7-day avg" = "ค่าเฉลี่ย 7 วัน"; "in the last year" = "ในปีที่ผ่านมา"; "No activity in the last 12 months" = "ไม่มีกิจกรรมในช่วง 12 เดือนที่ผ่านมา"; "Each column = 1 week" = "แต่ละคอลัมน์ = 1 สัปดาห์"; "Running total" = "ยอดรวมสะสม"; "Less" = "น้อย"; "More" = "มาก"; +"Mo" = "จ"; +"We" = "พ"; +"Fr" = "ศ"; +"Estimated" = "โดยประมาณ"; +"No per-client model history" = "ไม่มีประวัติโมเดลต่อไคลเอนต์"; + +"Cumulative %@ tokens as of %@" = "%@ โทเทนสะสมถึง %@"; +"%@ tokens in the week of %@" = "%@ โทเทนในสัปดาห์ของ %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 37c69e6368..fabc73afe3 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1275,6 +1275,18 @@ "Subscriptions" = "Abonelikler"; "By subscription" = "Aboneliğe göre"; "No model-level history" = "Model düzeyinde geçmiş yok"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "İzlenen model jetonları"; +"Priced model spend" = "Fiyatlandırılan model harcamaları"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Fiyatlandırılmış model geçmişi yok"; +"Other" = "Other"; +"Show all %d models" = "Tüm %d modeli göster"; +"Show top 20" = "İlk 20'yi göster"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Ölçüt"; +"%d days of usage data across %d models" = "%2$d model için %1$d günlük kullanım verisi"; +"%@ in · %@ out" = "%@ girdi · %@ çıktı"; "Daily estimated spend" = "Günlük tahmini harcama"; "≈%d full 5h windows of weekly left · %d windows until reset" = "Haftalık kotadan ≈%d tam 5 saatlik pencere kaldı · sıfırlamaya %d pencere"; "Weekly cannot run out before reset at this pace" = "Bu hızda haftalık kota sıfırlamadan önce tükenemez"; @@ -1287,6 +1299,20 @@ "Agent Plan" = "Ajan Planı"; "Team" = "Ekip"; +"7-day average" = "7 günlük ortalama"; +"Input" = "Girdi"; +"Output" = "Çıktı"; +"Cache write" = "Önbellek yazma"; +"Reasoning" = "Muhakeme"; +"%@ in" = "%@ girdi"; +"%@ out" = "%@ çıktı"; +"%@ cache read" = "%@ önbellek okuma"; +"%@ cache write" = "%@ önbellek yazma"; +"%@ reasoning" = "%@ muhakeme"; +"estimated" = "tahmini"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Tahmini maliyetler yerel günlüklerden liste fiyatlarıyla hesaplanır ve sağlayıcı faturalarından farklı olabilir."; +"Usage details for %@" = "%@ için kullanım ayrıntıları"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Düzen"; "menu_bar_layout_footer" = "Menü çubuğunu düzenlemek için belirteçleri sürükleyin. Eklemek için bir belirtece tıklayın; yerleştirilmiş bir belirteci seçip silmek için Delete tuşuna basın."; @@ -1356,9 +1382,36 @@ "Cumulative" = "Kümülatif"; "Token activity" = "Token etkinliği"; "View" = "Görünüm"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Modele göre"; +"By tool" = "Araca göre"; +"7-day avg" = "7 günlük ort."; "in the last year" = "son yılda"; "No activity in the last 12 months" = "Son 12 ayda etkinlik yok"; "Each column = 1 week" = "Her sütun = 1 hafta"; "Running total" = "Birikimli toplam"; "Less" = "Az"; "More" = "Çok"; +"Mo" = "Pt"; +"We" = "Ça"; +"Fr" = "Cu"; +"Estimated" = "Tahmini"; +"No per-client model history" = "İstemci başına model geçmişi yok"; + +"Cumulative %@ tokens as of %@" = "%@ tarihine kadar toplam %@ token"; +"%@ tokens in the week of %@" = "%@ haftasında %@ token"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index d7639b851f..35a6c775cd 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1273,6 +1273,18 @@ "Subscriptions" = "Підписки"; "By subscription" = "За підписками"; "No model-level history" = "Немає історії за моделями"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Відстежувані токени моделей"; +"Priced model spend" = "Витрати на моделі з ціною"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Немає історії моделей з ціною"; +"Other" = "Other"; +"Show all %d models" = "Показати всі моделі (%d)"; +"Show top 20" = "Показати перші 20"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Метрика"; +"%d days of usage data across %d models" = "%d днів використання даних у %d моделях"; +"%@ in · %@ out" = "%@ вх. · %@ вих."; "Daily estimated spend" = "Орієнтовні щоденні витрати"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d повних 5-годинних вікон тижневого ліміту · %d вікон до скидання"; "Weekly cannot run out before reset at this pace" = "За такого темпу тижневий ліміт не може вичерпатися до скидання"; @@ -1285,6 +1297,20 @@ "Agent Plan" = "План агента"; "Team" = "Команда"; +"7-day average" = "Середнє за 7 днів"; +"Input" = "Вхідні"; +"Output" = "Вихідні"; +"Cache write" = "Запис кешу"; +"Reasoning" = "Міркування"; +"%@ in" = "%@ вхідні"; +"%@ out" = "%@ вихідні"; +"%@ cache read" = "%@ читання кешу"; +"%@ cache write" = "%@ запис кешу"; +"%@ reasoning" = "%@ міркування"; +"estimated" = "оцінка"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Оціночні витрати обчислюються з локальних журналів за прейскурантними цінами й можуть відрізнятися від рахунків постачальників."; +"Usage details for %@" = "Деталі використання за %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Компонування"; "menu_bar_layout_footer" = "Перетягуйте елементи, щоб упорядкувати смугу меню. Натисніть елемент, щоб додати його; виберіть розміщений елемент і натисніть Delete, щоб видалити його."; @@ -1354,9 +1380,36 @@ "Cumulative" = "Наростаючим підсумком"; "Token activity" = "Активність токенів"; "View" = "Вигляд"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "За моделлю"; +"By tool" = "За інструментом"; +"7-day avg" = "Сер. за 7 дн."; "in the last year" = "за останній рік"; "No activity in the last 12 months" = "Немає активності за останні 12 місяців"; "Each column = 1 week" = "Кожен стовпець = 1 тиждень"; "Running total" = "Накопичений підсумок"; "Less" = "Менше"; "More" = "Більше"; +"Mo" = "пн"; +"We" = "ср"; +"Fr" = "пт"; +"Estimated" = "Оціночно"; +"No per-client model history" = "Немає історії моделей за клієнтами"; + +"Cumulative %@ tokens as of %@" = "Усього %@ токенів на %@"; +"%@ tokens in the week of %@" = "%@ токенів за тиждень від %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 2fd5319050..e2faa5b365 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1274,6 +1274,18 @@ "Subscriptions" = "Gói đăng ký"; "By subscription" = "Theo gói đăng ký"; "No model-level history" = "Không có lịch sử theo mô hình"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "Token mô hình được theo dõi"; +"Priced model spend" = "Chi tiêu mô hình đã định giá"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "Không có lịch sử mô hình đã định giá"; +"Other" = "Other"; +"Show all %d models" = "Hiển thị tất cả %d mô hình"; +"Show top 20" = "Hiển thị 20 hàng đầu"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "Chỉ số"; +"%d days of usage data across %d models" = "%d ngày của dữ liệu Mức sử dụng trên %d mô hình"; +"%@ in · %@ out" = "%@ đầu vào · %@ đầu ra"; "Daily estimated spend" = "Chi tiêu ước tính hằng ngày"; "≈%d full 5h windows of weekly left · %d windows until reset" = "Còn ≈%d cửa sổ 5 giờ đầy đủ của hạn mức tuần · %d cửa sổ đến khi đặt lại"; "Weekly cannot run out before reset at this pace" = "Với tốc độ này, hạn mức tuần không thể hết trước khi đặt lại"; @@ -1286,6 +1298,20 @@ "Agent Plan" = "Gói tác nhân"; "Team" = "Nhóm"; +"7-day average" = "Trung bình 7 ngày"; +"Input" = "Đầu vào"; +"Output" = "Đầu ra"; +"Cache write" = "Ghi bộ nhớ đệm"; +"Reasoning" = "Suy luận"; +"%@ in" = "%@ đầu vào"; +"%@ out" = "%@ đầu ra"; +"%@ cache read" = "%@ đọc bộ nhớ đệm"; +"%@ cache write" = "%@ ghi bộ nhớ đệm"; +"%@ reasoning" = "%@ suy luận"; +"estimated" = "ước tính"; +"Estimated costs are priced from local logs and may differ from provider bills." = "Chi phí ước tính được tính từ nhật ký cục bộ theo giá niêm yết và có thể khác với hóa đơn của nhà cung cấp."; +"Usage details for %@" = "Chi tiết sử dụng cho %@"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "Bố cục"; "menu_bar_layout_footer" = "Kéo các thẻ để sắp xếp thanh menu. Bấm vào thẻ để thêm; chọn thẻ đã đặt rồi nhấn Delete để xóa."; @@ -1355,9 +1381,36 @@ "Cumulative" = "Tích lũy"; "Token activity" = "Hoạt động token"; "View" = "Chế độ xem"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "Theo mô hình"; +"By tool" = "Theo công cụ"; +"7-day avg" = "TB 7 ngày"; "in the last year" = "trong năm qua"; "No activity in the last 12 months" = "Không có hoạt động trong 12 tháng qua"; "Each column = 1 week" = "Mỗi cột = 1 tuần"; "Running total" = "Tổng lũy kế"; "Less" = "Ít"; "More" = "Nhiều"; +"Mo" = "T2"; +"We" = "T4"; +"Fr" = "T6"; +"Estimated" = "Ước tính"; +"No per-client model history" = "Không có lịch sử mô hình theo ứng dụng"; + +"Cumulative %@ tokens as of %@" = "Tích lũy %@ token tính đến %@"; +"%@ tokens in the week of %@" = "%@ token trong tuần từ %@"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 246e5773af..23872859c3 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -758,7 +758,7 @@ "Quit" = "退出"; "Last %d day" = "近 %d 天"; "Last %d days" = "近 %d 天"; -"%@ tokens" = "%@ token 用量"; +"%@ tokens" = "%@ 令牌"; "Latest billing day" = "最近结算日"; "Latest billing day (%@)" = "最近结算日(%@)"; "%@ left" = "%@ 剩余"; @@ -1243,12 +1243,24 @@ "Spend unavailable" = "支出数据不可用"; "Model breakdown unavailable" = "模型明细不可用"; "Local estimated history" = "本地估算历史"; -"Coverage" = "覆盖范围"; +"Coverage" = "共同完整覆盖"; "Estimated spend" = "估算支出"; "Tracked tokens" = "已跟踪 token"; "Subscriptions" = "订阅"; "By subscription" = "按订阅"; "No model-level history" = "暂无模型级历史"; +"Tokens" = "令牌"; +"Tracked model tokens" = "已跟踪模型令牌"; +"Priced model spend" = "已定价模型支出"; +"Partial model history: incomplete source-days are excluded." = "模型历史不完整:已排除明细不完整的来源日期。"; +"No priced model history" = "暂无已定价模型历史"; +"Other" = "其他"; +"Show all %d models" = "显示全部 %d 个模型"; +"Show top 20" = "仅显示前 20 个"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "模型名称会在去除首尾空白并忽略大小写后精确合并;不同提供商之间不会自动去重。"; +"Metric" = "指标"; +"%d days of usage data across %d models" = "%d 天用量数据,涵盖 %d 个模型"; +"%@ in · %@ out" = "%@ 输入 · %@ 输出"; "Daily estimated spend" = "每日估算支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "每周额度约剩 %d 个完整 5 小时窗口 · 距重置还有 %d 个窗口"; "Weekly cannot run out before reset at this pace" = "按此速度,每周额度无法在重置前用完"; @@ -1261,6 +1273,20 @@ "Agent Plan" = "智能体套餐"; "Team" = "团队"; +"7-day average" = "7 天平均"; +"Input" = "输入"; +"Output" = "输出"; +"Cache write" = "缓存写入"; +"Reasoning" = "推理"; +"%@ in" = "%@ 输入"; +"%@ out" = "%@ 输出"; +"%@ cache read" = "%@ 缓存读取"; +"%@ cache write" = "%@ 缓存写入"; +"%@ reasoning" = "%@ 推理"; +"estimated" = "估算"; +"Estimated costs are priced from local logs and may differ from provider bills." = "估算费用基于本地日志按刊例价计算,可能与提供商账单不一致。"; +"Usage details for %@" = "%@ 的用量详情"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "布局"; "menu_bar_layout_footer" = "拖动项目以排列菜单栏。点按项目可追加;选择已放置的项目并按 Delete 键可将其移除。"; @@ -1330,9 +1356,44 @@ "Cumulative" = "累计"; "Token activity" = "Token 活动"; "View" = "视图"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "按模型"; +"By tool" = "按工具"; +"7-day avg" = "7 天平均"; "in the last year" = "近一年"; "No activity in the last 12 months" = "近 12 个月暂无活动"; "Each column = 1 week" = "每列 = 1 周"; "Running total" = "累计总量"; "Less" = "少"; "More" = "多"; +"Mo" = "一"; +"We" = "三"; +"Fr" = "五"; +"Estimated" = "估算"; +"No per-client model history" = "暂无按工具划分的模型历史"; +"Same-model comparison" = "同模型工具对比"; +"Observed history for the same model and time range; workload differences still apply." = "仅对比同一模型与时间范围内的历史表现;不同任务仍会影响结果。"; +"Show fewer models" = "收起模型"; +"%d models" = "%d 个模型"; +"%d%% context reuse" = "%d%% 上下文复用"; +"%d projects" = "%d 个项目"; +"%d sessions" = "%d 个会话"; +"%d days covered" = "覆盖 %d 天"; +"%d%% reuse" = "%d%% 复用"; +"Reuse unavailable" = "复用数据不可用"; +"%@ per 1M tokens" = "每百万 Token %@"; +"%d days" = "%d 天"; +"Unpriced usage" = "未定价用量"; +"Pricing unavailable" = "暂无定价"; +"Known estimated spend" = "已知估算支出"; +"%d of %d subscriptions have pricing" = "%d/%d 个订阅已有定价"; + +"Cumulative %@ tokens as of %@" = "截至 %2$@ 累计 %1$@ 个 Token"; +"%@ tokens in the week of %@" = "%2$@ 当周使用了 %1$@ 个 Token"; +"Desktop" = "桌面端"; +"CLI" = "命令行"; +"IDE" = "IDE"; +"Extension" = "扩展"; +"API" = "API"; +"Tool" = "工具"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index e4809afe99..5ada41b2a1 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1298,12 +1298,24 @@ "Spend unavailable" = "無法取得支出資料"; "Model breakdown unavailable" = "無法取得模型明細"; "Local estimated history" = "本機預估歷史"; -"Coverage" = "涵蓋範圍"; +"Coverage" = "共同完整涵蓋"; "Estimated spend" = "預估支出"; "Tracked tokens" = "已追蹤 token"; "Subscriptions" = "訂閱"; "By subscription" = "依訂閱"; "No model-level history" = "尚無模型層級歷史"; +"Tokens" = "Tokens"; +"Tracked model tokens" = "已追蹤的模型 token"; +"Priced model spend" = "已計價模型支出"; +"Partial model history: incomplete source-days are excluded." = "Partial model history: incomplete source-days are excluded."; +"No priced model history" = "沒有已計價模型歷史記錄"; +"Other" = "Other"; +"Show all %d models" = "顯示全部 %d 個模型"; +"Show top 20" = "僅顯示前 20 個"; +"Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers." = "Model names are grouped after trimming and case-insensitive exact matching. Sources are not deduplicated across providers."; +"Metric" = "指標"; +"%d days of usage data across %d models" = "%d 天使用量資料,涵蓋 %d 個模型"; +"%@ in · %@ out" = "%@ 輸入 · %@ 輸出"; "Daily estimated spend" = "每日預估支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "每週額度約剩 %d 個完整 5 小時視窗 · 距重置還有 %d 個視窗"; "Weekly cannot run out before reset at this pace" = "依此速度,每週額度無法在重置前用完"; @@ -1316,6 +1328,20 @@ "Agent Plan" = "智慧體方案"; "Team" = "團隊"; +"7-day average" = "7 天平均"; +"Input" = "輸入"; +"Output" = "輸出"; +"Cache write" = "快取寫入"; +"Reasoning" = "推理"; +"%@ in" = "%@ 輸入"; +"%@ out" = "%@ 輸出"; +"%@ cache read" = "%@ 快取讀取"; +"%@ cache write" = "%@ 快取寫入"; +"%@ reasoning" = "%@ 推理"; +"estimated" = "估算"; +"Estimated costs are priced from local logs and may differ from provider bills." = "估算費用依本地日誌以牌價計算,可能與供應商帳單不同。"; +"Usage details for %@" = "%@ 的用量詳情"; + /* Menu bar layout editor */ "menu_bar_layout_title" = "佈局"; "menu_bar_layout_footer" = "拖曳項目以排列選單列。點按項目可附加;選取已放置的項目並按 Delete 鍵可將其移除。"; @@ -1385,9 +1411,36 @@ "Cumulative" = "累計"; "Token activity" = "Token 活動"; "View" = "檢視"; + +/* Token usage dashboard (usage & spend) */ +"By model" = "按模型"; +"By tool" = "按工具"; +"7-day avg" = "7 天平均"; "in the last year" = "近一年"; "No activity in the last 12 months" = "近 12 個月暫無活動"; "Each column = 1 week" = "每列 = 1 週"; "Running total" = "累計總量"; "Less" = "少"; "More" = "多"; +"Mo" = "一"; +"We" = "三"; +"Fr" = "五"; +"Estimated" = "估算"; +"No per-client model history" = "暫無按工具劃分的模型歷史"; + +"Cumulative %@ tokens as of %@" = "截至 %2$@ 累計 %1$@ 個 Token"; +"%@ tokens in the week of %@" = "%2$@ 當週使用了 %1$@ 個 Token"; + +/* Tool comparison evidence (English fallback) */ +"Same-model comparison" = "Same-model comparison"; +"Observed history for the same model and time range; workload differences still apply." = "Observed history for the same model and time range; workload differences still apply."; +"Show fewer models" = "Show fewer models"; +"Reuse unavailable" = "Reuse unavailable"; +"%d models" = "%d models"; +"%d projects" = "%d projects"; +"%d sessions" = "%d sessions"; +"%d days" = "%d days"; +"%d days covered" = "%d days covered"; +"%d%% context reuse" = "%d%% context reuse"; +"%d%% reuse" = "%d%% reuse"; +"%@ per 1M tokens" = "%@ per 1M tokens"; diff --git a/Sources/CodexBar/ShareStatsPayload.swift b/Sources/CodexBar/ShareStatsPayload.swift index 744f41ee17..47650f20a9 100644 --- a/Sources/CodexBar/ShareStatsPayload.swift +++ b/Sources/CodexBar/ShareStatsPayload.swift @@ -115,8 +115,10 @@ struct ShareStatsSubscriptionName: Sendable, Equatable { private static let labelsByProvider: [String: [String: String]] = [ UsageProvider.codex.rawValue: [ - "guest": "Guest", "free": "Free", "go": "Go", "plus": "Plus", "plus plan": "Plus", - "chatgpt plus": "Plus", "chatgpt-plus": "Plus", "chatgpt_plus": "Plus", + "guest": "Guest", "free": "Free", "go": "Go", + "plus": "ChatGPT Plus", "plus plan": "ChatGPT Plus", + "chatgpt plus": "ChatGPT Plus", "chatgpt-plus": "ChatGPT Plus", + "chatgpt_plus": "ChatGPT Plus", "pro": "Pro 20x", "codex pro": "Pro 20x", "prolite": "Pro 5x", "pro_lite": "Pro 5x", "pro-lite": "Pro 5x", "pro lite": "Pro 5x", "codex pro lite": "Pro 5x", @@ -155,6 +157,7 @@ struct ShareStatsSubscriptionName: Sendable, Equatable { ], UsageProvider.antigravity.rawValue: [ "free": "Free", "paid": "Paid", "pro": "Pro", + "google ai pro": "Google AI Pro", "google one ai pro": "Google AI Pro", "ultra": "Google AI Ultra", "google ai ultra": "Google AI Ultra", ], UsageProvider.copilot.rawValue: [ @@ -209,6 +212,10 @@ struct ShareStatsSubscriptionName: Sendable, Equatable { ] /// Converts plan-bearing provider identity into a closed, non-identifying share-card value. + static func hasPlanIdentityContract(for provider: UsageProvider) -> Bool { + self.labelsByProvider[provider.rawValue] != nil + } + static func from(snapshot: UsageSnapshot?, provider: UsageProvider) -> Self? { guard let identity = snapshot?.identity(for: provider), let rawName = identity.loginMethod, diff --git a/Sources/CodexBar/SpendActivityHeatmap.swift b/Sources/CodexBar/SpendActivityHeatmap.swift index 3febded346..497bbf19e7 100644 --- a/Sources/CodexBar/SpendActivityHeatmap.swift +++ b/Sources/CodexBar/SpendActivityHeatmap.swift @@ -299,6 +299,30 @@ struct SpendActivityHeatmapView: View { self._series = State(initialValue: SpendActivitySeries.make(from: points, now: now)) } + /// Per-currency-group entry point: folds the model analysis' per-model daily values into + /// per-day totals, preserving "unknown" days so the heatmap never fabricates inactivity. + init(analysis: SpendDashboardModel.ModelAnalysis, now: Date = Date()) { + var totalsByDay: [Date: Int] = [:] + var unknownDays: Set = [] + for value in analysis.dailyValues { + let day = Calendar.current.startOfDay(for: value.day) + guard let tokens = value.totalTokens else { + totalsByDay.removeValue(forKey: day) + unknownDays.insert(day) + continue + } + guard !unknownDays.contains(day) else { continue } + let sum = (totalsByDay[day] ?? 0).addingReportingOverflow(tokens) + totalsByDay[day] = sum.overflow ? Int.max : sum.partialValue + } + let points = Set(totalsByDay.keys).union(unknownDays).sorted().map { day in + SpendDashboardModel.TokenActivityPoint( + day: day, + totalTokens: unknownDays.contains(day) ? nil : totalsByDay[day]) + } + self.init(points: points, now: now) + } + var body: some View { let hasActivity = (self.series.daily.max() ?? 0) > 0 let hasUnknownCoverage = self.series.hasUnknownCoverage diff --git a/Sources/CodexBar/SpendBillingAttribution.swift b/Sources/CodexBar/SpendBillingAttribution.swift new file mode 100644 index 0000000000..67d4313ca1 --- /dev/null +++ b/Sources/CodexBar/SpendBillingAttribution.swift @@ -0,0 +1,400 @@ +import CodexBarCore +import Foundation + +/// Billing-source attribution for the "By subscription" view. +/// +/// The dashboard's default grouping attributes spend to the *tool* that consumed it. A tool can +/// also act as a harness for another vendor's API, so the subscription view uses routing evidence +/// retained by the source record (`billingProviderID`) to show who actually billed the request. +/// +/// `SpendBillingAttribution` re-attributes each source's daily usage to the vendor that bills it: +/// +/// Model names are presentation metadata, not billing proof: Cursor can bundle a model with the +/// same family name that a user can also add through an external endpoint. Records without routing +/// evidence therefore stay with their source tool instead of being silently guessed. This rule is +/// generic across tools and model vendors; MiniMax and Claude Code are regression fixtures, not +/// special product boundaries. +enum SpendBillingAttribution { + /// Splits every input into one attributed input per billing vendor. Vendors are keyed by + /// `UsageProvider`, so downstream grouping/summing is unchanged — only the provider each entry + /// is attributed to differs. Sources that keep all their usage (Cursor, Antigravity, …) come + /// back as a single, unchanged input. + static func attribute( + _ inputs: [SpendDashboardModel.ProviderInput]) -> [SpendDashboardModel.ProviderInput] + { + // Every scanner gets the same attribution behavior. Native/bundled usage simply carries + // no different `billingProviderID` and is preserved byte-for-byte; any harness can opt + // into external billing by retaining the route evidence on its model breakdown. + let fragments = inputs.flatMap(self.splitByVendor) + + // This is a provider/subscription ranking, not a tool ranking. Collapse every fragment + // billed by the same vendor into one row and always use the vendor brand as its title. + // Scanner labels such as "Kimi Code CLI" and "MiniMax Code" belong only in "By tool". + let fingerprintsByProvider = Dictionary(grouping: fragments, by: \.input.provider) + .mapValues { fragments in + Set(fragments.compactMap { + guard let value = $0.input.snapshot.credentialScopeFingerprint? + .trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { return nil } + return value + }) + } + let grouped = Dictionary(grouping: fragments) { fragment in + Self.groupKey( + fragment, + uniqueCredentialFingerprint: fingerprintsByProvider[fragment.input.provider] + .flatMap { $0.count == 1 ? $0.first : nil }) + } + return grouped.keys.sorted { lhs, rhs in + if lhs.provider != rhs.provider { return lhs.provider.rawValue < rhs.provider.rawValue } + return lhs.identity < rhs.identity + }.map { key in + let vendor = key.provider + let vendorFragments = grouped[key, default: []] + let inputs = vendorFragments.map(\.input) + let displayName = Self.vendorDisplayName(for: vendor) + if inputs.count == 1, let input = inputs.first { + return SpendDashboardModel.ProviderInput( + id: vendorFragments[0].routed + ? "billing:\(vendor.rawValue):\(input.id)" + : input.id, + provider: vendor, + displayName: displayName, + modelProviderName: displayName, + subscriptionName: input.subscriptionName, + snapshot: input.snapshot) + } + let native = vendorFragments.first { !$0.routed }?.input + return Self.mergedInput( + inputs, + id: native?.id ?? "billing:\(vendor.rawValue):\(key.identity)", + provider: vendor, + displayName: displayName, + modelProviderName: displayName) + } + } + + // MARK: - Splitting + + private struct Fragment { + let input: SpendDashboardModel.ProviderInput + let routed: Bool + } + + private struct BillingGroupKey: Hashable { + let provider: UsageProvider + let identity: String + } + + private static func groupKey( + _ fragment: Fragment, + uniqueCredentialFingerprint: String?) -> BillingGroupKey + { + let input = fragment.input + if let fingerprint = input.snapshot.credentialScopeFingerprint? + .trimmingCharacters(in: .whitespacesAndNewlines), + !fingerprint.isEmpty + { + return BillingGroupKey(provider: input.provider, identity: "credential:\(fingerprint)") + } + if !fragment.routed, + input.id.contains(":local"), + let uniqueCredentialFingerprint + { + return BillingGroupKey( + provider: input.provider, + identity: "credential:\(uniqueCredentialFingerprint)") + } + // Codex can publish several account-scoped histories simultaneously. Keep those rows + // separate even when an old cache predates credential fingerprints. + if input.provider == .codex, + input.id.hasPrefix("codex:"), + !input.id.contains(":local") + { + return BillingGroupKey(provider: input.provider, identity: "source:\(input.id)") + } + // Other provider rows represent the currently selected account. Their native local + // supplement and explicitly routed fragments therefore belong to the same provider row + // unless a credential fingerprint above proves otherwise. + return BillingGroupKey(provider: input.provider, identity: "provider-default") + } + + private static func splitByVendor(_ input: SpendDashboardModel.ProviderInput) -> [Fragment] { + // Bucket each day's model breakdowns by billing vendor, then rebuild one attributed input + // per vendor with only that vendor's usage. Days where the source has no breakdowns fall + // back to the tool's own provider so the totals still add up. + let explicitVendors = Set(input.snapshot.daily.flatMap { entry in + (entry.modelBreakdowns ?? []).map { + self.billingVendor(for: $0, defaultProvider: input.provider) + } + }) + if explicitVendors.isEmpty || explicitVendors == [input.provider] { + // No third-party model is present. Preserve the scanner's original aggregate proofs, + // coverage bounds and malformed-row semantics instead of rebuilding an equivalent- + // looking snapshot from daily rows. + return [Fragment(input: input, routed: false)] + } + + var dailyByVendor: [UsageProvider: [CostUsageDailyReport.Entry]] = [:] + for entry in input.snapshot.daily { + let breakdowns = entry.modelBreakdowns ?? [] + if breakdowns.isEmpty { + dailyByVendor[input.provider, default: []].append(entry) + continue + } + guard self.breakdownsReconcile(with: entry, breakdowns: breakdowns) else { + // A partial breakdown cannot be split without dropping the residual usage. + // Preserve the original aggregate under its source until stronger evidence exists. + dailyByVendor[input.provider, default: []].append(entry) + continue + } + for breakdown in breakdowns { + let vendor = self.billingVendor(for: breakdown, defaultProvider: input.provider) + let dayEntry = Self.entry(from: entry, keeping: breakdown) + dailyByVendor[vendor, default: []].append(dayEntry) + } + } + + // Merge same-day entries per vendor (a vendor may appear in several breakdowns of one day). + var attributed: [Fragment] = [] + for vendor in dailyByVendor.keys.sorted(by: { $0.rawValue < $1.rawValue }) { + let entries = dailyByVendor[vendor, default: []] + let merged = Self.mergeByDay(entries) + guard !merged.isEmpty else { continue } + let snapshot = Self.snapshot( + from: input.snapshot, + daily: merged, + historyLabel: Self.vendorDisplayName(for: vendor)) + attributed.append(Fragment( + input: SpendDashboardModel.ProviderInput( + id: input.id, + provider: vendor, + displayName: Self.vendorDisplayName(for: vendor), + modelProviderName: Self.vendorDisplayName(for: vendor), + subscriptionName: vendor == input.provider ? input.subscriptionName : nil, + snapshot: snapshot), + routed: vendor != input.provider)) + } + return attributed.isEmpty ? [Fragment(input: input, routed: false)] : attributed + } + + private static func breakdownsReconcile( + with entry: CostUsageDailyReport.Entry, + breakdowns: [CostUsageDailyReport.ModelBreakdown]) -> Bool + { + if let totalTokens = entry.totalTokens, + sum(breakdowns.map(\.totalTokens)) != totalTokens + { + return false + } + if let cost = entry.costUSD { + guard cost.isFinite, + let breakdownCost = Self.sumCost(breakdowns.map(\.costUSD)), + abs(breakdownCost - cost) <= max(0.000_001, abs(cost) * 0.000_001) + else { + return false + } + } + return true + } + + private static func mergedInput( + _ inputs: [SpendDashboardModel.ProviderInput], + id: String, + provider: UsageProvider, + displayName: String, + modelProviderName: String) -> SpendDashboardModel.ProviderInput + { + guard let first = inputs.first else { + preconditionFailure("Cannot merge an empty billing attribution input") + } + // A provider can publish both a live quota snapshot and a local session-history snapshot. + // Live quota snapshots intentionally carry `historyCoverageIsEstablished == false`; mixing + // one into complete local history used to downgrade the whole subscription to + // "Spend unavailable" (notably MiniMax). Once at least one source establishes historical + // coverage, only those historical sources participate in the spend/history merge. + let establishedHistoryInputs = inputs.filter(\.snapshot.historyCoverageIsEstablished) + let historyInputs = establishedHistoryInputs.isEmpty ? inputs : establishedHistoryInputs + if historyInputs.count == 1, let input = historyInputs.first { + return SpendDashboardModel.ProviderInput( + id: input.id, + provider: provider, + displayName: displayName, + modelProviderName: modelProviderName, + subscriptionName: inputs.compactMap(\.subscriptionName).first, + snapshot: input.snapshot) + } + + let daily = Self.mergeByDay(historyInputs.flatMap(\.snapshot.daily)) + // These inputs are independent histories billed by the same vendor (for example, Codex + // routing MiniMax plus MiniMax Code's native SQLite history). The merged snapshot contains + // the union of their daily entries, so its coverage bounds must describe that union too. + // Using the shortest history and oldest refresh time makes valid entries from the newer or + // longer source fall outside `sourceCoverageInterval`; the dashboard then downgrades both + // an inactive recent window and cumulative spend to "unavailable". + let historyDays = historyInputs.map(\.snapshot.historyDays).max() ?? first.snapshot.historyDays + let updatedAt = historyInputs.map(\.snapshot.updatedAt).max() ?? first.snapshot.updatedAt + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: Self.sum(daily.map(\.totalTokens)), + last30DaysCostUSD: Self.completeCostSum(daily.map(\.costUSD)), + last30DaysRequests: Self.sum(daily.map(\.requestCount)), + currencyCode: historyInputs.first?.snapshot.currencyCode ?? first.snapshot.currencyCode, + historyDays: historyDays, + historyCoverageIsEstablished: historyInputs.allSatisfy(\.snapshot.historyCoverageIsEstablished), + historyLabel: displayName, + meteredCostUSD: nil, + costSource: historyInputs.allSatisfy { $0.snapshot.costSource == .providerReported } + ? .providerReported + : .estimated, + credentialScopeFingerprint: nil, + daily: daily, + updatedAt: updatedAt) + return SpendDashboardModel.ProviderInput( + id: id, + provider: provider, + displayName: displayName, + modelProviderName: modelProviderName, + subscriptionName: inputs.compactMap(\.subscriptionName).first, + snapshot: snapshot) + } + + /// Rebuilds a daily entry restricted to a single model breakdown, recomputing the aggregate + /// token/cost figures from that breakdown so per-vendor day totals stay consistent. + private static func entry( + from entry: CostUsageDailyReport.Entry, + keeping breakdown: CostUsageDailyReport.ModelBreakdown) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: entry.date, + inputTokens: breakdown.inputTokens, + outputTokens: breakdown.outputTokens, + cacheReadTokens: breakdown.cacheReadTokens, + cacheCreationTokens: breakdown.cacheCreationTokens, + totalTokens: breakdown.totalTokens, + requestCount: breakdown.requestCount, + costUSD: breakdown.costUSD, + modelsUsed: [breakdown.modelName], + modelBreakdowns: [breakdown]) + } + + private static func mergeByDay(_ entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.Entry] { + let grouped = Dictionary(grouping: entries, by: \.date) + return grouped.keys.sorted().map { day in + let dayEntries = grouped[day] ?? [] + if dayEntries.count == 1, let only = dayEntries.first { return only } + return CostUsageDailyReport.Entry( + date: day, + inputTokens: Self.sum(dayEntries.map(\.inputTokens)), + outputTokens: Self.sum(dayEntries.map(\.outputTokens)), + cacheReadTokens: Self.sum(dayEntries.map(\.cacheReadTokens)), + cacheCreationTokens: Self.sum(dayEntries.map(\.cacheCreationTokens)), + totalTokens: Self.sum(dayEntries.map(\.totalTokens)), + requestCount: Self.sum(dayEntries.map(\.requestCount)), + costUSD: Self.completeCostSum(dayEntries.map(\.costUSD)), + modelsUsed: dayEntries.flatMap { $0.modelsUsed ?? [] }, + modelBreakdowns: dayEntries.flatMap { $0.modelBreakdowns ?? [] }) + } + } + + private static func snapshot( + from snapshot: CostUsageTokenSnapshot, + daily: [CostUsageDailyReport.Entry], + historyLabel: String) -> CostUsageTokenSnapshot + { + let totalTokens = Self.sum(daily.map(\.totalTokens)) + let totalCost = Self.completeCostSum(daily.map(\.costUSD)) + let totalRequests = Self.sum(daily.map(\.requestCount)) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: totalTokens, + last30DaysCostUSD: totalCost, + last30DaysRequests: totalRequests, + currencyCode: snapshot.currencyCode, + historyDays: snapshot.historyDays, + historyCoverageIsEstablished: snapshot.historyCoverageIsEstablished, + historyLabel: historyLabel, + meteredCostUSD: nil, + costSource: snapshot.costSource, + credentialScopeFingerprint: snapshot.credentialScopeFingerprint, + daily: daily, + updatedAt: snapshot.updatedAt) + } + + // MARK: - Vendor mapping + + /// Compatibility helper for callers that only have a model label. A label is + /// not routing evidence, so it deliberately keeps the source provider. + static func billingVendor(forModel model: String, defaultProvider: UsageProvider) -> UsageProvider { + _ = model + return defaultProvider + } + + private static func billingVendor( + for breakdown: CostUsageDailyReport.ModelBreakdown, + defaultProvider: UsageProvider) -> UsageProvider + { + guard let rawProviderID = breakdown.billingProviderID? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !rawProviderID.isEmpty + else { + return defaultProvider + } + if let exact = UsageProvider(rawValue: rawProviderID) { + return exact + } + let aliases: [String: UsageProvider] = [ + "anthropic": .claude, + "google": .gemini, + "google-ai": .gemini, + "moonshotai": .kimi, + "moonshot": .kimi, + "openai": .openai, + "qwen": .qwencloud, + "alibabacloud": .qwencloud, + "z.ai": .zai, + ] + return aliases[rawProviderID] ?? defaultProvider + } + + private static func vendorDisplayName(for provider: UsageProvider) -> String { + ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + } + + // MARK: - Numeric helpers + + private static func sum(_ values: [Int?]) -> Int? { + let present = values.compactMap(\.self) + guard !present.isEmpty else { return nil } + var total = 0 + for value in present { + let addition = total.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + total = addition.partialValue + } + return total + } + + private static func sumCost(_ values: [Double?]) -> Double? { + let present = values.compactMap(\.self).filter(\.isFinite) + guard !present.isEmpty else { return nil } + let total = present.reduce(0, +) + return total.isFinite ? total : nil + } + + /// Sums costs only when every contributing value is present and finite; a single missing + /// value withholds the merged total so a partial subtotal is not presented as complete. + private static func completeCostSum(_ values: [Double?]) -> Double? { + guard !values.isEmpty, + values.allSatisfy({ $0?.isFinite == true }) + else { + return nil + } + let total = values.compactMap(\.self).reduce(0, +) + return total.isFinite ? total : nil + } +} diff --git a/Sources/CodexBar/SpendClientsView.swift b/Sources/CodexBar/SpendClientsView.swift new file mode 100644 index 0000000000..38c2c0f7b1 --- /dev/null +++ b/Sources/CodexBar/SpendClientsView.swift @@ -0,0 +1,549 @@ +import CodexBarCore +import SwiftUI + +// MARK: - Helper + +private func cleanToolName(_ name: String) -> String { + var result = name + let suffixes = [" Desktop", " CLI", " IDE", " Extension", " API"] + for suffix in suffixes where result.hasSuffix(suffix) { + result = String(result.dropLast(suffix.count)) + } + return result +} + +// MARK: - 按工具分组数据 + +/// A model's usage attributed to one tool (client). +struct SpendClientModel: Identifiable, Equatable { + let id: String + let displayName: String + let modelProvider: UsageProvider + let tokens: Int? + let cost: Double? + let costIsEstimated: Bool + let requestCount: Int? +} + +/// One tool (client) with its models, sorted by tokens descending. +struct SpendClientGroup: Identifiable, Equatable { + let sourceID: String + let provider: UsageProvider + let kind: SpendToolIdentity.Kind + /// Tool name, e.g. "Claude Code", "Codex Desktop", "Kimi Code CLI". + let toolName: String + /// Product family name, e.g. "Claude", "Codex", "Kimi". + let providerName: String + let totalTokens: Int? + let totalCost: Double? + let costIsEstimated: Bool + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let reasoningTokens: Int? + let requestCount: Int? + let coveredDayCount: Int + let projectCount: Int? + let sessionCount: Int? + let models: [SpendClientModel] + + var id: String { + self.sourceID + } + + var displayTitle: String { + self.toolName + } +} + +enum SpendClientBreakdown { + /// Groups model rows by contributing tool (one card per tool/account, e.g. each Codex + /// account, Claude Code, Kimi Code CLI). A model used by several tools appears under each, + /// with that tool's token/cost share (from `contributions`); the five-bucket breakdown is the + /// model's own, shown for context under each tool it ran in. + static func groups(from analysis: SpendDashboardModel.ModelAnalysis) -> [SpendClientGroup] { + var bySource: + [String: (provider: UsageProvider, identity: SpendToolIdentity, family: String, models: [String: Accum])] = + [:] + + for row in analysis.rows { + for contribution in row.contributions { + let tokens = contribution.totalTokens + guard (tokens ?? 0) > 0 || contribution.estimatedCost != nil else { continue } + var bucket = bySource[contribution.sourceID] + ?? ( + contribution.provider, + SpendToolIdentity.resolve( + provider: contribution.provider, + sourceName: contribution.sourceName, + providerName: contribution.providerName), + contribution.providerName, + [:]) + var accum = bucket.models[row.id] ?? Accum( + displayName: row.displayName, + modelProvider: row.modelProvider, + costIsEstimated: contribution.costIsEstimated) + accum.tokens = Self.add(accum.tokens, tokens) + if let cost = contribution.estimatedCost { + let nextCost = (accum.cost ?? 0) + cost + accum.cost = nextCost.isFinite ? nextCost : nil + } + accum.inputTokens = contribution.inputTokens + accum.outputTokens = contribution.outputTokens + accum.cacheReadTokens = contribution.cacheReadTokens + accum.cacheCreationTokens = contribution.cacheCreationTokens + accum.reasoningTokens = contribution.reasoningTokens + accum.requestCount = contribution.requestCount + accum.coveredDayCount = contribution.coveredDayCount + accum.projectCount = contribution.projectCount + accum.sessionCount = contribution.sessionCount + bucket.models[row.id] = accum + bySource[contribution.sourceID] = bucket + } + } + + return bySource.map { sourceID, bucket in + let models = bucket.models.map { id, accum in + SpendClientModel( + id: id, + displayName: accum.displayName, + modelProvider: accum.modelProvider, + tokens: accum.tokens, + cost: accum.cost, + costIsEstimated: accum.costIsEstimated, + requestCount: accum.requestCount) + } + .sorted { ($0.tokens ?? -1) > ($1.tokens ?? -1) } + let totalTokens = Self.completeSum(models.map(\.tokens)) + let totalCost = Self.completeCostSum(models.map(\.cost)) + return SpendClientGroup( + sourceID: sourceID, + provider: bucket.provider, + kind: bucket.identity.kind, + toolName: bucket.identity.displayName, + providerName: bucket.family, + totalTokens: totalTokens, + totalCost: totalCost, + costIsEstimated: models.contains { $0.costIsEstimated }, + inputTokens: Self.completeSum(bucket.models.values.map(\.inputTokens)), + outputTokens: Self.completeSum(bucket.models.values.map(\.outputTokens)), + cacheReadTokens: Self.completeSum(bucket.models.values.map(\.cacheReadTokens)), + cacheCreationTokens: Self.completeSum(bucket.models.values.map(\.cacheCreationTokens)), + reasoningTokens: Self.completeSum(bucket.models.values.map(\.reasoningTokens)), + requestCount: Self.completeSum(bucket.models.values.map(\.requestCount)), + coveredDayCount: bucket.models.values.map(\.coveredDayCount).max() ?? 0, + projectCount: bucket.models.values.compactMap(\.projectCount).max(), + sessionCount: bucket.models.values.compactMap(\.sessionCount).max(), + models: models) + } + .sorted { ($0.totalTokens ?? -1) > ($1.totalTokens ?? -1) } + } + + private struct Accum { + let displayName: String + let modelProvider: UsageProvider + var tokens: Int? = 0 + var cost: Double? + var costIsEstimated: Bool + var inputTokens: Int? + var outputTokens: Int? + var cacheReadTokens: Int? + var cacheCreationTokens: Int? + var reasoningTokens: Int? + var requestCount: Int? + var coveredDayCount = 0 + var projectCount: Int? + var sessionCount: Int? + } + + private static func completeSum(_ values: [Int?]) -> Int? { + guard values.allSatisfy({ $0 != nil }) else { return nil } + var total = 0 + for value in values.compactMap(\.self) { + let result = total.addingReportingOverflow(value) + guard !result.overflow else { return nil } + total = result.partialValue + } + return total + } + + private static func completeCostSum(_ values: [Double?]) -> Double? { + guard values.allSatisfy({ $0?.isFinite == true }) else { return nil } + var total = 0.0 + for value in values.compactMap(\.self) { + total += value + guard total.isFinite else { return nil } + } + return total + } + + private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { + guard let lhs, let rhs else { return nil } + let result = lhs.addingReportingOverflow(rhs) + return result.overflow ? nil : result.partialValue + } +} + +struct SpendToolModelComparison: Identifiable, Equatable { + struct Tool: Identifiable, Equatable { + let sourceID: String + let provider: UsageProvider + let displayName: String + let kind: SpendToolIdentity.Kind + let contextReuseRate: Double? + let requestCount: Int? + let costPerMillionTokens: Double? + let totalTokens: Int? + let coveredDayCount: Int + + var id: String { + self.sourceID + } + } + + let id: String + let displayName: String + let modelProvider: UsageProvider + let tools: [Tool] + let totalTokens: Int +} + +enum SpendToolComparisonPresentation { + static func comparisons(from analysis: SpendDashboardModel.ModelAnalysis) -> [SpendToolModelComparison] { + analysis.rows.compactMap { row in + let tools = row.contributions.map { contribution in + let identity = SpendToolIdentity.resolve( + provider: contribution.provider, + sourceName: contribution.sourceName, + providerName: contribution.providerName) + return SpendToolModelComparison.Tool( + sourceID: contribution.sourceID, + provider: contribution.provider, + displayName: identity.displayName, + kind: identity.kind, + contextReuseRate: self.contextReuseRate( + input: contribution.inputTokens, + cacheRead: contribution.cacheReadTokens, + cacheCreation: contribution.cacheCreationTokens), + requestCount: contribution.requestCount, + costPerMillionTokens: self.costPerMillionTokens( + cost: contribution.estimatedCost, + tokens: contribution.totalTokens), + totalTokens: contribution.totalTokens, + coveredDayCount: contribution.coveredDayCount) + } + .sorted(by: self.toolOrder) + guard Set(tools.map(\.sourceID)).count > 1 else { return nil } + return SpendToolModelComparison( + id: row.id, + displayName: row.displayName, + modelProvider: row.modelProvider, + tools: tools, + totalTokens: tools.compactMap(\.totalTokens).reduce(0) { total, value in + let result = total.addingReportingOverflow(value) + return result.overflow ? Int.max : result.partialValue + }) + } + .sorted { + if $0.totalTokens != $1.totalTokens { return $0.totalTokens > $1.totalTokens } + return $0.displayName.localizedStandardCompare($1.displayName) == .orderedAscending + } + } + + static func contextReuseRate(input: Int?, cacheRead: Int?, cacheCreation: Int?) -> Double? { + guard let input, let cacheRead, let cacheCreation, + input >= 0, cacheRead >= 0, cacheCreation >= 0 + else { + return nil + } + let denominator = Double(input) + Double(cacheRead) + Double(cacheCreation) + guard denominator.isFinite, denominator > 0 else { return nil } + return Double(cacheRead) / denominator + } + + static func costPerMillionTokens(cost: Double?, tokens: Int?) -> Double? { + guard let cost, cost.isFinite, cost >= 0, let tokens, tokens > 0 else { return nil } + return cost * 1_000_000 / Double(tokens) + } + + private static func toolOrder( + _ lhs: SpendToolModelComparison.Tool, + _ rhs: SpendToolModelComparison.Tool) -> Bool + { + switch (lhs.contextReuseRate, rhs.contextReuseRate) { + case let (left?, right?) where left != right: return left > right + case (_?, nil): return true + case (nil, _?): return false + default: + let left = lhs.totalTokens ?? -1 + let right = rhs.totalTokens ?? -1 + if left != right { return left > right } + return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending + } + } +} + +// MARK: - 按工具分组视图 + +struct SpendClientsView: View { + let analysis: SpendDashboardModel.ModelAnalysis + let currencyCode: String + @State private var expandedGroupIDs: Set = [] + @State private var selectedComparisonID: String? + + private static let collapsedModelLimit = 5 + + var body: some View { + let groups = SpendClientBreakdown.groups(from: self.analysis) + let comparisons = SpendToolComparisonPresentation.comparisons(from: self.analysis) + if groups.isEmpty { + Text(L("No per-client model history")) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + } else { + VStack(alignment: .leading, spacing: 12) { + if !comparisons.isEmpty { + self.comparisonCard(comparisons) + } + ForEach(groups) { group in + self.card(group) + } + } + } + } + + private func comparisonCard(_ comparisons: [SpendToolModelComparison]) -> some View { + let selected = comparisons.first { $0.id == self.selectedComparisonID } ?? comparisons[0] + return VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text(L("Same-model comparison")) + .font(SpendModelsListStyle.primaryEmphasizedFont) + Spacer() + Menu { + ForEach(comparisons) { comparison in + Button { + self.selectedComparisonID = comparison.id + } label: { + Label( + comparison.displayName, + systemImage: comparison.id == selected.id ? "checkmark" : "") + } + } + } label: { + HStack(spacing: 5) { + SpendProviderIcon( + provider: selected.modelProvider, + size: SpendModelsListStyle.modelIconSize) + Text(selected.displayName) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.caption2.weight(.semibold)) + } + .font(SpendModelsListStyle.primaryFont) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + Text(L("Observed history for the same model and time range; workload differences still apply.")) + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(selected.tools.enumerated()), id: \.element.id) { index, tool in + if index > 0 { Divider().padding(.leading, SpendModelsListStyle.modelIndent) } + self.comparisonRow(tool) + } + } + } + .padding(14) + .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + private func comparisonRow(_ tool: SpendToolModelComparison.Tool) -> some View { + HStack(spacing: 8) { + SpendProviderIcon(provider: tool.provider, size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + Text(cleanToolName(tool.displayName)) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + Text(tool.kind.displayName) + .font(SpendModelsListStyle.tertiaryFont.weight(.medium)) + .foregroundStyle(.secondary) + Spacer(minLength: 12) + Text(self.comparisonMetrics(tool)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(1) + } + .padding(.vertical, 5) + } + + private func card(_ group: SpendClientGroup) -> some View { + let isExpanded = self.expandedGroupIDs.contains(group.id) + let visibleModels = isExpanded + ? group.models + : Array(group.models.prefix(Self.collapsedModelLimit)) + return VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 7) { + SpendProviderIcon( + provider: group.provider, + size: SpendModelsListStyle.iconSize) + .frame( + width: SpendModelsListStyle.iconFrameSize, + height: SpendModelsListStyle.iconFrameSize) + Text(cleanToolName(group.displayTitle)) + .font(SpendModelsListStyle.primaryEmphasizedFont) + Text(group.kind.displayName) + .font(SpendModelsListStyle.tertiaryFont.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.12), in: Capsule()) + if group.costIsEstimated { + Text(L("Estimated")) + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.15), in: Capsule()) + } + Spacer() + Text(self.totalText(group)) + .font(SpendModelsListStyle.primaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.bottom, 6) + + Text(self.toolEvidenceSummary(group)) + .font(SpendModelsListStyle.secondaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .lineLimit(2) + .padding(.bottom, 8) + .padding(.leading, SpendModelsListStyle.iconFrameSize + 7) + + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(visibleModels.enumerated()), id: \.element.id) { index, model in + if index > 0 { Divider().padding(.vertical, 2) } + self.modelRow(model) + } + if group.models.count > Self.collapsedModelLimit { + Button { + if isExpanded { + self.expandedGroupIDs.remove(group.id) + } else { + self.expandedGroupIDs.insert(group.id) + } + } label: { + HStack(spacing: 5) { + Text(isExpanded + ? L("Show fewer models") + : String(format: L("Show all %d models"), group.models.count)) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption2.weight(.semibold)) + } + .font(SpendModelsListStyle.secondaryFont.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.top, 7) + } + .buttonStyle(.plain) + } + } + .padding(.leading, SpendModelsListStyle.modelIndent) + } + .padding(14) + .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + } + + private func modelRow(_ model: SpendClientModel) -> some View { + HStack(alignment: .center, spacing: 8) { + SpendProviderIcon(provider: model.modelProvider, size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + Text(model.displayName) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + Spacer() + Text(self.modelMetric(model)) + .font(SpendModelsListStyle.valueFont) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.vertical, 5) + } + + private func totalText(_ group: SpendClientGroup) -> String { + var parts: [String] = [] + if let tokens = group.totalTokens { + parts.append(UsageFormatter.tokenCountString(tokens)) + } + if let cost = group.totalCost { + parts.append(UsageFormatter.currencyString(cost, currencyCode: self.currencyCode)) + } + return parts.joined(separator: " · ") + } + + private func modelMetric(_ model: SpendClientModel) -> String { + var parts: [String] = [] + if let tokens = model.tokens { + parts.append(UsageFormatter.tokenCountString(tokens)) + } + if let cost = model.cost { + parts.append(UsageFormatter.currencyString(cost, currencyCode: self.currencyCode)) + } + return parts.joined(separator: " · ") + } + + private func toolEvidenceSummary(_ group: SpendClientGroup) -> String { + var parts = [String(format: L("%d models"), group.models.count)] + if let requests = group.requestCount { + parts.append(String(format: L("%@ requests"), UsageFormatter.tokenCountString(requests))) + } + if let rate = SpendToolComparisonPresentation.contextReuseRate( + input: group.inputTokens, + cacheRead: group.cacheReadTokens, + cacheCreation: group.cacheCreationTokens) + { + parts.append(String(format: L("%d%% context reuse"), Int((rate * 100).rounded()))) + } + if let projects = group.projectCount { + parts.append(String(format: L("%d projects"), projects)) + } + if let sessions = group.sessionCount { + parts.append(String(format: L("%d sessions"), sessions)) + } + if group.coveredDayCount > 0 { + parts.append(String(format: L("%d days covered"), group.coveredDayCount)) + } + return parts.joined(separator: " · ") + } + + private func comparisonMetrics(_ tool: SpendToolModelComparison.Tool) -> String { + var parts: [String] = [] + if let rate = tool.contextReuseRate { + parts.append(String(format: L("%d%% reuse"), Int((rate * 100).rounded()))) + } else { + parts.append("\(L("Cache read")) —") + } + if let requests = tool.requestCount { + parts.append(String(format: L("%@ requests"), UsageFormatter.tokenCountString(requests))) + } + if let cost = tool.costPerMillionTokens { + parts.append(String( + format: L("%@ per 1M tokens"), + UsageFormatter.currencyString(cost, currencyCode: self.currencyCode))) + } + if tool.coveredDayCount > 0 { + parts.append(String(format: L("%d days"), tool.coveredDayCount)) + } + return parts.joined(separator: " · ") + } +} diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index dd1fc52f3f..78ede05526 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -41,6 +41,12 @@ struct CodexSpendScanRequest: Equatable, Sendable { let cacheIdentity: String } +struct LocalSpendHistoryRequest: Equatable, Sendable { + let source: ProviderLocalHistorySource + let provider: UsageProvider + let homePath: String +} + enum SpendDashboardRequestBuildMode: Equatable, Sendable { case refreshMissing case forceRefresh @@ -65,15 +71,46 @@ struct SpendDashboardLoadRequest: Sendable { let unavailableSourceIDs: Set let confirmedEmptySourceIDs: Set let codexRequests: [CodexSpendScanRequest] + let localHistoryRequests: [LocalSpendHistoryRequest] let now: Date let force: Bool + var kimiCodeHomePath: String? { + self.localHistoryRequests.first { $0.source == .kimiCode }?.homePath + } + + var geminiCLIHomePath: String? { + self.localHistoryRequests.first { $0.source == .geminiCLI }?.homePath + } + + var openCodeDataHomePath: String? { + self.localHistoryRequests.first { $0.source == .openCode }?.homePath + } + + var miniMaxHomePath: String? { + self.localHistoryRequests.first { $0.source == .miniMax }?.homePath + } + + var antigravityHomePath: String? { + self.localHistoryRequests.first { $0.source == .antigravity }?.homePath + } + + var qwenCodeHomePath: String? { + self.localHistoryRequests.first { $0.source == .qwenCode }?.homePath + } + init( configuration: SpendDashboardConfiguration, capturedInputs: [SpendDashboardModel.ProviderInput], unavailableSourceIDs: Set, confirmedEmptySourceIDs: Set = [], codexRequests: [CodexSpendScanRequest], + localHistoryRequests: [LocalSpendHistoryRequest]? = nil, + kimiCodeHomePath: String? = nil, + geminiCLIHomePath: String? = nil, + openCodeDataHomePath: String? = nil, + miniMaxHomePath: String? = nil, + antigravityHomePath: String? = nil, now: Date, force: Bool) { @@ -82,6 +119,23 @@ struct SpendDashboardLoadRequest: Sendable { self.unavailableSourceIDs = unavailableSourceIDs self.confirmedEmptySourceIDs = confirmedEmptySourceIDs self.codexRequests = codexRequests + self.localHistoryRequests = localHistoryRequests ?? [ + kimiCodeHomePath.map { + LocalSpendHistoryRequest(source: .kimiCode, provider: .kimi, homePath: $0) + }, + geminiCLIHomePath.map { + LocalSpendHistoryRequest(source: .geminiCLI, provider: .gemini, homePath: $0) + }, + openCodeDataHomePath.map { + LocalSpendHistoryRequest(source: .openCode, provider: .opencode, homePath: $0) + }, + miniMaxHomePath.map { + LocalSpendHistoryRequest(source: .miniMax, provider: .minimax, homePath: $0) + }, + antigravityHomePath.map { + LocalSpendHistoryRequest(source: .antigravity, provider: .antigravity, homePath: $0) + }, + ].compactMap(\.self) self.now = now self.force = force } @@ -119,11 +173,60 @@ struct CodexSpendSnapshotLoadContext: Sendable { var progress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? } +struct KimiCodeSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct GeminiSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct OpenCodeSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct MiniMaxSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct AntigravitySpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct QwenCodeSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot - static let scanDays = 30 + typealias KimiCodeSnapshotLoader = @Sendable (KimiCodeSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? + typealias GeminiSnapshotLoader = @Sendable (GeminiSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? + typealias OpenCodeSnapshotLoader = @Sendable (OpenCodeSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? + typealias MiniMaxSnapshotLoader = @Sendable (MiniMaxSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? + typealias AntigravitySnapshotLoader = @Sendable (AntigravitySpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? + typealias QwenCodeSnapshotLoader = @Sendable (QwenCodeSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? + + static let scanDays = 365 static let activityScanDays = SpendDashboardModel.tokenActivityDayCount private static let activitySnapshotCache = SpendDashboardCodexActivitySnapshotCache() @@ -150,7 +253,8 @@ enum SpendDashboardSource { SpendDashboardConfiguration( costUsageEnabled: settings.costUsageEnabled, preferredCurrencyCode: settings.preferredCurrencyCode, - providerIDs: providers.map(\.rawValue), + providerIDs: Array(Set( + (providers + self.localModelHistoryProviders(store: store)).map(\.rawValue))).sorted(), codexAccountIdentities: codexRequests.map { "\($0.id)|\($0.cacheIdentity)" }, codexAccountDisplayNames: self.codexDisplayNamesByID(codexRequests), sourceOwnershipFingerprints: self.sourceOwnershipFingerprints( @@ -241,6 +345,9 @@ enum SpendDashboardSource { inputs.append(SpendDashboardModel.ProviderInput( provider: provider, displayName: store.metadata(for: provider).displayName, + subscriptionName: SpendSubscriptionPlan + .from(snapshot: store.snapshot(for: provider), provider: provider)? + .displayName, snapshot: snapshot)) } return SpendDashboardLoadRequest( @@ -249,6 +356,7 @@ enum SpendDashboardSource { unavailableSourceIDs: unavailableSourceIDs, confirmedEmptySourceIDs: confirmedEmptySourceIDs, codexRequests: codexRequests, + localHistoryRequests: self.localHistoryRequests(store: store), now: captureNow, force: mode.forcesLoader) } @@ -271,22 +379,80 @@ enum SpendDashboardSource { }) } + private struct LocalHistoryAdapter { + let source: ProviderLocalHistorySource + let displayName: String + let load: @Sendable (String, Date, Int) async throws -> CostUsageTokenSnapshot? + } + + private struct LocalHistoryLoaders { + let kimiCode: KimiCodeSnapshotLoader + let gemini: GeminiSnapshotLoader + let openCode: OpenCodeSnapshotLoader + let miniMax: MiniMaxSnapshotLoader + let antigravity: AntigravitySnapshotLoader + let qwenCode: QwenCodeSnapshotLoader + } + + /// A local tool whose usage snapshot is loaded from a home directory via a + /// registered, injectable adapter. + private struct LocalSnapshotSource { + let homePath: String? + let sourceID: String + let provider: UsageProvider + let displayName: String + let load: (String) async throws -> CostUsageTokenSnapshot? + + func loadInput() async throws -> SpendDashboardModel.ProviderInput? { + guard let homePath else { return nil } + guard let snapshot = try await self.load(homePath) else { return nil } + return SpendDashboardModel.ProviderInput( + id: self.sourceID, + provider: self.provider, + displayName: self.displayName, + modelProviderName: ProviderDescriptorRegistry.descriptor(for: self.provider) + .metadata.displayName, + snapshot: snapshot) + } + } + static func load( _ request: SpendDashboardLoadRequest, codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil, - codexSnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult + codexSnapshotLoader: @escaping CodexSnapshotLoader) async -> SpendDashboardLoadResult { await self.load( request, + codexProgress: codexProgress, codexSnapshotLoader: codexSnapshotLoader, codexActivitySnapshotLoader: codexSnapshotLoader) } static func load( _ request: SpendDashboardLoadRequest, - codexSnapshotLoader: CodexSnapshotLoader, - codexActivitySnapshotLoader: CodexSnapshotLoader) async -> SpendDashboardLoadResult + codexProgress: (@Sendable (_ scanned: Int, _ total: Int) -> Void)? = nil, + codexSnapshotLoader: @escaping CodexSnapshotLoader, + codexActivitySnapshotLoader: CodexSnapshotLoader? = nil, + kimiCodeSnapshotLoader: @escaping KimiCodeSnapshotLoader = { context in + try await Self.loadKimiCodeSnapshot(context) + }, + geminiSnapshotLoader: @escaping GeminiSnapshotLoader = { context in + try await Self.loadGeminiSnapshot(context) + }, + openCodeSnapshotLoader: @escaping OpenCodeSnapshotLoader = { context in + try await Self.loadOpenCodeSnapshot(context) + }, + miniMaxSnapshotLoader: @escaping MiniMaxSnapshotLoader = { context in + try await Self.loadMiniMaxSnapshot(context) + }, + antigravitySnapshotLoader: @escaping AntigravitySnapshotLoader = { context in + try await Self.loadAntigravitySnapshot(context) + }, + qwenCodeSnapshotLoader: @escaping QwenCodeSnapshotLoader = { context in + try await Self.loadQwenCodeSnapshot(context) + }) async -> SpendDashboardLoadResult { + let codexActivitySnapshotLoader = codexActivitySnapshotLoader ?? codexSnapshotLoader var inputs = request.capturedInputs var failedSourceIDs = request.unavailableSourceIDs var invalidatedSourceIDs: Set = [] @@ -340,6 +506,7 @@ enum SpendDashboardSource { provider: .codex, displayName: account.displayName, modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName, + subscriptionName: nil, snapshot: snapshot, tokenActivitySnapshot: tokenActivitySnapshot)) } catch is CancellationError { @@ -352,6 +519,46 @@ enum SpendDashboardSource { failedSourceIDs.insert(sourceID) } } + let adapters = self.localHistoryAdapters(loaders: LocalHistoryLoaders( + kimiCode: kimiCodeSnapshotLoader, + gemini: geminiSnapshotLoader, + openCode: openCodeSnapshotLoader, + miniMax: miniMaxSnapshotLoader, + antigravity: antigravitySnapshotLoader, + qwenCode: qwenCodeSnapshotLoader)) + let localSources: [LocalSnapshotSource] = request.localHistoryRequests.compactMap { localRequest in + guard let adapter = adapters[localRequest.source] else { + failedSourceIDs.insert(self.localSourceID(for: localRequest)) + return nil + } + return LocalSnapshotSource( + homePath: localRequest.homePath, + sourceID: self.localSourceID(for: localRequest), + provider: localRequest.provider, + displayName: adapter.displayName, + load: { homePath in + try await adapter.load(homePath, request.now, Self.scanDays) + }) + } + for source in localSources { + do { + if let input = try await source.loadInput() { + inputs.append(input) + // The spend dashboard's canonical history for these providers is the local + // scanner. A failed/unchanged live quota publication must not remain as a + // refresh warning after the corresponding local history loaded successfully. + failedSourceIDs.remove(source.provider.rawValue) + } + } catch is CancellationError { + failedSourceIDs.insert(source.sourceID) + return SpendDashboardLoadResult( + inputs: [], + failedSourceIDs: failedSourceIDs, + invalidatedSourceIDs: invalidatedSourceIDs) + } catch { + failedSourceIDs.insert(source.sourceID) + } + } let lateInvalidatedSourceIDs = Set(request.codexRequests.compactMap { account in self.currentAuthFingerprint(for: account) == account.authFingerprint ? nil @@ -381,6 +588,180 @@ enum SpendDashboardSource { codexProgress: context.progress) } + private static func loadKimiCodeSnapshot( + _ context: KimiCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try KimiCodeSessionScanner.scanCancellable( + environment: [KimiSettingsReader.codeHomeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + private static func loadGeminiSnapshot( + _ context: GeminiSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try GeminiSessionScanner.scanCancellable( + environment: [GeminiSessionScanner.cliHomeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + private static func loadOpenCodeSnapshot( + _ context: OpenCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try OpenCodeSessionScanner.scanCancellable( + environment: [OpenCodeSessionScanner.dataHomeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + private static func loadMiniMaxSnapshot( + _ context: MiniMaxSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try MiniMaxSessionScanner.scanCancellable( + environment: [MiniMaxSessionScanner.homeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + private static func loadAntigravitySnapshot( + _ context: AntigravitySpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try AntigravitySessionScanner.scanCancellable( + environment: [AntigravitySessionScanner.homeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + private static func loadQwenCodeSnapshot( + _ context: QwenCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try QwenCodeSessionScanner.scanCancellable( + environment: [QwenCodeSessionScanner.homeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + + private static func localHistoryAdapters(loaders: LocalHistoryLoaders) + -> [ProviderLocalHistorySource: LocalHistoryAdapter] + { + let adapters = [ + LocalHistoryAdapter(source: .kimiCode, displayName: "Kimi Code CLI") { homePath, now, days in + try await loaders.kimiCode(KimiCodeSpendSnapshotLoadContext( + homePath: homePath, now: now, historyDays: days)) + }, + LocalHistoryAdapter(source: .geminiCLI, displayName: "Gemini CLI") { homePath, now, days in + try await loaders.gemini(GeminiSpendSnapshotLoadContext( + homePath: homePath, now: now, historyDays: days)) + }, + LocalHistoryAdapter(source: .openCode, displayName: "OpenCode") { homePath, now, days in + try await loaders.openCode(OpenCodeSpendSnapshotLoadContext( + homePath: homePath, now: now, historyDays: days)) + }, + LocalHistoryAdapter(source: .miniMax, displayName: "MiniMax Code") { homePath, now, days in + try await loaders.miniMax(MiniMaxSpendSnapshotLoadContext( + homePath: homePath, now: now, historyDays: days)) + }, + LocalHistoryAdapter(source: .antigravity, displayName: "Antigravity") { homePath, now, days in + try await loaders.antigravity(AntigravitySpendSnapshotLoadContext( + homePath: homePath, now: now, historyDays: days)) + }, + LocalHistoryAdapter(source: .qwenCode, displayName: "Qwen Code CLI") { homePath, now, days in + try await loaders.qwenCode(QwenCodeSpendSnapshotLoadContext( + homePath: homePath, now: now, historyDays: days)) + }, + ] + return Dictionary(uniqueKeysWithValues: adapters.map { ($0.source, $0) }) + } + + private static func localSourceID(for request: LocalSpendHistoryRequest) -> String { + let descriptor = ProviderDescriptorRegistry.descriptor(for: request.provider) + if descriptor.tokenCost.localHistorySources.count == 1 { + return "\(request.provider.rawValue):local" + } + return "\(request.provider.rawValue):local:\(request.source.rawValue)" + } + + /// Main-actor capture of the Gemini CLI home, mirroring `KimiSettingsReader.kimiCodeHomeURL` + /// (the scanner itself appends `tmp` to whatever `GEMINI_CLI_HOME` resolves to). + private static func geminiCLIHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[GeminiSessionScanner.cliHomeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + } + + /// Main-actor capture of the XDG data home feeding `OpenCodeSessionScanner` (the scanner + /// itself appends `opencode/storage/message` to whatever `XDG_DATA_HOME` resolves to). + private static func openCodeDataHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[OpenCodeSessionScanner.dataHomeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + + /// Main-actor capture of the MiniMax home feeding `MiniMaxSessionScanner` (the scanner itself + /// appends `v2/sqlite/runtime-state.sqlite` to whatever `MINIMAX_HOME` resolves to). + private static func miniMaxHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[MiniMaxSessionScanner.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".minimax", isDirectory: true) + } + + /// Main-actor capture of the Antigravity home feeding `AntigravitySessionScanner` (the scanner + /// itself appends `conversations` to whatever `ANTIGRAVITY_HOME` resolves to). + private static func antigravityHomeURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let override = environment[AntigravitySessionScanner.homeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty + { + return URL(fileURLWithPath: override, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini", isDirectory: true) + .appendingPathComponent("antigravity", isDirectory: true) + } + @MainActor static func costCapableProviders(store: UsageStore) -> [UsageProvider] { store.enabledProvidersForDisplay().filter { @@ -388,6 +769,44 @@ enum SpendDashboardSource { } } + @MainActor + static func localModelHistoryProviders(store: UsageStore) -> [UsageProvider] { + store.enabledProvidersForDisplay().filter { + !ProviderDescriptorRegistry.descriptor(for: $0).tokenCost.localHistorySources.isEmpty + } + } + + @MainActor + static func localHistoryRequests(store: UsageStore) -> [LocalSpendHistoryRequest] { + self.localModelHistoryProviders(store: store).flatMap { provider in + ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.localHistorySources.map { source in + LocalSpendHistoryRequest( + source: source, + provider: provider, + homePath: self.localHistoryHomeURL(for: source).path) + } + } + } + + private static func localHistoryHomeURL( + for source: ProviderLocalHistorySource, + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + let resolvers: [ProviderLocalHistorySource: ([String: String]) -> URL] = [ + .kimiCode: { KimiSettingsReader.kimiCodeHomeURL(environment: $0) }, + .geminiCLI: { self.geminiCLIHomeURL(environment: $0) }, + .openCode: { self.openCodeDataHomeURL(environment: $0) }, + .miniMax: { self.miniMaxHomeURL(environment: $0) }, + .antigravity: { self.antigravityHomeURL(environment: $0) }, + .qwenCode: { QwenCodeSessionScanner.homeURL(environment: $0) }, + ] + // Unknown adapter identifiers cannot be scanned until their plugin + // registers a resolver. Returning an impossible path keeps request + // construction deterministic; loading reports the source as failed. + return resolvers[source]?(environment) + ?? URL(fileURLWithPath: "/.codexbar/unknown-local-history-source", isDirectory: true) + } + @MainActor static func codexRequests(settings: SettingsStore, store: UsageStore) -> [CodexSpendScanRequest] { let accounts = settings.codexVisibleAccountProjection.visibleAccounts @@ -775,6 +1194,7 @@ final class SpendDashboardController { } private(set) var failedSourceCount = 0 + private(set) var failedSourceIDs: Set = [] private(set) var generation: UInt64 = 0 private(set) var configuration: SpendDashboardConfiguration? private(set) var selectedDays: Int @@ -788,6 +1208,7 @@ final class SpendDashboardController { private var loadTask: Task? private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] private var loadedAt = Date() + private var lastCompletedLoadAt: Date? private var lastSuccessfulConfiguration: SpendDashboardConfiguration? private var phase = LoadPhase.ordinary @@ -854,12 +1275,14 @@ final class SpendDashboardController { if !invalidatedSourceIDs.isEmpty { self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) } self.failedSourceCount = 0 + self.failedSourceIDs = [] self.rebuildModel() } guard configuration.costUsageEnabled, !configuration.providerIDs.isEmpty else { self.loadedInputs = [] self.failedSourceCount = 0 + self.failedSourceIDs = [] self.isRefreshing = false self.lastSuccessfulConfiguration = configuration self.phase = .ordinary @@ -1021,8 +1444,10 @@ final class SpendDashboardController { self.configuration = request.configuration self.loadedInputs = nextInputs self.loadedAt = request.now + self.lastCompletedLoadAt = self.nowProvider() self.lastSuccessfulConfiguration = request.configuration self.failedSourceCount = result.failedSourceCount + self.failedSourceIDs = result.failedSourceIDs self.isRefreshing = false self.progressStore.value = nil self.phase = .ordinary @@ -1057,7 +1482,9 @@ final class SpendDashboardController { !forceFailed.contains(input.id) && !invalidated.contains(input.id) && !outcome.confirmedEmptySourceIDs.contains(input.id) && - (forcedCodexIDs.contains(input.id) || barrierFailed.contains(input.id)) + (forcedCodexIDs.contains(input.id) || + barrierFailed.contains(input.id) || + Self.isLocalHistorySource(input.id)) { inputs.append(input) capturedIDs.insert(input.id) @@ -1070,6 +1497,10 @@ final class SpendDashboardController { confirmedEmptySourceIDs: outcome.confirmedEmptySourceIDs) } + private static func isLocalHistorySource(_ sourceID: String) -> Bool { + sourceID.hasSuffix(":local") || sourceID.contains(":local:") + } + func refresh() { guard let configuration else { return } self.update(configuration: configuration, force: true) @@ -1083,10 +1514,20 @@ final class SpendDashboardController { self.rebuildModel() } - func refreshDateWindow(now: Date? = nil) { - self.loadedAt = now ?? self.nowProvider() + func refreshDateWindow( + now: Date? = nil, + reloadIfOlderThan minimumReloadInterval: TimeInterval? = 0) + { + let now = now ?? self.nowProvider() + self.loadedAt = now self.rebuildModel() guard let configuration else { return } + guard let minimumReloadInterval else { return } + if let lastCompletedLoadAt, + now.timeIntervalSince(lastCompletedLoadAt) < minimumReloadInterval + { + return + } let nextPhase: LoadPhase = self.phase.manualRefreshOutstanding ? .forcing : .ordinary self.startLoad(configuration: configuration, phase: nextPhase) } @@ -1134,6 +1575,7 @@ final class SpendDashboardController { provider: input.provider, displayName: displayName, modelProviderName: input.modelProviderName, + subscriptionName: input.subscriptionName, snapshot: input.snapshot, tokenActivitySnapshot: input.tokenActivitySnapshot) } @@ -1186,6 +1628,6 @@ final class SpendDashboardController { } private static func normalizedDays(_ value: Int) -> Int { - value == 7 ? 7 : 30 + [7, 30, 365].contains(value) ? value : 30 } } diff --git a/Sources/CodexBar/SpendDashboardModel+Aggregation.swift b/Sources/CodexBar/SpendDashboardModel+Aggregation.swift new file mode 100644 index 0000000000..47805b3b7c --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel+Aggregation.swift @@ -0,0 +1,229 @@ +import CodexBarCore +import Foundation + +extension SpendDashboardModel { + struct ClassifiedInput { + let currencyCode: String + let input: ProviderInput + let costMultiplier: Double + } + + struct InputSummary { + let input: ProviderInput + let costMultiplier: Double + let entries: [WindowEntry] + let totalTokens: Int? + let totalCost: Double? + let coveredInterval: ClosedRange? + let coveredDayCount: Int + let projectCount: Int? + let sessionCount: Int? + let hasInvalidCostHistory: Bool + } + + struct WindowEntry { + let day: Date + let entry: CostUsageDailyReport.Entry + } + + // MARK: - Aggregation helpers + + // + // Pure numeric/date helpers that do not touch the private `InputSummary`/`ModelRange` + // plumbing. Kept in a separate file to stay within the file-length lint budget; the + // `private` aggregation pipeline in `SpendDashboardModel.swift` calls these via `Self`. + + static func addAvailable(_ value: Int?, to current: Int?) -> Int? { + guard let value else { return current } + return (current ?? 0).addingReportingOverflow(value).overflow + ? current + : (current ?? 0) + value + } + + static func addAvailableCost(_ value: Double?, to current: Double?) -> Double? { + guard let value = validCost(value) else { return current } + let result = (current ?? 0) + value + return result.isFinite ? result : current + } + + static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { + let end = calendar.startOfDay(for: now) + let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end + return start...end + } + + static func gregorianCalendar(timeZone: TimeZone) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + return calendar + } + + static func chartDomain(bounds: ClosedRange, calendar: Calendar) -> ClosedRange { + let end = calendar.date(byAdding: .day, value: 1, to: bounds.upperBound) ?? bounds.upperBound + return bounds.lowerBound...end + } + + static func dayCount(in interval: ClosedRange?, calendar: Calendar) -> Int { + guard let interval else { return 0 } + return (calendar.dateComponents([.day], from: interval.lowerBound, to: interval.upperBound).day ?? 0) + 1 + } + + static func currencyCode(_ rawValue: String) -> String { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + return value.isEmpty ? "XXX" : value + } + + static func validCost(_ value: Double?) -> Double? { + guard let value, value.isFinite, value >= 0 else { return nil } + return value + } + + static func nonnegative(_ value: Int?) -> Int? { + guard let value, value >= 0 else { return nil } + return value + } + + static func safeCostSum(_ 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 + } + + static func completeCostSum(_ values: [Double?]) -> Double? { + guard values.allSatisfy({ $0 != nil }) else { return nil } + return self.safeCostSum(values.compactMap(\.self)) + } + + /// Sums the providers that *have* a cost figure, ignoring those without one (e.g. a provider + /// with no local cost history). One cost-less provider must not void the whole currency's + /// spend total; it is still surfaced individually as "unavailable". Returns nil only when no + /// provider contributes a cost. + static func availableCostSum(_ values: [Double?]) -> Double? { + let present = values.compactMap(\.self) + guard !present.isEmpty else { return nil } + return self.safeCostSum(present) + } + + /// Sums the token values that were actually observed. Missing source totals remain visible on + /// their individual rows, but do not turn the dashboard-wide "Tracked tokens" subtotal into + /// an em dash. + static func availableIntSum(_ values: [Int?]) -> Int? { + self.safeIntSum(values.compactMap(\.self)) + } + + static func safeIntSum(_ values: [Int]) -> Int? { + guard !values.isEmpty else { return nil } + var result = 0 + for value in values { + let addition = result.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + result = addition.partialValue + } + return result + } + + static func completeIntSum(_ values: [Int?]) -> Int? { + guard values.allSatisfy({ $0 != nil }) else { return nil } + return self.safeIntSum(values.compactMap(\.self)) + } + + static func add(_ value: Int, to current: Int?, overflowed: inout Bool) -> Int? { + guard !overflowed, let current else { return nil } + let addition = current.addingReportingOverflow(value) + if addition.overflow { + overflowed = true + return nil + } + return addition.partialValue + } + + static func add(_ value: Double, to current: Double?, overflowed: inout Bool) -> Double? { + guard !overflowed, let current else { return nil } + let result = current + value + guard result.isFinite else { + overflowed = true + return nil + } + return result + } + + // MARK: - Coverage / day bucketing + + static func coverageInterval( + input: ProviderInput, + bounds: ClosedRange, + displayCalendar: Calendar) -> ClosedRange? + { + guard input.snapshot.historyCoverageIsEstablished else { return nil } + let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: displayCalendar) + let overlapStart = max(bounds.lowerBound, sourceCoverage.lowerBound) + let overlapEnd = min(bounds.upperBound, sourceCoverage.upperBound) + guard overlapStart <= overlapEnd else { return nil } + return overlapStart...overlapEnd + } + + static func sourceCoverageInterval( + input: ProviderInput, + displayCalendar: Calendar) -> ClosedRange + { + let bucketCalendar = Self.bucketCalendar(for: input.provider, displayCalendar: displayCalendar) + let bucketEnd = bucketCalendar.startOfDay(for: input.snapshot.updatedAt) + let scanEnd = displayCalendar.startOfDay(for: bucketEnd) + let scanDays = max(1, input.snapshot.historyDays) + let bucketStart = bucketCalendar.date(byAdding: .day, value: -(scanDays - 1), to: bucketEnd) ?? bucketEnd + let scanStart = displayCalendar.startOfDay(for: bucketStart) + return scanStart...scanEnd + } + + static func commonCoverageDayCount(summaries: [InputSummary], calendar: Calendar) -> Int { + guard let first = summaries.first?.coveredInterval else { return 0 } + var intersection = first + for summary in summaries.dropFirst() { + guard let interval = summary.coveredInterval else { return 0 } + let start = max(intersection.lowerBound, interval.lowerBound) + let end = min(intersection.upperBound, interval.upperBound) + guard start <= end else { return 0 } + intersection = start...end + } + return Self.dayCount(in: intersection, calendar: calendar) + } + + static func day( + _ rawValue: String, + provider: UsageProvider, + displayCalendar: Calendar) -> Date? + { + let bytes = Array(rawValue.utf8) + let digitIndices = [0, 1, 2, 3, 5, 6, 8, 9] + guard bytes.count == 10, + bytes[4] == 45, + bytes[7] == 45, + digitIndices.allSatisfy({ (48...57).contains(bytes[$0]) }) + else { return nil } + let parts = rawValue.split(separator: "-") + let bucketCalendar = Self.bucketCalendar(for: provider, displayCalendar: displayCalendar) + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]), + let date = bucketCalendar.date(from: DateComponents(year: year, month: month, day: day)) + else { return nil } + guard bucketCalendar.dateComponents([.year, .month, .day], from: date) == DateComponents( + year: year, + month: month, + day: day) + else { return nil } + return displayCalendar.startOfDay(for: date) + } + + static func bucketCalendar(for provider: UsageProvider, displayCalendar: Calendar) -> Calendar { + guard provider == .mistral else { return displayCalendar } + // Mistral labels both daily buckets and snapshot coverage by UTC day. Map each UTC boundary into the + // containing local dashboard day instead of reinterpreting the label as a local date. + return self.gregorianCalendar(timeZone: TimeZone(secondsFromGMT: 0) ?? .gmt) + } +} diff --git a/Sources/CodexBar/SpendDashboardModel+ChartDomain.swift b/Sources/CodexBar/SpendDashboardModel+ChartDomain.swift new file mode 100644 index 0000000000..c0bb7a6c29 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel+ChartDomain.swift @@ -0,0 +1,145 @@ +import Foundation + +private struct SpendChartActivityDay { + let day: Date + let tokens: Double + let spend: Double +} + +extension SpendDashboardModel { + static func allModelChartDomain( + analysis: ModelAnalysis, + bounds: ClosedRange, + calendar: Calendar) -> ClosedRange + { + let activityDays = Dictionary(grouping: analysis.dailyValues, by: { + calendar.startOfDay(for: $0.day) + }) + .compactMap { day, values -> SpendChartActivityDay? in + let tokens = values.reduce(0.0) { partial, value in + if let total = value.totalTokens, total > 0 { + return partial + Double(total) + } + // Reasoning is a sub-bucket of output and must not be added again. + return partial + [ + value.inputTokens, + value.outputTokens, + value.cacheReadTokens, + value.cacheCreationTokens, + ].reduce(0.0) { subtotal, amount in + subtotal + Double(max(0, amount ?? 0)) + } + } + let spend = values.reduce(0.0) { partial, value in + partial + max(0, value.estimatedCost ?? 0) + } + guard tokens > 0 || spend > 0 else { return nil } + return SpendChartActivityDay(day: day, tokens: tokens, spend: spend) + } + .sorted { $0.day < $1.day } + let focusedDays = Self.droppingSparseLeadingActivity( + activityDays, + calendar: calendar) + guard let firstActiveDay = focusedDays.first?.day, + let lastActiveDay = focusedDays.last?.day + else { + let fallbackStart = calendar.date( + byAdding: .day, + value: -6, + to: bounds.upperBound) ?? bounds.upperBound + let fallbackEnd = calendar.date( + byAdding: .day, + value: 1, + to: bounds.upperBound) ?? bounds.upperBound + return max(bounds.lowerBound, fallbackStart)...fallbackEnd + } + + let observedDays = max( + 1, + (calendar.dateComponents( + [.day], + from: firstActiveDay, + to: lastActiveDay).day ?? 0) + 1) + let paddingDays = min(14, max(1, Int(ceil(Double(observedDays) * 0.04)))) + var start = max( + bounds.lowerBound, + calendar.date(byAdding: .day, value: -paddingDays, to: firstActiveDay) ?? firstActiveDay) + var lastVisibleDay = min( + bounds.upperBound, + calendar.date(byAdding: .day, value: paddingDays, to: lastActiveDay) ?? lastActiveDay) + + let visibleDays = max( + 1, + (calendar.dateComponents([.day], from: start, to: lastVisibleDay).day ?? 0) + 1) + if visibleDays < 7 { + let missingDays = 7 - visibleDays + let earlierStart = calendar.date(byAdding: .day, value: -missingDays, to: start) ?? start + start = max(bounds.lowerBound, earlierStart) + let expandedDays = max( + 1, + (calendar.dateComponents([.day], from: start, to: lastVisibleDay).day ?? 0) + 1) + if expandedDays < 7 { + let laterEnd = calendar.date( + byAdding: .day, + value: 7 - expandedDays, + to: lastVisibleDay) ?? lastVisibleDay + lastVisibleDay = min(bounds.upperBound, laterEnd) + } + } + + let end = calendar.date(byAdding: .day, value: 1, to: lastVisibleDay) ?? lastVisibleDay + return start...end + } + + /// Cumulative history occasionally contains one or two tiny legacy samples followed by + /// months of nothing. Keeping those samples on a calendar axis compresses the useful recent + /// history into a narrow strip. Drop only a leading segment that is separated by a long gap, + /// contains very few active days, and contributes no more than 5% of either tokens or spend. + /// The samples remain in rankings and totals; this affects chart framing only. + private static func droppingSparseLeadingActivity( + _ days: [SpendChartActivityDay], + calendar: Calendar) -> [SpendChartActivityDay] + { + guard days.count >= 4 else { return days } + let totalTokens = days.reduce(0.0) { $0 + $1.tokens } + let totalSpend = days.reduce(0.0) { $0 + $1.spend } + var lowerBound = 0 + + while days.count - lowerBound >= 4 { + let visible = days[lowerBound...] + let prefixCountLimit = max(2, Int(ceil(Double(visible.count) * 0.10))) + var splitIndex: Int? + for candidate in visible.indices.dropLast() { + let gap = Self.dayGap( + days[candidate].day, + days[candidate + 1].day, + calendar: calendar) + guard gap >= 21 else { continue } + let prefix = days[lowerBound...candidate] + let prefixTokens = prefix.reduce(0.0) { $0 + $1.tokens } + let prefixSpend = prefix.reduce(0.0) { $0 + $1.spend } + let tokenShare = totalTokens > 0 ? prefixTokens / totalTokens : 0 + let spendShare = totalSpend > 0 ? prefixSpend / totalSpend : 0 + if prefix.count <= prefixCountLimit, + tokenShare <= 0.05, + spendShare <= 0.05 + { + splitIndex = candidate + break + } + } + guard let splitIndex else { break } + lowerBound = splitIndex + 1 + } + + return Array(days[lowerBound...]) + } + + private static func dayGap( + _ lhs: Date, + _ rhs: Date, + calendar: Calendar) -> Int + { + max(0, calendar.dateComponents([.day], from: lhs, to: rhs).day ?? 0) + } +} diff --git a/Sources/CodexBar/SpendDashboardModel+CurrencySafety.swift b/Sources/CodexBar/SpendDashboardModel+CurrencySafety.swift new file mode 100644 index 0000000000..84029cbb13 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel+CurrencySafety.swift @@ -0,0 +1,174 @@ +import CodexBarCore +import Foundation + +extension SpendDashboardModel.ModelAnalysis { + func removingCosts(if shouldRemove: Bool) -> Self { + guard shouldRemove else { return self } + return Self( + rows: self.rows.map { row in + SpendDashboardModel.ModelAnalysisRow( + id: row.id, + displayName: row.displayName, + modelProvider: row.modelProvider, + rawModelNames: row.rawModelNames, + providers: row.providers, + providerNames: row.providerNames, + contributions: row.contributions.map { contribution in + SpendDashboardModel.ModelSourceContribution( + sourceID: contribution.sourceID, + provider: contribution.provider, + sourceName: contribution.sourceName, + providerName: contribution.providerName, + rawModelNames: contribution.rawModelNames, + totalTokens: contribution.totalTokens, + inputTokens: contribution.inputTokens, + outputTokens: contribution.outputTokens, + cacheReadTokens: contribution.cacheReadTokens, + cacheCreationTokens: contribution.cacheCreationTokens, + reasoningTokens: contribution.reasoningTokens, + requestCount: contribution.requestCount, + coveredDayCount: contribution.coveredDayCount, + projectCount: contribution.projectCount, + sessionCount: contribution.sessionCount, + estimatedCost: nil, + costIsEstimated: false) + }, + totalTokens: row.totalTokens, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + estimatedCost: nil, + cacheReadTokens: row.cacheReadTokens, + cacheCreationTokens: row.cacheCreationTokens, + reasoningTokens: row.reasoningTokens, + costIsEstimated: false) + }, + dailyValues: self.dailyValues.map { value in + SpendDashboardModel.ModelDailyValue( + modelID: value.modelID, + modelName: value.modelName, + day: value.day, + totalTokens: value.totalTokens, + inputTokens: value.inputTokens, + outputTokens: value.outputTokens, + estimatedCost: nil, + cacheReadTokens: value.cacheReadTokens, + cacheCreationTokens: value.cacheCreationTokens, + reasoningTokens: value.reasoningTokens) + }, + trackedTokenTotal: self.trackedTokenTotal, + pricedCostTotal: nil, + sourceCount: self.sourceCount, + tokenCoverage: self.tokenCoverage, + costCoverage: .unavailable) + } +} + +extension SpendDashboardModel.CurrencyGroup { + func removingCosts() -> Self { + Self( + currencyCode: self.currencyCode, + providers: self.providers.map { row in + SpendDashboardModel.ProviderRow( + id: row.id, + rank: row.rank, + provider: row.provider, + displayName: row.displayName, + subscriptionName: row.subscriptionName, + totalTokens: row.totalTokens, + totalCost: nil, + coveredDayCount: row.coveredDayCount) + }, + models: self.models.map { row in + SpendDashboardModel.ModelRow( + rank: row.rank, + provider: row.provider, + providerName: row.providerName, + modelName: row.modelName, + totalTokens: row.totalTokens, + totalCost: nil) + }, + modelAnalysis: self.modelAnalysis.removingCosts(if: true), + dailyPoints: [], + dailySpendDetails: [], + totalTokens: self.totalTokens, + totalCost: nil, + coveredDayCount: self.coveredDayCount, + chartDomain: self.chartDomain, + modelHistoryCompleteness: self.modelHistoryCompleteness) + } + + var pricedProviderCount: Int { + self.providers.count { $0.totalCost != nil } + } + + var costCoverage: SpendDashboardModel.ModelMetricCoverage { + guard !self.providers.isEmpty, self.pricedProviderCount > 0 else { return .unavailable } + return self.pricedProviderCount == self.providers.count ? .complete : .partial + } +} + +extension SpendDashboardModel.ProviderInput { + /// Returns a copy whose cost figures are pre-converted into the group's display currency by + /// `multiplier`. Billing attribution merges inputs from different sources — and therefore from + /// different original currencies — into a single vendor row, so the conversion must be baked in + /// *before* attribution (after which a single multiplier of 1 applies). Token counts and other + /// non-monetary fields pass through unchanged. + func preMultipliedCosts(by multiplier: Double) -> Self { + guard multiplier != 1 else { return self } + let snapshot = self.snapshot + let scaledDaily = snapshot.daily.map { entry in + CostUsageDailyReport.Entry( + date: entry.date, + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + cacheReadTokens: entry.cacheReadTokens, + cacheCreationTokens: entry.cacheCreationTokens, + totalTokens: entry.totalTokens, + requestCount: entry.requestCount, + costUSD: entry.costUSD.map { $0 * multiplier }, + modelsUsed: entry.modelsUsed, + modelBreakdowns: entry.modelBreakdowns?.map { breakdown in + CostUsageDailyReport.ModelBreakdown( + modelName: breakdown.modelName, + billingProviderID: breakdown.billingProviderID, + costUSD: breakdown.costUSD.map { $0 * multiplier }, + totalTokens: breakdown.totalTokens, + inputTokens: breakdown.inputTokens, + cacheReadTokens: breakdown.cacheReadTokens, + cacheCreationTokens: breakdown.cacheCreationTokens, + outputTokens: breakdown.outputTokens, + reasoningTokens: breakdown.reasoningTokens, + requestCount: breakdown.requestCount, + standardCostUSD: breakdown.standardCostUSD.map { $0 * multiplier }, + priorityCostUSD: breakdown.priorityCostUSD.map { $0 * multiplier }, + standardTokens: breakdown.standardTokens, + priorityTokens: breakdown.priorityTokens) + }) + } + let scaledSnapshot = CostUsageTokenSnapshot( + sessionTokens: snapshot.sessionTokens, + sessionCostUSD: snapshot.sessionCostUSD.map { $0 * multiplier }, + sessionRequests: snapshot.sessionRequests, + last30DaysTokens: snapshot.last30DaysTokens, + last30DaysCostUSD: snapshot.last30DaysCostUSD.map { $0 * multiplier }, + last30DaysRequests: snapshot.last30DaysRequests, + currencyCode: snapshot.currencyCode, + historyDays: snapshot.historyDays, + historyCoverageIsEstablished: snapshot.historyCoverageIsEstablished, + historyLabel: snapshot.historyLabel, + meteredCostUSD: snapshot.meteredCostUSD.map { $0 * multiplier }, + costSource: snapshot.costSource, + credentialScopeFingerprint: snapshot.credentialScopeFingerprint, + daily: scaledDaily, + projects: snapshot.projects, + sessions: snapshot.sessions, + updatedAt: snapshot.updatedAt) + return SpendDashboardModel.ProviderInput( + id: self.id, + provider: self.provider, + displayName: self.displayName, + modelProviderName: self.modelProviderName, + subscriptionName: self.subscriptionName, + snapshot: scaledSnapshot) + } +} diff --git a/Sources/CodexBar/SpendDashboardModel+Evidence.swift b/Sources/CodexBar/SpendDashboardModel+Evidence.swift new file mode 100644 index 0000000000..3f6f06706a --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel+Evidence.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation + +extension SpendDashboardModel { + struct ModelAnalysisAccumulator { + var rawNames: Set = [] + var displayNames: Set = [] + var providerNames: [UsageProvider: String] = [:] + var sourceContributions: [String: ModelAnalysisSourceAccumulator] = [:] + var tokens: Int? = 0 + var inputTokens: Int? = 0 + var outputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var reasoningTokens: Int? = 0 + var requestCount: Int? = 0 + var cost: Double? = 0 + var sawTokens = false + var sawTokenSplit = false + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var sawReasoningTokens = false + var missingReasoningTokens = false + var sawRequestCount = false + var missingRequestCount = false + var sawCost = false + var sawEstimatedCost = false + var invalidTokenSplit = false + var overflowedTokens = false + var overflowedInputTokens = false + var overflowedOutputTokens = false + var overflowedCacheReadTokens = false + var overflowedCacheCreationTokens = false + var overflowedReasoningTokens = false + var overflowedRequestCount = false + var overflowedCost = false + } + + struct ModelAnalysisSourceAccumulator { + let provider: UsageProvider + let sourceName: String + let providerName: String + let coveredDayCount: Int + let projectCount: Int? + let sessionCount: Int? + var rawNames: Set = [] + var tokens: Int? = 0 + var inputTokens: Int? = 0 + var outputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var reasoningTokens: Int? = 0 + var requestCount: Int? = 0 + var cost: Double? = 0 + var sawTokens = false + var sawTokenSplit = false + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var sawReasoningTokens = false + var missingReasoningTokens = false + var sawRequestCount = false + var missingRequestCount = false + var invalidTokenSplit = false + var sawCost = false + var sawEstimatedCost = false + var overflowedTokens = false + var overflowedInputTokens = false + var overflowedOutputTokens = false + var overflowedCacheReadTokens = false + var overflowedCacheCreationTokens = false + var overflowedReasoningTokens = false + var overflowedRequestCount = false + var overflowedCost = false + } +} diff --git a/Sources/CodexBar/SpendDashboardModel+TokenActivity.swift b/Sources/CodexBar/SpendDashboardModel+TokenActivity.swift new file mode 100644 index 0000000000..8844f0c802 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel+TokenActivity.swift @@ -0,0 +1,154 @@ +import CodexBarCore +import Foundation + +extension SpendDashboardModel { + static func tokenActivity( + inputs: [ProviderInput], + now: Date, + calendar: Calendar) -> [TokenActivityPoint] + { + guard !inputs.isEmpty else { return [] } + let bounds = Self.bounds(days: Self.tokenActivityDayCount, now: now, calendar: calendar) + let summaries = inputs.map { + Self.tokenActivityInputSummary(input: $0, bounds: bounds, calendar: calendar) + } + return (0.., + calendar: Calendar) -> SpendTokenActivityInputSummary + { + let annualInput = ProviderInput( + id: input.id, + provider: input.provider, + displayName: input.displayName, + modelProviderName: input.modelProviderName, + snapshot: input.tokenActivitySnapshot) + let annual = Self.tokenActivitySnapshotSummary(input: annualInput, bounds: bounds, calendar: calendar) + guard input.snapshot != input.tokenActivitySnapshot else { return annual } + + let recentInput = ProviderInput( + id: input.id, + provider: input.provider, + displayName: input.displayName, + modelProviderName: input.modelProviderName, + snapshot: input.snapshot) + let recent = Self.tokenActivitySnapshotSummary(input: recentInput, bounds: bounds, calendar: calendar) + var totalsByDay = annual.totalsByDay + var invalidDays = annual.invalidDays + var zeroKnownDays = annual.zeroKnownDays + for day in recent.coveredDays { + totalsByDay.removeValue(forKey: day) + invalidDays.remove(day) + zeroKnownDays.remove(day) + if let tokens = recent.totalsByDay[day] { + totalsByDay[day] = tokens + } + if recent.invalidDays.contains(day) { + invalidDays.insert(day) + } + if recent.zeroKnownDays.contains(day) { + zeroKnownDays.insert(day) + } + } + return SpendTokenActivityInputSummary( + coveredDays: annual.coveredDays.union(recent.coveredDays), + totalsByDay: totalsByDay, + invalidDays: invalidDays, + zeroKnownDays: zeroKnownDays) + } + + static func tokenActivitySnapshotSummary( + input: ProviderInput, + bounds: ClosedRange, + calendar: Calendar) -> SpendTokenActivityInputSummary + { + let coveredInterval = Self.coverageInterval( + input: input, + bounds: bounds, + displayCalendar: calendar) + let coveredDays = Self.days(in: coveredInterval, calendar: calendar) + let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: calendar) + var totalsByDay: [Date: Int] = [:] + var invalidDays: Set = [] + var hasUnplacedTokens = false + for entry in input.snapshot.daily { + guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: calendar) else { + hasUnplacedTokens = hasUnplacedTokens || !Self.hasProvenZeroTokens(entry) + continue + } + guard sourceCoverage.contains(day) else { continue } + guard let tokens = Self.nonnegative(entry.totalTokens) else { + invalidDays.insert(day) + continue + } + guard !invalidDays.contains(day) else { continue } + let addition = (totalsByDay[day] ?? 0).addingReportingOverflow(tokens) + if addition.overflow { + totalsByDay.removeValue(forKey: day) + invalidDays.insert(day) + } else { + totalsByDay[day] = addition.partialValue + } + } + + // A successful scan that found no sessions is confirmed zero activity: every covered day + // renders as zero instead of unavailable. Nonempty histories must still reconcile their + // aggregate against the daily entries. + let confirmedZeroHistory = input.snapshot.daily.isEmpty + && input.snapshot.last30DaysTokens == nil + let hasCompleteHistory = confirmedZeroHistory + || Self.hasCompleteTokenHistory(input, displayCalendar: calendar) + let aggregateIsInconsistent = input.snapshot.last30DaysTokens != nil && !hasCompleteHistory + if hasUnplacedTokens || aggregateIsInconsistent { + invalidDays.formUnion(coveredDays) + } + return SpendTokenActivityInputSummary( + coveredDays: coveredDays, + totalsByDay: totalsByDay, + invalidDays: invalidDays, + zeroKnownDays: hasCompleteHistory ? coveredDays : []) + } + + static func days(in interval: ClosedRange?, calendar: Calendar) -> Set { + guard let interval else { return [] } + var result: Set = [] + var day = interval.lowerBound + while day <= interval.upperBound { + result.insert(day) + guard let next = calendar.date(byAdding: .day, value: 1, to: day), next > day else { break } + day = next + } + return result + } + + struct SpendTokenActivityInputSummary { + let coveredDays: Set + let totalsByDay: [Date: Int] + let invalidDays: Set + let zeroKnownDays: Set + + func tokens(on day: Date) -> Int? { + guard self.coveredDays.contains(day), !self.invalidDays.contains(day) else { return nil } + if let tokens = self.totalsByDay[day] { + return tokens + } + return self.zeroKnownDays.contains(day) ? 0 : nil + } + } +} diff --git a/Sources/CodexBar/SpendDashboardModel+TokenBuckets.swift b/Sources/CodexBar/SpendDashboardModel+TokenBuckets.swift new file mode 100644 index 0000000000..27b55c2b0c --- /dev/null +++ b/Sources/CodexBar/SpendDashboardModel+TokenBuckets.swift @@ -0,0 +1,54 @@ +/// Shared split-resolution state of the model-analysis accumulators. +protocol ModelTokenSplitAccumulating { + var inputTokens: Int? { get } + var outputTokens: Int? { get } + var cacheReadTokens: Int? { get } + var cacheCreationTokens: Int? { get } + var reasoningTokens: Int? { get } + var sawTokenSplit: Bool { get } + var sawCacheReadTokens: Bool { get } + var missingCacheReadTokens: Bool { get } + var sawCacheCreationTokens: Bool { get } + var missingCacheCreationTokens: Bool { get } + var sawReasoningTokens: Bool { get } + var missingReasoningTokens: Bool { get } + var invalidTokenSplit: Bool { get } + var overflowedInputTokens: Bool { get } + var overflowedOutputTokens: Bool { get } + var overflowedCacheReadTokens: Bool { get } + var overflowedCacheCreationTokens: Bool { get } + var overflowedReasoningTokens: Bool { get } +} + +extension ModelTokenSplitAccumulating { + func resolvedTokenBuckets() -> SpendDashboardModel.ModelTokenSplitBuckets { + let hasCompleteTokenSplit = self.sawTokenSplit + && !self.invalidTokenSplit + && !self.overflowedInputTokens + && !self.overflowedOutputTokens + return SpendDashboardModel.ModelTokenSplitBuckets( + inputTokens: hasCompleteTokenSplit ? self.inputTokens : nil, + outputTokens: hasCompleteTokenSplit ? self.outputTokens : nil, + cacheReadTokens: SpendDashboardModel.optionalTokenBucket( + self.cacheReadTokens, + saw: self.sawCacheReadTokens, + missing: self.missingCacheReadTokens, + overflowed: self.overflowedCacheReadTokens, + splitIsComplete: hasCompleteTokenSplit), + cacheCreationTokens: SpendDashboardModel.optionalTokenBucket( + self.cacheCreationTokens, + saw: self.sawCacheCreationTokens, + missing: self.missingCacheCreationTokens, + overflowed: self.overflowedCacheCreationTokens, + splitIsComplete: hasCompleteTokenSplit), + reasoningTokens: SpendDashboardModel.optionalTokenBucket( + self.reasoningTokens, + saw: self.sawReasoningTokens, + missing: self.missingReasoningTokens, + overflowed: self.overflowedReasoningTokens, + splitIsComplete: hasCompleteTokenSplit)) + } +} + +extension SpendDashboardModel.ModelAnalysisAccumulator: ModelTokenSplitAccumulating {} +extension SpendDashboardModel.ModelAnalysisDailyAccumulator: ModelTokenSplitAccumulating {} diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 4602ef1d74..b306d7765a 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -7,14 +7,21 @@ struct SpendDashboardModel: Equatable, Sendable { let provider: UsageProvider let displayName: String let modelProviderName: String + let subscriptionName: String? let snapshot: CostUsageTokenSnapshot let tokenActivitySnapshot: CostUsageTokenSnapshot + /// Origin of this source's cost figures (provider-billed vs locally estimated). + var costSource: CostUsageCostSource { + self.snapshot.costSource + } + init( id: String? = nil, provider: UsageProvider, displayName: String, modelProviderName: String? = nil, + subscriptionName: String? = nil, snapshot: CostUsageTokenSnapshot, tokenActivitySnapshot: CostUsageTokenSnapshot? = nil) { @@ -22,6 +29,7 @@ struct SpendDashboardModel: Equatable, Sendable { self.provider = provider self.displayName = displayName self.modelProviderName = modelProviderName ?? displayName + self.subscriptionName = subscriptionName self.snapshot = snapshot self.tokenActivitySnapshot = tokenActivitySnapshot ?? snapshot } @@ -32,6 +40,7 @@ struct SpendDashboardModel: Equatable, Sendable { let rank: Int let provider: UsageProvider let displayName: String + var subscriptionName: String? let totalTokens: Int? let totalCost: Double? let coveredDayCount: Int @@ -54,6 +63,7 @@ struct SpendDashboardModel: Equatable, Sendable { let sourceID: String let provider: UsageProvider let providerName: String + let toolKind: SpendToolIdentity.Kind let day: Date let cost: Double let stackStart: Double @@ -62,6 +72,59 @@ struct SpendDashboardModel: Equatable, Sendable { var id: String { "\(self.sourceID):\(Int(self.day.timeIntervalSince1970))" } + + init( + sourceID: String, + provider: UsageProvider, + providerName: String, + toolKind: SpendToolIdentity.Kind = .other, + day: Date, + cost: Double, + stackStart: Double, + stackEnd: Double) + { + self.sourceID = sourceID + self.provider = provider + self.providerName = providerName + self.toolKind = toolKind + self.day = day + self.cost = cost + self.stackStart = stackStart + self.stackEnd = stackEnd + } + } + + struct DailySpendModel: Identifiable, Equatable, Sendable { + let id: String + let displayName: String + let modelProvider: UsageProvider + let tokens: Int? + let cost: Double? + } + + struct DailySpendTool: Identifiable, Equatable, Sendable { + let sourceID: String + let provider: UsageProvider + let displayName: String + let kind: SpendToolIdentity.Kind + let tokens: Int? + let cost: Double + let models: [DailySpendModel] + + var id: String { + self.sourceID + } + } + + struct DailySpendDetail: Identifiable, Equatable, Sendable { + let day: Date + let totalTokens: Int? + let totalCost: Double + let tools: [DailySpendTool] + + var id: Date { + self.day + } } struct TokenActivityPoint: Identifiable, Equatable, Sendable { @@ -80,11 +143,162 @@ struct SpendDashboardModel: Equatable, Sendable { case incomplete } + enum ModelMetricCoverage: Equatable, Sendable { + case complete + case partial + case unavailable + } + + struct ModelSourceContribution: Identifiable, Equatable, Sendable { + let sourceID: String + let provider: UsageProvider + let sourceName: String + let providerName: String + let rawModelNames: [String] + let totalTokens: Int? + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let reasoningTokens: Int? + let requestCount: Int? + let coveredDayCount: Int + let projectCount: Int? + let sessionCount: Int? + let estimatedCost: Double? + let costIsEstimated: Bool + + var id: String { + "\(self.sourceID):\(self.rawModelNames.joined(separator: "\u{0}"))" + } + } + + struct ModelAnalysisRow: Identifiable, Equatable, Sendable { + let id: String + let displayName: String + let modelProvider: UsageProvider + let rawModelNames: [String] + let providers: [UsageProvider] + let providerNames: [String] + let contributions: [ModelSourceContribution] + let totalTokens: Int? + let inputTokens: Int? + let outputTokens: Int? + let estimatedCost: Double? + /// Non-cached input bucket complement: cache reads/writes, reported separately when every + /// contributing breakdown carries them (nil means "unknown", not zero). + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + /// Reasoning sub-bucket of `outputTokens` (never add it on top of output). + let reasoningTokens: Int? + /// True when any contributing cost was locally estimated rather than provider-billed. + let costIsEstimated: Bool + + init( + id: String, + displayName: String, + modelProvider: UsageProvider? = nil, + rawModelNames: [String], + providers: [UsageProvider], + providerNames: [String], + contributions: [ModelSourceContribution], + totalTokens: Int?, + inputTokens: Int?, + outputTokens: Int?, + estimatedCost: Double?, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, + costIsEstimated: Bool = false) + { + self.id = id + self.displayName = displayName + self.modelProvider = modelProvider ?? SpendProviderIdentity.modelProvider( + rawNames: rawModelNames, + fallbackProviders: providers) + self.rawModelNames = rawModelNames + self.providers = providers + self.providerNames = providerNames + self.contributions = contributions + self.totalTokens = totalTokens + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.estimatedCost = estimatedCost + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens + self.costIsEstimated = costIsEstimated + } + } + + struct ModelDailyValue: Identifiable, Equatable, Sendable { + let modelID: String + let modelName: String + let day: Date + let totalTokens: Int? + let inputTokens: Int? + let outputTokens: Int? + let estimatedCost: Double? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + /// Reasoning sub-bucket of `outputTokens` (never add it on top of output). + let reasoningTokens: Int? + + var id: String { + "\(self.modelID):\(Int(self.day.timeIntervalSince1970))" + } + + init( + modelID: String, + modelName: String, + day: Date, + totalTokens: Int?, + inputTokens: Int?, + outputTokens: Int?, + estimatedCost: Double?, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil) + { + self.modelID = modelID + self.modelName = modelName + self.day = day + self.totalTokens = totalTokens + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.estimatedCost = estimatedCost + self.cacheReadTokens = cacheReadTokens + self.cacheCreationTokens = cacheCreationTokens + self.reasoningTokens = reasoningTokens + } + } + + struct ModelAnalysis: Equatable, Sendable { + let rows: [ModelAnalysisRow] + let dailyValues: [ModelDailyValue] + let trackedTokenTotal: Int? + let pricedCostTotal: Double? + let sourceCount: Int + let tokenCoverage: ModelMetricCoverage + let costCoverage: ModelMetricCoverage + + static let empty = Self( + rows: [], + dailyValues: [], + trackedTokenTotal: nil, + pricedCostTotal: nil, + sourceCount: 0, + tokenCoverage: .unavailable, + costCoverage: .unavailable) + } + struct CurrencyGroup: Identifiable, Equatable, Sendable { let currencyCode: String let providers: [ProviderRow] let models: [ModelRow] + var modelAnalysis: ModelAnalysis = .empty let dailyPoints: [DailyPoint] + var dailySpendDetails: [DailySpendDetail] = [] let totalTokens: Int? let totalCost: Double? let coveredDayCount: Int @@ -101,15 +315,61 @@ struct SpendDashboardModel: Equatable, Sendable { let tokenActivity: [TokenActivityPoint] static let tokenActivityDayCount = 365 + private let globalModelAnalysis: ModelAnalysis? + private let globalModelChartDomain: ClosedRange? + private let globalModelRanges: [Int: ModelRange] + + struct ModelRange: Equatable, Sendable { + let analysis: ModelAnalysis + let chartDomain: ClosedRange + } + + var modelAnalysis: ModelAnalysis { + self.globalModelAnalysis ?? self.groups.first?.modelAnalysis ?? .empty + } + + var modelChartDomain: ClosedRange? { + self.globalModelChartDomain ?? self.groups.first?.chartDomain + } + + func modelAnalysis(for requestedDays: Int) -> ModelAnalysis { + self.globalModelRanges[Self.normalizedModelDays(requestedDays)]?.analysis ?? self.modelAnalysis + } + + func modelChartDomain(for requestedDays: Int) -> ClosedRange? { + self.globalModelRanges[Self.normalizedModelDays(requestedDays)]?.chartDomain ?? self.modelChartDomain + } init( requestedDays: Int, groups: [CurrencyGroup], - tokenActivity: [TokenActivityPoint] = []) + tokenActivity: [TokenActivityPoint] = [], + globalModelAnalysis: ModelAnalysis? = nil, + globalModelChartDomain: ClosedRange? = nil) + { + self.init( + requestedDays: requestedDays, + groups: groups, + tokenActivity: tokenActivity, + globalModelAnalysis: globalModelAnalysis, + globalModelChartDomain: globalModelChartDomain, + globalModelRanges: [:]) + } + + private init( + requestedDays: Int, + groups: [CurrencyGroup], + tokenActivity: [TokenActivityPoint], + globalModelAnalysis: ModelAnalysis?, + globalModelChartDomain: ClosedRange?, + globalModelRanges: [Int: ModelRange]) { self.requestedDays = requestedDays self.groups = groups self.tokenActivity = tokenActivity + self.globalModelAnalysis = globalModelAnalysis + self.globalModelChartDomain = globalModelChartDomain + self.globalModelRanges = globalModelRanges } static func build( @@ -119,10 +379,14 @@ struct SpendDashboardModel: Equatable, Sendable { calendar: Calendar = .current, preferredCurrencyCode: String = "auto") -> Self { - let days = max(1, min(30, requestedDays)) + let days = max(1, min(365, requestedDays)) let calculationCalendar = Self.gregorianCalendar(timeZone: calendar.timeZone) - let classifiedInputs = inputs.compactMap { input -> ClassifiedInput? in - guard let sourceCurrencyCode = Self.currencyCode(input.snapshot.currencyCode) else { return nil } + let bounds = Self.bounds(days: days, now: now, calendar: calculationCalendar) + // Classify each input into its display currency, converting costs into the user's preferred + // currency when a rate is available. An unknown currency (XXX) keeps its own group so its + // tokens stay visible without being summed into a money total. + let classifiedInputs = inputs.map { input -> ClassifiedInput in + let sourceCurrencyCode = Self.currencyCode(input.snapshot.currencyCode) let targetCurrencyCode = UsageFormatter.effectiveCurrencyCode( preferred: preferredCurrencyCode, providerCurrency: sourceCurrencyCode) @@ -135,53 +399,82 @@ struct SpendDashboardModel: Equatable, Sendable { input: input, costMultiplier: conversion ?? 1) } + let summaryInputs = classifiedInputs.map { ($0.input, $0.costMultiplier) } + let globalSummaries = summaryInputs.map { + Self.inputSummary(input: $0.0, costMultiplier: $0.1, bounds: bounds, calendar: calculationCalendar) + } + let currencyCodes = Set(classifiedInputs.map(\.currencyCode)) + let pricedCurrencyCodes = currencyCodes.subtracting(["XXX"]) + let combinesCurrencies = pricedCurrencyCodes.count > 1 + let globalModelAnalysis = Self.modelAnalysis(summaries: globalSummaries) + .removingCosts(if: combinesCurrencies) + let globalModelRanges = Dictionary(uniqueKeysWithValues: Set([7, 30, 365, days]).map { rangeDays in + let rangeBounds = Self.bounds(days: rangeDays, now: now, calendar: calculationCalendar) + let summaries = summaryInputs.map { + Self.inputSummary( + input: $0.0, + costMultiplier: $0.1, + bounds: rangeBounds, + calendar: calculationCalendar) + } + let analysis = Self.modelAnalysis(summaries: summaries) + .removingCosts(if: combinesCurrencies) + let chartDomain = rangeDays == 365 + ? Self.allModelChartDomain( + analysis: analysis, + bounds: rangeBounds, + calendar: calculationCalendar) + : Self.chartDomain(bounds: rangeBounds, calendar: calculationCalendar) + return (rangeDays, ModelRange(analysis: analysis, chartDomain: chartDomain)) + }) let groups = Dictionary(grouping: classifiedInputs, by: { $0.currencyCode }) .map { currencyCode, inputs in - Self.buildCurrencyGroup( + let group = Self.buildCurrencyGroup( currencyCode: currencyCode, inputs: inputs, days: days, now: now, calendar: calculationCalendar) + // XXX means the source did not establish a billing currency. Preserve its + // observed tokens and models, but never render its numeric cost as money. + return currencyCode == "XXX" ? group.removingCosts() : group + } + .sorted { + if $0.currencyCode == "XXX" { return false } + if $1.currencyCode == "XXX" { return true } + return $0.currencyCode < $1.currencyCode } - .sorted { $0.currencyCode < $1.currencyCode } return Self( requestedDays: days, groups: groups, tokenActivity: Self.tokenActivity( inputs: inputs, now: now, - calendar: calculationCalendar)) - } - - private struct ClassifiedInput { - let currencyCode: String - let input: ProviderInput - let costMultiplier: Double - } - - private struct InputSummary { - let input: ProviderInput - let costMultiplier: Double - let entries: [WindowEntry] - let totalTokens: Int? - let totalCost: Double? - let coveredInterval: ClosedRange? - let coveredDayCount: Int - let hasInvalidCostHistory: Bool + calendar: calculationCalendar), + globalModelAnalysis: globalModelAnalysis, + globalModelChartDomain: days == 365 + ? Self.allModelChartDomain( + analysis: globalModelAnalysis, + bounds: bounds, + calendar: calculationCalendar) + : Self.chartDomain(bounds: bounds, calendar: calculationCalendar), + globalModelRanges: globalModelRanges) } - private struct WindowEntry { - let day: Date - let entry: CostUsageDailyReport.Entry + private static func normalizedModelDays(_ requestedDays: Int) -> Int { + switch requestedDays { + case 7: 7 + case 30: 30 + default: 365 + } } - private struct ModelKey: Hashable { + struct ModelKey: Hashable { let provider: UsageProvider let modelName: String } - private struct ModelAccumulator { + struct ModelAccumulator { let providerName: String var tokens: Int? var cost: Double? @@ -193,12 +486,44 @@ struct SpendDashboardModel: Equatable, Sendable { var overflowedCost = false } - private struct ModelSummary { + struct ModelSummary { let rows: [ModelRow] let completeness: ModelHistoryCompleteness } - private struct DailyKey: Hashable { + struct ModelAnalysisDailyKey: Hashable { + let modelID: String + let day: Date + } + + struct ModelAnalysisDailyAccumulator { + var tokens: Int? = 0 + var inputTokens: Int? = 0 + var outputTokens: Int? = 0 + var cacheReadTokens: Int? = 0 + var cacheCreationTokens: Int? = 0 + var reasoningTokens: Int? = 0 + var cost: Double? = 0 + var sawTokens = false + var sawTokenSplit = false + var sawCacheReadTokens = false + var missingCacheReadTokens = false + var sawCacheCreationTokens = false + var missingCacheCreationTokens = false + var sawReasoningTokens = false + var missingReasoningTokens = false + var sawCost = false + var invalidTokenSplit = false + var overflowedTokens = false + var overflowedInputTokens = false + var overflowedOutputTokens = false + var overflowedCacheReadTokens = false + var overflowedCacheCreationTokens = false + var overflowedReasoningTokens = false + var overflowedCost = false + } + + struct DailyKey: Hashable { let day: Date let sourceID: String } @@ -206,11 +531,14 @@ struct SpendDashboardModel: Equatable, Sendable { private struct DailyAccumulator { let provider: UsageProvider let providerName: String + let toolKind: SpendToolIdentity.Kind var cost: Double? var invalid = false var overflowed = false } +} +extension SpendDashboardModel { private static func buildCurrencyGroup( currencyCode: String, inputs: [ClassifiedInput], @@ -226,23 +554,50 @@ struct SpendDashboardModel: Equatable, Sendable { bounds: bounds, calendar: calendar) } - let providers = Self.providerRows(summaries) + // "By subscription" shows which vendor the user actually paid, so re-attribute each source's + // usage to its billing vendor (Codex driving MiniMax, Claude Code as a third-party harness, + // …) before ranking. The per-model breakdown, stacked chart and analysis keep the original + // per-tool attribution. + // Convert each input's costs into the group's display currency BEFORE attribution: a vendor + // row can merge sources that were originally billed in different currencies, so a single + // post-attribution multiplier would misconvert all but one of them. After pre-multiplication + // the merged vendor costs are already in the display currency (multiplier 1). + let billingInputs = SpendBillingAttribution.attribute( + inputs.map { $0.input.preMultipliedCosts(by: $0.costMultiplier) }) + let billingSummaries = billingInputs.map { input in + Self.inputSummary( + input: input, + costMultiplier: 1, + bounds: bounds, + calendar: calendar) + } + let providers = Self.providerRows(billingSummaries) let completeModelSummaries = summaries.filter { summary in guard summary.totalCost != nil else { return false } return Self.modelSummary(summaries: [summary]).completeness == .complete } let modelSummary = Self.modelSummary(summaries: completeModelSummaries) + let modelAnalysis = Self.modelAnalysis(summaries: summaries) let modelHistoryCompleteness = completeModelSummaries.count == summaries.count ? ModelHistoryCompleteness.complete : ModelHistoryCompleteness.incomplete + // Daily spend is an operational tool view: it answers which local app or harness generated + // the usage. Subscription ownership remains isolated to `providers` above. let dailyPoints = Self.dailyPoints(summaries: summaries) + let dailySpendDetails = Self.dailySpendDetails(summaries: summaries) return CurrencyGroup( currencyCode: currencyCode, providers: providers, models: modelSummary.rows, + modelAnalysis: modelAnalysis, dailyPoints: dailyPoints, - totalTokens: Self.completeIntSum(providers.map(\.totalTokens)), - totalCost: Self.completeCostSum(providers.map(\.totalCost)), + dailySpendDetails: dailySpendDetails, + // "Tracked tokens" is the subtotal we actually parsed, not a completeness assertion. + // A source without token detail must not erase known tokens from every other source. + // This mirrors Tokscale's aggregation: parsed token buckets always sum independently + // of whether every model/source can also be priced. + totalTokens: Self.availableIntSum(providers.map(\.totalTokens)), + totalCost: Self.availableCostSum(providers.map(\.totalCost)), coveredDayCount: Self.commonCoverageDayCount(summaries: summaries, calendar: calendar), chartDomain: Self.chartDomain(bounds: bounds, calendar: calendar), modelHistoryCompleteness: modelHistoryCompleteness) @@ -286,13 +641,57 @@ struct SpendDashboardModel: Equatable, Sendable { let hasCompleteCostHistory = Self.hasCompleteCostHistory(input, displayCalendar: calendar) let costAggregateIsConsistent = input.snapshot.last30DaysCostUSD == nil || hasCompleteCostHistory let invalidCostHistory = hasInvalidCostHistory || !costAggregateIsConsistent - let totalCost = invalidCostHistory - ? nil - : entries.isEmpty - ? (coveredDayCount > 0 && hasCompleteCostHistory ? 0 : nil) - : Self.completeCostSum(entries.map { - Self.validCost($0.entry.costUSD).map { $0 * costMultiplier } - }) + // Daily-sum fallback: when the provider's aggregate cost figure uses a different window or + // accounting than the local per-day logs (so the strict consistency check fails), fall back + // to summing the per-day costs instead of voiding the whole provider. + // + // The fallback sums only the days that were individually priceable. A day whose events all + // lack a cost figure (e.g. Cursor usage events that omit `totalCents`) yields a nil + // `entry.costUSD`; that single unpriceable day must not void the spend total for the whole + // window — the priceable days still carry a meaningful subtotal, matching how the model + // breakdown ("By tool") already aggregates them. The incomplete coverage is still surfaced + // via `hasInvalidCostHistory` / `modelHistoryCompleteness`. + let dailyCostSum = Self.completeCostSum(entries.map { + Self.validCost($0.entry.costUSD).map { $0 * costMultiplier } + }) + let availableDailyCostSum = Self.availableCostSum(entries.map { + Self.validCost($0.entry.costUSD).map { $0 * costMultiplier } + }) + let totalCost: Double? = if !invalidCostHistory { + entries.isEmpty + ? (coveredDayCount > 0 && hasCompleteCostHistory ? 0 : nil) + : dailyCostSum + } else if !hasInvalidCostHistory { + availableDailyCostSum + } else { + nil + } + // Project/session evidence is currently produced only by the Codex session scanner. + // Count it inside the selected dashboard window instead of exposing the snapshot's + // broader 30-day totals under a shorter range. + let projectCount: Int? = if input.provider == .codex, !input.snapshot.projects.isEmpty { + input.snapshot.projects.count { project in + project.daily.contains { entry in + guard let day = Self.day( + entry.date, + provider: input.provider, + displayCalendar: calendar) + else { + return false + } + return bounds.contains(day) + } + } + } else { + nil + } + let sessionCount: Int? = if input.provider == .codex, !input.snapshot.sessions.isEmpty { + input.snapshot.sessions.count { session in + bounds.contains(calendar.startOfDay(for: session.lastActivity)) + } + } else { + nil + } return InputSummary( input: input, costMultiplier: costMultiplier, @@ -301,6 +700,8 @@ struct SpendDashboardModel: Equatable, Sendable { totalCost: totalCost, coveredInterval: coveredInterval, coveredDayCount: coveredDayCount, + projectCount: projectCount, + sessionCount: sessionCount, hasInvalidCostHistory: invalidCostHistory) } @@ -321,6 +722,7 @@ struct SpendDashboardModel: Equatable, Sendable { rank: rank + 1, provider: entry.element.input.provider, displayName: entry.element.input.displayName, + subscriptionName: entry.element.input.subscriptionName, totalTokens: entry.element.totalTokens, totalCost: entry.element.totalCost, coveredDayCount: entry.element.coveredDayCount) @@ -410,6 +812,472 @@ struct SpendDashboardModel: Equatable, Sendable { return ModelSummary(rows: rows, completeness: completeness) } + private static func modelAnalysis(summaries: [InputSummary]) -> ModelAnalysis { + var models: [String: ModelAnalysisAccumulator] = [:] + var daily: [ModelAnalysisDailyKey: ModelAnalysisDailyAccumulator] = [:] + var tokenCoverageIsPartial = false + var costCoverageIsPartial = false + + for summary in summaries { + let input = summary.input + tokenCoverageIsPartial = tokenCoverageIsPartial || summary.totalTokens == nil + costCoverageIsPartial = costCoverageIsPartial || summary.totalCost == nil + for windowEntry in summary.entries { + let entry = windowEntry.entry + let tokenBreakdownIsComplete = Self.hasCompleteModelTokenCoverage(entry) + let costBreakdownIsComplete = Self.hasCompleteModelCostCoverage(entry) + tokenCoverageIsPartial = tokenCoverageIsPartial || !tokenBreakdownIsComplete + costCoverageIsPartial = costCoverageIsPartial || !costBreakdownIsComplete + + for breakdown in entry.modelBreakdowns ?? [] { + let rawName = breakdown.modelName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawName.isEmpty else { continue } + let modelIdentity = Self.modelIdentity(rawName: rawName, provider: input.provider) + let identity = modelIdentity.id + var aggregate = models[identity] ?? ModelAnalysisAccumulator() + aggregate.rawNames.insert(rawName) + aggregate.displayNames.insert(modelIdentity.displayName) + aggregate.providerNames[input.provider] = input.modelProviderName + var source = aggregate.sourceContributions[input.id] ?? ModelAnalysisSourceAccumulator( + provider: input.provider, + sourceName: input.displayName, + providerName: input.modelProviderName, + coveredDayCount: summary.coveredDayCount, + projectCount: summary.projectCount, + sessionCount: summary.sessionCount) + source.rawNames.insert(rawName) + + let dailyKey = ModelAnalysisDailyKey(modelID: identity, day: windowEntry.day) + var dailyValue = daily[dailyKey] ?? ModelAnalysisDailyAccumulator() + if Self.addModelTokenBreakdown( + breakdown, + isComplete: tokenBreakdownIsComplete, + aggregate: &aggregate, + source: &source, + dailyValue: &dailyValue) + { + daily[dailyKey] = dailyValue + } + + if costBreakdownIsComplete, + let cost = Self.validCost(breakdown.costUSD).map({ $0 * summary.costMultiplier }) + { + aggregate.sawCost = true + aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowedCost) + if input.costSource == .estimated { + aggregate.sawEstimatedCost = true + source.sawEstimatedCost = true + } + source.sawCost = true + source.cost = Self.add(cost, to: source.cost, overflowed: &source.overflowedCost) + let key = ModelAnalysisDailyKey(modelID: identity, day: windowEntry.day) + var value = daily[key] ?? ModelAnalysisDailyAccumulator() + value.sawCost = true + value.cost = Self.add(cost, to: value.cost, overflowed: &value.overflowedCost) + daily[key] = value + } + + aggregate.sourceContributions[input.id] = source + models[identity] = aggregate + } + } + } + + let rows = Self.modelAnalysisRows(models) + + let namesByID: [String: String] = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0.displayName) }) + let dailyValues = daily.compactMap { key, value -> ModelDailyValue? in + let tokens = value.sawTokens && !value.overflowedTokens ? value.tokens : nil + let buckets = value.resolvedTokenBuckets() + let cost = value.sawCost && !value.overflowedCost ? value.cost : nil + guard tokens != nil || cost != nil, let name = namesByID[key.modelID] else { return nil } + return ModelDailyValue( + modelID: key.modelID, + modelName: name, + day: key.day, + totalTokens: tokens, + inputTokens: buckets.inputTokens, + outputTokens: buckets.outputTokens, + estimatedCost: cost, + cacheReadTokens: buckets.cacheReadTokens, + cacheCreationTokens: buckets.cacheCreationTokens, + reasoningTokens: buckets.reasoningTokens) + } + .sorted { lhs, rhs in + if lhs.day != rhs.day { return lhs.day < rhs.day } + return lhs.modelID < rhs.modelID + } + + let trackedTokenTotal = Self.safeIntSum(rows.compactMap(\.totalTokens)) + let pricedCostTotal = Self.safeCostSum(rows.compactMap(\.estimatedCost)) + return ModelAnalysis( + rows: rows, + dailyValues: dailyValues, + trackedTokenTotal: trackedTokenTotal, + pricedCostTotal: pricedCostTotal, + sourceCount: Set(rows.flatMap(\.contributions).map(\.sourceID)).count, + tokenCoverage: Self.modelMetricCoverage( + hasValue: trackedTokenTotal != nil, + isPartial: tokenCoverageIsPartial), + costCoverage: Self.modelMetricCoverage(hasValue: pricedCostTotal != nil, isPartial: costCoverageIsPartial)) + } + + private static func modelAnalysisRows( + _ models: [String: ModelAnalysisAccumulator]) -> [ModelAnalysisRow] + { + models.compactMap { identity, aggregate -> ModelAnalysisRow? in + let totalTokens = aggregate.sawTokens && !aggregate.overflowedTokens ? aggregate.tokens : nil + let buckets = aggregate.resolvedTokenBuckets() + let estimatedCost = aggregate.sawCost && !aggregate.overflowedCost ? aggregate.cost : nil + guard totalTokens != nil || estimatedCost != nil else { return nil } + let rawNames = aggregate.rawNames.sorted(by: Self.modelNameOrder) + let displayNames = aggregate.displayNames.sorted(by: Self.modelNameOrder) + let contributions = aggregate.sourceContributions.map { sourceID, source in + let sourceSplitIsComplete = source.sawTokenSplit && !source.invalidTokenSplit + return ModelSourceContribution( + sourceID: sourceID, + provider: source.provider, + sourceName: source.sourceName, + providerName: source.providerName, + rawModelNames: source.rawNames.sorted(by: Self.modelNameOrder), + totalTokens: source.sawTokens && !source.overflowedTokens ? source.tokens : nil, + inputTokens: sourceSplitIsComplete && !source.overflowedInputTokens + ? source.inputTokens + : nil, + outputTokens: sourceSplitIsComplete && !source.overflowedOutputTokens + ? source.outputTokens + : nil, + cacheReadTokens: Self.optionalTokenBucket( + source.cacheReadTokens, + saw: source.sawCacheReadTokens, + missing: source.missingCacheReadTokens, + overflowed: source.overflowedCacheReadTokens, + splitIsComplete: sourceSplitIsComplete), + cacheCreationTokens: Self.optionalTokenBucket( + source.cacheCreationTokens, + saw: source.sawCacheCreationTokens, + missing: source.missingCacheCreationTokens, + overflowed: source.overflowedCacheCreationTokens, + splitIsComplete: sourceSplitIsComplete), + reasoningTokens: Self.optionalTokenBucket( + source.reasoningTokens, + saw: source.sawReasoningTokens, + missing: source.missingReasoningTokens, + overflowed: source.overflowedReasoningTokens, + splitIsComplete: sourceSplitIsComplete), + requestCount: source.sawRequestCount && !source.missingRequestCount + && !source.overflowedRequestCount + ? source.requestCount + : nil, + coveredDayCount: source.coveredDayCount, + projectCount: source.projectCount, + sessionCount: source.sessionCount, + estimatedCost: source.sawCost && !source.overflowedCost ? source.cost : nil, + costIsEstimated: source.sawEstimatedCost) + } + .sorted { lhs, rhs in + if lhs.providerName != rhs.providerName { return lhs.providerName < rhs.providerName } + if lhs.sourceName != rhs.sourceName { return lhs.sourceName < rhs.sourceName } + return lhs.sourceID < rhs.sourceID + } + let providers = aggregate.providerNames.keys.sorted { lhs, rhs in + let left = aggregate.providerNames[lhs] ?? lhs.rawValue + let right = aggregate.providerNames[rhs] ?? rhs.rawValue + if left != right { return left < right } + return lhs.rawValue < rhs.rawValue + } + return ModelAnalysisRow( + id: identity, + displayName: displayNames.first ?? rawNames.first ?? identity, + rawModelNames: rawNames, + providers: providers, + providerNames: providers.map { aggregate.providerNames[$0] ?? $0.rawValue }, + contributions: contributions, + totalTokens: totalTokens, + inputTokens: buckets.inputTokens, + outputTokens: buckets.outputTokens, + estimatedCost: estimatedCost, + cacheReadTokens: buckets.cacheReadTokens, + cacheCreationTokens: buckets.cacheCreationTokens, + reasoningTokens: buckets.reasoningTokens, + costIsEstimated: aggregate.sawEstimatedCost) + } + .sorted(by: self.modelAnalysisRowOrder) + } + + private static func addModelTokenBreakdown( + _ breakdown: CostUsageDailyReport.ModelBreakdown, + isComplete: Bool, + aggregate: inout ModelAnalysisAccumulator, + source: inout ModelAnalysisSourceAccumulator, + dailyValue: inout ModelAnalysisDailyAccumulator) -> Bool + { + guard isComplete, let tokens = nonnegative(breakdown.totalTokens) else { + aggregate.invalidTokenSplit = true + return false + } + + aggregate.sawTokens = true + aggregate.tokens = Self.add(tokens, to: aggregate.tokens, overflowed: &aggregate.overflowedTokens) + source.sawTokens = true + source.tokens = Self.add(tokens, to: source.tokens, overflowed: &source.overflowedTokens) + if let requestCount = nonnegative(breakdown.requestCount) { + aggregate.sawRequestCount = true + aggregate.requestCount = Self.add( + requestCount, + to: aggregate.requestCount, + overflowed: &aggregate.overflowedRequestCount) + source.sawRequestCount = true + source.requestCount = Self.add( + requestCount, + to: source.requestCount, + overflowed: &source.overflowedRequestCount) + } else { + aggregate.missingRequestCount = true + source.missingRequestCount = true + } + + dailyValue.sawTokens = true + dailyValue.tokens = Self.add( + tokens, + to: dailyValue.tokens, + overflowed: &dailyValue.overflowedTokens) + if let split = Self.modelTokenSplit(breakdown) { + aggregate.sawTokenSplit = true + aggregate.inputTokens = Self.add( + split.input, + to: aggregate.inputTokens, + overflowed: &aggregate.overflowedInputTokens) + aggregate.outputTokens = Self.add( + split.output, + to: aggregate.outputTokens, + overflowed: &aggregate.overflowedOutputTokens) + source.sawTokenSplit = true + source.inputTokens = Self.add( + split.input, + to: source.inputTokens, + overflowed: &source.overflowedInputTokens) + source.outputTokens = Self.add( + split.output, + to: source.outputTokens, + overflowed: &source.overflowedOutputTokens) + dailyValue.sawTokenSplit = true + dailyValue.inputTokens = Self.add( + split.input, + to: dailyValue.inputTokens, + overflowed: &dailyValue.overflowedInputTokens) + dailyValue.outputTokens = Self.add( + split.output, + to: dailyValue.outputTokens, + overflowed: &dailyValue.overflowedOutputTokens) + Self.addOptionalTokenBucket( + split.cacheRead, + into: &aggregate.cacheReadTokens, + saw: &aggregate.sawCacheReadTokens, + missing: &aggregate.missingCacheReadTokens, + overflowed: &aggregate.overflowedCacheReadTokens) + Self.addOptionalTokenBucket( + split.cacheRead, + into: &source.cacheReadTokens, + saw: &source.sawCacheReadTokens, + missing: &source.missingCacheReadTokens, + overflowed: &source.overflowedCacheReadTokens) + Self.addOptionalTokenBucket( + split.cacheRead, + into: &dailyValue.cacheReadTokens, + saw: &dailyValue.sawCacheReadTokens, + missing: &dailyValue.missingCacheReadTokens, + overflowed: &dailyValue.overflowedCacheReadTokens) + Self.addOptionalTokenBucket( + split.cacheCreation, + into: &aggregate.cacheCreationTokens, + saw: &aggregate.sawCacheCreationTokens, + missing: &aggregate.missingCacheCreationTokens, + overflowed: &aggregate.overflowedCacheCreationTokens) + Self.addOptionalTokenBucket( + split.cacheCreation, + into: &source.cacheCreationTokens, + saw: &source.sawCacheCreationTokens, + missing: &source.missingCacheCreationTokens, + overflowed: &source.overflowedCacheCreationTokens) + Self.addOptionalTokenBucket( + split.cacheCreation, + into: &dailyValue.cacheCreationTokens, + saw: &dailyValue.sawCacheCreationTokens, + missing: &dailyValue.missingCacheCreationTokens, + overflowed: &dailyValue.overflowedCacheCreationTokens) + Self.addOptionalTokenBucket( + split.reasoning, + into: &aggregate.reasoningTokens, + saw: &aggregate.sawReasoningTokens, + missing: &aggregate.missingReasoningTokens, + overflowed: &aggregate.overflowedReasoningTokens) + Self.addOptionalTokenBucket( + split.reasoning, + into: &source.reasoningTokens, + saw: &source.sawReasoningTokens, + missing: &source.missingReasoningTokens, + overflowed: &source.overflowedReasoningTokens) + Self.addOptionalTokenBucket( + split.reasoning, + into: &dailyValue.reasoningTokens, + saw: &dailyValue.sawReasoningTokens, + missing: &dailyValue.missingReasoningTokens, + overflowed: &dailyValue.overflowedReasoningTokens) + } else { + aggregate.invalidTokenSplit = true + source.invalidTokenSplit = true + dailyValue.invalidTokenSplit = true + } + return true + } + + /// Accumulates one optional split bucket (cache read/creation, reasoning). Mirrors the + /// `merged()` breakdown rule: the bucket is only known when *every* contributing breakdown + /// reports it, so a single missing value poisons the aggregate to nil. + private static func addOptionalTokenBucket( + _ value: Int?, + into bucket: inout Int?, + saw: inout Bool, + missing: inout Bool, + overflowed: inout Bool) + { + guard let value else { + missing = true + return + } + saw = true + bucket = Self.add(value, to: bucket, overflowed: &overflowed) + } + + static func optionalTokenBucket( + _ bucket: Int?, + saw: Bool, + missing: Bool, + overflowed: Bool, + splitIsComplete: Bool) -> Int? + { + guard splitIsComplete, saw, !missing, !overflowed else { return nil } + return bucket + } + + /// Resolved per-model token buckets for one analysis row or daily value. + struct ModelTokenSplitBuckets: Equatable, Sendable { + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let reasoningTokens: Int? + } + + private static func modelMetricCoverage(hasValue: Bool, isPartial: Bool) -> ModelMetricCoverage { + guard hasValue else { return .unavailable } + return isPartial ? .partial : .complete + } + + /// Per-breakdown token buckets for the model analysis. `input` is always the non-cached + /// input, so `input + cacheRead + cacheCreation + output == total` holds whenever every + /// bucket is known. `reasoning` is a sub-bucket of `output` (billing-inclusive) and must + /// never be added on top. Optional buckets are nil when the source does not report them. + private struct ModelTokenSplit { + let input: Int + let output: Int + let cacheRead: Int? + let cacheCreation: Int? + let reasoning: Int? + } + + private static func modelTokenSplit( + _ breakdown: CostUsageDailyReport.ModelBreakdown) -> ModelTokenSplit? + { + guard breakdown.cacheReadTokens.map({ $0 >= 0 }) ?? true, + breakdown.cacheCreationTokens.map({ $0 >= 0 }) ?? true, + let total = nonnegative(breakdown.totalTokens), + let output = nonnegative(breakdown.outputTokens), + output <= total, + // Reasoning is billed as output, so it can never exceed the output bucket. + breakdown.reasoningTokens.map({ $0 >= 0 && $0 <= output }) ?? true + else { + return nil + } + + if let input = nonnegative(breakdown.inputTokens) { + let cacheRead = breakdown.cacheReadTokens ?? 0 + let cacheCreation = breakdown.cacheCreationTokens ?? 0 + if let explicitSum = Self.sumTokenBuckets([input, cacheRead, cacheCreation, output]), + explicitSum == total + { + // Cache-exclusive input (Claude/Gemini/OpenCode shape): carry every bucket as-is. + return ModelTokenSplit( + input: input, + output: output, + cacheRead: breakdown.cacheReadTokens, + cacheCreation: breakdown.cacheCreationTokens, + reasoning: breakdown.reasoningTokens) + } + // Cache-inclusive input (Codex shape, where input + output == total): subtract the + // cache read overlap so the explicit buckets sum to the total, mirroring tokscale. + let inputOutputSum = input.addingReportingOverflow(output) + if !inputOutputSum.overflow, inputOutputSum.partialValue == total, cacheRead <= input { + return ModelTokenSplit( + input: input - cacheRead, + output: output, + cacheRead: breakdown.cacheReadTokens, + cacheCreation: breakdown.cacheCreationTokens, + reasoning: breakdown.reasoningTokens) + } + // Mixed-source merges (e.g. Codex native + Pi) fit neither shape exactly; fall + // through to the legacy inference so the row keeps a consistent input/output split. + } + + // Legacy inference: everything non-output counts as input and the cache buckets stay + // unknown. Reasoning is shape-independent (a validated sub-bucket of output), so it is + // carried whenever the source reports it. + return ModelTokenSplit( + input: total - output, + output: output, + cacheRead: nil, + cacheCreation: nil, + reasoning: breakdown.reasoningTokens) + } + + private static func sumTokenBuckets(_ 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 modelNameOrder(_ lhs: String, _ rhs: String) -> Bool { + if lhs.count != rhs.count { return lhs.count < rhs.count } + let comparison = lhs.localizedCaseInsensitiveCompare(rhs) + if comparison != .orderedSame { return comparison == .orderedAscending } + return lhs < rhs + } + + private static func modelIdentity(rawName: String, provider: UsageProvider) -> (id: String, displayName: String) { + let identity = SpendModelIdentity(rawName: rawName, provider: provider) + return (identity.id, identity.displayName) + } + + private static func modelAnalysisRowOrder(_ lhs: ModelAnalysisRow, _ rhs: ModelAnalysisRow) -> Bool { + switch (lhs.totalTokens, rhs.totalTokens) { + case let (left?, right?) where left != right: left > right + case (_?, nil): true + case (nil, _?): false + default: + switch (lhs.estimatedCost, rhs.estimatedCost) { + case let (left?, right?) where left != right: left > right + case (_?, nil): true + case (nil, _?): false + default: self.modelNameOrder(lhs.displayName, rhs.displayName) + } + } + } + private static func hasProvenZeroCost(_ entry: CostUsageDailyReport.Entry) -> Bool { self.validCost(entry.costUSD) == 0 && (entry.modelBreakdowns?.allSatisfy(self.hasProvenZeroCost) ?? true) @@ -423,7 +1291,7 @@ struct SpendDashboardModel: Equatable, Sendable { } } - private static func hasProvenZeroTokens(_ entry: CostUsageDailyReport.Entry) -> Bool { + static func hasProvenZeroTokens(_ entry: CostUsageDailyReport.Entry) -> Bool { let optionalTokens = [ entry.inputTokens, entry.cacheReadTokens, @@ -435,7 +1303,7 @@ struct SpendDashboardModel: Equatable, Sendable { && (entry.modelBreakdowns?.allSatisfy(Self.hasProvenZeroTokens) ?? true) } - private static func hasProvenZeroTokens(_ breakdown: CostUsageDailyReport.ModelBreakdown) -> Bool { + static func hasProvenZeroTokens(_ breakdown: CostUsageDailyReport.ModelBreakdown) -> Bool { let optionalTokens = [breakdown.standardTokens, breakdown.priorityTokens] return Self.nonnegative(breakdown.totalTokens) == 0 && optionalTokens.allSatisfy { $0 == nil || Self.nonnegative($0) == 0 } @@ -508,7 +1376,7 @@ struct SpendDashboardModel: Equatable, Sendable { return self.costsMatch(aggregate, dailyTotal) } - private static func hasCompleteTokenHistory( + static func hasCompleteTokenHistory( _ input: ProviderInput, displayCalendar: Calendar) -> Bool { @@ -537,9 +1405,14 @@ struct SpendDashboardModel: Equatable, Sendable { let day = windowEntry.day let entry = windowEntry.entry let key = DailyKey(day: day, sourceID: input.id) + let tool = SpendToolIdentity.resolve( + provider: input.provider, + sourceName: input.displayName, + providerName: input.modelProviderName) var aggregate = aggregates[key] ?? DailyAccumulator( provider: input.provider, - providerName: input.displayName, + providerName: tool.displayName, + toolKind: tool.kind, cost: 0) if let cost = Self.validCost(entry.costUSD).map({ $0 * summary.costMultiplier }) { aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowed) @@ -566,6 +1439,7 @@ struct SpendDashboardModel: Equatable, Sendable { sourceID: key.sourceID, provider: value.provider, providerName: value.providerName, + toolKind: value.toolKind, day: day, cost: cost, stackStart: start, @@ -575,315 +1449,77 @@ struct SpendDashboardModel: Equatable, Sendable { } } - private static func tokenActivity( - inputs: [ProviderInput], - now: Date, - calendar: Calendar) -> [TokenActivityPoint] - { - guard !inputs.isEmpty else { return [] } - let bounds = Self.bounds(days: Self.tokenActivityDayCount, now: now, calendar: calendar) - let summaries = inputs.map { - Self.tokenActivityInputSummary(input: $0, bounds: bounds, calendar: calendar) - } - return (0.. [DailySpendDetail] { + struct Key: Hashable { + let day: Date + let sourceID: String } - } - - private static func tokenActivityInputSummary( - input: ProviderInput, - bounds: ClosedRange, - calendar: Calendar) -> SpendTokenActivityInputSummary - { - let annualInput = ProviderInput( - id: input.id, - provider: input.provider, - displayName: input.displayName, - modelProviderName: input.modelProviderName, - snapshot: input.tokenActivitySnapshot) - let annual = Self.tokenActivitySnapshotSummary(input: annualInput, bounds: bounds, calendar: calendar) - guard input.snapshot != input.tokenActivitySnapshot else { return annual } - - let recentInput = ProviderInput( - id: input.id, - provider: input.provider, - displayName: input.displayName, - modelProviderName: input.modelProviderName, - snapshot: input.snapshot) - let recent = Self.tokenActivitySnapshotSummary(input: recentInput, bounds: bounds, calendar: calendar) - var totalsByDay = annual.totalsByDay - var invalidDays = annual.invalidDays - var zeroKnownDays = annual.zeroKnownDays - for day in recent.coveredDays { - totalsByDay.removeValue(forKey: day) - invalidDays.remove(day) - zeroKnownDays.remove(day) - if let tokens = recent.totalsByDay[day] { - totalsByDay[day] = tokens - } - if recent.invalidDays.contains(day) { - invalidDays.insert(day) - } - if recent.zeroKnownDays.contains(day) { - zeroKnownDays.insert(day) - } + struct ToolAccum { + let input: ProviderInput + let identity: SpendToolIdentity + var tokens: Int? + var cost = 0.0 + var models: [String: (name: String, provider: UsageProvider, tokens: Int?, cost: Double?)] = [:] } - return SpendTokenActivityInputSummary( - coveredDays: annual.coveredDays.union(recent.coveredDays), - totalsByDay: totalsByDay, - invalidDays: invalidDays, - zeroKnownDays: zeroKnownDays) - } - private static func tokenActivitySnapshotSummary( - input: ProviderInput, - bounds: ClosedRange, - calendar: Calendar) -> SpendTokenActivityInputSummary - { - let coveredInterval = Self.coverageInterval( - input: input, - bounds: bounds, - displayCalendar: calendar) - let coveredDays = Self.days(in: coveredInterval, calendar: calendar) - let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: calendar) - var totalsByDay: [Date: Int] = [:] - var invalidDays: Set = [] - var hasUnplacedTokens = false - for entry in input.snapshot.daily { - guard let day = Self.day(entry.date, provider: input.provider, displayCalendar: calendar) else { - hasUnplacedTokens = hasUnplacedTokens || !Self.hasProvenZeroTokens(entry) - continue - } - guard sourceCoverage.contains(day) else { continue } - guard let tokens = Self.nonnegative(entry.totalTokens) else { - invalidDays.insert(day) - continue - } - guard !invalidDays.contains(day) else { continue } - let addition = (totalsByDay[day] ?? 0).addingReportingOverflow(tokens) - if addition.overflow { - totalsByDay.removeValue(forKey: day) - invalidDays.insert(day) - } else { - totalsByDay[day] = addition.partialValue + var toolsByKey: [Key: ToolAccum] = [:] + for summary in summaries where !summary.hasInvalidCostHistory { + let input = summary.input + let identity = SpendToolIdentity.resolve( + provider: input.provider, + sourceName: input.displayName, + providerName: input.modelProviderName) + for windowEntry in summary.entries { + guard let entryCost = Self.validCost(windowEntry.entry.costUSD) else { continue } + let key = Key(day: windowEntry.day, sourceID: input.id) + var tool = toolsByKey[key] ?? ToolAccum( + input: input, + identity: identity, + tokens: 0) + tool.cost += entryCost + tool.tokens = Self.addAvailable(windowEntry.entry.totalTokens, to: tool.tokens) + for breakdown in windowEntry.entry.modelBreakdowns ?? [] { + let modelIdentity = SpendModelIdentity(rawName: breakdown.modelName, provider: input.provider) + let modelProvider = SpendProviderIdentity.modelProvider( + rawName: breakdown.modelName, + fallback: input.provider) + let existing = tool.models[modelIdentity.id] + tool.models[modelIdentity.id] = ( + modelIdentity.displayName, + modelProvider, + Self.addAvailable(breakdown.totalTokens, to: existing?.tokens), + Self.addAvailableCost(breakdown.costUSD, to: existing?.cost)) + } + toolsByKey[key] = tool } } - // A successful scan that found no sessions is confirmed zero activity: every covered day - // renders as zero instead of unavailable. Nonempty histories must still reconcile their - // aggregate against the daily entries. - let confirmedZeroHistory = input.snapshot.daily.isEmpty - && input.snapshot.last30DaysTokens == nil - let hasCompleteHistory = confirmedZeroHistory - || Self.hasCompleteTokenHistory(input, displayCalendar: calendar) - let aggregateIsInconsistent = input.snapshot.last30DaysTokens != nil && !hasCompleteHistory - if hasUnplacedTokens || aggregateIsInconsistent { - invalidDays.formUnion(coveredDays) - } - return SpendTokenActivityInputSummary( - coveredDays: coveredDays, - totalsByDay: totalsByDay, - invalidDays: invalidDays, - zeroKnownDays: hasCompleteHistory ? coveredDays : []) - } - - private static func days(in interval: ClosedRange?, calendar: Calendar) -> Set { - guard let interval else { return [] } - var result: Set = [] - var day = interval.lowerBound - while day <= interval.upperBound { - result.insert(day) - guard let next = calendar.date(byAdding: .day, value: 1, to: day), next > day else { break } - day = next - } - return result - } - - private static func bounds(days: Int, now: Date, calendar: Calendar) -> ClosedRange { - let end = calendar.startOfDay(for: now) - let start = calendar.date(byAdding: .day, value: -(days - 1), to: end) ?? end - return start...end - } - - private static func gregorianCalendar(timeZone: TimeZone) -> Calendar { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = timeZone - return calendar - } - - private static func chartDomain(bounds: ClosedRange, calendar: Calendar) -> ClosedRange { - let end = calendar.date(byAdding: .day, value: 1, to: bounds.upperBound) ?? bounds.upperBound - return bounds.lowerBound...end - } - - private static func coverageInterval( - input: ProviderInput, - bounds: ClosedRange, - displayCalendar: Calendar) -> ClosedRange? - { - guard input.snapshot.historyCoverageIsEstablished else { return nil } - let sourceCoverage = Self.sourceCoverageInterval(input: input, displayCalendar: displayCalendar) - let overlapStart = max(bounds.lowerBound, sourceCoverage.lowerBound) - let overlapEnd = min(bounds.upperBound, sourceCoverage.upperBound) - guard overlapStart <= overlapEnd else { return nil } - return overlapStart...overlapEnd - } - - private static func sourceCoverageInterval( - input: ProviderInput, - displayCalendar: Calendar) -> ClosedRange - { - let bucketCalendar = Self.bucketCalendar(for: input.provider, displayCalendar: displayCalendar) - let bucketEnd = bucketCalendar.startOfDay(for: input.snapshot.updatedAt) - let scanEnd = displayCalendar.startOfDay(for: bucketEnd) - let scanDays = max(1, input.snapshot.historyDays) - let bucketStart = bucketCalendar.date(byAdding: .day, value: -(scanDays - 1), to: bucketEnd) ?? bucketEnd - let scanStart = displayCalendar.startOfDay(for: bucketStart) - return scanStart...scanEnd - } - - private static func commonCoverageDayCount(summaries: [InputSummary], calendar: Calendar) -> Int { - guard let first = summaries.first?.coveredInterval else { return 0 } - var intersection = first - for summary in summaries.dropFirst() { - guard let interval = summary.coveredInterval else { return 0 } - let start = max(intersection.lowerBound, interval.lowerBound) - let end = min(intersection.upperBound, interval.upperBound) - guard start <= end else { return 0 } - intersection = start...end - } - return Self.dayCount(in: intersection, calendar: calendar) - } - - private static func dayCount(in interval: ClosedRange?, calendar: Calendar) -> Int { - guard let interval else { return 0 } - return (calendar.dateComponents([.day], from: interval.lowerBound, to: interval.upperBound).day ?? 0) + 1 - } - - private static func day( - _ rawValue: String, - provider: UsageProvider, - displayCalendar: Calendar) -> Date? - { - let bytes = Array(rawValue.utf8) - let digitIndices = [0, 1, 2, 3, 5, 6, 8, 9] - guard bytes.count == 10, - bytes[4] == 45, - bytes[7] == 45, - digitIndices.allSatisfy({ (48...57).contains(bytes[$0]) }) - else { return nil } - let parts = rawValue.split(separator: "-") - let bucketCalendar = Self.bucketCalendar(for: provider, displayCalendar: displayCalendar) - guard parts.count == 3, - let year = Int(parts[0]), - let month = Int(parts[1]), - let day = Int(parts[2]), - let date = bucketCalendar.date(from: DateComponents(year: year, month: month, day: day)) - else { return nil } - guard bucketCalendar.dateComponents([.year, .month, .day], from: date) == DateComponents( - year: year, - month: month, - day: day) - else { return nil } - return displayCalendar.startOfDay(for: date) - } - - private static func bucketCalendar(for provider: UsageProvider, displayCalendar: Calendar) -> Calendar { - guard provider == .mistral else { return displayCalendar } - // Mistral labels both daily buckets and snapshot coverage by UTC day. Map each UTC boundary into the - // containing local dashboard day instead of reinterpreting the label as a local date. - return self.gregorianCalendar(timeZone: TimeZone(secondsFromGMT: 0) ?? .gmt) - } - - private static func currencyCode(_ rawValue: String) -> String? { - let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - return value.isEmpty || value == "XXX" ? nil : value - } - - private static func validCost(_ value: Double?) -> Double? { - guard let value, value.isFinite, value >= 0 else { return nil } - return value - } - - private static func nonnegative(_ value: Int?) -> Int? { - guard let value, value >= 0 else { return nil } - return value - } - - private static func safeCostSum(_ 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 - } - - private static func completeCostSum(_ values: [Double?]) -> Double? { - guard values.allSatisfy({ $0 != nil }) else { return nil } - return self.safeCostSum(values.compactMap(\.self)) - } - - private static func safeIntSum(_ values: [Int]) -> Int? { - guard !values.isEmpty else { return nil } - 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 completeIntSum(_ values: [Int?]) -> Int? { - guard values.allSatisfy({ $0 != nil }) else { return nil } - return self.safeIntSum(values.compactMap(\.self)) - } - - private static func add(_ value: Int, to current: Int?, overflowed: inout Bool) -> Int? { - guard !overflowed, let current else { return nil } - let addition = current.addingReportingOverflow(value) - if addition.overflow { - overflowed = true - return nil - } - return addition.partialValue - } - - private static func add(_ value: Double, to current: Double?, overflowed: inout Bool) -> Double? { - guard !overflowed, let current else { return nil } - let result = current + value - guard result.isFinite else { - overflowed = true - return nil - } - return result - } -} - -private struct SpendTokenActivityInputSummary { - let coveredDays: Set - let totalsByDay: [Date: Int] - let invalidDays: Set - let zeroKnownDays: Set - - func tokens(on day: Date) -> Int? { - guard self.coveredDays.contains(day), !self.invalidDays.contains(day) else { return nil } - if let tokens = self.totalsByDay[day] { - return tokens + let byDay = Dictionary(grouping: toolsByKey, by: \.key.day) + return byDay.keys.sorted().map { day in + let tools = byDay[day, default: []].map { key, value in + DailySpendTool( + sourceID: key.sourceID, + provider: value.input.provider, + displayName: value.identity.displayName, + kind: value.identity.kind, + tokens: value.tokens, + cost: value.cost, + models: value.models.map { id, model in + DailySpendModel( + id: id, + displayName: model.name, + modelProvider: model.provider, + tokens: model.tokens, + cost: model.cost) + } + .sorted { ($0.cost ?? 0) > ($1.cost ?? 0) }) + } + .sorted { $0.cost > $1.cost } + return DailySpendDetail( + day: day, + totalTokens: Self.availableIntSum(tools.map(\.tokens)), + totalCost: tools.reduce(0) { $0 + $1.cost }, + tools: tools) } - return self.zeroKnownDays.contains(day) ? 0 : nil } } diff --git a/Sources/CodexBar/SpendModelIdentity.swift b/Sources/CodexBar/SpendModelIdentity.swift new file mode 100644 index 0000000000..f5d04171eb --- /dev/null +++ b/Sources/CodexBar/SpendModelIdentity.swift @@ -0,0 +1,307 @@ +import CodexBarCore +import Foundation + +// MARK: - SpendModelIdentity + +/// Normalized cross-provider identity for one reported model name. +/// +/// The spend dashboard's "Models" card merges usage across providers, so the same model must land on a +/// single row no matter how each source spells it: Claude Code reports `claude-sonnet-4-5`, Vertex reports +/// `anthropic/claude-sonnet-4-5-20250929`, and OpenAI snapshots report `gpt-5-2025-08-07` next to `gpt-5`. +/// +/// Normalization is deliberately conservative — it only removes decoration that provably does not +/// identify a different model: +/// - surrounding whitespace and repeated internal whitespace; +/// - CLIProxyAPI-style `(high)` reasoning-tier annotations (recognized tier names only); +/// - vendor routing path prefixes (`anthropic/…`, `openai/…`, `openrouter/…`, or the provider's own name); +/// - trailing snapshot dates (`-YYYYMMDD` and `-YYYY-MM-DD`, valid calendar dates only); +/// - Claude version punctuation and order (`claude-3.5-sonnet` ≡ `claude-3-5-sonnet` ≡ `claude-sonnet-3-5`). +/// +/// Semantic suffixes that denote a genuinely different model (`-codex`, `-thinking`, `-mini`, `-latest`, …) +/// are never stripped: when unsure, spellings keep distinct identities and stay on separate rows. +struct SpendModelIdentity: Equatable, Sendable { + /// Lowercase merge key shared by every spelling of the same model. + let id: String + /// Human-facing label: a curated alias when one exists, else the stripped name in its original casing. + let displayName: String + + init(rawName: String, provider: UsageProvider? = nil) { + let collapsed = Self.collapsingWhitespace(rawName) + let withoutTier = Self.strippingReasoningTierSuffix(collapsed) + let withoutPrefix = Self.strippingVendorPathPrefixes(withoutTier, providerHint: provider?.rawValue) + let pretty = Self.strippingTrailingDateStamp(withoutPrefix) + let canonical = Self.canonicalID(for: pretty) + if let alias = Self.displayAliases[canonical] { + self.id = alias.lowercased() + self.displayName = alias + } else { + self.id = canonical + self.displayName = Self.brandStyledDisplayName(pretty, canonicalID: canonical) + } + } + + // MARK: - Normalization steps + + /// Trims the name and collapses every internal whitespace run to a single space. + private static func collapsingWhitespace(_ name: String) -> String { + name.components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + /// Strips a trailing `(tier)` reasoning-effort annotation added by routing proxies. Only recognized + /// tier names are stripped, and a space before the parenthesis refuses the strip (the annotation is + /// then treated as part of the name), so unknown parenthesized suffixes keep a distinct identity. + private static func strippingReasoningTierSuffix(_ name: String) -> String { + guard name.hasSuffix(")"), + let openingParen = name.lastIndex(of: "(") + else { return name } + let base = name[.. String { + var segments = name.split(separator: "/", omittingEmptySubsequences: true) + guard !segments.isEmpty else { return name } + let hint = providerHint?.lowercased() + while segments.count > 1 { + let head = segments[0].lowercased() + guard self.vendorPathPrefixes.contains(head) || head == hint else { break } + segments.removeFirst() + } + return segments.joined(separator: "/") + } + + /// Strips one trailing snapshot date (`-YYYYMMDD` or `-YYYY-MM-DD`). The digits must form a + /// plausible calendar date (year 1900–2099, month 1–12, day 1–31), so version-ish suffixes such as + /// `deepseek-v3-0324` or `gpt-5-20251345` are never eaten. A date with no model name before it is + /// kept as-is. + private static func strippingTrailingDateStamp(_ name: String) -> String { + if name.count > 11 { + let suffix = name.suffix(11) + let parts = suffix.split(separator: "-", omittingEmptySubsequences: false) + if parts.count == 4, parts[0].isEmpty, + let year = Self.paddedNumber(parts[1], digits: 4), + let month = Self.paddedNumber(parts[2], digits: 2), + let day = Self.paddedNumber(parts[3], digits: 2), + self.looksLikeSnapshotDate(year: year, month: month, day: day) + { + return String(name.dropLast(11)) + } + } + if name.count > 9 { + let suffix = name.suffix(9) + if suffix.hasPrefix("-") { + let digits = suffix.dropFirst() + if let year = Self.paddedNumber(digits.prefix(4), digits: 4), + let month = Self.paddedNumber(digits.dropFirst(4).prefix(2), digits: 2), + let day = Self.paddedNumber(digits.suffix(2), digits: 2), + self.looksLikeSnapshotDate(year: year, month: month, day: day) + { + return String(name.dropLast(9)) + } + } + } + return name + } + + /// Lowercase merge key: Claude version dots (`claude-3.5` → `claude-3-5`) and the legacy + /// `claude---` segment order are folded so API-style and Claude Code-style + /// spellings of the same model merge. + private static func canonicalID(for name: String) -> String { + let lowercased = name.lowercased() + let dotsNormalized = Self.normalizingClaudeVersionDots(lowercased) + return Self.normalizingClaudeVersionOrder(dotsNormalized) + } + + /// Rewrites `.` to `-` between digits, but only for Claude names: `claude-3.5-sonnet` and the + /// `claude-3-5-sonnet` spelling refer to one model, while dots in other families (`k2.5`, …) are + /// part of the name and stay untouched. + private static func normalizingClaudeVersionDots(_ name: String) -> String { + guard name.contains("claude"), name.contains(".") else { return name } + let characters = Array(name) + var result = String() + result.reserveCapacity(characters.count) + for index in characters.indices { + let character = characters[index] + if character == ".", + index > characters.startIndex, + index < characters.index(before: characters.endIndex), + characters[index - 1].isASCII, characters[index - 1].isNumber, + characters[index + 1].isASCII, characters[index + 1].isNumber + { + result.append("-") + } else { + result.append(character) + } + } + return result + } + + /// Rewrites the legacy `claude---` order (Anthropic API, Bedrock, and Vertex + /// spelling) to the `claude---` order Claude Code reports. The match is exact + /// — two numeric segments plus a known family and nothing else — so unrelated names never reorder. + private static func normalizingClaudeVersionOrder(_ name: String) -> String { + guard name.hasPrefix("claude-") else { return name } + let parts = name.dropFirst("claude-".count).split(separator: "-", omittingEmptySubsequences: false) + guard parts.count == 3, + Self.isASCIINumber(parts[0]), + Self.isASCIINumber(parts[1]), + self.claudeVersionFamilies.contains(String(parts[2])) + else { return name } + return "claude-\(parts[2])-\(parts[0])-\(parts[1])" + } + + // MARK: - Lookup tables and parsing helpers + + /// Reasoning-effort tier names a routing proxy may append as `(tier)`. + private static let reasoningTierSuffixes: Set = [ + "minimal", "low", "medium", "high", "xhigh", "auto", "none", + ] + + /// Vendor and gateway tokens that only route to a model when they appear as a leading path segment. + private static let vendorPathPrefixes: Set = [ + "anthropic", "openai", "google", "gemini", "moonshot", "moonshotai", "kimi", "kimi-code", + "deepseek", "xai", "x-ai", "zai", "z-ai", "meta", "meta-llama", "mistral", "mistralai", + "azure", "azureopenai", "bedrock", "vertex", "vertexai", "vertex_ai", "openrouter", + "qwen", "cohere", "perplexity", "minimax", + ] + + /// Claude family names allowed in the legacy `claude---` order. + private static let claudeVersionFamilies: Set = ["opus", "sonnet", "haiku"] + + /// Curated pretty names for Kimi models, keyed by canonical id. Kept in sync with the historical + /// `kimi-code/` prefix behavior: the prefix strips via `vendorPathPrefixes`, then these aliases + /// provide the display casing. + private static let displayAliases: [String: String] = [ + "k3": "Kimi K3", + "kimi-k3": "Kimi K3", + "k3-256k": "Kimi K3 (256K)", + "kimi-k3-256k": "Kimi K3 (256K)", + "k2.5": "Kimi K2.5", + "kimi-k2.5": "Kimi K2.5", + "k2": "Kimi K2", + "kimi-k2": "Kimi K2", + "kimi-for-coding": "Kimi for Coding", + "kimi-for-coding-highspeed": "Kimi for Coding High-Speed", + // Antigravity's internal Gemini/Claude codenames → the public model names they route to. + "gemini-pro-default": "Gemini 3.1 Pro", + "gemini-pro-agent": "Gemini 3.1 Pro", + "gemini-3.1-pro": "Gemini 3.1 Pro", + "gemini-3.1-pro-high": "Gemini 3.1 Pro", + "gemini-3.1-pro-low": "Gemini 3.1 Pro", + "gemini-3-pro": "Gemini 3 Pro", + "gemini-3-pro-high": "Gemini 3 Pro", + "gemini-3-pro-low": "Gemini 3 Pro", + "gemini-3-flash": "Gemini 3 Flash", + "gemini-3-flash-c": "Gemini 3 Flash", + "gemini-default": "Gemini 3 Flash", + "gemini-3-flash-a": "Gemini 3.5 Flash (High)", + "gemini-3-flash-agent": "Gemini 3.5 Flash (High)", + "gemini-3-flash-b": "Gemini 3.5 Flash (High)", + "gemini-3.5-flash-high": "Gemini 3.5 Flash (High)", + "gemini-3.5-flash-low": "Gemini 3.5 Flash (Medium)", + "gemini-3.5-flash-medium": "Gemini 3.5 Flash (Medium)", + "gemini-3.5-flash-extra-low": "Gemini 3.5 Flash (Low)", + "gemini-3.6-flash": "Gemini 3.6 Flash", + "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", + ] + + /// Applies the public brand's casing and version style while preserving the canonical model + /// identity. This is intentionally structural instead of a list of current model releases, so + /// newly scanned models inherit a recognizable label without requiring a dashboard update. + private static func brandStyledDisplayName(_ original: String, canonicalID: String) -> String { + let parts = canonicalID.split(separator: "-", omittingEmptySubsequences: false).map(String.init) + guard let family = parts.first, parts.count > 1 else { return original } + let tail = Array(parts.dropFirst()) + + switch family { + case "gpt": + return self.gptDisplayName(tail) + case "codex": + return "Codex \(tail.map(self.modelTokenDisplayName).joined(separator: " "))" + case "claude": + return self.claudeDisplayName(tail) + case "gemini": + return "Gemini \(tail.map(self.modelTokenDisplayName).joined(separator: " "))" + case "minimax": + return "MiniMax \(tail.map(self.modelTokenDisplayName).joined(separator: " "))" + case "deepseek": + return "DeepSeek-\(tail.map(self.modelTokenDisplayName).joined(separator: "-"))" + case "kimi": + return "Kimi \(tail.map(self.modelTokenDisplayName).joined(separator: " "))" + default: + return original + } + } + + private static func gptDisplayName(_ parts: [String]) -> String { + guard let version = parts.first else { return "GPT" } + var result = "GPT-\(version)" + for token in parts.dropFirst() { + if token == "codex" { + result += "-Codex" + } else { + result += " \(self.modelTokenDisplayName(token))" + } + } + return result + } + + private static func claudeDisplayName(_ parts: [String]) -> String { + var result = ["Claude"] + var index = 0 + while index < parts.count { + if index + 1 < parts.count, + self.isASCIINumber(parts[index]), + self.isASCIINumber(parts[index + 1]) + { + result.append("\(parts[index]).\(parts[index + 1])") + index += 2 + } else { + result.append(self.modelTokenDisplayName(parts[index])) + index += 1 + } + } + return result.joined(separator: " ") + } + + private static func modelTokenDisplayName(_ token: String) -> String { + guard !token.isEmpty else { return token } + if token == "mini" { return token } + if let first = token.first, + ["k", "m", "r", "v"].contains(String(first)), + token.dropFirst().first?.isNumber == true + { + return token.uppercased() + } + return token.prefix(1).uppercased() + token.dropFirst() + } + + /// Parses an ASCII digit run of exactly `digits` characters, rejecting anything else. + private static func paddedNumber(_ text: some StringProtocol, digits: Int) -> Int? { + guard text.count == digits, text.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } + return Int(text) + } + + private static func isASCIINumber(_ text: some StringProtocol) -> Bool { + !text.isEmpty && text.allSatisfy { $0.isASCII && $0.isNumber } + } + + /// Plausibility check for snapshot dates: real model stamps use calendar-ish values, so loose + /// bounds reject version fragments (`-0324`) and digit runs (`-12345678`) that are not dates. + private static func looksLikeSnapshotDate(year: Int, month: Int, day: Int) -> Bool { + (1900...2099).contains(year) && (1...12).contains(month) && (1...31).contains(day) + } +} diff --git a/Sources/CodexBar/SpendProviderIdentity.swift b/Sources/CodexBar/SpendProviderIdentity.swift new file mode 100644 index 0000000000..ec3e9462f7 --- /dev/null +++ b/Sources/CodexBar/SpendProviderIdentity.swift @@ -0,0 +1,93 @@ +import CodexBarCore +import Foundation + +/// Canonical vendor identity used by every model-centric spend surface. +/// +/// Tools are deliberately not vendors: Cursor can run Kimi, Antigravity can run Claude, and +/// Claude Code can be a harness for MiniMax. This resolver keeps that distinction in one place so +/// model icons, colors, labels, charts, and future exports cannot drift into separate heuristics. +enum SpendProviderIdentity { + static func modelProvider( + rawNames: some Sequence, + fallbackProviders: some Sequence) -> UsageProvider + { + for name in rawNames { + if let provider = self.explicitModelProvider(name) { + return provider + } + } + for provider in fallbackProviders { + return provider + } + return .openai + } + + static func modelProvider(rawName: String, fallback: UsageProvider) -> UsageProvider { + self.explicitModelProvider(rawName) ?? fallback + } + + static func displayName(for provider: UsageProvider) -> String { + ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName + } + + /// Explicit model-family detection. Generic provider ids/display names cover new providers + /// automatically; aliases handle families whose public model ids differ from product ids. + private static func explicitModelProvider(_ rawName: String) -> UsageProvider? { + let normalized = rawName + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !normalized.isEmpty, normalized != "" else { return nil } + + let components = normalized + .split(whereSeparator: { $0 == "/" || $0 == ":" || $0 == "." || $0 == "-" || $0 == " " }) + .map(String.init) + let first = components.first ?? normalized + + let aliases: [(matches: Bool, provider: UsageProvider)] = [ + (normalized.contains("claude") || first == "anthropic", .claude), + (normalized.contains("gemini") || first == "google", .gemini), + (normalized.contains("minimax"), .minimax), + (normalized.contains("deepseek"), .deepseek), + ( + normalized.contains("kimi") || normalized.contains("moonshot") || + normalized == "k3" || normalized.hasPrefix("k3-"), + .kimi), + ( + normalized.contains("gpt") || normalized.hasPrefix("chatgpt") || + normalized.hasPrefix("codex-") || Self.isOpenAIReasoningFamily(normalized), + .openai), + (normalized.contains("grok") || first == "xai", .grok), + ( + normalized.contains("mistral") || normalized.contains("mixtral") || + normalized.contains("codestral") || normalized.contains("devstral"), + .mistral), + (normalized.contains("qwen"), .alibaba), + (normalized.contains("glm") || first == "zai", .zai), + (normalized.contains("doubao") || normalized.hasPrefix("seed-"), .doubao), + (normalized.contains("stepfun") || normalized.hasPrefix("step-"), .stepfun), + (normalized.contains("mimo"), .mimo), + (normalized.contains("longcat"), .longcat), + (normalized.contains("nova-") || normalized.hasPrefix("amazon.nova"), .bedrock), + (normalized.contains("perplexity") || normalized.hasPrefix("sonar-"), .perplexity), + ] + if let explicit = aliases.first(where: \.matches) { + return explicit.provider + } + + // The provider registry is the extensibility path: a newly added provider whose model id + // begins with either its canonical id or public display name needs no dashboard change. + return UsageProvider.allCases.first { provider in + let id = provider.rawValue.lowercased() + let display = self.displayName(for: provider).lowercased() + return first == id || normalized.hasPrefix("\(id)/") || + normalized.hasPrefix("\(id)-") || normalized.hasPrefix("\(display)/") || + normalized.hasPrefix("\(display)-") + } + } + + private static func isOpenAIReasoningFamily(_ name: String) -> Bool { + ["o1", "o3", "o4"].contains { family in + name == family || name.hasPrefix("\(family)-") + } + } +} diff --git a/Sources/CodexBar/SpendSubscriptionPlan.swift b/Sources/CodexBar/SpendSubscriptionPlan.swift new file mode 100644 index 0000000000..e9167dd0f6 --- /dev/null +++ b/Sources/CodexBar/SpendSubscriptionPlan.swift @@ -0,0 +1,51 @@ +import CodexBarCore +import Foundation + +/// Reader-facing subscription label for the private local dashboard. +/// +/// Share cards intentionally use `ShareStatsSubscriptionName`'s closed allow-list. The local +/// dashboard can be more capable: it first uses that curated catalog, then safely humanizes a new +/// provider plan returned in `loginMethod`. This makes future provider tiers appear automatically +/// without confusing authentication methods or account identifiers for plan names. +struct SpendSubscriptionPlan: Equatable, Sendable { + let displayName: String + + static func from(snapshot: UsageSnapshot?, provider: UsageProvider) -> Self? { + if let curated = ShareStatsSubscriptionName.from(snapshot: snapshot, provider: provider) { + return Self(displayName: curated.displayName) + } + guard ShareStatsSubscriptionName.hasPlanIdentityContract(for: provider), + let identity = snapshot?.identity(for: provider), + let raw = identity.loginMethod?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + !self.matchesAccountIdentity(raw, identity: identity), + !self.looksLikeAuthenticationMethod(raw) + else { return nil } + + let cleaned = UsageFormatter.cleanPlanName(raw) + return Self(displayName: cleaned.isEmpty ? raw : cleaned) + } + + private static func matchesAccountIdentity( + _ value: String, + identity: ProviderIdentitySnapshot) -> Bool + { + [identity.accountEmail, identity.accountOrganization] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .contains { $0.localizedCaseInsensitiveCompare(value) == .orderedSame } + } + + private static func looksLikeAuthenticationMethod(_ value: String) -> Bool { + let normalized = value.lowercased() + if normalized.contains("@") || normalized.contains("://") { return true } + if [ + "api spend", "balance", "credits", "remaining", "usage", "spent", "this month", + ].contains(where: normalized.contains) { + return true + } + return [ + "api key", "apikey", "oauth", "browser cookie", "cookie", "access token", + "bearer token", "service account", "admin api", + ].contains(normalized) + } +} diff --git a/Sources/CodexBar/SpendToolIdentity.swift b/Sources/CodexBar/SpendToolIdentity.swift new file mode 100644 index 0000000000..71a1dcba4b --- /dev/null +++ b/Sources/CodexBar/SpendToolIdentity.swift @@ -0,0 +1,81 @@ +import CodexBarCore +import Foundation + +/// Stable presentation identity for the local product that emitted usage history. +/// +/// Provider identity answers "who made the model"; tool identity answers "which app or +/// harness used it". Keeping the two separate prevents a Kimi model used in Cursor from being +/// presented as though Kimi Code produced the history. +struct SpendToolIdentity: Equatable, Sendable { + enum Kind: String, CaseIterable, Sendable { + case desktop + case cli + case ide + case extensionTool + case api + case other + + var displayName: String { + switch self { + case .desktop: L("Desktop") + case .cli: L("CLI") + case .ide: L("IDE") + case .extensionTool: L("Extension") + case .api: L("API") + case .other: L("Tool") + } + } + } + + let displayName: String + let kind: Kind + + static func resolve( + provider: UsageProvider, + sourceName: String, + providerName: String) -> Self + { + let source = sourceName.trimmingCharacters(in: .whitespacesAndNewlines) + let family = providerName.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = source.lowercased() + + if normalized.contains(" cli") || normalized.hasSuffix("cli") { + return Self(displayName: source, kind: .cli) + } + if normalized.contains("desktop") { + return Self(displayName: source, kind: .desktop) + } + if normalized.contains("extension") || normalized.contains("copilot") { + return Self(displayName: source, kind: .extensionTool) + } + if normalized.contains(" api") || normalized.hasSuffix("api") { + return Self(displayName: source, kind: .api) + } + + let sourceIsFamily = source.localizedCaseInsensitiveCompare(family) == .orderedSame + switch provider { + case .codex: + return Self(displayName: sourceIsFamily ? "Codex Desktop" : source, kind: .desktop) + case .claude: + return Self(displayName: sourceIsFamily ? "Claude Code" : source, kind: .cli) + case .cursor: + return Self(displayName: sourceIsFamily ? "Cursor" : source, kind: .ide) + case .antigravity: + return Self(displayName: sourceIsFamily ? "Antigravity" : source, kind: .ide) + case .kimi: + return Self(displayName: sourceIsFamily ? "Kimi Code CLI" : source, kind: .cli) + case .gemini: + return Self(displayName: sourceIsFamily ? "Gemini CLI" : source, kind: .cli) + case .minimax: + return Self(displayName: sourceIsFamily ? "MiniMax Code" : source, kind: .desktop) + case .opencode, .opencodego: + return Self(displayName: sourceIsFamily ? "OpenCode CLI" : source, kind: .cli) + case .zed, .qoder: + return Self(displayName: sourceIsFamily ? family : source, kind: .ide) + case .copilot: + return Self(displayName: sourceIsFamily ? "GitHub Copilot" : source, kind: .extensionTool) + default: + return Self(displayName: source.isEmpty ? family : source, kind: .other) + } + } +} diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index a55a14e72c..b6fee83fa1 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -403,6 +403,8 @@ extension UsageStore { snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() case .mistral: snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + case .groq: + snapshot?.groqConsoleUsage?.toCostUsageTokenSnapshot() case .opencodego: // Web-only source mode and machines with no readable local database leave // `opencodegoUsage.daily` empty; a non-nil-but-dataless projection would still @@ -418,7 +420,7 @@ extension UsageStore { nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { switch provider { - case .mistral, .openai, .opencodego: + case .groq, .mistral, .openai, .opencodego: true default: false 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/Tests/CodexBarTests/AppDelegateTests.swift b/Tests/CodexBarTests/AppDelegateTests.swift index ffc7656a5c..a0f4c0ec79 100644 --- a/Tests/CodexBarTests/AppDelegateTests.swift +++ b/Tests/CodexBarTests/AppDelegateTests.swift @@ -44,7 +44,8 @@ struct AppDelegateTests { account: account, selection: PreferencesSelection(), managedCodexAccountCoordinator: managedCodexAccountCoordinator, - codexAccountPromotionCoordinator: promotionCoordinator)) + codexAccountPromotionCoordinator: promotionCoordinator, + spendDashboardController: nil)) #expect(factoryCalls == 0) // construction happens once after launch diff --git a/Tests/CodexBarTests/GroqConsoleFetcherTests.swift b/Tests/CodexBarTests/GroqConsoleFetcherTests.swift index ca2c616048..76e08a1453 100644 --- a/Tests/CodexBarTests/GroqConsoleFetcherTests.swift +++ b/Tests/CodexBarTests/GroqConsoleFetcherTests.swift @@ -79,6 +79,7 @@ struct GroqConsoleFetcherTests { let projected = snapshot.toCostUsageTokenSnapshot() #expect(projected.last30DaysRequests == 6) #expect(abs((projected.last30DaysCostUSD ?? 0) - 0.035) < 1e-9) + #expect(projected.costSource == .providerReported) } @Test diff --git a/Tests/CodexBarTests/GroqMenuCardModelTests.swift b/Tests/CodexBarTests/GroqMenuCardModelTests.swift index 8a903934ed..55a8625b13 100644 --- a/Tests/CodexBarTests/GroqMenuCardModelTests.swift +++ b/Tests/CodexBarTests/GroqMenuCardModelTests.swift @@ -5,7 +5,7 @@ import Testing extension StatusMenuTests { @Test - func `groq cost data stays reachable via inline dashboard regardless of cost row gating`() throws { + func `groq cost data is available in both generic and inline dashboards`() throws { StatusItemController.menuCardRenderingEnabled = true StatusItemController.setMenuRefreshEnabledForTesting(false) defer { self.disableMenuCardsForTesting() } @@ -51,15 +51,11 @@ extension StatusMenuTests { statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } - // Groq's descriptor sets `tokenCost.supportsTokenCost = false`, so the generic Cost - // row/submenu is unreachable regardless of display style or `tokenCostMenuSectionEnabled` - // (that guard runs first in `tokenUsageSection`) — Groq relies solely on the inline - // dashboard for its cost data, same as openai/mistral. This locks in that Groq's absence - // from the "Cost" row is unaffected by which provider set gates that row, so a future - // predicate change there can't silently break Groq the way it silently broke when this - // row's gate briefly reused `usesProviderCostHistoryAsPrimaryDashboard`. + // Groq exposes structured token and cost history, so it participates in the descriptor- + // driven generic Cost section while retaining its richer provider-specific dashboard. let model = try #require(controller.menuCardModel(for: .groq)) - #expect(model.tokenUsage == nil) + #expect(model.tokenUsage?.sessionLine == "Today: $0.00 · 0 tokens") + #expect(model.tokenUsage?.monthLine == "Last 30 days: $1.50 · 150 tokens") #expect(model.inlineUsageDashboard != nil) } } diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 064e4c33a9..3f65f75d01 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -13,23 +13,22 @@ struct ProviderIconResourcesTests { let slugs = [ "codex", - "claude", "clinepass", - "zai", + "antigravity", + "claude", + "gemini", + "kimi", "minimax", "cursor", "opencode", "opencodego", "alibaba", - "gemini", - "antigravity", "factory", "copilot", "devin", "crof", "commandcode", "t3chat", - "kimi", "longcat", "bedrock", "elevenlabs", @@ -90,6 +89,17 @@ struct ProviderIconResourcesTests { #expect(first.isTemplate) } + @Test + func `official color provider icons preserve template rendering`() throws { + ProviderBrandIcon.resetCacheForTesting() + defer { ProviderBrandIcon.resetCacheForTesting() } + + for provider in [UsageProvider.antigravity, .claude, .gemini, .kimi, .minimax] { + let image = try #require(ProviderBrandIcon.image(for: provider)) + #expect(image.isTemplate, "\(provider.rawValue) must use template rendering for global UI") + } + } + @Test func `ollama provider icon uses template rendering`() throws { ProviderBrandIcon.resetCacheForTesting() diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index adb0a24651..a41f0a3ad2 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -79,9 +79,14 @@ struct ShareStatsTests { @Test func `subscription labels require a plan tier provider contract`() { #expect(Self.subscriptionName(provider: .codex, rawName: "pro")?.displayName == "Pro 20x") - #expect(Self.subscriptionName(provider: .codex, rawName: "Plus Plan")?.displayName == "Plus") + #expect(Self.subscriptionName( + provider: .codex, + rawName: "Plus Plan")?.displayName == "ChatGPT Plus") #expect(Self.subscriptionName(provider: .cursor, rawName: "Cursor Pro")?.displayName == "Cursor Pro") #expect(Self.subscriptionName(provider: .gemini, rawName: "Paid")?.displayName == "Paid") + #expect(Self.subscriptionName( + provider: .antigravity, + rawName: "Google AI Pro")?.displayName == "Google AI Pro") #expect(Self.subscriptionName(provider: .copilot, rawName: "Business")?.displayName == "Business") #expect(Self.subscriptionName(provider: .perplexity, rawName: "Max")?.displayName == "Max") #expect(Self.subscriptionName(provider: .windsurf, rawName: "Teams")?.displayName == "Teams") @@ -108,6 +113,19 @@ struct ShareStatsTests { #expect(name?.displayName == "Pro 20x") } + @Test + func `private dashboard accepts future plan tiers but rejects authentication labels`() { + let future = Self.snapshot(provider: .cursor, rawName: "Cursor Super Pro") + let auth = Self.snapshot(provider: .cursor, rawName: "API key") + let spend = Self.snapshot(provider: .mistral, rawName: "API spend: $12.34 this month") + let status = Self.snapshot(provider: .mistral, rawName: "Mistral Enterprise") + + #expect(SpendSubscriptionPlan.from(snapshot: future, provider: .cursor)?.displayName == "Cursor Super Pro") + #expect(SpendSubscriptionPlan.from(snapshot: auth, provider: .cursor) == nil) + #expect(SpendSubscriptionPlan.from(snapshot: spend, provider: .mistral) == nil) + #expect(SpendSubscriptionPlan.from(snapshot: status, provider: .mistral) == nil) + } + @Test func `bedrock regional model identifiers map to public families`() { #expect(ShareStatsSanitizer.modelName("us.amazon.nova-2-lite-v1:0") == "Amazon Nova") @@ -152,6 +170,7 @@ struct ShareStatsTests { coveredDayCount: 7), ], models: rows, + modelAnalysis: .empty, dailyPoints: [], totalTokens: 1, totalCost: nil, @@ -207,6 +226,7 @@ struct ShareStatsTests { totalTokens: nil, totalCost: nil), ], + modelAnalysis: .empty, dailyPoints: [], totalTokens: 10, totalCost: -.infinity, @@ -367,6 +387,7 @@ struct ShareStatsTests { totalTokens: 1000, totalCost: 1), ], + modelAnalysis: .empty, dailyPoints: [], totalTokens: 300, totalCost: 12, @@ -402,6 +423,7 @@ struct ShareStatsTests { totalTokens: 200, totalCost: 4) }, + modelAnalysis: .empty, dailyPoints: [], totalTokens: nil, totalCost: nil, diff --git a/Tests/CodexBarTests/SpendBillingAttributionTests.swift b/Tests/CodexBarTests/SpendBillingAttributionTests.swift new file mode 100644 index 0000000000..b95eeab4cc --- /dev/null +++ b/Tests/CodexBarTests/SpendBillingAttributionTests.swift @@ -0,0 +1,410 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendBillingAttributionTests { + @Test + func `subscription rows follow the vendor that owns the quota`() throws { + let inputs = [ + SpendDashboardModel.ProviderInput( + id: "cursor", + provider: .cursor, + displayName: "Cursor", + snapshot: Self.snapshot([ + Self.entry(model: "default", cost: 50, tokens: 500), + Self.entry(model: "claude-sonnet-4-6", cost: 100, tokens: 1000), + Self.entry(model: "k3", cost: 25, tokens: 250, billingProviderID: "kimi"), + ])), + SpendDashboardModel.ProviderInput( + id: "antigravity", + provider: .antigravity, + displayName: "Antigravity", + snapshot: Self.snapshot([ + Self.entry(model: "gemini-3.1-pro", cost: 80, tokens: 800), + Self.entry(model: "claude-opus-4-6", cost: 20, tokens: 200), + ])), + SpendDashboardModel.ProviderInput( + id: "codex", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot([ + Self.entry(model: "gpt-5.5", cost: 300, tokens: 3000), + Self.entry(model: "MiniMax-M3", cost: 70, tokens: 700, billingProviderID: "minimax"), + ])), + SpendDashboardModel.ProviderInput( + id: "claude", + provider: .claude, + displayName: "Claude Code", + snapshot: Self.snapshot([ + Self.entry(model: "deepseek-v4-pro", cost: 40, tokens: 400, billingProviderID: "deepseek"), + Self.entry(model: "kimi-for-coding", cost: 20, tokens: 200, billingProviderID: "moonshot"), + Self.entry(model: "claude-sonnet-4-6", cost: 5, tokens: 50), + ])), + SpendDashboardModel.ProviderInput( + id: "kimi-native", + provider: .kimi, + displayName: "Kimi Code CLI", + snapshot: Self.snapshot([ + Self.entry(model: "kimi-code/k3", cost: 10, tokens: 100), + ])), + SpendDashboardModel.ProviderInput( + id: "minimax-native", + provider: .minimax, + displayName: "MiniMax Code", + snapshot: Self.snapshot([ + Self.entry(model: "MiniMax-M3", cost: 0.23, tokens: 17), + ])), + ] + + let group = try #require(SpendDashboardModel.build( + inputs: inputs, + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.providers.map(\.provider) == [ + .codex, + .cursor, + .antigravity, + .minimax, + .deepseek, + .kimi, + .moonshot, + .claude, + ]) + #expect(group.providers.map(\.id) == [ + "codex", + "cursor", + "antigravity", + "minimax-native", + "billing:deepseek:claude", + "kimi-native", + "billing:moonshot:claude", + "claude", + ]) + #expect(group.providers.map(\.totalCost) == [300, 150, 100, 70.23, 40, 35, 20, 5]) + #expect(group.providers.map(\.displayName) == [ + "Codex", + "Cursor", + "Antigravity", + "MiniMax", + "DeepSeek", + "Kimi", + "Moonshot / Kimi API", + "Claude", + ]) + #expect(group.providers.first(where: { $0.provider == .kimi })?.totalTokens == 350) + #expect(group.providers.first(where: { $0.provider == .moonshot })?.totalTokens == 200) + #expect(group.providers.first(where: { $0.provider == .minimax })?.totalTokens == 717) + } + + @Test + func `model labels alone never change the billing owner`() { + #expect(SpendBillingAttribution.billingVendor(forModel: "default", defaultProvider: .cursor) == .cursor) + #expect(SpendBillingAttribution.billingVendor( + forModel: "claude-sonnet-4-6", + defaultProvider: .cursor) == .cursor) + #expect(SpendBillingAttribution.billingVendor(forModel: "gpt-5.6", defaultProvider: .cursor) == .cursor) + #expect(SpendBillingAttribution.billingVendor(forModel: "k3", defaultProvider: .cursor) == .cursor) + #expect(SpendBillingAttribution.billingVendor( + forModel: "kimi-for-coding", + defaultProvider: .cursor) == .cursor) + #expect(SpendBillingAttribution.billingVendor( + forModel: "MiniMax-M3", + defaultProvider: .cursor) == .cursor) + #expect(SpendBillingAttribution.billingVendor( + forModel: "deepseek-v4", + defaultProvider: .cursor) == .cursor) + } + + @Test + func `only explicit model namespaces become billing evidence`() { + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "MiniMax-M3") == nil) + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "minimax/MiniMax-M3") == "minimax") + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "qwen/qwen3-coder") == "qwencloud") + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "moonshot/kimi-k2") == "moonshot") + #expect(CostUsageBillingProvider.providerID( + fromNamespacedModel: "gateway/team/moonshot/kimi-k2") == "moonshot") + #expect(CostUsageBillingProvider.providerID( + fromNamespacedModel: "openrouter/anthropic/claude-sonnet-4") == "claude") + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "gateway/team/model") == nil) + #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "/unowned") == nil) + } + + @Test + func `bundled and official tools never leak models to another subscription`() { + #expect(SpendBillingAttribution.billingVendor( + forModel: "claude-opus-4-6", + defaultProvider: .antigravity) == .antigravity) + #expect(SpendBillingAttribution.billingVendor( + forModel: "gemini-3.6-flash", + defaultProvider: .antigravity) == .antigravity) + #expect(SpendBillingAttribution.billingVendor( + forModel: "k3", + defaultProvider: .kimi) == .kimi) + #expect(SpendBillingAttribution.billingVendor( + forModel: "MiniMax-M3", + defaultProvider: .minimax) == .minimax) + } + + @Test + func `live quota snapshot does not erase complete MiniMax local history`() throws { + let liveQuota = CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "USD", + historyDays: 30, + historyCoverageIsEstablished: false, + daily: [], + updatedAt: Self.now) + let localHistory = Self.snapshot([ + Self.entry(model: "minimax/MiniMax-M3", cost: 0.23, tokens: 1_738_342), + ]) + let model = SpendDashboardModel.build( + inputs: [ + SpendDashboardModel.ProviderInput( + id: "minimax", + provider: .minimax, + displayName: "MiniMax", + subscriptionName: "Token Plan Plus", + snapshot: liveQuota), + SpendDashboardModel.ProviderInput( + id: "minimax:local", + provider: .minimax, + displayName: "MiniMax", + snapshot: localHistory), + ], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + + let row = try #require(model.groups.first?.providers.first) + #expect(row.id == "minimax:local") + #expect(row.displayName == "MiniMax") + #expect(row.subscriptionName == "Token Plan Plus") + #expect(row.totalTokens == 1_738_342) + #expect(abs((row.totalCost ?? 0) - 0.23) < 0.000_001) + } + + @Test + func `inactive recent MiniMax window is zero while cumulative keeps historical spend`() throws { + let now = Date(timeIntervalSince1970: 1_785_427_200) // 2026-07-29 00:00:00 UTC + let entry = CostUsageDailyReport.Entry( + date: "2026-07-12", + inputTokens: 1_000_000, + outputTokens: 738_342, + totalTokens: 1_738_342, + costUSD: 0.23, + modelsUsed: ["minimax/MiniMax-M3"], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown( + modelName: "minimax/MiniMax-M3", + costUSD: 0.23, + totalTokens: 1_738_342, + inputTokens: 1_000_000, + outputTokens: 738_342)]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 1_738_342, + last30DaysCostUSD: 0.23, + currencyCode: "USD", + historyDays: 365, + historyCoverageIsEstablished: true, + daily: [entry], + updatedAt: now) + let input = SpendDashboardModel.ProviderInput( + provider: .minimax, + displayName: "MiniMax", + subscriptionName: "Token Plan Plus", + snapshot: snapshot) + + let recent = try #require(SpendDashboardModel.build( + inputs: [input], + requestedDays: 7, + now: now, + calendar: Self.calendar).groups.first?.providers.first) + let cumulative = try #require(SpendDashboardModel.build( + inputs: [input], + requestedDays: 365, + now: now, + calendar: Self.calendar).groups.first?.providers.first) + + #expect(recent.totalTokens == 0) + #expect(recent.totalCost == 0) + #expect(recent.subscriptionName == "Token Plan Plus") + #expect(cumulative.totalTokens == 1_738_342) + #expect(abs((cumulative.totalCost ?? 0) - 0.23) < 0.000_001) + } + + @Test + func `merged MiniMax sources preserve their union of history windows`() throws { + let now = Date(timeIntervalSince1970: 1_785_427_200) // 2026-07-29 00:00:00 UTC + let routedEntry = Self.entry( + date: "2026-06-30", + model: "MiniMax-M3", + cost: 1, + tokens: 1000, + billingProviderID: "minimax") + let localEntry = Self.entry( + date: "2026-07-12", + model: "minimax/MiniMax-M3", + cost: 0.23, + tokens: 1_738_342) + let routed = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: routedEntry.totalTokens, + last30DaysCostUSD: routedEntry.costUSD, + currencyCode: "USD", + historyDays: 30, + historyCoverageIsEstablished: true, + daily: [routedEntry], + updatedAt: Date(timeIntervalSince1970: 1_783_396_800)) // 2026-07-05 UTC + let local = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: localEntry.totalTokens, + last30DaysCostUSD: localEntry.costUSD, + currencyCode: "USD", + historyDays: 365, + historyCoverageIsEstablished: true, + daily: [localEntry], + updatedAt: now) + let inputs = [ + SpendDashboardModel.ProviderInput( + id: "codex", + provider: .codex, + displayName: "Codex", + snapshot: routed), + SpendDashboardModel.ProviderInput( + id: "minimax:local", + provider: .minimax, + displayName: "MiniMax", + snapshot: local), + ] + + let recent = try #require(SpendDashboardModel.build( + inputs: inputs, + requestedDays: 7, + now: now, + calendar: Self.calendar).groups.first?.providers.first { $0.provider == .minimax }) + let cumulative = try #require(SpendDashboardModel.build( + inputs: inputs, + requestedDays: 365, + now: now, + calendar: Self.calendar).groups.first?.providers.first { $0.provider == .minimax }) + + #expect(recent.totalTokens == 0) + #expect(recent.totalCost == 0) + #expect(cumulative.totalTokens == 1_739_342) + #expect(abs((cumulative.totalCost ?? 0) - 1.23) < 0.000_001) + } + + @Test + func `model provider identity follows the model vendor instead of its harness`() { + #expect(SpendProviderIdentity.modelProvider( + rawName: "claude-opus-4-6-thinking", + fallback: .antigravity) == .claude) + #expect(SpendProviderIdentity.modelProvider( + rawName: "gemini-3.6-flash", + fallback: .antigravity) == .gemini) + #expect(SpendProviderIdentity.modelProvider(rawName: "k3", fallback: .cursor) == .kimi) + #expect(SpendProviderIdentity.modelProvider(rawName: "gpt-5.6-terra", fallback: .codex) == .openai) + #expect(SpendProviderIdentity.modelProvider(rawName: "future-model", fallback: .cursor) == .cursor) + #expect(SpendProviderIdentity.modelProvider(rawName: "qwen3-coder", fallback: .cursor) == .alibaba) + #expect(SpendProviderIdentity.modelProvider(rawName: "glm-5", fallback: .cursor) == .zai) + #expect(SpendProviderIdentity.modelProvider(rawName: "amazon.nova-pro", fallback: .cursor) == .bedrock) + #expect(SpendProviderIdentity.modelProvider(rawName: "sonar-pro", fallback: .cursor) == .perplexity) + } + + @Test + func `merged same-day costs withhold when any entry lacks a price`() throws { + // Two fragments from the same billing vendor overlap on one day; one is priced and the + // other is token-only. The merged day must not present the priced subtotal as complete. + let priced = Self.entry( + date: "2026-07-24", + model: "claude-sonnet-4", + cost: 0.5, + tokens: 100, + billingProviderID: UsageProvider.claude.rawValue) + let tokenOnly = CostUsageDailyReport.Entry( + date: "2026-07-24", + inputTokens: 10, + outputTokens: 10, + totalTokens: 20, + costUSD: nil, + modelsUsed: ["claude-sonnet-4"], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown( + modelName: "claude-sonnet-4", + costUSD: nil, + totalTokens: 20, + inputTokens: 10, + outputTokens: 10)]) + + let attributed = SpendBillingAttribution.attribute([ + SpendDashboardModel.ProviderInput( + id: "a", + provider: .claude, + displayName: "Claude", + modelProviderName: "Claude", + snapshot: Self.snapshot([priced])), + SpendDashboardModel.ProviderInput( + id: "b", + provider: .claude, + displayName: "Claude", + modelProviderName: "Claude", + snapshot: Self.snapshot([tokenOnly])), + ]) + + let merged = try #require(attributed.first) + let day = try #require(merged.snapshot.daily.first) + #expect(day.costUSD == nil) + #expect(merged.snapshot.last30DaysCostUSD == nil) + } + + private static func entry( + date: String = "2026-07-24", + model: String, + cost: Double, + tokens: Int, + billingProviderID: String? = nil) -> CostUsageDailyReport.Entry + { + CostUsageDailyReport.Entry( + date: date, + inputTokens: tokens / 2, + outputTokens: tokens - tokens / 2, + totalTokens: tokens, + costUSD: cost, + modelsUsed: [model], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown( + modelName: model, + billingProviderID: billingProviderID, + costUSD: cost, + totalTokens: tokens, + inputTokens: tokens / 2, + outputTokens: tokens - tokens / 2)]) + } + + private static func snapshot(_ entries: [CostUsageDailyReport.Entry]) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: entries.compactMap(\.totalTokens).reduce(0, +), + last30DaysCostUSD: entries.compactMap(\.costUSD).reduce(0, +), + currencyCode: "USD", + historyDays: 30, + daily: entries, + updatedAt: self.now) + } + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private static let now = Date(timeIntervalSince1970: 1_785_024_000) // 2026-07-24 00:00:00 UTC +} diff --git a/Tests/CodexBarTests/SpendChartDayHitTargetTests.swift b/Tests/CodexBarTests/SpendChartDayHitTargetTests.swift new file mode 100644 index 0000000000..a5689dfa8d --- /dev/null +++ b/Tests/CodexBarTests/SpendChartDayHitTargetTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing +@testable import CodexBar + +struct SpendChartDayHitTargetTests { + @Test + func `dense days receive a minimum clickable radius`() { + let days = Self.days(count: 3) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(100), 103, 106])) + + let selected = SpendChartDayHitTarget.nearestDay( + toX: 113, + days: days, + position: { positions[$0] }) + + #expect(selected == days[2]) + } + + @Test + func `sparse days use midpoint-sized targets without capturing distant empty space`() { + let days = Self.days(count: 2) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(20), 60])) + + #expect(SpendChartDayHitTarget.nearestDay( + toX: 39, + days: days, + position: { positions[$0] }) == days[0]) + #expect(SpendChartDayHitTarget.nearestDay( + toX: 100, + days: days, + position: { positions[$0] }) == nil) + } + + @Test + func `space between active days belongs continuously to the nearest day`() { + let days = Self.days(count: 2) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(20), 80])) + + #expect(SpendChartDayHitTarget.nearestDay( + toX: 49, + days: days, + position: { positions[$0] }) == days[0]) + #expect(SpendChartDayHitTarget.nearestDay( + toX: 51, + days: days, + position: { positions[$0] }) == days[1]) + } + + @Test + func `hit testing follows rendered x order rather than input order`() { + let days = Self.days(count: 3) + let positions = [ + days[0]: CGFloat(80), + days[1]: CGFloat(20), + days[2]: CGFloat(50), + ] + + #expect(SpendChartDayHitTarget.nearestDay( + toX: 24, + days: days, + position: { positions[$0] }) == days[1]) + } + + @Test + func `hover retains the current day inside the neighboring boundary hysteresis`() { + let days = Self.days(count: 2) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(20), 80])) + + let selected = SpendChartDayHoverResolver.resolvedDay( + toX: 54, + days: days, + currentDay: days[0], + position: { positions[$0] }) + + // The geometric midpoint is 50, but the 7.2-point hysteresis keeps the first day stable. + #expect(selected == days[0]) + } + + @Test + func `hover changes after the pointer clearly enters the neighboring lane`() { + let days = Self.days(count: 2) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(20), 80])) + + let selected = SpendChartDayHoverResolver.resolvedDay( + toX: 58, + days: days, + currentDay: days[0], + position: { positions[$0] }) + + #expect(selected == days[1]) + } + + @Test + func `hover catches up immediately across multiple columns`() { + let days = Self.days(count: 4) + let positions = Dictionary(uniqueKeysWithValues: zip(days, [CGFloat(20), 50, 80, 110])) + + let selected = SpendChartDayHoverResolver.resolvedDay( + toX: 109, + days: days, + currentDay: days[0], + position: { positions[$0] }) + + #expect(selected == days[3]) + } + + private static func days(count: Int) -> [Date] { + let start = Date(timeIntervalSince1970: 1_700_000_000) + return (0.. CostUsageTokenSnapshot + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: nil, + outputTokens: nil, + totalTokens: tokens, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: [.init(modelName: model, costUSD: cost, totalTokens: tokens)]) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: currency, + historyDays: 30, + daily: [entry], + updatedAt: self.now) + } + + private static func multiModelSnapshot(models: [(name: String, tokens: Int)]) -> CostUsageTokenSnapshot { + let totalTokens = models.map(\.tokens).reduce(0, +) + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: nil, + outputTokens: nil, + totalTokens: totalTokens, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: models.map { + .init(modelName: $0.name, costUSD: nil, totalTokens: $0.tokens) + }) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "XXX", + historyDays: 30, + daily: [entry], + updatedAt: self.now) + } + + private static let now = Date(timeIntervalSince1970: 1_784_179_200) + private static var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} diff --git a/Tests/CodexBarTests/SpendDashboardLocalAdapterTests.swift b/Tests/CodexBarTests/SpendDashboardLocalAdapterTests.swift new file mode 100644 index 0000000000..8637b1f61b --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardLocalAdapterTests.swift @@ -0,0 +1,69 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendDashboardLocalAdapterTests { + @Test + func `registered Qwen Code adapter loads through the generic local history pipeline`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = Self.localHistorySnapshot(tokens: 21, model: "qwen3-coder-plus", now: now) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.qwencloud.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + localHistoryRequests: [ + LocalSpendHistoryRequest( + source: .qwenCode, + provider: .qwencloud, + homePath: "/synthetic/qwen-code"), + ], + now: now, + force: false) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return snapshot + }, + qwenCodeSnapshotLoader: { context in + #expect(context.homePath == "/synthetic/qwen-code") + return snapshot + }) + + let input = try #require(result.inputs.first) + #expect(input.id == "qwencloud:local") + #expect(input.provider == .qwencloud) + #expect(input.displayName == "Qwen Code CLI") + #expect(result.failedSourceIDs.isEmpty) + } + + private static func localHistorySnapshot( + tokens: Int, + model: String, + now: Date) -> CostUsageTokenSnapshot + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: tokens, + outputTokens: 0, + totalTokens: tokens, + requestCount: 1, + costUSD: nil, + modelsUsed: [model], + modelBreakdowns: [.init(modelName: model, costUSD: nil, totalTokens: tokens)]) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: tokens, + last30DaysCostUSD: nil, + currencyCode: "XXX", + daily: [entry], + updatedAt: now) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardLocalHistoryRecoveryTests.swift b/Tests/CodexBarTests/SpendDashboardLocalHistoryRecoveryTests.swift new file mode 100644 index 0000000000..264658a8a6 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardLocalHistoryRecoveryTests.swift @@ -0,0 +1,117 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct SpendDashboardLocalHistoryRecoveryTests { + @Test + func `successful local history clears unavailable live provider warning`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.minimax.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [UsageProvider.minimax.rawValue], + codexRequests: [], + miniMaxHomePath: "/synthetic/minimax-home", + now: now, + force: true) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: 0.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: 0.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("No Codex source should be loaded") + return snapshot + }, + miniMaxSnapshotLoader: { context in + #expect(context.homePath == "/synthetic/minimax-home") + return snapshot + }) + + #expect(result.inputs.map(\.id) == ["minimax:local"]) + #expect(result.failedSourceIDs.isEmpty) + } + + @Test + func `manual refresh reconciliation retains successful local history`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.kimi.rawValue], + codexAccountIdentities: []) + let request = SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + now: now, + force: true) + let input = SpendDashboardModel.ProviderInput( + id: "kimi:local", + provider: .kimi, + displayName: "Kimi Code CLI", + snapshot: Self.snapshot(now: now, cost: 2)) + let defaults = try #require(UserDefaults(suiteName: "SpendDashboardLocalHistoryRecoveryTests")) + defaults.removePersistentDomain(forName: "SpendDashboardLocalHistoryRecoveryTests") + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { _ in request }, + loader: { _ in + SpendDashboardLoadResult(inputs: [input], failedSourceIDs: []) + }) + + controller.update(configuration: configuration, force: true) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.model.groups.first?.providers.map(\.id) == ["kimi:local"]) + #expect(controller.model.groups.first?.totalCost == 2) + } + + private static func snapshot(now: Date, cost: Double) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: cost, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + } + + private static func waitUntil(_ condition: @MainActor () -> Bool) async { + for _ in 0..<1000 { + if condition() { + return + } + await Task.yield() + } + Issue.record("Timed out waiting for controller state") + } +} diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 1235307a99..20b0de6f6a 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -3,28 +3,75 @@ import Foundation import Testing @testable import CodexBar +// swiftlint:disable type_body_length struct SpendDashboardModelTests { @Test func `count labels avoid plural agreement and localize numbers`() { CodexBarLocalizationOverride.$appLanguage.withValue("en") { #expect(spendDashboardRefreshFailureText(1) == "Refresh failures: 1") #expect(spendDashboardRefreshFailureText(2) == "Refresh failures: 2") - #expect(spendDashboardCoverageText(covered: 3, requested: 7) == "Coverage: 3 / 7") + #expect(spendDashboardCoverageText( + covered: 30, + requested: 365) == "Common complete coverage: 30d · Time range: Cumulative") } CodexBarLocalizationOverride.$appLanguage.withValue("de") { #expect(spendDashboardRefreshFailureText(1234) == "Fehlgeschlagene Aktualisierungen: 1.234") - #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "Abdeckung: 3 / 30") + #expect(spendDashboardCoverageText( + covered: 7, + requested: 30) == "Abdeckung: 7d · Zeitraum: 30d") } CodexBarLocalizationOverride.$appLanguage.withValue("fa") { #expect(codexBarLocalizedInteger(12) == "۱۲") #expect(spendDashboardDayRangeText(7) == "۷ روز") #expect(spendDashboardDayRangeText(30) == "۳۰ روز") + #expect(spendDashboardDayRangeText(365) == "تجمعی") #expect(spendDashboardRankText(1234) == "#۱٬۲۳۴") #expect(spendDashboardRefreshFailureText(2) == "\(L("Refresh failures")): ۲") - #expect(spendDashboardCoverageText(covered: 3, requested: 30) == "پوشش: ۳ / ۳۰") + #expect(spendDashboardCoverageText( + covered: 7, + requested: 30) == "پوشش: ۷ روز · بازه زمانی: ۳۰ روز") } } + @Test + func `tracked token subtotal keeps known values when another subscription is incomplete`() throws { + let known = Self.input(id: "known", provider: .codex, currency: "USD", cost: 2) + let incomplete = SpendDashboardModel.ProviderInput( + id: "incomplete", + provider: .minimax, + displayName: "MiniMax", + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: nil, + last30DaysCostUSD: nil, + currencyCode: "USD", + historyDays: 30, + daily: [CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + costUSD: nil, + modelsUsed: ["MiniMax-M3"], + modelBreakdowns: [CostUsageDailyReport.ModelBreakdown( + modelName: "MiniMax-M3", + costUSD: nil, + totalTokens: nil)])], + updatedAt: Self.now)) + + let group = try #require(SpendDashboardModel.build( + inputs: [known, incomplete], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + let miniMax = try #require(group.providers.first { $0.provider == .minimax }) + let knownTokens = try #require(known.snapshot.daily.first?.totalTokens) + #expect(miniMax.totalTokens == nil) + #expect(group.totalTokens == knownTokens) + } + @Test func `Codex account indices use app locale numerals`() throws { let home = FileManager.default.temporaryDirectory @@ -68,7 +115,54 @@ struct SpendDashboardModelTests { let providers = Set(ProviderDescriptorRegistry.all .filter(\.tokenCost.supportsTokenCost) .map(\.id)) - #expect(providers == [.codex, .claude, .vertexai, .openai, .mistral, .bedrock, .cursor, .opencodego]) + #expect(providers == [ + .bedrock, + .claude, + .codex, + .cursor, + .groq, + .mistral, + .openai, + .opencodego, + .vertexai, + ]) + } + + @Test + func `dashboard history capability is declared by provider descriptors`() { + let descriptors = ProviderDescriptorRegistry.all + let localDescriptors = descriptors.filter { !$0.tokenCost.localHistorySources.isEmpty } + let declaredSources = localDescriptors.flatMap(\.tokenCost.localHistorySources) + + // zcode / traeLocal / cursorLocal exist in the registry but no descriptor opts them + // into the dashboard yet; that declaration belongs to a follow-up local-history PR. + #expect(Set(declaredSources) == [ + .antigravity, + .copilot, + .geminiCLI, + .kimiCode, + .miniMax, + .openCode, + .qwenCode, + ]) + #expect(declaredSources.count == Set(declaredSources).count) + #expect(Set(localDescriptors.map(\.id)) == [ + .antigravity, + .copilot, + .gemini, + .kimi, + .minimax, + .opencode, + .qwencloud, + ]) + + let dashboardProviders = Set(descriptors + .filter(\.tokenCost.supportsDashboardHistory) + .map(\.id)) + let expectedProviders = Set(descriptors + .filter { $0.tokenCost.supportsTokenCost || !$0.tokenCost.localHistorySources.isEmpty } + .map(\.id)) + #expect(dashboardProviders == expectedProviders) } @Test @@ -180,6 +274,180 @@ struct SpendDashboardModelTests { #expect(thirtyDays.chartDomain == thirtyDayStart...end) } + @Test + func `all model chart starts at earliest available model day`() throws { + let earlier = try Self.calendar.startOfDay(for: #require( + Self.calendar.date(byAdding: .day, value: -74, to: Self.now))) + let paddedStart = try #require(Self.calendar.date(byAdding: .day, value: -3, to: earlier)) + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-05-03", cost: 1, model: "early"), + Self.entry(day: "2026-07-16", cost: 1, model: "current"), + ], + historyDays: 365)) + let model = SpendDashboardModel.build( + inputs: [input], + requestedDays: 365, + now: Self.now, + calendar: Self.calendar) + let domain = try #require(model.modelChartDomain) + let today = Self.calendar.startOfDay(for: Self.now) + let end = try #require(Self.calendar.date(byAdding: .day, value: 1, to: today)) + + #expect(domain == paddedStart...end) + #expect(model.modelAnalysis.rows.map(\.displayName) == ["early", "current"]) + } + + @Test + func `cumulative model chart ignores zero-only edges and follows observed activity`() throws { + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-02-01", cost: 0, tokens: 0, model: "idle-start"), + Self.entry(day: "2026-05-01", cost: 1, model: "active"), + Self.entry(day: "2026-05-10", cost: 1, model: "active"), + Self.entry(day: "2026-07-16", cost: 0, tokens: 0, model: "idle-end"), + ], + historyDays: 365)) + let model = SpendDashboardModel.build( + inputs: [input], + requestedDays: 365, + now: Self.now, + calendar: Self.calendar) + let domain = try #require(model.modelChartDomain) + let expectedStart = try #require(Self.calendar.date( + from: DateComponents(year: 2026, month: 4, day: 30))) + let expectedEnd = try #require(Self.calendar.date( + from: DateComponents(year: 2026, month: 5, day: 12))) + + #expect(domain == expectedStart...expectedEnd) + } + + @Test + func `cumulative model chart drops a negligible legacy island before a long empty gap`() throws { + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-02-08", cost: 0.01, tokens: 1000, model: "legacy"), + // This later gap is larger than the leading legacy gap. The chart must still + // evaluate the leading sparse island instead of looking only at the maximum gap. + Self.entry(day: "2026-04-01", cost: 10, tokens: 1_000_000, model: "active"), + Self.entry(day: "2026-06-15", cost: 10, tokens: 1_000_000, model: "active"), + Self.entry(day: "2026-07-16", cost: 10, tokens: 1_000_000, model: "active"), + ], + historyDays: 365)) + let model = SpendDashboardModel.build( + inputs: [input], + requestedDays: 365, + now: Self.now, + calendar: Self.calendar) + let domain = try #require(model.modelChartDomain) + let expectedStart = try #require(Self.calendar.date( + from: DateComponents(year: 2026, month: 3, day: 27))) + let expectedEnd = try #require(Self.calendar.date( + from: DateComponents(year: 2026, month: 7, day: 17))) + + #expect(domain == expectedStart...expectedEnd) + #expect(model.modelAnalysis.rows.map(\.displayName).contains("legacy")) + } + + @Test + func `cumulative model chart keeps an old island with meaningful contribution`() throws { + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-02-08", cost: 10, tokens: 1_000_000, model: "legacy"), + Self.entry(day: "2026-05-01", cost: 10, tokens: 1_000_000, model: "active"), + Self.entry(day: "2026-06-15", cost: 10, tokens: 1_000_000, model: "active"), + Self.entry(day: "2026-07-16", cost: 10, tokens: 1_000_000, model: "active"), + ], + historyDays: 365)) + let model = SpendDashboardModel.build( + inputs: [input], + requestedDays: 365, + now: Self.now, + calendar: Self.calendar) + let domain = try #require(model.modelChartDomain) + let expectedStart = try #require(Self.calendar.date( + from: DateComponents(year: 2026, month: 2, day: 1))) + let expectedEnd = try #require(Self.calendar.date( + from: DateComponents(year: 2026, month: 7, day: 17))) + + #expect(domain == expectedStart...expectedEnd) + } + + @Test + func `cumulative model chart keeps a compact seven day fallback without activity`() throws { + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-02-01", cost: 0, tokens: 0, model: "idle"), + ], + historyDays: 365)) + let model = SpendDashboardModel.build( + inputs: [input], + requestedDays: 365, + now: Self.now, + calendar: Self.calendar) + let domain = try #require(model.modelChartDomain) + let expectedStart = try #require(Self.calendar.date( + byAdding: .day, + value: -6, + to: Self.calendar.startOfDay(for: Self.now))) + let expectedEnd = try #require(Self.calendar.date( + byAdding: .day, + value: 1, + to: Self.calendar.startOfDay(for: Self.now))) + + #expect(domain == expectedStart...expectedEnd) + } + + @Test + func `model range stays independent from overview range`() throws { + let earlier = try Self.calendar.startOfDay(for: #require( + Self.calendar.date(byAdding: .day, value: -74, to: Self.now))) + let paddedStart = try #require(Self.calendar.date(byAdding: .day, value: -3, to: earlier)) + let input = SpendDashboardModel.ProviderInput( + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entry(day: "2026-05-03", cost: 1, model: "early"), + Self.entry(day: "2026-07-16", cost: 1, model: "current"), + ], + historyDays: 365)) + let model = SpendDashboardModel.build( + inputs: [input], + requestedDays: 30, + now: Self.now, + calendar: Self.calendar) + let allDomain = try #require(model.modelChartDomain(for: 365)) + let today = Self.calendar.startOfDay(for: Self.now) + let end = try #require(Self.calendar.date(byAdding: .day, value: 1, to: today)) + + #expect(model.groups.first?.modelAnalysis.rows.map(\.displayName) == ["current"]) + #expect(model.modelAnalysis(for: 30).rows.map(\.displayName) == ["current"]) + #expect(model.modelAnalysis(for: 365).rows.map(\.displayName) == ["early", "current"]) + #expect(allDomain == paddedStart...end) + } + @Test func `currency coverage intersects disjoint provider windows`() throws { let earlier = try SpendDashboardModel.ProviderInput( @@ -260,8 +528,8 @@ struct SpendDashboardModelTests { now: Self.now, calendar: Self.calendar).groups.first) - #expect(group.totalCost == nil) - #expect(group.totalTokens == nil) + #expect(group.totalCost == 4) + #expect(group.totalTokens == 10) #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.map(\.provider) == [.claude]) #expect(group.models.map(\.modelName) == ["test-model"]) @@ -319,6 +587,443 @@ struct SpendDashboardModelTests { #expect(usd.models.map(\.totalCost) == [4]) } + @Test + func `model analysis merges exact normalized names without changing overview rows`() throws { + let first = SpendDashboardModel.ProviderInput( + id: "claude-source", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 2, tokens: 20, model: " Model-A "), + ])) + let second = SpendDashboardModel.ProviderInput( + id: "codex-source", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30, model: "model-a"), + ])) + let group = try #require(SpendDashboardModel.build( + inputs: [first, second], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let row = try #require(group.modelAnalysis.rows.first) + + #expect(group.models.count == 2) + #expect(group.modelAnalysis.rows.count == 1) + #expect(row.id == "model-a") + #expect(row.rawModelNames == ["Model-A", "model-a"]) + #expect(row.totalTokens == 50) + #expect(row.estimatedCost == 5) + #expect(row.contributions.map(\.sourceID) == ["claude-source", "codex-source"]) + #expect(group.modelAnalysis.dailyValues.map(\.totalTokens) == [50]) + #expect(group.modelAnalysis.dailyValues.map(\.estimatedCost) == [5]) + #expect(group.modelAnalysis.tokenCoverage == .complete) + #expect(group.modelAnalysis.costCoverage == .complete) + } + + @Test + func `model analysis merges claude spellings across providers and snapshot dates`() throws { + let claude = SpendDashboardModel.ProviderInput( + id: "claude-source", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 2, tokens: 20, model: "claude-sonnet-4-5"), + ])) + let vertex = SpendDashboardModel.ProviderInput( + id: "vertex-source", + provider: .vertexai, + displayName: "Vertex AI", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30, model: "anthropic/claude-sonnet-4-5-20250929"), + ])) + let group = try #require(SpendDashboardModel.build( + inputs: [claude, vertex], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let row = try #require(group.modelAnalysis.rows.first) + + #expect(group.modelAnalysis.rows.count == 1) + #expect(row.id == "claude-sonnet-4-5") + #expect(row.displayName == "Claude Sonnet 4.5") + #expect(row.rawModelNames == ["claude-sonnet-4-5", "anthropic/claude-sonnet-4-5-20250929"]) + #expect(row.providers == [.claude, .vertexai]) + #expect(row.providerNames == ["Claude", "Vertex AI"]) + #expect(row.totalTokens == 50) + #expect(row.estimatedCost == 5) + #expect(row.contributions.map(\.sourceID) == ["claude-source", "vertex-source"]) + #expect(group.modelAnalysis.dailyValues.map(\.totalTokens) == [50]) + } + + @Test + func `model analysis merges dated snapshots into the base model row`() throws { + let codex = SpendDashboardModel.ProviderInput( + id: "codex-source", + provider: .codex, + displayName: "Codex", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 2, tokens: 20, model: "gpt-5"), + ])) + let openai = SpendDashboardModel.ProviderInput( + id: "openai-source", + provider: .openai, + displayName: "OpenAI", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 3, tokens: 30, model: "gpt-5-2025-08-07"), + ])) + let group = try #require(SpendDashboardModel.build( + inputs: [codex, openai], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let row = try #require(group.modelAnalysis.rows.first) + + #expect(group.modelAnalysis.rows.count == 1) + #expect(row.id == "gpt-5") + #expect(row.providers == [.codex, .openai]) + #expect(row.providerNames == ["Codex", "OpenAI"]) + #expect(row.totalTokens == 50) + #expect(row.estimatedCost == 5) + } + + @Test + func `model analysis keeps semantic model variants in separate rows`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 6, + totalTokens: 60, + breakdowns: [ + .init(modelName: "gpt-5", costUSD: 1, totalTokens: 10), + .init(modelName: "gpt-5-codex", costUSD: 2, totalTokens: 20), + .init(modelName: "gpt-5-mini", costUSD: 3, totalTokens: 30), + ]), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.modelAnalysis.rows.map(\.id) == ["gpt-5-mini", "gpt-5-codex", "gpt-5"]) + #expect(group.modelAnalysis.rows.map(\.totalTokens) == [30, 20, 10]) + } + + @Test + func `model analysis keeps kimi alias display names`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 1, tokens: 10, model: "kimi-code/kimi-for-coding"), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .kimi, displayName: "Kimi", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let row = try #require(group.modelAnalysis.rows.first) + + #expect(group.modelAnalysis.rows.count == 1) + #expect(row.id == "kimi for coding") + #expect(row.displayName == "Kimi for Coding") + #expect(row.rawModelNames == ["kimi-code/kimi-for-coding"]) + } + + @Test + func `model analysis excludes incomplete source days and labels partial coverage`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entry(day: "2026-07-16", cost: 4, tokens: 40, model: "model-a"), + Self.entryWithBreakdowns( + day: "2026-07-15", + totalCost: 5, + totalTokens: 50, + breakdowns: [.init(modelName: "model-a", costUSD: 3, totalTokens: 30)]), + ]) + let group = try #require(SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + let row = try #require(group.modelAnalysis.rows.first) + + #expect(group.models.isEmpty) + #expect(row.totalTokens == 40) + #expect(row.estimatedCost == 4) + #expect(group.modelAnalysis.tokenCoverage == .partial) + #expect(group.modelAnalysis.costCoverage == .partial) + #expect(group.modelAnalysis.dailyValues.count == 1) + } + + @Test + func `model analysis preserves complete token splits and leaves total-only models unchanged`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 196, + breakdowns: [ + .init( + modelName: "split-model", + costUSD: 0, + totalTokens: 100, + inputTokens: 60, + cacheReadTokens: 20, + outputTokens: 20), + .init( + modelName: "total-only-model", + costUSD: 0, + totalTokens: 96), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let split = try #require(analysis.rows.first(where: { $0.id == "split-model" })) + let totalOnly = try #require(analysis.rows.first(where: { $0.id == "total-only-model" })) + let daily = try #require(analysis.dailyValues.first(where: { $0.modelID == "split-model" })) + + // Explicit buckets are carried as-is: cache reads no longer fold into the input bucket. + #expect(split.totalTokens == 100) + #expect(split.inputTokens == 60) + #expect(split.outputTokens == 20) + #expect(split.cacheReadTokens == 20) + #expect(split.cacheCreationTokens == nil) + #expect(split.reasoningTokens == nil) + #expect(totalOnly.totalTokens == 96) + #expect(totalOnly.inputTokens == nil) + #expect(totalOnly.outputTokens == nil) + #expect(totalOnly.cacheReadTokens == nil) + #expect(daily.inputTokens == 60) + #expect(daily.outputTokens == 20) + #expect(daily.cacheReadTokens == 20) + } + + @Test + func `model analysis carries reasoning and cache creation buckets to rows and daily values`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 120, + breakdowns: [ + .init( + modelName: "reasoning-model", + costUSD: 0, + totalTokens: 120, + inputTokens: 50, + cacheReadTokens: 10, + cacheCreationTokens: 5, + outputTokens: 55, + reasoningTokens: 30), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let row = try #require(analysis.rows.first(where: { $0.id == "reasoning-model" })) + let daily = try #require(analysis.dailyValues.first(where: { $0.modelID == "reasoning-model" })) + + #expect(row.inputTokens == 50) + #expect(row.outputTokens == 55) + #expect(row.cacheReadTokens == 10) + #expect(row.cacheCreationTokens == 5) + // Reasoning is a sub-bucket of output: 30 of the 55 output tokens, never added on top. + #expect(row.reasoningTokens == 30) + #expect(daily.reasoningTokens == 30) + #expect(daily.cacheCreationTokens == 5) + } + + @Test + func `model analysis normalizes cache inclusive input so explicit buckets sum to the total`() throws { + // Codex-shape breakdowns report cache-inclusive input (input + output == total). + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 110, + breakdowns: [ + .init( + modelName: "codex-shaped-model", + costUSD: 0, + totalTokens: 110, + inputTokens: 100, + cacheReadTokens: 20, + outputTokens: 10, + reasoningTokens: 4), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let row = try #require(analysis.rows.first(where: { $0.id == "codex-shaped-model" })) + + #expect(row.inputTokens == 80) + #expect(row.outputTokens == 10) + #expect(row.cacheReadTokens == 20) + #expect(row.reasoningTokens == 4) + } + + @Test + func `model analysis falls back to inferred input for legacy breakdowns without split fields`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 100, + breakdowns: [ + .init( + modelName: "legacy-model", + costUSD: 0, + totalTokens: 100, + outputTokens: 25), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let row = try #require(analysis.rows.first(where: { $0.id == "legacy-model" })) + + // Legacy inference: everything non-output counts as input, optional buckets stay unknown. + #expect(row.inputTokens == 75) + #expect(row.outputTokens == 25) + #expect(row.cacheReadTokens == nil) + #expect(row.cacheCreationTokens == nil) + #expect(row.reasoningTokens == nil) + } + + @Test + func `model analysis degrades mixed source shapes to the legacy input inference`() throws { + // Merged-source breakdowns (e.g. cache-inclusive Codex native plus cache-exclusive Pi) + // fit neither explicit shape; the row keeps the legacy split and the shape-independent + // reasoning bucket instead of losing the split entirely. + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 130, + breakdowns: [ + .init( + modelName: "merged-shape-model", + costUSD: 0, + totalTokens: 130, + inputTokens: 105, + cacheReadTokens: 20, + cacheCreationTokens: 5, + outputTokens: 20, + reasoningTokens: 8), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .codex, displayName: "Codex", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let row = try #require(analysis.rows.first(where: { $0.id == "merged-shape-model" })) + + #expect(row.inputTokens == 110) + #expect(row.outputTokens == 20) + #expect(row.cacheReadTokens == nil) + #expect(row.cacheCreationTokens == nil) + #expect(row.reasoningTokens == 8) + } + + @Test + func `model analysis drops optional buckets any contributing breakdown does not report`() throws { + let snapshot = Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: "2026-07-16", + totalCost: 0, + totalTokens: 220, + breakdowns: [ + .init( + modelName: "mixed-model", + costUSD: 0, + totalTokens: 120, + inputTokens: 50, + cacheReadTokens: 10, + cacheCreationTokens: 5, + outputTokens: 55, + reasoningTokens: 30), + .init( + modelName: "mixed-model", + costUSD: 0, + totalTokens: 100, + inputTokens: 75, + outputTokens: 25), + ]), + ]) + let analysis = SpendDashboardModel.build( + inputs: [.init(provider: .claude, displayName: "Claude", snapshot: snapshot)], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let row = try #require(analysis.rows.first(where: { $0.id == "mixed-model" })) + + // input/output stay complete (both breakdowns resolve a split); the optional buckets + // vanish because the second breakdown does not report them. + #expect(row.inputTokens == 125) + #expect(row.outputTokens == 80) + #expect(row.cacheReadTokens == nil) + #expect(row.cacheCreationTokens == nil) + #expect(row.reasoningTokens == nil) + } + + @Test + func `model analysis flags rows whose cost is estimated and clears provider reported rows`() throws { + let day = "2026-07-16" + let estimated = SpendDashboardModel.ProviderInput( + id: "estimated-source", + provider: .claude, + displayName: "Claude", + snapshot: Self.snapshot(currency: "USD", entries: [ + Self.entryWithBreakdowns( + day: day, + totalCost: 4, + totalTokens: 40, + breakdowns: [.init(modelName: "shared-model", costUSD: 4, totalTokens: 40)]), + ])) + let providerReported = SpendDashboardModel.ProviderInput( + id: "billed-source", + provider: .cursor, + displayName: "Cursor", + snapshot: Self.snapshot( + currency: "USD", + entries: [ + Self.entryWithBreakdowns( + day: day, + totalCost: 6, + totalTokens: 60, + breakdowns: [.init(modelName: "shared-model", costUSD: 6, totalTokens: 60)]), + ], + costSource: .providerReported)) + let analysis = SpendDashboardModel.build( + inputs: [estimated, providerReported], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let mixed = try #require(analysis.rows.first(where: { $0.id == "shared-model" })) + #expect(mixed.estimatedCost == 10) + #expect(mixed.costIsEstimated == true) + + let billedOnly = SpendDashboardModel.build( + inputs: [providerReported], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).modelAnalysis + let billedRow = try #require(billedOnly.rows.first(where: { $0.id == "shared-model" })) + #expect(billedRow.estimatedCost == 6) + #expect(billedRow.costIsEstimated == false) + } + @Test func `ISO history stays Gregorian while preserving the injected timezone`() throws { let timeZone = try #require(TimeZone(secondsFromGMT: 7 * 60 * 60)) @@ -395,7 +1100,7 @@ struct SpendDashboardModelTests { #expect(group.providers.first(where: { $0.id == "invalid" })?.totalCost == nil) #expect(group.totalCost == nil) - #expect(group.totalTokens == nil) + #expect(group.totalTokens == 20) #expect(group.dailyPoints.isEmpty) } @@ -418,6 +1123,10 @@ struct SpendDashboardModelTests { #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.isEmpty) #expect(group.dailyPoints.isEmpty) + #expect(group.modelAnalysis.rows.first?.totalTokens == 40) + #expect(group.modelAnalysis.rows.first?.estimatedCost == 4) + #expect(group.modelAnalysis.tokenCoverage == .partial) + #expect(group.modelAnalysis.costCoverage == .partial) } @Test @@ -513,7 +1222,7 @@ struct SpendDashboardModelTests { #expect(group.providers.first(where: { $0.id == "nonfinite" })?.totalTokens == 2) #expect(group.providers.filter { $0.id != "nonfinite" }.allSatisfy { $0.totalTokens == nil }) #expect(group.totalCost == nil) - #expect(group.totalTokens == nil) + #expect(group.totalTokens == 2) } @Test @@ -595,7 +1304,9 @@ struct SpendDashboardModelTests { #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.isEmpty) } +} +extension SpendDashboardModelTests { @Test func `blank model names fail closed unless their usage is explicitly zero`() throws { let incomplete = Self.snapshot(currency: "USD", entries: [Self.entryWithBreakdowns( @@ -723,6 +1434,43 @@ struct SpendDashboardModelTests { #expect(group.providers.first?.totalCost == nil) } + @Test + func `partial provider pricing is labelled partial without hiding known spend`() throws { + let priced = Self.input(id: "priced", provider: .codex, currency: "USD", cost: 4) + let unpriced = SpendDashboardModel.ProviderInput( + id: "unpriced", + provider: .qwencloud, + displayName: "Qwen Code", + snapshot: Self.snapshot( + currency: "USD", + entries: [Self.entry(day: "2026-07-16", cost: nil, tokens: 12)])) + let group = try #require(SpendDashboardModel.build( + inputs: [priced, unpriced], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar).groups.first) + + #expect(group.totalCost == 4) + #expect(group.costCoverage == .partial) + #expect(group.pricedProviderCount == 1) + } + + @Test + func `global model analysis never sums costs across currencies`() { + let usd = Self.input(id: "usd", provider: .codex, currency: "USD", cost: 4) + let eur = Self.input(id: "eur", provider: .claude, currency: "EUR", cost: 3) + let model = SpendDashboardModel.build( + inputs: [usd, eur], + requestedDays: 7, + now: Self.now, + calendar: Self.calendar) + + #expect(model.groups.count == 2) + #expect(model.modelAnalysis.rows.allSatisfy { $0.estimatedCost == nil }) + #expect(model.modelAnalysis.costCoverage == .unavailable) + #expect(model.modelAnalysis.dailyValues.allSatisfy { $0.estimatedCost == nil }) + } + @Test func `Codex requests freeze source home auth and cache identity`() throws { let id = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")) @@ -753,6 +1501,7 @@ struct SpendDashboardModelTests { #expect(!request.authFileWasReadable) #expect(request.displayName == "Codex · #2") #expect(request.cacheIdentity.count == 64) + #expect(SpendDashboardSource.scanDays == 365) #expect(SpendDashboardSource.codexRequest( account: account, homePath: "relative/path", @@ -814,6 +1563,7 @@ struct SpendDashboardModelTests { currency: String, entries: [CostUsageDailyReport.Entry], historyDays: Int = 30, + costSource: CostUsageCostSource = .estimated, updatedAt: Date = now) -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( @@ -823,6 +1573,7 @@ struct SpendDashboardModelTests { last30DaysCostUSD: nil, currencyCode: currency, historyDays: historyDays, + costSource: costSource, daily: entries, updatedAt: updatedAt) } @@ -868,3 +1619,5 @@ struct SpendDashboardModelTests { return calendar } } + +// swiftlint:enable type_body_length diff --git a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift index 75c0ddc09b..2b8366d006 100644 --- a/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift +++ b/Tests/CodexBarTests/SpendDashboardSourceConcurrencyTests.swift @@ -5,6 +5,238 @@ import Testing @MainActor struct SpendDashboardSourceConcurrencyTests { + @Test + func `local Kimi history is appended without entering the cost provider pipeline`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: 4, + outputTokens: 2, + totalTokens: 6, + requestCount: 1, + costUSD: nil, + modelsUsed: ["kimi-code/k3"], + modelBreakdowns: [.init(modelName: "kimi-code/k3", costUSD: nil, totalTokens: 6)]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 6, + last30DaysCostUSD: nil, + currencyCode: "XXX", + daily: [entry], + updatedAt: now) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.kimi.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + kimiCodeHomePath: "/synthetic/kimi-code", + now: now, + force: false) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return snapshot + }, + kimiCodeSnapshotLoader: { context in + #expect(context.homePath == "/synthetic/kimi-code") + return snapshot + }) + + let input = try #require(result.inputs.first) + #expect(input.id == "kimi:local") + #expect(input.provider == .kimi) + #expect(input.displayName == "Kimi Code CLI") + #expect(input.snapshot.currencyCode == "XXX") + #expect(result.failedSourceIDs.isEmpty) + } + + @Test + func `local Gemini and OpenCode histories are appended without entering the cost provider pipeline`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let geminiSnapshot = Self.localHistorySnapshot(tokens: 12, model: "gemini-test-model", now: now) + let openCodeSnapshot = Self.localHistorySnapshot(tokens: 8, model: "opencode-test-model", now: now) + let recorder = SpendDashboardLocalLoaderRecorder() + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.gemini.rawValue, UsageProvider.opencode.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + geminiCLIHomePath: "/synthetic/gemini-cli", + openCodeDataHomePath: "/synthetic/opencode-data", + now: now, + force: false) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return geminiSnapshot + }, + kimiCodeSnapshotLoader: { _ in + Issue.record("Kimi loader should not run") + return nil + }, + geminiSnapshotLoader: { context in + await recorder.record("gemini") + #expect(context.homePath == "/synthetic/gemini-cli") + return geminiSnapshot + }, + openCodeSnapshotLoader: { context in + await recorder.record("opencode") + #expect(context.homePath == "/synthetic/opencode-data") + return openCodeSnapshot + }) + + #expect(await recorder.invokedIDs == ["gemini", "opencode"]) + let geminiInput = try #require(result.inputs.first { $0.id == "gemini:local" }) + #expect(geminiInput.provider == .gemini) + #expect(geminiInput.displayName == "Gemini CLI") + #expect(geminiInput.modelProviderName == "Gemini") + #expect(geminiInput.snapshot.currencyCode == "XXX") + let openCodeInput = try #require(result.inputs.first { $0.id == "opencode:local" }) + #expect(openCodeInput.provider == .opencode) + #expect(openCodeInput.displayName == "OpenCode") + #expect(openCodeInput.modelProviderName == "OpenCode") + #expect(openCodeInput.snapshot.currencyCode == "XXX") + #expect(result.failedSourceIDs.isEmpty) + } + + @Test + func `local history failure marks only its own source id`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = Self.localHistorySnapshot(tokens: 8, model: "opencode-test-model", now: now) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.gemini.rawValue, UsageProvider.opencode.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + geminiCLIHomePath: "/synthetic/gemini-cli", + openCodeDataHomePath: "/synthetic/opencode-data", + now: now, + force: false) + + let geminiFailed = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return snapshot + }, + geminiSnapshotLoader: { _ in + throw SpendDashboardSyntheticError.failed + }, + openCodeSnapshotLoader: { _ in snapshot }) + #expect(geminiFailed.inputs.map(\.id) == ["opencode:local"]) + #expect(geminiFailed.failedSourceIDs == ["gemini:local"]) + + let openCodeFailed = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return snapshot + }, + geminiSnapshotLoader: { _ in snapshot }, + openCodeSnapshotLoader: { _ in + throw SpendDashboardSyntheticError.failed + }) + #expect(openCodeFailed.inputs.map(\.id) == ["gemini:local"]) + #expect(openCodeFailed.failedSourceIDs == ["opencode:local"]) + } + + @Test + func `local history cancellation marks the source failed and short-circuits remaining local scans`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = Self.localHistorySnapshot(tokens: 8, model: "opencode-test-model", now: now) + let recorder = SpendDashboardLocalLoaderRecorder() + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.gemini.rawValue, UsageProvider.opencode.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + geminiCLIHomePath: "/synthetic/gemini-cli", + openCodeDataHomePath: "/synthetic/opencode-data", + now: now, + force: false) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return snapshot + }, + geminiSnapshotLoader: { _ in + await recorder.record("gemini") + throw CancellationError() + }, + openCodeSnapshotLoader: { _ in + await recorder.record("opencode") + return snapshot + }) + + #expect(result.inputs.isEmpty) + #expect(result.failedSourceIDs == ["gemini:local"]) + #expect(await recorder.invokedIDs == ["gemini"]) + } + + @Test + func `local model history providers follow the enabled provider set`() async { + let settings = testSettingsStore(suiteName: "SpendDashboardSourceConcurrencyTests-local-history") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .kimi || provider == .gemini || provider == .opencode || provider == .qwencloud) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + #expect(Set(SpendDashboardSource.localModelHistoryProviders(store: store)) + == [.gemini, .kimi, .opencode, .qwencloud]) + let configuration = SpendDashboardSource.configuration(settings: settings, store: store) + #expect(configuration.providerIDs.contains(UsageProvider.gemini.rawValue)) + #expect(configuration.providerIDs.contains(UsageProvider.kimi.rawValue)) + #expect(configuration.providerIDs.contains(UsageProvider.opencode.rawValue)) + #expect(configuration.providerIDs.contains(UsageProvider.qwencloud.rawValue)) + + let request = await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: .captureOnly) + #expect(request.kimiCodeHomePath != nil) + #expect(request.geminiCLIHomePath != nil) + #expect(request.openCodeDataHomePath != nil) + #expect(request.qwenCodeHomePath != nil) + + if let metadata = ProviderRegistry.shared.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: metadata, enabled: false) + } + #expect(!SpendDashboardSource.localModelHistoryProviders(store: store).contains(.gemini)) + let disabledRequest = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly) + #expect(disabledRequest.geminiCLIHomePath == nil) + #expect(disabledRequest.kimiCodeHomePath != nil) + #expect(disabledRequest.openCodeDataHomePath != nil) + } + @Test func `Codex batch revalidates completed and failed accounts after later scans`() async throws { let root = FileManager.default.temporaryDirectory @@ -104,7 +336,7 @@ struct SpendDashboardSourceConcurrencyTests { } @Test - func `Codex removal relabels retained failed account from second to first`() async throws { + func `Codex removal retains failed account across separate subscription rows`() async throws { let gate = SpendDashboardResultBatchGate() let requestGate = SpendDashboardProviderBatchGate() let initialRequests = [ @@ -157,10 +389,9 @@ struct SpendDashboardSourceConcurrencyTests { controller.update(configuration: replacement) let pendingRows = try #require(controller.model.groups.first?.providers) - #expect(Dictionary(uniqueKeysWithValues: pendingRows.map { ($0.id, $0.displayName) }) == [ - "codex:b": "Codex · #1", - "codex:c": "Codex · #2", - ]) + #expect(pendingRows.count == 2) + #expect(pendingRows.map(\.id) == ["codex:c", "codex:b"]) + #expect(pendingRows.map(\.totalCost) == [7, 5]) await Self.waitForProviderGate(requestGate) #expect(await gate.pendingCount == 0) await requestGate.resume() @@ -171,11 +402,9 @@ struct SpendDashboardSourceConcurrencyTests { await Self.waitUntil { !controller.isRefreshing } let finalRows = try #require(controller.model.groups.first?.providers) - #expect(Dictionary(uniqueKeysWithValues: finalRows.map { ($0.id, $0.displayName) }) == [ - "codex:b": "Codex · #1", - "codex:c": "Codex · #2", - ]) - #expect(finalRows.first { $0.id == "codex:b" }?.totalCost == 5) + #expect(finalRows.count == 2) + #expect(finalRows.map(\.id) == ["codex:c", "codex:b"]) + #expect(finalRows.map(\.totalCost) == [8, 5]) #expect(controller.failedSourceCount == 1) } @@ -529,6 +758,30 @@ struct SpendDashboardSourceConcurrencyTests { snapshot: snapshot) } + private static func localHistorySnapshot( + tokens: Int, + model: String, + now: Date) -> CostUsageTokenSnapshot + { + let entry = CostUsageDailyReport.Entry( + date: "2026-07-16", + inputTokens: tokens, + outputTokens: 0, + totalTokens: tokens, + requestCount: 1, + costUSD: nil, + modelsUsed: [model], + modelBreakdowns: [.init(modelName: model, costUSD: nil, totalTokens: tokens)]) + return CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: tokens, + last30DaysCostUSD: nil, + currencyCode: "XXX", + daily: [entry], + updatedAt: now) + } + private static func waitForCodexGate(_ gate: SpendDashboardCodexBatchGate) async { for _ in 0..<1000 { if await gate.isSuspended { @@ -587,6 +840,14 @@ private enum SpendDashboardSyntheticError: Error { case failed } +private actor SpendDashboardLocalLoaderRecorder { + private(set) var invokedIDs: [String] = [] + + func record(_ id: String) { + self.invokedIDs.append(id) + } +} + @MainActor private final class SpendDashboardRequestSequence { struct Item { diff --git a/Tests/CodexBarTests/SpendModelIdentityTests.swift b/Tests/CodexBarTests/SpendModelIdentityTests.swift new file mode 100644 index 0000000000..ac5f86fec3 --- /dev/null +++ b/Tests/CodexBarTests/SpendModelIdentityTests.swift @@ -0,0 +1,132 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendModelIdentityTests { + @Test + func `identity trims and collapses whitespace`() { + #expect(Self.id(" claude-sonnet-4-5 ") == "claude-sonnet-4-5") + #expect(Self.id("\n\tgpt-5\n") == "gpt-5") + #expect(Self.id("test model") == "test model") + } + + @Test + func `identity merges compact and dashed snapshot dates`() { + #expect(Self.id("gpt-5-20250807") == "gpt-5") + #expect(Self.id("gpt-5-2025-08-07") == "gpt-5") + #expect(Self.id("gpt-5-2024-01-15") == Self.id("gpt-5-2025-08-07")) + #expect(Self.id("claude-sonnet-4-5-20250929") == "claude-sonnet-4-5") + } + + @Test + func `identity rejects digit suffixes that are not valid dates`() { + #expect(Self.id("gpt-5-20251345") == "gpt-5-20251345") // month 13 is not a date + #expect(Self.id("test-12345678") == "test-12345678") // year 1234 is implausible + #expect(Self.id("deepseek-v3-0324") == "deepseek-v3-0324") // short version suffix stays + #expect(Self.id("gpt-5-20251345") != Self.id("gpt-5")) + } + + @Test + func `identity strips vendor routing prefixes but keeps unknown prefixes`() { + #expect(Self.id("anthropic/claude-sonnet-4-5", provider: .vertexai) == "claude-sonnet-4-5") + #expect(Self.id("openrouter/anthropic/claude-sonnet-4-5", provider: .openrouter) == "claude-sonnet-4-5") + #expect(Self.id("openai/gpt-5", provider: .openai) == "gpt-5") + #expect(Self.id("bedrock/claude-sonnet-4-5", provider: .bedrock) == "claude-sonnet-4-5") + #expect(Self.id("acme-corp/gpt-5", provider: .openai) == "acme-corp/gpt-5") + #expect(Self.id("acme-corp/gpt-5") != Self.id("gpt-5")) + } + + @Test + func `identity uses the provider name as an extra prefix hint`() { + #expect(Self.id("cursor/gpt-5", provider: .cursor) == "gpt-5") + #expect(Self.id("cursor/gpt-5", provider: .openai) == "cursor/gpt-5") + #expect(Self.id("cursor/gpt-5") == "cursor/gpt-5") + } + + @Test + func `identity normalizes claude version punctuation and order`() { + #expect(Self.id("claude-3.5-sonnet") == "claude-sonnet-3-5") + #expect(Self.id("claude-3-5-sonnet") == "claude-sonnet-3-5") + #expect(Self.id("claude-3.5-sonnet-20241022") == Self.id("claude-sonnet-3-5")) + #expect(Self.id("claude-sonnet-4-5") == "claude-sonnet-4-5") // family-first order stays + #expect(Self.id("claude-opus-4-5") != Self.id("claude-sonnet-4-5")) + } + + @Test + func `identity leaves dots in non claude names alone`() { + #expect(Self.id("test-model-1.5-pro") == "test-model-1.5-pro") + #expect(Self.id("test-model-1.5-pro") != Self.id("test-model-1-5-pro")) + } + + @Test + func `identity keeps kimi alias display names`() { + let prefixed = SpendModelIdentity(rawName: "kimi-code/kimi-k2.5", provider: .kimi) + #expect(prefixed.displayName == "Kimi K2.5") + #expect(prefixed.id == "kimi k2.5") + #expect(SpendModelIdentity(rawName: "K2", provider: .kimi).displayName == "Kimi K2") + #expect(SpendModelIdentity(rawName: "k2.5", provider: .kimi).displayName == "Kimi K2.5") + let coding = SpendModelIdentity(rawName: "kimi-code/kimi-for-coding-highspeed", provider: .kimi) + #expect(coding.displayName == "Kimi for Coding High-Speed") + #expect(SpendModelIdentity(rawName: "k3-256k", provider: .kimi).displayName == "Kimi K3 (256K)") + } + + @Test + func `identity applies official brand casing to model ids`() { + #expect(SpendModelIdentity(rawName: "gpt-5-mini", provider: .openai).displayName == "GPT-5 mini") + #expect(SpendModelIdentity(rawName: "gpt-5-codex", provider: .codex).displayName == "GPT-5-Codex") + #expect(SpendModelIdentity(rawName: "codex-auto-review", provider: .codex).displayName == "Codex Auto Review") + #expect(SpendModelIdentity(rawName: "claude-sonnet-4-5", provider: .claude) + .displayName == "Claude Sonnet 4.5") + #expect(SpendModelIdentity(rawName: "minimax-m3", provider: .minimax).displayName == "MiniMax M3") + #expect(SpendModelIdentity(rawName: "deepseek-v3", provider: .deepseek).displayName == "DeepSeek-V3") + } + + @Test + func `identity names Antigravity aliases by their public model tier`() { + #expect(SpendModelIdentity( + rawName: "gemini-pro-default", + provider: .antigravity).displayName == "Gemini 3.1 Pro") + #expect(SpendModelIdentity( + rawName: "gemini-3-flash-a", + provider: .antigravity).displayName == "Gemini 3.5 Flash (High)") + #expect(SpendModelIdentity( + rawName: "gemini-3.5-flash-low", + provider: .antigravity).displayName == "Gemini 3.5 Flash (Medium)") + #expect(SpendModelIdentity( + rawName: "gemini-3.5-flash-extra-low", + provider: .antigravity).displayName == "Gemini 3.5 Flash (Low)") + #expect(SpendModelIdentity( + rawName: "gemini-default", + provider: .antigravity).displayName == "Gemini 3 Flash") + } + + @Test + func `identity merges recognized proxy reasoning tier annotations only`() { + #expect(Self.id("gpt-5(high)") == "gpt-5") + #expect(Self.id("gpt-5(xhigh)") == Self.id("gpt-5")) + #expect(Self.id("gpt-5(turbo)") == "gpt-5(turbo)") // unknown tiers stay distinct + #expect(Self.id("gpt-5 (high)") == "gpt-5 (high)") // spaced annotation stays, conservatively + } + + @Test + func `identity never strips semantic model suffixes`() { + #expect(Self.id("gpt-5-codex") != Self.id("gpt-5")) + #expect(Self.id("gpt-5-mini") != Self.id("gpt-5")) + #expect(Self.id("gpt-5-latest") != Self.id("gpt-5")) + #expect(Self.id("claude-sonnet-4-5-thinking") != Self.id("claude-sonnet-4-5")) + } + + @Test + func `identity survives empty and garbage names`() { + #expect(Self.id("").isEmpty) + #expect(Self.id(" ").isEmpty) + #expect(Self.id("///") == "///") + #expect(Self.id("anthropic/") == "anthropic") + #expect(Self.id("-20250807") == "-20250807") // a date dash with no model name stays + } + + private static func id(_ rawName: String, provider: UsageProvider? = nil) -> String { + SpendModelIdentity(rawName: rawName, provider: provider).id + } +} diff --git a/Tests/CodexBarTests/SpendModelsPresentationTests.swift b/Tests/CodexBarTests/SpendModelsPresentationTests.swift new file mode 100644 index 0000000000..5168ce2ddc --- /dev/null +++ b/Tests/CodexBarTests/SpendModelsPresentationTests.swift @@ -0,0 +1,637 @@ +import Foundation +import Testing +@testable import CodexBar + +struct SpendModelsPresentationTests { + @Test + func `dashboard range labels preserve compact order in English`() { + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect([7, 30, 365].map(spendDashboardDayRangeText) == ["7d", "30d", "Cumulative"]) + } + } + + @Test + func `model card date labels follow the app locale`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let day = try #require(calendar.date(from: DateComponents(year: 2026, month: 5, day: 2))) + + let expected = day.formatted(.dateTime.month(.abbreviated).day().locale(.autoupdatingCurrent)) + #expect(SpendModelsDateFormatter.dayText(day) == expected) + } + + @Test + func `All axis keeps endpoints without crowded trailing labels`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 5, day: 2))) + let last = try #require(calendar.date(byAdding: .day, value: 78, to: start)) + let domainEnd = try #require(calendar.date(byAdding: .day, value: 79, to: start)) + + let dates = SpendModelsAxisDates.make( + selectedDays: 365, + dataDays: [start, last], + domain: start...domainEnd, + calendar: calendar) + + #expect(dates.count == 6) + #expect(dates.first == start) + #expect(dates.last == last) + for pair in zip(dates, dates.dropFirst()) { + let gap = calendar.dateComponents([.day], from: pair.0, to: pair.1).day + #expect((gap ?? 0) >= 8) + } + } + + @Test + func `cumulative active domain stays inside the supplied focused window`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let legacy = try #require(calendar.date(from: DateComponents(year: 2025, month: 8, day: 1))) + let recent = try #require(calendar.date(from: DateComponents(year: 2026, month: 7, day: 1))) + let recentEnd = try #require(calendar.date(byAdding: .day, value: 30, to: recent)) + let focusedDomain = recent...recentEnd + + let domain = spendModelsActiveChartDomain( + selectedDays: 365, + dataDays: [legacy, recent, recent.addingTimeInterval(10 * 86400)], + chartDomain: focusedDomain, + calendar: calendar) + + #expect(domain.lowerBound >= focusedDomain.lowerBound) + #expect(domain.upperBound <= focusedDomain.upperBound) + } + + @Test + func `All axis replaces a near-duplicate trailing tick with the latest day`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 1, day: 1))) + let last = try #require(calendar.date(byAdding: .day, value: 57, to: start)) + let domainEnd = try #require(calendar.date(byAdding: .day, value: 58, to: start)) + + let dates = SpendModelsAxisDates.make( + selectedDays: 365, + dataDays: [start, last], + domain: start...domainEnd, + calendar: calendar) + + #expect(dates.count == 5) + #expect(dates.last == last) + let trailingGap = try #require(calendar.dateComponents( + [.day], + from: dates[dates.count - 2], + to: dates[dates.count - 1]).day) + #expect(trailingGap >= 8) + } + + @Test + func `every model remains a named chart series`() { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: (1...6).map { index in + Self.row(id: "model-\(index)", tokens: index * 10, cost: Double(index)) + }, + dailyValues: [ + .init( + modelID: "model-6", + modelName: "model-6", + day: Self.day, + totalTokens: 60, + inputTokens: 50, + outputTokens: 10, + estimatedCost: 6), + .init( + modelID: "model-1", + modelName: "model-1", + day: Self.day, + totalTokens: 10, + inputTokens: 8, + outputTokens: 2, + estimatedCost: 1), + ], + trackedTokenTotal: 210, + pricedCostTotal: 21, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .complete) + let presentation = SpendModelsPresentation(analysis: analysis, metric: .tokens) + + #expect(presentation.rows.map(\.source.id) == [ + "model-6", + "model-5", + "model-4", + "model-3", + "model-2", + "model-1", + ]) + #expect(presentation.series.map(\.id) == [ + "model-6", + "model-5", + "model-4", + "model-3", + "model-2", + "model-1", + ]) + #expect(presentation.series.last?.name == "model-1") + #expect(presentation.series.last?.value == 10) + #expect(presentation.points.map(\.seriesID) == ["model-6", "model-1"]) + #expect(presentation.points.map(\.stackStart) == [0, 60]) + #expect(presentation.points.map(\.stackEnd) == [60, 70]) + } + + @Test + func `token rows show in and out only when the split is complete`() { + let complete = SpendModelsPresentation.Row( + source: Self.row( + id: "complete", + tokens: 100, + inputTokens: 80, + outputTokens: 20, + cost: nil, + providers: ["Codex"]), + rank: 1, + value: 100, + share: 1) + let totalOnly = SpendModelsPresentation.Row( + source: Self.row( + id: "total", + tokens: 96, + cost: nil, + providers: ["Kimi"]), + rank: 2, + value: 96, + share: 1) + + #expect(spendModelsRowDetailText(complete) == "80 in · 20 out · Codex") + #expect(spendModelsRowDetailText(totalOnly) == "96 · Kimi") + } + + @Test + func `spend metric ranks priced rows before rows with no price`() { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [ + Self.row(id: "unpriced", tokens: 1000, cost: nil), + Self.row(id: "priced", tokens: 10, cost: 2), + ], + dailyValues: [], + trackedTokenTotal: 1010, + pricedCostTotal: 2, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .partial) + let presentation = SpendModelsPresentation(analysis: analysis, metric: .estimatedSpend) + + #expect(presentation.rows.map(\.source.id) == ["priced", "unpriced"]) + #expect(presentation.rows.first?.share == 1) + #expect(presentation.rows.last?.share == nil) + #expect(presentation.coverage == .partial) + } + + @Test + func `ranking primary value uses the selected metric unit`() { + let row = SpendModelsPresentation.Row( + source: Self.row(id: "priced", tokens: 632_000_000, cost: 89.29), + rank: 1, + value: 89.29, + share: 1) + + #expect(spendModelsRankingValueText(row, metric: .tokens) == "632M") + #expect(spendModelsRankingValueText(row, metric: .estimatedSpend) == "$89.29") + } + + @Test + func `ranking context keeps only the complementary metric`() { + let bucketed = SpendModelsPresentation.Row( + source: Self.row( + id: "bucketed", + tokens: 500_000_000, + inputTokens: 19_000_000, + outputTokens: 930_000, + cost: 405.12), + rank: 1, + value: 500_000_000, + share: 1) + let aggregateOnly = SpendModelsPresentation.Row( + source: Self.row(id: "aggregate", tokens: 632_000_000, cost: 89.29), + rank: 2, + value: 632_000_000, + share: 1) + + #expect(spendModelsRankingContextText(bucketed, metric: .tokens) == "$405.12") + #expect(spendModelsRankingContextText(aggregateOnly, metric: .tokens) == "$89.29") + #expect(spendModelsRankingContextText(aggregateOnly, metric: .estimatedSpend) == "632M tokens") + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect(spendModelsRankingContextText(aggregateOnly, metric: .estimatedSpend) == "632M 令牌") + } + } + + @Test + func `chart values follow the selected metric unit`() { + #expect(spendModelsChartMetricText(632_000_000, metric: .tokens) == "632M") + #expect(spendModelsChartMetricText(89.29, metric: .estimatedSpend) == "$89.29") + } + + @Test + func `estimated spend chart uses daily costs for bar heights`() { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [ + Self.row(id: "expensive", tokens: 10, cost: 8), + Self.row(id: "token-heavy", tokens: 1000, cost: 2), + ], + dailyValues: [ + Self.dailyValue(modelID: "expensive", day: Self.day, totalTokens: 10, cost: 8), + Self.dailyValue(modelID: "token-heavy", day: Self.day, totalTokens: 1000, cost: 2), + ], + trackedTokenTotal: 1010, + pricedCostTotal: 10, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .complete) + + let tokens = SpendModelsPresentation(analysis: analysis, metric: .tokens) + let spend = SpendModelsPresentation(analysis: analysis, metric: .estimatedSpend) + + #expect(tokens.points.map(\.value) == [1000, 10]) + #expect(tokens.points.last?.stackEnd == 1010) + #expect(spend.points.map(\.value) == [8, 2]) + #expect(spend.points.last?.stackEnd == 10) + #expect(spend.dailyTotals.map(\.value) == [10]) + #expect(spend.dailyTotals.first?.stackStart == 0) + #expect(spend.dailyTotals.first?.stackEnd == 10) + } + + @Test + func `ranking keeps every row visible at the collapsed limit`() { + #expect(SpendModelsRanking.collapsedRowLimit == 5) + let rows = Self.rankingRows(count: SpendModelsRanking.collapsedRowLimit) + + #expect(!SpendModelsRanking.showsDisclosure(rowCount: rows.count)) + #expect(SpendModelsRanking.visibleRows(rows, showsAll: false).map(\.id) == rows.map(\.id)) + #expect(SpendModelsRanking.visibleRows(rows, showsAll: true).map(\.id) == rows.map(\.id)) + } + + @Test + func `ranking truncates rows beyond the collapsed limit until expanded`() { + let rows = Self.rankingRows(count: SpendModelsRanking.collapsedRowLimit + 5) + + #expect(SpendModelsRanking.showsDisclosure(rowCount: rows.count)) + + let collapsed = SpendModelsRanking.visibleRows(rows, showsAll: false) + #expect(collapsed.count == SpendModelsRanking.collapsedRowLimit) + #expect(collapsed.map(\.id) == rows.prefix(SpendModelsRanking.collapsedRowLimit).map(\.id)) + #expect(collapsed.last?.rank == SpendModelsRanking.collapsedRowLimit) + + let expanded = SpendModelsRanking.visibleRows(rows, showsAll: true) + #expect(expanded.map(\.id) == rows.map(\.id)) + } + + @Test + func `dashboard range labels localize with the app language`() { + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect([7, 30, 365].map(spendDashboardDayRangeText) == ["7 天", "30 天", "累计"]) + } + } + + @Test + func `token row detail localizes the in and out split`() { + let row = SpendModelsPresentation.Row( + source: Self.row( + id: "complete", + tokens: 100, + inputTokens: 80, + outputTokens: 20, + cost: nil, + providers: ["Codex"]), + rank: 1, + value: 100, + share: 1) + + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect(spendModelsRowDetailText(row) == "80 输入 · 20 输出 · Codex") + } + } + + @Test + func `trailing average smooths each series over fewer samples at the window edge`() { + let days = (0..<4).map { Self.day.addingTimeInterval(Double($0) * 86400) } + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [Self.row(id: "model-a", tokens: 100, cost: nil)], + dailyValues: zip(days, [10, 20, 30, 40]).map { day, tokens in + Self.dailyValue(modelID: "model-a", day: day, totalTokens: tokens) + }, + trackedTokenTotal: 100, + pricedCostTotal: nil, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .partial) + + let smoothed = SpendModelsPresentation(analysis: analysis, metric: .tokens).applyingTrailingAverage() + + #expect(SpendModelsPresentation.trailingAverageWindow == 7) + #expect(smoothed.points.map(\.day) == days) + #expect(smoothed.points.map(\.value) == [10, 15, 20, 25]) + #expect(smoothed.points.map(\.stackStart) == [0, 0, 0, 0]) + #expect(smoothed.points.map(\.stackEnd) == [10, 15, 20, 25]) + // Ranking stays raw: only the chart points are smoothed. + #expect(smoothed.rows.map(\.value) == [100]) + #expect(smoothed.series.map(\.value) == [100]) + } + + @Test + func `trailing average treats days without series data as zero samples`() { + let days = (0..<4).map { Self.day.addingTimeInterval(Double($0) * 86400) } + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [ + Self.row(id: "model-a", tokens: 70, cost: nil), + Self.row(id: "model-b", tokens: 40, cost: nil), + ], + dailyValues: [ + Self.dailyValue(modelID: "model-a", day: days[0], totalTokens: 10), + Self.dailyValue(modelID: "model-a", day: days[1], totalTokens: 20), + Self.dailyValue(modelID: "model-a", day: days[3], totalTokens: 40), + Self.dailyValue(modelID: "model-b", day: days[0], totalTokens: 4), + Self.dailyValue(modelID: "model-b", day: days[1], totalTokens: 8), + Self.dailyValue(modelID: "model-b", day: days[2], totalTokens: 12), + Self.dailyValue(modelID: "model-b", day: days[3], totalTokens: 16), + ], + trackedTokenTotal: 110, + pricedCostTotal: nil, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .partial) + + let smoothed = SpendModelsPresentation(analysis: analysis, metric: .tokens) + .applyingTrailingAverage(window: 2) + + // model-a: [10, 15, 10, 20]; model-b: [4, 6, 10, 14]. Series stack in ranking order. + #expect(smoothed.points.map(\.seriesID) == [ + "model-a", "model-b", + "model-a", "model-b", + "model-a", "model-b", + "model-a", "model-b", + ]) + #expect(smoothed.points.map(\.value) == [10, 4, 15, 6, 10, 10, 20, 14]) + #expect(smoothed.points.map(\.stackStart) == [0, 10, 0, 15, 0, 10, 0, 20]) + #expect(smoothed.points.map(\.stackEnd) == [10, 14, 15, 21, 10, 20, 20, 34]) + } + + @Test + func `day detail aggregates buckets and sorts models by tokens`() throws { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [ + Self.row( + id: "model-a", + tokens: 130, + cost: 1.5, + providers: ["Codex"], + costIsEstimated: true), + Self.row(id: "model-b", tokens: 50, cost: 0.5, providers: ["Kimi"]), + ], + dailyValues: [ + Self.dailyValue( + modelID: "model-a", + day: Self.day, + totalTokens: 100, + inputTokens: 80, + outputTokens: 20, + cost: 1.5, + cacheReadTokens: 15, + cacheCreationTokens: 5, + reasoningTokens: 4), + Self.dailyValue( + modelID: "model-b", + day: Self.day, + totalTokens: 50, + inputTokens: 30, + outputTokens: 20, + cost: 0.5), + ], + trackedTokenTotal: 180, + pricedCostTotal: 2, + sourceCount: 2, + tokenCoverage: .complete, + costCoverage: .complete) + + let detail = try #require(SpendModelsDayDetailPresentation( + analysis: analysis, + day: Self.day, + metric: .estimatedSpend)) + + #expect(detail.totalTokens == 150) + #expect(detail.totalCost == 2) + #expect(detail.buckets.map(\.kind) == [.input, .output, .cacheRead, .cacheWrite, .reasoning]) + #expect(detail.buckets.map(\.tokens) == [110, 40, 15, 5, 4]) + + #expect(detail.models.map(\.id) == ["model-a", "model-b"]) + #expect(detail.pricedModelCount == 2) + let first = try #require(detail.models.first) + #expect(first.modelProvider == .openai) + #expect(first.providerNames == ["Codex"]) + #expect(first.costIsEstimated) + #expect(first.buckets.map(\.kind) == [.input, .output, .cacheRead, .cacheWrite, .reasoning]) + #expect(first.buckets.map(\.tokens) == [80, 20, 15, 5, 4]) + #expect(detail.models.last?.buckets.map(\.kind) == [.input, .output]) + } + + @Test + func `day detail with overflowing aggregate buckets exposes them as unavailable`() throws { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [ + Self.row(id: "model-a", tokens: Int.max, cost: nil), + Self.row(id: "model-b", tokens: 1, cost: nil), + ], + dailyValues: [ + Self.dailyValue( + modelID: "model-a", + day: Self.day, + totalTokens: Int.max, + inputTokens: Int.max), + Self.dailyValue( + modelID: "model-b", + day: Self.day, + totalTokens: 1, + inputTokens: 1), + ], + trackedTokenTotal: nil, + pricedCostTotal: nil, + sourceCount: 2, + tokenCoverage: .complete, + costCoverage: .partial) + + let detail = try #require(SpendModelsDayDetailPresentation( + analysis: analysis, + day: Self.day, + metric: .tokens)) + + #expect(detail.totalTokens == nil) + #expect(detail.buckets.isEmpty) + #expect(detail.models.allSatisfy { !$0.buckets.isEmpty }) + } + + @Test + func `day detail model summary keeps bucket splits behind disclosure`() { + let priced = SpendModelsDayDetailPresentation.Model( + id: "priced", + name: "Priced", + modelProvider: .openai, + providerNames: ["Codex"], + totalTokens: 80, + cost: 8, + costIsEstimated: true, + buckets: [ + .init(kind: .input, tokens: 60), + .init(kind: .output, tokens: 20), + ]) + let unpriced = SpendModelsDayDetailPresentation.Model( + id: "unpriced", + name: "Unpriced", + modelProvider: .gemini, + providerNames: ["Antigravity"], + totalTokens: 20, + cost: nil, + costIsEstimated: false, + buckets: []) + + #expect(spendModelsDayDetailModelSummaryText( + priced, + metric: .tokens, + totalTokens: 100, + totalCost: 8) == "80 · 80%") + #expect(spendModelsDayDetailModelSummaryText( + priced, + metric: .estimatedSpend, + totalTokens: 100, + totalCost: 8) == "$8.00 · 100%") + #expect(spendModelsDayDetailModelSummaryText( + unpriced, + metric: .estimatedSpend, + totalTokens: 100, + totalCost: 8) == "20 · Unavailable") + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect(spendModelsDayDetailModelSummaryText( + unpriced, + metric: .estimatedSpend, + totalTokens: 100, + totalCost: 8) == "20 · 不可用") + } + #expect(spendModelsDayDetailModelSplitText(priced) == "60 in · 20 out") + } + + @Test + func `day detail hides the category bar when no bucket data exists`() throws { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [Self.row(id: "model-a", tokens: 50, cost: nil)], + dailyValues: [ + Self.dailyValue(modelID: "model-a", day: Self.day, totalTokens: 50), + ], + trackedTokenTotal: 50, + pricedCostTotal: nil, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .partial) + + let detail = try #require(SpendModelsDayDetailPresentation( + analysis: analysis, + day: Self.day, + metric: .tokens)) + + #expect(detail.buckets.isEmpty) + let model = try #require(detail.models.first) + #expect(model.buckets.isEmpty) + #expect(spendModelsDayDetailModelSplitText(model) == "50") + } + + @Test + func `day detail is nil outside the charted range and matches inside`() { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [Self.row(id: "model-a", tokens: 10, cost: nil)], + dailyValues: [ + Self.dailyValue(modelID: "model-a", day: Self.day, totalTokens: 10), + ], + trackedTokenTotal: 10, + pricedCostTotal: nil, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .partial) + let presentation = SpendModelsPresentation(analysis: analysis, metric: .tokens) + let outside = Self.day.addingTimeInterval(10 * 86400) + + #expect(presentation.day(matching: Self.day) == Self.day) + #expect(presentation.day(matching: outside) == nil) + #expect(SpendModelsDayDetailPresentation(analysis: analysis, day: outside, metric: .tokens) == nil) + } + + @Test + func `day detail bucket text localizes the cache read label`() { + let bucket = SpendModelsDayDetailPresentation.Bucket(kind: .cacheRead, tokens: 15) + + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hans") { + #expect(spendModelsDayDetailBucketText(bucket) == "15 缓存读取") + } + } + + private static func row( + id: String, + tokens: Int?, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cost: Double?, + providers: [String] = [], + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, + costIsEstimated: Bool = false) -> SpendDashboardModel.ModelAnalysisRow + { + SpendDashboardModel.ModelAnalysisRow( + id: id, + displayName: id, + rawModelNames: [id], + providers: [], + providerNames: providers, + contributions: [], + totalTokens: tokens, + inputTokens: inputTokens, + outputTokens: outputTokens, + estimatedCost: cost, + cacheReadTokens: cacheReadTokens, + cacheCreationTokens: cacheCreationTokens, + reasoningTokens: reasoningTokens, + costIsEstimated: costIsEstimated) + } + + private static func dailyValue( + modelID: String, + day: Date, + totalTokens: Int?, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cost: Double? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil) -> SpendDashboardModel.ModelDailyValue + { + SpendDashboardModel.ModelDailyValue( + modelID: modelID, + modelName: modelID, + day: day, + totalTokens: totalTokens, + inputTokens: inputTokens, + outputTokens: outputTokens, + estimatedCost: cost, + cacheReadTokens: cacheReadTokens, + cacheCreationTokens: cacheCreationTokens, + reasoningTokens: reasoningTokens) + } + + private static func rankingRows(count: Int) -> [SpendModelsPresentation.Row] { + (1...count).map { index in + SpendModelsPresentation.Row( + source: Self.row(id: "model-\(index)", tokens: index, cost: nil), + rank: index, + value: Double(index), + share: nil) + } + } + + private static let day = Date(timeIntervalSince1970: 1_784_179_200) +} diff --git a/Tests/CodexBarTests/SpendToolPresentationTests.swift b/Tests/CodexBarTests/SpendToolPresentationTests.swift new file mode 100644 index 0000000000..ea5922857d --- /dev/null +++ b/Tests/CodexBarTests/SpendToolPresentationTests.swift @@ -0,0 +1,194 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SpendToolPresentationTests { + @Test + func `tool identity separates product family from client kind`() { + #expect(SpendToolIdentity.resolve( + provider: .codex, + sourceName: "Codex", + providerName: "Codex") == .init(displayName: "Codex Desktop", kind: .desktop)) + #expect(SpendToolIdentity.resolve( + provider: .cursor, + sourceName: "Cursor", + providerName: "Cursor") == .init(displayName: "Cursor", kind: .ide)) + #expect(SpendToolIdentity.resolve( + provider: .kimi, + sourceName: "Kimi Code CLI", + providerName: "Kimi") == .init(displayName: "Kimi Code CLI", kind: .cli)) + #expect(SpendToolIdentity.resolve( + provider: .qoder, + sourceName: "Qoder", + providerName: "Qoder") == .init(displayName: "Qoder", kind: .ide)) + } + + @Test + func `tool cards aggregate token buckets once per source`() throws { + let model = Self.model() + let groups = SpendClientBreakdown.groups(from: model.modelAnalysis) + let codex = try #require(groups.first { $0.provider == .codex }) + let cursor = try #require(groups.first { $0.provider == .cursor }) + + #expect(codex.kind == .desktop) + #expect(codex.inputTokens == 40) + #expect(codex.outputTokens == 10) + #expect(codex.cacheReadTokens == 20) + #expect(codex.reasoningTokens == 4) + #expect(codex.requestCount == 2) + #expect(codex.models.first?.modelProvider == .openai) + #expect(cursor.kind == .ide) + #expect(cursor.inputTokens == 20) + #expect(cursor.outputTokens == 10) + #expect(cursor.cacheReadTokens == 10) + #expect(cursor.reasoningTokens == 2) + #expect(cursor.requestCount == 2) + #expect(cursor.models.first?.modelProvider == .openai) + } + + @Test + func `same model comparison keeps observed tool evidence separate`() throws { + let comparisons = SpendToolComparisonPresentation.comparisons(from: Self.model().modelAnalysis) + let comparison = try #require(comparisons.first) + let codex = try #require(comparison.tools.first { $0.provider == .codex }) + let cursor = try #require(comparison.tools.first { $0.provider == .cursor }) + + #expect(comparisons.count == 1) + #expect(comparison.displayName == "GPT-5 Test") + #expect(codex.requestCount == 2) + #expect(cursor.requestCount == 2) + #expect(codex.contextReuseRate == 20.0 / 60.0) + #expect(cursor.contextReuseRate == 10.0 / 30.0) + #expect(codex.costPerMillionTokens == 1_000_000.0 / 70.0) + #expect(cursor.costPerMillionTokens == 2_000_000.0 / 40.0) + } + + @Test + func `context reuse stays unavailable when any input bucket is unknown`() { + #expect(SpendToolComparisonPresentation.contextReuseRate( + input: 10, + cacheRead: 20, + cacheCreation: nil) == nil) + #expect(SpendToolComparisonPresentation.contextReuseRate( + input: 0, + cacheRead: 0, + cacheCreation: 0) == nil) + } + + @Test + func `token chart partitions each day by semantic token bucket`() { + let presentation = SpendModelsTokenChartPresentation(analysis: Self.model().modelAnalysis) + + #expect(presentation.points.map(\.kind) == [ + .input, + .cacheRead, + .output, + .reasoning, + ]) + #expect(presentation.points.map(\.value) == [60, 30, 14, 6]) + #expect(presentation.points.last?.stackEnd == 110) + } + + @Test + func `token chart exposes one total bar per day while retaining bucket detail`() throws { + let presentation = SpendModelsTokenChartPresentation(analysis: Self.model().modelAnalysis) + let total = try #require(presentation.dailyTotals.first) + + #expect(presentation.dailyTotals.count == 1) + #expect(total.kind == nil) + #expect(total.stackStart == 0) + #expect(total.stackEnd == 110) + #expect(total.value == 110) + } + + @Test + func `daily spend details preserve tool type models amounts and tokens`() throws { + let group = try #require(Self.model().groups.first) + let detail = try #require(group.dailySpendDetails.first) + let codex = try #require(detail.tools.first { $0.provider == .codex }) + + #expect(codex.kind == .desktop) + #expect(codex.displayName == "Codex Desktop") + #expect(codex.tokens == 70) + #expect(codex.cost == 1) + #expect(codex.models.first?.displayName == "GPT-5 Test") + #expect(codex.models.first?.modelProvider == .openai) + } + + private static func model() -> SpendDashboardModel { + let inputs = [ + Self.input( + provider: .codex, + name: "Codex", + tokens: .init(input: 40, output: 10, cache: 20, reasoning: 4), + cost: 1), + Self.input( + provider: .cursor, + name: "Cursor", + tokens: .init(input: 20, output: 10, cache: 10, reasoning: 2), + cost: 2), + ] + return SpendDashboardModel.build( + inputs: inputs, + requestedDays: 7, + now: Self.now, + calendar: Self.calendar) + } + + private static func input( + provider: UsageProvider, + name: String, + tokens: TokenBuckets, + cost: Double) -> SpendDashboardModel.ProviderInput + { + let total = tokens.input + tokens.output + tokens.cache + let breakdown = CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5-test", + costUSD: cost, + totalTokens: total, + inputTokens: tokens.input, + cacheReadTokens: tokens.cache, + cacheCreationTokens: 0, + outputTokens: tokens.output, + reasoningTokens: tokens.reasoning, + requestCount: 2) + let entry = CostUsageDailyReport.Entry( + date: "2026-07-27", + inputTokens: tokens.input, + outputTokens: tokens.output, + cacheReadTokens: tokens.cache, + cacheCreationTokens: 0, + totalTokens: total, + requestCount: 2, + costUSD: cost, + modelsUsed: ["gpt-5-test"], + modelBreakdowns: [breakdown]) + return SpendDashboardModel.ProviderInput( + provider: provider, + displayName: name, + snapshot: CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: total, + last30DaysCostUSD: cost, + currencyCode: "USD", + historyDays: 7, + daily: [entry], + updatedAt: Self.now)) + } + + private struct TokenBuckets { + let input: Int + let output: Int + let cache: Int + let reasoning: Int + } + + private static let now = Date(timeIntervalSince1970: 1_785_240_000) + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() +} diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000000..59617e1532 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,263 @@ +**Comparison Target** + +- Source visual truth: + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-d61a0b80-5855-45df-81be-5866b387794d.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-c475cb6b-a430-4395-9f01-1d443b2ab3d6.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-f2c06b25-eeb1-4f46-ab86-80154c23557c.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-05011861-8496-4d9c-9864-4de48d51d187.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-9c6899c9-9fc8-4131-a50b-73fd69ba3a3d.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-aff7bf4c-f7db-493f-b03d-c104242fa7c6.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-286cecb4-20e8-4b7c-9c4b-b813c065d5a5.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-f5087642-7171-4aaa-b7e4-a782bbc912e9.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-d984c2e7-2c5d-4b64-b225-a9f2890c5718.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-8831ab26-5ea9-4bba-ad7b-ca2c4bf985a0.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-b3cf82b8-1e7b-4f4f-8825-cfaa82692c6e.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-31718974-22ba-4050-99b5-6355126198b9.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-2f7d0845-7d43-4002-82ba-0c3ab5632d5c.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-87f47e7e-e40e-481e-8eff-429bef7aff9e.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-7bfa21e8-6b4b-4d7a-acb7-64976e967fe3.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-0f2d65c9-1c83-4d87-a614-5bf1903f286a.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-c792e6a9-dca7-4fbc-88a8-70db4e9eff1e.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-b080cfa6-b019-40eb-bdc2-6f0364e3a96e.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-0dc00a25-f3e3-476d-9462-9b18533124d8.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-4225b455-dc8c-4d5d-b31f-426e225d2316.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-1344fb2c-0cd7-4d6f-b8f9-87ebd5cb87f3.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-3786cefd-33c0-43b7-9732-2a0e8a9551d9.png` + - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-b5131666-4aaa-4192-bed8-3b44b357041e.png` +- Implementation screenshot: + `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/com.openai.sky.CUAService/CodexBar Screenshot 2026-07-28 at 11.55.52 PM.jpeg` +- Focused comparison: + `/private/tmp/codexbar-subscription-comparison.png` +- Viewport: CodexBar macOS Settings > Usage & Spend > Models +- Source pixels: 1184 x 598; implementation pixels: 880 x 620 +- Density normalization: the implementation subscription card was cropped and + scaled to the source height before side-by-side comparison. +- State: 30-day range, grouped by model, token mode, subscription summary visible + +**Full-view Comparison Evidence** + +The signed development build compiled, passed code-sign validation, launched +successfully from this checkout, and rendered the populated Usage & Spend +settings after its background history scan. The full-window capture verifies +the 30-day controls, summary metrics, one-line subscription card, compact +144-point model chart, ranking controls, and first five ranked models together. + +**Focused Region Comparison Evidence** + +The focused side-by-side comparison places the supplied two-line subscription +card beside the rendered one-line implementation. Provider, plan, and amount +now share one baseline; rank and icon columns remain aligned; all six rows fit +in materially less vertical space without truncating the three available plan +names. The rendered model chart remains readable directly below the card. + +Rendered and automated evidence also confirms: + +- The Usage & Spend hierarchy uses one controlled optical scale: 15-point + semibold tool titles, 14-point regular model titles, and caption-sized + secondary metadata. This keeps the surfaces consistent while making the + tool/model relationship immediately visible. +- Provider glyphs use an 18-point optical size inside a consistent 22-point + alignment frame. +- Ranking and tool-card spacing was tightened without shrinking the primary + content or changing unrelated CodexBar surfaces. +- Model rankings default to five rows, preserve ordinal numbers, and expose a + compact token/estimated-spend sort control. Expanding and changing the sort + are independent, persistent interactions. +- Both history charts share the same 144-point plot height, leaving more room + for the actionable ranked and selected-day details below. +- Token and estimated-spend modes are merged into one token-category chart; + estimated cost is shown alongside token totals in the concise day tooltip. +- The 7-day rolling-average control was removed from the primary UI because it + obscured the exact daily values used by hover and pinned details. +- Hover now contains only date, total tokens, and estimated spend. Bucket and + per-model splits remain in the pinned day detail below the chart. +- Daily estimated spend now follows the same hover/click contract as the model + chart: hover is a two-value summary, click pins the date, and the tool/model + breakdown is rendered below the chart with explicit tool-kind badges. +- Tool cards now use a true parent/child layout: the tool header owns the first + column, the token summary aligns with the tool title, and the complete model + list moves 48 points into a second column. Child-only dividers no longer run + through the parent column. +- Both charts resolve clicks against screen-space day positions. Dense dates + now form continuous nearest-day lanes, so there is no dead strip between + adjacent active dates. The first and last lanes extend by at least 14 points + while genuinely distant outer space stays empty. +- Both charts support click-and-drag scrubbing across dates. Hovered or pinned + dates receive a translucent 18-point column highlight plus the persistent + accent rule, and the chart surface uses the pointing-hand cursor. +- Model rankings now carry ordinal numbers, and pinned-day plus tool-card model + rows use their resolved model-provider icon instead of a generic blue square + or the containing tool's icon. +- Ranking rows use a stable two-column hierarchy. The right column owns the + selected metric and its share: token mode renders compact token counts, while + estimated-spend mode renders full currency values. The left subtitle contains + only explanatory bucket or complementary-token context. +- The ranking metric control now owns the complete model-analysis state. Token + mode charts daily token buckets with token-formatted Y-axis labels; estimated + spend mode charts the sum of priced model costs for each day with + currency-formatted Y-axis labels. Pinned-day details follow the same metric. +- Hover and click now resolve dates from the same rendered screen-space lanes. + Hover adds a bounded 12-percent hysteresis zone around adjacent lane + boundaries, while large multi-column movements still catch up immediately. +- Ranking model rows now share the pinned-day model row's system body font, + 16-point provider icon, and 20-point icon frame, with reduced row padding. +- Aggregate-only token rows label their complementary estimate explicitly, and + the disclosure is now a compact capsule with a directional chevron instead of + looking like an unstyled line of body text. +- Existing provider-icon, token-chart, hover-detail, and quota-warning work in + the checkout was preserved. + +**Findings** + +- No actionable P0/P1/P2 visual differences remain in the requested + subscription-density and chart-height scope. + +**Comparison History** + +- Iteration 1: provider glyphs were recolored using chart palette colors. +- Iteration 2: first-party assets and explicit original/template rendering modes + replaced provider tinting; provenance is recorded in + `docs/provider-icon-sources.md`. +- Iteration 3: semantic token stacks, exact day hover details, tool-type labels, + and tool-level token aggregation replaced visually ambiguous single-blue + stacks and repeated per-model metadata. +- Iteration 4: fixed 13/14-point text was replaced by the macOS semantic + typography hierarchy, with consistent icon alignment and denser but readable + model/tool rows. +- Iteration 5: duplicated token/spend modes and the rolling-average switch were + removed; hover was reduced to two headline values, while ranks and + model-provider icons were added to the scan-heavy lists. +- Iteration 6: the optical hierarchy now distinguishes 15-point tool titles from + 14-point model rows; ranking defaults to five rows and can sort by token or + spend; both charts use a 168-point plot and the daily chart gained pinned + date details. +- Iteration 7: model rows moved into a visibly indented child column with scoped + dividers, and chart clicks gained bounded screen-space hit targets plus a + stronger selected-day rule. +- Iteration 8: independent click radii were replaced with continuous + nearest-day Voronoi lanes; click-and-drag scrubbing, a pointing-hand cursor, + and full-column interaction highlighting were added to both charts. +- Iteration 9: ranking rows moved the active metric out of the subtitle and into + a dedicated right-aligned value column above the percentage. Estimated spend + gained currency formatting, token and spend context were separated, and the + expand control gained a clear disclosure treatment. +- Iteration 10: the token/estimated-spend ranking control was promoted to a + shared chart metric. Switching it now changes daily bar heights, Y-axis units, + selected-day details, valid interaction dates, legend visibility, and ranking + order together while preserving the approved hit-target behavior. +- Iteration 11: hover date selection was moved from calendar-distance lookup to + the chart's rendered date lanes and given bounded boundary hysteresis. + Ranking typography, provider-icon size, and row rhythm were matched to the + pinned-day model list to reduce density and remove the visual jump. +- Iteration 12: the complete Usage & Spend surface was audited for typography + drift. Model rows, values, tool titles, section titles, metadata, badges, + axes, legends, disclosures, and segmented controls now use one shared + 15/14/13/12/11-point hierarchy. Model rows across pinned-day, ranking, + daily-spend, and tool cards share the same 16-point icon in a 20-point frame. +- Iteration 13: token and estimated-spend chart modes now share one stable + vertical structure. Both render a single accent-colored daily-total bar; the + token-only five-item legend was removed, while the semantic input, output, + cache, and reasoning buckets remain available in the pinned-day detail. +- Iteration 14: pinned-day model names remain 14-point primary content while + their token splits and estimated costs move to 12-point secondary content. +- Iteration 15: subscription rows use a quieter 13-point name/value tier with + 11-point rank and plan metadata, 18-point provider marks, and tighter row + rhythm so the provider summary no longer competes with model analysis. + Ranking and aligned monetary values use a 14-point medium weight instead of + semibold, and Simplified Chinese now renders the complementary metric as + “令牌” instead of the mixed-script “token 用量”. +- Iteration 15: model ranking rows were flattened into a single scan line. + Token sorting now shows cost as its compact complementary value, spend + sorting shows total tokens, and the selected value plus share stay together + at the trailing edge. Input, output, cache, and reasoning splits remain in + the pinned-day detail instead of repeating throughout the ranking. +- Iteration 16: pinned-day model rows now default to one summary value and + share instead of repeating every token bucket. Token mode keeps the aggregate + bucket composition once at day level; spend mode replaces it with explicit + priced-model coverage. Per-model token splits are available through a compact + disclosure and reset when the selected day or metric changes. +- Iteration 17: native QA first captured a stale running process that still + showed plan names on a second line. After quitting and relaunching the freshly + packaged app, its background scan produced a true one-line provider/plan + layout. The focused side-by-side comparison verifies the intended density, + and both history charts now use a 144-point plot. +- Iteration 18: cumulative model charts now treat isolated legacy samples as + framing noise only when they precede a gap of at least 21 days, represent no + more than 10% of active dates, and contribute no more than 5% of either tokens + or spend. The scan evaluates qualifying gaps from left to right, so a larger + normal gap later in the history cannot preserve a negligible early island. + Rankings, totals, and drill-down history retain every sample. +- Iteration 18 native capture is pending. The packaged app passed signing and + model-domain regression tests, but the local SkyComputerUseService repeatedly + crashed while resolving three installed CodexBar copies with the same bundle + identifier. The attempted isolated QA copy could not be created because the + execution approval service had reached its usage limit. Do not treat the + pre-restart February-axis screenshot as evidence for the final build. + +**Implementation Checklist** + +- Capture model, tool, and daily-spend hover states from the development app. +- Verify the 15-point semibold tool title reads as one level above the 14-point + model row without making MiniMax Code or other provider titles look oversized. +- Verify captions remain readable at default macOS text size and that long + localized labels do not collide with values. +- Verify provider icons remain optically balanced inside their 22-point frames + in light and dark appearance. +- Verify tooltip placement does not obscure the hovered bar at narrow and wide + Settings window widths. +- Verify the compact tooltip shows only total tokens and estimated spend, and + clicking a bar exposes the detailed token buckets below. +- Verify both ranking sort modes, the five-row collapsed state, expansion, and + collapse remain readable with long localized model names. +- Verify the daily-spend click target pins the intended day and presents the + tool/model hierarchy below without duplicating the large hover card. +- Verify the 48-point child indent remains readable at the narrowest supported + Settings width and that long model values still retain adequate trailing room. +- Verify every position between adjacent active dates selects the intended + nearest day, dragging scrubs without toggling dates off, and distant outer + regions do not unexpectedly select old activity. +- Verify rank numbers align as one column and that model icons remain correct + when the model is used through a different tool provider. +- Verify long model names leave enough room for the 84-point metric column, + currency values retain symbols and decimals, and the disclosure capsule + remains compact in all supported localizations. +- Verify switching to estimated spend visibly rescales the chart to currency, + removes the token-bucket legend, and preserves the selected-day click and + drag behavior; switching back must restore token buckets without stale dates. +- Verify slow movement near a midpoint remains on the current date until the + pointer clearly enters the neighboring lane, while a quick sweep across + several columns follows immediately. +- Verify ranking and pinned-day model rows now have matching optical title and + icon sizes, and that five collapsed rows fit comfortably in the panel. +- Verify the shared 15/14/13/12/11-point hierarchy remains visually distinct + without any default SwiftUI font leaking into model, tool, subscription, + chart-axis, legend, badge, or disclosure content. +- Verify switching between token and estimated-spend modes leaves the ranking + control and first ranked row at the same vertical position, and clicking a + token bar still reveals every available semantic token bucket. +- Verify long pinned-day token splits read as secondary data rather than + competing with model names, and that ranking currency values no longer look + like a second set of section headings. +- Verify collapsed and expanded rankings stay one line per model in both sort + modes, long model names truncate before the trailing value, and no token + bucket breakdown leaks back into the ranking. +- Verify token mode shows one aggregate category breakdown, spend mode shows + priced-model coverage instead of token categories, unpriced rows remain + understandable, and expanding one model reveals only that model's buckets. +- Verify cumulative token and spend charts begin near the first meaningful + activity cluster when a negligible legacy island precedes a 21+ day gap, and + that meaningful early activity remains visible. + +**Open Questions** + +- Re-run the cumulative-model native capture after the desktop-control service + is available; compare the first visible axis label against the actual first + meaningful activity cluster. + +**Follow-up Polish** + +- Adjust individual optical icon sizing or row spacing only if the eventual + native capture reveals a concrete imbalance; keep the semantic type hierarchy + shared across providers and tools. + +final result: blocked diff --git a/docs/provider-icon-sources.md b/docs/provider-icon-sources.md new file mode 100644 index 0000000000..8c9efa3633 --- /dev/null +++ b/docs/provider-icon-sources.md @@ -0,0 +1,16 @@ +# Provider icon sources + +The spend dashboard must use first-party marks. Chart-series colors remain an +independent visualization concern and must not recolor provider icons. + +| Provider | First-party source | Resource treatment | +| --- | --- | --- | +| OpenAI / Codex | https://openai.com/brand/ | Monochrome OpenAI mark, rendered as a template for light/dark appearance | +| Cursor | https://cursor.com/brand | `CUBE_2D_LIGHT.svg` from Cursor's downloadable brand assets, rendered as a template for light/dark appearance | +| Gemini | https://about.google/products/ | Current multicolor Gemini product icon, preserved in original color | +| Google Antigravity | https://antigravity.google/press | `Icon - Full Color` from the official press kit, preserved in original color | +| Claude | https://claude.ai/favicon.ico | Current Claude product icon, preserved in original color | +| Kimi | https://www.kimi.com/favicon.ico | Current Kimi product icon, preserved in original color | +| MiniMax | https://platform.minimax.io/docs/faq/contact-us | Symbol extracted without redrawing from the official `MiniMax_Logo.zip` package, preserving its pink-to-coral gradient | + +Last verified: 2026-07-27. From 564941d05196b182502f027eb77a5525eb308110 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:50:56 +0800 Subject: [PATCH 31/40] chore: remove local design QA scratch file --- design-qa.md | 263 --------------------------------------------------- 1 file changed, 263 deletions(-) delete mode 100644 design-qa.md diff --git a/design-qa.md b/design-qa.md deleted file mode 100644 index 59617e1532..0000000000 --- a/design-qa.md +++ /dev/null @@ -1,263 +0,0 @@ -**Comparison Target** - -- Source visual truth: - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-d61a0b80-5855-45df-81be-5866b387794d.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-c475cb6b-a430-4395-9f01-1d443b2ab3d6.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-f2c06b25-eeb1-4f46-ab86-80154c23557c.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-05011861-8496-4d9c-9864-4de48d51d187.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-9c6899c9-9fc8-4131-a50b-73fd69ba3a3d.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-aff7bf4c-f7db-493f-b03d-c104242fa7c6.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-286cecb4-20e8-4b7c-9c4b-b813c065d5a5.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-f5087642-7171-4aaa-b7e4-a782bbc912e9.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-d984c2e7-2c5d-4b64-b225-a9f2890c5718.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-8831ab26-5ea9-4bba-ad7b-ca2c4bf985a0.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-b3cf82b8-1e7b-4f4f-8825-cfaa82692c6e.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-31718974-22ba-4050-99b5-6355126198b9.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-2f7d0845-7d43-4002-82ba-0c3ab5632d5c.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-87f47e7e-e40e-481e-8eff-429bef7aff9e.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-7bfa21e8-6b4b-4d7a-acb7-64976e967fe3.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-0f2d65c9-1c83-4d87-a614-5bf1903f286a.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-c792e6a9-dca7-4fbc-88a8-70db4e9eff1e.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-b080cfa6-b019-40eb-bdc2-6f0364e3a96e.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-0dc00a25-f3e3-476d-9462-9b18533124d8.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-4225b455-dc8c-4d5d-b31f-426e225d2316.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-1344fb2c-0cd7-4d6f-b8f9-87ebd5cb87f3.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-3786cefd-33c0-43b7-9732-2a0e8a9551d9.png` - - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/codex-clipboard-b5131666-4aaa-4192-bed8-3b44b357041e.png` -- Implementation screenshot: - `/var/folders/xh/c0sb9bl978g59ywm7f_29n6m0000gn/T/com.openai.sky.CUAService/CodexBar Screenshot 2026-07-28 at 11.55.52 PM.jpeg` -- Focused comparison: - `/private/tmp/codexbar-subscription-comparison.png` -- Viewport: CodexBar macOS Settings > Usage & Spend > Models -- Source pixels: 1184 x 598; implementation pixels: 880 x 620 -- Density normalization: the implementation subscription card was cropped and - scaled to the source height before side-by-side comparison. -- State: 30-day range, grouped by model, token mode, subscription summary visible - -**Full-view Comparison Evidence** - -The signed development build compiled, passed code-sign validation, launched -successfully from this checkout, and rendered the populated Usage & Spend -settings after its background history scan. The full-window capture verifies -the 30-day controls, summary metrics, one-line subscription card, compact -144-point model chart, ranking controls, and first five ranked models together. - -**Focused Region Comparison Evidence** - -The focused side-by-side comparison places the supplied two-line subscription -card beside the rendered one-line implementation. Provider, plan, and amount -now share one baseline; rank and icon columns remain aligned; all six rows fit -in materially less vertical space without truncating the three available plan -names. The rendered model chart remains readable directly below the card. - -Rendered and automated evidence also confirms: - -- The Usage & Spend hierarchy uses one controlled optical scale: 15-point - semibold tool titles, 14-point regular model titles, and caption-sized - secondary metadata. This keeps the surfaces consistent while making the - tool/model relationship immediately visible. -- Provider glyphs use an 18-point optical size inside a consistent 22-point - alignment frame. -- Ranking and tool-card spacing was tightened without shrinking the primary - content or changing unrelated CodexBar surfaces. -- Model rankings default to five rows, preserve ordinal numbers, and expose a - compact token/estimated-spend sort control. Expanding and changing the sort - are independent, persistent interactions. -- Both history charts share the same 144-point plot height, leaving more room - for the actionable ranked and selected-day details below. -- Token and estimated-spend modes are merged into one token-category chart; - estimated cost is shown alongside token totals in the concise day tooltip. -- The 7-day rolling-average control was removed from the primary UI because it - obscured the exact daily values used by hover and pinned details. -- Hover now contains only date, total tokens, and estimated spend. Bucket and - per-model splits remain in the pinned day detail below the chart. -- Daily estimated spend now follows the same hover/click contract as the model - chart: hover is a two-value summary, click pins the date, and the tool/model - breakdown is rendered below the chart with explicit tool-kind badges. -- Tool cards now use a true parent/child layout: the tool header owns the first - column, the token summary aligns with the tool title, and the complete model - list moves 48 points into a second column. Child-only dividers no longer run - through the parent column. -- Both charts resolve clicks against screen-space day positions. Dense dates - now form continuous nearest-day lanes, so there is no dead strip between - adjacent active dates. The first and last lanes extend by at least 14 points - while genuinely distant outer space stays empty. -- Both charts support click-and-drag scrubbing across dates. Hovered or pinned - dates receive a translucent 18-point column highlight plus the persistent - accent rule, and the chart surface uses the pointing-hand cursor. -- Model rankings now carry ordinal numbers, and pinned-day plus tool-card model - rows use their resolved model-provider icon instead of a generic blue square - or the containing tool's icon. -- Ranking rows use a stable two-column hierarchy. The right column owns the - selected metric and its share: token mode renders compact token counts, while - estimated-spend mode renders full currency values. The left subtitle contains - only explanatory bucket or complementary-token context. -- The ranking metric control now owns the complete model-analysis state. Token - mode charts daily token buckets with token-formatted Y-axis labels; estimated - spend mode charts the sum of priced model costs for each day with - currency-formatted Y-axis labels. Pinned-day details follow the same metric. -- Hover and click now resolve dates from the same rendered screen-space lanes. - Hover adds a bounded 12-percent hysteresis zone around adjacent lane - boundaries, while large multi-column movements still catch up immediately. -- Ranking model rows now share the pinned-day model row's system body font, - 16-point provider icon, and 20-point icon frame, with reduced row padding. -- Aggregate-only token rows label their complementary estimate explicitly, and - the disclosure is now a compact capsule with a directional chevron instead of - looking like an unstyled line of body text. -- Existing provider-icon, token-chart, hover-detail, and quota-warning work in - the checkout was preserved. - -**Findings** - -- No actionable P0/P1/P2 visual differences remain in the requested - subscription-density and chart-height scope. - -**Comparison History** - -- Iteration 1: provider glyphs were recolored using chart palette colors. -- Iteration 2: first-party assets and explicit original/template rendering modes - replaced provider tinting; provenance is recorded in - `docs/provider-icon-sources.md`. -- Iteration 3: semantic token stacks, exact day hover details, tool-type labels, - and tool-level token aggregation replaced visually ambiguous single-blue - stacks and repeated per-model metadata. -- Iteration 4: fixed 13/14-point text was replaced by the macOS semantic - typography hierarchy, with consistent icon alignment and denser but readable - model/tool rows. -- Iteration 5: duplicated token/spend modes and the rolling-average switch were - removed; hover was reduced to two headline values, while ranks and - model-provider icons were added to the scan-heavy lists. -- Iteration 6: the optical hierarchy now distinguishes 15-point tool titles from - 14-point model rows; ranking defaults to five rows and can sort by token or - spend; both charts use a 168-point plot and the daily chart gained pinned - date details. -- Iteration 7: model rows moved into a visibly indented child column with scoped - dividers, and chart clicks gained bounded screen-space hit targets plus a - stronger selected-day rule. -- Iteration 8: independent click radii were replaced with continuous - nearest-day Voronoi lanes; click-and-drag scrubbing, a pointing-hand cursor, - and full-column interaction highlighting were added to both charts. -- Iteration 9: ranking rows moved the active metric out of the subtitle and into - a dedicated right-aligned value column above the percentage. Estimated spend - gained currency formatting, token and spend context were separated, and the - expand control gained a clear disclosure treatment. -- Iteration 10: the token/estimated-spend ranking control was promoted to a - shared chart metric. Switching it now changes daily bar heights, Y-axis units, - selected-day details, valid interaction dates, legend visibility, and ranking - order together while preserving the approved hit-target behavior. -- Iteration 11: hover date selection was moved from calendar-distance lookup to - the chart's rendered date lanes and given bounded boundary hysteresis. - Ranking typography, provider-icon size, and row rhythm were matched to the - pinned-day model list to reduce density and remove the visual jump. -- Iteration 12: the complete Usage & Spend surface was audited for typography - drift. Model rows, values, tool titles, section titles, metadata, badges, - axes, legends, disclosures, and segmented controls now use one shared - 15/14/13/12/11-point hierarchy. Model rows across pinned-day, ranking, - daily-spend, and tool cards share the same 16-point icon in a 20-point frame. -- Iteration 13: token and estimated-spend chart modes now share one stable - vertical structure. Both render a single accent-colored daily-total bar; the - token-only five-item legend was removed, while the semantic input, output, - cache, and reasoning buckets remain available in the pinned-day detail. -- Iteration 14: pinned-day model names remain 14-point primary content while - their token splits and estimated costs move to 12-point secondary content. -- Iteration 15: subscription rows use a quieter 13-point name/value tier with - 11-point rank and plan metadata, 18-point provider marks, and tighter row - rhythm so the provider summary no longer competes with model analysis. - Ranking and aligned monetary values use a 14-point medium weight instead of - semibold, and Simplified Chinese now renders the complementary metric as - “令牌” instead of the mixed-script “token 用量”. -- Iteration 15: model ranking rows were flattened into a single scan line. - Token sorting now shows cost as its compact complementary value, spend - sorting shows total tokens, and the selected value plus share stay together - at the trailing edge. Input, output, cache, and reasoning splits remain in - the pinned-day detail instead of repeating throughout the ranking. -- Iteration 16: pinned-day model rows now default to one summary value and - share instead of repeating every token bucket. Token mode keeps the aggregate - bucket composition once at day level; spend mode replaces it with explicit - priced-model coverage. Per-model token splits are available through a compact - disclosure and reset when the selected day or metric changes. -- Iteration 17: native QA first captured a stale running process that still - showed plan names on a second line. After quitting and relaunching the freshly - packaged app, its background scan produced a true one-line provider/plan - layout. The focused side-by-side comparison verifies the intended density, - and both history charts now use a 144-point plot. -- Iteration 18: cumulative model charts now treat isolated legacy samples as - framing noise only when they precede a gap of at least 21 days, represent no - more than 10% of active dates, and contribute no more than 5% of either tokens - or spend. The scan evaluates qualifying gaps from left to right, so a larger - normal gap later in the history cannot preserve a negligible early island. - Rankings, totals, and drill-down history retain every sample. -- Iteration 18 native capture is pending. The packaged app passed signing and - model-domain regression tests, but the local SkyComputerUseService repeatedly - crashed while resolving three installed CodexBar copies with the same bundle - identifier. The attempted isolated QA copy could not be created because the - execution approval service had reached its usage limit. Do not treat the - pre-restart February-axis screenshot as evidence for the final build. - -**Implementation Checklist** - -- Capture model, tool, and daily-spend hover states from the development app. -- Verify the 15-point semibold tool title reads as one level above the 14-point - model row without making MiniMax Code or other provider titles look oversized. -- Verify captions remain readable at default macOS text size and that long - localized labels do not collide with values. -- Verify provider icons remain optically balanced inside their 22-point frames - in light and dark appearance. -- Verify tooltip placement does not obscure the hovered bar at narrow and wide - Settings window widths. -- Verify the compact tooltip shows only total tokens and estimated spend, and - clicking a bar exposes the detailed token buckets below. -- Verify both ranking sort modes, the five-row collapsed state, expansion, and - collapse remain readable with long localized model names. -- Verify the daily-spend click target pins the intended day and presents the - tool/model hierarchy below without duplicating the large hover card. -- Verify the 48-point child indent remains readable at the narrowest supported - Settings width and that long model values still retain adequate trailing room. -- Verify every position between adjacent active dates selects the intended - nearest day, dragging scrubs without toggling dates off, and distant outer - regions do not unexpectedly select old activity. -- Verify rank numbers align as one column and that model icons remain correct - when the model is used through a different tool provider. -- Verify long model names leave enough room for the 84-point metric column, - currency values retain symbols and decimals, and the disclosure capsule - remains compact in all supported localizations. -- Verify switching to estimated spend visibly rescales the chart to currency, - removes the token-bucket legend, and preserves the selected-day click and - drag behavior; switching back must restore token buckets without stale dates. -- Verify slow movement near a midpoint remains on the current date until the - pointer clearly enters the neighboring lane, while a quick sweep across - several columns follows immediately. -- Verify ranking and pinned-day model rows now have matching optical title and - icon sizes, and that five collapsed rows fit comfortably in the panel. -- Verify the shared 15/14/13/12/11-point hierarchy remains visually distinct - without any default SwiftUI font leaking into model, tool, subscription, - chart-axis, legend, badge, or disclosure content. -- Verify switching between token and estimated-spend modes leaves the ranking - control and first ranked row at the same vertical position, and clicking a - token bar still reveals every available semantic token bucket. -- Verify long pinned-day token splits read as secondary data rather than - competing with model names, and that ranking currency values no longer look - like a second set of section headings. -- Verify collapsed and expanded rankings stay one line per model in both sort - modes, long model names truncate before the trailing value, and no token - bucket breakdown leaks back into the ranking. -- Verify token mode shows one aggregate category breakdown, spend mode shows - priced-model coverage instead of token categories, unpriced rows remain - understandable, and expanding one model reveals only that model's buckets. -- Verify cumulative token and spend charts begin near the first meaningful - activity cluster when a negligible legacy island precedes a 21+ day gap, and - that meaningful early activity remains visible. - -**Open Questions** - -- Re-run the cumulative-model native capture after the desktop-control service - is available; compare the first visible axis label against the actual first - meaningful activity cluster. - -**Follow-up Polish** - -- Adjust individual optical icon sizing or row spacing only if the eventual - native capture reveals a concrete imbalance; keep the semantic type hierarchy - shared across providers and tools. - -final result: blocked From a2d1021fcb1fc93fdc7d8cf375c158bc811229bc Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:48:33 +0800 Subject: [PATCH 32/40] refactor(spend): remove unfinished daily estimated spend card from models PR The daily estimated spend chart (SpendDailyChart) and its model-side DailyPoint/DailySpendDetail aggregation were split out of #2322 but are not ready for review yet. Withhold them from this PR; the code remains in #2322 for a follow-up PR. --- .../PreferencesSpendDashboardPane.swift | 419 ------------------ .../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 - .../SpendDashboardModel+CurrencySafety.swift | 2 - Sources/CodexBar/SpendDashboardModel.swift | 216 --------- Tests/CodexBarTests/ShareStatsTests.swift | 5 - .../SpendActivityHeatmapTests.swift | 3 +- .../SpendDashboardClockRolloverTests.swift | 4 +- .../SpendDashboardControllerTests.swift | 2 +- .../SpendDashboardDateTruthTests.swift | 34 -- .../SpendDashboardKimiModelTests.swift | 1 - .../SpendDashboardModelTests.swift | 66 --- .../SpendToolPresentationTests.swift | 14 - .../UserFacingLocalizationCoverageTests.swift | 42 -- 35 files changed, 4 insertions(+), 827 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index fae69e64b6..3d62283d17 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -357,7 +357,6 @@ private struct SpendCurrencySection: View { selectedDays: self.requestedDays, currencyCode: self.group.currencyCode) } - SpendDailyChart(group: self.group) if let activityAnalysis { SpendDashboardPanel { SpendActivityHeatmapView(analysis: activityAnalysis) @@ -442,424 +441,6 @@ private struct SpendProviderPanel: View { } } -struct SpendDailyChartPresentation: Equatable { - enum Content: Equatable { - case chart - case unavailable - } - - struct Series: Equatable { - let name: String - let provider: UsageProvider - } - - let content: Content - let series: [Series] - let dayCount: Int - let aggregateTotal: Double? - - init(dailyPoints: [SpendDashboardModel.DailyPoint], aggregateTotal: Double?) { - self.content = dailyPoints.isEmpty ? .unavailable : .chart - self.dayCount = Set(dailyPoints.map(\.day)).count - self.aggregateTotal = aggregateTotal - - var seenNames: Set = [] - self.series = dailyPoints.compactMap { point in - guard seenNames.insert(point.providerName).inserted else { return nil } - return Series(name: point.providerName, provider: point.provider) - } - } - - var accessibilityValue: String { - L("%d days of usage data across %d services", self.dayCount, self.series.count) - } -} - -private struct SpendDailyChart: View { - let group: SpendDashboardModel.CurrencyGroup - @State private var selectedDay: Date? - @State private var pinnedDay: Date? - @State private var cachedDays: [Date] = [] - @State private var cachedDetails: [Date: SpendDashboardModel.DailySpendDetail] = [:] - - var body: some View { - let presentation = SpendDailyChartPresentation( - dailyPoints: self.group.dailyPoints, - aggregateTotal: self.group.totalCost) - SpendDashboardPanel { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 3) { - Text(L("Daily estimated spend")) - .font(SpendModelsListStyle.sectionTitleFont) - if presentation.content == .chart || presentation.aggregateTotal != nil { - Text( - "\(L("Active")) \(codexBarLocalizedInteger(presentation.dayCount)) · " + - "\(L("Total")) \(self.totalCostText(presentation.aggregateTotal))") - .font(SpendModelsListStyle.secondaryFont) - .foregroundStyle(.secondary) - } - } - Spacer() - } - if presentation.content == .unavailable { - ContentUnavailableView(L("Spend unavailable"), systemImage: "chart.bar.xaxis") - .frame(maxWidth: .infinity, minHeight: SpendModelsListStyle.compactChartHeight) - } else { - Chart { - if let interactionDay = self.pinnedDay ?? self.selectedDay { - RuleMark(x: .value(L("Day"), interactionDay, unit: .day)) - .foregroundStyle(Color.accentColor.opacity(0.09)) - .lineStyle(StrokeStyle(lineWidth: 18)) - } - ForEach(self.group.dailyPoints) { point in - BarMark( - x: .value(L("Day"), point.day, unit: .day), - yStart: .value(L("Estimated spend"), point.stackStart), - yEnd: .value(L("Estimated spend"), point.stackEnd), - width: .ratio(0.58)) - .foregroundStyle(by: .value(L("Provider"), point.providerName)) - .accessibilityLabel(Text(self.pointAccessibilityLabel(point))) - .accessibilityValue(Text(UsageFormatter.currencyString( - point.cost, - currencyCode: self.group.currencyCode))) - } - if let pinnedDay { - RuleMark(x: .value(L("Day"), pinnedDay, unit: .day)) - .foregroundStyle(Color.accentColor.opacity(0.72)) - .lineStyle(StrokeStyle(lineWidth: 2)) - } - if let selectedDay { - RuleMark(x: .value(L("Day"), selectedDay, unit: .day)) - .foregroundStyle(.clear) - .annotation(position: .top, overflowResolution: .init( - x: .fit(to: .chart), - y: .fit(to: .chart))) - { - self.dayTooltip(selectedDay) - } - } - } - .chartXScale(domain: self.activeChartDomain) - .chartForegroundStyleScale( - domain: presentation.series.map(\.name), - range: presentation.series.map { self.providerColor($0.provider) }) - .chartLegend(.hidden) - .chartXAxis { - AxisMarks(values: .automatic(desiredCount: 6)) { value in - AxisGridLine() - .foregroundStyle(Color.secondary.opacity(0.08)) - AxisTick() - .foregroundStyle(Color.secondary.opacity(0.35)) - AxisValueLabel { - if let date = value.as(Date.self) { - Text(date.formatted(self.axisDateFormat)) - .font(SpendModelsListStyle.secondaryFont) - } - } - } - } - .chartYAxis { - AxisMarks(position: .leading) { value in - AxisGridLine() - .foregroundStyle(Color.secondary.opacity(0.16)) - AxisValueLabel { - if let amount = value.as(Double.self) { - Text(UsageFormatter.compactCurrencyString( - amount, - currencyCode: self.group.currencyCode)) - .font(SpendModelsListStyle.secondaryFont) - } - } - } - } - .chartPlotStyle { plotArea in - plotArea - .background(Color.primary.opacity(0.018)) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - .frame(height: SpendModelsListStyle.compactChartHeight) - .accessibilityLabel(L("Daily estimated spend")) - .accessibilityValue(presentation.accessibilityValue) - .chartOverlay { proxy in - GeometryReader { geo in - SpendModelsChartMouseReader( - onMoved: { location in - self.updateSelectedDay(location: location, proxy: proxy, geo: geo) - }, - onClicked: { location in - self.handleChartClick(location: location, proxy: proxy, geo: geo) - }, - onDragged: { location in - self.handleChartDrag(location: location, proxy: proxy, geo: geo) - }, - onEscape: { - self.selectedDay = nil - self.pinnedDay = nil - }) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - LazyVGrid( - columns: [GridItem(.adaptive(minimum: 156), spacing: 12, alignment: .leading)], - alignment: .leading, - spacing: 6) - { - ForEach(presentation.series, id: \.name) { series in - HStack(spacing: 6) { - SpendProviderIcon(provider: series.provider, size: 14) - .frame(width: 18, height: 18) - Text(series.name) - .font(SpendModelsListStyle.controlFont) - .lineLimit(1) - if let kind = self.toolKind(for: series.name) { - Text(kind.displayName) - .font(SpendModelsListStyle.tertiaryFont) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - } - if let detail = self.pinnedDetail { - self.dayDetail(detail) - } - } - } - } - .onAppear { self.rebuildHoverCache() } - .onChange(of: self.group.dailySpendDetails) { _, _ in self.rebuildHoverCache() } - } - - private func totalCostText(_ aggregateTotal: Double?) -> String { - aggregateTotal.map { - UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? "—" - } - - private var activeChartDomain: ClosedRange { - guard let firstDay = self.group.dailyPoints.map(\.day).min(), - let lastDay = self.group.dailyPoints.map(\.day).max() - else { return self.group.chartDomain } - let calendar = Calendar.current - let paddedStart = calendar.date(byAdding: .day, value: -1, to: firstDay) ?? firstDay - let paddedEnd = calendar.date(byAdding: .day, value: 2, to: lastDay) ?? lastDay - let start = max(self.group.chartDomain.lowerBound, paddedStart) - let end = min(self.group.chartDomain.upperBound, max(paddedEnd, start)) - return start...end - } - - private var axisDateFormat: Date.FormatStyle { - let interval = self.activeChartDomain.upperBound.timeIntervalSince(self.activeChartDomain.lowerBound) - if interval <= 90 * 24 * 60 * 60 { - return .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()) - } - return .dateTime.year().month(.abbreviated).locale(codexBarLocalizedLocale()) - } - - private func pointAccessibilityLabel(_ point: SpendDashboardModel.DailyPoint) -> String { - let day = point.day.formatted( - .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale())) - return "\(point.providerName), \(day)" - } - - private func providerColor(_ provider: UsageProvider) -> Color { - let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color - return Color(red: color.red, green: color.green, blue: color.blue) - } - - private func rebuildHoverCache() { - self.cachedDays = self.group.dailySpendDetails.map(\.day).sorted() - self.cachedDetails = Dictionary(uniqueKeysWithValues: self.group.dailySpendDetails.map { ($0.day, $0) }) - } - - private func toolKind(for name: String) -> SpendToolIdentity.Kind? { - self.group.dailyPoints.first { $0.providerName == name }?.toolKind - } - - private func updateSelectedDay(location: CGPoint?, proxy: ChartProxy, geo: GeometryProxy) { - guard let location, let plotAnchor = proxy.plotFrame else { - self.selectedDay = nil - return - } - let plotFrame = geo[plotAnchor] - guard plotFrame.contains(location), - let date: Date = proxy.value(atX: location.x - plotFrame.origin.x) - else { - self.selectedDay = nil - return - } - self.selectedDay = self.nearestDay(to: date) - } - - private func handleChartClick(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { - guard let day = self.chartDay(at: location, proxy: proxy, geo: geo) else { return } - self.pinnedDay = self.pinnedDay == day ? nil : day - } - - private func handleChartDrag(location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) { - guard let day = self.chartDay(at: location, proxy: proxy, geo: geo) else { return } - self.pinnedDay = day - } - - private func chartDay(at location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) -> Date? { - guard let plotAnchor = proxy.plotFrame else { return nil } - let plotFrame = geo[plotAnchor] - guard plotFrame.contains(location) else { return nil } - return SpendChartDayHitTarget.nearestDay( - toX: location.x - plotFrame.origin.x, - days: self.cachedDays, - position: { proxy.position(forX: $0) }) - } - - private var pinnedDetail: SpendDashboardModel.DailySpendDetail? { - guard let pinnedDay else { return nil } - return self.cachedDetails[Calendar.current.startOfDay(for: pinnedDay)] - ?? self.cachedDetails[pinnedDay] - } - - private func nearestDay(to date: Date) -> Date? { - let days = self.cachedDays - guard !days.isEmpty else { return nil } - var lower = 0 - var upper = days.count - while lower < upper { - let middle = (lower + upper) / 2 - if days[middle] < date { - lower = middle + 1 - } else { - upper = middle - } - } - let nearest: Date - if lower == 0 { - nearest = days[0] - } else if lower == days.count { - nearest = days[days.count - 1] - } else { - let before = days[lower - 1] - let after = days[lower] - nearest = abs(before.timeIntervalSince(date)) <= abs(after.timeIntervalSince(date)) - ? before - : after - } - guard abs(nearest.timeIntervalSince(date)) <= 43200 else { return nil } - return nearest - } - - private func dayTooltip(_ day: Date) -> some View { - let detail = self.cachedDetails[Calendar.current.startOfDay(for: day)] - ?? self.cachedDetails[day] - return VStack(alignment: .leading, spacing: 6) { - if let detail { - Text(day.formatted( - .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()))) - .font(SpendModelsListStyle.tooltipTitleFont) - HStack(spacing: 14) { - self.tooltipSummary( - title: L("Tokens"), - value: detail.totalTokens.map(UsageFormatter.tokenCountString) ?? "—") - self.tooltipSummary( - title: L("Estimated spend"), - value: UsageFormatter.currencyString( - detail.totalCost, - currencyCode: self.group.currencyCode)) - } - } - } - .padding(10) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) - .shadow(color: .black.opacity(0.09), radius: 10, y: 3) - } - - private func tooltipSummary(title: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(SpendModelsListStyle.tertiaryFont) - .foregroundStyle(.secondary) - Text(value) - .font(SpendModelsListStyle.tooltipRowFont.weight(.medium)) - .monospacedDigit() - } - } - - private func dayDetail(_ detail: SpendDashboardModel.DailySpendDetail) -> some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .firstTextBaseline) { - Text(detail.day.formatted( - .dateTime.month(.abbreviated).day().locale(codexBarLocalizedLocale()))) - .font(SpendModelsListStyle.primaryEmphasizedFont) - Spacer() - if let tokens = detail.totalTokens { - Text(UsageFormatter.tokenCountString(tokens)) - .font(SpendModelsListStyle.primaryFont) - .foregroundStyle(.secondary) - } - Text(UsageFormatter.currencyString(detail.totalCost, currencyCode: self.group.currencyCode)) - .font(SpendModelsListStyle.primaryEmphasizedFont) - } - .monospacedDigit() - - ForEach(detail.tools) { tool in - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 7) { - SpendProviderIcon(provider: tool.provider, size: SpendModelsListStyle.iconSize) - .frame( - width: SpendModelsListStyle.iconFrameSize, - height: SpendModelsListStyle.iconFrameSize) - Text(tool.displayName) - .font(SpendModelsListStyle.toolTitleFont) - Text(tool.kind.displayName) - .font(SpendModelsListStyle.tertiaryFont.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.secondary.opacity(0.12), in: Capsule()) - Spacer() - Text(UsageFormatter.currencyString(tool.cost, currencyCode: self.group.currencyCode)) - .font(SpendModelsListStyle.primaryFont) - .monospacedDigit() - } - VStack(alignment: .leading, spacing: 1) { - ForEach(Array(tool.models.enumerated()), id: \.element.id) { index, model in - if index > 0 { Divider().padding(.vertical, 2) } - HStack(spacing: 8) { - SpendProviderIcon( - provider: model.modelProvider, - size: SpendModelsListStyle.modelIconSize) - .frame( - width: SpendModelsListStyle.modelIconFrameSize, - height: SpendModelsListStyle.modelIconFrameSize) - Text(model.displayName) - .font(SpendModelsListStyle.primaryFont) - .lineLimit(1) - Spacer() - Text(model.cost.map { - UsageFormatter.currencyString($0, currencyCode: self.group.currencyCode) - } ?? model.tokens.map(UsageFormatter.tokenCountString) ?? "—") - .font(SpendModelsListStyle.secondaryFont) - .foregroundStyle(.secondary) - .monospacedDigit() - } - .padding(.vertical, 2) - } - } - .padding(.leading, SpendModelsListStyle.modelIndent) - } - .padding(10) - .background( - Color.secondary.opacity(0.055), - in: RoundedRectangle(cornerRadius: 9, style: .continuous)) - } - } - .padding(11) - .background( - Color.secondary.opacity(0.05), - in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - } -} - private struct SpendRefreshFailureNotice: View { let sourceNames: [String] let refresh: () -> Void diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index d9237961a9..e29acdf0ed 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1289,7 +1289,6 @@ "Metric" = "المقياس"; "%d days of usage data across %d models" = "%d بيانات الاستخدام عبر نماذج %d"; "%@ in · %@ out" = "%@ داخل · %@ خارج"; -"Daily estimated spend" = "الإنفاق اليومي التقديري"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d نافذة كاملة مدتها 5 ساعات متبقية من الأسبوعي · %d نافذة حتى إعادة التعيين"; "Weekly cannot run out before reset at this pace" = "لا يمكن أن ينفد الحد الأسبوعي قبل إعادة التعيين بهذه الوتيرة"; "Weekly can run out ≈%d windows early" = "قد ينفد الحد الأسبوعي مبكرًا بنحو %d نافذة"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 3bc8938347..918520d6c5 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1288,7 +1288,6 @@ "Metric" = "Mètrica"; "%d days of usage data across %d models" = "%d dies de dades d'ús en %d models"; "%@ in · %@ out" = "%@ d'entrada · %@ de sortida"; -"Daily estimated spend" = "Despesa diària estimada"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d finestres completes de 5 h de quota setmanal · %d finestres fins al reinici"; "Weekly cannot run out before reset at this pace" = "La quota setmanal no es pot esgotar abans del reinici a aquest ritme"; "Weekly can run out ≈%d windows early" = "La quota setmanal es pot esgotar ≈%d finestres abans"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index b80984f9cc..a5c667715f 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1286,7 +1286,6 @@ "Metric" = "Metrik"; "%d days of usage data across %d models" = "%d Tage Nutzungsdaten für %d Modelle"; "%@ in · %@ out" = "%@ ein · %@ aus"; -"Daily estimated spend" = "Geschätzte tägliche Ausgaben"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d volle 5-Std.-Fenster des Wochenlimits übrig · %d Fenster bis zum Reset"; "Weekly cannot run out before reset at this pace" = "Das Wochenlimit kann bei diesem Tempo nicht vor dem Reset aufgebraucht sein"; "Weekly can run out ≈%d windows early" = "Das Wochenlimit kann ≈%d Fenster früher aufgebraucht sein"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index ecf0f28b9d..1837b6a28c 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1290,7 +1290,6 @@ "Metric" = "Metric"; "%d days of usage data across %d models" = "%d days of usage data across %d models"; "%@ in · %@ out" = "%@ in · %@ out"; -"Daily estimated spend" = "Daily estimated spend"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d full 5h windows of weekly left · %d windows until reset"; "Weekly cannot run out before reset at this pace" = "Weekly cannot run out before reset at this pace"; "Weekly can run out ≈%d windows early" = "Weekly can run out ≈%d windows early"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 716c215310..2a8fcd4284 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1284,7 +1284,6 @@ "Metric" = "Métrica"; "%d days of usage data across %d models" = "%d días de datos de uso en %d modelos"; "%@ in · %@ out" = "%@ de entrada · %@ de salida"; -"Daily estimated spend" = "Gasto diario estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d ventanas completas de 5 h de cuota semanal · %d ventanas hasta el reinicio"; "Weekly cannot run out before reset at this pace" = "La cuota semanal no puede agotarse antes del reinicio a este ritmo"; "Weekly can run out ≈%d windows early" = "La cuota semanal puede agotarse ≈%d ventanas antes"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index dff71954d7..821f2d867d 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1289,7 +1289,6 @@ "Metric" = "متریک"; "%d days of usage data across %d models" = "%d روز داده های استفاده در مدل های %d"; "%@ in · %@ out" = "%@ ورودی · %@ خروجی"; -"Daily estimated spend" = "برآورد هزینه روزانه"; "≈%d full 5h windows of weekly left · %d windows until reset" = "حدود %d بازه کامل ۵ ساعته از سهم هفتگی مانده · %d بازه تا بازنشانی"; "Weekly cannot run out before reset at this pace" = "با این روند، سهم هفتگی پیش از بازنشانی تمام نمی‌شود"; "Weekly can run out ≈%d windows early" = "سهم هفتگی ممکن است حدود %d بازه زودتر تمام شود"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index bbfa768190..933eccc5de 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1285,7 +1285,6 @@ "Metric" = "Métrique"; "%d days of usage data across %d models" = "%d jours de données d'utilisation sur %d modèles"; "%@ in · %@ out" = "%@ en entrée · %@ en sortie"; -"Daily estimated spend" = "Dépenses quotidiennes estimées"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d fenêtres complètes de 5 h de quota hebdomadaire · %d fenêtres avant réinitialisation"; "Weekly cannot run out before reset at this pace" = "Le quota hebdomadaire ne peut pas être épuisé avant la réinitialisation à ce rythme"; "Weekly can run out ≈%d windows early" = "Le quota hebdomadaire peut être épuisé ≈%d fenêtres plus tôt"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 6a2cdd0941..dbf0ea1e50 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1285,7 +1285,6 @@ "Metric" = "Métrica"; "%d days of usage data across %d models" = "%d días de datos de uso en %d modelos"; "%@ in · %@ out" = "%@ de entrada · %@ de saída"; -"Daily estimated spend" = "Gasto diario estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d xanelas completas de 5 h de cota semanal · %d xanelas ata o restablecemento"; "Weekly cannot run out before reset at this pace" = "A cota semanal non pode esgotarse antes do restablecemento a este ritmo"; "Weekly can run out ≈%d windows early" = "A cota semanal pode esgotarse ≈%d xanelas antes"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 2010081d5e..d46199c370 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1289,7 +1289,6 @@ "Metric" = "Metrik"; "%d days of usage data across %d models" = "%d hari data penggunaan di %d model"; "%@ in · %@ out" = "%@ masuk · %@ keluar"; -"Daily estimated spend" = "Perkiraan pengeluaran harian"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d jendela 5 jam penuh dari kuota mingguan tersisa · %d jendela hingga reset"; "Weekly cannot run out before reset at this pace" = "Kuota mingguan tidak dapat habis sebelum reset dengan laju ini"; "Weekly can run out ≈%d windows early" = "Kuota mingguan dapat habis ≈%d jendela lebih awal"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index fdc2ef17c4..a0148189cf 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1289,7 +1289,6 @@ "Metric" = "Metrica"; "%d days of usage data across %d models" = "%d giorni di dati di utilizzo su %d modelli"; "%@ in · %@ out" = "%@ in ingresso · %@ in uscita"; -"Daily estimated spend" = "Spesa giornaliera stimata"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d finestre complete da 5 h di quota settimanale · %d finestre al reset"; "Weekly cannot run out before reset at this pace" = "La quota settimanale non può esaurirsi prima del reset a questo ritmo"; "Weekly can run out ≈%d windows early" = "La quota settimanale può esaurirsi ≈%d finestre prima"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index b8cfb46ce5..c2e5c39636 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1286,7 +1286,6 @@ "Metric" = "指標"; "%d days of usage data across %d models" = "%2$dモデルにわたる%1$d日間の使用状況データ"; "%@ in · %@ out" = "%@ 入力 · %@ 出力"; -"Daily estimated spend" = "日別推定支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "週間枠は約%d回分の完全な5時間ウィンドウ · リセットまで%d回"; "Weekly cannot run out before reset at this pace" = "このペースではリセット前に週間枠を使い切れません"; "Weekly can run out ≈%d windows early" = "週間枠は約%dウィンドウ早く使い切る可能性があります"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 481c0768d3..e6b598438e 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1253,7 +1253,6 @@ "Metric" = "지표"; "%d days of usage data across %d models" = "%2$d개 모델의 %1$d일간 사용량 데이터"; "%@ in · %@ out" = "%@ 입력 · %@ 출력"; -"Daily estimated spend" = "일별 예상 지출"; "≈%d full 5h windows of weekly left · %d windows until reset" = "주간 한도 약 %d개의 전체 5시간 창 남음 · 재설정까지 %d개 창"; "Weekly cannot run out before reset at this pace" = "이 속도라면 재설정 전에 주간 한도를 소진할 수 없습니다"; "Weekly can run out ≈%d windows early" = "주간 한도가 약 %d개 창 일찍 소진될 수 있습니다"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index ef40336ab7..b4345605d6 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1285,7 +1285,6 @@ "Metric" = "Metriek"; "%d days of usage data across %d models" = "%d dagen aan gebruiksgegevens voor %d modellen"; "%@ in · %@ out" = "%@ in · %@ uit"; -"Daily estimated spend" = "Geschatte dagelijkse uitgaven"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d volledige vensters van 5 uur aan weeklimiet over · %d vensters tot reset"; "Weekly cannot run out before reset at this pace" = "Het weeklimiet kan bij dit tempo niet vóór de reset opraken"; "Weekly can run out ≈%d windows early" = "Het weeklimiet kan ≈%d vensters eerder opraken"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 4202be783a..98865ef0ee 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1289,7 +1289,6 @@ "Metric" = "Metryka"; "%d days of usage data across %d models" = "%d dni danych użycia dla %d modeli"; "%@ in · %@ out" = "%@ wej. · %@ wyj."; -"Daily estimated spend" = "Szacowane dzienne wydatki"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d pełnych 5-godz. okien limitu tygodniowego · %d okien do resetu"; "Weekly cannot run out before reset at this pace" = "Przy tym tempie limit tygodniowy nie może wyczerpać się przed resetem"; "Weekly can run out ≈%d windows early" = "Limit tygodniowy może wyczerpać się ≈%d okien wcześniej"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 7ca95b2812..11dd32455b 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1286,7 +1286,6 @@ "Metric" = "Métrica"; "%d days of usage data across %d models" = "%d dias de dados de uso em %d modelos"; "%@ in · %@ out" = "%@ de entrada · %@ de saída"; -"Daily estimated spend" = "Gasto diário estimado"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d janelas completas de 5 h da cota semanal · %d janelas até a renovação"; "Weekly cannot run out before reset at this pace" = "A cota semanal não pode acabar antes da renovação nesse ritmo"; "Weekly can run out ≈%d windows early" = "A cota semanal pode acabar ≈%d janelas antes"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index f37877046e..5169f75595 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1287,7 +1287,6 @@ "Metric" = "Метрика"; "%d days of usage data across %d models" = "Данные об использовании за %d дней по %d моделям"; "%@ in · %@ out" = "%@ вх. · %@ исх."; -"Daily estimated spend" = "Предполагаемые ежедневные расходы"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d полных 5-часовых окон недельного лимита · %d окон до сброса"; "Weekly cannot run out before reset at this pace" = "При таком темпе недельный лимит не может закончиться до сброса"; "Weekly can run out ≈%d windows early" = "Недельный лимит может закончиться на ≈%d окон раньше"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index baa467c201..f46e85d2cd 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1284,7 +1284,6 @@ "Metric" = "Mått"; "%d days of usage data across %d models" = "%d dagar med användningsdata för %d modeller"; "%@ in · %@ out" = "%@ in · %@ ut"; -"Daily estimated spend" = "Uppskattade dagliga utgifter"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d fulla 5-timmarsfönster av veckokvoten kvar · %d fönster till återställning"; "Weekly cannot run out before reset at this pace" = "Veckokvoten kan inte ta slut före återställningen i den här takten"; "Weekly can run out ≈%d windows early" = "Veckokvoten kan ta slut ≈%d fönster tidigare"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index a30dc09dde..5dd8568841 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1289,7 +1289,6 @@ "Metric" = "ตัวชี้วัด"; "%d days of usage data across %d models" = "ข้อมูลการใช้งาน %d วันในโมเดล %d"; "%@ in · %@ out" = "%@ ขาเข้า · %@ ขาออก"; -"Daily estimated spend" = "ค่าใช้จ่ายรายวันโดยประมาณ"; "≈%d full 5h windows of weekly left · %d windows until reset" = "เหลือโควตารายสัปดาห์ ≈%d ช่วงเต็ม 5 ชม. · อีก %d ช่วงจนรีเซ็ต"; "Weekly cannot run out before reset at this pace" = "ด้วยอัตรานี้ โควตารายสัปดาห์จะไม่หมดก่อนรีเซ็ต"; "Weekly can run out ≈%d windows early" = "โควตารายสัปดาห์อาจหมดเร็วขึ้น ≈%d ช่วง"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index fabc73afe3..674f7f7389 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1287,7 +1287,6 @@ "Metric" = "Ölçüt"; "%d days of usage data across %d models" = "%2$d model için %1$d günlük kullanım verisi"; "%@ in · %@ out" = "%@ girdi · %@ çıktı"; -"Daily estimated spend" = "Günlük tahmini harcama"; "≈%d full 5h windows of weekly left · %d windows until reset" = "Haftalık kotadan ≈%d tam 5 saatlik pencere kaldı · sıfırlamaya %d pencere"; "Weekly cannot run out before reset at this pace" = "Bu hızda haftalık kota sıfırlamadan önce tükenemez"; "Weekly can run out ≈%d windows early" = "Haftalık kota ≈%d pencere erken tükenebilir"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 35a6c775cd..b9b8ef624b 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1285,7 +1285,6 @@ "Metric" = "Метрика"; "%d days of usage data across %d models" = "%d днів використання даних у %d моделях"; "%@ in · %@ out" = "%@ вх. · %@ вих."; -"Daily estimated spend" = "Орієнтовні щоденні витрати"; "≈%d full 5h windows of weekly left · %d windows until reset" = "≈%d повних 5-годинних вікон тижневого ліміту · %d вікон до скидання"; "Weekly cannot run out before reset at this pace" = "За такого темпу тижневий ліміт не може вичерпатися до скидання"; "Weekly can run out ≈%d windows early" = "Тижневий ліміт може вичерпатися на ≈%d вікон раніше"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index e2faa5b365..f3dfc49773 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1286,7 +1286,6 @@ "Metric" = "Chỉ số"; "%d days of usage data across %d models" = "%d ngày của dữ liệu Mức sử dụng trên %d mô hình"; "%@ in · %@ out" = "%@ đầu vào · %@ đầu ra"; -"Daily estimated spend" = "Chi tiêu ước tính hằng ngày"; "≈%d full 5h windows of weekly left · %d windows until reset" = "Còn ≈%d cửa sổ 5 giờ đầy đủ của hạn mức tuần · %d cửa sổ đến khi đặt lại"; "Weekly cannot run out before reset at this pace" = "Với tốc độ này, hạn mức tuần không thể hết trước khi đặt lại"; "Weekly can run out ≈%d windows early" = "Hạn mức tuần có thể hết sớm ≈%d cửa sổ"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 23872859c3..18712d4035 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1261,7 +1261,6 @@ "Metric" = "指标"; "%d days of usage data across %d models" = "%d 天用量数据,涵盖 %d 个模型"; "%@ in · %@ out" = "%@ 输入 · %@ 输出"; -"Daily estimated spend" = "每日估算支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "每周额度约剩 %d 个完整 5 小时窗口 · 距重置还有 %d 个窗口"; "Weekly cannot run out before reset at this pace" = "按此速度,每周额度无法在重置前用完"; "Weekly can run out ≈%d windows early" = "每周额度可能提前约 %d 个窗口用完"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 5ada41b2a1..8183a9a666 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1316,7 +1316,6 @@ "Metric" = "指標"; "%d days of usage data across %d models" = "%d 天使用量資料,涵蓋 %d 個模型"; "%@ in · %@ out" = "%@ 輸入 · %@ 輸出"; -"Daily estimated spend" = "每日預估支出"; "≈%d full 5h windows of weekly left · %d windows until reset" = "每週額度約剩 %d 個完整 5 小時視窗 · 距重置還有 %d 個視窗"; "Weekly cannot run out before reset at this pace" = "依此速度,每週額度無法在重置前用完"; "Weekly can run out ≈%d windows early" = "每週額度可能提前約 %d 個視窗用完"; diff --git a/Sources/CodexBar/SpendDashboardModel+CurrencySafety.swift b/Sources/CodexBar/SpendDashboardModel+CurrencySafety.swift index 84029cbb13..e419eedda1 100644 --- a/Sources/CodexBar/SpendDashboardModel+CurrencySafety.swift +++ b/Sources/CodexBar/SpendDashboardModel+CurrencySafety.swift @@ -88,8 +88,6 @@ extension SpendDashboardModel.CurrencyGroup { totalCost: nil) }, modelAnalysis: self.modelAnalysis.removingCosts(if: true), - dailyPoints: [], - dailySpendDetails: [], totalTokens: self.totalTokens, totalCost: nil, coveredDayCount: self.coveredDayCount, diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index b306d7765a..9ba99dd24f 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -59,74 +59,6 @@ struct SpendDashboardModel: Equatable, Sendable { } } - struct DailyPoint: Identifiable, Equatable, Sendable { - let sourceID: String - let provider: UsageProvider - let providerName: String - let toolKind: SpendToolIdentity.Kind - let day: Date - let cost: Double - let stackStart: Double - let stackEnd: Double - - var id: String { - "\(self.sourceID):\(Int(self.day.timeIntervalSince1970))" - } - - init( - sourceID: String, - provider: UsageProvider, - providerName: String, - toolKind: SpendToolIdentity.Kind = .other, - day: Date, - cost: Double, - stackStart: Double, - stackEnd: Double) - { - self.sourceID = sourceID - self.provider = provider - self.providerName = providerName - self.toolKind = toolKind - self.day = day - self.cost = cost - self.stackStart = stackStart - self.stackEnd = stackEnd - } - } - - struct DailySpendModel: Identifiable, Equatable, Sendable { - let id: String - let displayName: String - let modelProvider: UsageProvider - let tokens: Int? - let cost: Double? - } - - struct DailySpendTool: Identifiable, Equatable, Sendable { - let sourceID: String - let provider: UsageProvider - let displayName: String - let kind: SpendToolIdentity.Kind - let tokens: Int? - let cost: Double - let models: [DailySpendModel] - - var id: String { - self.sourceID - } - } - - struct DailySpendDetail: Identifiable, Equatable, Sendable { - let day: Date - let totalTokens: Int? - let totalCost: Double - let tools: [DailySpendTool] - - var id: Date { - self.day - } - } - struct TokenActivityPoint: Identifiable, Equatable, Sendable { let day: Date /// `nil` means at least one included source cannot establish coverage for this day. @@ -297,8 +229,6 @@ struct SpendDashboardModel: Equatable, Sendable { let providers: [ProviderRow] let models: [ModelRow] var modelAnalysis: ModelAnalysis = .empty - let dailyPoints: [DailyPoint] - var dailySpendDetails: [DailySpendDetail] = [] let totalTokens: Int? let totalCost: Double? let coveredDayCount: Int @@ -522,20 +452,6 @@ struct SpendDashboardModel: Equatable, Sendable { var overflowedReasoningTokens = false var overflowedCost = false } - - struct DailyKey: Hashable { - let day: Date - let sourceID: String - } - - private struct DailyAccumulator { - let provider: UsageProvider - let providerName: String - let toolKind: SpendToolIdentity.Kind - var cost: Double? - var invalid = false - var overflowed = false - } } extension SpendDashboardModel { @@ -581,17 +497,11 @@ extension SpendDashboardModel { let modelHistoryCompleteness = completeModelSummaries.count == summaries.count ? ModelHistoryCompleteness.complete : ModelHistoryCompleteness.incomplete - // Daily spend is an operational tool view: it answers which local app or harness generated - // the usage. Subscription ownership remains isolated to `providers` above. - let dailyPoints = Self.dailyPoints(summaries: summaries) - let dailySpendDetails = Self.dailySpendDetails(summaries: summaries) return CurrencyGroup( currencyCode: currencyCode, providers: providers, models: modelSummary.rows, modelAnalysis: modelAnalysis, - dailyPoints: dailyPoints, - dailySpendDetails: dailySpendDetails, // "Tracked tokens" is the subtotal we actually parsed, not a completeness assertion. // A source without token detail must not erase known tokens from every other source. // This mirrors Tokscale's aggregation: parsed token buckets always sum independently @@ -1396,130 +1306,4 @@ extension SpendDashboardModel { } return aggregate == dailyTotal } - - private static func dailyPoints(summaries: [InputSummary]) -> [DailyPoint] { - var aggregates: [DailyKey: DailyAccumulator] = [:] - for summary in summaries where !summary.hasInvalidCostHistory { - let input = summary.input - for windowEntry in summary.entries { - let day = windowEntry.day - let entry = windowEntry.entry - let key = DailyKey(day: day, sourceID: input.id) - let tool = SpendToolIdentity.resolve( - provider: input.provider, - sourceName: input.displayName, - providerName: input.modelProviderName) - var aggregate = aggregates[key] ?? DailyAccumulator( - provider: input.provider, - providerName: tool.displayName, - toolKind: tool.kind, - cost: 0) - if let cost = Self.validCost(entry.costUSD).map({ $0 * summary.costMultiplier }) { - aggregate.cost = Self.add(cost, to: aggregate.cost, overflowed: &aggregate.overflowed) - } else { - aggregate.invalid = true - } - aggregates[key] = aggregate - } - } - - let byDay = Dictionary(grouping: aggregates, by: { $0.key.day }) - return byDay.keys.sorted().flatMap { day -> [DailyPoint] in - let rows = (byDay[day] ?? []) - .filter { !$0.value.invalid && !$0.value.overflowed && $0.value.cost != nil } - .sorted { $0.key.sourceID < $1.key.sourceID } - guard let total = Self.completeCostSum(rows.map(\.value.cost)), total.isFinite else { return [] } - var cursor = 0.0 - var points: [DailyPoint] = [] - for (key, value) in rows { - guard let cost = value.cost else { return [] } - let start = cursor - cursor += cost - points.append(DailyPoint( - sourceID: key.sourceID, - provider: value.provider, - providerName: value.providerName, - toolKind: value.toolKind, - day: day, - cost: cost, - stackStart: start, - stackEnd: cursor)) - } - return points - } - } - - private static func dailySpendDetails(summaries: [InputSummary]) -> [DailySpendDetail] { - struct Key: Hashable { - let day: Date - let sourceID: String - } - struct ToolAccum { - let input: ProviderInput - let identity: SpendToolIdentity - var tokens: Int? - var cost = 0.0 - var models: [String: (name: String, provider: UsageProvider, tokens: Int?, cost: Double?)] = [:] - } - - var toolsByKey: [Key: ToolAccum] = [:] - for summary in summaries where !summary.hasInvalidCostHistory { - let input = summary.input - let identity = SpendToolIdentity.resolve( - provider: input.provider, - sourceName: input.displayName, - providerName: input.modelProviderName) - for windowEntry in summary.entries { - guard let entryCost = Self.validCost(windowEntry.entry.costUSD) else { continue } - let key = Key(day: windowEntry.day, sourceID: input.id) - var tool = toolsByKey[key] ?? ToolAccum( - input: input, - identity: identity, - tokens: 0) - tool.cost += entryCost - tool.tokens = Self.addAvailable(windowEntry.entry.totalTokens, to: tool.tokens) - for breakdown in windowEntry.entry.modelBreakdowns ?? [] { - let modelIdentity = SpendModelIdentity(rawName: breakdown.modelName, provider: input.provider) - let modelProvider = SpendProviderIdentity.modelProvider( - rawName: breakdown.modelName, - fallback: input.provider) - let existing = tool.models[modelIdentity.id] - tool.models[modelIdentity.id] = ( - modelIdentity.displayName, - modelProvider, - Self.addAvailable(breakdown.totalTokens, to: existing?.tokens), - Self.addAvailableCost(breakdown.costUSD, to: existing?.cost)) - } - toolsByKey[key] = tool - } - } - - let byDay = Dictionary(grouping: toolsByKey, by: \.key.day) - return byDay.keys.sorted().map { day in - let tools = byDay[day, default: []].map { key, value in - DailySpendTool( - sourceID: key.sourceID, - provider: value.input.provider, - displayName: value.identity.displayName, - kind: value.identity.kind, - tokens: value.tokens, - cost: value.cost, - models: value.models.map { id, model in - DailySpendModel( - id: id, - displayName: model.name, - modelProvider: model.provider, - tokens: model.tokens, - cost: model.cost) - } - .sorted { ($0.cost ?? 0) > ($1.cost ?? 0) }) - } - .sorted { $0.cost > $1.cost } - return DailySpendDetail( - day: day, - totalTokens: Self.availableIntSum(tools.map(\.tokens)), - totalCost: tools.reduce(0) { $0 + $1.cost }, - tools: tools) - } - } } diff --git a/Tests/CodexBarTests/ShareStatsTests.swift b/Tests/CodexBarTests/ShareStatsTests.swift index a41f0a3ad2..52224b6cd8 100644 --- a/Tests/CodexBarTests/ShareStatsTests.swift +++ b/Tests/CodexBarTests/ShareStatsTests.swift @@ -171,7 +171,6 @@ struct ShareStatsTests { ], models: rows, modelAnalysis: .empty, - dailyPoints: [], totalTokens: 1, totalCost: nil, coveredDayCount: 0, @@ -227,7 +226,6 @@ struct ShareStatsTests { totalCost: nil), ], modelAnalysis: .empty, - dailyPoints: [], totalTokens: 10, totalCost: -.infinity, coveredDayCount: 7, @@ -268,7 +266,6 @@ struct ShareStatsTests { totalTokens: 10, totalCost: 2), ], - dailyPoints: [], totalTokens: nil, totalCost: nil, coveredDayCount: 7, @@ -388,7 +385,6 @@ struct ShareStatsTests { totalCost: 1), ], modelAnalysis: .empty, - dailyPoints: [], totalTokens: 300, totalCost: 12, coveredDayCount: 10, @@ -424,7 +420,6 @@ struct ShareStatsTests { totalCost: 4) }, modelAnalysis: .empty, - dailyPoints: [], totalTokens: nil, totalCost: nil, coveredDayCount: 0, diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 8705f5ea8e..59e1d917a6 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -96,7 +96,6 @@ struct SpendActivityHeatmapTests { #expect(model.tokenActivity.count == SpendDashboardModel.tokenActivityDayCount) #expect(model.tokenActivity.first { $0.day == oldDate }?.totalTokens == nil) #expect(model.tokenActivity.first { $0.day == now }?.totalTokens == 70) - #expect(model.groups.first?.dailyPoints.allSatisfy { $0.day == now } == true) } @Test @@ -129,7 +128,7 @@ struct SpendActivityHeatmapTests { let oldDate = try #require(Self.calendar.date(from: DateComponents(year: 2025, month: 8, day: 1))) #expect(model.groups.first?.totalTokens == 10) #expect(model.groups.first?.totalCost == 2) - #expect(model.groups.first?.dailyPoints.count == 1) + #expect(model.groups.first?.coveredDayCount == 1) #expect(model.tokenActivity.first { $0.day == oldDate }?.totalTokens == 40) #expect(model.tokenActivity.first { $0.day == now }?.totalTokens == 10) } diff --git a/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift index 5e28592a4f..122a910f1b 100644 --- a/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift +++ b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift @@ -51,7 +51,7 @@ struct SpendDashboardClockRolloverTests { #expect(controller.generation == generation + 1) #expect(loadCount.value == 2) #expect(controller.model.groups.first?.totalCost == 6) - #expect(controller.model.groups.first?.dailyPoints.count == 1) + #expect(controller.model.groups.first?.coveredDayCount == 1) } @Test @@ -93,7 +93,7 @@ struct SpendDashboardClockRolloverTests { #expect(controller.generation == 2) #expect(controller.model.groups.first?.totalCost == 6) - #expect(controller.model.groups.first?.dailyPoints.count == 1) + #expect(controller.model.groups.first?.coveredDayCount == 1) } @Test diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index d5f52351b9..b969bcca29 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -1045,7 +1045,7 @@ struct SpendDashboardControllerRevisionTests { #expect(controller.model.groups.first?.providers.first?.totalCost == mutation.expectedCost) #expect( controller.model.groups.first?.modelHistoryCompleteness == mutation.expectedCompleteness) - #expect(controller.model.groups.first?.dailyPoints.isEmpty == true) + #expect(controller.model.groups.first?.coveredDayCount == 0) } } diff --git a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift index e67785f414..88acc8b140 100644 --- a/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift +++ b/Tests/CodexBarTests/SpendDashboardDateTruthTests.swift @@ -37,8 +37,6 @@ struct SpendDashboardDateTruthTests { #expect(group.totalCost == 3) #expect(group.totalTokens == 30) #expect(group.coveredDayCount == 2) - #expect(group.dailyPoints.map(\.day) == [june30, july1]) - #expect(group.dailyPoints.map(\.cost) == [1, 2]) } @Test @@ -103,7 +101,6 @@ struct SpendDashboardDateTruthTests { calendar: Self.calendar).groups.first) #expect(earlierGroup.providers.first?.coveredDayCount == 2) #expect(earlierGroup.providers.first?.totalCost == 3) - #expect(earlierGroup.dailyPoints.map(\.day) == [startDate, Self.calendar.startOfDay(for: endDate)]) let recentGroup = try #require(SpendDashboardModel.build( inputs: [.init(provider: .mistral, displayName: "Mistral", snapshot: snapshot)], @@ -113,7 +110,6 @@ struct SpendDashboardDateTruthTests { #expect(recentGroup.providers.first?.coveredDayCount == 0) #expect(recentGroup.providers.first?.totalCost == nil) #expect(recentGroup.providers.first?.totalTokens == nil) - #expect(recentGroup.dailyPoints.isEmpty) } @Test @@ -148,8 +144,6 @@ struct SpendDashboardDateTruthTests { #expect(group.coveredDayCount == 2) #expect(group.totalCost == 3) #expect(group.totalTokens == 30) - #expect(group.dailyPoints.map(\.day) == [july14, july15]) - #expect(group.dailyPoints.map(\.cost) == [1, 2]) } @Test @@ -178,7 +172,6 @@ struct SpendDashboardDateTruthTests { #expect(group.providers.first?.coveredDayCount == 0) #expect(group.totalCost == nil) #expect(group.totalTokens == nil) - #expect(group.dailyPoints.isEmpty) } @Test @@ -205,7 +198,6 @@ struct SpendDashboardDateTruthTests { #expect(Set(unpriced.providers.map(\.id)) == ["blank", "unknown"]) #expect(unpriced.providers.allSatisfy { $0.totalCost == nil }) #expect(unpriced.totalCost == nil) - #expect(unpriced.dailyPoints.isEmpty) } @Test @@ -228,7 +220,6 @@ struct SpendDashboardDateTruthTests { #expect(chf.totalCost == 5) #expect(usd.providers.map(\.id).sorted() == ["eur", "usd"]) #expect(abs((usd.totalCost ?? 0) - (2 + 3 / eurRate)) < 1e-9) - #expect(abs((usd.dailyPoints.map(\.cost).reduce(0, +)) - (2 + 3 / eurRate)) < 1e-9) } @Test @@ -245,7 +236,6 @@ struct SpendDashboardDateTruthTests { #expect(group.providers.first?.totalCost == nil) #expect(group.providers.first?.totalTokens == nil) #expect(group.modelHistoryCompleteness == .incomplete) - #expect(group.dailyPoints.isEmpty) } @Test @@ -291,7 +281,6 @@ struct SpendDashboardDateTruthTests { #expect(group.providers.first?.totalTokens == testCase.totalTokens, Comment(rawValue: testCase.name)) #expect(group.modelHistoryCompleteness == testCase.modelHistory, Comment(rawValue: testCase.name)) #expect(group.models.map(\.totalCost) == (testCase.totalCost == nil ? [] : [3])) - #expect(group.dailyPoints.first?.cost == testCase.chartCost, Comment(rawValue: testCase.name)) } } @@ -343,26 +332,17 @@ struct SpendDashboardDateTruthTests { #expect(usd.models.map(\.totalCost) == [4, 3]) #expect(usd.models.first(where: { $0.provider == .claude })?.totalTokens == nil) #expect(usd.models.first(where: { $0.provider == .codex })?.totalTokens == 10) - #expect(usd.dailyPoints.map(\.sourceID) == ["healthy-usd", "token-invalid"]) - #expect(SpendDailyChartPresentation( - dailyPoints: usd.dailyPoints, - aggregateTotal: usd.totalCost).content == .chart) #expect(cad.totalCost == 5) #expect(cad.totalTokens == 30) #expect(cad.modelHistoryCompleteness == .incomplete) #expect(cad.models.map(\.provider) == [.mistral]) #expect(cad.models.map(\.totalCost) == [5]) - #expect(cad.dailyPoints.map(\.sourceID) == ["healthy-cad"]) - #expect(SpendDailyChartPresentation( - dailyPoints: cad.dailyPoints, - aggregateTotal: cad.totalCost).content == .chart) #expect(eur.totalCost == 6) #expect(eur.totalTokens == 10) #expect(eur.modelHistoryCompleteness == .complete) #expect(eur.models.map(\.totalCost) == [6]) - #expect(eur.dailyPoints.map(\.sourceID) == ["healthy-eur"]) } } @@ -429,7 +409,6 @@ struct SpendDashboardDateTruthTests { #expect(group.models.map(\.modelName) == ["overflow", "mismatch", "negative", "valid"]) #expect(group.models.map(\.totalCost) == [7, 6, 5, 2]) #expect(group.models.map(\.totalTokens) == [nil, nil, nil, 2]) - #expect(Set(group.dailyPoints.map(\.sourceID)) == ["mismatch", "negative", "overflow", "valid"]) } @Test @@ -459,12 +438,9 @@ struct SpendDashboardDateTruthTests { #expect(usd.modelHistoryCompleteness == .incomplete) #expect(usd.models.map(\.provider) == [.codex]) #expect(usd.models.map(\.totalCost) == [4]) - #expect(usd.dailyPoints.map(\.sourceID) == ["healthy-usd"]) - #expect(usd.dailyPoints.map(\.cost) == [4]) #expect(eur.totalCost == 5) #expect(eur.totalTokens == 10) #expect(eur.modelHistoryCompleteness == .complete) - #expect(eur.dailyPoints.map(\.sourceID) == ["healthy-eur"]) } @Test @@ -495,7 +471,6 @@ struct SpendDashboardDateTruthTests { #expect(group.totalTokens == nil) #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.isEmpty) - #expect(group.dailyPoints.isEmpty) } } @@ -518,7 +493,6 @@ struct SpendDashboardDateTruthTests { #expect(group.totalTokens == 100) #expect(group.modelHistoryCompleteness == .complete) #expect(group.models.map(\.totalCost) == [10]) - #expect(group.dailyPoints.map(\.cost) == [7, 3]) } @Test @@ -551,14 +525,12 @@ struct SpendDashboardDateTruthTests { // instead of voiding the whole provider, so cost stays visible while tokens stay intact. #expect(costGroup.totalCost == 3) #expect(costGroup.totalTokens == 30) - #expect(costGroup.dailyPoints.isEmpty) #expect(costGroup.modelHistoryCompleteness == .complete) #expect(costGroup.models.map(\.totalCost) == [3]) #expect(costGroup.models.map(\.totalTokens) == [30]) #expect(tokenGroup.totalCost == 3) #expect(tokenGroup.totalTokens == nil) - #expect(tokenGroup.dailyPoints.map(\.cost) == [3]) #expect(tokenGroup.modelHistoryCompleteness == .complete) #expect(tokenGroup.models.map(\.totalCost) == [3]) #expect(tokenGroup.models.map(\.totalTokens) == [nil]) @@ -585,7 +557,6 @@ struct SpendDashboardDateTruthTests { #expect(group.totalTokens == 30) #expect(group.models.map(\.totalCost) == [3]) #expect(group.models.map(\.totalTokens) == [30]) - #expect(group.dailyPoints.map(\.cost) == [3]) } @Test @@ -608,7 +579,6 @@ struct SpendDashboardDateTruthTests { #expect(group.totalTokens == 30) #expect(group.modelHistoryCompleteness == .complete) #expect(group.models.map(\.totalCost) == [3]) - #expect(group.dailyPoints.map(\.cost) == [3]) } @Test @@ -638,12 +608,9 @@ struct SpendDashboardDateTruthTests { #expect(usd.modelHistoryCompleteness == .incomplete) #expect(usd.models.map(\.provider) == [.codex]) #expect(usd.models.map(\.totalCost) == [4]) - #expect(usd.dailyPoints.map(\.sourceID) == ["healthy-usd"]) - #expect(usd.dailyPoints.map(\.cost) == [4]) #expect(eur.totalCost == 5) #expect(eur.modelHistoryCompleteness == .complete) #expect(eur.models.map(\.totalCost) == [5]) - #expect(eur.dailyPoints.map(\.sourceID) == ["healthy-eur"]) } @Test @@ -700,7 +667,6 @@ struct SpendDashboardDateTruthTests { #expect(group.totalCost == nil) #expect(group.totalTokens == nil) #expect(group.modelHistoryCompleteness == .incomplete) - #expect(group.dailyPoints.isEmpty) } @Test diff --git a/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift b/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift index 62f8462ca2..2daa894efd 100644 --- a/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardKimiModelTests.swift @@ -28,7 +28,6 @@ struct SpendDashboardKimiModelTests { #expect(unpriced.totalCost == nil) #expect(unpriced.providers.map(\.totalCost) == [nil]) #expect(unpriced.modelAnalysis.pricedCostTotal == nil) - #expect(unpriced.dailyPoints.isEmpty) #expect(model.modelAnalysis.rows.map(\.displayName) == ["Kimi K3", "GPT-test"]) #expect(model.modelAnalysis.rows.map(\.totalTokens) == [90, 10]) #expect(model.modelAnalysis.rows.first?.rawModelNames == ["kimi-code/k3"]) diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 20b0de6f6a..30782c2fa8 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -268,8 +268,6 @@ struct SpendDashboardModelTests { let thirtyDayStart = try #require(Self.calendar.date(byAdding: .day, value: -29, to: anchor)) let end = try #require(Self.calendar.date(byAdding: .day, value: 1, to: anchor)) - #expect(sevenDays.dailyPoints.map(\.day) == [anchor]) - #expect(thirtyDays.dailyPoints.map(\.day) == [anchor]) #expect(sevenDays.chartDomain == sevenDayStart...end) #expect(thirtyDays.chartDomain == thirtyDayStart...end) } @@ -477,7 +475,6 @@ struct SpendDashboardModelTests { #expect(group.providers.allSatisfy { $0.coveredDayCount == 7 }) #expect(group.totalCost == 5) #expect(group.providers.map(\.id) == ["later", "earlier"]) - #expect(group.dailyPoints.map(\.sourceID) == ["earlier", "later"]) } @Test @@ -1048,34 +1045,6 @@ struct SpendDashboardModelTests { #expect(group.totalCost == 4) #expect(group.coveredDayCount == 7) - #expect(group.dailyPoints.map(\.day) == [gregorian.startOfDay(for: now)]) - } - - @Test - func `daily values aggregate once and produce deterministic nonoverlapping stacks`() throws { - let first = SpendDashboardModel.ProviderInput( - id: "a", - provider: .claude, - displayName: "Claude", - snapshot: Self.snapshot(currency: "USD", entries: [ - Self.entry(day: "2026-07-16", cost: 2), - Self.entry(day: "2026-07-16", cost: 3), - ])) - let second = SpendDashboardModel.ProviderInput( - id: "b", - provider: .codex, - displayName: "Codex", - snapshot: Self.snapshot(currency: "USD", entries: [Self.entry(day: "2026-07-16", cost: 4)])) - let group = try #require(SpendDashboardModel.build( - inputs: [second, first], - requestedDays: 7, - now: Self.now, - calendar: Self.calendar).groups.first) - - #expect(group.dailyPoints.map(\.sourceID) == ["a", "b"]) - #expect(group.dailyPoints.map(\.cost) == [5, 4]) - #expect(group.dailyPoints.map(\.stackStart) == [0, 5]) - #expect(group.dailyPoints.map(\.stackEnd) == [5, 9]) } @Test @@ -1101,7 +1070,6 @@ struct SpendDashboardModelTests { #expect(group.providers.first(where: { $0.id == "invalid" })?.totalCost == nil) #expect(group.totalCost == nil) #expect(group.totalTokens == 20) - #expect(group.dailyPoints.isEmpty) } @Test @@ -1122,7 +1090,6 @@ struct SpendDashboardModelTests { #expect(group.totalTokens == nil) #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.isEmpty) - #expect(group.dailyPoints.isEmpty) #expect(group.modelAnalysis.rows.first?.totalTokens == 40) #expect(group.modelAnalysis.rows.first?.estimatedCost == 4) #expect(group.modelAnalysis.tokenCoverage == .partial) @@ -1146,7 +1113,6 @@ struct SpendDashboardModelTests { #expect(group.totalTokens == nil) #expect(group.modelHistoryCompleteness == .incomplete) #expect(group.models.isEmpty) - #expect(group.dailyPoints.isEmpty) } @Test @@ -1173,7 +1139,6 @@ struct SpendDashboardModelTests { #expect(group.totalTokens == 30) #expect(group.modelHistoryCompleteness == .complete) #expect(group.models.map(\.totalCost) == [3]) - #expect(group.dailyPoints.map(\.cost) == [3]) } @Test @@ -1359,37 +1324,6 @@ extension SpendDashboardModelTests { #expect(group.models.isEmpty) } - @Test - func `incomplete duplicate day sources do not render partial chart stacks`() throws { - let missing = SpendDashboardModel.ProviderInput( - id: "missing", - provider: .claude, - displayName: "Missing", - snapshot: Self.snapshot(currency: "USD", entries: [ - Self.entry(day: "2026-07-16", cost: 2), - Self.entry(day: "2026-07-16", cost: nil), - ])) - let overflow = SpendDashboardModel.ProviderInput( - id: "overflow", - provider: .codex, - displayName: "Overflow", - snapshot: Self.snapshot(currency: "USD", entries: [ - Self.entry(day: "2026-07-16", cost: .greatestFiniteMagnitude), - Self.entry(day: "2026-07-16", cost: .greatestFiniteMagnitude), - ])) - let complete = Self.input(id: "complete", provider: .openai, currency: "USD", cost: 3) - let group = try #require(SpendDashboardModel.build( - inputs: [missing, overflow, complete], - requestedDays: 7, - now: Self.now, - calendar: Self.calendar).groups.first) - - #expect(group.dailyPoints.map(\.sourceID) == ["complete"]) - #expect(group.dailyPoints.map(\.cost) == [3]) - #expect(group.dailyPoints.map(\.stackStart) == [0]) - #expect(group.dailyPoints.map(\.stackEnd) == [3]) - } - @Test func `covered inactive sources contribute zero without hiding active totals`() throws { let inactive = SpendDashboardModel.ProviderInput( diff --git a/Tests/CodexBarTests/SpendToolPresentationTests.swift b/Tests/CodexBarTests/SpendToolPresentationTests.swift index ea5922857d..7dcbca578e 100644 --- a/Tests/CodexBarTests/SpendToolPresentationTests.swift +++ b/Tests/CodexBarTests/SpendToolPresentationTests.swift @@ -102,20 +102,6 @@ struct SpendToolPresentationTests { #expect(total.value == 110) } - @Test - func `daily spend details preserve tool type models amounts and tokens`() throws { - let group = try #require(Self.model().groups.first) - let detail = try #require(group.dailySpendDetails.first) - let codex = try #require(detail.tools.first { $0.provider == .codex }) - - #expect(codex.kind == .desktop) - #expect(codex.displayName == "Codex Desktop") - #expect(codex.tokens == 70) - #expect(codex.cost == 1) - #expect(codex.models.first?.displayName == "GPT-5 Test") - #expect(codex.models.first?.modelProvider == .openai) - } - private static func model() -> SpendDashboardModel { let inputs = [ Self.input( diff --git a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift index e4c9e3e37f..08d76fadd3 100644 --- a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift +++ b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift @@ -128,46 +128,4 @@ struct UserFacingLocalizationCoverageTests { #expect(source.contains(#"Text(L("Model breakdown unavailable"))"#)) #expect(source.contains(#"Text(L("No model-level history"))"#)) } - - @Test - func `spend dashboard chart keeps validated points when aggregate total is unavailable`() { - let start = Date(timeIntervalSince1970: 1_783_036_800) - let points = [ - SpendDashboardModel.DailyPoint( - sourceID: "healthy-claude", - provider: .claude, - providerName: "Claude", - day: start, - cost: 2, - stackStart: 0, - stackEnd: 2), - SpendDashboardModel.DailyPoint( - sourceID: "healthy-openai-1", - provider: .openai, - providerName: "OpenAI", - day: start, - cost: 3, - stackStart: 2, - stackEnd: 5), - SpendDashboardModel.DailyPoint( - sourceID: "healthy-openai-2", - provider: .openai, - providerName: "OpenAI", - day: start.addingTimeInterval(86400), - cost: 4, - stackStart: 0, - stackEnd: 4), - ] - - let partial = SpendDailyChartPresentation(dailyPoints: points, aggregateTotal: nil) - #expect(partial.content == .chart) - #expect(partial.series.map(\.name) == ["Claude", "OpenAI"]) - #expect(partial.dayCount == 2) - CodexBarLocalizationOverride.$appLanguage.withValue("en") { - #expect(partial.accessibilityValue == "2 days of usage data across 2 services") - } - - #expect(SpendDailyChartPresentation(dailyPoints: [], aggregateTotal: nil).content == .unavailable) - #expect(SpendDailyChartPresentation(dailyPoints: [], aggregateTotal: 0).content == .chart) - } } From dce897f34586a420e29143f49e4b11458fe1d9b8 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:37:11 +0800 Subject: [PATCH 33/40] feat(spend): CLIENTS & MODELS by-tool view with collapsible client rows and per-model bucket details --- .../Resources/ar.lproj/Localizable.strings | 2 + .../Resources/ca.lproj/Localizable.strings | 2 + .../Resources/de.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/fa.lproj/Localizable.strings | 2 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/gl.lproj/Localizable.strings | 2 + .../Resources/id.lproj/Localizable.strings | 2 + .../Resources/it.lproj/Localizable.strings | 2 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 2 + .../Resources/pl.lproj/Localizable.strings | 2 + .../Resources/pt-BR.lproj/Localizable.strings | 2 + .../Resources/ru.lproj/Localizable.strings | 2 + .../Resources/sv.lproj/Localizable.strings | 2 + .../Resources/th.lproj/Localizable.strings | 2 + .../Resources/tr.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + .../Resources/vi.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + Sources/CodexBar/SpendClientsView.swift | 231 +++++++++--------- .../SpendClientsPresentationTests.swift | 141 +++++++++++ 25 files changed, 304 insertions(+), 114 deletions(-) create mode 100644 Tests/CodexBarTests/SpendClientsPresentationTests.swift diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index e29acdf0ed..c1097707b7 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -882,6 +882,8 @@ "Total" = "الإجمالي"; "tokens" = "الرموز"; "requests" = "الطلبات"; +"CLIENTS & MODELS" = "العملاء والطرازات"; +"messages" = "رسائل"; "Latest" = "أحدث الإصدارات"; "Monthly" = "شهريا"; "Sonnet" = "السوناتة"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 918520d6c5..8208ab54f9 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -744,6 +744,8 @@ "Total" = "Total"; "tokens" = "tokens"; "requests" = "sol·licituds"; +"CLIENTS & MODELS" = "Clients i models"; +"messages" = "missatges"; "Latest" = "Més recent"; "Monthly" = "Mensual"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index a5c667715f..9bf2068004 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -873,6 +873,8 @@ "Total" = "Gesamt"; "tokens" = "Token"; "requests" = "Anfragen"; +"CLIENTS & MODELS" = "Clients & Modelle"; +"messages" = "Nachrichten"; "Latest" = "Letzte"; "Monthly" = "Monatlich"; "Sonnet" = "Sonett"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 1837b6a28c..ecd5d782b4 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -882,6 +882,8 @@ "Total" = "Total"; "tokens" = "tokens"; "requests" = "requests"; +"CLIENTS & MODELS" = "CLIENTS & MODELS"; +"messages" = "messages"; "Latest" = "Latest"; "Monthly" = "Monthly"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 2a8fcd4284..bfcb06639a 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -753,6 +753,8 @@ "Total" = "Total"; "tokens" = "tokens"; "requests" = "solicitudes"; +"CLIENTS & MODELS" = "Clientes y modelos"; +"messages" = "mensajes"; "Latest" = "Último"; "Monthly" = "Mensual"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 821f2d867d..f18908b78e 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -882,6 +882,8 @@ "Total" = "مجموع"; "tokens" = "توکن ها"; "requests" = "درخواست ها"; +"CLIENTS & MODELS" = "کلاینت‌ها و مدل‌ها"; +"messages" = "پیام‌ها"; "Latest" = "جدیدترین ها"; "Monthly" = "ماهانه"; "Sonnet" = "سونت"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 933eccc5de..e4ef68aaee 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -875,6 +875,8 @@ "Total" = "Total"; "tokens" = "jetons"; "requests" = "requêtes"; +"CLIENTS & MODELS" = "Clients et modèles"; +"messages" = "messages"; "Latest" = "Dernier"; "Monthly" = "Mensuel"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index dbf0ea1e50..dd1a57ec3a 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -733,6 +733,8 @@ "Total" = "Total"; "tokens" = "tokens"; "requests" = "solicitudes"; +"CLIENTS & MODELS" = "Clientes e modelos"; +"messages" = "mensaxes"; "Latest" = "Máis recente"; "Monthly" = "Mensual"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index d46199c370..2f8ecb4228 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -884,6 +884,8 @@ "Total" = "Total"; "tokens" = "token"; "requests" = "permintaan"; +"CLIENTS & MODELS" = "Klien dan model"; +"messages" = "pesan"; "Latest" = "Terbaru"; "Monthly" = "Bulanan"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index a0148189cf..9a2de4cc44 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -884,6 +884,8 @@ "Total" = "Totale"; "tokens" = "token"; "requests" = "richieste"; +"CLIENTS & MODELS" = "Clienti e modelli"; +"messages" = "messaggi"; "Latest" = "Più recente"; "Monthly" = "Mensile"; "Sonnet" = "Claude Sonnet"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index c2e5c39636..f10923ee50 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -872,6 +872,8 @@ "Total" = "合計"; "tokens" = "トークン"; "requests" = "リクエスト"; +"CLIENTS & MODELS" = "クライアントとモデル"; +"messages" = "メッセージ"; "Latest" = "最新"; "Monthly" = "月間"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index e6b598438e..9601d28bd2 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -843,6 +843,8 @@ "Total" = "합계"; "tokens" = "토큰"; "requests" = "요청"; +"CLIENTS & MODELS" = "클라이언트 및 모델"; +"messages" = "메시지"; "Latest" = "최신"; "Monthly" = "월간"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index b4345605d6..23082ca477 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -875,6 +875,8 @@ "Total" = "Totaal"; "tokens" = "tokens"; "requests" = "verzoeken"; +"CLIENTS & MODELS" = "Clients en modellen"; +"messages" = "berichten"; "Latest" = "Nieuwste"; "Monthly" = "Maandelijks"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 98865ef0ee..a5d8bd996f 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -884,6 +884,8 @@ "Total" = "Łącznie"; "tokens" = "tokeny"; "requests" = "żądania"; +"CLIENTS & MODELS" = "Klienci i modele"; +"messages" = "wiadomości"; "Latest" = "Najnowsze"; "Monthly" = "Miesięcznie"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 11dd32455b..61970ba47b 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -872,6 +872,8 @@ "Total" = "Total"; "tokens" = "tokens"; "requests" = "requisições"; +"CLIENTS & MODELS" = "Clientes e modelos"; +"messages" = "mensagens"; "Latest" = "Mais recente"; "Monthly" = "Mensal"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 5169f75595..3c1173ad95 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -877,6 +877,8 @@ "Total" = "Всего"; "tokens" = "токены"; "requests" = "запросы"; +"CLIENTS & MODELS" = "Клиенты и модели"; +"messages" = "сообщений"; "Latest" = "Последние"; "Monthly" = "Ежемесячно"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index f46e85d2cd..c484fa2c6c 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1077,6 +1077,8 @@ "Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI-distributionens namn. AZURE_OPENAI_DEPLOYMENT_NAME stöds också."; "Extra usage balance: %@" = "Saldo för extra användning: %@"; "requests" = "förfrågningar"; +"CLIENTS & MODELS" = "Klienter och modeller"; +"messages" = "meddelanden"; "CodexBar could not save the current system account before switching." = "CodexBar kunde inte spara det aktuella systemkontot före bytet."; "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Sparas i ~/.codexbar/config.json. För det officiella Kimi-API:t använder du Moonshot / Kimi API."; "Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\neller klistra in en cURL-fångst från Abacus AI-översikten"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 5dd8568841..31916d3e22 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -882,6 +882,8 @@ "Total" = "รวม"; "tokens" = "โทเค็น"; "requests" = "คําขอ"; +"CLIENTS & MODELS" = "ไคลเอนต์และโมเดล"; +"messages" = "ข้อความ"; "Latest" = "ล่าสุด"; "Monthly" = "รายเดือน"; "Sonnet" = "โคลง"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 674f7f7389..1272ac241f 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -878,6 +878,8 @@ "Total" = "Toplam"; "tokens" = "jeton"; "requests" = "istek"; +"CLIENTS & MODELS" = "İstemciler ve modeller"; +"messages" = "mesaj"; "Latest" = "Son"; "Monthly" = "Aylık"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index b9b8ef624b..d9beff3615 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -875,6 +875,8 @@ "Total" = "Усього"; "tokens" = "жетони"; "requests" = "запити"; +"CLIENTS & MODELS" = "Клієнти та моделі"; +"messages" = "повідомлень"; "Latest" = "Останній"; "Monthly" = "Щомісяця"; "Sonnet" = "Сонет"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index f3dfc49773..e1b6608f7d 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -871,6 +871,8 @@ "Total" = "Tổng"; "tokens" = "mã thông báo"; "requests" = "yêu cầu"; +"CLIENTS & MODELS" = "Khách hàng và mô hình"; +"messages" = "tin nhắn"; "Latest" = "Mới nhất"; "Monthly" = "Hàng tháng"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 18712d4035..98686a911d 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -842,6 +842,8 @@ "Total" = "总计"; "tokens" = "token"; "requests" = "请求"; +"CLIENTS & MODELS" = "客户端与模型"; +"messages" = "条消息"; "Latest" = "最新"; "Monthly" = "每月"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 8183a9a666..34d8ace1db 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -748,6 +748,8 @@ "Total" = "總計"; "tokens" = "token"; "requests" = "請求"; +"CLIENTS & MODELS" = "用戶端與模型"; +"messages" = "則訊息"; "Latest" = "最新"; "Monthly" = "每月"; "Sonnet" = "Sonnet"; diff --git a/Sources/CodexBar/SpendClientsView.swift b/Sources/CodexBar/SpendClientsView.swift index 38c2c0f7b1..61631a89d3 100644 --- a/Sources/CodexBar/SpendClientsView.swift +++ b/Sources/CodexBar/SpendClientsView.swift @@ -22,6 +22,11 @@ struct SpendClientModel: Identifiable, Equatable { let tokens: Int? let cost: Double? let costIsEstimated: Bool + let inputTokens: Int? + let outputTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + let reasoningTokens: Int? let requestCount: Int? } @@ -112,6 +117,11 @@ enum SpendClientBreakdown { tokens: accum.tokens, cost: accum.cost, costIsEstimated: accum.costIsEstimated, + inputTokens: accum.inputTokens, + outputTokens: accum.outputTokens, + cacheReadTokens: accum.cacheReadTokens, + cacheCreationTokens: accum.cacheCreationTokens, + reasoningTokens: accum.reasoningTokens, requestCount: accum.requestCount) } .sorted { ($0.tokens ?? -1) > ($1.tokens ?? -1) } @@ -185,6 +195,48 @@ enum SpendClientBreakdown { } } +// MARK: - 模型明细文本 + +/// Composes the per-model token-bucket detail line shown under each model row. +enum SpendClientModelDetailText { + static func detailText(model: SpendClientModel) -> String { + var parts: [String] = [] + if let tokens = model.tokens { + parts.append(String(format: L("%@ tokens"), UsageFormatter.tokenCountString(tokens))) + } + let buckets: [(kind: SpendModelsDayDetailPresentation.BucketKind, tokens: Int?)] = [ + (.input, model.inputTokens), + (.output, model.outputTokens), + (.cacheRead, model.cacheReadTokens), + (.cacheWrite, model.cacheCreationTokens), + (.reasoning, model.reasoningTokens), + ] + for bucket in buckets { + guard let tokens = bucket.tokens, tokens > 0 else { continue } + parts.append("\(bucket.kind.title) \(UsageFormatter.tokenCountString(tokens))") + } + if let requestCount = model.requestCount, requestCount > 0 { + parts.append("\(UsageFormatter.tokenCountString(requestCount)) \(L("messages"))") + } + return parts.joined(separator: " · ") + } +} + +// MARK: - 模型花费文本 + +/// Formats the model row's right-side metric: cost, falling back to tokens when unpriced. +enum SpendClientModelMetricText { + static func text(cost: Double?, tokens: Int?, currencyCode: String) -> String { + if let cost { + return UsageFormatter.currencyString(cost, currencyCode: currencyCode) + } + if let tokens { + return UsageFormatter.tokenCountString(tokens) + } + return "—" + } +} + struct SpendToolModelComparison: Identifiable, Equatable { struct Tool: Identifiable, Equatable { let sourceID: String @@ -289,11 +341,9 @@ enum SpendToolComparisonPresentation { struct SpendClientsView: View { let analysis: SpendDashboardModel.ModelAnalysis let currencyCode: String - @State private var expandedGroupIDs: Set = [] + @State private var collapsedGroupIDs: Set = [] @State private var selectedComparisonID: String? - private static let collapsedModelLimit = 5 - var body: some View { let groups = SpendClientBreakdown.groups(from: self.analysis) let comparisons = SpendToolComparisonPresentation.comparisons(from: self.analysis) @@ -305,6 +355,9 @@ struct SpendClientsView: View { .padding(.vertical, 10) } else { VStack(alignment: .leading, spacing: 12) { + Text(L("CLIENTS & MODELS")) + .font(SpendModelsListStyle.tertiaryFont.weight(.semibold)) + .foregroundStyle(.secondary) if !comparisons.isEmpty { self.comparisonCard(comparisons) } @@ -385,99 +438,80 @@ struct SpendClientsView: View { } private func card(_ group: SpendClientGroup) -> some View { - let isExpanded = self.expandedGroupIDs.contains(group.id) - let visibleModels = isExpanded - ? group.models - : Array(group.models.prefix(Self.collapsedModelLimit)) + let isCollapsed = self.collapsedGroupIDs.contains(group.id) return VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 7) { - SpendProviderIcon( - provider: group.provider, - size: SpendModelsListStyle.iconSize) - .frame( - width: SpendModelsListStyle.iconFrameSize, - height: SpendModelsListStyle.iconFrameSize) - Text(cleanToolName(group.displayTitle)) - .font(SpendModelsListStyle.primaryEmphasizedFont) - Text(group.kind.displayName) - .font(SpendModelsListStyle.tertiaryFont.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.secondary.opacity(0.12), in: Capsule()) - if group.costIsEstimated { - Text(L("Estimated")) - .font(SpendModelsListStyle.tertiaryFont) + Button { + if isCollapsed { + self.collapsedGroupIDs.remove(group.id) + } else { + self.collapsedGroupIDs.insert(group.id) + } + } label: { + HStack(spacing: 7) { + SpendProviderIcon( + provider: group.provider, + size: SpendModelsListStyle.iconSize) + .frame( + width: SpendModelsListStyle.iconFrameSize, + height: SpendModelsListStyle.iconFrameSize) + Text(cleanToolName(group.displayTitle)) + .font(SpendModelsListStyle.primaryEmphasizedFont) + Spacer() + Text(self.totalText(group)) + .font(SpendModelsListStyle.primaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(Color.secondary.opacity(0.15), in: Capsule()) } - Spacer() - Text(self.totalText(group)) - .font(SpendModelsListStyle.primaryFont) - .foregroundStyle(.secondary) - .monospacedDigit() + .contentShape(Rectangle()) } - .padding(.bottom, 6) - - Text(self.toolEvidenceSummary(group)) - .font(SpendModelsListStyle.secondaryFont) - .foregroundStyle(.secondary) - .monospacedDigit() - .lineLimit(2) - .padding(.bottom, 8) - .padding(.leading, SpendModelsListStyle.iconFrameSize + 7) - - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(visibleModels.enumerated()), id: \.element.id) { index, model in - if index > 0 { Divider().padding(.vertical, 2) } - self.modelRow(model) - } - if group.models.count > Self.collapsedModelLimit { - Button { - if isExpanded { - self.expandedGroupIDs.remove(group.id) - } else { - self.expandedGroupIDs.insert(group.id) - } - } label: { - HStack(spacing: 5) { - Text(isExpanded - ? L("Show fewer models") - : String(format: L("Show all %d models"), group.models.count)) - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.caption2.weight(.semibold)) - } - .font(SpendModelsListStyle.secondaryFont.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.top, 7) + .buttonStyle(.plain) + .padding(.bottom, isCollapsed ? 0 : 6) + + if !isCollapsed { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(group.models.enumerated()), id: \.element.id) { index, model in + if index > 0 { Divider().padding(.vertical, 2) } + self.modelRow(model) } - .buttonStyle(.plain) } + .padding(.leading, SpendModelsListStyle.modelIndent) } - .padding(.leading, SpendModelsListStyle.modelIndent) } .padding(14) .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } private func modelRow(_ model: SpendClientModel) -> some View { - HStack(alignment: .center, spacing: 8) { - SpendProviderIcon(provider: model.modelProvider, size: SpendModelsListStyle.modelIconSize) - .frame( - width: SpendModelsListStyle.modelIconFrameSize, - height: SpendModelsListStyle.modelIconFrameSize) - Text(model.displayName) - .font(SpendModelsListStyle.primaryFont) - .lineLimit(1) - Spacer() - Text(self.modelMetric(model)) - .font(SpendModelsListStyle.valueFont) - .foregroundStyle(.secondary) - .monospacedDigit() + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .center, spacing: 8) { + SpendProviderIcon(provider: model.modelProvider, size: SpendModelsListStyle.modelIconSize) + .frame( + width: SpendModelsListStyle.modelIconFrameSize, + height: SpendModelsListStyle.modelIconFrameSize) + Text(model.displayName) + .font(SpendModelsListStyle.primaryFont) + .lineLimit(1) + Spacer() + Text(self.modelMetric(model)) + .font(SpendModelsListStyle.valueFont) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.top, 5) + + let detail = SpendClientModelDetailText.detailText(model: model) + if !detail.isEmpty { + Text(detail) + .font(SpendModelsListStyle.tertiaryFont) + .foregroundStyle(.secondary) + .monospacedDigit() + .padding(.leading, SpendModelsListStyle.modelIconFrameSize + 8) + .padding(.bottom, 5) + } } - .padding(.vertical, 5) } private func totalText(_ group: SpendClientGroup) -> String { @@ -492,38 +526,7 @@ struct SpendClientsView: View { } private func modelMetric(_ model: SpendClientModel) -> String { - var parts: [String] = [] - if let tokens = model.tokens { - parts.append(UsageFormatter.tokenCountString(tokens)) - } - if let cost = model.cost { - parts.append(UsageFormatter.currencyString(cost, currencyCode: self.currencyCode)) - } - return parts.joined(separator: " · ") - } - - private func toolEvidenceSummary(_ group: SpendClientGroup) -> String { - var parts = [String(format: L("%d models"), group.models.count)] - if let requests = group.requestCount { - parts.append(String(format: L("%@ requests"), UsageFormatter.tokenCountString(requests))) - } - if let rate = SpendToolComparisonPresentation.contextReuseRate( - input: group.inputTokens, - cacheRead: group.cacheReadTokens, - cacheCreation: group.cacheCreationTokens) - { - parts.append(String(format: L("%d%% context reuse"), Int((rate * 100).rounded()))) - } - if let projects = group.projectCount { - parts.append(String(format: L("%d projects"), projects)) - } - if let sessions = group.sessionCount { - parts.append(String(format: L("%d sessions"), sessions)) - } - if group.coveredDayCount > 0 { - parts.append(String(format: L("%d days covered"), group.coveredDayCount)) - } - return parts.joined(separator: " · ") + SpendClientModelMetricText.text(cost: model.cost, tokens: model.tokens, currencyCode: self.currencyCode) } private func comparisonMetrics(_ tool: SpendToolModelComparison.Tool) -> String { diff --git a/Tests/CodexBarTests/SpendClientsPresentationTests.swift b/Tests/CodexBarTests/SpendClientsPresentationTests.swift new file mode 100644 index 0000000000..69521c4ef7 --- /dev/null +++ b/Tests/CodexBarTests/SpendClientsPresentationTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import CodexBar + +struct SpendClientsPresentationTests { + @Test + func `model detail text includes all buckets and messages`() { + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + let text = SpendClientModelDetailText.detailText(model: Self.model( + tokens: 53_700_000, + inputTokens: 1_200_000, + outputTokens: 77900, + cacheReadTokens: 52_400_000, + cacheCreationTokens: 0, + reasoningTokens: 27300, + requestCount: 403)) + #expect( + text + == "54M tokens · Input 1.2M · Output 78K · Cache read 52M · Reasoning 27K · 403 messages") + } + } + + @Test + func `model detail text skips missing and zero buckets`() { + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + let text = SpendClientModelDetailText.detailText(model: Self.model( + tokens: 1000, + inputTokens: nil, + outputTokens: 500, + cacheReadTokens: 0, + cacheCreationTokens: nil, + reasoningTokens: nil, + requestCount: nil)) + #expect(text == "1K tokens · Output 500") + } + } + + @Test + func `model detail text is empty when no data is available`() { + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + let text = SpendClientModelDetailText.detailText(model: Self.model()) + #expect(text.isEmpty) + } + } + + private static func model( + tokens: Int? = nil, + inputTokens: Int? = nil, + outputTokens: Int? = nil, + cacheReadTokens: Int? = nil, + cacheCreationTokens: Int? = nil, + reasoningTokens: Int? = nil, + requestCount: Int? = nil) -> SpendClientModel + { + SpendClientModel( + id: "model", + displayName: "model", + modelProvider: .openai, + tokens: tokens, + cost: nil, + costIsEstimated: false, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheReadTokens: cacheReadTokens, + cacheCreationTokens: cacheCreationTokens, + reasoningTokens: reasoningTokens, + requestCount: requestCount) + } + + @Test + func `model metric prefers cost then falls back to tokens`() { + #expect(SpendClientModelMetricText.text(cost: 1.5, tokens: 1000, currencyCode: "USD") == "$1.50") + #expect(SpendClientModelMetricText.text(cost: nil, tokens: 1000, currencyCode: "USD") == "1K") + #expect(SpendClientModelMetricText.text(cost: nil, tokens: nil, currencyCode: "USD") == "—") + } + + @Test + func `client breakdown passes per-model buckets into group models`() throws { + let analysis = SpendDashboardModel.ModelAnalysis( + rows: [ + SpendDashboardModel.ModelAnalysisRow( + id: "gpt-x", + displayName: "gpt-x", + rawModelNames: ["gpt-x"], + providers: [.openai], + providerNames: ["Codex"], + contributions: [ + SpendDashboardModel.ModelSourceContribution( + sourceID: "codex", + provider: .openai, + sourceName: "Codex Desktop", + providerName: "Codex", + rawModelNames: ["gpt-x"], + totalTokens: 10000, + inputTokens: 6000, + outputTokens: 3000, + cacheReadTokens: 1000, + cacheCreationTokens: 200, + reasoningTokens: 300, + requestCount: 25, + coveredDayCount: 30, + projectCount: 2, + sessionCount: 3, + estimatedCost: 1.5, + costIsEstimated: true), + ], + totalTokens: 10000, + inputTokens: 6000, + outputTokens: 3000, + estimatedCost: 1.5, + cacheReadTokens: 1000, + cacheCreationTokens: 200, + reasoningTokens: 300, + costIsEstimated: true), + ], + dailyValues: [], + trackedTokenTotal: 10000, + pricedCostTotal: 1.5, + sourceCount: 1, + tokenCoverage: .complete, + costCoverage: .complete) + + let groups = SpendClientBreakdown.groups(from: analysis) + #expect(groups.count == 1) + let group = try #require(groups.first) + #expect(group.displayTitle == "Codex Desktop") + #expect(group.providerName == "Codex") + #expect(group.models.count == 1) + let model = try #require(group.models.first) + #expect(model.displayName == "gpt-x") + #expect(model.tokens == 10000) + #expect(model.cost == 1.5) + #expect(model.costIsEstimated) + #expect(model.inputTokens == 6000) + #expect(model.outputTokens == 3000) + #expect(model.cacheReadTokens == 1000) + #expect(model.cacheCreationTokens == 200) + #expect(model.reasoningTokens == 300) + #expect(model.requestCount == 25) + } +} From 7774c2a9a7c9b638956d033696cf31fd06266fda Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:42:04 +0800 Subject: [PATCH 34/40] fix(spend): address ClawSweeper findings for local history, billing, and scan order - Deduplicate local-history providers: when a local scanner loads, drop the same provider's quota publication snapshot so totals are not summed twice. - Map moonshotai/moonshotai-cn billing aliases to the Moonshot API vendor instead of Kimi. - Register the Copilot local-history adapter (CopilotSessionScanner) so enabled Copilot users no longer see a permanent copilot:local failure. - Prefer recently modified OpenCode message files before enforcing the scan cap, so large histories keep recent usage. - Add regression tests and extract load contexts to keep the controller within the file-length limit. --- .../CodexBar/SpendBillingAttribution.swift | 5 +- .../CodexBar/SpendDashboardController.swift | 78 +++++++---------- .../CodexBar/SpendDashboardLoadContexts.swift | 55 ++++++++++++ .../OpenCode/OpenCodeSessionScanner.swift | 34 +++++--- .../OpenCodeSessionScannerTests.swift | 74 +++++++++++++++- .../SpendBillingAttributionTests.swift | 27 ++++++ .../SpendDashboardLocalAdapterTests.swift | 85 +++++++++++++++++++ 7 files changed, 291 insertions(+), 67 deletions(-) create mode 100644 Sources/CodexBar/SpendDashboardLoadContexts.swift diff --git a/Sources/CodexBar/SpendBillingAttribution.swift b/Sources/CodexBar/SpendBillingAttribution.swift index 67d4313ca1..632b6ecf6a 100644 --- a/Sources/CodexBar/SpendBillingAttribution.swift +++ b/Sources/CodexBar/SpendBillingAttribution.swift @@ -351,8 +351,9 @@ enum SpendBillingAttribution { "anthropic": .claude, "google": .gemini, "google-ai": .gemini, - "moonshotai": .kimi, - "moonshot": .kimi, + "moonshotai": .moonshot, + "moonshotai-cn": .moonshot, + "moonshot": .moonshot, "openai": .openai, "qwen": .qwencloud, "alibabacloud": .qwencloud, diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 78ede05526..00e34b52f3 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -161,54 +161,6 @@ struct SpendDashboardLoadResult: Sendable { } } -struct CodexSpendSnapshotLoadContext: Sendable { - let account: CodexSpendScanRequest - let cacheRoot: URL - let now: Date - let force: Bool - 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)? -} - -struct KimiCodeSpendSnapshotLoadContext: Sendable { - let homePath: String - let now: Date - let historyDays: Int -} - -struct GeminiSpendSnapshotLoadContext: Sendable { - let homePath: String - let now: Date - let historyDays: Int -} - -struct OpenCodeSpendSnapshotLoadContext: Sendable { - let homePath: String - let now: Date - let historyDays: Int -} - -struct MiniMaxSpendSnapshotLoadContext: Sendable { - let homePath: String - let now: Date - let historyDays: Int -} - -struct AntigravitySpendSnapshotLoadContext: Sendable { - let homePath: String - let now: Date - let historyDays: Int -} - -struct QwenCodeSpendSnapshotLoadContext: Sendable { - let homePath: String - let now: Date - let historyDays: Int -} - enum SpendDashboardSource { typealias CodexSnapshotLoader = @Sendable (CodexSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot @@ -225,6 +177,8 @@ enum SpendDashboardSource { -> CostUsageTokenSnapshot? typealias QwenCodeSnapshotLoader = @Sendable (QwenCodeSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + typealias CopilotSnapshotLoader = @Sendable (CopilotSpendSnapshotLoadContext) async throws + -> CostUsageTokenSnapshot? static let scanDays = 365 static let activityScanDays = SpendDashboardModel.tokenActivityDayCount @@ -392,6 +346,7 @@ enum SpendDashboardSource { let miniMax: MiniMaxSnapshotLoader let antigravity: AntigravitySnapshotLoader let qwenCode: QwenCodeSnapshotLoader + let copilot: CopilotSnapshotLoader } /// A local tool whose usage snapshot is loaded from a home directory via a @@ -450,6 +405,9 @@ enum SpendDashboardSource { }, qwenCodeSnapshotLoader: @escaping QwenCodeSnapshotLoader = { context in try await Self.loadQwenCodeSnapshot(context) + }, + copilotSnapshotLoader: @escaping CopilotSnapshotLoader = { context in + try await Self.loadCopilotSnapshot(context) }) async -> SpendDashboardLoadResult { let codexActivitySnapshotLoader = codexActivitySnapshotLoader ?? codexSnapshotLoader @@ -525,7 +483,8 @@ enum SpendDashboardSource { openCode: openCodeSnapshotLoader, miniMax: miniMaxSnapshotLoader, antigravity: antigravitySnapshotLoader, - qwenCode: qwenCodeSnapshotLoader)) + qwenCode: qwenCodeSnapshotLoader, + copilot: copilotSnapshotLoader)) let localSources: [LocalSnapshotSource] = request.localHistoryRequests.compactMap { localRequest in guard let adapter = adapters[localRequest.source] else { failedSourceIDs.insert(self.localSourceID(for: localRequest)) @@ -543,6 +502,10 @@ enum SpendDashboardSource { for source in localSources { do { if let input = try await source.loadInput() { + // The local scanner is the canonical history for this provider. Drop the + // quota publication snapshot for the same provider so its totals are not + // summed twice when a refresh also published a snapshot. + inputs.removeAll { $0.provider == source.provider && $0.id != source.sourceID } inputs.append(input) // The spend dashboard's canonical history for these providers is the local // scanner. A failed/unchanged live quota publication must not remain as a @@ -660,6 +623,18 @@ enum SpendDashboardSource { } } + private static func loadCopilotSnapshot( + _ context: CopilotSpendSnapshotLoadContext) async throws -> CostUsageTokenSnapshot? + { + try await CostUsageScanExecutor.run { checkCancellation in + try CopilotSessionScanner.scanCancellable( + environment: [CopilotSessionScanner.homeEnvironmentKey: context.homePath], + historyDays: context.historyDays, + now: context.now, + checkCancellation: checkCancellation) + } + } + private static func localHistoryAdapters(loaders: LocalHistoryLoaders) -> [ProviderLocalHistorySource: LocalHistoryAdapter] { @@ -688,6 +663,10 @@ enum SpendDashboardSource { try await loaders.qwenCode(QwenCodeSpendSnapshotLoadContext( homePath: homePath, now: now, historyDays: days)) }, + LocalHistoryAdapter(source: .copilot, displayName: "GitHub Copilot") { homePath, now, days in + try await loaders.copilot(CopilotSpendSnapshotLoadContext( + homePath: homePath, now: now, historyDays: days)) + }, ] return Dictionary(uniqueKeysWithValues: adapters.map { ($0.source, $0) }) } @@ -799,6 +778,7 @@ enum SpendDashboardSource { .miniMax: { self.miniMaxHomeURL(environment: $0) }, .antigravity: { self.antigravityHomeURL(environment: $0) }, .qwenCode: { QwenCodeSessionScanner.homeURL(environment: $0) }, + .copilot: { CopilotSessionScanner.homeURL(environment: $0) }, ] // Unknown adapter identifiers cannot be scanned until their plugin // registers a resolver. Returning an impossible path keeps request diff --git a/Sources/CodexBar/SpendDashboardLoadContexts.swift b/Sources/CodexBar/SpendDashboardLoadContexts.swift new file mode 100644 index 0000000000..b6d25bb506 --- /dev/null +++ b/Sources/CodexBar/SpendDashboardLoadContexts.swift @@ -0,0 +1,55 @@ +import Foundation + +struct CodexSpendSnapshotLoadContext: Sendable { + let account: CodexSpendScanRequest + let cacheRoot: URL + let now: Date + let force: Bool + 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)? +} + +struct KimiCodeSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct GeminiSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct OpenCodeSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct MiniMaxSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct AntigravitySpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct QwenCodeSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} + +struct CopilotSpendSnapshotLoadContext: Sendable { + let homePath: String + let now: Date + let historyDays: Int +} 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..3426b3cbba 100644 --- a/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift +++ b/Tests/CodexBarTests/OpenCodeSessionScannerTests.swift @@ -119,6 +119,62 @@ struct OpenCodeSessionScannerTests { #expect(breakdowns.map(\.billingProviderID) == ["anthropic", nil, "openai"]) } + @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 func `scanner buckets usage by local day across midnight`() throws { let root = try Self.makeRoot() @@ -273,14 +329,24 @@ 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 diff --git a/Tests/CodexBarTests/SpendBillingAttributionTests.swift b/Tests/CodexBarTests/SpendBillingAttributionTests.swift index b95eeab4cc..d66ed27736 100644 --- a/Tests/CodexBarTests/SpendBillingAttributionTests.swift +++ b/Tests/CodexBarTests/SpendBillingAttributionTests.swift @@ -118,6 +118,33 @@ struct SpendBillingAttributionTests { defaultProvider: .cursor) == .cursor) } + @Test + func `moonshotai billing aliases map to the Moonshot API vendor`() throws { + let inputs = [ + SpendDashboardModel.ProviderInput( + id: "claude", + provider: .claude, + displayName: "Claude Code", + snapshot: Self.snapshot([ + Self.entry(model: "kimi-k3", cost: 12, tokens: 120, billingProviderID: "moonshotai"), + Self.entry(model: "kimi-k2.6", cost: 3, tokens: 30, billingProviderID: "moonshotai-cn"), + ])), + ] + + let group = try #require(SpendDashboardModel.build( + inputs: inputs, + requestedDays: 30, + now: Self.now, + calendar: Self.calendar).groups.first) + + let moonshot = try #require(group.providers.first { $0.provider == .moonshot }) + #expect(moonshot.id == "billing:moonshot:claude") + #expect(moonshot.displayName == "Moonshot / Kimi API") + #expect(moonshot.totalTokens == 150) + #expect(moonshot.totalCost == 15) + #expect(group.providers.contains { $0.provider == .kimi } == false) + } + @Test func `only explicit model namespaces become billing evidence`() { #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "MiniMax-M3") == nil) diff --git a/Tests/CodexBarTests/SpendDashboardLocalAdapterTests.swift b/Tests/CodexBarTests/SpendDashboardLocalAdapterTests.swift index 8637b1f61b..b214222815 100644 --- a/Tests/CodexBarTests/SpendDashboardLocalAdapterTests.swift +++ b/Tests/CodexBarTests/SpendDashboardLocalAdapterTests.swift @@ -43,6 +43,91 @@ struct SpendDashboardLocalAdapterTests { #expect(result.failedSourceIDs.isEmpty) } + @Test + func `published quota snapshot is not double counted when local history loads`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let quota = Self.localHistorySnapshot(tokens: 100, model: "kimi-k3", now: now) + let local = Self.localHistorySnapshot(tokens: 21, model: "kimi-k3", now: now) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.kimi.rawValue], + codexAccountIdentities: []), + capturedInputs: [ + SpendDashboardModel.ProviderInput( + id: UsageProvider.kimi.rawValue, + provider: .kimi, + displayName: "Kimi Code CLI", + snapshot: quota), + ], + unavailableSourceIDs: [], + codexRequests: [], + localHistoryRequests: [ + LocalSpendHistoryRequest( + source: .kimiCode, + provider: .kimi, + homePath: "/synthetic/kimi"), + ], + now: now, + force: false) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return quota + }, + kimiCodeSnapshotLoader: { context in + #expect(context.homePath == "/synthetic/kimi") + return local + }) + + let kimiInputs = result.inputs.filter { $0.provider == .kimi } + #expect(kimiInputs.count == 1) + #expect(kimiInputs.first?.id == "kimi:local") + #expect(kimiInputs.first?.snapshot.last30DaysTokens == 21) + #expect(result.failedSourceIDs.isEmpty) + } + + @Test + func `registered Copilot adapter loads through the generic local history pipeline`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let snapshot = Self.localHistorySnapshot(tokens: 33, model: "gpt-5", now: now) + let request = SpendDashboardLoadRequest( + configuration: SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [UsageProvider.copilot.rawValue], + codexAccountIdentities: []), + capturedInputs: [], + unavailableSourceIDs: [], + codexRequests: [], + localHistoryRequests: [ + LocalSpendHistoryRequest( + source: .copilot, + provider: .copilot, + homePath: "/synthetic/copilot"), + ], + now: now, + force: false) + + let result = await SpendDashboardSource.load( + request, + codexSnapshotLoader: { _ in + Issue.record("Codex loader should not run") + return snapshot + }, + copilotSnapshotLoader: { context in + #expect(context.homePath == "/synthetic/copilot") + return snapshot + }) + + let input = try #require(result.inputs.first) + #expect(input.id == "copilot:local") + #expect(input.provider == .copilot) + #expect(input.displayName == "GitHub Copilot") + #expect(result.failedSourceIDs.isEmpty) + } + private static func localHistorySnapshot( tokens: Int, model: String, From a0245f2cabd07672ba50fc558c53b6d2f18cfa62 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:06:07 +0800 Subject: [PATCH 35/40] test(i18n): allowlist models strings kept in English in Italian catalog The Models PR adds dashboard strings to every catalog; the Italian catalog keeps them in English (same choice as #2322), so the language catalog test needs the same allowlist entries to stay green in CI. --- .../LocalizationLanguageCatalogTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 27a81f1d63..2f9b90ad36 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -513,13 +513,32 @@ struct LocalizationLanguageCatalogTests { "Gemini Flash", "GitHub", "Google OAuth", + "Input", + "Model names are grouped after trimming and case-insensitive exact matching. " + + "Sources are not deduplicated across providers.", "No", "Oasis-Token", + "Observed history for the same model and time range; workload differences still apply.", + "Other", + "Output", + "Partial model history: incomplete source-days are excluded.", "Password", "Provider", + "Reuse unavailable", + "Same-model comparison", + "Show fewer models", "Token", + "Tokens", "%@ %@", + "%@ per 1M tokens", "%@: %@", + "%d days", + "%d days covered", + "%d models", + "%d projects", + "%d sessions", + "%d%% context reuse", + "%d%% reuse", "byte_unit_byte", "byte_unit_gigabyte", "byte_unit_kilobyte", From 05fcc1a1e6f8d58a2a177accc4af3781699dd280 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:20:39 +0800 Subject: [PATCH 36/40] style(spend): refine clients view hierarchy and add colored Codex icon --- .../PreferencesSpendDashboardPane.swift | 4 +- .../CodexBar/PreferencesSpendModelsView.swift | 8 +++ Sources/CodexBar/ProviderBrandIcon.swift | 24 ++++++++ .../Resources/ProviderIcon-codex-color.svg | 10 ++++ Sources/CodexBar/SpendClientsView.swift | 55 ++++++++++--------- docs/provider-icon-sources.md | 2 +- 6 files changed, 74 insertions(+), 29 deletions(-) create mode 100644 Sources/CodexBar/Resources/ProviderIcon-codex-color.svg diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 3d62283d17..c6e387e112 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -478,7 +478,9 @@ struct SpendProviderIcon: View { var body: some View { Group { - if let icon = ProviderBrandIcon.image(for: self.provider) { + if let icon = ProviderBrandIcon.coloredImage(for: self.provider) + ?? ProviderBrandIcon.image(for: self.provider) + { Image(nsImage: icon).resizable().scaledToFit() } else { Image(systemName: "circle.dotted") diff --git a/Sources/CodexBar/PreferencesSpendModelsView.swift b/Sources/CodexBar/PreferencesSpendModelsView.swift index 2addd2ae36..30cf1f75e9 100644 --- a/Sources/CodexBar/PreferencesSpendModelsView.swift +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -40,11 +40,19 @@ enum SpendModelsListStyle { static let iconFrameSize: CGFloat = 22 static let modelIconSize: CGFloat = 16 static let modelIconFrameSize: CGFloat = 20 + static let clientIconSize: CGFloat = 20 + static let clientIconFrameSize: CGFloat = 24 + static let modelRowIconSize: CGFloat = 18 + static let modelRowIconFrameSize: CGFloat = 22 static let modelIndent: CGFloat = 48 static let sectionTitleFont = Font.headline static let toolTitleFont = Font.headline static let primaryFont = Font.body static let primaryEmphasizedFont = Font.body.weight(.semibold) + static let clientTotalsFont = Font.subheadline.weight(.medium) + static let modelNameFont = Font.body.weight(.medium) + static let modelCostFont = Font.body.weight(.semibold) + static let modelDetailFont = Font.caption static let valueFont = Font.body.weight(.medium) static let secondaryFont = Font.callout static let tertiaryFont = Font.caption diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index 4e428e5c0d..d37d31ffdf 100644 --- a/Sources/CodexBar/ProviderBrandIcon.swift +++ b/Sources/CodexBar/ProviderBrandIcon.swift @@ -5,6 +5,7 @@ import CodexBarCore enum ProviderBrandIcon { private static let size = NSSize(width: 16, height: 16) private static var cache: [UsageProvider: NSImage] = [:] + private static var coloredCache: [UsageProvider: NSImage] = [:] /// Lazy-loaded resource bundle for provider icons. private static let resourceBundle: Bundle? = { @@ -47,7 +48,30 @@ enum ProviderBrandIcon { return image } + /// Full-color brand variant (e.g. the tinted Codex knot) for surfaces where the + /// monochrome template reads too flat; nil when no `*-color` asset exists. + static func coloredImage(for provider: UsageProvider) -> NSImage? { + if let cached = self.coloredCache[provider] { + return cached + } + let baseName = ProviderDescriptorRegistry.descriptor(for: provider).branding.iconResourceName + guard let bundle = self.resourceBundle, + let url = bundle.url(forResource: "\(baseName)-color", withExtension: "svg") + ?? bundle.url(forResource: "\(baseName)-color", withExtension: "png") + else { + return nil + } + guard let image = NSImage(contentsOf: url) else { + return nil + } + image.size = self.size + image.isTemplate = false + self.coloredCache[provider] = image + return image + } + static func resetCacheForTesting() { self.cache.removeAll() + self.coloredCache.removeAll() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-codex-color.svg b/Sources/CodexBar/Resources/ProviderIcon-codex-color.svg new file mode 100644 index 0000000000..7a3e6f8703 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-codex-color.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Sources/CodexBar/SpendClientsView.swift b/Sources/CodexBar/SpendClientsView.swift index 61631a89d3..4040357c77 100644 --- a/Sources/CodexBar/SpendClientsView.swift +++ b/Sources/CodexBar/SpendClientsView.swift @@ -357,6 +357,7 @@ struct SpendClientsView: View { VStack(alignment: .leading, spacing: 12) { Text(L("CLIENTS & MODELS")) .font(SpendModelsListStyle.tertiaryFont.weight(.semibold)) + .tracking(1.2) .foregroundStyle(.secondary) if !comparisons.isEmpty { self.comparisonCard(comparisons) @@ -441,24 +442,26 @@ struct SpendClientsView: View { let isCollapsed = self.collapsedGroupIDs.contains(group.id) return VStack(alignment: .leading, spacing: 0) { Button { - if isCollapsed { - self.collapsedGroupIDs.remove(group.id) - } else { - self.collapsedGroupIDs.insert(group.id) + withAnimation(.easeInOut(duration: 0.2)) { + if isCollapsed { + self.collapsedGroupIDs.remove(group.id) + } else { + self.collapsedGroupIDs.insert(group.id) + } } } label: { - HStack(spacing: 7) { + HStack(spacing: 10) { SpendProviderIcon( provider: group.provider, - size: SpendModelsListStyle.iconSize) + size: SpendModelsListStyle.clientIconSize) .frame( - width: SpendModelsListStyle.iconFrameSize, - height: SpendModelsListStyle.iconFrameSize) + width: SpendModelsListStyle.clientIconFrameSize, + height: SpendModelsListStyle.clientIconFrameSize) Text(cleanToolName(group.displayTitle)) - .font(SpendModelsListStyle.primaryEmphasizedFont) + .font(SpendModelsListStyle.toolTitleFont) Spacer() Text(self.totalText(group)) - .font(SpendModelsListStyle.primaryFont) + .font(SpendModelsListStyle.clientTotalsFont) .foregroundStyle(.secondary) .monospacedDigit() Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") @@ -468,50 +471,48 @@ struct SpendClientsView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .padding(.bottom, isCollapsed ? 0 : 6) + .padding(.bottom, isCollapsed ? 0 : 10) if !isCollapsed { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(group.models.enumerated()), id: \.element.id) { index, model in - if index > 0 { Divider().padding(.vertical, 2) } + ForEach(group.models) { model in self.modelRow(model) } } - .padding(.leading, SpendModelsListStyle.modelIndent) + .padding(.leading, SpendModelsListStyle.clientIconFrameSize + 10) } } - .padding(14) - .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .padding(16) + .background(Color.secondary.opacity(0.07), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } private func modelRow(_ model: SpendClientModel) -> some View { - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center, spacing: 8) { - SpendProviderIcon(provider: model.modelProvider, size: SpendModelsListStyle.modelIconSize) + SpendProviderIcon(provider: model.modelProvider, size: SpendModelsListStyle.modelRowIconSize) .frame( - width: SpendModelsListStyle.modelIconFrameSize, - height: SpendModelsListStyle.modelIconFrameSize) + width: SpendModelsListStyle.modelRowIconFrameSize, + height: SpendModelsListStyle.modelRowIconFrameSize) Text(model.displayName) - .font(SpendModelsListStyle.primaryFont) + .font(SpendModelsListStyle.modelNameFont) .lineLimit(1) Spacer() Text(self.modelMetric(model)) - .font(SpendModelsListStyle.valueFont) - .foregroundStyle(.secondary) + .font(SpendModelsListStyle.modelCostFont) .monospacedDigit() } - .padding(.top, 5) let detail = SpendClientModelDetailText.detailText(model: model) if !detail.isEmpty { Text(detail) - .font(SpendModelsListStyle.tertiaryFont) + .font(SpendModelsListStyle.modelDetailFont) .foregroundStyle(.secondary) .monospacedDigit() - .padding(.leading, SpendModelsListStyle.modelIconFrameSize + 8) - .padding(.bottom, 5) + .lineSpacing(3) + .padding(.leading, SpendModelsListStyle.modelRowIconFrameSize + 8) } } + .padding(.vertical, 7) } private func totalText(_ group: SpendClientGroup) -> String { diff --git a/docs/provider-icon-sources.md b/docs/provider-icon-sources.md index 8c9efa3633..884123b467 100644 --- a/docs/provider-icon-sources.md +++ b/docs/provider-icon-sources.md @@ -5,7 +5,7 @@ independent visualization concern and must not recolor provider icons. | Provider | First-party source | Resource treatment | | --- | --- | --- | -| OpenAI / Codex | https://openai.com/brand/ | Monochrome OpenAI mark, rendered as a template for light/dark appearance | +| OpenAI / Codex | https://openai.com/brand/ | Monochrome OpenAI mark, rendered as a template for light/dark appearance. The spend Models section uses `ProviderIcon-codex-color.svg`, a white-to-periwinkle tint of the same first-party path, matching the product's usage UI | | Cursor | https://cursor.com/brand | `CUBE_2D_LIGHT.svg` from Cursor's downloadable brand assets, rendered as a template for light/dark appearance | | Gemini | https://about.google/products/ | Current multicolor Gemini product icon, preserved in original color | | Google Antigravity | https://antigravity.google/press | `Icon - Full Color` from the official press kit, preserved in original color | From 314a7a22325e65194683f169d8518010c2cc832d Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:40:38 +0800 Subject: [PATCH 37/40] test(spend): fix coverage assertions after daily chart removal coveredDayCount measures the coverage window, not the number of spend days, so the replacements used during the daily-card removal were wrong. The heatmap assertion now checks the 30-day window stays unwidened; the rollover and mutation cases already prove window behavior through totals. The localization coverage test follows #2322 and reads the embedded model card from PreferencesSpendModelsView instead of the dashboard pane. --- Tests/CodexBarTests/SpendActivityHeatmapTests.swift | 2 +- .../SpendDashboardClockRolloverTests.swift | 2 -- .../CodexBarTests/SpendDashboardControllerTests.swift | 1 - .../UserFacingLocalizationCoverageTests.swift | 10 +++++++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift index 59e1d917a6..cf42d1f0d7 100644 --- a/Tests/CodexBarTests/SpendActivityHeatmapTests.swift +++ b/Tests/CodexBarTests/SpendActivityHeatmapTests.swift @@ -128,7 +128,7 @@ struct SpendActivityHeatmapTests { let oldDate = try #require(Self.calendar.date(from: DateComponents(year: 2025, month: 8, day: 1))) #expect(model.groups.first?.totalTokens == 10) #expect(model.groups.first?.totalCost == 2) - #expect(model.groups.first?.coveredDayCount == 1) + #expect(model.groups.first?.coveredDayCount == 30) #expect(model.tokenActivity.first { $0.day == oldDate }?.totalTokens == 40) #expect(model.tokenActivity.first { $0.day == now }?.totalTokens == 10) } diff --git a/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift index 122a910f1b..7db2b934c5 100644 --- a/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift +++ b/Tests/CodexBarTests/SpendDashboardClockRolloverTests.swift @@ -51,7 +51,6 @@ struct SpendDashboardClockRolloverTests { #expect(controller.generation == generation + 1) #expect(loadCount.value == 2) #expect(controller.model.groups.first?.totalCost == 6) - #expect(controller.model.groups.first?.coveredDayCount == 1) } @Test @@ -93,7 +92,6 @@ struct SpendDashboardClockRolloverTests { #expect(controller.generation == 2) #expect(controller.model.groups.first?.totalCost == 6) - #expect(controller.model.groups.first?.coveredDayCount == 1) } @Test diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index b969bcca29..3c25becd48 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -1045,7 +1045,6 @@ struct SpendDashboardControllerRevisionTests { #expect(controller.model.groups.first?.providers.first?.totalCost == mutation.expectedCost) #expect( controller.model.groups.first?.modelHistoryCompleteness == mutation.expectedCompleteness) - #expect(controller.model.groups.first?.coveredDayCount == 0) } } diff --git a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift index 08d76fadd3..c1369b3da6 100644 --- a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift +++ b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift @@ -116,16 +116,20 @@ struct UserFacingLocalizationCoverageTests { } @Test - func `spend dashboard model breakdown state stays precise and localized`() throws { + func `spend dashboard embedded model card is fully localized`() throws { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() let source = try String( - contentsOf: root.appendingPathComponent("Sources/CodexBar/PreferencesSpendDashboardPane.swift"), + contentsOf: root.appendingPathComponent("Sources/CodexBar/PreferencesSpendModelsView.swift"), encoding: .utf8) - #expect(source.contains(#"Text(L("Model breakdown unavailable"))"#)) + #expect(source.contains(#"Text(L("Models"))"#)) #expect(source.contains(#"Text(L("No model-level history"))"#)) + #expect(source.contains(#"Text(L("Partial model history: incomplete source-days are excluded."))"#)) + #expect(!source.contains(#"Text("Models")"#)) + #expect(!source.contains(#"Text("No model-level history")"#)) + #expect(!source.contains(#"Text("Partial model history: incomplete source-days are excluded.")"#)) } } From dc9ae675c45dec37157f88a2deb52173b089eea7 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:46:24 +0800 Subject: [PATCH 38/40] test(spend): align scan budget tests with merged 365-day spend scan The stacked integration unified the spend and activity scans on a 365-day budget; the #2548-authored scan budget tests still expected a 30-day spend scan. Update expectations and distinguish the activity failure case by call order instead of historyDays. --- .../SpendDashboardScanBudgetTests.swift | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Tests/CodexBarTests/SpendDashboardScanBudgetTests.swift b/Tests/CodexBarTests/SpendDashboardScanBudgetTests.swift index cb43853e1d..8ae488d751 100644 --- a/Tests/CodexBarTests/SpendDashboardScanBudgetTests.swift +++ b/Tests/CodexBarTests/SpendDashboardScanBudgetTests.swift @@ -28,13 +28,13 @@ struct SpendDashboardScanBudgetTests { #expect(contexts.first?.cacheRoot.lastPathComponent == "inactive-cache") #expect(contexts.first?.now == now) #expect(contexts.first?.force == false) - #expect(contexts.first?.historyDays == 30) + #expect(contexts.first?.historyDays == SpendDashboardSource.scanDays) #expect(contexts.first?.refreshPricingInBackground == false) #expect(contexts.first?.includePiSessions == false) - #expect(contexts.last?.historyDays == 365) + #expect(contexts.last?.historyDays == SpendDashboardSource.activityScanDays) #expect(contexts.last?.force == false) - #expect(result.inputs.first?.snapshot.historyDays == 30) - #expect(result.inputs.first?.tokenActivitySnapshot.historyDays == 365) + #expect(result.inputs.first?.snapshot.historyDays == SpendDashboardSource.scanDays) + #expect(result.inputs.first?.tokenActivitySnapshot.historyDays == SpendDashboardSource.activityScanDays) } @Test @@ -51,9 +51,9 @@ struct SpendDashboardScanBudgetTests { }) let contexts = await recorder.contexts - #expect(SpendDashboardSource.scanDays == 30) + #expect(SpendDashboardSource.scanDays == 365) #expect(SpendDashboardSource.activityScanDays == 365) - #expect(contexts.map(\.historyDays) == [30, 365]) + #expect(contexts.map(\.historyDays) == [365, 365]) #expect(contexts.map(\.force) == [true, false]) } @@ -61,10 +61,12 @@ struct SpendDashboardScanBudgetTests { func `annual activity scan failure retains the normal spend snapshot`() async { let now = Date(timeIntervalSince1970: 1_784_179_200) let account = Self.account(id: "account", cacheIdentity: "activity-failure") + let recorder = SpendDashboardScanContextRecorder() let result = await SpendDashboardSource.load( Self.request(account: account, now: now, force: false), codexSnapshotLoader: { context in - if context.historyDays == SpendDashboardSource.activityScanDays { + await recorder.record(context) + if await recorder.contexts.count == 2 { throw CocoaError(.fileReadUnknown) } return Self.snapshot(context: context, tokens: 10, cost: 1) @@ -72,8 +74,8 @@ struct SpendDashboardScanBudgetTests { #expect(result.failedSourceIDs.isEmpty) #expect(result.inputs.count == 1) - #expect(result.inputs.first?.snapshot.historyDays == 30) - #expect(result.inputs.first?.tokenActivitySnapshot.historyDays == 30) + #expect(result.inputs.first?.snapshot.historyDays == SpendDashboardSource.scanDays) + #expect(result.inputs.first?.tokenActivitySnapshot.historyDays == SpendDashboardSource.scanDays) } @Test From 8efc03216e9c621a0231482c05e5ead221ffb784 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:00:30 +0800 Subject: [PATCH 39/40] ci: retrigger after flaky Linux fd test From 00a3d05bfda91fc481cebcb700f5a132589bbc21 Mon Sep 17 00:00:00 2001 From: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:06:57 +0800 Subject: [PATCH 40/40] fix(spend): pass active currency to model spend formatters and keep outer billing route - Chart axis and day-detail model summaries now forward the group currency, so EUR and other non-USD groups no longer render converted amounts with a dollar label. - Walk nested model namespaces from the outside in: openrouter/anthropic/ claude-* keeps OpenRouter as the billing owner. - Withhold window cost totals when any token-bearing day is unpriced. - Add EUR regressions for both model-spend paths and update the nested-route expectation from inner vendor to outer route. --- .../CodexBar/PreferencesSpendModelsDayDetailView.swift | 6 ++++-- Sources/CodexBar/PreferencesSpendModelsView.swift | 2 +- Sources/CodexBarCore/CostUsageModels.swift | 10 ++++++++-- Tests/CodexBarTests/SpendBillingAttributionTests.swift | 2 +- Tests/CodexBarTests/SpendModelsPresentationTests.swift | 10 ++++++++++ 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift b/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift index 2b69b09be3..24c8ac1d2d 100644 --- a/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift +++ b/Sources/CodexBar/PreferencesSpendModelsDayDetailView.swift @@ -380,7 +380,8 @@ struct SpendModelsDayDetailView: View { model, metric: self.metric, totalTokens: self.detail.totalTokens, - totalCost: self.detail.totalCost)) + totalCost: self.detail.totalCost, + currencyCode: self.currencyCode)) .font(SpendModelsListStyle.secondaryFont) .foregroundStyle(.secondary) .monospacedDigit() @@ -400,7 +401,8 @@ struct SpendModelsDayDetailView: View { model, metric: self.metric, totalTokens: self.detail.totalTokens, - totalCost: self.detail.totalCost)) + totalCost: self.detail.totalCost, + currencyCode: self.currencyCode)) .accessibilityHint(model.buckets.isEmpty ? "" : (self.expandedModelID == model.id ? L("Collapse") : L("Expand"))) diff --git a/Sources/CodexBar/PreferencesSpendModelsView.swift b/Sources/CodexBar/PreferencesSpendModelsView.swift index 30cf1f75e9..6d13313e4f 100644 --- a/Sources/CodexBar/PreferencesSpendModelsView.swift +++ b/Sources/CodexBar/PreferencesSpendModelsView.swift @@ -1048,7 +1048,7 @@ struct SpendModelsSection: View { } private func metricText(_ value: Double) -> String { - spendModelsChartMetricText(value, metric: self.sortMetric) + spendModelsChartMetricText(value, metric: self.sortMetric, currencyCode: self.currencyCode) } private func axisMetricText(_ value: Double) -> String { 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/SpendBillingAttributionTests.swift b/Tests/CodexBarTests/SpendBillingAttributionTests.swift index d66ed27736..2a4631d99e 100644 --- a/Tests/CodexBarTests/SpendBillingAttributionTests.swift +++ b/Tests/CodexBarTests/SpendBillingAttributionTests.swift @@ -154,7 +154,7 @@ struct SpendBillingAttributionTests { #expect(CostUsageBillingProvider.providerID( fromNamespacedModel: "gateway/team/moonshot/kimi-k2") == "moonshot") #expect(CostUsageBillingProvider.providerID( - fromNamespacedModel: "openrouter/anthropic/claude-sonnet-4") == "claude") + fromNamespacedModel: "openrouter/anthropic/claude-sonnet-4") == "openrouter") #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "gateway/team/model") == nil) #expect(CostUsageBillingProvider.providerID(fromNamespacedModel: "/unowned") == nil) } diff --git a/Tests/CodexBarTests/SpendModelsPresentationTests.swift b/Tests/CodexBarTests/SpendModelsPresentationTests.swift index 5168ce2ddc..53e2f3c0ff 100644 --- a/Tests/CodexBarTests/SpendModelsPresentationTests.swift +++ b/Tests/CodexBarTests/SpendModelsPresentationTests.swift @@ -229,6 +229,10 @@ struct SpendModelsPresentationTests { func `chart values follow the selected metric unit`() { #expect(spendModelsChartMetricText(632_000_000, metric: .tokens) == "632M") #expect(spendModelsChartMetricText(89.29, metric: .estimatedSpend) == "$89.29") + #expect(spendModelsChartMetricText( + 89.29, + metric: .estimatedSpend, + currencyCode: "EUR") == "€89.29") } @Test @@ -502,6 +506,12 @@ struct SpendModelsPresentationTests { metric: .estimatedSpend, totalTokens: 100, totalCost: 8) == "$8.00 · 100%") + #expect(spendModelsDayDetailModelSummaryText( + priced, + metric: .estimatedSpend, + totalTokens: 100, + totalCost: 8, + currencyCode: "EUR") == "€8.00 · 100%") #expect(spendModelsDayDetailModelSummaryText( unpriced, metric: .estimatedSpend,