diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d636828..237dc8b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,18 +9,30 @@ on Columbus Labs QuotaKit releases and product-facing changes. ### Added - Menu bar: add customizable drag-and-drop token layouts and weekly session-equivalent forecasting. -- Providers: add DeepInfra usage and balance, ai& spend, OpenRouter token accounts, DeepSeek cost summaries, and broader Doubao arkcli support. +- Providers: add DeepInfra usage and balance, ai& spend, OpenRouter token accounts, DeepSeek cost summaries, broader Doubao arkcli support, Qwen Cloud individual Token Plans, and ZoomMate credits, history, and pacing. +- Alibaba: add Personal/Solo Token Plan variants for mainland Bailian and international Model Studio accounts. - CLI: add safe cookie re-import and quota-aware guard commands under the QuotaKit command name. +- Claude: show prepaid credit balance from cached or manually configured web sessions and add an option to hide Daily Routines. - Claude: show model-scoped weekly claude-swap windows and optionally show a card when only one account is available. +- Codex: add local workspace indexing as the foundation for per-workspace usage attribution. - OpenCode Go: add daily local cost and plan-usage history. - Overview: raise the merged provider limit from three to six. ### Changed +- CLI: redact stored credentials from `quotakit config dump` by default; use `--show-secrets` to reveal raw values. - Menu bar: remove status-item hover tooltips while retaining VoiceOver titles. - Cost displays: use consistent labels while preserving reported-versus-estimated provenance in settings and per-value hints. - Synced upstream CodexBar changes through `02b4ba278`, including Claude weekly-window ordering and fallback accuracy, Kimi weekly duration accuracy, Ollama session reuse, safer automatic Safari-cookie handling, LongCat Firefox imports, localized session equivalents, stacked menu-title alignment, WidgetKit refresh-loop prevention, and Linux Alibaba Token Plan support while preserving QuotaKit release ownership, CloudKit setup, config paths, and iOS build numbers. ### Fixed +- Keychain: keep Cursor and Claude refresh available when Keychain access is disabled by using memory-only cookie caches and preventing background Claude prompts. +- Claude: isolate cached credentials by profile, preserve enterprise spend-cap extra usage, and keep the account Weekly quota ahead of exhausted model carve-outs. +- Codex: bound and resume cost scans across very large session corpora so they do not pin a CPU core or lose history. +- Amp: map subscription-plan usage to percentage windows instead of reporting a misleading cookie error. +- Grok: let explicit cookie refresh import and validate a browser session for safe background reuse. +- Hooks: preserve configured hooks across config saves and ignore stale hook launches from other installations. +- Menu: prioritize exhausted automatic-display windows while preserving provider-specific preferences. +- Widgets: prevent quota-reset reload loops and remove the unintended dark background overlay. - Refresh: prevent macOS 14 launch crashes caused by TaskLocal task-allocation corruption. - Menu bar: render custom-layout provider icons at native size with correct light/dark tinting. - Menu: prefer weekly quota windows for the switcher’s weekly progress, with provider-specific fallback. diff --git a/CodexBarMobile/CHANGELOG.md b/CodexBarMobile/CHANGELOG.md index 66d4cf28c..a9c69ed62 100644 --- a/CodexBarMobile/CHANGELOG.md +++ b/CodexBarMobile/CHANGELOG.md @@ -19,6 +19,11 @@ current Columbus Labs product surface and recent release history. - Monthly utilization-history labels synced from the Mac now use the selected iPhone app language. +### Changed + +- Provider presentation, colors, icons, and quota-alert subscriptions now + recognize Qwen Cloud and ZoomMate synced from the Mac app. + ## [1.11.3 (171)] — 2026-07-12 — Background sync reliability ### Fixed diff --git a/CodexBarMobile/CodexBarMobile/Design/ProviderBrandMark.swift b/CodexBarMobile/CodexBarMobile/Design/ProviderBrandMark.swift index d5a8c2b20..a7d4ea3b4 100644 --- a/CodexBarMobile/CodexBarMobile/Design/ProviderBrandMark.swift +++ b/CodexBarMobile/CodexBarMobile/Design/ProviderBrandMark.swift @@ -49,6 +49,7 @@ enum ProviderBrandAsset { "perplexity", "poe", "qoder", + "qwencloud", "sakana", "stepfun", "sub2api", @@ -61,6 +62,7 @@ enum ProviderBrandAsset { "zai", "zed", "zenmux", + "zoommate", "aiand", ] diff --git a/CodexBarMobile/CodexBarMobile/Localizable.xcstrings b/CodexBarMobile/CodexBarMobile/Localizable.xcstrings index 65330efde..d5f22c910 100644 --- a/CodexBarMobile/CodexBarMobile/Localizable.xcstrings +++ b/CodexBarMobile/CodexBarMobile/Localizable.xcstrings @@ -1044,6 +1044,34 @@ } } }, + "Balance": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Balance" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "残高" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "余额" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "餘額" + } + } + } + }, "Bar Chart": { "localizations": { "en": { @@ -19457,30 +19485,30 @@ } } }, - "Provider presentation and quota alerts now recognize ClinePass and LongCat synced from your Mac.": { + "Provider presentation and quota alerts now recognize ClinePass, LongCat, Qwen Cloud, and ZoomMate synced from your Mac.": { "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Provider presentation and quota alerts now recognize ClinePass and LongCat synced from your Mac." + "value": "Provider presentation and quota alerts now recognize ClinePass, LongCat, Qwen Cloud, and ZoomMate synced from your Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macから同期されたClinePassとLongCatが、プロバイダ表示とクォータ通知で認識されるようになりました。" + "value": "Macから同期されたClinePass、LongCat、Qwen Cloud、ZoomMateが、プロバイダ表示とクォータ通知で認識されるようになりました。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "提供商显示和配额提醒现在可识别从 Mac 同步的 ClinePass 和 LongCat。" + "value": "提供商显示和配额提醒现在可识别从 Mac 同步的 ClinePass、LongCat、Qwen Cloud 和 ZoomMate。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "供應商顯示和配額提醒現在可識別從 Mac 同步的 ClinePass 與 LongCat。" + "value": "供應商顯示和配額提醒現在可識別從 Mac 同步的 ClinePass、LongCat、Qwen Cloud 與 ZoomMate。" } } } diff --git a/CodexBarMobile/CodexBarMobile/Models/MobileReleaseNotesCatalog.swift b/CodexBarMobile/CodexBarMobile/Models/MobileReleaseNotesCatalog.swift index 45e0b76d1..d9079f0fb 100644 --- a/CodexBarMobile/CodexBarMobile/Models/MobileReleaseNotesCatalog.swift +++ b/CodexBarMobile/CodexBarMobile/Models/MobileReleaseNotesCatalog.swift @@ -34,7 +34,7 @@ enum MobileReleaseNotesCatalog { String( localized: "Background sync now finishes without leaving a local database write running after iOS closes the silent-push window."), String( - localized: "Provider presentation and quota alerts now recognize ClinePass and LongCat synced from your Mac."), + localized: "Provider presentation and quota alerts now recognize ClinePass, LongCat, Qwen Cloud, and ZoomMate synced from your Mac."), String( localized: "Codex banked resets now show the available count and exact expiration time for every reset detail synced from your Mac."), String( diff --git a/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift b/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift index 0311adf7d..8a3fa7e42 100644 --- a/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift +++ b/CodexBarMobile/CodexBarMobile/Models/ProviderColorPalette.swift @@ -82,6 +82,7 @@ enum ProviderColorPalette { ( ["alibabatokenplan", "alibabatoken", "bailiantokenplan"], RawColor(red: 1, green: 176 / 255, blue: 32 / 255)), + (["qwencloud"], RawColor(red: 147 / 255, green: 51 / 255, blue: 234 / 255)), (["factory", "droid"], RawColor(red: 255 / 255, green: 107 / 255, blue: 53 / 255)), (["gemini"], RawColor(red: 171 / 255, green: 135 / 255, blue: 234 / 255)), (["antigravity"], RawColor(red: 96 / 255, green: 186 / 255, blue: 126 / 255)), @@ -137,6 +138,7 @@ enum ProviderColorPalette { (["litellm"], RawColor(red: 76 / 255, green: 137 / 255, blue: 192 / 255)), (["deepgram"], RawColor(red: 0.49, green: 0.23, blue: 0.93)), (["aiand", "ai&"], RawColor(red: 226 / 255, green: 92 / 255, blue: 43 / 255)), + (["zoommate"], RawColor(red: 64 / 255, green: 176 / 255, blue: 255 / 255)), (["devin"], RawColor(red: 70 / 255, green: 180 / 255, blue: 130 / 255)), ] diff --git a/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-qwencloud.imageset/Contents.json b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-qwencloud.imageset/Contents.json new file mode 100644 index 000000000..9bfbb2fbb --- /dev/null +++ b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-qwencloud.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ProviderIcon-qwencloud.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-qwencloud.imageset/ProviderIcon-qwencloud.svg b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-qwencloud.imageset/ProviderIcon-qwencloud.svg new file mode 100644 index 000000000..424fc720f --- /dev/null +++ b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-qwencloud.imageset/ProviderIcon-qwencloud.svg @@ -0,0 +1,4 @@ + + + + diff --git a/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-zoommate.imageset/Contents.json b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-zoommate.imageset/Contents.json new file mode 100644 index 000000000..21cf80a13 --- /dev/null +++ b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-zoommate.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ProviderIcon-zoommate.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-zoommate.imageset/ProviderIcon-zoommate.svg b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-zoommate.imageset/ProviderIcon-zoommate.svg new file mode 100644 index 000000000..b37a5a263 --- /dev/null +++ b/CodexBarMobile/CodexBarMobile/ProviderIcons.xcassets/ProviderIcon-zoommate.imageset/ProviderIcon-zoommate.svg @@ -0,0 +1,3 @@ + + + diff --git a/CodexBarMobile/CodexBarMobile/Views/ClaudeExtraUsageCard.swift b/CodexBarMobile/CodexBarMobile/Views/ClaudeExtraUsageCard.swift index 2ab55db05..ceae1c4e3 100644 --- a/CodexBarMobile/CodexBarMobile/Views/ClaudeExtraUsageCard.swift +++ b/CodexBarMobile/CodexBarMobile/Views/ClaudeExtraUsageCard.swift @@ -72,28 +72,48 @@ struct ClaudeExtraUsageCard: View { } private var detailRow: some View { - HStack { - if let spend = extraUsage.monthlySpendUSD { - if let limit = extraUsage.monthlyLimitUSD { - Text(String( - format: String(localized: "claude_extra_usage_spend_limit_format", defaultValue: "%@ / %@"), - Self.formatUSD(spend), - Self.formatUSD(limit))) - .font(.subheadline.bold().monospacedDigit()) - .foregroundStyle(self.tintColor) - } else { - Text(Self.formatUSD(spend)) + VStack(alignment: .leading, spacing: 4) { + HStack { + if let spend = extraUsage.monthlySpendUSD { + if let limit = extraUsage.monthlyLimitUSD { + Text(String( + format: String(localized: "claude_extra_usage_spend_limit_format", defaultValue: "%@ / %@"), + Self.formatUSD(spend), + Self.formatUSD(limit))) + .font(.subheadline.bold().monospacedDigit()) + .foregroundStyle(self.tintColor) + } else { + Text(Self.formatUSD(spend)) + .font(.subheadline.bold().monospacedDigit()) + .foregroundStyle(self.tintColor) + } + } else if let balanceText = Self.balanceText(extraUsage.balanceUSD) { + Text(balanceText) .font(.subheadline.bold().monospacedDigit()) .foregroundStyle(self.tintColor) } + Spacer() + if self.extraUsage.monthlySpendUSD != nil { + Text(String(localized: "claude_extra_usage_period", defaultValue: "This month")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + if self.extraUsage.monthlySpendUSD != nil, + let balanceText = Self.balanceText(extraUsage.balanceUSD) + { + Text(balanceText) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) } - Spacer() - Text(String(localized: "claude_extra_usage_period", defaultValue: "This month")) - .font(.caption) - .foregroundStyle(.secondary) } } + static func balanceText(_ balanceUSD: Double?) -> String? { + guard let balanceUSD else { return nil } + return "\(String(localized: "Balance")): \(Self.formatUSD(balanceUSD))" + } + private static func formatUSD(_ value: Double) -> String { CostFormatting.usd(value) } diff --git a/CodexBarMobile/CodexBarMobilePushExtension/NotificationService.swift b/CodexBarMobile/CodexBarMobilePushExtension/NotificationService.swift index 8d1fe8941..0439619e7 100644 --- a/CodexBarMobile/CodexBarMobilePushExtension/NotificationService.swift +++ b/CodexBarMobile/CodexBarMobilePushExtension/NotificationService.swift @@ -156,7 +156,8 @@ final class NotificationService: UNNotificationServiceExtension { providerName: info.providerName, window: info.window, threshold: info.threshold, - accountEmail: info.accountEmail) + accountEmail: info.accountEmail, + windowDisplayLabel: info.windowDisplayLabel) NSEInvocationLog.shared.recordEntry( timestamp: startedAt, event: .ok, @@ -206,7 +207,12 @@ final class NotificationService: UNNotificationServiceExtension { /// returning version, but for now we want the NSE log to record the /// exact CloudKit error message when fetch fails. enum WarningFetchResult { - case success(providerName: String, window: String, threshold: Int, accountEmail: String?) + case success( + providerName: String, + window: String, + threshold: Int, + accountEmail: String?, + windowDisplayLabel: String?) case empty(reason: String) case error(message: String) } @@ -234,16 +240,14 @@ final class NotificationService: UNNotificationServiceExtension { // bucketing on the writer side, zones rarely accumulate beyond a few // dozen records, so the over-fetch is cheap. // - // v0.27.0 build 65.2 adds `accountEmail` to `desiredKeys` — Mac - // writes it when the triggering provider has a resolvable account - // (Codex managed, Claude multi-account, etc.). Pre-65.2 Macs leave - // it absent so we treat nil as "no account scope" and fall back to - // the existing non-scoped body template. + // Optional fields remain backward compatible. Pre-65.2 Macs omit + // `accountEmail`; older Macs also omit `windowDisplayLabel`. Missing + // values fall back to the bare provider title and localized lane name. do { let (matchResults, _) = try await container.privateCloudDatabase.records( matching: query, inZoneWith: zoneID, - desiredKeys: ["providerName", "accountEmail"], + desiredKeys: ["providerName", "accountEmail", "windowDisplayLabel"], resultsLimit: 100) var newest: CKRecord? for (_, result) in matchResults { @@ -266,6 +270,11 @@ final class NotificationService: UNNotificationServiceExtension { } let accountEmail = (record["accountEmail"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedAccount = (accountEmail?.isEmpty ?? true) ? nil : accountEmail + let windowDisplayLabel = (record["windowDisplayLabel"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedWindowDisplayLabel = (windowDisplayLabel?.isEmpty ?? true) + ? nil + : windowDisplayLabel guard let parsed = QuotaZoneNotificationParser.parseWarningRecordName( record.recordID.recordName) else { @@ -273,13 +282,15 @@ final class NotificationService: UNNotificationServiceExtension { providerName: providerName, window: "", threshold: 0, - accountEmail: normalizedAccount) + accountEmail: normalizedAccount, + windowDisplayLabel: normalizedWindowDisplayLabel) } return .success( providerName: providerName, window: parsed.window, threshold: parsed.threshold, - accountEmail: normalizedAccount) + accountEmail: normalizedAccount, + windowDisplayLabel: normalizedWindowDisplayLabel) } catch { let ckErr = error as? CKError let ckCode = ckErr.map { "code=\($0.code.rawValue)" } ?? "type=\(type(of: error))" @@ -413,9 +424,16 @@ final class NotificationService: UNNotificationServiceExtension { providerName: String, window: String, threshold: Int, - accountEmail _: String? = nil) -> String + accountEmail _: String? = nil, + windowDisplayLabel: String? = nil) -> String { - let windowLabel = self.localizedWindowLabel(window) + let normalizedWindowDisplayLabel = windowDisplayLabel? + .trimmingCharacters(in: .whitespacesAndNewlines) + let windowLabel = if let normalizedWindowDisplayLabel, !normalizedWindowDisplayLabel.isEmpty { + normalizedWindowDisplayLabel + } else { + self.localizedWindowLabel(window) + } let template = String(localized: "Push.QuotaWarning.detailBody") // %1$@ providerName · %2$@ windowLabel · %3$lld threshold return String(format: template, providerName, windowLabel, threshold) diff --git a/CodexBarMobile/CodexBarMobileTests/MobileDisplayFormattingTests.swift b/CodexBarMobile/CodexBarMobileTests/MobileDisplayFormattingTests.swift index d39b4a321..7f0b098b4 100644 --- a/CodexBarMobile/CodexBarMobileTests/MobileDisplayFormattingTests.swift +++ b/CodexBarMobile/CodexBarMobileTests/MobileDisplayFormattingTests.swift @@ -116,4 +116,11 @@ struct MobileDisplayFormattingTests { #expect(MobileChartAxisFormatter.axisValues(for: [0.18, 1.42, 2.48]) == [0, 1, 2, 3]) #expect(MobileChartAxisFormatter.axisLabel(for: 3) == "3") } + + @Test + func `Claude prepaid balance has dedicated display copy`() { + let expected = "\(String(localized: "Balance")): \(CostFormatting.usd(27.5))" + #expect(ClaudeExtraUsageCard.balanceText(27.5) == expected) + #expect(ClaudeExtraUsageCard.balanceText(nil) == nil) + } } diff --git a/CodexBarMobile/CodexBarMobileTests/ProviderBrandAssetTests.swift b/CodexBarMobile/CodexBarMobileTests/ProviderBrandAssetTests.swift index af2b970fe..23fbb8784 100644 --- a/CodexBarMobile/CodexBarMobileTests/ProviderBrandAssetTests.swift +++ b/CodexBarMobile/CodexBarMobileTests/ProviderBrandAssetTests.swift @@ -15,6 +15,8 @@ struct ProviderBrandAssetTests { #expect(ProviderBrandAsset.assetName(for: "clinepass") == "ProviderIcon-clinepass") #expect(ProviderBrandAsset.assetName(for: "longcat") == "ProviderIcon-longcat") #expect(ProviderBrandAsset.assetName(for: "neuralwatt") == "ProviderIcon-neuralwatt") + #expect(ProviderBrandAsset.assetName(for: "qwencloud") == "ProviderIcon-qwencloud") + #expect(ProviderBrandAsset.assetName(for: "zoommate") == "ProviderIcon-zoommate") } @Test diff --git a/CodexBarMobile/CodexBarMobileTests/ProviderColorPaletteTests.swift b/CodexBarMobile/CodexBarMobileTests/ProviderColorPaletteTests.swift index 4100bc8d7..235706f18 100644 --- a/CodexBarMobile/CodexBarMobileTests/ProviderColorPaletteTests.swift +++ b/CodexBarMobile/CodexBarMobileTests/ProviderColorPaletteTests.swift @@ -20,6 +20,7 @@ struct ProviderColorPaletteTests { ("opencodego", 52 / 255, 211 / 255, 153 / 255), ("alibaba", 1, 106 / 255, 0), ("alibabatokenplan", 1, 176 / 255, 32 / 255), + ("qwencloud", 147 / 255, 51 / 255, 234 / 255), ("factory", 255 / 255, 107 / 255, 53 / 255), ("gemini", 171 / 255, 135 / 255, 234 / 255), ("antigravity", 96 / 255, 186 / 255, 126 / 255), @@ -69,6 +70,7 @@ struct ProviderColorPaletteTests { ("clinepass", 0.38, 0.64, 0.98), ("longcat", 1, 209 / 255, 0), ("neuralwatt", 0.12, 0.72, 0.38), + ("zoommate", 64 / 255, 176 / 255, 255 / 255), ] for (provider, red, green, blue) in expected { @@ -89,6 +91,8 @@ struct ProviderColorPaletteTests { ("GroqCloud", "groq"), ("Sakana AI", "sakana"), ("Qoder", "qoder"), + ("Qwen Cloud", "qwencloud"), + ("ZoomMate", "zoommate"), ] for (displayName, providerID) in pairs { @@ -137,14 +141,14 @@ private func expectColor(_ provider: String, red: Double, green: Double, blue: D private let knownDistinctProviders = [ "codex", "openai", "azureopenai", "claude", "cursor", "opencode", "opencodego", - "alibaba", "alibabatokenplan", "factory", "gemini", "antigravity", "copilot", + "alibaba", "alibabatokenplan", "qwencloud", "factory", "gemini", "antigravity", "copilot", "zai", "minimax", "manus", "kimi", "kilo", "kiro", "vertexai", "augment", "jetbrains", "kimik2", "moonshot", "amp", "t3chat", "ollama", "synthetic", "warp", "openrouter", "elevenlabs", "windsurf", "perplexity", "mimo", "doubao", "sakana", "abacus", "mistral", "deepseek", "codebuff", "crof", "venice", "commandcode", "qoder", "stepfun", "bedrock", "grok", "groq", "llmproxy", "litellm", "deepgram", "crossmodel", "clinepass", "longcat", "deepinfra", "aiand", - "zenmux", + "zenmux", "zoommate", ] private func expectDistinctColors( diff --git a/CodexBarMobile/CodexBarMobileTests/QuotaProviderListTests.swift b/CodexBarMobile/CodexBarMobileTests/QuotaProviderListTests.swift index 9127f75ef..482902c98 100644 --- a/CodexBarMobile/CodexBarMobileTests/QuotaProviderListTests.swift +++ b/CodexBarMobile/CodexBarMobileTests/QuotaProviderListTests.swift @@ -18,7 +18,7 @@ import Testing @Suite("Quota provider list") struct QuotaProviderListTests { @Test - func `Total count is 56 including DeepInfra`() { + func `Total count is 58 including Qwen Cloud and ZoomMate`() { // Outcome: 25 → 27 in iOS 1.5.0 (Abacus + Mistral) → // 38 in iOS 1.6.0 (11 new from Mac v0.24+v0.25 catch-up) → // 40 in iOS 1.7.0 (2 new from Mac v0.26.0: moonshot + bedrock) → @@ -28,17 +28,18 @@ struct QuotaProviderListTests { // alibabatokenplan, t3chat) → // 49 in iOS 1.10.0 (Sakana AI from upstream v0.36.x) → // 50 after Qoder, 51 after Sub2API, 52 after ZenMux, 54 after - // ClinePass and LongCat, 55 after Neuralwatt, and 56 after DeepInfra. + // ClinePass and LongCat, 55 after Neuralwatt, 56 after DeepInfra, + // then 58 after Qwen Cloud and ZoomMate. // ai& is spend-only and has no quota transitions, so it intentionally // does not consume three CloudKit quota-zone subscriptions. // If this number shifts without matching upstream updates, // the push-subscription set drifts out of sync with Mac's // actual emitting providers. - #expect(QuotaProviderList.providers.count == 56) + #expect(QuotaProviderList.providers.count == 58) } @Test - func `Subscription zone count is 168 (56 providers × 3 states)`() { + func `Subscription zone count is 174 (58 providers × 3 states)`() { // iOS 1.5.0: 27 × 2 = 54 zones. // iOS 1.6.0 / Mac 0.25.2: 38 × 3 (depleted/restored/warning) = 114. // iOS 1.7.0 / Mac 0.26.2: 40 × 3 = 120 zones (+moonshot, +bedrock). @@ -51,10 +52,11 @@ struct QuotaProviderListTests { // ClinePass/LongCat catch-up: 54 × 3 = 162 zones. // Neuralwatt catch-up: 55 × 3 = 165 zones. // DeepInfra catch-up: 56 × 3 = 168 zones. + // Qwen Cloud + ZoomMate catch-up: 58 × 3 = 174 zones. // `QuotaTransitionSubscriptions.makeConfigs()` builds one // `SubConfig` per (provider, state) — pinning here so a // future state addition/removal can't drift silently. - #expect(QuotaProviderList.providers.count * 3 == 168) + #expect(QuotaProviderList.providers.count * 3 == 174) } @Test @@ -130,7 +132,7 @@ struct QuotaProviderListTests { /// re-create them all. Verify Abacus + Mistral + the 11 v0.24/v0.25 /// additions are appended at the END (additive), not interleaved. @Test - func `Cause: new providers through DeepInfra are appended at the tail`() { + func `Cause: new providers through ZoomMate are appended at the tail`() { let providers = QuotaProviderList.providers // Providers are append-only so per-(provider,state) CK subscription // IDs stay stable across upgrades. Pin the recent tail so a careless @@ -143,12 +145,13 @@ struct QuotaProviderListTests { // - Sub2API catch-up appended Sub2API (position [50]). // - ZenMux, ClinePass, and LongCat occupy positions [51...53]. // - DeepInfra occupies position [55]. - let tail = providers.suffix(16).map(\.id) + // - Qwen Cloud and ZoomMate occupy positions [56...57]. + let tail = providers.suffix(18).map(\.id) #expect(tail == [ "grok", "groq", "elevenlabs", "deepgram", "llmproxy", "azureopenai", "alibabatokenplan", "t3chat", "sakana", "qoder", "sub2api", "zenmux", - "clinepass", "longcat", "neuralwatt", "deepinfra", - ], "provider catch-up additions through DeepInfra must stay at the tail in this order") + "clinepass", "longcat", "neuralwatt", "deepinfra", "qwencloud", "zoommate", + ], "provider catch-up additions through ZoomMate must stay at the tail in this order") } // MARK: - iOS 1.6.0 · v0.24+v0.25 catch-up presence @@ -247,9 +250,9 @@ struct QuotaProviderListTests { /// (Zone count is providers × 3 states since iOS 1.6.0 added the /// `warning` state alongside `depleted`/`restored`.) @Test - func `Cause: catalog 56/168 numbers match the actual list`() { - #expect(QuotaProviderList.providers.count == 56) - #expect(QuotaProviderList.providers.count * 3 == 168) + func `Cause: catalog 58/174 numbers match the actual list`() { + #expect(QuotaProviderList.providers.count == 58) + #expect(QuotaProviderList.providers.count * 3 == 174) } @Test diff --git a/README.md b/README.md index a92597d8b..875fba670 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Provider setup notes and Mac provider internals live in [docs/providers.md](docs - [OpenCode Go](docs/opencode.md) — Browser or local SQLite data for Go usage windows. - [Alibaba Coding Plan](docs/alibaba-coding-plan.md) — Web cookies or API key for coding-plan quotas. - [Alibaba Token Plan](docs/alibaba-token-plan.md) — Bailian browser/manual cookies for token-plan credits. +- [Qwen Cloud](docs/qwen-cloud.md) — 5-hour and weekly individual Token Plan usage via browser/manual cookies. - [Gemini](docs/gemini.md) — OAuth-backed quota API using Gemini CLI credentials (no browser cookies). - [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth. - [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing. @@ -81,6 +82,7 @@ Provider setup notes and Mac provider internals live in [docs/providers.md](docs - [Manus](docs/manus.md) — Browser `session_id` auth for credit balance, monthly credits, and daily refresh tracking. - [MiniMax](docs/minimax.md) — API token, cookie header, or browser cookies for coding-plan usage. - [T3 Chat](docs/t3chat.md) — Browser cookies capture for Base and Overage usage buckets. +- [ZoomMate](docs/zoommate.md) — Chrome cookie auto-import or manual cURL capture for credits usage. - [Kimi](docs/kimi.md) — Auth token (JWT from `kimi-auth` cookie) for weekly quota + 5‑hour rate limit. - [Kilo](docs/kilo.md) — API token with CLI-auth fallback for Kilo Pass usage. - [Kiro](docs/kiro.md) — CLI-based usage; monthly credits + bonus credits. diff --git a/Scripts/audit_customer_branding.py b/Scripts/audit_customer_branding.py index f7ea069d9..c13337575 100755 --- a/Scripts/audit_customer_branding.py +++ b/Scripts/audit_customer_branding.py @@ -60,6 +60,7 @@ r"messageHandlers\?\.codexbarLog", r"window\.__codexbar", r"codexbar:claude-oauth-history-owner:v1", + r"codexbar:claude-oauth-cache-profile:v1", r"clientName: \"codexbar\"", r"installationId\": \"codexbar\"", r"fingerprintId\": \"codexbar-usage\"", diff --git a/Shared/Models/V027Snapshots.swift b/Shared/Models/V027Snapshots.swift index 8e0aad4f9..0a0532049 100644 --- a/Shared/Models/V027Snapshots.swift +++ b/Shared/Models/V027Snapshots.swift @@ -406,6 +406,9 @@ public struct SyncClaudeExtraUsage: Codable, Sendable, Equatable { /// Configured monthly cap in USD. Nil for uncapped Team plans — /// iOS hides the "/ $X" suffix in that case. public let monthlyLimitUSD: Double? + /// Remaining prepaid Extra usage balance. Optional for wire compatibility + /// with snapshots produced before Mac 0.48.0. + public let balanceUSD: Double? /// Whether the user has enabled extra-usage billing on the /// Anthropic console. When false, iOS shows a "Disabled" badge /// instead of a usage bar. @@ -419,6 +422,7 @@ public struct SyncClaudeExtraUsage: Codable, Sendable, Equatable { utilization: Double?, monthlySpendUSD: Double?, monthlyLimitUSD: Double?, + balanceUSD: Double? = nil, isEnabled: Bool, planTier: String?, updatedAt: Date) @@ -426,6 +430,7 @@ public struct SyncClaudeExtraUsage: Codable, Sendable, Equatable { self.utilization = utilization self.monthlySpendUSD = monthlySpendUSD self.monthlyLimitUSD = monthlyLimitUSD + self.balanceUSD = balanceUSD self.isEnabled = isEnabled self.planTier = planTier self.updatedAt = updatedAt diff --git a/Shared/Notifications/QuotaProviderList.swift b/Shared/Notifications/QuotaProviderList.swift index 79d0ae630..274942e85 100644 --- a/Shared/Notifications/QuotaProviderList.swift +++ b/Shared/Notifications/QuotaProviderList.swift @@ -123,6 +123,11 @@ public enum QuotaProviderList { // iOS 1.11.3 upstream-sync catch-up. Appended to preserve every // existing per-provider CloudKit subscription identifier. Provider(id: "deepinfra", displayName: "DeepInfra"), + // iOS 1.11.3 upstream-sync catch-up. Appended for upstream providers + // added after DeepInfra so existing CloudKit subscription identifiers + // remain stable. + Provider(id: "qwencloud", displayName: "Qwen Cloud"), + Provider(id: "zoommate", displayName: "ZoomMate"), ] /// Returns the CloudKit zone name for a given `(providerID, state)`. The diff --git a/Shared/iCloud/CloudSyncManager.swift b/Shared/iCloud/CloudSyncManager.swift index 89a690a15..e42330bd2 100644 --- a/Shared/iCloud/CloudSyncManager.swift +++ b/Shared/iCloud/CloudSyncManager.swift @@ -810,9 +810,10 @@ public final class CloudSyncManager: SyncPushing, @unchecked Sendable { /// Writes a quota **warning** transition record (iOS 1.6.0 / Mac 0.25.2). /// - /// Reuses the existing `QuotaTransition` CKRecord type with **no new - /// fields** — the threshold and window are packed into `recordName` so - /// no CloudKit Production Dashboard schema deploy is required. `state` + /// Reuses the existing `QuotaTransition` CKRecord type. The threshold and + /// lane are packed into `recordName`; an optional `windowDisplayLabel` + /// String preserves named windows such as "Fable only". Older records omit + /// that field, and iOS falls back to the localized lane name. `state` /// is set to the literal string `"warning"` so the same NSE that /// handles `depleted`/`restored` zone notifications can dispatch by /// state and read the recordName to construct a richer body @@ -841,7 +842,8 @@ public final class CloudSyncManager: SyncPushing, @unchecked Sendable { threshold: Int, transitionAt: Date, accountEmail: String? = nil, - deduplicationScope: String? = nil) async -> SyncPushResult + deduplicationScope: String? = nil, + windowDisplayLabel: String? = nil) async -> SyncPushResult { guard self.cloudKitAvailable, self._privateDatabase != nil else { return .failure("CloudKit not available") @@ -885,6 +887,13 @@ public final class CloudSyncManager: SyncPushing, @unchecked Sendable { if let accountEmail, !accountEmail.isEmpty { record["accountEmail"] = accountEmail as CKRecordValue } + let normalizedWindowDisplayLabel = windowDisplayLabel? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let normalizedWindowDisplayLabel, !normalizedWindowDisplayLabel.isEmpty { + // Optional for backward compatibility: records written by older + // Mac builds omit this field and iOS falls back to the lane label. + record["windowDisplayLabel"] = normalizedWindowDisplayLabel as CKRecordValue + } do { try await self._privateDatabase!.save(record) @@ -895,6 +904,7 @@ public final class CloudSyncManager: SyncPushing, @unchecked Sendable { "zone": zone.zoneID.zoneName, "recordName": recordName, "accountEmail": EmailRedaction.redact(accountEmail), + "windowDisplayLabel": normalizedWindowDisplayLabel ?? "", ]) return .success } catch let error as CKError where error.code == .serverRecordChanged { diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 2083094be..d1d168c25 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -6,7 +6,28 @@ import QuartzCore import Security import SwiftUI +enum CodexBarLaunchMode: Equatable { + case application + case hookEvent + + static func resolve(arguments: [String]) -> Self { + // Other CodexBar installations can leave this app path registered in ~/.codex/hooks.json. + // Treat those invocations as a no-op before AppKit creates a second set of status items. + arguments.dropFirst().contains("--hook-event") ? .hookEvent : .application + } +} + @main +enum CodexBarEntryPoint { + @MainActor + static func main() { + guard CodexBarLaunchMode.resolve(arguments: CommandLine.arguments) == .application else { + return + } + CodexBarApp.main() + } +} + struct CodexBarApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @State private var settings: SettingsStore diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index afbfe1004..bdc5c21cc 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -255,6 +255,12 @@ extension UsageMenuCardView.Model { { return Self.poeInlineDashboard(usage, now: input.now) } + if input.provider == .zoommate, + let history = input.snapshot?.zoommateCreditsHistory, + !history.dailyBreakdown().isEmpty || history.pacingVerdict() != nil + { + return Self.zoommateInlineDashboard(history) + } if [.codex, .claude, .vertexai, .bedrock, .cursor, .opencodego].contains(input.provider), input.tokenCostInlineDashboardEnabled, let tokenSnapshot = input.tokenSnapshot, @@ -268,6 +274,57 @@ extension UsageMenuCardView.Model { return nil } + private static func zoommateInlineDashboard( + _ history: ZoomMateCreditsHistorySnapshot) + -> InlineUsageDashboardModel + { + let breakdown = history.dailyBreakdown() + let today = history.todayCreditsUsed() + let total = breakdown.reduce(0) { $0 + $1.totalCreditsUsed } + let points = breakdown.suffix(30).map { + InlineUsageDashboardModel.Point( + id: $0.day, + label: Self.shortDayLabel($0.day), + value: $0.totalCreditsUsed, + accessibilityValue: "\($0.day): \(Self.creditsSummary($0.totalCreditsUsed))") + } + var details: [String] = [] + if let pace = history.pacingVerdict() { + details.append(Self.zoommatePaceLabel(for: pace)) + } + var model = InlineUsageDashboardModel( + accessibilityLabel: L("ZoomMate 30 day credits usage trend"), + valueStyle: .tokens, + kpis: [ + .init(title: L("Today"), value: Self.creditsSummary(today ?? 0), emphasis: true), + .init(title: L("30d credits"), value: Self.creditsSummary(total), emphasis: false), + ], + points: points, + detailLines: details) + model.barColor = Self.inlineDashboardBarColor(for: .zoommate) + return model + } + + private static func creditsSummary(_ value: Double) -> String { + value.formatted(.number.precision(.fractionLength(0...2))) + } + + private static func zoommatePaceLabel(for pace: UsagePace) -> String { + let deltaValue = Int(abs(pace.deltaPercent).rounded()) + switch pace.stage { + case .onTrack: + return L("Pace: on track") + case .slightlyAhead, .ahead, .farAhead: + return deltaValue == 0 + ? L("Pace: ahead of budget") + : L("Pace: %d%% ahead of budget", deltaValue) + case .slightlyBehind, .behind, .farBehind: + return deltaValue == 0 + ? L("Pace: behind budget") + : L("Pace: %d%% behind budget", deltaValue) + } + } + static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool { provider == .openai || provider == .mistral || provider == .groq } @@ -777,7 +834,7 @@ extension UsageMenuCardView.Model { return .currency(symbol: symbol) } - private static func shortDayLabel(_ day: String) -> String { + static func shortDayLabel(_ day: String) -> String { let pieces = day.split(separator: "-") guard pieces.count == 3, let rawDay = Int(pieces[2]) else { return day } return "\(rawDay)" diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 7134441b9..a0eced755 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -60,11 +60,20 @@ enum MenuBarMetricWindowResolver { } } + static func automaticSelectionPrioritizesExhaustedWindow(for provider: UsageProvider) -> Bool { + switch provider { + case .antigravity, .perplexity, .zai, .copilot, .cursor, .minimax, .claude, .codex: + false + default: + true + } + } + private static func tertiaryOrder(for provider: UsageProvider) -> [Lane] { if provider == .zai { return [.tertiary, .primary, .secondary] } - if provider == .perplexity || provider == .antigravity { + if provider == .perplexity || provider == .cursor || provider == .antigravity { return [.tertiary, .secondary, .primary] } return [.primary, .secondary] @@ -141,10 +150,14 @@ enum MenuBarMetricWindowResolver { secondary: snapshot.tertiary, tertiary: nil) ?? snapshot.secondary } - if provider == .factory || provider == .kimi { - return snapshot.secondary ?? snapshot.primary - } - if provider == .litellm { + if provider == .factory || provider == .kimi || provider == .litellm { + if let exhausted = exhaustedWindow( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: nil) + { + return exhausted + } return snapshot.secondary ?? snapshot.primary } if provider == .copilot, @@ -154,15 +167,29 @@ enum MenuBarMetricWindowResolver { return primary.usedPercent >= secondary.usedPercent ? primary : secondary } if provider == .cursor { - if snapshot.tertiary != nil { + if let layout = snapshot.cursorRateWindowLayout { + return switch layout { + case .autoAPI: + Self.mostConstrainedCursorWindow( + total: nil, + auto: snapshot.primary, + api: snapshot.secondary) + case .requests, .autoOnly, .apiOnly, .plan: + snapshot.primary + } + } + if snapshot.tertiary == nil { + // Backward compatibility for snapshots written before Cursor's explicit layout + // discriminator: the two stored lanes were Auto and API, not Total and Auto. return Self.mostConstrainedCursorWindow( - auto: snapshot.secondary, - api: snapshot.tertiary) - ?? snapshot.primary + total: nil, + auto: snapshot.primary, + api: snapshot.secondary) } return Self.mostConstrainedCursorWindow( - auto: snapshot.primary, - api: snapshot.secondary) + total: snapshot.primary, + auto: snapshot.secondary, + api: snapshot.tertiary) } if provider == .minimax { return Self.mostConstrainedWindow( @@ -173,6 +200,14 @@ enum MenuBarMetricWindowResolver { if provider == .claude, let spendLimit = Self.claudeSpendLimitWindow(snapshot: snapshot) { return spendLimit } + if Self.automaticSelectionPrioritizesExhaustedWindow(for: provider), + let exhausted = Self.exhaustedWindow( + primary: snapshot.primary, + secondary: snapshot.secondary, + tertiary: snapshot.tertiary) + { + return exhausted + } return snapshot.primary ?? snapshot.secondary } @@ -347,14 +382,35 @@ enum MenuBarMetricWindowResolver { return windows.max(by: { $0.usedPercent < $1.usedPercent }) } - private static func mostConstrainedCursorWindow(auto: RateWindow?, api: RateWindow?) -> RateWindow? { + private static func exhaustedWindow( + primary: RateWindow?, + secondary: RateWindow?, + tertiary: RateWindow?) + -> RateWindow? + { + [primary, secondary, tertiary] + .compactMap(\.self) + .first { $0.usedPercent >= 100 } + } + + private static func mostConstrainedCursorWindow( + total: RateWindow?, + auto: RateWindow?, + api: RateWindow?) + -> RateWindow? + { + if let total, total.usedPercent >= 100 { + return total + } + let subquotaWindows = [auto, api].compactMap(\.self) let usableSubquotaWindows = subquotaWindows.filter { $0.usedPercent < 100 } if !subquotaWindows.isEmpty, usableSubquotaWindows.isEmpty { return subquotaWindows.max(by: { $0.usedPercent < $1.usedPercent }) } - return usableSubquotaWindows.max(by: { $0.usedPercent < $1.usedPercent }) + return ([total].compactMap(\.self) + usableSubquotaWindows) + .max(by: { $0.usedPercent < $1.usedPercent }) } /// The Claude spend-limit window when the account only exposes an enterprise/extra-usage spend limit diff --git a/Sources/CodexBar/MenuCardHeightFingerprint.swift b/Sources/CodexBar/MenuCardHeightFingerprint.swift index 1d57244af..36e950bbd 100644 --- a/Sources/CodexBar/MenuCardHeightFingerprint.swift +++ b/Sources/CodexBar/MenuCardHeightFingerprint.swift @@ -87,11 +87,11 @@ extension UsageMenuCardView.Model.Metric { MenuCardHeightFingerprint.field("detailLeft", self.detailLeftText), MenuCardHeightFingerprint.field("detailRight", self.detailRightText), MenuCardHeightFingerprint.field( - "sessionEquivalentVerdict", - self.sessionEquivalentDetail?.verdictText), + "sessionEquivalentLeft", + self.sessionEquivalentDetail?.leftText), MenuCardHeightFingerprint.field( - "sessionEquivalentNumber", - self.sessionEquivalentDetail?.numberText), + "sessionEquivalentRight", + self.sessionEquivalentDetail?.rightText), self.pacePercent == nil ? "pace=0" : "pace=1", self.paceOnTop ? "paceTop=1" : "paceTop=0", self.cardStyle ? "card=1" : "card=0", diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 68ce9462f..12312714f 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -1,19 +1,91 @@ import CodexBarCore import Foundation +import SwiftUI + +struct ProviderCostContent: View { + let section: UsageMenuCardView.Model.ProviderCostSection + let progressColor: Color + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + if self.section.presentation == .inlineValue { + HStack(alignment: .firstTextBaseline) { + Text(self.section.title) + .font(.body) + .fontWeight(.medium) + Spacer() + Text(self.section.spendLine) + .font(.footnote) + .monospacedDigit() + .lineLimit(1) + } + } else { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline) { + Text(self.section.title) + .font(.body) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.tail) + if let balanceLine = self.section.balanceLine { + Spacer(minLength: 8) + Text(balanceLine) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .monospacedDigit() + .lineLimit(1) + .layoutPriority(1) + } + } + if let percentUsed = self.section.percentUsed { + UsageProgressBar( + percent: percentUsed, + tint: self.progressColor, + accessibilityLabel: L("Extra usage spent")) + } + HStack(alignment: .firstTextBaseline) { + Text(self.section.spendLine).font(.footnote).lineLimit(1) + Spacer() + if let percentLine = self.section.percentLine { + Text(percentLine) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + } + if let personalSpendLine = self.section.personalSpendLine { + Text(personalSpendLine) + .font(.footnote).foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)).lineLimit(1) + } + } + } + } +} extension UsageMenuCardView.Model.ProviderCostSection { + enum Presentation: Equatable { + case detail + case inlineValue + } + init( title: String, percentUsed: Double?, spendLine: String, - percentLine: String?) + percentLine: String?, + balanceLine: String? = nil, + presentation: Presentation = .detail, + showsInProviderDetails: Bool = true) { self.init( title: title, percentUsed: percentUsed, spendLine: spendLine, percentLine: percentLine, - personalSpendLine: nil) + balanceLine: balanceLine, + personalSpendLine: nil, + presentation: presentation, + showsInProviderDetails: showsInProviderDetails) } } @@ -301,7 +373,8 @@ extension UsageMenuCardView.Model { static func providerCostSection( provider: UsageProvider, - cost: ProviderCostSnapshot?) -> ProviderCostSection? + cost: ProviderCostSnapshot?, + isClaudeAdminAPI: Bool = false) -> ProviderCostSection? { if provider == .manus { return nil @@ -345,7 +418,46 @@ extension UsageMenuCardView.Model { percentLine: nil) } - if provider == .openai || provider == .claude || provider == .litellm || provider == .aiand, + if provider == .claude { + if isClaudeAdminAPI { + let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") + return ProviderCostSection( + title: L("API spend"), + percentUsed: nil, + spendLine: "\(periodLabel): \(spend)", + percentLine: nil) + } + + if cost.limit <= 0 { + guard let balance = cost.balance else { return nil } + let value = UsageFormatter.currencyString(balance, currencyCode: cost.currencyCode) + return ProviderCostSection( + title: L("Credits"), + percentUsed: nil, + spendLine: value, + percentLine: nil, + presentation: .inlineValue, + showsInProviderDetails: false) + } + + let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + let percentUsed = Self.clamped((cost.used / cost.limit) * 100) + let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month") + let balanceLine = cost.balance.map { + "\(L("Balance")): \(UsageFormatter.currencyString($0, currencyCode: cost.currencyCode))" + } + return ProviderCostSection( + title: L("Extra usage"), + percentUsed: percentUsed, + spendLine: "\(periodLabel): \(used) / \(limit)", + percentLine: String(format: L("%.0f%% used"), min(100, max(0, percentUsed))), + balanceLine: balanceLine, + showsInProviderDetails: false) + } + + if provider == .openai || provider == .litellm || provider == .aiand, cost.limit <= 0 { let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) @@ -406,6 +518,7 @@ extension UsageMenuCardView.Model { percentUsed: percentUsed, spendLine: "\(periodLabel): \(used) / \(limit)", percentLine: String(format: L("%.0f%% used"), min(100, max(0, percentUsed))), + balanceLine: nil, personalSpendLine: personalSpendLine) } diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index ca3482bf3..802ef1760 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -122,6 +122,16 @@ extension UsageMenuCardView.Model { self.placeholder != nil } + var creditsOnlyInlineUsageDashboard: Bool { + self.creditsText != nil && + self.inlineUsageDashboard != nil && + self.metrics.isEmpty && + self.usageNotes.isEmpty && + self.openAIAPIUsage == nil && + self.codexResetCredits == nil && + self.placeholder == nil + } + var usesStackedDetailLayout: Bool { !self.metrics.isEmpty || self.creditsText != nil || @@ -286,12 +296,23 @@ extension UsageMenuCardView.Model { DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel } else if input.provider == .sub2api { Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? input.metadata.sessionLabel + } else if input.provider == .amp { + AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) ?? input.metadata.sessionLabel + } else if input.provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel } else { input.metadata.sessionLabel } + let secondaryLabel = if input.provider == .amp { + AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? input.metadata.weeklyLabel + } else if input.provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot.secondary) ?? input.metadata.weeklyLabel + } else { + input.metadata.weeklyLabel + } return ( L(primaryLabel), - L(input.metadata.weeklyLabel), + L(secondaryLabel), input.metadata.opusLabel.map(L) ?? L("Sonnet"), input.metadata.supportsOpus) } @@ -618,11 +639,16 @@ extension UsageMenuCardView.Model { if input.provider == .copilot, !input.copilotBudgetExtrasEnabled { return [] } - let visibleRateWindows = if input.provider == .codex, !input.codexSparkUsageVisible { + var visibleRateWindows = if input.provider == .codex, !input.codexSparkUsageVisible { extraRateWindows.filter { !Self.isCodexSparkRateWindow($0) } } else { extraRateWindows } + if input.provider == .claude, + !input.showOptionalCreditsAndExtraUsage || !input.claudeDailyRoutinesUsageVisible + { + visibleRateWindows.removeAll(where: Self.isClaudeDailyRoutinesRateWindow) + } return visibleRateWindows.map { namedWindow in let paceDetail = Self.extraRateWindowPaceDetail( provider: input.provider, @@ -677,6 +703,10 @@ extension UsageMenuCardView.Model { namedWindow.id == CodexAdditionalRateLimitMapper.sparkWeeklyWindowID } + private static func isClaudeDailyRoutinesRateWindow(_ namedWindow: NamedRateWindow) -> Bool { + namedWindow.id == "claude-routines" + } + private static let antigravityQuotaSummaryWindowIDPrefix = "antigravity-quota-summary-" private static func hasAntigravityQuotaSummaryWindows(_ snapshot: UsageSnapshot) -> Bool { diff --git a/Sources/CodexBar/MenuCardView+ModelInput.swift b/Sources/CodexBar/MenuCardView+ModelInput.swift index ee6168eb6..12d5ab9fb 100644 --- a/Sources/CodexBar/MenuCardView+ModelInput.swift +++ b/Sources/CodexBar/MenuCardView+ModelInput.swift @@ -27,6 +27,7 @@ extension UsageMenuCardView.Model { let tokenCostMenuSectionEnabled: Bool let costComparisonPeriodsEnabled: Bool let showOptionalCreditsAndExtraUsage: Bool + let claudeDailyRoutinesUsageVisible: Bool let codexSparkUsageVisible: Bool let copilotBudgetExtrasEnabled: Bool let sourceLabel: String? @@ -64,6 +65,7 @@ extension UsageMenuCardView.Model { tokenCostMenuSectionEnabled: Bool? = nil, costComparisonPeriodsEnabled: Bool = false, showOptionalCreditsAndExtraUsage: Bool, + claudeDailyRoutinesUsageVisible: Bool = true, codexSparkUsageVisible: Bool = true, copilotBudgetExtrasEnabled: Bool = false, sourceLabel: String? = nil, @@ -100,6 +102,7 @@ extension UsageMenuCardView.Model { self.tokenCostMenuSectionEnabled = tokenCostMenuSectionEnabled ?? tokenCostUsageEnabled self.costComparisonPeriodsEnabled = costComparisonPeriodsEnabled self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage + self.claudeDailyRoutinesUsageVisible = claudeDailyRoutinesUsageVisible self.codexSparkUsageVisible = codexSparkUsageVisible self.copilotBudgetExtrasEnabled = copilotBudgetExtrasEnabled self.sourceLabel = sourceLabel diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 78381922b..d55143b7c 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -121,7 +121,10 @@ struct UsageMenuCardView: View { let percentUsed: Double? let spendLine: String let percentLine: String? + var balanceLine: String? var personalSpendLine: String? + var presentation: Presentation = .detail + var showsInProviderDetails = true } let provider: UsageProvider @@ -192,10 +195,10 @@ struct UsageMenuCardView: View { let hasCost = liveModel.tokenUsage != nil || hasProviderCost VStack(alignment: .leading, spacing: 12) { - if hasUsage { + if hasUsage, !liveModel.creditsOnlyInlineUsageDashboard { UsageMenuCardUsageContentView(model: liveModel, showBottomDivider: false) } - if hasUsage, hasCredits || hasCost { + if hasUsage, !liveModel.creditsOnlyInlineUsageDashboard, hasCredits || hasCost { Divider() } if let credits = liveModel.creditsText { @@ -208,6 +211,9 @@ struct UsageMenuCardView: View { hintCopyText: liveModel.creditsHintCopyText, progressColor: liveModel.progressColor) } + if liveModel.creditsOnlyInlineUsageDashboard, let dashboard = liveModel.inlineUsageDashboard { + InlineUsageDashboardContent(model: dashboard) + } if hasCredits, hasCost { Divider() } @@ -462,40 +468,6 @@ private struct TokenUsageSectionContent: View { } } -private struct ProviderCostContent: View { - let section: UsageMenuCardView.Model.ProviderCostSection - let progressColor: Color - @Environment(\.menuItemHighlighted) private var isHighlighted - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(self.section.title) - .font(.body) - .fontWeight(.medium) - if let percentUsed = self.section.percentUsed { - UsageProgressBar( - percent: percentUsed, - tint: self.progressColor, - accessibilityLabel: L("Extra usage spent")) - } - HStack(alignment: .firstTextBaseline) { - Text(self.section.spendLine).font(.footnote).lineLimit(1) - Spacer() - if let percentLine = self.section.percentLine { - Text(percentLine) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - } - } - if let personalSpendLine = self.section.personalSpendLine { - Text(personalSpendLine) - .font(.footnote).foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)).lineLimit(1) - } - } - } -} - private struct MetricRow: View { let metric: UsageMenuCardView.Model.Metric let title: String @@ -552,16 +524,19 @@ private struct MetricRow: View { } } if let sessionEquivalentDetail = self.metric.sessionEquivalentDetail { - Text(sessionEquivalentDetail.verdictText) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) - .lineLimit(1) - .accessibilityLabel(sessionEquivalentDetail.verdictAccessibilityLabel) - Text(sessionEquivalentDetail.numberText) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) - .accessibilityLabel(sessionEquivalentDetail.numberAccessibilityLabel) + HStack(alignment: .firstTextBaseline) { + Text(sessionEquivalentDetail.leftText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + Spacer() + Text(sessionEquivalentDetail.rightText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(sessionEquivalentDetail.accessibilityLabel) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -922,7 +897,7 @@ extension UsageMenuCardView.Model { let creditsScaleText = Self.creditsScaleText(credits: input.credits) let codexCreditLimitDetail = Self.codexCreditLimitDetail(credits: input.credits, now: input.now) let isClaudeAdminAPI = input.provider == .claude && - input.snapshot?.identity?.loginMethod == "Admin API" + input.snapshot?.claudeAdminAPIUsage != nil let isRequiredOpenCodeZenBalance = Self.isRequiredOpenCodeZenBalance(input.snapshot) let hidesOptionalProviderCost = ((input.provider == .claude && !isClaudeAdminAPI) || input.provider == .factory || @@ -938,7 +913,10 @@ extension UsageMenuCardView.Model { { nil } else { - Self.providerCostSection(provider: input.provider, cost: input.snapshot?.providerCost) + Self.providerCostSection( + provider: input.provider, + cost: input.snapshot?.providerCost, + isClaudeAdminAPI: isClaudeAdminAPI) } let tokenUsageSnapshot = Self.tokenUsageSnapshot(input: input) let tokenUsage = Self.tokenUsageSection( @@ -1035,6 +1013,12 @@ extension UsageMenuCardView.Model { } return self.planDisplay(pass, for: provider) } + if provider == .amp, + let plan = snapshot?.ampUsage?.subscriptionPlan, + !plan.isEmpty + { + return self.planDisplay(plan, for: provider) + } if let plan = snapshot?.loginMethod(for: provider), !plan.isEmpty { return self.planDisplay(plan, for: provider) } @@ -1335,7 +1319,7 @@ extension UsageMenuCardView.Model { { primaryDetailLeft = detail } - if [.warp, .kilo, .mimo, .deepseek, .deepinfra, .qoder, .mistral, .neuralwatt, .litellm] + if [.warp, .kilo, .mimo, .deepseek, .deepinfra, .qoder, .mistral, .neuralwatt, .litellm, .chutes] .contains(input.provider), let detail = primary.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -1365,7 +1349,7 @@ extension UsageMenuCardView.Model { primaryResetText = nil } } - if [.warp, .kilo, .mimo, .deepseek, .deepinfra, .qoder, .mistral, .neuralwatt, .litellm, .zenmux] + if [.warp, .kilo, .mimo, .deepseek, .deepinfra, .qoder, .mistral, .neuralwatt, .litellm, .zenmux, .chutes] .contains(input.provider), primary.resetsAt == nil { @@ -1496,7 +1480,7 @@ extension UsageMenuCardView.Model { weeklyResetText = nil weeklyDetailText = detail } - if input.provider == .kilo || input.provider == .litellm, + if [.kilo, .litellm, .chutes].contains(input.provider), let detail = weekly.resetDescription, !detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 94c75683c..d4da449e6 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -236,7 +236,8 @@ struct MenuDescriptor { let primaryDetail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) let primaryDescriptionIsDetail = provider == .warp || provider == .kilo || provider == .abacus || provider == .deepseek || provider == .deepinfra || provider == .neuralwatt || - provider == .azureopenai || provider == .mimo || provider == .qoder || provider == .sub2api + provider == .azureopenai || provider == .mimo || provider == .qoder || provider == .sub2api || + provider == .chutes let primaryWindow = if primaryDescriptionIsDetail { // Some providers use resetDescription for non-reset detail // (e.g., "Unlimited", "X/Y credits"). Avoid rendering it as a "Resets ..." line. @@ -280,11 +281,11 @@ struct MenuDescriptor { if let weekly = snap.secondary { let weeklyResetOverride: String? = { guard provider == .warp || provider == .kilo || provider == .perplexity || provider == .crof || - provider == .sub2api + provider == .sub2api || provider == .chutes else { return nil } let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) guard let detail, !detail.isEmpty else { return nil } - if provider == .kilo, weekly.resetsAt != nil { + if [.kilo, .chutes].contains(provider), weekly.resetsAt != nil { return nil } return detail @@ -296,7 +297,7 @@ struct MenuDescriptor { resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed, resetOverride: weeklyResetOverride) - if provider == .kilo, + if [.kilo, .chutes].contains(provider), weekly.resetsAt != nil, let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty @@ -677,12 +678,23 @@ struct MenuDescriptor { DoubaoProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel } else if provider == .sub2api { Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? metadata.sessionLabel + } else if provider == .amp { + AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) ?? metadata.sessionLabel + } else if provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel } else { metadata.sessionLabel } + let secondaryLabel = if provider == .amp { + AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? metadata.weeklyLabel + } else if provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot.secondary) ?? metadata.weeklyLabel + } else { + metadata.weeklyLabel + } return ( L(primaryLabel), - L(metadata.weeklyLabel), + L(secondaryLabel), metadata.opusLabel.map(L) ?? L("Sonnet"), metadata.supportsOpus) } diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index 6b306b36c..9f547edf9 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -317,6 +317,7 @@ struct DebugPane: View { Text("Augment").tag(UsageProvider.augment) Text("Amp").tag(UsageProvider.amp) Text("T3 Chat").tag(UsageProvider.t3chat) + Text("ZoomMate").tag(UsageProvider.zoommate) Text("Ollama").tag(UsageProvider.ollama) } .pickerStyle(.segmented) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 46ae6cedd..680b750ea 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -382,7 +382,7 @@ struct ProviderMetricsInlineView: View { let hasMetrics = !self.model.metrics.isEmpty let hasUsageNotes = !self.model.usageNotes.isEmpty let infoRows = Self.infoRows(for: self.model, openAIWebDiagnostic: self.openAIWebDiagnostic) - let hasProviderCost = self.model.providerCost != nil + let hasProviderCost = self.model.providerCost?.showsInProviderDetails == true let hasTokenUsage = self.model.tokenUsage != nil let hasResetCredits = self.model.codexResetCredits != nil @@ -418,7 +418,7 @@ struct ProviderMetricsInlineView: View { ProviderCodexResetCreditsInlineRow(presentation: resetCredits) } - if let providerCost = self.model.providerCost { + if let providerCost = self.model.providerCost, providerCost.showsInProviderDetails { ProviderMetricInlineCostRow( section: providerCost, progressColor: self.model.progressColor) diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index f1503ea77..dd677fbc0 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -2,6 +2,17 @@ import AppKit import CodexBarCore import SwiftUI +@MainActor +enum ProviderSettingsRefreshInteraction { + static func perform(operation: () async -> Void) async { + await BrowserCookieAccessGate.withExplicitRetry { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await operation() + } + } + } +} + @MainActor struct ProvidersPane: View { let provider: UsageProvider @@ -139,7 +150,7 @@ struct ProvidersPane: View { private func triggerRefresh(for provider: UsageProvider) { Task { @MainActor in - await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ProviderSettingsRefreshInteraction.perform { if provider == .codex { await self.store.refreshCodexAccountScopedState(allowDisabled: true) } else { @@ -574,6 +585,7 @@ struct ProvidersPane: View { // available cost data in their Usage section. tokenCostMenuSectionEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + claudeDailyRoutinesUsageVisible: self.settings.claudeDailyRoutinesUsageVisible, codexSparkUsageVisible: self.settings.codexSparkUsageVisible, copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, hidePersonalInfo: self.settings.hidePersonalInfo, diff --git a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift index d3f0e5c03..0d1b7cb70 100644 --- a/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Alibaba/AlibabaTokenPlanProviderImplementation.swift @@ -37,12 +37,15 @@ struct AlibabaTokenPlanProviderImplementation: ProviderImplementation { allowsOff: false, keychainDisabled: context.settings.debugDisableKeychainAccess) let cookieSubtitle: () -> String? = { - let host = context.settings.alibabaTokenPlanAPIRegion.dashboardURL.host ?? "the selected console" + let region = context.settings.alibabaTokenPlanAPIRegion + let host = region.usesPersonalTokenPlanAPI + ? URL(string: region.quotaBaseURLString)?.host + : region.dashboardURL.host return ProviderCookieSourceUI.subtitle( source: context.settings.alibabaTokenPlanCookieSource, keychainDisabled: context.settings.debugDisableKeychainAccess, auto: "Automatic imports browser cookies from Model Studio/Bailian.", - manual: "Paste a Cookie header from \(host).", + manual: "Paste a Cookie header from \(host ?? "the selected console").", off: "Alibaba Token Plan cookies are disabled.") } diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 55ae70767..6f77c775e 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -92,6 +92,21 @@ struct ClaudeProviderImplementation: ProviderImplementation { set: { context.settings.claudeSwapShowSingleAccount = $0 }) return [ + ProviderSettingsToggleDescriptor( + id: "claude-daily-routines-usage-visible", + title: "Show Daily Routines usage", + subtitle: [ + "Shows the Daily Routines quota row in the menu and provider preview.", + "Requires optional credits and extra usage in Display settings.", + ].joined(separator: " "), + binding: context.boolBinding(\.claudeDailyRoutinesUsageVisible), + statusText: nil, + actions: [], + isVisible: nil, + isEnabled: { context.settings.showOptionalCreditsAndExtraUsage }, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", title: "Avoid Keychain prompts", @@ -284,9 +299,16 @@ struct ClaudeProviderImplementation: ProviderImplementation { context.settings.showOptionalCreditsAndExtraUsage, cost.currencyCode != "Quota" { - let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) - entries.append(.text(String(format: L("extra_usage_format"), used, limit), .primary)) + if cost.limit > 0 { + let used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + let limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) + entries.append(.text(String(format: L("extra_usage_format"), used, limit), .primary)) + } + if let balance = cost.balance { + let value = UsageFormatter.currencyString(balance, currencyCode: cost.currencyCode) + let label = cost.limit > 0 ? L("Balance") : L("Credits") + entries.append(.text("\(label): \(value)", .primary)) + } } } diff --git a/Sources/CodexBar/Providers/QwenCloud/QwenCloudProviderImplementation.swift b/Sources/CodexBar/Providers/QwenCloud/QwenCloudProviderImplementation.swift new file mode 100644 index 000000000..213eb6bf3 --- /dev/null +++ b/Sources/CodexBar/Providers/QwenCloud/QwenCloudProviderImplementation.swift @@ -0,0 +1,91 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct QwenCloudProviderImplementation: ProviderImplementation { + let id: UsageProvider = .qwencloud + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { context in + context.store.sourceLabel(for: context.provider) + } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.qwenCloudCookieSource + _ = settings.qwenCloudCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + _ = context + return .qwenCloud(context.settings.qwenCloudSettingsSnapshot()) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.qwenCloudCookieSource.rawValue }, + set: { raw in + context.settings.qwenCloudCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.qwenCloudCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports browser cookies from Qwen Cloud.", + manual: "Paste a Cookie header from home.qwencloud.com.", + off: "Qwen Cloud cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "qwen-cloud-cookie-source", + title: "Cookie source", + subtitle: "Automatic imports browser cookies from Qwen Cloud.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + guard let entry = CookieHeaderCache.loadForDisplay(provider: .qwencloud) else { return nil } + let when = entry.storedAt.relativeDescription() + return "Cached: \(entry.sourceLabel) • \(when)" + }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "qwen-cloud-cookie", + title: "Cookie header", + subtitle: "", + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.qwenCloudCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "qwen-cloud-open-dashboard", + title: "Open Token Plan", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(QwenCloudUsageFetcher.dashboardURL) + }), + ], + isVisible: { + context.settings.qwenCloudCookieSource == .manual + }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/QwenCloud/QwenCloudSettingsStore.swift b/Sources/CodexBar/Providers/QwenCloud/QwenCloudSettingsStore.swift new file mode 100644 index 000000000..ddd79395b --- /dev/null +++ b/Sources/CodexBar/Providers/QwenCloud/QwenCloudSettingsStore.swift @@ -0,0 +1,30 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var qwenCloudCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .qwencloud)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .qwencloud) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .qwencloud, field: "cookieHeader", value: newValue) + } + } + + var qwenCloudCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .qwencloud, fallback: .auto) } + set { + self.updateProviderConfig(provider: .qwencloud) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .qwencloud, field: "cookieSource", value: newValue.rawValue) + } + } + + func qwenCloudSettingsSnapshot() -> ProviderSettingsSnapshot.QwenCloudProviderSettings { + ProviderSettingsSnapshot.QwenCloudProviderSettings( + cookieSource: self.qwenCloudCookieSource, + manualCookieHeader: self.qwenCloudCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 0c13716e7..722f54617 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -23,6 +23,7 @@ enum ProviderImplementationRegistry { case .opencodego: OpenCodeGoProviderImplementation() case .alibaba: AlibabaCodingPlanProviderImplementation() case .alibabatokenplan: AlibabaTokenPlanProviderImplementation() + case .qwencloud: QwenCloudProviderImplementation() case .factory: FactoryProviderImplementation() case .gemini: GeminiProviderImplementation() case .antigravity: AntigravityProviderImplementation() @@ -76,6 +77,7 @@ enum ProviderImplementationRegistry { case .wayfinder: WayfinderProviderImplementation() case .zenmux: ZenMuxProviderImplementation() case .aiand: AiAndProviderImplementation() + case .zoommate: ZoomMateProviderImplementation() } } diff --git a/Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift b/Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift new file mode 100644 index 000000000..d83e1fb96 --- /dev/null +++ b/Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift @@ -0,0 +1,100 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct ZoomMateProviderImplementation: ProviderImplementation { + let id: UsageProvider = .zoommate + + /// ZoomMate is a web-cookie provider with no CLI/version detector, so the default detail line + /// ("zoommate not detected") would misleadingly read as "provider not found". Match the other + /// web-cookie providers (Cursor, Perplexity, Manus, …) and surface the source instead. + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.zoomMateCookieSource + _ = settings.zoomMateCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .zoommate(context.settings.zoomMateSettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.zoomMateCookieSource.rawValue }, + set: { raw in + context.settings.zoomMateCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.zoomMateCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically signs in using your ZoomMate session cookies from Chrome.", + manual: "Paste a cURL capture from the ZoomMate AI credit usage page.", + off: "Paste a cURL capture from the ZoomMate AI credit usage page.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "zoommate-cookie-source", + title: "Cookie source", + subtitle: "Automatically signs in using your ZoomMate session cookies from Chrome.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { + ProviderCookieRefreshAction.trailingText( + provider: .zoommate, + cookieSource: context.settings.zoomMateCookieSource, + context: context) + }, + trailingActions: [ + ProviderCookieRefreshAction.descriptor( + provider: .zoommate, + cookieSource: { context.settings.zoomMateCookieSource }, + context: context), + ]), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "zoommate-cookie", + title: "ZoomMate capture", + subtitle: "Paste a full cURL capture from the ZoomMate AI credit usage page. " + + "The token expires approximately hourly, so you may need to re-paste periodically.", + kind: .secure, + placeholder: "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: ...'", + binding: context.stringBinding(\.zoomMateCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "zoommate-open-app", + title: "Open ZoomMate", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://zoommate.zoom.us/#/?settings=credit-usage") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.zoomMateCookieSource == .manual }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift b/Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift new file mode 100644 index 000000000..f44e01ad4 --- /dev/null +++ b/Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var zoomMateCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .zoommate)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .zoommate) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .zoommate, field: "cookieHeader", value: newValue) + } + } + + var zoomMateCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .zoommate, fallback: .auto) } + set { + self.updateProviderConfig(provider: .zoommate) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .zoommate, field: "cookieSource", value: newValue.rawValue) + } + } +} + +extension SettingsStore { + func zoomMateSettingsSnapshot( + tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.ZoomMateProviderSettings + { + self.resolvedCookieSettings( + provider: .zoommate, + configuredSource: self.zoomMateCookieSource, + configuredHeader: self.zoomMateCookieHeader, + tokenOverride: tokenOverride) + } +} diff --git a/Sources/CodexBar/Resources/ProviderIcon-qwencloud.svg b/Sources/CodexBar/Resources/ProviderIcon-qwencloud.svg new file mode 100644 index 000000000..2e8609e5d --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-qwencloud.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Sources/CodexBar/Resources/ProviderIcon-zoommate.svg b/Sources/CodexBar/Resources/ProviderIcon-zoommate.svg new file mode 100644 index 000000000..03b027dd4 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-zoommate.svg @@ -0,0 +1 @@ + diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 17a30e8c5..5e09ef418 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1235,6 +1235,8 @@ "section_links" = "روابط"; "Show Codex Spark usage" = "عرض استخدام Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "يعرض صفوف حصة Codex Spark في القائمة ومعاينة المزوّد. يتطلب تفعيل «عرض الاعتمادات + الاستخدام الإضافي» في إعدادات العرض."; +"Show Daily Routines usage" = "عرض استخدام الروتين اليومي"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "يعرض صف حصة الروتين اليومي في القائمة ومعاينة المزوّد. يتطلب تفعيل «عرض الاعتمادات + الاستخدام الإضافي» في إعدادات العرض."; "Scroll to see more models" = "مرر لرؤية المزيد من النماذج"; /* Shareable usage card */ "Copy Image" = "نسخ الصورة"; @@ -1277,6 +1279,9 @@ "Weekly cannot run out before reset at this pace" = "لا يمكن أن ينفد الحد الأسبوعي قبل إعادة التعيين بهذه الوتيرة"; "Weekly can run out ≈%d windows early" = "قد ينفد الحد الأسبوعي مبكرًا بنحو %d نافذة"; "Estimated: %@" = "تقديري: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "حصة الجلسة"; +"session quotas" = "حصص الجلسات"; "Coding Plan" = "خطة البرمجة"; "Agent Plan" = "خطة الوكيل"; "Team" = "فريق"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 0634867c0..19b77763a 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1234,6 +1234,8 @@ "section_links" = "Enllaços"; "Show Codex Spark usage" = "Mostreu l'ús de Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostreu les files de quota de Codex Spark al menú i a la previsualització del proveïdor. Cal activar «Mostreu crèdits + ús addicional» a la configuració de Pantalla."; +"Show Daily Routines usage" = "Mostreu l'ús de Rutines diàries"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostreu la fila de quota de Rutines diàries al menú i a la previsualització del proveïdor. Cal activar «Mostreu crèdits + ús addicional» a la configuració de Pantalla."; "Scroll to see more models" = "Desplaceu-vos per veure més models"; /* Shareable usage card */ "Copy Image" = "Copia la imatge"; @@ -1276,6 +1278,9 @@ "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"; "Estimated: %@" = "Estimació: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "quota de sessió"; +"session quotas" = "quotes de sessió"; "Coding Plan" = "Pla de programació"; "Agent Plan" = "Pla d'agent"; "Team" = "Equip"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 9650653a4..c74e5c5bc 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1216,6 +1216,8 @@ "section_links" = "Links"; "Show Codex Spark usage" = "Codex-Spark-Nutzung anzeigen"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Zeigt die Codex-Spark-Kontingentzeilen im Menü und in der Anbietervorschau an. Erfordert, dass „Credits + zusätzliche Nutzung anzeigen“ in den Anzeigeeinstellungen aktiviert ist."; +"Show Daily Routines usage" = "Nutzung von „Tägliche Routinen“ anzeigen"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Zeigt die Kontingentzeile „Tägliche Routinen“ im Menü und in der Anbietervorschau an. Erfordert, dass „Credits + zusätzliche Nutzung anzeigen“ in den Anzeigeeinstellungen aktiviert ist."; "Scroll to see more models" = "Scrollen, um weitere Modelle zu sehen"; "Copy Image" = "Bild kopieren"; "Copy Stats" = "Statistiken kopieren"; @@ -1274,6 +1276,9 @@ "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"; "Estimated: %@" = "Geschätzt: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "Sitzungskontingent"; +"session quotas" = "Sitzungskontingente"; "Coding Plan" = "Coding-Plan"; "Agent Plan" = "Agentenplan"; "Team" = "Team"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 5fa4b9952..3e7bacaf8 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1235,6 +1235,8 @@ "section_links" = "Links"; "Show Codex Spark usage" = "Show Codex Spark usage"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings."; +"Show Daily Routines usage" = "Show Daily Routines usage"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings."; "Scroll to see more models" = "Scroll to see more models"; /* Shareable usage card */ @@ -1277,7 +1279,10 @@ "≈%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"; -"Estimated: %@" = "Estimated: %@"; +"Estimated: %@" = "Est. %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "session quota"; +"session quotas" = "session quotas"; "Coding Plan" = "Coding Plan"; "Agent Plan" = "Agent Plan"; "Team" = "Team"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 9525bb161..aac8103b3 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1077,6 +1077,8 @@ "section_links" = "Enlaces"; "Show Codex Spark usage" = "Mostrar el uso de Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Muestra las filas de cuota de Codex Spark en el menú y en la vista previa del proveedor. Requiere activar «Mostrar créditos + uso adicional» en los ajustes de Pantalla."; +"Show Daily Routines usage" = "Mostrar el uso de Rutinas diarias"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Muestra la fila de cuota de Rutinas diarias en el menú y en la vista previa del proveedor. Requiere activar «Mostrar créditos + uso adicional» en los ajustes de Pantalla."; "Scroll to see more models" = "Desplázate para ver más modelos"; "Copy Image" = "Copiar imagen"; "Copy Stats" = "Copiar estadísticas"; @@ -1272,6 +1274,9 @@ "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"; "Estimated: %@" = "Estimación: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "cuota de sesión"; +"session quotas" = "cuotas de sesión"; "Coding Plan" = "Plan de programación"; "Agent Plan" = "Plan de agente"; "Team" = "Equipo"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 12e02342c..12a3b97cf 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1235,6 +1235,8 @@ "section_links" = "پیوندها"; "Show Codex Spark usage" = "نمایش استفاده از Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "ردیف‌های سهمیه Codex Spark را در منو و پیش‌نمایش ارائه‌دهنده نمایش می‌دهد. لازم است «نمایش اعتبارها + استفاده اضافی» در تنظیمات نمایش فعال باشد."; +"Show Daily Routines usage" = "نمایش استفاده از روال روزانه"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "ردیف سهمیه روال روزانه را در منو و پیش‌نمایش ارائه‌دهنده نمایش می‌دهد. لازم است «نمایش اعتبارها + استفاده اضافی» در تنظیمات نمایش فعال باشد."; "Scroll to see more models" = "برای دیدن مدل‌های بیشتر پیمایش کنید"; /* Shareable usage card */ "Copy Image" = "کپی تصویر"; @@ -1277,6 +1279,9 @@ "Weekly cannot run out before reset at this pace" = "با این روند، سهم هفتگی پیش از بازنشانی تمام نمی‌شود"; "Weekly can run out ≈%d windows early" = "سهم هفتگی ممکن است حدود %d بازه زودتر تمام شود"; "Estimated: %@" = "برآوردی: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "سهمیه جلسه"; +"session quotas" = "سهمیه‌های جلسه"; "Coding Plan" = "طرح کدنویسی"; "Agent Plan" = "طرح عامل"; "Team" = "تیم"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index ecd3b2e9f..a15146fcb 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1216,6 +1216,8 @@ "section_links" = "Liens"; "Show Codex Spark usage" = "Afficher l’utilisation de Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Affiche les lignes de quota Codex Spark dans le menu et l’aperçu du fournisseur. Nécessite d’activer « Afficher les crédits + utilisation supplémentaire » dans les réglages Affichage."; +"Show Daily Routines usage" = "Afficher l’utilisation de Routines quotidiennes"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Affiche la ligne de quota Routines quotidiennes dans le menu et l’aperçu du fournisseur. Nécessite d’activer « Afficher les crédits + utilisation supplémentaire » dans les réglages Affichage."; "Scroll to see more models" = "Faites défiler pour voir plus de modèles"; "Copy Image" = "Copier l’image"; "Copy Stats" = "Copier les statistiques"; @@ -1273,6 +1275,9 @@ "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"; "Estimated: %@" = "Estimation : %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "quota de session"; +"session quotas" = "quotas de session"; "Coding Plan" = "Plan de codage"; "Agent Plan" = "Plan d'agent"; "Team" = "Équipe"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 258d9d34d..e9fdf3091 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1230,6 +1230,8 @@ "≈ %d%% run-out risk" = "≈ %d%% de risco de esgotamento"; "Show Codex Spark usage" = "Amosar o uso de Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Amosa as filas de cota de Codex Spark no menú e na previsualización do provedor. Require activar «Amosar créditos + uso adicional» nos axustes de Pantalla."; +"Show Daily Routines usage" = "Amosar o uso de Rutinas diarias"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Amosa a fila de cota de Rutinas diarias no menú e na previsualización do provedor. Require activar «Amosar créditos + uso adicional» nos axustes de Pantalla."; "Scroll to see more models" = "Desprázate para ver máis modelos"; /* Shareable usage card */ @@ -1273,6 +1275,9 @@ "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"; "Estimated: %@" = "Estimación: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "cota de sesión"; +"session quotas" = "cotas de sesión"; "Coding Plan" = "Plan de programación"; "Agent Plan" = "Plan de axente"; "Team" = "Equipo"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 103960672..bfa4980de 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1234,6 +1234,8 @@ "section_links" = "Tautan"; "Show Codex Spark usage" = "Tampilkan penggunaan Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Menampilkan baris kuota Codex Spark di menu dan pratinjau penyedia. Mengharuskan “Tampilkan kredit + penggunaan ekstra” diaktifkan di pengaturan Tampilan."; +"Show Daily Routines usage" = "Tampilkan penggunaan Rutinitas Harian"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Menampilkan baris kuota Rutinitas Harian di menu dan pratinjau penyedia. Mengharuskan “Tampilkan kredit + penggunaan ekstra” diaktifkan di pengaturan Tampilan."; "Scroll to see more models" = "Gulir untuk melihat model lainnya"; /* Shareable usage card */ @@ -1277,6 +1279,9 @@ "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"; "Estimated: %@" = "Perkiraan: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "kuota sesi"; +"session quotas" = "kuota sesi"; "Coding Plan" = "Paket Coding"; "Agent Plan" = "Paket Agen"; "Team" = "Tim"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index ba6a40809..60d0d48a4 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1234,6 +1234,8 @@ "section_links" = "Link"; "Show Codex Spark usage" = "Mostra l’utilizzo di Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostra le righe della quota Codex Spark nel menu e nell’anteprima del provider. Richiede di attivare «Mostra crediti + uso extra» nelle impostazioni Aspetto."; +"Show Daily Routines usage" = "Mostra l’utilizzo di Routine quotidiane"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostra la riga della quota Routine quotidiane nel menu e nell’anteprima del provider. Richiede di attivare «Mostra crediti + uso extra» nelle impostazioni Aspetto."; "Scroll to see more models" = "Scorri per vedere altri modelli"; /* Shareable usage card */ @@ -1277,6 +1279,9 @@ "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"; "Estimated: %@" = "Stima: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "quota di sessione"; +"session quotas" = "quote di sessione"; "Coding Plan" = "Piano di codifica"; "Agent Plan" = "Piano agente"; "Team" = "Squadra"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index fa53dbacf..b0f6a2334 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1217,6 +1217,8 @@ "section_links" = "リンク"; "Show Codex Spark usage" = "Codex Spark の使用量を表示"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "メニューとプロバイダのプレビューに Codex Spark のクォータ行を表示します。表示設定で「クレジットと追加使用量を表示」を有効にする必要があります。"; +"Show Daily Routines usage" = "デイリールーティンの使用量を表示"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "メニューとプロバイダのプレビューにデイリールーティンのクォータ行を表示します。表示設定で「クレジットと追加使用量を表示」を有効にする必要があります。"; "Scroll to see more models" = "スクロールして他のモデルを表示"; "Copy Image" = "画像をコピー"; "Copy Stats" = "統計をコピー"; @@ -1274,6 +1276,9 @@ "Weekly cannot run out before reset at this pace" = "このペースではリセット前に週間枠を使い切れません"; "Weekly can run out ≈%d windows early" = "週間枠は約%dウィンドウ早く使い切る可能性があります"; "Estimated: %@" = "推定:%@"; +"session_quota_estimate_value_format" = "%1$@%2$@"; +"session quota" = "セッション枠"; +"session quotas" = "セッション枠"; "Coding Plan" = "コーディングプラン"; "Agent Plan" = "エージェントプラン"; "Team" = "チーム"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index bd879aca4..0d981a29f 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1185,6 +1185,8 @@ "section_links" = "링크"; "Show Codex Spark usage" = "Codex Spark 사용량 표시"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "메뉴와 공급자 미리보기에 Codex Spark 할당량 행을 표시합니다. 표시 설정에서 ‘크레딧 + 추가 사용량 표시’를 활성화해야 합니다."; +"Show Daily Routines usage" = "일일 루틴 사용량 표시"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "메뉴와 공급자 미리보기에 일일 루틴 할당량 행을 표시합니다. 표시 설정에서 ‘크레딧 + 추가 사용량 표시’를 활성화해야 합니다."; "Scroll to see more models" = "스크롤하여 더 많은 모델 보기"; "Copy Image" = "이미지 복사"; "Copy Stats" = "통계 복사"; @@ -1241,6 +1243,9 @@ "Weekly cannot run out before reset at this pace" = "이 속도라면 재설정 전에 주간 한도를 소진할 수 없습니다"; "Weekly can run out ≈%d windows early" = "주간 한도가 약 %d개 창 일찍 소진될 수 있습니다"; "Estimated: %@" = "예상: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "세션 할당량"; +"session quotas" = "세션 할당량"; "Coding Plan" = "코딩 요금제"; "Agent Plan" = "에이전트 요금제"; "Team" = "팀"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index be86c89d9..66717acd6 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1216,6 +1216,8 @@ "section_links" = "Links"; "Show Codex Spark usage" = "Codex Spark-gebruik tonen"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Toont Codex Spark-quotumregels in het menu en de voorvertoning van de provider. Vereist dat ‘Toon credits + extra gebruik’ is ingeschakeld in de instellingen voor Weergave."; +"Show Daily Routines usage" = "Gebruik van Dagelijkse routines tonen"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Toont de quotumregel voor Dagelijkse routines in het menu en de voorvertoning van de provider. Vereist dat ‘Toon credits + extra gebruik’ is ingeschakeld in de instellingen voor Weergave."; "Scroll to see more models" = "Scroll om meer modellen te bekijken"; "Copy Image" = "Afbeelding kopiëren"; "Copy Stats" = "Statistieken kopiëren"; @@ -1273,6 +1275,9 @@ "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"; "Estimated: %@" = "Schatting: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "sessiequotum"; +"session quotas" = "sessiequota"; "Coding Plan" = "Codeerplan"; "Agent Plan" = "Agentplan"; "Team" = "Team"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 67ebb2c15..2cde39d1f 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1234,6 +1234,8 @@ "section_links" = "Linki"; "Show Codex Spark usage" = "Pokaż użycie Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Pokazuje wiersze limitu Codex Spark w menu i podglądzie dostawcy. Wymaga włączenia opcji „Pokaż kredyty + dodatkowe użycie” w ustawieniach Wyświetlanie."; +"Show Daily Routines usage" = "Pokaż użycie Codziennych rutyn"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Pokazuje wiersz limitu Codziennych rutyn w menu i podglądzie dostawcy. Wymaga włączenia opcji „Pokaż kredyty + dodatkowe użycie” w ustawieniach Wyświetlanie."; "Scroll to see more models" = "Przewiń, aby zobaczyć więcej modeli"; /* Shareable usage card */ @@ -1277,6 +1279,9 @@ "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"; "Estimated: %@" = "Szacunek: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "limit sesji"; +"session quotas" = "limity sesji"; "Coding Plan" = "Plan kodowania"; "Agent Plan" = "Plan agenta"; "Team" = "Zespół"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 17b5e9816..7d62a21ef 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1217,6 +1217,8 @@ "section_links" = "Links"; "Show Codex Spark usage" = "Mostrar uso do Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostra as linhas de cota do Codex Spark no menu e na prévia do provedor. Requer ativar “Mostrar créditos + uso extra” nos ajustes de Exibição."; +"Show Daily Routines usage" = "Mostrar uso de Rotinas diárias"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Mostra a linha de cota de Rotinas diárias no menu e na prévia do provedor. Requer ativar “Mostrar créditos + uso extra” nos ajustes de Exibição."; "Scroll to see more models" = "Role para ver mais modelos"; "Copy Image" = "Copiar imagem"; "Copy Stats" = "Copiar estatísticas"; @@ -1274,6 +1276,9 @@ "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"; "Estimated: %@" = "Estimativa: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "cota da sessão"; +"session quotas" = "cotas da sessão"; "Coding Plan" = "Plano de codificação"; "Agent Plan" = "Plano de agente"; "Team" = "Equipe"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 13bf66e84..a8c989873 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1223,6 +1223,8 @@ "section_links" = "Ссылки"; "Show Codex Spark usage" = "Показывать использование Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Показывает строки квот Codex Spark в меню и предварительном просмотре провайдера. Требует включить «Показывать кредиты и доп. использование» в настройках «Отображение»."; +"Show Daily Routines usage" = "Показывать использование ежедневных задач"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Показывает строку квоты ежедневных задач в меню и предварительном просмотре провайдера. Требует включить «Показывать кредиты и доп. использование» в настройках «Отображение»."; "Scroll to see more models" = "Прокрутите, чтобы увидеть больше моделей"; "Copy Image" = "Копировать изображение"; "Copy Stats" = "Копировать статистику"; @@ -1275,6 +1277,9 @@ "Weekly cannot run out before reset at this pace" = "При таком темпе недельный лимит не может закончиться до сброса"; "Weekly can run out ≈%d windows early" = "Недельный лимит может закончиться на ≈%d окон раньше"; "Estimated: %@" = "Оценка: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "лимит сессии"; +"session quotas" = "лимиты сессий"; "Coding Plan" = "План программирования"; "Agent Plan" = "План агента"; "Team" = "Команда"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 99ceec69b..b25228096 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1215,6 +1215,8 @@ "section_links" = "Länkar"; "Show Codex Spark usage" = "Visa Codex Spark-användning"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Visar kvotrader för Codex Spark i menyn och i förhandsvisningen för leverantören. Kräver att ”Visa krediter och extra användning” är aktiverat under Visning i Inställningar."; +"Show Daily Routines usage" = "Visa användning för Dagliga rutiner"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Visar kvotraden för Dagliga rutiner i menyn och i förhandsvisningen för leverantören. Kräver att ”Visa krediter och extra användning” är aktiverat under Visning i Inställningar."; "Scroll to see more models" = "Rulla för att se fler modeller"; "Copy Image" = "Kopiera bild"; "Copy Stats" = "Kopiera statistik"; @@ -1272,6 +1274,9 @@ "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"; "Estimated: %@" = "Uppskattning: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "sessionskvot"; +"session quotas" = "sessionskvoter"; "Coding Plan" = "Kodningsplan"; "Agent Plan" = "Agentplan"; "Team" = "Team"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index 5bcfe2b97..ffcad0151 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1235,6 +1235,8 @@ "section_links" = "ลิงก์"; "Show Codex Spark usage" = "แสดงการใช้งาน Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "แสดงแถวโควตา Codex Spark ในเมนูและตัวอย่างผู้ให้บริการ ต้องเปิดใช้ “แสดงเครดิต + การใช้งานเพิ่มเติม” ในการตั้งค่าการแสดงผล"; +"Show Daily Routines usage" = "แสดงการใช้งานกิจวัตรประจำวัน"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "แสดงแถวโควตากิจวัตรประจำวันในเมนูและตัวอย่างผู้ให้บริการ ต้องเปิดใช้ “แสดงเครดิต + การใช้งานเพิ่มเติม” ในการตั้งค่าการแสดงผล"; "Scroll to see more models" = "เลื่อนเพื่อดูโมเดลเพิ่มเติม"; /* Shareable usage card */ "Copy Image" = "คัดลอกรูปภาพ"; @@ -1277,6 +1279,9 @@ "Weekly cannot run out before reset at this pace" = "ด้วยอัตรานี้ โควตารายสัปดาห์จะไม่หมดก่อนรีเซ็ต"; "Weekly can run out ≈%d windows early" = "โควตารายสัปดาห์อาจหมดเร็วขึ้น ≈%d ช่วง"; "Estimated: %@" = "โดยประมาณ: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "โควตาเซสชัน"; +"session quotas" = "โควตาเซสชัน"; "Coding Plan" = "แผนการเขียนโค้ด"; "Agent Plan" = "แผนเอเจนต์"; "Team" = "ทีม"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 7e8ed37e2..34892da72 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1232,6 +1232,8 @@ "section_links" = "Bağlantılar"; "Show Codex Spark usage" = "Codex Spark kullanımını göster"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Codex Spark kota satırlarını menüde ve sağlayıcı önizlemesinde gösterir. Görünüm ayarlarında “Krediler + ekstra kullanımı göster” seçeneğinin etkin olmasını gerektirir."; +"Show Daily Routines usage" = "Günlük Rutinler kullanımını göster"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Günlük Rutinler kota satırını menüde ve sağlayıcı önizlemesinde gösterir. Görünüm ayarlarında “Krediler + ekstra kullanımı göster” seçeneğinin etkin olmasını gerektirir."; "Scroll to see more models" = "Daha fazla model görmek için kaydırın"; /* Shareable usage card */ @@ -1275,6 +1277,9 @@ "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"; "Estimated: %@" = "Tahmini: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "oturum kotası"; +"session quotas" = "oturum kotaları"; "Coding Plan" = "Kodlama Planı"; "Agent Plan" = "Ajan Planı"; "Team" = "Ekip"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index b3793675d..954e616c6 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1216,6 +1216,8 @@ "section_links" = "Посилання"; "Show Codex Spark usage" = "Показати використання Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Показує рядки квоти Codex Spark у меню та попередньому перегляді провайдера. Потрібно ввімкнути «Показати кредити + додаткове використання» в налаштуваннях «Відображення»."; +"Show Daily Routines usage" = "Показати використання щоденних завдань"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Показує рядок квоти щоденних завдань у меню та попередньому перегляді провайдера. Потрібно ввімкнути «Показати кредити + додаткове використання» в налаштуваннях «Відображення»."; "Scroll to see more models" = "Прокрутіть, щоб побачити більше моделей"; "Copy Image" = "Копіювати зображення"; "Copy Stats" = "Копіювати статистику"; @@ -1273,6 +1275,9 @@ "Weekly cannot run out before reset at this pace" = "За такого темпу тижневий ліміт не може вичерпатися до скидання"; "Weekly can run out ≈%d windows early" = "Тижневий ліміт може вичерпатися на ≈%d вікон раніше"; "Estimated: %@" = "Оцінка: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "ліміт сесії"; +"session quotas" = "ліміти сесій"; "Coding Plan" = "План кодування"; "Agent Plan" = "План агента"; "Team" = "Команда"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 956c8b252..e8886a28c 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1217,6 +1217,8 @@ "section_links" = "Liên kết"; "Show Codex Spark usage" = "Hiển thị mức sử dụng Codex Spark"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Hiển thị các hàng hạn mức Codex Spark trong menu và bản xem trước của nhà cung cấp. Yêu cầu bật “Hiển thị tín dụng + mức sử dụng bổ sung” trong phần cài đặt Hiển thị."; +"Show Daily Routines usage" = "Hiển thị mức sử dụng Quy trình hàng ngày"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "Hiển thị hàng hạn mức Quy trình hàng ngày trong menu và bản xem trước của nhà cung cấp. Yêu cầu bật “Hiển thị tín dụng + mức sử dụng bổ sung” trong phần cài đặt Hiển thị."; "Scroll to see more models" = "Cuộn để xem thêm mô hình"; "Copy Image" = "Sao chép hình ảnh"; "Copy Stats" = "Sao chép số liệu"; @@ -1274,6 +1276,9 @@ "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ổ"; "Estimated: %@" = "Ước tính: %@"; +"session_quota_estimate_value_format" = "%1$@ %2$@"; +"session quota" = "hạn mức phiên"; +"session quotas" = "hạn mức phiên"; "Coding Plan" = "Gói lập trình"; "Agent Plan" = "Gói tác nhân"; "Team" = "Nhóm"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index ff907a81c..480b20600 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1193,6 +1193,8 @@ "section_links" = "链接"; "Show Codex Spark usage" = "显示 Codex Spark 用量"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "在菜单和提供商预览中显示 Codex Spark 配额行。需要在“显示”设置中启用“显示额度 + 额外用量”。"; +"Show Daily Routines usage" = "显示日常任务用量"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "在菜单和提供商预览中显示日常任务配额行。需要在“显示”设置中启用“显示额度 + 额外用量”。"; "Scroll to see more models" = "滚动查看更多模型"; "Copy Image" = "复制图像"; "Copy Stats" = "复制统计数据"; @@ -1249,6 +1251,9 @@ "Weekly cannot run out before reset at this pace" = "按此速度,每周额度无法在重置前用完"; "Weekly can run out ≈%d windows early" = "每周额度可能提前约 %d 个窗口用完"; "Estimated: %@" = "估算:%@"; +"session_quota_estimate_value_format" = "%1$@%2$@"; +"session quota" = "会话额度"; +"session quotas" = "会话额度"; "Coding Plan" = "编程套餐"; "Agent Plan" = "智能体套餐"; "Team" = "团队"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index e7568e5d6..3d26f5d7a 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1252,6 +1252,8 @@ "section_links" = "連結"; "Show Codex Spark usage" = "顯示 Codex Spark 使用量"; "Shows Codex Spark quota rows in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "在選單和提供者預覽中顯示 Codex Spark 配額列。需要在「顯示」設定中啟用「顯示額度 + 額外使用量」。"; +"Show Daily Routines usage" = "顯示每日例行工作使用量"; +"Shows the Daily Routines quota row in the menu and provider preview. Requires optional credits and extra usage in Display settings." = "在選單和提供者預覽中顯示每日例行工作的配額列。需要在「顯示」設定中啟用「顯示額度 + 額外使用量」。"; "Scroll to see more models" = "捲動查看更多模型"; "Copy Image" = "拷貝影像"; "Copy Stats" = "拷貝統計資料"; @@ -1304,6 +1306,9 @@ "Weekly cannot run out before reset at this pace" = "依此速度,每週額度無法在重置前用完"; "Weekly can run out ≈%d windows early" = "每週額度可能提前約 %d 個視窗用完"; "Estimated: %@" = "預估:%@"; +"session_quota_estimate_value_format" = "%1$@%2$@"; +"session quota" = "工作階段額度"; +"session quotas" = "工作階段額度"; "Coding Plan" = "程式設計方案"; "Agent Plan" = "智慧體方案"; "Team" = "團隊"; diff --git a/Sources/CodexBar/SessionEquivalentForecast.swift b/Sources/CodexBar/SessionEquivalentForecast.swift index 045327604..c89f048d3 100644 --- a/Sources/CodexBar/SessionEquivalentForecast.swift +++ b/Sources/CodexBar/SessionEquivalentForecast.swift @@ -51,7 +51,6 @@ struct SessionEquivalentForecast: Equatable, Sendable { == self.sessionWindowMinutes, weeklyWindow.windowMinutes.map({ PlanUtilizationSeriesName.weekly.canonicalWindowMinutes($0) }) == self.weeklyWindowMinutes, - let sessionResetsAt = sessionWindow.resetsAt, let weeklyResetsAt = weeklyWindow.resetsAt, weeklyWindow.usedPercent.isFinite, (0...100).contains(weeklyWindow.usedPercent), @@ -64,12 +63,18 @@ struct SessionEquivalentForecast: Equatable, Sendable { let sessionSeconds = TimeInterval(Self.sessionWindowMinutes * 60) let weeklySeconds = TimeInterval(Self.weeklyWindowMinutes * 60) - let sessionRemaining = sessionResetsAt.timeIntervalSince(now) + if let sessionResetsAt = sessionWindow.resetsAt { + let sessionRemaining = sessionResetsAt.timeIntervalSince(now) + guard sessionRemaining.isFinite, + sessionRemaining > 0, + sessionRemaining <= sessionSeconds + Self.resetTolerance + else { + return nil + } + } + let weeklyRemaining = weeklyResetsAt.timeIntervalSince(now) - guard sessionRemaining.isFinite, - sessionRemaining > 0, - sessionRemaining <= sessionSeconds + Self.resetTolerance, - weeklyRemaining.isFinite, + guard weeklyRemaining.isFinite, weeklyRemaining > 0, weeklyRemaining <= weeklySeconds + Self.resetTolerance else { @@ -167,7 +172,7 @@ enum SessionEquivalentBurnEstimator { static func estimate( histories: [PlanUtilizationSeriesHistory], - currentSessionResetsAt: Date, + currentSessionResetsAt: Date?, now: Date, sampleLimit: Int = Self.defaultSampleLimit) -> SessionEquivalentBurnEstimate? { @@ -188,15 +193,20 @@ enum SessionEquivalentBurnEstimator { let sessionDuration = TimeInterval(SessionEquivalentForecast.sessionWindowMinutes * 60) let weeklyDuration = TimeInterval(SessionEquivalentForecast.weeklyWindowMinutes * 60) - let currentSessionRemaining = currentSessionResetsAt.timeIntervalSince(now) - guard currentSessionRemaining.isFinite, - currentSessionRemaining > 0, - currentSessionRemaining <= sessionDuration + Self.resetEquivalenceTolerance, - Self.isChronologicallyOrdered(sessionHistory.entries), + guard Self.isChronologicallyOrdered(sessionHistory.entries), Self.isChronologicallyOrdered(weeklyHistory.entries) else { return nil } + if let currentSessionResetsAt { + let currentSessionRemaining = currentSessionResetsAt.timeIntervalSince(now) + guard currentSessionRemaining.isFinite, + currentSessionRemaining > 0, + currentSessionRemaining <= sessionDuration + Self.resetEquivalenceTolerance + else { + return nil + } + } var groups: [SessionGroup] = [] groups.reserveCapacity(sessionHistory.entries.count) @@ -226,7 +236,10 @@ enum SessionEquivalentBurnEstimator { } let completedActiveGroups = groups.reversed().compactMap { group -> SessionGroup? in - guard group.resetsAt < currentSessionResetsAt.addingTimeInterval(-Self.resetEquivalenceTolerance), + let precedesCurrentSession = currentSessionResetsAt.map { + group.resetsAt < $0.addingTimeInterval(-Self.resetEquivalenceTolerance) + } ?? true + guard precedesCurrentSession, group.resetsAt <= now, group.maximumUsedPercent > 0 else { @@ -399,10 +412,13 @@ enum SessionEquivalentBurnEstimator { } private struct SessionEquivalentBurnCacheKey: Equatable { + static let idleTimeBucketSeconds: TimeInterval = 60 + let historyRevision: Int let historySelectionIdentity: String - let currentSessionResetsAt: Date + let currentSessionResetsAt: Date? let weeklyWindowID: String? + let idleTimeBucket: Int64? } struct SessionEquivalentBurnCacheEntry { @@ -422,12 +438,14 @@ extension UsageStore { now: Date = .init()) -> SessionEquivalentForecast? { guard sessionWindow.windowMinutes.map({ PlanUtilizationSeriesName.session.canonicalWindowMinutes($0) }) - == SessionEquivalentForecast.sessionWindowMinutes, - let currentSessionResetsAt = sessionWindow.resetsAt, - currentSessionResetsAt.timeIntervalSinceReferenceDate.isFinite + == SessionEquivalentForecast.sessionWindowMinutes else { return nil } + let currentSessionResetsAt = sessionWindow.resetsAt + guard currentSessionResetsAt?.timeIntervalSinceReferenceDate.isFinite ?? true else { + return nil + } let selection = historySelection ?? self.planUtilizationHistorySelection(for: provider) guard self.sessionEquivalentHistoryIdentityMatches( @@ -441,7 +459,11 @@ extension UsageStore { historyRevision: self.planUtilizationHistoryRevision, historySelectionIdentity: selection.cacheIdentity, currentSessionResetsAt: currentSessionResetsAt, - weeklyWindowID: weeklyWindowID) + weeklyWindowID: weeklyWindowID, + idleTimeBucket: currentSessionResetsAt == nil + ? Int64(floor(now.timeIntervalSinceReferenceDate / + SessionEquivalentBurnCacheKey.idleTimeBucketSeconds)) + : nil) let burnEstimate: SessionEquivalentBurnEstimate? if let cached = self.sessionEquivalentBurnCache[provider], cached.key == cacheKey { burnEstimate = cached.estimate diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 7f047fe15..63e778013 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -685,6 +685,14 @@ extension SettingsStore { } } + var claudeDailyRoutinesUsageVisible: Bool { + get { self.defaultsState.claudeDailyRoutinesUsageVisible } + set { + self.defaultsState.claudeDailyRoutinesUsageVisible = newValue + self.userDefaults.set(newValue, forKey: "claudeDailyRoutinesUsageVisible") + } + } + var codexSparkUsageVisible: Bool { get { self.defaultsState.codexSparkUsageVisible } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index c8e05dfd6..f4ecdee6f 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -56,6 +56,7 @@ extension SettingsStore { _ = self.claudeWebExtrasEnabled _ = self.copilotBudgetExtrasEnabled _ = self.showOptionalCreditsAndExtraUsage + _ = self.claudeDailyRoutinesUsageVisible _ = self.codexSparkUsageVisible _ = self.openAIWebAccessEnabled _ = self.openAIWebBatterySaverEnabled @@ -80,6 +81,7 @@ extension SettingsStore { _ = self.augmentCookieSource _ = self.ampCookieSource _ = self.t3ChatCookieSource + _ = self.zoomMateCookieSource _ = self.ollamaCookieSource _ = self.mergeIcons _ = self.switcherShowsIcons @@ -101,6 +103,7 @@ extension SettingsStore { _ = self.augmentCookieHeader _ = self.ampCookieHeader _ = self.t3ChatCookieHeader + _ = self.zoomMateCookieHeader _ = self.ollamaCookieHeader _ = self.copilotAPIToken _ = self.warpAPIToken diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 84a8267a8..409137f75 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -386,6 +386,7 @@ extension SettingsStore { private struct OptionalCreditsDefaults { let showOptionalCreditsAndExtraUsage: Bool + let claudeDailyRoutinesUsageVisible: Bool let codexSparkUsageVisible: Bool } @@ -572,6 +573,7 @@ extension SettingsStore { claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, showOptionalCreditsAndExtraUsage: optionalCreditsDefaults.showOptionalCreditsAndExtraUsage, + claudeDailyRoutinesUsageVisible: optionalCreditsDefaults.claudeDailyRoutinesUsageVisible, codexSparkUsageVisible: optionalCreditsDefaults.codexSparkUsageVisible, openAIWebAccessEnabled: openAIWebDefaults.accessEnabled, openAIWebBatterySaverEnabled: openAIWebDefaults.batterySaverEnabled, @@ -598,6 +600,13 @@ extension SettingsStore { userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage") } + let claudeDailyRoutinesUsageVisibleDefault = userDefaults.object( + forKey: "claudeDailyRoutinesUsageVisible") as? Bool + let claudeDailyRoutinesUsageVisible = claudeDailyRoutinesUsageVisibleDefault ?? true + if Self.isRunningTests, claudeDailyRoutinesUsageVisibleDefault == nil { + userDefaults.set(true, forKey: "claudeDailyRoutinesUsageVisible") + } + let codexSparkUsageVisibleDefault = userDefaults.object(forKey: "codexSparkUsageVisible") as? Bool let codexSparkUsageVisible = codexSparkUsageVisibleDefault ?? true if Self.isRunningTests, codexSparkUsageVisibleDefault == nil { @@ -606,6 +615,7 @@ extension SettingsStore { return OptionalCreditsDefaults( showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, + claudeDailyRoutinesUsageVisible: claudeDailyRoutinesUsageVisible, codexSparkUsageVisible: codexSparkUsageVisible) } diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index e18bdd921..02459e8cf 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -58,6 +58,7 @@ struct SettingsDefaultsState { var claudeOAuthKeychainReadStrategyRaw: String? var claudeWebExtrasEnabledRaw: Bool var showOptionalCreditsAndExtraUsage: Bool + var claudeDailyRoutinesUsageVisible: Bool var codexSparkUsageVisible: Bool var openAIWebAccessEnabled: Bool var openAIWebBatterySaverEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 5e0d7a424..3dbcbb590 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -89,7 +89,7 @@ extension StatusItemController { /// Providers that surface the live component list as a native submenu. Every other provider /// keeps the plain "Status Page" link that opens the website. Kept deliberately small: these /// are the statuspage.io/incident.io feeds we actively curate and trust to render well. - static let statusComponentsSubmenuProviders: Set = [.claude, .codex, .augment] + static let statusComponentsSubmenuProviders: Set = [.claude, .codex, .augment, .zoommate] /// Builds the status submenu (component rows + a website link) for the curated providers in /// `statusComponentsSubmenuProviders`. Gated on the provider being in that allowlist (and @@ -600,7 +600,7 @@ extension StatusItemController { // Before the first fetch lands the submenu still renders (just the website link below), so // every provider with a status feed gets the native submenu rather than a bare link; it // re-hydrates with the live component list once data arrives (see makeStatusComponentsSubmenu). - let components = self.store.statusComponents(for: provider) + let components = Self.filterStatusComponents(self.store.statusComponents(for: provider), for: provider) if !components.isEmpty { if self.menuCardRenderingEnabledForController { final class HostingRelay { diff --git a/Sources/CodexBar/StatusItemController+IconObservation.swift b/Sources/CodexBar/StatusItemController+IconObservation.swift index 832747991..435a30cd0 100644 --- a/Sources/CodexBar/StatusItemController+IconObservation.swift +++ b/Sources/CodexBar/StatusItemController+IconObservation.swift @@ -48,6 +48,9 @@ extension StatusItemController { let layoutCostSignature = showBrandPercent ? self.storedMenuBarLayoutCostSignature(for: provider) : nil + let layoutAccountSignature = showBrandPercent + ? self.storedMenuBarLayoutAccountSignature(for: provider, snapshot: snapshot) + : nil return [ provider.rawValue, @@ -62,9 +65,26 @@ extension StatusItemController { "refreshing=\(self.store.refreshingProviders.contains(provider) ? "1" : "0")", "text=\(displayText ?? "nil")", "layoutCost=\(layoutCostSignature ?? "nil")", + "layoutAccount=\(layoutAccountSignature ?? "nil")", ].joined(separator: "|") } + private func storedMenuBarLayoutAccountSignature( + for provider: UsageProvider, + snapshot: UsageSnapshot?) + -> String? + { + let resolution = self.settings.menuBarLayoutResolution(for: provider) + guard !resolution.usesLegacyRendering, + resolution.layout.lines.joined().contains(.accountLabel), + let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot) + else { return nil } + + var hasher = Hasher() + hasher.combine(accountLabel) + return String(hasher.finalize()) + } + private func storedMenuBarLayoutCostSignature(for provider: UsageProvider) -> String? { let resolution = self.settings.menuBarLayoutResolution(for: provider) guard !resolution.usesLegacyRendering else { return nil } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 81e3940aa..bddb1a30e 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1606,6 +1606,18 @@ extension StatusItemController { return self.makeHostedSubviewPlaceholderMenu(chartID: Self.storageBreakdownID, provider: provider) } + /// Filters `components` down to a provider's descriptor-owned named allowlist, if configured; + /// returns `components` unchanged when the provider has no allowlist. Matching is by exact + /// `name` equality at the top level only (groups and leaves alike). + static func filterStatusComponents( + _ components: [ProviderStatusComponent], + for provider: UsageProvider) -> [ProviderStatusComponent] + { + let metadata = ProviderDescriptorRegistry.descriptor(for: provider).metadata + guard let allowlist = metadata.statusComponentAllowlist else { return components } + return components.filter { allowlist.contains($0.name) } + } + @objc func menuCardNoOp(_ sender: NSMenuItem) { _ = sender } diff --git a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift index 133dac8a1..1899bce45 100644 --- a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift +++ b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift @@ -62,11 +62,7 @@ extension StatusItemController { .flatMap { UsagePaceText.weeklyDetail(provider: provider, pace: $0, now: now).rightLabel } let costStrings = self.menuBarLayoutCostStrings(provider: provider, now: now) let providerName = L(self.store.metadata(for: provider).displayName) - let rawAccountLabel = snapshot?.accountEmail(for: provider)? - .trimmingCharacters(in: .whitespacesAndNewlines) - let accountLabel = self.settings.hidePersonalInfo || rawAccountLabel?.isEmpty != false - ? nil - : rawAccountLabel + let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot) return MenuBarLayoutRenderData( iconKey: "\(provider.rawValue):\(warningFlash ? "warning" : "normal")", @@ -80,6 +76,14 @@ extension StatusItemController { cost30d: costStrings.last30Days) } + func menuBarLayoutAccountLabel(provider: UsageProvider, snapshot: UsageSnapshot?) -> String? { + let rawAccountLabel = snapshot?.accountEmail(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return self.settings.hidePersonalInfo || rawAccountLabel?.isEmpty != false + ? nil + : rawAccountLabel + } + func menuBarLayoutCostStrings( provider: UsageProvider, now: Date = .init()) diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 64d5bad33..43d02f638 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -173,6 +173,7 @@ extension StatusItemController { self.settings.costSummaryShowsSubmenu(for: target), costComparisonPeriodsEnabled: self.settings.costComparisonPeriodsEnabled, showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, + claudeDailyRoutinesUsageVisible: self.settings.claudeDailyRoutinesUsageVisible, codexSparkUsageVisible: self.settings.codexSparkUsageVisible, copilotBudgetExtrasEnabled: self.settings.copilotBudgetExtrasEnabled, sourceLabel: sourceLabel, diff --git a/Sources/CodexBar/Sync/QuotaTransitionWriter.swift b/Sources/CodexBar/Sync/QuotaTransitionWriter.swift index ab1591015..c56586cbf 100644 --- a/Sources/CodexBar/Sync/QuotaTransitionWriter.swift +++ b/Sources/CodexBar/Sync/QuotaTransitionWriter.swift @@ -28,7 +28,8 @@ protocol QuotaTransitionWriting: AnyObject { threshold: Int, accountDisplayName: String?, accountDiscriminator: String?, - windowID: String?) + windowID: String?, + windowDisplayLabel: String?) // swiftlint:enable function_parameter_count } @@ -153,7 +154,8 @@ final class QuotaTransitionWriter: QuotaTransitionWriting { threshold: Int, accountDisplayName: String?, accountDiscriminator: String?, - windowID: String?) + windowID: String?, + windowDisplayLabel: String?) { let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let windowString = window.rawValue @@ -172,7 +174,7 @@ final class QuotaTransitionWriter: QuotaTransitionWriting { return } - Task { [providerName, windowString, accountDisplayName, deduplicationScope] in + Task { [providerName, windowString, accountDisplayName, deduplicationScope, windowDisplayLabel] in let result = await CloudSyncManager.shared.writeQuotaWarningTransition( providerName: providerName, providerID: provider.rawValue, @@ -180,7 +182,8 @@ final class QuotaTransitionWriter: QuotaTransitionWriting { threshold: threshold, transitionAt: now, accountEmail: accountDisplayName, - deduplicationScope: deduplicationScope) + deduplicationScope: deduplicationScope, + windowDisplayLabel: windowDisplayLabel) if result.succeeded { self.lastWarningWriteByKey[key] = now self.logger.info( diff --git a/Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift b/Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift index 745f89521..bb1b067f4 100644 --- a/Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift +++ b/Sources/CodexBar/Sync/SyncCoordinator+ProviderMappers.swift @@ -354,17 +354,20 @@ extension SyncCoordinator { // `period` like "Last month" / "This month". // // We heuristically synthesise an envelope from `providerCost` - // when both used + limit + USD currency are present. The + // when either used + limit or a prepaid balance is present in USD. The // brittle OAuth path is deferred to a follow-up that adds a // structured field on `UsageSnapshot` to avoid string sniffing. // Until then, OAuth-only Claude accounts continue to surface // the spend-limit metric via the existing primary RateWindow. guard let cost = providerCost, - cost.limit > 0, - cost.currencyCode == "USD" + cost.currencyCode.caseInsensitiveCompare("USD") == .orderedSame, + cost.limit > 0 || cost.balance != nil else { return nil } - let utilization = min(max((cost.used / cost.limit) * 100, 0), 100) + let hasSpendLimit = cost.limit > 0 + let utilization = hasSpendLimit + ? min(max((cost.used / cost.limit) * 100, 0), 100) + : nil let planTier: String? = { let login = snapshot?.identity?.loginMethod ?? "" if login.localizedCaseInsensitiveContains("enterprise") { return "Enterprise" } @@ -375,8 +378,9 @@ extension SyncCoordinator { }() return SyncClaudeExtraUsage( utilization: utilization, - monthlySpendUSD: cost.used, - monthlyLimitUSD: cost.limit, + monthlySpendUSD: hasSpendLimit ? cost.used : nil, + monthlyLimitUSD: hasSpendLimit ? cost.limit : nil, + balanceUSD: cost.balance, isEnabled: true, planTier: planTier, updatedAt: snapshot?.updatedAt ?? cost.updatedAt) diff --git a/Sources/CodexBar/Sync/SyncCoordinator.swift b/Sources/CodexBar/Sync/SyncCoordinator.swift index b7e78f841..778574324 100644 --- a/Sources/CodexBar/Sync/SyncCoordinator.swift +++ b/Sources/CodexBar/Sync/SyncCoordinator.swift @@ -784,6 +784,13 @@ final class SyncCoordinator { // ProviderCostSnapshot with a zero limit. Those are not used/limit // budgets and would render on iOS as the false statement "$balance / $0". guard provider != .zenmux, provider != .neuralwatt, provider != .aiand else { return nil } + if provider == .claude, + let providerCost, + providerCost.limit <= 0, + providerCost.balance != nil + { + return nil + } return providerCost.map { pc in SyncBudgetSnapshot( usedAmount: pc.used, @@ -863,6 +870,27 @@ final class SyncCoordinator { metadata: ProviderMetadata?, snapshot: UsageSnapshot?) -> (primary: String?, secondary: String?, tertiary: String) { + if provider == .amp { + return ( + AmpProviderDescriptor.primaryLabel(details: snapshot?.ampUsage) ?? metadata?.sessionLabel, + AmpProviderDescriptor.secondaryLabel(details: snapshot?.ampUsage) ?? metadata?.weeklyLabel, + metadata?.opusLabel ?? "Sonnet") + } + + if provider == .alibabatokenplan { + return ( + AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot?.primary) ?? metadata?.sessionLabel, + AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot?.secondary) ?? + metadata?.weeklyLabel, + metadata?.opusLabel ?? "Sonnet") + } + + if provider == .qwencloud, + snapshot?.primary?.windowMinutes == 30 * 24 * 60 + { + return ("30-day", metadata?.weeklyLabel, metadata?.opusLabel ?? "Sonnet") + } + if provider == .cursor { // Cursor's legacy projection stored Auto/API in primary/secondary, // while the current projection uses Total/Auto/API across all @@ -1554,7 +1582,7 @@ final class SyncCoordinator { // from their own APIs/local sessions — never via the local // pricing tables. .devin, .zed, .sakana, .poe, .chutes, .qoder, .clawrouter, .wayfinder, .sub2api, - .zenmux, .clinepass, .longcat, .neuralwatt, .deepinfra, .aiand: + .zenmux, .clinepass, .longcat, .neuralwatt, .deepinfra, .aiand, .qwencloud, .zoommate: // These providers never reach the local pricing table — their // costs come pre-computed from upstream APIs (or don't exist). // No fallback applies, so they are never "estimated". diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index fab95f947..4c4cdd625 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -10,10 +10,9 @@ enum UsagePaceText { } struct SessionEquivalentDetail: Equatable { - let verdictText: String - let numberText: String - let verdictAccessibilityLabel: String - let numberAccessibilityLabel: String + let leftText: String + let rightText: String + let accessibilityLabel: String } private enum DetailContext { @@ -38,38 +37,40 @@ enum UsagePaceText { } static func sessionEquivalentDetail(forecast: SessionEquivalentForecast) -> SessionEquivalentDetail { - let displayedEstimate = Self.boundedFullWindowCount(forecast.estimatedWindowsToExhaustWeekly) - let formattingLocale = codexBarLocalizedResourceLocale() - let numberText = String( - format: L("≈%d full 5h windows of weekly left · %d windows until reset"), - locale: formattingLocale, - arguments: [displayedEstimate, forecast.windowsUntilReset]) - let verdictText: String - if forecast.estimatedWindowsToExhaustWeekly >= forecast.availableWindowsUntilReset { - verdictText = L("Weekly cannot run out before reset at this pace") + let leftText = Self.sessionQuotaEstimateText(forecast.estimatedWindowsToExhaustWeekly) + let rightText = Self.windowsUntilResetText(forecast.windowsUntilReset) + return SessionEquivalentDetail( + leftText: leftText, + rightText: rightText, + accessibilityLabel: L("%@ · %@", leftText, rightText)) + } + + private static func sessionQuotaEstimateText(_ value: Double) -> String { + let displayedEstimate: String + let unit: String + if value.isFinite, value > 0 { + let boundedValue = min(value, 1_000_000) + let roundedValue = (boundedValue * 10).rounded() / 10 + displayedEstimate = roundedValue.formatted( + .number + .precision(.fractionLength(0...1)) + .locale(codexBarLocalizedLocale())) + unit = roundedValue > 0 && roundedValue <= 1 ? L("session quota") : L("session quotas") } else { - let windowsEarly = Self.boundedWindowCount( - forecast.availableWindowsUntilReset - forecast.estimatedWindowsToExhaustWeekly) - verdictText = String( - format: L("Weekly can run out ≈%d windows early"), - locale: formattingLocale, - arguments: [max(1, windowsEarly)]) + displayedEstimate = codexBarLocalizedInteger(0) + unit = L("session quotas") } - return SessionEquivalentDetail( - verdictText: verdictText, - numberText: numberText, - verdictAccessibilityLabel: L("Estimated: %@", verdictText), - numberAccessibilityLabel: L("Estimated: %@", numberText)) + let estimateValue = L("session_quota_estimate_value_format", displayedEstimate, unit) + return L("Estimated: %@", L("%@ left", estimateValue)) } - private static func boundedWindowCount(_ value: Double) -> Int { - guard value.isFinite, value > 0 else { return 0 } - return Int(min(value, 1_000_000).rounded()) - } - - private static func boundedFullWindowCount(_ value: Double) -> Int { - guard value.isFinite, value > 0 else { return 0 } - return Int(floor(min(value, 1_000_000))) + private static func windowsUntilResetText(_ count: Int) -> String { + let combinedText = String( + format: L("≈%d full 5h windows of weekly left · %d windows until reset"), + locale: codexBarLocalizedResourceLocale(), + arguments: [0, count]) + guard let separatorRange = combinedText.range(of: " · ") else { return combinedText } + return String(combinedText[separatorRange.upperBound...]) } private static func detailLeftLabel(for pace: UsagePace) -> String { diff --git a/Sources/CodexBar/UsageStore+HighestUsage.swift b/Sources/CodexBar/UsageStore+HighestUsage.swift index e1f91866f..f681256c4 100644 --- a/Sources/CodexBar/UsageStore+HighestUsage.swift +++ b/Sources/CodexBar/UsageStore+HighestUsage.swift @@ -122,6 +122,17 @@ extension UsageStore { guard !percents.isEmpty else { return true } return percents.allSatisfy { $0 >= 100 } } + if effectivePreference == .automatic, + MenuBarMetricWindowResolver.automaticSelectionPrioritizesExhaustedWindow(for: provider) + { + let percents = [ + snapshot.primary?.usedPercent, + snapshot.secondary?.usedPercent, + snapshot.tertiary?.usedPercent, + ].compactMap(\.self) + guard !percents.isEmpty else { return true } + return percents.allSatisfy { $0 >= 100 } + } return true } diff --git a/Sources/CodexBar/UsageStore+QuotaWarnings.swift b/Sources/CodexBar/UsageStore+QuotaWarnings.swift index 6004d4f34..ccc92bc46 100644 --- a/Sources/CodexBar/UsageStore+QuotaWarnings.swift +++ b/Sources/CodexBar/UsageStore+QuotaWarnings.swift @@ -42,20 +42,28 @@ extension UsageStore { primaryWindow = provider == .mimo || provider == .qoder ? nil : snapshot.primary secondaryWindow = provider == .mimo || provider == .qoder ? nil : snapshot.secondary } + let primaryWindowDisplayLabel = provider == .amp + ? AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) + : nil + let secondaryWindowDisplayLabel = provider == .amp + ? AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) + : nil if notificationsEnabled { self.handleQuotaWarningTransition( provider: provider, transition: QuotaWarningTransition( window: .session, rateWindow: primaryWindow, - source: source), + source: source, + windowDisplayLabel: primaryWindowDisplayLabel), account: account) self.handleQuotaWarningTransition( provider: provider, transition: QuotaWarningTransition( window: .weekly, rateWindow: secondaryWindow, - source: source), + source: source, + windowDisplayLabel: secondaryWindowDisplayLabel), account: account) self.handleClaudeExtraWindowQuotaWarnings( provider: provider, @@ -69,7 +77,7 @@ extension UsageStore { lane: QuotaLowHookLane( window: .session, windowID: nil, - label: QuotaWarningWindow.session.displayName), + label: primaryWindowDisplayLabel ?? QuotaWarningWindow.session.displayName), rateWindow: primaryWindow, accountDiscriminator: account.discriminator, accountDisplayName: account.displayName) @@ -78,7 +86,7 @@ extension UsageStore { lane: QuotaLowHookLane( window: .weekly, windowID: nil, - label: QuotaWarningWindow.weekly.displayName), + label: secondaryWindowDisplayLabel ?? QuotaWarningWindow.weekly.displayName), rateWindow: secondaryWindow, accountDiscriminator: account.discriminator, accountDisplayName: account.displayName) diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index 317f1d574..2e5818d78 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -147,6 +147,19 @@ extension UsageStore { window: window) } } + if provider == .claude, + let spendLimit = MenuBarMetricWindowResolver.claudeSpendLimitWindow(snapshot: snapshot) + { + let period = snapshot.providerCost?.period?.trimmingCharacters(in: .whitespacesAndNewlines) + let title = period.flatMap { $0.isEmpty ? nil : $0 } ?? "Extra usage" + return [ + WidgetSnapshot.WidgetUsageRowSnapshot( + id: "extraUsage", + title: title, + percentLeft: spendLimit.remainingPercent, + window: spendLimit), + ] + } if provider == .antigravity, let rows = Self.antigravityQuotaSummaryWidgetRows(snapshot: snapshot), !rows.isEmpty @@ -169,6 +182,14 @@ extension UsageStore { provider: provider, metadata: metadata, snapshot: snapshot) + let secondaryTitle = if provider == .amp { + AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? metadata?.weeklyLabel ?? "Weekly" + } else if provider == .alibabatokenplan { + AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot.secondary) ?? + metadata?.weeklyLabel ?? "Weekly" + } else { + metadata?.weeklyLabel ?? "Weekly" + } var rows: [WidgetSnapshot.WidgetUsageRowSnapshot] = [ WidgetSnapshot.WidgetUsageRowSnapshot( @@ -177,7 +198,7 @@ extension UsageStore { percentLeft: snapshot.primary?.remainingPercent), WidgetSnapshot.WidgetUsageRowSnapshot( id: "secondary", - title: metadata?.weeklyLabel ?? "Weekly", + title: secondaryTitle, percentLeft: snapshot.secondary?.remainingPercent), ] if metadata?.supportsOpus == true { @@ -295,6 +316,16 @@ extension UsageStore { { return dyn } + if provider == .amp, + let dyn = AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) + { + return dyn + } + if provider == .alibabatokenplan, + let dyn = AlibabaTokenPlanProviderDescriptor.primaryLabel(window: snapshot.primary) + { + return dyn + } return metadata?.sessionLabel ?? "Session" } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 2a0b9703f..95d2bf2ab 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -878,7 +878,8 @@ final class UsageStore { threshold: event.threshold, accountDisplayName: event.accountDisplayName, accountDiscriminator: event.accountDiscriminator, - windowID: event.windowID) + windowID: event.windowID, + windowDisplayLabel: event.windowDisplayLabel) } } @@ -1123,6 +1124,7 @@ extension UsageStore { .opencode: "OpenCode debug log not yet implemented", .alibaba: "Alibaba Coding Plan debug log not yet implemented", .alibabatokenplan: "Alibaba Token Plan debug log not yet implemented", + .qwencloud: "Qwen Cloud debug log not yet implemented", .factory: "Droid debug log not yet implemented", .copilot: "Copilot debug log not yet implemented", .manus: "Manus debug log not yet implemented", @@ -1143,6 +1145,7 @@ extension UsageStore { .grok: "Grok debug log not yet implemented", .groq: "Groq debug log not yet implemented", .t3chat: "T3 Chat debug log not yet implemented", + .zoommate: "ZoomMate debug log not yet implemented", .llmproxy: "LLM Proxy debug log not yet implemented", .litellm: "LiteLLM debug log not yet implemented", .deepgram: "Deepgram debug log not yet implemented", @@ -1226,12 +1229,12 @@ extension UsageStore { configToken: nil, hasEnvToken: deepSeekHasEnvToken, hasTokenAccount: deepSeekHasTokenAccount) - case .clinepass, .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, + case .clinepass, .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .qwencloud, .factory, .copilot, .devin, .vertexai, .kilo, .kiro, .kimi, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .deepinfra, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .t3chat, .llmproxy, .litellm, .zed, .deepgram, .poe, .chutes, .neuralwatt, .clawrouter, .longcat, .wayfinder, - .sub2api, .zenmux, .aiand: + .sub2api, .zenmux, .aiand, .zoommate: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/CLIConfigCommand.swift b/Sources/CodexBarCLI/CLIConfigCommand.swift index 8442f3452..934655150 100644 --- a/Sources/CodexBarCLI/CLIConfigCommand.swift +++ b/Sources/CodexBarCLI/CLIConfigCommand.swift @@ -54,7 +54,8 @@ extension CodexBarCLI { static func runConfigDump(_ values: ParsedValues) { let output = CLIOutputPreferences.from(values: values) - let config = Self.loadConfig(output: output) + let showSecrets = values.flags.contains("showSecrets") + let config = Self.loadConfig(output: output).sanitizedForDump(showSecrets: showSecrets) Self.printJSON(config, pretty: output.pretty) Self.exit(code: .success, output: output, kind: .config) } @@ -373,6 +374,32 @@ struct ConfigOptions: CommanderParsable { var pretty: Bool = false } +struct ConfigDumpOptions: CommanderParsable { + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") + var logLevel: String? + + @Option(name: .long("format"), help: "Output format: text | json") + var format: OutputFormat? + + @Flag(name: .long("json"), help: "") + var jsonShortcut: Bool = false + + @Flag(name: .long("json-only"), help: "Emit JSON only (suppress non-JSON output)") + var jsonOnly: Bool = false + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false + + @Flag(name: .long("show-secrets"), help: "Include raw un-redacted API keys and tokens in output") + var showSecrets: Bool = false +} + struct ConfigSetAPIKeyOptions: CommanderParsable { @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") var verbose: Bool = false diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift index 34e750039..22ab65ded 100644 --- a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -297,6 +297,8 @@ extension CodexBarCLI { switch provider { case .alibabatokenplan: AlibabaTokenPlanSettingsReader.cookieHeader(environment: environment) != nil + case .qwencloud: + QwenCloudSettingsReader.cookieHeader(environment: environment) != nil case .kimi: KimiSettingsReader.authToken(environment: environment) != nil case .manus: diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index 363c5d8a5..a24e4e6e4 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -109,6 +109,7 @@ enum CodexBarCLI { let sessionsFocusSignature = CommandSignature.describe(SessionsFocusOptions()) let serveSignature = CommandSignature.describe(ServeOptions()) let configSignature = CommandSignature.describe(ConfigOptions()) + let configDumpSignature = CommandSignature.describe(ConfigDumpOptions()) let configProviderToggleSignature = CommandSignature.describe(ConfigProviderToggleOptions()) let configSetAPIKeySignature = CommandSignature.describe(ConfigSetAPIKeyOptions()) let cacheSignature = CommandSignature.describe(CacheOptions()) @@ -176,7 +177,7 @@ enum CodexBarCLI { name: "dump", abstract: "Print normalized config JSON", discussion: nil, - signature: configSignature), + signature: configDumpSignature), CommandDescriptor( name: "providers", abstract: "List provider enablement", diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index b62c3ef58..80d92f12c 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -188,7 +188,7 @@ extension CodexBarCLI { [--json-output] [--log-level ] [-v|--verbose] [--pretty] - quotakit config dump [--format text|json] + quotakit config dump [--show-secrets] [--format text|json] [--json] [--json-only] [--json-output] [--log-level ] @@ -205,6 +205,8 @@ extension CodexBarCLI { Description: Validate or print the QuotaKit config file (default: validate). + dump prints normalized config JSON with stored credentials redacted by default + (use --show-secrets to reveal raw values). providers lists persistent provider enablement. enable/disable updates the same provider toggle used by Settings. set-api-key stores a provider API key in the resolved config file and enables that provider by default. diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index 5bb576f0e..06f8282f8 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -396,6 +396,10 @@ extension CodexBarCLI { CommandSignature.describe(ConfigSetAPIKeyOptions()) } + static func _configDumpSignatureForTesting() -> CommandSignature { + CommandSignature.describe(ConfigDumpOptions()) + } + static func _configProviderToggleSignatureForTesting() -> CommandSignature { CommandSignature.describe(ConfigProviderToggleOptions()) } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index bc1d6540d..c9f90eaf9 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -46,6 +46,11 @@ enum CLIRenderer { snapshot: snapshot, useColor: context.useColor, lines: &lines) + self.appendClaudeExtraUsageBalanceLine( + provider: provider, + snapshot: snapshot, + useColor: context.useColor, + lines: &lines) self.appendLimitsUnavailableLine( provider: provider, snapshot: snapshot, @@ -109,6 +114,11 @@ enum CLIRenderer { snapshot: snapshot, useColor: context.useColor, lines: &lines) + self.appendClaudeExtraUsageBalanceLine( + provider: provider, + snapshot: snapshot, + useColor: context.useColor, + lines: &lines) self.appendLimitsUnavailableLine( provider: provider, snapshot: snapshot, @@ -491,6 +501,11 @@ enum CLIRenderer { snapshot: snapshot, useColor: context.useColor, lines: &lines) + self.appendClaudeExtraUsageBalanceLine( + provider: provider, + snapshot: snapshot, + useColor: context.useColor, + lines: &lines) self.appendLimitsUnavailableLine( provider: provider, snapshot: snapshot, @@ -597,7 +612,8 @@ enum CLIRenderer { guard provider != .clawrouter, let cost = snapshot.providerCost, - !(provider == .devin && cost.period == "Extra usage balance") + !(provider == .devin && cost.period == "Extra usage balance"), + !(provider == .claude && cost.used == 0 && cost.limit == 0 && cost.balance != nil) else { return } // Fallback to cost/quota display if no primary rate window. let label = cost.currencyCode == "Quota" ? "Quota" : "Cost" @@ -648,6 +664,20 @@ enum CLIRenderer { lines.append(self.labelValueLine("Extra usage", value: balance, useColor: useColor)) } + private static func appendClaudeExtraUsageBalanceLine( + provider: UsageProvider, + snapshot: UsageSnapshot, + useColor: Bool, + lines: inout [String]) + { + guard provider == .claude, + let cost = snapshot.providerCost, + let balance = cost.balance + else { return } + let value = UsageFormatter.currencyString(balance, currencyCode: cost.currencyCode) + lines.append(self.labelValueLine("Extra usage balance", value: value, useColor: useColor)) + } + private static func appendClawRouterUsageLines( snapshot: UsageSnapshot, useColor: Bool, @@ -811,12 +841,19 @@ enum CLIRenderer { GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel } else if provider == .sub2api { Sub2APIProviderDescriptor.primaryLabel(details: snapshot.sub2APIUsage) ?? metadata.sessionLabel + } else if provider == .amp { + AmpProviderDescriptor.primaryLabel(details: snapshot.ampUsage) ?? metadata.sessionLabel } else { metadata.sessionLabel } + let secondaryLabel = if provider == .amp { + AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? metadata.weeklyLabel + } else { + metadata.weeklyLabel + } return RateWindowLabels( primary: primaryLabel, - secondary: metadata.weeklyLabel, + secondary: secondaryLabel, tertiary: metadata.opusLabel ?? "Sonnet", showsTertiary: metadata.supportsOpus) } @@ -976,7 +1013,7 @@ enum CLIRenderer { private static func usesDetailBackedWindow(provider: UsageProvider) -> Bool { switch provider { - case .warp, .kilo, .mistral, .deepseek, .deepinfra, .qoder, .crof: + case .warp, .kilo, .mistral, .deepseek, .deepinfra, .qoder, .crof, .chutes: true default: false diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 9f17cdbfb..32ce4cfe9 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -720,11 +720,24 @@ extension CodexBarCLI { { return false } + if provider == .qwencloud, + self.qwenCloudCanFetchWithoutWeb( + sourceMode: sourceMode, + environment: environment, + settings: settings) + { + return false + } if provider == .qoder, settings?.qoder?.cookieSource == .manual { return false } + if provider == .zoommate, + self.hasZoomMateManualCapture(settings: settings) + { + return false + } if provider == .ollama, sourceMode == .auto { @@ -767,4 +780,26 @@ extension CodexBarCLI { false } } + + private static func hasZoomMateManualCapture(settings: ProviderSettingsSnapshot?) -> Bool { + settings?.zoommate?.cookieSource == .manual && + CookieHeaderNormalizer.normalize(settings?.zoommate?.manualCookieHeader) != nil + } + + private static func qwenCloudCanFetchWithoutWeb( + sourceMode: ProviderSourceMode, + environment: [String: String]?, + settings: ProviderSettingsSnapshot?) -> Bool + { + guard sourceMode == .auto || sourceMode == .web, + settings?.qwenCloud?.cookieSource != .off + else { return false } + + let hasEnvironmentCookie = environment.map { + QwenCloudSettingsReader.cookieHeader(environment: $0) != nil + } == true + let hasManualCookie = settings?.qwenCloud?.cookieSource == .manual && + CookieHeaderNormalizer.normalize(settings?.qwenCloud?.manualCookieHeader) != nil + return hasEnvironmentCookie || hasManualCookie + } } diff --git a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift index 3b7ad1d20..a14c2aeff 100644 --- a/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift +++ b/Sources/CodexBarCLI/DashboardSnapshotBuilder.swift @@ -164,12 +164,15 @@ enum DashboardSnapshotBuilder { guard let usage else { return [] } let labels = self.rateWindowLabels(provider: provider, metadata: metadata, usage: usage) var windows: [DashboardWindowPayload] = [] + let isAmpSubscription = provider == .amp && usage.ampUsage?.subscriptionPlan != nil if let primary = usage.primary { - windows.append(self.makeWindow(kind: "session", label: labels.primary, window: primary)) + let kind = isAmpSubscription ? "other" : "session" + windows.append(self.makeWindow(kind: kind, label: labels.primary, window: primary)) } if let secondary = usage.secondary { - windows.append(self.makeWindow(kind: "weekly", label: labels.secondary, window: secondary)) + let kind = isAmpSubscription ? "orb" : "weekly" + windows.append(self.makeWindow(kind: kind, label: labels.secondary, window: secondary)) } if let tertiary = usage.tertiary { windows.append(self.makeWindow(kind: "tertiary", label: labels.tertiary, window: tertiary)) @@ -196,9 +199,19 @@ enum DashboardSnapshotBuilder { return RateWindowLabels(primary: "5-hour", secondary: "Weekly", tertiary: "Monthly") } + let primaryLabel = if provider == .amp { + AmpProviderDescriptor.primaryLabel(details: usage.ampUsage) ?? metadata?.sessionLabel ?? "Session" + } else { + metadata?.sessionLabel ?? "Session" + } + let secondaryLabel = if provider == .amp { + AmpProviderDescriptor.secondaryLabel(details: usage.ampUsage) ?? metadata?.weeklyLabel ?? "Weekly" + } else { + metadata?.weeklyLabel ?? "Weekly" + } return RateWindowLabels( - primary: metadata?.sessionLabel ?? "Session", - secondary: metadata?.weeklyLabel ?? "Weekly", + primary: primaryLabel, + secondary: secondaryLabel, tertiary: metadata?.opusLabel ?? "Tertiary") } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 370889291..4ee5c3320 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -151,6 +151,7 @@ struct TokenAccountCLIContext { } } + // swiftlint:disable:next cyclomatic_complexity private func makeCookieBackedSnapshot( provider: UsageProvider, account: ProviderTokenAccount?, @@ -187,6 +188,8 @@ struct TokenAccountCLIContext { cookieSource: cookieSettings.cookieSource, manualCookieHeader: cookieSettings.manualCookieHeader, apiRegion: self.resolveAlibabaTokenPlanRegion(config))) + case .qwencloud: + return self.makeSnapshot(qwenCloud: self.makeProviderCookieSettings(cookieSettings)) case .factory: return self.makeSnapshot(factory: self.makeProviderCookieSettings(cookieSettings)) case .minimax: @@ -215,6 +218,8 @@ struct TokenAccountCLIContext { return self.makeSnapshot(abacus: self.makeProviderCookieSettings(cookieSettings)) case .mistral: return self.makeSnapshot(mistral: self.makeProviderCookieSettings(cookieSettings)) + case .zoommate: + return self.makeSnapshot(zoommate: self.makeProviderCookieSettings(cookieSettings)) case .stepfun: let stepfunSettings = self.cookieSettings( provider: provider, @@ -240,6 +245,7 @@ struct TokenAccountCLIContext { opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings? = nil, alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? = nil, alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings? = nil, + qwenCloud: ProviderSettingsSnapshot.QwenCloudProviderSettings? = nil, factory: ProviderSettingsSnapshot.FactoryProviderSettings? = nil, minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? = nil, manus: ProviderSettingsSnapshot.ManusProviderSettings? = nil, @@ -258,7 +264,8 @@ struct TokenAccountCLIContext { abacus: ProviderSettingsSnapshot.AbacusProviderSettings? = nil, mistral: ProviderSettingsSnapshot.MistralProviderSettings? = nil, qoder: ProviderSettingsSnapshot.QoderProviderSettings? = nil, - stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? = nil, + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( codex: codex, @@ -268,6 +275,7 @@ struct TokenAccountCLIContext { opencodego: opencodego, alibaba: alibaba, alibabaTokenPlan: alibabaTokenPlan, + qwenCloud: qwenCloud, factory: factory, minimax: minimax, manus: manus, @@ -278,6 +286,7 @@ struct TokenAccountCLIContext { augment: augment, moonshot: moonshot, amp: amp, + zoommate: zoommate, commandcode: commandcode, ollama: ollama, jetbrains: jetbrains, diff --git a/Sources/CodexBarCore/CodexLocalDataScope.swift b/Sources/CodexBarCore/CodexLocalDataScope.swift new file mode 100644 index 000000000..b71c2b057 --- /dev/null +++ b/Sources/CodexBarCore/CodexLocalDataScope.swift @@ -0,0 +1,63 @@ +import Foundation + +/// Resolves all local Codex sources as one scope. A managed Codex home must +/// never silently borrow an ambient state database or cache identity. +struct CodexLocalDataScope: Sendable, Equatable { + let identifier: String + let codexHome: URL + let sessionsRoot: URL + let archivedSessionsRoot: URL + let stateDatabaseURL: URL + + static func resolve(options: CostUsageScanner.Options) -> CodexLocalDataScope { + if let sessionsRoot = options.codexSessionsRoot?.standardizedFileURL, + sessionsRoot.lastPathComponent == "sessions" + { + let home = sessionsRoot.deletingLastPathComponent() + return self.make(home: home) + } + + let environment = ProcessInfo.processInfo.environment + if let sqliteHome = Self.nonEmpty(environment["CODEX_SQLITE_HOME"]) { + let home = URL(fileURLWithPath: sqliteHome, isDirectory: true).standardizedFileURL + return self.make(home: home) + } + if let codexHome = Self.nonEmpty(environment["CODEX_HOME"]) { + let home = URL(fileURLWithPath: codexHome, isDirectory: true).standardizedFileURL + return self.make(home: home) + } + return self.make(home: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex")) + } + + func applying(to options: CostUsageScanner.Options) -> CostUsageScanner.Options { + var copy = options + copy.codexSessionsRoot = self.sessionsRoot + return copy + } + + private static func make(home: URL) -> CodexLocalDataScope { + let standardizedHome = home.standardizedFileURL + return CodexLocalDataScope( + identifier: "codex-workspaces:" + Self.scopeFingerprint(standardizedHome.path), + codexHome: standardizedHome, + sessionsRoot: standardizedHome.appendingPathComponent("sessions", isDirectory: true), + archivedSessionsRoot: standardizedHome.appendingPathComponent("archived_sessions", isDirectory: true), + stateDatabaseURL: standardizedHome.appendingPathComponent("state_5.sqlite", isDirectory: false)) + } + + /// Stable local identifier for cache partitioning. It is not a security + /// primitive; it only prevents raw home paths from entering cache metadata. + private static func scopeFingerprint(_ value: String) -> String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(hash, radix: 16) + } + + private static func nonEmpty(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } +} diff --git a/Sources/CodexBarCore/CodexLocalProjectRootResolver.swift b/Sources/CodexBarCore/CodexLocalProjectRootResolver.swift new file mode 100644 index 000000000..4314accfa --- /dev/null +++ b/Sources/CodexBarCore/CodexLocalProjectRootResolver.swift @@ -0,0 +1,64 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation + +public enum CodexLocalProjectRootResolver { + public typealias ProjectIdentity = (id: String, displayName: String, path: String?) + + public static let chatsProjectId = "chats" + public static let chatsDisplayName = "Chats" + + public static func projectIdentity(for cwd: String?) -> ProjectIdentity { + guard let cwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines), !cwd.isEmpty else { + return (self.chatsProjectId, self.chatsDisplayName, nil) + } + + let root = self.resolveProjectRoot(from: URL(fileURLWithPath: cwd, isDirectory: true)) + let canonicalRoot = self.canonicalProjectURL(root) + let path = canonicalRoot.path + return ( + self.projectId(for: path), + canonicalRoot.lastPathComponent.isEmpty ? path : canonicalRoot.lastPathComponent, + path) + } + + public static func resolveProjectRoot(from cwd: URL) -> URL { + let fm = FileManager.default + let loggedCWD = cwd.standardizedFileURL + var current = loggedCWD + var isDirectory: ObjCBool = false + let cwdExists = fm.fileExists(atPath: current.path, isDirectory: &isDirectory) + // A missing CWD is historical evidence. Do not walk up to an existing + // parent repository because that would collapse a deleted worktree or + // folder into a different project identity. + guard cwdExists else { return loggedCWD } + if cwdExists, !isDirectory.boolValue { + current = current.deletingLastPathComponent() + } + + var candidate: URL? = current + while let url = candidate { + let gitURL = url.appendingPathComponent(".git") + if fm.fileExists(atPath: gitURL.path) { + return url.standardizedFileURL + } + let parent = url.deletingLastPathComponent() + candidate = parent.path == url.path ? nil : parent + } + + return current.standardizedFileURL + } + + private static func canonicalProjectURL(_ url: URL) -> URL { + url.resolvingSymlinksInPath().standardizedFileURL + } + + public static func projectId(for path: String) -> String { + let digest = SHA256.hash(data: Data(path.utf8)) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return "project-\(hex.prefix(16))" + } +} diff --git a/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift b/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift new file mode 100644 index 000000000..3deb393dc --- /dev/null +++ b/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift @@ -0,0 +1,1182 @@ +import Foundation + +enum CodexLocalProjectUsageIndexer { + enum IndexError: Error, Equatable { + case cacheScopeMismatch + } + + struct Options: Sendable { + var scannerOptions: CostUsageScanner.Options + + init(scannerOptions: CostUsageScanner.Options = CostUsageScanner.Options()) { + self.scannerOptions = scannerOptions + } + } + + static func cachedSnapshot( + now: Date = Date(), + historyDays: Int = 30, + options: Options = Options()) -> CodexLocalProjectUsageSnapshot? + { + _ = now + let clampedHistoryDays = max(1, min(365, historyDays)) + let stableScopeSignature = self.stableScopeSignature(options: options.scannerOptions) + let sidecar = CodexWorkspaceUsageSidecar(cacheRoot: options.scannerOptions.cacheRoot) + let catalogResult = CodexThreadCatalogReader.loadResult(options: options.scannerOptions) + let sourceStatus = CodexLocalProjectUsageSourceStatus(catalog: catalogResult.completeness) + if let snapshot = sidecar.loadLatestSnapshot( + scopeSignature: stableScopeSignature, + historyDays: clampedHistoryDays, + catalog: catalogResult.isComplete ? catalogResult.catalog : nil) + { + return self.projecting(snapshot, sourceStatus: sourceStatus) + } + return nil + } + + static func loadSnapshot( + now: Date = Date(), + historyDays: Int = 30, + forceRefresh: Bool = false, + options: Options = Options(), + progress: (@Sendable (CodexLocalProjectUsageIndexProgress) -> Void)? = nil, + checkCancellation: CostUsageScanner.CancellationCheck? = nil) throws -> CodexLocalProjectUsageSnapshot + { + let refreshSignpost = CodexModelsTelemetry.begin("IndexRefresh") + defer { CodexModelsTelemetry.end("IndexRefresh", id: refreshSignpost) } + let clampedHistoryDays = max(1, min(365, historyDays)) + let until = now + let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now + let comparisonSince = self.modelsAnalyticsScanStart(since: since, until: until) + var scannerOptions = options.scannerOptions + if forceRefresh { + scannerOptions.refreshMinIntervalSeconds = 0 + } + + progress?(CodexLocalProjectUsageIndexProgress(phase: .scanningLogs)) + _ = try CostUsageScanner.loadDailyReportCancellable( + provider: .codex, + since: comparisonSince, + until: until, + now: now, + options: scannerOptions, + checkCancellation: checkCancellation) + try checkCancellation?() + + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: scannerOptions.cacheRoot) + let catalogResult = CodexThreadCatalogReader.loadResult(options: scannerOptions) + let catalog = catalogResult.catalog + let sourceStatus = CodexLocalProjectUsageSourceStatus(catalog: catalogResult.completeness) + let rootsFingerprint = self.rootsFingerprint(CostUsageScanner.codexRootsFingerprint(options: scannerOptions)) + let sidecar = CodexWorkspaceUsageSidecar(cacheRoot: scannerOptions.cacheRoot) + if !forceRefresh { + if let snapshot = sidecar.loadLatestSnapshot( + scopeSignature: self.stableScopeSignature(options: scannerOptions), + historyDays: clampedHistoryDays, + rootsFingerprint: rootsFingerprint, + cache: cache, + catalog: catalogResult.isComplete ? catalog : nil) + { + CodexModelsTelemetry.cacheHit(historyDays: clampedHistoryDays) + return self.projecting(snapshot, sourceStatus: sourceStatus) + } + } + + // Import only scanner-derived deltas, then aggregate from the + // sidecar's normalized rows. The raw cache remains the cursor and + // cumulative-token authority; it is no longer the aggregation source. + try sidecar.synchronizeSources( + cache: cache, + catalog: catalog, + catalogIsComplete: catalogResult.isComplete) + let sidecarCache = try sidecar.usageCache(roots: rootsFingerprint) + let snapshot = try self.buildSnapshotFromCostCache( + now: now, + historyDays: clampedHistoryDays, + since: since, + until: until, + options: scannerOptions, + cacheOverride: sidecarCache, + catalogOverride: catalog, + sourceStatus: sourceStatus, + progress: progress, + checkCancellation: checkCancellation) + progress?(CodexLocalProjectUsageIndexProgress(phase: .saving)) + try sidecar.synchronize( + snapshot: snapshot, + cache: cache, + catalog: catalog, + catalogIsComplete: catalogResult.isComplete, + rootsFingerprint: rootsFingerprint) + return snapshot + } + + private static func projecting( + _ snapshot: CodexLocalProjectUsageSnapshot, + sourceStatus: CodexLocalProjectUsageSourceStatus) -> CodexLocalProjectUsageSnapshot + { + guard snapshot.sourceStatus != sourceStatus else { return snapshot } + return CodexLocalProjectUsageSnapshot( + updatedAt: snapshot.updatedAt, + historyDays: snapshot.historyDays, + scopeSignature: snapshot.scopeSignature, + rootsFingerprint: snapshot.rootsFingerprint, + indexedFileCount: snapshot.indexedFileCount, + skippedFileCount: snapshot.skippedFileCount, + total: snapshot.total, + projects: snapshot.projects, + sessions: snapshot.sessions, + modelBreakdowns: snapshot.modelBreakdowns, + daily: snapshot.daily, + sourceStatus: sourceStatus, + modelsAnalytics: snapshot.modelsAnalytics) + } + + static func buildSnapshotFromCostCache( + now: Date = Date(), + historyDays: Int = 30, + since: Date, + until: Date, + options: CostUsageScanner.Options = CostUsageScanner.Options(), + cacheOverride: CostUsageCache? = nil, + catalogOverride: CodexThreadCatalog? = nil, + sourceStatus: CodexLocalProjectUsageSourceStatus = .complete, + progress: (@Sendable (CodexLocalProjectUsageIndexProgress) -> Void)? = nil, + checkCancellation: CostUsageScanner.CancellationCheck? = nil) throws -> CodexLocalProjectUsageSnapshot + { + let clampedHistoryDays = max(1, min(365, historyDays)) + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let cache = cacheOverride ?? CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) + let catalog = catalogOverride ?? CodexThreadCatalogReader.load(options: options) + let expectedRoots = CostUsageScanner.codexRootsFingerprint(options: options) + let scopeSignature = self.stableScopeSignature(options: options) + let rootsFingerprint = self.rootsFingerprint(expectedRoots) + + guard cache.roots == expectedRoots else { + // The cost cache belongs to a different Codex home. Publishing an + // empty replacement would erase a still-valid last-complete view. + throw IndexError.cacheScopeMismatch + } + + let indexed = try self.sessionBuckets( + from: cache, + range: range, + catalog: catalog, + progress: progress, + checkCancellation: checkCancellation) + let indexedFiles = indexed.indexedFiles + let skippedFiles = indexed.skippedFiles + let sessionBuckets = indexed.sessionBuckets + + let sessions = sessionBuckets.values.map { bucket in + CodexLocalSessionUsage( + id: bucket.id, + projectId: bucket.projectId, + displayTitle: self.sessionTitle( + explicitTitle: bucket.title, + startedAt: bucket.startedAt, + latestActivity: bucket.latestActivity, + model: bucket.topModel), + cwd: bucket.cwd, + startedAt: bucket.startedAt, + latestActivity: bucket.latestActivity, + totals: bucket.total, + costEstimate: CodexLocalCostEstimate( + knownUSD: self.usd(fromNanos: bucket.costNanos) ?? 0, + unknownTokens: bucket.unknownCostTokens), + topModel: bucket.displayModel, + daily: self.dailyPoints(from: bucket.dailyTotals)) + }.sorted { lhs, rhs in + self.sortSessions(lhs, rhs) + } + + let projectBuckets = Dictionary(grouping: sessionBuckets.values, by: \.projectId) + let rawProjects = projectBuckets.values.map { buckets in + let sortedSessions = buckets.map { bucket in + CodexLocalSessionUsage( + id: bucket.id, + projectId: bucket.projectId, + displayTitle: self.sessionTitle( + explicitTitle: bucket.title, + startedAt: bucket.startedAt, + latestActivity: bucket.latestActivity, + model: bucket.topModel), + cwd: bucket.cwd, + startedAt: bucket.startedAt, + latestActivity: bucket.latestActivity, + totals: bucket.total, + costEstimate: CodexLocalCostEstimate( + knownUSD: self.usd(fromNanos: bucket.costNanos) ?? 0, + unknownTokens: bucket.unknownCostTokens), + topModel: bucket.displayModel, + daily: self.dailyPoints(from: bucket.dailyTotals)) + }.sorted { lhs, rhs in + self.sortSessions(lhs, rhs) + } + var total = CodexLocalUsageTotals.empty + var costNanos: Int64? + var unknownCostTokens = 0 + var latestActivity: Date? + var modelTotals: [String: ModelTotals] = [:] + var dailyTotals: [String: DailyTotals] = [:] + for bucket in buckets { + total = total.adding(bucket.total) + costNanos = self.addCost(costNanos, bucket.costNanos) + unknownCostTokens += bucket.unknownCostTokens + latestActivity = self.later(latestActivity, bucket.latestActivity) + for (day, daily) in bucket.dailyTotals { + self.mergeDailyTotals(&dailyTotals, day: day, totals: daily) + } + for (model, totals) in bucket.modelTotals { + self.mergeModelTotals(&modelTotals, model: model, totals: totals) + } + } + let first = buckets[0] + return CodexLocalProjectUsage( + id: first.projectId, + displayName: first.projectDisplayName, + path: first.projectPath, + totals: total, + costEstimate: CodexLocalCostEstimate( + knownUSD: self.usd(fromNanos: costNanos) ?? 0, + unknownTokens: unknownCostTokens), + sessionCount: buckets.count, + latestActivity: latestActivity, + topModel: self.topModel(from: modelTotals), + topSessions: Array(sortedSessions.prefix(5)), + modelBreakdowns: self.modelBreakdowns(from: modelTotals), + daily: self.dailyPoints(from: dailyTotals)) + }.sorted { lhs, rhs in + self.sortProjects(lhs, rhs) + } + let projects = rawProjects + + return try CodexLocalProjectUsageSnapshot( + updatedAt: now, + historyDays: clampedHistoryDays, + scopeSignature: scopeSignature, + rootsFingerprint: rootsFingerprint, + indexedFileCount: indexedFiles, + skippedFileCount: skippedFiles, + total: self.total(from: projects), + projects: projects, + sessions: sessions, + modelBreakdowns: self.globalModelBreakdowns(from: sessionBuckets.values), + daily: self.dailyPoints(from: cache.files.values, range: range), + sourceStatus: sourceStatus, + modelsAnalytics: self.modelsAnalyticsPayload( + context: ModelsAnalyticsContext( + currentBuckets: sessionBuckets, + cache: cache, + catalog: catalog, + projects: projects, + sourceStatus: sourceStatus), + window: ModelsAnalyticsWindow( + since: since, + until: until, + historyDays: clampedHistoryDays, + generatedAt: now), + identity: ModelsAnalyticsIdentity( + scopeSignature: scopeSignature, + rootsFingerprint: rootsFingerprint), + checkCancellation: checkCancellation)) + } +} + +extension CodexLocalProjectUsageIndexer { + fileprivate struct ModelsAnalyticsContext { + let currentBuckets: [String: SessionBucket] + let cache: CostUsageCache + let catalog: CodexThreadCatalog + let projects: [CodexLocalProjectUsage] + let sourceStatus: CodexLocalProjectUsageSourceStatus + } + + fileprivate struct ModelsAnalyticsWindow { + let since: Date + let until: Date + let historyDays: Int + let generatedAt: Date + } + + fileprivate struct ModelsAnalyticsIdentity { + let scopeSignature: String + let rootsFingerprint: [String: Int64] + } + + fileprivate struct FileTotals { + var totals: CodexLocalUsageTotals + var costNanos: Int64? + var unknownCostTokens: Int + var modelTotals: [String: ModelTotals] + var dailyTotals: [String: DailyTotals] + var modelDailyTotals: [String: [String: ModelDailyTotals]] + var topModel: String? + } + + fileprivate struct DailyTotals { + var totalTokens: Int + var cachedInputTokens: Int + var costNanos: Int64? + var unknownCostTokens: Int + } + + fileprivate struct ModelTotals { + var totalTokens: Int + var costNanos: Int64? + var unknownCostTokens: Int + } + + fileprivate struct ModelDailyTotals { + var inputTokens: Int + var cachedInputTokens: Int + var outputTokens: Int + var costNanos: Int64? + var unknownCostTokens: Int + } + + fileprivate struct SessionBucket { + var id: String + var projectId: String + var projectDisplayName: String + var projectPath: String? + var cwd: String? + var title: String? + var startedAt: Date? + var latestActivity: Date? + var catalogModel: String? + var modelTotals: [String: ModelTotals] + var dailyTotals: [String: DailyTotals] + var modelDailyTotals: [String: [String: ModelDailyTotals]] + var usageRows: [CostUsageScanner.CodexUsageRow] + var hasCompleteEventRows: Bool + var total: CodexLocalUsageTotals + var costNanos: Int64? + var unknownCostTokens: Int + + var topModel: String? { + CodexLocalProjectUsageIndexer.topModel(from: self.modelTotals) + } + + var displayModel: String? { + self.catalogModel ?? self.topModel + } + } + + fileprivate struct Metadata { + var sessionId: String? + var cwd: String? + var title: String? + var startedAt: Date? + var latestActivity: Date? + } + + fileprivate struct BucketMergeInput { + var sessionId: String + var projectIdentity: CodexLocalProjectRootResolver.ProjectIdentity + var metadata: Metadata + var started: Date? + var latest: Date? + var catalogModel: String? + var model: String? + var fileTotals: FileTotals + var usageRows: [CostUsageScanner.CodexUsageRow] + var hasCompleteEventRows: Bool + } + + fileprivate static func sessionBuckets( + from cache: CostUsageCache, + range: CostUsageScanner.CostUsageDayRange, + catalog: CodexThreadCatalog, + progress: (@Sendable (CodexLocalProjectUsageIndexProgress) -> Void)?, + checkCancellation: CostUsageScanner.CancellationCheck?) + throws -> (sessionBuckets: [String: SessionBucket], indexedFiles: Int, skippedFiles: Int) + { + var indexedFiles = 0 + var skippedFiles = 0 + var sessionBuckets: [String: SessionBucket] = [:] + let files = cache.files.sorted(by: { $0.key < $1.key }).filter { + $0.value.touchesCodexScanWindow(sinceKey: range.sinceKey, untilKey: range.untilKey) + } + progress?(CodexLocalProjectUsageIndexProgress( + phase: .indexingProjects, + processedFileCount: 0, + totalFileCount: files.count)) + + for (offset, entry) in files.enumerated() { + try checkCancellation?() + let path = entry.key + let usage = entry.value + guard let fileTotals = self.fileTotals(from: usage, range: range) else { + skippedFiles += 1 + self.reportProgressIfNeeded( + progress, + processedFiles: offset + 1, + totalFiles: files.count, + indexedFiles: indexedFiles, + skippedFiles: skippedFiles) + continue + } + indexedFiles += 1 + let fileURL = URL(fileURLWithPath: path) + let cachedMetadata = self.metadata(from: usage, catalogEntry: nil) + let catalogEntry = catalog.entry( + sessionId: cachedMetadata.sessionId ?? usage.sessionId, + rolloutPath: path) + let metadata = self.metadata(from: usage, catalogEntry: catalogEntry) + let sessionId = metadata.sessionId ?? usage.sessionId ?? fileURL.deletingPathExtension().lastPathComponent + let projectIdentity = CodexLocalProjectRootResolver.projectIdentity(for: metadata.cwd) + let latest = metadata.latestActivity ?? self.latestDate(from: usage.days.keys) + let started = metadata.startedAt ?? self.earliestDate(from: usage.days.keys) + let model = catalogEntry?.model ?? usage.lastModel ?? fileTotals.topModel + sessionBuckets[sessionId] = self.mergedBucket( + sessionBuckets[sessionId], + input: BucketMergeInput( + sessionId: sessionId, + projectIdentity: projectIdentity, + metadata: metadata, + started: started, + latest: latest, + catalogModel: catalogEntry?.model, + model: model, + fileTotals: fileTotals, + usageRows: (usage.codexRows ?? []).filter { + CostUsageScanner.CostUsageDayRange.isInRange( + dayKey: $0.day, + since: range.sinceKey, + until: range.untilKey) + }, + hasCompleteEventRows: usage.days.isEmpty || !(usage.codexRows?.isEmpty ?? true) + && + (usage.codexRows? + .allSatisfy { $0.eventIndex != nil && $0.timestampUnixMs != nil } ?? false))) + self.reportProgressIfNeeded( + progress, + processedFiles: offset + 1, + totalFiles: files.count, + indexedFiles: indexedFiles, + skippedFiles: skippedFiles) + } + + return (sessionBuckets, indexedFiles, skippedFiles) + } + + fileprivate static func reportProgressIfNeeded( + _ progress: (@Sendable (CodexLocalProjectUsageIndexProgress) -> Void)?, + processedFiles: Int, + totalFiles: Int, + indexedFiles: Int, + skippedFiles: Int) + { + guard processedFiles == 1 || processedFiles == totalFiles || processedFiles.isMultiple(of: 25) else { + return + } + progress?(CodexLocalProjectUsageIndexProgress( + phase: .indexingProjects, + processedFileCount: processedFiles, + totalFileCount: totalFiles, + indexedFileCount: indexedFiles, + skippedFileCount: skippedFiles)) + } + + fileprivate static func mergedBucket( + _ current: SessionBucket?, + input: BucketMergeInput) -> SessionBucket + { + var bucket = current ?? SessionBucket( + id: input.sessionId, + projectId: input.projectIdentity.id, + projectDisplayName: input.projectIdentity.displayName, + projectPath: input.projectIdentity.path, + cwd: input.metadata.cwd, + title: input.metadata.title, + startedAt: input.started, + latestActivity: input.latest, + catalogModel: input.catalogModel, + modelTotals: [:], + dailyTotals: [:], + modelDailyTotals: [:], + usageRows: [], + hasCompleteEventRows: true, + total: .empty, + costNanos: nil, + unknownCostTokens: 0) + if self.shouldUseProjectIdentity(input.projectIdentity, over: bucket, latestActivity: input.latest) { + bucket.projectId = input.projectIdentity.id + bucket.projectDisplayName = input.projectIdentity.displayName + bucket.projectPath = input.projectIdentity.path + } + bucket.cwd = bucket.cwd ?? input.metadata.cwd + bucket.title = input.metadata.title ?? bucket.title + bucket.catalogModel = input.catalogModel ?? bucket.catalogModel + bucket.startedAt = self.earlier(bucket.startedAt, input.started) + bucket.latestActivity = self.later(bucket.latestActivity, input.latest) + bucket.total = bucket.total.adding(input.fileTotals.totals) + bucket.usageRows.append(contentsOf: input.usageRows) + bucket.hasCompleteEventRows = bucket.hasCompleteEventRows && input.hasCompleteEventRows + bucket.costNanos = self.addCost(bucket.costNanos, input.fileTotals.costNanos) + bucket.unknownCostTokens += input.fileTotals.unknownCostTokens + for (modelName, totals) in input.fileTotals.modelTotals { + self.mergeModelTotals(&bucket.modelTotals, model: modelName, totals: totals) + } + for (day, totals) in input.fileTotals.dailyTotals { + self.mergeDailyTotals(&bucket.dailyTotals, day: day, totals: totals) + } + for (day, models) in input.fileTotals.modelDailyTotals { + for (model, totals) in models { + self.mergeModelDailyTotals(&bucket.modelDailyTotals, day: day, model: model, totals: totals) + } + } + if let model = input.model, !model.isEmpty, bucket.modelTotals[model] == nil { + bucket.modelTotals[model] = ModelTotals(totalTokens: 0, costNanos: nil, unknownCostTokens: 0) + } + return bucket + } + + fileprivate static func shouldUseProjectIdentity( + _ identity: CodexLocalProjectRootResolver.ProjectIdentity, + over bucket: SessionBucket, + latestActivity: Date?) -> Bool + { + guard identity.id != CodexLocalProjectRootResolver.chatsProjectId else { + return bucket.projectId == CodexLocalProjectRootResolver.chatsProjectId + } + guard bucket.projectId != CodexLocalProjectRootResolver.chatsProjectId else { + return true + } + let currentLatest = bucket.latestActivity ?? .distantPast + let incomingLatest = latestActivity ?? .distantPast + return incomingLatest >= currentLatest + } + + fileprivate static func fileTotals( + from usage: CostUsageFileUsage, + range: CostUsageScanner.CostUsageDayRange) -> FileTotals? + { + var input = 0 + var cached = 0 + var output = 0 + var costNanos: Int64? + var unknownCostTokens = 0 + var modelTotals: [String: ModelTotals] = [:] + var dailyTotals: [String: DailyTotals] = [:] + var modelDailyTotals: [String: [String: ModelDailyTotals]] = [:] + + for (day, models) in usage.days where CostUsageScanner.CostUsageDayRange + .isInRange(dayKey: day, since: range.sinceKey, until: range.untilKey) + { + for (model, values) in models { + let modelInput = max(0, values[safe: 0] ?? 0) + let modelCached = max(0, values[safe: 1] ?? 0) + let modelOutput = max(0, values[safe: 2] ?? 0) + let modelTotal = modelInput + modelOutput + guard modelTotal > 0 else { continue } + input += modelInput + cached += min(modelCached, modelInput) + output += modelOutput + let modelCostNanos = usage.codexCostNanos?[day]?[model] + let modelUnknownCostTokens = modelCostNanos == nil ? modelTotal : 0 + self.addModelTotals( + &modelTotals, + model: model, + tokens: modelTotal, + costNanos: modelCostNanos, + unknownCostTokens: modelUnknownCostTokens) + costNanos = self.addCost(costNanos, modelCostNanos) + unknownCostTokens += modelUnknownCostTokens + self.addDailyTotals( + &dailyTotals, + day: day, + totals: DailyTotals( + totalTokens: modelTotal, + cachedInputTokens: min(modelCached, modelInput), + costNanos: modelCostNanos, + unknownCostTokens: modelUnknownCostTokens)) + self.mergeModelDailyTotals( + &modelDailyTotals, + day: day, + model: model, + totals: ModelDailyTotals( + inputTokens: modelInput, + cachedInputTokens: min(modelCached, modelInput), + outputTokens: modelOutput, + costNanos: modelCostNanos, + unknownCostTokens: modelUnknownCostTokens)) + } + } + + guard input + output > 0 else { return nil } + return FileTotals( + totals: CodexLocalUsageTotals( + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + reasoningOutputTokens: nil, + totalTokens: input + output), + costNanos: costNanos, + unknownCostTokens: unknownCostTokens, + modelTotals: modelTotals, + dailyTotals: dailyTotals, + modelDailyTotals: modelDailyTotals, + topModel: self.topModel(from: modelTotals)) + } + + static func modelsAnalyticsPeriods( + since: Date, + until: Date, + calendar: Calendar = .current) -> CodexModelsAnalyticsPeriods + { + let currentStart = calendar.startOfDay(for: since) + let currentEnd = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: until)) ?? until + let current = DateInterval(start: currentStart, end: currentEnd) + let previousEnd = current.start + let previousStart = previousEnd.addingTimeInterval(-current.duration) + return CodexModelsAnalyticsPeriods( + current: current, + previous: DateInterval(start: previousStart, end: previousEnd)) + } + + static func modelsAnalyticsScanStart( + since: Date, + until: Date, + calendar: Calendar = .current) -> Date + { + let periods = self.modelsAnalyticsPeriods(since: since, until: until, calendar: calendar) + return calendar.startOfDay(for: periods.previous.start) + } + + fileprivate static func modelsAnalyticsPayload( + context: ModelsAnalyticsContext, + window: ModelsAnalyticsWindow, + identity: ModelsAnalyticsIdentity, + checkCancellation: CostUsageScanner.CancellationCheck?) throws -> CodexModelsAnalyticsPayload + { + let aggregationSignpost = CodexModelsTelemetry.begin("SnapshotAggregation") + defer { CodexModelsTelemetry.end("SnapshotAggregation", id: aggregationSignpost) } + let periods = self.modelsAnalyticsPeriods(since: window.since, until: window.until) + let previousInterval = periods.previous + let previousRange = CostUsageScanner.CostUsageDayRange( + since: previousInterval.start, + until: previousInterval.end.addingTimeInterval(-1)) + let previousBuckets = try self.sessionBuckets( + from: context.cache, + range: previousRange, + catalog: context.catalog, + progress: nil, + checkCancellation: checkCancellation).sessionBuckets + let currentFragments = self.analyticsFragments(from: context.currentBuckets.values) + let previousFragments = self.analyticsFragments(from: previousBuckets.values) + let currentBucketsByProject = Dictionary(grouping: context.currentBuckets.values, by: \.projectId) + let previousBucketsByProject = Dictionary(grouping: previousBuckets.values, by: \.projectId) + let currentFragmentsByProject = Dictionary(grouping: currentFragments, by: \.workspaceID) + let previousFragmentsByProject = Dictionary(grouping: previousFragments, by: \.workspaceID) + let revisionParts = identity.rootsFingerprint.sorted { $0.key < $1.key }.map { "\($0.key)=\($0.value)" } + let indexRevision = ([ + identity.scopeSignature, + context.cache.producerKey ?? "", + context.cache.codexPricingKey ?? "", + ] + revisionParts) + .joined(separator: "|") + let builder = CodexModelsAnalyticsBuilder() + let source = CodexModelsAnalyticsSource(current: currentFragments, previous: previousFragments) + let revision = CodexModelsAnalyticsRevision(generatedAt: window.generatedAt, indexRevision: indexRevision) + // Catalog failures can make workspace attribution incomplete, while aggregate-only + // cache rows cannot prove exact timestamp-boundary coverage. Treat either condition + // conservatively so an observed partial period is never presented as a full comparison. + let metadataIsComplete = context.sourceStatus == .complete + let currentIsComplete = metadataIsComplete + && context.currentBuckets.values.allSatisfy(\.hasCompleteEventRows) + let previousIsComplete = metadataIsComplete + && previousBuckets.values.allSatisfy(\.hasCompleteEventRows) + let all = builder.build(CodexModelsAnalyticsRequest( + source: source, + scopeID: nil, + periods: periods, + revision: revision, + legacy: self.legacyBaseline( + current: Array(context.currentBuckets.values), + previous: Array(previousBuckets.values)), + currentIsComplete: currentIsComplete, + previousIsComplete: previousIsComplete)) + + let workspaces = Dictionary(uniqueKeysWithValues: context.projects.map { project in + let projectBuckets = currentBucketsByProject[project.id] ?? [] + let previousProjectBuckets = previousBucketsByProject[project.id] ?? [] + let currentProjectIsComplete = metadataIsComplete + && projectBuckets.allSatisfy(\.hasCompleteEventRows) + let previousProjectIsComplete = metadataIsComplete + && previousProjectBuckets.allSatisfy(\.hasCompleteEventRows) + return ( + project.id, + builder.build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource( + current: currentFragmentsByProject[project.id] ?? [], + previous: previousFragmentsByProject[project.id] ?? []), + scopeID: project.id, + periods: periods, + revision: revision, + legacy: self.legacyBaseline( + current: Array(projectBuckets), + previous: Array(previousProjectBuckets)), + currentIsComplete: currentProjectIsComplete, + previousIsComplete: previousProjectIsComplete))) + }) + CodexModelsTelemetry.parity( + dimensions: all.diagnostics.mismatchDimensions ?? [], + rowCount: all.rows.count) + return CodexModelsAnalyticsPayload(allWorkspaces: all, workspaces: workspaces) + } + + fileprivate static func legacyBaseline( + current: [SessionBucket], + previous: [SessionBucket]) -> CodexModelsLegacyBaseline + { + struct LegacyModelAggregates { + var tokens: Int64 = 0 + var costNanos: Int64? + var unpricedTokens: Int64 = 0 + var sessionIDs: Set = [] + } + + struct LegacyAggregates { + let tokens: Int64 + let cost: Decimal + let pricedTokens: Int64 + let unpricedTokens: Int64 + let modelIDs: [String] + let topModelID: String? + let sessionReferences: Int + let models: [CodexModelsLegacyModelBaseline] + } + + func aggregates(_ buckets: [SessionBucket]) -> LegacyAggregates { + let tokens = Int64(buckets.reduce(0) { $0 + ($1.total.totalTokens ?? 0) }) + let costNanos = buckets.reduce(Int64.zero) { $0 + ($1.costNanos ?? 0) } + let unpricedTokens = Int64(buckets.reduce(0) { $0 + $1.unknownCostTokens }) + var models: [String: LegacyModelAggregates] = [:] + for bucket in buckets { + for (model, totals) in bucket.modelTotals { + guard totals.totalTokens > 0 else { continue } + let canonical = CostUsagePricing.normalizeCodexModel( + model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + var values = models[canonical, default: LegacyModelAggregates()] + values.tokens += Int64(totals.totalTokens) + values.costNanos = self.addCost(values.costNanos, totals.costNanos) + values.unpricedTokens += Int64(totals.unknownCostTokens) + values.sessionIDs.insert(bucket.id) + models[canonical] = values + } + } + let modelIDs = models.keys.sorted() + let topModelID = models.max { + if $0.value.tokens != $1.value.tokens { + return $0.value.tokens < $1.value.tokens + } + return $0.key > $1.key + }?.key + let modelBaselines = models.sorted { $0.key < $1.key }.map { modelID, values in + CodexModelsLegacyModelBaseline( + modelID: modelID, + totalTokens: values.tokens, + knownCost: values.costNanos.map { Decimal($0) / 1_000_000_000 }, + pricedTokens: max(0, values.tokens - values.unpricedTokens), + unpricedTokens: values.unpricedTokens, + sessionReferences: values.sessionIDs.count) + } + return LegacyAggregates( + tokens: tokens, + cost: Decimal(costNanos) / 1_000_000_000, + pricedTokens: max(0, tokens - unpricedTokens), + unpricedTokens: unpricedTokens, + modelIDs: modelIDs, + topModelID: topModelID, + sessionReferences: models.values.reduce(0) { $0 + $1.sessionIDs.count }, + models: modelBaselines) + } + + let currentValues = aggregates(current) + let previousValues = aggregates(previous) + return CodexModelsLegacyBaseline( + totalTokens: currentValues.tokens, + modelIDs: currentValues.modelIDs, + knownCost: currentValues.cost, + pricedTokens: currentValues.pricedTokens, + unpricedTokens: currentValues.unpricedTokens, + activeModelCount: currentValues.modelIDs.count, + topModelID: currentValues.topModelID, + sessionReferenceTotal: currentValues.sessionReferences, + previousTotalTokens: previousValues.tokens, + previousKnownCost: previousValues.cost, + previousUnpricedTokens: previousValues.unpricedTokens, + previousSessionReferenceTotal: previousValues.sessionReferences, + currentModels: currentValues.models, + previousModels: previousValues.models) + } + + fileprivate static func analyticsFragments( + from buckets: Dictionary.Values) -> [CodexModelsUsageFragment] + { + buckets.flatMap { bucket in + if bucket.hasCompleteEventRows, !bucket.usageRows.isEmpty { + return bucket.usageRows.compactMap { row -> CodexModelsUsageFragment? in + guard let timestampUnixMs = row.timestampUnixMs else { return nil } + let timestamp = Date(timeIntervalSince1970: Double(timestampUnixMs) / 1000) + let day = Calendar.current.startOfDay(for: timestamp) + let inputTokens = max(0, row.input) + let outputTokens = max(0, row.output) + let totalTokens = Int64(inputTokens + outputTokens) + return CodexModelsUsageFragment( + workspaceID: bucket.projectId, + sessionID: bucket.id, + day: day, + timestamp: timestamp, + rawModelID: row.rawModel ?? row.model, + inputTokens: Int64(inputTokens), + cachedInputTokens: Int64(max(0, row.cached)), + outputTokens: Int64(outputTokens), + reasoningTokens: row.reasoning.map(Int64.init), + costNanos: row.knownCostNanos, + unpricedTokens: row.unpricedTokens.map(Int64.init) + ?? (row.knownCostNanos == nil ? totalTokens : 0)) + } + } + return bucket.modelDailyTotals.flatMap { day, models -> [CodexModelsUsageFragment] in + guard let date = CostUsageDateParser.parse(day) else { return [] } + return models.map { model, totals in + CodexModelsUsageFragment( + workspaceID: bucket.projectId, + sessionID: bucket.id, + day: date, + rawModelID: model, + inputTokens: Int64(totals.inputTokens), + cachedInputTokens: Int64(totals.cachedInputTokens), + outputTokens: Int64(totals.outputTokens), + reasoningTokens: nil, + costNanos: totals.costNanos, + unpricedTokens: Int64(totals.unknownCostTokens)) + } + } + } + } + + fileprivate static func metadata( + from usage: CostUsageFileUsage, + catalogEntry: CodexThreadCatalogEntry?) -> Metadata + { + let session = usage.codexSession + return Metadata( + sessionId: catalogEntry?.id ?? session?.sessionId ?? usage.sessionId, + cwd: catalogEntry?.cwd ?? session?.cwd, + title: catalogEntry?.title ?? catalogEntry?.preview ?? session?.title, + startedAt: self.date(fromUnixMilliseconds: catalogEntry?.createdAtUnixMs ?? session?.startedAtUnixMs), + latestActivity: self + .date(fromUnixMilliseconds: catalogEntry?.updatedAtUnixMs ?? session?.latestActivityUnixMs)) + } + + fileprivate static func date(fromUnixMilliseconds unixMs: Int64?) -> Date? { + guard let unixMs else { return nil } + return Date(timeIntervalSince1970: Double(unixMs) / 1000) + } + + fileprivate static func scopeSignature( + options: CostUsageScanner.Options, + cache: CostUsageCache, + catalogFingerprint: String? = nil) -> String + { + let roots = self.rootsFingerprint(CostUsageScanner.codexRootsFingerprint(options: options)) + var parts = roots.sorted { $0.key < $1.key }.map { "root:\($0.key)=\($0.value)" } + parts.append("producer=\(cache.producerKey ?? "")") + parts.append("pricing=\(cache.codexPricingKey ?? "")") + parts.append("priorityMetadata=\(cache.codexPriorityMetadataKey ?? "")") + if let catalogFingerprint { + parts.append("catalog=\(catalogFingerprint)") + } + return "codex-local-project:" + parts.joined(separator: "|") + } + + fileprivate static func stableScopeSignature(options: CostUsageScanner.Options) -> String { + CodexLocalDataScope.resolve(options: options).identifier + } + + fileprivate static func rootsFingerprint(_ roots: [String: Int64]) -> [String: Int64] { + Dictionary(uniqueKeysWithValues: roots.sorted { $0.key < $1.key }) + } + + fileprivate static func total(from projects: [CodexLocalProjectUsage]) -> CodexLocalUsageTotals { + projects.reduce(.empty) { partial, project in + partial.adding(project.totals) + } + } + + fileprivate static func dailyPoints( + from usages: Dictionary.Values, + range: CostUsageScanner.CostUsageDayRange) -> [CodexLocalUsageDailyPoint] + { + var buckets: [String: (tokens: Int, cachedInputTokens: Int, costNanos: Int64?)] = [:] + for usage in usages { + for (day, models) in usage.days where CostUsageScanner.CostUsageDayRange + .isInRange(dayKey: day, since: range.sinceKey, until: range.untilKey) + { + var bucket = buckets[day] ?? (0, 0, nil) + for (model, values) in models { + let input = max(0, values[safe: 0] ?? 0) + let cached = min(max(0, values[safe: 1] ?? 0), input) + let tokens = input + max(0, values[safe: 2] ?? 0) + bucket.tokens += tokens + bucket.cachedInputTokens += cached + if let nanos = usage.codexCostNanos?[day]?[model] { + bucket.costNanos = self.addCost(bucket.costNanos, nanos) + } + } + buckets[day] = bucket + } + } + return buckets.sorted { $0.key < $1.key }.map { + CodexLocalUsageDailyPoint( + day: $0.key, + totalTokens: $0.value.tokens, + cachedInputTokens: $0.value.cachedInputTokens, + estimatedCostUSD: self.usd(fromNanos: $0.value.costNanos)) + } + } + + fileprivate static func dailyPoints(from totals: [String: DailyTotals]) -> [CodexLocalUsageDailyPoint] { + totals.sorted { $0.key < $1.key }.map { + CodexLocalUsageDailyPoint( + day: $0.key, + totalTokens: $0.value.totalTokens, + cachedInputTokens: $0.value.cachedInputTokens, + estimatedCostUSD: self.usd(fromNanos: $0.value.costNanos)) + } + } + + fileprivate static func modelBreakdowns(from modelTotals: [String: ModelTotals]) + -> [CodexLocalUsageModelBreakdown] { + modelTotals.sorted { + if $0.value.totalTokens != $1.value.totalTokens { + return $0.value.totalTokens > $1.value.totalTokens + } + return $0.key < $1.key + }.map { + CodexLocalUsageModelBreakdown( + model: $0.key, + totals: CodexLocalUsageTotals( + inputTokens: nil, + cachedInputTokens: nil, + outputTokens: nil, + reasoningOutputTokens: nil, + totalTokens: $0.value.totalTokens), + costEstimate: CodexLocalCostEstimate( + knownUSD: self.usd(fromNanos: $0.value.costNanos) ?? 0, + unknownTokens: $0.value.unknownCostTokens)) + } + } + + fileprivate static func globalModelBreakdowns( + from sessions: Dictionary.Values) -> [CodexLocalUsageModelBreakdown] + { + var modelTotals: [String: ModelTotals] = [:] + for session in sessions { + for (model, totals) in session.modelTotals { + self.mergeModelTotals(&modelTotals, model: model, totals: totals) + } + } + return self.modelBreakdowns(from: modelTotals) + } + + fileprivate static func topModel(from modelTotals: [String: ModelTotals]) -> String? { + modelTotals.max { + if $0.value.totalTokens != $1.value.totalTokens { + return $0.value.totalTokens < $1.value.totalTokens + } + return $0.key > $1.key + }?.key + } + + fileprivate static func addModelTotals( + _ modelTotals: inout [String: ModelTotals], + model: String, + tokens: Int, + costNanos: Int64?, + unknownCostTokens: Int) + { + self.mergeModelTotals( + &modelTotals, + model: model, + totals: ModelTotals( + totalTokens: tokens, + costNanos: costNanos, + unknownCostTokens: unknownCostTokens)) + } + + fileprivate static func addDailyTotals( + _ dailyTotals: inout [String: DailyTotals], + day: String, + totals: DailyTotals) + { + self.mergeDailyTotals( + &dailyTotals, + day: day, + totals: totals) + } + + fileprivate static func mergeDailyTotals( + _ dailyTotals: inout [String: DailyTotals], + day: String, + totals: DailyTotals) + { + var current = dailyTotals[day] ?? DailyTotals( + totalTokens: 0, + cachedInputTokens: 0, + costNanos: nil, + unknownCostTokens: 0) + current.totalTokens += totals.totalTokens + current.cachedInputTokens += totals.cachedInputTokens + current.costNanos = self.addCost(current.costNanos, totals.costNanos) + current.unknownCostTokens += totals.unknownCostTokens + dailyTotals[day] = current + } + + fileprivate static func mergeModelDailyTotals( + _ modelDailyTotals: inout [String: [String: ModelDailyTotals]], + day: String, + model: String, + totals: ModelDailyTotals) + { + var current = modelDailyTotals[day]?[model] ?? ModelDailyTotals( + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + costNanos: nil, + unknownCostTokens: 0) + current.inputTokens += totals.inputTokens + current.cachedInputTokens += totals.cachedInputTokens + current.outputTokens += totals.outputTokens + current.costNanos = self.addCost(current.costNanos, totals.costNanos) + current.unknownCostTokens += totals.unknownCostTokens + modelDailyTotals[day, default: [:]][model] = current + } + + fileprivate static func mergeModelTotals( + _ modelTotals: inout [String: ModelTotals], + model: String, + totals: ModelTotals) + { + var existing = modelTotals[model] ?? ModelTotals(totalTokens: 0, costNanos: nil, unknownCostTokens: 0) + existing.totalTokens += totals.totalTokens + existing.costNanos = self.addCost(existing.costNanos, totals.costNanos) + existing.unknownCostTokens += totals.unknownCostTokens + modelTotals[model] = existing + } + + fileprivate static func sortProjects(_ lhs: CodexLocalProjectUsage, _ rhs: CodexLocalProjectUsage) -> Bool { + let lTokens = lhs.totals.totalTokens ?? -1 + let rTokens = rhs.totals.totalTokens ?? -1 + if lTokens != rTokens { + return lTokens > rTokens + } + let lCost = lhs.estimatedCostUSD ?? -1 + let rCost = rhs.estimatedCostUSD ?? -1 + if lCost != rCost { + return lCost > rCost + } + if lhs.sessionCount != rhs.sessionCount { + return lhs.sessionCount > rhs.sessionCount + } + if lhs.latestActivity != rhs.latestActivity { + return (lhs.latestActivity ?? .distantPast) > (rhs.latestActivity ?? .distantPast) + } + return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending + } + + fileprivate static func sortSessions(_ lhs: CodexLocalSessionUsage, _ rhs: CodexLocalSessionUsage) -> Bool { + let lTokens = lhs.totals.totalTokens ?? -1 + let rTokens = rhs.totals.totalTokens ?? -1 + if lTokens != rTokens { + return lTokens > rTokens + } + let lCost = lhs.estimatedCostUSD ?? -1 + let rCost = rhs.estimatedCostUSD ?? -1 + if lCost != rCost { + return lCost > rCost + } + let lDate = lhs.latestActivity ?? .distantPast + let rDate = rhs.latestActivity ?? .distantPast + if lDate != rDate { + return lDate > rDate + } + return lhs.id < rhs.id + } + + fileprivate static func sessionTitle( + explicitTitle: String?, + startedAt: Date?, + latestActivity: Date?, + model: String?) -> String + { + if let explicitTitle = explicitTitle?.trimmingCharacters(in: .whitespacesAndNewlines), + !explicitTitle.isEmpty + { + return explicitTitle + } + let date = latestActivity ?? startedAt + var parts: [String] = [] + if let date { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateStyle = .medium + formatter.timeStyle = .short + parts.append(formatter.string(from: date)) + } + if let model, !model.isEmpty { + parts.append(model) + } + // Core stores a semantic fallback title, never a UI localization key. + return parts.isEmpty ? CodexLocalSessionUsage.localChatFallbackTitle : parts.joined(separator: " · ") + } + + fileprivate static func latestDate(from dayKeys: Dictionary.Keys) -> Date? { + dayKeys.compactMap(CostUsageDateParser.parse).max() + } + + fileprivate static func earliestDate(from dayKeys: Dictionary.Keys) -> Date? { + dayKeys.compactMap(CostUsageDateParser.parse).min() + } + + fileprivate static func addCost(_ lhs: Int64?, _ rhs: Int64?) -> Int64? { + guard let rhs else { return lhs } + guard let lhs else { return rhs } + return lhs + rhs + } + + fileprivate static func earlier(_ lhs: Date?, _ rhs: Date?) -> Date? { + switch (lhs, rhs) { + case let (lhs?, rhs?): + min(lhs, rhs) + case let (lhs?, nil): + lhs + case let (nil, rhs?): + rhs + case (nil, nil): + nil + } + } + + fileprivate static func later(_ lhs: Date?, _ rhs: Date?) -> Date? { + switch (lhs, rhs) { + case let (lhs?, rhs?): + max(lhs, rhs) + case let (lhs?, nil): + lhs + case let (nil, rhs?): + rhs + case (nil, nil): + nil + } + } + + fileprivate static func usd(fromNanos nanos: Int64?) -> Double? { + guard let nanos else { return nil } + return Double(nanos) / 1_000_000_000 + } +} diff --git a/Sources/CodexBarCore/CodexLocalProjectUsageModels.swift b/Sources/CodexBarCore/CodexLocalProjectUsageModels.swift new file mode 100644 index 000000000..c32687bf7 --- /dev/null +++ b/Sources/CodexBarCore/CodexLocalProjectUsageModels.swift @@ -0,0 +1,676 @@ +import Foundation + +public struct CodexLocalProjectUsageIndexProgress: Sendable, Equatable { + public enum Phase: Sendable, Equatable { + case scanningLogs + case indexingProjects + case saving + } + + public let phase: Phase + public let processedFileCount: Int? + public let totalFileCount: Int? + public let indexedFileCount: Int + public let skippedFileCount: Int + + public init( + phase: Phase, + processedFileCount: Int? = nil, + totalFileCount: Int? = nil, + indexedFileCount: Int = 0, + skippedFileCount: Int = 0) + { + self.phase = phase + self.processedFileCount = processedFileCount + self.totalFileCount = totalFileCount + self.indexedFileCount = indexedFileCount + self.skippedFileCount = skippedFileCount + } +} + +public struct CodexLocalProjectUsageSnapshot: Sendable, Codable, Equatable { + public let updatedAt: Date + public let historyDays: Int + public let scopeSignature: String + public let rootsFingerprint: [String: Int64] + public let indexedFileCount: Int + public let skippedFileCount: Int + public let total: CodexLocalUsageTotals + public let projects: [CodexLocalProjectUsage] + public let sessions: [CodexLocalSessionUsage] + public let modelBreakdowns: [CodexLocalUsageModelBreakdown] + public let daily: [CodexLocalUsageDailyPoint] + public let sourceStatus: CodexLocalProjectUsageSourceStatus + public let modelsAnalytics: CodexModelsAnalyticsPayload? + + private enum CodingKeys: String, CodingKey { + case updatedAt + case historyDays + case scopeSignature + case rootsFingerprint + case indexedFileCount + case skippedFileCount + case total + case projects + case sessions + case modelBreakdowns + case daily + case sourceStatus + case modelsAnalytics + } + + public init( + updatedAt: Date, + historyDays: Int, + scopeSignature: String, + rootsFingerprint: [String: Int64], + indexedFileCount: Int, + skippedFileCount: Int, + total: CodexLocalUsageTotals, + projects: [CodexLocalProjectUsage], + sessions: [CodexLocalSessionUsage] = [], + modelBreakdowns: [CodexLocalUsageModelBreakdown] = [], + daily: [CodexLocalUsageDailyPoint], + sourceStatus: CodexLocalProjectUsageSourceStatus = .complete, + modelsAnalytics: CodexModelsAnalyticsPayload? = nil) + { + self.updatedAt = updatedAt + self.historyDays = historyDays + self.scopeSignature = scopeSignature + self.rootsFingerprint = rootsFingerprint + self.indexedFileCount = indexedFileCount + self.skippedFileCount = skippedFileCount + self.total = total + self.projects = projects + self.sessions = sessions + self.modelBreakdowns = modelBreakdowns + self.daily = daily + self.sourceStatus = sourceStatus + self.modelsAnalytics = modelsAnalytics + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Self.CodingKeys.self) + self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) + self.historyDays = try container.decode(Int.self, forKey: .historyDays) + self.scopeSignature = try container.decode(String.self, forKey: .scopeSignature) + self.rootsFingerprint = try container.decode([String: Int64].self, forKey: .rootsFingerprint) + self.indexedFileCount = try container.decode(Int.self, forKey: .indexedFileCount) + self.skippedFileCount = try container.decode(Int.self, forKey: .skippedFileCount) + self.total = try container.decode(CodexLocalUsageTotals.self, forKey: .total) + self.projects = try container.decode([CodexLocalProjectUsage].self, forKey: .projects) + self.sessions = try container.decodeIfPresent([CodexLocalSessionUsage].self, forKey: .sessions) ?? [] + self.modelBreakdowns = try container.decodeIfPresent( + [CodexLocalUsageModelBreakdown].self, + forKey: .modelBreakdowns) ?? [] + self.daily = try container.decode([CodexLocalUsageDailyPoint].self, forKey: .daily) + self.sourceStatus = try container.decodeIfPresent( + CodexLocalProjectUsageSourceStatus.self, + forKey: .sourceStatus) ?? .complete + self.modelsAnalytics = try container.decodeIfPresent( + CodexModelsAnalyticsPayload.self, + forKey: .modelsAnalytics) + } + + /// An aggregate without per-project detail cannot render the inspector + /// truthfully. It should trigger a sidecar-backed refresh instead of + /// publishing empty charts and session lists beside valid totals. + public var hasInspectorDetail: Bool { + let projectsAreComplete = self.projects.allSatisfy { project in + guard (project.totals.totalTokens ?? 0) > 0 else { return true } + return !project.daily.isEmpty && (project.sessionCount == 0 || !project.topSessions.isEmpty) + } + let sessionsAreComplete = self.sessions.allSatisfy { session in + guard (session.totals.totalTokens ?? 0) > 0 else { return true } + return !session.daily.isEmpty + } + return projectsAreComplete && sessionsAreComplete + } +} + +extension CodexLocalProjectUsageSnapshot { + func hidingPersonalInformation(_ isEnabled: Bool) -> Self { + guard isEnabled else { return self } + return Self( + updatedAt: self.updatedAt, + historyDays: self.historyDays, + scopeSignature: self.scopeSignature, + rootsFingerprint: [:], + indexedFileCount: self.indexedFileCount, + skippedFileCount: self.skippedFileCount, + total: self.total, + projects: self.projects.map { $0.hidingPersonalInformation() }, + sessions: self.sessions.map { $0.hidingPersonalInformation() }, + modelBreakdowns: self.modelBreakdowns, + daily: self.daily, + sourceStatus: self.sourceStatus, + modelsAnalytics: self.modelsAnalytics) + } +} + +public enum CodexLocalProjectUsageSourceStatus: String, Sendable, Codable, Equatable { + case complete + case catalogMissing + case catalogLocked + case catalogCorrupt + case catalogIncompatible + case catalogUnreadable + + public var isPartial: Bool { + self != .complete + } + + init(catalog: CodexThreadCatalogCompleteness) { + switch catalog { + case .complete: + self = .complete + case let .unavailable(failure): + switch failure { + case .missing: self = .catalogMissing + case .locked: self = .catalogLocked + case .corrupt: self = .catalogCorrupt + case .incompatible: self = .catalogIncompatible + case .unreadable: self = .catalogUnreadable + } + } + } +} + +public enum CodexLocalUsageSeverity: String, Sendable, Codable, Equatable { + case normal + case elevated + case high +} + +public enum CodexLocalCostCoverage: String, Sendable, Codable, Equatable { + case known + case partial + case unavailable +} + +public struct CodexLocalCostEstimate: Sendable, Codable, Equatable { + public let knownUSD: Double + public let unknownTokens: Int + + public init(knownUSD: Double = 0, unknownTokens: Int = 0) { + self.knownUSD = max(0, knownUSD) + self.unknownTokens = max(0, unknownTokens) + } + + public var coverage: CodexLocalCostCoverage { + if self.unknownTokens == 0 { + return .known + } + return self.knownUSD > 0 ? .partial : .unavailable + } + + public var knownUSDOrNil: Double? { + self.knownUSD > 0 || self.unknownTokens == 0 ? self.knownUSD : nil + } +} + +public struct CodexLocalProjectUsage: Sendable, Codable, Identifiable, Equatable { + public let id: String + public let displayName: String + public let path: String? + public let totals: CodexLocalUsageTotals + public let costEstimate: CodexLocalCostEstimate + public let usageSeverity: CodexLocalUsageSeverity? + public let sessionCount: Int + public let latestActivity: Date? + public let topModel: String? + public let topSessions: [CodexLocalSessionUsage] + public let modelBreakdowns: [CodexLocalUsageModelBreakdown] + /// Raw daily totals belonging to this project. The presentation layer + /// applies cache inclusion and cost visibility without re-indexing. + public let daily: [CodexLocalUsageDailyPoint] + + public var severity: CodexLocalUsageSeverity { + self.usageSeverity ?? .normal + } + + public var estimatedCostUSD: Double? { + self.costEstimate.knownUSDOrNil + } + + public var hasUnknownCost: Bool { + self.costEstimate.unknownTokens > 0 + } + + public init( + id: String, + displayName: String, + path: String?, + totals: CodexLocalUsageTotals, + estimatedCostUSD: Double?, + hasUnknownCost: Bool, + sessionCount: Int, + latestActivity: Date?, + topModel: String?, + topSessions: [CodexLocalSessionUsage], + modelBreakdowns: [CodexLocalUsageModelBreakdown], + daily: [CodexLocalUsageDailyPoint] = [], + usageSeverity: CodexLocalUsageSeverity = .normal) + { + self.id = id + self.displayName = displayName + self.path = path + self.totals = totals + self.costEstimate = CodexLocalCostEstimate( + knownUSD: estimatedCostUSD ?? 0, + unknownTokens: hasUnknownCost ? totals.totalTokens ?? 0 : 0) + self.usageSeverity = usageSeverity + self.sessionCount = sessionCount + self.latestActivity = latestActivity + self.topModel = topModel + self.topSessions = topSessions + self.modelBreakdowns = modelBreakdowns + self.daily = daily + } + + public init( + id: String, + displayName: String, + path: String?, + totals: CodexLocalUsageTotals, + costEstimate: CodexLocalCostEstimate, + sessionCount: Int, + latestActivity: Date?, + topModel: String?, + topSessions: [CodexLocalSessionUsage], + modelBreakdowns: [CodexLocalUsageModelBreakdown], + daily: [CodexLocalUsageDailyPoint] = [], + usageSeverity: CodexLocalUsageSeverity? = nil) + { + self.id = id + self.displayName = displayName + self.path = path + self.totals = totals + self.costEstimate = costEstimate + self.usageSeverity = usageSeverity + self.sessionCount = sessionCount + self.latestActivity = latestActivity + self.topModel = topModel + self.topSessions = topSessions + self.modelBreakdowns = modelBreakdowns + self.daily = daily + } + + private enum CodingKeys: String, CodingKey { + case id + case displayName + case path + case totals + case costEstimate + case estimatedCostUSD + case hasUnknownCost + case sessionCount + case latestActivity + case topModel + case topSessions + case modelBreakdowns + case daily + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.displayName = try container.decode(String.self, forKey: .displayName) + self.path = try container.decodeIfPresent(String.self, forKey: .path) + self.totals = try container.decode(CodexLocalUsageTotals.self, forKey: .totals) + self.costEstimate = try container.decodeIfPresent(CodexLocalCostEstimate.self, forKey: .costEstimate) + ?? CodexLocalCostEstimate( + knownUSD: container.decodeIfPresent(Double.self, forKey: .estimatedCostUSD) ?? 0, + unknownTokens: (container.decodeIfPresent(Bool.self, forKey: .hasUnknownCost) ?? false) + ? self.totals.totalTokens ?? 0 : 0) + // Severity is a display projection and intentionally is not persisted. + self.usageSeverity = nil + self.sessionCount = try container.decode(Int.self, forKey: .sessionCount) + self.latestActivity = try container.decodeIfPresent(Date.self, forKey: .latestActivity) + self.topModel = try container.decodeIfPresent(String.self, forKey: .topModel) + self.topSessions = try container.decodeIfPresent([CodexLocalSessionUsage].self, forKey: .topSessions) ?? [] + self.modelBreakdowns = try container.decodeIfPresent( + [CodexLocalUsageModelBreakdown].self, + forKey: .modelBreakdowns) ?? [] + self.daily = try container.decodeIfPresent([CodexLocalUsageDailyPoint].self, forKey: .daily) ?? [] + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.displayName, forKey: .displayName) + try container.encodeIfPresent(self.path, forKey: .path) + try container.encode(self.totals, forKey: .totals) + try container.encode(self.costEstimate, forKey: .costEstimate) + try container.encode(self.sessionCount, forKey: .sessionCount) + try container.encodeIfPresent(self.latestActivity, forKey: .latestActivity) + try container.encodeIfPresent(self.topModel, forKey: .topModel) + try container.encode(self.topSessions, forKey: .topSessions) + try container.encode(self.modelBreakdowns, forKey: .modelBreakdowns) + try container.encode(self.daily, forKey: .daily) + } +} + +extension CodexLocalProjectUsage { + fileprivate func hidingPersonalInformation() -> Self { + Self( + id: self.id, + displayName: self.id == CodexLocalProjectRootResolver.chatsProjectId + ? CodexLocalProjectRootResolver.chatsDisplayName + : "Workspace", + path: nil, + totals: self.totals, + costEstimate: self.costEstimate, + sessionCount: self.sessionCount, + latestActivity: self.latestActivity, + topModel: self.topModel, + topSessions: self.topSessions.map { $0.hidingPersonalInformation() }, + modelBreakdowns: self.modelBreakdowns, + daily: self.daily, + usageSeverity: self.usageSeverity) + } +} + +public struct CodexLocalSessionUsage: Sendable, Codable, Identifiable, Equatable { + /// Semantic fallback used when Codex did not persist a title. UI layers + /// localize this value instead of storing a localization key in Core. + public static let localChatFallbackTitle = "Local Codex chat" + + public let id: String + public let projectId: String + public let displayTitle: String + public let cwd: String? + public let startedAt: Date? + public let latestActivity: Date? + public let totals: CodexLocalUsageTotals + public let costEstimate: CodexLocalCostEstimate + public let topModel: String? + public let daily: [CodexLocalUsageDailyPoint] + + public init( + id: String, + projectId: String, + displayTitle: String, + cwd: String?, + startedAt: Date?, + latestActivity: Date?, + totals: CodexLocalUsageTotals, + estimatedCostUSD: Double?, + hasUnknownCost: Bool, + topModel: String?, + daily: [CodexLocalUsageDailyPoint] = []) + { + self.id = id + self.projectId = projectId + self.displayTitle = displayTitle + self.cwd = cwd + self.startedAt = startedAt + self.latestActivity = latestActivity + self.totals = totals + self.costEstimate = CodexLocalCostEstimate( + knownUSD: estimatedCostUSD ?? 0, + unknownTokens: hasUnknownCost ? totals.totalTokens ?? 0 : 0) + self.topModel = topModel + self.daily = daily + } + + public var estimatedCostUSD: Double? { + self.costEstimate.knownUSDOrNil + } + + public var hasUnknownCost: Bool { + self.costEstimate.unknownTokens > 0 + } + + public init( + id: String, + projectId: String, + displayTitle: String, + cwd: String?, + startedAt: Date?, + latestActivity: Date?, + totals: CodexLocalUsageTotals, + costEstimate: CodexLocalCostEstimate, + topModel: String?, + daily: [CodexLocalUsageDailyPoint] = []) + { + self.id = id + self.projectId = projectId + self.displayTitle = displayTitle + self.cwd = cwd + self.startedAt = startedAt + self.latestActivity = latestActivity + self.totals = totals + self.costEstimate = costEstimate + self.topModel = topModel + self.daily = daily + } + + private enum CodingKeys: String, CodingKey { + case id + case projectId + case displayTitle + case cwd + case startedAt + case latestActivity + case totals + case costEstimate + case estimatedCostUSD + case hasUnknownCost + case topModel + case daily + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(String.self, forKey: .id) + self.projectId = try container.decode(String.self, forKey: .projectId) + self.displayTitle = try container.decode(String.self, forKey: .displayTitle) + self.cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + self.startedAt = try container.decodeIfPresent(Date.self, forKey: .startedAt) + self.latestActivity = try container.decodeIfPresent(Date.self, forKey: .latestActivity) + self.totals = try container.decode(CodexLocalUsageTotals.self, forKey: .totals) + self.costEstimate = try container.decodeIfPresent(CodexLocalCostEstimate.self, forKey: .costEstimate) + ?? CodexLocalCostEstimate( + knownUSD: container.decodeIfPresent(Double.self, forKey: .estimatedCostUSD) ?? 0, + unknownTokens: (container.decodeIfPresent(Bool.self, forKey: .hasUnknownCost) ?? false) + ? self.totals.totalTokens ?? 0 : 0) + self.topModel = try container.decodeIfPresent(String.self, forKey: .topModel) + self.daily = try container.decodeIfPresent([CodexLocalUsageDailyPoint].self, forKey: .daily) ?? [] + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.projectId, forKey: .projectId) + try container.encode(self.displayTitle, forKey: .displayTitle) + try container.encodeIfPresent(self.cwd, forKey: .cwd) + try container.encodeIfPresent(self.startedAt, forKey: .startedAt) + try container.encodeIfPresent(self.latestActivity, forKey: .latestActivity) + try container.encode(self.totals, forKey: .totals) + try container.encode(self.costEstimate, forKey: .costEstimate) + try container.encodeIfPresent(self.topModel, forKey: .topModel) + try container.encode(self.daily, forKey: .daily) + } +} + +extension CodexLocalSessionUsage { + fileprivate func hidingPersonalInformation() -> Self { + Self( + id: self.id, + projectId: self.projectId, + displayTitle: Self.localChatFallbackTitle, + cwd: nil, + startedAt: self.startedAt, + latestActivity: self.latestActivity, + totals: self.totals, + costEstimate: self.costEstimate, + topModel: self.topModel, + daily: self.daily) + } +} + +public struct CodexLocalUsageTotals: Sendable, Codable, Equatable { + public let inputTokens: Int? + public let cachedInputTokens: Int? + public let outputTokens: Int? + public let reasoningOutputTokens: Int? + public let totalTokens: Int? + + public init( + inputTokens: Int?, + cachedInputTokens: Int?, + outputTokens: Int?, + reasoningOutputTokens: Int? = nil, + totalTokens: Int?) + { + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.reasoningOutputTokens = reasoningOutputTokens + self.totalTokens = totalTokens + } + + public static let unknown = CodexLocalUsageTotals( + inputTokens: nil, + cachedInputTokens: nil, + outputTokens: nil, + totalTokens: nil) + + public static let empty = CodexLocalUsageTotals( + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + totalTokens: 0) + + public func adding(_ other: CodexLocalUsageTotals) -> CodexLocalUsageTotals { + CodexLocalUsageTotals( + inputTokens: Self.add(self.inputTokens, other.inputTokens), + cachedInputTokens: Self.add(self.cachedInputTokens, other.cachedInputTokens), + outputTokens: Self.add(self.outputTokens, other.outputTokens), + reasoningOutputTokens: Self.add(self.reasoningOutputTokens, other.reasoningOutputTokens), + totalTokens: Self.add(self.totalTokens, other.totalTokens)) + } + + private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? { + switch (lhs, rhs) { + case let (lhs?, rhs?): + lhs + rhs + case let (lhs?, nil): + lhs + case let (nil, rhs?): + rhs + case (nil, nil): + nil + } + } +} + +public struct CodexLocalUsageModelBreakdown: Sendable, Codable, Identifiable, Equatable { + public var id: String { + self.model + } + + public let model: String + public let totals: CodexLocalUsageTotals + public let costEstimate: CodexLocalCostEstimate + + public init( + model: String, + totals: CodexLocalUsageTotals, + estimatedCostUSD: Double?, + hasUnknownCost: Bool) + { + self.model = model + self.totals = totals + self.costEstimate = CodexLocalCostEstimate( + knownUSD: estimatedCostUSD ?? 0, + unknownTokens: hasUnknownCost ? totals.totalTokens ?? 0 : 0) + } + + public var estimatedCostUSD: Double? { + self.costEstimate.knownUSDOrNil + } + + public var hasUnknownCost: Bool { + self.costEstimate.unknownTokens > 0 + } + + public init(model: String, totals: CodexLocalUsageTotals, costEstimate: CodexLocalCostEstimate) { + self.model = model + self.totals = totals + self.costEstimate = costEstimate + } + + private enum CodingKeys: String, CodingKey { + case model + case totals + case costEstimate + case estimatedCostUSD + case hasUnknownCost + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.model = try container.decode(String.self, forKey: .model) + self.totals = try container.decode(CodexLocalUsageTotals.self, forKey: .totals) + self.costEstimate = try container.decodeIfPresent(CodexLocalCostEstimate.self, forKey: .costEstimate) + ?? CodexLocalCostEstimate( + knownUSD: container.decodeIfPresent(Double.self, forKey: .estimatedCostUSD) ?? 0, + unknownTokens: (container.decodeIfPresent(Bool.self, forKey: .hasUnknownCost) ?? false) + ? self.totals.totalTokens ?? 0 : 0) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.model, forKey: .model) + try container.encode(self.totals, forKey: .totals) + try container.encode(self.costEstimate, forKey: .costEstimate) + } +} + +public struct CodexLocalUsageDailyPoint: Sendable, Codable, Identifiable, Equatable { + public var id: String { + self.day + } + + public let day: String + public let totalTokens: Int + public let cachedInputTokens: Int? + public let estimatedCostUSD: Double? + + public init( + day: String, + totalTokens: Int, + cachedInputTokens: Int? = nil, + estimatedCostUSD: Double?) + { + self.day = day + self.totalTokens = totalTokens + self.cachedInputTokens = cachedInputTokens + self.estimatedCostUSD = estimatedCostUSD + } + + private enum CodingKeys: String, CodingKey { + case day + case totalTokens + case cachedInputTokens + case estimatedCostUSD + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.day = try container.decode(String.self, forKey: .day) + self.totalTokens = try container.decode(Int.self, forKey: .totalTokens) + self.cachedInputTokens = try container.decodeIfPresent(Int.self, forKey: .cachedInputTokens) + self.estimatedCostUSD = try container.decodeIfPresent(Double.self, forKey: .estimatedCostUSD) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.day, forKey: .day) + try container.encode(self.totalTokens, forKey: .totalTokens) + try container.encodeIfPresent(self.cachedInputTokens, forKey: .cachedInputTokens) + try container.encodeIfPresent(self.estimatedCostUSD, forKey: .estimatedCostUSD) + } +} diff --git a/Sources/CodexBarCore/CodexLocalProjectUsageProjection.swift b/Sources/CodexBarCore/CodexLocalProjectUsageProjection.swift new file mode 100644 index 000000000..d440ab512 --- /dev/null +++ b/Sources/CodexBarCore/CodexLocalProjectUsageProjection.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Transient presentation rules for a raw local-usage snapshot. +/// +/// The index persists only source-derived counters and cost coverage. Settings +/// such as cache inclusion and cost visibility are deliberately applied here, +/// so changing them never requires a corpus scan or sidecar rewrite. +public struct CodexLocalProjectUsageProjection: Sendable, Equatable { + public let includesCachedInput: Bool + public let showsEstimatedCost: Bool + + public init(includesCachedInput: Bool, showsEstimatedCost: Bool) { + self.includesCachedInput = includesCachedInput + self.showsEstimatedCost = showsEstimatedCost + } + + public func displayedTokens(for totals: CodexLocalUsageTotals) -> Int? { + guard let total = totals.totalTokens else { return nil } + return self.displayedTokens(totalTokens: total, cachedInputTokens: totals.cachedInputTokens) + } + + public func displayedTokens(totalTokens: Int, cachedInputTokens: Int?) -> Int { + guard !self.includesCachedInput else { return max(0, totalTokens) } + return max(0, totalTokens - (cachedInputTokens ?? 0)) + } + + public func rankedProjects(_ projects: [CodexLocalProjectUsage]) -> [CodexLocalProjectUsage] { + let severities = self.severities(for: projects) + return projects.sorted { lhs, rhs in + let lhsTokens = self.displayedTokens(for: lhs.totals) ?? -1 + let rhsTokens = self.displayedTokens(for: rhs.totals) ?? -1 + if lhsTokens != rhsTokens { return lhsTokens > rhsTokens } + + let lhsCost = lhs.costEstimate.knownUSD + let rhsCost = rhs.costEstimate.knownUSD + if lhsCost != rhsCost { return lhsCost > rhsCost } + if lhs.sessionCount != rhs.sessionCount { return lhs.sessionCount > rhs.sessionCount } + if lhs.latestActivity != rhs.latestActivity { + return (lhs.latestActivity ?? .distantPast) > (rhs.latestActivity ?? .distantPast) + } + return lhs.displayName.localizedStandardCompare(rhs.displayName) == .orderedAscending + }.map { project in + project.withDisplaySeverity(severities[project.id] ?? .normal) + } + } + + public func displayedCost(for estimate: CodexLocalCostEstimate) -> CodexLocalCostEstimate? { + self.showsEstimatedCost ? estimate : nil + } + + private func severities(for projects: [CodexLocalProjectUsage]) -> [String: CodexLocalUsageSeverity] { + let nonZero = projects.compactMap { self.displayedTokens(for: $0.totals) }.filter { $0 > 0 }.sorted() + let total = nonZero.reduce(0, +) + let median = self.percentile(nonZero, percentile: 0.5) + let p90 = self.percentile(nonZero, percentile: 0.9) + let outlierThreshold = max(p90, median * 5) + let maximum = nonZero.last ?? 0 + + return Dictionary(uniqueKeysWithValues: projects.map { project in + let tokens = self.displayedTokens(for: project.totals) ?? 0 + let severity: CodexLocalUsageSeverity = if tokens > 0, total > 0, + Double(tokens) >= Double(total) * 0.5 || + (tokens == maximum && tokens > outlierThreshold) + { + .high + } else if tokens > 0, median > 0, tokens >= median * 2 { + .elevated + } else { + .normal + } + return (project.id, severity) + }) + } + + private func percentile(_ values: [Int], percentile: Double) -> Int { + guard !values.isEmpty else { return 0 } + let index = Int((Double(values.count - 1) * percentile).rounded(.up)) + return values[min(max(index, 0), values.count - 1)] + } +} + +extension CodexLocalProjectUsage { + fileprivate func withDisplaySeverity(_ severity: CodexLocalUsageSeverity) -> Self { + CodexLocalProjectUsage( + id: self.id, + displayName: self.displayName, + path: self.path, + totals: self.totals, + costEstimate: self.costEstimate, + sessionCount: self.sessionCount, + latestActivity: self.latestActivity, + topModel: self.topModel, + topSessions: self.topSessions, + modelBreakdowns: self.modelBreakdowns, + daily: self.daily, + usageSeverity: severity) + } +} diff --git a/Sources/CodexBarCore/CodexModelsAnalyticsModels.swift b/Sources/CodexBarCore/CodexModelsAnalyticsModels.swift new file mode 100644 index 000000000..556fd16fe --- /dev/null +++ b/Sources/CodexBarCore/CodexModelsAnalyticsModels.swift @@ -0,0 +1,883 @@ +import Foundation + +public enum CodexModelsRollout { + public static let featureFlagKey = "codex.models.revamp.enabled" + public static let defaultEnabled = true + + public static func isEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: self.featureFlagKey) as? Bool ?? self.defaultEnabled + } +} + +public enum CodexModelsMetric: String, CaseIterable, Codable, Identifiable, Sendable { + case tokens + case knownCost + case sessionReferences + + public var id: Self { + self + } + + public var title: String { + switch self { + case .tokens: "Tokens" + case .knownCost: "Cost" + case .sessionReferences: "Session refs" + } + } +} + +public enum CodexModelsGranularity: String, CaseIterable, Codable, Identifiable, Sendable { + case daily + case weekly + case monthly + + public var id: Self { + self + } + + public var title: String { + self.rawValue.capitalized + } +} + +public enum CodexModelsComparison: Codable, Equatable, Sendable { + case unavailable + case new + case ended + case unchanged + case percent(Double) + + public static func make(current: Double, previous: Double, previousIsComplete: Bool = true) -> Self { + guard previousIsComplete else { return .unavailable } + if current == 0, previous == 0 { return .unchanged } + if previous == 0 { return .new } + if current == 0 { return .ended } + let value = (current - previous) / previous + return value == 0 ? .unchanged : .percent(value) + } + + public var sortableValue: Double { + switch self { + case .unavailable: -.infinity + case .new: .infinity + case .ended: -1 + case .unchanged: 0 + case let .percent(value): value + } + } +} + +public struct CodexModelsCost: Codable, Equatable, Sendable { + public let knownAmount: Decimal + public let pricedTokens: Int64 + public let unpricedTokens: Int64 + public let currencyCode: String + + public init( + knownAmount: Decimal, + pricedTokens: Int64, + unpricedTokens: Int64, + currencyCode: String = "USD") + { + self.knownAmount = knownAmount + self.pricedTokens = pricedTokens + self.unpricedTokens = unpricedTokens + self.currencyCode = currencyCode + } + + public static let zero = Self(knownAmount: 0, pricedTokens: 0, unpricedTokens: 0) + + public var coverage: Double { + let total = self.pricedTokens + self.unpricedTokens + return total == 0 ? 1 : Double(self.pricedTokens) / Double(total) + } + + public func adding(_ other: Self) -> Self { + Self( + knownAmount: self.knownAmount + other.knownAmount, + pricedTokens: self.pricedTokens + other.pricedTokens, + unpricedTokens: self.unpricedTokens + other.unpricedTokens, + currencyCode: self.currencyCode) + } +} + +public struct CodexModelsUsageFragment: Equatable, Sendable { + public let workspaceID: String + public let sessionID: String + public let day: Date + public let timestamp: Date + public let rawModelID: String + public let inputTokens: Int64 + public let cachedInputTokens: Int64 + public let outputTokens: Int64 + public let reasoningTokens: Int64? + public let costNanos: Int64? + public let unpricedTokens: Int64 + + public init( + workspaceID: String, + sessionID: String, + day: Date, + timestamp: Date? = nil, + rawModelID: String, + inputTokens: Int64, + cachedInputTokens: Int64, + outputTokens: Int64, + reasoningTokens: Int64? = nil, + costNanos: Int64?, + unpricedTokens: Int64? = nil) + { + self.workspaceID = workspaceID + self.sessionID = sessionID + self.day = day + self.timestamp = timestamp ?? day + self.rawModelID = rawModelID + self.inputTokens = max(0, inputTokens) + self.cachedInputTokens = min(max(0, cachedInputTokens), max(0, inputTokens)) + self.outputTokens = max(0, outputTokens) + self.reasoningTokens = reasoningTokens.map { min(max(0, $0), max(0, outputTokens)) } + self.costNanos = costNanos + let totalTokens = self.inputTokens + self.outputTokens + let defaultUnpricedTokens = costNanos == nil ? totalTokens : 0 + self.unpricedTokens = min(max(0, unpricedTokens ?? defaultUnpricedTokens), totalTokens) + } + + public var totalTokens: Int64 { + self.inputTokens + self.outputTokens + } +} + +public struct CodexModelsDailyBucket: Codable, Equatable, Identifiable, Sendable { + public let day: Date + public let interval: DateInterval? + public let tokens: Int64 + public let sessionIDs: [String] + public let sessionReferenceIDs: [String] + public let cost: CodexModelsCost + + public var id: Date { + self.day + } + + public var sessionReferences: Int { + self.sessionReferenceIDs.count + } + + public init( + day: Date, + interval: DateInterval? = nil, + tokens: Int64, + sessionIDs: [String], + sessionReferenceIDs: [String]? = nil, + cost: CodexModelsCost) + { + self.day = day + self.interval = interval + self.tokens = tokens + self.sessionIDs = sessionIDs + self.sessionReferenceIDs = sessionReferenceIDs ?? sessionIDs + self.cost = cost + } + + private enum CodingKeys: String, CodingKey { + case day + case interval + case tokens + case sessionIDs + case sessionReferenceIDs + case cost + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.day = try container.decode(Date.self, forKey: .day) + self.interval = try container.decodeIfPresent(DateInterval.self, forKey: .interval) + self.tokens = try container.decode(Int64.self, forKey: .tokens) + self.sessionIDs = try container.decode([String].self, forKey: .sessionIDs) + self.sessionReferenceIDs = try container.decodeIfPresent([String].self, forKey: .sessionReferenceIDs) + ?? self.sessionIDs + self.cost = try container.decode(CodexModelsCost.self, forKey: .cost) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.day, forKey: .day) + try container.encodeIfPresent(self.interval, forKey: .interval) + try container.encode(self.tokens, forKey: .tokens) + try container.encode(self.sessionIDs, forKey: .sessionIDs) + try container.encode(self.sessionReferenceIDs, forKey: .sessionReferenceIDs) + try container.encode(self.cost, forKey: .cost) + } + + public var effectiveInterval: DateInterval { + self.interval ?? DateInterval(start: self.day, duration: 24 * 60 * 60) + } +} + +public struct CodexModelsRow: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let displayName: String + public let rawAliases: [String] + public let inputTokens: Int64 + public let cachedInputTokens: Int64 + public let outputTokens: Int64 + public let reasoningTokens: Int64? + public let totalTokens: Int64 + public let share: Double + public let sessionReferences: Int + public let cost: CodexModelsCost + public let previousTotalTokens: Int64? + public let previousCost: CodexModelsCost? + public let previousSessionReferences: Int? + public let associatedSessionIDs: [String]? + public let tokenComparison: CodexModelsComparison + public let costComparison: CodexModelsComparison + public let sessionReferenceComparison: CodexModelsComparison + + public init( + id: String, + displayName: String, + rawAliases: [String], + inputTokens: Int64, + cachedInputTokens: Int64, + outputTokens: Int64, + reasoningTokens: Int64?, + totalTokens: Int64, + share: Double, + sessionReferences: Int, + cost: CodexModelsCost, + previousTotalTokens: Int64? = nil, + previousCost: CodexModelsCost? = nil, + previousSessionReferences: Int? = nil, + associatedSessionIDs: [String]? = nil, + tokenComparison: CodexModelsComparison, + costComparison: CodexModelsComparison, + sessionReferenceComparison: CodexModelsComparison) + { + self.id = id + self.displayName = displayName + self.rawAliases = rawAliases + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens + self.totalTokens = totalTokens + self.share = share + self.sessionReferences = sessionReferences + self.cost = cost + self.previousTotalTokens = previousTotalTokens + self.previousCost = previousCost + self.previousSessionReferences = previousSessionReferences + self.associatedSessionIDs = associatedSessionIDs + self.tokenComparison = tokenComparison + self.costComparison = costComparison + self.sessionReferenceComparison = sessionReferenceComparison + } + + public func metricValue(_ metric: CodexModelsMetric) -> Double { + switch metric { + case .tokens: Double(self.totalTokens) + case .knownCost: NSDecimalNumber(decimal: self.cost.knownAmount).doubleValue + case .sessionReferences: Double(self.sessionReferences) + } + } + + public func comparison(_ metric: CodexModelsMetric) -> CodexModelsComparison { + switch metric { + case .tokens: self.tokenComparison + case .knownCost: self.costComparison + case .sessionReferences: self.sessionReferenceComparison + } + } +} + +public enum CodexModelsParityDimension: String, Codable, CaseIterable, Sendable { + case totalTokens = "total_tokens" + case modelIdentities = "model_identities" + case knownCost = "known_cost" + case pricingCoverage = "pricing_coverage" + case activeModelCount = "active_model_count" + case topModel = "top_model" + case comparisons + case sessionReferences = "session_references" + case modelTokens = "model_tokens" + case modelKnownCost = "model_known_cost" + case modelPricingCoverage = "model_pricing_coverage" + case modelSessionReferences = "model_session_references" +} + +public struct CodexModelsRolloutDiagnostics: Codable, Equatable, Sendable { + public let legacyTotalTokens: Int64 + public let revisedTotalTokens: Int64 + public let legacyModelIDs: [String] + public let revisedModelIDs: [String] + public let mismatches: [String] + public let mismatchDimensions: [CodexModelsParityDimension]? + + public init( + legacyTotalTokens: Int64, + revisedTotalTokens: Int64, + legacyModelIDs: [String], + revisedModelIDs: [String], + mismatches: [String], + mismatchDimensions: [CodexModelsParityDimension]? = nil) + { + self.legacyTotalTokens = legacyTotalTokens + self.revisedTotalTokens = revisedTotalTokens + self.legacyModelIDs = legacyModelIDs + self.revisedModelIDs = revisedModelIDs + self.mismatches = mismatches + self.mismatchDimensions = mismatchDimensions + } + + public var isMatched: Bool { + self.mismatches.isEmpty + } +} + +public struct CodexModelsAnalyticsSnapshot: Codable, Equatable, Sendable { + public let scopeID: String? + public let generatedAt: Date + public let indexRevision: String + public let currentInterval: DateInterval + public let previousInterval: DateInterval + /// Nil only when decoding a snapshot written before period completeness was persisted. + public let currentIsComplete: Bool? + /// Nil only when decoding a snapshot written before period completeness was persisted. + public let previousIsComplete: Bool? + public let totalTokens: Int64 + public let cost: CodexModelsCost + public let activeModelCount: Int + public let previousActiveModelCount: Int? + public let newlyActiveModelCount: Int? + public let uniqueSessionCount: Int + public let sessionReferenceTotal: Int + public let previousSessionReferenceTotal: Int? + public let tokenComparison: CodexModelsComparison + public let costComparison: CodexModelsComparison + public let sessionReferenceComparison: CodexModelsComparison? + public let rows: [CodexModelsRow] + public let daily: [CodexModelsDailyBucket] + public let dailyByModel: [String: [CodexModelsDailyBucket]] + public let diagnostics: CodexModelsRolloutDiagnostics + + public func totalValue(_ metric: CodexModelsMetric) -> Double { + switch metric { + case .tokens: Double(self.totalTokens) + case .knownCost: NSDecimalNumber(decimal: self.cost.knownAmount).doubleValue + case .sessionReferences: Double(self.sessionReferenceTotal) + } + } + + public func share(of row: CodexModelsRow, metric: CodexModelsMetric) -> Double { + let total = self.totalValue(metric) + return total == 0 ? 0 : row.metricValue(metric) / total + } + + public func comparison(_ metric: CodexModelsMetric) -> CodexModelsComparison { + switch metric { + case .tokens: self.tokenComparison + case .knownCost: self.costComparison + case .sessionReferences: self.sessionReferenceComparison ?? .unavailable + } + } + + public func invariantViolations() -> [String] { + var failures: [String] = [] + if self.rows.reduce(Int64.zero, { $0 + $1.totalTokens }) != self.totalTokens { + failures.append("summary_tokens") + } + let rowCost = self.rows.reduce(CodexModelsCost.zero) { $0.adding($1.cost) } + if rowCost != self.cost { failures.append("cost_coverage") } + if self.rows.count != self.activeModelCount { failures.append("active_models") } + if self.rows.reduce(0, { $0 + $1.sessionReferences }) != self.sessionReferenceTotal { + failures.append("session_references") + } + if self.daily.reduce(Int64.zero, { $0 + $1.tokens }) != self.totalTokens { + failures.append("timeline_tokens") + } + return failures + } +} + +public struct CodexModelsAnalyticsPayload: Codable, Equatable, Sendable { + public let allWorkspaces: CodexModelsAnalyticsSnapshot + public let workspaces: [String: CodexModelsAnalyticsSnapshot] + + public func snapshot(workspaceID: String?) -> CodexModelsAnalyticsSnapshot { + guard let workspaceID else { return self.allWorkspaces } + return self.workspaces[workspaceID] ?? self.allWorkspaces + } +} + +public struct CodexModelsAnalyticsSource: Sendable { + public let current: [CodexModelsUsageFragment] + public let previous: [CodexModelsUsageFragment] + + public init(current: [CodexModelsUsageFragment], previous: [CodexModelsUsageFragment]) { + self.current = current + self.previous = previous + } +} + +public struct CodexModelsAnalyticsPeriods: Sendable { + public let current: DateInterval + public let previous: DateInterval + + public init(current: DateInterval, previous: DateInterval) { + self.current = current + self.previous = previous + } +} + +public struct CodexModelsAnalyticsRevision: Sendable { + public let generatedAt: Date + public let indexRevision: String + + public init(generatedAt: Date, indexRevision: String) { + self.generatedAt = generatedAt + self.indexRevision = indexRevision + } +} + +public struct CodexModelsLegacyModelBaseline: Sendable { + public let modelID: String + public let totalTokens: Int64? + public let knownCost: Decimal? + public let pricedTokens: Int64? + public let unpricedTokens: Int64? + public let sessionReferences: Int? + + public init( + modelID: String, + totalTokens: Int64? = nil, + knownCost: Decimal? = nil, + pricedTokens: Int64? = nil, + unpricedTokens: Int64? = nil, + sessionReferences: Int? = nil) + { + self.modelID = modelID + self.totalTokens = totalTokens + self.knownCost = knownCost + self.pricedTokens = pricedTokens + self.unpricedTokens = unpricedTokens + self.sessionReferences = sessionReferences + } +} + +public struct CodexModelsLegacyBaseline: Sendable { + public let totalTokens: Int64 + public let modelIDs: [String] + public let knownCost: Decimal? + public let pricedTokens: Int64? + public let unpricedTokens: Int64? + public let activeModelCount: Int? + public let topModelID: String? + public let sessionReferenceTotal: Int? + public let previousTotalTokens: Int64? + public let previousKnownCost: Decimal? + public let previousUnpricedTokens: Int64? + public let previousSessionReferenceTotal: Int? + public let currentModels: [CodexModelsLegacyModelBaseline]? + public let previousModels: [CodexModelsLegacyModelBaseline]? + + public init( + totalTokens: Int64, + modelIDs: [String], + knownCost: Decimal? = nil, + pricedTokens: Int64? = nil, + unpricedTokens: Int64? = nil, + activeModelCount: Int? = nil, + topModelID: String? = nil, + sessionReferenceTotal: Int? = nil, + previousTotalTokens: Int64? = nil, + previousKnownCost: Decimal? = nil, + previousUnpricedTokens: Int64? = nil, + previousSessionReferenceTotal: Int? = nil, + currentModels: [CodexModelsLegacyModelBaseline]? = nil, + previousModels: [CodexModelsLegacyModelBaseline]? = nil) + { + self.totalTokens = totalTokens + self.modelIDs = modelIDs + self.knownCost = knownCost + self.pricedTokens = pricedTokens + self.unpricedTokens = unpricedTokens + self.activeModelCount = activeModelCount + self.topModelID = topModelID + self.sessionReferenceTotal = sessionReferenceTotal + self.previousTotalTokens = previousTotalTokens + self.previousKnownCost = previousKnownCost + self.previousUnpricedTokens = previousUnpricedTokens + self.previousSessionReferenceTotal = previousSessionReferenceTotal + self.currentModels = currentModels + self.previousModels = previousModels + } +} + +public struct CodexModelsAnalyticsRequest: Sendable { + public let source: CodexModelsAnalyticsSource + public let scopeID: String? + public let periods: CodexModelsAnalyticsPeriods + public let revision: CodexModelsAnalyticsRevision + public let legacy: CodexModelsLegacyBaseline + public let currentIsComplete: Bool + public let previousIsComplete: Bool + + public init( + source: CodexModelsAnalyticsSource, + scopeID: String?, + periods: CodexModelsAnalyticsPeriods, + revision: CodexModelsAnalyticsRevision, + legacy: CodexModelsLegacyBaseline, + currentIsComplete: Bool = true, + previousIsComplete: Bool = true) + { + self.source = source + self.scopeID = scopeID + self.periods = periods + self.revision = revision + self.legacy = legacy + self.currentIsComplete = currentIsComplete + self.previousIsComplete = previousIsComplete + } +} + +public struct CodexModelsAnalyticsBuilder: Sendable { + public init() {} + + public func build(_ request: CodexModelsAnalyticsRequest) -> CodexModelsAnalyticsSnapshot { + let current = self.filtered( + request.source.current, + scopeID: request.scopeID, + interval: request.periods.current) + let previous = self.filtered( + request.source.previous, + scopeID: request.scopeID, + interval: request.periods.previous) + let currentGroups = Dictionary(grouping: current) { self.canonicalID($0.rawModelID) } + let previousGroups = Dictionary(grouping: previous) { self.canonicalID($0.rawModelID) } + let comparisonIsComplete = request.currentIsComplete && request.previousIsComplete + let totalTokens = current.reduce(Int64.zero) { $0 + $1.totalTokens } + let previousTokens = previous.reduce(Int64.zero) { $0 + $1.totalTokens } + let previousSessionReferenceTotal = previousGroups.values.reduce(0) { partial, events in + partial + Set(events.map(\.sessionID)).count + } + let rows = self.makeRows( + currentGroups: currentGroups, + previousGroups: previousGroups, + totalTokens: totalTokens, + previousIsComplete: request.previousIsComplete, + comparisonIsComplete: comparisonIsComplete) + + let totalCost = rows.reduce(CodexModelsCost.zero) { $0.adding($1.cost) } + let previousCost = self.cost(previous) + let sessionReferenceTotal = rows.reduce(0) { $0 + $1.sessionReferences } + let tokenComparison = CodexModelsComparison.make( + current: Double(totalTokens), + previous: Double(previousTokens), + previousIsComplete: comparisonIsComplete) + let costComparison = CodexModelsComparison.make( + current: NSDecimalNumber(decimal: totalCost.knownAmount).doubleValue, + previous: NSDecimalNumber(decimal: previousCost.knownAmount).doubleValue, + previousIsComplete: comparisonIsComplete + && totalCost.unpricedTokens == 0 + && previousCost.unpricedTokens == 0) + let sessionReferenceComparison = CodexModelsComparison.make( + current: Double(sessionReferenceTotal), + previous: Double(previousSessionReferenceTotal), + previousIsComplete: comparisonIsComplete) + let newlyActiveModelCount: Int? = comparisonIsComplete + ? currentGroups.keys.count(where: { previousGroups[$0] == nil }) + : nil + let revisedIDs = rows.map(\.id).sorted() + let mismatchDimensions = self.parityMismatches(ParityInputs( + request: request, + currentGroups: currentGroups, + previousGroups: previousGroups, + rows: rows, + totalTokens: totalTokens, + totalCost: totalCost, + sessionReferenceTotal: sessionReferenceTotal, + comparisonIsComplete: comparisonIsComplete, + tokenComparison: tokenComparison, + costComparison: costComparison, + sessionReferenceComparison: sessionReferenceComparison)) + + let daily = self.dailyBuckets(current) + let dailyByModel = currentGroups.mapValues { self.dailyBuckets($0) } + return CodexModelsAnalyticsSnapshot( + scopeID: request.scopeID, + generatedAt: request.revision.generatedAt, + indexRevision: request.revision.indexRevision, + currentInterval: request.periods.current, + previousInterval: request.periods.previous, + currentIsComplete: request.currentIsComplete, + previousIsComplete: request.previousIsComplete, + totalTokens: totalTokens, + cost: totalCost, + activeModelCount: rows.count, + previousActiveModelCount: request.previousIsComplete ? previousGroups.count : nil, + newlyActiveModelCount: newlyActiveModelCount, + uniqueSessionCount: Set(current.map(\.sessionID)).count, + sessionReferenceTotal: sessionReferenceTotal, + previousSessionReferenceTotal: request.previousIsComplete ? previousSessionReferenceTotal : nil, + tokenComparison: tokenComparison, + costComparison: costComparison, + sessionReferenceComparison: sessionReferenceComparison, + rows: rows, + daily: daily, + dailyByModel: dailyByModel, + diagnostics: CodexModelsRolloutDiagnostics( + legacyTotalTokens: request.legacy.totalTokens, + revisedTotalTokens: totalTokens, + legacyModelIDs: request.legacy.modelIDs.sorted(), + revisedModelIDs: revisedIDs, + mismatches: mismatchDimensions.map(\.rawValue), + mismatchDimensions: mismatchDimensions)) + } + + private func makeRows( + currentGroups: [String: [CodexModelsUsageFragment]], + previousGroups: [String: [CodexModelsUsageFragment]], + totalTokens: Int64, + previousIsComplete: Bool, + comparisonIsComplete: Bool) -> [CodexModelsRow] + { + currentGroups.map { modelID, events in + let earlier = previousGroups[modelID, default: []] + let tokens = events.reduce(Int64.zero) { $0 + $1.totalTokens } + let previousModelTokens = earlier.reduce(Int64.zero) { $0 + $1.totalTokens } + let cost = self.cost(events) + let previousCost = self.cost(earlier) + let sessionReferences = Set(events.map(\.sessionID)).count + let previousSessionReferences = Set(earlier.map(\.sessionID)).count + let reasoningValues = events.compactMap(\.reasoningTokens) + return CodexModelsRow( + id: modelID, + displayName: CostUsagePricing.codexDisplayLabel(model: modelID) ?? modelID, + rawAliases: Array(Set(events.map(\.rawModelID))).sorted(), + inputTokens: events.reduce(0) { $0 + $1.inputTokens }, + cachedInputTokens: events.reduce(0) { $0 + $1.cachedInputTokens }, + outputTokens: events.reduce(0) { $0 + $1.outputTokens }, + reasoningTokens: reasoningValues.isEmpty ? nil : reasoningValues.reduce(0, +), + totalTokens: tokens, + share: totalTokens == 0 ? 0 : Double(tokens) / Double(totalTokens), + sessionReferences: sessionReferences, + cost: cost, + previousTotalTokens: previousIsComplete ? previousModelTokens : nil, + previousCost: previousIsComplete ? previousCost : nil, + previousSessionReferences: previousIsComplete ? previousSessionReferences : nil, + associatedSessionIDs: Array(Set(events.map(\.sessionID))).sorted(), + tokenComparison: .make( + current: Double(tokens), + previous: Double(previousModelTokens), + previousIsComplete: comparisonIsComplete), + costComparison: .make( + current: NSDecimalNumber(decimal: cost.knownAmount).doubleValue, + previous: NSDecimalNumber(decimal: previousCost.knownAmount).doubleValue, + previousIsComplete: comparisonIsComplete + && cost.unpricedTokens == 0 + && previousCost.unpricedTokens == 0), + sessionReferenceComparison: .make( + current: Double(sessionReferences), + previous: Double(previousSessionReferences), + previousIsComplete: comparisonIsComplete)) + } + .sorted { + if $0.totalTokens != $1.totalTokens { return $0.totalTokens > $1.totalTokens } + if $0.sessionReferences != $1.sessionReferences { return $0.sessionReferences > $1.sessionReferences } + return $0.id < $1.id + } + } + + private struct ParityInputs { + let request: CodexModelsAnalyticsRequest + let currentGroups: [String: [CodexModelsUsageFragment]] + let previousGroups: [String: [CodexModelsUsageFragment]] + let rows: [CodexModelsRow] + let totalTokens: Int64 + let totalCost: CodexModelsCost + let sessionReferenceTotal: Int + let comparisonIsComplete: Bool + let tokenComparison: CodexModelsComparison + let costComparison: CodexModelsComparison + let sessionReferenceComparison: CodexModelsComparison + } + + private func parityMismatches(_ inputs: ParityInputs) -> [CodexModelsParityDimension] { + var dimensions: [CodexModelsParityDimension] = [] + let request = inputs.request + if request.legacy.totalTokens != inputs.totalTokens { dimensions.append(.totalTokens) } + let legacyIDs = Array(Set(request.legacy.modelIDs.map { self.canonicalID($0) })).sorted() + if legacyIDs != inputs.rows.map(\.id).sorted() { dimensions.append(.modelIdentities) } + if let knownCost = request.legacy.knownCost, knownCost != inputs.totalCost.knownAmount { + dimensions.append(.knownCost) + } + if let priced = request.legacy.pricedTokens, + let unpriced = request.legacy.unpricedTokens, + priced != inputs.totalCost.pricedTokens || unpriced != inputs.totalCost.unpricedTokens + { + dimensions.append(.pricingCoverage) + } + if let count = request.legacy.activeModelCount, count != inputs.rows.count { + dimensions.append(.activeModelCount) + } + if let topModelID = request.legacy.topModelID, + self.canonicalID(topModelID) != inputs.rows.first?.id + { + dimensions.append(.topModel) + } + if let references = request.legacy.sessionReferenceTotal, references != inputs.sessionReferenceTotal { + dimensions.append(.sessionReferences) + } + if inputs.comparisonIsComplete, + self.legacyComparisonsMismatch( + request.legacy, + tokenComparison: inputs.tokenComparison, + costComparison: inputs.costComparison, + sessionReferenceComparison: inputs.sessionReferenceComparison) + { + dimensions.append(.comparisons) + } + let modelDimensions = self.modelParityMismatches( + legacy: request.currentIsComplete ? request.legacy.currentModels : nil, + revised: inputs.currentGroups) + .union(self.modelParityMismatches( + legacy: request.previousIsComplete ? request.legacy.previousModels : nil, + revised: inputs.previousGroups)) + for dimension in [ + CodexModelsParityDimension.modelTokens, + .modelKnownCost, + .modelPricingCoverage, + .modelSessionReferences, + ] where modelDimensions.contains(dimension) { + dimensions.append(dimension) + } + return dimensions + } + + private func legacyComparisonsMismatch( + _ legacy: CodexModelsLegacyBaseline, + tokenComparison: CodexModelsComparison, + costComparison: CodexModelsComparison, + sessionReferenceComparison: CodexModelsComparison) -> Bool + { + guard let previousTokens = legacy.previousTotalTokens else { return false } + let legacyToken = CodexModelsComparison.make( + current: Double(legacy.totalTokens), + previous: Double(previousTokens)) + let legacyCost = legacy.knownCost.flatMap { currentCost in + legacy.previousKnownCost.map { previousCost in + CodexModelsComparison.make( + current: NSDecimalNumber(decimal: currentCost).doubleValue, + previous: NSDecimalNumber(decimal: previousCost).doubleValue, + previousIsComplete: legacy.previousUnpricedTokens == 0) + } + } + let legacySessions = legacy.sessionReferenceTotal.flatMap { currentReferences in + legacy.previousSessionReferenceTotal.map { previousReferences in + CodexModelsComparison.make(current: Double(currentReferences), previous: Double(previousReferences)) + } + } + return legacyToken != tokenComparison + || legacyCost.map { $0 != costComparison } == true + || legacySessions.map { $0 != sessionReferenceComparison } == true + } + + public func canonicalID(_ rawID: String) -> String { + CostUsagePricing.normalizeCodexModel( + rawID.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + + private func modelParityMismatches( + legacy: [CodexModelsLegacyModelBaseline]?, + revised: [String: [CodexModelsUsageFragment]]) -> Set + { + guard let legacy else { return [] } + let normalized = Dictionary(grouping: legacy) { self.canonicalID($0.modelID) } + var mismatches: Set = [] + for (modelID, legacyRows) in normalized { + guard let revisedFragments = revised[modelID] else { continue } + let revisedTokens = revisedFragments.reduce(Int64.zero) { $0 + $1.totalTokens } + let revisedCost = self.cost(revisedFragments) + let revisedSessionReferences = Set(revisedFragments.map(\.sessionID)).count + let legacyTokens = self.sumIfComplete(legacyRows.map(\.totalTokens)) + let legacyKnownCost = self.sumIfComplete(legacyRows.map(\.knownCost)) + let legacyPricedTokens = self.sumIfComplete(legacyRows.map(\.pricedTokens)) + let legacyUnpricedTokens = self.sumIfComplete(legacyRows.map(\.unpricedTokens)) + // Distinct session counts cannot be safely summed across alias rows because + // the underlying session sets may overlap. Production baselines canonicalize + // first, so an exact value remains available there. + let legacySessionReferences = legacyRows.count == 1 ? legacyRows[0].sessionReferences : nil + + if let legacyTokens, legacyTokens != revisedTokens { + mismatches.insert(.modelTokens) + } + if let legacyKnownCost, legacyKnownCost != revisedCost.knownAmount { + mismatches.insert(.modelKnownCost) + } + if legacyPricedTokens.map({ $0 != revisedCost.pricedTokens }) == true + || legacyUnpricedTokens.map({ $0 != revisedCost.unpricedTokens }) == true + { + mismatches.insert(.modelPricingCoverage) + } + if let legacySessionReferences, legacySessionReferences != revisedSessionReferences { + mismatches.insert(.modelSessionReferences) + } + } + return mismatches + } + + private func sumIfComplete(_ values: [Int64?]) -> Int64? { + let available = values.compactMap(\.self) + return available.count == values.count ? available.reduce(0, +) : nil + } + + private func sumIfComplete(_ values: [Decimal?]) -> Decimal? { + let available = values.compactMap(\.self) + return available.count == values.count ? available.reduce(0, +) : nil + } + + private func filtered( + _ fragments: [CodexModelsUsageFragment], + scopeID: String?, + interval: DateInterval) -> [CodexModelsUsageFragment] + { + fragments.filter { fragment in + (scopeID == nil || fragment.workspaceID == scopeID) + && fragment.timestamp >= interval.start + && fragment.timestamp < interval.end + } + } + + private func cost(_ fragments: [CodexModelsUsageFragment]) -> CodexModelsCost { + fragments.reduce(.zero) { partial, fragment in + let unpriced = fragment.unpricedTokens + let priced = fragment.totalTokens - unpriced + let amount = fragment.costNanos.map { Decimal($0) / 1_000_000_000 } ?? 0 + return partial.adding(CodexModelsCost( + knownAmount: amount, + pricedTokens: priced, + unpricedTokens: unpriced)) + } + } + + private func dailyBuckets(_ fragments: [CodexModelsUsageFragment]) -> [CodexModelsDailyBucket] { + let calendar = Calendar.current + return Dictionary(grouping: fragments, by: \.day).map { day, values in + let start = calendar.startOfDay(for: day) + let end = calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(24 * 60 * 60) + return CodexModelsDailyBucket( + day: start, + interval: DateInterval(start: start, end: end), + tokens: values.reduce(0) { $0 + $1.totalTokens }, + sessionIDs: Array(Set(values.map(\.sessionID))).sorted(), + sessionReferenceIDs: Array(Set(values.map { + "\($0.sessionID)\u{1F}\(self.canonicalID($0.rawModelID))" + })).sorted(), + cost: self.cost(values)) + } + .sorted { $0.day < $1.day } + } +} diff --git a/Sources/CodexBarCore/CodexModelsCSVExporter.swift b/Sources/CodexBarCore/CodexModelsCSVExporter.swift new file mode 100644 index 000000000..50c7672a1 --- /dev/null +++ b/Sources/CodexBarCore/CodexModelsCSVExporter.swift @@ -0,0 +1,124 @@ +import Foundation + +public enum CodexModelsCSVExporter { + private static let columns = [ + "query_scope", + "period_start", + "period_end", + "canonical_model_id", + "display_name", + "raw_aliases", + "associated_session_ids", + "active_metric", + "tokens", + "input_tokens", + "cached_input_tokens", + "output_tokens", + "reasoning_tokens", + "share", + "session_references", + "previous_tokens", + "previous_session_references", + "known_cost", + "previous_known_cost", + "cost_status", + "cost_coverage", + "previous_cost_status", + "previous_cost_coverage", + "currency", + "priced_tokens", + "unpriced_tokens", + "previous_priced_tokens", + "previous_unpriced_tokens", + "comparison_kind", + "comparison_value", + "index_revision", + "snapshot_generated_at", + ] + + public static func export( + snapshot: CodexModelsAnalyticsSnapshot, + rows: [CodexModelsRow]? = nil, + metric: CodexModelsMetric = .tokens) -> String + { + let iso8601 = ISO8601DateFormatter() + let rows = rows ?? snapshot.rows + let body = rows.map { row in + let comparison = self.comparisonFields(row.comparison(metric)) + let share = String(format: "%.17g", snapshot.share(of: row, metric: metric)) + let associatedSessionIDs = (row.associatedSessionIDs ?? []).joined(separator: "|") + let previousTokens = row.previousTotalTokens.map(String.init) ?? "" + let previousSessionReferences = row.previousSessionReferences.map(String.init) ?? "" + let knownCost = row.cost.pricedTokens == 0 + ? "" + : NSDecimalNumber(decimal: row.cost.knownAmount).stringValue + let previousKnownCost = row.previousCost.flatMap { cost in + cost.pricedTokens == 0 ? nil : NSDecimalNumber(decimal: cost.knownAmount).stringValue + } ?? "" + let costStatus = self.costStatus(row.cost, usageTokens: row.totalTokens) + let previousCostStatus = row.previousCost.map { + self.costStatus($0, usageTokens: $0.pricedTokens + $0.unpricedTokens) + } ?? "unavailable" + let fields: [String] = [ + snapshot.scopeID ?? "all_workspaces", + iso8601.string(from: snapshot.currentInterval.start), + iso8601.string(from: snapshot.currentInterval.end), + row.id, + row.displayName, + row.rawAliases.joined(separator: "|"), + associatedSessionIDs, + metric.rawValue, + String(row.totalTokens), + String(row.inputTokens), + String(row.cachedInputTokens), + String(row.outputTokens), + row.reasoningTokens.map(String.init) ?? "", + share, + String(row.sessionReferences), + previousTokens, + previousSessionReferences, + knownCost, + previousKnownCost, + costStatus, + String(format: "%.17g", row.cost.coverage), + previousCostStatus, + row.previousCost.map { String(format: "%.17g", $0.coverage) } ?? "", + row.cost.currencyCode, + String(row.cost.pricedTokens), + String(row.cost.unpricedTokens), + row.previousCost.map { String($0.pricedTokens) } ?? "", + row.previousCost.map { String($0.unpricedTokens) } ?? "", + comparison.kind, + comparison.value, + snapshot.indexRevision, + iso8601.string(from: snapshot.generatedAt), + ] + return fields.map(self.escape).joined(separator: ",") + } + return ([self.columns.joined(separator: ",")] + body).joined(separator: "\n") + "\n" + } + + private static func comparisonFields(_ comparison: CodexModelsComparison) -> (kind: String, value: String) { + switch comparison { + case .unavailable: ("unavailable", "") + case .new: ("new", "") + case .ended: ("ended", "") + case .unchanged: ("unchanged", "0") + case let .percent(value): ("percent", String(format: "%.17g", value)) + } + } + + private static func costStatus(_ cost: CodexModelsCost, usageTokens: Int64) -> String { + if usageTokens == 0 { return "no_usage" } + if cost.pricedTokens == 0 { return "unavailable" } + if cost.unpricedTokens > 0 { return "partial" } + return "known" + } + + private static func escape(_ value: String) -> String { + guard value.contains(",") || value.contains("\"") || value.contains("\n") || value.contains("\r") else { + return value + } + return "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } +} diff --git a/Sources/CodexBarCore/CodexModelsTelemetry.swift b/Sources/CodexBarCore/CodexModelsTelemetry.swift new file mode 100644 index 000000000..aba571cda --- /dev/null +++ b/Sources/CodexBarCore/CodexModelsTelemetry.swift @@ -0,0 +1,57 @@ +import Foundation + +#if canImport(os) +import os + +typealias CodexModelsSignpostID = OSSignpostID + +enum CodexModelsTelemetry { + private static let log = OSLog(subsystem: "com.steipete.codexbar", category: "ModelsAnalytics") + + static func begin(_ name: StaticString) -> CodexModelsSignpostID { + let id = OSSignpostID(log: self.log) + os_signpost(.begin, log: self.log, name: name, signpostID: id) + return id + } + + static func end(_ name: StaticString, id: OSSignpostID) { + os_signpost(.end, log: self.log, name: name, signpostID: id) + } + + static func cacheHit(historyDays: Int) { + os_signpost(.event, log: self.log, name: "SnapshotCacheHit", "historyDays=%{public}d", historyDays) + } + + static func parity(dimensions: [CodexModelsParityDimension], rowCount: Int) { + let mask = dimensions.reduce(0) { partial, dimension in + guard let index = CodexModelsParityDimension.allCases.firstIndex(of: dimension) else { return partial } + return partial | (1 << index) + } + os_signpost( + .event, + log: self.log, + name: "DualRunParity", + "mismatches=%{public}d mask=%{public}d rows=%{public}d", + dimensions.count, + mask, + rowCount) + } +} + +#else + +struct CodexModelsSignpostID: Sendable {} + +enum CodexModelsTelemetry { + static func begin(_: StaticString) -> CodexModelsSignpostID { + CodexModelsSignpostID() + } + + static func end(_: StaticString, id _: CodexModelsSignpostID) {} + + static func cacheHit(historyDays _: Int) {} + + static func parity(dimensions _: [CodexModelsParityDimension], rowCount _: Int) {} +} + +#endif diff --git a/Sources/CodexBarCore/CodexThreadCatalogReader.swift b/Sources/CodexBarCore/CodexThreadCatalogReader.swift new file mode 100644 index 000000000..37f94282e --- /dev/null +++ b/Sources/CodexBarCore/CodexThreadCatalogReader.swift @@ -0,0 +1,261 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +struct CodexThreadCatalog: Sendable { + static let empty = CodexThreadCatalog(entriesById: [:], entriesByRolloutPath: [:], fingerprint: nil) + + let entriesById: [String: CodexThreadCatalogEntry] + let entriesByRolloutPath: [String: CodexThreadCatalogEntry] + let fingerprint: String? + + func entry(sessionId: String?, rolloutPath: String) -> CodexThreadCatalogEntry? { + if let sessionId, let entry = self.entriesById[sessionId] { + return entry + } + return self.entriesByRolloutPath[URL(fileURLWithPath: rolloutPath).standardizedFileURL.path] + } +} + +enum CodexThreadCatalogCompleteness: Sendable, Equatable { + case complete + case unavailable(CodexThreadCatalogFailure) +} + +enum CodexThreadCatalogFailure: Sendable, Equatable { + case missing + case locked + case corrupt + case incompatible + case unreadable +} + +struct CodexThreadCatalogReadResult: Sendable { + let catalog: CodexThreadCatalog + let databaseURL: URL + let completeness: CodexThreadCatalogCompleteness + + var isComplete: Bool { + if case .complete = self.completeness { return true } + return false + } +} + +struct CodexThreadCatalogEntry: Sendable, Equatable { + let id: String + let rolloutPath: String + let cwd: String? + let title: String? + let preview: String? + let modelProvider: String? + let model: String? + let reasoningEffort: String? + let createdAtUnixMs: Int64? + let updatedAtUnixMs: Int64? + let archived: Bool +} + +enum CodexThreadCatalogReader { + static func load(options: CostUsageScanner.Options) -> CodexThreadCatalog { + self.loadResult(options: options).catalog + } + + static func loadResult(options: CostUsageScanner.Options) -> CodexThreadCatalogReadResult { + let url = self.databaseURL(options: options) + guard FileManager.default.fileExists(atPath: url.path) else { + return CodexThreadCatalogReadResult( + catalog: .empty, + databaseURL: url, + completeness: .unavailable(.missing)) + } + + #if canImport(SQLite3) || canImport(CSQLite3) + var db: OpaquePointer? + let openResult = sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) + guard openResult == SQLITE_OK else { + let failure: CodexThreadCatalogFailure = openResult == SQLITE_BUSY || openResult == SQLITE_LOCKED + ? .locked + : openResult == SQLITE_CORRUPT || openResult == SQLITE_NOTADB ? .corrupt : .unreadable + sqlite3_close(db) + return CodexThreadCatalogReadResult( + catalog: .empty, + databaseURL: url, + completeness: .unavailable(failure)) + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + sqlite3_exec(db, "PRAGMA query_only = ON", nil, nil, nil) + + guard let hasThreadsTable = self.hasThreadsTable(db) else { + let code = sqlite3_errcode(db) + let failure: CodexThreadCatalogFailure = code == SQLITE_BUSY || code == SQLITE_LOCKED + ? .locked + : code == SQLITE_CORRUPT || code == SQLITE_NOTADB ? .corrupt : .unreadable + return CodexThreadCatalogReadResult( + catalog: .empty, + databaseURL: url, + completeness: .unavailable(failure)) + } + guard hasThreadsTable else { + return CodexThreadCatalogReadResult( + catalog: .empty, + databaseURL: url, + completeness: .unavailable(.incompatible)) + } + guard let entries = self.readEntries(db) else { + let code = sqlite3_errcode(db) + let failure: CodexThreadCatalogFailure = code == SQLITE_BUSY || code == SQLITE_LOCKED + ? .locked + : code == SQLITE_CORRUPT || code == SQLITE_NOTADB ? .corrupt : .unreadable + return CodexThreadCatalogReadResult( + catalog: .empty, + databaseURL: url, + completeness: .unavailable(failure)) + } + let fingerprint = self.fingerprint(databaseURL: url, db: db) + let catalog = CodexThreadCatalog( + entriesById: Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }), + entriesByRolloutPath: Dictionary(uniqueKeysWithValues: entries.map { + (URL(fileURLWithPath: $0.rolloutPath).standardizedFileURL.path, $0) + }), + fingerprint: fingerprint) + return CodexThreadCatalogReadResult(catalog: catalog, databaseURL: url, completeness: .complete) + #else + return CodexThreadCatalogReadResult( + catalog: .empty, + databaseURL: url, + completeness: .unavailable(.incompatible)) + #endif + } + + private static func databaseURL(options: CostUsageScanner.Options) -> URL { + CodexLocalDataScope.resolve(options: options).stateDatabaseURL + } + + #if canImport(SQLite3) || canImport(CSQLite3) + private static func hasThreadsTable(_ db: OpaquePointer?) -> Bool? { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'threads' LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { return nil } + defer { sqlite3_finalize(stmt) } + switch sqlite3_step(stmt) { + case SQLITE_ROW: + return true + case SQLITE_DONE: + return false + default: + return nil + } + } + + private static func readEntries(_ db: OpaquePointer?) -> [CodexThreadCatalogEntry]? { + let query = """ + SELECT id, rollout_path, cwd, title, preview, model_provider, model, reasoning_effort, + created_at_ms, updated_at_ms, created_at, updated_at, archived + FROM threads + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return nil } + defer { sqlite3_finalize(stmt) } + + var entries: [CodexThreadCatalogEntry] = [] + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { return nil } + guard let id = self.text(stmt, 0), + let rolloutPath = self.text(stmt, 1) + else { continue } + entries.append(CodexThreadCatalogEntry( + id: id, + rolloutPath: rolloutPath, + cwd: self.text(stmt, 2), + title: self.nonEmptyText(stmt, 3), + preview: self.nonEmptyText(stmt, 4), + modelProvider: self.nonEmptyText(stmt, 5), + model: self.nonEmptyText(stmt, 6), + reasoningEffort: self.nonEmptyText(stmt, 7), + createdAtUnixMs: self.integer(stmt, preferredColumn: 8, fallbackColumn: 10), + updatedAtUnixMs: self.integer(stmt, preferredColumn: 9, fallbackColumn: 11), + archived: sqlite3_column_int(stmt, 12) != 0)) + } + return entries + } + + private static func fingerprint(databaseURL: URL, db: OpaquePointer?) -> String { + let attributes = (try? FileManager.default.attributesOfItem(atPath: databaseURL.path)) ?? [:] + let size = (attributes[.size] as? NSNumber)?.int64Value ?? -1 + let mtime = (attributes[.modificationDate] as? Date)?.timeIntervalSince1970 ?? -1 + let walURL = URL(fileURLWithPath: databaseURL.path + "-wal") + let walAttributes = (try? FileManager.default.attributesOfItem(atPath: walURL.path)) ?? [:] + let walSize = (walAttributes[.size] as? NSNumber)?.int64Value ?? -1 + let walMtime = (walAttributes[.modificationDate] as? Date)?.timeIntervalSince1970 ?? -1 + let summary = self.catalogSummary(db) + return [ + databaseURL.standardizedFileURL.path, + "size=\(size)", + "mtime=\(Int64(mtime * 1000))", + "walSize=\(walSize)", + "walMtime=\(Int64(walMtime * 1000))", + "rows=\(summary.rowCount)", + "maxUpdated=\(summary.maxUpdatedAtUnixMs ?? -1)", + ].joined(separator: "|") + } + + private static func catalogSummary(_ db: OpaquePointer?) -> (rowCount: Int64, maxUpdatedAtUnixMs: Int64?) { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT COUNT(*), MAX(CASE WHEN updated_at_ms IS NOT NULL THEN updated_at_ms " + + "ELSE updated_at * 1000 END) FROM threads", + -1, + &stmt, + nil) == SQLITE_OK + else { return (0, nil) } + defer { sqlite3_finalize(stmt) } + guard sqlite3_step(stmt) == SQLITE_ROW else { return (0, nil) } + let rowCount = sqlite3_column_int64(stmt, 0) + let maxUpdated = sqlite3_column_type(stmt, 1) == SQLITE_NULL ? nil : sqlite3_column_int64(stmt, 1) + return (rowCount, maxUpdated) + } + + private static func nonEmptyText(_ stmt: OpaquePointer?, _ index: Int32) -> String? { + self.text(stmt, index)?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + private static func text(_ 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) + } + + private static func integer( + _ stmt: OpaquePointer?, + preferredColumn: Int32, + fallbackColumn: Int32) -> Int64? + { + if sqlite3_column_type(stmt, preferredColumn) != SQLITE_NULL { + return sqlite3_column_int64(stmt, preferredColumn) + } + if sqlite3_column_type(stmt, fallbackColumn) != SQLITE_NULL { + return sqlite3_column_int64(stmt, fallbackColumn) * 1000 + } + return nil + } + #endif +} + +extension String { + fileprivate var nilIfEmpty: String? { + self.isEmpty ? nil : self + } +} diff --git a/Sources/CodexBarCore/CodexWorkspaceUsageFingerprint.swift b/Sources/CodexBarCore/CodexWorkspaceUsageFingerprint.swift new file mode 100644 index 000000000..4602f8810 --- /dev/null +++ b/Sources/CodexBarCore/CodexWorkspaceUsageFingerprint.swift @@ -0,0 +1,66 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif +import Foundation + +/// Stable sidecar identity for a normalized Codex rollout. +/// Scanner cursors are intentionally excluded: they do not alter the sidecar's +/// persisted usage, but rows, attribution, and cost maps do. +struct CodexWorkspaceUsageFingerprintPayload: Encodable { + let days: [String: [String: [Int]]] + let lastModel: String? + let sessionID: String? + let forkedFromID: String? + let projectPath: String? + let canonicalProjectPath: String? + let session: CostUsageCodexSessionMetadata? + let costNanos: [String: [String: Int64]]? + let prioritySurchargeNanos: [String: [String: Int64]]? + let standardCostNanos: [String: [String: Int64]]? + let priorityCostNanos: [String: [String: Int64]]? + let standardTokens: [String: [String: Int]]? + let priorityTokens: [String: [String: Int]]? + let rows: [CostUsageScanner.CodexUsageRow]? + + init(usage: CostUsageFileUsage) { + self.days = usage.days + self.lastModel = usage.lastModel + self.sessionID = usage.sessionId + self.forkedFromID = usage.forkedFromId + self.projectPath = usage.projectPath + self.canonicalProjectPath = usage.canonicalProjectPath + self.session = usage.codexSession + self.costNanos = usage.codexCostNanos + self.prioritySurchargeNanos = usage.codexPrioritySurchargeNanos + self.standardCostNanos = usage.codexStandardCostNanos + self.priorityCostNanos = usage.codexPriorityCostNanos + self.standardTokens = usage.codexStandardTokens + self.priorityTokens = usage.codexPriorityTokens + self.rows = usage.codexRows + } +} + +enum CodexWorkspaceUsageFingerprint { + static func make(for usage: CostUsageFileUsage) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(CodexWorkspaceUsageFingerprintPayload(usage: usage)) else { + return "unavailable" + } + return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +extension CostUsageFileUsage { + func codexWorkspaceUsageFingerprintValue() -> String { + self.codexWorkspaceContentFingerprint ?? CodexWorkspaceUsageFingerprint.make(for: self) + } + + func refreshingCodexWorkspaceUsageFingerprint() -> Self { + var updated = self + updated.codexWorkspaceContentFingerprint = CodexWorkspaceUsageFingerprint.make(for: updated) + return updated + } +} diff --git a/Sources/CodexBarCore/CodexWorkspaceUsageSidecar.swift b/Sources/CodexBarCore/CodexWorkspaceUsageSidecar.swift new file mode 100644 index 000000000..241d1d468 --- /dev/null +++ b/Sources/CodexBarCore/CodexWorkspaceUsageSidecar.swift @@ -0,0 +1,987 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +// swiftlint:disable type_body_length +/// CodexBar-owned persistence for local Workspaces attribution. This database +/// never attaches to or writes Codex's state database; it only imports typed +/// catalog/cache values after those sources have been read successfully. +struct CodexWorkspaceUsageSidecar: Sendable { + private static let schemaVersion = 5 + private static let snapshotPayloadFormatVersion = 3 + private let cacheRoot: URL? + + private struct RolloutSourceIdentity: Equatable { + let mtimeUnixMs: Int64 + let size: Int64 + let parsedBytes: Int64 + let sessionID: String + let producerKey: String + let pricingKey: String + let contentFingerprint: String + + init( + mtimeUnixMs: Int64, + size: Int64, + parsedBytes: Int64, + sessionID: String, + producerKey: String, + pricingKey: String, + contentFingerprint: String) + { + self.mtimeUnixMs = mtimeUnixMs + self.size = size + self.parsedBytes = parsedBytes + self.sessionID = sessionID + self.producerKey = producerKey + self.pricingKey = pricingKey + self.contentFingerprint = contentFingerprint + } + + init(usage: CostUsageFileUsage, cache: CostUsageCache) { + self.mtimeUnixMs = usage.mtimeUnixMs + self.size = usage.size + self.parsedBytes = usage.parsedBytes ?? -1 + self.sessionID = usage.codexSession?.sessionId ?? usage.sessionId ?? "" + self.producerKey = cache.producerKey ?? "" + self.pricingKey = cache.codexPricingKey ?? "" + self.contentFingerprint = usage.codexWorkspaceUsageFingerprintValue() + } + + var legacyFingerprint: String { + [ + "version=3", + "mtime=\(self.mtimeUnixMs)", + "size=\(self.size)", + "parsed=\(self.parsedBytes)", + "session=\(self.sessionID)", + "producer=\(self.producerKey)", + "pricing=\(self.pricingKey)", + "content=\(self.contentFingerprint)", + ].joined(separator: "|") + } + } + + init(cacheRoot: URL? = nil) { + self.cacheRoot = cacheRoot + } + + func loadLatestSnapshot( + scopeSignature: String, + historyDays: Int, + rootsFingerprint: [String: Int64]? = nil, + cache: CostUsageCache? = nil, + catalog: CodexThreadCatalog? = nil) -> CodexLocalProjectUsageSnapshot? + { + #if canImport(SQLite3) || canImport(CSQLite3) + guard let db = self.open(readOnly: true) else { return nil } + defer { sqlite3_close(db) } + let sql = """ + SELECT snapshot_payloads.payload, + index_state.roots_fingerprint, + index_state.catalog_fingerprint, + index_state.cache_producer_key, + index_state.pricing_key, + index_state.cache_fingerprint, + snapshot_payloads.payload_format_version + FROM snapshot_payloads + JOIN index_state ON index_state.scope_signature = snapshot_payloads.scope_signature + WHERE snapshot_payloads.scope_signature = ? + AND snapshot_payloads.history_days = ? + AND snapshot_payloads.is_complete = 1 + ORDER BY updated_at_ms DESC + LIMIT 1 + """ + guard let statement = Self.prepare(db, sql) else { return nil } + defer { sqlite3_finalize(statement) } + Self.bind(scopeSignature, to: statement, at: 1) + sqlite3_bind_int64(statement, 2, Int64(historyDays)) + guard sqlite3_step(statement) == SQLITE_ROW, + let bytes = sqlite3_column_blob(statement, 0) + else { return nil } + let length = Int(sqlite3_column_bytes(statement, 0)) + let data = Data(bytes: bytes, count: length) + let payloadFormatVersion = Int(sqlite3_column_int(statement, 6)) + guard payloadFormatVersion == Self.snapshotPayloadFormatVersion, + let snapshot = try? JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalProjectUsageSnapshot.self, + from: data) + else { return nil } + // Snapshots written before project-level detail was persisted can still + // contain valid totals, but cannot render a selected project's chart or + // sessions. Keep the sidecar's normalized rows and rebuild only this + // presentation payload on the next background refresh. + guard snapshot.hasInspectorDetail, snapshot.modelsAnalytics != nil else { return nil } + if let rootsFingerprint { + guard let rootsBytes = sqlite3_column_blob(statement, 1) else { return nil } + let rootsLength = Int(sqlite3_column_bytes(statement, 1)) + guard let persistedRoots = try? JSONDecoder().decode( + [String: Int64].self, + from: Data(bytes: rootsBytes, count: rootsLength)), + persistedRoots == rootsFingerprint + else { return nil } + } + if let cache { + guard Self.columnString(statement, at: 3) == cache.producerKey, + Self.columnString(statement, at: 4) == cache.codexPricingKey, + Self.columnString(statement, at: 5) == Self.cacheFingerprint(cache) + else { return nil } + } + if let catalog, Self.columnString(statement, at: 2) != catalog.fingerprint { + return nil + } + return snapshot + #else + return nil + #endif + } + + func synchronize( + snapshot: CodexLocalProjectUsageSnapshot, + cache: CostUsageCache, + catalog: CodexThreadCatalog, + catalogIsComplete: Bool = true, + rootsFingerprint: [String: Int64]) throws + { + #if canImport(SQLite3) || canImport(CSQLite3) + guard let db = self.open(readOnly: false) else { + throw SidecarError.openFailed + } + defer { sqlite3_close(db) } + try Self.ensureSchema(db) + try Self.begin(db) + do { + let generation = UUID().uuidString + try self.upsertCatalog(catalog, generation: generation, db: db) + if catalogIsComplete { + try self.pruneCatalog(generation: generation, db: db) + } + try self.importChangedRollouts(cache, catalog: catalog, generation: generation, db: db) + try self.markMissingRollouts(generation: generation, db: db) + try self.storeSnapshot(snapshot, cache: cache, catalog: catalog, rootsFingerprint: rootsFingerprint, db: db) + try Self.commit(db) + } catch { + Self.rollback(db) + throw error + } + #else + _ = snapshot + _ = cache + _ = catalog + _ = catalogIsComplete + _ = rootsFingerprint + #endif + } + + /// Imports only source deltas. Callers can then aggregate from + /// `usageCache(roots:)` before committing a new complete snapshot. + func synchronizeSources( + cache: CostUsageCache, + catalog: CodexThreadCatalog, + catalogIsComplete: Bool = true) throws + { + #if canImport(SQLite3) || canImport(CSQLite3) + guard let db = self.open(readOnly: false) else { throw SidecarError.openFailed } + defer { sqlite3_close(db) } + try Self.ensureSchema(db) + try Self.begin(db) + do { + let generation = UUID().uuidString + try self.upsertCatalog(catalog, generation: generation, db: db) + if catalogIsComplete { + try self.pruneCatalog(generation: generation, db: db) + } + try self.importChangedRollouts(cache, catalog: catalog, generation: generation, db: db) + try self.markMissingRollouts(generation: generation, db: db) + try Self.commit(db) + } catch { + Self.rollback(db) + throw error + } + #else + _ = cache + _ = catalog + _ = catalogIsComplete + #endif + } + + /// Rehydrates only the attribution fields and daily usage rows needed by + /// the existing project aggregator. The scanner remains authoritative for + /// JSONL cursors and token deltas; this avoids another walk of its cache. + func usageCache(roots: [String: Int64]) throws -> CostUsageCache { + #if canImport(SQLite3) || canImport(CSQLite3) + guard let db = self.open(readOnly: true) else { throw SidecarError.openFailed } + defer { sqlite3_close(db) } + let sql = """ + SELECT r.rollout_path, + COALESCE(c.id, r.session_id), + COALESCE(c.cwd, r.cwd), + COALESCE(c.title, c.preview, r.title), + COALESCE(c.created_at_ms, r.started_at_ms), + COALESCE(c.updated_at_ms, r.latest_activity_ms), + COALESCE(c.model, r.last_model), + r.forked_from_id, + r.project_path, + r.canonical_project_path, + d.day, + d.model, + d.input_tokens, + d.cached_input_tokens, + d.output_tokens, + d.cost_nanos, + d.standard_tokens, + d.priority_tokens, + d.standard_cost_nanos, + d.priority_cost_nanos, + d.priority_surcharge_nanos + FROM usage_rollouts r + LEFT JOIN catalog_threads c ON c.id = r.session_id OR c.rollout_path = r.rollout_path + JOIN usage_daily d ON d.rollout_path = r.rollout_path + WHERE r.is_present = 1 + ORDER BY r.rollout_path, d.day, d.model + """ + guard let statement = Self.prepare(db, sql) else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(statement) } + + var cache = CostUsageCache() + cache.roots = roots + while sqlite3_step(statement) == SQLITE_ROW { + guard let path = Self.columnString(statement, at: 0), + let day = Self.columnString(statement, at: 10), + let model = Self.columnString(statement, at: 11) + else { continue } + + var usage = cache.files[path] ?? Self.emptyFileUsage( + sessionId: Self.columnString(statement, at: 1), + cwd: Self.columnString(statement, at: 2), + title: Self.columnString(statement, at: 3), + startedAtUnixMs: Self.columnInt64(statement, at: 4), + latestActivityUnixMs: Self.columnInt64(statement, at: 5), + lastModel: Self.columnString(statement, at: 6), + forkedFromId: Self.columnString(statement, at: 7), + projectPath: Self.columnString(statement, at: 8), + canonicalProjectPath: Self.columnString(statement, at: 9)) + usage.days[day, default: [:]][model] = [ + Int(sqlite3_column_int64(statement, 12)), + Int(sqlite3_column_int64(statement, 13)), + Int(sqlite3_column_int64(statement, 14)), + ] + Self.assign(Self.columnInt64(statement, at: 15), to: &usage.codexCostNanos, day: day, model: model) + Self.assign(Self.columnInt64(statement, at: 16), to: &usage.codexStandardTokens, day: day, model: model) + Self.assign(Self.columnInt64(statement, at: 17), to: &usage.codexPriorityTokens, day: day, model: model) + Self.assign(Self.columnInt64(statement, at: 18), to: &usage.codexStandardCostNanos, day: day, model: model) + Self.assign(Self.columnInt64(statement, at: 19), to: &usage.codexPriorityCostNanos, day: day, model: model) + Self.assign( + Self.columnInt64(statement, at: 20), + to: &usage.codexPrioritySurchargeNanos, + day: day, + model: model) + cache.files[path] = usage + } + let eventSQL = """ + SELECT e.rollout_path, e.day, e.canonical_model, e.raw_model, e.turn_id, e.event_index, + e.timestamp_ms, e.input_tokens, e.cached_input_tokens, e.output_tokens, + e.known_cost_nanos, e.unpriced_tokens, e.pricing_model, e.pricing_mode, + e.reasoning_tokens + FROM usage_events e + JOIN usage_rollouts r ON r.rollout_path = e.rollout_path + WHERE r.is_present = 1 AND r.event_detail_complete = 1 + ORDER BY e.rollout_path, e.event_index + """ + guard let eventStatement = Self.prepare(db, eventSQL) else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(eventStatement) } + while sqlite3_step(eventStatement) == SQLITE_ROW { + guard let path = Self.columnString(eventStatement, at: 0), + var usage = cache.files[path], + let day = Self.columnString(eventStatement, at: 1), + let canonicalModel = Self.columnString(eventStatement, at: 2) + else { continue } + var rows = usage.codexRows ?? [] + rows.append(CostUsageScanner.CodexUsageRow( + day: day, + model: canonicalModel, + rawModel: Self.columnString(eventStatement, at: 3), + turnID: Self.columnString(eventStatement, at: 4), + eventIndex: Self.columnInt64(eventStatement, at: 5).map(Int.init), + timestampUnixMs: Self.columnInt64(eventStatement, at: 6), + input: Int(sqlite3_column_int64(eventStatement, 7)), + cached: Int(sqlite3_column_int64(eventStatement, 8)), + output: Int(sqlite3_column_int64(eventStatement, 9)), + reasoning: Self.columnInt64(eventStatement, at: 14).map(Int.init), + knownCostNanos: Self.columnInt64(eventStatement, at: 10), + unpricedTokens: Self.columnInt64(eventStatement, at: 11).map(Int.init), + pricingModel: Self.columnString(eventStatement, at: 12), + pricingMode: Self.columnString(eventStatement, at: 13))) + usage.codexRows = rows + cache.files[path] = usage + } + return cache + #else + _ = roots + return CostUsageCache() + #endif + } + + func clear() { + try? FileManager.default.removeItem(at: self.databaseURL()) + try? FileManager.default.removeItem(at: URL(fileURLWithPath: self.databaseURL().path + "-wal")) + try? FileManager.default.removeItem(at: URL(fileURLWithPath: self.databaseURL().path + "-shm")) + } + + private func databaseURL() -> URL { + Self.databaseURL(cacheRoot: self.cacheRoot) + } + + static func databaseURL(cacheRoot: URL?) -> URL { + let root = cacheRoot ?? CostUsageCacheIO.defaultCacheRoot() + return root + .appendingPathComponent("local-usage", isDirectory: true) + .appendingPathComponent("codex-workspaces-v1.sqlite", isDirectory: false) + } + + #if canImport(SQLite3) || canImport(CSQLite3) + private func open(readOnly: Bool) -> OpaquePointer? { + let url = self.databaseURL() + if !readOnly { + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + } + var db: OpaquePointer? + let flags = readOnly ? SQLITE_OPEN_READONLY : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE + guard sqlite3_open_v2(url.path, &db, flags, nil) == SQLITE_OK else { + sqlite3_close(db) + return nil + } + sqlite3_busy_timeout(db, 250) + if !readOnly { + guard sqlite3_exec(db, "PRAGMA journal_mode = WAL", nil, nil, nil) == SQLITE_OK, + sqlite3_exec(db, "PRAGMA synchronous = NORMAL", nil, nil, nil) == SQLITE_OK + else { + sqlite3_close(db) + return nil + } + } + return db + } + + private static func ensureSchema(_ db: OpaquePointer?) throws { + let current = Self.userVersion(db) + guard current == 0 || current == Self.schemaVersion else { + throw SidecarError.incompatibleSchema + } + guard current == 0 else { return } + try Self.execute(db, """ + CREATE TABLE IF NOT EXISTS schema_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS catalog_threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + cwd TEXT, + title TEXT, + preview TEXT, + model_provider TEXT, + model TEXT, + reasoning_effort TEXT, + created_at_ms INTEGER, + updated_at_ms INTEGER, + archived INTEGER NOT NULL, + source_fingerprint TEXT, + last_seen_generation TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS usage_rollouts ( + rollout_path TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + session_id TEXT, + cwd TEXT, + title TEXT, + started_at_ms INTEGER, + latest_activity_ms INTEGER, + last_model TEXT, + project_path TEXT, + canonical_project_path TEXT, + forked_from_id TEXT, + source_mtime_ms INTEGER, + source_size INTEGER, + source_parsed_bytes INTEGER, + source_session_id TEXT, + source_producer_key TEXT, + source_pricing_key TEXT, + content_fingerprint TEXT, + event_detail_complete INTEGER NOT NULL DEFAULT 0, + is_present INTEGER NOT NULL DEFAULT 1, + last_seen_generation TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS usage_daily ( + rollout_path TEXT NOT NULL, + day TEXT NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL, + cached_input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + cost_nanos INTEGER, + standard_tokens INTEGER, + priority_tokens INTEGER, + standard_cost_nanos INTEGER, + priority_cost_nanos INTEGER, + priority_surcharge_nanos INTEGER, + PRIMARY KEY (rollout_path, day, model) + ); + CREATE TABLE IF NOT EXISTS usage_events ( + rollout_path TEXT NOT NULL, + event_index INTEGER NOT NULL, + timestamp_ms INTEGER NOT NULL, + day TEXT NOT NULL, + canonical_model TEXT NOT NULL, + raw_model TEXT, + turn_id TEXT, + input_tokens INTEGER NOT NULL, + cached_input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + known_cost_nanos INTEGER, + unpriced_tokens INTEGER NOT NULL, + pricing_model TEXT, + pricing_mode TEXT, + reasoning_tokens INTEGER, + PRIMARY KEY (rollout_path, event_index) + ); + CREATE TABLE IF NOT EXISTS snapshot_payloads ( + scope_signature TEXT NOT NULL, + history_days INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL, + is_complete INTEGER NOT NULL, + payload_format_version INTEGER NOT NULL DEFAULT 1, + payload BLOB NOT NULL, + PRIMARY KEY (scope_signature, history_days) + ); + CREATE TABLE IF NOT EXISTS index_state ( + scope_signature TEXT PRIMARY KEY, + roots_fingerprint TEXT NOT NULL, + catalog_fingerprint TEXT, + cache_producer_key TEXT, + pricing_key TEXT, + cache_fingerprint TEXT, + last_success_ms INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS usage_daily_rollout_day ON usage_daily (rollout_path, day); + CREATE INDEX IF NOT EXISTS usage_events_timestamp ON usage_events (timestamp_ms); + CREATE INDEX IF NOT EXISTS usage_events_model_timestamp ON usage_events (canonical_model, timestamp_ms); + CREATE INDEX IF NOT EXISTS usage_events_turn ON usage_events (turn_id); + CREATE INDEX IF NOT EXISTS usage_rollouts_session ON usage_rollouts (session_id); + CREATE INDEX IF NOT EXISTS catalog_threads_rollout ON catalog_threads (rollout_path); + """) + try Self.execute(db, "PRAGMA user_version = \(Self.schemaVersion)") + } + + private func upsertCatalog( + _ catalog: CodexThreadCatalog, + generation: String, + db: OpaquePointer?) throws + { + let sql = """ + INSERT INTO catalog_threads ( + id, rollout_path, cwd, title, preview, model_provider, model, reasoning_effort, + created_at_ms, updated_at_ms, archived, source_fingerprint, last_seen_generation + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + rollout_path = excluded.rollout_path, + cwd = excluded.cwd, + title = excluded.title, + preview = excluded.preview, + model_provider = excluded.model_provider, + model = excluded.model, + reasoning_effort = excluded.reasoning_effort, + created_at_ms = excluded.created_at_ms, + updated_at_ms = excluded.updated_at_ms, + archived = excluded.archived, + source_fingerprint = excluded.source_fingerprint, + last_seen_generation = excluded.last_seen_generation + """ + guard let statement = Self.prepare(db, sql) else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(statement) } + for entry in catalog.entriesById.values { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + Self.bind(entry.id, to: statement, at: 1) + Self.bind(entry.rolloutPath, to: statement, at: 2) + Self.bind(entry.cwd, to: statement, at: 3) + Self.bind(entry.title, to: statement, at: 4) + Self.bind(entry.preview, to: statement, at: 5) + Self.bind(entry.modelProvider, to: statement, at: 6) + Self.bind(entry.model, to: statement, at: 7) + Self.bind(entry.reasoningEffort, to: statement, at: 8) + Self.bind(entry.createdAtUnixMs, to: statement, at: 9) + Self.bind(entry.updatedAtUnixMs, to: statement, at: 10) + sqlite3_bind_int(statement, 11, entry.archived ? 1 : 0) + Self.bind(catalog.fingerprint, to: statement, at: 12) + Self.bind(generation, to: statement, at: 13) + guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + } + } + + /// A complete catalog generation is authoritative: entries absent from it + /// must no longer override rollout-derived metadata. Unavailable reads + /// deliberately skip this deletion so the last-good attribution remains. + private func pruneCatalog(generation: String, db: OpaquePointer?) throws { + guard let statement = Self.prepare( + db, + "DELETE FROM catalog_threads WHERE last_seen_generation != ?") + else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(statement) } + Self.bind(generation, to: statement, at: 1) + guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + } + + private func importChangedRollouts( + _ cache: CostUsageCache, + catalog: CodexThreadCatalog, + generation: String, + db: OpaquePointer?) throws + { + let existing = try Self.existingRolloutSourceIdentities(db: db) + guard let touchStatement = Self.prepare( + db, + "UPDATE usage_rollouts SET is_present = 1, last_seen_generation = ? WHERE rollout_path = ?") + else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(touchStatement) } + + for (path, usage) in cache.files { + let identity = RolloutSourceIdentity(usage: usage, cache: cache) + if existing[path] == identity { + try Self.touchRollout(path: path, generation: generation, statement: touchStatement) + continue + } + let catalogEntry = catalog.entry( + sessionId: usage.codexSession?.sessionId ?? usage.sessionId, + rolloutPath: path) + try Self.deleteUsage(path: path, db: db) + try Self.upsertRollout( + path: path, + usage: usage, + catalogEntry: catalogEntry, + identity: identity, + generation: generation, + db: db) + try Self.insertDaily(path: path, usage: usage, db: db) + try Self.insertEvents(path: path, usage: usage, db: db) + } + } + + private func markMissingRollouts(generation: String, db: OpaquePointer?) throws { + guard let statement = Self.prepare( + db, + "UPDATE usage_rollouts SET is_present = 0 WHERE last_seen_generation != ?") + else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(statement) } + Self.bind(generation, to: statement, at: 1) + guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + } + + private func storeSnapshot( + _ snapshot: CodexLocalProjectUsageSnapshot, + cache: CostUsageCache, + catalog: CodexThreadCatalog, + rootsFingerprint: [String: Int64], + db: OpaquePointer?) throws + { + let data = try JSONEncoder.codexLocalProjectUsageSidecar.encode(snapshot) + let sql = """ + INSERT INTO snapshot_payloads ( + scope_signature, history_days, updated_at_ms, is_complete, payload_format_version, payload + ) VALUES (?, ?, ?, 1, ?, ?) + ON CONFLICT(scope_signature, history_days) DO UPDATE SET + updated_at_ms = excluded.updated_at_ms, + is_complete = 1, + payload_format_version = excluded.payload_format_version, + payload = excluded.payload + """ + guard let statement = Self.prepare(db, sql) else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(statement) } + Self.bind(snapshot.scopeSignature, to: statement, at: 1) + sqlite3_bind_int64(statement, 2, Int64(snapshot.historyDays)) + sqlite3_bind_int64(statement, 3, Int64((snapshot.updatedAt.timeIntervalSince1970 * 1000).rounded())) + sqlite3_bind_int(statement, 4, Int32(Self.snapshotPayloadFormatVersion)) + Self.bind(data, to: statement, at: 5) + guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + + let stateSQL = """ + INSERT INTO index_state ( + scope_signature, roots_fingerprint, catalog_fingerprint, cache_producer_key, pricing_key, cache_fingerprint, + last_success_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(scope_signature) DO UPDATE SET + roots_fingerprint = excluded.roots_fingerprint, + catalog_fingerprint = COALESCE(excluded.catalog_fingerprint, index_state.catalog_fingerprint), + cache_producer_key = excluded.cache_producer_key, + pricing_key = excluded.pricing_key, + cache_fingerprint = excluded.cache_fingerprint, + last_success_ms = excluded.last_success_ms + """ + guard let state = Self.prepare(db, stateSQL) else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(state) } + Self.bind(snapshot.scopeSignature, to: state, at: 1) + let rootsData = try JSONEncoder().encode(rootsFingerprint) + Self.bind(rootsData, to: state, at: 2) + Self.bind(catalog.fingerprint, to: state, at: 3) + Self.bind(cache.producerKey, to: state, at: 4) + Self.bind(cache.codexPricingKey, to: state, at: 5) + Self.bind(Self.cacheFingerprint(cache), to: state, at: 6) + sqlite3_bind_int64(state, 7, Int64((snapshot.updatedAt.timeIntervalSince1970 * 1000).rounded())) + guard sqlite3_step(state) == SQLITE_DONE else { throw SidecarError.writeFailed } + } + + // Source fields remain explicit so the sidecar never reconstructs parser state. + // swiftlint:disable:next function_parameter_count + private static func upsertRollout( + path: String, + usage: CostUsageFileUsage, + catalogEntry: CodexThreadCatalogEntry?, + identity: RolloutSourceIdentity, + generation: String, + db: OpaquePointer?) throws + { + let session = usage.codexSession + let sql = """ + INSERT INTO usage_rollouts ( + rollout_path, fingerprint, session_id, cwd, title, started_at_ms, latest_activity_ms, last_model, + project_path, canonical_project_path, forked_from_id, + source_mtime_ms, source_size, source_parsed_bytes, source_session_id, source_producer_key, + source_pricing_key, content_fingerprint, event_detail_complete, is_present, last_seen_generation + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?) + ON CONFLICT(rollout_path) DO UPDATE SET + fingerprint = excluded.fingerprint, + session_id = COALESCE(excluded.session_id, usage_rollouts.session_id), + cwd = COALESCE(excluded.cwd, usage_rollouts.cwd), + title = COALESCE(excluded.title, usage_rollouts.title), + started_at_ms = COALESCE(excluded.started_at_ms, usage_rollouts.started_at_ms), + latest_activity_ms = COALESCE(excluded.latest_activity_ms, usage_rollouts.latest_activity_ms), + last_model = COALESCE(excluded.last_model, usage_rollouts.last_model), + project_path = COALESCE(excluded.project_path, usage_rollouts.project_path), + canonical_project_path = COALESCE(excluded.canonical_project_path, usage_rollouts.canonical_project_path), + forked_from_id = COALESCE(excluded.forked_from_id, usage_rollouts.forked_from_id), + source_mtime_ms = excluded.source_mtime_ms, + source_size = excluded.source_size, + source_parsed_bytes = excluded.source_parsed_bytes, + source_session_id = excluded.source_session_id, + source_producer_key = excluded.source_producer_key, + source_pricing_key = excluded.source_pricing_key, + content_fingerprint = excluded.content_fingerprint, + event_detail_complete = excluded.event_detail_complete, + is_present = 1, + last_seen_generation = excluded.last_seen_generation + """ + guard let statement = Self.prepare(db, sql) else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(statement) } + Self.bind(path, to: statement, at: 1) + Self.bind(identity.legacyFingerprint, to: statement, at: 2) + Self.bind(catalogEntry?.id ?? session?.sessionId ?? usage.sessionId, to: statement, at: 3) + Self.bind(catalogEntry?.cwd ?? session?.cwd, to: statement, at: 4) + Self.bind(catalogEntry?.title ?? session?.title, to: statement, at: 5) + Self.bind(catalogEntry?.createdAtUnixMs ?? session?.startedAtUnixMs, to: statement, at: 6) + Self.bind(catalogEntry?.updatedAtUnixMs ?? session?.latestActivityUnixMs, to: statement, at: 7) + Self.bind(catalogEntry?.model ?? usage.lastModel, to: statement, at: 8) + Self.bind(usage.projectPath, to: statement, at: 9) + Self.bind(usage.canonicalProjectPath, to: statement, at: 10) + Self.bind( + catalogEntry == nil ? session?.forkedFromId ?? usage.forkedFromId : usage.forkedFromId, + to: statement, + at: 11) + sqlite3_bind_int64(statement, 12, identity.mtimeUnixMs) + sqlite3_bind_int64(statement, 13, identity.size) + sqlite3_bind_int64(statement, 14, identity.parsedBytes) + Self.bind(identity.sessionID, to: statement, at: 15) + Self.bind(identity.producerKey, to: statement, at: 16) + Self.bind(identity.pricingKey, to: statement, at: 17) + Self.bind(identity.contentFingerprint, to: statement, at: 18) + sqlite3_bind_int(statement, 19, Self.hasCompleteEventDetail(usage) ? 1 : 0) + Self.bind(generation, to: statement, at: 20) + guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + } + + private static func insertDaily(path: String, usage: CostUsageFileUsage, db: OpaquePointer?) throws { + let sql = """ + INSERT INTO usage_daily ( + rollout_path, day, model, input_tokens, cached_input_tokens, output_tokens, cost_nanos, + standard_tokens, priority_tokens, standard_cost_nanos, priority_cost_nanos, priority_surcharge_nanos + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + guard let statement = Self.prepare(db, sql) else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(statement) } + for (day, models) in usage.days { + for (model, values) in models { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + Self.bind(path, to: statement, at: 1) + Self.bind(day, to: statement, at: 2) + Self.bind(model, to: statement, at: 3) + sqlite3_bind_int64(statement, 4, Int64(max(0, values[safe: 0] ?? 0))) + sqlite3_bind_int64(statement, 5, Int64(max(0, values[safe: 1] ?? 0))) + sqlite3_bind_int64(statement, 6, Int64(max(0, values[safe: 2] ?? 0))) + Self.bind(usage.codexCostNanos?[day]?[model], to: statement, at: 7) + Self.bind(usage.codexStandardTokens?[day]?[model], to: statement, at: 8) + Self.bind(usage.codexPriorityTokens?[day]?[model], to: statement, at: 9) + Self.bind(usage.codexStandardCostNanos?[day]?[model], to: statement, at: 10) + Self.bind(usage.codexPriorityCostNanos?[day]?[model], to: statement, at: 11) + Self.bind(usage.codexPrioritySurchargeNanos?[day]?[model], to: statement, at: 12) + guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + } + } + } + + private static func insertEvents(path: String, usage: CostUsageFileUsage, db: OpaquePointer?) throws { + guard self.hasCompleteEventDetail(usage), let rows = usage.codexRows else { return } + let sql = """ + INSERT INTO usage_events ( + rollout_path, event_index, timestamp_ms, day, canonical_model, raw_model, turn_id, + input_tokens, cached_input_tokens, output_tokens, known_cost_nanos, unpriced_tokens, + pricing_model, pricing_mode, reasoning_tokens + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + guard let statement = Self.prepare(db, sql) else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(statement) } + for row in rows { + guard let eventIndex = row.eventIndex, let timestampUnixMs = row.timestampUnixMs else { continue } + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + Self.bind(path, to: statement, at: 1) + sqlite3_bind_int64(statement, 2, Int64(eventIndex)) + sqlite3_bind_int64(statement, 3, timestampUnixMs) + Self.bind(row.day, to: statement, at: 4) + Self.bind(row.model, to: statement, at: 5) + Self.bind(row.rawModel, to: statement, at: 6) + Self.bind(row.turnID, to: statement, at: 7) + sqlite3_bind_int64(statement, 8, Int64(max(0, row.input))) + sqlite3_bind_int64(statement, 9, Int64(max(0, row.cached))) + sqlite3_bind_int64(statement, 10, Int64(max(0, row.output))) + Self.bind(row.knownCostNanos, to: statement, at: 11) + sqlite3_bind_int64(statement, 12, Int64(max(0, row.unpricedTokens ?? 0))) + Self.bind(row.pricingModel, to: statement, at: 13) + Self.bind(row.pricingMode, to: statement, at: 14) + Self.bind(row.reasoning.map(Int64.init), to: statement, at: 15) + guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + } + } + + private static func deleteUsage(path: String, db: OpaquePointer?) throws { + for table in ["usage_daily", "usage_events"] { + guard let statement = prepare(db, "DELETE FROM \(table) WHERE rollout_path = ?") + else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(statement) } + Self.bind(path, to: statement, at: 1) + guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + } + } + + private static func hasCompleteEventDetail(_ usage: CostUsageFileUsage) -> Bool { + guard let rows = usage.codexRows, !rows.isEmpty else { return usage.days.isEmpty } + return rows.allSatisfy { $0.eventIndex != nil && $0.timestampUnixMs != nil } + } + + private static func touchRollout(path: String, generation: String, statement: OpaquePointer?) throws { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + self.bind(generation, to: statement, at: 1) + self.bind(path, to: statement, at: 2) + guard sqlite3_step(statement) == SQLITE_DONE else { throw SidecarError.writeFailed } + } + + private static func existingRolloutSourceIdentities( + db: OpaquePointer?) throws -> [String: RolloutSourceIdentity] + { + guard let statement = prepare( + db, + """ + SELECT rollout_path, source_mtime_ms, source_size, source_parsed_bytes, source_session_id, + source_producer_key, source_pricing_key, content_fingerprint + FROM usage_rollouts + WHERE event_detail_complete = 1 + """) + else { throw SidecarError.statementFailed } + defer { sqlite3_finalize(statement) } + var identities: [String: RolloutSourceIdentity] = [:] + while sqlite3_step(statement) == SQLITE_ROW { + guard let path = Self.columnString(statement, at: 0), + let contentFingerprint = Self.columnString(statement, at: 7) + else { continue } + identities[path] = RolloutSourceIdentity( + mtimeUnixMs: Self.columnInt64(statement, at: 1) ?? Int64.min, + size: Self.columnInt64(statement, at: 2) ?? Int64.min, + parsedBytes: Self.columnInt64(statement, at: 3) ?? Int64.min, + sessionID: Self.columnString(statement, at: 4) ?? "", + producerKey: Self.columnString(statement, at: 5) ?? "", + pricingKey: Self.columnString(statement, at: 6) ?? "", + contentFingerprint: contentFingerprint) + } + return identities + } + + private static func cacheFingerprint(_ cache: CostUsageCache) -> String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for (path, usage) in cache.files.sorted(by: { $0.key < $1.key }) { + let identity = RolloutSourceIdentity(usage: usage, cache: cache) + for byte in "\(path)|\(identity.legacyFingerprint)\n".utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + } + return String(hash, radix: 16) + } + + private static func columnString(_ statement: OpaquePointer?, at index: Int32) -> String? { + guard let text = sqlite3_column_text(statement, index) else { return nil } + return String(cString: text) + } + + private static func columnInt64(_ statement: OpaquePointer?, at index: Int32) -> Int64? { + sqlite3_column_type(statement, index) == SQLITE_NULL ? nil : sqlite3_column_int64(statement, index) + } + + private static func assign( + _ value: Int64?, + to map: inout [String: [String: T]]?, + day: String, + model: String) + { + guard let value else { return } + var values = map ?? [:] + values[day, default: [:]][model] = T(value) + map = values + } + + // A SQL row carries scanner metadata as individual columns. Keeping the + // parameters explicit avoids a lossy intermediate representation. + // swiftlint:disable:next function_parameter_count + private static func emptyFileUsage( + sessionId: String?, + cwd: String?, + title: String?, + startedAtUnixMs: Int64?, + latestActivityUnixMs: Int64?, + lastModel: String?, + forkedFromId: String?, + projectPath: String?, + canonicalProjectPath: String?) -> CostUsageFileUsage + { + CostUsageFileUsage( + mtimeUnixMs: 0, + size: 0, + days: [:], + parsedBytes: nil, + lastModel: lastModel, + lastTotals: nil, + lastCountedTotals: nil, + lastRawTotalsBaseline: nil, + hasDivergentTotals: nil, + lastCodexTurnID: nil, + sessionId: sessionId, + forkedFromId: forkedFromId, + projectPath: projectPath, + canonicalProjectPath: canonicalProjectPath, + codexCostCacheComplete: true, + codexSession: CostUsageCodexSessionMetadata( + sessionId: sessionId, + forkedFromId: forkedFromId, + cwd: cwd, + title: title, + startedAtUnixMs: startedAtUnixMs, + latestActivityUnixMs: latestActivityUnixMs), + codexCostNanos: nil, + codexPrioritySurchargeNanos: nil, + codexStandardCostNanos: nil, + codexPriorityCostNanos: nil, + codexStandardTokens: nil, + codexPriorityTokens: nil, + codexTurnIDs: nil, + codexRows: nil, + claudeRows: nil) + } + + private static func begin(_ db: OpaquePointer?) throws { + try self.execute(db, "BEGIN IMMEDIATE") + } + + private static func commit(_ db: OpaquePointer?) throws { + try self.execute(db, "COMMIT") + } + + private static func rollback(_ db: OpaquePointer?) { + try? self.execute(db, "ROLLBACK") + } + + private static func execute(_ db: OpaquePointer?, _ sql: String) throws { + guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { throw SidecarError.writeFailed } + } + + private static func prepare(_ db: OpaquePointer?, _ sql: String) -> OpaquePointer? { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return nil } + return statement + } + + private static func bind(_ value: String?, to statement: OpaquePointer?, at index: Int32) { + guard let value else { + sqlite3_bind_null(statement, index) + return + } + sqlite3_bind_text(statement, index, value, -1, Self.sqliteTransient) + } + + private static func bind(_ value: Int64?, to statement: OpaquePointer?, at index: Int32) { + guard let value else { + sqlite3_bind_null(statement, index) + return + } + sqlite3_bind_int64(statement, index, value) + } + + private static func bind(_ value: Int?, to statement: OpaquePointer?, at index: Int32) { + self.bind(value.map(Int64.init), to: statement, at: index) + } + + private static func bind(_ value: Data, to statement: OpaquePointer?, at index: Int32) { + _ = value.withUnsafeBytes { bytes in + sqlite3_bind_blob(statement, index, bytes.baseAddress, Int32(value.count), Self.sqliteTransient) + } + } + + private static func userVersion(_ db: OpaquePointer?) -> Int32 { + guard let statement = prepare(db, "PRAGMA user_version") else { return 0 } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { return 0 } + return sqlite3_column_int(statement, 0) + } + + private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + private enum SidecarError: Error { + case openFailed + case incompatibleSchema + case statementFailed + case writeFailed + } + #endif +} + +extension JSONDecoder { + static var codexLocalProjectUsageSidecar: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + return decoder + } +} + +extension JSONEncoder { + static var codexLocalProjectUsageSidecar: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + return encoder + } +} + +// swiftlint:enable type_body_length diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 09323cce5..f58697bba 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -129,6 +129,13 @@ public struct CodexBarConfig: Codable, Sendable { hooks: self.hooks) } + public func sanitizedForDump(showSecrets: Bool = false) -> CodexBarConfig { + guard !showSecrets else { return self } + var copy = self + copy.providers = copy.providers.map { $0.sanitizedForDump() } + return copy + } + public func orderedProviders() -> [UsageProvider] { self.providers.map(\.id) } @@ -291,6 +298,23 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { Self.clean(self.deepseekProfileScope) } + public func sanitizedForDump() -> ProviderConfig { + var copy = self + if copy.apiKey != nil { + copy.apiKey = "[REDACTED]" + } + if copy.secretKey != nil { + copy.secretKey = "[REDACTED]" + } + if copy.cookieHeader != nil { + copy.cookieHeader = "[REDACTED]" + } + if let tokenAccounts = copy.tokenAccounts { + copy.tokenAccounts = tokenAccounts.sanitizedForDump() + } + return copy + } + private static func clean(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 295fa75e0..557357585 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -26,6 +26,7 @@ public enum CostUsageError: LocalizedError, Sendable { } } +// swiftlint:disable:next type_body_length public struct CostUsageFetcher: Sendable { package struct CachedCodexTokenSnapshotResult: Sendable { package let snapshot: CostUsageTokenSnapshot @@ -66,6 +67,45 @@ public struct CostUsageFetcher: Sendable { scannerOptions: self.scannerOptionsOverride()) } + public func loadCachedCodexLocalProjectUsageSnapshot( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30, + hidePersonalInfo: Bool) async -> CodexLocalProjectUsageSnapshot? + { + await Self.loadCachedCodexLocalProjectUsageSnapshot( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + hidePersonalInfo: hidePersonalInfo, + scannerOptions: self.scannerOptionsOverride()) + } + + public func loadCodexLocalProjectUsageSnapshot( + now: Date = Date(), + forceRefresh: Bool = false, + codexHomePath: String? = nil, + historyDays: Int = 30, + hidePersonalInfo: Bool, + progress: (@Sendable (CodexLocalProjectUsageIndexProgress) -> Void)? = nil) + async throws -> CodexLocalProjectUsageSnapshot + { + try await Self.loadCodexLocalProjectUsageSnapshot( + now: now, + forceRefresh: forceRefresh, + codexHomePath: codexHomePath, + historyDays: historyDays, + hidePersonalInfo: hidePersonalInfo, + progress: progress, + scannerOptions: self.scannerOptionsOverride()) + } + + public func clearCachedCodexLocalProjectUsageSnapshot(codexHomePath: String? = nil) async { + await Self.clearCachedCodexLocalProjectUsageSnapshot( + codexHomePath: codexHomePath, + scannerOptions: self.scannerOptionsOverride()) + } + public func loadTokenSnapshot( provider: UsageProvider, environment: [String: String] = ProcessInfo.processInfo.environment, @@ -571,6 +611,77 @@ public struct CostUsageFetcher: Sendable { } } + static func loadCachedCodexLocalProjectUsageSnapshot( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30, + hidePersonalInfo: Bool, + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CodexLocalProjectUsageSnapshot? + { + let cachedSnapshot: CodexLocalProjectUsageSnapshot?? = try? await CostUsageScanExecutor.run { _ in + let options = Self.codexLocalScannerOptions( + codexHomePath: codexHomePath, + overrideScannerOptions: overrideScannerOptions) + return CodexLocalProjectUsageIndexer.cachedSnapshot( + now: now, + historyDays: historyDays, + options: CodexLocalProjectUsageIndexer.Options(scannerOptions: options)) + } + return cachedSnapshot.flatMap(\.self)?.hidingPersonalInformation(hidePersonalInfo) + } + + static func loadCodexLocalProjectUsageSnapshot( + now: Date = Date(), + forceRefresh: Bool = false, + codexHomePath: String? = nil, + historyDays: Int = 30, + hidePersonalInfo: Bool, + progress: (@Sendable (CodexLocalProjectUsageIndexProgress) -> Void)? = nil, + scannerOptions overrideScannerOptions: CostUsageScanner + .Options? = nil) async throws -> CodexLocalProjectUsageSnapshot + { + let options = Self.codexLocalScannerOptions( + codexHomePath: codexHomePath, + overrideScannerOptions: overrideScannerOptions) + let scanOptions = options + let snapshot = try await CostUsageScanExecutor.run { checkCancellation in + try CodexLocalProjectUsageIndexer.loadSnapshot( + now: now, + historyDays: historyDays, + forceRefresh: forceRefresh, + options: CodexLocalProjectUsageIndexer.Options(scannerOptions: scanOptions), + progress: progress, + checkCancellation: checkCancellation) + } + return snapshot.hidingPersonalInformation(hidePersonalInfo) + } + + static func clearCachedCodexLocalProjectUsageSnapshot( + codexHomePath: String? = nil, + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async + { + _ = try? await CostUsageScanExecutor.run { _ in + let options = Self.codexLocalScannerOptions( + codexHomePath: codexHomePath, + overrideScannerOptions: overrideScannerOptions) + CodexWorkspaceUsageSidecar(cacheRoot: options.cacheRoot).clear() + } + } + + private static func codexLocalScannerOptions( + codexHomePath: String?, + overrideScannerOptions: CostUsageScanner.Options?) -> CostUsageScanner.Options + { + var options = overrideScannerOptions ?? CostUsageScanner.Options() + if let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !codexHomePath.isEmpty + { + options.codexSessionsRoot = URL(fileURLWithPath: codexHomePath, isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + } + return CodexLocalDataScope.resolve(options: options).applying(to: options) + } + private static func loadBedrockDailyReport( environment: [String: String], since: Date, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 686820c9a..726441f0e 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 = "458ef34b42c9c5da" + static let value = "54bb705996cff6d9" } diff --git a/Sources/CodexBarCore/KeychainAccessGate.swift b/Sources/CodexBarCore/KeychainAccessGate.swift index f6308db04..3fa2c505a 100644 --- a/Sources/CodexBarCore/KeychainAccessGate.swift +++ b/Sources/CodexBarCore/KeychainAccessGate.swift @@ -18,9 +18,15 @@ public enum KeychainAccessGate { self.stateLock.withLock { self.isDisabledLocked() } } set { - self.stateLock.withLock { + let shouldClearDisabledAccessMemory = self.stateLock.withLock { () -> Bool in + let wasExplicitlyDisabled = self.isExplicitlyDisabledLocked() self.overrideValue = newValue + let nowExplicitlyDisabled = self.isExplicitlyDisabledLocked() self.updateBrowserCookieMirrorLocked() + return wasExplicitlyDisabled != nowExplicitlyDisabled + } + if shouldClearDisabledAccessMemory { + KeychainCacheStore.clearDisabledAccessMemoryStore() } } } @@ -38,6 +44,23 @@ public enum KeychainAccessGate { return false } + /// True when Keychain access was turned off by the user, environment, or an explicit test override. + /// Unlike `isDisabled`, this ignores the default test-process Keychain block so production-only + /// recovery paths (in-process cookie cache while Keychain is disabled) stay scoped correctly. + public static var isExplicitlyDisabled: Bool { + self.stateLock.withLock { self.isExplicitlyDisabledLocked() } + } + + private static func isExplicitlyDisabledLocked() -> Bool { + if let taskOverrideValue { return taskOverrideValue } + if self.isDisabledByEnvironment() { return true } + if self.processForceDisabledReason != nil { return true } + if let overrideValue { return overrideValue } + if UserDefaults.standard.bool(forKey: Self.flagKey) { return true } + if let shared = AppGroupSupport.sharedDefaults(), shared.bool(forKey: Self.flagKey) { return true } + return false + } + private static func updateBrowserCookieMirrorLocked() { #if os(macOS) && canImport(SweetCookieKit) BrowserCookieKeychainAccessGate.isDisabled = self.isDisabledLocked() @@ -51,9 +74,15 @@ public enum KeychainAccessGate { } public static func forceDisabledForProcess(reason: String) { - self.stateLock.withLock { + let shouldClearDisabledAccessMemory = self.stateLock.withLock { () -> Bool in + let wasExplicitlyDisabled = self.isExplicitlyDisabledLocked() self.processForceDisabledReason = reason + let nowExplicitlyDisabled = self.isExplicitlyDisabledLocked() self.updateBrowserCookieMirrorLocked() + return wasExplicitlyDisabled != nowExplicitlyDisabled + } + if shouldClearDisabledAccessMemory { + KeychainCacheStore.clearDisabledAccessMemoryStore() } } diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index 86d2584b0..409ba047b 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -95,9 +95,14 @@ public enum KeychainCacheStore { return self.loadResultForKeychainReadFailure(status: status, key: key) } #endif - if let testResult = loadFromTestStore(key: key, as: type) { + if let testResult = loadFromTestStore(key: key, as: type), + !self.prefersDisabledAccessMemoryStoreOverTestStore + { return testResult } + if self.shouldUseDisabledAccessMemoryStore(for: key.category) { + return self.loadFromDisabledAccessMemory(key: key, as: type) + } guard self.canUseRealKeychain else { return .missing } #if os(macOS) var query: [String: Any] = [ @@ -146,9 +151,14 @@ public enum KeychainCacheStore { return false } #endif - if let stored = self.storeInTestStore(key: key, entry: entry) { + if !self.prefersDisabledAccessMemoryStoreOverTestStore, + let stored = self.storeInTestStore(key: key, entry: entry) + { return stored } + if self.shouldUseDisabledAccessMemoryStore(for: key.category) { + return self.storeInDisabledAccessMemory(key: key, entry: entry) + } guard self.canUseRealKeychain else { return false } #if os(macOS) let encoder = Self.makeEncoder() @@ -207,9 +217,14 @@ public enum KeychainCacheStore { return self.clearResultForKeychainDeleteStatus(status, key: key) } #endif - if let removed = self.clearTestStore(key: key) { + if !self.prefersDisabledAccessMemoryStoreOverTestStore, + let removed = self.clearTestStore(key: key) + { return removed ? .removed : .missing } + if self.shouldUseDisabledAccessMemoryStore(for: key.category) { + return self.clearDisabledAccessMemory(key: key) ? .removed : .missing + } guard self.canUseRealKeychain else { return .failed } #if os(macOS) var query: [String: Any] = [ @@ -239,9 +254,14 @@ public enum KeychainCacheStore { return self.keysResultForKeychainStatus(status, category: category, result: nil) } #endif - if let keys = self.keysFromTestStore(category: category) { + if !self.prefersDisabledAccessMemoryStoreOverTestStore, + let keys = self.keysFromTestStore(category: category) + { return .found(keys) } + if self.shouldUseDisabledAccessMemoryStore(for: category) { + return .found(self.keysFromDisabledAccessMemory(category: category)) + } guard self.canUseRealKeychain else { return .failed } #if os(macOS) var query: [String: Any] = [ @@ -412,6 +432,70 @@ public enum KeychainCacheStore { !KeychainAccessGate.isDisabled } + /// When the user disables Keychain access, keep an in-process cache so cookie/session + /// reconciliation can still succeed without treating every refresh as a session change. + /// Unit tests keep using the isolated test stores instead, unless a test explicitly opts in. + private static func shouldUseDisabledAccessMemoryStore(for category: String) -> Bool { + guard category == "cookie" else { return false } + #if DEBUG + if self.disabledAccessMemoryStoreEnabledForTesting == true { + return true + } + if KeychainTestSafety.isRunningUnderTests( + processName: ProcessInfo.processInfo.processName, + environment: ProcessInfo.processInfo.environment) + { + return false + } + #endif + return KeychainAccessGate.isExplicitlyDisabled + } + + #if DEBUG + @TaskLocal private static var disabledAccessMemoryStoreEnabledForTesting: Bool? + + static func withDisabledAccessMemoryStoreForTesting( + _ enabled: Bool, + operation: () throws -> T) rethrows -> T + { + try self.$disabledAccessMemoryStoreEnabledForTesting.withValue(enabled) { + try operation() + } + } + + static func withDisabledAccessMemoryStoreForTesting( + _ enabled: Bool, + isolation _: isolated (any Actor)? = #isolation, + operation: () async throws -> T) async rethrows -> T + { + try await self.$disabledAccessMemoryStoreEnabledForTesting.withValue(enabled) { + try await operation() + } + } + + static func resetDisabledAccessMemoryStoreForTesting() { + self.clearDisabledAccessMemoryStore() + } + #endif + + /// Drops the in-process fallback used while Keychain access is explicitly disabled. + static func clearDisabledAccessMemoryStore() { + self.disabledAccessMemoryLock.lock() + self.disabledAccessMemoryStore.removeAll() + self.disabledAccessMemoryLock.unlock() + } + + private static let disabledAccessMemoryLock = NSLock() + private nonisolated(unsafe) static var disabledAccessMemoryStore: [TestStoreKey: Data] = [:] + + private static var prefersDisabledAccessMemoryStoreOverTestStore: Bool { + #if DEBUG + self.disabledAccessMemoryStoreEnabledForTesting == true + #else + false + #endif + } + #if DEBUG private static var shouldUseImplicitTestStore: Bool { KeychainTestSafety.isRunningUnderTests( @@ -666,6 +750,50 @@ public enum KeychainCacheStore { .sorted { $0.identifier < $1.identifier } } + private static func loadFromDisabledAccessMemory( + key: Key, + as type: Entry.Type) -> LoadResult + { + self.disabledAccessMemoryLock.lock() + defer { self.disabledAccessMemoryLock.unlock() } + let memoryKey = TestStoreKey(service: self.serviceName, account: key.account) + guard let data = self.disabledAccessMemoryStore[memoryKey] else { return .missing } + let decoder = Self.makeDecoder() + guard let decoded = try? decoder.decode(Entry.self, from: data) else { + return .invalid + } + return .found(decoded) + } + + private static func storeInDisabledAccessMemory(key: Key, entry: some Codable) -> Bool { + let encoder = Self.makeEncoder() + guard let data = try? encoder.encode(entry) else { return false } + self.disabledAccessMemoryLock.lock() + defer { self.disabledAccessMemoryLock.unlock() } + let memoryKey = TestStoreKey(service: self.serviceName, account: key.account) + self.disabledAccessMemoryStore[memoryKey] = data + self.log.debug("Keychain cache stored in memory (Keychain access disabled)", metadata: [ + "account": key.account, + ]) + return true + } + + private static func clearDisabledAccessMemory(key: Key) -> Bool { + self.disabledAccessMemoryLock.lock() + defer { self.disabledAccessMemoryLock.unlock() } + let memoryKey = TestStoreKey(service: self.serviceName, account: key.account) + return self.disabledAccessMemoryStore.removeValue(forKey: memoryKey) != nil + } + + private static func keysFromDisabledAccessMemory(category: String) -> [Key] { + self.disabledAccessMemoryLock.lock() + defer { self.disabledAccessMemoryLock.unlock() } + return self.disabledAccessMemoryStore.keys + .filter { $0.service == self.serviceName } + .compactMap { self.key(fromAccount: $0.account, category: category) } + .sorted { $0.identifier < $1.identifier } + } + private static func key(fromAccount account: String, category: String) -> Key? { let prefix = "\(category)." guard account.hasPrefix(prefix) else { return nil } diff --git a/Sources/CodexBarCore/KeychainSecurity.swift b/Sources/CodexBarCore/KeychainSecurity.swift index da72effa4..e24a883b0 100644 --- a/Sources/CodexBarCore/KeychainSecurity.swift +++ b/Sources/CodexBarCore/KeychainSecurity.swift @@ -14,6 +14,14 @@ enum KeychainTestSafety { return self.isRunningUnderTests(processName: processName, environment: environment) } + static func shouldIsolateUserStateUnderTests( + processName: String = ProcessInfo.processInfo.processName, + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + guard environment[self.allowAccessEnvironmentKey] != "1" else { return false } + return self.isRunningUnderTests(processName: processName, environment: environment) + } + static func isRunningUnderTests( processName: String, environment: [String: String]) -> Bool diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index cd90bea03..ffadf6c22 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -102,4 +102,5 @@ public enum LogCategories { public static let zaiTokenStore = "zai-token-store" public static let zaiUsage = "zai-usage" public static let stepfunUsage = "stepfun-usage" + public static let zoommate = "zoommate" } diff --git a/Sources/CodexBarCore/ProviderCostSnapshot.swift b/Sources/CodexBarCore/ProviderCostSnapshot.swift index 5d838d593..51768ebe2 100644 --- a/Sources/CodexBarCore/ProviderCostSnapshot.swift +++ b/Sources/CodexBarCore/ProviderCostSnapshot.swift @@ -14,6 +14,8 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable { /// This account's own contribution when `used`/`limit` describe a shared/pooled budget /// (e.g. Cursor team on-demand pool). nil when the budget is already personal. public let personalUsed: Double? + /// Remaining prepaid balance, when the provider exposes it separately from spend and budget. + public let balance: Double? public let updatedAt: Date public init( @@ -24,6 +26,7 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable { resetsAt: Date? = nil, nextRegenAmount: Double? = nil, personalUsed: Double? = nil, + balance: Double? = nil, updatedAt: Date) { self.used = used @@ -33,6 +36,20 @@ public struct ProviderCostSnapshot: Equatable, Codable, Sendable { self.resetsAt = resetsAt self.nextRegenAmount = nextRegenAmount self.personalUsed = personalUsed + self.balance = balance self.updatedAt = updatedAt } + + func replacing(balance: Double?) -> Self { + Self( + used: self.used, + limit: self.limit, + currencyCode: self.currencyCode, + period: self.period, + resetsAt: self.resetsAt, + nextRegenAmount: self.nextRegenAmount, + personalUsed: self.personalUsed, + balance: balance, + updatedAt: self.updatedAt) + } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift index 183b122af..aa7b908b3 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift @@ -1,16 +1,15 @@ import Foundation #if os(macOS) -import CommonCrypto -import Security -import SQLite3 import SweetCookieKit private let alibabaCookieImportOrder: BrowserCookieImportOrder = ProviderDefaults.metadata[.alibaba]?.browserCookieOrder ?? Browser.defaultImportOrder +/// Alibaba-specific entry point for the shared OneConsole cookie importer. public enum AlibabaCodingPlanCookieImporter { - private static let cookieClient = BrowserCookieClient() + public typealias SessionInfo = AliyunOneConsoleCookieImporter.SessionInfo + private static let cookieDomains = [ "bailian-singapore-cs.alibabacloud.com", "bailian-cs.console.aliyun.com", @@ -27,42 +26,6 @@ public enum AlibabaCodingPlanCookieImporter { "aliyun.com", ] - public struct SessionInfo: Sendable { - public let cookies: [HTTPCookie] - public let sourceLabel: String - - public init(cookies: [HTTPCookie], sourceLabel: String) { - self.cookies = cookies - self.sourceLabel = sourceLabel - } - - public var cookieHeader: String { - var byName: [String: HTTPCookie] = [:] - byName.reserveCapacity(self.cookies.count) - - for cookie in self.cookies { - if let expiry = cookie.expiresDate, expiry < Date() { - continue - } - guard !cookie.value.isEmpty else { continue } - if let existing = byName[cookie.name] { - let existingExpiry = existing.expiresDate ?? .distantPast - let candidateExpiry = cookie.expiresDate ?? .distantPast - if candidateExpiry >= existingExpiry { - byName[cookie.name] = cookie - } - } else { - byName[cookie.name] = cookie - } - } - - return byName.keys.sorted().compactMap { name in - guard let cookie = byName[name] else { return nil } - return "\(cookie.name)=\(cookie.value)" - }.joined(separator: "; ") - } - } - #if DEBUG final class ImportSessionOverrideStore: @unchecked Sendable { let importSession: (BrowserDetection, ((String) -> Void)?) throws -> SessionInfo @@ -102,65 +65,21 @@ public enum AlibabaCodingPlanCookieImporter { return try override(browserDetection, logger) } #endif - let log: (String) -> Void = { msg in logger?("[alibaba-cookie] \(msg)") } - var accessDeniedHints: [String] = [] - var failureDetails: [String] = [] - let installedBrowsers = self.cookieImportCandidates(browserDetection: browserDetection) - log("Cookie import candidates: \(installedBrowsers.map(\.displayName).joined(separator: ", "))") - - for browserSource in installedBrowsers { - do { - log("Checking \(browserSource.displayName)") - let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.codexBarRecords( - matching: query, - in: browserSource, - logger: log) - if sources.isEmpty { - log("No matching cookie records in \(browserSource.displayName)") - if let fallbackSession = try Self.importChromiumFallbackSession( - browser: browserSource, - logger: log) - { - return fallbackSession - } - } - for source in sources where !source.records.isEmpty { - let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) - if self.isAuthenticatedSession(cookies: httpCookies) { - log("Found \(httpCookies.count) Alibaba cookies in \(source.label)") - return SessionInfo(cookies: httpCookies, sourceLabel: source.label) - } - let cookieNames = Set(httpCookies.map(\.name)) - let hasTicket = cookieNames.contains("login_aliyunid_ticket") - let hasAccount = - cookieNames.contains("login_aliyunid_pk") || - cookieNames.contains("login_current_pk") || - cookieNames.contains("login_aliyunid") - log("Skipping \(source.label): missing auth cookies (ticket=\(hasTicket), account=\(hasAccount))") - } - if let fallbackSession = try Self.importChromiumFallbackSession(browser: browserSource, logger: log) { - return fallbackSession - } - } catch let error as BrowserCookieError { - BrowserCookieAccessGate.recordIfNeeded(error) - if let hint = error.accessDeniedHint { - accessDeniedHints.append(hint) - } - failureDetails.append("\(browserSource.displayName): \(error.localizedDescription)") - log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") - } catch { - failureDetails.append("\(browserSource.displayName): \(error.localizedDescription)") - log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") - } + do { + return try AliyunOneConsoleCookieImporter.importSession( + browserDetection: browserDetection, + domains: self.cookieDomains, + isAuthenticatedSession: self.isAuthenticatedSession(cookies:), + logPrefix: "alibaba-cookie", + sessionLabel: "Alibaba", + importOrder: alibabaCookieImportOrder, + logger: logger) + } catch let error as AliyunOneConsoleCookieImportError { + throw AlibabaCodingPlanSettingsError.missingCookie(details: error.details) } - - let details = (Array(Set(accessDeniedHints)).sorted() + Array(Set(failureDetails)).sorted()) - .joined(separator: " ") - throw AlibabaCodingPlanSettingsError.missingCookie(details: details.isEmpty ? nil : details) } - private static func isAuthenticatedSession(cookies: [HTTPCookie]) -> Bool { + static func isAuthenticatedSession(cookies: [HTTPCookie]) -> Bool { guard !cookies.isEmpty else { return false } let names = Set(cookies.map(\.name)) let hasTicket = names.contains("login_aliyunid_ticket") @@ -175,22 +94,13 @@ public enum AlibabaCodingPlanCookieImporter { browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) -> Bool { - do { - _ = try self.importSession(browserDetection: browserDetection, logger: logger) - return true - } catch { - return false - } - } - - private static func importChromiumFallbackSession( - browser: Browser, - logger: ((String) -> Void)? = nil) throws -> SessionInfo? - { - guard browser.usesChromiumProfileStore else { return nil } - return try AlibabaChromiumCookieFallbackImporter.importSession( - browser: browser, + AliyunOneConsoleCookieImporter.hasSession( + browserDetection: browserDetection, domains: self.cookieDomains, + isAuthenticatedSession: self.isAuthenticatedSession(cookies:), + logPrefix: "alibaba-cookie", + sessionLabel: "Alibaba", + importOrder: alibabaCookieImportOrder, logger: logger) } @@ -198,342 +108,17 @@ public enum AlibabaCodingPlanCookieImporter { browserDetection: BrowserDetection, importOrder: BrowserCookieImportOrder = alibabaCookieImportOrder) -> [Browser] { - importOrder.cookieImportCandidates(using: browserDetection) + AliyunOneConsoleCookieImporter.cookieImportCandidates( + browserDetection: browserDetection, + importOrder: importOrder) } static func matchesCookieDomain(_ domain: String, patterns: [String] = Self.cookieDomains) -> Bool { - let normalized = self.normalizeCookieDomain(domain) - return patterns.contains { pattern in - let normalizedPattern = self.normalizeCookieDomain(pattern) - return normalized == normalizedPattern || normalized.hasSuffix(".\(normalizedPattern)") - } + AliyunOneConsoleCookieImporter.matchesCookieDomain(domain, patterns: patterns) } static func normalizeCookieDomain(_ domain: String) -> String { - let trimmed = domain.trimmingCharacters(in: .whitespacesAndNewlines) - let normalized = trimmed.hasPrefix(".") ? String(trimmed.dropFirst()) : trimmed - return normalized.lowercased() - } -} - -enum AlibabaChromiumCookieFallbackImporter { - private struct ChromiumCookieRecord { - let domain: String - let name: String - let path: String - let value: String - let expires: Date? - let isSecure: Bool - } - - enum ImportError: LocalizedError { - case keyUnavailable(browser: Browser) - case keychainDenied(browser: Browser) - case sqliteFailed(label: String, details: String) - - var errorDescription: String? { - switch self { - case let .keyUnavailable(browser): - "\(browser.displayName) Safe Storage key not found." - case let .keychainDenied(browser): - "macOS Keychain denied access to \(browser.displayName) Safe Storage." - case let .sqliteFailed(label, details): - "\(label) cookie fallback failed: \(details)" - } - } - } - - static func importSession( - browser: Browser, - domains: [String], - cookieClient: BrowserCookieClient = BrowserCookieClient(), - logger: ((String) -> Void)? = nil) throws -> AlibabaCodingPlanCookieImporter.SessionInfo? - { - let stores = try cookieClient.codexBarStores(for: browser).filter { $0.databaseURL != nil } - guard !stores.isEmpty else { return nil } - - logger?("[alibaba-cookie] Trying \(browser.displayName) Chromium fallback") - let keys = try self.derivedKeys(for: browser) - for store in stores { - let cookies = try self.loadCookies(from: store, domains: domains, keys: keys) - guard !cookies.isEmpty else { continue } - if self.isAuthenticatedSession(cookies) { - logger?("[alibaba-cookie] Found \(cookies.count) Alibaba cookies via \(store.label) fallback") - return AlibabaCodingPlanCookieImporter.SessionInfo(cookies: cookies, sourceLabel: store.label) - } - } - return nil - } - - private static func isAuthenticatedSession(_ cookies: [HTTPCookie]) -> Bool { - let names = Set(cookies.map(\.name)) - let hasTicket = names.contains("login_aliyunid_ticket") - let hasAccount = - names.contains("login_aliyunid_pk") || - names.contains("login_current_pk") || - names.contains("login_aliyunid") - return hasTicket && hasAccount - } - - private static func loadCookies( - from store: BrowserCookieStore, - domains: [String], - keys: [Data]) throws -> [HTTPCookie] - { - guard let sourceDB = store.databaseURL else { return [] } - let records = try self.readCookiesFromLockedDB( - sourceDB: sourceDB, - domains: domains, - keys: keys, - label: store.label) - return records.compactMap(self.makeCookie) - } - - private static func readCookiesFromLockedDB( - sourceDB: URL, - domains: [String], - keys: [Data], - label: String) throws -> [ChromiumCookieRecord] - { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent("alibaba-chromium-cookies-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - - let copiedDB = tempDir.appendingPathComponent("Cookies") - try FileManager.default.copyItem(at: sourceDB, to: copiedDB) - for suffix in ["-wal", "-shm"] { - let src = URL(fileURLWithPath: sourceDB.path + suffix) - if FileManager.default.fileExists(atPath: src.path) { - let dst = URL(fileURLWithPath: copiedDB.path + suffix) - try? FileManager.default.copyItem(at: src, to: dst) - } - } - defer { try? FileManager.default.removeItem(at: tempDir) } - - return try self.readCookies(fromDB: copiedDB.path, domains: domains, keys: keys, label: label) - } - - private static func readCookies( - fromDB path: String, - domains: [String], - keys: [Data], - label: String) throws -> [ChromiumCookieRecord] - { - var db: OpaquePointer? - guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { - throw ImportError.sqliteFailed(label: label, details: String(cString: sqlite3_errmsg(db))) - } - defer { sqlite3_close(db) } - - let sql = "SELECT host_key, name, path, expires_utc, is_secure, value, encrypted_value FROM cookies" - var stmt: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { - throw ImportError.sqliteFailed(label: label, details: String(cString: sqlite3_errmsg(db))) - } - defer { sqlite3_finalize(stmt) } - - var records: [ChromiumCookieRecord] = [] - while sqlite3_step(stmt) == SQLITE_ROW { - guard let hostKey = self.readText(stmt, index: 0), self.matches(domain: hostKey, patterns: domains) else { - continue - } - guard let name = self.readText(stmt, index: 1), let path = self.readText(stmt, index: 2) else { - continue - } - - let value: String? = if let plain = self.readText(stmt, index: 5), !plain.isEmpty { - plain - } else if let encrypted = self.readBlob(stmt, index: 6) { - self.decrypt(encrypted, usingAnyOf: keys) - } else { - nil - } - guard let value, !value.isEmpty else { continue } - - records.append(ChromiumCookieRecord( - domain: AlibabaCodingPlanCookieImporter.normalizeCookieDomain(hostKey), - name: name, - path: path, - value: value, - expires: self.chromiumExpiry(sqlite3_column_int64(stmt, 3)), - isSecure: sqlite3_column_int(stmt, 4) != 0)) - } - - return records.filter { record in - guard let expires = record.expires else { return true } - return expires >= Date() - } - } - - private static func derivedKeys(for browser: Browser) throws -> [Data] { - var keys: [Data] = [] - var sawDenied = false - - for label in browser.safeStorageLabels { - switch KeychainAccessPreflight.checkGenericPassword(service: label.service, account: label.account) { - case .interactionRequired: - sawDenied = true - continue - case .allowed, .notFound, .failure: - break - } - - if let password = self.safeStoragePassword(service: label.service, account: label.account) { - keys.append(self.deriveKey(from: password)) - } - } - - if !keys.isEmpty { - return keys - } - if sawDenied { - throw ImportError.keychainDenied(browser: browser) - } - throw ImportError.keyUnavailable(browser: browser) - } - - private static func safeStoragePassword(service: String, account: String) -> String? { - // The preflight classifies prompt-requiring items as .interactionRequired, but its - // .notFound (gate disabled) and .failure outcomes still reach this read. Honor the - // access gate and keep the read strictly non-interactive so it can never prompt. - guard !KeychainAccessGate.isDisabled else { return nil } - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnData as String: true, - ] - KeychainNoUIQuery.apply(to: &query) - - var result: AnyObject? - let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - private static func deriveKey(from password: String) -> Data { - let salt = Data("saltysalt".utf8) - var key = Data(count: kCCKeySizeAES128) - let keyLength = key.count - _ = key.withUnsafeMutableBytes { keyBytes in - password.utf8CString.withUnsafeBytes { passBytes in - salt.withUnsafeBytes { saltBytes in - CCKeyDerivationPBKDF( - CCPBKDFAlgorithm(kCCPBKDF2), - passBytes.bindMemory(to: Int8.self).baseAddress, - passBytes.count - 1, - saltBytes.bindMemory(to: UInt8.self).baseAddress, - salt.count, - CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), - 1003, - keyBytes.bindMemory(to: UInt8.self).baseAddress, - keyLength) - } - } - } - return key - } - - private static func decrypt(_ encryptedValue: Data, usingAnyOf keys: [Data]) -> String? { - for key in keys { - if let value = self.decrypt(encryptedValue, key: key) { - return value - } - } - return nil - } - - private static func decrypt(_ encryptedValue: Data, key: Data) -> String? { - guard encryptedValue.count > 3 else { return nil } - let prefix = String(data: encryptedValue.prefix(3), encoding: .utf8) - guard prefix == "v10" else { return nil } - - let payload = Data(encryptedValue.dropFirst(3)) - let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) - var outLength = 0 - var out = Data(count: payload.count + kCCBlockSizeAES128) - let outCapacity = out.count - - let status = out.withUnsafeMutableBytes { outBytes in - payload.withUnsafeBytes { payloadBytes in - key.withUnsafeBytes { keyBytes in - iv.withUnsafeBytes { ivBytes in - CCCrypt( - CCOperation(kCCDecrypt), - CCAlgorithm(kCCAlgorithmAES), - CCOptions(kCCOptionPKCS7Padding), - keyBytes.baseAddress, - key.count, - ivBytes.baseAddress, - payloadBytes.baseAddress, - payload.count, - outBytes.baseAddress, - outCapacity, - &outLength) - } - } - } - } - - guard status == kCCSuccess else { return nil } - out.count = outLength - - if let value = String(data: out, encoding: .utf8), !value.isEmpty { - return value - } - if out.count > 32 { - let trimmed = out.dropFirst(32) - if let value = String(data: trimmed, encoding: .utf8), !value.isEmpty { - return value - } - } - return nil - } - - private static func makeCookie(from record: ChromiumCookieRecord) -> HTTPCookie? { - var properties: [HTTPCookiePropertyKey: Any] = [ - .domain: record.domain, - .path: record.path, - .name: record.name, - .value: record.value, - ] - if record.isSecure { - properties[.secure] = true - } - if let expires = record.expires { - properties[.expires] = expires - } - return HTTPCookie(properties: properties) - } - - private static func readText(_ stmt: OpaquePointer?, index: Int32) -> String? { - guard sqlite3_column_type(stmt, index) != SQLITE_NULL, - let value = sqlite3_column_text(stmt, index) - else { - return nil - } - return String(cString: value) - } - - private static func readBlob(_ stmt: OpaquePointer?, index: Int32) -> Data? { - guard sqlite3_column_type(stmt, index) != SQLITE_NULL, - let bytes = sqlite3_column_blob(stmt, index) - else { - return nil - } - return Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) - } - - private static func matches(domain: String, patterns: [String]) -> Bool { - AlibabaCodingPlanCookieImporter.matchesCookieDomain(domain, patterns: patterns) - } - - private static func chromiumExpiry(_ expiresUTC: Int64) -> Date? { - guard expiresUTC > 0 else { return nil } - let seconds = (Double(expiresUTC) / 1_000_000.0) - 11_644_473_600.0 - guard seconds > 0 else { return nil } - return Date(timeIntervalSince1970: seconds) + AliyunOneConsoleCookieImporter.normalizeCookieDomain(domain) } } #endif diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift index 2eb7918ee..d9f0c8b0a 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift @@ -859,30 +859,12 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { } private static func expandedJSON(_ value: Any) -> Any { - if let dict = value as? [String: Any] { - var expanded: [String: Any] = [:] - expanded.reserveCapacity(dict.count) - for (key, nested) in dict { - expanded[key] = self.expandedJSON(nested) - } - return expanded - } - if let array = value as? [Any] { - return array.map { self.expandedJSON($0) } - } - if let string = value as? String, - let data = string.data(using: .utf8), - let nested = try? JSONSerialization.jsonObject(with: data, options: []), - nested is [String: Any] || nested is [Any] - { - return self.expandedJSON(nested) - } - return value + OneConsoleJSON.expandEmbeddedJSON(value) } private static func anyInt(for keys: [String], in dict: [String: Any]) -> Int? { for key in keys { - if let value = self.parseInt(dict[key]) { + if let value = OneConsoleJSON.int(dict[key]) { return value } } @@ -891,7 +873,7 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { private static func anyString(for keys: [String], in dict: [String: Any]) -> String? { for key in keys { - if let value = self.parseString(dict[key]) { + if let value = OneConsoleJSON.string(dict[key]) { return value } } @@ -900,7 +882,7 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { private static func anyDate(for keys: [String], in dict: [String: Any]) -> Date? { for key in keys { - if let value = self.parseDate(dict[key]) { + if let value = OneConsoleJSON.date(dict[key]) { return value } } @@ -950,47 +932,15 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { } private static func parseDate(_ raw: Any?) -> Date? { - if let intValue = self.parseInt(raw) { - if intValue > 1_000_000_000_000 { - return Date(timeIntervalSince1970: TimeInterval(intValue) / 1000) - } - if intValue > 1_000_000_000 { - return Date(timeIntervalSince1970: TimeInterval(intValue)) - } - } - if let string = self.parseString(raw) { - let formatter = ISO8601DateFormatter() - if let date = formatter.date(from: string) { - return date - } - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - for format in ["yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"] { - dateFormatter.dateFormat = format - if let date = dateFormatter.date(from: string) { - return date - } - } - } - return nil + OneConsoleJSON.date(raw) } private static func parseInt(_ raw: Any?) -> Int? { - if let value = raw as? Int { return value } - if let value = raw as? Int64 { return Int(value) } - if let value = raw as? Double { return Int(value) } - if let value = raw as? NSNumber { return value.intValue } - if let value = raw as? String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return Int(trimmed) - } - return nil + OneConsoleJSON.int(raw) } private static func parseString(_ raw: Any?) -> String? { - guard let value = raw as? String else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + OneConsoleJSON.string(raw) } private static func parsePercent(_ raw: Any?) -> Double? { diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift index ae7453f90..9d31f3bdb 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanAPIRegion.swift @@ -3,25 +3,44 @@ import Foundation public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { case international = "intl" case chinaMainland = "cn" + case internationalPersonal = "intl-personal" + case chinaMainlandPersonal = "cn-personal" public var displayName: String { switch self { case .international: - "International (modelstudio.console.alibabacloud.com)" + "International — Team (modelstudio.console.alibabacloud.com)" case .chinaMainland: - "China mainland (bailian.console.aliyun.com)" + "China mainland — Team (bailian.console.aliyun.com)" + case .internationalPersonal: + "International — Personal/Solo (modelstudio.console.alibabacloud.com)" + case .chinaMainlandPersonal: + "China mainland — Personal/Solo (bailian.console.aliyun.com)" } } public var gatewayBaseURLString: String { switch self { - case .international: + case .international, .internationalPersonal: "https://modelstudio.console.alibabacloud.com" - case .chinaMainland: + case .chinaMainland, .chinaMainlandPersonal: "https://bailian.console.aliyun.com" } } + public var quotaBaseURLString: String { + switch self { + case .international: + self.gatewayBaseURLString + case .chinaMainland: + self.gatewayBaseURLString + case .internationalPersonal: + "https://bailian-singapore-cs.alibabacloud.com" + case .chinaMainlandPersonal: + "https://bailian-cs.console.aliyun.com" + } + } + public var dashboardOriginURLString: String { self.gatewayBaseURLString } @@ -33,14 +52,22 @@ public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { string: "https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=plan#/efm/subscription/token-plan")! case .chinaMainland: URL(string: "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan")! + case .internationalPersonal: + URL( + string: "https://modelstudio.console.alibabacloud.com/ap-southeast-1/" + + "?tab=plan#/efm/subscription/token-plan/personal")! + case .chinaMainlandPersonal: + URL( + string: "https://bailian.console.aliyun.com/cn-beijing" + + "?tab=plan#/efm/subscription/token-plan/personal")! } } public var currentRegionID: String { switch self { - case .international: + case .international, .internationalPersonal: "ap-southeast-1" - case .chinaMainland: + case .chinaMainland, .chinaMainlandPersonal: "cn-beijing" } } @@ -51,6 +78,38 @@ public enum AlibabaTokenPlanAPIRegion: String, CaseIterable, Sendable { "sfm_tokenplanteams_dp_intl" case .chinaMainland: "sfm_tokenplanteams_dp_cn" + case .internationalPersonal: + "sfm_tokenplansolo_public_intl" + case .chinaMainlandPersonal: + "sfm_tokenplansolo_public_cn" + } + } + + public var usesPersonalTokenPlanAPI: Bool { + switch self { + case .international, .chinaMainland: + false + case .internationalPersonal, .chinaMainlandPersonal: + true + } + } + + public var personalAPIAction: String { + switch self { + case .international, .internationalPersonal: + "IntlBroadScopeAspnGateway" + case .chinaMainland, .chinaMainlandPersonal: + "BroadScopeAspnGateway" + } + } + + public var personalConsoleSite: String { + switch self { + case .international, .internationalPersonal: + // This is Alibaba's live console contract, including its historical spelling. + "MODELSTUDIO_ALBABACLOUD" + case .chinaMainland, .chinaMainlandPersonal: + "BAILIAN_ALIYUN" } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift index 14a99af97..bb1cc2a0d 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanCookieHeader.swift @@ -3,90 +3,49 @@ import Foundation import FoundationNetworking #endif -struct AlibabaTokenPlanCookieHeaders { - private static let cachedAPIHeaderName = "__codexbar_alibaba_token_plan_api" - private static let cachedDashboardHeaderName = "__codexbar_alibaba_token_plan_dashboard" - - let apiCookieHeader: String - let dashboardCookieHeader: String - - init(apiCookieHeader: String, dashboardCookieHeader: String) { - self.apiCookieHeader = apiCookieHeader - self.dashboardCookieHeader = dashboardCookieHeader - } - - init?(singleHeader raw: String?) { - guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } - self.apiCookieHeader = normalized - self.dashboardCookieHeader = normalized - } +/// Alibaba Token Plan cookie-header pair. +/// +/// Wraps the shared `OneConsoleCookieHeaders` so the rest of the codebase +/// keeps referring to `AlibabaTokenPlanCookieHeaders`. The cached-header +/// round-trip uses a namespace unique to Alibaba Token Plan so it can never +/// collide with Qwen Cloud or another provider's cached entry. +public typealias AlibabaTokenPlanCookieHeaders = OneConsoleCookieHeaders + +extension OneConsoleCookieHeaders { + static let alibabaTokenPlanCacheNamespace = "alibaba_token_plan" +} - init?(cachedHeader raw: String?) { - var valuesByName: [String: String] = [:] - for pair in CookieHeaderNormalizer.pairs(from: raw ?? "") { - valuesByName[pair.name] = pair.value - } - if let encodedAPI = valuesByName[Self.cachedAPIHeaderName], - let encodedDashboard = valuesByName[Self.cachedDashboardHeaderName], - let apiHeader = Self.decodeCachedHeader(encodedAPI), - let dashboardHeader = Self.decodeCachedHeader(encodedDashboard), - let normalizedAPI = CookieHeaderNormalizer.normalize(apiHeader), - let normalizedDashboard = CookieHeaderNormalizer.normalize(dashboardHeader) +extension OneConsoleCookieHeaders { + public init?(alibabaTokenPlanCachedHeader raw: String?) { + if let headers = OneConsoleCookieHeaders( + cachedHeader: raw, + cacheNamespace: Self.alibabaTokenPlanCacheNamespace) { - self.init(apiCookieHeader: normalizedAPI, dashboardCookieHeader: normalizedDashboard) - return + self = headers + } else if let headers = OneConsoleCookieHeaders(singleHeader: raw) { + self = headers + } else { + return nil } - - self.init(singleHeader: raw) - } - - var cacheCookieHeader: String { - [ - "\(Self.cachedAPIHeaderName)=\(Self.encodeCachedHeader(self.apiCookieHeader))", - "\(Self.cachedDashboardHeaderName)=\(Self.encodeCachedHeader(self.dashboardCookieHeader))", - ].joined(separator: "; ") - } - - var apiCookieNames: [String] { - Self.cookieNames(from: self.apiCookieHeader) - } - - var dashboardCookieNames: [String] { - Self.cookieNames(from: self.dashboardCookieHeader) - } - - func hasCookie(named name: String) -> Bool { - Self.cookieNames(from: self.apiCookieHeader).contains(name) || - Self.cookieNames(from: self.dashboardCookieHeader).contains(name) - } - - private static func cookieNames(from header: String) -> [String] { - CookieHeaderNormalizer.pairs(from: header) - .map(\.name) - .filter { !$0.isEmpty } - .uniquedSorted() } - private static func encodeCachedHeader(_ header: String) -> String { - Data(header.utf8).base64EncodedString() - } - - private static func decodeCachedHeader(_ encoded: String) -> String? { - guard let data = Data(base64Encoded: encoded) else { return nil } - return String(data: data, encoding: .utf8) + public func cacheAlibabaTokenPlanCookieHeader() -> String { + self.cacheCookieHeader(namespace: Self.alibabaTokenPlanCacheNamespace) } } enum AlibabaTokenPlanCookieHeader { + static let cacheNamespace = OneConsoleCookieHeaders.alibabaTokenPlanCacheNamespace + static func headers( from cookies: [HTTPCookie], region: AlibabaTokenPlanAPIRegion = .international, environment: [String: String] = ProcessInfo.processInfo.environment) -> AlibabaTokenPlanCookieHeaders? { - guard let apiHeader = self.header( + guard let apiHeader = OneConsoleCookieHeaderBuilder.header( from: cookies, targetURL: AlibabaTokenPlanUsageFetcher.resolveQuotaURL(region: region, environment: environment)), - let dashboardHeader = self.header( + let dashboardHeader = OneConsoleCookieHeaderBuilder.header( from: cookies, targetURL: AlibabaTokenPlanUsageFetcher.dashboardURL(region: region, environment: environment)) else { @@ -96,68 +55,6 @@ enum AlibabaTokenPlanCookieHeader { } static func header(from cookies: [HTTPCookie], targetURL: URL) -> String? { - var byName: [String: HTTPCookie] = [:] - for cookie in cookies { - guard !cookie.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } - guard !cookie.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } - if let expiry = cookie.expiresDate, expiry < Date() { continue } - guard self.matchesRequestURL(cookie: cookie, url: targetURL) else { continue } - - if let existing = byName[cookie.name] { - if self.cookieSortKey(for: cookie) >= self.cookieSortKey(for: existing) { - byName[cookie.name] = cookie - } - } else { - byName[cookie.name] = cookie - } - } - - guard !byName.isEmpty else { return nil } - return byName.keys.sorted().compactMap { name in - guard let cookie = byName[name] else { return nil } - return "\(cookie.name)=\(cookie.value)" - }.joined(separator: "; ") - } - - private static func matchesRequestURL(cookie: HTTPCookie, url: URL) -> Bool { - guard let host = url.host?.lowercased() else { return false } - let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) - guard !normalizedDomain.isEmpty else { return false } - guard host == normalizedDomain || host.hasSuffix(".\(normalizedDomain)") else { return false } - - let cookiePath = cookie.path.isEmpty ? "/" : cookie.path - let requestPath = url.path.isEmpty ? "/" : url.path - if requestPath == cookiePath { - return true - } - guard requestPath.hasPrefix(cookiePath) else { return false } - guard cookiePath != "/" else { return true } - if cookiePath.hasSuffix("/") { - return true - } - guard - let boundaryIndex = requestPath.index( - requestPath.startIndex, - offsetBy: cookiePath.count, - limitedBy: requestPath.endIndex), - boundaryIndex < requestPath.endIndex - else { - return true - } - return requestPath[boundaryIndex] == "/" - } - - private static func cookieSortKey(for cookie: HTTPCookie) -> (Int, Int, Date) { - let pathLength = cookie.path.count - let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) - let domainLength = normalizedDomain.count - let expiry = cookie.expiresDate ?? .distantPast - return (pathLength, domainLength, expiry) - } -} - -extension [String] { - fileprivate func uniquedSorted() -> [String] { - Array(Set(self)).sorted() + OneConsoleCookieHeaderBuilder.header(from: cookies, targetURL: targetURL) } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalUsageParser.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalUsageParser.swift new file mode 100644 index 000000000..6d8d2355a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalUsageParser.swift @@ -0,0 +1,115 @@ +import Foundation + +/// Shared parser for Alibaba Token Plan Personal/Solo rolling-window responses. +/// Both mainland and international variants expose the same payload contract. +enum AlibabaTokenPlanPersonalUsageParser { + static func parse( + from usageData: Data, + subscriptionData: Data?, + quotaConfigData: Data?, + now: Date) throws -> AlibabaTokenPlanUsageSnapshot + { + guard !usageData.isEmpty else { + throw AlibabaTokenPlanUsageError.parseFailed("Empty response body") + } + + let raw: Any + do { + raw = try JSONSerialization.jsonObject(with: usageData) + } catch { + if let text = String(data: usageData, encoding: .utf8)?.lowercased(), + text.contains(" String? { + guard let raw = try? JSONSerialization.jsonObject(with: data) else { return nil } + let expanded = OneConsoleJSON.expandEmbeddedJSON(raw) + guard let plan = OneConsoleJSON.findObject( + containingAnyOf: ["specCode", "spec_code", "planName", "plan_name"], + in: expanded) + else { + return nil + } + for key in ["specCode", "spec_code", "planName", "plan_name"] { + if let value = OneConsoleJSON.string(plan[key])?.lowercased(), !value.isEmpty { + return value + } + } + return nil + } + + private static func displayPlanName(_ planCode: String) -> String { + switch planCode { + case "lite": "Lite" + case "standard": "Standard" + case "pro": "Pro" + case "max": "Max" + default: planCode + } + } + + private static func quotaTotals( + from data: Data, + planCode: String?) -> (fiveHour: Double?, weekly: Double?)? + { + guard let planCode, + let raw = try? JSONSerialization.jsonObject(with: data) + else { + return nil + } + let expanded = OneConsoleJSON.expandEmbeddedJSON(raw) + guard let value = OneConsoleJSON.findFirstValue(forKeys: [planCode], in: expanded), + let quota = value as? [String: Any] + else { + return nil + } + let fiveHour = OneConsoleJSON.number(quota["five_hour"] ?? quota["fiveHour"]) + let weekly = OneConsoleJSON.number(quota["weekly"]) + guard fiveHour != nil || weekly != nil else { return nil } + return (fiveHour, weekly) + } +} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift index c36be807b..7bd33be2a 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift @@ -7,6 +7,14 @@ import SweetCookieKit public enum AlibabaTokenPlanProviderDescriptor { public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + public static func primaryLabel(window: RateWindow?) -> String? { + window?.windowMinutes == 5 * 60 ? "5-hour" : nil + } + + public static func secondaryLabel(window: RateWindow?) -> String? { + window?.windowMinutes == 7 * 24 * 60 ? "7-day" : nil + } + static func makeDescriptor() -> ProviderDescriptor { #if os(macOS) let browserOrder: BrowserCookieImportOrder = [ @@ -183,7 +191,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { #if os(macOS) if allowCached, let cached = Self.cachedCookieEntry(region: region), - let headers = AlibabaTokenPlanCookieHeaders(cachedHeader: cached.cookieHeader) + let headers = AlibabaTokenPlanCookieHeaders(alibabaTokenPlanCachedHeader: cached.cookieHeader) { Self.log.info( "Alibaba Token Plan using cached browser cookie header", @@ -219,7 +227,7 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { CookieHeaderCache.store( provider: .alibabatokenplan, scope: region.cookieCacheScope, - cookieHeader: headers.cacheCookieHeader, + cookieHeader: headers.cacheAlibabaTokenPlanCookieHeader(), sourceLabel: session.sourceLabel) Self.log.info( "Alibaba Token Plan imported browser cookies", @@ -277,12 +285,6 @@ struct AlibabaTokenPlanWebFetchStrategy: ProviderFetchStrategy { } } -extension [String] { - fileprivate func uniquedSorted() -> [String] { - Array(Set(self)).sorted() - } -} - extension AlibabaTokenPlanUsageError { fileprivate var isCredentialFailure: Bool { switch self { diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 27aee5a63..b3a4700e4 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -28,9 +28,20 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { // swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { + private struct PersonalAPIContext: Sendable { + let apiCookieHeader: String + let region: AlibabaTokenPlanAPIRegion + let environment: [String: String] + let session: URLSession + } + private static let log = CodexBarLog.logger("alibaba-token-plan") private static let bssServiceCode = "BssOpenAPI-V3" private static let subscriptionSummaryAction = "GetSubscriptionSummary" + private static let personalConsoleProduct = "sfm_bailian" + private static let personalUsageAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" + private static let personalSubscriptionAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription" + private static let personalQuotaConfigAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config" private static let browserLikeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" @@ -117,6 +128,16 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { dashboardSession.invalidateAndCancel() } } + + if region.usesPersonalTokenPlanAPI { + return try await self.fetchPersonalUsage( + apiCookieHeader: normalizedAPIHeader, + region: region, + environment: environment, + now: now, + session: apiSession) + } + let secToken = await self.resolveSECToken( dashboardCookieHeader: normalizedDashboardHeader, apiCookieHeader: normalizedAPIHeader, @@ -213,7 +234,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return override } if let host = AlibabaTokenPlanSettingsReader.hostOverride(environment: environment), - let hostURL = self.quotaURL(from: host) + let hostURL = self.quotaURL(from: host, region: region) { return hostURL } @@ -225,13 +246,22 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } static func defaultQuotaURL(region: AlibabaTokenPlanAPIRegion) -> URL { - var components = URLComponents(string: region.gatewayBaseURLString)! + var components = URLComponents(string: region.quotaBaseURLString)! components.path = "/data/api.json" - components.queryItems = [ - URLQueryItem(name: "action", value: Self.subscriptionSummaryAction), - URLQueryItem(name: "product", value: Self.bssServiceCode), - URLQueryItem(name: "_tag", value: ""), - ] + components.queryItems = if region.usesPersonalTokenPlanAPI { + [ + URLQueryItem(name: "action", value: region.personalAPIAction), + URLQueryItem(name: "product", value: Self.personalConsoleProduct), + URLQueryItem(name: "api", value: Self.personalUsageAPI), + URLQueryItem(name: "_v", value: "undefined"), + ] + } else { + [ + URLQueryItem(name: "action", value: Self.subscriptionSummaryAction), + URLQueryItem(name: "product", value: Self.bssServiceCode), + URLQueryItem(name: "_tag", value: ""), + ] + } return components.url! } @@ -280,6 +310,186 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { updatedAt: now) } + private static func fetchPersonalUsage( + apiCookieHeader: String, + region: AlibabaTokenPlanAPIRegion, + environment: [String: String], + now: Date, + session: URLSession) async throws -> AlibabaTokenPlanUsageSnapshot + { + let context = PersonalAPIContext( + apiCookieHeader: apiCookieHeader, + region: region, + environment: environment, + session: session) + self.log.info( + "Fetching Alibaba Token Plan Personal usage", + metadata: [ + "apiHost": self.resolveQuotaURL(region: region, environment: environment).host ?? "unknown", + "region": region.rawValue, + "apiCookieNames": self.cookieNamesDescription(from: apiCookieHeader), + "hasCSRF": self.hasCSRF(in: apiCookieHeader) ? "1" : "0", + "secTokenSource": "not-required", + ]) + + let usageData = try await self.fetchPersonalAPI( + api: self.personalUsageAPI, + dataParameters: [:], + context: context) + let subscriptionData = await self.fetchOptionalPersonalAPI( + api: self.personalSubscriptionAPI, + dataParameters: ["commodityCode": region.tokenPlanProductCode], + context: context) + let quotaConfigData = await self.fetchOptionalPersonalAPI( + api: self.personalQuotaConfigAPI, + dataParameters: [:], + context: context) + + return try AlibabaTokenPlanPersonalUsageParser.parse( + from: usageData, + subscriptionData: subscriptionData, + quotaConfigData: quotaConfigData, + now: now) + } + + private static func fetchOptionalPersonalAPI( + api: String, + dataParameters: [String: String], + context: PersonalAPIContext) async -> Data? + { + do { + return try await self.fetchPersonalAPI( + api: api, + dataParameters: dataParameters, + context: context) + } catch { + self.log.warning( + "Optional Alibaba Token Plan Personal metadata fetch failed", + metadata: ["api": api, "error": error.localizedDescription]) + return nil + } + } + + private static func fetchPersonalAPI( + api: String, + dataParameters: [String: String], + context: PersonalAPIContext) async throws -> Data + { + let url = self.personalAPIURL(api: api, region: context.region, environment: context.environment) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 20 + request.httpBody = try self.personalAPIRequestBody( + api: api, + dataParameters: dataParameters, + apiCookieHeader: context.apiCookieHeader, + region: context.region, + environment: context.environment) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(context.apiCookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + request.setValue(Self.browserLikeUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue(context.region.dashboardOriginURLString, forHTTPHeaderField: "Origin") + request.setValue( + self.dashboardURL(region: context.region, environment: context.environment).absoluteString, + forHTTPHeaderField: "Referer") + if let csrf = self.extractCookieValue(name: "login_aliyunid_csrf", from: context.apiCookieHeader) ?? + self.extractCookieValue(name: "csrf", from: context.apiCookieHeader) + { + request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") + request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await context.session.data(for: request) + } catch { + throw AlibabaTokenPlanUsageError.networkError(error.localizedDescription) + } + guard let httpResponse = response as? HTTPURLResponse else { + throw AlibabaTokenPlanUsageError.networkError("Invalid response") + } + Self.log.info( + "Alibaba Token Plan Personal HTTP response", + metadata: [ + "api": api, + "status": "\(httpResponse.statusCode)", + "bodyBytes": "\(data.count)", + "contentType": httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "none", + ]) + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw AlibabaTokenPlanUsageError.loginRequired + } + throw AlibabaTokenPlanUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + return data + } + + private static func personalAPIURL( + api: String, + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) -> URL + { + let usageURL = self.resolveQuotaURL(region: region, environment: environment) + var components = URLComponents(url: usageURL, resolvingAgainstBaseURL: false)! + var queryItems = components.queryItems ?? [] + queryItems.removeAll { $0.name == "api" } + queryItems.append(URLQueryItem(name: "api", value: api)) + components.queryItems = queryItems + return components.url ?? usageURL + } + + private static func personalAPIRequestBody( + api: String, + dataParameters: [String: String], + apiCookieHeader: String, + region: AlibabaTokenPlanAPIRegion, + environment: [String: String]) throws -> Data + { + let dashboardURL = self.dashboardURL(region: region, environment: environment) + var cornerstone: [String: Any] = [ + "feTraceId": UUID().uuidString.lowercased(), + "feURL": dashboardURL.absoluteString, + "protocol": "V2", + "console": "ONE_CONSOLE", + "productCode": "p_efm", + "switchAgent": 1_233_135, + "switchUserType": 3, + "domain": dashboardURL.host ?? "", + "consoleSite": region.personalConsoleSite, + "userNickName": "", + "userPrincipalName": "", + "xsp_lang": "en-US", + ] + if let anonymousID = self.extractCookieValue(name: "cna", from: apiCookieHeader), !anonymousID.isEmpty { + cornerstone["X-Anonymous-Id"] = anonymousID + } + var apiData = dataParameters as [String: Any] + apiData["cornerstoneParam"] = cornerstone + let params: [String: Any] = [ + "Api": api, + "V": "1.0", + "Data": apiData, + ] + let paramsData = try JSONSerialization.data(withJSONObject: params) + guard let paramsJSON = String(data: paramsData, encoding: .utf8) else { + throw AlibabaTokenPlanUsageError.parseFailed("Could not encode request parameters") + } + + var body = URLComponents() + body.queryItems = [ + URLQueryItem(name: "product", value: self.personalConsoleProduct), + URLQueryItem(name: "action", value: region.personalAPIAction), + URLQueryItem(name: "region", value: region.currentRegionID), + URLQueryItem(name: "language", value: "en-US"), + URLQueryItem(name: "params", value: paramsJSON), + ] + return Data((body.percentEncodedQuery ?? "").utf8) + } + private static func subscriptionSummaryRequestBody(region: AlibabaTokenPlanAPIRegion, secToken: String?) -> Data { let paramsObject = ["ProductCode": region.tokenPlanProductCode] guard let paramsData = try? JSONSerialization.data(withJSONObject: paramsObject, options: []), @@ -411,13 +621,13 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return components.url ?? URL(string: region.dashboardOriginURLString)! } - private static func quotaURL(from rawHost: String) -> URL? { + private static func quotaURL(from rawHost: String, region: AlibabaTokenPlanAPIRegion) -> URL? { let cleaned = AlibabaTokenPlanSettingsReader.cleaned(rawHost) guard let cleaned else { return nil } guard let base = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: cleaned) else { return nil } var components = URLComponents(url: base, resolvingAgainstBaseURL: false) let defaultComponents = URLComponents( - url: Self.defaultQuotaURL(region: .international), + url: Self.defaultQuotaURL(region: region), resolvingAgainstBaseURL: false) components?.path = "/data/api.json" components?.queryItems = defaultComponents?.queryItems @@ -485,15 +695,17 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return sourceHost == targetHost } - private static func throwIfErrorPayload(_ dictionary: [String: Any]) throws { + static func throwIfErrorPayload(_ dictionary: [String: Any]) throws { if self.parseBool(dictionary["successResponse"]) == false { if let statusCode = self.findFirstInt(forKeys: ["statusCode", "status_code", "code"], in: dictionary), statusCode == 401 || statusCode == 403 { throw AlibabaTokenPlanUsageError.invalidCredentials } - let code = self.findFirstString(forKeys: ["code", "status", "statusCode"], in: dictionary) - let message = self.findFirstString(forKeys: ["message", "msg", "statusMessage"], in: dictionary) ?? + let code = self.findFirstString(forKeys: ["errorCode", "code", "status", "statusCode"], in: dictionary) + let message = self.findFirstString( + forKeys: ["errorMsg", "message", "msg", "statusMessage"], + in: dictionary) ?? code ?? "request was not successful" if self.isLoginOrTokenError(code: code, message: message) { @@ -503,9 +715,10 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } if self.findBoolValues(forKeys: ["Success", "success"], in: dictionary).contains(false) { - let code = self.findFirstString(forKeys: ["Code", "code"], in: dictionary) - let message = self.findFirstString(forKeys: ["Message", "message", "msg", "Code", "code"], in: dictionary) - ?? "request was not successful" + let code = self.findFirstString(forKeys: ["errorCode", "Code", "code"], in: dictionary) + let message = self.findFirstString( + forKeys: ["errorMsg", "Message", "message", "msg", "Code", "code"], + in: dictionary) ?? "request was not successful" if self.isLoginOrTokenError(code: code, message: message) { throw AlibabaTokenPlanUsageError.loginRequired } @@ -526,8 +739,12 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { throw AlibabaTokenPlanUsageError.apiError(message) } - let codeText = self.findFirstString(forKeys: ["code", "status", "statusCode"], in: dictionary)?.lowercased() - let messageText = self.findFirstString(forKeys: ["message", "msg", "statusMessage"], in: dictionary)? + let codeText = self.findFirstString( + forKeys: ["errorCode", "code", "status", "statusCode"], + in: dictionary)?.lowercased() + let messageText = self.findFirstString( + forKeys: ["errorMsg", "message", "msg", "statusMessage"], + in: dictionary)? .lowercased() if self.isLoginOrTokenError(code: codeText, message: messageText) { throw AlibabaTokenPlanUsageError.loginRequired @@ -554,6 +771,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "package_name", "commodityName", "commodity_name", + "specType", + "SpecType", "instanceName", "instance_name", "displayName", @@ -592,6 +811,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "amount", "totalValue", "TotalValue", + "cycleTotalValue", + "CycleTotalValue", ] private static let remainingQuotaKeys = [ "remainingQuota", @@ -607,6 +828,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "TotalSurplusValue", "surplusValue", "SurplusValue", + "cycleSurplusValue", + "CycleSurplusValue", ] private static let subscriptionCountKeys = [ "totalCount", @@ -625,21 +848,34 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "endTime", "validEndTime", "instanceEndTime", + "EndTime", + "cycleEndTime", + "CycleEndTime", "nearestExpireDate", "NearestExpireDate", ] private static func findSubscriptionSummary(in payload: [String: Any]) -> [String: Any]? { + let quotaKeys = Self.usedQuotaKeys + Self.totalQuotaKeys + Self.remainingQuotaKeys if let data = self.findFirstDictionary( forKeys: ["Data", "data", "successResponse", "success_response"], in: payload), self.containsSubscriptionSummaryFields(data) { + // Some consoles (e.g. Qwen Cloud) wrap the quota values inside a nested + // `EquityList` entry while the outer dictionary only carries `TotalCount`. + // Prefer a nested dictionary that actually contains quota numbers so the + // used/total/remaining fields are read from the right level. + if quotaKeys.contains(where: { data[$0] != nil }) { + return data + } + if let nested = self.findFirstDictionary(matchingAnyKey: quotaKeys, in: data) { + return nested + } return data } return self.findFirstDictionary( - matchingAnyKey: Self.usedQuotaKeys + Self.totalQuotaKeys + Self.remainingQuotaKeys + - Self.subscriptionCountKeys, + matchingAnyKey: quotaKeys + Self.subscriptionCountKeys, in: payload) } @@ -804,30 +1040,12 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func expandedJSON(_ value: Any) -> Any { - if let dict = value as? [String: Any] { - var expanded: [String: Any] = [:] - expanded.reserveCapacity(dict.count) - for (key, nested) in dict { - expanded[key] = self.expandedJSON(nested) - } - return expanded - } - if let array = value as? [Any] { - return array.map { self.expandedJSON($0) } - } - if let string = value as? String, - let data = string.data(using: .utf8), - let nested = try? JSONSerialization.jsonObject(with: data, options: []), - nested is [String: Any] || nested is [Any] - { - return self.expandedJSON(nested) - } - return value + OneConsoleJSON.expandEmbeddedJSON(value) } private static func anyString(for keys: [String], in dict: [String: Any]) -> String? { for key in keys { - if let value = self.parseString(dict[key]) { + if let value = OneConsoleJSON.string(dict[key]) { return value } } @@ -845,7 +1063,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { private static func anyDate(for keys: [String], in dict: [String: Any]) -> Date? { for key in keys { - if let value = self.parseDate(dict[key]) { + if let value = OneConsoleJSON.date(dict[key]) { return value } } @@ -862,20 +1080,23 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func parseInt(_ raw: Any?) -> Int? { - if let value = raw as? Int { return value } - if let value = raw as? Int64 { return Int(value) } - if let value = raw as? Double { return Int(value) } - if let value = raw as? NSNumber { return value.intValue } - if let value = self.parseString(raw) { return Int(value) } - return nil + OneConsoleJSON.int(raw) } private static func parseDouble(_ raw: Any?) -> Double? { - if let value = raw as? Double { return value } - if let value = raw as? Int { return Double(value) } - if let value = raw as? Int64 { return Double(value) } - if let value = raw as? NSNumber { return value.doubleValue } - if let value = self.parseString(raw) { + if let value = raw as? Double { + return value + } + if let value = raw as? Int { + return Double(value) + } + if let value = raw as? Int64 { + return Double(value) + } + if let value = raw as? NSNumber { + return value.doubleValue + } + if let value = OneConsoleJSON.string(raw) { let cleaned = value.replacingOccurrences(of: ",", with: "") return Double(cleaned) } @@ -883,9 +1104,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func parseString(_ raw: Any?) -> String? { - guard let value = raw as? String else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed + OneConsoleJSON.string(raw) } private static func parseDate(_ raw: Any?) -> Date? { @@ -915,8 +1134,12 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func parseBool(_ raw: Any?) -> Bool? { - if let value = raw as? Bool { return value } - if let number = raw as? NSNumber { return number.boolValue } + if let value = raw as? Bool { + return value + } + if let number = raw as? NSNumber { + return number.boolValue + } guard let string = self.parseString(raw)?.lowercased() else { return nil } switch string { case "true", "1", "yes", "active", "valid", "normal": @@ -989,9 +1212,3 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return value.isEmpty ? nil : String(value) } } - -extension [String] { - fileprivate func uniquedSorted() -> [String] { - Array(Set(self)).sorted() - } -} diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift index 60d8b2870..5a7e153fc 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageSnapshot.swift @@ -6,6 +6,12 @@ public struct AlibabaTokenPlanUsageSnapshot: Sendable { public let totalQuota: Double? public let remainingQuota: Double? public let resetsAt: Date? + public let fiveHourUsedPercent: Double? + public let fiveHourTotalQuota: Double? + public let fiveHourResetsAt: Date? + public let weeklyUsedPercent: Double? + public let weeklyTotalQuota: Double? + public let weeklyResetsAt: Date? public let updatedAt: Date public init( @@ -14,6 +20,12 @@ public struct AlibabaTokenPlanUsageSnapshot: Sendable { totalQuota: Double?, remainingQuota: Double?, resetsAt: Date?, + fiveHourUsedPercent: Double? = nil, + fiveHourTotalQuota: Double? = nil, + fiveHourResetsAt: Date? = nil, + weeklyUsedPercent: Double? = nil, + weeklyTotalQuota: Double? = nil, + weeklyResetsAt: Date? = nil, updatedAt: Date) { self.planName = planName @@ -21,13 +33,26 @@ public struct AlibabaTokenPlanUsageSnapshot: Sendable { self.totalQuota = totalQuota self.remainingQuota = remainingQuota self.resetsAt = resetsAt + self.fiveHourUsedPercent = fiveHourUsedPercent + self.fiveHourTotalQuota = fiveHourTotalQuota + self.fiveHourResetsAt = fiveHourResetsAt + self.weeklyUsedPercent = weeklyUsedPercent + self.weeklyTotalQuota = weeklyTotalQuota + self.weeklyResetsAt = weeklyResetsAt self.updatedAt = updatedAt } } extension AlibabaTokenPlanUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - let primary: RateWindow? = Self.usedPercent( + let personalPrimary = self.fiveHourUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 5 * 60, + resetsAt: self.fiveHourResetsAt, + resetDescription: Self.quotaDetail(usedPercent: $0, total: self.fiveHourTotalQuota)) + } + let teamPrimary: RateWindow? = Self.usedPercent( used: self.usedQuota, total: self.totalQuota, remaining: self.remainingQuota).map { @@ -40,6 +65,14 @@ extension AlibabaTokenPlanUsageSnapshot { total: self.totalQuota, remaining: self.remainingQuota)) } + let primary = personalPrimary ?? teamPrimary + let secondary = self.weeklyUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 7 * 24 * 60, + resetsAt: self.weeklyResetsAt, + resetDescription: Self.quotaDetail(usedPercent: $0, total: self.weeklyTotalQuota)) + } let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -51,7 +84,7 @@ extension AlibabaTokenPlanUsageSnapshot { return UsageSnapshot( primary: primary, - secondary: nil, + secondary: secondary, tertiary: nil, providerCost: nil, alibabaTokenPlanUsage: self, @@ -86,6 +119,12 @@ extension AlibabaTokenPlanUsageSnapshot { return nil } + private static func quotaDetail(usedPercent: Double, total: Double?) -> String? { + guard let total, total > 0 else { return nil } + let used = total * usedPercent / 100 + return "\(Self.format(used)) / \(Self.format(total)) credits used" + } + private static func format(_ value: Double) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal diff --git a/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift index 8adaf8a94..c1877e40f 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpProviderDescriptor.swift @@ -35,6 +35,7 @@ public enum AmpProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Amp cost summary is not supported." }), + pace: .calendarMonthResetWindow, fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api, .web, .cli], pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), @@ -43,6 +44,14 @@ public enum AmpProviderDescriptor { versionDetector: nil)) } + public static func primaryLabel(details: AmpUsageDetails?) -> String? { + details?.subscriptionPlan == nil ? nil : "Other usage" + } + + public static func secondaryLabel(details: AmpUsageDetails?) -> String? { + details?.subscriptionPlan == nil ? nil : "Orb usage" + } + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { switch context.sourceMode { case .auto: diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageParser.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageParser.swift index 90ac8fca9..be0a959a2 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageParser.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageParser.swift @@ -31,6 +31,10 @@ enum AmpUsageParser { #"\s+remaining(?:\s*\(replenishes\s*\+\$?"# + amountPattern + #"\s*/\s*hour\))?"# let freePercentPattern = #"(?im)^\s*Amp Free:\s*"# + amountPattern + #"\s*%\s+remaining(?:\s+today)?(?:\s*\(resets\s+daily\))?"# + let subscriptionPattern = #"(?im)^\s*Subscription\s+(.+?):\s*"# + amountPattern + + #"\s*%\s+other\s+usage\s+and\s+"# + amountPattern + + #"\s*%\s+orb\s+usage\s+remaining\s*-\s*resets\s+upon\s+renewal\s+in\s+"# + + #"([0-9][0-9,]*)\s+days?(?:\s+-\s+https?://\S+)?\s*$"# let creditsPattern = #"(?im)^\s*Individual credits:\s*\$?"# + amountPattern + #"\s+remaining"# let individualCredits = self.captures(in: text, pattern: creditsPattern)?.first .flatMap(self.number(from:)) @@ -73,7 +77,25 @@ enum AmpUsageParser { resetDescription: "resets daily") }() let resolvedFreeUsage = freeUsage ?? freePercentUsage - guard resolvedFreeUsage != nil || individualCredits != nil || !workspaceBalances.isEmpty else { + let subscriptionUsage: AmpSubscriptionUsage? = { + guard let subscription = self.captures(in: text, pattern: subscriptionPattern), + subscription.count == 4, + let plan = self.nonEmpty(subscription[0]), + let otherRemaining = self.number(from: subscription[1]), + let orbRemaining = self.number(from: subscription[2]), + let renewalDays = Int(subscription[3].replacingOccurrences(of: ",", with: "")) + else { return nil } + let resetDescription = renewalDays == 1 ? "renews in 1 day" : "renews in \(renewalDays) days" + return AmpSubscriptionUsage( + plan: plan, + otherUsedPercent: 100 - min(100, max(0, otherRemaining)), + orbUsedPercent: 100 - min(100, max(0, orbRemaining)), + resetsAt: now.addingTimeInterval(TimeInterval(renewalDays) * 24 * 60 * 60), + resetDescription: resetDescription) + }() + guard resolvedFreeUsage != nil || subscriptionUsage != nil || individualCredits != nil || + !workspaceBalances.isEmpty + else { throw AmpUsageError.parseFailed("Missing Amp usage data.") } @@ -87,7 +109,8 @@ enum AmpUsageParser { accountEmail: self.nonEmpty(identity?[0]), accountOrganization: self.nonEmpty(identity?[1]), updatedAt: now, - freeResetDescription: resolvedFreeUsage?.resetDescription) + freeResetDescription: resolvedFreeUsage?.resetDescription, + subscription: subscriptionUsage) } private struct FreeTierUsage { diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageSnapshot.swift index dfa670d1c..58e951522 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageSnapshot.swift @@ -13,10 +13,38 @@ public struct AmpWorkspaceBalance: Codable, Equatable, Sendable { public struct AmpUsageDetails: Codable, Equatable, Sendable { public let individualCredits: Double? public let workspaceBalances: [AmpWorkspaceBalance] + public let subscriptionPlan: String? - public init(individualCredits: Double?, workspaceBalances: [AmpWorkspaceBalance]) { + public init( + individualCredits: Double?, + workspaceBalances: [AmpWorkspaceBalance], + subscriptionPlan: String? = nil) + { self.individualCredits = individualCredits self.workspaceBalances = workspaceBalances + self.subscriptionPlan = subscriptionPlan + } +} + +public struct AmpSubscriptionUsage: Equatable, Sendable { + public let plan: String + public let otherUsedPercent: Double + public let orbUsedPercent: Double + public let resetsAt: Date + public let resetDescription: String + + public init( + plan: String, + otherUsedPercent: Double, + orbUsedPercent: Double, + resetsAt: Date, + resetDescription: String) + { + self.plan = plan + self.otherUsedPercent = otherUsedPercent + self.orbUsedPercent = orbUsedPercent + self.resetsAt = resetsAt + self.resetDescription = resetDescription } } @@ -31,6 +59,7 @@ public struct AmpUsageSnapshot: Sendable { public let accountOrganization: String? public let updatedAt: Date public let freeResetDescription: String? + public let subscription: AmpSubscriptionUsage? public init( freeQuota: Double?, @@ -42,7 +71,8 @@ public struct AmpUsageSnapshot: Sendable { accountEmail: String? = nil, accountOrganization: String? = nil, updatedAt: Date, - freeResetDescription: String? = nil) + freeResetDescription: String? = nil, + subscription: AmpSubscriptionUsage? = nil) { self.freeQuota = freeQuota self.freeUsed = freeUsed @@ -54,12 +84,13 @@ public struct AmpUsageSnapshot: Sendable { self.accountOrganization = accountOrganization self.updatedAt = updatedAt self.freeResetDescription = freeResetDescription + self.subscription = subscription } } extension AmpUsageSnapshot { public func toUsageSnapshot(now: Date = Date()) -> UsageSnapshot { - let primary: RateWindow? = if let freeQuota, let freeUsed { + let freeWindow: RateWindow? = if let freeQuota, let freeUsed { { let quota = max(0, freeQuota) let used = max(0, freeUsed) @@ -83,23 +114,42 @@ extension AmpUsageSnapshot { nil } + let subscriptionPrimary = self.subscription.map { usage in + RateWindow( + usedPercent: usage.otherUsedPercent, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: usage.resetsAt, + resetDescription: usage.resetDescription) + } + let subscriptionSecondary = self.subscription.map { usage in + RateWindow( + usedPercent: usage.orbUsedPercent, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: usage.resetsAt, + resetDescription: usage.resetDescription) + } + let primary = subscriptionPrimary ?? freeWindow + let identity = ProviderIdentitySnapshot( providerID: .amp, accountEmail: self.accountEmail, accountOrganization: self.accountOrganization, - loginMethod: primary == nil ? "Amp" : "Amp Free") + loginMethod: self.subscription?.plan ?? (primary == nil ? "Amp" : "Amp Free")) - let ampUsage: AmpUsageDetails? = if self.individualCredits != nil || !self.workspaceBalances.isEmpty { + let ampUsage: AmpUsageDetails? = if self.individualCredits != nil || !self.workspaceBalances.isEmpty || + self.subscription != nil + { AmpUsageDetails( individualCredits: self.individualCredits, - workspaceBalances: self.workspaceBalances) + workspaceBalances: self.workspaceBalances, + subscriptionPlan: self.subscription?.plan) } else { nil } return UsageSnapshot( primary: primary, - secondary: nil, + secondary: subscriptionSecondary, tertiary: nil, ampUsage: ampUsage, providerCost: nil, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeConfigPaths.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeConfigPaths.swift new file mode 100644 index 000000000..1c76f77ce --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeConfigPaths.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Resolves Claude-owned files from the fetch environment, matching Claude Code's profile boundary. +public enum ClaudeConfigPaths { + public static let configDirectoryEnvironmentKey = "CLAUDE_CONFIG_DIR" + public static let secureStorageDirectoryEnvironmentKey = "CLAUDE_SECURESTORAGE_CONFIG_DIR" + + /// Claude treats `CLAUDE_CONFIG_DIR` as one literal directory. Empty means the default `~/.claude` root. + public static func configRoot( + environment: [String: String], + workingDirectory: URL? = nil) -> URL + { + if let configuredRoot = self.nonemptyLiteral(environment[self.configDirectoryEnvironmentKey]) { + return self.directoryURL(configuredRoot, workingDirectory: workingDirectory) + } + return self.defaultConfigRoot(environment: environment, workingDirectory: workingDirectory) + } + + public static func accountConfigURL( + environment: [String: String], + workingDirectory: URL? = nil) -> URL + { + let root = self.configRoot(environment: environment, workingDirectory: workingDirectory) + let profileConfig = root.appendingPathComponent(".config.json") + if FileManager.default.fileExists(atPath: profileConfig.path) { + return profileConfig + } + + if self.nonemptyLiteral(environment[self.configDirectoryEnvironmentKey]) != nil { + return root.appendingPathComponent(".claude.json") + } + return self.homeDirectory(environment: environment, workingDirectory: workingDirectory) + .appendingPathComponent(".claude.json") + } + + public static func credentialsURL( + environment: [String: String], + workingDirectory: URL? = nil) -> URL + { + let root: URL = if let secureStorageRoot = environment[self.secureStorageDirectoryEnvironmentKey] { + if secureStorageRoot.isEmpty { + self.defaultConfigRoot(environment: environment, workingDirectory: workingDirectory) + } else { + self.directoryURL(secureStorageRoot, workingDirectory: workingDirectory) + } + } else { + self.configRoot(environment: environment, workingDirectory: workingDirectory) + } + return root.appendingPathComponent(".credentials.json") + } + + public static func homeDirectory( + environment: [String: String], + workingDirectory: URL? = nil) -> URL + { + if let rawHome = self.nonemptyLiteral(environment["HOME"]) { + return self.directoryURL(rawHome, workingDirectory: workingDirectory) + } + return FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL + } + + private static func defaultConfigRoot( + environment: [String: String], + workingDirectory: URL?) -> URL + { + self.homeDirectory(environment: environment, workingDirectory: workingDirectory) + .appendingPathComponent(".claude", isDirectory: true) + } + + private static func nonemptyLiteral(_ raw: String?) -> String? { + guard let raw, !raw.isEmpty else { return nil } + return raw + } + + private static func directoryURL(_ path: String, workingDirectory: URL?) -> URL { + // `NSString.isAbsolutePath` treats `~/...` as absolute and Foundation expands it. Claude receives the + // environment value directly, so only a leading POSIX slash is absolute here. + if path.hasPrefix("/") { + return URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + } + + // Claude resolves literal relative profile roots against its process CWD. All CodexBar-owned Claude + // subprocesses run from this dedicated probe directory, so resolve plain-text profile evidence there too. + // `appendingPathComponent` intentionally keeps `~` literal instead of applying shell-style expansion. + let base = workingDirectory ?? ClaudeStatusProbe.probeWorkingDirectoryURL() + return base.appendingPathComponent(path, isDirectory: true).standardizedFileURL + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift index d64007abc..c4aeeddf1 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+TestingOverrides.swift @@ -22,34 +22,118 @@ extension ClaudeOAuthCredentialsStore { @TaskLocal static var taskMemoryCacheStoreOverride: MemoryCacheStore? @TaskLocal static var taskClaudeKeychainFingerprintStoreOverride: ClaudeKeychainFingerprintStore? @TaskLocal static var taskPendingCacheClearStoreOverride: ClaudeOAuthPendingCacheClearStore? + @TaskLocal static var taskImplicitPendingCacheClearStoreOverride: ClaudeOAuthPendingCacheClearStore? + @TaskLocal static var taskUseEnvironmentCredentialsURLForTesting = false typealias OAuthCacheOperation = KeychainCacheStore.Operation typealias OAuthCacheOperationRecorder = KeychainCacheStore.OperationRecorder final class PendingCacheClearMemoryStore: ClaudeOAuthPendingCacheClearStore, @unchecked Sendable { private let lock = NSLock() - private var pending: Bool + private var unscopedPending: Bool + private var pendingProfileIdentifiers: Set = [] + private var legacyCleanupProfileIdentifiers: Set = [] + private var legacyRecheckProfileIdentifiers: Set = [] init(isPending: Bool = false) { - self.pending = isPending + self.unscopedPending = isPending } var isPending: Bool { self.lock.lock() defer { self.lock.unlock() } - return self.pending + return self.unscopedPending || + !self.pendingProfileIdentifiers.isEmpty || + !self.legacyCleanupProfileIdentifiers.isEmpty || + !self.legacyRecheckProfileIdentifiers.isEmpty } func markPending() { self.lock.lock() - self.pending = true + self.unscopedPending = true self.lock.unlock() } func withCacheTransaction(_ operation: (inout Bool) -> Void) { self.lock.lock() defer { self.lock.unlock() } - operation(&self.pending) + operation(&self.unscopedPending) + } + + func isPending(profileIdentifier: String) -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.unscopedPending || + self.pendingProfileIdentifiers.contains(profileIdentifier) || + self.legacyCleanupProfileIdentifiers.contains(profileIdentifier) || + self.legacyRecheckProfileIdentifiers.contains(profileIdentifier) + } + + func markPending(profileIdentifier: String) { + self.lock.lock() + self.pendingProfileIdentifiers.insert(profileIdentifier) + self.lock.unlock() + } + + func withCacheTransaction(profileIdentifier: String, _ operation: (inout Bool) -> Void) { + self.withCacheTransaction( + profileIdentifier: profileIdentifier, + includingLegacyCleanup: { profilePending, legacyCleanupPending in + var pending = profilePending || legacyCleanupPending + operation(&pending) + if pending { + if !profilePending, !legacyCleanupPending { + profilePending = true + } + } else { + profilePending = false + legacyCleanupPending = false + } + }) + } + + func withCacheTransaction( + profileIdentifier: String, + includingLegacyCleanup operation: (inout Bool, inout Bool) -> Void) + { + self.withCacheTransaction( + profileIdentifier: profileIdentifier, + includingLegacyState: { profilePending, legacyCleanupPending, legacyRecheckPending in + operation(&profilePending, &legacyCleanupPending) + if legacyCleanupPending { + legacyRecheckPending = false + } + }) + } + + func withCacheTransaction( + profileIdentifier: String, + includingLegacyState operation: (inout Bool, inout Bool, inout Bool) -> Void) + { + self.lock.lock() + defer { self.lock.unlock() } + var profilePending = self.unscopedPending || + self.pendingProfileIdentifiers.contains(profileIdentifier) + var legacyCleanupPending = self.unscopedPending || + self.legacyCleanupProfileIdentifiers.contains(profileIdentifier) + var legacyRecheckPending = self.legacyRecheckProfileIdentifiers.contains(profileIdentifier) + operation(&profilePending, &legacyCleanupPending, &legacyRecheckPending) + self.unscopedPending = false + if profilePending { + self.pendingProfileIdentifiers.insert(profileIdentifier) + } else { + self.pendingProfileIdentifiers.remove(profileIdentifier) + } + if legacyCleanupPending { + self.legacyCleanupProfileIdentifiers.insert(profileIdentifier) + } else { + self.legacyCleanupProfileIdentifiers.remove(profileIdentifier) + } + if legacyRecheckPending { + self.legacyRecheckProfileIdentifiers.insert(profileIdentifier) + } else { + self.legacyRecheckProfileIdentifiers.remove(profileIdentifier) + } } } @@ -64,6 +148,7 @@ extension ClaudeOAuthCredentialsStore { final class MemoryCacheStore: @unchecked Sendable { var record: ClaudeOAuthCredentialRecord? var timestamp: Date? + var profileIdentifier: String? } static func withClaudeKeychainOverridesForTesting( @@ -113,7 +198,9 @@ extension ClaudeOAuthCredentialsStore { let preAlertStore = ClaudeOAuthKeychainPreAlertGate.StateStore() return try ClaudeOAuthKeychainPreAlertGate.withStateStoreOverrideForTesting(preAlertStore) { try self.$taskMemoryCacheStoreOverride.withValue(store) { - try operation() + try self.withAutoIsolatedPendingCacheClearStoreIfNeededForTesting { + try operation() + } } } } @@ -123,24 +210,79 @@ extension ClaudeOAuthCredentialsStore { let preAlertStore = ClaudeOAuthKeychainPreAlertGate.StateStore() return try await ClaudeOAuthKeychainPreAlertGate.withStateStoreOverrideForTesting(preAlertStore) { try await self.$taskMemoryCacheStoreOverride.withValue(store) { - try await operation() + try await self.withAutoIsolatedPendingCacheClearStoreIfNeededForTesting { + try await operation() + } } } } + private static func withAutoIsolatedPendingCacheClearStoreIfNeededForTesting( + operation: () throws -> T) rethrows -> T + { + if self.taskPendingCacheClearStoreOverride != nil { + return try operation() + } + let pendingStore = PendingCacheClearMemoryStore() + return try self.$taskPendingCacheClearStoreOverride.withValue(pendingStore) { + try operation() + } + } + + private static func withAutoIsolatedPendingCacheClearStoreIfNeededForTesting( + operation: () async throws -> T) async rethrows -> T + { + if self.taskPendingCacheClearStoreOverride != nil { + return try await operation() + } + let pendingStore = PendingCacheClearMemoryStore() + return try await self.$taskPendingCacheClearStoreOverride.withValue(pendingStore) { + try await operation() + } + } + final class CredentialsFileFingerprintStore: @unchecked Sendable { - var fingerprint: CredentialsFileFingerprint? + private var fingerprints: [String: CredentialsFileFingerprint] = [:] + private var legacyFingerprint: CredentialsFileFingerprint? init(fingerprint: CredentialsFileFingerprint? = nil) { - self.fingerprint = fingerprint + self.legacyFingerprint = fingerprint } - func load() -> CredentialsFileFingerprint? { - self.fingerprint + func load( + profileIdentifier: String, + historicalProfileIdentifier: String) -> CredentialsFileFingerprint? + { + if let fingerprint = self.fingerprints[profileIdentifier] { + if profileIdentifier == historicalProfileIdentifier { + self.legacyFingerprint = nil + } + return fingerprint + } + guard profileIdentifier == historicalProfileIdentifier, + let fingerprint = self.legacyFingerprint + else { + return nil + } + self.fingerprints[profileIdentifier] = fingerprint + self.legacyFingerprint = nil + return fingerprint } - func save(_ fingerprint: CredentialsFileFingerprint?) { - self.fingerprint = fingerprint + func save( + _ fingerprint: CredentialsFileFingerprint?, + profileIdentifier: String, + historicalProfileIdentifier: String) + { + if profileIdentifier == historicalProfileIdentifier { + self.legacyFingerprint = nil + } + self.fingerprints[profileIdentifier] = fingerprint + } + + func reset() { + self.fingerprints.removeAll() + self.legacyFingerprint = nil } } @@ -214,7 +356,7 @@ extension ClaudeOAuthCredentialsStore { } } - fileprivate static func withCredentialsFileFingerprintStoreOverrideForTesting( + static func withCredentialsFileFingerprintStoreOverrideForTesting( _ store: CredentialsFileFingerprintStore?, operation: () throws -> T) rethrows -> T { @@ -223,7 +365,7 @@ extension ClaudeOAuthCredentialsStore { } } - fileprivate static func withCredentialsFileFingerprintStoreOverrideForTesting( + static func withCredentialsFileFingerprintStoreOverrideForTesting( _ store: CredentialsFileFingerprintStore?, operation: () async throws -> T) async rethrows -> T { @@ -241,6 +383,22 @@ extension ClaudeOAuthCredentialsStore { } } + /// Test-only opt-in for fixtures that supply an explicit temporary Claude profile directory. + /// Default test execution still resolves the normal credentials path to an isolated nonexistent file. + static func withEnvironmentCredentialsURLForTesting(operation: () throws -> T) rethrows -> T { + try self.$taskUseEnvironmentCredentialsURLForTesting.withValue(true) { + try operation() + } + } + + static func withEnvironmentCredentialsURLForTesting( + operation: () async throws -> T) async rethrows -> T + { + try await self.$taskUseEnvironmentCredentialsURLForTesting.withValue(true) { + try await operation() + } + } + static func withPendingCacheClearStoreOverrideForTesting( _ store: ClaudeOAuthPendingCacheClearStore?, operation: () throws -> T) rethrows -> T @@ -259,6 +417,15 @@ extension ClaudeOAuthCredentialsStore { } } + static func withImplicitPendingCacheClearStoreOverrideForTesting( + _ store: ClaudeOAuthPendingCacheClearStore?, + operation: () throws -> T) rethrows -> T + { + try self.$taskImplicitPendingCacheClearStoreOverride.withValue(store) { + try operation() + } + } + static func withOAuthCacheOperationRecorderForTesting( _ recorder: OAuthCacheOperationRecorder?, operation: () throws -> T) rethrows -> T diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 91ff4c175..e92e7389f 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -12,9 +12,11 @@ import Security // swiftlint:disable type_body_length file_length public enum ClaudeOAuthCredentialsStore { - private static let credentialsPath = ".claude/.credentials.json" static let claudeKeychainService = "Claude Code-credentials" - private static let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + /// The pre-profile cache key used by released builds. New entries are scoped to the + /// one-way credentials-profile identity so one `CLAUDE_CONFIG_DIR` cannot evict another. + private static let legacyCacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + private static let profileCacheKeyPrefix = "claude.profile." public static let environmentTokenKey = "CODEXBAR_CLAUDE_OAUTH_TOKEN" public static let environmentScopesKey = "CODEXBAR_CLAUDE_OAUTH_SCOPES" @@ -32,7 +34,10 @@ public enum ClaudeOAuthCredentialsStore { } static let log = CodexBarLog.logger(LogCategories.claudeUsage) - private static let fileFingerprintKey = "ClaudeOAuthCredentialsFileFingerprintV2" + /// The unscoped fingerprint key used by released builds. It is attributable only to the + /// historical default credentials profile and is migrated lazily to that profile. + private static let legacyFileFingerprintKey = "ClaudeOAuthCredentialsFileFingerprintV2" + private static let fileFingerprintProfileSeparator = ".profile." private static let claudeKeychainPromptLock = NSLock() private enum PromptAttemptResult { case record(ClaudeOAuthCredentialRecord) @@ -54,6 +59,7 @@ public enum ClaudeOAuthCredentialsStore { private struct PromptAttemptOutcome { let generation: UInt64 let requestID: UUID? + let profileIdentifier: String let policy: PromptAttemptPolicy let result: PromptAttemptResult } @@ -102,8 +108,31 @@ public enum ClaudeOAuthCredentialsStore { } struct CredentialsFileFingerprint: Codable, Equatable { + let path: String let modifiedAtMs: Int? let size: Int + + private enum CodingKeys: String, CodingKey { + case path + case modifiedAtMs + case size + } + + init(path: String, modifiedAtMs: Int?, size: Int) { + self.path = path + self.modifiedAtMs = modifiedAtMs + self.size = size + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Released V2 fingerprints predate profile scoping and did not encode a path. Those + // fingerprints could only describe the historical default Claude credentials file. + self.path = try container.decodeIfPresent(String.self, forKey: .path) + ?? ClaudeOAuthCredentialsStore.historicalDefaultCredentialsFilePath + self.modifiedAtMs = try container.decodeIfPresent(Int.self, forKey: .modifiedAtMs) + self.size = try container.decode(Int.self, forKey: .size) + } } struct CacheEntry: Codable { @@ -111,52 +140,81 @@ public enum ClaudeOAuthCredentialsStore { let storedAt: Date let owner: ClaudeOAuthCredentialOwner? let historyOwnerIdentifier: String? + /// One-way ownership evidence for the Claude profile whose credentials path produced this cache. + /// A missing value is a legacy entry. It can be migrated only to the historical default profile. + let profileIdentifier: String? init( data: Data, storedAt: Date, owner: ClaudeOAuthCredentialOwner? = nil, - historyOwnerIdentifier: String? = nil) + historyOwnerIdentifier: String? = nil, + profileIdentifier: String? = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: ProcessInfo.processInfo.environment)) { self.data = data self.storedAt = storedAt self.owner = owner self.historyOwnerIdentifier = ClaudeOAuthCredentials.normalizedHistoryOwnerIdentifier( historyOwnerIdentifier) + self.profileIdentifier = profileIdentifier } } #if DEBUG @TaskLocal private static var taskCredentialsURLOverride: URL? + @TaskLocal private static var taskCredentialsProfileIdentifierOverride: String? + private static let isolatedTestCredentialsURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("credentials.json") #endif // In-memory cache (nonisolated for synchronous access) private static let memoryCacheLock = NSLock() private nonisolated(unsafe) static var cachedCredentialRecord: ClaudeOAuthCredentialRecord? private nonisolated(unsafe) static var cacheTimestamp: Date? + private nonisolated(unsafe) static var cachedProfileIdentifier: String? private static let memoryCacheValidityDuration: TimeInterval = 1800 - private static func readMemoryCache() -> (record: ClaudeOAuthCredentialRecord?, timestamp: Date?) { + private static func readMemoryCache() + -> (record: ClaudeOAuthCredentialRecord?, timestamp: Date?, profileIdentifier: String?) + { #if DEBUG if let store = self.taskMemoryCacheStoreOverride { - return (store.record, store.timestamp) + return (store.record, store.timestamp, store.profileIdentifier) } #endif self.memoryCacheLock.lock() defer { self.memoryCacheLock.unlock() } - return (self.cachedCredentialRecord, self.cacheTimestamp) + return (self.cachedCredentialRecord, self.cacheTimestamp, self.cachedProfileIdentifier) + } + + private static func readMemoryCache( + profileIdentifier: String) + -> (record: ClaudeOAuthCredentialRecord?, timestamp: Date?, profileIdentifier: String?) + { + let memory = self.readMemoryCache() + guard memory.record != nil, memory.profileIdentifier != profileIdentifier else { return memory } + self.writeMemoryCache(record: nil, timestamp: nil, profileIdentifier: nil) + return (nil, nil, nil) } - private static func writeMemoryCache(record: ClaudeOAuthCredentialRecord?, timestamp: Date?) { + private static func writeMemoryCache( + record: ClaudeOAuthCredentialRecord?, + timestamp: Date?, + profileIdentifier: String?) + { #if DEBUG if let store = self.taskMemoryCacheStoreOverride { store.record = record store.timestamp = timestamp + store.profileIdentifier = profileIdentifier return } #endif self.memoryCacheLock.lock() self.cachedCredentialRecord = record self.cacheTimestamp = timestamp + self.cachedProfileIdentifier = profileIdentifier self.memoryCacheLock.unlock() } @@ -224,6 +282,8 @@ public enum ClaudeOAuthCredentialsStore { allowClaudeKeychainRepairWithoutPrompt: Bool) throws -> ClaudeOAuthCredentialRecord { try self.context.run { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environment) let shouldRespectKeychainPromptCooldownForSilentProbes = respectKeychainPromptCooldown || !allowKeychainPrompt @@ -231,16 +291,18 @@ public enum ClaudeOAuthCredentialsStore { return immediateRecord } - let recovery = Recovery(context: self.context) - let memory = ClaudeOAuthCredentialsStore.readMemoryCache() + let recovery = Recovery(context: self.context, profileIdentifier: profileIdentifier) + let memory = ClaudeOAuthCredentialsStore.readMemoryCache(profileIdentifier: profileIdentifier) if ClaudeOAuthCredentialsStore.shouldUseCodexBarOAuthKeychainCache, - !ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear, + !ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear( + profileIdentifier: profileIdentifier), let cachedRecord = memory.record, let timestamp = memory.timestamp, + memory.profileIdentifier == profileIdentifier, Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, !cachedRecord.credentials.isExpired { - let owner = self.resolvedCacheOwner(cachedRecord.owner) + let owner = self.resolvedCacheOwner(cachedRecord.owner, environment: environment) let record = ClaudeOAuthCredentialRecord( credentials: cachedRecord.credentials, owner: owner, @@ -260,10 +322,12 @@ public enum ClaudeOAuthCredentialsStore { var expiredRecord: ClaudeOAuthCredentialRecord? var cacheTemporarilyUnavailable = false - switch ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache() { + switch ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache( + profileIdentifier: profileIdentifier) + { case let .found(entry): if let creds = try? ClaudeOAuthCredentials.parse(data: entry.data) { - let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI) + let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI, environment: environment) let record = ClaudeOAuthCredentialRecord( credentials: creds, owner: owner, @@ -285,14 +349,15 @@ public enum ClaudeOAuthCredentialsStore { owner: owner, source: .memoryCache, historyOwnerIdentifier: record.historyOwnerIdentifier), - timestamp: Date()) + timestamp: Date(), + profileIdentifier: profileIdentifier) return record } } else { - ClaudeOAuthCredentialsStore.clearCacheKeychain() + ClaudeOAuthCredentialsStore.clearCacheKeychain(profileIdentifier: profileIdentifier) } case .invalid: - ClaudeOAuthCredentialsStore.clearCacheKeychain() + ClaudeOAuthCredentialsStore.clearCacheKeychain(profileIdentifier: profileIdentifier) case .temporarilyUnavailable: cacheTemporarilyUnavailable = true case .missing: @@ -300,7 +365,7 @@ public enum ClaudeOAuthCredentialsStore { } do { - let fileData = try ClaudeOAuthCredentialsStore.loadFromFile() + let fileData = try ClaudeOAuthCredentialsStore.loadFromFile(environment: environment) let creds = try ClaudeOAuthCredentials.parse(data: fileData) let record = ClaudeOAuthCredentialRecord( credentials: creds, @@ -314,9 +379,13 @@ public enum ClaudeOAuthCredentialsStore { credentials: creds, owner: .claudeCLI, source: .memoryCache), - timestamp: Date()) + timestamp: Date(), + profileIdentifier: profileIdentifier) if !cacheTemporarilyUnavailable { - ClaudeOAuthCredentialsStore.saveToCacheKeychain(fileData, owner: .claudeCLI) + ClaudeOAuthCredentialsStore.saveToCacheKeychain( + fileData, + owner: .claudeCLI, + profileIdentifier: profileIdentifier) } return record } @@ -343,6 +412,7 @@ public enum ClaudeOAuthCredentialsStore { allowKeychainPrompt: allowKeychainPrompt, respectKeychainPromptCooldown: respectKeychainPromptCooldown, allowCacheKeychainWrite: !cacheTemporarilyUnavailable, + environment: environment, lastError: &lastError) { return prompted @@ -365,10 +435,13 @@ public enum ClaudeOAuthCredentialsStore { owner: .environment, source: .environment) } - _ = self.invalidateCacheIfCredentialsFileChanged() + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environment) + _ = self.invalidateCacheIfCredentialsFileChanged(environment: environment) guard let requestID = ProviderRefreshRequestContext.id, let outcome = ClaudeOAuthCredentialsStore.readPromptAttemptOutcome(), outcome.requestID == requestID, + outcome.profileIdentifier == profileIdentifier, outcome.generation == ClaudeOAuthKeychainAccessGate.promptAttemptGeneration(), outcome.policy == PromptAttemptPolicy.current else { @@ -385,9 +458,12 @@ public enum ClaudeOAuthCredentialsStore { allowKeychainPrompt: Bool, respectKeychainPromptCooldown: Bool, allowCacheKeychainWrite: Bool, + environment: [String: String], lastError: inout Error?) -> ClaudeOAuthCredentialRecord? { guard allowKeychainPrompt else { return nil } + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environment) let promptGeneration = ClaudeOAuthKeychainAccessGate.promptAttemptGeneration() let refreshRequestID = ProviderRefreshRequestContext.id #if DEBUG @@ -405,6 +481,7 @@ public enum ClaudeOAuthCredentialsStore { let policy = PromptAttemptPolicy.current if currentPromptGeneration != promptGeneration || (refreshRequestID != nil && outcome?.requestID == refreshRequestID), + outcome?.profileIdentifier == profileIdentifier, outcome?.policy == policy { guard outcome?.generation == currentPromptGeneration else { return nil } @@ -417,7 +494,10 @@ public enum ClaudeOAuthCredentialsStore { } } - if let cachedRecord = self.validCachedCredentialAfterWaitingForPromptLock() { + if let cachedRecord = self.validCachedCredentialAfterWaitingForPromptLock( + environment: environment, + profileIdentifier: profileIdentifier) + { return cachedRecord } @@ -436,6 +516,7 @@ public enum ClaudeOAuthCredentialsStore { ClaudeOAuthCredentialsStore.writePromptAttemptOutcome(PromptAttemptOutcome( generation: generation, requestID: refreshRequestID, + profileIdentifier: profileIdentifier, policy: policy, result: promptAttemptResult)) } @@ -445,7 +526,8 @@ public enum ClaudeOAuthCredentialsStore { promptMode: promptMode, allowKeychainPrompt: allowKeychainPrompt, respectKeychainPromptCooldown: respectKeychainPromptCooldown, - allowCacheKeychainWrite: allowCacheKeychainWrite) + allowCacheKeychainWrite: allowCacheKeychainWrite, + profileIdentifier: profileIdentifier) if let record { promptAttemptResult = .record(record) } @@ -465,29 +547,35 @@ public enum ClaudeOAuthCredentialsStore { return nil } - private func validCachedCredentialAfterWaitingForPromptLock() -> ClaudeOAuthCredentialRecord? { - let memory = ClaudeOAuthCredentialsStore.readMemoryCache() + private func validCachedCredentialAfterWaitingForPromptLock( + environment: [String: String], + profileIdentifier: String) -> ClaudeOAuthCredentialRecord? + { + let memory = ClaudeOAuthCredentialsStore.readMemoryCache(profileIdentifier: profileIdentifier) if ClaudeOAuthCredentialsStore.shouldUseCodexBarOAuthKeychainCache, - !ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear, + !ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear( + profileIdentifier: profileIdentifier), let cachedRecord = memory.record, let timestamp = memory.timestamp, + memory.profileIdentifier == profileIdentifier, Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, !cachedRecord.credentials.isExpired { - let owner = self.resolvedCacheOwner(cachedRecord.owner) + let owner = self.resolvedCacheOwner(cachedRecord.owner, environment: environment) return ClaudeOAuthCredentialRecord( credentials: cachedRecord.credentials, owner: owner, source: .memoryCache, historyOwnerIdentifier: cachedRecord.historyOwnerIdentifier) } - guard case let .found(entry) = ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache(), - let credentials = try? ClaudeOAuthCredentials.parse(data: entry.data), - !credentials.isExpired + guard case let .found(entry) = ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache( + profileIdentifier: profileIdentifier), + let credentials = try? ClaudeOAuthCredentials.parse(data: entry.data), + !credentials.isExpired else { return nil } - let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI) + let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI, environment: environment) return ClaudeOAuthCredentialRecord( credentials: credentials, owner: owner, @@ -499,13 +587,15 @@ public enum ClaudeOAuthCredentialsStore { promptMode: ClaudeOAuthKeychainPromptMode, allowKeychainPrompt: Bool, respectKeychainPromptCooldown: Bool, - allowCacheKeychainWrite: Bool) throws -> ClaudeOAuthCredentialRecord? + allowCacheKeychainWrite: Bool, + profileIdentifier: String) throws -> ClaudeOAuthCredentialRecord? { #if DEBUG if let readOverride = ClaudeOAuthCredentialsStore.taskInteractiveClaudeKeychainReadOverride { return try self.recordClaudeKeychainData( readOverride(), - allowCacheKeychainWrite: allowCacheKeychainWrite) + allowCacheKeychainWrite: allowCacheKeychainWrite, + profileIdentifier: profileIdentifier) } #endif @@ -516,7 +606,8 @@ public enum ClaudeOAuthCredentialsStore { { return try self.recordClaudeKeychainData( keychainData, - allowCacheKeychainWrite: allowCacheKeychainWrite) + allowCacheKeychainWrite: allowCacheKeychainWrite, + profileIdentifier: profileIdentifier) } var securityFrameworkPromptMode = promptMode @@ -555,12 +646,14 @@ public enum ClaudeOAuthCredentialsStore { } return try self.recordClaudeKeychainData( keychainData, - allowCacheKeychainWrite: allowCacheKeychainWrite) + allowCacheKeychainWrite: allowCacheKeychainWrite, + profileIdentifier: profileIdentifier) } private func recordClaudeKeychainData( _ keychainData: Data, - allowCacheKeychainWrite: Bool) throws -> ClaudeOAuthCredentialRecord + allowCacheKeychainWrite: Bool, + profileIdentifier: String) throws -> ClaudeOAuthCredentialRecord { let credentials = try ClaudeOAuthCredentials.parse(data: keychainData) let record = ClaudeOAuthCredentialRecord( @@ -572,22 +665,29 @@ public enum ClaudeOAuthCredentialsStore { credentials: credentials, owner: .claudeCLI, source: .memoryCache), - timestamp: Date()) + timestamp: Date(), + profileIdentifier: profileIdentifier) if allowCacheKeychainWrite { - ClaudeOAuthCredentialsStore.saveToCacheKeychain(keychainData, owner: .claudeCLI) + ClaudeOAuthCredentialsStore.saveToCacheKeychain( + keychainData, + owner: .claudeCLI, + profileIdentifier: profileIdentifier) } return record } - private func resolvedCacheOwner(_ owner: ClaudeOAuthCredentialOwner) -> ClaudeOAuthCredentialOwner { + private func resolvedCacheOwner( + _ owner: ClaudeOAuthCredentialOwner, + environment: [String: String]) -> ClaudeOAuthCredentialOwner + { guard owner == .codexbar else { return owner } - guard self.hasClaudeCLIStorageWithoutPrompt() else { return owner } + guard self.hasClaudeCLIStorageWithoutPrompt(environment: environment) else { return owner } // Claude Code rotates refresh tokens; when its storage exists, it owns the refresh lifecycle. return .claudeCLI } - private func hasClaudeCLIStorageWithoutPrompt() -> Bool { - if ClaudeOAuthCredentialsStore.currentFileFingerprint() != nil { + private func hasClaudeCLIStorageWithoutPrompt(environment: [String: String]) -> Bool { + if ClaudeOAuthCredentialsStore.currentFileFingerprint(environment: environment) != nil { return true } guard ClaudeOAuthKeychainPromptPreference.storedMode() != .never else { return false } @@ -595,28 +695,39 @@ public enum ClaudeOAuthCredentialsStore { } @discardableResult - func invalidateCacheIfCredentialsFileChanged() -> Bool { + func invalidateCacheIfCredentialsFileChanged( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { self.context.run { - let current = ClaudeOAuthCredentialsStore.currentFileFingerprint() - let stored = ClaudeOAuthCredentialsStore.loadFileFingerprint() + let current = ClaudeOAuthCredentialsStore.currentFileFingerprint(environment: environment) + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environment) + let stored = ClaudeOAuthCredentialsStore.loadFileFingerprint(profileIdentifier: profileIdentifier) guard current != stored else { return false } ClaudeOAuthCredentialsStore.log.info("Claude OAuth credentials file changed; invalidating cache") ClaudeOAuthCredentialsStore.invalidatePromptAttemptOutcome() - ClaudeOAuthCredentialsStore.writeMemoryCache(record: nil, timestamp: nil) + ClaudeOAuthCredentialsStore.writeMemoryCache( + record: nil, + timestamp: nil, + profileIdentifier: nil) var shouldClearKeychainCache = false var shouldSaveFileFingerprint = true if ClaudeOAuthCredentialsStore.shouldUseCodexBarOAuthKeychainCache { - if ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear { - // The next cache access owns the deferred clear. Avoid repeated delete attempts inside one - // load and leave the fingerprint pending until that clear succeeds. - shouldSaveFileFingerprint = false + if ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear( + profileIdentifier: profileIdentifier) + { + // Pending legacy cleanup is distinct from a pending profile-key clear. A file change must + // still invalidate the profile entry before a later load can return it as authoritative. + shouldClearKeychainCache = true } else if let current { if let modifiedAtMs = current.modifiedAtMs { let modifiedAt = Date( timeIntervalSince1970: TimeInterval(Double(modifiedAtMs) / 1000.0)) - switch ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache() { + switch ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache( + profileIdentifier: profileIdentifier) + { case let .found(entry): if entry.storedAt < modifiedAt { shouldClearKeychainCache = true @@ -634,24 +745,32 @@ public enum ClaudeOAuthCredentialsStore { shouldClearKeychainCache = true } } else { - ClaudeOAuthCredentialsStore.markPendingCodexBarOAuthKeychainCacheClear() + ClaudeOAuthCredentialsStore.markPendingCodexBarOAuthKeychainCacheClear( + profileIdentifier: profileIdentifier) } if shouldClearKeychainCache { - ClaudeOAuthCredentialsStore.clearCacheKeychain() + if !ClaudeOAuthCredentialsStore.clearCacheKeychain(profileIdentifier: profileIdentifier) { + shouldSaveFileFingerprint = false + } } if shouldSaveFileFingerprint { - ClaudeOAuthCredentialsStore.saveFileFingerprint(current) + ClaudeOAuthCredentialsStore.saveFileFingerprint(current, profileIdentifier: profileIdentifier) } return true } } - func invalidateCache() { + func invalidateCache(environment: [String: String]) { self.context.run { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environment) ClaudeOAuthCredentialsStore.invalidatePromptAttemptOutcome() - ClaudeOAuthCredentialsStore.writeMemoryCache(record: nil, timestamp: nil) - ClaudeOAuthCredentialsStore.clearCacheKeychain() + ClaudeOAuthCredentialsStore.writeMemoryCache( + record: nil, + timestamp: nil, + profileIdentifier: nil) + ClaudeOAuthCredentialsStore.clearCacheKeychain(profileIdentifier: profileIdentifier) } } @@ -684,18 +803,24 @@ public enum ClaudeOAuthCredentialsStore { return true } - let memory = ClaudeOAuthCredentialsStore.readMemoryCache() + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: environment) + let memory = ClaudeOAuthCredentialsStore.readMemoryCache(profileIdentifier: profileIdentifier) if ClaudeOAuthCredentialsStore.shouldUseCodexBarOAuthKeychainCache, - !ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear, + !ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear( + profileIdentifier: profileIdentifier), let timestamp = memory.timestamp, let cached = memory.record, + memory.profileIdentifier == profileIdentifier, Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, isRefreshableOrValid(cached) { return true } - switch ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache() { + switch ClaudeOAuthCredentialsStore.loadCodexBarOAuthKeychainCache( + profileIdentifier: profileIdentifier) + { case let .found(entry): guard let creds = try? ClaudeOAuthCredentials.parse(data: entry.data) else { return false } let record = ClaudeOAuthCredentialRecord( @@ -704,7 +829,9 @@ public enum ClaudeOAuthCredentialsStore { source: .cacheKeychain) return isRefreshableOrValid(record) case .temporarilyUnavailable: - if ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear { + if ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClear( + profileIdentifier: profileIdentifier) + { break } return true @@ -712,7 +839,7 @@ public enum ClaudeOAuthCredentialsStore { break } - if let fileData = try? ClaudeOAuthCredentialsStore.loadFromFile(), + if let fileData = try? ClaudeOAuthCredentialsStore.loadFromFile(environment: environment), let creds = try? ClaudeOAuthCredentials.parse(data: fileData), isRefreshableOrValid( ClaudeOAuthCredentialRecord( @@ -797,6 +924,7 @@ public enum ClaudeOAuthCredentialsStore { private struct Recovery { let context: CollaboratorContext + let profileIdentifier: String func shouldAttemptFreshnessSyncFromClaudeKeychain(cached: ClaudeOAuthCredentialRecord) -> Bool { guard !cached.credentials.isExpired else { return false } @@ -878,8 +1006,12 @@ public enum ClaudeOAuthCredentialsStore { credentials: keychainCreds, owner: .claudeCLI, source: .memoryCache), - timestamp: now) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI) + timestamp: now, + profileIdentifier: self.profileIdentifier) + ClaudeOAuthCredentialsStore.saveToCacheKeychain( + data, + owner: .claudeCLI, + profileIdentifier: self.profileIdentifier) return synced } catch let error as ClaudeOAuthCredentialsError { if case let .keychainError(status) = error, @@ -943,9 +1075,13 @@ public enum ClaudeOAuthCredentialsStore { credentials: creds, owner: .claudeCLI, source: .memoryCache), - timestamp: now) + timestamp: now, + profileIdentifier: self.profileIdentifier) if allowCacheKeychainWrite { - ClaudeOAuthCredentialsStore.saveToCacheKeychain(securityData, owner: .claudeCLI) + ClaudeOAuthCredentialsStore.saveToCacheKeychain( + securityData, + owner: .claudeCLI, + profileIdentifier: self.profileIdentifier) } ClaudeOAuthCredentialsStore.log.info( @@ -983,9 +1119,13 @@ public enum ClaudeOAuthCredentialsStore { credentials: creds, owner: .claudeCLI, source: .memoryCache), - timestamp: now) + timestamp: now, + profileIdentifier: self.profileIdentifier) if allowCacheKeychainWrite { - ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI) + ClaudeOAuthCredentialsStore.saveToCacheKeychain( + data, + owner: .claudeCLI, + profileIdentifier: self.profileIdentifier) } ClaudeOAuthCredentialsStore.log.info( @@ -1035,8 +1175,12 @@ public enum ClaudeOAuthCredentialsStore { credentials: creds, owner: .claudeCLI, source: .memoryCache), - timestamp: now) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI) + timestamp: now, + profileIdentifier: self.profileIdentifier) + ClaudeOAuthCredentialsStore.saveToCacheKeychain( + data, + owner: .claudeCLI, + profileIdentifier: self.profileIdentifier) return true } } @@ -1070,8 +1214,12 @@ public enum ClaudeOAuthCredentialsStore { credentials: creds, owner: .claudeCLI, source: .memoryCache), - timestamp: now) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(override, owner: .claudeCLI) + timestamp: now, + profileIdentifier: self.profileIdentifier) + ClaudeOAuthCredentialsStore.saveToCacheKeychain( + override, + owner: .claudeCLI, + profileIdentifier: self.profileIdentifier) return true } #endif @@ -1099,8 +1247,12 @@ public enum ClaudeOAuthCredentialsStore { credentials: creds, owner: .claudeCLI, source: .memoryCache), - timestamp: now) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(data, owner: .claudeCLI) + timestamp: now, + profileIdentifier: self.profileIdentifier) + ClaudeOAuthCredentialsStore.saveToCacheKeychain( + data, + owner: .claudeCLI, + profileIdentifier: self.profileIdentifier) return true } @@ -1122,8 +1274,12 @@ public enum ClaudeOAuthCredentialsStore { credentials: creds, owner: .claudeCLI, source: .memoryCache), - timestamp: now) - ClaudeOAuthCredentialsStore.saveToCacheKeychain(legacyData, owner: .claudeCLI) + timestamp: now, + profileIdentifier: self.profileIdentifier) + ClaudeOAuthCredentialsStore.saveToCacheKeychain( + legacyData, + owner: .claudeCLI, + profileIdentifier: self.profileIdentifier) return true } @@ -1138,6 +1294,8 @@ public enum ClaudeOAuthCredentialsStore { private struct Refresher { let context: CollaboratorContext + let profileIdentifier: String + let environment: [String: String] func refreshAccessToken( refreshToken: String, @@ -1155,14 +1313,16 @@ public enum ClaudeOAuthCredentialsStore { ClaudeOAuthCredentialsStore.saveRefreshedCredentialsToCache( newCredentials, - historyOwnerIdentifier: historyOwnerIdentifier) + historyOwnerIdentifier: historyOwnerIdentifier, + profileIdentifier: self.profileIdentifier) ClaudeOAuthCredentialsStore.writeMemoryCache( record: ClaudeOAuthCredentialRecord( credentials: newCredentials, owner: .codexbar, source: .memoryCache, historyOwnerIdentifier: historyOwnerIdentifier), - timestamp: Date()) + timestamp: Date(), + profileIdentifier: self.profileIdentifier) ClaudeOAuthRefreshFailureGate.recordSuccess() return newCredentials @@ -1225,7 +1385,7 @@ public enum ClaudeOAuthCredentialsStore { switch disposition { case .terminalInvalidGrant: ClaudeOAuthRefreshFailureGate.recordTerminalAuthFailure() - Repository(context: self.context).invalidateCache() + Repository(context: self.context).invalidateCache(environment: self.environment) let message = "HTTP \(response.statusCode) invalid_grant. " + ClaudeOAuthCredentialsStore.reauthenticateHint throw ClaudeOAuthCredentialsError.refreshFailed( @@ -1300,7 +1460,10 @@ public enum ClaudeOAuthCredentialsStore { { let context = self.currentCollaboratorContext() let repository = Repository(context: context) - let refresher = Refresher(context: context) + let refresher = Refresher( + context: context, + profileIdentifier: self.credentialsProfileIdentifier(environment: environment), + environment: environment) let record = try repository.loadRecord( environment: environment, allowKeychainPrompt: allowKeychainPrompt, @@ -1381,7 +1544,8 @@ public enum ClaudeOAuthCredentialsStore { /// Save refreshed credentials to CodexBar's keychain cache private static func saveRefreshedCredentialsToCache( _ credentials: ClaudeOAuthCredentials, - historyOwnerIdentifier: String?) + historyOwnerIdentifier: String?, + profileIdentifier: String) { var oauth: [String: Any] = [ "accessToken": credentials.accessToken, @@ -1409,7 +1573,8 @@ public enum ClaudeOAuthCredentialsStore { self.saveToCacheKeychain( jsonData, owner: .codexbar, - historyOwnerIdentifier: historyOwnerIdentifier) + historyOwnerIdentifier: historyOwnerIdentifier, + profileIdentifier: profileIdentifier) self.log.debug("Saved refreshed credentials to QuotaKit keychain cache") } @@ -1428,8 +1593,10 @@ public enum ClaudeOAuthCredentialsStore { } } - public static func loadFromFile() throws -> Data { - let url = self.credentialsFileURL() + public static func loadFromFile( + environment: [String: String] = ProcessInfo.processInfo.environment) throws -> Data + { + let url = self.credentialsFileURL(environment: environment) do { return try Data(contentsOf: url) } catch { @@ -1440,14 +1607,18 @@ public enum ClaudeOAuthCredentialsStore { } } - public static func credentialsFileFingerprintToken() -> String? { - guard let fingerprint = self.currentFileFingerprint() else { return nil } + public static func credentialsFileFingerprintToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + guard let fingerprint = self.currentFileFingerprint(environment: environment) else { return nil } let modifiedAt = fingerprint.modifiedAtMs.map(String.init) ?? "nil" - return "\(modifiedAt):\(fingerprint.size)" + return "\(fingerprint.path):\(modifiedAt):\(fingerprint.size)" } - public static func authFingerprintToken() -> String { - let file = self.credentialsFileFingerprintToken() ?? "nil" + public static func authFingerprintToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String + { + let file = self.credentialsFileFingerprintToken(environment: environment) ?? "nil" let keychain = self.claudeKeychainFingerprintToken() ?? "nil" return "file=\(file)|keychain=\(keychain)" } @@ -1637,13 +1808,18 @@ public enum ClaudeOAuthCredentialsStore { } @discardableResult - public static func invalidateCacheIfCredentialsFileChanged() -> Bool { - Repository(context: self.currentCollaboratorContext()).invalidateCacheIfCredentialsFileChanged() + public static func invalidateCacheIfCredentialsFileChanged( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + Repository(context: self.currentCollaboratorContext()) + .invalidateCacheIfCredentialsFileChanged(environment: environment) } - /// Invalidate the credentials cache (call after login/logout) - public static func invalidateCache() { - Repository(context: self.currentCollaboratorContext()).invalidateCache() + /// Invalidate the active Claude profile's credentials cache (call after login/logout). + public static func invalidateCache( + environment: [String: String] = ProcessInfo.processInfo.environment) + { + Repository(context: self.currentCollaboratorContext()).invalidateCache(environment: environment) } /// Check if CodexBar has cached credentials (in memory or keychain cache) @@ -1815,7 +1991,9 @@ public enum ClaudeOAuthCredentialsStore { } static func currentCredentialsFileFingerprintWithoutPromptForAuthGate() -> String? { - guard let fingerprint = self.currentFileFingerprint() else { return nil } + guard let fingerprint = self.currentFileFingerprint(environment: ProcessInfo.processInfo.environment) else { + return nil + } let modifiedAt = fingerprint.modifiedAtMs ?? 0 return "\(modifiedAt):\(fingerprint.size)" } @@ -2327,84 +2505,283 @@ public enum ClaudeOAuthCredentialsStore { public static var currentCredentialsURLOverrideForTesting: URL? { self.taskCredentialsURLOverride } + + static func withCredentialsProfileIdentifierOverrideForTesting( + _ profileIdentifier: String?, + operation: () throws -> T) rethrows -> T + { + try self.$taskCredentialsProfileIdentifierOverride.withValue(profileIdentifier) { + try operation() + } + } + + static var resolvedCredentialsURLForTesting: URL { + self.credentialsFileURL(environment: ProcessInfo.processInfo.environment) + } #endif private static func saveToCacheKeychain( _ data: Data, owner: ClaudeOAuthCredentialOwner? = nil, - historyOwnerIdentifier: String? = nil) + historyOwnerIdentifier: String? = nil, + profileIdentifier: String) { guard self.shouldUseCodexBarOAuthKeychainCache else { - self.markPendingCodexBarOAuthKeychainCacheClear() + self.markPendingCodexBarOAuthKeychainCacheClear(profileIdentifier: profileIdentifier) return } let entry = CacheEntry( data: data, storedAt: Date(), owner: owner, - historyOwnerIdentifier: historyOwnerIdentifier) - self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction { pending in - if pending { - switch KeychainCacheStore.clearResult(key: self.cacheKey) { - case .removed, .missing: - pending = false - case .failed: - break + historyOwnerIdentifier: historyOwnerIdentifier, + profileIdentifier: profileIdentifier) + self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction( + profileIdentifier: profileIdentifier, + includingLegacyState: { profilePending, legacyCleanupPending, _ in + if profilePending { + if self.clearProfileCacheKeychain(profileIdentifier: profileIdentifier) { + profilePending = false + } else { + return + } } - } - pending = !KeychainCacheStore.storeResult(key: self.cacheKey, entry: entry) - } + if legacyCleanupPending { + legacyCleanupPending = !self.clearLegacyCacheKeychain() + } + profilePending = !KeychainCacheStore.storeResult( + key: self.cacheKey(profileIdentifier: profileIdentifier), + entry: entry) + }) } - private static func clearCacheKeychain() { + @discardableResult + private static func clearCacheKeychain(profileIdentifier: String) -> Bool { if self.shouldUseCodexBarOAuthKeychainCache { - self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction { pending in - switch KeychainCacheStore.clearResult(key: self.cacheKey) { - case .removed, .missing: - pending = false - case .failed: - pending = true - } - } + var profileCleared = false + self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction( + profileIdentifier: profileIdentifier, + includingLegacyState: { profilePending, legacyCleanupPending, legacyRecheckPending in + switch KeychainCacheStore.clearResult(key: self.cacheKey(profileIdentifier: profileIdentifier)) { + case .removed, .missing: + profilePending = false + profileCleared = true + case .failed: + profilePending = true + profileCleared = false + } + if legacyCleanupPending { + legacyCleanupPending = !self.clearLegacyCacheKeychain() + } + // Invalidation must retire an attributable entry from the released one-key layout before + // a later load can migrate it back. A failed delete leaves a distinct cleanup tombstone. + if !legacyCleanupPending { + switch KeychainCacheStore.load(key: self.legacyCacheKey, as: CacheEntry.self) { + case let .found(entry) + where self.legacyCacheEntry(entry, isAttributableTo: profileIdentifier): + legacyRecheckPending = false + legacyCleanupPending = !self.clearLegacyCacheKeychain() + case .invalid: + legacyRecheckPending = false + legacyCleanupPending = !self.clearLegacyCacheKeychain() + case .found, .missing: + legacyRecheckPending = false + case .temporarilyUnavailable: + legacyRecheckPending = true + } + if legacyCleanupPending { + legacyRecheckPending = false + } + } + }) + return profileCleared } else { - self.markPendingCodexBarOAuthKeychainCacheClear() + self.markPendingCodexBarOAuthKeychainCacheClear(profileIdentifier: profileIdentifier) + return false } } - private static func loadCodexBarOAuthKeychainCache() -> KeychainCacheStore.LoadResult { + private static func loadCodexBarOAuthKeychainCache( + profileIdentifier: String) -> KeychainCacheStore.LoadResult + { guard self.shouldUseCodexBarOAuthKeychainCache else { return .missing } var result: KeychainCacheStore.LoadResult = .temporarilyUnavailable - self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction { pending in - if pending { - switch KeychainCacheStore.clearResult(key: self.cacheKey) { - case .removed, .missing: - pending = false - case .failed: + self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction( + profileIdentifier: profileIdentifier, + includingLegacyState: { profilePending, legacyCleanupPending, legacyRecheckPending in + if profilePending { + if self.clearProfileCacheKeychain(profileIdentifier: profileIdentifier) { + profilePending = false + } else { + return + } + } + if legacyCleanupPending { + legacyCleanupPending = !self.clearLegacyCacheKeychain() + } + + let profileCacheKey = self.cacheKey(profileIdentifier: profileIdentifier) + let loaded = KeychainCacheStore.load(key: profileCacheKey, as: CacheEntry.self) + switch loaded { + case .found, .missing: + break + case .invalid, .temporarilyUnavailable: + result = loaded return } - } - result = KeychainCacheStore.load(key: self.cacheKey, as: CacheEntry.self) - } + if legacyRecheckPending { + switch KeychainCacheStore.load(key: self.legacyCacheKey, as: CacheEntry.self) { + case let .found(entry) + where self.legacyCacheEntry(entry, isAttributableTo: profileIdentifier): + legacyRecheckPending = false + legacyCleanupPending = !self.clearLegacyCacheKeychain() + result = loaded + return + case .invalid: + legacyRecheckPending = false + legacyCleanupPending = !self.clearLegacyCacheKeychain() + result = loaded + return + case .found, .missing: + legacyRecheckPending = false + result = loaded + return + case .temporarilyUnavailable: + result = if case .found = loaded { + loaded + } else { + .temporarilyUnavailable + } + return + } + } + if case .found = loaded { + result = loaded + return + } + // A failed legacy delete is a durable tombstone for this profile. Safe sources may repopulate + // the profile cache, but the stale one-key entry is never eligible for migration while pending. + guard !legacyCleanupPending else { + result = .missing + return + } + + let legacyLoaded = KeychainCacheStore.load(key: self.legacyCacheKey, as: CacheEntry.self) + guard case let .found(entry) = legacyLoaded else { + result = legacyLoaded + return + } + if self.legacyCacheEntry(entry, isAttributableTo: profileIdentifier) { + let migrated = CacheEntry( + data: entry.data, + storedAt: entry.storedAt, + owner: entry.owner, + historyOwnerIdentifier: entry.historyOwnerIdentifier, + profileIdentifier: profileIdentifier) + guard KeychainCacheStore.storeResult(key: profileCacheKey, entry: migrated) else { + self.log.warning("Claude OAuth legacy cache profile migration could not be persisted") + result = .temporarilyUnavailable + return + } + legacyCleanupPending = !self.clearLegacyCacheKeychain() + result = .found(migrated) + return + } + result = .missing + }) return result } + private static func cacheKey(profileIdentifier: String) -> KeychainCacheStore.Key { + KeychainCacheStore.Key( + category: self.legacyCacheKey.category, + identifier: self.profileCacheKeyPrefix + profileIdentifier) + } + + private static func clearProfileCacheKeychain(profileIdentifier: String) -> Bool { + guard self.shouldUseCodexBarOAuthKeychainCache else { return false } + return switch KeychainCacheStore.clearResult(key: self.cacheKey(profileIdentifier: profileIdentifier)) { + case .removed, .missing: + true + case .failed: + false + } + } + + private static func clearLegacyCacheKeychain() -> Bool { + switch KeychainCacheStore.clearResult(key: self.legacyCacheKey) { + case .removed, .missing: + true + case .failed: + false + } + } + + private static func legacyCacheEntry( + _ entry: CacheEntry, + isAttributableTo profileIdentifier: String) -> Bool + { + entry.profileIdentifier == profileIdentifier || + entry.profileIdentifier == nil && + profileIdentifier == self.historicalDefaultCredentialsProfileIdentifier + } + + #if DEBUG + static func cacheKeyForTesting(profileIdentifier: String) -> KeychainCacheStore.Key { + self.cacheKey(profileIdentifier: profileIdentifier) + } + #endif + + private static var historicalDefaultCredentialsFilePath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent(".credentials.json") + .standardizedFileURL.path + } + + static var historicalDefaultCredentialsProfileIdentifier: String { + self.credentialsProfileIdentifier(for: URL(fileURLWithPath: self.historicalDefaultCredentialsFilePath)) + } + private static var shouldUseCodexBarOAuthKeychainCache: Bool { ClaudeOAuthKeychainPromptPreference.storedMode() != .never } - private static func markPendingCodexBarOAuthKeychainCacheClear() { - self.currentPendingCodexBarOAuthKeychainCacheClearStore.markPending() + private static func markPendingCodexBarOAuthKeychainCacheClear(profileIdentifier: String) { + self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction( + profileIdentifier: profileIdentifier, + includingLegacyState: { profilePending, _, legacyRecheckPending in + profilePending = true + // Cache I/O is unavailable here, so legacy ownership cannot be classified. Persist a conditional + // recheck: the historical default can retire its old entry without deleting a custom sibling. + legacyRecheckPending = true + }) + } + + private static func hasPendingCodexBarOAuthKeychainCacheClear(profileIdentifier: String) -> Bool { + self.currentPendingCodexBarOAuthKeychainCacheClearStore.isPending(profileIdentifier: profileIdentifier) } - private static var hasPendingCodexBarOAuthKeychainCacheClear: Bool { + #if DEBUG + static var hasPendingCodexBarOAuthKeychainCacheClearForTesting: Bool { self.currentPendingCodexBarOAuthKeychainCacheClearStore.isPending } + #endif private static var currentPendingCodexBarOAuthKeychainCacheClearStore: ClaudeOAuthPendingCacheClearStore { #if DEBUG if let store = self.taskPendingCacheClearStoreOverride { return store } + // Under tests without a TaskLocal store, use an ephemeral sink so concurrent tests never + // share pending state or write the process-shared app tombstone suite. Coherent pending + // semantics require withPendingCacheClearStoreOverrideForTesting / isolated memory cache. + if KeychainTestSafety.shouldIsolateUserStateUnderTests() { + return PendingCacheClearMemoryStore() + } + if let store = self.taskImplicitPendingCacheClearStoreOverride { + return store + } #endif return self.pendingCodexBarOAuthKeychainCacheClearStore } @@ -2518,58 +2895,125 @@ public enum ClaudeOAuthCredentialsStore { #endif } - private static func credentialsFileURL() -> URL { + private static func credentialsFileURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { #if DEBUG if let override = self.taskCredentialsURLOverride { return override } + if KeychainTestSafety.shouldIsolateUserStateUnderTests(), + !self.taskUseEnvironmentCredentialsURLForTesting + { + return self.isolatedTestCredentialsURL + } + #endif + return ClaudeConfigPaths.credentialsURL(environment: environment) + } + + static func credentialsProfileIdentifier(environment: [String: String]) -> String { + #if DEBUG + if let override = self.taskCredentialsProfileIdentifierOverride { + return override + } #endif - return self.defaultCredentialsURL() + return self.credentialsProfileIdentifier(for: self.credentialsFileURL(environment: environment)) + } + + private static func credentialsProfileIdentifier(for credentialsURL: URL) -> String { + let path = credentialsURL.standardizedFileURL.path + let material = Data("codexbar:claude-oauth-cache-profile:v1\0\(path)".utf8) + return self.sha256Hex(material) + } + + private static func fileFingerprintKey(profileIdentifier: String) -> String { + self.legacyFileFingerprintKey + self.fileFingerprintProfileSeparator + profileIdentifier } - private static func loadFileFingerprint() -> CredentialsFileFingerprint? { + private static func loadFileFingerprint(profileIdentifier: String) -> CredentialsFileFingerprint? { #if DEBUG if let store = self.taskCredentialsFileFingerprintStoreOverride { - return store.load() + return store.load( + profileIdentifier: profileIdentifier, + historicalProfileIdentifier: self.historicalDefaultCredentialsProfileIdentifier) } #endif - guard let data = UserDefaults.standard.data(forKey: self.fileFingerprintKey) else { + let defaults = UserDefaults.standard + let scopedKey = self.fileFingerprintKey(profileIdentifier: profileIdentifier) + if let data = defaults.data(forKey: scopedKey) { + if profileIdentifier == self.historicalDefaultCredentialsProfileIdentifier { + defaults.removeObject(forKey: self.legacyFileFingerprintKey) + } + return try? JSONDecoder().decode(CredentialsFileFingerprint.self, from: data) + } + guard profileIdentifier == self.historicalDefaultCredentialsProfileIdentifier, + let data = defaults.data(forKey: self.legacyFileFingerprintKey) + else { + return nil + } + guard let fingerprint = try? JSONDecoder().decode(CredentialsFileFingerprint.self, from: data) else { return nil } - return try? JSONDecoder().decode(CredentialsFileFingerprint.self, from: data) + let migratedData = (try? JSONEncoder().encode(fingerprint)) ?? data + defaults.set(migratedData, forKey: scopedKey) + defaults.removeObject(forKey: self.legacyFileFingerprintKey) + return fingerprint } - private static func saveFileFingerprint(_ fingerprint: CredentialsFileFingerprint?) { + private static func saveFileFingerprint( + _ fingerprint: CredentialsFileFingerprint?, + profileIdentifier: String) + { #if DEBUG if let store = self.taskCredentialsFileFingerprintStoreOverride { - store.save(fingerprint); return + store.save( + fingerprint, + profileIdentifier: profileIdentifier, + historicalProfileIdentifier: self.historicalDefaultCredentialsProfileIdentifier) + return } #endif + let defaults = UserDefaults.standard + let scopedKey = self.fileFingerprintKey(profileIdentifier: profileIdentifier) + if profileIdentifier == self.historicalDefaultCredentialsProfileIdentifier { + defaults.removeObject(forKey: self.legacyFileFingerprintKey) + } guard let fingerprint else { - UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey) + defaults.removeObject(forKey: scopedKey) return } if let data = try? JSONEncoder().encode(fingerprint) { - UserDefaults.standard.set(data, forKey: self.fileFingerprintKey) + defaults.set(data, forKey: scopedKey) } } - private static func currentFileFingerprint() -> CredentialsFileFingerprint? { - let url = self.credentialsFileURL() + private static func currentFileFingerprint( + environment: [String: String] = ProcessInfo.processInfo.environment) -> CredentialsFileFingerprint? + { + let url = self.credentialsFileURL(environment: environment) guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil } let size = (attrs[.size] as? NSNumber)?.intValue ?? 0 let modifiedAtMs = (attrs[.modificationDate] as? Date).map { Int($0.timeIntervalSince1970 * 1000) } - return CredentialsFileFingerprint(modifiedAtMs: modifiedAtMs, size: size) + return CredentialsFileFingerprint( + path: url.standardizedFileURL.path, + modifiedAtMs: modifiedAtMs, + size: size) } #if DEBUG static func _resetCredentialsFileTrackingForTesting() { if let store = self.taskCredentialsFileFingerprintStoreOverride { - store.save(nil) + store.reset() } else { - UserDefaults.standard.removeObject(forKey: self.fileFingerprintKey) + let defaults = UserDefaults.standard + defaults.removeObject(forKey: self.legacyFileFingerprintKey) + for key in defaults.dictionaryRepresentation().keys + where key.hasPrefix(self.legacyFileFingerprintKey + self.fileFingerprintProfileSeparator) + { + defaults.removeObject(forKey: key) + } } if self.taskPendingCacheClearStoreOverride != nil { self.currentPendingCodexBarOAuthKeychainCacheClearStore.withCacheTransaction { pending in @@ -2592,11 +3036,6 @@ public enum ClaudeOAuthCredentialsStore { self.claudeKeychainChangeCheckLock.unlock() } #endif - - private static func defaultCredentialsURL() -> URL { - let home = FileManager.default.homeDirectoryForCurrentUser - return home.appendingPathComponent(self.credentialsPath) - } } // swiftlint:enable type_body_length @@ -2605,8 +3044,14 @@ extension ClaudeOAuthCredentialsStore { /// After delegated Claude CLI refresh, re-load the Claude keychain entry without prompting and sync it into /// CodexBar's caches. This is used to avoid triggering a second OS keychain dialog during the OAuth retry. @discardableResult - static func syncFromClaudeKeychainWithoutPrompt(now: Date = Date()) -> Bool { - Recovery(context: self.currentCollaboratorContext()).syncFromClaudeKeychainWithoutPrompt(now: now) + static func syncFromClaudeKeychainWithoutPrompt( + now: Date = Date(), + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + let profileIdentifier = self.credentialsProfileIdentifier(environment: environment) + return Recovery( + context: self.currentCollaboratorContext(), + profileIdentifier: profileIdentifier).syncFromClaudeKeychainWithoutPrompt(now: now) } private static func shouldShowClaudeKeychainPreAlert() -> Bool { @@ -2650,7 +3095,11 @@ extension ClaudeOAuthCredentialsStore { existingSubscriptionType: String? = nil) async throws -> ClaudeOAuthCredentials { let historyOwnerIdentifier = ClaudeOAuthCredentials.historyOwnerIdentifier(forRefreshToken: refreshToken) - return try await Refresher(context: self.currentCollaboratorContext()).refreshAccessToken( + let environment = ProcessInfo.processInfo.environment + return try await Refresher( + context: self.currentCollaboratorContext(), + profileIdentifier: self.credentialsProfileIdentifier(environment: environment), + environment: environment).refreshAccessToken( refreshToken: refreshToken, existingScopes: existingScopes, existingRateLimitTier: existingRateLimitTier, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift index 6919edd0a..a753dc5cd 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthDelegatedRefreshCoordinator.swift @@ -141,12 +141,14 @@ public enum ClaudeOAuthDelegatedRefreshCoordinator { #endif let task = Task.detached(priority: .utility) { #if DEBUG - return await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(securityCLIReadOverride) { - await self.performAttempt( - now: now, - timeout: timeout, - configuration: configuration, - state: state) + return await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(configuration.promptMode) { + await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(securityCLIReadOverride) { + await self.performAttempt( + now: now, + timeout: timeout, + configuration: configuration, + state: state) + } } #else await self.performAttempt( diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift index f909c5408..5828c3bce 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthKeychainPromptMode.swift @@ -30,6 +30,7 @@ public enum ClaudeOAuthKeychainPromptPreference { @TaskLocal private static var taskOverride: ClaudeOAuthKeychainPromptMode? @TaskLocal private static var taskApplicationUserDefaultsOverride: UserDefaultsBox? + @TaskLocal private static var taskImplicitApplicationUserDefaultsOverride: UserDefaultsBox? #endif public static func current(userDefaults: UserDefaults? = nil) -> ClaudeOAuthKeychainPromptMode { @@ -41,6 +42,14 @@ public enum ClaudeOAuthKeychainPromptPreference { if let taskOverride { return taskOverride } + // Unit tests must not inherit the developer's persisted app preference. Tests that exercise a specific + // policy use a task or UserDefaults override explicitly. + if userDefaults == nil, + self.taskApplicationUserDefaultsOverride == nil, + KeychainTestSafety.shouldIsolateUserStateUnderTests() + { + return .onlyOnUserAction + } #endif let userDefaults = userDefaults ?? self.applicationUserDefaults if let raw = userDefaults.string(forKey: self.userDefaultsKey), @@ -84,6 +93,9 @@ public enum ClaudeOAuthKeychainPromptPreference { if let taskApplicationUserDefaultsOverride { return taskApplicationUserDefaultsOverride.value } + if let taskImplicitApplicationUserDefaultsOverride { + return taskImplicitApplicationUserDefaultsOverride.value + } #endif return UserDefaults(suiteName: self.applicationDefaultsDomain) ?? .standard } @@ -173,5 +185,14 @@ public enum ClaudeOAuthKeychainPromptPreference { try await operation() } } + + static func withImplicitApplicationUserDefaultsOverrideForTesting( + _ userDefaults: UserDefaults?, + operation: () throws -> T) rethrows -> T + { + try self.$taskImplicitApplicationUserDefaultsOverride.withValue(userDefaults.map(UserDefaultsBox.init)) { + try operation() + } + } #endif } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthPendingCacheClearStore.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthPendingCacheClearStore.swift index d9c84f89a..3abc76aa2 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthPendingCacheClearStore.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthPendingCacheClearStore.swift @@ -12,11 +12,25 @@ protocol ClaudeOAuthPendingCacheClearStore: Sendable { func markPending() func withCacheTransaction(_ operation: (inout Bool) -> Void) + + func isPending(profileIdentifier: String) -> Bool + func markPending(profileIdentifier: String) + func withCacheTransaction(profileIdentifier: String, _ operation: (inout Bool) -> Void) + func withCacheTransaction( + profileIdentifier: String, + includingLegacyCleanup operation: (inout Bool, inout Bool) -> Void) + func withCacheTransaction( + profileIdentifier: String, + includingLegacyState operation: (inout Bool, inout Bool, inout Bool) -> Void) } final class ClaudeOAuthPendingCacheClearUserDefaultsStore: ClaudeOAuthPendingCacheClearStore, @unchecked Sendable { private static let processLock = NSLock() private static let log = CodexBarLog.logger(LogCategories.claudeUsage) + private static let legacyProfileIdentifier = "__legacy__" + private static let legacyCleanupProfilePrefix = "__legacy_cleanup__." + private static let legacyRecheckProfilePrefix = "__legacy_recheck__." + private static let unlockedProfileFallbackKeyPrefix = ".unlocked-profile." private let domain: String private let key: String @@ -37,7 +51,10 @@ final class ClaudeOAuthPendingCacheClearUserDefaultsStore: ClaudeOAuthPendingCac var isPending: Bool { do { return try self.withInterprocessLock { - self.currentGeneration() != nil + if case .none = self.currentState() { + return self.hasAnyUnlockedProfileFallback + } + return true } } catch { Self.log.error("Claude OAuth cache tombstone lock failed: \(error.localizedDescription)") @@ -75,6 +92,170 @@ final class ClaudeOAuthPendingCacheClearUserDefaultsStore: ClaudeOAuthPendingCac } } + func isPending(profileIdentifier: String) -> Bool { + do { + return try self.withInterprocessLock { + if self.unlockedProfileFallbackGeneration(profileIdentifier: profileIdentifier) != nil { + return true + } + return switch self.currentState() { + case let .profiles(generations): + generations[profileIdentifier] != nil || + generations[Self.legacyProfileIdentifier] != nil || + generations[Self.legacyCleanupIdentifier(profileIdentifier: profileIdentifier)] != nil || + generations[Self.legacyRecheckIdentifier(profileIdentifier: profileIdentifier)] != nil + case .legacy: + // A V1 tombstone did not record its source profile. Treat it as pending until + // the first profile-owned retry resolves it; no current code writes V1 state. + true + case .none: + false + } + } + } catch { + Self.log.error("Claude OAuth cache tombstone lock failed: \(error.localizedDescription)") + return true + } + } + + func markPending(profileIdentifier: String) { + do { + try self.withInterprocessLock { + var generations = self.currentProfileGenerations() + generations[profileIdentifier] = UUID().uuidString + self.writeProfileGenerations(generations) + } + } catch { + // Preserve the failed invalidation's owner. A shared V1 fallback could be claimed and + // removed by another profile before this profile's stale cache was cleared. + Self.log.error("Claude OAuth cache tombstone lock failed: \(error.localizedDescription)") + self.writeUnlockedProfileFallbackGeneration( + UUID().uuidString, + profileIdentifier: profileIdentifier) + } + } + + func withCacheTransaction(profileIdentifier: String, _ operation: (inout Bool) -> Void) { + self.withCacheTransaction( + profileIdentifier: profileIdentifier, + includingLegacyCleanup: { profilePending, legacyCleanupPending in + var pending = profilePending || legacyCleanupPending + operation(&pending) + if pending { + if !profilePending, !legacyCleanupPending { + profilePending = true + } + } else { + profilePending = false + legacyCleanupPending = false + } + }) + } + + func withCacheTransaction( + profileIdentifier: String, + includingLegacyCleanup operation: (inout Bool, inout Bool) -> Void) + { + self.withCacheTransaction( + profileIdentifier: profileIdentifier, + includingLegacyState: { profilePending, legacyCleanupPending, legacyRecheckPending in + operation(&profilePending, &legacyCleanupPending) + if legacyCleanupPending { + legacyRecheckPending = false + } + }) + } + + func withCacheTransaction( + profileIdentifier: String, + includingLegacyState operation: (inout Bool, inout Bool, inout Bool) -> Void) + { + do { + try self.withInterprocessLock { + let initialFallbackGeneration = self.unlockedProfileFallbackGeneration( + profileIdentifier: profileIdentifier) + switch self.currentState() { + case let .profiles(initialGenerations): + var generations = initialGenerations + let hadUnscopedPending = generations[Self.legacyProfileIdentifier] != nil + let legacyCleanupIdentifier = Self.legacyCleanupIdentifier( + profileIdentifier: profileIdentifier) + let legacyRecheckIdentifier = Self.legacyRecheckIdentifier( + profileIdentifier: profileIdentifier) + var profilePending = hadUnscopedPending || + generations[profileIdentifier] != nil || + initialFallbackGeneration != nil + var legacyCleanupPending = hadUnscopedPending || + generations[legacyCleanupIdentifier] != nil + var legacyRecheckPending = generations[legacyRecheckIdentifier] != nil + operation(&profilePending, &legacyCleanupPending, &legacyRecheckPending) + generations[Self.legacyProfileIdentifier] = nil + if profilePending { + generations[profileIdentifier] = generations[profileIdentifier] ?? UUID().uuidString + } else { + generations[profileIdentifier] = nil + } + if legacyCleanupPending { + generations[legacyCleanupIdentifier] = + generations[legacyCleanupIdentifier] ?? UUID().uuidString + } else { + generations[legacyCleanupIdentifier] = nil + } + if legacyRecheckPending { + generations[legacyRecheckIdentifier] = + generations[legacyRecheckIdentifier] ?? UUID().uuidString + } else { + generations[legacyRecheckIdentifier] = nil + } + let persisted = self.persist( + generations: generations, + initialGenerations: initialGenerations) + if persisted { + self.consumeUnlockedProfileFallbackGeneration( + initialFallbackGeneration, + profileIdentifier: profileIdentifier) + } + case let .legacy(initialGeneration): + // V1 did not distinguish profile invalidation from legacy-key cleanup. The first + // profile-owned transaction claims both responsibilities and persists any retry + // independently in the profile-aware layout. + var profilePending = true + var legacyCleanupPending = true + var legacyRecheckPending = false + operation(&profilePending, &legacyCleanupPending, &legacyRecheckPending) + guard self.currentGeneration() == initialGeneration else { return } + self.writeProfileGenerations(self.pendingGenerations( + profileIdentifier: profileIdentifier, + profilePending: profilePending, + legacyCleanupPending: legacyCleanupPending, + legacyRecheckPending: legacyRecheckPending)) + self.consumeUnlockedProfileFallbackGeneration( + initialFallbackGeneration, + profileIdentifier: profileIdentifier) + case .none: + var profilePending = initialFallbackGeneration != nil + var legacyCleanupPending = false + var legacyRecheckPending = false + operation(&profilePending, &legacyCleanupPending, &legacyRecheckPending) + self.writeProfileGenerations(self.pendingGenerations( + profileIdentifier: profileIdentifier, + profilePending: profilePending, + legacyCleanupPending: legacyCleanupPending, + legacyRecheckPending: legacyRecheckPending)) + self.consumeUnlockedProfileFallbackGeneration( + initialFallbackGeneration, + profileIdentifier: profileIdentifier) + } + } + } catch { + Self.log.error("Claude OAuth cache transaction lock failed: \(error.localizedDescription)") + // Fail closed: without the shared lock, do not touch the cache and leave a fresh invalidation marker. + self.writeUnlockedProfileFallbackGeneration( + UUID().uuidString, + profileIdentifier: profileIdentifier) + } + } + private func persist( pending: Bool, initialGeneration: String?) @@ -106,6 +287,114 @@ final class ClaudeOAuthPendingCacheClearUserDefaultsStore: ClaudeOAuthPendingCac return nil } + private enum State { + case none + case legacy(String) + case profiles([String: String]) + } + + private func currentState() -> State { + let userDefaults = UserDefaults(suiteName: self.domain) ?? .standard + userDefaults.synchronize() + if let generations = userDefaults.dictionary(forKey: self.key) as? [String: String], !generations.isEmpty { + return .profiles(generations) + } + if let generation = self.currentGeneration() { + return .legacy(generation) + } + return .none + } + + private func currentProfileGenerations() -> [String: String] { + switch self.currentState() { + case let .profiles(generations): + generations + case let .legacy(generation): + [Self.legacyProfileIdentifier: generation] + case .none: + [:] + } + } + + private static func legacyCleanupIdentifier(profileIdentifier: String) -> String { + self.legacyCleanupProfilePrefix + profileIdentifier + } + + private static func legacyRecheckIdentifier(profileIdentifier: String) -> String { + self.legacyRecheckProfilePrefix + profileIdentifier + } + + private func pendingGenerations( + profileIdentifier: String, + profilePending: Bool, + legacyCleanupPending: Bool, + legacyRecheckPending: Bool) -> [String: String] + { + var generations: [String: String] = [:] + if profilePending { + generations[profileIdentifier] = UUID().uuidString + } + if legacyCleanupPending { + generations[Self.legacyCleanupIdentifier(profileIdentifier: profileIdentifier)] = UUID().uuidString + } + if legacyRecheckPending { + generations[Self.legacyRecheckIdentifier(profileIdentifier: profileIdentifier)] = UUID().uuidString + } + return generations + } + + private func persist(generations: [String: String], initialGenerations: [String: String]) -> Bool { + let currentGenerations = self.currentProfileGenerations() + guard currentGenerations == initialGenerations else { return false } + self.writeProfileGenerations(generations) + return true + } + + private var hasAnyUnlockedProfileFallback: Bool { + let userDefaults = UserDefaults(suiteName: self.domain) ?? .standard + userDefaults.synchronize() + let prefix = self.key + Self.unlockedProfileFallbackKeyPrefix + return userDefaults.persistentDomain(forName: self.domain)?.keys.contains { + $0.hasPrefix(prefix) + } ?? false + } + + private func unlockedProfileFallbackKey(profileIdentifier: String) -> String { + self.key + Self.unlockedProfileFallbackKeyPrefix + profileIdentifier + } + + private func unlockedProfileFallbackGeneration(profileIdentifier: String) -> String? { + let userDefaults = UserDefaults(suiteName: self.domain) ?? .standard + userDefaults.synchronize() + let fallbackKey = self.unlockedProfileFallbackKey(profileIdentifier: profileIdentifier) + guard let generation = userDefaults.string(forKey: fallbackKey), !generation.isEmpty else { return nil } + return generation + } + + private func consumeUnlockedProfileFallbackGeneration( + _ initialGeneration: String?, + profileIdentifier: String) + { + guard let initialGeneration, + self.unlockedProfileFallbackGeneration(profileIdentifier: profileIdentifier) == initialGeneration + else { return } + self.writeUnlockedProfileFallbackGeneration(nil, profileIdentifier: profileIdentifier) + } + + private func writeUnlockedProfileFallbackGeneration( + _ generation: String?, + profileIdentifier: String) + { + let userDefaults = UserDefaults(suiteName: self.domain) ?? .standard + let fallbackKey = self.unlockedProfileFallbackKey(profileIdentifier: profileIdentifier) + if let generation { + userDefaults.set(generation, forKey: fallbackKey) + } else { + userDefaults.removeObject(forKey: fallbackKey) + } + userDefaults.synchronize() + } + private func writeGeneration(_ generation: String?) { let userDefaults = UserDefaults(suiteName: self.domain) ?? .standard if let generation { @@ -116,6 +405,16 @@ final class ClaudeOAuthPendingCacheClearUserDefaultsStore: ClaudeOAuthPendingCac userDefaults.synchronize() } + private func writeProfileGenerations(_ generations: [String: String]) { + let userDefaults = UserDefaults(suiteName: self.domain) ?? .standard + if generations.isEmpty { + userDefaults.removeObject(forKey: self.key) + } else { + userDefaults.set(generations, forKey: self.key) + } + userDefaults.synchronize() + } + private func withInterprocessLock(_ operation: () throws -> T) throws -> T { Self.processLock.lock() defer { Self.processLock.unlock() } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 52c1f8695..bf713f0e0 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -44,6 +44,7 @@ public enum ClaudeOAuthFetchError: LocalizedError, Sendable { enum ClaudeOAuthUsageFetcher { private static let baseURL = "https://api.anthropic.com" private static let usagePath = "/api/oauth/usage" + private static let profilePath = "/api/oauth/profile" private static let betaHeader = "oauth-2025-04-20" private static let fallbackClaudeCodeVersion = "2.1.0" @@ -104,6 +105,37 @@ enum ClaudeOAuthUsageFetcher { } } + static func fetchProfile( + accessToken: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> OAuthProfileResponse + { + guard let url = URL(string: self.baseURL + self.profilePath) else { + throw ClaudeOAuthFetchError.invalidResponse + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + do { + let response = try await transport.response(for: request) + guard response.statusCode == 200 else { + let body = String(data: response.data, encoding: .utf8) + throw ClaudeOAuthFetchError.serverError(response.statusCode, body) + } + return try JSONDecoder().decode(OAuthProfileResponse.self, from: response.data) + } catch let error as ClaudeOAuthFetchError { + throw error + } catch is DecodingError { + throw ClaudeOAuthFetchError.invalidResponse + } catch { + throw ClaudeOAuthFetchError.networkError(error) + } + } + static func decodeUsageResponse(_ data: Data) throws -> OAuthUsageResponse { let decoder = JSONDecoder() return try decoder.decode(OAuthUsageResponse.self, from: data) @@ -159,6 +191,59 @@ enum ClaudeOAuthUsageFetcher { } } +struct OAuthProfileResponse: Decodable, Sendable { + let emailAddress: String? + let organizationUuid: String? + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + let account = try Self.decodeNestedContainer(in: container, key: "account") + let organization = try Self.decodeNestedContainer(in: container, key: "organization") + self.emailAddress = + account.flatMap { Self.decodeString(in: $0, keys: ["emailAddress", "email_address", "email"]) } + ?? Self.decodeString(in: container, keys: ["emailAddress", "email_address", "email"]) + self.organizationUuid = + organization.flatMap { Self.decodeString(in: $0, keys: ["uuid"]) } + ?? Self.decodeString(in: container, keys: ["organizationUuid", "organization_uuid"]) + } + + init(emailAddress: String?, organizationUuid: String?) { + self.emailAddress = emailAddress + self.organizationUuid = organizationUuid + } + + private static func decodeString( + in container: KeyedDecodingContainer, + keys: [String]) -> String? + { + for keyName in keys { + guard let key = DynamicCodingKey(stringValue: keyName), + let value = try? container.decodeIfPresent(String.self, forKey: key) + else { + continue + } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalized.isEmpty { + return normalized + } + } + return nil + } + + private static func decodeNestedContainer( + in container: KeyedDecodingContainer, + key keyName: String) throws -> KeyedDecodingContainer? + { + guard let key = DynamicCodingKey(stringValue: keyName), + container.contains(key), + !((try? container.decodeNil(forKey: key)) ?? true) + else { + return nil + } + return try container.nestedContainer(keyedBy: DynamicCodingKey.self, forKey: key) + } +} + struct OAuthUsageResponse: Decodable { let fiveHour: OAuthUsageWindow? let sevenDay: OAuthUsageWindow? diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift index 1ae78675f..7943471cb 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift @@ -57,7 +57,7 @@ public enum ClaudeProviderDescriptor { let planningInput = Self.makePlanningInput(context: context) let plan = ClaudeSourcePlanner.resolve(input: planningInput) - let manualCookieHeader = Self.manualCookieHeader(from: context) + let webEnrichmentAccess = Self.webEnrichmentAccess(context: context) return plan.orderedSteps.map { step in let strategy: any ProviderFetchStrategy = switch step.dataSource { @@ -70,8 +70,12 @@ public enum ClaudeProviderDescriptor { case .cli: ClaudeCLIFetchStrategy( useWebExtras: context.runtime == .app - && planningInput.webExtrasEnabled, - manualCookieHeader: manualCookieHeader, + && planningInput.webExtrasEnabled + && webEnrichmentAccess.isAvailable, + includePrepaidBalance: context.runtime == .app + && context.includeOptionalUsage + && webEnrichmentAccess.isAvailable, + manualCookieHeader: webEnrichmentAccess.manualCookieHeader, browserDetection: context.browserDetection, hasWebFallback: planningInput.hasWebSession) case .auto: @@ -133,6 +137,28 @@ public enum ClaudeProviderDescriptor { return CookieHeaderNormalizer.normalize(context.settings?.claude?.manualCookieHeader) } + fileprivate struct WebEnrichmentAccess { + let isAvailable: Bool + let manualCookieHeader: String? + } + + fileprivate static func webEnrichmentAccess(context: ProviderFetchContext) -> WebEnrichmentAccess { + switch context.settings?.claude?.cookieSource { + case .off?: + return WebEnrichmentAccess(isAvailable: false, manualCookieHeader: nil) + case .manual?: + let header = self.manualCookieHeader(from: context) + return WebEnrichmentAccess( + isAvailable: ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header), + manualCookieHeader: header) + case .auto?, nil: + let header = CookieHeaderCache.load(provider: .claude)?.cookieHeader + return WebEnrichmentAccess( + isAvailable: ClaudeWebAPIFetcher.hasSessionKey(cookieHeader: header), + manualCookieHeader: header) + } + } + private static func noDataMessage() -> String { "No Claude usage logs found in ~/.config/claude/projects, ~/.claude/projects, " + "or Claude Desktop sessions." @@ -376,6 +402,13 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let webEnrichmentAccess = ClaudeProviderDescriptor.webEnrichmentAccess(context: context) + let useWebExtras = context.runtime == .app && + (context.settings?.claude?.webExtrasEnabled ?? false) && + webEnrichmentAccess.isAvailable + let includePrepaidBalance = context.runtime == .app && + context.includeOptionalUsage && + webEnrichmentAccess.isAvailable let fetcher = ClaudeUsageFetcher( browserDetection: context.browserDetection, environment: context.env, @@ -383,7 +416,11 @@ struct ClaudeOAuthFetchStrategy: ProviderFetchStrategy { dataSource: .oauth, oauthKeychainPromptCooldownEnabled: context.sourceMode == .auto, allowBackgroundDelegatedRefresh: false, - useWebExtras: false) + useWebExtras: useWebExtras, + manualCookieHeader: webEnrichmentAccess.manualCookieHeader, + webOrganizationID: context.settings?.claude?.organizationID, + webExtrasTimeout: context.webTimeout, + includePrepaidBalance: includePrepaidBalance) let usage = try await fetcher.loadLatestUsage(model: "sonnet") return ProviderFetchResult( usage: Self.snapshot(from: usage), @@ -584,7 +621,8 @@ struct ClaudeWebFetchStrategy: ProviderFetchStrategy { dataSource: .web, useWebExtras: false, manualCookieHeader: Self.manualCookieHeader(from: context), - webOrganizationID: context.settings?.claude?.organizationID) + webOrganizationID: context.settings?.claude?.organizationID, + includePrepaidBalance: context.includeOptionalUsage) return try await fetcher.loadLatestUsage(model: "sonnet") } let race = BoundedTaskJoin(sourceTask: sourceTask) @@ -637,29 +675,34 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { let id: String = "claude.cli" let kind: ProviderFetchKind = .cli let useWebExtras: Bool + let includePrepaidBalance: Bool let manualCookieHeader: String? let browserDetection: BrowserDetection let hasWebFallback: Bool func isAvailable(_ context: ProviderFetchContext) async -> Bool { - // Claude's "auth status" command is a child process that may invoke /usr/bin/security itself. A no-prompt - // policy in CodexBar cannot constrain that child process, so background Auto refresh must not launch it - // unless the user explicitly opted into Keychain access for background work. let isBackgroundAutoRefresh = context.runtime == .app && context.sourceMode == .auto && ProviderInteractionContext.current == .background if isBackgroundAutoRefresh { - guard !KeychainAccessGate.isDisabled, - ClaudeOAuthKeychainPromptPreference.storedMode() == .always + // Every Claude child process is opaque to CodexBar's no-UI Keychain controls, including + // `claude auth status`. Background Auto therefore reuses only availability established by a + // successful user-initiated CLI fetch in this process; it never probes the CLI itself. + guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env), + ClaudeCLIBackgroundAvailability.isEstablished(binary: binary) else { return false } + // Disable Keychain is a complete opt-out, so no prompt policy applies. With Keychain enabled, + // retain the explicit background opt-in introduced with the opaque-child safety gate. + return KeychainAccessGate.isExplicitlyDisabled + || ClaudeOAuthKeychainPromptPreference.storedMode() == .always } - // The interactive Claude REPL can open browser OAuth when it starts logged out. CLI runtime and the - // explicitly opted-in background Auto path establish authentication through the status command first. - let requiresAuthPreflight = context.runtime == .cli || isBackgroundAutoRefresh - guard requiresAuthPreflight else { return true } + // The interactive Claude REPL can open browser OAuth when it starts logged out. CLI-runtime paths + // establish authentication through the noninteractive status command first. App user + // actions intentionally launch the interactive path directly so the user can complete authentication. + guard context.runtime == .cli else { return true } guard let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) else { return false } return await ClaudeCLIAuthStatusProbe.isLoggedIn(binary: binary, environment: context.env) } @@ -673,8 +716,25 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { useWebExtras: self.useWebExtras, manualCookieHeader: self.manualCookieHeader, webOrganizationID: context.settings?.claude?.organizationID, + webExtrasTimeout: context.webTimeout, + includePrepaidBalance: self.includePrepaidBalance && context.includeOptionalUsage, keepCLISessionsAlive: keepAlive) - let usage = try await fetcher.loadLatestUsage(model: "sonnet") + let binary = ClaudeCLIResolver.resolvedBinaryPath(environment: context.env) + let usage: ClaudeUsageSnapshot + do { + usage = try await fetcher.loadLatestUsage(model: "sonnet") + } catch { + if let binary { + ClaudeCLIBackgroundAvailability.revoke(binary: binary) + } + throw error + } + if context.runtime == .app, + ProviderInteractionContext.current == .userInitiated, + let binary + { + ClaudeCLIBackgroundAvailability.establish(binary: binary) + } return self.makeResult( usage: ClaudeOAuthFetchStrategy.snapshot(from: usage), sourceLabel: "claude") @@ -689,3 +749,51 @@ struct ClaudeCLIFetchStrategy: ProviderFetchStrategy { return self.hasWebFallback } } + +enum ClaudeCLIBackgroundAvailability { + final class Store: @unchecked Sendable { + private let lock = NSLock() + private var establishedBinaries: Set = [] + + func contains(_ binary: String) -> Bool { + self.lock.withLock { self.establishedBinaries.contains(binary) } + } + + func insert(_ binary: String) { + self.lock.withLock { _ = self.establishedBinaries.insert(binary) } + } + + func remove(_ binary: String) { + self.lock.withLock { _ = self.establishedBinaries.remove(binary) } + } + } + + private static let sharedStore = Store() + @TaskLocal private static var storeOverrideForTesting: Store? + + private static var store: Store { + self.storeOverrideForTesting ?? self.sharedStore + } + + static func isEstablished(binary: String) -> Bool { + self.store.contains(binary) + } + + static func establish(binary: String) { + self.store.insert(binary) + } + + static func revoke(binary: String) { + self.store.remove(binary) + } + + #if DEBUG + static func withIsolatedStoreForTesting( + operation: () async throws -> T) async rethrows -> T + { + try await self.$storeOverrideForTesting.withValue(Store()) { + try await operation() + } + } + #endif +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index d3557f205..fecaa0839 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -113,6 +113,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let useWebExtras: Bool let manualCookieHeader: String? let webOrganizationID: String? + let webExtrasTimeout: TimeInterval + let includePrepaidBalance: Bool let keepCLISessionsAlive: Bool let browserDetection: BrowserDetection } @@ -163,6 +165,14 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { self.configuration.webOrganizationID } + private var webExtrasTimeout: TimeInterval { + self.configuration.webExtrasTimeout + } + + private var includePrepaidBalance: Bool { + self.configuration.includePrepaidBalance + } + private var keepCLISessionsAlive: Bool { self.configuration.keepCLISessionsAlive } @@ -248,6 +258,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { @TaskLocal static var fetchOAuthUsageOverride: (@Sendable ( String, Bool) async throws -> OAuthUsageResponse)? + @TaskLocal static var fetchOAuthProfileOverride: (@Sendable (String) async throws -> OAuthProfileResponse)? @TaskLocal static var delegatedRefreshAttemptOverride: (@Sendable ( Date, TimeInterval, @@ -270,6 +281,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { useWebExtras: Bool = false, manualCookieHeader: String? = nil, webOrganizationID: String? = nil, + webExtrasTimeout: TimeInterval = 15, + includePrepaidBalance: Bool = false, keepCLISessionsAlive: Bool = false) { self.configuration = Configuration( @@ -281,6 +294,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { useWebExtras: useWebExtras, manualCookieHeader: manualCookieHeader, webOrganizationID: webOrganizationID, + webExtrasTimeout: webExtrasTimeout, + includePrepaidBalance: includePrepaidBalance, keepCLISessionsAlive: keepCLISessionsAlive, browserDetection: browserDetection) } @@ -322,7 +337,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { detectClaudeVersion: self.fetcher.allowsOAuthClaudeVersionDetection) let keychainMatch = ClaudeOAuthCredentialsStore .claudeKeychainCredentialMatchWithoutPrompt(for: credentialRecord) - return try ClaudeUsageFetcher.mapOAuthUsage( + let snapshot = try ClaudeUsageFetcher.mapOAuthUsage( usage, credentials: credentials, oauthKeychainPersistentRefHash: keychainMatch.persistentRefHash, @@ -330,6 +345,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { oauthKeychainCredentialMismatch: keychainMatch.isMismatch, oauthKeychainCredentialAbsent: keychainMatch.isAbsent, oauthKeychainCredentialUnavailable: keychainMatch.isUnavailable) + return try await self.fetcher.applyWebExtrasIfNeeded( + to: snapshot, + oauthAccessToken: credentials.accessToken) } catch let error as CancellationError { throw error } catch let error as ClaudeUsageError { @@ -343,7 +361,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { if case .rateLimited = error { throw ClaudeUsageError.oauthFailed(error.localizedDescription) } - ClaudeOAuthCredentialsStore.invalidateCache() + ClaudeOAuthCredentialsStore.invalidateCache(environment: self.fetcher.environment) if case let .serverError(statusCode, body) = error, statusCode == 403, body?.contains("user:profile") ?? false @@ -395,10 +413,13 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { try Task.checkCancellation() - _ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged() + _ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged( + environment: self.fetcher.environment) let didSyncSilently = delegatedOutcome == .attemptedSucceeded - && ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt(now: Date()) + && ClaudeOAuthCredentialsStore.syncFromClaudeKeychainWithoutPrompt( + now: Date(), + environment: self.fetcher.environment) let promptPolicy = ClaudeUsageFetcher.currentClaudeOAuthInteractivePromptPolicy() ClaudeUsageFetcher.logDeferredBackgroundDelegatedRecoveryIfNeeded( @@ -458,7 +479,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { detectClaudeVersion: self.fetcher.allowsOAuthClaudeVersionDetection) let keychainMatch = ClaudeOAuthCredentialsStore .claudeKeychainCredentialMatchWithoutPrompt(for: refreshedRecord) - return try ClaudeUsageFetcher.mapOAuthUsage( + let snapshot = try ClaudeUsageFetcher.mapOAuthUsage( usage, credentials: refreshedCredentials, oauthKeychainPersistentRefHash: keychainMatch.persistentRefHash, @@ -466,6 +487,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { oauthKeychainCredentialMismatch: keychainMatch.isMismatch, oauthKeychainCredentialAbsent: keychainMatch.isAbsent, oauthKeychainCredentialUnavailable: keychainMatch.isUnavailable) + return try await self.fetcher.applyWebExtrasIfNeeded( + to: snapshot, + oauthAccessToken: refreshedCredentials.accessToken) } catch { ClaudeUsageFetcher.log.debug( "Claude OAuth post-delegation retry failed", @@ -503,10 +527,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { case .api: throw ClaudeUsageError.parseFailed("Claude Admin API usage is handled by the provider descriptor.") case .oauth: - var snapshot = try await self.fetcher.loadViaOAuth( + return try await self.fetcher.loadViaOAuth( allowDelegatedRetry: self.fetcher.allowsDelegatedOAuthRefresh) - snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) - return snapshot case .web: return try await self.fetcher.loadViaWebAPI() case .cli: @@ -579,10 +601,8 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { case .api: throw ClaudeUsageError.parseFailed("Planner emitted invalid api execution step.") case .oauth: - var snapshot = try await self.fetcher.loadViaOAuth( + return try await self.fetcher.loadViaOAuth( allowDelegatedRetry: self.fetcher.allowsDelegatedOAuthRefresh) - snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) - return snapshot case .web: return try await self.fetcher.loadViaWebAPI() case .cli: @@ -650,7 +670,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } } ClaudeCLIRateLimitGate.recordSuccess() - snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) + snapshot = try await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) return snapshot } @@ -885,6 +905,15 @@ extension ClaudeUsageFetcher { detectClaudeVersion: detectClaudeVersion) } + private static func fetchOAuthProfile(accessToken: String) async throws -> OAuthProfileResponse { + #if DEBUG + if let override = fetchOAuthProfileOverride { + return try await override(accessToken) + } + #endif + return try await ClaudeOAuthUsageFetcher.fetchProfile(accessToken: accessToken) + } + private static func attemptDelegatedRefresh( now: Date = Date(), timeout: TimeInterval = 15, @@ -1204,14 +1233,16 @@ extension ClaudeUsageFetcher { if let header = self.manualCookieHeader { try await ClaudeWebAPIFetcher.fetchUsage( cookieHeader: header, - targetOrganizationID: self.webOrganizationID) + targetOrganizationID: self.webOrganizationID, + includePrepaidBalance: self.includePrepaidBalance) { msg in Self.log.debug(msg) } } else { try await ClaudeWebAPIFetcher.fetchUsage( browserDetection: self.browserDetection, - targetOrganizationID: self.webOrganizationID) + targetOrganizationID: self.webOrganizationID, + includePrepaidBalance: self.includePrepaidBalance) { msg in Self.log.debug(msg) } @@ -1344,30 +1375,75 @@ extension ClaudeUsageFetcher { rawText: snap.rawText) } - private func applyWebExtrasIfNeeded(to snapshot: ClaudeUsageSnapshot) async -> ClaudeUsageSnapshot { - guard self.useWebExtras, self.dataSource != .web else { return snapshot } - do { + private struct WebExtrasCandidate: Sendable { + let webData: ClaudeWebAPIFetcher.WebUsageData + let oauthProfile: OAuthProfileResponse? + } + + private func applyWebExtrasIfNeeded( + to snapshot: ClaudeUsageSnapshot, + oauthAccessToken: String? = nil) async throws -> ClaudeUsageSnapshot + { + guard self.useWebExtras || self.includePrepaidBalance, self.dataSource != .web else { return snapshot } + guard self.webExtrasTimeout.isFinite, + self.webExtrasTimeout >= 0, + self.webExtrasTimeout <= TimeInterval(Int64.max) + else { + return snapshot + } + + let sourceTask = Task { + let oauthProfile: OAuthProfileResponse? = if let oauthAccessToken { + try await Self.fetchOAuthProfile(accessToken: oauthAccessToken) + } else { + nil + } let webData: ClaudeWebAPIFetcher.WebUsageData = if let header = self.manualCookieHeader { try await ClaudeWebAPIFetcher.fetchUsage( cookieHeader: header, - targetOrganizationID: self.webOrganizationID) + targetOrganizationID: self.webOrganizationID, + includeUsageDetails: self.useWebExtras, + includePrepaidBalance: self.includePrepaidBalance) { msg in Self.log.debug(msg) } } else { try await ClaudeWebAPIFetcher.fetchUsage( browserDetection: self.browserDetection, - targetOrganizationID: self.webOrganizationID) + targetOrganizationID: self.webOrganizationID, + includeUsageDetails: self.useWebExtras, + includePrepaidBalance: self.includePrepaidBalance) { msg in Self.log.debug(msg) } } + return WebExtrasCandidate(webData: webData, oauthProfile: oauthProfile) + } + + let race = BoundedTaskJoin(sourceTask: sourceTask) + switch await race.value(joinGrace: .seconds(self.webExtrasTimeout)) { + case let .value(candidate): + try Task.checkCancellation() + let webData = candidate.webData + guard Self.webExtrasAccountMatches( + snapshot: snapshot, + webData: webData, + oauthProfile: candidate.oauthProfile) + else { + Self.log.debug("Claude web extras account did not match primary usage account") + return snapshot + } // Only merge usage/cost extras; keep identity fields from the primary data source. - let mergedExtraRateWindows = Self.mergeExtraRateWindows( - primary: snapshot.extraRateWindows, - web: webData.extraRateWindows) - let mergedProviderCost = snapshot.providerCost ?? webData.extraUsageCost + let mergedExtraRateWindows = self.useWebExtras + ? Self.mergeExtraRateWindows( + primary: snapshot.extraRateWindows, + web: webData.extraRateWindows) + : snapshot.extraRateWindows + let mergedProviderCost = Self.mergeProviderCost( + primary: snapshot.providerCost, + web: webData.extraUsageCost, + includeUsageDetails: self.useWebExtras) if mergedProviderCost != snapshot.providerCost || mergedExtraRateWindows != snapshot.extraRateWindows { return ClaudeUsageSnapshot( primary: snapshot.primary, @@ -1387,12 +1463,80 @@ extension ClaudeUsageFetcher { oauthKeychainCredentialAbsent: snapshot.oauthKeychainCredentialAbsent, oauthKeychainCredentialUnavailable: snapshot.oauthKeychainCredentialUnavailable) } - } catch { + case let .failure(error): + if error is CancellationError || + (error as? URLError)?.code == .cancelled || + Task.isCancelled + { + throw CancellationError() + } Self.log.debug("Claude web extras fetch failed: \(error.localizedDescription)") + case .timedOut: + try Task.checkCancellation() + Self.log.debug("Claude web extras fetch timed out") } return snapshot } + static func webExtrasAccountMatches( + snapshot: ClaudeUsageSnapshot, + webData: ClaudeWebAPIFetcher.WebUsageData, + oauthProfile: OAuthProfileResponse?) -> Bool + { + let primaryEmail = Self.normalizedAccountField(oauthProfile?.emailAddress ?? snapshot.accountEmail) + let webEmail = Self.normalizedAccountField(webData.accountEmail) + if let primaryEmail, let webEmail, primaryEmail != webEmail { + return false + } + + let primaryOrganization = Self.normalizedAccountField( + oauthProfile?.organizationUuid ?? snapshot.accountOrganization) + let webOrganization = Self.normalizedAccountField( + oauthProfile == nil ? webData.accountOrganization : webData.accountOrganizationID) + if let primaryOrganization, let webOrganization, primaryOrganization != webOrganization { + return false + } + + let emailMatches = primaryEmail != nil && primaryEmail == webEmail + let organizationMatches = primaryOrganization != nil && primaryOrganization == webOrganization + return emailMatches || organizationMatches + } + + private static func normalizedAccountField(_ value: String?) -> String? { + guard let value else { return nil } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return normalized.isEmpty ? nil : normalized + } + + private static func mergeProviderCost( + primary: ProviderCostSnapshot?, + web: ProviderCostSnapshot?, + includeUsageDetails: Bool = true) -> ProviderCostSnapshot? + { + if !includeUsageDetails { + guard let web, let balance = web.balance else { return primary } + guard let primary else { + return ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: web.currencyCode, + period: web.period, + balance: balance, + updatedAt: web.updatedAt) + } + guard primary.currencyCode.caseInsensitiveCompare(web.currencyCode) == .orderedSame else { + return primary + } + return primary.replacing(balance: balance) + } + guard let primary else { return web } + guard let web, + let balance = web.balance, + primary.currencyCode.caseInsensitiveCompare(web.currencyCode) == .orderedSame + else { return primary } + return primary.replacing(balance: balance) + } + private static func mergeExtraRateWindows( primary: [NamedRateWindow], web: [NamedRateWindow]) -> [NamedRateWindow] @@ -1497,6 +1641,13 @@ extension ClaudeUsageFetcher { #if DEBUG extension ClaudeUsageFetcher { + public static func _mergeProviderCostForTesting( + primary: ProviderCostSnapshot?, + web: ProviderCostSnapshot?) -> ProviderCostSnapshot? + { + self.mergeProviderCost(primary: primary, web: web) + } + public static func _mergeExtraRateWindowsForTesting( primary: [NamedRateWindow], web: [NamedRateWindow]) -> [NamedRateWindow] diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index 84e62dcf0..624bc52fd 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -19,6 +19,21 @@ enum ClaudeWebHTTPTransport { } } +enum ClaudeWebPrepaidCreditsRequest { + private static let requestTimeout: Duration = .seconds(2) + #if DEBUG + @TaskLocal static var timeoutOverrideForTesting: Duration? + #endif + + static var timeout: Duration { + #if DEBUG + self.timeoutOverrideForTesting ?? self.requestTimeout + #else + self.requestTimeout + #endif + } +} + enum ClaudeWebSessionKeyImport { #if DEBUG @TaskLocal static var overrideForTesting: ClaudeWebAPIFetcher.SessionKeyInfo? @@ -107,6 +122,7 @@ private enum ClaudeWebBrowserFetchSerialization { /// API endpoints used: /// - `GET https://claude.ai/api/organizations` → get org UUID /// - `GET https://claude.ai/api/organizations/{org_id}/usage` → usage percentages + reset times +/// - `GET https://claude.ai/api/organizations/{org_id}/prepaid/credits` → remaining Extra usage balance public enum ClaudeWebAPIFetcher { private static let baseURL = "https://claude.ai/api" private static let maxProbeBytes = 200_000 @@ -183,10 +199,11 @@ public enum ClaudeWebAPIFetcher { public let weeklyResetsAt: Date? public let opusPercentUsed: Double? public let extraRateWindows: [NamedRateWindow] - public let extraUsageCost: ProviderCostSnapshot? - public let accountOrganization: String? - public let accountEmail: String? - public let loginMethod: String? + public fileprivate(set) var extraUsageCost: ProviderCostSnapshot? + public fileprivate(set) var accountOrganization: String? + public fileprivate(set) var accountOrganizationID: String? + public fileprivate(set) var accountEmail: String? + public fileprivate(set) var loginMethod: String? /// Whether the API reported a `five_hour` session object. When `false` (the API sent /// `five_hour: null`, as enterprise/credit accounts with no live session do), `sessionPercentUsed` /// is the synthesized `0` placeholder rather than a real reading. Distinguishing this from a real @@ -203,6 +220,7 @@ public enum ClaudeWebAPIFetcher { extraRateWindows: [NamedRateWindow], extraUsageCost: ProviderCostSnapshot?, accountOrganization: String?, + accountOrganizationID: String? = nil, accountEmail: String?, loginMethod: String?, hasLiveSessionWindow: Bool = true) @@ -215,6 +233,7 @@ public enum ClaudeWebAPIFetcher { self.extraRateWindows = extraRateWindows self.extraUsageCost = extraUsageCost self.accountOrganization = accountOrganization + self.accountOrganizationID = accountOrganizationID self.accountEmail = accountEmail self.loginMethod = loginMethod self.hasLiveSessionWindow = hasLiveSessionWindow @@ -232,6 +251,20 @@ public enum ClaudeWebAPIFetcher { public let bodyPreview: String? } + private struct CachePersistence { + let sourceLabel: String + let expectedObservation: CookieHeaderCache.ConditionalMutationObservation + let persistInitialSessionKey: Bool + } + + private struct FetchOptions { + let targetOrganizationID: String? + let includeUsageDetails: Bool + let includePrepaidBalance: Bool + } +} + +extension ClaudeWebAPIFetcher { // MARK: - Public API #if os(macOS) @@ -241,12 +274,17 @@ public enum ClaudeWebAPIFetcher { public static func fetchUsage( browserDetection: BrowserDetection, targetOrganizationID: String? = nil, + includeUsageDetails: Bool = true, + includePrepaidBalance: Bool = true, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { try await ClaudeWebBrowserFetchSerialization.run { try await self.fetchUsageSerialized( browserDetection: browserDetection, - targetOrganizationID: targetOrganizationID, + options: FetchOptions( + targetOrganizationID: targetOrganizationID, + includeUsageDetails: includeUsageDetails, + includePrepaidBalance: includePrepaidBalance), logger: logger) } } @@ -254,6 +292,8 @@ public enum ClaudeWebAPIFetcher { public static func fetchUsage( cookieHeader: String, targetOrganizationID: String? = nil, + includeUsageDetails: Bool = true, + includePrepaidBalance: Bool = true, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") } @@ -261,44 +301,52 @@ public enum ClaudeWebAPIFetcher { log("Using manual session key (\(sessionInfo.cookieCount) cookies)") return try await self.fetchUsage( using: sessionInfo, - targetOrganizationID: targetOrganizationID, - logger: log) + options: FetchOptions( + targetOrganizationID: targetOrganizationID, + includeUsageDetails: includeUsageDetails, + includePrepaidBalance: includePrepaidBalance), + logger: log, + cachePersistence: nil) } public static func fetchUsage( using sessionKeyInfo: SessionKeyInfo, targetOrganizationID: String? = nil, + includeUsageDetails: Bool = true, + includePrepaidBalance: Bool = true, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { try await self.fetchUsage( using: sessionKeyInfo, - targetOrganizationID: targetOrganizationID, + options: FetchOptions( + targetOrganizationID: targetOrganizationID, + includeUsageDetails: includeUsageDetails, + includePrepaidBalance: includePrepaidBalance), logger: logger, - cacheSourceLabel: nil, - expectedCacheObservation: .authoritative(nil)) + cachePersistence: nil) } private static func fetchUsageAndRenewCache( cachedEntry: CookieHeaderCache.Entry, - targetOrganizationID: String?, + options: FetchOptions, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { let sessionInfo = try self.sessionKeyInfo(cookieHeader: cachedEntry.cookieHeader) return try await self.fetchUsage( using: sessionInfo, - targetOrganizationID: targetOrganizationID, + options: options, logger: logger, - cacheSourceLabel: cachedEntry.sourceLabel, - expectedCacheObservation: .authoritative(cachedEntry)) + cachePersistence: CachePersistence( + sourceLabel: cachedEntry.sourceLabel, + expectedObservation: .authoritative(cachedEntry), + persistInitialSessionKey: false)) } private static func fetchUsage( using sessionKeyInfo: SessionKeyInfo, - targetOrganizationID: String?, + options: FetchOptions, logger: ((String) -> Void)?, - cacheSourceLabel: String?, - expectedCacheObservation: CookieHeaderCache.ConditionalMutationObservation, - persistInitialSessionKey: Bool = false) async throws -> WebUsageData + cachePersistence: CachePersistence?) async throws -> WebUsageData { let log: (String) -> Void = { msg in logger?(msg) } let sessionKey = sessionKeyInfo.key @@ -307,7 +355,7 @@ public enum ClaudeWebAPIFetcher { // Fetch organization info let organization = try await fetchOrganizationInfo( sessionKey: sessionKey, - targetOrganizationID: targetOrganizationID, + targetOrganizationID: options.targetOrganizationID, logger: log, renewalTracker: renewalTracker) log("Organization resolved") @@ -317,68 +365,51 @@ public enum ClaudeWebAPIFetcher { sessionKey: renewalTracker.sessionKey, logger: log, renewalTracker: renewalTracker) - if usage.extraUsageCost == nil, - let extra = await ClaudeWebExtraUsageCost.fetch( + usage.accountOrganizationID = organization.id + if options.includeUsageDetails, + usage.extraUsageCost == nil, + let extra = try await ClaudeWebExtraUsageCost.fetch( baseURL: Self.baseURL, orgId: organization.id, sessionKey: renewalTracker.sessionKey, logger: log, renewalTracker: renewalTracker) { - usage = WebUsageData( - sessionPercentUsed: usage.sessionPercentUsed, - sessionResetsAt: usage.sessionResetsAt, - weeklyPercentUsed: usage.weeklyPercentUsed, - weeklyResetsAt: usage.weeklyResetsAt, - opusPercentUsed: usage.opusPercentUsed, - extraRateWindows: usage.extraRateWindows, - extraUsageCost: extra, - accountOrganization: usage.accountOrganization, - accountEmail: usage.accountEmail, - loginMethod: usage.loginMethod, - hasLiveSessionWindow: usage.hasLiveSessionWindow) - } - if let account = await fetchAccountInfo( + usage.extraUsageCost = extra + } + if options.includePrepaidBalance, + let balance = try await ClaudeWebExtraUsageCost.fetchPrepaidBalance( + baseURL: Self.baseURL, + orgId: organization.id, + sessionKey: renewalTracker.sessionKey, + logger: log, + renewalTracker: renewalTracker) + { + usage.extraUsageCost = ClaudeWebExtraUsageCost.applyingPrepaidBalance( + balance, + to: usage.extraUsageCost) + } + if let account = try await fetchAccountInfo( sessionKey: renewalTracker.sessionKey, orgId: organization.id, logger: log, renewalTracker: renewalTracker) { - usage = WebUsageData( - sessionPercentUsed: usage.sessionPercentUsed, - sessionResetsAt: usage.sessionResetsAt, - weeklyPercentUsed: usage.weeklyPercentUsed, - weeklyResetsAt: usage.weeklyResetsAt, - opusPercentUsed: usage.opusPercentUsed, - extraRateWindows: usage.extraRateWindows, - extraUsageCost: usage.extraUsageCost, - accountOrganization: usage.accountOrganization, - accountEmail: account.email, - loginMethod: account.loginMethod, - hasLiveSessionWindow: usage.hasLiveSessionWindow) + usage.accountEmail = account.email + usage.loginMethod = account.loginMethod } if usage.accountOrganization == nil, let name = organization.name { - usage = WebUsageData( - sessionPercentUsed: usage.sessionPercentUsed, - sessionResetsAt: usage.sessionResetsAt, - weeklyPercentUsed: usage.weeklyPercentUsed, - weeklyResetsAt: usage.weeklyResetsAt, - opusPercentUsed: usage.opusPercentUsed, - extraRateWindows: usage.extraRateWindows, - extraUsageCost: usage.extraUsageCost, - accountOrganization: name, - accountEmail: usage.accountEmail, - loginMethod: usage.loginMethod, - hasLiveSessionWindow: usage.hasLiveSessionWindow) - } - if let cacheSourceLabel { + usage.accountOrganization = name + } + if let cachePersistence { self.persistSessionKeyIfNeeded( - source: (sessionKey, cacheSourceLabel), + source: (sessionKey, cachePersistence.sourceLabel), renewedCookieHeader: renewalTracker.renewedCookieHeader, - expectedCacheObservation: expectedCacheObservation, - persistInitialSessionKey: persistInitialSessionKey, + expectedCacheObservation: cachePersistence.expectedObservation, + persistInitialSessionKey: cachePersistence.persistInitialSessionKey, logger: log) } + try Task.checkCancellation() return usage } @@ -701,6 +732,11 @@ public enum ClaudeWebAPIFetcher { ClaudeWebExtraUsageCost.parseOverageSpendLimit(data) } + public static func _parsePrepaidCreditsForTesting(_ data: Data) -> ProviderCostSnapshot? { + guard let balance = ClaudeWebExtraUsageCost.parsePrepaidBalance(data) else { return nil } + return ClaudeWebExtraUsageCost.applyingPrepaidBalance(balance, to: nil) + } + public static func _parseAccountInfoForTesting(_ data: Data, orgId: String?) -> WebAccountInfo? { self.parseAccountInfo(data, orgId: orgId) } @@ -795,7 +831,7 @@ public enum ClaudeWebAPIFetcher { sessionKey: String, orgId: String?, logger: ((String) -> Void)? = nil, - renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async -> WebAccountInfo? + renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async throws -> WebAccountInfo? { let url = URL(string: "\(baseURL)/account")! var request = URLRequest(url: url) @@ -805,13 +841,18 @@ public enum ClaudeWebAPIFetcher { request.timeoutInterval = 15 do { - let (data, response) = try await ClaudeWebHTTPTransport.current.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { return nil } - renewalTracker?.observe(response: httpResponse) - logger?("Account API status: \(httpResponse.statusCode)") - guard httpResponse.statusCode == 200 else { return nil } - return Self.parseAccountInfo(data, orgId: orgId) + let response = try await ClaudeWebHTTPTransport.current.response(for: request) + renewalTracker?.observe(response: response.response) + logger?("Account API status: \(response.statusCode)") + guard response.statusCode == 200 else { return nil } + return Self.parseAccountInfo(response.data, orgId: orgId) } catch { + if error is CancellationError || + (error as? URLError)?.code == .cancelled || + Task.isCancelled + { + throw CancellationError() + } return nil } } @@ -969,10 +1010,14 @@ public enum ClaudeWebAPIFetcher { public static func fetchUsage( browserDetection: BrowserDetection, targetOrganizationID: String? = nil, + includeUsageDetails: Bool = true, + includePrepaidBalance: Bool = true, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { _ = browserDetection _ = targetOrganizationID + _ = includeUsageDetails + _ = includePrepaidBalance _ = logger throw FetchError.notSupportedOnThisPlatform } @@ -980,10 +1025,14 @@ public enum ClaudeWebAPIFetcher { public static func fetchUsage( cookieHeader: String, targetOrganizationID: String? = nil, + includeUsageDetails: Bool = true, + includePrepaidBalance: Bool = true, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { _ = cookieHeader _ = targetOrganizationID + _ = includeUsageDetails + _ = includePrepaidBalance _ = logger throw FetchError.notSupportedOnThisPlatform } @@ -991,9 +1040,13 @@ public enum ClaudeWebAPIFetcher { public static func fetchUsage( using sessionKeyInfo: SessionKeyInfo, targetOrganizationID: String? = nil, + includeUsageDetails: Bool = true, + includePrepaidBalance: Bool = true, logger: ((String) -> Void)? = nil) async throws -> WebUsageData { _ = targetOrganizationID + _ = includeUsageDetails + _ = includePrepaidBalance throw FetchError.notSupportedOnThisPlatform } @@ -1130,6 +1183,16 @@ private final class ClaudeWebSessionKeyRenewalTracker: @unchecked Sendable { private enum ClaudeWebExtraUsageCost { // MARK: - Extra usage cost (Claude "Extra") + struct PrepaidBalance: Equatable, Sendable { + let amount: Double + let currencyCode: String + } + + private struct PrepaidCreditsResponse: Decodable { + let amount: Double + let currency: String + } + static func parse(from value: Any?) -> ProviderCostSnapshot? { guard let extraUsage = value as? [String: Any] else { return nil } guard let used = Self.doubleValue(extraUsage["used_credits"]), @@ -1163,25 +1226,18 @@ private enum ClaudeWebExtraUsageCost { orgId: String, sessionKey: String, logger: ((String) -> Void)? = nil, - renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async -> ProviderCostSnapshot? + renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async throws -> ProviderCostSnapshot? { - let url = URL(string: "\(baseURL)/organizations/\(orgId)/overage_spend_limit")! - var request = URLRequest(url: url) - request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpMethod = "GET" - request.timeoutInterval = 15 - - do { - let (data, response) = try await ClaudeWebHTTPTransport.current.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { return nil } - renewalTracker?.observe(response: httpResponse) - logger?("Overage API status: \(httpResponse.statusCode)") - guard httpResponse.statusCode == 200 else { return nil } - return Self.parseOverageSpendLimit(data) - } catch { - return nil - } + let data = try await Self.fetchBestEffortJSON( + request: BestEffortRequest( + url: URL(string: "\(baseURL)/organizations/\(orgId)/overage_spend_limit"), + requestTimeout: 15, + joinGrace: .seconds(15), + logLabel: "Overage"), + sessionKey: sessionKey, + logger: logger, + renewalTracker: renewalTracker) + return data.flatMap(Self.parseOverageSpendLimit) } static func parseOverageSpendLimit(_ data: Data) -> ProviderCostSnapshot? { @@ -1198,6 +1254,101 @@ private enum ClaudeWebExtraUsageCost { currencyCode: currency) } + /// Best-effort fetch of Claude's remaining prepaid Extra usage balance. + static func fetchPrepaidBalance( + baseURL: String, + orgId: String, + sessionKey: String, + logger: ((String) -> Void)? = nil, + renewalTracker: ClaudeWebSessionKeyRenewalTracker? = nil) async throws -> PrepaidBalance? + { + let data = try await Self.fetchBestEffortJSON( + request: BestEffortRequest( + url: URL(string: "\(baseURL)/organizations/\(orgId)/prepaid/credits"), + requestTimeout: 15, + joinGrace: ClaudeWebPrepaidCreditsRequest.timeout, + logLabel: "Prepaid credits"), + sessionKey: sessionKey, + logger: logger, + renewalTracker: renewalTracker) + return data.flatMap(Self.parsePrepaidBalance) + } + + private static func fetchBestEffortJSON( + request spec: BestEffortRequest, + sessionKey: String, + logger: ((String) -> Void)?, + renewalTracker: ClaudeWebSessionKeyRenewalTracker?) async throws -> Data? + { + guard let url = spec.url else { return nil } + var request = URLRequest(url: url) + request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpMethod = "GET" + request.timeoutInterval = spec.requestTimeout + + let transport = ClaudeWebHTTPTransport.current + let sourceTask = Task { + try await transport.response(for: request) + } + let race = BoundedTaskJoin(sourceTask: sourceTask) + switch await race.value(joinGrace: spec.joinGrace) { + case let .value(payload): + try Task.checkCancellation() + renewalTracker?.observe(response: payload.response) + logger?("\(spec.logLabel) API status: \(payload.statusCode)") + guard payload.statusCode == 200 else { return nil } + return payload.data + case let .failure(error): + if error is CancellationError || + (error as? URLError)?.code == .cancelled || + Task.isCancelled + { + throw CancellationError() + } + return nil + case .timedOut: + try Task.checkCancellation() + return nil + } + } + + static func parsePrepaidBalance(_ data: Data) -> PrepaidBalance? { + guard let response = try? JSONDecoder().decode(PrepaidCreditsResponse.self, from: data), + response.amount.isFinite, + response.amount >= 0 + else { return nil } + let currency = response.currency.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard !currency.isEmpty else { return nil } + return PrepaidBalance(amount: response.amount / 100.0, currencyCode: currency) + } + + static func applyingPrepaidBalance( + _ balance: PrepaidBalance, + to cost: ProviderCostSnapshot?) -> ProviderCostSnapshot + { + if let cost { + guard cost.currencyCode.caseInsensitiveCompare(balance.currencyCode) == .orderedSame else { + return cost + } + return cost.replacing(balance: balance.amount) + } + return ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: balance.currencyCode, + period: "Extra usage", + balance: balance.amount, + updatedAt: Date()) + } + + private struct BestEffortRequest { + let url: URL? + let requestTimeout: TimeInterval + let joinGrace: Duration + let logLabel: String + } + static func makeExtraUsageCost( usedCredits: Double, monthlyCreditLimit: Double, @@ -1231,9 +1382,9 @@ private enum ClaudeWebExtraUsageCost { #if os(macOS) extension ClaudeWebAPIFetcher { - fileprivate static func fetchUsageSerialized( + private static func fetchUsageSerialized( browserDetection: BrowserDetection, - targetOrganizationID: String?, + options: FetchOptions, logger: ((String) -> Void)?) async throws -> WebUsageData { let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") } @@ -1246,7 +1397,7 @@ extension ClaudeWebAPIFetcher { do { return try await self.fetchUsageAndRenewCache( cachedEntry: cached, - targetOrganizationID: targetOrganizationID, + options: options, logger: log) } catch let error as FetchError { switch error { @@ -1266,11 +1417,12 @@ extension ClaudeWebAPIFetcher { return try await self.fetchUsage( using: sessionInfo, - targetOrganizationID: targetOrganizationID, + options: options, logger: log, - cacheSourceLabel: sessionInfo.sourceLabel, - expectedCacheObservation: cacheObservation, - persistInitialSessionKey: true) + cachePersistence: CachePersistence( + sourceLabel: sessionInfo.sourceLabel, + expectedObservation: cacheObservation, + persistInitialSessionKey: true)) } } #endif diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index eba33f481..440d9ede2 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -126,12 +126,21 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { sourceLabel: String, authenticatedByAuthFile: Bool) + /// Browser-cookie import must stay limited to surfaces where a person explicitly asked for it: + /// the menu-bar app runtime, a `userInitiated` interaction (set only by explicit refresh + /// commands and app UI gestures), or the environment override. Scheduled and background work + /// must keep the default `.background` context so it can never reach Chromium Keychain prompts. static func canImportBrowserCookies(runtime: ProviderRuntime, env: [String: String]) -> Bool { - runtime == .app || env["CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT"] == "1" + runtime == .app || + ProviderInteractionContext.current == .userInitiated || + env["CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT"] == "1" } func isAvailable(_ context: ProviderFetchContext) async -> Bool { #if os(macOS) + if CookieHeaderCache.load(provider: .grok) != nil { + return true + } if Self.canImportBrowserCookies(runtime: context.runtime, env: context.env), GrokCookieImporter.hasSession(browserDetection: context.browserDetection) { @@ -201,13 +210,31 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { let browserCredentials = try? credentialsResult.get() #if os(macOS) + var cacheObservation = CookieHeaderCache.observeForConditionalMutation(provider: .grok) + var lastCookieError: Error? + if let cached = cacheObservation.entry { + do { + let snapshot = try await Self.fetchValidCookieHeader( + cached.cookieHeader, + credentials: browserCredentials, + preferTrailingAuthenticationFailure: true) + return (snapshot, cached.sourceLabel, false) + } catch { + guard Self.isCookieAuthenticationFailure(error) else { throw error } + if CookieHeaderCache.clearIfCurrent(provider: .grok, expected: cached) { + cacheObservation = cacheObservation.afterOwnedClear() + } + lastCookieError = error + } + } + if Self.canImportBrowserCookies(runtime: context.runtime, env: context.env) { - var lastCookieError: Error? do { let sessions = try GrokCookieImporter.importSessions(browserDetection: context.browserDetection) let (snapshot, sourceLabel) = try await Self.fetchFirstValidCookieSession( sessions, - credentials: browserCredentials) + credentials: browserCredentials, + cacheObservation: cacheObservation) return (snapshot, sourceLabel, false) } catch { lastCookieError = error @@ -242,6 +269,7 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { static func fetchFirstValidCookieSession( _ sessions: [GrokCookieImporter.SessionInfo], credentials: GrokCredentials? = nil, + cacheObservation: CookieHeaderCache.ConditionalMutationObservation? = nil, fetch: ((String, GrokCredentials?) async throws -> GrokWebBillingSnapshot)? = nil) async throws -> (GrokWebBillingSnapshot, String) { @@ -253,25 +281,83 @@ struct GrokWebFetchStrategy: ProviderFetchStrategy { var lastError: Error? var teamUsageUnsupportedError: Error? for session in sessions { - for authCredentials in Self.cookieAuthAttempts(credentials: credentials) { - do { - let snapshot = try await fetchSnapshot(session.cookieHeader, authCredentials) - return (snapshot, session.sourceLabel) - } catch { - if case GrokWebBillingError.teamUsageUnsupported = error { - teamUsageUnsupportedError = error - } - lastError = error + do { + let snapshot = try await Self.fetchValidCookieHeader( + session.cookieHeader, + credentials: credentials, + fetch: fetchSnapshot) + if let cacheObservation { + CookieHeaderCache.storeIfObservationCurrent( + provider: .grok, + expected: cacheObservation, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + } + return (snapshot, session.sourceLabel) + } catch { + if case GrokWebBillingError.teamUsageUnsupported = error { + teamUsageUnsupportedError = error } + lastError = error } } throw teamUsageUnsupportedError ?? lastError ?? GrokWebBillingError.missingCredentials } + /// `preferTrailingAuthenticationFailure` lets a cached-cookie caller surface a trailing + /// 401/403 over the team classification so stale sessions still trigger cache eviction. + /// Non-authentication trailing errors keep `teamUsageUnsupported` so team principals + /// degrade to identity-only data instead of failing outright. + static func fetchValidCookieHeader( + _ cookieHeader: String, + credentials: GrokCredentials? = nil, + preferTrailingAuthenticationFailure: Bool = false, + fetch: ((String, GrokCredentials?) async throws -> GrokWebBillingSnapshot)? = nil) async throws + -> GrokWebBillingSnapshot + { + let fetchSnapshot = fetch ?? { cookieHeader, credentials in + try await GrokWebBillingFetcher.fetch( + cookieHeader: cookieHeader, + credentials: credentials) + } + var lastError: Error? + var teamUsageUnsupportedError: Error? + for authCredentials in Self.cookieAuthAttempts(credentials: credentials) { + do { + return try await fetchSnapshot(cookieHeader, authCredentials) + } catch { + if case GrokWebBillingError.teamUsageUnsupported = error { + teamUsageUnsupportedError = error + } + lastError = error + } + } + if let teamUsageUnsupportedError { + let trailingAuthenticationFailure = preferTrailingAuthenticationFailure + && lastError.map(Self.isCookieAuthenticationFailure) == true + if !trailingAuthenticationFailure { + throw teamUsageUnsupportedError + } + } + throw lastError ?? GrokWebBillingError.missingCredentials + } + static func cookieAuthAttempts(credentials: GrokCredentials?) -> [GrokCredentials?] { guard let credentials, !credentials.isExpired else { return [nil] } return [credentials, nil] } + + static func isCookieAuthenticationFailure(_ error: Error) -> Bool { + guard let error = error as? GrokWebBillingError else { return false } + switch error { + case let .requestFailed(status, _): + return status == 401 || status == 403 + case let .rpcFailed(status, message): + return GrokWebBillingError.isAuthenticationFailure(status: status, message: message) + case .missingCredentials, .emptyResponse, .invalidResponse, .teamUsageUnsupported, .parseFailed: + return false + } + } #endif func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { diff --git a/Sources/CodexBarCore/Providers/JetBrains/JetBrainsStatusProbe.swift b/Sources/CodexBarCore/Providers/JetBrains/JetBrainsStatusProbe.swift index 198d830d1..4799a667c 100644 --- a/Sources/CodexBarCore/Providers/JetBrains/JetBrainsStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/JetBrains/JetBrainsStatusProbe.swift @@ -81,16 +81,15 @@ public struct JetBrainsStatusSnapshot: Sendable { identity: identity) } - private static func formatResetDescription(_ date: Date?) -> String? { + static func formatResetDescription(_ date: Date?, now: Date = Date()) -> String? { guard let date else { return nil } - let now = Date() let interval = date.timeIntervalSince(now) guard interval > 0 else { return "Expired" } let hours = Int(interval / 3600) let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) - if hours > 24 { + if hours >= 24 { let days = hours / 24 let remainingHours = hours % 24 return "Resets in \(days)d \(remainingHours)h" diff --git a/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyUsageFetcher.swift b/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyUsageFetcher.swift index 4f90463fb..fc9b43517 100644 --- a/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyUsageFetcher.swift @@ -265,7 +265,12 @@ public struct LLMProxyUsageFetcher: Sendable { let quotaGroups = providers.values.flatMap { $0.quotaGroups ?? [] } let minRemaining = quotaGroups.compactMap(\.remainingPercent).min() - let reset = quotaGroups.compactMap { Self.parseDate($0.resetTime) }.min() + // Ignore already-elapsed reset times so a stale past reset can't win over the real + // upcoming one; mirrors GrokWebBillingFetcher and ClaudeStatusProbe. + let reset = quotaGroups + .compactMap { Self.parseDate($0.resetTime) } + .filter { $0 > updatedAt } + .min() return LLMProxyUsageSnapshot( providerCount: providers.count, diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index d533368cd..165746c0e 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -133,6 +133,7 @@ public enum ProviderDescriptorRegistry { .opencodego: OpenCodeGoProviderDescriptor.descriptor, .alibaba: AlibabaCodingPlanProviderDescriptor.descriptor, .alibabatokenplan: AlibabaTokenPlanProviderDescriptor.descriptor, + .qwencloud: QwenCloudProviderDescriptor.descriptor, .factory: FactoryProviderDescriptor.descriptor, .gemini: GeminiProviderDescriptor.descriptor, .antigravity: AntigravityProviderDescriptor.descriptor, @@ -186,6 +187,7 @@ public enum ProviderDescriptorRegistry { .wayfinder: WayfinderProviderDescriptor.descriptor, .zenmux: ZenMuxProviderDescriptor.descriptor, .aiand: AiAndProviderDescriptor.descriptor, + .zoommate: ZoomMateProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index 9c744f589..1f14ab28c 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -18,6 +18,7 @@ public struct ProviderSettingsSnapshot: Sendable { opencodego: OpenCodeProviderSettings? = nil, alibaba: AlibabaCodingPlanProviderSettings? = nil, alibabaTokenPlan: AlibabaTokenPlanProviderSettings? = nil, + qwenCloud: QwenCloudProviderSettings? = nil, factory: FactoryProviderSettings? = nil, minimax: MiniMaxProviderSettings? = nil, manus: ManusProviderSettings? = nil, @@ -30,6 +31,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings? = nil, t3chat: T3ChatProviderSettings? = nil, + zoommate: ZoomMateProviderSettings? = nil, devin: DevinProviderSettings? = nil, commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, @@ -52,6 +54,7 @@ public struct ProviderSettingsSnapshot: Sendable { opencodego: opencodego, alibaba: alibaba, alibabaTokenPlan: alibabaTokenPlan, + qwenCloud: qwenCloud, factory: factory, minimax: minimax, manus: manus, @@ -64,6 +67,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: moonshot, amp: amp, t3chat: t3chat, + zoommate: zoommate, devin: devin, commandcode: commandcode, ollama: ollama, @@ -198,6 +202,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct QwenCloudProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource = .auto, manualCookieHeader: String? = nil) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct FactoryProviderSettings: ProviderCookieSettings { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -351,6 +365,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct ZoomMateProviderSettings: ProviderCookieSettings { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct DevinProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualBearerToken: String? @@ -477,6 +501,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let opencodego: OpenCodeProviderSettings? public let alibaba: AlibabaCodingPlanProviderSettings? public let alibabaTokenPlan: AlibabaTokenPlanProviderSettings? + public let qwenCloud: QwenCloudProviderSettings? public let factory: FactoryProviderSettings? public let minimax: MiniMaxProviderSettings? public let manus: ManusProviderSettings? @@ -489,6 +514,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let moonshot: MoonshotProviderSettings? public let amp: AmpProviderSettings? public let t3chat: T3ChatProviderSettings? + public let zoommate: ZoomMateProviderSettings? public let devin: DevinProviderSettings? public let commandcode: CommandCodeProviderSettings? public let ollama: OllamaProviderSettings? @@ -515,6 +541,7 @@ public struct ProviderSettingsSnapshot: Sendable { opencodego: OpenCodeProviderSettings?, alibaba: AlibabaCodingPlanProviderSettings?, alibabaTokenPlan: AlibabaTokenPlanProviderSettings? = nil, + qwenCloud: QwenCloudProviderSettings? = nil, factory: FactoryProviderSettings?, minimax: MiniMaxProviderSettings?, manus: ManusProviderSettings?, @@ -527,6 +554,7 @@ public struct ProviderSettingsSnapshot: Sendable { moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings?, t3chat: T3ChatProviderSettings? = nil, + zoommate: ZoomMateProviderSettings? = nil, devin: DevinProviderSettings? = nil, commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings?, @@ -548,6 +576,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.opencodego = opencodego self.alibaba = alibaba self.alibabaTokenPlan = alibabaTokenPlan + self.qwenCloud = qwenCloud self.factory = factory self.minimax = minimax self.manus = manus @@ -560,6 +589,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.moonshot = moonshot self.amp = amp self.t3chat = t3chat + self.zoommate = zoommate self.devin = devin self.commandcode = commandcode self.ollama = ollama @@ -582,6 +612,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case opencodego(ProviderSettingsSnapshot.OpenCodeProviderSettings) case alibaba(ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings) case alibabaTokenPlan(ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings) + case qwenCloud(ProviderSettingsSnapshot.QwenCloudProviderSettings) case factory(ProviderSettingsSnapshot.FactoryProviderSettings) case minimax(ProviderSettingsSnapshot.MiniMaxProviderSettings) case manus(ProviderSettingsSnapshot.ManusProviderSettings) @@ -594,6 +625,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) case t3chat(ProviderSettingsSnapshot.T3ChatProviderSettings) + case zoommate(ProviderSettingsSnapshot.ZoomMateProviderSettings) case devin(ProviderSettingsSnapshot.DevinProviderSettings) case commandcode(ProviderSettingsSnapshot.CommandCodeProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) @@ -617,6 +649,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings? public var alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? public var alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings? + public var qwenCloud: ProviderSettingsSnapshot.QwenCloudProviderSettings? public var factory: ProviderSettingsSnapshot.FactoryProviderSettings? public var minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? public var manus: ProviderSettingsSnapshot.ManusProviderSettings? @@ -629,6 +662,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? public var t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings? + public var zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings? public var devin: ProviderSettingsSnapshot.DevinProviderSettings? public var commandcode: ProviderSettingsSnapshot.CommandCodeProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? @@ -656,6 +690,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .opencodego(value): self.opencodego = value case let .alibaba(value): self.alibaba = value case let .alibabaTokenPlan(value): self.alibabaTokenPlan = value + case let .qwenCloud(value): self.qwenCloud = value case let .factory(value): self.factory = value case let .minimax(value): self.minimax = value case let .manus(value): self.manus = value @@ -668,6 +703,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .moonshot(value): self.moonshot = value case let .amp(value): self.amp = value case let .t3chat(value): self.t3chat = value + case let .zoommate(value): self.zoommate = value case let .devin(value): self.devin = value case let .commandcode(value): self.commandcode = value case let .ollama(value): self.ollama = value @@ -693,6 +729,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { opencodego: self.opencodego, alibaba: self.alibaba, alibabaTokenPlan: self.alibabaTokenPlan, + qwenCloud: self.qwenCloud, factory: self.factory, minimax: self.minimax, manus: self.manus, @@ -705,6 +742,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { moonshot: self.moonshot, amp: self.amp, t3chat: self.t3chat, + zoommate: self.zoommate, devin: self.devin, commandcode: self.commandcode, ollama: self.ollama, diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 4953c94b0..391b0c3a3 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -13,6 +13,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case opencodego case alibaba case alibabatokenplan + case qwencloud case factory case gemini case antigravity @@ -66,6 +67,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case wayfinder case zenmux case aiand + case zoommate } // swiftformat:enable sortDeclarations @@ -84,6 +86,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case opencode case opencodego case alibaba + case qwencloud case factory case copilot case devin @@ -132,6 +135,7 @@ public enum IconStyle: String, Sendable, CaseIterable { case wayfinder case zenmux case aiand + case zoommate case combined } @@ -160,6 +164,8 @@ public struct ProviderMetadata: Sendable { public let statusLinkURL: String? /// Google Workspace product ID for status polling (appsstatus dashboard). public let statusWorkspaceProductID: String? + /// Optional top-level component/group names to show from a provider status feed. + public let statusComponentAllowlist: Set? public init( id: UsageProvider, @@ -181,7 +187,8 @@ public struct ProviderMetadata: Sendable { changelogURL: String? = nil, statusPageURL: String?, statusLinkURL: String? = nil, - statusWorkspaceProductID: String? = nil) + statusWorkspaceProductID: String? = nil, + statusComponentAllowlist: Set? = nil) { self.id = id self.displayName = displayName @@ -203,6 +210,7 @@ public struct ProviderMetadata: Sendable { self.statusPageURL = statusPageURL self.statusLinkURL = statusLinkURL self.statusWorkspaceProductID = statusWorkspaceProductID + self.statusComponentAllowlist = statusComponentAllowlist } } @@ -213,6 +221,14 @@ public enum ProviderDefaults { } public enum ProviderBrowserCookieDefaults { + public static var chromeOnlyImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } + public static var defaultImportOrder: BrowserCookieImportOrder? { #if os(macOS) Browser.defaultImportOrder diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieHeader.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieHeader.swift new file mode 100644 index 000000000..3605bdb8c --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieHeader.swift @@ -0,0 +1,54 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Qwen Cloud cookie-header pair. +/// +/// Wraps the shared `OneConsoleCookieHeaders` so the rest of the codebase +/// keeps referring to `QwenCloudCookieHeaders`. The cached-header round-trip +/// uses a namespace unique to Qwen Cloud so it can never collide with +/// Alibaba Token Plan or another provider's cached entry. +public typealias QwenCloudCookieHeaders = OneConsoleCookieHeaders + +extension OneConsoleCookieHeaders { + static let qwenCloudCacheNamespace = "qwen_cloud" + + public init?(qwenCloudCachedHeader raw: String?) { + if let headers = OneConsoleCookieHeaders(cachedHeader: raw, cacheNamespace: Self.qwenCloudCacheNamespace) { + self = headers + } else if let headers = OneConsoleCookieHeaders(singleHeader: raw) { + self = headers + } else { + return nil + } + } + + public func cacheQwenCloudCookieHeader() -> String { + self.cacheCookieHeader(namespace: Self.qwenCloudCacheNamespace) + } +} + +enum QwenCloudCookieHeader { + static let cacheNamespace = OneConsoleCookieHeaders.qwenCloudCacheNamespace + + static func headers( + from cookies: [HTTPCookie], + environment: [String: String] = ProcessInfo.processInfo.environment) -> QwenCloudCookieHeaders? + { + guard let apiHeader = OneConsoleCookieHeaderBuilder.header( + from: cookies, + targetURL: QwenCloudUsageFetcher.resolveQuotaURL(environment: environment)), + let dashboardHeader = OneConsoleCookieHeaderBuilder.header( + from: cookies, + targetURL: QwenCloudUsageFetcher.dashboardURL(environment: environment)) + else { + return nil + } + return QwenCloudCookieHeaders(apiCookieHeader: apiHeader, dashboardCookieHeader: dashboardHeader) + } + + static func header(from cookies: [HTTPCookie], targetURL: URL) -> String? { + OneConsoleCookieHeaderBuilder.header(from: cookies, targetURL: targetURL) + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieImporter.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieImporter.swift new file mode 100644 index 000000000..8f2c72a69 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudCookieImporter.swift @@ -0,0 +1,60 @@ +#if os(macOS) +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import SweetCookieKit + +/// Qwen Cloud (home.qwencloud.com) shares the Aliyun one-console auth backend, so its +/// session cookies live on `qwencloud.com` plus the alibabacloud/aliyun passport domains. +public enum QwenCloudCookieImport { + static let cookieDomains: [String] = [ + "qwencloud.com", + "home.qwencloud.com", + "account.qwencloud.com", + "signin.qwencloud.com", + "www.qwencloud.com", + "alibabacloud.com", + "account.alibabacloud.com", + "aliyun.com", + "console.aliyun.com", + ] + + /// Cookie names that prove an authenticated Qwen Cloud session. Locale + /// preferences, account-id markers, and CSRF tokens are intentionally + /// excluded: a browser profile that merely visited qwencloud.com already + /// carries them while logged out, and treating such a profile as + /// authenticated would make the fetcher send a ticketless request, receive + /// `loginRequired`, and keep re-importing the same profile forever. + static let authTicketCookies: Set = [ + "login_aliyunid_ticket", + "login_qwencloud_ticket", + "qwen_sso_ticket", + ] + + public static func importSession( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil, + importOrder: BrowserCookieImportOrder = Browser.defaultImportOrder) + throws -> AliyunOneConsoleCookieImporter.SessionInfo + { + try AliyunOneConsoleCookieImporter.importSession( + browserDetection: browserDetection, + domains: self.cookieDomains, + isAuthenticatedSession: self.isAuthenticatedSession(cookies:), + logPrefix: "qwen-cloud-cookie", + sessionLabel: "Qwen Cloud", + importOrder: importOrder, + logger: logger) + } + + static func isAuthenticatedSession(cookies: [HTTPCookie]) -> Bool { + // Qwen Cloud uses its own login ticket for direct accounts and the + // alibabacloud passport ticket for legacy/federated accounts. Accept SSO + // tickets too so SAML/SSO logins work. Never accept locale/account-id + // cookies on their own — logged-out profiles carry them as well. + let names = Set(cookies.map(\.name)) + return !names.isDisjoint(with: self.authTicketCookies) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudHTTPTransport.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudHTTPTransport.swift new file mode 100644 index 000000000..b195f8de5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudHTTPTransport.swift @@ -0,0 +1,52 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Qwen Cloud transport that applies its OneConsole redirect policy per request. +/// +/// Redirect behavior stays provider-local: the shared provider client blocks +/// cross-origin redirects, while Qwen may follow credential-free login navigation. +struct QwenCloudHTTPTransport: ProviderHTTPTransport, @unchecked Sendable { + private static let sharedSession: URLSession = { + let configuration = ProviderHTTPClient.defaultConfiguration() + configuration.httpCookieStorage = nil + configuration.urlCredentialStorage = nil + configuration.httpShouldSetCookies = false + return URLSession(configuration: configuration) + }() + + private let session: URLSession + private let routing: OneConsoleCookieRouting + + init(session: URLSession? = nil, routing: OneConsoleCookieRouting) { + self.session = session ?? Self.sharedSession + self.routing = routing + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let delegate = RedirectDelegate(routing: self.routing) + return try await self.session.data(for: request, delegate: delegate) + } + + private final class RedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + private let routing: OneConsoleCookieRouting + + init(routing: OneConsoleCookieRouting) { + self.routing = routing + } + + func urlSession( + _: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest) async -> URLRequest? + { + guard let original = task.originalRequest else { return nil } + return self.routing.redirectedRequest( + forRedirectFrom: original, + response: response, + to: request) + } + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift new file mode 100644 index 000000000..4f261a271 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudProviderDescriptor.swift @@ -0,0 +1,255 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit +#endif + +public enum QwenCloudProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + #if os(macOS) + let browserOrder: BrowserCookieImportOrder = [.chrome] + #else + let browserOrder: BrowserCookieImportOrder? = nil + #endif + + return ProviderDescriptor( + id: .qwencloud, + metadata: ProviderMetadata( + id: .qwencloud, + displayName: "Qwen Cloud", + sessionLabel: "5-hour", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Qwen Cloud usage", + cliName: "qwen-cloud", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: browserOrder, + dashboardURL: QwenCloudUsageFetcher.dashboardURL.absoluteString, + statusPageURL: nil, + statusLinkURL: "https://status.alibabacloud.com"), + branding: ProviderBranding( + iconStyle: .qwencloud, + iconResourceName: "ProviderIcon-qwencloud", + color: ProviderColor(red: 147 / 255, green: 51 / 255, blue: 234 / 255), + confettiPalette: [ + ProviderColor(hex: 0x9333EA), + ProviderColor(hex: 0x8B86F5), + ProviderColor(hex: 0xFFFFFF), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Qwen Cloud cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), + cli: ProviderCLIConfig( + name: "qwen-cloud", + aliases: ["qwencloud", "qwen", "qwen-token-plan"], + versionDetector: nil)) + } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + guard context.settings?.qwenCloud?.cookieSource != .off else { return [] } + switch context.sourceMode { + case .auto, .web: + return [QwenCloudWebFetchStrategy()] + case .api, .cli, .oauth: + return [] + } + } +} + +struct QwenCloudWebFetchStrategy: ProviderFetchStrategy { + private static let log = CodexBarLog.logger("qwen-cloud") + + #if os(macOS) + static let browserOrder: BrowserCookieImportOrder = [.chrome] + #endif + + let id: String = "qwen-cloud.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.qwenCloud?.cookieSource != .off else { return false } + + if QwenCloudSettingsReader.cookieHeader(environment: context.env) != nil { + return true + } + + if let settings = context.settings?.qwenCloud, + settings.cookieSource == .manual + { + return CookieHeaderNormalizer.normalize(settings.manualCookieHeader) != nil + } + + #if os(macOS) + if let cached = CookieHeaderCache.load(provider: .qwencloud), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.qwenCloud?.cookieSource ?? .auto + let cookieHeaders = try Self.resolveCookieHeaders(context: context, allowCached: true) + do { + let usage = try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: cookieHeaders.apiCookieHeader, + dashboardCookieHeader: cookieHeaders.dashboardCookieHeader, + environment: context.env) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") + } catch let error as QwenCloudUsageError + where error.isCredentialFailure && cookieSource != .manual + { + #if os(macOS) + CookieHeaderCache.clear(provider: .qwencloud) + let refreshedHeaders = try Self.resolveCookieHeaders(context: context, allowCached: false) + let usage = try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: refreshedHeaders.apiCookieHeader, + dashboardCookieHeader: refreshedHeaders.dashboardCookieHeader, + environment: context.env) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "web") + #else + throw error + #endif + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { + try self.resolveCookieHeaders(context: context, allowCached: allowCached).apiCookieHeader + } + + static func resolveCookieHeaders( + context: ProviderFetchContext, + allowCached: Bool) throws -> QwenCloudCookieHeaders + { + if let settings = context.settings?.qwenCloud, + settings.cookieSource == .manual + { + guard let headers = QwenCloudCookieHeaders(singleHeader: settings.manualCookieHeader) else { + self.log.warning("Qwen Cloud manual cookie header is invalid") + throw QwenCloudSettingsError.invalidCookie + } + Self.log.info( + "Qwen Cloud using manual cookie header", + metadata: [ + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + ]) + return headers + } + + if let envCookie = QwenCloudSettingsReader.cookieHeader(environment: context.env), + let headers = QwenCloudCookieHeaders(singleHeader: envCookie) + { + Self.log.info( + "Qwen Cloud using environment cookie header", + metadata: [ + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + ]) + return headers + } + + #if os(macOS) + if allowCached, + let cached = CookieHeaderCache.load(provider: .qwencloud), + let headers = QwenCloudCookieHeaders(qwenCloudCachedHeader: cached.cookieHeader) + { + Self.log.info( + "Qwen Cloud using cached browser cookie header", + metadata: [ + "source": cached.sourceLabel, + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + ]) + return headers + } + + do { + var importLog: [String] = [] + let session = try QwenCloudCookieImport.importSession( + browserDetection: context.browserDetection, + logger: { importLog.append($0) }, + importOrder: Self.browserOrder) + let rawCookieNames = session.cookies.map(\.name).filter { !$0.isEmpty }.uniquedSorted() + guard let headers = QwenCloudCookieHeader.headers( + from: session.cookies, + environment: context.env) + else { + Self.log.warning( + "Qwen Cloud browser cookie header was empty", + metadata: [ + "source": session.sourceLabel, + "rawCookieNames": rawCookieNames.joined(separator: ","), + ]) + throw QwenCloudSettingsError.missingCookie( + details: "No Qwen Cloud browser cookies were available after import.") + } + CookieHeaderCache.store( + provider: .qwencloud, + cookieHeader: headers.cacheQwenCloudCookieHeader(), + sourceLabel: session.sourceLabel) + Self.log.info( + "Qwen Cloud imported browser cookies", + metadata: [ + "source": session.sourceLabel, + "rawCookieNames": rawCookieNames.joined(separator: ","), + "apiCookieNames": headers.apiCookieNames.joined(separator: ","), + "dashboardCookieNames": headers.dashboardCookieNames.joined(separator: ","), + "hasSecToken": headers.hasCookie(named: "sec_token") ? "1" : "0", + "importLogLines": "\(importLog.count)", + ]) + return headers + } catch { + Self.log.warning( + "Qwen Cloud cookie resolution failed", + metadata: ["error": error.localizedDescription]) + throw QwenCloudSettingsError.missingCookie(details: Self.missingCookieDetails(from: error)) + } + #else + throw QwenCloudSettingsError.missingCookie() + #endif + } + + private static func missingCookieDetails(from error: Error) -> String? { + if let error = error as? AliyunOneConsoleCookieImportError { + return error.details + } + if case let AlibabaCodingPlanSettingsError.missingCookie(details) = error { + return details + } + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? nil : message + } +} + +extension QwenCloudUsageError { + fileprivate var isCredentialFailure: Bool { + switch self { + case .loginRequired, .invalidCredentials: + true + case .apiError, .networkError, .parseFailed: + false + } + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudSettingsReader.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudSettingsReader.swift new file mode 100644 index 000000000..484a9d6cb --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudSettingsReader.swift @@ -0,0 +1,65 @@ +import Foundation + +public struct QwenCloudSettingsReader: Sendable { + public static let cookieHeaderKey = "QWEN_CLOUD_COOKIE" + public static let hostKey = "QWEN_CLOUD_HOST" + public static let quotaURLKey = "QWEN_CLOUD_QUOTA_URL" + + public static func cookieHeader( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.cookieHeaderKey]) + } + + public static func hostOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + guard let raw = self.cleaned(environment[self.hostKey]) else { return nil } + // Accept full https:// URLs and normalize bare hosts (e.g. "qwen-cloud.test" + // or "qwen-cloud.test:8443") to HTTPS, so dashboardURL / defaultQuotaURL + // always build valid URLs. Mirrors the shared endpoint-override rules used + // by the Alibaba token-plan host override. + return ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: raw)?.absoluteString + } + + public static func quotaURL( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? + { + guard let raw = self.cleaned(environment[self.quotaURLKey]) else { return nil } + if let url = URL(string: raw), let scheme = url.scheme { + return scheme.lowercased() == "https" ? url : nil + } + return URL(string: "https://\(raw)") + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} + +public enum QwenCloudSettingsError: LocalizedError, Sendable { + case missingCookie(details: String? = nil) + case invalidCookie + + public var errorDescription: String? { + switch self { + case let .missingCookie(details): + let base = "No Qwen Cloud session cookies found in browsers. " + + "Sign in to Qwen Cloud in Chrome, allow QuotaKit to access Chrome Safe Storage in Keychain Access, " + + "or paste a manual Cookie header." + guard let details, !details.isEmpty else { return base } + return "\(base) \(details)" + case .invalidCookie: + return "Qwen Cloud cookie header is invalid." + } + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudTokenPlanAPIClient.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudTokenPlanAPIClient.swift new file mode 100644 index 000000000..b3a34e3a4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudTokenPlanAPIClient.swift @@ -0,0 +1,169 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Qwen Cloud token-plan gateway client. +/// +/// Owns request construction (cornerstoneParam envelope, CSRF / cookie / +/// origin / referer headers, percent-encoded form body) for the three +/// `zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/*` endpoints the +/// dashboard exposes. Endpoint URLs and required authentication metadata +/// (sec_token) come from the caller; the client does not own the session +/// itself. +struct QwenCloudTokenPlanAPIClient: Sendable { + private static let log = CodexBarLog.logger("qwen-cloud") + struct Context: Sendable { + let secToken: String + let secTokenSource: String + let environment: [String: String] + let apiCookieHeader: String + let dashboardURL: URL + } + + let transport: any ProviderHTTPTransport + + func fetch( + api: String, + dataParameters: [String: String], + context: Context) async throws -> Data + { + let url = QwenCloudUsageFetcher.resolveAPIURL(api: api, environment: context.environment) + guard let host = url.host else { + throw QwenCloudUsageError.networkError("Invalid quota URL") + } + + let request = try self.makeRequest( + api: api, + dataParameters: dataParameters, + context: context, + url: url) + Self.log.info( + "Fetching Qwen Cloud token plan API", + metadata: [ + "api": api, + "apiHost": host, + "apiCookieNames": Self.cookieNames(from: context.apiCookieHeader) + .joined(separator: ","), + "hasCSRF": Self.cookieValue(named: "login_aliyunid_csrf", in: context.apiCookieHeader) == nil + ? "0" : "1", + "secTokenSource": context.secTokenSource, + ]) + + let (data, response) = try await self.transport.data(for: request) + return try Self.processResponse(data: data, response: response) + } + + func fetchOptional( + api: String, + dataParameters: [String: String], + context: Context) async -> Data? + { + do { + return try await self.fetch(api: api, dataParameters: dataParameters, context: context) + } catch { + Self.log.warning( + "Optional Qwen Cloud token plan metadata fetch failed", + metadata: ["api": api, "error": error.localizedDescription]) + return nil + } + } + + private func makeRequest( + api: String, + dataParameters: [String: String], + context: Context, + url: URL) throws -> URLRequest + { + var cornerstone: [String: Any] = [ + "feTraceId": UUID().uuidString.lowercased(), + "feURL": context.dashboardURL.absoluteString, + "protocol": "V2", + "console": "ONE_CONSOLE", + "productCode": "p_efm", + "domain": context.dashboardURL.host ?? "home.qwencloud.com", + "consoleSite": "QWENCLOUD", + "userNickName": "", + "userPrincipalName": "", + "xsp_lang": QwenCloudUsageFetcher.language, + ] + if let anonymousID = Self.cookieValue(named: "cna", in: context.apiCookieHeader) { + cornerstone["X-Anonymous-Id"] = anonymousID + } + var apiData = dataParameters as [String: Any] + apiData["cornerstoneParam"] = cornerstone + let params: [String: Any] = [ + "Api": api, + "V": "1.0", + "Data": apiData, + ] + let paramsData = try JSONSerialization.data(withJSONObject: params) + guard let paramsJSON = String(data: paramsData, encoding: .utf8) else { + throw QwenCloudUsageError.parseFailed("Could not encode request parameters") + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.timeoutInterval = 30 + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(context.apiCookieHeader, forHTTPHeaderField: "Cookie") + request.setValue( + QwenCloudUsageFetcher.gatewayHostBase(environment: context.environment), + forHTTPHeaderField: "Origin") + request.setValue(context.dashboardURL.absoluteString, forHTTPHeaderField: "Referer") + request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With") + if let csrf = Self.cookieValue(named: "login_aliyunid_csrf", in: context.apiCookieHeader) ?? + Self.cookieValue(named: "csrf", in: context.apiCookieHeader) + { + request.setValue(csrf, forHTTPHeaderField: "x-xsrf-token") + request.setValue(csrf, forHTTPHeaderField: "x-csrf-token") + } + + var body = URLComponents() + body.queryItems = [ + URLQueryItem(name: "product", value: QwenCloudUsageFetcher.consoleProduct), + URLQueryItem(name: "action", value: QwenCloudUsageFetcher.consoleAction), + URLQueryItem(name: "sec_token", value: context.secToken), + URLQueryItem(name: "region", value: QwenCloudUsageFetcher.region), + URLQueryItem(name: "language", value: QwenCloudUsageFetcher.language), + URLQueryItem(name: "params", value: paramsJSON), + ] + request.httpBody = Data((body.percentEncodedQuery ?? "").utf8) + return request + } + + private static func processResponse(data: Data, response: URLResponse) throws -> Data { + if let http = response as? HTTPURLResponse { + QwenCloudUsageFetcher.log.info( + "Qwen Cloud HTTP response", + metadata: [ + "status": "\(http.statusCode)", + "contentType": http.value(forHTTPHeaderField: "Content-Type") ?? "unknown", + "bodyBytes": "\(data.count)", + ]) + switch http.statusCode { + case 200: + return data + case 401, 403: + throw QwenCloudUsageError.invalidCredentials + default: + throw QwenCloudUsageError.apiError("HTTP \(http.statusCode)") + } + } + return data + } + + private static func cookieValue(named name: String, in header: String) -> String? { + CookieHeaderNormalizer.pairs(from: header) + .first { $0.name.caseInsensitiveCompare(name) == .orderedSame }? + .value + } + + private static func cookieNames(from header: String) -> [String] { + CookieHeaderNormalizer.pairs(from: header) + .map(\.name) + .filter { !$0.isEmpty } + .uniquedSorted() + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageFetcher.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageFetcher.swift new file mode 100644 index 000000000..fe33a5dbd --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageFetcher.swift @@ -0,0 +1,249 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum QwenCloudUsageError: LocalizedError, Equatable { + case loginRequired + case invalidCredentials + case apiError(String) + case networkError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .loginRequired: + "Qwen Cloud login required. Sign in to Qwen Cloud in your browser and try again." + case .invalidCredentials: + "Qwen Cloud rejected the stored session. Re-import or re-paste your Cookie header." + case let .apiError(message): + "Qwen Cloud usage API error: \(message)" + case let .networkError(message): + "Qwen Cloud network error: \(message)" + case let .parseFailed(message): + "Failed to parse Qwen Cloud usage: \(message)" + } + } +} + +/// Orchestrator for Qwen Cloud token-plan usage fetches. +/// +/// Owns: dashboard / API URL resolution, host-override normalization, and +/// the sec_token -> three API calls -> snapshot flow. Request construction +/// lives in `QwenCloudTokenPlanAPIClient`; response parsing lives in +/// `QwenCloudUsageParser`. +public struct QwenCloudUsageFetcher: Sendable { + public static let gatewayBaseURLString = "https://home.qwencloud.com" + static let dataGatewayBaseURLString = "https://cs-data.qwencloud.com" + /// Qwen Cloud international "token plan (individual)" product code. + static let productCode = "sfm_tokenplansolo_public_intl" + static let consoleProduct = "sfm_bailian" + static let consoleAction = "IntlBroadScopeAspnGateway" + static let usageAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" + static let subscriptionAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription" + static let quotaConfigAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config" + static let region = "ap-southeast-1" + static let language = "en-US" + + static let log = CodexBarLog.logger("qwen-cloud") + + public static var dashboardURL: URL { + self.dashboardURL(environment: ProcessInfo.processInfo.environment) + } + + public static func dashboardURL(environment: [String: String]) -> URL { + if let override = QwenCloudSettingsReader.hostOverride(environment: environment) { + let base = override.hasSuffix("/") ? String(override.dropLast()) : override + if let url = URL(string: "\(base)/billing/subscription/token-plan-individual") { + return url + } + } + return URL(string: "\(self.gatewayBaseURLString)/billing/subscription/token-plan-individual")! + } + + public static var defaultQuotaURL: URL { + self.defaultQuotaURL(environment: ProcessInfo.processInfo.environment) + } + + public static func defaultQuotaURL(environment: [String: String]) -> URL { + self.defaultAPIURL(api: self.usageAPI, environment: environment) + } + + public static func resolveQuotaURL(environment: [String: String]) -> URL { + self.resolveAPIURL(api: self.usageAPI, environment: environment) + } + + static func resolveAPIURL(api: String, environment: [String: String]) -> URL { + if let override = QwenCloudSettingsReader.quotaURL(environment: environment) { + return override + } + return self.defaultAPIURL(api: api, environment: environment) + } + + fileprivate static func defaultAPIURL(api: String, environment: [String: String]) -> URL { + let base = self.dataGatewayHostBase(environment: environment) + var components = URLComponents(string: "\(base)/data/api.json")! + components.queryItems = [ + URLQueryItem(name: "action", value: self.consoleAction), + URLQueryItem(name: "product", value: self.consoleProduct), + URLQueryItem(name: "api", value: api), + URLQueryItem(name: "_v", value: "undefined"), + ] + return components.url! + } + + static func gatewayHostBase(environment: [String: String]) -> String { + if let override = QwenCloudSettingsReader.hostOverride(environment: environment) { + return override.hasSuffix("/") ? String(override.dropLast()) : override + } + return self.gatewayBaseURLString + } + + fileprivate static func dataGatewayHostBase(environment: [String: String]) -> String { + if let override = QwenCloudSettingsReader.hostOverride(environment: environment) { + return override.hasSuffix("/") ? String(override.dropLast()) : override + } + return self.dataGatewayBaseURLString + } + + private static let secTokenResolver = OneConsoleSECTokenResolver( + configuration: OneConsoleSECTokenResolver.Configuration( + dashboardURL: { QwenCloudUsageFetcher.dashboardURL(environment: $0) }, + userInfoPath: "/tool/user/info.json", + isLoginPage: QwenCloudUsageFetcher.looksLikeLoginPage)) + + public static func fetchUsage( + apiCookieHeader rawCookieHeader: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + transport: (any ProviderHTTPTransport)? = nil) async throws -> QwenCloudUsageSnapshot + { + try await self.fetchUsage( + apiCookieHeader: rawCookieHeader, + dashboardCookieHeader: rawCookieHeader, + environment: environment, + now: now, + transport: transport) + } + + public static func fetchUsage( + apiCookieHeader: String, + dashboardCookieHeader: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + transport: (any ProviderHTTPTransport)? = nil) async throws -> QwenCloudUsageSnapshot + { + let normalizedAPI = CookieHeaderNormalizer.normalize(apiCookieHeader) + let normalizedDashboard = CookieHeaderNormalizer.normalize(dashboardCookieHeader) ?? normalizedAPI + guard let cookieHeader = normalizedAPI, !cookieHeader.isEmpty else { + throw QwenCloudSettingsError.invalidCookie + } + let dashboardHeader = normalizedDashboard ?? cookieHeader + + let usageURL = self.resolveQuotaURL(environment: environment) + guard let host = usageURL.host else { + throw QwenCloudUsageError.networkError("Invalid quota URL") + } + let dashboardURL = self.dashboardURL(environment: environment) + guard dashboardURL.host != nil else { + throw QwenCloudUsageError.networkError("Invalid dashboard URL") + } + let activeTransport: any ProviderHTTPTransport = transport ?? QwenCloudHTTPTransport( + routing: OneConsoleCookieRouting( + apiURL: usageURL, + dashboardURL: dashboardURL, + apiCookieHeader: cookieHeader, + dashboardCookieHeader: dashboardHeader)) + let resolved: OneConsoleSECTokenResolver.Resolved + do { + resolved = try await self.secTokenResolver.resolve( + cookieHeader: dashboardHeader, + environment: environment, + transport: activeTransport) + } catch OneConsoleSECTokenError.notFound { + throw QwenCloudUsageError.loginRequired + } catch { + throw QwenCloudUsageError.networkError(error.localizedDescription) + } + Self.log.info("Resolved Qwen Cloud sec_token from \(resolved.source.rawValue)") + + let client = QwenCloudTokenPlanAPIClient(transport: activeTransport) + let context = QwenCloudTokenPlanAPIClient.Context( + secToken: resolved.value, + secTokenSource: resolved.source.rawValue, + environment: environment, + apiCookieHeader: cookieHeader, + dashboardURL: dashboardURL) + + let usageData: Data + do { + usageData = try await client.fetch( + api: self.usageAPI, + dataParameters: [:], + context: context) + } catch let error as QwenCloudUsageError { + throw error + } catch { + throw QwenCloudUsageError.networkError(error.localizedDescription) + } + + let subscriptionData = await client.fetchOptional( + api: self.subscriptionAPI, + dataParameters: ["commodityCode": self.productCode], + context: context) + let quotaConfigData = await client.fetchOptional( + api: self.quotaConfigAPI, + dataParameters: [:], + context: context) + + do { + return try QwenCloudUsageParser.parse( + from: usageData, + subscriptionData: subscriptionData, + quotaConfigData: quotaConfigData, + now: now) + } catch let error as QwenCloudUsageError { + let contentType = Self.contentType(of: usageData) + let bodyPreview = String(data: usageData.prefix(200), encoding: .utf8)? + .replacingOccurrences(of: "\n", with: " ") ?? "<\(usageData.count) bytes>" + Self.log.warning( + "Qwen Cloud usage parse failed", + metadata: [ + "apiHost": host, + "status": "200", + "contentType": contentType ?? "unknown", + "bodyPreview": bodyPreview, + "error": "\(error)", + ]) + throw error + } + } + + private static func contentType(of data: Data) -> String? { + let head = data.prefix(64) + if head.contains(0x7B) || head.contains(0x5B) { + return "application/json" + } + let prefix = String(data: head, encoding: .utf8)?.lowercased() ?? "" + if prefix.hasPrefix(" Bool { + let lowered = html.lowercased() + return lowered.contains("passport.alibabacloud.com") || + lowered.contains("signin.aliyun.com") || + lowered.contains("account.alibabacloud.com/login") || + lowered.contains("login.qwencloud.com") || + (lowered.contains("login") && lowered.contains("password") && lowered.contains("sign in")) + } + + public static func parseUsageSnapshot( + from data: Data, + now: Date = Date()) throws -> QwenCloudUsageSnapshot + { + try QwenCloudUsageParser.parseUsageSnapshot(from: data, now: now) + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageParser.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageParser.swift new file mode 100644 index 000000000..e2709b6af --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageParser.swift @@ -0,0 +1,139 @@ +import Foundation + +/// Parses Qwen Cloud token-plan responses (current shape) and falls back to +/// the legacy Alibaba subscription-summary envelope when the current shape +/// does not contain usable usage data. +enum QwenCloudUsageParser { + static func parse( + from usageData: Data, + subscriptionData: Data?, + quotaConfigData: Data?, + now: Date) throws -> QwenCloudUsageSnapshot + { + if let snapshot = try self.parseCurrentTokenPlanUsage( + from: usageData, + subscriptionData: subscriptionData, + quotaConfigData: quotaConfigData, + now: now) + { + return snapshot + } + do { + let alibaba = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: usageData, now: now) + return QwenCloudUsageSnapshot(alibabaSnapshot: alibaba) + } catch let error as AlibabaTokenPlanUsageError { + throw Self.map(error) + } + } + + static func parseUsageSnapshot(from data: Data, now: Date = Date()) throws -> QwenCloudUsageSnapshot { + try self.parse(from: data, subscriptionData: nil, quotaConfigData: nil, now: now) + } + + private static func parseCurrentTokenPlanUsage( + from data: Data, + subscriptionData: Data?, + quotaConfigData: Data?, + now: Date) throws -> QwenCloudUsageSnapshot? + { + let raw: Any + do { + raw = try JSONSerialization.jsonObject(with: data) + } catch { + return nil + } + let expanded = OneConsoleJSON.expandEmbeddedJSON(raw) + guard let usage = OneConsoleJSON.findObject( + containingAnyOf: ["per5HourPercentage", "per1WeekPercentage"], + in: expanded) + else { + return nil + } + + let fiveHourPercent = OneConsoleJSON.percentagePoints( + fromRatio: OneConsoleJSON.number(usage["per5HourPercentage"])) + let weeklyPercent = OneConsoleJSON.percentagePoints( + fromRatio: OneConsoleJSON.number(usage["per1WeekPercentage"])) + guard fiveHourPercent != nil || weeklyPercent != nil else { return nil } + let planCode = subscriptionData.flatMap(self.planCode) + let planName = planCode.map(self.displayPlanName) + let quota = quotaConfigData.flatMap { + self.quotaTotals(from: $0, planCode: planCode) + } + + return QwenCloudUsageSnapshot( + planName: planName, + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + fiveHourUsedPercent: fiveHourPercent, + fiveHourTotalQuota: quota?.fiveHour, + fiveHourResetsAt: OneConsoleJSON.date(usage["per5HourResetTime"]), + weeklyUsedPercent: weeklyPercent, + weeklyTotalQuota: quota?.weekly, + weeklyResetsAt: OneConsoleJSON.date(usage["per1WeekResetTime"]), + updatedAt: now) + } + + private static func planCode(from data: Data) -> String? { + guard let raw = try? JSONSerialization.jsonObject(with: data) else { return nil } + let expanded = OneConsoleJSON.expandEmbeddedJSON(raw) + guard let plan = OneConsoleJSON.findObject( + containingAnyOf: ["specCode", "spec_code", "planName", "plan_name"], + in: expanded) + else { + return nil + } + for key in ["specCode", "spec_code", "planName", "plan_name"] { + if let value = plan[key] as? String { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if !normalized.isEmpty { + return normalized + } + } + } + return nil + } + + private static func displayPlanName(_ planCode: String) -> String { + switch planCode { + case "lite": "Lite" + case "standard": "Standard" + case "pro": "Pro" + case "max": "Max" + default: planCode + } + } + + private static func quotaTotals( + from data: Data, + planCode: String?) -> (fiveHour: Double?, weekly: Double?)? + { + guard let planCode, + let raw = try? JSONSerialization.jsonObject(with: data) + else { + return nil + } + let expanded = OneConsoleJSON.expandEmbeddedJSON(raw) + guard let value = OneConsoleJSON.findFirstValue(forKeys: [planCode], in: expanded), + let quota = value as? [String: Any] + else { + return nil + } + let fiveHour = OneConsoleJSON.number(quota["five_hour"] ?? quota["fiveHour"]) + let weekly = OneConsoleJSON.number(quota["weekly"]) + guard fiveHour != nil || weekly != nil else { return nil } + return (fiveHour, weekly) + } + + private static func map(_ error: AlibabaTokenPlanUsageError) -> QwenCloudUsageError { + switch error { + case .loginRequired: .loginRequired + case .invalidCredentials: .invalidCredentials + case let .apiError(message): .apiError(message) + case let .networkError(message): .networkError(message) + case let .parseFailed(message): .parseFailed(message) + } + } +} diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageSnapshot.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageSnapshot.swift new file mode 100644 index 000000000..67df73bd1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageSnapshot.swift @@ -0,0 +1,145 @@ +import Foundation + +public struct QwenCloudUsageSnapshot: Sendable { + public let planName: String? + public let usedQuota: Double? + public let totalQuota: Double? + public let remainingQuota: Double? + public let resetsAt: Date? + public let fiveHourUsedPercent: Double? + public let fiveHourTotalQuota: Double? + public let fiveHourResetsAt: Date? + public let weeklyUsedPercent: Double? + public let weeklyTotalQuota: Double? + public let weeklyResetsAt: Date? + public let updatedAt: Date + + public init( + planName: String?, + usedQuota: Double?, + totalQuota: Double?, + remainingQuota: Double?, + resetsAt: Date?, + fiveHourUsedPercent: Double? = nil, + fiveHourTotalQuota: Double? = nil, + fiveHourResetsAt: Date? = nil, + weeklyUsedPercent: Double? = nil, + weeklyTotalQuota: Double? = nil, + weeklyResetsAt: Date? = nil, + updatedAt: Date) + { + self.planName = planName + self.usedQuota = usedQuota + self.totalQuota = totalQuota + self.remainingQuota = remainingQuota + self.resetsAt = resetsAt + self.fiveHourUsedPercent = fiveHourUsedPercent + self.fiveHourTotalQuota = fiveHourTotalQuota + self.fiveHourResetsAt = fiveHourResetsAt + self.weeklyUsedPercent = weeklyUsedPercent + self.weeklyTotalQuota = weeklyTotalQuota + self.weeklyResetsAt = weeklyResetsAt + self.updatedAt = updatedAt + } +} + +extension QwenCloudUsageSnapshot { + init(alibabaSnapshot: AlibabaTokenPlanUsageSnapshot) { + self.init( + planName: alibabaSnapshot.planName, + usedQuota: alibabaSnapshot.usedQuota, + totalQuota: alibabaSnapshot.totalQuota, + remainingQuota: alibabaSnapshot.remainingQuota, + resetsAt: alibabaSnapshot.resetsAt, + updatedAt: alibabaSnapshot.updatedAt) + } + + public func toUsageSnapshot() -> UsageSnapshot { + let currentPrimary = self.fiveHourUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 5 * 60, + resetsAt: self.fiveHourResetsAt, + resetDescription: Self.quotaDetail(usedPercent: $0, total: self.fiveHourTotalQuota)) + } + let legacyPrimary = Self.usedPercent( + used: self.usedQuota, + total: self.totalQuota, + remaining: self.remainingQuota).map { + RateWindow( + usedPercent: $0, + windowMinutes: 30 * 24 * 60, + resetsAt: self.resetsAt, + resetDescription: Self.quotaDetail( + used: self.usedQuota, + total: self.totalQuota, + remaining: self.remainingQuota)) + } + let primary = currentPrimary ?? legacyPrimary + let secondary = self.weeklyUsedPercent.map { + RateWindow( + usedPercent: $0, + windowMinutes: 7 * 24 * 60, + resetsAt: self.weeklyResetsAt, + resetDescription: Self.quotaDetail(usedPercent: $0, total: self.weeklyTotalQuota)) + } + + let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (planName?.isEmpty ?? true) ? nil : planName + let identity = ProviderIdentitySnapshot( + providerID: .qwencloud, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + private static func usedPercent(used: Double?, total: Double?, remaining: Double?) -> Double? { + guard let total, total > 0 else { return nil } + let usedValue: Double? = if let used { + used + } else if let remaining { + total - remaining + } else { + nil + } + guard let usedValue else { return nil } + let normalizedUsed = max(0, min(usedValue, total)) + return normalizedUsed / total * 100 + } + + private static func quotaDetail(used: Double?, total: Double?, remaining: Double?) -> String? { + if let used, let total, total > 0 { + return "\(self.format(used)) / \(self.format(total)) credits used" + } + if let remaining, let total, total > 0 { + return "\(Self.format(remaining)) / \(Self.format(total)) credits left" + } + if let remaining { + return "\(Self.format(remaining)) credits left" + } + return nil + } + + private static func quotaDetail(usedPercent: Double, total: Double?) -> String? { + guard let total, total > 0 else { return nil } + let used = total * usedPercent / 100 + return "\(Self.format(used)) / \(Self.format(total)) credits used" + } + + private static func format(_ value: Double) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.usesGroupingSeparator = true + formatter.maximumFractionDigits = value.rounded() == value ? 0 : 2 + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleChromiumCookieFallbackImporter.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleChromiumCookieFallbackImporter.swift new file mode 100644 index 000000000..0b06d9d74 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleChromiumCookieFallbackImporter.swift @@ -0,0 +1,321 @@ +import Foundation + +#if os(macOS) +import CommonCrypto +import Security +import SQLite3 +import SweetCookieKit + +enum AliyunOneConsoleChromiumCookieFallbackImporter { + private struct ChromiumCookieRecord { + let domain: String + let name: String + let path: String + let value: String + let expires: Date? + let isSecure: Bool + } + + enum ImportError: LocalizedError { + case keyUnavailable(browser: Browser) + case keychainDenied(browser: Browser) + case sqliteFailed(label: String, details: String) + + var errorDescription: String? { + switch self { + case let .keyUnavailable(browser): + "\(browser.displayName) Safe Storage key not found." + case let .keychainDenied(browser): + "macOS Keychain denied access to \(browser.displayName) Safe Storage." + case let .sqliteFailed(label, details): + "\(label) cookie fallback failed: \(details)" + } + } + } + + static func importSession( + browser: Browser, + domains: [String], + isAuthenticatedSession: ([HTTPCookie]) -> Bool, + sessionLabel: String, + cookieClient: BrowserCookieClient = BrowserCookieClient(), + logger: ((String) -> Void)? = nil) throws -> AliyunOneConsoleCookieImporter.SessionInfo? + { + let stores = try cookieClient.codexBarStores(for: browser).filter { $0.databaseURL != nil } + guard !stores.isEmpty else { return nil } + + logger?("Trying \(browser.displayName) Chromium fallback") + let keys = try self.derivedKeys(for: browser) + for store in stores { + let cookies = try self.loadCookies(from: store, domains: domains, keys: keys) + guard !cookies.isEmpty else { continue } + if isAuthenticatedSession(cookies) { + logger?("Found \(cookies.count) \(sessionLabel) cookies via \(store.label) fallback") + return AliyunOneConsoleCookieImporter.SessionInfo(cookies: cookies, sourceLabel: store.label) + } + } + return nil + } + + private static func loadCookies( + from store: BrowserCookieStore, + domains: [String], + keys: [Data]) throws -> [HTTPCookie] + { + guard let sourceDB = store.databaseURL else { return [] } + let records = try self.readCookiesFromLockedDB( + sourceDB: sourceDB, + domains: domains, + keys: keys, + label: store.label) + return records.compactMap(self.makeCookie) + } + + private static func readCookiesFromLockedDB( + sourceDB: URL, + domains: [String], + keys: [Data], + label: String) throws -> [ChromiumCookieRecord] + { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("aliyun-oneconsole-chromium-cookies-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + let copiedDB = tempDir.appendingPathComponent("Cookies") + try FileManager.default.copyItem(at: sourceDB, to: copiedDB) + for suffix in ["-wal", "-shm"] { + let src = URL(fileURLWithPath: sourceDB.path + suffix) + if FileManager.default.fileExists(atPath: src.path) { + let dst = URL(fileURLWithPath: copiedDB.path + suffix) + try? FileManager.default.copyItem(at: src, to: dst) + } + } + defer { try? FileManager.default.removeItem(at: tempDir) } + + return try self.readCookies(fromDB: copiedDB.path, domains: domains, keys: keys, label: label) + } + + private static func readCookies( + fromDB path: String, + domains: [String], + keys: [Data], + label: String) throws -> [ChromiumCookieRecord] + { + var db: OpaquePointer? + guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + throw ImportError.sqliteFailed(label: label, details: String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_close(db) } + + let sql = "SELECT host_key, name, path, expires_utc, is_secure, value, encrypted_value FROM cookies" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + throw ImportError.sqliteFailed(label: label, details: String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(stmt) } + + var records: [ChromiumCookieRecord] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + guard let hostKey = self.readText(stmt, index: 0), self.matches(domain: hostKey, patterns: domains) else { + continue + } + guard let name = self.readText(stmt, index: 1), let path = self.readText(stmt, index: 2) else { + continue + } + + let value: String? = if let plain = self.readText(stmt, index: 5), !plain.isEmpty { + plain + } else if let encrypted = self.readBlob(stmt, index: 6) { + self.decrypt(encrypted, usingAnyOf: keys) + } else { + nil + } + guard let value, !value.isEmpty else { continue } + + records.append(ChromiumCookieRecord( + domain: AliyunOneConsoleCookieImporter.normalizeCookieDomain(hostKey), + name: name, + path: path, + value: value, + expires: self.chromiumExpiry(sqlite3_column_int64(stmt, 3)), + isSecure: sqlite3_column_int(stmt, 4) != 0)) + } + + return records.filter { record in + guard let expires = record.expires else { return true } + return expires >= Date() + } + } + + private static func derivedKeys(for browser: Browser) throws -> [Data] { + var keys: [Data] = [] + var sawDenied = false + + for label in browser.safeStorageLabels { + switch KeychainAccessPreflight.checkGenericPassword(service: label.service, account: label.account) { + case .interactionRequired: + sawDenied = true + continue + case .allowed, .notFound, .failure: + break + } + + if let password = self.safeStoragePassword(service: label.service, account: label.account) { + keys.append(self.deriveKey(from: password)) + } + } + + if !keys.isEmpty { + return keys + } + if sawDenied { + throw ImportError.keychainDenied(browser: browser) + } + throw ImportError.keyUnavailable(browser: browser) + } + + private static func safeStoragePassword(service: String, account: String) -> String? { + // The preflight classifies prompt-requiring items as .interactionRequired, but its + // .notFound (gate disabled) and .failure outcomes still reach this read. Honor the + // access gate and keep the read strictly non-interactive so it can never prompt. + guard !KeychainAccessGate.isDisabled else { return nil } + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + KeychainNoUIQuery.apply(to: &query) + + var result: AnyObject? + let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func deriveKey(from password: String) -> Data { + let salt = Data("saltysalt".utf8) + var key = Data(count: kCCKeySizeAES128) + let keyLength = key.count + _ = key.withUnsafeMutableBytes { keyBytes in + password.utf8CString.withUnsafeBytes { passBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passBytes.bindMemory(to: Int8.self).baseAddress, + passBytes.count - 1, + saltBytes.bindMemory(to: UInt8.self).baseAddress, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + keyBytes.bindMemory(to: UInt8.self).baseAddress, + keyLength) + } + } + } + return key + } + + private static func decrypt(_ encryptedValue: Data, usingAnyOf keys: [Data]) -> String? { + for key in keys { + if let value = self.decrypt(encryptedValue, key: key) { + return value + } + } + return nil + } + + private static func decrypt(_ encryptedValue: Data, key: Data) -> String? { + guard encryptedValue.count > 3 else { return nil } + let prefix = String(data: encryptedValue.prefix(3), encoding: .utf8) + guard prefix == "v10" else { return nil } + + let payload = Data(encryptedValue.dropFirst(3)) + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + var outLength = 0 + var out = Data(count: payload.count + kCCBlockSizeAES128) + let outCapacity = out.count + + let status = out.withUnsafeMutableBytes { outBytes in + payload.withUnsafeBytes { payloadBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + payloadBytes.baseAddress, + payload.count, + outBytes.baseAddress, + outCapacity, + &outLength) + } + } + } + } + + guard status == kCCSuccess else { return nil } + out.count = outLength + + if let value = String(data: out, encoding: .utf8), !value.isEmpty { + return value + } + if out.count > 32 { + let trimmed = out.dropFirst(32) + if let value = String(data: trimmed, encoding: .utf8), !value.isEmpty { + return value + } + } + return nil + } + + private static func makeCookie(from record: ChromiumCookieRecord) -> HTTPCookie? { + var properties: [HTTPCookiePropertyKey: Any] = [ + .domain: record.domain, + .path: record.path, + .name: record.name, + .value: record.value, + ] + if record.isSecure { + properties[.secure] = true + } + if let expires = record.expires { + properties[.expires] = expires + } + return HTTPCookie(properties: properties) + } + + private static func readText(_ stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let value = sqlite3_column_text(stmt, index) + else { + return nil + } + return String(cString: value) + } + + private static func readBlob(_ stmt: OpaquePointer?, index: Int32) -> Data? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let bytes = sqlite3_column_blob(stmt, index) + else { + return nil + } + return Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) + } + + private static func matches(domain: String, patterns: [String]) -> Bool { + AliyunOneConsoleCookieImporter.matchesCookieDomain(domain, patterns: patterns) + } + + private static func chromiumExpiry(_ expiresUTC: Int64) -> Date? { + guard expiresUTC > 0 else { return nil } + let seconds = (Double(expiresUTC) / 1_000_000.0) - 11_644_473_600.0 + guard seconds > 0 else { return nil } + return Date(timeIntervalSince1970: seconds) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieHeaders.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieHeaders.swift new file mode 100644 index 000000000..79449708b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieHeaders.swift @@ -0,0 +1,155 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Generic cookie-header pair for Aliyun OneConsole-based providers. +/// +/// Each provider keeps one HTTPCookie jar but may need to send a slightly +/// different Cookie header on dashboard GETs versus API POSTs (different +/// host scopes, different CSRF / sec_token presence). This struct holds +/// both headers together and handles the cached-header round-trip so +/// providers don't reinvent that plumbing. +public struct OneConsoleCookieHeaders: Sendable { + public let apiCookieHeader: String + public let dashboardCookieHeader: String + + public init(apiCookieHeader: String, dashboardCookieHeader: String) { + self.apiCookieHeader = apiCookieHeader + self.dashboardCookieHeader = dashboardCookieHeader + } + + public init?(singleHeader raw: String?) { + guard let normalized = CookieHeaderNormalizer.normalize(raw) else { return nil } + self.apiCookieHeader = normalized + self.dashboardCookieHeader = normalized + } + + public init?(cachedHeader raw: String?, cacheNamespace: String) { + var valuesByName: [String: String] = [:] + for pair in CookieHeaderNormalizer.pairs(from: raw ?? "") { + valuesByName[pair.name] = pair.value + } + if let encodedAPI = valuesByName["__codexbar_\(cacheNamespace)_api"], + let encodedDashboard = valuesByName["__codexbar_\(cacheNamespace)_dashboard"], + let apiHeader = Self.decodeCachedHeader(encodedAPI), + let dashboardHeader = Self.decodeCachedHeader(encodedDashboard), + let normalizedAPI = CookieHeaderNormalizer.normalize(apiHeader), + let normalizedDashboard = CookieHeaderNormalizer.normalize(dashboardHeader) + { + self.init(apiCookieHeader: normalizedAPI, dashboardCookieHeader: normalizedDashboard) + return + } + + self.init(singleHeader: raw) + } + + public func cacheCookieHeader(namespace: String) -> String { + [ + "__codexbar_\(namespace)_api=\(Self.encodeCachedHeader(self.apiCookieHeader))", + "__codexbar_\(namespace)_dashboard=\(Self.encodeCachedHeader(self.dashboardCookieHeader))", + ].joined(separator: "; ") + } + + public var apiCookieNames: [String] { + Self.cookieNames(from: self.apiCookieHeader) + } + + public var dashboardCookieNames: [String] { + Self.cookieNames(from: self.dashboardCookieHeader) + } + + public func hasCookie(named name: String) -> Bool { + Self.cookieNames(from: self.apiCookieHeader).contains(name) || + Self.cookieNames(from: self.dashboardCookieHeader).contains(name) + } + + private static func cookieNames(from header: String) -> [String] { + CookieHeaderNormalizer.pairs(from: header) + .map(\.name) + .filter { !$0.isEmpty } + .uniquedSorted() + } + + private static func encodeCachedHeader(_ header: String) -> String { + Data(header.utf8).base64EncodedString() + } + + private static func decodeCachedHeader(_ encoded: String) -> String? { + guard let data = Data(base64Encoded: encoded) else { return nil } + return String(data: data, encoding: .utf8) + } +} + +/// Builds a Cookie header from an [HTTPCookie] jar scoped to a target URL. +public enum OneConsoleCookieHeaderBuilder { + /// Returns a single Cookie header string that includes every cookie in + /// `cookies` whose domain and path match `targetURL`, choosing the most + /// specific cookie (longest path, longest domain, latest expiry) when + /// duplicates exist. + public static func header(from cookies: [HTTPCookie], targetURL: URL) -> String? { + var byName: [String: HTTPCookie] = [:] + for cookie in cookies { + guard !cookie.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + guard !cookie.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + if let expiry = cookie.expiresDate, expiry < Date() { continue } + guard Self.matchesRequestURL(cookie: cookie, url: targetURL) else { continue } + + if let existing = byName[cookie.name] { + if Self.cookieSortKey(for: cookie) >= Self.cookieSortKey(for: existing) { + byName[cookie.name] = cookie + } + } else { + byName[cookie.name] = cookie + } + } + + guard !byName.isEmpty else { return nil } + return byName.keys.sorted().compactMap { name in + guard let cookie = byName[name] else { return nil } + return "\(cookie.name)=\(cookie.value)" + }.joined(separator: "; ") + } + + private static func matchesRequestURL(cookie: HTTPCookie, url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + guard !normalizedDomain.isEmpty else { return false } + guard host == normalizedDomain || host.hasSuffix(".\(normalizedDomain)") else { return false } + + let cookiePath = cookie.path.isEmpty ? "/" : cookie.path + let requestPath = url.path.isEmpty ? "/" : url.path + if requestPath == cookiePath { + return true + } + guard requestPath.hasPrefix(cookiePath) else { return false } + guard cookiePath != "/" else { return true } + if cookiePath.hasSuffix("/") { + return true + } + guard + let boundaryIndex = requestPath.index( + requestPath.startIndex, + offsetBy: cookiePath.count, + limitedBy: requestPath.endIndex), + boundaryIndex < requestPath.endIndex + else { + return true + } + return requestPath[boundaryIndex] == "/" + } + + private static func cookieSortKey(for cookie: HTTPCookie) -> (Int, Int, Date) { + let pathLength = cookie.path.count + let normalizedDomain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + let domainLength = normalizedDomain.count + let expiry = cookie.expiresDate ?? .distantPast + return (pathLength, domainLength, expiry) + } +} + +extension [String] { + func uniquedSorted() -> [String] { + Array(Set(self)).sorted() + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieImporter.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieImporter.swift new file mode 100644 index 000000000..ac35e4d49 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/AliyunOneConsoleCookieImporter.swift @@ -0,0 +1,215 @@ +import Foundation + +public struct AliyunOneConsoleCookieImportError: LocalizedError, Sendable { + public let details: String? + + public init(details: String? = nil) { + self.details = details + } + + public var errorDescription: String? { + self.details + } +} + +#if os(macOS) +import SweetCookieKit + +/// Generic browser-cookie importer for Aliyun OneConsole-based providers. +/// +/// Each provider (Alibaba Coding Plan, Alibaba Token Plan, Qwen Cloud, ...) declares +/// its own cookie domains and "is this an authenticated session" predicate. Everything +/// else -- the per-browser iteration, Chromium fallback, Keychain preflight, and +/// diagnostic collection -- is shared here. +public enum AliyunOneConsoleCookieImporter { + private static let cookieClient = BrowserCookieClient() + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + var byName: [String: HTTPCookie] = [:] + byName.reserveCapacity(self.cookies.count) + + for cookie in self.cookies { + if let expiry = cookie.expiresDate, expiry < Date() { + continue + } + guard !cookie.value.isEmpty else { continue } + if let existing = byName[cookie.name] { + let existingExpiry = existing.expiresDate ?? .distantPast + let candidateExpiry = cookie.expiresDate ?? .distantPast + if candidateExpiry >= existingExpiry { + byName[cookie.name] = cookie + } + } else { + byName[cookie.name] = cookie + } + } + + return byName.keys.sorted().compactMap { name in + guard let cookie = byName[name] else { return nil } + return "\(cookie.name)=\(cookie.value)" + }.joined(separator: "; ") + } + } + + /// Generic browser-cookie import for Aliyun OneConsole-based providers. + /// The domain list, session-validation rules, and browser-import order are + /// provider-specific; everything else is shared. + public static func importSession( + browserDetection: BrowserDetection, + domains: [String], + isAuthenticatedSession: @escaping ([HTTPCookie]) -> Bool, + logPrefix: String, + sessionLabel: String, + importOrder: BrowserCookieImportOrder = Browser.defaultImportOrder, + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let log: (String) -> Void = { msg in logger?("[\(logPrefix)] \(msg)") } + var accessDeniedHints: [String] = [] + var failureDetails: [String] = [] + let installedBrowsers = self.cookieImportCandidates( + browserDetection: browserDetection, + importOrder: importOrder) + log("Cookie import candidates: \(installedBrowsers.map(\.displayName).joined(separator: ", "))") + + for browserSource in installedBrowsers { + do { + log("Checking \(browserSource.displayName)") + let query = BrowserCookieQuery(domains: domains) + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + if sources.isEmpty { + log("No matching cookie records in \(browserSource.displayName)") + if let fallbackSession = try Self.importChromiumFallbackSession( + browser: browserSource, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + sessionLabel: sessionLabel, + logger: log) + { + return fallbackSession + } + } + for source in sources where !source.records.isEmpty { + let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + if isAuthenticatedSession(httpCookies) { + log("Found \(httpCookies.count) \(sessionLabel) cookies in \(source.label)") + return SessionInfo(cookies: httpCookies, sourceLabel: source.label) + } + log( + "Skipping \(source.label): missing auth cookies" + + " (\(httpCookies.count) cookies)") + } + if let fallbackSession = try Self.importChromiumFallbackSession( + browser: browserSource, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + sessionLabel: sessionLabel, + logger: log) + { + return fallbackSession + } + } catch let error as BrowserCookieError { + BrowserCookieAccessGate.recordIfNeeded(error) + if let hint = error.accessDeniedHint { + accessDeniedHints.append(hint) + } + failureDetails.append("\(browserSource.displayName): \(error.localizedDescription)") + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } catch { + failureDetails.append("\(browserSource.displayName): \(error.localizedDescription)") + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + let details = (Array(Set(accessDeniedHints)).sorted() + Array(Set(failureDetails)).sorted()) + .joined(separator: " ") + throw AliyunOneConsoleCookieImportError(details: details.isEmpty ? nil : details) + } + + public static func hasSession( + browserDetection: BrowserDetection, + domains: [String], + isAuthenticatedSession: @escaping ([HTTPCookie]) -> Bool, + logPrefix: String, + sessionLabel: String, + importOrder: BrowserCookieImportOrder = Browser.defaultImportOrder, + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession( + browserDetection: browserDetection, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + logPrefix: logPrefix, + sessionLabel: sessionLabel, + importOrder: importOrder, + logger: logger) + return true + } catch { + return false + } + } + + private static func importChromiumFallbackSession( + browser: Browser, + domains: [String], + isAuthenticatedSession: @escaping ([HTTPCookie]) -> Bool, + sessionLabel: String, + logger: ((String) -> Void)? = nil) throws -> SessionInfo? + { + guard browser.usesChromiumProfileStore else { return nil } + guard let fallbackSession = try AliyunOneConsoleChromiumCookieFallbackImporter.importSession( + browser: browser, + domains: domains, + isAuthenticatedSession: isAuthenticatedSession, + sessionLabel: sessionLabel, + logger: logger) + else { + return nil + } + guard isAuthenticatedSession(fallbackSession.cookies) else { + logger?( + "Fallback cookies missing auth cookies" + + " (\(fallbackSession.cookies.count) cookies); ignoring" + + " \(fallbackSession.sourceLabel)") + return nil + } + logger?( + "Using fallback import (\(fallbackSession.cookies.count) \(sessionLabel) cookies)" + + " from \(fallbackSession.sourceLabel)") + return fallbackSession + } + + public static func cookieImportCandidates( + browserDetection: BrowserDetection, + importOrder: BrowserCookieImportOrder) -> [Browser] + { + importOrder.cookieImportCandidates(using: browserDetection) + } + + public static func matchesCookieDomain(_ domain: String, patterns: [String]) -> Bool { + let normalized = self.normalizeCookieDomain(domain) + return patterns.contains { pattern in + let normalizedPattern = self.normalizeCookieDomain(pattern) + return normalized == normalizedPattern || normalized.hasSuffix(".\(normalizedPattern)") + } + } + + public static func normalizeCookieDomain(_ domain: String) -> String { + let trimmed = domain.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.hasPrefix(".") ? String(trimmed.dropFirst()) : trimmed + return normalized.lowercased() + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleCookieRouting.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleCookieRouting.swift new file mode 100644 index 000000000..e3cd432e0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleCookieRouting.swift @@ -0,0 +1,123 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Redirect policy for Aliyun OneConsole sessions with distinct dashboard and API cookie scopes. +/// +/// Trusted dashboard/API redirects receive the matching cookie header. Cross-origin +/// redirects are followed only as credential-free GET/HEAD navigations; body-bearing +/// redirects are rejected so a 307/308 cannot forward `sec_token` or request parameters. +public struct OneConsoleCookieRouting: Sendable { + private static let redirectStatusCodes: Set = [301, 302, 303, 307, 308] + + public let apiURL: URL + public let dashboardURL: URL + public let apiCookieHeader: String + public let dashboardCookieHeader: String + + public init( + apiURL: URL, + dashboardURL: URL, + apiCookieHeader: String, + dashboardCookieHeader: String) + { + self.apiURL = apiURL + self.dashboardURL = dashboardURL + self.apiCookieHeader = apiCookieHeader + self.dashboardCookieHeader = dashboardCookieHeader + } + + public func redirectedRequest( + forRedirectFrom original: URLRequest, + response: HTTPURLResponse, + to redirected: URLRequest) -> URLRequest? + { + guard Self.redirectStatusCodes.contains(response.statusCode) else { return nil } + guard let originalURL = original.url, + originalURL.scheme?.caseInsensitiveCompare("https") == .orderedSame, + originalURL.user == nil, + originalURL.password == nil + else { + return nil + } + let sourceURL = response.url ?? originalURL + guard sourceURL.scheme?.caseInsensitiveCompare("https") == .orderedSame, + sourceURL.user == nil, + sourceURL.password == nil + else { + return nil + } + guard let redirectedURL = redirected.url, + redirectedURL.scheme?.caseInsensitiveCompare("https") == .orderedSame, + redirectedURL.host != nil, + redirectedURL.user == nil, + redirectedURL.password == nil + else { + return nil + } + + let isCrossOrigin = !Self.isSameOrigin(sourceURL, redirectedURL) + if isCrossOrigin, !Self.isCredentialFreeNavigation(redirected) { + return nil + } + + let acceptsHTML = original.value(forHTTPHeaderField: "Accept")?.contains("text/html") == true + var routed: URLRequest + if isCrossOrigin { + guard let sanitized = Self.sanitizedNavigation(from: redirected) else { return nil } + routed = sanitized + } else { + routed = redirected + } + if Self.isSameOrigin(redirectedURL, self.apiURL), + redirectedURL.path == self.apiURL.path, + !acceptsHTML + { + routed.setValue(self.apiCookieHeader, forHTTPHeaderField: "Cookie") + return routed + } + if Self.isSameOrigin(redirectedURL, self.dashboardURL) { + routed.setValue(self.dashboardCookieHeader, forHTTPHeaderField: "Cookie") + return routed + } + + return Self.sanitizedNavigation(from: routed) + } + + private static func isCredentialFreeNavigation(_ request: URLRequest) -> Bool { + let method = request.httpMethod?.uppercased() ?? "GET" + guard method == "GET" || method == "HEAD" else { return false } + return request.httpBody == nil && request.httpBodyStream == nil + } + + private static func sanitizedNavigation(from request: URLRequest) -> URLRequest? { + guard let url = request.url, self.isCredentialFreeNavigation(request) else { return nil } + var sanitized = URLRequest( + url: url, + cachePolicy: request.cachePolicy, + timeoutInterval: request.timeoutInterval) + sanitized.httpMethod = request.httpMethod?.uppercased() ?? "GET" + for header in ["Accept", "Accept-Language", "User-Agent"] { + if let value = request.value(forHTTPHeaderField: header) { + sanitized.setValue(value, forHTTPHeaderField: header) + } + } + return sanitized + } + + private static func isSameOrigin(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.scheme?.lowercased() == rhs.scheme?.lowercased() && + lhs.host?.lowercased() == rhs.host?.lowercased() && + self.normalizedPort(lhs) == self.normalizedPort(rhs) + } + + private static func normalizedPort(_ url: URL) -> Int? { + if let port = url.port { return port } + switch url.scheme?.lowercased() { + case "http": return 80 + case "https": return 443 + default: return nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleJSON.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleJSON.swift new file mode 100644 index 000000000..7a45602f1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleJSON.swift @@ -0,0 +1,223 @@ +import Foundation + +/// Recursive JSON traversal + scalar coercion helpers shared across Aliyun +/// OneConsole-based providers. The gateway responses nest subscription +/// metadata inside double-stringified JSON envelopes; these helpers walk +/// the tree without committing to a particular payload schema. +public enum OneConsoleJSON { + /// Recursively expands any string value that itself parses as JSON, + /// so consumers can treat `{"data": "{\"foo\": 1}"}` as if it were + /// `{"data": {"foo": 1}}`. Non-JSON strings and primitives pass through. + public static func expandEmbeddedJSON(_ value: Any) -> Any { + if let string = value as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else { return value } + guard let data = trimmed.data(using: .utf8), + let decoded = try? JSONSerialization.jsonObject(with: data) + else { + return value + } + return self.expandEmbeddedJSON(decoded) + } + if let dictionary = value as? [String: Any] { + return dictionary.mapValues(self.expandEmbeddedJSON) + } + if let array = value as? [Any] { + return array.map(self.expandEmbeddedJSON) + } + return value + } + + /// Returns the first dictionary anywhere in `value` that contains any + /// of the given keys. Useful for "find the quota object regardless of + /// where the API nested it" parsers. + public static func findObject( + containingAnyOf keys: Set, + in value: Any) -> [String: Any]? + { + if let dictionary = value as? [String: Any] { + if !keys.isDisjoint(with: dictionary.keys) { + return dictionary + } + for nested in dictionary.values { + if let found = self.findObject(containingAnyOf: keys, in: nested) { + return found + } + } + } else if let array = value as? [Any] { + for nested in array { + if let found = self.findObject(containingAnyOf: keys, in: nested) { + return found + } + } + } + return nil + } + + /// Returns the first value associated with any of `keys` anywhere in `value`. + public static func findFirstValue(forKeys keys: [String], in value: Any) -> Any? { + let lowercasedKeys = Set(keys.map { $0.lowercased() }) + if let dictionary = value as? [String: Any] { + for (key, nested) in dictionary where lowercasedKeys.contains(key.lowercased()) { + return nested + } + for nested in dictionary.values { + if let found = self.findFirstValue(forKeys: keys, in: nested) { + return found + } + } + } else if let array = value as? [Any] { + for nested in array { + if let found = self.findFirstValue(forKeys: keys, in: nested) { + return found + } + } + } + return nil + } + + /// Returns the first string value associated with any of `keys` in `value`. + public static func findFirstString(forKeys keys: [String], in value: Any) -> String? { + for key in keys { + if let found = self.findFirstConvertedValue( + forKey: key, + in: value, + transform: self.string) + { + return found + } + } + return nil + } + + /// Returns the first integer value associated with any of `keys` in `value`. + public static func findFirstInt(forKeys keys: [String], in value: Any) -> Int? { + for key in keys { + if let found = self.findFirstConvertedValue( + forKey: key, + in: value, + transform: self.int) + { + return found + } + } + return nil + } + + /// Returns the first array value associated with any of `keys` in `value`. + public static func findFirstArray(forKeys keys: [String], in value: Any) -> [Any]? { + for key in keys { + if let found = self.findFirstConvertedValue( + forKey: key, + in: value, + transform: { $0 as? [Any] }) + { + return found + } + } + return nil + } + + /// Searches one key at a time so caller priority is preserved across the full tree. + /// Invalid values do not mask a later valid value for the same key. + private static func findFirstConvertedValue( + forKey expectedKey: String, + in value: Any, + transform: (Any?) -> T?) -> T? + { + if let dictionary = value as? [String: Any] { + for (key, nested) in dictionary where key.caseInsensitiveCompare(expectedKey) == .orderedSame { + if let converted = transform(nested) { + return converted + } + } + for nested in dictionary.values { + if let found = self.findFirstConvertedValue( + forKey: expectedKey, + in: nested, + transform: transform) + { + return found + } + } + } else if let array = value as? [Any] { + for nested in array { + if let found = self.findFirstConvertedValue( + forKey: expectedKey, + in: nested, + transform: transform) + { + return found + } + } + } + return nil + } + + /// Coerces `value` to Double. Accepts NSNumber, Int, Double, and numeric + /// strings. Returns nil if the value is non-numeric. + public static func number(_ value: Any?) -> Double? { + guard let value else { return nil } + if let number = value as? NSNumber { + return number.doubleValue + } + if let string = value as? String { + return Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + /// Coerces `value` to Int. Accepts NSNumber, Int, Int64, Double (truncated), + /// and numeric strings. + public static func int(_ value: Any?) -> Int? { + guard let value else { return nil } + if let intValue = value as? Int { return intValue } + if let int64Value = value as? Int64 { return Int(int64Value) } + if let number = value as? NSNumber { return number.intValue } + if let doubleValue = value as? Double { return Int(doubleValue) } + if let string = value as? String { + return Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + /// Coerces `value` to String after trimming whitespace. Returns nil for + /// empty or non-string values. + public static func string(_ value: Any?) -> String? { + guard let value = value as? String else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// Converts a 0..1 ratio into a 0..100 percentage, clamping to that range. + /// Returns nil for non-finite ratios. + public static func percentagePoints(fromRatio ratio: Double?) -> Double? { + guard let ratio, ratio.isFinite else { return nil } + return min(max(ratio, 0), 1) * 100 + } + + /// Coerces `value` to a Date. Accepts: + /// - Positive numeric epoch in seconds or milliseconds (auto-detected via magnitude) + /// - ISO 8601 strings + /// - "yyyy-MM-dd", "yyyy-MM-dd HH:mm", and "yyyy-MM-dd HH:mm:ss" strings + public static func date(_ value: Any?) -> Date? { + if let number = self.number(value), number > 0 { + let seconds = number >= 1_000_000_000_000 ? number / 1000 : number + return Date(timeIntervalSince1970: seconds) + } + guard let string = value as? String else { return nil } + let formatter = ISO8601DateFormatter() + if let date = formatter.date(from: string) { + return date + } + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + for format in ["yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"] { + dateFormatter.dateFormat = format + if let date = dateFormatter.date(from: string) { + return date + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleSECTokenResolver.swift b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleSECTokenResolver.swift new file mode 100644 index 000000000..ad685272c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/OneConsoleSECTokenResolver.swift @@ -0,0 +1,233 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Resolves the Aliyun OneConsole `sec_token` for a given authenticated +/// session. The token is required for every gateway API call; it lives +/// either in the dashboard HTML (as an inline JS constant), on the user-info +/// endpoint, or as a cookie scoped to the console host. Providers configure +/// which sources to probe and how to recognize their login page. +public struct OneConsoleSECTokenResolver: Sendable { + public struct Configuration: Sendable { + /// Resolves the dashboard URL for the current environment (host override aware). + public let dashboardURL: @Sendable ([String: String]) -> URL + /// Path under the dashboard host to the user-info JSON endpoint (typically "tool/user/info.json"). + public let userInfoPath: String + /// Provider-specific login-page detector. OneConsole providers use + /// different passport hosts and login markup, so the provider owns this + /// classification instead of flattening alternatives into shared rules. + public let isLoginPage: @Sendable (String) -> Bool + /// Additional regex patterns the provider wants to try beyond the + /// built-in set (secToken, sec_token, csrfToken). + public let extraHTMLPatterns: [String] + + public init( + dashboardURL: @escaping @Sendable ([String: String]) -> URL, + userInfoPath: String, + isLoginPage: @escaping @Sendable (String) -> Bool, + extraHTMLPatterns: [String] = []) + { + self.dashboardURL = dashboardURL + self.userInfoPath = userInfoPath + self.isLoginPage = isLoginPage + self.extraHTMLPatterns = extraHTMLPatterns + } + } + + public enum Source: String, Sendable { + case dashboardHTML = "dashboard-html" + case cookie + case userInfo = "user-info" + } + + public struct Resolved: Sendable, Equatable { + public let value: String + public let source: Source + } + + public let configuration: Configuration + + public init(configuration: Configuration) { + self.configuration = configuration + } + + public func resolve( + cookieHeader: String, + environment: [String: String], + transport: any ProviderHTTPTransport) async throws -> Resolved + { + let dashboardURL = self.configuration.dashboardURL(environment) + + // 1. Try the dashboard HTML. The one-console injects sec_token as an + // inline JS constant; it's the freshest source. + var dashboardFailure: Error? + do { + let token = try await self.fetchFromDashboard( + cookieHeader: cookieHeader, + dashboardURL: dashboardURL, + transport: transport) + return Resolved(value: token, source: .dashboardHTML) + } catch OneConsoleSECTokenError.notFound { + // Continue through the token fallbacks. + } catch { + dashboardFailure = error + } + + // 2. Fall back to a sec_token cookie scoped to the console host. + if let cookieToken = Self.secTokenCookieValue(from: cookieHeader, host: dashboardURL.host) { + return Resolved(value: cookieToken, source: .cookie) + } + + // 3. Final fallback: the user-info JSON endpoint. + do { + let userInfoToken = try await self.fetchFromUserInfo( + cookieHeader: cookieHeader, + dashboardURL: dashboardURL, + transport: transport) + return Resolved(value: userInfoToken, source: .userInfo) + } catch OneConsoleSECTokenError.notFound { + // A retained dashboard failure is more accurate than missing credentials. + } catch { + throw error + } + + if let dashboardFailure { + throw dashboardFailure + } + + throw OneConsoleSECTokenError.notFound + } + + // MARK: - Dashboard HTML + + private func fetchFromDashboard( + cookieHeader: String, + dashboardURL: URL, + transport: any ProviderHTTPTransport) async throws -> String + { + var request = URLRequest(url: dashboardURL) + request.httpMethod = "GET" + request.timeoutInterval = 20 + request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + let (data, response) = try await transport.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + guard http.statusCode == 200 else { + if (500...599).contains(http.statusCode) { + throw URLError(.badServerResponse) + } + throw OneConsoleSECTokenError.notFound + } + guard let html = String(data: data, encoding: .utf8) else { + throw OneConsoleSECTokenError.notFound + } + if self.configuration.isLoginPage(html) { + throw OneConsoleSECTokenError.notFound + } + if let token = Self.extractToken(from: html, extraPatterns: self.configuration.extraHTMLPatterns) { + return token + } + throw OneConsoleSECTokenError.notFound + } + + private static func extractToken(from html: String, extraPatterns: [String]) -> String? { + let patterns = [ + #""secToken"\s*:\s*"([^"]+)""#, + #""sec_token"\s*:\s*"([^"]+)""#, + #"secToken['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + #"sec_token['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + #"csrfToken['"]?\s*[:=]\s*['"]([^'"]+)['"]"#, + ] + extraPatterns + for pattern in patterns { + if let token = Self.firstMatchGroup(pattern: pattern, in: html), !token.isEmpty { + return token + } + } + return nil + } + + private static func firstMatchGroup(pattern: String, in text: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return nil + } + let range = NSRange(text.startIndex.. 1, + let valueRange = Range(match.range(at: 1), in: text) + else { + return nil + } + let value = text[valueRange].trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : String(value) + } + + // MARK: - Cookie fallback + + private static func secTokenCookieValue(from cookieHeader: String, host: String?) -> String? { + var fallback: String? + for pair in CookieHeaderNormalizer.pairs(from: cookieHeader) { + guard pair.name.lowercased() == "sec_token", !pair.value.isEmpty else { continue } + if let host, pair.value.contains(host) { + return pair.value + } + fallback = pair.value + } + return fallback + } + + // MARK: - User-info fallback + + private func fetchFromUserInfo( + cookieHeader: String, + dashboardURL: URL, + transport: any ProviderHTTPTransport) async throws -> String + { + var components = URLComponents() + components.scheme = dashboardURL.scheme ?? "https" + components.host = dashboardURL.host ?? "home.qwencloud.com" + if let port = dashboardURL.port { + components.port = port + } + components.path = self.configuration.userInfoPath + guard let url = components.url else { + throw OneConsoleSECTokenError.notFound + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 20 + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + let (data, response) = try await transport.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw OneConsoleSECTokenError.notFound + } + guard let json = try? JSONSerialization.jsonObject(with: data) else { + throw OneConsoleSECTokenError.notFound + } + let expanded = OneConsoleJSON.expandEmbeddedJSON(json) + if let token = OneConsoleJSON.findFirstString( + forKeys: ["secToken", "sec_token", "csrfToken", "token"], + in: expanded) + { + return token + } + throw OneConsoleSECTokenError.notFound + } +} + +public enum OneConsoleSECTokenError: LocalizedError, Sendable { + case notFound + + public var errorDescription: String? { + switch self { + case .notFound: + "sec_token not found in dashboard, cookies, or user-info" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Shared/CurlCaptureParser.swift b/Sources/CodexBarCore/Providers/Shared/CurlCaptureParser.swift new file mode 100644 index 000000000..1f0910888 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Shared/CurlCaptureParser.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Shared parsing for "Copy as cURL" DevTools captures pasted by users into manual-auth provider +/// settings fields. Extracted from T3 Chat's original implementation (see #1830-era history) so +/// other web-cookie/bearer-token providers (e.g. ZoomMate) can reuse the exact same regex/shell +/// unescaping behavior instead of duplicating subtle parsing logic. +public enum CurlCaptureParser { + /// Extracts the request URL from the standard DevTools "Copy as cURL" shape, where the URL is + /// the first argument after `curl`. Returns `nil` for malformed captures or option-first forms. + public static func requestURL(from raw: String) -> URL? { + let pattern = + #"(?s)(?:^|\s)curl\s+"# + + #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|\"((?:\\.|[^\"])*)\"|([^\s\\]+))"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let range = NSRange(raw.startIndex.. [String] { + var fields: [String] = [] + let pattern = + #"(?s)(?:^|\s)(?:-H|--header)(?:\s+|=|(?=['"$]))"# + + #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|"((?:\\.|[^"])*)"|(\S+))"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return fields } + let range = NSRange(raw.startIndex.. String? { + for field in fields { + guard let colon = field.firstIndex(of: ":") else { continue } + let rawName = field[.. canonical HTTP header name). + public static func forwardedHeaders(from fields: [String], allowlist: [String: String]) -> [String: String] { + var headers: [String: String] = [:] + for field in fields { + guard let colon = field.firstIndex(of: ":") else { continue } + let rawName = field[.. String? { + guard match.numberOfRanges > index, + let range = Range(match.range(at: index), in: raw) + else { + return nil + } + return String(raw[range]) + } + + private static func unescapeShellSegment(_ raw: String, ansi: Bool) -> String { + var output = "" + var index = raw.startIndex + while index < raw.endIndex { + guard raw[index] == "\\" else { + output.append(raw[index]) + index = raw.index(after: index) + continue + } + let next = raw.index(after: index) + guard next < raw.endIndex else { return output } + switch raw[next] { + case "n" where ansi: + output.append("\n") + case "r" where ansi: + output.append("\r") + case "t" where ansi: + output.append("\t") + case "\n": + break + default: + output.append(raw[next]) + } + index = raw.index(after: next) + } + return output + } +} diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift index bddc960e4..53478f609 100644 --- a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift @@ -244,11 +244,11 @@ public struct T3ChatUsageFetcher: Sendable { static func requestContext(from raw: String?) -> RequestContext? { guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } - let headerFields = Self.headerFields(from: raw) + let headerFields = CurlCaptureParser.headerFields(from: raw) guard let cookieHeader = Self.cookieHeader(from: headerFields) ?? CookieHeaderNormalizer.normalize(raw) else { return nil } - let headers = Self.forwardedHeaders(from: headerFields) + let headers = CurlCaptureParser.forwardedHeaders(from: headerFields, allowlist: self.forwardedManualHeaders) return RequestContext(cookieHeader: cookieHeader, headers: headers) } @@ -268,88 +268,9 @@ public struct T3ChatUsageFetcher: Sendable { request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") } - private static func forwardedHeaders(from fields: [String]) -> [String: String] { - var headers: [String: String] = [:] - for field in fields { - guard let colon = field.firstIndex(of: ":") else { continue } - let rawName = field[.. String? { - for field in fields { - guard let colon = field.firstIndex(of: ":") else { continue } - let rawName = field[.. [String] { - var fields: [String] = [] - let pattern = - #"(?s)(?:^|\s)(?:-H|--header)(?:\s+|=|(?=['"$]))"# + - #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|"((?:\\.|[^"])*)"|(\S+))"# - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return fields } - let range = NSRange(raw.startIndex.. String? { - guard match.numberOfRanges > index, - let range = Range(match.range(at: index), in: raw) - else { - return nil - } - return String(raw[range]) - } - - private static func unescapeShellSegment(_ raw: String, ansi: Bool) -> String { - var output = "" - var index = raw.startIndex - while index < raw.endIndex { - guard raw[index] == "\\" else { - output.append(raw[index]) - index = raw.index(after: index) - continue - } - let next = raw.index(after: index) - guard next < raw.endIndex else { return output } - switch raw[next] { - case "n" where ansi: - output.append("\n") - case "r" where ansi: - output.append("\r") - case "t" where ansi: - output.append("\t") - case "\n": - break - default: - output.append(raw[next]) - } - index = raw.index(after: next) - } - return output + guard let raw = CurlCaptureParser.headerValue(named: "Cookie", in: fields) else { return nil } + return CookieHeaderNormalizer.normalize(raw) } private static func customerDataURL() throws -> URL { diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift index 73f863ec8..09a87594d 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift @@ -259,16 +259,15 @@ extension WindsurfCachedPlanInfo { resetDescription: "\(clampedUsed) / \(total) \(unit)") } - private static func formatResetDescription(_ date: Date?) -> String? { + static func formatResetDescription(_ date: Date?, now: Date = Date()) -> String? { guard let date else { return nil } - let now = Date() let interval = date.timeIntervalSince(now) guard interval > 0 else { return "Expired" } let hours = Int(interval / 3600) let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) - if hours > 24 { + if hours >= 24 { let days = hours / 24 let remainingHours = hours % 24 return "Resets in \(days)d \(remainingHours)h" diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift index 27bc8579f..b2c3ddf8b 100644 --- a/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift @@ -79,16 +79,15 @@ extension WindsurfGetPlanStatusResponse { identity: identity) } - private static func formatResetDescription(_ date: Date?) -> String? { + static func formatResetDescription(_ date: Date?, now: Date = Date()) -> String? { guard let date else { return nil } - let now = Date() let interval = date.timeIntervalSince(now) guard interval > 0 else { return "Expired" } let hours = Int(interval / 3600) let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) - if hours > 24 { + if hours >= 24 { let days = hours / 24 let remainingHours = hours % 24 return "Resets in \(days)d \(remainingHours)h" diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 344327196..39380095b 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -84,7 +84,9 @@ extension ZaiLimitEntry { if let computed = self.computedUsedPercent { return computed } - return self.percentage + // The raw API percentage can fall outside 0...100 (z.ai omits/misreports quota fields); + // clamp it like computedUsedPercent and every sibling provider instead of surfacing it raw. + return min(100, max(0, self.percentage)) } public var windowMinutes: Int? { diff --git a/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift b/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift index 8b6f18338..ab25b7260 100644 --- a/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Zed/ZedStatusProbe.swift @@ -527,15 +527,14 @@ extension ZedUsageSnapshot { return max(0, min(100, elapsed / total * 100)) } - private static func formatResetDescription(_ date: Date) -> String? { - let now = Date() + static func formatResetDescription(_ date: Date, now: Date = Date()) -> String? { let interval = date.timeIntervalSince(now) guard interval > 0 else { return "Cycle ended" } let hours = Int(interval / 3600) let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) - if hours > 24 { + if hours >= 24 { let days = hours / 24 let remainingHours = hours % 24 return "Cycle ends in \(days)d \(remainingHours)h" diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift new file mode 100644 index 000000000..26914db78 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateBearerTokenCache.swift @@ -0,0 +1,64 @@ +import Foundation +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +/// Process-lifetime, in-memory cache of freshly-minted ZoomMate bearer JWTs. +/// +/// The `.auto` cookie-mint path exchanges long-lived browser session cookies for a short-lived +/// (~hourly) bearer JWT on demand. Without a cache that mint happens on *every* refresh; this cache +/// lets a still-valid token be reused across refreshes instead. +/// +/// Safety properties (why reuse can't serve a bad token): +/// - Entries are keyed by a non-reversible SHA-256 of the originating host-scoped cookie headers, so distinct +/// browser sessions / accounts never collide and the raw cookies are never stored as a key. +/// - A token is cached *only* when its JWT carries a decodable `exp` claim, and is served only +/// while `now < exp - refreshSkew`. A token whose expiry cannot be determined is never cached +/// (the caller mints fresh), so the cache can never hand back a token past its own expiry. +/// - Nothing is persisted — the cache is empty on every launch. +/// +/// A revoked-before-expiry session is handled by the caller: a `401/403` from a downstream request +/// evicts the entry (see `ZoomMateWebFetchStrategy`) so the next refresh mints fresh. +actor ZoomMateBearerTokenCache { + static let shared = ZoomMateBearerTokenCache() + + /// Refresh this many seconds before the JWT's own `exp`, so an in-flight request never rides a + /// token that expires mid-flight. + static let refreshSkew: TimeInterval = 60 + + struct Entry: Sendable { + let token: String + let accountEmail: String? + let expiry: Date + } + + private var entries: [String: Entry] = [:] + + /// Non-reversible cache key for a cookie session. SHA-256 hex of its canonical host map. + static func key(forCookieHeaders cookieHeaders: ZoomMateCookieHeaders) -> String { + let canonical = cookieHeaders.encodedForStorage() ?? "" + let digest = SHA256.hash(data: Data(canonical.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + /// Returns the cached entry for `key` when it is still comfortably in-date, evicting and + /// returning `nil` once it enters the `refreshSkew` window (or has passed `exp`). + func validEntry(forKey key: String, now: Date) -> Entry? { + guard let entry = self.entries[key] else { return nil } + guard entry.expiry.addingTimeInterval(-Self.refreshSkew) > now else { + self.entries[key] = nil + return nil + } + return entry + } + + func store(_ entry: Entry, forKey key: String) { + self.entries[key] = entry + } + + func invalidate(forKey key: String) { + self.entries[key] = nil + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift new file mode 100644 index 000000000..37dc42be1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift @@ -0,0 +1,138 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +/// Cookie headers narrowed to ZoomMate's fixed request hosts. Keeping the destination in the +/// credential value makes it impossible for host failover to reuse a leaf-host cookie on its +/// sibling host. +public struct ZoomMateCookieHeaders: Codable, Equatable, Sendable { + static let allowedHosts = ["ai.zoom.us", "zoommate.zoom.us"] + + private let headersByHost: [String: String] + + public init(headersByHost: [String: String]) { + self.headersByHost = Dictionary(uniqueKeysWithValues: Self.allowedHosts.compactMap { host in + guard let header = headersByHost[host]?.trimmingCharacters(in: .whitespacesAndNewlines), + !header.isEmpty + else { + return nil + } + return (host, header) + }) + } + + public func header(forHost host: String) -> String? { + self.headersByHost[host.lowercased()] + } + + public var isEmpty: Bool { + self.headersByHost.isEmpty + } + + func encodedForStorage() -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + guard let data = try? encoder.encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } + + static func decodeFromStorage(_ value: String) -> Self? { + guard let data = value.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(Self.self, from: data) + } +} + +#if os(macOS) +private let zoomMateCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.zoommate]?.browserCookieOrder ?? Browser.defaultImportOrder + +/// Imports ZoomMate's browser session cookies (not the bearer JWT itself — see +/// `ZoomMateUsageFetcher.mintBearerToken`, which exchanges these cookies for a fresh JWT via +/// ZoomMate's own cookie-to-token bootstrap endpoint). Modeled on `T3ChatCookieImporter`. +public enum ZoomMateCookieImporter { + private static let cookieClient = BrowserCookieClient() + /// Includes the parent "zoom.us" domain — ZoomMate's SSO session cookies (`_zm_*`, + /// `cf_clearance`, etc.) are scoped to the shared parent domain, not the leaf subdomains, and + /// domain matching here is substring-based (`.contains`), so this one pattern also matches the + /// leaf domains below; both are kept for clarity. The over-broad `.contains("zoom.us")` read is + /// then narrowed at send time by `isSendable(toSessionHosts:)`. + private static let cookieDomains = ["zoommate.zoom.us", "ai.zoom.us", "zoom.us"] + + public struct SessionInfo: Sendable { + public let cookieHeaders: ZoomMateCookieHeaders + public let sourceLabel: String + + public init(cookieHeaders: ZoomMateCookieHeaders, sourceLabel: String) { + self.cookieHeaders = cookieHeaders + self.sourceLabel = sourceLabel + } + } + + public static func importSession( + browserDetection: BrowserDetection, + logger: (@Sendable (String) -> Void)? = nil) throws -> SessionInfo + { + try self.importSessions(browserDetection: browserDetection, logger: logger)[0] + } + + public static func importSessions( + browserDetection: BrowserDetection, + logger: (@Sendable (String) -> Void)? = nil) throws -> [SessionInfo] + { + let log: @Sendable (String) -> Void = { msg in logger?("[zoommate-cookie] \(msg)") } + let installed = zoomMateCookieImportOrder.cookieImportCandidates(using: browserDetection) + var sessions: [SessionInfo] = [] + + for browserSource in installed { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + for source in sources where !source.records.isEmpty { + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + let cookieHeaders = Self.cookieHeaders(from: cookies) + guard !cookieHeaders.isEmpty else { continue } + log("\(source.label): found host-scoped cookie headers") + sessions.append(SessionInfo(cookieHeaders: cookieHeaders, sourceLabel: source.label)) + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + guard !sessions.isEmpty else { throw ZoomMateUsageError.noSession } + return sessions + } + + /// Whether a browser would attach a cookie scoped to `cookieDomain` to a request to `host`, per + /// RFC 6265 domain-matching: a host-only cookie matches its exact host; a + /// domain cookie (stored with a leading dot) matches that host and all of its subdomains. This + /// keeps parent `.zoom.us` SSO cookies while preventing an `ai.zoom.us` host-only cookie from + /// reaching `zoommate.zoom.us` (and vice versa). + static func isSendable(cookieDomain: String, toHost host: String) -> Bool { + let normalizedDomain = cookieDomain.lowercased() + let normalizedHost = host.lowercased() + guard ZoomMateCookieHeaders.allowedHosts.contains(normalizedHost), !normalizedDomain.isEmpty else { + return false + } + guard normalizedDomain.hasPrefix(".") else { return normalizedHost == normalizedDomain } + let bareDomain = String(normalizedDomain.dropFirst()) + guard !bareDomain.isEmpty else { return false } + return normalizedHost == bareDomain || normalizedHost.hasSuffix("." + bareDomain) + } + + static func cookieHeaders(from cookies: [HTTPCookie]) -> ZoomMateCookieHeaders { + let pairs: [(String, String)] = ZoomMateCookieHeaders.allowedHosts.compactMap { host in + let sendable = cookies.filter { Self.isSendable(cookieDomain: $0.domain, toHost: host) } + guard !sendable.isEmpty else { return nil } + let header = sendable.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + return (host, header) + } + return ZoomMateCookieHeaders(headersByHost: Dictionary(uniqueKeysWithValues: pairs)) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift new file mode 100644 index 000000000..5ee73b0a0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift @@ -0,0 +1,260 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// One raw ledger row from `GET .../credits/history` (design.md D3). `time` is ISO8601-shaped; +/// `cost` is the credits consumed by that session/task run. +public struct ZoomMateCreditHistoryRecord: Decodable, Sendable { + public let sessionID: String? + public let title: String? + public let cost: Double? + public let time: String? + public let isRunning: Bool? + public let isDeleted: Bool? + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case title + case cost + case time + case isRunning = "is_running" + case isDeleted = "is_deleted" + } + + public init( + sessionID: String?, + title: String?, + cost: Double?, + time: String?, + isRunning: Bool?, + isDeleted: Bool?) + { + self.sessionID = sessionID + self.title = title + self.cost = cost + self.time = time + self.isRunning = isRunning + self.isDeleted = isDeleted + } +} + +/// Aggregated result of fetching `credits/history` across as many pages as needed to cover the +/// requested window. Kept separate from the daily-bucketed breakdown so the same raw records can +/// be re-aggregated without refetching. +/// +/// `creditStatus` carries the `credits/status` snapshot the history fetch was paired with, so +/// the menu layer can compute the pacing verdict (`ZoomMateUsageSnapshot.pacingVerdict`) directly +/// from this one attached object instead of needing a second field on `UsageSnapshot` — deferring +/// pace computation to render time also means it always reflects "now," not the last fetch time. +public struct ZoomMateCreditsHistorySnapshot: Sendable { + public let records: [ZoomMateCreditHistoryRecord] + public let creditStatus: ZoomMateCreditStatus? + public let updatedAt: Date + + public init( + records: [ZoomMateCreditHistoryRecord], + creditStatus: ZoomMateCreditStatus? = nil, + updatedAt: Date) + { + self.records = records + self.creditStatus = creditStatus + self.updatedAt = updatedAt + } + + /// Pacing verdict computed from the paired `credits/status` snapshot, if one was attached at + /// fetch time. `nil` when no `creditStatus` is available (e.g. it wasn't passed to `fetch`) + /// or when the account is unlimited / missing cycle dates — see + /// `ZoomMateCreditStatus.pacingVerdict`. + public func pacingVerdict(now: Date = Date()) -> UsagePace? { + self.creditStatus?.pacingVerdict(now: now) + } +} + +/// Fetches and paginates `GET https://ai.zoom.us/ai-computer/api/v1/credits/history` (design.md +/// D3). Reuses the same minted-bearer `RequestContext` as `credits/status` — no separate auth +/// mechanism. `app_id` is confirmed not a scoping filter (D3/R2), so a fixed placeholder matching +/// ZoomMate's own web UI (`demo_app`) is sent on every request. +public struct ZoomMateCreditsHistoryFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.zoommate) + private static let historyPath = "/ai-computer/api/v1/credits/history" + private static let refererURL = URL(string: "https://zoommate.zoom.us")! + private static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + + /// Confirmed cheap for real accounts (design.md D3/R3): 30 days of history is at most a + /// couple of pages at this size, well below any practical rate-limit concern. A larger + /// `limit` than the web UI's `10` reduces round-trips without meaningfully increasing + /// payload size (records are small). + public static let defaultPageLimit = 50 + /// Hard ceiling on pagination requests per fetch, independent of the account's actual + /// history size — guards against an unexpectedly large or misbehaving account/response + /// (e.g. a `total` that never gets satisfied) turning into an unbounded fetch loop. + public static let maxPages = 20 + + public init() {} + + /// Fetches every record whose `time` falls within `[startTime, endTime]`, paginating with + /// `limit`/`page` until the endpoint's flat `total` count is satisfied (no other pagination + /// metadata exists — design.md D3). + public static func fetch( + context: ZoomMateUsageFetcher.RequestContext, + startTime: Date, + endTime: Date, + creditStatus: ZoomMateCreditStatus? = nil, + limit: Int = ZoomMateCreditsHistoryFetcher.defaultPageLimit, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateCreditsHistorySnapshot + { + // The whole pagination loop fails over as a unit so all pages of one snapshot come from + // the same host. + try await ZoomMateUsageFetcher.withAPIHostFailover( + hosts: ZoomMateUsageFetcher.hosts(preferred: context.preferredHost)) + { host in + var allRecords: [ZoomMateCreditHistoryRecord] = [] + var page = 0 + var total = Int.max + + while page * limit < total, page < self.maxPages { + let request = PageRequest( + host: host, + context: context, + startTime: startTime, + endTime: endTime, + limit: limit, + page: page, + timeout: timeout, + transport: transport) + let envelope = try await self.fetchPage(request) + guard let data = envelope.data else { + throw ZoomMateUsageError.parseFailed("Missing data object in credits/history response.") + } + let pageRecords = data.records ?? [] + allRecords.append(contentsOf: pageRecords) + total = data.total ?? allRecords.count + if pageRecords.isEmpty { + // Defensive: stop if the server ever returns an empty page before `total` is + // reached, rather than looping until `maxPages`. + break + } + // Defensive date-boundary stop (design.md D2): `total` reflects the account's entire + // history, not just the requested window, so a server-side filtering quirk could + // otherwise cause extra pagination past what the window actually needs. If every + // record on this page is already older than the requested `startTime` (rows are + // sorted `time desc`, so an entirely-stale page means all subsequent pages are stale + // too), stop here rather than trusting `total`/`maxPages` to eventually end the loop. + let allOlderThanWindow = pageRecords.allSatisfy { record in + guard let time = record.time, let parsed = Self.parseRecordTime(time) else { return false } + return parsed < startTime + } + if allOlderThanWindow { + break + } + page += 1 + } + + return ZoomMateCreditsHistorySnapshot(records: allRecords, creditStatus: creditStatus, updatedAt: now) + } + } + + private static func fetchPage(_ pageRequest: PageRequest) async throws -> HistoryEnvelope { + var components = URLComponents(string: "https://\(pageRequest.host)\(self.historyPath)")! + components.queryItems = [ + URLQueryItem(name: "app_id", value: "demo_app"), + URLQueryItem(name: "limit", value: String(pageRequest.limit)), + URLQueryItem(name: "page", value: String(pageRequest.page)), + URLQueryItem(name: "sort_by", value: "time"), + URLQueryItem(name: "sort_order", value: "desc"), + URLQueryItem(name: "start_time", value: Self.iso8601String(pageRequest.startTime)), + URLQueryItem(name: "end_time", value: Self.iso8601String(pageRequest.endTime)), + ] + guard let url = components.url else { + throw ZoomMateUsageError.apiError("Failed to build credits/history URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = pageRequest.timeout + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") + request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") + request.setValue("same-site", forHTTPHeaderField: "Sec-Fetch-Site") + for (name, value) in pageRequest.context.headers { + request.setValue(value, forHTTPHeaderField: name) + } + request.setValue( + pageRequest.context.cookieHeaders.header(forHost: pageRequest.host), + forHTTPHeaderField: "Cookie") + request.setValue(pageRequest.context.authorization, forHTTPHeaderField: "Authorization") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + + let response = try await pageRequest.transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + Self.log.error("ZoomMate credits/history returned \(response.statusCode)") + if response.statusCode == 401 || response.statusCode == 403 { + throw ZoomMateUsageError.invalidCredentials + } + throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + return try JSONDecoder().decode(HistoryEnvelope.self, from: data) + } catch { + Self.log.error("ZoomMate credits/history parse failed") + throw ZoomMateUsageError.parseFailed(error.localizedDescription) + } + } + + private static func iso8601String(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + } + + /// Parses a record's `time` field for the pagination date-boundary check. Tries with and + /// without fractional seconds, matching the range of ISO8601 shapes the API may return. + private static func parseRecordTime(_ text: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: text) { + return date + } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: text) + } + + private struct PageRequest { + let host: String + let context: ZoomMateUsageFetcher.RequestContext + let startTime: Date + let endTime: Date + let limit: Int + let page: Int + let timeout: TimeInterval + let transport: any ProviderHTTPTransport + } + + private struct HistoryEnvelope: Decodable { + struct DataBox: Decodable { + let records: [ZoomMateCreditHistoryRecord]? + let total: Int? + } + + let data: DataBox? + let statusCode: Int? + let errorMessage: String? + + private enum CodingKeys: String, CodingKey { + case data + case statusCode = "status_code" + case errorMessage = "error_message" + } + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift new file mode 100644 index 000000000..902076ad5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift @@ -0,0 +1,248 @@ +import Foundation + +private func zoomMateDate(fromMilliseconds raw: Int64?) -> Date? { + guard let raw, raw > 0 else { return nil } + return Date(timeIntervalSince1970: Double(raw) / 1000) +} + +public enum ZoomMateUsageError: LocalizedError, Sendable { + case noCapture + case noSession + case invalidCredentials + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .noCapture: + "Paste a cURL capture of the HTTPS ZoomMate credits/status request " + + "(from ai.zoom.us or zoommate.zoom.us)." + case .noSession: + "No ZoomMate session is cached and no session cookies were imported from Chrome. " + + "Sign in to zoommate.zoom.us in Chrome and refresh from QuotaKit, or paste a cURL capture." + case .invalidCredentials: + "ZoomMate rejected the current credentials. Sign in again in Chrome or paste a fresh cURL capture." + case let .apiError(message): + "ZoomMate API error: \(message)" + case let .parseFailed(message): + "Could not parse ZoomMate usage: \(message)" + } + } +} + +/// Decoded shape of `data.credit_status` from +/// `GET https://ai.zoom.us/ai-computer/api/v1/credits/status`. Dates are epoch milliseconds. +public struct ZoomMateCreditStatus: Decodable, Sendable { + public let budgetCap: Double? + public let usedCredit: Double? + public let remainingCredit: Double? + public let overageCredit: Double? + public let allowOverage: Bool? + public let cycleStartDate: Int64? + public let cycleEndDate: Int64? + public let isQuotaAvailable: Bool? + public let isUnlimited: Bool? + + private enum CodingKeys: String, CodingKey { + case budgetCap = "budget_cap" + case usedCredit = "used_credit" + case remainingCredit = "remaining_credit" + case overageCredit = "overage_credit" + case allowOverage = "allow_overage" + case cycleStartDate = "cycle_start_date" + case cycleEndDate = "cycle_end_date" + case isQuotaAvailable = "is_quota_available" + case isUnlimited = "is_unlimited" + } + + public init( + budgetCap: Double?, + usedCredit: Double?, + remainingCredit: Double?, + overageCredit: Double?, + allowOverage: Bool?, + cycleStartDate: Int64?, + cycleEndDate: Int64?, + isQuotaAvailable: Bool?, + isUnlimited: Bool?) + { + self.budgetCap = budgetCap + self.usedCredit = usedCredit + self.remainingCredit = remainingCredit + self.overageCredit = overageCredit + self.allowOverage = allowOverage + self.cycleStartDate = cycleStartDate + self.cycleEndDate = cycleEndDate + self.isQuotaAvailable = isQuotaAvailable + self.isUnlimited = isUnlimited + } +} + +public struct ZoomMateUsageSnapshot: Sendable { + public let creditStatus: ZoomMateCreditStatus + public let updatedAt: Date + + public init(creditStatus: ZoomMateCreditStatus, updatedAt: Date) { + self.creditStatus = creditStatus + self.updatedAt = updatedAt + } + + /// Implements design D5's credits mapping. `history` is optional and attached only when a + /// `credits/history` fetch succeeded (design.md D3) — its absence never blocks the primary + /// credits/status snapshot from being usable. + public func toUsageSnapshot( + history: ZoomMateCreditsHistorySnapshot? = nil, + accountEmail: String? = nil) -> UsageSnapshot + { + let budgetCap = self.creditStatus.budgetCap ?? 0 + let usedCredit = self.creditStatus.usedCredit ?? 0 + let isUnlimited = self.creditStatus.isUnlimited ?? false + + let usedPercent: Double = if isUnlimited || budgetCap <= 0 { + 0 + } else { + min(100, max(0, usedCredit / budgetCap * 100)) + } + + let resetsAt: Date? = (isUnlimited || budgetCap <= 0) + ? nil + : zoomMateDate(fromMilliseconds: self.creditStatus.cycleEndDate) + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: resetsAt, + resetDescription: "Credits") + + let identity = ProviderIdentitySnapshot( + providerID: .zoommate, + accountEmail: accountEmail, + accountOrganization: nil, + loginMethod: accountEmail != nil ? "Cookie" : nil) + + return UsageSnapshot( + primary: primary, + secondary: nil, + zoommateCreditsHistory: history, + updatedAt: self.updatedAt, + identity: identity) + } + + /// Pacing verdict (design.md D3), delegated to `ZoomMateCreditStatus.pacingVerdict` so both + /// this snapshot and `ZoomMateCreditsHistorySnapshot` (which carries its own paired + /// `creditStatus`) can compute the identical verdict without duplicating the algorithm. + public func pacingVerdict(now: Date = Date()) -> UsagePace? { + self.creditStatus.pacingVerdict(now: now) + } +} + +extension ZoomMateCreditStatus { + /// Pacing verdict (design.md D3): reuses `UsagePace`'s generic stage thresholds rather than + /// reinventing them. ZoomMate's billing cycle has an arbitrary length (not a fixed weekly + /// cadence), so `windowMinutes` is set to the actual cycle duration in minutes — with + /// `workDays: nil`, `UsagePace.weekly()`'s workday-aware branch never engages and it reduces + /// to a plain linear elapsed-fraction-of-cycle comparison, which is exactly what's needed + /// here despite the "weekly" name. + public func pacingVerdict(now: Date = Date()) -> UsagePace? { + guard let budgetCap, budgetCap > 0, + self.isUnlimited != true, + let cycleStartMillis = self.cycleStartDate, + let cycleEndMillis = self.cycleEndDate + else { + return nil + } + guard let cycleStart = zoomMateDate(fromMilliseconds: cycleStartMillis), + let cycleEnd = zoomMateDate(fromMilliseconds: cycleEndMillis), + cycleEnd > cycleStart + else { + return nil + } + + let usedCredit = self.usedCredit ?? 0 + let usedPercent = min(100, max(0, usedCredit / budgetCap * 100)) + let cycleMinutes = Int(cycleEnd.timeIntervalSince(cycleStart) / 60) + guard cycleMinutes > 0 else { return nil } + + let window = RateWindow( + usedPercent: usedPercent, + windowMinutes: cycleMinutes, + resetsAt: cycleEnd, + resetDescription: "Credits") + return UsagePace.weekly(window: window, now: now, workDays: nil) + } +} + +/// One calendar day's total credit consumption, aggregated from raw `credits/history` ledger +/// records. Mirrors `OpenAIDashboardDailyBreakdown`'s shape (`day` as a local `yyyy-MM-dd` key) +/// so the same day-key parsing/formatting used by the Codex credits-history chart applies here +/// unchanged. +public struct ZoomMateCreditDailyBreakdown: Equatable, Sendable { + /// Day key in `yyyy-MM-dd` (local time). + public let day: String + public let totalCreditsUsed: Double + + public init(day: String, totalCreditsUsed: Double) { + self.day = day + self.totalCreditsUsed = totalCreditsUsed + } +} + +extension ZoomMateCreditsHistorySnapshot { + /// Aggregates raw `credits/history` records into a Today/N-day series, one entry per + /// calendar day (local time) that has at least one qualifying record. `is_deleted` records + /// are excluded per design.md D3 (they represent removed sessions, not real spend); running + /// sessions (`is_running == true`) are still counted since their `cost` reflects consumption + /// so far. Records with an unparseable `time` or a negative `cost` are skipped defensively + /// rather than corrupting the aggregate. + /// + /// Records older than a trailing 30-calendar-day window from `now` are excluded before + /// bucketing, mirroring `CostUsageFetcher`'s `since = now - (historyDays - 1)` boundary + /// (design.md D3). This makes the 30-day window an explicit, model-level guarantee rather + /// than an implicit assumption inherited from the fetcher's request parameters — the result + /// stays calendar-bounded even if the fetch window, caching, or pagination ever changes. + public func dailyBreakdown(calendar: Calendar = .current, now: Date = Date()) -> [ZoomMateCreditDailyBreakdown] { + var totalsByDay: [String: Double] = [:] + let dayKeyFormatter = DateFormatter() + dayKeyFormatter.calendar = calendar + dayKeyFormatter.timeZone = calendar.timeZone + dayKeyFormatter.dateFormat = "yyyy-MM-dd" + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let isoFormatterNoFraction = ISO8601DateFormatter() + isoFormatterNoFraction.formatOptions = [.withInternetDateTime] + + // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. + let since = calendar.date(byAdding: .day, value: -29, to: now) ?? now + + for record in self.records { + guard record.isDeleted != true else { continue } + guard let cost = record.cost, cost >= 0 else { continue } + guard let timeString = record.time else { continue } + guard let date = isoFormatter.date(from: timeString) ?? isoFormatterNoFraction.date(from: timeString) + else { + continue + } + guard date >= calendar.startOfDay(for: since) else { continue } + let dayKey = dayKeyFormatter.string(from: date) + totalsByDay[dayKey, default: 0] += cost + } + + return totalsByDay + .map { ZoomMateCreditDailyBreakdown(day: $0.key, totalCreditsUsed: $0.value) } + .sorted { $0.day < $1.day } + } + + /// Sum of `cost` for whichever calendar day (local time) is "today" relative to `now`, i.e. + /// the current-day bucket from `dailyBreakdown()` if one exists. Used by the inline Today/30d + /// KPI tiles (tasks.md 3.4 follow-up) so the UI layer doesn't need to re-derive day-key + /// formatting itself. + public func todayCreditsUsed(now: Date = Date(), calendar: Calendar = .current) -> Double? { + let dayKeyFormatter = DateFormatter() + dayKeyFormatter.calendar = calendar + dayKeyFormatter.timeZone = calendar.timeZone + dayKeyFormatter.dateFormat = "yyyy-MM-dd" + let todayKey = dayKeyFormatter.string(from: now) + return self.dailyBreakdown(calendar: calendar, now: now).first { $0.day == todayKey }?.totalCreditsUsed + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift new file mode 100644 index 000000000..95af1cbe2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift @@ -0,0 +1,171 @@ +import Foundation + +public enum ZoomMateProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .zoommate, + metadata: ProviderMetadata( + id: .zoommate, + displayName: "ZoomMate", + sessionLabel: "Credits", + weeklyLabel: "Credits", + opusLabel: nil, + supportsOpus: false, + supportsCredits: true, + creditsHint: "Shows used/remaining credits against your ZoomMate budget cap.", + toggleTitle: "Show ZoomMate usage", + cliName: "zoommate", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.chromeOnlyImportOrder, + dashboardURL: "https://zoommate.zoom.us/#/?settings=credit-usage", + subscriptionDashboardURL: nil, + statusPageURL: "https://www.zoomstatus.com/", + statusComponentAllowlist: [ + "Zoom Meetings", + "ZoomMate", + "My Notes", + "Zoom Workflows", + "Zoom Developer Platform", + "Zoom Support", + "Zoom Website", + ]), + branding: ProviderBranding( + iconStyle: .zoommate, + iconResourceName: "ProviderIcon-zoommate", + // Zoom Brand Center "Visual identity > Color", retrieved 2026-07-18: + // https://brand.zoom.com/document/1#/visual-identity/color + // Bloom is primary; Dawn and Midnight are supporting core colors. + color: ProviderColor(red: 64 / 255, green: 176 / 255, blue: 255 / 255), + confettiPalette: [ + ProviderColor(hex: 0x40B0FF), + ProviderColor(hex: 0xB4D0F8), + ProviderColor(hex: 0x00053D), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "ZoomMate cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [ZoomMateWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "zoommate", + aliases: [], + versionDetector: nil)) + } +} + +/// Single unified strategy (modeled on `T3ChatWebFetchStrategy`) branching internally on the +/// selected `cookieSource`: `.auto` resolves a cookie session — the `CookieHeaderCache`d host map +/// first, else a fresh browser import whose validated headers are persisted back through the cache — +/// and mints a bearer JWT via `ZoomMateUsageFetcher.mintBearerToken`, reusing a still-valid token +/// from `ZoomMateBearerTokenCache` across refreshes; `.manual` uses the pasted cURL capture. +/// Cookies outlive the ~hourly JWT by weeks, so minting from cookies (and caching the result until +/// it nears expiry) avoids the manual re-paste entirely as long as the underlying browser session +/// stays valid, and the persisted headers let background refreshes and the bundled CLI reuse that +/// session without rereading Chrome. A rejected session clears the cached header and retries once +/// with a fresh import (see `fetch`). +struct ZoomMateWebFetchStrategy: ProviderFetchStrategy { + let id: String = "zoommate.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let cookieSource = context.settings?.zoommate?.cookieSource ?? .auto + guard cookieSource != .off else { return false } + if cookieSource == .manual { + return true + } + #if os(macOS) + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.zoommate?.cookieSource ?? .auto + do { + return try await self.fetchOnce(context, allowCachedCookieHeader: true) + } catch ZoomMateUsageError.invalidCredentials where cookieSource == .auto { + // The persisted cookie session (or a bearer minted from it) was rejected. Drop the + // cached headers and retry once against a fresh browser import, mirroring + // OpenCodeUsageFetchStrategy. Outside user-initiated contexts the import is + // gate-blocked, so the retry surfaces `noSession` instead of replaying a dead cookie. + CookieHeaderCache.clear(provider: .zoommate) + return try await self.fetchOnce(context, allowCachedCookieHeader: false) + } + } + + private func fetchOnce( + _ context: ProviderFetchContext, + allowCachedCookieHeader: Bool) async throws -> ProviderFetchResult + { + let fetcher = ZoomMateUsageFetcher(browserDetection: context.browserDetection) + let manual = Self.manualCookieHeader(from: context) + let logger: (@Sendable (String) -> Void)? = context.verbose + ? { @Sendable msg in CodexBarLog.logger(LogCategories.zoommate).verbose(msg) } + : nil + let requestContext = try await fetcher.resolveRequestContext( + manualCaptureOverride: manual, + allowCachedCookieHeader: allowCachedCookieHeader, + timeout: context.webTimeout, + logger: logger) + let snapshot: ZoomMateUsageSnapshot + do { + snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( + context: requestContext, + timeout: context.webTimeout) + } catch ZoomMateUsageError.invalidCredentials { + // A reused cached bearer token was rejected (revoked session before its own expiry). + // Evict it so the next refresh mints fresh rather than replaying the dead token. + await Self.invalidateCachedBearerToken(for: requestContext) + throw ZoomMateUsageError.invalidCredentials + } + + // The Today/30-day history chart (design.md D3) is a non-fatal adjunct: a failure here + // (e.g. a transient credits/history error) must never block the primary credits/status + // snapshot from being usable, mirroring ZaiUsageStats.fetchUsageWithModelUsage's + // secondary-fetch pattern. + var history: ZoomMateCreditsHistorySnapshot? + do { + let now = Date() + let startTime = Calendar.current.date(byAdding: .day, value: -30, to: now) ?? now + history = try await ZoomMateCreditsHistoryFetcher.fetch( + context: requestContext, + startTime: startTime, + endTime: now, + creditStatus: snapshot.creditStatus, + timeout: context.webTimeout) + } catch ZoomMateUsageError.invalidCredentials { + await Self.invalidateCachedBearerToken(for: requestContext) + CodexBarLog.logger(LogCategories.zoommate) + .info("ZoomMate credits history fetch failed (non-fatal): invalid credentials") + history = nil + } catch { + CodexBarLog.logger(LogCategories.zoommate) + .info("ZoomMate credits history fetch failed (non-fatal): \(error.localizedDescription)") + history = nil + } + + return self.makeResult( + usage: snapshot.toUsageSnapshot(history: history, accountEmail: requestContext.accountEmail), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.zoommate?.cookieSource == .manual else { return nil } + return context.settings?.zoommate?.manualCookieHeader ?? "" + } + + private static func invalidateCachedBearerToken(for requestContext: ZoomMateUsageFetcher.RequestContext) async { + guard let cacheKey = requestContext.cacheKey else { return } + await ZoomMateBearerTokenCache.shared.invalidate(forKey: cacheKey) + } +} diff --git a/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift new file mode 100644 index 000000000..522a54062 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift @@ -0,0 +1,556 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct ZoomMateUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.zoommate) + private static let refererURL = URL(string: "https://zoommate.zoom.us")! + /// First-party API hosts, tried in order. `ai.zoom.us` and `zoommate.zoom.us` currently serve + /// the same `/ai-computer/` API interchangeably and either may retire in the future, so every + /// API request falls over to the next host on non-auth failures via `withAPIHostFailover` + /// (precedent: `FactoryStatusProbe`'s base-URL candidates). + static let apiHosts = ZoomMateCookieHeaders.allowedHosts + static let creditsStatusPath = "/ai-computer/api/v1/credits/status" + private static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + + /// Forwarded headers allowlist for the manual `.web` cURL capture. Unlike T3 Chat's, this + /// MUST include `authorization` (design D2) because ZoomMate's credential is a bearer token, + /// not a cookie. + private static let forwardedManualHeaders = [ + "authorization": "Authorization", + "cookie": "Cookie", + "user-agent": "User-Agent", + "accept": "Accept", + "accept-language": "Accept-Language", + "sec-fetch-dest": "Sec-Fetch-Dest", + "sec-fetch-mode": "Sec-Fetch-Mode", + "sec-fetch-site": "Sec-Fetch-Site", + ] + + public struct RequestContext: Sendable { + public let authorization: String + public let headers: [String: String] + public let cookieHeaders: ZoomMateCookieHeaders + public let preferredHost: String? + /// Signed-in user's email, when known. Only populated by the `.auto` cookie-mint path + /// (sourced from the login bootstrap response's `data.user_profile.email`); the manual + /// `.web` cURL-capture path has no equivalent payload to read it from, so this stays `nil` + /// there. + public let accountEmail: String? + /// Bearer-token cache key for the originating cookie session (`.auto` path only). Lets a + /// caller evict the reused token from `ZoomMateBearerTokenCache` when a downstream request + /// rejects it (`401/403`). `nil` for the manual `.web` path, which carries its own bearer. + public let cacheKey: String? + + public init( + authorization: String, + headers: [String: String] = [:], + cookieHeaders: ZoomMateCookieHeaders = ZoomMateCookieHeaders(headersByHost: [:]), + preferredHost: String? = nil, + accountEmail: String? = nil, + cacheKey: String? = nil) + { + self.authorization = authorization + self.headers = headers + self.cookieHeaders = cookieHeaders + self.preferredHost = preferredHost + self.accountEmail = accountEmail + self.cacheKey = cacheKey + } + } + + /// Result of `mintBearerToken`: the freshly-minted bearer JWT plus whatever identity + /// enrichment (currently just `email`) the same login bootstrap response happened to include + /// in its `data.user_profile` object. Modeled on the small multi-field result structs other + /// providers return from a single fetch (e.g. `ZoomMateCookieImporter.SessionInfo`). + public struct MintedToken: Sendable { + public let bearerToken: String + public let accountEmail: String? + + public init(bearerToken: String, accountEmail: String?) { + self.bearerToken = bearerToken + self.accountEmail = accountEmail + } + } + + public let browserDetection: BrowserDetection + + public init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + } + + public func fetch( + manualCaptureOverride: String? = nil, + timeout: TimeInterval = 15, + logger: (@Sendable (String) -> Void)? = nil, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateUsageSnapshot + { + let log: @Sendable (String) -> Void = { msg in logger?("[zoommate] \(msg)") } + let context = try await self.resolveRequestContext( + manualCaptureOverride: manualCaptureOverride, + timeout: timeout, + logger: log, + transport: transport) + if !context.headers.isEmpty || !context.cookieHeaders.isEmpty { + var names = Set(context.headers.keys) + if !context.cookieHeaders.isEmpty { + names.insert("Cookie") + } + let headerNames = names.sorted().joined(separator: ", ") + log("Forwarding captured headers: \(headerNames)") + } + return try await Self.fetchCreditsStatus( + context: context, + timeout: timeout, + now: now, + transport: transport) + } + + public static func fetchCreditsStatus( + context: RequestContext, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> ZoomMateUsageSnapshot + { + try await self.withAPIHostFailover(hosts: self.hosts(preferred: context.preferredHost)) { host in + try await self.fetchCreditsStatus( + context: context, + host: host, + timeout: timeout, + now: now, + transport: transport) + } + } + + /// Runs one API request per host in `apiHosts` order, returning the first success. Auth + /// rejections and parse failures propagate immediately — the host answered, so retrying the + /// interchangeable alternate cannot help; anything else (unreachable host, non-auth HTTP + /// error) falls through to the next host so the provider keeps working if either host + /// retires. + static func withAPIHostFailover( + hosts: [String] = ZoomMateUsageFetcher.apiHosts, + operation: (String) async throws -> T) async throws -> T + { + var lastError: Error? + for (index, host) in hosts.enumerated() { + try Task.checkCancellation() + do { + return try await operation(host) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch ZoomMateUsageError.invalidCredentials { + throw ZoomMateUsageError.invalidCredentials + } catch let ZoomMateUsageError.parseFailed(message) { + throw ZoomMateUsageError.parseFailed(message) + } catch { + if Task.isCancelled { + throw CancellationError() + } + lastError = error + if index < hosts.count - 1 { + Self.log.info("ZoomMate API host unavailable; retrying on the alternate host") + } + } + } + throw lastError ?? ZoomMateUsageError.apiError("No ZoomMate API host succeeded.") + } + + private static func fetchCreditsStatus( + context: RequestContext, + host: String, + timeout: TimeInterval, + now: Date, + transport: any ProviderHTTPTransport) async throws -> ZoomMateUsageSnapshot + { + var request = URLRequest(url: URL(string: "https://\(host)\(self.creditsStatusPath)")!) + request.httpMethod = "GET" + request.timeoutInterval = timeout + self.applyDefaultHeaders(to: &request) + for (name, value) in context.headers { + request.setValue(value, forHTTPHeaderField: name) + } + request.setValue(context.cookieHeaders.header(forHost: host), forHTTPHeaderField: "Cookie") + // Authorization is always sent (the required credential per design D2). Origin and Referer + // are fixed here so captured values can never widen the first-party request boundary. + request.setValue(context.authorization, forHTTPHeaderField: "Authorization") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + + let response = try await transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + Self.log.error("ZoomMate API returned \(response.statusCode)") + if response.statusCode == 401 || response.statusCode == 403 { + throw ZoomMateUsageError.invalidCredentials + } + throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + let envelope = try JSONDecoder().decode(CreditsStatusEnvelope.self, from: data) + guard let creditStatus = envelope.data?.creditStatus else { + throw ZoomMateUsageError.parseFailed("Missing credit_status object.") + } + return ZoomMateUsageSnapshot(creditStatus: creditStatus, updatedAt: now) + } catch let error as ZoomMateUsageError { + throw error + } catch { + Self.log.error("ZoomMate credits/status parse failed") + throw ZoomMateUsageError.parseFailed(error.localizedDescription) + } + } + + /// Exchanges ZoomMate/Zoom session cookie headers for a fresh bearer JWT via ZoomMate's own + /// cookie-to-token bootstrap endpoint — the same call its web frontend makes on every page + /// load. Cookies (session/SSO-backed) live far longer than the ~hourly JWT, so minting a fresh + /// token from cookies avoids the manual re-paste entirely as long as the underlying browser + /// session cookies remain valid. Callers should prefer `cachedOrMintedToken`, which reuses a + /// still-valid minted token from `ZoomMateBearerTokenCache` instead of re-minting every fetch. + public static func mintBearerToken( + cookieHeaders: ZoomMateCookieHeaders, + timeout: TimeInterval = 15, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MintedToken + { + let credentialedHosts = self.apiHosts.filter { cookieHeaders.header(forHost: $0) != nil } + return try await self.withAPIHostFailover(hosts: credentialedHosts) { host in + try await self.mintBearerToken( + cookieHeader: cookieHeaders.header(forHost: host), + host: host, + timeout: timeout, + transport: transport) + } + } + + private static func mintBearerToken( + cookieHeader: String?, + host: String, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> MintedToken + { + var components = URLComponents(string: "https://\(host)/ai-computer/api/v1/login/")! + components.queryItems = [URLQueryItem(name: "continue", value: "https://zoommate.zoom.us/")] + guard let url = components.url else { + throw ZoomMateUsageError.apiError("Failed to build login bootstrap URL.") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + self.applyDefaultHeaders(to: &request) + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + + let response = try await transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + Self.log.error("ZoomMate login bootstrap returned \(response.statusCode)") + if response.statusCode == 401 || response.statusCode == 403 { + throw ZoomMateUsageError.invalidCredentials + } + throw ZoomMateUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + let envelope = try JSONDecoder().decode(LoginBootstrapEnvelope.self, from: data) + guard let nak = envelope.data?.nak, !nak.isEmpty else { + throw ZoomMateUsageError.parseFailed("Missing nak in login bootstrap response.") + } + let email = envelope.data?.userProfile?.email?.trimmingCharacters(in: .whitespacesAndNewlines) + return MintedToken(bearerToken: nak, accountEmail: (email?.isEmpty ?? true) ? nil : email) + } catch let error as ZoomMateUsageError { + throw error + } catch { + throw ZoomMateUsageError.parseFailed(error.localizedDescription) + } + } + + func resolveRequestContext( + manualCaptureOverride: String?, + allowCachedCookieHeader: Bool = true, + timeout: TimeInterval, + logger: (@Sendable (String) -> Void)?, + cache: ZoomMateBearerTokenCache = .shared, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> RequestContext + { + if let manualCaptureOverride { + guard let override = Self.requestContext(from: manualCaptureOverride) else { + throw ZoomMateUsageError.noCapture + } + logger?("[zoommate] Using manual cURL capture") + return override + } + + #if os(macOS) + // Cached host-scoped cookie headers first (Perplexity/OpenCode precedent): Chrome's cookie decryption + // is gated behind user-initiated contexts (`BrowserCookieAccessGate`) to avoid Keychain + // prompts, so background refreshes and the bundled CLI must be able to run entirely from + // the last validated session instead of rereading the browser. + if allowCachedCookieHeader, + let cached = CookieHeaderCache.load(provider: .zoommate), + let cookieHeaders = ZoomMateCookieHeaders.decodeFromStorage(cached.cookieHeader), + !cookieHeaders.isEmpty + { + logger?("[zoommate] Using cached cookie headers from \(cached.sourceLabel)") + return try await Self.requestContext( + forCookieHeaders: cookieHeaders, + persistingValidatedHeaderAs: nil, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + } + + let sessions = try ZoomMateCookieImporter.importSessions( + browserDetection: self.browserDetection, + logger: logger) + return try await Self.requestContext( + forCookieSessions: sessions, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + #else + throw ZoomMateUsageError.noSession + #endif + } + + #if os(macOS) + /// Tries browser cookie profiles in import order, advancing only when the login bootstrap + /// explicitly rejects a candidate. Network and parse failures surface immediately rather than + /// being hidden by another profile. Only the first successfully minted session is persisted. + static func requestContext( + forCookieSessions sessions: [ZoomMateCookieImporter.SessionInfo], + cache: ZoomMateBearerTokenCache = .shared, + timeout: TimeInterval, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + logger: (@Sendable (String) -> Void)?) async throws -> RequestContext + { + guard !sessions.isEmpty else { throw ZoomMateUsageError.noSession } + + for session in sessions { + logger?("[zoommate] Trying cookies from \(session.sourceLabel)") + do { + return try await self.requestContext( + forCookieHeaders: session.cookieHeaders, + persistingValidatedHeaderAs: session.sourceLabel, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + } catch ZoomMateUsageError.invalidCredentials { + logger?("[zoommate] Cookie session from \(session.sourceLabel) was rejected") + } + } + + throw ZoomMateUsageError.invalidCredentials + } + + /// Builds the `.auto` request context for a cookie session: reuses or mints the bearer JWT + /// and, when `sourceLabel` is non-nil (a fresh browser import), persists the now-validated + /// cookie headers through `CookieHeaderCache`. The successful mint is the validation — + /// ZoomMate's login bootstrap rejects a dead session with 401/403 before anything is stored. + /// Only the cookie headers are persisted; the minted bearer stays in the in-memory + /// `ZoomMateBearerTokenCache`. + static func requestContext( + forCookieHeaders cookieHeaders: ZoomMateCookieHeaders, + persistingValidatedHeaderAs sourceLabel: String?, + cache: ZoomMateBearerTokenCache = .shared, + timeout: TimeInterval, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + logger: (@Sendable (String) -> Void)?) async throws -> RequestContext + { + let minted = try await Self.cachedOrMintedToken( + cookieHeaders: cookieHeaders, + cache: cache, + timeout: timeout, + transport: transport, + logger: logger) + if let sourceLabel, let encodedCookieHeaders = cookieHeaders.encodedForStorage() { + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: encodedCookieHeaders, + sourceLabel: sourceLabel) + } + return RequestContext( + authorization: Self.bearerHeaderValue(from: minted.bearerToken), + cookieHeaders: cookieHeaders, + accountEmail: minted.accountEmail, + cacheKey: ZoomMateBearerTokenCache.key(forCookieHeaders: cookieHeaders)) + } + #endif + + /// Returns a still-valid cached bearer token for `cookieHeaders`, or mints a fresh one and caches + /// it when the minted JWT exposes an `exp` claim. A token whose expiry can't be read is returned + /// but never cached, so `.auto` refreshes degrade to the mint-every-fetch behavior rather than + /// risk serving an undatable (possibly expired) token. + static func cachedOrMintedToken( + cookieHeaders: ZoomMateCookieHeaders, + cache: ZoomMateBearerTokenCache, + timeout: TimeInterval, + transport: any ProviderHTTPTransport, + logger: (@Sendable (String) -> Void)?) async throws -> MintedToken + { + let cacheKey = ZoomMateBearerTokenCache.key(forCookieHeaders: cookieHeaders) + if let entry = await cache.validEntry(forKey: cacheKey, now: Date()) { + logger?("[zoommate] Reusing cached bearer token") + return MintedToken(bearerToken: entry.token, accountEmail: entry.accountEmail) + } + let minted = try await Self.mintBearerToken( + cookieHeaders: cookieHeaders, + timeout: timeout, + transport: transport) + if let expiry = Self.expiry(fromJWT: minted.bearerToken) { + await cache.store( + ZoomMateBearerTokenCache.Entry( + token: minted.bearerToken, + accountEmail: minted.accountEmail, + expiry: expiry), + forKey: cacheKey) + logger?("[zoommate] Minted fresh bearer token via cookie session (cached until expiry)") + } else { + logger?("[zoommate] Minted fresh bearer token via cookie session (not cached: no expiry claim)") + } + return minted + } + + /// Reads the `exp` claim (seconds since epoch) from a bearer JWT, returning its expiry `Date`. + /// Returns `nil` for anything that isn't a decodable JWT with a numeric `exp` — the caller then + /// treats the token as non-cacheable. Mirrors the base64url/JSON payload decode used elsewhere + /// (e.g. `MiniMaxLocalStorageImporter`); no signature verification (we minted it ourselves). + static func expiry(fromJWT token: String) -> Date? { + let raw = Self.bearerHeaderValue(from: token).dropFirst("Bearer ".count) + let parts = raw.split(separator: ".") + guard parts.count >= 2, let data = Self.base64URLDecode(String(parts[1])) else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let exp = (object["exp"] as? NSNumber)?.doubleValue, exp > 0 + else { + return nil + } + return Date(timeIntervalSince1970: exp) + } + + private static func base64URLDecode(_ value: String) -> Data? { + var base64 = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padding = (4 - base64.count % 4) % 4 + if padding > 0 { + base64.append(String(repeating: "=", count: padding)) + } + return Data(base64Encoded: base64) + } + + static func bearerHeaderValue(from rawToken: String) -> String { + let trimmed = rawToken.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.lowercased().hasPrefix("bearer ") { + return trimmed + } + return "Bearer \(trimmed)" + } + + /// Parses a manual cURL capture into a `RequestContext`. Returns `nil` when no non-empty + /// `Authorization` header can be extracted — that's the required credential (design D2). + static func requestContext(from raw: String?) -> RequestContext? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + guard let captureURL = CurlCaptureParser.requestURL(from: raw), + self.isAllowedCaptureURL(captureURL), + let captureHost = captureURL.host?.lowercased() + else { + return nil + } + let headerFields = CurlCaptureParser.headerFields(from: raw) + guard let authorization = CurlCaptureParser.headerValue(named: "Authorization", in: headerFields), + !authorization.isEmpty + else { + return nil + } + var headers = CurlCaptureParser.forwardedHeaders(from: headerFields, allowlist: self.forwardedManualHeaders) + headers.removeValue(forKey: "Authorization") + let cookieHeader = headers.removeValue(forKey: "Cookie") + let cookieHeaders = ZoomMateCookieHeaders(headersByHost: cookieHeader.map { [captureHost: $0] } ?? [:]) + return RequestContext( + authorization: Self.bearerHeaderValue(from: authorization), + headers: headers, + cookieHeaders: cookieHeaders, + preferredHost: captureHost) + } + + /// Captures are accepted from any host in `apiHosts` (the interchangeable first-party API + /// hosts) — DevTools shows the credits/status request on whichever host the web client used — + /// but only for the exact HTTPS credits/status path with no port, userinfo, query, or fragment. + private static func isAllowedCaptureURL(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + return url.scheme?.lowercased() == "https" && + self.apiHosts.contains(host) && + url.port == nil && + url.user == nil && + url.password == nil && + url.path == self.creditsStatusPath && + url.query == nil && + url.fragment == nil + } + + private static func applyDefaultHeaders(to request: inout URLRequest) { + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") + request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") + request.setValue("same-site", forHTTPHeaderField: "Sec-Fetch-Site") + } + + static func hosts(preferred host: String?) -> [String] { + guard let host = host?.lowercased(), self.apiHosts.contains(host) else { return self.apiHosts } + return [host] + self.apiHosts.filter { $0 != host } + } + + private struct CreditsStatusEnvelope: Decodable { + struct DataBox: Decodable { + let creditStatus: ZoomMateCreditStatus? + + private enum CodingKeys: String, CodingKey { + case creditStatus = "credit_status" + } + } + + let data: DataBox? + let statusCode: Int? + let errorMessage: String? + + private enum CodingKeys: String, CodingKey { + case data + case statusCode = "status_code" + case errorMessage = "error_message" + } + } + + /// Shape of ZoomMate's cookie-to-token bootstrap response (`GET .../login/?continue=...`). + /// `data.nak` (the freshly-minted bearer JWT) is required; `data.user_profile.email` is + /// decoded as an optional identity-enrichment nice-to-have (never required — a missing/absent + /// `user_profile` or `email` must never fail the mint). The rest of the payload (permissions, + /// cluster config, etc.) is ignored. + private struct LoginBootstrapEnvelope: Decodable { + struct UserProfile: Decodable { + let email: String? + } + + struct DataBox: Decodable { + let nak: String? + let userProfile: UserProfile? + + private enum CodingKeys: String, CodingKey { + case nak + case userProfile = "user_profile" + } + } + + let success: Bool? + let data: DataBox? + } +} diff --git a/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift b/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift index bb30c2ce8..6ae6d26e4 100644 --- a/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift +++ b/Sources/CodexBarCore/Sync/AccountIdentityComputer.swift @@ -79,7 +79,7 @@ public enum AccountIdentityComputer { .azureopenai, .alibabatokenplan, .t3chat, // Upstream 0.33+ new providers. Same rationale as above. .devin, .zed, .sakana, .poe, .chutes, .qoder, .clawrouter, .wayfinder, .sub2api, - .zenmux, .clinepass, .longcat, .neuralwatt, .deepinfra, .aiand: + .zenmux, .clinepass, .longcat, .neuralwatt, .deepinfra, .aiand, .qwencloud, .zoommate: // Non-Tier-A providers: no stable account model required by // iOS today. Return nil → iOS falls back to per-device legacy // bucket. If a future provider needs cross-Mac merging, add diff --git a/Sources/CodexBarCore/TokenAccounts.swift b/Sources/CodexBarCore/TokenAccounts.swift index 0b7502760..4dfa9578b 100644 --- a/Sources/CodexBarCore/TokenAccounts.swift +++ b/Sources/CodexBarCore/TokenAccounts.swift @@ -73,6 +73,19 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) return (trimmed?.isEmpty ?? true) ? nil : trimmed } + + public func sanitizedForDump() -> ProviderTokenAccount { + ProviderTokenAccount( + id: self.id, + label: self.label, + token: "[REDACTED]", + addedAt: self.addedAt, + lastUsed: self.lastUsed, + externalIdentifier: self.externalIdentifier, + usageScope: self.usageScope, + organizationID: self.organizationID, + workspaceID: self.workspaceID) + } } public struct ProviderTokenAccountData: Codable, Sendable { @@ -90,6 +103,13 @@ public struct ProviderTokenAccountData: Codable, Sendable { guard !self.accounts.isEmpty else { return 0 } return min(max(self.activeIndex, 0), self.accounts.count - 1) } + + public func sanitizedForDump() -> ProviderTokenAccountData { + ProviderTokenAccountData( + version: self.version, + accounts: self.accounts.map { $0.sanitizedForDump() }, + activeIndex: self.activeIndex) + } } private struct ProviderTokenAccountsFile: Codable { diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 753856440..9ca839741 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -193,6 +193,7 @@ public struct UsageSnapshot: Codable, Sendable { public let kiroUsage: KiroUsageDetails? public let ampUsage: AmpUsageDetails? public let zaiUsage: ZaiUsageSnapshot? + public let zoommateCreditsHistory: ZoomMateCreditsHistorySnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? public let deepseekUsage: DeepSeekUsageSummary? public let deepseekDetailedUsageState: DeepSeekDetailedUsageState @@ -287,6 +288,7 @@ public struct UsageSnapshot: Codable, Sendable { ampUsage: AmpUsageDetails? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, + zoommateCreditsHistory: ZoomMateCreditsHistorySnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, deepseekUsage: DeepSeekUsageSummary? = nil, deepseekDetailedUsageState: DeepSeekDetailedUsageState = .notRequested, @@ -332,6 +334,7 @@ public struct UsageSnapshot: Codable, Sendable { self.ampUsage = ampUsage self.providerCost = providerCost self.zaiUsage = zaiUsage + self.zoommateCreditsHistory = zoommateCreditsHistory self.minimaxUsage = minimaxUsage self.deepseekUsage = deepseekUsage self.deepseekDetailedUsageState = deepseekDetailedUsageState @@ -394,6 +397,7 @@ public struct UsageSnapshot: Codable, Sendable { self.kiroUsage = try container.decodeIfPresent(KiroUsageDetails.self, forKey: .kiroUsage) self.ampUsage = try container.decodeIfPresent(AmpUsageDetails.self, forKey: .ampUsage) self.zaiUsage = nil // Not persisted, fetched fresh each time + self.zoommateCreditsHistory = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time self.deepseekUsage = nil // Not persisted, fetched fresh each time self.deepseekDetailedUsageState = .notRequested // Live-only fetch state @@ -656,6 +660,7 @@ public struct UsageSnapshot: Codable, Sendable { ampUsage: self.ampUsage, providerCost: self.providerCost, zaiUsage: self.zaiUsage, + zoommateCreditsHistory: self.zoommateCreditsHistory, minimaxUsage: self.minimaxUsage, deepseekUsage: deepseekUsage.resolving(self.deepseekUsage), deepseekDetailedUsageState: deepseekDetailedUsageState.resolving(self.deepseekDetailedUsageState), diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index 7323a07c4..b8bed59c8 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -11,7 +11,7 @@ enum CostUsageCacheIO { private static func artifactVersion(for provider: UsageProvider) -> Int { switch provider { case .codex: - 10 + 11 case .claude, .vertexai: 5 default: @@ -19,7 +19,7 @@ enum CostUsageCacheIO { } } - private static func defaultCacheRoot() -> URL { + static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! return root.appendingPathComponent("CodexBar", isDirectory: true) } @@ -147,6 +147,7 @@ struct CostUsageFileUsage: Codable { var projectPath: String? var canonicalProjectPath: String? var codexCostCacheComplete: Bool? + var codexSession: CostUsageCodexSessionMetadata? var codexCostNanos: [String: [String: Int64]]? var codexPrioritySurchargeNanos: [String: [String: Int64]]? var codexStandardCostNanos: [String: [String: Int64]]? @@ -154,12 +155,75 @@ struct CostUsageFileUsage: Codable { var codexStandardTokens: [String: [String: Int]]? var codexPriorityTokens: [String: [String: Int]]? var codexTurnIDs: [String]? + /// Refreshed by Codex normalization paths, never by sidecar cache validation. + var codexWorkspaceContentFingerprint: String? var codexRows: [CostUsageScanner.CodexUsageRow]? var claudeRows: [CostUsageScanner.ClaudeUsageRow]? + /// Identity and target size for an in-progress bounded Codex parse. + var codexScanFileId: String? + var codexScanTargetSize: Int64? + var codexScanComplete: Bool? + var codexJSONLResumeState: CostUsageJsonl.ResumeState? + /// Compact relevant events retained while a subagent rollout awaits full-shape classification. + var codexBufferedSubagentLines: [CostUsageScanner.CodexBufferedFastLine]? +} + +struct CostUsageCodexSessionMetadata: Codable, Equatable { + var sessionId: String? + var forkedFromId: String? + var cwd: String? + var title: String? + var startedAtUnixMs: Int64? + var latestActivityUnixMs: Int64? + + var isEmpty: Bool { + self.sessionId == nil + && self.forkedFromId == nil + && self.cwd == nil + && self.title == nil + && self.startedAtUnixMs == nil + && self.latestActivityUnixMs == nil + } + + func merging(_ newer: CostUsageCodexSessionMetadata) -> CostUsageCodexSessionMetadata { + CostUsageCodexSessionMetadata( + sessionId: newer.sessionId ?? self.sessionId, + forkedFromId: newer.forkedFromId ?? self.forkedFromId, + cwd: newer.cwd ?? self.cwd, + title: newer.title ?? self.title, + startedAtUnixMs: Self.earlier(self.startedAtUnixMs, newer.startedAtUnixMs), + latestActivityUnixMs: Self.later(self.latestActivityUnixMs, newer.latestActivityUnixMs)) + } + + private static func earlier(_ lhs: Int64?, _ rhs: Int64?) -> Int64? { + switch (lhs, rhs) { + case let (lhs?, rhs?): min(lhs, rhs) + case let (lhs?, nil): lhs + case let (nil, rhs?): rhs + case (nil, nil): nil + } + } + + private static func later(_ lhs: Int64?, _ rhs: Int64?) -> Int64? { + switch (lhs, rhs) { + case let (lhs?, rhs?): max(lhs, rhs) + case let (lhs?, nil): lhs + case let (nil, rhs?): rhs + case (nil, nil): nil + } + } } struct CostUsageCodexTotals: Codable, Equatable { var input: Int var cached: Int var output: Int + var reasoning: Int? + + init(input: Int, cached: Int, output: Int, reasoning: Int? = nil) { + self.input = input + self.cached = cached + self.output = output + self.reasoning = reasoning + } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift index fd17555f8..4911826e5 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift @@ -6,8 +6,23 @@ enum CostUsageJsonl { let wasTruncated: Bool } - private struct JSONTailState { - private enum ScalarState { + struct ResumeState: Codable { + let offset: Int64 + fileprivate let lineStartOffset: Int64 + fileprivate let prefix: Data + fileprivate let lineBytes: Int + fileprivate let truncated: Bool + fileprivate let jsonTailState: JSONTailState + } + + struct ScanProgress { + let committedOffset: Int64 + let readOffset: Int64 + let resumeState: ResumeState? + } + + fileprivate struct JSONTailState: Codable { + private enum ScalarState: Codable { case notScalar case trueLiteral(Int) case falseLiteral(Int) @@ -16,7 +31,7 @@ enum CostUsageJsonl { case invalid } - private enum NumberState { + private enum NumberState: Codable { private enum ByteKind { case zero case digit @@ -234,6 +249,7 @@ enum CostUsageJsonl { offset: offset, maxLineBytes: maxLineBytes, prefixBytes: prefixBytes, + maxBytesToRead: nil, checkCancellation: nil, onLine: onLine) } @@ -244,25 +260,51 @@ enum CostUsageJsonl { offset: Int64 = 0, maxLineBytes: Int, prefixBytes: Int, + maxBytesToRead: Int64? = nil, checkCancellation: (() throws -> Void)? = nil, onLine: (Line) -> Void) throws -> Int64 + { + try self.scanBounded( + fileURL: fileURL, + offset: offset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + maxBytesToRead: maxBytesToRead, + resumeState: nil, + checkCancellation: checkCancellation, + onLine: onLine).committedOffset + } + + // swiftlint:disable:next function_parameter_count + static func scanBounded( + fileURL: URL, + offset: Int64 = 0, + maxLineBytes: Int, + prefixBytes: Int, + maxBytesToRead: Int64?, + resumeState: ResumeState?, + checkCancellation: (() throws -> Void)? = nil, + onLine: (Line) -> Void) throws -> ScanProgress { let handle = try FileHandle(forReadingFrom: fileURL) defer { try? handle.close() } - let startOffset = max(0, offset) + let startOffset = resumeState?.offset ?? max(0, offset) if startOffset > 0 { try handle.seek(toOffset: UInt64(startOffset)) } - var current = Data() + var current = resumeState?.prefix ?? Data() current.reserveCapacity(4 * 1024) - var lineBytes = 0 - var truncated = false + var lineBytes = resumeState?.lineBytes ?? 0 + var truncated = resumeState?.truncated ?? false var bytesRead: Int64 = 0 - var committedOffset = startOffset - var jsonTailState = JSONTailState() + var lineStartOffset = resumeState?.lineStartOffset ?? startOffset + var committedOffset = lineStartOffset + var jsonTailState = resumeState?.jsonTailState ?? JSONTailState() + let fileSize = (try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? NSNumber)? + .int64Value func appendSegment(_ bytes: UnsafePointer, count: Int) { guard count > 0 else { return } @@ -288,6 +330,17 @@ enum CostUsageJsonl { jsonTailState.reset() } + func currentResumeState() -> ResumeState? { + guard lineBytes > 0 else { return nil } + return ResumeState( + offset: startOffset + bytesRead, + lineStartOffset: lineStartOffset, + prefix: current, + lineBytes: lineBytes, + truncated: truncated, + jsonTailState: jsonTailState) + } + func hasCompleteJSONTail() -> Bool { guard jsonTailState.isStructurallyComplete else { return false } if truncated { @@ -301,12 +354,23 @@ enum CostUsageJsonl { while true { try checkCancellation?() + let remaining = maxBytesToRead.map { max(0, $0 - bytesRead) } + if remaining == 0 { + if let fileSize, startOffset + bytesRead >= fileSize, hasCompleteJSONTail() { + flushLine() + committedOffset = startOffset + bytesRead + lineStartOffset = committedOffset + } + break + } let reachedEOF = try autoreleasepool { - let chunk = try handle.read(upToCount: 256 * 1024) ?? Data() + let readCount = min(256 * 1024, Int(remaining ?? Int64(256 * 1024))) + let chunk = try handle.read(upToCount: readCount) ?? Data() if chunk.isEmpty { if hasCompleteJSONTail() { flushLine() committedOffset = startOffset + bytesRead + lineStartOffset = committedOffset } return true } @@ -323,6 +387,7 @@ enum CostUsageJsonl { appendSegment(base.advanced(by: segmentStart), count: index - segmentStart) flushLine() committedOffset = chunkStartOffset + Int64(index + 1) + lineStartOffset = committedOffset segmentStart = index + 1 } else { jsonTailState.append(base[index]) @@ -341,6 +406,9 @@ enum CostUsageJsonl { try checkCancellation?() } - return committedOffset + return ScanProgress( + committedOffset: committedOffset, + readOffset: startOffset + bytesRead, + resumeState: currentResumeState()) } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 8bdbeec25..a720f3ecb 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1,3 +1,5 @@ +// swiftlint:disable file_length + import Foundation #if canImport(Musl) import Musl @@ -291,6 +293,7 @@ extension CostUsageScanner { projectPath: String? = nil, canonicalProjectPath: String? = nil, codexCostCacheComplete: Bool? = true, + codexSession: CostUsageCodexSessionMetadata? = nil, codexCostNanos: [String: [String: Int64]]? = nil, codexPrioritySurchargeNanos: [String: [String: Int64]]? = nil, codexStandardCostNanos: [String: [String: Int64]]? = nil, @@ -299,7 +302,12 @@ extension CostUsageScanner { codexPriorityTokens: [String: [String: Int]]? = nil, codexTurnIDs: [String]? = nil, codexRows: [CodexUsageRow]? = nil, - claudeRows: [ClaudeUsageRow]? = nil) -> CostUsageFileUsage + claudeRows: [ClaudeUsageRow]? = nil, + codexScanFileId: String? = nil, + codexScanTargetSize: Int64? = nil, + codexScanComplete: Bool? = nil, + codexJSONLResumeState: CostUsageJsonl.ResumeState? = nil, + codexBufferedSubagentLines: [CodexBufferedFastLine]? = nil) -> CostUsageFileUsage { CostUsageFileUsage( mtimeUnixMs: mtimeUnixMs, @@ -321,6 +329,7 @@ extension CostUsageScanner { projectPath: projectPath, canonicalProjectPath: canonicalProjectPath, codexCostCacheComplete: codexCostCacheComplete, + codexSession: codexSession, codexCostNanos: codexCostNanos, codexPrioritySurchargeNanos: codexPrioritySurchargeNanos, codexStandardCostNanos: codexStandardCostNanos, @@ -329,7 +338,12 @@ extension CostUsageScanner { codexPriorityTokens: codexPriorityTokens, codexTurnIDs: codexTurnIDs, codexRows: codexRows, - claudeRows: claudeRows) + claudeRows: claudeRows, + codexScanFileId: codexScanFileId, + codexScanTargetSize: codexScanTargetSize, + codexScanComplete: codexScanComplete, + codexJSONLResumeState: codexJSONLResumeState, + codexBufferedSubagentLines: codexBufferedSubagentLines) } static func needsCodexCostCache(_ usage: CostUsageFileUsage) -> Bool { @@ -424,8 +438,59 @@ extension CostUsageScanner { splitMaps.priorityTokens) updated.codexCostCacheComplete = true updated.codexTurnIDs = Self.mergeCodexTurnIDs(usage.codexTurnIDs, rows: migratedRows) - updated.codexRows = rows - return updated + updated.codexRows = Self.codexRowsWithPricingAudit( + rows, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + return updated.refreshingCodexWorkspaceUsageFingerprint() + } + + static func codexRowsWithPricingAudit( + _ rows: [CodexUsageRow], + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> [CodexUsageRow] + { + rows.map { row in + let priorityMetadata = row.turnID.flatMap { priorityTurns[$0] } + let pricedModel = priorityMetadata.map { Self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } + ?? row.model + let baseCost = CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + let exactCost: Double? = if priorityMetadata != nil, + let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + { + max(priorityCost, baseCost ?? priorityCost) + } else { + baseCost + } + let totalTokens = max(0, row.input) + max(0, row.output) + return CodexUsageRow( + day: row.day, + model: row.model, + rawModel: row.rawModel, + turnID: row.turnID, + eventIndex: row.eventIndex, + timestampUnixMs: row.timestampUnixMs, + input: row.input, + cached: row.cached, + output: row.output, + reasoning: row.reasoning, + knownCostNanos: exactCost.map { Int64(($0 * Self.costScale).rounded()) }, + unpricedTokens: exactCost == nil ? totalTokens : 0, + pricingModel: pricedModel, + pricingMode: priorityMetadata == nil ? "standard" : "priority") + } } static func codexMergedCostMap( @@ -741,6 +806,7 @@ extension CostUsageScanner { splitMaps.priorityTokens), codexTurnIDs: Self.mergeCodexTurnIDs(nil, rows: rows), codexRows: rows) + .refreshingCodexWorkspaceUsageFingerprint() } static func mergeCostMaps( @@ -894,6 +960,7 @@ extension CostUsageScanner { let needsSessionId = cached.sessionId == nil guard cached.mtimeUnixMs == input.metadata.mtimeUnixMs, cached.size == input.metadata.size, + cached.codexScanComplete != false, !needsSessionId, !context.forceFullScan else { return false } @@ -964,11 +1031,13 @@ extension CostUsageScanner { return !(Set(cached.codexTurnIDs ?? []).isDisjoint(with: context.changedPriorityTurnIDs)) } + // swiftlint:disable:next function_body_length static func appendCodexFileIncrementIfPossible( input: CodexFileScanInput, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) throws -> Bool + state: inout CodexScanState, + maxBytesToRead: Int64? = nil) throws -> Bool { try context.checkCancellation?() guard let cached = input.cached, cached.sessionId != nil, !context.forceFullScan else { return false } @@ -978,13 +1047,24 @@ extension CostUsageScanner { } // Subagent shape depends on the complete lineage prefix. Appended metadata can change an // independent counter into a copied-prefix rollout, so a tail-only parse is not sound. - if try Self.codexFileIsSubagentThread( + let startOffset = cached.parsedBytes ?? cached.size + let hasMatchingResumeOffset = cached.codexJSONLResumeState?.offset == nil + || cached.codexJSONLResumeState?.offset == startOffset + let isResumablePartial = cached.codexScanComplete == false + && cached.codexScanFileId != nil + && cached.codexScanFileId == input.metadata.fileId + && cached.codexScanTargetSize == input.metadata.size + && cached.mtimeUnixMs == input.metadata.mtimeUnixMs + && hasMatchingResumeOffset + if cached.codexScanComplete == false, !isResumablePartial { + return false + } + if !isResumablePartial, try Self.codexFileIsSubagentThread( fileURL: input.fileURL, checkCancellation: context.checkCancellation) { return false } - let startOffset = cached.parsedBytes ?? cached.size let initialCountedTotals = cached.lastCountedTotals ?? cached.lastTotals let initialRawTotalsBaseline = cached.lastRawTotalsBaseline ?? cached.lastTotals let initialHasDivergentTotals = cached.hasDivergentTotals ?? (cached.lastTotals == nil) @@ -994,11 +1074,13 @@ extension CostUsageScanner { (cached.hasInterleavedTotals == true && cached.lastRawTotalsWatermark == nil) || (cached.lastRawTotalsWatermark != nil && cached.hasInterleavedTotals == nil) || (initialHasDivergentTotals && cached.lastRawTotalsWatermark == nil) - let canIncremental = input.metadata.size > cached.size && startOffset > 0 + let canIncremental = startOffset > 0 && startOffset <= input.metadata.size - && initialCountedTotals != nil - && cached.forkedFromId == nil - && !hasIncompleteInterleaveState + && (isResumablePartial + || (input.metadata.size > cached.size + && initialCountedTotals != nil + && cached.forkedFromId == nil + && !hasIncompleteInterleaveState)) guard canIncremental else { return false } let delta = try Self.parseCodexFileCancellable( @@ -1014,12 +1096,28 @@ extension CostUsageScanner { initialHasInterleavedTotals: cached.hasInterleavedTotals ?? false, initialCodexTurnID: cached.lastCodexTurnID, initialCodexUsageRowIndex: Self.nextCodexUsageRowIndex(cached.codexRows), + initialBufferedSubagentLines: cached.codexBufferedSubagentLines, + initialJSONLResumeState: cached.codexJSONLResumeState, + maxBytesToRead: maxBytesToRead, checkCancellation: context.checkCancellation) - if delta.forkedFromId != nil { + if delta.forkedFromId != nil, !isResumablePartial { return false } - let sessionId = delta.sessionId ?? cached.sessionId + let migrated = Self.codexFileUsageWithCostCache(cached, context: context) + let cachedSessionMetadata = migrated.codexSession ?? CostUsageCodexSessionMetadata( + sessionId: migrated.sessionId, + forkedFromId: migrated.forkedFromId, + cwd: nil, + title: nil, + startedAtUnixMs: nil, + latestActivityUnixMs: nil) + let codexSession = cachedSessionMetadata.merging(delta.codexSession) + let sessionId = codexSession.sessionId ?? delta.sessionId ?? cached.sessionId let projectPath = delta.projectPath ?? cached.projectPath + let forkBaselineDependencyKey = Self.codexForkBaselineDependencyKey( + parentSessionId: delta.forkedFromId, + dependsOnParentTotals: delta.dependsOnParentTotals, + inheritedResolver: context.resources.inheritedResolver) let canonicalProjectPath = delta.projectPath.map { context.resources.projectPathResolver.canonicalProjectPath(for: $0) } ?? cached.canonicalProjectPath ?? context.resources.projectPathResolver.canonicalProjectPath(for: projectPath) @@ -1046,7 +1144,6 @@ extension CostUsageScanner { fileIdentity: input.metadata.path, state: &state) - let migrated = Self.codexFileUsageWithCostCache(cached, context: context) let migratedCached = sessionAlreadyContributed ? Self.codexFileUsageByFilteringRows(migrated, rows: retainedCachedRows, context: context) : migrated @@ -1087,9 +1184,11 @@ extension CostUsageScanner { hasInterleavedTotals: delta.hasInterleavedTotals, lastCodexTurnID: delta.lastCodexTurnID, sessionId: sessionId, - forkedFromId: delta.forkedFromId ?? migratedCached.forkedFromId, + forkedFromId: codexSession.forkedFromId ?? delta.forkedFromId ?? migratedCached.forkedFromId, + forkBaselineDependencyKey: forkBaselineDependencyKey ?? migratedCached.forkBaselineDependencyKey, projectPath: projectPath, canonicalProjectPath: canonicalProjectPath, + codexSession: codexSession.isEmpty ? nil : codexSession, codexCostNanos: Self.codexMergedCostMap( migratedCached.codexCostNanos, deltaRows: uniqueRows, @@ -1111,7 +1210,17 @@ extension CostUsageScanner { migratedCached.codexPriorityTokens, splitMaps.priorityTokens), codexTurnIDs: Self.mergeCodexTurnIDs(migratedCached.codexTurnIDs, rows: uniqueRows), - codexRows: Self.mergeCodexRows(retainedCachedRows, rows: uniqueRows, sessionId: sessionId)) + codexRows: Self.codexRowsWithPricingAudit( + Self.mergeCodexRows(retainedCachedRows, rows: uniqueRows, sessionId: sessionId) ?? [], + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot), + codexScanFileId: input.metadata.fileId, + codexScanTargetSize: input.metadata.size, + codexScanComplete: delta.parsedBytes >= input.metadata.size && delta.jsonlResumeState == nil, + codexJSONLResumeState: delta.jsonlResumeState, + codexBufferedSubagentLines: delta.bufferedSubagentLines) + .refreshingCodexWorkspaceUsageFingerprint() Self.rememberScannedCodexFile( input: input, session: CodexScannedSession(id: sessionId, days: mergedDays), @@ -1125,7 +1234,8 @@ extension CostUsageScanner { input: CodexFileScanInput, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) throws + state: inout CodexScanState, + maxBytesToRead: Int64? = nil) throws { try context.checkCancellation?() if let cached = input.cached { @@ -1139,13 +1249,22 @@ extension CostUsageScanner { let parsed = try Self.parseCodexFileCancellable( fileURL: input.fileURL, range: context.range, + maxBytesToRead: maxBytesToRead, inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:), checkCancellation: context.checkCancellation) let forkBaselineDependencyKey = Self.codexForkBaselineDependencyKey( parentSessionId: parsed.forkedFromId, dependsOnParentTotals: parsed.dependsOnParentTotals, inheritedResolver: context.resources.inheritedResolver) - let sessionId = parsed.sessionId ?? input.cached?.sessionId + let cachedSessionMetadata = input.cached?.codexSession ?? CostUsageCodexSessionMetadata( + sessionId: input.cached?.sessionId, + forkedFromId: input.cached?.forkedFromId, + cwd: nil, + title: nil, + startedAtUnixMs: nil, + latestActivityUnixMs: nil) + let parsedCodexSession = cachedSessionMetadata.merging(parsed.codexSession) + let sessionId = parsedCodexSession.sessionId ?? parsed.sessionId ?? input.cached?.sessionId let projectPath = parsed.projectPath ?? input.cached?.projectPath let canonicalProjectPath = parsed.projectPath.map { context.resources.projectPathResolver.canonicalProjectPath(for: $0) @@ -1159,7 +1278,8 @@ extension CostUsageScanner { if let sessionId, state.contributingSessionIds.contains(sessionId), uniqueRows.isEmpty, - usageDays.isEmpty + usageDays.isEmpty, + parsed.bufferedSubagentLines == nil { cache.files.removeValue(forKey: input.metadata.path) return @@ -1188,10 +1308,11 @@ extension CostUsageScanner { hasInterleavedTotals: parsed.hasInterleavedTotals, lastCodexTurnID: parsed.lastCodexTurnID, sessionId: sessionId, - forkedFromId: parsed.forkedFromId, + forkedFromId: parsedCodexSession.forkedFromId ?? parsed.forkedFromId, forkBaselineDependencyKey: forkBaselineDependencyKey, projectPath: projectPath, canonicalProjectPath: canonicalProjectPath, + codexSession: parsedCodexSession.isEmpty ? nil : parsedCodexSession, codexCostNanos: Self.mergeCostMaps( context.dropDeferredCodexRows ? nil @@ -1236,7 +1357,17 @@ extension CostUsageScanner { : Self.mergeCodexTurnIDs(migratedCached?.codexTurnIDs, rows: uniqueRows), codexRows: context.dropDeferredCodexRows ? nil - : Self.mergeCodexRows(migratedCached?.codexRows, rows: uniqueRows, sessionId: sessionId)) + : Self.codexRowsWithPricingAudit( + Self.mergeCodexRows(migratedCached?.codexRows, rows: uniqueRows, sessionId: sessionId) ?? [], + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot), + codexScanFileId: input.metadata.fileId, + codexScanTargetSize: input.metadata.size, + codexScanComplete: parsed.parsedBytes >= input.metadata.size && parsed.jsonlResumeState == nil, + codexJSONLResumeState: parsed.jsonlResumeState, + codexBufferedSubagentLines: parsed.bufferedSubagentLines) + .refreshingCodexWorkspaceUsageFingerprint() Self.applyFileDays(cache: &cache, fileDays: cache.files[input.metadata.path]?.days ?? [:], sign: 1) Self.rememberScannedCodexFile( input: input, diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index bfbdafd98..df42d4c24 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -33,6 +33,14 @@ enum CostUsageScanner { var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. var forceRescan: Bool = false + /// Maximum bounded slice read from one Codex rollout per refresh. Larger files + /// resume from cached progress on later refreshes. Default 256 MiB. + var maxCodexSessionFileBytes: Int64 = 256 * 1024 * 1024 + /// Soft budget for newly-read Codex session bytes in one refresh. + /// Remaining dirty files are deferred to later refreshes. Default 512 MiB. + var maxCodexScanBytesPerRefresh: Int64 = 512 * 1024 * 1024 + /// Prefer newest session files first so recent usage lands before catch-up work. + var preferNewestCodexSessionsFirst: Bool = true init( codexSessionsRoot: URL? = nil, @@ -40,7 +48,10 @@ enum CostUsageScanner { cacheRoot: URL? = nil, codexTraceDatabaseURL: URL? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, - forceRescan: Bool = false) + forceRescan: Bool = false, + maxCodexSessionFileBytes: Int64 = 256 * 1024 * 1024, + maxCodexScanBytesPerRefresh: Int64 = 512 * 1024 * 1024, + preferNewestCodexSessionsFirst: Bool = true) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots @@ -48,6 +59,50 @@ enum CostUsageScanner { self.codexTraceDatabaseURL = codexTraceDatabaseURL self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan + self.maxCodexSessionFileBytes = max(0, maxCodexSessionFileBytes) + self.maxCodexScanBytesPerRefresh = max(0, maxCodexScanBytesPerRefresh) + self.preferNewestCodexSessionsFirst = preferNewestCodexSessionsFirst + } + } + + /// Per-refresh work limiter for Codex cost scans. Prevents multi-GB rollout corpora from + /// monopolizing a core for hours while still allowing progressive catch-up. + final class CodexScanBudget: @unchecked Sendable { + let maxFileBytes: Int64 + let maxBytesPerRefresh: Int64 + private(set) var bytesConsumed: Int64 = 0 + private(set) var resumedPartialFileCount = 0 + private(set) var deferredByBudgetFileCount = 0 + + init(maxFileBytes: Int64, maxBytesPerRefresh: Int64) { + self.maxFileBytes = max(0, maxFileBytes) + self.maxBytesPerRefresh = max(0, maxBytesPerRefresh) + } + + enum Admission { + case allow(Int64) + case deferBudget + } + + func admit(workBytes: Int64) -> Admission { + let work = max(0, workBytes) + let refreshRemaining = self.maxBytesPerRefresh > 0 + ? max(0, self.maxBytesPerRefresh - self.bytesConsumed) + : Int64.max + if work > 0, refreshRemaining == 0 { + self.deferredByBudgetFileCount += 1 + return .deferBudget + } + let fileAllowance = self.maxFileBytes > 0 ? self.maxFileBytes : Int64.max + let allowance = min(work, fileAllowance, refreshRemaining) + if allowance < work { + self.resumedPartialFileCount += 1 + } + return .allow(allowance) + } + + func consume(workBytes: Int64) { + self.bytesConsumed += max(0, workBytes) } } @@ -67,17 +122,59 @@ enum CostUsageScanner { let forkedFromId: String? let dependsOnParentTotals: Bool let projectPath: String? + let codexSession: CostUsageCodexSessionMetadata let rows: [CodexUsageRow] + let jsonlResumeState: CostUsageJsonl.ResumeState? + let bufferedSubagentLines: [CodexBufferedFastLine]? } struct CodexUsageRow: Codable, Equatable { let day: String let model: String + let rawModel: String? let turnID: String? let eventIndex: Int? + let timestampUnixMs: Int64? let input: Int let cached: Int let output: Int + let reasoning: Int? + let knownCostNanos: Int64? + let unpricedTokens: Int? + let pricingModel: String? + let pricingMode: String? + + init( + day: String, + model: String, + rawModel: String? = nil, + turnID: String?, + eventIndex: Int?, + timestampUnixMs: Int64? = nil, + input: Int, + cached: Int, + output: Int, + reasoning: Int? = nil, + knownCostNanos: Int64? = nil, + unpricedTokens: Int? = nil, + pricingModel: String? = nil, + pricingMode: String? = nil) + { + self.day = day + self.model = model + self.rawModel = rawModel + self.turnID = turnID + self.eventIndex = eventIndex + self.timestampUnixMs = timestampUnixMs + self.input = input + self.cached = cached + self.output = output + self.reasoning = reasoning.map { min(max(0, $0), max(0, output)) } + self.knownCostNanos = knownCostNanos + self.unpricedTokens = unpricedTokens + self.pricingModel = pricingModel + self.pricingMode = pricingMode + } } struct CodexScanState { @@ -138,7 +235,8 @@ enum CostUsageScanner { CostUsageCodexTotals( input: lhs.input + rhs.input, cached: lhs.cached + rhs.cached, - output: lhs.output + rhs.output) + output: lhs.output + rhs.output, + reasoning: self.codexAddOptional(lhs.reasoning, rhs.reasoning)) } private static func codexMinTotals( @@ -148,18 +246,24 @@ enum CostUsageScanner { CostUsageCodexTotals( input: min(lhs.input, rhs.input), cached: min(lhs.cached, rhs.cached), - output: min(lhs.output, rhs.output)) + output: min(lhs.output, rhs.output), + reasoning: self.codexMinOptional(lhs.reasoning, rhs.reasoning)) } private static func codexTotalDelta( from baseline: CostUsageCodexTotals?, to current: CostUsageCodexTotals) -> CostUsageCodexTotals { + let reasoning = Self.codexOptionalDelta( + from: baseline?.reasoning, + to: current.reasoning, + hasBaseline: baseline != nil) let baseline = baseline ?? .init(input: 0, cached: 0, output: 0) return CostUsageCodexTotals( input: max(0, current.input - baseline.input), cached: max(0, current.cached - baseline.cached), - output: max(0, current.output - baseline.output)) + output: max(0, current.output - baseline.output), + reasoning: reasoning) } private static func codexDivergentTotalDelta( @@ -180,7 +284,11 @@ enum CostUsageScanner { return CostUsageCodexTotals( input: delta(raw: rawBaseline.input, counted: countedBaseline.input, current: current.input), cached: delta(raw: rawBaseline.cached, counted: countedBaseline.cached, current: current.cached), - output: delta(raw: rawBaseline.output, counted: countedBaseline.output, current: current.output)) + output: delta(raw: rawBaseline.output, counted: countedBaseline.output, current: current.output), + reasoning: Self.codexDivergentOptionalDelta( + raw: rawBaseline.reasoning, + counted: countedBaseline.reasoning, + current: current.reasoning)) } private static func codexMaxTotals( @@ -191,7 +299,8 @@ enum CostUsageScanner { return CostUsageCodexTotals( input: max(lhs.input, rhs.input), cached: max(lhs.cached, rhs.cached), - output: max(lhs.output, rhs.output)) + output: max(lhs.output, rhs.output), + reasoning: Self.codexMaxOptional(lhs.reasoning, rhs.reasoning)) } /// Post-latch totals containment for interleaved cumulative counters (issue #2037 Phase 1). @@ -218,7 +327,58 @@ enum CostUsageScanner { return CostUsageCodexTotals( input: component(water: watermark.input, counted: counted.input, current: current.input), cached: component(water: watermark.cached, counted: counted.cached, current: current.cached), - output: component(water: watermark.output, counted: counted.output, current: current.output)) + output: component(water: watermark.output, counted: counted.output, current: current.output), + reasoning: Self.codexContainedOptionalDelta( + water: watermark.reasoning, + counted: counted.reasoning, + current: current.reasoning)) + } + + private static func codexAddOptional(_ lhs: Int?, _ rhs: Int?) -> Int? { + guard let lhs, let rhs else { return nil } + return lhs + rhs + } + + private static func codexMinOptional(_ lhs: Int?, _ rhs: Int?) -> Int? { + guard let lhs, let rhs else { return nil } + return min(lhs, rhs) + } + + private static func codexMaxOptional(_ lhs: Int?, _ rhs: Int?) -> Int? { + switch (lhs, rhs) { + case let (lhs?, rhs?): max(lhs, rhs) + case let (lhs?, nil): lhs + case let (nil, rhs?): rhs + case (nil, nil): nil + } + } + + private static func codexSubtractOptional(_ value: Int?, _ baseline: Int?) -> Int? { + guard let value, let baseline else { return nil } + return max(0, value - baseline) + } + + private static func codexOptionalDelta(from baseline: Int?, to current: Int?, hasBaseline: Bool) -> Int? { + guard let current else { return nil } + if !hasBaseline { return current } + guard let baseline else { return nil } + return max(0, current - baseline) + } + + private static func codexDivergentOptionalDelta(raw: Int?, counted: Int?, current: Int?) -> Int? { + guard let raw, let counted, let current else { return nil } + if current >= raw { + return max(0, current - raw) + } + return max(0, current - counted) + } + + private static func codexContainedOptionalDelta(water: Int?, counted: Int?, current: Int?) -> Int? { + guard let water, let counted, let current else { return nil } + if current >= water { + return max(0, current - max(water, counted)) + } + return max(0, current - counted) } /// Post-latch event delta: contained totals growth, optionally capped by `last`. @@ -264,7 +424,7 @@ enum CostUsageScanner { } func isSeen(_ totals: CostUsageCodexTotals) -> Bool { - self.seenRawTotals.contains(totals) + self.seenRawTotals.contains { CostUsageScanner.codexTotalsEqual($0, totals) } } /// Latches interleaved mode when any component of an observed cumulative snapshot drops @@ -284,7 +444,7 @@ enum CostUsageScanner { /// value for best-effort re-emission suppression. Call after computing the event's delta. mutating func commitObserved(_ totals: CostUsageCodexTotals) { self.raiseWatermark(to: totals) - if !self.seenRawTotals.contains(totals) { + if !self.seenRawTotals.contains(where: { CostUsageScanner.codexTotalsEqual($0, totals) }) { self.seenRawTotals.append(totals) if self.seenRawTotals.count > Self.seenRawTotalsLimit { self.seenRawTotals.removeFirst(self.seenRawTotals.count - Self.seenRawTotalsLimit) @@ -313,7 +473,12 @@ enum CostUsageScanner { last: CostUsageCodexTotals?, total: CostUsageCodexTotals?) -> CostUsageCodexTotals { - let base = self.countedTotals ?? .init(input: 0, cached: 0, output: 0) + let hasReasoning = last?.reasoning != nil || total?.reasoning != nil + let base = self.countedTotals ?? .init( + input: 0, + cached: 0, + output: 0, + reasoning: hasReasoning ? 0 : nil) if let total { // Best-effort exact re-emission suppression (precision only; containment is load-bearing). if self.tracker.isSeen(total) { @@ -408,6 +573,7 @@ enum CostUsageScanner { let changedPriorityTurnIDs: Set let resources: CodexScanResources let checkCancellation: CancellationCheck? + let scanBudget: CodexScanBudget? } final class CodexCanonicalProjectPathResolver { @@ -619,11 +785,17 @@ enum CostUsageScanner { private let fileIndex: CodexSessionFileIndex private let checkCancellation: CancellationCheck? + private let scanBudget: CodexScanBudget? private var snapshotResolutions: [String: SnapshotResolution] = [:] - init(fileIndex: CodexSessionFileIndex, checkCancellation: CancellationCheck?) { + init( + fileIndex: CodexSessionFileIndex, + checkCancellation: CancellationCheck?, + scanBudget: CodexScanBudget? = nil) + { self.fileIndex = fileIndex self.checkCancellation = checkCancellation + self.scanBudget = scanBudget } func inheritedTotals(for sessionId: String, atOrBefore cutoffTimestamp: String) throws -> CodexForkBaseline { @@ -693,6 +865,43 @@ enum CostUsageScanner { return resolution } + let parentMetadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + if let budget = self.scanBudget { + switch budget.admit(workBytes: parentMetadata.size) { + case let .allow(allowance) where allowance >= parentMetadata.size: + break + case .allow: + CostUsageScanner.log.warning( + "Deferring oversized Codex parent baseline read while its file scan resumes", + metadata: [ + "sessionId": sessionId, + "path": fileURL.path, + "bytes": "\(parentMetadata.size)", + "slice": "\(budget.maxFileBytes)", + ]) + let resolution = SnapshotResolution( + dependencyKey: self.dependencyKey(for: sessionId, fileURL: fileURL), + snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + return resolution + case .deferBudget: + CostUsageScanner.log.debug( + "Deferring Codex parent session baseline read until a later refresh", + metadata: [ + "sessionId": sessionId, + "path": fileURL.path, + "pendingBytes": "\(parentMetadata.size)", + "consumed": "\(budget.bytesConsumed)", + "limit": "\(budget.maxBytesPerRefresh)", + ]) + let resolution = SnapshotResolution( + dependencyKey: self.dependencyKey(for: sessionId, fileURL: fileURL), + snapshots: nil) + self.snapshotResolutions[sessionId] = resolution + return resolution + } + } + for _ in 0..<2 { let dependencyKeyBeforeParse = self.dependencyKey(for: sessionId, fileURL: fileURL) let parsed = try CostUsageScanner.parseCodexTokenSnapshots( @@ -709,6 +918,7 @@ enum CostUsageScanner { dependencyKey: dependencyKeyAfterParse, snapshots: nil) self.snapshotResolutions[sessionId] = resolution + self.scanBudget?.consume(workBytes: parentMetadata.size) return resolution } if parsedSessionId != sessionId { @@ -723,12 +933,14 @@ enum CostUsageScanner { dependencyKey: dependencyKeyAfterParse, snapshots: nil) self.snapshotResolutions[sessionId] = resolution + self.scanBudget?.consume(workBytes: parentMetadata.size) return resolution } let resolution = SnapshotResolution( dependencyKey: dependencyKeyAfterParse, snapshots: parsed.snapshots) self.snapshotResolutions[sessionId] = resolution + self.scanBudget?.consume(workBytes: parentMetadata.size) return resolution } @@ -825,12 +1037,12 @@ enum CostUsageScanner { options: filtered, checkCancellation: checkCancellation) case .openai, .azureopenai, .clinepass, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, - .alibabatokenplan, .factory, + .alibabatokenplan, .qwencloud, .factory, .copilot, .devin, .minimax, .manus, .kilo, .kiro, .kimi, .moonshot, .augment, .jetbrains, .amp, .ollama, .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .sakana, .abacus, .mistral, .deepseek, .deepinfra, .codebuff, .crof, .windsurf, .zed, .venice, .commandcode, .qoder, .stepfun, .bedrock, .grok, .groq, .llmproxy, .litellm, .deepgram, .poe, .chutes, .neuralwatt, - .clawrouter, .longcat, .sub2api, .wayfinder, .zenmux, .aiand: + .clawrouter, .longcat, .sub2api, .wayfinder, .zenmux, .aiand, .zoommate: return emptyReport } } @@ -1291,7 +1503,7 @@ enum CostUsageScanner { return String(filename[matchRange]) } - private struct CodexSessionMetadata { + struct CodexSessionMetadata: Codable { let sessionId: String? let forkedFromId: String? let forkTimestamp: String? @@ -1299,7 +1511,14 @@ enum CostUsageScanner { let isSubagentThread: Bool } - private struct CodexTokenCountRecord { + struct CodexTurnContextMetadata: Codable { + let timestamp: String? + let model: String? + let cwd: String? + let title: String? + } + + struct CodexTokenCountRecord: Codable { let timestamp: String let model: String? let turnID: String? @@ -1307,9 +1526,9 @@ enum CostUsageScanner { let total: CostUsageCodexTotals? } - private enum CodexFastLine { + enum CodexFastLine: Codable { case sessionMeta(CodexSessionMetadata) - case turnContext(model: String?) + case turnContext(CodexTurnContextMetadata) case interAgentCommunication(triggerTurn: Bool) case taskStarted(turnID: String?) case tokenCount(CodexTokenCountRecord) @@ -1324,7 +1543,7 @@ enum CostUsageScanner { } } - private struct CodexBufferedFastLine { + struct CodexBufferedFastLine: Codable { let lineIndex: Int let line: CodexFastLine } @@ -1340,6 +1559,7 @@ enum CostUsageScanner { private static let codexJSONFieldModel = Array("model".utf8) private static let codexJSONFieldModelName = Array("model_name".utf8) private static let codexJSONFieldOutputTokens = Array("output_tokens".utf8) + private static let codexJSONFieldReasoningOutputTokens = Array("reasoning_output_tokens".utf8) private static let codexJSONFieldParentSessionId = Array("parent_session_id".utf8) private static let codexJSONFieldParentSessionIdCamel = Array("parentSessionId".utf8) private static let codexJSONFieldPayload = Array("payload".utf8) @@ -1348,12 +1568,16 @@ enum CostUsageScanner { private static let codexJSONFieldSessionId = Array("session_id".utf8) private static let codexJSONFieldSessionIdCamel = Array("sessionId".utf8) private static let codexJSONFieldTimestamp = Array("timestamp".utf8) + private static let codexJSONFieldTitle = Array("title".utf8) + private static let codexJSONFieldName = Array("name".utf8) private static let codexJSONFieldTotalTokenUsage = Array("total_token_usage".utf8) private static let codexJSONFieldTriggerTurn = Array("trigger_turn".utf8) private static let codexJSONFieldTurnId = Array("turn_id".utf8) private static let codexJSONFieldTurnIdCamel = Array("turnId".utf8) private static let codexJSONFieldType = Array("type".utf8) private static let codexJSONFieldCwd = Array("cwd".utf8) + private static let codexJSONFieldCurrentWorkingDirectory = Array("current_working_directory".utf8) + private static let codexJSONFieldCurrentWorkingDirectoryCamel = Array("currentWorkingDirectory".utf8) static func codexModelEvidence(_ raw: String?) -> String? { guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } @@ -1533,7 +1757,12 @@ enum CostUsageScanner { Self .extractJSONByteIntField(Self.codexJSONFieldOutputTokens, from: bytes, in: objectRange, atDepth: 1) ?? 0) - return CostUsageCodexTotals(input: input, cached: cached, output: output) + let reasoning = Self.extractJSONByteIntField( + Self.codexJSONFieldReasoningOutputTokens, + from: bytes, + in: objectRange, + atDepth: 1).map { min(max(0, $0), output) } + return CostUsageCodexTotals(input: input, cached: cached, output: output, reasoning: reasoning) } private static func codexInterAgentCommunication( @@ -1554,6 +1783,7 @@ enum CostUsageScanner { return .interAgentCommunication(triggerTurn: triggerTurn) } + // swiftlint:disable:next function_body_length private static func parseCodexFastLine(_ bytes: Data) -> CodexFastLine? { bytes.withUnsafeBytes { rawBytes in let rawBuffer = rawBytes.bindMemory(to: UInt8.self) @@ -1593,12 +1823,23 @@ enum CostUsageScanner { } ?? false)) case "turn_context": + let timestamp = Self.extractJSONByteStringField( + Self.codexJSONFieldTimestamp, + from: rawBuffer, + in: objectRange, + atDepth: 1) guard let payloadRange = Self.extractJSONByteObjectField( Self.codexJSONFieldPayload, from: rawBuffer, in: objectRange, atDepth: 1) - else { return .turnContext(model: nil) } + else { + return .turnContext(CodexTurnContextMetadata( + timestamp: timestamp, + model: nil, + cwd: nil, + title: nil)) + } let infoRange = Self.extractJSONByteObjectField( Self.codexJSONFieldInfo, from: rawBuffer, @@ -1629,7 +1870,36 @@ enum CostUsageScanner { in: $0, atDepth: 1) }) - return .turnContext(model: model) + let cwd = Self.extractJSONByteStringField( + Self.codexJSONFieldCwd, + from: rawBuffer, + in: payloadRange, + atDepth: 1) + ?? Self.extractJSONByteStringField( + Self.codexJSONFieldCurrentWorkingDirectory, + from: rawBuffer, + in: payloadRange, + atDepth: 1) + ?? Self.extractJSONByteStringField( + Self.codexJSONFieldCurrentWorkingDirectoryCamel, + from: rawBuffer, + in: payloadRange, + atDepth: 1) + let title = Self.extractJSONByteStringField( + Self.codexJSONFieldTitle, + from: rawBuffer, + in: payloadRange, + atDepth: 1) + ?? Self.extractJSONByteStringField( + Self.codexJSONFieldName, + from: rawBuffer, + in: payloadRange, + atDepth: 1) + return .turnContext(CodexTurnContextMetadata( + timestamp: timestamp, + model: model, + cwd: cwd, + title: title)) case "inter_agent_communication_metadata": // Compact Codex JSONL uses this exact spelling. Whitespace/escaped variants fall @@ -1941,16 +2211,22 @@ enum CostUsageScanner { } let total = (info["total_token_usage"] as? [String: Any]).map { - CostUsageCodexTotals( + let output = toInt($0["output_tokens"]) + return CostUsageCodexTotals( input: toInt($0["input_tokens"]), cached: toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"]), - output: toInt($0["output_tokens"])) + output: output, + reasoning: ($0["reasoning_output_tokens"] as? NSNumber) + .map { min(max(0, $0.intValue), max(0, output)) }) } let last = (info["last_token_usage"] as? [String: Any]).map { - CostUsageCodexTotals( + let output = max(0, toInt($0["output_tokens"])) + return CostUsageCodexTotals( input: max(0, toInt($0["input_tokens"])), cached: max(0, toInt($0["cached_input_tokens"] ?? $0["cache_read_input_tokens"])), - output: max(0, toInt($0["output_tokens"]))) + output: output, + reasoning: ($0["reasoning_output_tokens"] as? NSNumber) + .map { min(max(0, $0.intValue), output) }) } appendSnapshot(timestamp: timestamp, last: last, total: total) } @@ -2010,7 +2286,16 @@ enum CostUsageScanner { forkedFromId: nil, dependsOnParentTotals: false, projectPath: nil, - rows: []) + codexSession: CostUsageCodexSessionMetadata( + sessionId: nil, + forkedFromId: nil, + cwd: nil, + title: nil, + startedAtUnixMs: nil, + latestActivityUnixMs: nil), + rows: [], + jsonlResumeState: nil, + bufferedSubagentLines: nil) } // swiftlint:disable:next cyclomatic_complexity function_body_length @@ -2027,6 +2312,9 @@ enum CostUsageScanner { initialHasInterleavedTotals: Bool = false, initialCodexTurnID: String? = nil, initialCodexUsageRowIndex: Int = 0, + initialBufferedSubagentLines: [CodexBufferedFastLine]? = nil, + initialJSONLResumeState: CostUsageJsonl.ResumeState? = nil, + maxBytesToRead: Int64? = nil, inheritedTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, checkCancellation: CancellationCheck? = nil) throws -> CodexParseResult { @@ -2043,6 +2331,13 @@ enum CostUsageScanner { var candidateBoundaryDependsOnParentTotals = false var parentConfirmedLocalBoundary = false var suppressUnownedCopiedPrefix = false + var codexSession = CostUsageCodexSessionMetadata( + sessionId: nil, + forkedFromId: nil, + cwd: nil, + title: nil, + startedAtUnixMs: nil, + latestActivityUnixMs: nil) var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? var forkBaselineResolved = false @@ -2075,6 +2370,41 @@ enum CostUsageScanner { days[dayKey] = dayModels } + func sanitizedString(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func unixMilliseconds(from timestamp: String?) -> Int64? { + guard let timestamp, + let date = Self.dateFromTimestamp(timestamp) + else { return nil } + return Int64((date.timeIntervalSince1970 * 1000).rounded()) + } + + func observeTimestamp(_ timestamp: String?) { + guard let unixMs = unixMilliseconds(from: timestamp) else { return } + codexSession.startedAtUnixMs = switch codexSession.startedAtUnixMs { + case let current?: min(current, unixMs) + case nil: unixMs + } + codexSession.latestActivityUnixMs = switch codexSession.latestActivityUnixMs { + case let current?: max(current, unixMs) + case nil: unixMs + } + } + + func observeCwd(_ value: String?) { + guard let value = sanitizedString(value) else { return } + codexSession.cwd = value + } + + func observeTitle(_ value: String?) { + guard let value = sanitizedString(value) else { return } + codexSession.title = value + } + func resolveForkBaseline(parentSessionId: String, forkedAt: String) throws { guard !forkBaselineResolved else { return } guard let inheritedTotalsResolver else { return } @@ -2115,12 +2445,17 @@ enum CostUsageScanner { guard CodexSubagentRolloutShape.sameConcreteSessionID(metadata.sessionId, sessionId) else { return } if forkedFromId == nil, let enrichedParentID = metadata.forkedFromId { forkedFromId = enrichedParentID + codexSession.forkedFromId = enrichedParentID forkTimestamp = metadata.forkTimestamp ?? forkTimestamp try configureForkAccountingIfReady() } if projectPath == nil { projectPath = metadata.projectPath } + observeTimestamp(metadata.forkTimestamp) + if codexSession.cwd == nil { + observeCwd(metadata.projectPath) + } return } didCaptureLeafMetadata = true @@ -2128,12 +2463,17 @@ enum CostUsageScanner { forkedFromId = metadata.forkedFromId forkTimestamp = metadata.forkTimestamp projectPath = metadata.projectPath + codexSession.sessionId = metadata.sessionId + codexSession.forkedFromId = metadata.forkedFromId + observeTimestamp(metadata.forkTimestamp) + observeCwd(metadata.projectPath) isSubagentThread = metadata.isSubagentThread try configureForkAccountingIfReady() } // swiftlint:disable:next function_body_length func handleTokenCount(_ record: CodexTokenCountRecord) throws { + observeTimestamp(record.timestamp) guard let dayKey = Self.dayKeyFromTimestamp(record.timestamp) ?? Self.dayKeyFromParsedISO(record.timestamp) else { return } guard !suppressUnownedCopiedPrefix else { return } @@ -2147,6 +2487,7 @@ enum CostUsageScanner { var deltaInput = 0 var deltaCached = 0 var deltaOutput = 0 + var deltaReasoning: Int? func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { guard var remaining = remainingInheritedTotals else { return rawDelta } @@ -2154,11 +2495,13 @@ enum CostUsageScanner { let adjusted = CostUsageCodexTotals( input: max(0, rawDelta.input - remaining.input), cached: max(0, rawDelta.cached - remaining.cached), - output: max(0, rawDelta.output - remaining.output)) + output: max(0, rawDelta.output - remaining.output), + reasoning: Self.codexSubtractOptional(rawDelta.reasoning, remaining.reasoning)) remaining.input = max(0, remaining.input - rawDelta.input) remaining.cached = max(0, remaining.cached - rawDelta.cached) remaining.output = max(0, remaining.output - rawDelta.output) + remaining.reasoning = Self.codexSubtractOptional(remaining.reasoning, rawDelta.reasoning) remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, remaining.output == 0 { @@ -2177,7 +2520,8 @@ enum CostUsageScanner { return CostUsageCodexTotals( input: max(0, rawTotals.input - inheritedTotals.input), cached: max(0, rawTotals.cached - inheritedTotals.cached), - output: max(0, rawTotals.output - inheritedTotals.output)) + output: max(0, rawTotals.output - inheritedTotals.output), + reasoning: Self.codexSubtractOptional(rawTotals.reasoning, inheritedTotals.reasoning)) } if let adjustedTotal { @@ -2216,7 +2560,12 @@ enum CostUsageScanner { deltaInput = delta.input deltaCached = delta.cached deltaOutput = delta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + deltaReasoning = delta.reasoning + let prev = previousTotals ?? .init( + input: 0, + cached: 0, + output: 0, + reasoning: delta.reasoning == nil ? nil : 0) previousTotals = Self.codexAddTotals(prev, delta) rawTotalsBaseline = rawBaseline if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { @@ -2244,7 +2593,12 @@ enum CostUsageScanner { deltaInput = adjustedDelta.input deltaCached = adjustedDelta.cached deltaOutput = adjustedDelta.output - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + deltaReasoning = adjustedDelta.reasoning + let prev = previousTotals ?? .init( + input: 0, + cached: 0, + output: 0, + reasoning: adjustedDelta.reasoning == nil ? nil : 0) previousTotals = Self.codexAddTotals(prev, adjustedDelta) rawTotalsBaseline = previousTotals } @@ -2271,7 +2625,11 @@ enum CostUsageScanner { let rawDelta = last let hadRemainingInheritedTotals = remainingInheritedTotals != nil var adjustedDelta = adjustedLastDelta(rawDelta) - let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + let prev = previousTotals ?? .init( + input: 0, + cached: 0, + output: 0, + reasoning: adjustedDelta.reasoning == nil ? nil : 0) if let currentTotals = adjustedTotal, !hasUnresolvedForkBaseline { if tracker.sawInterleavedTotals { @@ -2301,6 +2659,7 @@ enum CostUsageScanner { deltaInput = adjustedDelta.input deltaCached = adjustedDelta.cached deltaOutput = adjustedDelta.output + deltaReasoning = adjustedDelta.reasoning previousTotals = countedTotals rawTotalsBaseline = countedTotals tracker.raiseWatermark(to: countedTotals) @@ -2332,11 +2691,14 @@ enum CostUsageScanner { rows.append(CodexUsageRow( day: dayKey, model: normModel, + rawModel: model, turnID: record.turnID ?? currentTurnID, eventIndex: eventIndex, + timestampUnixMs: unixMilliseconds(from: record.timestamp), input: deltaInput, cached: deltaCached, - output: deltaOutput)) + output: deltaOutput, + reasoning: deltaReasoning)) } } @@ -2344,9 +2706,13 @@ enum CostUsageScanner { switch fastLine { case let .sessionMeta(metadata): try handleSessionMetadata(metadata) - case let .turnContext(model): - if let model { - currentModel = model + case let .turnContext(metadata): + observeTimestamp(metadata.timestamp) + observeCwd(metadata.cwd) + observeTitle(metadata.title) + if let model = metadata.model { + // An explicitly blank context clears stale model evidence; an omitted field preserves it. + currentModel = sanitizedString(model) } case .interAgentCommunication: break @@ -2367,12 +2733,17 @@ enum CostUsageScanner { // already use maxLineBytes here. let prefixBytes = maxLineBytes - var pendingSubagentLines: [CodexBufferedFastLine]? + var pendingSubagentLines = initialBufferedSubagentLines - if startOffset == 0, - let metadata = try Self.parseCodexSessionMetadata( - fileURL: fileURL, - checkCancellation: checkCancellation) + if let initialBufferedSubagentLines, startOffset > 0 { + for buffered in initialBufferedSubagentLines { + guard case let .sessionMeta(metadata) = buffered.line else { continue } + try handleSessionMetadata(metadata) + } + } else if startOffset == 0, + let metadata = try Self.parseCodexSessionMetadata( + fileURL: fileURL, + checkCancellation: checkCancellation) { try handleSessionMetadata(metadata) if metadata.isSubagentThread { @@ -2391,13 +2762,17 @@ enum CostUsageScanner { } var parsedBytes: Int64 - var physicalLineIndex = 0 + let targetSize = Self.codexFileMetadata(fileURL: fileURL).size + var physicalLineIndex = (initialBufferedSubagentLines?.last?.lineIndex ?? -1) + 1 + var jsonlResumeState = initialJSONLResumeState do { - parsedBytes = try CostUsageJsonl.scan( + let scanProgress = try CostUsageJsonl.scanBounded( fileURL: fileURL, offset: startOffset, maxLineBytes: maxLineBytes, prefixBytes: prefixBytes, + maxBytesToRead: maxBytesToRead, + resumeState: initialJSONLResumeState, checkCancellation: checkCancellation, onLine: { line in let lineIndex = physicalLineIndex @@ -2414,7 +2789,11 @@ enum CostUsageScanner { if truncatedTurnContext.isValid { do { try routeFastLine( - .turnContext(model: truncatedTurnContext.model), + .turnContext(CodexTurnContextMetadata( + timestamp: nil, + model: truncatedTurnContext.model, + cwd: nil, + title: nil)), lineIndex: lineIndex) } catch { deferredError = error @@ -2507,17 +2886,27 @@ enum CostUsageScanner { } if type == "turn_context" { - var model: String? + var metadata = CodexTurnContextMetadata( + timestamp: tsText, + model: nil, + cwd: nil, + title: nil) if let payload = obj["payload"] as? [String: Any] { let info = payload["info"] as? [String: Any] - model = Self.codexTurnContextModel( - payloadModel: payload["model"] as? String, - payloadModelName: payload["model_name"] as? String, - infoModel: info?["model"] as? String, - infoModelName: info?["model_name"] as? String) + metadata = CodexTurnContextMetadata( + timestamp: tsText, + model: Self.codexTurnContextModel( + payloadModel: payload["model"] as? String, + payloadModelName: payload["model_name"] as? String, + infoModel: info?["model"] as? String, + infoModelName: info?["model_name"] as? String), + cwd: payload["cwd"] as? String + ?? payload["current_working_directory"] as? String + ?? payload["currentWorkingDirectory"] as? String, + title: payload["title"] as? String ?? payload["name"] as? String) } do { - try routeFastLine(.turnContext(model: model), lineIndex: lineIndex) + try routeFastLine(.turnContext(metadata), lineIndex: lineIndex) } catch { deferredError = error } @@ -2552,10 +2941,13 @@ enum CostUsageScanner { } func tokenTotals(_ usage: [String: Any]) -> CostUsageCodexTotals { - CostUsageCodexTotals( + let output = max(0, toInt(usage["output_tokens"])) + return CostUsageCodexTotals( input: max(0, toInt(usage["input_tokens"])), cached: max(0, toInt(usage["cached_input_tokens"] ?? usage["cache_read_input_tokens"])), - output: max(0, toInt(usage["output_tokens"]))) + output: output, + reasoning: (usage["reasoning_output_tokens"] as? NSNumber) + .map { min(max(0, $0.intValue), output) }) } let record = CodexTokenCountRecord( @@ -2571,11 +2963,13 @@ enum CostUsageScanner { } } }) + parsedBytes = scanProgress.readOffset + jsonlResumeState = scanProgress.resumeState if let deferredError { throw deferredError } - if let pendingSubagentLines { + if let pendingSubagentLines, parsedBytes >= targetSize, jsonlResumeState == nil { // Same-leaf metadata can fill lineage fields after the opening record. Collect it // before replay so copied-prefix totals never run once on the wrong baseline, and // so an owned-suffix filter cannot discard the only fork identifier. @@ -2585,11 +2979,16 @@ enum CostUsageScanner { else { continue } if forkedFromId == nil, let enrichedParentID = metadata.forkedFromId { forkedFromId = enrichedParentID + codexSession.forkedFromId = enrichedParentID forkTimestamp = metadata.forkTimestamp ?? forkTimestamp } if projectPath == nil { projectPath = metadata.projectPath } + observeTimestamp(metadata.forkTimestamp) + if codexSession.cwd == nil { + observeCwd(metadata.projectPath) + } } let observations = pendingSubagentLines.compactMap { buffered -> CodexSubagentRolloutShape .Observation? in @@ -2685,6 +3084,7 @@ enum CostUsageScanner { "Codex cost usage failed while scanning session file", metadata: ["path": fileURL.path, "error": error.localizedDescription]) parsedBytes = startOffset + jsonlResumeState = initialJSONLResumeState } return CodexParseResult( @@ -2707,7 +3107,10 @@ enum CostUsageScanner { && (candidateBoundaryDependsOnParentTotals || (subagentCounterSemantics != .independent && !usesLocalSubagentBoundary)), projectPath: projectPath, - rows: rows) + codexSession: codexSession, + rows: rows, + jsonlResumeState: jsonlResumeState, + bufferedSubagentLines: parsedBytes < targetSize || jsonlResumeState != nil ? pendingSubagentLines : nil) } private static func codexTurnID(from payload: [String: Any]) -> String? { @@ -2739,10 +3142,89 @@ enum CostUsageScanner { if try Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { return } - if try Self.appendCodexFileIncrementIfPossible(input: input, context: context, cache: &cache, state: &state) { + + let pendingWorkBytes = Self.pendingCodexScanWorkBytes(metadata: metadata, cached: cached) + let allowedWorkBytes: Int64 + if let budget = context.scanBudget { + switch budget.admit(workBytes: pendingWorkBytes) { + case let .allow(allowance): + allowedWorkBytes = allowance + case .deferBudget: + Self.log.debug( + "Deferring Codex session cost scan until a later refresh", + metadata: [ + "path": metadata.path, + "pendingBytes": "\(pendingWorkBytes)", + "consumed": "\(budget.bytesConsumed)", + "limit": "\(budget.maxBytesPerRefresh)", + ]) + // Preserve stale cache so later refreshes can resume catch-up. + return + } + } else { + allowedWorkBytes = pendingWorkBytes + } + + if try Self.appendCodexFileIncrementIfPossible( + input: input, + context: context, + cache: &cache, + state: &state, + maxBytesToRead: allowedWorkBytes) + { + context.scanBudget?.consume(workBytes: allowedWorkBytes) return } - try Self.rescanCodexFile(input: input, context: context, cache: &cache, state: &state) + let fullRescanWorkBytes = max(0, metadata.size) + let fullRescanAllowedBytes: Int64 + if fullRescanWorkBytes == pendingWorkBytes { + fullRescanAllowedBytes = allowedWorkBytes + } else if let budget = context.scanBudget { + switch budget.admit(workBytes: fullRescanWorkBytes) { + case let .allow(allowance): + fullRescanAllowedBytes = allowance + case .deferBudget: + // No work was consumed by the rejected incremental path, so this is only + // reachable when the refresh budget has no allowance for the full rescan. + return + } + } else { + fullRescanAllowedBytes = fullRescanWorkBytes + } + + try Self.rescanCodexFile( + input: input, + context: context, + cache: &cache, + state: &state, + maxBytesToRead: fullRescanAllowedBytes) + context.scanBudget?.consume(workBytes: fullRescanAllowedBytes) + } + + static func pendingCodexScanWorkBytes(metadata: CodexFileMetadata, cached: CostUsageFileUsage?) -> Int64 { + // Called only after keepCachedCodexFileIfFresh failed. Even when size/mtime still match + // (forced full rescan, priority invalidation, fork-dependency drift, etc.), the scanner + // will read the whole file — never report zero pending work in that case. + guard let cached else { return max(0, metadata.size) } + if cached.codexScanComplete == false { + if cached.codexScanFileId != nil, + cached.codexScanFileId == metadata.fileId, + cached.codexScanTargetSize == metadata.size, + cached.mtimeUnixMs == metadata.mtimeUnixMs + { + return max(0, metadata.size - (cached.parsedBytes ?? 0)) + } + return max(0, metadata.size) + } + let startOffset = cached.parsedBytes ?? cached.size + if metadata.size > cached.size, + startOffset > 0, + startOffset <= metadata.size, + cached.forkedFromId == nil + { + return max(0, metadata.size - startOffset) + } + return max(0, metadata.size) } private static func makeCodexRefreshPlan( @@ -2840,6 +3322,7 @@ enum CostUsageScanner { shouldRefresh: shouldRefresh) } + // swiftlint:disable:next function_body_length private static func loadCodexDaily( range: CostUsageDayRange, now: Date, @@ -2900,6 +3383,10 @@ enum CostUsageScanner { files.append(fileURL) } + if options.preferNewestCodexSessionsFirst { + files = Self.sortedCodexSessionFilesNewestFirst(files) + } + let filePathsInScan = Set(files.map(\.path)) var scanState = CodexScanState() let fileIndex = CodexSessionFileIndex( @@ -2910,9 +3397,13 @@ enum CostUsageScanner { roots: plan.roots, knownExistingPaths: filePathsInScan), checkCancellation: checkCancellation) + let scanBudget = CodexScanBudget( + maxFileBytes: options.maxCodexSessionFileBytes, + maxBytesPerRefresh: options.maxCodexScanBytesPerRefresh) let inheritedResolver = CodexInheritedTotalsResolver( fileIndex: fileIndex, - checkCancellation: checkCancellation) + checkCancellation: checkCancellation, + scanBudget: scanBudget) let resources = CodexScanResources( fileIndex: fileIndex, inheritedResolver: inheritedResolver, @@ -2925,7 +3416,8 @@ enum CostUsageScanner { options: options, plan: plan, resources: resources, - checkCancellation: checkCancellation) + checkCancellation: checkCancellation, + scanBudget: scanBudget) for fileURL in files { try Self.scanCodexFile( fileURL: fileURL, @@ -2933,6 +3425,17 @@ enum CostUsageScanner { cache: &cache, state: &scanState) } + if scanBudget.resumedPartialFileCount > 0 || scanBudget.deferredByBudgetFileCount > 0 { + Self.log.info( + "Codex cost scan applied work limits", + metadata: [ + "partialFiles": "\(scanBudget.resumedPartialFileCount)", + "deferredByBudget": "\(scanBudget.deferredByBudgetFileCount)", + "bytesConsumed": "\(scanBudget.bytesConsumed)", + "maxFileBytes": "\(scanBudget.maxFileBytes)", + "maxBytesPerRefresh": "\(scanBudget.maxBytesPerRefresh)", + ]) + } try checkCancellation?() Self.pruneForceRescanFilesOutsideWindow( @@ -3011,7 +3514,8 @@ enum CostUsageScanner { options: Options, plan: CodexRefreshPlan, resources: CodexScanResources, - checkCancellation: CancellationCheck?) -> CodexFileScanContext + checkCancellation: CancellationCheck?, + scanBudget: CodexScanBudget? = nil) -> CodexFileScanContext { CodexFileScanContext( range: range, @@ -3022,7 +3526,22 @@ enum CostUsageScanner { requiresTurnIDCache: plan.needsTurnIDCacheMigration, changedPriorityTurnIDs: plan.changedPriorityTurnIDs, resources: resources, - checkCancellation: checkCancellation) + checkCancellation: checkCancellation, + scanBudget: scanBudget) + } + + static func sortedCodexSessionFilesNewestFirst(_ files: [URL]) -> [URL] { + files.sorted { lhs, rhs in + let left = Self.codexFileMetadata(fileURL: lhs) + let right = Self.codexFileMetadata(fileURL: rhs) + if left.mtimeUnixMs != right.mtimeUnixMs { + return left.mtimeUnixMs > right.mtimeUnixMs + } + if left.size != right.size { + return left.size < right.size + } + return lhs.path < rhs.path + } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 6f25cd218..325c3f6da 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -9,6 +9,7 @@ enum ProviderChoice: String, AppEnum { case gemini case alibaba case alibabatokenplan + case qwencloud case antigravity case cursor case zai @@ -29,6 +30,7 @@ enum ProviderChoice: String, AppEnum { .gemini: DisplayRepresentation(title: "Gemini"), .alibaba: DisplayRepresentation(title: "Alibaba"), .alibabatokenplan: DisplayRepresentation(title: "Alibaba Token Plan"), + .qwencloud: DisplayRepresentation(title: "Qwen Cloud"), .antigravity: DisplayRepresentation(title: "Antigravity"), .cursor: DisplayRepresentation(title: "Cursor"), .zai: DisplayRepresentation(title: "z.ai"), @@ -49,6 +51,7 @@ enum ProviderChoice: String, AppEnum { case .gemini: .gemini case .alibaba: .alibaba case .alibabatokenplan: .alibabatokenplan + case .qwencloud: .qwencloud case .antigravity: .antigravity case .cursor: .cursor case .zai: .zai @@ -74,6 +77,7 @@ enum ProviderChoice: String, AppEnum { case .gemini: self = .gemini case .alibaba: self = .alibaba case .alibabatokenplan: self = .alibabatokenplan + case .qwencloud: self = .qwencloud case .antigravity: self = .antigravity case .cursor: self = .cursor case .opencode: self = .opencode @@ -93,6 +97,7 @@ enum ProviderChoice: String, AppEnum { case .moonshot: return nil // Moonshot not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets case .t3chat: return nil // T3 Chat not yet supported in widgets + case .zoommate: return nil // ZoomMate not yet supported in widgets case .ollama: return nil // Ollama not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 11720d33f..cd4f188a6 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -12,14 +12,14 @@ struct CodexBarUsageWidgetView: View { var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } - ZStack { - Color.black.opacity(0.02) + Group { if let providerEntry { self.content(providerEntry: providerEntry) } else { self.emptyState } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) .environment(\.widgetUsageShowsUsed, self.entry.snapshot.usageBarsShowUsed) } @@ -55,14 +55,14 @@ struct CodexBarHistoryWidgetView: View { var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } - ZStack { - Color.black.opacity(0.02) + Group { if let providerEntry { HistoryView(entry: providerEntry, isLarge: self.family == .systemLarge) } else { self.emptyState } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) } @@ -84,14 +84,14 @@ struct CodexBarCompactWidgetView: View { var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } - ZStack { - Color.black.opacity(0.02) + Group { if let providerEntry { CompactMetricView(entry: providerEntry, metric: self.entry.metric) } else { self.emptyState } } + .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) } @@ -114,23 +114,21 @@ struct CodexBarSwitcherWidgetView: View { var body: some View { let providerEntry = self.entry.snapshot.entries.first { $0.provider == self.entry.provider } - ZStack { - Color.black.opacity(0.02) - VStack(alignment: .leading, spacing: 10) { - ProviderSwitcherRow( - providers: self.entry.availableProviders, - selected: self.entry.provider, - updatedAt: providerEntry?.updatedAt ?? Date(), - compact: self.family == .systemSmall, - showsTimestamp: self.family != .systemSmall) - if let providerEntry { - self.content(providerEntry: providerEntry) - } else { - self.emptyState - } + VStack(alignment: .leading, spacing: 10) { + ProviderSwitcherRow( + providers: self.entry.availableProviders, + selected: self.entry.provider, + updatedAt: providerEntry?.updatedAt ?? Date(), + compact: self.family == .systemSmall, + showsTimestamp: self.family != .systemSmall) + if let providerEntry { + self.content(providerEntry: providerEntry) + } else { + self.emptyState } - .padding(12) } + .padding(12) + .frame(maxWidth: .infinity, maxHeight: .infinity) .containerBackground(.fill.tertiary, for: .widget) .environment(\.widgetUsageShowsUsed, self.entry.snapshot.usageBarsShowUsed) } @@ -313,6 +311,7 @@ private struct ProviderSwitchChip: View { case .opencodego: "OpenCode Go" case .alibaba: "Alibaba" case .alibabatokenplan: "Token Plan" + case .qwencloud: "Qwen Cloud" case .zai: "z.ai" case .factory: "Droid" case .copilot: "Copilot" @@ -328,6 +327,7 @@ private struct ProviderSwitchChip: View { case .moonshot: "Moonshot" case .amp: "Amp" case .t3chat: "T3 Chat" + case .zoommate: "ZoomMate" case .ollama: "Ollama" case .synthetic: "Synthetic" case .openrouter: "OpenRouter" @@ -1018,6 +1018,8 @@ enum WidgetColors { Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) case .alibaba, .alibabatokenplan: Color(red: 1.0, green: 106 / 255, blue: 0) + case .qwencloud: + Color(red: 97 / 255, green: 92 / 255, blue: 237 / 255) case .zai: Color(red: 232 / 255, green: 90 / 255, blue: 106 / 255) case .factory: @@ -1048,6 +1050,8 @@ enum WidgetColors { Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red case .t3chat: Color(red: 245 / 255, green: 102 / 255, blue: 71 / 255) + case .zoommate: + Color(red: 11 / 255, green: 92 / 255, blue: 255 / 255) // Zoom blue case .ollama: Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal case .synthetic: diff --git a/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift index 94f16f171..fd09a491a 100644 --- a/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift +++ b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift @@ -53,9 +53,11 @@ struct AlibabaCodingPlanCookieImporterTests { return .allowed } operation: { #expect(throws: BrowserCookieStoreAccessSuppressedError.self) { - _ = try AlibabaChromiumCookieFallbackImporter.importSession( + _ = try AliyunOneConsoleChromiumCookieFallbackImporter.importSession( browser: .chrome, - domains: ["example.com"]) + domains: ["example.com"], + isAuthenticatedSession: { _ in false }, + sessionLabel: "Test") } } } diff --git a/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift index 9975e9368..cd8b078c5 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanMenuCardModelTests.swift @@ -4,6 +4,44 @@ import Testing @testable import CodexBar struct AlibabaTokenPlanMenuCardModelTests { + @Test + func `Personal rolling windows use duration labels`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "Pro", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + fiveHourUsedPercent: 0, + weeklyUsedPercent: 10, + updatedAt: now) + .toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.alibabatokenplan]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .alibabatokenplan, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["5-hour", "7-day"]) + } + @Test func `monthly quota shows deficit and run out details`() throws { let now = Date(timeIntervalSince1970: 10_368_000) // 1970-05-01T00:00:00Z diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 1db85ef8a..30b0e4f3e 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -2,6 +2,14 @@ import Foundation import Testing @testable import CodexBarCore +private func alibabaTokenPlanFixture(_ name: String) throws -> Data { + try Data( + contentsOf: #require(Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Fixtures/AlibabaTokenPlan"))) +} + struct AlibabaTokenPlanSettingsReaderTests { @Test func `cookie reads from environment`() { @@ -66,6 +74,25 @@ struct AlibabaTokenPlanSettingsReaderTests { #expect(url.absoluteString.contains("GetSubscriptionSummary")) #expect(url.absoluteString.contains("BssOpenAPI-V3")) } + + @Test + func `personal variants target their rolling window hosts without changing Team routes`() { + let internationalTeam = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .international) + let mainlandTeam = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .chinaMainland) + let internationalPersonal = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .internationalPersonal) + let mainlandPersonal = AlibabaTokenPlanUsageFetcher.defaultQuotaURL(region: .chinaMainlandPersonal) + + #expect(internationalTeam.host == "modelstudio.console.alibabacloud.com") + #expect(mainlandTeam.host == "bailian.console.aliyun.com") + #expect(internationalTeam.absoluteString.contains("GetSubscriptionSummary")) + #expect(mainlandTeam.absoluteString.contains("GetSubscriptionSummary")) + #expect(internationalPersonal.host == "bailian-singapore-cs.alibabacloud.com") + #expect(mainlandPersonal.host == "bailian-cs.console.aliyun.com") + #expect(internationalPersonal.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + #expect(mainlandPersonal.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + #expect(!internationalPersonal.absoluteString.contains("GetSubscriptionSummary")) + #expect(!mainlandPersonal.absoluteString.contains("GetSubscriptionSummary")) + } } struct AlibabaTokenPlanCookieHeaderTests { @@ -109,13 +136,64 @@ struct AlibabaTokenPlanCookieHeaderTests { #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) } + @Test + func `mainland Personal rebuilds cookies for the quota host`() throws { + let cookies = [ + self.cookie(name: "parent", value: "shared", domain: ".console.aliyun.com"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "quota_only", value: "quota", domain: "bailian-cs.console.aliyun.com"), + self.cookie(name: "collision", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "collision", value: "quota", domain: "bailian-cs.console.aliyun.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + region: .chinaMainlandPersonal)) + + #expect(headers.apiCookieHeader.contains("parent=shared")) + #expect(headers.apiCookieHeader.contains("quota_only=quota")) + #expect(headers.apiCookieHeader.contains("collision=quota")) + #expect(!headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(headers.dashboardCookieHeader.contains("parent=shared")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(headers.dashboardCookieHeader.contains("collision=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("quota_only=quota")) + } + + @Test + func `international Personal rebuilds cookies for the quota host`() throws { + let cookies = [ + self.cookie(name: "parent", value: "shared", domain: ".alibabacloud.com"), + self.cookie( + name: "dashboard_only", + value: "dashboard", + domain: "modelstudio.console.alibabacloud.com"), + self.cookie( + name: "quota_only", + value: "quota", + domain: "bailian-singapore-cs.alibabacloud.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + region: .internationalPersonal)) + + #expect(headers.apiCookieHeader.contains("parent=shared")) + #expect(headers.apiCookieHeader.contains("quota_only=quota")) + #expect(!headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(headers.dashboardCookieHeader.contains("parent=shared")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("quota_only=quota")) + } + @Test func `cached token plan headers preserve URL scoping`() throws { let headers = AlibabaTokenPlanCookieHeaders( apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api", dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard") - let cached = try #require(AlibabaTokenPlanCookieHeaders(cachedHeader: headers.cacheCookieHeader)) + let cached = try #require( + AlibabaTokenPlanCookieHeaders(alibabaTokenPlanCachedHeader: headers.cacheAlibabaTokenPlanCookieHeader())) #expect(cached.apiCookieHeader.contains("api_only=api")) #expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard")) @@ -204,10 +282,106 @@ struct AlibabaTokenPlanUsageSnapshotTests { #expect(usage.primary == nil) #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") } + + @Test + func `emits a weekly window when 5 hour usage is absent`() { + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "Personal", + usedQuota: nil, + totalQuota: nil, + remainingQuota: nil, + resetsAt: nil, + weeklyUsedPercent: 10, + weeklyTotalQuota: 40000, + weeklyResetsAt: Date(timeIntervalSince1970: 1_785_234_900), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 10) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.secondary?.resetDescription == "4,000 / 40,000 credits used") + } } @Suite(.serialized) struct AlibabaTokenPlanUsageParsingTests { + @Test + func `shared Personal parser maps issue documented rolling windows and tier quotas`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = try AlibabaTokenPlanPersonalUsageParser.parse( + from: alibabaTokenPlanFixture("personal_usage"), + subscriptionData: alibabaTokenPlanFixture("personal_subscription"), + quotaConfigData: alibabaTokenPlanFixture("personal_quota_config"), + now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.planName == "Pro") + #expect(snapshot.fiveHourTotalQuota == 12000) + #expect(snapshot.weeklyTotalQuota == 40000) + #expect(abs((usage.primary?.usedPercent ?? -.infinity) - 0.09973083333333333) < 0.000_000_001) + #expect(usage.primary?.windowMinutes == 5 * 60) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_784_813_220)) + #expect(abs((usage.secondary?.usedPercent ?? -.infinity) - 0.03014725) < 0.000_000_001) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_785_234_900)) + #expect(usage.loginMethod(for: .alibabatokenplan) == "Pro") + } + + @Test + func `shared Personal parser accepts weekly only responses`() throws { + let json = """ + { + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per1WeekPercentage": 0.10007527475, + "per1WeekResetTime": 1785234900000 + } + } + } + }, + "successResponse": true + } + """ + + let snapshot = try AlibabaTokenPlanPersonalUsageParser.parse( + from: Data(json.utf8), + subscriptionData: nil, + quotaConfigData: nil, + now: Date(timeIntervalSince1970: 1_700_000_000)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(abs((usage.secondary?.usedPercent ?? -.infinity) - 10.007527475) < 0.000_000_001) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_785_234_900)) + } + + @Test + func `Personal login error maps to login required`() { + let json = """ + { + "data": { + "success": false, + "errorCode": "BailianGateway.Login.NotLogined", + "errorMsg": "BailianGateway.Login.NotLogined" + }, + "httpStatusCode": "200" + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanPersonalUsageParser.parse( + from: Data(json.utf8), + subscriptionData: nil, + quotaConfigData: nil, + now: Date(timeIntervalSince1970: 1_700_000_000)) + } + } + @Test func `parses subscription summary payload`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -371,6 +545,59 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `mainland Personal fetch uses quota host cookies without requiring SEC token`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + let usageBody = try #require(String(data: alibabaTokenPlanFixture("personal_usage"), encoding: .utf8)) + let subscriptionBody = try #require( + String(data: alibabaTokenPlanFixture("personal_subscription"), encoding: .utf8)) + let quotaBody = try #require( + String(data: alibabaTokenPlanFixture("personal_quota_config"), encoding: .utf8)) + + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.host == "bailian-cs.console.aliyun.com") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Cookie") == "quota_only=quota") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") + let body = Self.requestBodyString(from: request) + #expect(!body.contains("sec_token")) + #expect(body.removingPercentEncoding?.contains("cornerstoneParam") == true) + + let api = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "api" })? + .value + switch api { + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage": + return Self.makeResponse(url: url, body: usageBody, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription": + #expect(body.removingPercentEncoding?.contains("sfm_tokenplansolo_public_cn") == true) + return Self.makeResponse(url: url, body: subscriptionBody, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config": + return Self.makeResponse(url: url, body: quotaBody, statusCode: 200) + default: + throw URLError(.unsupportedURL) + } + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "quota_only=quota", + dashboardCookieHeader: "dashboard_only=dashboard", + region: .chinaMainlandPersonal, + environment: [:], + session: session) + + #expect(snapshot.planName == "Pro") + #expect(snapshot.toUsageSnapshot().primary != nil) + #expect(snapshot.toUsageSnapshot().secondary != nil) + } + @Test func `SEC token preflight falls back to user info`() async throws { defer { @@ -921,6 +1148,8 @@ final class AlibabaTokenPlanStubURLProtocol: URLProtocol { override static func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host else { return false } return host == "bailian.console.aliyun.com" || + host == "bailian-cs.console.aliyun.com" || + host == "bailian-singapore-cs.alibabacloud.com" || host == "alibaba-token-plan.test" || host == "session-token.test" } diff --git a/Tests/CodexBarTests/AmpUsageParserTests.swift b/Tests/CodexBarTests/AmpUsageParserTests.swift index 3c8a51a5b..08ae83f93 100644 --- a/Tests/CodexBarTests/AmpUsageParserTests.swift +++ b/Tests/CodexBarTests/AmpUsageParserTests.swift @@ -85,6 +85,47 @@ struct AmpUsageParserTests { #expect(usage.primary?.resetDescription == "resets daily") } + @Test + func `parses amp subscription usage`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let output = """ + Signed in as user@example.com (username) + Subscription Megawatt: 97% other usage and 100% orb usage remaining - resets upon renewal in 29 days + """ + + let snapshot = try AmpUsageParser.parse(displayText: output, now: now) + let usage = snapshot.toUsageSnapshot(now: now) + + #expect(snapshot.subscription == AmpSubscriptionUsage( + plan: "Megawatt", + otherUsedPercent: 3, + orbUsedPercent: 0, + resetsAt: now.addingTimeInterval(29 * 24 * 60 * 60), + resetDescription: "renews in 29 days")) + #expect(usage.primary?.usedPercent == 3) + #expect(usage.secondary?.usedPercent == 0) + #expect(usage.primary?.windowMinutes == ProviderPaceCapability.monthlyWindowSentinelMinutes) + #expect(usage.secondary?.resetsAt == now.addingTimeInterval(29 * 24 * 60 * 60)) + #expect(usage.identity?.loginMethod == "Megawatt") + #expect(usage.ampUsage?.subscriptionPlan == "Megawatt") + #expect(AmpProviderDescriptor.primaryLabel(details: usage.ampUsage) == "Other usage") + #expect(AmpProviderDescriptor.secondaryLabel(details: usage.ampUsage) == "Orb usage") + } + + @Test + func `parses amp subscription usage with settings link`() throws { + let output = """ + Subscription Megawatt: 97% other usage and 100% orb usage remaining - resets upon renewal in 29 days \ + - https://ampcode.com/settings#subscription + """ + + let snapshot = try AmpUsageParser.parse(displayText: output) + + #expect(snapshot.subscription?.plan == "Megawatt") + #expect(snapshot.subscription?.otherUsedPercent == 3) + #expect(snapshot.subscription?.orbUsedPercent == 0) + } + @Test func `legacy amp free usage keeps replenishment reset when percentage text also exists`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) @@ -134,8 +175,11 @@ struct AmpUsageParserTests { #expect(snapshot.freeUsed == nil) #expect(snapshot.individualCredits == 25.64) #expect(usage.primary == nil) + #expect(usage.secondary == nil) #expect(usage.ampUsage == AmpUsageDetails(individualCredits: 25.64, workspaceBalances: [])) #expect(usage.identity?.loginMethod == "Amp") + #expect(AmpProviderDescriptor.primaryLabel(details: usage.ampUsage) == nil) + #expect(AmpProviderDescriptor.secondaryLabel(details: usage.ampUsage) == nil) } @Test diff --git a/Tests/CodexBarTests/CLIConfigCommandTests.swift b/Tests/CodexBarTests/CLIConfigCommandTests.swift index 6562788b8..7df448e0c 100644 --- a/Tests/CodexBarTests/CLIConfigCommandTests.swift +++ b/Tests/CodexBarTests/CLIConfigCommandTests.swift @@ -1,5 +1,6 @@ import CodexBarCore import Commander +import Foundation import Testing @testable import CodexBarCLI @@ -180,5 +181,155 @@ struct CLIConfigCommandTests { #expect(help.contains("--stdin")) #expect(help.contains("--usage-scope team")) #expect(help.contains("enables that provider by default")) + #expect(help.contains("--show-secrets")) + } + + @Test + func `config dump parses show-secrets flag`() throws { + let parser = CommandParser(signature: CodexBarCLI._configDumpSignatureForTesting()) + let parsed = try parser.parse(arguments: ["--show-secrets", "--pretty"]) + + #expect(parsed.flags.contains("showSecrets")) + #expect(parsed.flags.contains("pretty")) + } + + @Test + func `config dump redacts credentials by default`() { + let rawAccount = ProviderTokenAccount( + id: UUID(), + label: "Team", + token: "cb_test_token_123", + addedAt: 1000, + lastUsed: nil, + usageScope: "team", + organizationID: "org-1", + workspaceID: "proj-1") + let provider = ProviderConfig( + id: .zai, + apiKey: "cb_test_api_key_456", + secretKey: "cb_test_secret_key_789", + cookieHeader: "cb_test_cookie_abc", + tokenAccounts: ProviderTokenAccountData(version: 1, accounts: [rawAccount], activeIndex: 0)) + let config = CodexBarConfig(providers: [provider]) + + let redacted = config.sanitizedForDump(showSecrets: false) + let redactedProvider = redacted.providerConfig(for: .zai) + + #expect(redactedProvider?.apiKey == "[REDACTED]") + #expect(redactedProvider?.secretKey == "[REDACTED]") + #expect(redactedProvider?.cookieHeader == "[REDACTED]") + #expect(redactedProvider?.tokenAccounts?.accounts.first?.token == "[REDACTED]") + } + + @Test + func `config dump reveals credentials when show-secrets is true`() { + let rawAccount = ProviderTokenAccount( + id: UUID(), + label: "Team", + token: "cb_test_token_123", + addedAt: 1000, + lastUsed: nil, + usageScope: "team", + organizationID: "org-1", + workspaceID: "proj-1") + let provider = ProviderConfig( + id: .zai, + apiKey: "cb_test_api_key_456", + secretKey: "cb_test_secret_key_789", + cookieHeader: "cb_test_cookie_abc", + tokenAccounts: ProviderTokenAccountData(version: 1, accounts: [rawAccount], activeIndex: 0)) + let config = CodexBarConfig(providers: [provider]) + + let unredacted = config.sanitizedForDump(showSecrets: true) + let unredactedProvider = unredacted.providerConfig(for: .zai) + + #expect(unredactedProvider?.apiKey == "cb_test_api_key_456") + #expect(unredactedProvider?.secretKey == "cb_test_secret_key_789") + #expect(unredactedProvider?.cookieHeader == "cb_test_cookie_abc") + #expect(unredactedProvider?.tokenAccounts?.accounts.first?.token == "cb_test_token_123") + } + + @Test + func `config dump command redacts fixture secrets unless explicitly requested`() throws { + let fixtureDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-config-dump-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: fixtureDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: fixtureDirectory) } + + let secrets = [ + "fixture-api-key-value", + "fixture-secret-key-value", + "fixture-cookie-value", + "fixture-token-account-value", + ] + let account = ProviderTokenAccount( + id: UUID(), + label: "Fixture account", + token: secrets[3], + addedAt: 1000, + lastUsed: nil, + usageScope: "team", + organizationID: "fixture-org", + workspaceID: "fixture-workspace") + let config = CodexBarConfig(providers: [ProviderConfig( + id: .zai, + enabled: true, + apiKey: secrets[0], + secretKey: secrets[1], + cookieHeader: secrets[2], + tokenAccounts: ProviderTokenAccountData(version: 1, accounts: [account], activeIndex: 0))]) + let configURL = fixtureDirectory.appendingPathComponent("config.json") + try CodexBarConfigStore(fileURL: configURL).save(config) + + let redactedData = try Self.runConfigDump(configURL: configURL, showSecrets: false) + let redactedJSON = try JSONSerialization.jsonObject(with: redactedData) + let redactedOutput = try #require(String(data: redactedData, encoding: .utf8)) + #expect(redactedJSON is [String: Any]) + #expect(redactedOutput.contains("[REDACTED]")) + for secret in secrets { + #expect(!redactedOutput.contains(secret)) + } + + let rawData = try Self.runConfigDump(configURL: configURL, showSecrets: true) + let rawJSON = try JSONSerialization.jsonObject(with: rawData) + let rawOutput = try #require(String(data: rawData, encoding: .utf8)) + #expect(rawJSON is [String: Any]) + for secret in secrets { + #expect(rawOutput.contains(secret)) + } + } + + private static func runConfigDump(configURL: URL, showSecrets: Bool) throws -> Data { + let process = Process() + process.executableURL = Self.cliExecutableURL + process.arguments = ["config", "dump"] + (showSecrets ? ["--show-secrets"] : []) + process.environment = ProcessInfo.processInfo.environment.merging([ + CodexBarConfigStore.pathEnvironmentKey: configURL.path, + ]) { _, fixturePath in fixturePath } + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + try process.run() + process.waitUntilExit() + + let output = stdout.fileHandleForReading.readDataToEndOfFile() + let errorOutput = stderr.fileHandleForReading.readDataToEndOfFile() + guard process.terminationStatus == 0 else { + let message = String(data: errorOutput, encoding: .utf8) ?? "CodexBarCLI exited without an error message" + throw NSError(domain: "CLIConfigCommandTests", code: Int(process.terminationStatus), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + return output + } + + private static var cliExecutableURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent(".build/debug/CodexBarCLI") } } diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index 55ec10a13..426b3d05a 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -457,6 +457,47 @@ final class CLIEntryTests: XCTestCase { environment: ["MIMO_LOCAL_USAGE_PATH": directory.appendingPathComponent("missing.json").path])) } + func test_sourceModeRequiresWebSupportAllowsZoomMateManualCapture() { + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .zoommate, + settings: ProviderSettingsSnapshot.make( + zoommate: .init( + cookieSource: .manual, + manualCookieHeader: "curl 'https://zoommate.zoom.us/' -H 'authorization: Bearer fake'")))) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .zoommate, + settings: ProviderSettingsSnapshot.make( + zoommate: .init( + cookieSource: .manual, + manualCookieHeader: nil)))) + } + + func test_sourceModeRequiresWebSupportAllowsQwenCookiesOnLinuxGate() { + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .qwencloud, + environment: ["QWEN_CLOUD_COOKIE": "login_qwencloud_ticket=test"])) + XCTAssertFalse(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .qwencloud, + settings: ProviderSettingsSnapshot.make( + qwenCloud: .init( + cookieSource: .manual, + manualCookieHeader: "login_qwencloud_ticket=test")))) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .qwencloud, + environment: [:])) + XCTAssertTrue(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .qwencloud, + environment: ["QWEN_CLOUD_COOKIE": "login_qwencloud_ticket=test"], + settings: ProviderSettingsSnapshot.make( + qwenCloud: .init(cookieSource: .off, manualCookieHeader: nil)))) + } + private func assertKimiCodeCredentialSourceMode(in directory: URL) throws { let home = directory.appendingPathComponent("kimi-code", isDirectory: true) let credentials = home.appendingPathComponent("credentials", isDirectory: true) diff --git a/Tests/CodexBarTests/CLIOutputTests.swift b/Tests/CodexBarTests/CLIOutputTests.swift index 82ae4f2f5..fd52513a7 100644 --- a/Tests/CodexBarTests/CLIOutputTests.swift +++ b/Tests/CodexBarTests/CLIOutputTests.swift @@ -96,6 +96,40 @@ struct CLIOutputTests { #expect(!text.contains("Amp Free:")) } + @Test + func `text renderer labels amp subscription pools`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = AmpUsageSnapshot( + freeQuota: nil, + freeUsed: nil, + hourlyReplenishment: nil, + windowHours: nil, + updatedAt: now, + subscription: AmpSubscriptionUsage( + plan: "Megawatt", + otherUsedPercent: 3, + orbUsedPercent: 0, + resetsAt: now.addingTimeInterval(29 * 24 * 60 * 60), + resetDescription: "renews in 29 days")) + .toUsageSnapshot(now: now) + + let text = CLIRenderer.renderText( + provider: .amp, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Amp (cli)", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(text.contains("Other usage:")) + #expect(text.contains("Orb usage:")) + #expect(!text.contains("Amp Free:")) + #expect(!text.contains("Balance:")) + } + @Test func `text renderer shows mimo balance without quota or reset text`() { let snapshot = MiMoUsageSnapshot( @@ -174,4 +208,61 @@ struct CLIOutputTests { #expect(text.contains("Plan: \(summary)")) #expect(!text.contains("Stale 34D")) } + + @Test + func `text renderer includes Claude extra usage balance`() { + let now = Date(timeIntervalSince1970: 0) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + balance: 100, + updatedAt: now), + updatedAt: now) + + let text = CLIRenderer.renderText( + provider: .claude, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Claude (web)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Extra usage balance: $100.00")) + } + + @Test + func `text renderer does not show zero cost for Claude balance only snapshot`() { + let now = Date(timeIntervalSince1970: 0) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "USD", + period: "Extra usage", + balance: 100, + updatedAt: now), + updatedAt: now) + + let text = CLIRenderer.renderText( + provider: .claude, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Claude (web)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(text.contains("Extra usage balance: $100.00")) + #expect(!text.contains("Cost: 0.0 / 0.0")) + } } diff --git a/Tests/CodexBarTests/CLIWebFallbackTests.swift b/Tests/CodexBarTests/CLIWebFallbackTests.swift index cfe9a0317..97c731d7e 100644 --- a/Tests/CodexBarTests/CLIWebFallbackTests.swift +++ b/Tests/CodexBarTests/CLIWebFallbackTests.swift @@ -167,11 +167,13 @@ struct CLIWebFallbackTests { func `claude CLI fallback is enabled only for app auto`() { let webAvailableStrategy = ClaudeCLIFetchStrategy( useWebExtras: false, + includePrepaidBalance: false, manualCookieHeader: nil, browserDetection: BrowserDetection(cacheTTL: 0), hasWebFallback: true) let webUnavailableStrategy = ClaudeCLIFetchStrategy( useWebExtras: false, + includePrepaidBalance: false, manualCookieHeader: nil, browserDetection: BrowserDetection(cacheTTL: 0), hasWebFallback: false) diff --git a/Tests/CodexBarTests/ChutesPresentationTests.swift b/Tests/CodexBarTests/ChutesPresentationTests.swift new file mode 100644 index 000000000..c7afbe80d --- /dev/null +++ b/Tests/CodexBarTests/ChutesPresentationTests.swift @@ -0,0 +1,145 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI + +@MainActor +struct ChutesPresentationTests { + @Test + func `menu card keeps quota detail separate from reset text`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.chutes]) + let model = UsageMenuCardView.Model.make(.init( + provider: .chutes, + metadata: metadata, + snapshot: Self.snapshot(now: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first { $0.id == "primary" }) + #expect(primary.resetText?.hasPrefix("Resets") == true) + #expect(primary.detailText == "40/100 requests") + + let secondary = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(secondary.resetText == nil) + #expect(secondary.detailText == "250/1000 credits") + } + + @Test + func `CLI keeps quota detail separate from reset text`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = Self.snapshot(now: now) + let metadata = ProviderDescriptorRegistry.descriptor(for: .chutes).metadata + let card = CLICardsRenderer.makeCard(CLICardBuildInput( + provider: .chutes, + snapshot: snapshot, + credits: nil, + source: "api", + status: nil, + notes: [], + useColor: false, + resetStyle: .countdown, + weeklyWorkDays: nil, + now: now)) + + let primary = try #require(card.metrics.first { $0.label == metadata.sessionLabel }) + #expect(primary.resetText?.hasPrefix("⏳ Resets") == true) + #expect(primary.detailText == "40/100 requests") + + let secondary = try #require(card.metrics.first { $0.label == metadata.weeklyLabel }) + #expect(secondary.resetText == nil) + #expect(secondary.detailText == "250/1000 credits") + + let output = CLIRenderer.renderText( + provider: .chutes, + snapshot: snapshot, + credits: nil, + context: RenderContext( + header: "Chutes", + status: nil, + useColor: false, + resetStyle: .countdown), + now: now) + + #expect(output.contains("40/100 requests")) + #expect(output.contains("250/1000 credits")) + #expect(!output.contains("Resets 40/100 requests")) + #expect(!output.contains("Resets 250/1000 credits")) + } + + @Test + func `native menu keeps quota detail separate from reset text`() throws { + let suite = "ChutesPresentationTests-native-menu" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting(Self.snapshot(now: Date()), provider: .chutes) + + let descriptor = MenuDescriptor.build( + provider: .chutes, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + let textLines = descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(textLines.contains("40/100 requests")) + #expect(textLines.contains("250/1000 credits")) + #expect(textLines.contains { $0.hasPrefix("Resets") }) + #expect(!textLines.contains { $0.contains("Resets 40/100 requests") }) + #expect(!textLines.contains { $0.contains("Resets 250/1000 credits") }) + } + + private static func snapshot(now: Date) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 240, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: "40/100 requests"), + secondary: RateWindow( + usedPercent: 25, + windowMinutes: 30 * 24 * 60, + resetsAt: nil, + resetDescription: "250/1000 credits"), + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .chutes, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + } +} diff --git a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift index 94a42e7c8..22a3696dc 100644 --- a/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift +++ b/Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift @@ -196,7 +196,7 @@ struct ClaudeBaselineCharacterizationTests { } @Test - func `app background auto does not start logged out Claude CLI`() async throws { + func `app background auto does not start Claude CLI before foreground availability`() async throws { let settings = ProviderSettingsSnapshot.make(claude: .init( usageDataSource: .auto, webExtrasEnabled: false, @@ -207,7 +207,7 @@ struct ClaudeBaselineCharacterizationTests { let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) let env = ["CLAUDE_CLI_PATH": stubCLIPath] - await ClaudeCLIAuthStatusProbe.withTimeoutOverrideForTesting(20) { + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { await self.withBackgroundKeychainAccess { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { await self.withNoOAuthCredentials { @@ -224,8 +224,7 @@ struct ClaudeBaselineCharacterizationTests { } } - let invocations = try String(contentsOf: invocationLog, encoding: .utf8) - #expect(invocations == "auth status --json\n") + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) } @Test @@ -266,30 +265,29 @@ struct ClaudeBaselineCharacterizationTests { manualCookieHeader: nil)) let invocationLog = FileManager.default.temporaryDirectory .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") - let stubCLIPath = try self.makeStubClaudeCLI(invocationLog: invocationLog) + let stubCLIPath = try self.makeStubClaudeCLI(loggedIn: false, invocationLog: invocationLog) let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + let cli = try #require(strategies.first { $0.id == "claude.cli" }) - await KeychainAccessGate.withTaskOverrideForTesting(true) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - await self.withNoOAuthCredentials { - let outcome = await self.fetchOutcome( - runtime: .app, - sourceMode: .auto, - env: env, - settings: settings) - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, false, false]) + let cliAvailable = await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ProviderInteractionContext.$current.withValue(.background) { + await cli.isAvailable(context) + } } } } + #expect(!cliAvailable) #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) } - @Test(arguments: ["nonzero", "timeout", "malformed"]) - func `app background auto falls back to web when auth status is unusable`( - failureMode: String) async throws - { + @Test + func `app background auto falls back to web without probing Claude CLI`() async throws { let settings = ProviderSettingsSnapshot.make(claude: .init( usageDataSource: .auto, webExtrasEnabled: false, @@ -297,17 +295,7 @@ struct ClaudeBaselineCharacterizationTests { manualCookieHeader: "sessionKey=sk-ant-session-token")) let invocationLog = FileManager.default.temporaryDirectory .appendingPathComponent("claude-invocations-\(UUID().uuidString).log") - let authStatusScript = switch failureMode { - case "nonzero": - "exit 9" - case "timeout": - "sleep 6" - default: - "printf '%s\\n' 'not-json'" - } - let stubCLIPath = try self.makeStubClaudeCLI( - authStatusScript: authStatusScript, - invocationLog: invocationLog) + let stubCLIPath = try self.makeStubClaudeCLI(invocationLog: invocationLog) let env = ["CLAUDE_CLI_PATH": stubCLIPath] let usageLoader: ClaudeWebFetchStrategy.UsageLoader = { _ in ClaudeUsageSnapshot( @@ -325,7 +313,7 @@ struct ClaudeBaselineCharacterizationTests { rawText: nil) } - let outcome = await ClaudeCLIAuthStatusProbe.withTimeoutOverrideForTesting(20) { + let outcome = await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { await self.withBackgroundKeychainAccess { await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { await self.withNoOAuthCredentials { @@ -341,8 +329,7 @@ struct ClaudeBaselineCharacterizationTests { #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli", "claude.web"]) #expect(outcome.attempts.map(\.wasAvailable) == [false, false, true]) #expect(result.strategyID == "claude.web") - let invocations = try String(contentsOf: invocationLog, encoding: .utf8) - #expect(invocations == "auth status --json\n") + #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) } @Test @@ -369,6 +356,52 @@ struct ClaudeBaselineCharacterizationTests { #expect(!FileManager.default.fileExists(atPath: invocationLog.path)) } + @Test + func `successful user initiated CLI fetch establishes background availability`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .off, + manualCookieHeader: nil)) + let stubCLIPath = try self.makeStubClaudeCLI() + let env = ["CLAUDE_CLI_PATH": stubCLIPath] + let descriptor = ProviderDescriptorRegistry.descriptor(for: .claude) + let context = self.makeContext(runtime: .app, sourceMode: .auto, env: env, settings: settings) + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + let cli = try #require(strategies.first { $0.id == "claude.cli" }) + let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws + -> ClaudeStatusSnapshot = { binary, _, _ in + #expect(binary == stubCLIPath) + return ClaudeStatusSnapshot( + sessionPercentLeft: 88, + weeklyPercentLeft: 60, + opusPercentLeft: 95, + accountEmail: "user@example.com", + accountOrganization: "Example Org", + loginMethod: nil, + primaryResetDescription: "Resets 11am", + secondaryResetDescription: "Resets Nov 21", + opusResetDescription: "Resets Nov 21", + rawText: "") + } + + try await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + _ = try await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await cli.fetch(context) + } + } + let available = await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + await ProviderInteractionContext.$current.withValue(.background) { + await cli.isAvailable(context) + } + } + } + #expect(available) + } + } + @Test func `app auto pipeline retains OAuth bootstrap strategy at startup`() async { let settings = ProviderSettingsSnapshot.make(claude: .init( @@ -413,41 +446,44 @@ struct ClaudeBaselineCharacterizationTests { let stubCLIPath = try self.makeStubClaudeCLI() let env = ["CLAUDE_CLI_PATH": stubCLIPath] - await self.withBackgroundKeychainAccess { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - await self.withNoOAuthCredentials { - let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws - -> ClaudeStatusSnapshot = { binary, _, _ in - #expect(binary == stubCLIPath) - return ClaudeStatusSnapshot( - sessionPercentLeft: 88, - weeklyPercentLeft: 60, - opusPercentLeft: 95, - accountEmail: "user@example.com", - accountOrganization: "Example Org", - loginMethod: nil, - primaryResetDescription: "Resets 11am", - secondaryResetDescription: "Resets Nov 21", - opusResetDescription: "Resets Nov 21", - rawText: "stub") + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: stubCLIPath) + await self.withBackgroundKeychainAccess { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await self.withNoOAuthCredentials { + let fetchOverride: @Sendable (String, TimeInterval, Bool) async throws + -> ClaudeStatusSnapshot = { binary, _, _ in + #expect(binary == stubCLIPath) + return ClaudeStatusSnapshot( + sessionPercentLeft: 88, + weeklyPercentLeft: 60, + opusPercentLeft: 95, + accountEmail: "user@example.com", + accountOrganization: "Example Org", + loginMethod: nil, + primaryResetDescription: "Resets 11am", + secondaryResetDescription: "Resets Nov 21", + opusResetDescription: "Resets Nov 21", + rawText: "stub") + } + let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { + await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) } - let outcome = await ClaudeStatusProbe.$fetchOverride.withValue(fetchOverride) { - await self.fetchOutcome(runtime: .app, sourceMode: .auto, env: env, settings: settings) - } - #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) - #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) - - switch outcome.result { - case let .success(result): - #expect(result.strategyID == "claude.cli") - #expect(result.sourceLabel == "claude") - #expect(result.usage.primary?.usedPercent == 12) - #expect(result.usage.secondary?.usedPercent == 40) - #expect(result.usage.tertiary?.usedPercent == 5) - #expect(result.usage.identity?.accountEmail == "user@example.com") - case let .failure(error): - Issue.record("Unexpected failure: \(error)") + #expect(outcome.attempts.map(\.strategyID) == ["claude.oauth", "claude.cli"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, true]) + + switch outcome.result { + case let .success(result): + #expect(result.strategyID == "claude.cli") + #expect(result.sourceLabel == "claude") + #expect(result.usage.primary?.usedPercent == 12) + #expect(result.usage.secondary?.usedPercent == 40) + #expect(result.usage.tertiary?.usedPercent == 5) + #expect(result.usage.identity?.accountEmail == "user@example.com") + case let .failure(error): + Issue.record("Unexpected failure: \(error)") + } } } } diff --git a/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift new file mode 100644 index 000000000..72a2ea080 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeCLIBackgroundAvailabilityTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeCLIBackgroundAvailabilityTests { + @Test + func `background Auto CLI is unavailable before a user establishes availability`() async { + let strategy = self.makeStrategy() + let context = self.makeContext() + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.background) { + #expect(await !strategy.isAvailable(context)) + } + } + } + } + } + } + + @Test + func `disabled Keychain allows background Auto after foreground availability is established`() async { + let strategy = self.makeStrategy() + let context = self.makeContext() + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo") + await KeychainAccessGate.withTaskOverrideForTesting(true) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.background) { + #expect(await strategy.isAvailable(context)) + } + } + } + } + } + } + + @Test + func `background Auto CLI keeps prompt policy after foreground availability is established`() async { + let strategy = self.makeStrategy() + let context = self.makeContext() + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo") + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.background) { + #expect(await !strategy.isAvailable(context)) + } + } + } + } + } + } + + @Test + func `background Auto CLI uses foreground availability with explicit prompt opt in`() async { + let strategy = self.makeStrategy() + let context = self.makeContext() + + await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: "/bin/echo") + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting("/bin/echo") { + await ProviderInteractionContext.$current.withValue(.background) { + #expect(await strategy.isAvailable(context)) + } + } + } + } + } + } + + private func makeStrategy() -> ClaudeCLIFetchStrategy { + ClaudeCLIFetchStrategy( + useWebExtras: false, + includePrepaidBalance: false, + manualCookieHeader: nil, + browserDetection: BrowserDetection(cacheTTL: 0), + hasWebFallback: false) + } + + private func makeContext() -> ProviderFetchContext { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: nil, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } +} diff --git a/Tests/CodexBarTests/ClaudeConfigPathsTests.swift b/Tests/CodexBarTests/ClaudeConfigPathsTests.swift new file mode 100644 index 000000000..2ddae6f90 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeConfigPathsTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeConfigPathsTests { + @Test + func `custom profile prefers config json and otherwise uses local claude json`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let environment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: root.path] + let legacy = root.appendingPathComponent(".claude.json") + let profile = root.appendingPathComponent(".config.json") + + #expect(ClaudeConfigPaths.accountConfigURL(environment: environment) == legacy) + + try Data("{}".utf8).write(to: profile) + #expect(ClaudeConfigPaths.accountConfigURL(environment: environment) == profile) + } + + @Test + func `default profile uses home claude data and home account fallback`() throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + let dataRoot = home.appendingPathComponent(".claude", isDirectory: true) + try FileManager.default.createDirectory(at: dataRoot, withIntermediateDirectories: true) + let environment = ["HOME": home.path] + + #expect(ClaudeConfigPaths.configRoot(environment: environment) == dataRoot) + #expect(ClaudeConfigPaths.accountConfigURL(environment: environment) == home.appendingPathComponent( + ".claude.json")) + + let profile = dataRoot.appendingPathComponent(".config.json") + try Data("{}".utf8).write(to: profile) + #expect(ClaudeConfigPaths.accountConfigURL(environment: environment) == profile) + } + + @Test + func `secure storage root owns credentials independently of config root`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("home", isDirectory: true) + let config = root.appendingPathComponent("config", isDirectory: true) + let secure = root.appendingPathComponent("secure", isDirectory: true) + let base = [ + "HOME": home.path, + ClaudeConfigPaths.configDirectoryEnvironmentKey: config.path, + ] + + #expect(ClaudeConfigPaths.credentialsURL(environment: base) == config.appendingPathComponent( + ".credentials.json")) + + var explicitSecure = base + explicitSecure[ClaudeConfigPaths.secureStorageDirectoryEnvironmentKey] = secure.path + #expect(ClaudeConfigPaths.credentialsURL(environment: explicitSecure) == secure.appendingPathComponent( + ".credentials.json")) + + var emptySecure = base + emptySecure[ClaudeConfigPaths.secureStorageDirectoryEnvironmentKey] = "" + #expect(ClaudeConfigPaths.credentialsURL(environment: emptySecure) == home + .appendingPathComponent(".claude/.credentials.json")) + } + + @Test + func `config directory is one literal path rather than a list`() throws { + let parent = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let literal = parent.appendingPathComponent("first, second", isDirectory: true) + let environment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: literal.path] + + #expect(ClaudeConfigPaths.configRoot(environment: environment) == literal.standardizedFileURL) + #expect(ClaudeConfigPaths.credentialsURL(environment: environment) == literal.appendingPathComponent( + ".credentials.json")) + } + + @Test + func `relative literal roots resolve from the Claude owner working directory`() throws { + let parent = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let workingDirectory = parent.appendingPathComponent("probe", isDirectory: true) + let relativeConfig = "first, second" + let configRoot = workingDirectory.appendingPathComponent(relativeConfig, isDirectory: true) + try FileManager.default.createDirectory(at: configRoot, withIntermediateDirectories: true) + let profile = configRoot.appendingPathComponent(".config.json") + try Data("{}".utf8).write(to: profile) + let environment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: relativeConfig] + + #expect(ClaudeConfigPaths.configRoot( + environment: environment, + workingDirectory: workingDirectory) == configRoot.standardizedFileURL) + #expect(ClaudeConfigPaths.accountConfigURL( + environment: environment, + workingDirectory: workingDirectory) == profile.standardizedFileURL) + #expect(ClaudeConfigPaths.credentialsURL( + environment: environment, + workingDirectory: workingDirectory) == configRoot.appendingPathComponent(".credentials.json")) + + let tildeEnvironment = [ClaudeConfigPaths.configDirectoryEnvironmentKey: "~/.claude-profile"] + #expect(ClaudeConfigPaths.configRoot( + environment: tildeEnvironment, + workingDirectory: workingDirectory) == workingDirectory + .appendingPathComponent("~/.claude-profile", isDirectory: true) + .standardizedFileURL) + } + + @Test + func `empty config and secure roots follow relative HOME from the owner working directory`() throws { + let parent = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: parent) } + let workingDirectory = parent.appendingPathComponent("probe", isDirectory: true) + let environment = [ + "HOME": "relative-home", + ClaudeConfigPaths.configDirectoryEnvironmentKey: "", + ClaudeConfigPaths.secureStorageDirectoryEnvironmentKey: "", + ] + let home = workingDirectory.appendingPathComponent("relative-home", isDirectory: true) + + #expect(ClaudeConfigPaths.homeDirectory( + environment: environment, + workingDirectory: workingDirectory) == home.standardizedFileURL) + #expect(ClaudeConfigPaths.configRoot( + environment: environment, + workingDirectory: workingDirectory) == home.appendingPathComponent(".claude", isDirectory: true)) + #expect(ClaudeConfigPaths.accountConfigURL( + environment: environment, + workingDirectory: workingDirectory) == home.appendingPathComponent(".claude.json")) + #expect(ClaudeConfigPaths.credentialsURL( + environment: environment, + workingDirectory: workingDirectory) == home.appendingPathComponent(".claude/.credentials.json")) + } + + private static func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-claude-paths-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } +} diff --git a/Tests/CodexBarTests/ClaudeDailyRoutinesVisibilityTests.swift b/Tests/CodexBarTests/ClaudeDailyRoutinesVisibilityTests.swift new file mode 100644 index 000000000..960aafca5 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeDailyRoutinesVisibilityTests.swift @@ -0,0 +1,145 @@ +import CodexBarCore +import Foundation +import Observation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct ClaudeDailyRoutinesSettingsTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + + @Test + func `visibility defaults on persists and refreshes only menus`() async throws { + let suite = "ClaudeDailyRoutinesSettingsTests-visibility" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.claudeDailyRoutinesUsageVisible) + let backgroundRevision = store.backgroundWorkSettingsRevision + let menuDidChange = ObservationFlag() + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + menuDidChange.set() + } + store.claudeDailyRoutinesUsageVisible = false + try? await Task.sleep(nanoseconds: 50_000_000) + #expect(store.backgroundWorkSettingsRevision == backgroundRevision) + #expect(menuDidChange.get()) + + let reloaded = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + #expect(reloaded.claudeDailyRoutinesUsageVisible == false) + } +} + +struct ClaudeDailyRoutinesMenuCardTests { + @Test + func `visibility hides only the daily routines bar`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Max") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 8, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil), + tertiary: RateWindow( + usedPercent: 16, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7800), + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 11, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(8600), + resetDescription: nil)), + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 7, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(9200), + resetDescription: nil)), + ], + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + func makeModel(showOptionalUsage: Bool, routinesVisible: Bool) -> UsageMenuCardView.Model { + UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: "plus"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: showOptionalUsage, + claudeDailyRoutinesUsageVisible: routinesVisible, + hidePersonalInfo: false, + now: now)) + } + + let visibleModel = makeModel(showOptionalUsage: true, routinesVisible: true) + #expect(visibleModel.metrics.map(\.title) == [ + "Session", + "Weekly", + "Sonnet", + "Fable only", + "Daily Routines", + ]) + + let providerHiddenModel = makeModel(showOptionalUsage: true, routinesVisible: false) + #expect(providerHiddenModel.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Fable only"]) + + let globalHiddenModel = makeModel(showOptionalUsage: false, routinesVisible: true) + #expect(globalHiddenModel.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Fable only"]) + } +} diff --git a/Tests/CodexBarTests/ClaudeEducationAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeEducationAvailabilityTests.swift index 098f6ae1c..a74aa9db8 100644 --- a/Tests/CodexBarTests/ClaudeEducationAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeEducationAvailabilityTests.swift @@ -9,6 +9,7 @@ struct ClaudeEducationAvailabilityTests { let browserDetection = BrowserDetection(cacheTTL: 0) let strategy = ClaudeCLIFetchStrategy( useWebExtras: false, + includePrepaidBalance: false, manualCookieHeader: "sessionKey=test-session", browserDetection: browserDetection, hasWebFallback: true) diff --git a/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift b/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift index eedea332d..560217e2c 100644 --- a/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift +++ b/Tests/CodexBarTests/ClaudeExtraWindowQuotaWarningTests.swift @@ -48,6 +48,8 @@ struct ClaudeExtraWindowQuotaWarningTests { settings.quotaWarningThresholds = [50, 20] settings.setQuotaWarningWindowEnabled(.session, enabled: true) settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + settings.showOptionalCreditsAndExtraUsage = false + settings.claudeDailyRoutinesUsageVisible = false let notifier = SessionQuotaNotifierSpy() let store = UsageStore( diff --git a/Tests/CodexBarTests/ClaudeKeychainLiveProofTests.swift b/Tests/CodexBarTests/ClaudeKeychainLiveProofTests.swift index 6add89699..507f34db9 100644 --- a/Tests/CodexBarTests/ClaudeKeychainLiveProofTests.swift +++ b/Tests/CodexBarTests/ClaudeKeychainLiveProofTests.swift @@ -8,9 +8,23 @@ struct ClaudeKeychainLiveProofTests { ProcessInfo.processInfo.environment["LIVE_CLAUDE_KEYCHAIN_PROOF"] == "1" } + private static func allowsLiveKeychainAccess(environment: [String: String]) -> Bool { + environment[KeychainTestSafety.allowAccessEnvironmentKey] == "1" + } + + @Test + func `live proof requires explicit access to real user state`() { + #expect(Self.allowsLiveKeychainAccess(environment: [:]) == false) + #expect(Self.allowsLiveKeychainAccess(environment: [KeychainTestSafety.allowAccessEnvironmentKey: "1"])) + } + @Test func `live background Auto skips the opaque Claude Keychain boundary`() async { guard Self.isEnabled else { return } + guard Self.allowsLiveKeychainAccess(environment: ProcessInfo.processInfo.environment) else { + Issue.record("Live proof requires CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS=1 to read the real prompt policy") + return + } let mode = ClaudeOAuthKeychainPromptPreference.storedMode() guard mode == .onlyOnUserAction || mode == .never else { Issue.record("Live proof requires a restrictive stored Claude Keychain prompt mode; found \(mode.rawValue)") diff --git a/Tests/CodexBarTests/ClaudeMenuCardCostTests.swift b/Tests/CodexBarTests/ClaudeMenuCardCostTests.swift new file mode 100644 index 000000000..45db95490 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeMenuCardCostTests.swift @@ -0,0 +1,190 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ClaudeMenuCardCostTests { + @Test + func `claude extra usage card shows balance above monthly cap`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + balance: 100, + updatedAt: now), + updatedAt: now, + identity: nil) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Extra usage") + #expect(model.providerCost?.balanceLine == "Balance: $100.00") + #expect(model.providerCost?.spendLine == "Monthly cap: $5.00 / $20.00") + #expect(model.providerCost?.percentUsed == 25) + #expect(model.providerCost?.percentLine == "25% used") + #expect(model.providerCost?.presentation == .detail) + #expect(model.providerCost?.showsInProviderDetails == false) + } + + @Test + func `claude balance only card stays compact`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "USD", + period: "Usage credits", + balance: 100, + updatedAt: now), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Credits") + #expect(model.providerCost?.spendLine == "$100.00") + #expect(model.providerCost?.balanceLine == nil) + #expect(model.providerCost?.percentUsed == nil) + #expect(model.providerCost?.percentLine == nil) + #expect(model.providerCost?.presentation == .inlineValue) + #expect(model.providerCost?.showsInProviderDetails == false) + } + + @Test + func `claude admin api spend remains visible without prepaid balance`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 0, + currencyCode: "USD", + period: "Last 30 days", + updatedAt: now), + claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot(daily: [], updatedAt: now), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "admin@example.com", + accountOrganization: nil, + loginMethod: "Admin API")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "API spend") + #expect(model.providerCost?.spendLine == "Last 30 days: $12.34") + #expect(model.providerCost?.presentation == .detail) + #expect(model.providerCost?.showsInProviderDetails == true) + } + + @Test + func `claude monthly cap stays visible when prepaid balance is unavailable`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 0.49, + limit: 50, + currencyCode: "USD", + period: "Monthly cap", + updatedAt: now), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Extra usage") + #expect(model.providerCost?.balanceLine == nil) + #expect(model.providerCost?.spendLine == "Monthly cap: $0.49 / $50.00") + let percentUsed = try #require(model.providerCost?.percentUsed) + #expect(abs(percentUsed - 0.98) < 0.0001) + #expect(model.providerCost?.percentLine == "1% used") + #expect(model.providerCost?.presentation == .detail) + #expect(model.providerCost?.showsInProviderDetails == false) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsProfileCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsProfileCacheTests.swift new file mode 100644 index 000000000..588d8f6b5 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsProfileCacheTests.swift @@ -0,0 +1,599 @@ +import Foundation +import Security +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsProfileCacheTests { + private struct LegacyCacheEntry: Codable { + let data: Data + let storedAt: Date + let owner: ClaudeOAuthCredentialOwner? + let historyOwnerIdentifier: String? + } + + private func makeCredentialsData( + accessToken: String, + expiresAt: Date = Date(timeIntervalSinceNow: 3600)) -> Data + { + let expiresAt = Int(expiresAt.timeIntervalSince1970 * 1000) + return Data(""" + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(expiresAt), + "scopes": ["user:profile"] + } + } + """.utf8) + } + + private func withIsolatedCache(_ operation: () throws -> T) throws -> T { + let service = "com.steipete.codexbar.cache.profile-tests.\(UUID().uuidString)" + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + return try KeychainCacheStore.withServiceOverrideForTesting(service) { + try KeychainAccessGate.withTaskOverrideForTesting(false) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting(operation: operation) + } + } + } + } + } + } + + private func profileIdentifier(environment: [String: String]) -> String { + ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + } + } + + @Test + func `newer cache from another profile never overrides older credentials file`() throws { + let tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = tempRoot.appendingPathComponent("profile-a", isDirectory: true) + let profileB = tempRoot.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempRoot) } + + let credentialsA = profileA.appendingPathComponent(".credentials.json") + let credentialsB = profileB.appendingPathComponent(".credentials.json") + try self.makeCredentialsData(accessToken: "profile-a-token").write(to: credentialsA) + try self.makeCredentialsData(accessToken: "profile-b-token").write(to: credentialsB) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -3600)], + ofItemAtPath: credentialsB.path) + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let missingEnvironment = ["CLAUDE_CONFIG_DIR": tempRoot.appendingPathComponent("missing").path] + + try self.withIsolatedCache { + ClaudeOAuthCredentialsStore.invalidateCache() + let first = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.load( + environment: environmentA, + allowKeychainPrompt: false) + } + #expect(first.accessToken == "profile-a-token") + + // A's cache is newer than B's file. The profile identity, not freshness, must decide ownership. + let second = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.load( + environment: environmentB, + allowKeychainPrompt: false) + } + #expect(second.accessToken == "profile-b-token") + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: missingEnvironment) == false) + } + } + + @Test + func `profile caches survive switching Claude config directories`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = root.appendingPathComponent("profile-a", isDirectory: true) + let profileB = root.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let identifierA = self.profileIdentifier(environment: environmentA) + let identifierB = self.profileIdentifier(environment: environmentB) + + try self.withIsolatedCache { + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierA), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-a-refresh-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierA)) + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierB), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-b-refresh-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierB)) + + let first = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environmentA, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + let second = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environmentB, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + let resumed = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environmentA, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + + #expect(first.credentials.accessToken == "profile-a-refresh-token") + #expect(second.credentials.accessToken == "profile-b-refresh-token") + #expect(resumed.credentials.accessToken == "profile-a-refresh-token") + } + } + + @Test + func `file fingerprint from one profile does not clear cache only sibling`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = root.appendingPathComponent("profile-a", isDirectory: true) + let profileB = root.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let identifierB = self.profileIdentifier(environment: environmentB) + try self.makeCredentialsData(accessToken: "profile-a-file-token") + .write(to: profileA.appendingPathComponent(".credentials.json")) + + try self.withIsolatedCache { + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierB), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-b-cache-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierB)) + + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged( + environment: environmentA)) + #expect(!ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged( + environment: environmentB)) + + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environmentB, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + #expect(record.credentials.accessToken == "profile-b-cache-token") + #expect(record.source == .cacheKeychain) + } + } + + @Test + func `invalidating one profile preserves another profile cache`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = root.appendingPathComponent("profile-a", isDirectory: true) + let profileB = root.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let identifierA = self.profileIdentifier(environment: environmentA) + let identifierB = self.profileIdentifier(environment: environmentB) + + try self.withIsolatedCache { + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierA), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-a-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierA)) + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierB), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-b-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierB)) + + ClaudeOAuthCredentialsStore.invalidateCache(environment: environmentA) + + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: environmentA) == false) + #expect(ClaudeOAuthCredentialsStore.hasCachedCredentials(environment: environmentB) == true) + } + } + + @Test + func `deferred cleanup for one profile does not erase another profile cache`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let profileA = root.appendingPathComponent("profile-a", isDirectory: true) + let profileB = root.appendingPathComponent("profile-b", isDirectory: true) + try FileManager.default.createDirectory(at: profileA, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: profileB, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let environmentA = ["CLAUDE_CONFIG_DIR": profileA.path] + let environmentB = ["CLAUDE_CONFIG_DIR": profileB.path] + let identifierA = self.profileIdentifier(environment: environmentA) + let identifierB = self.profileIdentifier(environment: environmentB) + + try self.withIsolatedCache { + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: identifierB), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "profile-b-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: identifierB)) + + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + pendingStore.markPending(profileIdentifier: identifierA) + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: environmentB, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + #expect(record.credentials.accessToken == "profile-b-token") + #expect(pendingStore.isPending(profileIdentifier: identifierA)) + #expect(!pendingStore.isPending(profileIdentifier: identifierB)) + } + } + } + + @Test + func `legacy default profile cache migrates without losing refreshed credentials`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let fileURL = tempDir.appendingPathComponent("credentials.json") + let fileModifiedAt = Date(timeIntervalSince1970: 1_700_000_000) + let cacheStoredAt = Date(timeIntervalSince1970: 1_700_000_100) + let fileData = self.makeCredentialsData( + accessToken: "expired-file-token", + expiresAt: Date(timeIntervalSince1970: 1_600_000_000)) + try fileData.write(to: fileURL) + try FileManager.default.setAttributes( + [.modificationDate: fileModifiedAt], + ofItemAtPath: fileURL.path) + let legacyCacheData = self.makeCredentialsData(accessToken: "legacy-refreshed-token") + let historyOwnerIdentifier = String(repeating: "a", count: 64) + let historicalProfileIdentifier = self.profileIdentifier(environment: [:]) + + try self.withIsolatedCache { + try ClaudeOAuthCredentialsStore.withCredentialsProfileIdentifierOverrideForTesting( + historicalProfileIdentifier) + { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let environment: [String: String] = [:] + KeychainCacheStore.store( + key: .oauth(provider: .claude), + entry: LegacyCacheEntry( + data: legacyCacheData, + storedAt: cacheStoredAt, + owner: .codexbar, + historyOwnerIdentifier: historyOwnerIdentifier)) + + let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: environment, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + } + #expect(record.credentials.accessToken == "legacy-refreshed-token") + #expect(record.source == .cacheKeychain) + + switch KeychainCacheStore.load( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: historicalProfileIdentifier), + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + #expect(entry.data == legacyCacheData) + #expect(entry.storedAt == cacheStoredAt) + #expect(entry.owner == .codexbar) + #expect(entry.historyOwnerIdentifier == historyOwnerIdentifier) + #expect(entry.profileIdentifier == historicalProfileIdentifier) + case .missing, .invalid, .temporarilyUnavailable: + Issue.record("Expected the legacy default cache to be migrated to its profile key") + } + } + } + } + } + + @Test + func `failed legacy deletion cannot resurrect after profile invalidation`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let missingCredentialsURL = tempDir.appendingPathComponent("missing-credentials.json") + let historicalProfileIdentifier = self.profileIdentifier(environment: [:]) + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + + try self.withIsolatedCache { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try ClaudeOAuthCredentialsStore.withCredentialsProfileIdentifierOverrideForTesting( + historicalProfileIdentifier) + { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + let legacyKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store( + key: legacyKey, + entry: LegacyCacheEntry( + data: self.makeCredentialsData(accessToken: "stale-legacy-token"), + storedAt: Date(), + owner: .codexbar, + historyOwnerIdentifier: nil)) + + let migrated = try KeychainCacheStore.withClearFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + } + #expect(migrated.credentials.accessToken == "stale-legacy-token") + #expect(pendingStore.isPending(profileIdentifier: historicalProfileIdentifier)) + + ClaudeOAuthCredentialsStore.invalidateCache(environment: [:]) + + #expect(!pendingStore.isPending(profileIdentifier: historicalProfileIdentifier)) + do { + _ = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + Issue.record("Expected invalidation to prevent stale legacy cache re-import") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + guard case .missing = KeychainCacheStore.load( + key: legacyKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + else { + Issue.record("Expected deferred legacy cleanup to remove the stale cache") + return + } + } + } + } + } + } + + @Test + func `pending legacy cleanup cannot shield migrated cache from file change`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let credentialsURL = root.appendingPathComponent("credentials.json") + let historicalProfileIdentifier = self.profileIdentifier(environment: [:]) + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + + try self.withIsolatedCache { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try ClaudeOAuthCredentialsStore.withCredentialsProfileIdentifierOverrideForTesting( + historicalProfileIdentifier) + { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(credentialsURL) { + let legacyKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store( + key: legacyKey, + entry: LegacyCacheEntry( + data: self.makeCredentialsData(accessToken: "stale-legacy-token"), + storedAt: Date(), + owner: .codexbar, + historyOwnerIdentifier: nil)) + + _ = try KeychainCacheStore.withClearFailureStatusOverrideForTesting( + errSecInteractionNotAllowed) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + } + #expect(pendingStore.isPending(profileIdentifier: historicalProfileIdentifier)) + + try self.makeCredentialsData(accessToken: "changed-file-token").write(to: credentialsURL) + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged(environment: [:])) + + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + #expect(record.credentials.accessToken == "changed-file-token") + #expect(record.source == .credentialsFile) + } + } + } + } + } + + @Test + func `cache disabled invalidation conditionally retires legacy default entry`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let missingCredentialsURL = root.appendingPathComponent("missing-credentials.json") + let historicalProfileIdentifier = self.profileIdentifier(environment: [:]) + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + + try self.withIsolatedCache { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try ClaudeOAuthCredentialsStore.withCredentialsProfileIdentifierOverrideForTesting( + historicalProfileIdentifier) + { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + let legacyKey = KeychainCacheStore.Key.oauth(provider: .claude) + KeychainCacheStore.store( + key: legacyKey, + entry: LegacyCacheEntry( + data: self.makeCredentialsData(accessToken: "invalidated-legacy-token"), + storedAt: Date(), + owner: .codexbar, + historyOwnerIdentifier: nil)) + + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache(environment: [:]) + } + #expect(pendingStore.isPending(profileIdentifier: historicalProfileIdentifier)) + + do { + _ = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + } + Issue.record("Expected invalidation to prevent legacy cache migration") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + guard case .missing = KeychainCacheStore.load(key: legacyKey, as: LegacyCacheEntry.self) else { + Issue.record("Expected the invalidated legacy cache to be retired") + return + } + } + } + } + } + } + + @Test + func `released fingerprint without path detects removed default credentials file`() throws { + let releasedFingerprintData = Data(#"{"modifiedAtMs":1700000000000,"size":321}"#.utf8) + let releasedFingerprint = try JSONDecoder().decode( + ClaudeOAuthCredentialsStore.CredentialsFileFingerprint.self, + from: releasedFingerprintData) + let historicalCredentialsPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude", isDirectory: true) + .appendingPathComponent(".credentials.json") + .standardizedFileURL.path + #expect(releasedFingerprint.path == historicalCredentialsPath) + + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let missingCredentialsURL = root.appendingPathComponent("removed-credentials.json") + let historicalProfileIdentifier = self.profileIdentifier(environment: [:]) + let fingerprintStore = ClaudeOAuthCredentialsStore.CredentialsFileFingerprintStore( + fingerprint: releasedFingerprint) + + try self.withIsolatedCache { + ClaudeOAuthCredentialsStore.withCredentialsFileFingerprintStoreOverrideForTesting( + fingerprintStore) + { + ClaudeOAuthCredentialsStore.withCredentialsProfileIdentifierOverrideForTesting( + historicalProfileIdentifier) + { + ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(missingCredentialsURL) { + let profileKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: historicalProfileIdentifier) + KeychainCacheStore.store( + key: profileKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.makeCredentialsData(accessToken: "removed-file-cache-token"), + storedAt: Date(), + owner: .codexbar, + profileIdentifier: historicalProfileIdentifier)) + + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged( + environment: [:])) + guard case .missing = KeychainCacheStore.load( + key: profileKey, + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + else { + Issue.record("Expected the removed default credentials file to invalidate its cache") + return + } + } + } + } + } + } + + @Test + func `legacy cache without profile identity fails closed for custom profile`() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let fileURL = tempDir.appendingPathComponent(".credentials.json") + let fileData = self.makeCredentialsData(accessToken: "file-token") + try fileData.write(to: fileURL) + try FileManager.default.setAttributes( + [.modificationDate: Date(timeIntervalSinceNow: -3600)], + ofItemAtPath: fileURL.path) + let legacyCacheData = self.makeCredentialsData(accessToken: "legacy-cache-token") + + try self.withIsolatedCache { + let environment = ["CLAUDE_CONFIG_DIR": tempDir.path] + _ = ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged(environment: environment) + KeychainCacheStore.store( + key: .oauth(provider: .claude), + entry: LegacyCacheEntry( + data: legacyCacheData, + storedAt: Date(), + owner: .claudeCLI, + historyOwnerIdentifier: nil)) + + let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: environment, + allowKeychainPrompt: false, + allowClaudeKeychainRepairWithoutPrompt: false) + } + #expect(record.credentials.accessToken == "file-token") + #expect(record.source == .credentialsFile) + + switch KeychainCacheStore.load( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: self.profileIdentifier(environment: environment)), + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + #expect(entry.profileIdentifier == self.profileIdentifier(environment: environment)) + #expect(try ClaudeOAuthCredentials.parse(data: entry.data).accessToken == "file-token") + case .missing, .invalid, .temporarilyUnavailable: + Issue.record("Expected the custom profile file to populate its own cache entry") + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift index 660a542fd..9876f305d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift @@ -22,6 +22,30 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { return Data(json.utf8) } + private func withDeterministicCacheService( + _ service: String, + operation: () throws -> T) rethrows -> T + { + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + return try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try KeychainCacheStore.withServiceOverrideForTesting(service, operation: operation) + } + } + } + + private func withDeterministicCacheService( + _ service: String, + operation: () async throws -> T) async rethrows -> T + { + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + return try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try await ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try await KeychainCacheStore.withServiceOverrideForTesting(service, operation: operation) + } + } + } + private func withClaudeOAuthTokenRefreshStub( handler: @escaping (URLRequest) throws -> (HTTPURLResponse, Data), operation: () async throws -> T) async rethrows -> T @@ -64,7 +88,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { @Test func `successful codexbar refresh is re-owned when Claude CLI storage appears`() async throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -81,7 +105,9 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) defer { KeychainCacheStore.clear(key: cacheKey) } let expiredData = self.makeCredentialsData( @@ -178,7 +204,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { @Test func `rotated refresh token preserves history owner through cache restart`() async throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -190,12 +216,13 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } let fileURL = tempDir.appendingPathComponent("credentials.json") - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - defer { KeychainCacheStore.clear(key: cacheKey) } - try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) + defer { KeychainCacheStore.clear(key: cacheKey) } ClaudeOAuthCredentialsStore.invalidateCache() let expiredData = self.makeCredentialsData( accessToken: "access-before-rotation", @@ -273,7 +300,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { @Test func `load record treats codexbar cache as claude CLI owned when credentials file exists`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try KeychainCacheStore.withServiceOverrideForTesting(service) { + try self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -328,7 +355,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { @Test func `load with auto refresh delegates expired codexbar cache when credentials file exists`() async throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -386,7 +413,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { @Test func `load with auto refresh keeps codexbar cache ownership without Claude CLI storage`() async throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -444,7 +471,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { @Test func `load record treats codexbar cache as claude CLI owned when Claude keychain item exists`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try KeychainCacheStore.withServiceOverrideForTesting(service) { + try self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -573,7 +600,7 @@ struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { } """.utf8) - try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift index 04ce3622f..a1e80fe9a 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests.swift @@ -70,24 +70,27 @@ struct ClaudeOAuthCredentialsStoreIsolatedSecurityCLITests { ClaudeOAuthCredentialsStore.isolatedSecurityCLIKeychainEnvironmentKey: "/tmp/verify.keychain-db", ] - let isMcpOnly = ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { - ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( - interaction: .background, - readStrategy: .securityCLIExperimental, - keychainAccessDisabled: true, - environment: environment) - } - #expect(isMcpOnly) + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + let isMcpOnly = ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityCLIExperimental, + keychainAccessDisabled: true, + environment: environment) + } + #expect(isMcpOnly) - let blockedWithoutIsolatedKeychain = ClaudeOAuthCredentialsStore - .withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { - ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( - interaction: .background, - readStrategy: .securityCLIExperimental, - keychainAccessDisabled: true, - environment: [KeychainAccessGate.disableAccessEnvironmentKey: "1"]) - } - #expect(blockedWithoutIsolatedKeychain == false) + let blockedWithoutIsolatedKeychain = ClaudeOAuthCredentialsStore + .withSecurityCLIReadOverrideForTesting(.data(mcpOnlyPayload)) { + ClaudeOAuthCredentialsStore.isMcpOAuthOnlyClaudeKeychainPayloadPresent( + interaction: .background, + readStrategy: .securityCLIExperimental, + keychainAccessDisabled: true, + environment: [KeychainAccessGate.disableAccessEnvironmentKey: "1"]) + } + #expect(blockedWithoutIsolatedKeychain == false) + } } @Test diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift index e5f93854e..a762f8392 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreNeverPromptCacheTests.swift @@ -6,9 +6,13 @@ import Testing @Suite(.serialized) struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { private struct TestState { - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) let pendingStore: ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore let recorder: ClaudeOAuthCredentialsStore.OAuthCacheOperationRecorder + + var cacheKey: KeychainCacheStore.Key { + ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: [:])) + } } private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { @@ -259,7 +263,7 @@ struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { #expect(credentials.accessToken == "file-token-new") #expect(!state.pendingStore.isPending) - #expect(state.recorder.operations == [.clear, .load, .store]) + #expect(state.recorder.operations == [.clear, .load, .load, .store]) let cachedToken = try self.cachedToken(state) #expect(cachedToken == "file-token-new") } @@ -295,7 +299,7 @@ struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { } #expect(!state.pendingStore.isPending) - #expect(state.recorder.operations == [.clear, .load]) + #expect(state.recorder.operations == [.clear, .load, .load]) let cachedToken = try self.cachedToken(state) #expect(cachedToken == nil) } @@ -335,7 +339,16 @@ struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { } #expect(second.accessToken == "file-token-new") #expect(!state.pendingStore.isPending) - #expect(state.recorder.operations == [.clear, .clear, .load, .store]) + #expect(state.recorder.operations == [ + .clear, + .load, + .clear, + .clear, + .load, + .load, + .load, + .store, + ]) let refreshedToken = try self.cachedToken(state) #expect(refreshedToken == "file-token-new") } @@ -411,7 +424,7 @@ struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { #expect(synced) #expect(state.pendingStore.isPending) - #expect(state.recorder.operations == [.clear, .store]) + #expect(state.recorder.operations == [.clear]) let cachedToken = try self.cachedToken(state) #expect(cachedToken == "cached-token") } @@ -643,6 +656,40 @@ struct ClaudeOAuthCredentialsStoreNeverPromptCacheTests { let cachedToken = try self.cachedToken(state) #expect(cachedToken == "cached-token") + do { + _ = try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthKeychainReadStrategyPreference.withTaskOverrideForTesting( + .securityCLIExperimental) + { + try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(nil)) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: Data(), + fingerprint: nil) + { + try ProviderInteractionContext.$current.withValue(.background) { + try ClaudeOAuthCredentialsStore.load( + environment: [:], + allowKeychainPrompt: false) + } + } + } + } + } + } + Issue.record("Expected ClaudeOAuthCredentialsError.notFound") + } catch let error as ClaudeOAuthCredentialsError { + guard case .notFound = error else { + Issue.record("Expected .notFound, got \(error)") + return + } + } + + #expect(!state.pendingStore.isPending) + #expect(state.recorder.operations == [.clear, .load, .load]) + let clearedToken = try self.cachedToken(state) + #expect(clearedToken == nil) + let mcpOnly = Data(#"{"mcpOAuth":{"plugin:test":{"accessToken":"synthetic"}}}"#.utf8) let isMcpOnly = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting(.data(mcpOnly)) { diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift index 4969e44cc..30d2f998a 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStorePromptPolicyTests.swift @@ -10,6 +10,36 @@ struct ClaudeOAuthCredentialsStorePromptPolicyTests { _ = notify } + @Test + func `safety does not inherit the application prompt preference`() throws { + guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else { + return + } + + #expect(ClaudeOAuthKeychainPromptPreference.currentTaskOverrideForTesting == nil) + + let domain = "ClaudeOAuthPromptPolicyIsolationTests.\(UUID().uuidString)" + let key = "claudeOAuthKeychainPromptMode" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { + defaults.removePersistentDomain(forName: domain) + defaults.synchronize() + } + defaults.set(ClaudeOAuthKeychainPromptMode.never.rawValue, forKey: key) + defaults.synchronize() + + ClaudeOAuthKeychainPromptPreference.withImplicitApplicationUserDefaultsOverrideForTesting(defaults) { + // Isolation must ignore a conflicting value in the implicit application defaults domain. + #expect(ClaudeOAuthKeychainPromptPreference.storedMode() == .onlyOnUserAction) + #expect(ClaudeOAuthKeychainPromptPreference.storedMode(userDefaults: defaults) == .never) + + let explicit = ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + ClaudeOAuthKeychainPromptPreference.storedMode() + } + #expect(explicit == .always) + } + } + private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { let millis = Int(expiresAt.timeIntervalSince1970 * 1000) let refreshField: String = { diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift index b9e0630f5..900f0e77d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests.swift @@ -26,11 +26,23 @@ struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { return Data(json.utf8) } + private func withDeterministicCacheService( + _ service: String, + operation: () throws -> T) rethrows -> T + { + let pendingStore = ClaudeOAuthCredentialsStore.PendingCacheClearMemoryStore() + return try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.onlyOnUserAction) { + try ClaudeOAuthCredentialsStore.withPendingCacheClearStoreOverrideForTesting(pendingStore) { + try KeychainCacheStore.withServiceOverrideForTesting(service, operation: operation) + } + } + } + #if os(macOS) @Test func `credentials file invalidation preserves keychain cache when temporarily unavailable`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try KeychainCacheStore.withServiceOverrideForTesting(service) { + try self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -102,7 +114,7 @@ struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { @Test func `temporary keychain cache unavailability does not overwrite cache from credentials file fallback`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try KeychainCacheStore.withServiceOverrideForTesting(service) { + try self.withDeterministicCacheService(service) { try KeychainAccessGate.withTaskOverrideForTesting(true) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -161,7 +173,7 @@ struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { @Test func `has cached credentials treats temporary keychain cache unavailability as present`() { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - KeychainCacheStore.withServiceOverrideForTesting(service) { + self.withDeterministicCacheService(service) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } @@ -191,7 +203,7 @@ struct ClaudeOAuthCredentialsStoreTemporaryKeychainCacheTests { @Test func `invalid keychain cache is cleared by load`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" - try KeychainCacheStore.withServiceOverrideForTesting(service) { + try self.withDeterministicCacheService(service) { try KeychainAccessGate.withTaskOverrideForTesting(true) { KeychainCacheStore.setTestStoreForTesting(true) defer { KeychainCacheStore.setTestStoreForTesting(false) } diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index e11193e1d..663dfe62d 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -3,6 +3,7 @@ import Testing @testable import CodexBarCore @Suite(.serialized) +// swiftlint:disable:next type_body_length struct ClaudeOAuthCredentialsStoreTests { private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { let millis = Int(expiresAt.timeIntervalSince1970 * 1000) @@ -50,6 +51,88 @@ struct ClaudeOAuthCredentialsStoreTests { #expect(refreshedHash == firstHash) } + @Test + func `safety isolates the default Claude credentials file`() { + guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else { + return + } + + let defaultURL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude/.credentials.json") + #expect(ClaudeOAuthCredentialsStore.resolvedCredentialsURLForTesting != defaultURL) + + let overrideURL = FileManager.default.temporaryDirectory + .appendingPathComponent("explicit-credentials.json") + let resolvedOverride = ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(overrideURL) { + ClaudeOAuthCredentialsStore.resolvedCredentialsURLForTesting + } + #expect(resolvedOverride == overrideURL) + } + + @Test + func `safety isolates pending cache clear from the application suite`() throws { + guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else { + return + } + + let domain = "ClaudeOAuthPendingCacheIsolationTests.\(UUID().uuidString)" + let key = "ClaudeOAuthPendingCodexBarOAuthKeychainCacheClearV1" + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { + defaults.removePersistentDomain(forName: domain) + defaults.synchronize() + try? FileManager.default.removeItem(at: tempDirectory) + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + } + + let sentinel = "isolation-sentinel-\(UUID().uuidString)" + defaults.set(sentinel, forKey: key) + defaults.synchronize() + let implicitStore = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: tempDirectory.appendingPathComponent("cache.lock")) + + // never-mode cache invalidation marks pending clear without an explicit store override. + ClaudeOAuthCredentialsStore.withImplicitPendingCacheClearStoreOverrideForTesting(implicitStore) { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + } + + defaults.synchronize() + #expect(defaults.string(forKey: key) == sentinel) + } + + @Test + func `safety isolates pending cache clear across isolation scopes`() { + guard ProcessInfo.processInfo.environment[KeychainTestSafety.allowAccessEnvironmentKey] != "1" else { + return + } + + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + #expect(ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClearForTesting) + } + + // A fresh isolation scope must not inherit pending state from a prior scope. + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + #expect(!ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClearForTesting) + } + + // Unscoped never-mode marks must also leave subsequent isolation scopes clean. + ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + ClaudeOAuthCredentialsStore.invalidateCache() + } + ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + #expect(!ClaudeOAuthCredentialsStore.hasPendingCodexBarOAuthKeychainCacheClearForTesting) + } + } + @Test func `loads from keychain cache before expired file`() throws { let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" @@ -274,7 +357,9 @@ struct ClaudeOAuthCredentialsStoreTests { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) defer { KeychainCacheStore.clear(key: cacheKey) } let expiredData = self.makeCredentialsData( @@ -324,7 +409,9 @@ struct ClaudeOAuthCredentialsStoreTests { await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { ClaudeOAuthCredentialsStore.invalidateCache() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) defer { KeychainCacheStore.clear(key: cacheKey) } let expiredData = self.makeCredentialsData( @@ -510,7 +597,9 @@ struct ClaudeOAuthCredentialsStoreTests { // Avoid cross-suite interference from UserDefaults fingerprint persistence. let fingerprintStore = ClaudeOAuthCredentialsStore.ClaudeKeychainFingerprintStore() - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) let cachedData = self.makeCredentialsData( accessToken: "cached-token", expiresAt: Date(timeIntervalSinceNow: 3600)) @@ -614,7 +703,9 @@ struct ClaudeOAuthCredentialsStoreTests { persistentRefHash: "ref1") fingerprintStore.fingerprint = fingerprint1 - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) let cachedData = self.makeCredentialsData( accessToken: "cached-token", expiresAt: Date(timeIntervalSinceNow: 3600)) @@ -679,7 +770,9 @@ struct ClaudeOAuthCredentialsStoreTests { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() } - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) let cachedData = self.makeCredentialsData( accessToken: "cached-token", expiresAt: Date(timeIntervalSinceNow: 3600)) @@ -736,7 +829,9 @@ struct ClaudeOAuthCredentialsStoreTests { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() } - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) let cachedData = self.makeCredentialsData( accessToken: "cached-token", expiresAt: Date(timeIntervalSinceNow: 3600)) @@ -806,7 +901,9 @@ struct ClaudeOAuthCredentialsStoreTests { ClaudeOAuthCredentialsStore._resetClaudeKeychainChangeTrackingForTesting() } - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheKey = ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier( + environment: [:])) let cachedData = self.makeCredentialsData( accessToken: "cached-token", expiresAt: Date(timeIntervalSinceNow: 3600)) diff --git a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift index 54b59d7c8..f559f7b46 100644 --- a/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthDelegatedRefreshCoordinatorTests.swift @@ -670,14 +670,17 @@ struct ClaudeOAuthDelegatedRefreshCoordinatorTests { }, operation: { await ClaudeOAuthCredentialsStore.withSecurityCLIReadOverrideForTesting( - .dynamic { _ in dataBox.load() }) - { - await ProviderInteractionContext.$current.withValue(.userInitiated) { - await ClaudeOAuthDelegatedRefreshCoordinator.attempt( - now: Date(timeIntervalSince1970: 61000), - timeout: 0.1) + .dynamic { _ in + #expect( + ClaudeOAuthKeychainPromptPreference.currentTaskOverrideForTesting == .always) + return dataBox.load() + }) { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeOAuthDelegatedRefreshCoordinator.attempt( + now: Date(timeIntervalSince1970: 61000), + timeout: 0.1) + } } - } }) #expect(outcome == .attemptedSucceeded) diff --git a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift index 3e74ebe84..3427aa394 100644 --- a/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift @@ -21,17 +21,21 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { private func makeContext( sourceMode: ProviderSourceMode, - env: [String: String] = [:]) -> ProviderFetchContext + env: [String: String] = [:], + settings: ProviderSettingsSnapshot? = nil, + includeOptionalUsage: Bool = true, + webTimeout: TimeInterval = 1) -> ProviderFetchContext { ProviderFetchContext( runtime: .app, sourceMode: sourceMode, includeCredits: false, - webTimeout: 1, + includeOptionalUsage: includeOptionalUsage, + webTimeout: webTimeout, webDebugDumpHTML: false, verbose: false, env: env, - settings: nil, + settings: settings, fetcher: UsageFetcher(environment: env), claudeFetcher: StubClaudeFetcher(), browserDetection: BrowserDetection(cacheTTL: 0)) @@ -49,6 +53,340 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { source: .cacheKeychain) } + @Test + func `O auth strategy enriches usage with web balance when optional usage is enabled`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .manual, + manualCookieHeader: "sessionKey=sk-ant-session-token")) + let context = self.makeContext(sourceMode: .auto, settings: settings) + let credentials = ClaudeOAuthCredentials( + accessToken: "oauth-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: "claude_pro") + let usageResponse = try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(""" + { + "five_hour": { "utilization": 7 }, + "extra_usage": { + "is_enabled": true, + "monthly_limit": 2000, + "used_credits": 500, + "currency": "USD" + } + } + """.utf8)) + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let body: String + let statusCode: Int + switch url.path { + case "/api/organizations": + body = #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"# + statusCode = 200 + case "/api/organizations/org-123/usage": + body = #"{"five_hour":{"utilization":44}}"# + statusCode = 200 + case "/api/organizations/org-123/prepaid/credits": + body = #"{"amount":10000,"currency":"USD"}"# + statusCode = 200 + case "/api/account": + body = """ + { + "email_address": "user@example.com", + "memberships": [{"organization": {"uuid": "org-123"}}] + } + """ + statusCode = 200 + default: + body = "{}" + statusCode = 404 + } + let response = try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } + let loadCredentials: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in credentials } + let fetchUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in usageResponse } + let fetchProfile: @Sendable (String) async throws -> OAuthProfileResponse = { _ in + OAuthProfileResponse(emailAddress: "user@example.com", organizationUuid: "org-123") + } + + let result = try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredentials) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchUsage) { + try await ClaudeUsageFetcher.$fetchOAuthProfileOverride.withValue(fetchProfile) { + try await ClaudeOAuthFetchStrategy().fetch(context) + } + } + } + } + + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.providerCost?.used == 5) + #expect(result.usage.providerCost?.limit == 20) + #expect(result.usage.providerCost?.balance == 100) + } + + @Test + func `auto without cached web session publishes O auth usage without browser discovery`() async throws { + try await self.withIsolatedCookieCache { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(sourceMode: .auto, settings: settings) + let credentials = ClaudeOAuthCredentials( + accessToken: "oauth-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: "claude_pro") + let usageResponse = try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(""" + {"five_hour":{"utilization":7}} + """.utf8)) + let importedSession = ClaudeWebAPIFetcher.SessionKeyInfo( + key: "sk-ant-browser-session", + sourceLabel: "Browser", + cookieCount: 1) + let transport = ProviderHTTPTransportHandler { request in + let url = request.url?.absoluteString ?? "nil" + Issue.record("Unexpected Claude browser-cookie discovery request: \(url)") + throw URLError(.badServerResponse) + } + let loadCredentials: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in credentials } + let fetchUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in usageResponse } + let fetchProfile: @Sendable (String) async throws -> OAuthProfileResponse = { _ in + OAuthProfileResponse(emailAddress: "user@example.com", organizationUuid: "org-123") + } + + let result = try await ClaudeWebSessionKeyImport.$overrideForTesting.withValue(importedSession) { + try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredentials) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchUsage) { + try await ClaudeUsageFetcher.$fetchOAuthProfileOverride.withValue(fetchProfile) { + try await ClaudeOAuthFetchStrategy().fetch(context) + } + } + } + } + } + + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.providerCost == nil) + } + } + + @Test + func `blank manual cookie does not fall back to browser enrichment`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .oauth, + webExtrasEnabled: true, + cookieSource: .manual, + manualCookieHeader: " ")) + let context = self.makeContext(sourceMode: .oauth, settings: settings) + let credentials = ClaudeOAuthCredentials( + accessToken: "oauth-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: "claude_pro") + let usageResponse = try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(""" + { + "five_hour": { "utilization": 7 } + } + """.utf8)) + let transport = ProviderHTTPTransportHandler { request in + Issue.record("Unexpected Claude web request: \(request.url?.absoluteString ?? "nil")") + throw URLError(.badServerResponse) + } + let loadCredentials: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in credentials } + let fetchUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in usageResponse } + + let result = try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredentials) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchUsage) { + try await ClaudeOAuthFetchStrategy().fetch(context) + } + } + } + + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.providerCost == nil) + } + + @Test + func `O auth web enrichment timeout preserves completed usage`() async throws { + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .oauth, + webExtrasEnabled: false, + cookieSource: .manual, + manualCookieHeader: "sessionKey=sk-ant-session-token")) + let context = self.makeContext( + sourceMode: .oauth, + settings: settings, + webTimeout: 0.02) + let credentials = ClaudeOAuthCredentials( + accessToken: "oauth-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: "claude_pro") + let usageResponse = try ClaudeOAuthUsageFetcher._decodeUsageResponseForTesting(Data(""" + {"five_hour":{"utilization":7}} + """.utf8)) + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + if url.path == "/api/organizations" { + try await Task.sleep(for: .seconds(10)) + throw CancellationError() + } + throw URLError(.badServerResponse) + } + let loadCredentials: @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials = { _, _, _ in credentials } + let fetchUsage: @Sendable (String, Bool) async throws -> OAuthUsageResponse = { _, _ in usageResponse } + let fetchProfile: @Sendable (String) async throws -> OAuthProfileResponse = { _ in + OAuthProfileResponse(emailAddress: "user@example.com", organizationUuid: "org-123") + } + + let result = try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(loadCredentials) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchUsage) { + try await ClaudeUsageFetcher.$fetchOAuthProfileOverride.withValue(fetchProfile) { + try await ClaudeOAuthFetchStrategy().fetch(context) + } + } + } + } + + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.providerCost?.balance == nil) + } + + @Test + func `auto CLI fallback enriches usage with matching web balance`() async throws { + let cliPath = try Self.makeExecutableClaudeStub() + defer { try? FileManager.default.removeItem(atPath: cliPath) } + let settings = ProviderSettingsSnapshot.make(claude: .init( + usageDataSource: .auto, + webExtrasEnabled: false, + cookieSource: .manual, + manualCookieHeader: "sessionKey=sk-ant-session-token")) + let context = self.makeContext( + sourceMode: .auto, + env: ["CLAUDE_CLI_PATH": cliPath], + settings: settings) + let unavailableOAuthRecord = ClaudeOAuthCredentialRecord( + credentials: ClaudeOAuthCredentials( + accessToken: "oauth-token-without-profile-scope", + refreshToken: nil, + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:inference"], + rateLimitTier: nil), + owner: .claudeCLI, + source: .environment) + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let body: String + let statusCode: Int + switch url.path { + case "/api/organizations": + body = #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"# + statusCode = 200 + case "/api/organizations/org-123/usage": + body = #"{"five_hour":{"utilization":44}}"# + statusCode = 200 + case "/api/organizations/org-123/prepaid/credits": + body = #"{"amount":10000,"currency":"USD"}"# + statusCode = 200 + case "/api/account": + body = """ + { + "email_address": "user@example.com", + "memberships": [{"organization": {"uuid": "org-123"}}] + } + """ + statusCode = 200 + default: + body = "{}" + statusCode = 404 + } + let response = try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } + let cliFetch: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in + ClaudeStatusSnapshot( + sessionPercentLeft: 93, + weeklyPercentLeft: nil, + opusPercentLeft: nil, + accountEmail: "user@example.com", + accountOrganization: "Test Org", + loginMethod: "Pro", + primaryResetDescription: nil, + secondaryResetDescription: nil, + opusResetDescription: nil, + rawText: "stub") + } + + func fetchResult(context: ProviderFetchContext) async throws -> ProviderFetchResult { + let outcome = await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + await ClaudeOAuthFetchStrategy.$nonInteractiveCredentialRecordOverride.withValue( + unavailableOAuthRecord) + { + await ClaudeOAuthFetchStrategy.$claudeCLIAvailableOverride.withValue(true) { + await ClaudeOAuthKeychainAccessGate.withShouldAllowPromptOverrideForTesting(false) { + await ClaudeStatusProbe.$fetchOverride.withValue(cliFetch) { + await ProviderInteractionContext.$current.withValue(.userInitiated) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } + } + } + } + } + } + return try outcome.result.get() + } + + let result = try await fetchResult(context: context) + + #expect(result.strategyID == "claude.cli") + #expect(result.usage.primary?.usedPercent == 7) + #expect(result.usage.providerCost?.balance == 100) + + let hiddenResult = try await fetchResult(context: self.makeContext( + sourceMode: .auto, + env: ["CLAUDE_CLI_PATH": cliPath], + settings: settings, + includeOptionalUsage: false)) + #expect(hiddenResult.strategyID == "claude.cli") + #expect(hiddenResult.usage.primary?.usedPercent == 7) + #expect(hiddenResult.usage.providerCost == nil) + } + @Test func `auto mode expired CLI creds remain available after Keychain opt in`() async { let context = self.makeContext(sourceMode: .auto) @@ -454,6 +792,29 @@ struct ClaudeOAuthFetchStrategyAvailabilityTests { Data(#"{"claudeAiOauth":{"accessToken":"fixture"}}"#.utf8) } + private static func makeExecutableClaudeStub() throws -> String { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-extra-credit-\(UUID().uuidString)") + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.path + } + + private func withIsolatedCookieCache(_ operation: () async throws -> T) async rethrows -> T { + let legacyBase = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-oauth-enrichment-\(UUID().uuidString)", isDirectory: true) + let service = "claude-oauth-enrichment-\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await CookieHeaderCache.withLegacyBaseURLOverrideForTesting(legacyBase) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + return try await operation() + } + } + } + private func expiredCLIAvailability( sourceMode: ProviderSourceMode, interaction: ProviderInteraction, diff --git a/Tests/CodexBarTests/ClaudeOAuthPendingCacheClearStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthPendingCacheClearStoreTests.swift new file mode 100644 index 000000000..7b597afe1 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthPendingCacheClearStoreTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthPendingCacheClearStoreTests { + @Test + func `legacy ownership recheck persists for only its invalidated profile`() throws { + let domain = "ClaudeOAuthPendingLegacyRecheckTests.\(UUID().uuidString)" + let key = "pending" + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let userDefaults = try #require(UserDefaults(suiteName: domain)) + defer { + userDefaults.removePersistentDomain(forName: domain) + userDefaults.synchronize() + try? FileManager.default.removeItem(at: tempDirectory) + } + let lockURL = tempDirectory.appendingPathComponent("cache.lock") + let store = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: lockURL) + store.withCacheTransaction( + profileIdentifier: "profile-a", + includingLegacyState: { profilePending, legacyCleanupPending, legacyRecheckPending in + #expect(!profilePending) + #expect(!legacyCleanupPending) + #expect(!legacyRecheckPending) + legacyRecheckPending = true + }) + + let reloadedStore = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: lockURL) + #expect(reloadedStore.isPending(profileIdentifier: "profile-a")) + #expect(!reloadedStore.isPending(profileIdentifier: "profile-b")) + reloadedStore.withCacheTransaction( + profileIdentifier: "profile-a", + includingLegacyState: { profilePending, legacyCleanupPending, legacyRecheckPending in + #expect(!profilePending) + #expect(!legacyCleanupPending) + #expect(legacyRecheckPending) + legacyRecheckPending = false + }) + #expect(!reloadedStore.isPending) + } + + @Test + func `profile lock failure remains owned by the invalidated profile`() throws { + let domain = "ClaudeOAuthPendingProfileLockFailureTests.\(UUID().uuidString)" + let key = "pending" + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + let blockedLockDirectory = tempDirectory.appendingPathComponent("not-a-directory") + try Data().write(to: blockedLockDirectory) + let userDefaults = try #require(UserDefaults(suiteName: domain)) + defer { + userDefaults.removePersistentDomain(forName: domain) + userDefaults.synchronize() + try? FileManager.default.removeItem(at: tempDirectory) + } + + let store = ClaudeOAuthPendingCacheClearUserDefaultsStore( + domain: domain, + key: key, + lockURL: blockedLockDirectory.appendingPathComponent("cache.lock")) + store.markPending(profileIdentifier: "profile-a") + userDefaults.synchronize() + + #expect(userDefaults.object(forKey: key) == nil) + + try FileManager.default.removeItem(at: blockedLockDirectory) + try FileManager.default.createDirectory(at: blockedLockDirectory, withIntermediateDirectories: true) + + #expect(store.isPending(profileIdentifier: "profile-a")) + #expect(!store.isPending(profileIdentifier: "profile-b")) + store.withCacheTransaction(profileIdentifier: "profile-b") { pending in + #expect(!pending) + } + #expect(store.isPending(profileIdentifier: "profile-a")) + #expect(!store.isPending(profileIdentifier: "profile-b")) + + store.withCacheTransaction(profileIdentifier: "profile-a") { pending in + #expect(pending) + pending = false + } + #expect(!store.isPending) + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index 72b64df37..9023fc204 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -114,6 +114,42 @@ struct ClaudeOAuthTests { #expect(snap.oauthHistoryOwnerIdentifier?.count == 64) } + @Test + func `O auth profile request decodes nested account identity`() async throws { + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + let body = """ + { + "account": { + "uuid": "account-123", + "email": "user@example.com" + }, + "organization": { + "uuid": "org-123" + } + } + """ + return (Data(body.utf8), response) + } + + let profile = try await ClaudeOAuthUsageFetcher.fetchProfile( + accessToken: "oauth-token", + transport: transport) + + #expect(profile.emailAddress == "user@example.com") + #expect(profile.organizationUuid == "org-123") + let request = try #require(await transport.requests().first) + #expect(request.url?.absoluteString == "https://api.anthropic.com/api/oauth/profile") + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer oauth-token") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + } + @Test func `maps O auth subscription type when rate limit tier is generic`() throws { let json = """ diff --git a/Tests/CodexBarTests/ClaudeProviderImplementationTests.swift b/Tests/CodexBarTests/ClaudeProviderImplementationTests.swift new file mode 100644 index 000000000..59ae424a6 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeProviderImplementationTests.swift @@ -0,0 +1,123 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct ClaudeProviderImplementationTests { + @Test + func `prepaid balance respects optional usage setting`() throws { + let now = Date(timeIntervalSince1970: 0) + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + balance: 100, + updatedAt: now), + updatedAt: now) + + var hiddenEntries: [ProviderMenuEntry] = [] + try ClaudeProviderImplementation().appendUsageMenuEntries( + context: Self.context(snapshot: snapshot, showOptionalUsage: false), + entries: &hiddenEntries) + #expect(hiddenEntries.isEmpty) + + var visibleEntries: [ProviderMenuEntry] = [] + try ClaudeProviderImplementation().appendUsageMenuEntries( + context: Self.context(snapshot: snapshot, showOptionalUsage: true), + entries: &visibleEntries) + + guard case let .text(extraUsageTitle, extraUsageStyle) = try #require(visibleEntries.first), + case let .text(balanceTitle, balanceStyle) = try #require(visibleEntries.last) + else { + Issue.record("Expected Claude extra usage and prepaid balance menu text") + return + } + #expect(extraUsageTitle == "Extra usage: $5.00 / $20.00") + #expect(extraUsageStyle == .primary) + #expect(balanceTitle == "Balance: $100.00") + #expect(balanceStyle == .primary) + #expect(visibleEntries.count == 2) + } + + @Test + func `prepaid balance without monthly cap stays a compact credits entry`() throws { + let now = Date(timeIntervalSince1970: 0) + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "USD", + period: "Usage credits", + balance: 100, + updatedAt: now), + updatedAt: now) + + var entries: [ProviderMenuEntry] = [] + try ClaudeProviderImplementation().appendUsageMenuEntries( + context: Self.context(snapshot: snapshot, showOptionalUsage: true), + entries: &entries) + + guard case let .text(title, style) = try #require(entries.first) else { + Issue.record("Expected Claude prepaid balance menu text") + return + } + #expect(title == "Credits: $100.00") + #expect(style == .primary) + #expect(entries.count == 1) + } + + private static func context( + snapshot: UsageSnapshot, + showOptionalUsage: Bool) throws -> ProviderMenuUsageContext + { + let suite = "ClaudeProviderImplementationTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.showOptionalCreditsAndExtraUsage = showOptionalUsage + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + return ProviderMenuUsageContext( + provider: .claude, + store: store, + settings: settings, + metadata: ClaudeProviderDescriptor.descriptor.metadata, + snapshot: snapshot) + } +} diff --git a/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift b/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift index 06cb71eba..c41221510 100644 --- a/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageDelegatedRefreshEnvironmentTests.swift @@ -2,7 +2,58 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct ClaudeUsageDelegatedRefreshEnvironmentTests { + private typealias CredentialLoader = @Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials + + private func credentialsData(accessToken: String) -> Data { + Data( + """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "refreshToken": "refresh-\(accessToken)", + "expiresAt": \(Int(Date(timeIntervalSinceNow: 3600).timeIntervalSince1970 * 1000)), + "scopes": ["user:profile"] + } + } + """.utf8) + } + + private func cacheKey(environment: [String: String]) -> KeychainCacheStore.Key { + ClaudeOAuthCredentialsStore.cacheKeyForTesting( + profileIdentifier: ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment)) + } + + private func storeCache(accessToken: String, environment: [String: String]) { + let profileIdentifier = ClaudeOAuthCredentialsStore.credentialsProfileIdentifier(environment: environment) + KeychainCacheStore.store( + key: ClaudeOAuthCredentialsStore.cacheKeyForTesting(profileIdentifier: profileIdentifier), + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: self.credentialsData(accessToken: accessToken), + storedAt: Date(), + owner: .claudeCLI, + profileIdentifier: profileIdentifier)) + } + + private func cachedToken(environment: [String: String]) throws -> String? { + switch KeychainCacheStore.load( + key: self.cacheKey(environment: environment), + as: ClaudeOAuthCredentialsStore.CacheEntry.self) + { + case let .found(entry): + return try ClaudeOAuthCredentials.parse(data: entry.data).accessToken + case .missing: + return nil + case .invalid, .temporarilyUnavailable: + Issue.record("Expected a valid or missing profile cache entry") + return nil + } + } + @Test func `oauth delegated retry passes fetcher environment to delegated refresh`() async throws { let fetcher = ClaudeUsageFetcher( @@ -50,4 +101,120 @@ struct ClaudeUsageDelegatedRefreshEnvironmentTests { #expect(message.contains("Claude CLI is not available")) } } + + @Test + func `oauth rejection invalidates only the fetcher profile`() async throws { + let service = "com.steipete.codexbar.cache.environment-tests.\(UUID().uuidString)" + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let environmentA = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("profile-a").path] + let environmentB = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("profile-b").path] + let credentials = try ClaudeOAuthCredentials.parse(data: self.credentialsData(accessToken: "request-token")) + let loadOverride: CredentialLoader? = { environment, _, _ in + #expect(environment == environmentA) + return credentials + } + let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in + throw ClaudeOAuthFetchError.serverError(500, "synthetic rejection") + } + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: environmentA, + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + self.storeCache(accessToken: "profile-a-token", environment: environmentA) + self.storeCache(accessToken: "profile-b-token", environment: environmentB) + + await #expect(throws: ClaudeUsageError.self) { + try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) { + try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadOverride) + { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + + let cachedA = try self.cachedToken(environment: environmentA) + let cachedB = try self.cachedToken(environment: environmentB) + #expect(cachedA == nil) + #expect(cachedB == "profile-b-token") + } + } + } + } + } + + @Test + func `delegated recovery syncs only the fetcher profile`() async throws { + let service = "com.steipete.codexbar.cache.environment-tests.\(UUID().uuidString)" + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let environmentA = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("profile-a").path] + let environmentB = ["CLAUDE_CONFIG_DIR": root.appendingPathComponent("profile-b").path] + let syncedData = self.credentialsData(accessToken: "synced-profile-a-token") + let loadOverride: CredentialLoader? = { environment, _, _ in + #expect(environment == environmentA) + throw ClaudeOAuthCredentialsError.refreshDelegatedToClaudeCLI + } + let delegatedOverride: (@Sendable (Date, TimeInterval, [String: String]) async + -> ClaudeOAuthDelegatedRefreshCoordinator.Outcome)? = { _, _, environment in + #expect(environment == environmentA) + return .attemptedSucceeded + } + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: environmentA, + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + try await KeychainAccessGate.withTaskOverrideForTesting(false) { + try await ClaudeOAuthCredentialsStore.withEnvironmentCredentialsURLForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + self.storeCache(accessToken: "profile-a-old", environment: environmentA) + self.storeCache(accessToken: "profile-b-token", environment: environmentB) + + await #expect(throws: ClaudeUsageError.self) { + try await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting( + .onlyOnUserAction) + { + try await ProviderInteractionContext.$current.withValue(.userInitiated) { + try await ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: syncedData, + fingerprint: nil) + { + try await ClaudeUsageFetcher.$hasCachedCredentialsOverride.withValue(true) { + try await ClaudeUsageFetcher.$delegatedRefreshAttemptOverride.withValue( + delegatedOverride) + { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride + .withValue(loadOverride) { + try await fetcher.loadLatestUsage(model: "sonnet") + } + } + } + } + } + } + } + + let cachedA = try self.cachedToken(environment: environmentA) + let cachedB = try self.cachedToken(environment: environmentB) + #expect(cachedA == "synced-profile-a-token") + #expect(cachedB == "profile-b-token") + } + } + } + } + } } diff --git a/Tests/CodexBarTests/ClaudeWebCookieRenewalTests.swift b/Tests/CodexBarTests/ClaudeWebCookieRenewalTests.swift index 80ca1ba92..b90bb3638 100644 --- a/Tests/CodexBarTests/ClaudeWebCookieRenewalTests.swift +++ b/Tests/CodexBarTests/ClaudeWebCookieRenewalTests.swift @@ -4,6 +4,179 @@ import Testing @Suite(.serialized) struct ClaudeWebCookieRenewalTests { + @Test + func `stalled prepaid credits request does not fail completed usage`() async throws { + let probe = ClaudePrepaidRequestProbe() + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + if url.path == "/api/organizations/org-123/prepaid/credits" { + try await probe.stallUntilCancelled() + } + let (response, data) = try Self.response(for: request, setCookie: nil) + return (data, response) + } + let usage = try await ClaudeWebPrepaidCreditsRequest.$timeoutOverrideForTesting.withValue( + .milliseconds(20)) + { + try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token") + } + } + + #expect(usage.sessionPercentUsed == 11) + #expect(usage.extraUsageCost?.balance == nil) + #expect(await probe.waitForCancellation()) + } + + @Test + func `caller cancellation during prepaid credits request is propagated`() async { + let probe = ClaudePrepaidRequestProbe() + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + if url.path == "/api/organizations/org-123/prepaid/credits" { + try await probe.stallUntilCancelled() + } + let (response, data) = try Self.response(for: request, setCookie: nil) + return (data, response) + } + let fetchTask = Task { + try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token") + } + } + + await probe.waitUntilStarted() + fetchTask.cancel() + + await #expect(throws: CancellationError.self) { + try await fetchTask.value + } + #expect(await probe.waitForCancellation()) + } + + @Test + func `web fetch merges prepaid credits into Claude extra usage cost`() async throws { + try await self.withClaudeWebStub { request in + let url = try #require(request.url) + if url.path == "/api/organizations/org-123/prepaid/credits" { + return Self.jsonResponse( + url: url, + body: #"{"amount":10000,"currency":"USD"}"#, + setCookie: nil) + } + if url.path == "/api/organizations/org-123/usage" { + return Self.jsonResponse( + url: url, + body: """ + { + "five_hour": { "utilization": 11 }, + "extra_usage": { + "is_enabled": true, + "monthly_limit": 2000, + "used_credits": 500, + "currency": "USD" + } + } + """, + setCookie: nil) + } + return try Self.response(for: request, setCookie: nil) + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token") + + #expect(usage.extraUsageCost?.balance == 100) + #expect(usage.extraUsageCost?.currencyCode == "USD") + } + } + + @Test + func `balance only fetch skips legacy overage request`() async throws { + let paths = RequestHeaderLog() + try await self.withClaudeWebStub { request in + let url = try #require(request.url) + paths.append(url.path) + if url.path == "/api/organizations/org-123/prepaid/credits" { + return Self.jsonResponse( + url: url, + body: #"{"amount":10000,"currency":"USD"}"#, + setCookie: nil) + } + return try Self.response(for: request, setCookie: nil) + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token", + includeUsageDetails: false, + includePrepaidBalance: true) + + #expect(usage.extraUsageCost?.balance == 100) + #expect(usage.extraUsageCost?.used == 0) + #expect(usage.extraUsageCost?.limit == 0) + #expect(!paths.values.contains("/api/organizations/org-123/overage_spend_limit")) + } + } + + @Test + func `balance enrichment preserves extra usage cost from usage response`() async throws { + let paths = RequestHeaderLog() + try await self.withClaudeWebStub { request in + let url = try #require(request.url) + paths.append(url.path) + switch url.path { + case "/api/organizations/org-123/usage": + return Self.jsonResponse( + url: url, + body: """ + { + "five_hour": { "utilization": 11 }, + "extra_usage": { + "is_enabled": true, + "monthly_limit": 10000, + "used_credits": 42, + "currency": "USD" + } + } + """, + setCookie: nil) + case "/api/organizations/org-123/prepaid/credits": + return Self.jsonResponse( + url: url, + body: #"{"amount":9958,"currency":"USD"}"#, + setCookie: nil) + default: + return try Self.response(for: request, setCookie: nil) + } + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token", + includeUsageDetails: false, + includePrepaidBalance: true) + + #expect(usage.extraUsageCost?.used == 0.42) + #expect(usage.extraUsageCost?.limit == 100) + #expect(usage.extraUsageCost?.balance == 99.58) + #expect(!paths.values.contains("/api/organizations/org-123/overage_spend_limit")) + } + } + + @Test + func `web fetch skips prepaid credits when optional usage is disabled`() async throws { + let paths = RequestHeaderLog() + try await self.withClaudeWebStub { request in + paths.append(request.url?.path) + return try Self.response(for: request, setCookie: nil) + } operation: { + let usage = try await ClaudeWebAPIFetcher.fetchUsage( + cookieHeader: "sessionKey=sk-ant-manual-token", + includePrepaidBalance: false) + + #expect(usage.sessionPercentUsed == 11) + #expect(!paths.values.contains("/api/organizations/org-123/prepaid/credits")) + } + } + @Test func `cached web session key renews from set cookie after successful fetch`() async throws { try await self.withIsolatedCookieCache { @@ -226,7 +399,7 @@ struct ClaudeWebCookieRenewalTests { } @Test - func `usage response renewal propagates to later requests and cache`() async throws { + func `usage and prepaid response renewals propagate to later requests and cache`() async throws { try await self.withIsolatedCookieCache { CookieHeaderCache.store( provider: .claude, @@ -235,6 +408,7 @@ struct ClaudeWebCookieRenewalTests { defer { CookieHeaderCache.clear(provider: .claude) } let usageCookies = RequestHeaderLog() let overageCookies = RequestHeaderLog() + let prepaidCookies = RequestHeaderLog() let accountCookies = RequestHeaderLog() try await self.withClaudeWebStub { request in @@ -244,22 +418,39 @@ struct ClaudeWebCookieRenewalTests { usageCookies.append(request.value(forHTTPHeaderField: "Cookie")) case "/api/organizations/org-123/overage_spend_limit": overageCookies.append(request.value(forHTTPHeaderField: "Cookie")) + case "/api/organizations/org-123/prepaid/credits": + prepaidCookies.append(request.value(forHTTPHeaderField: "Cookie")) case "/api/account": accountCookies.append(request.value(forHTTPHeaderField: "Cookie")) default: break } + let setCookie: String? = switch path { + case "/api/organizations/org-123/usage": + Self.renewedSessionCookie + case "/api/organizations/org-123/prepaid/credits": + "sessionKey=sk-ant-prepaid-token; Path=/; HttpOnly" + default: + nil + } + if path == "/api/organizations/org-123/prepaid/credits" { + return try Self.jsonResponse( + url: #require(request.url), + body: #"{"amount":10000,"currency":"USD"}"#, + setCookie: setCookie) + } return try Self.response( for: request, - setCookie: path == "/api/organizations/org-123/usage" ? Self.renewedSessionCookie : nil) + setCookie: setCookie) } operation: { _ = try await ClaudeWebAPIFetcher.fetchUsage(browserDetection: BrowserDetection(cacheTTL: 0)) #expect(usageCookies.values == ["sessionKey=sk-ant-old-token"]) #expect(overageCookies.values == ["sessionKey=sk-ant-renewed-token"]) - #expect(accountCookies.values == ["sessionKey=sk-ant-renewed-token"]) + #expect(prepaidCookies.values == ["sessionKey=sk-ant-renewed-token"]) + #expect(accountCookies.values == ["sessionKey=sk-ant-prepaid-token"]) let cached = try #require(CookieHeaderCache.load(provider: .claude)) - #expect(cached.cookieHeader == "sessionKey=sk-ant-renewed-token") + #expect(cached.cookieHeader == "sessionKey=sk-ant-prepaid-token") } } } @@ -429,6 +620,43 @@ struct ClaudeWebCookieRenewalTests { } } +private actor ClaudePrepaidRequestProbe { + private var started = false + private var cancelled = false + private var startedWaiters: [CheckedContinuation] = [] + + func stallUntilCancelled() async throws { + self.started = true + for waiter in self.startedWaiters { + waiter.resume() + } + self.startedWaiters.removeAll() + + do { + try await Task.sleep(for: .seconds(10)) + } catch { + self.cancelled = true + throw error + } + throw CancellationError() + } + + func waitUntilStarted() async { + if self.started { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append(continuation) + } + } + + func waitForCancellation() async -> Bool { + for _ in 0..<100 { + if self.cancelled { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return self.cancelled + } +} + private final class RequestHeaderLog: @unchecked Sendable { private let lock = NSLock() private var storage: [String?] = [] diff --git a/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift b/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift index 94713480c..2972e61f0 100644 --- a/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift +++ b/Tests/CodexBarTests/ClaudeWebFetchDeadlineTests.swift @@ -3,6 +3,47 @@ import Testing @testable import CodexBarCore struct ClaudeWebFetchDeadlineTests { + @Test + func `prepaid timeout preserves usage before outer web deadline`() async throws { + let context = Self.makeContext(sourceMode: .web, webTimeout: 0.5) + let transport = ProviderHTTPTransportHandler { request in + let url = try #require(request.url) + let body: String + let statusCode: Int + switch url.path { + case "/api/organizations": + body = #"[{"uuid":"org-123","name":"Test Org","capabilities":["chat"]}]"# + statusCode = 200 + case "/api/organizations/org-123/usage": + body = #"{"five_hour":{"utilization":11}}"# + statusCode = 200 + case "/api/organizations/org-123/prepaid/credits": + try await Task.sleep(for: .seconds(10)) + throw CancellationError() + default: + body = "{}" + statusCode = 404 + } + let response = try #require(HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } + let strategy = ClaudeWebFetchStrategy(browserDetection: context.browserDetection) + let result = try await ClaudeWebPrepaidCreditsRequest.$timeoutOverrideForTesting.withValue( + .milliseconds(20)) + { + try await ClaudeWebHTTPTransport.$overrideForTesting.withValue(transport) { + try await strategy.fetch(context) + } + } + + #expect(result.usage.primary?.usedPercent == 11) + #expect(result.usage.providerCost?.balance == nil) + } + @Test func `CLI auto descriptor defers browser probe and falls back after web deadline`() async throws { let planningProbe = ClaudeWebPlanningAvailabilityProbe() @@ -71,16 +112,19 @@ struct ClaudeWebFetchDeadlineTests { let cliFetchOverride: @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot = { _, _, _ in Self.makeClaudeStatus() } - let outcome = await KeychainAccessGate.withTaskOverrideForTesting(false) { - await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { - await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue( - availabilityOverride) - { - await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(oauthLoadOverride) { - await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) { - await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( - context: context, - provider: .claude) + let outcome = await ClaudeCLIBackgroundAvailability.withIsolatedStoreForTesting { + ClaudeCLIBackgroundAvailability.establish(binary: cliPath) + return await KeychainAccessGate.withTaskOverrideForTesting(false) { + await ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.always) { + await ClaudeWebFetchStrategy.$availabilityProbeOverrideForTesting.withValue( + availabilityOverride) + { + await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(oauthLoadOverride) { + await ClaudeStatusProbe.$fetchOverride.withValue(cliFetchOverride) { + await ClaudeProviderDescriptor.makeDescriptor().fetchPlan.fetchOutcome( + context: context, + provider: .claude) + } } } } diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift index ac9704029..066a3746c 100644 --- a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -30,6 +30,125 @@ struct ClaudeWebUsageExtraWindowTests { #expect(parsed.opusPercentUsed == 6) } + @Test + func `parses Claude prepaid credits in minor units`() throws { + let data = Data(#"{"amount":10000,"currency":"usd"}"#.utf8) + let cost = try #require(ClaudeWebAPIFetcher._parsePrepaidCreditsForTesting(data)) + + #expect(cost.balance == 100) + #expect(cost.currencyCode == "USD") + #expect(cost.used == 0) + #expect(cost.limit == 0) + } + + @Test + func `rejects invalid Claude prepaid credit balances`() { + let negative = Data(#"{"amount":-1,"currency":"USD"}"#.utf8) + let missingCurrency = Data(#"{"amount":10000}"#.utf8) + let booleanAmount = Data(#"{"amount":true,"currency":"USD"}"#.utf8) + + #expect(ClaudeWebAPIFetcher._parsePrepaidCreditsForTesting(negative) == nil) + #expect(ClaudeWebAPIFetcher._parsePrepaidCreditsForTesting(missingCurrency) == nil) + #expect(ClaudeWebAPIFetcher._parsePrepaidCreditsForTesting(booleanAmount) == nil) + } + + @Test + func `merges Claude prepaid balance into primary O auth cost`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let primary = ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + updatedAt: now) + let web = ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "usd", + period: "Extra usage", + balance: 100, + updatedAt: now.addingTimeInterval(1)) + + let merged = try #require(ClaudeUsageFetcher._mergeProviderCostForTesting( + primary: primary, + web: web)) + + #expect(merged.used == 5) + #expect(merged.limit == 20) + #expect(merged.period == "Monthly cap") + #expect(merged.balance == 100) + #expect(merged.updatedAt == now) + } + + @Test + func `does not merge Claude prepaid balance with a different currency`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let primary = ProviderCostSnapshot( + used: 5, + limit: 20, + currencyCode: "USD", + period: "Monthly cap", + updatedAt: now) + let web = ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "EUR", + period: "Extra usage", + balance: 100, + updatedAt: now) + + let merged = try #require(ClaudeUsageFetcher._mergeProviderCostForTesting( + primary: primary, + web: web)) + + #expect(merged == primary) + } + + @Test + func `web extras require the same Claude account and organization`() { + let snapshot = ClaudeUsageSnapshot( + primary: RateWindow(usedPercent: 7, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + opus: nil, + providerCost: nil, + updatedAt: Date(), + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro", + rawText: nil) + let webData = ClaudeWebAPIFetcher.WebUsageData( + sessionPercentUsed: 7, + sessionResetsAt: nil, + weeklyPercentUsed: nil, + weeklyResetsAt: nil, + opusPercentUsed: nil, + extraRateWindows: [], + extraUsageCost: nil, + accountOrganization: "Test Org", + accountOrganizationID: "org-123", + accountEmail: "user@example.com", + loginMethod: "Pro") + + #expect(ClaudeUsageFetcher.webExtrasAccountMatches( + snapshot: snapshot, + webData: webData, + oauthProfile: OAuthProfileResponse( + emailAddress: "user@example.com", + organizationUuid: "org-123"))) + #expect(!ClaudeUsageFetcher.webExtrasAccountMatches( + snapshot: snapshot, + webData: webData, + oauthProfile: OAuthProfileResponse( + emailAddress: "other@example.com", + organizationUuid: "org-123"))) + #expect(!ClaudeUsageFetcher.webExtrasAccountMatches( + snapshot: snapshot, + webData: webData, + oauthProfile: OAuthProfileResponse( + emailAddress: "user@example.com", + organizationUuid: "org-other"))) + } + @Test func `ignores merged claude web API omelette usage window`() throws { let json = """ diff --git a/Tests/CodexBarTests/CodexBarLaunchModeTests.swift b/Tests/CodexBarTests/CodexBarLaunchModeTests.swift new file mode 100644 index 000000000..cad79cb9c --- /dev/null +++ b/Tests/CodexBarTests/CodexBarLaunchModeTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import CodexBar + +struct CodexBarLaunchModeTests { + @Test + func `normal launch starts the application`() { + #expect(CodexBarLaunchMode.resolve(arguments: ["/Applications/CodexBar"]) == .application) + } + + @Test + func `hook event launch skips application initialization`() { + #expect(CodexBarLaunchMode.resolve( + arguments: ["/Applications/CodexBar", "--hook-event"]) == .hookEvent) + } + + @Test + func `hook event is recognized among other arguments`() { + #expect(CodexBarLaunchMode.resolve( + arguments: ["/Applications/CodexBar", "--verbose", "--hook-event"]) == .hookEvent) + } + + @Test + func `similar argument still starts the application`() { + #expect(CodexBarLaunchMode.resolve( + arguments: ["/Applications/CodexBar", "--hook-events"]) == .application) + } +} diff --git a/Tests/CodexBarTests/CodexLocalProjectUsageTests.swift b/Tests/CodexBarTests/CodexLocalProjectUsageTests.swift new file mode 100644 index 000000000..a0d208ea5 --- /dev/null +++ b/Tests/CodexBarTests/CodexLocalProjectUsageTests.swift @@ -0,0 +1,1747 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#endif +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +// Shared JSONL/SQLite fixtures make the attribution and sidecar assertions +// readable without duplicating test environments across several files. +// swiftlint:disable file_length +// swiftlint:disable type_body_length +struct CodexLocalProjectUsageTests { + private final class ProgressRecorder: @unchecked Sendable { + private let lock = NSLock() + private var events: [CodexLocalProjectUsageIndexProgress] = [] + + func append(_ progress: CodexLocalProjectUsageIndexProgress) { + self.lock.lock() + defer { self.lock.unlock() } + self.events.append(progress) + } + + var snapshot: [CodexLocalProjectUsageIndexProgress] { + self.lock.lock() + defer { self.lock.unlock() } + return self.events + } + } + + private struct CodexUsageFixture { + var filename: String + var sessionID: String + var cwd: String? + var input: Int + var cached: Int + var output: Int + } + + @Test + func `project root resolver keeps sibling paths distinct`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-project-root-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let app = root.appendingPathComponent("app", isDirectory: true) + let appOld = root.appendingPathComponent("app-old", isDirectory: true) + let appSource = app.appendingPathComponent("Sources", isDirectory: true) + let appOldSource = appOld.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: app.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: appOld.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: appSource, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: appOldSource, withIntermediateDirectories: true) + + let appIdentity = CodexLocalProjectRootResolver.projectIdentity(for: appSource.path) + let appOldIdentity = CodexLocalProjectRootResolver.projectIdentity(for: appOldSource.path) + + #expect(appIdentity.path == app.standardizedFileURL.path) + #expect(appOldIdentity.path == appOld.standardizedFileURL.path) + #expect(appIdentity.id != appOldIdentity.id) + } + + @Test + func `local data scope avoids persisting raw Codex home paths`() { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("workspaces-private-home", isDirectory: true) + let scope = CodexLocalDataScope.resolve(options: CostUsageScanner.Options( + codexSessionsRoot: home.appendingPathComponent("sessions", isDirectory: true))) + + #expect(scope.codexHome == home.standardizedFileURL) + #expect(scope.identifier.hasPrefix("codex-workspaces:")) + #expect(!scope.identifier.contains(home.path)) + } + + @Test + func `workspace sidecar defaults to the shared application cache root`() throws { + let cachesDirectory = try #require(FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first) + let expected = cachesDirectory + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("local-usage", isDirectory: true) + .appendingPathComponent("codex-workspaces-v1.sqlite", isDirectory: false) + + #expect(CodexWorkspaceUsageSidecar.databaseURL(cacheRoot: nil) == expected) + } + + @Test + func `v10 cache remains untouched while v11 rebuilds and then refreshes incrementally`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let costCacheRoot = env.cacheRoot.appendingPathComponent("cost-usage", isDirectory: true) + try FileManager.default.createDirectory(at: costCacheRoot, withIntermediateDirectories: true) + let v10URL = costCacheRoot.appendingPathComponent("codex-v10.json", isDirectory: false) + let v10Bytes = Data("recoverable-v10-cursor".utf8) + try v10Bytes.write(to: v10URL) + let v11URL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) + #expect(!FileManager.default.fileExists(atPath: v11URL.path)) + + try self.writeCodexUsageFile( + env: env, + day: day, + fixture: CodexUsageFixture( + filename: "upgrade-first.jsonl", + sessionID: "upgrade-first", + cwd: env.root.path, + input: 100, + cached: 20, + output: 30)) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + #expect(first.data.first?.totalTokens == 130) + #expect(try Data(contentsOf: v10URL) == v10Bytes) + #expect(FileManager.default.fileExists(atPath: v11URL.path)) + #expect(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files.count == 1) + + try self.writeCodexUsageFile( + env: env, + day: day, + fixture: CodexUsageFixture( + filename: "upgrade-second.jsonl", + sessionID: "upgrade-second", + cwd: env.root.path, + input: 40, + cached: 5, + output: 10)) + let warmNow = Date().addingTimeInterval(1) + let warm = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: warmNow, + now: warmNow, + options: options) + + #expect(warm.data.first?.totalTokens == 180) + #expect(CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot).files.count == 2) + #expect(try Data(contentsOf: v10URL) == v10Bytes) + } + + @Test + func `project root resolver treats git file worktree as most specific project`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-project-worktree-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let app = root.appendingPathComponent("app", isDirectory: true) + let worktree = app.appendingPathComponent("worktree", isDirectory: true) + let source = worktree.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: app.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try "gitdir: ../.git/worktrees/worktree\n".write( + to: worktree.appendingPathComponent(".git", isDirectory: false), + atomically: true, + encoding: .utf8) + + let identity = CodexLocalProjectRootResolver.projectIdentity(for: source.path) + + #expect(identity.path == worktree.standardizedFileURL.path) + #expect(identity.displayName == "worktree") + } + + @Test + func `project root resolver preserves missing logged CWD when no git root exists`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-missing-cwd-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let loggedCWD = root.appendingPathComponent("deleted-project/Sources", isDirectory: true) + + let identity = CodexLocalProjectRootResolver.projectIdentity(for: loggedCWD.path) + + #expect(identity.path == loggedCWD.standardizedFileURL.path) + #expect(identity.displayName == "Sources") + } + + @Test + func `project root resolver preserves a deleted CWD below an existing repository`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let repository = root.appendingPathComponent("project", isDirectory: true) + let deletedCWD = repository.appendingPathComponent("removed-worktree", isDirectory: true) + try FileManager.default.createDirectory( + at: repository.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + + let identity = CodexLocalProjectRootResolver.projectIdentity(for: deletedCWD.path) + + #expect(identity.path == deletedCWD.standardizedFileURL.path) + #expect(identity.displayName == "removed-worktree") + } + + @Test + func `project root resolver canonicalizes a live symlinked repository`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let repository = root.appendingPathComponent("project", isDirectory: true) + let source = repository.appendingPathComponent("Sources", isDirectory: true) + let symlink = root.appendingPathComponent("project-link", isDirectory: true) + try FileManager.default.createDirectory( + at: repository.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: symlink.path, withDestinationPath: repository.path) + + let direct = CodexLocalProjectRootResolver.projectIdentity(for: source.path) + let linked = CodexLocalProjectRootResolver.projectIdentity( + for: symlink.appendingPathComponent("Sources", isDirectory: true).path) + + #expect(linked.id == direct.id) + #expect(linked.path == repository.standardizedFileURL.path) + } + + @Test + func `project usage index aggregates projects and chats from existing codex scan cache`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let project = env.root.appendingPathComponent("CodexBar", isDirectory: true) + let projectSource = project.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: projectSource, withIntermediateDirectories: true) + + try self.writeCodexUsageFile( + env: env, + day: day, + fixture: CodexUsageFixture( + filename: "project.jsonl", + sessionID: "project-session", + cwd: projectSource.path, + input: 100, + cached: 20, + output: 30)) + try self.writeCodexUsageFile( + env: env, + day: day, + fixture: CodexUsageFixture( + filename: "chat.jsonl", + sessionID: "chat-session", + cwd: nil, + input: 50, + cached: 5, + output: 10)) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let snapshot = try await CostUsageFetcher.loadCodexLocalProjectUsageSnapshot( + now: day, + forceRefresh: true, + historyDays: 2, + hidePersonalInfo: false, + scannerOptions: options) + + #expect(snapshot.indexedFileCount == 2) + #expect(snapshot.projects.map(\.displayName) == ["CodexBar", "Chats"]) + #expect(snapshot.projects.first?.totals.totalTokens == 130) + #expect(snapshot.projects.first?.totals.cachedInputTokens == 20) + #expect(snapshot.projects.first?.path == project.standardizedFileURL.path) + #expect(snapshot.projects.first?.estimatedCostUSD != nil) + #expect(snapshot.projects.first?.modelBreakdowns.first?.estimatedCostUSD != nil) + #expect(snapshot.projects.first?.modelBreakdowns.first?.hasUnknownCost == false) + #expect(snapshot.projects.first?.daily.first?.totalTokens == 130) + #expect(snapshot.projects.last?.id == CodexLocalProjectRootResolver.chatsProjectId) + #expect(snapshot.projects.last?.daily.first?.totalTokens == 60) + #expect(snapshot.total.totalTokens == 190) + #expect(snapshot.sessions.count == 2) + #expect(snapshot.daily.first?.totalTokens == 190) + let hiddenSnapshot = try #require(await CostUsageFetcher.loadCachedCodexLocalProjectUsageSnapshot( + now: day, + historyDays: 2, + hidePersonalInfo: true, + scannerOptions: options)) + #expect(hiddenSnapshot.rootsFingerprint.isEmpty) + #expect(hiddenSnapshot.projects.first?.displayName == "Workspace") + #expect(hiddenSnapshot.projects.first?.path == nil) + #expect(hiddenSnapshot.projects.first?.topSessions.first?.displayTitle + == CodexLocalSessionUsage.localChatFallbackTitle) + #expect(hiddenSnapshot.projects.first?.topSessions.first?.cwd == nil) + #expect(hiddenSnapshot.sessions.allSatisfy { + $0.displayTitle == CodexLocalSessionUsage.localChatFallbackTitle && $0.cwd == nil + }) + #expect(snapshot.projects.first?.displayName == "CodexBar") + #expect(snapshot.projects.first?.path == project.standardizedFileURL.path) + let allModels = try #require(snapshot.modelsAnalytics?.allWorkspaces) + #if canImport(SQLite3) || canImport(CSQLite3) + #expect(snapshot.sourceStatus == .catalogMissing) + #expect(allModels.currentIsComplete == false) + #expect(allModels.previousIsComplete == false) + #else + #expect(snapshot.sourceStatus == .complete) + #expect(allModels.currentIsComplete == true) + #expect(allModels.previousIsComplete == true) + #endif + } + + @Test + func `project usage index uses cached codex session metadata without reading jsonl`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let project = env.root.appendingPathComponent("CodexBar", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + let fixture = CodexUsageFixture( + filename: "missing.jsonl", + sessionID: "cached-session", + cwd: project.path, + input: 100, + cached: 20, + output: 30) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let missingFileURL = env.root.appendingPathComponent("missing-session.jsonl", isDirectory: false) + var cache = CostUsageCache() + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[missingFileURL.path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: fixture, + costNanos: 1) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options) + + #expect(FileManager.default.fileExists(atPath: missingFileURL.path) == false) + #expect(snapshot.projects.count == 1) + #expect(snapshot.projects.first?.displayName == "CodexBar") + #expect(snapshot.projects.first?.path == project.standardizedFileURL.path) + #expect(snapshot.projects.first?.totals.totalTokens == 130) + } + + @Test + func `project usage index uses codex state database catalog when available`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date(timeIntervalSince1970: 1_800_000_000) + let project = env.root.appendingPathComponent("CatalogProject", isDirectory: true) + let projectSource = project.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: projectSource, withIntermediateDirectories: true) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let rolloutURL = env.codexSessionsRoot.appendingPathComponent("catalog-session.jsonl", isDirectory: false) + let stateDatabaseURL = env.codexHomeRoot.appendingPathComponent("state_5.sqlite", isDirectory: false) + try self.writeCodexStateDatabase( + at: stateDatabaseURL, + thread: CodexStateThreadFixture( + id: "catalog-session", + rolloutPath: rolloutURL.path, + cwd: projectSource.path, + title: "Catalog title", + preview: "Catalog preview", + model: "openai/gpt-5.4-catalog", + createdAtUnixMs: 1_800_000_000_000, + updatedAtUnixMs: 1_800_000_120_000)) + + var cache = CostUsageCache() + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[rolloutURL.path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: CodexUsageFixture( + filename: "catalog-session.jsonl", + sessionID: "catalog-session", + cwd: nil, + input: 100, + cached: 10, + output: 25), + costNanos: 1) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options) + + #expect(snapshot.projects.map(\.displayName) == ["CatalogProject"]) + #expect(snapshot.projects.first?.path == project.standardizedFileURL.path) + #expect(snapshot.sessions.first?.displayTitle == "Catalog title") + #expect(snapshot.sessions.first?.cwd == projectSource.path) + #expect(snapshot.sessions.first?.latestActivity == Date(timeIntervalSince1970: 1_800_000_120)) + #expect(snapshot.sessions.first?.topModel == "openai/gpt-5.4-catalog") + #expect(snapshot.total.totalTokens == 125) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `catalog reader normalizes legacy seconds timestamps to milliseconds`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let rolloutURL = env.codexSessionsRoot.appendingPathComponent("legacy-timestamp.jsonl", isDirectory: false) + try self.writeCodexStateDatabase( + at: env.codexHomeRoot.appendingPathComponent("state_5.sqlite", isDirectory: false), + thread: CodexStateThreadFixture( + id: "legacy-timestamp", + rolloutPath: rolloutURL.path, + cwd: env.root.path, + title: "Legacy timestamp", + preview: "Legacy timestamp preview", + model: "openai/gpt-5.4", + createdAtUnixMs: 1_800_000_000_000, + updatedAtUnixMs: 1_800_000_120_000, + usesLegacyTimestampColumns: true)) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let entry = try #require(CodexThreadCatalogReader.load(options: options).entriesById["legacy-timestamp"]) + + #expect(entry.createdAtUnixMs == 1_800_000_000_000) + #expect(entry.updatedAtUnixMs == 1_800_000_120_000) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `cached refresh surfaces catalog degradation while retaining last good usage`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date(timeIntervalSince1970: 1_800_000_000) + let project = env.root.appendingPathComponent("CachedCatalogProject", isDirectory: true) + let source = project.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: source, withIntermediateDirectories: true) + let rolloutURL = env.codexSessionsRoot.appendingPathComponent("cached-catalog.jsonl", isDirectory: false) + let catalogURL = env.codexHomeRoot.appendingPathComponent("state_5.sqlite", isDirectory: false) + try self.writeCodexStateDatabase( + at: catalogURL, + thread: CodexStateThreadFixture( + id: "cached-catalog", + rolloutPath: rolloutURL.path, + cwd: source.path, + title: "Cached catalog title", + preview: "Cached catalog preview", + model: "openai/gpt-5.4-catalog", + createdAtUnixMs: 1_800_000_000_000, + updatedAtUnixMs: 1_800_000_120_000)) + try self.writeCodexUsageFile( + env: env, + day: day, + fixture: CodexUsageFixture( + filename: "cached-catalog.jsonl", + sessionID: "cached-catalog", + cwd: nil, + input: 100, + cached: 10, + output: 25)) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let complete = try CodexLocalProjectUsageIndexer.loadSnapshot( + now: day, + historyDays: 1, + forceRefresh: true, + options: .init(scannerOptions: options)) + try FileManager.default.removeItem(at: catalogURL) + let degraded = try CodexLocalProjectUsageIndexer.loadSnapshot( + now: day, + historyDays: 1, + options: .init(scannerOptions: options)) + + #expect(complete.sourceStatus == .complete) + #expect(degraded.sourceStatus == .catalogMissing) + #expect(degraded.total == complete.total) + #expect(degraded.projects == complete.projects) + #expect(degraded.sessions == complete.sessions) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `sidecar retains catalog metadata when a sparse rollout update arrives during catalog failure`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date(timeIntervalSince1970: 1_800_000_000) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let rolloutURL = env.codexSessionsRoot.appendingPathComponent("retained-metadata.jsonl", isDirectory: false) + let project = env.root.appendingPathComponent("RetainedCatalogProject", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + try self.writeCodexStateDatabase( + at: env.codexHomeRoot.appendingPathComponent("state_5.sqlite", isDirectory: false), + thread: CodexStateThreadFixture( + id: "retained-session", + rolloutPath: rolloutURL.path, + cwd: project.path, + title: "Retained catalog title", + preview: "Retained catalog preview", + model: "openai/gpt-5.4-catalog", + createdAtUnixMs: 1_800_000_000_000, + updatedAtUnixMs: 1_800_000_120_000)) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let sparseFixture = CodexUsageFixture( + filename: "retained-metadata.jsonl", + sessionID: "retained-session", + cwd: nil, + input: 100, + cached: 0, + output: 20) + var cache = CostUsageCache() + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[rolloutURL.path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: sparseFixture, + costNanos: 1) + let sidecar = CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot) + try sidecar.synchronizeSources(cache: cache, catalog: CodexThreadCatalogReader.load(options: options)) + + var changedFixture = sparseFixture + changedFixture.input = 110 + var changedUsage = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: changedFixture, + costNanos: 1) + changedUsage.mtimeUnixMs = 1 + cache.files[rolloutURL.path] = changedUsage + try sidecar.synchronizeSources(cache: cache, catalog: .empty, catalogIsComplete: false) + + let rehydrated = try sidecar.usageCache(roots: cache.roots ?? [:]) + let metadata = rehydrated.files[rolloutURL.path]?.codexSession + #expect(metadata?.cwd == project.path) + #expect(metadata?.title == "Retained catalog title") + #expect(rehydrated.files[rolloutURL.path]?.days[dayKey]?["openai/gpt-5.4"]?[0] == 110) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `sidecar prunes catalog metadata absent from a complete generation`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let rolloutURL = env.codexSessionsRoot.appendingPathComponent("pruned-catalog.jsonl", isDirectory: false) + let entry = CodexThreadCatalogEntry( + id: "pruned-catalog", + rolloutPath: rolloutURL.path, + cwd: "/catalog/cwd", + title: "Catalog title", + preview: "Catalog preview", + modelProvider: "openai", + model: "openai/gpt-5.4-catalog", + reasoningEffort: "high", + createdAtUnixMs: 1_800_000_000_000, + updatedAtUnixMs: 1_800_000_120_000, + archived: false) + let catalog = CodexThreadCatalog( + entriesById: [entry.id: entry], + entriesByRolloutPath: [rolloutURL.standardizedFileURL.path: entry], + fingerprint: "complete-generation-1") + var cache = CostUsageCache() + cache.files[rolloutURL.path] = self.makeCachedFileUsage( + dayKey: "2027-01-15", + fixture: CodexUsageFixture( + filename: "pruned-catalog.jsonl", + sessionID: entry.id, + cwd: "/rollout/cwd", + input: 100, + cached: 0, + output: 20), + costNanos: 1) + let sidecar = CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot) + try sidecar.synchronizeSources(cache: cache, catalog: catalog) + #expect(try sidecar.usageCache(roots: [:]).files[rolloutURL.path]?.codexSession?.cwd == "/catalog/cwd") + + try sidecar.synchronizeSources(cache: cache, catalog: .empty) + + #expect(try sidecar.usageCache(roots: [:]).files[rolloutURL.path]?.codexSession?.cwd == "/rollout/cwd") + #else + #expect(Bool(true)) + #endif + } + + @Test + func `catalog reader distinguishes missing and corrupt state databases`() throws { + #if canImport(SQLite3) + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + + #expect(CodexThreadCatalogReader.loadResult(options: options).completeness == .unavailable(.missing)) + + let databaseURL = env.codexHomeRoot.appendingPathComponent("state_5.sqlite", isDirectory: false) + try "not a SQLite database".write(to: databaseURL, atomically: true, encoding: .utf8) + #expect(CodexThreadCatalogReader.loadResult(options: options).completeness == .unavailable(.corrupt)) + #else + #expect(Bool(true)) + #endif + } + + @Test + func `cached project usage snapshot preserves last complete data when pricing changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let project = env.root.appendingPathComponent("CodexBar", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + let fixture = CodexUsageFixture( + filename: "project.jsonl", + sessionID: "cached-session", + cwd: project.path, + input: 100, + cached: 20, + output: 30) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + var cache = CostUsageCache() + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.codexPricingKey = "pricing-a" + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[env.root.appendingPathComponent("project.jsonl").path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: fixture, + costNanos: 1) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options) + let catalog = CodexThreadCatalogReader.load(options: options) + try CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot).synchronize( + snapshot: snapshot, + cache: cache, + catalog: catalog, + rootsFingerprint: CostUsageScanner.codexRootsFingerprint(options: options)) + + #expect(CodexLocalProjectUsageIndexer.cachedSnapshot(now: day, historyDays: 1, options: .init( + scannerOptions: options)) != nil) + + cache.codexPricingKey = "pricing-b" + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + #expect(CodexLocalProjectUsageIndexer.cachedSnapshot(now: day, historyDays: 1, options: .init( + scannerOptions: options))?.total.totalTokens == 130) + } + + @Test + func `project usage severity separates high usage from unknown cost coverage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let highProject = env.root.appendingPathComponent("high", isDirectory: true) + let normalProject = env.root.appendingPathComponent("normal", isDirectory: true) + let partialProject = env.root.appendingPathComponent("partial", isDirectory: true) + for project in [highProject, normalProject, partialProject] { + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + } + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + var cache = CostUsageCache() + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[env.root.appendingPathComponent("high.jsonl").path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: CodexUsageFixture( + filename: "high.jsonl", + sessionID: "high", + cwd: highProject.path, + input: 800, + cached: 0, + output: 200), + costNanos: 1) + cache.files[env.root.appendingPathComponent("normal.jsonl").path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: CodexUsageFixture( + filename: "normal.jsonl", + sessionID: "normal", + cwd: normalProject.path, + input: 80, + cached: 0, + output: 20), + costNanos: 1) + cache.files[env.root.appendingPathComponent("partial.jsonl").path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: CodexUsageFixture( + filename: "partial.jsonl", + sessionID: "partial", + cwd: partialProject.path, + input: 80, + cached: 0, + output: 20), + costNanos: nil) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options) + let projected = CodexLocalProjectUsageProjection( + includesCachedInput: true, + showsEstimatedCost: true) + .rankedProjects(snapshot.projects) + let projectsByName = Dictionary(uniqueKeysWithValues: projected.map { ($0.displayName, $0) }) + + #expect(projectsByName["high"]?.severity == .high) + #expect(projectsByName["normal"]?.severity == .normal) + #expect(projectsByName["partial"]?.hasUnknownCost == true) + #expect(projectsByName["partial"]?.severity == .normal) + #expect(projectsByName["partial"]?.costEstimate.unknownTokens == 100) + } + + @Test + func `project usage index does not downgrade known session project to chats`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let project = env.root.appendingPathComponent("CodexBar", isDirectory: true) + let projectSource = project.appendingPathComponent("Sources", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: projectSource, withIntermediateDirectories: true) + + let projectFixture = CodexUsageFixture( + filename: "a-project-fragment.jsonl", + sessionID: "split-session", + cwd: projectSource.path, + input: 100, + cached: 20, + output: 30) + let chatFixture = CodexUsageFixture( + filename: "z-chat-fragment.jsonl", + sessionID: "split-session", + cwd: nil, + input: 50, + cached: 5, + output: 10) + let projectFileURL = try self.writeCodexUsageFile( + env: env, + day: day, + fixture: projectFixture) + let chatFileURL = try self.writeCodexUsageFile( + env: env, + day: day, + fixture: chatFixture) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + var cache = CostUsageCache() + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[projectFileURL.path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: projectFixture, + costNanos: 1) + cache.files[chatFileURL.path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: chatFixture, + costNanos: 1) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options) + + #expect(snapshot.projects.count == 1) + #expect(snapshot.projects.first?.displayName == "CodexBar") + #expect(snapshot.projects.first?.path == project.standardizedFileURL.path) + #expect(snapshot.projects.first?.sessionCount == 1) + #expect(snapshot.projects.first?.totals.totalTokens == 190) + #expect(snapshot.sessions.first?.projectId != CodexLocalProjectRootResolver.chatsProjectId) + } + + @Test + func `project usage index reports remaining file progress`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = Date() + let firstFixture = CodexUsageFixture( + filename: "first.jsonl", + sessionID: "first-session", + cwd: nil, + input: 100, + cached: 20, + output: 30) + let secondFixture = CodexUsageFixture( + filename: "second.jsonl", + sessionID: "second-session", + cwd: nil, + input: 50, + cached: 5, + output: 10) + let firstFileURL = try self.writeCodexUsageFile(env: env, day: day, fixture: firstFixture) + let secondFileURL = try self.writeCodexUsageFile(env: env, day: day, fixture: secondFixture) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + var cache = CostUsageCache() + cache.scanSinceKey = dayKey + cache.scanUntilKey = dayKey + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[firstFileURL.path] = self.makeCachedFileUsage(dayKey: dayKey, fixture: firstFixture, costNanos: 1) + cache.files[secondFileURL.path] = self.makeCachedFileUsage(dayKey: dayKey, fixture: secondFixture, costNanos: 1) + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let recorder = ProgressRecorder() + _ = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: day, + historyDays: 1, + since: day, + until: day, + options: options, + progress: { progress in + recorder.append(progress) + }) + let events = recorder.snapshot + + #expect(events.first?.phase == .indexingProjects) + #expect(events.first?.processedFileCount == 0) + #expect(events.first?.totalFileCount == 2) + #expect(events.last?.processedFileCount == 2) + #expect(events.last?.totalFileCount == 2) + #expect(events.last?.indexedFileCount == 2) + } + + @discardableResult + private func writeCodexUsageFile( + env: CostUsageTestEnvironment, + day: Date, + fixture: CodexUsageFixture) throws + -> URL + { + var turnPayload: [String: Any] = [ + "model": "openai/gpt-5.4", + ] + if let cwd = fixture.cwd { + turnPayload["cwd"] = cwd + } + let objects: [[String: Any]] = [ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": ["id": fixture.sessionID], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": turnPayload, + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": fixture.input, + "cached_input_tokens": fixture.cached, + "output_tokens": fixture.output, + ], + "model": "openai/gpt-5.4", + ], + ], + ], + ] + return try env.writeCodexSessionFile(day: day, filename: fixture.filename, contents: env.jsonl(objects)) + } + + private func makeCachedFileUsage( + dayKey: String, + fixture: CodexUsageFixture, + costNanos: Int64?) -> CostUsageFileUsage + { + let model = "openai/gpt-5.4" + return CostUsageFileUsage( + mtimeUnixMs: 0, + size: 0, + days: [dayKey: [model: [fixture.input, fixture.cached, fixture.output]]], + parsedBytes: nil, + lastModel: model, + lastTotals: nil, + lastCountedTotals: nil, + lastRawTotalsBaseline: nil, + hasDivergentTotals: nil, + lastCodexTurnID: nil, + sessionId: fixture.sessionID, + forkedFromId: nil, + codexSession: CostUsageCodexSessionMetadata( + sessionId: fixture.sessionID, + forkedFromId: nil, + cwd: fixture.cwd, + title: nil, + startedAtUnixMs: nil, + latestActivityUnixMs: nil), + codexCostNanos: costNanos.map { [dayKey: [model: $0]] }, + codexPrioritySurchargeNanos: nil, + codexStandardCostNanos: nil, + codexPriorityCostNanos: nil, + codexStandardTokens: nil, + codexPriorityTokens: nil, + codexTurnIDs: nil, + codexRows: nil, + claudeRows: nil) + } + + @Test + func `workspace fingerprint covers sidecar semantics but not scanner cursors`() throws { + let fixture = CodexUsageFixture( + filename: "rollout.jsonl", + sessionID: "fingerprint-session", + cwd: "/tmp/fingerprint-project", + input: 12, + cached: 3, + output: 4) + let day = "2026-07-25" + let baseline = self.makeCachedFileUsage(dayKey: day, fixture: fixture, costNanos: 42) + .refreshingCodexWorkspaceUsageFingerprint() + let fingerprint = try #require(baseline.codexWorkspaceContentFingerprint) + + var cursorOnly = baseline + cursorOnly.lastRawTotalsWatermark = CostUsageCodexTotals(input: 1000, cached: 900, output: 100) + #expect(cursorOnly.codexWorkspaceUsageFingerprintValue() == fingerprint) + + var changedDaily = baseline + changedDaily.days[day]?["openai/gpt-5.4"] = [24, 6, 8] + changedDaily = changedDaily.refreshingCodexWorkspaceUsageFingerprint() + #expect(changedDaily.codexWorkspaceUsageFingerprintValue() != fingerprint) + + var changedCost = baseline + changedCost.codexCostNanos?[day]?["openai/gpt-5.4"] = 84 + changedCost = changedCost.refreshingCodexWorkspaceUsageFingerprint() + #expect(changedCost.codexWorkspaceUsageFingerprintValue() != fingerprint) + + var changedProject = baseline + changedProject.projectPath = "/tmp/another-project" + changedProject = changedProject.refreshingCodexWorkspaceUsageFingerprint() + #expect(changedProject.codexWorkspaceUsageFingerprintValue() != fingerprint) + } + + #if canImport(SQLite3) + private struct CodexStateThreadFixture { + var id: String + var rolloutPath: String + var cwd: String + var title: String + var preview: String + var model: String + var createdAtUnixMs: Int64 + var updatedAtUnixMs: Int64 + var usesLegacyTimestampColumns = false + } + + private func writeCodexStateDatabase(at url: URL, thread: CodexStateThreadFixture) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { + sqlite3_close(db) + throw NSError(domain: "CodexLocalProjectUsageTests", code: 1) + } + defer { sqlite3_close(db) } + try self.execSQLite(db, """ + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + source TEXT NOT NULL, + model_provider TEXT NOT NULL, + cwd TEXT NOT NULL, + title TEXT NOT NULL, + sandbox_policy TEXT NOT NULL, + approval_mode TEXT NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + model TEXT, + reasoning_effort TEXT, + created_at_ms INTEGER, + updated_at_ms INTEGER, + preview TEXT NOT NULL DEFAULT '' + ) + """) + var stmt: OpaquePointer? + let insert = """ + INSERT INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, archived, model, reasoning_effort, + created_at_ms, updated_at_ms, preview + ) VALUES (?, ?, ?, ?, 'codex', 'openai', ?, ?, 'workspace-write', 'never', 0, 0, ?, 'high', ?, ?, ?) + """ + guard sqlite3_prepare_v2(db, insert, -1, &stmt, nil) == SQLITE_OK else { + throw NSError(domain: "CodexLocalProjectUsageTests", code: 2) + } + defer { sqlite3_finalize(stmt) } + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, thread.id, -1, transient) + sqlite3_bind_text(stmt, 2, thread.rolloutPath, -1, transient) + sqlite3_bind_int64(stmt, 3, thread.createdAtUnixMs / 1000) + sqlite3_bind_int64(stmt, 4, thread.updatedAtUnixMs / 1000) + sqlite3_bind_text(stmt, 5, thread.cwd, -1, transient) + sqlite3_bind_text(stmt, 6, thread.title, -1, transient) + sqlite3_bind_text(stmt, 7, thread.model, -1, transient) + if thread.usesLegacyTimestampColumns { + sqlite3_bind_null(stmt, 8) + sqlite3_bind_null(stmt, 9) + } else { + sqlite3_bind_int64(stmt, 8, thread.createdAtUnixMs) + sqlite3_bind_int64(stmt, 9, thread.updatedAtUnixMs) + } + sqlite3_bind_text(stmt, 10, thread.preview, -1, transient) + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw NSError(domain: "CodexLocalProjectUsageTests", code: 3) + } + } + + private func execSQLite(_ db: OpaquePointer?, _ sql: String) throws { + var error: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &error) == SQLITE_OK else { + sqlite3_free(error) + throw NSError(domain: "CodexLocalProjectUsageTests", code: 4) + } + } + #endif + + private func makeProject( + id: String, + name: String, + input: Int, + cached: Int, + output: Int) -> CodexLocalProjectUsage + { + CodexLocalProjectUsage( + id: id, + displayName: name, + path: "/tmp/\(name)", + totals: CodexLocalUsageTotals( + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + totalTokens: input + output), + estimatedCostUSD: Double(input + output) / 1000, + hasUnknownCost: false, + sessionCount: 1, + latestActivity: nil, + topModel: "gpt-5.4", + topSessions: [], + modelBreakdowns: []) + } +} + +extension CodexLocalProjectUsageTests { + @Test + func `daily usage projection follows cached input setting`() { + let point = CodexLocalUsageDailyPoint( + day: "2026-07-12", + totalTokens: 100, + cachedInputTokens: 40, + estimatedCostUSD: 1) + let includeCache = CodexLocalProjectUsageProjection(includesCachedInput: true, showsEstimatedCost: true) + let excludeCache = CodexLocalProjectUsageProjection(includesCachedInput: false, showsEstimatedCost: true) + + #expect(includeCache.displayedTokens( + totalTokens: point.totalTokens, + cachedInputTokens: point.cachedInputTokens) == 100) + #expect(excludeCache.displayedTokens( + totalTokens: point.totalTokens, + cachedInputTokens: point.cachedInputTokens) == 60) + } + + @Test + func `ranking projects preserves daily usage`() throws { + let daily = CodexLocalUsageDailyPoint( + day: "2026-07-12", + totalTokens: 100, + cachedInputTokens: 40, + estimatedCostUSD: 1) + let project = CodexLocalProjectUsage( + id: "project", + displayName: "Project", + path: "/tmp/Project", + totals: CodexLocalUsageTotals( + inputTokens: 80, + cachedInputTokens: 40, + outputTokens: 20, + totalTokens: 100), + costEstimate: CodexLocalCostEstimate(knownUSD: 1, unknownTokens: 0), + sessionCount: 1, + latestActivity: nil, + topModel: "gpt-5.4", + topSessions: [], + modelBreakdowns: [], + daily: [daily]) + let projection = CodexLocalProjectUsageProjection( + includesCachedInput: true, + showsEstimatedCost: true) + + let ranked = try #require(projection.rankedProjects([project]).first) + #expect(ranked.daily == [daily]) + } + + @Test + func `projection derives severity from displayed tokens not price`() { + let projection = CodexLocalProjectUsageProjection( + includesCachedInput: true, + showsEstimatedCost: true) + let dominant = self.makeProject( + id: "dominant", + name: "Dominant", + input: 1000, + cached: 0, + output: 0) + let costlySmall = CodexLocalProjectUsage( + id: "costly-small", + displayName: "CostlySmall", + path: "/tmp/CostlySmall", + totals: CodexLocalUsageTotals( + inputTokens: 1, + cachedInputTokens: 0, + outputTokens: 0, + totalTokens: 1), + costEstimate: CodexLocalCostEstimate(knownUSD: 10000, unknownTokens: 0), + sessionCount: 1, + latestActivity: nil, + topModel: "gpt-5.4", + topSessions: [], + modelBreakdowns: []) + + let ranked = projection.rankedProjects([costlySmall, dominant]) + #expect(ranked.map(\.id) == ["dominant", "costly-small"]) + #expect(ranked.first?.severity == .high) + #expect(ranked.last?.severity == .normal) + } + + @Test + func `cost coverage retains the number of unpriced tokens`() { + let estimate = CodexLocalCostEstimate(knownUSD: 2.5, unknownTokens: 37) + + #expect(estimate.coverage == .partial) + #expect(estimate.knownUSD == 2.5) + #expect(estimate.unknownTokens == 37) + } + + @Test + func `persisted projects exclude display severity`() throws { + let project = CodexLocalProjectUsage( + id: "project", + displayName: "Project", + path: "/tmp/project", + totals: .empty, + costEstimate: CodexLocalCostEstimate(knownUSD: 1, unknownTokens: 0), + sessionCount: 1, + latestActivity: nil, + topModel: nil, + topSessions: [], + modelBreakdowns: [], + usageSeverity: .high) + + let encoded = try JSONEncoder.codexLocalProjectUsageSidecar.encode(project) + let json = try #require(String(data: encoded, encoding: .utf8)) + let decoded = try JSONDecoder.codexLocalProjectUsageSidecar.decode(CodexLocalProjectUsage.self, from: encoded) + + #expect(!json.contains("usageSeverity")) + #expect(decoded.severity == .normal) + } + + @Test + func `snapshot ignores legacy process state without persisting it again`() throws { + let snapshot = CodexLocalProjectUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + historyDays: 30, + scopeSignature: "scope", + rootsFingerprint: ["sessions": 1], + indexedFileCount: 1, + skippedFileCount: 0, + total: .empty, + projects: [], + daily: []) + + let encoded = try JSONEncoder.codexLocalProjectUsageSidecar.encode(snapshot) + let currentJSON = try #require(String(data: encoded, encoding: .utf8)) + var legacyObject = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + legacyObject["stale"] = true + legacyObject["indexing"] = true + legacyObject["errorMessage"] = "previous refresh failed" + let legacyData = try JSONSerialization.data(withJSONObject: legacyObject) + let decoded = try JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalProjectUsageSnapshot.self, + from: legacyData) + + #expect(decoded == snapshot) + #expect(!currentJSON.contains("\"stale\"")) + #expect(!currentJSON.contains("\"indexing\"")) + #expect(!currentJSON.contains("\"errorMessage\"")) + } + + @Test + func `snapshot persists source completeness and defaults legacy payloads to complete`() throws { + let snapshot = CodexLocalProjectUsageSnapshot( + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + historyDays: 30, + scopeSignature: "scope", + rootsFingerprint: ["sessions": 1], + indexedFileCount: 1, + skippedFileCount: 0, + total: .empty, + projects: [], + daily: [], + sourceStatus: .catalogLocked) + + let encoded = try JSONEncoder.codexLocalProjectUsageSidecar.encode(snapshot) + let decoded = try JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalProjectUsageSnapshot.self, + from: encoded) + #expect(decoded.sourceStatus == .catalogLocked) + + var legacyObject = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + legacyObject.removeValue(forKey: "sourceStatus") + let legacyData = try JSONSerialization.data(withJSONObject: legacyObject) + let decodedLegacy = try JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalProjectUsageSnapshot.self, + from: legacyData) + #expect(decodedLegacy.sourceStatus == .complete) + } + + @Test + func `aggregate only snapshots are rejected until inspector detail is available`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let dailyPoint = CodexLocalUsageDailyPoint( + day: "2023-11-14", + totalTokens: 100, + estimatedCostUSD: 1) + let totals = CodexLocalUsageTotals( + inputTokens: 80, + cachedInputTokens: 20, + outputTokens: 20, + totalTokens: 100) + let incompleteProject = CodexLocalProjectUsage( + id: "project", + displayName: "Project", + path: "/tmp/project", + totals: totals, + costEstimate: CodexLocalCostEstimate(knownUSD: 1, unknownTokens: 0), + sessionCount: 1, + latestActivity: now, + topModel: "gpt-5.4", + topSessions: [], + modelBreakdowns: []) + let incomplete = CodexLocalProjectUsageSnapshot( + updatedAt: now, + historyDays: 1, + scopeSignature: "scope", + rootsFingerprint: [:], + indexedFileCount: 1, + skippedFileCount: 0, + total: totals, + projects: [incompleteProject], + daily: []) + #expect(!incomplete.hasInspectorDetail) + + let session = CodexLocalSessionUsage( + id: "session", + projectId: "project", + displayTitle: "Project session", + cwd: "/tmp/project", + startedAt: now, + latestActivity: now, + totals: totals, + estimatedCostUSD: 1, + hasUnknownCost: false, + topModel: "gpt-5.4", + daily: [dailyPoint]) + let completeProject = CodexLocalProjectUsage( + id: "project", + displayName: "Project", + path: "/tmp/project", + totals: totals, + costEstimate: CodexLocalCostEstimate(knownUSD: 1, unknownTokens: 0), + sessionCount: 1, + latestActivity: now, + topModel: "gpt-5.4", + topSessions: [session], + modelBreakdowns: [], + daily: [dailyPoint]) + let complete = CodexLocalProjectUsageSnapshot( + updatedAt: now, + historyDays: 1, + scopeSignature: "scope", + rootsFingerprint: [:], + indexedFileCount: 1, + skippedFileCount: 0, + total: totals, + projects: [completeProject], + sessions: [session], + daily: [dailyPoint]) + #expect(complete.hasInspectorDetail) + } + + @Test + func `session daily attribution round trips and legacy payload defaults to empty`() throws { + let daily = [ + CodexLocalUsageDailyPoint( + day: "2026-07-12", + totalTokens: 100, + cachedInputTokens: 20, + estimatedCostUSD: 1.25), + CodexLocalUsageDailyPoint( + day: "2026-07-13", + totalTokens: 250, + cachedInputTokens: 50, + estimatedCostUSD: 2.5), + ] + let session = CodexLocalSessionUsage( + id: "session", + projectId: "project", + displayTitle: "A chat spanning two days", + cwd: "/tmp/project", + startedAt: nil, + latestActivity: nil, + totals: CodexLocalUsageTotals( + inputTokens: 280, + cachedInputTokens: 70, + outputTokens: 70, + totalTokens: 350), + estimatedCostUSD: 3.75, + hasUnknownCost: false, + topModel: "gpt-5.4", + daily: daily) + + let encoded = try JSONEncoder.codexLocalProjectUsageSidecar.encode(session) + let decoded = try JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalSessionUsage.self, + from: encoded) + #expect(decoded.daily == daily) + + var legacyObject = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + legacyObject.removeValue(forKey: "daily") + let legacyData = try JSONSerialization.data(withJSONObject: legacyObject) + let decodedLegacy = try JSONDecoder.codexLocalProjectUsageSidecar.decode( + CodexLocalSessionUsage.self, + from: legacyData) + #expect(decodedLegacy.daily.isEmpty) + } + + @Test + func `sidecar rejects unreleased schema versions`() throws { + #if canImport(SQLite3) + for version in [2, 3, 4] { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let databaseDirectory = env.cacheRoot.appendingPathComponent("local-usage", isDirectory: true) + try FileManager.default.createDirectory(at: databaseDirectory, withIntermediateDirectories: true) + let databaseURL = databaseDirectory.appendingPathComponent("codex-workspaces-v1.sqlite") + var database: OpaquePointer? + #expect(sqlite3_open(databaseURL.path, &database) == SQLITE_OK) + try self.execSQLite(database, "PRAGMA user_version = \(version);") + sqlite3_close(database) + + #expect(throws: (any Error).self) { + try CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot).synchronizeSources( + cache: CostUsageCache(), + catalog: .empty) + } + + database = nil + #expect(sqlite3_open(databaseURL.path, &database) == SQLITE_OK) + var statement: OpaquePointer? + #expect(sqlite3_prepare_v2(database, "PRAGMA user_version", -1, &statement, nil) == SQLITE_OK) + #expect(sqlite3_step(statement) == SQLITE_ROW) + #expect(sqlite3_column_int(statement, 0) == Int32(version)) + sqlite3_finalize(statement) + sqlite3_close(database) + } + #endif + } + + @Test + func `aggregate models analytics preserves priced zero and unavailable coverage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = Date() + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: now) + let project = env.root.appendingPathComponent("Project", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let pricedFixture = CodexUsageFixture( + filename: "priced.jsonl", + sessionID: "priced-fallback", + cwd: project.path, + input: 10, + cached: 0, + output: 0) + let unavailableFixture = CodexUsageFixture( + filename: "unavailable.jsonl", + sessionID: "unavailable-fallback", + cwd: project.path, + input: 20, + cached: 0, + output: 0) + var unavailableUsage = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: unavailableFixture, + costNanos: nil) + let unavailableModel = "openai/gpt-5.4-mini" + unavailableUsage.days = [dayKey: [unavailableModel: [20, 0, 0]]] + unavailableUsage.lastModel = unavailableModel + + var cache = CostUsageCache() + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[env.root.appendingPathComponent("priced.jsonl").path] = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: pricedFixture, + costNanos: 0) + cache.files[env.root.appendingPathComponent("unavailable.jsonl").path] = unavailableUsage + + let snapshot = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: now, + historyDays: 1, + since: now, + until: now, + options: options, + cacheOverride: cache, + catalogOverride: .empty) + let analytics = try #require(snapshot.modelsAnalytics?.allWorkspaces) + let priced = try #require(analytics.rows.first { $0.id == "gpt-5.4" }) + let unavailable = try #require(analytics.rows.first { $0.id == "gpt-5.4-mini" }) + + #expect(priced.cost.knownAmount == 0) + #expect(priced.cost.pricedTokens == 10) + #expect(priced.cost.unpricedTokens == 0) + #expect(unavailable.cost.knownAmount == 0) + #expect(unavailable.cost.pricedTokens == 0) + #expect(unavailable.cost.unpricedTokens == 20) + #expect(analytics.cost.knownAmount == 0) + #expect(analytics.cost.pricedTokens == 10) + #expect(analytics.cost.unpricedTokens == 20) + #expect(analytics.diagnostics.isMatched) + } + + @Test + func `sidecar rehydrates cache-backed project aggregates`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = Date() + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: now) + let project = env.root.appendingPathComponent("Project", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let path = env.root.appendingPathComponent("rollout.jsonl").path + let fixture = CodexUsageFixture( + filename: "rollout.jsonl", + sessionID: "sidecar-session", + cwd: project.path, + input: 120, + cached: 20, + output: 30) + var cache = CostUsageCache() + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + var cachedUsage = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: fixture, + costNanos: 42) + cachedUsage.codexRows = [CostUsageScanner.CodexUsageRow( + day: dayKey, + model: "openai/gpt-5.4", + rawModel: "GPT-5.4", + turnID: "turn-1", + eventIndex: 0, + timestampUnixMs: Int64(now.timeIntervalSince1970 * 1000), + input: fixture.input, + cached: fixture.cached, + output: fixture.output, + reasoning: 12, + knownCostNanos: 42, + unpricedTokens: 0, + pricingModel: "openai/gpt-5.4", + pricingMode: "standard")] + cache.files[path] = cachedUsage + let catalog = CodexThreadCatalog.empty + let baseline = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: now, + historyDays: 1, + since: now, + until: now, + options: options, + cacheOverride: cache, + catalogOverride: catalog) + let baselineModel = try #require(baseline.modelsAnalytics?.allWorkspaces.rows.first) + let expectedKnownCost = try #require(Decimal(string: "0.000000042")) + #expect(baselineModel.reasoningTokens == 12) + #expect(baselineModel.cost.knownAmount == expectedKnownCost) + #expect(baselineModel.cost.pricedTokens == Int64(fixture.input + fixture.output)) + #expect(baselineModel.cost.unpricedTokens == 0) + #expect(baseline.modelsAnalytics?.allWorkspaces.diagnostics.isMatched == true) + let sidecar = CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot) + try sidecar.synchronize( + snapshot: baseline, + cache: cache, + catalog: catalog, + rootsFingerprint: cache.roots ?? [:]) + #if canImport(SQLite3) + let sidecarURL = env.cacheRoot + .appendingPathComponent("local-usage", isDirectory: true) + .appendingPathComponent("codex-workspaces-v1.sqlite", isDirectory: false) + var database: OpaquePointer? + #expect(sqlite3_open(sidecarURL.path, &database) == SQLITE_OK) + defer { sqlite3_close(database) } + var statement: OpaquePointer? + #expect(sqlite3_prepare_v2( + database, + "SELECT payload_format_version, payload FROM snapshot_payloads", + -1, + &statement, + nil) == SQLITE_OK) + #expect(sqlite3_step(statement) == SQLITE_ROW) + #expect(sqlite3_column_int(statement, 0) == 3) + let numericPayloadBytes = try #require(sqlite3_column_blob(statement, 1)) + let numericPayload = Data( + bytes: numericPayloadBytes, + count: Int(sqlite3_column_bytes(statement, 1))) + sqlite3_finalize(statement) + let numericObject = try #require(JSONSerialization.jsonObject(with: numericPayload) as? [String: Any]) + #expect(numericObject["updatedAt"] is NSNumber) + + statement = nil + #expect(sqlite3_prepare_v2( + database, + "UPDATE snapshot_payloads SET payload_format_version = 2 WHERE scope_signature = ? AND history_days = ?", + -1, + &statement, + nil) == SQLITE_OK) + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 1, baseline.scopeSignature, -1, transient) + sqlite3_bind_int64(statement, 2, Int64(baseline.historyDays)) + #expect(sqlite3_step(statement) == SQLITE_DONE) + sqlite3_finalize(statement) + #expect(sidecar.loadLatestSnapshot( + scopeSignature: baseline.scopeSignature, + historyDays: baseline.historyDays) == nil) + #expect(try sidecar.usageCache(roots: cache.roots ?? [:]).files[path]?.codexRows?.first?.knownCostNanos == 42) + try sidecar.synchronize( + snapshot: baseline, + cache: cache, + catalog: catalog, + rootsFingerprint: cache.roots ?? [:]) + statement = nil + #expect(sqlite3_prepare_v2( + database, + "SELECT payload_format_version FROM snapshot_payloads", + -1, + &statement, + nil) == SQLITE_OK) + #expect(sqlite3_step(statement) == SQLITE_ROW) + #expect(sqlite3_column_int(statement, 0) == 3) + sqlite3_finalize(statement) + #expect(sidecar.loadLatestSnapshot( + scopeSignature: baseline.scopeSignature, + historyDays: baseline.historyDays)?.total == baseline.total) + #endif + let rehydratedCache = try sidecar.usageCache(roots: cache.roots ?? [:]) + let rehydratedEvent = try #require(rehydratedCache.files[path]?.codexRows?.first) + let rehydrated = try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: now, + historyDays: 1, + since: now, + until: now, + options: options, + cacheOverride: rehydratedCache, + catalogOverride: catalog) + + #expect(rehydrated.total == baseline.total) + #expect(rehydrated.projects.map(\.id) == baseline.projects.map(\.id)) + #expect(rehydrated.projects.first?.costEstimate == baseline.projects.first?.costEstimate) + #expect(rehydratedEvent.rawModel == "GPT-5.4") + #expect(rehydratedEvent.timestampUnixMs == Int64(now.timeIntervalSince1970 * 1000)) + #expect(rehydratedEvent.reasoning == 12) + #expect(rehydratedEvent.knownCostNanos == 42) + #expect(rehydratedEvent.pricingMode == "standard") + let rehydratedModel = try #require(rehydrated.modelsAnalytics?.allWorkspaces.rows.first) + #expect(rehydratedModel.reasoningTokens == 12) + #expect(rehydratedModel.cost.knownAmount == expectedKnownCost) + #expect(rehydratedModel.cost.pricedTokens == Int64(fixture.input + fixture.output)) + #expect(rehydratedModel.cost.unpricedTokens == 0) + #expect(rehydrated.modelsAnalytics?.allWorkspaces.diagnostics.isMatched == true) + } + + @Test + func `sidecar refreshes changed usage when rollout metadata is unchanged`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: Date()) + let path = env.root.appendingPathComponent("rollout.jsonl").path + let fixture = CodexUsageFixture( + filename: "rollout.jsonl", + sessionID: "sidecar-content-change", + cwd: env.root.path, + input: 120, + cached: 20, + output: 30) + var cache = CostUsageCache() + let timestampUnixMs = Int64(Date().timeIntervalSince1970 * 1000) + var initialUsage = self.makeCachedFileUsage( + dayKey: dayKey, + fixture: fixture, + costNanos: 42) + initialUsage.codexRows = [CostUsageScanner.CodexUsageRow( + day: dayKey, + model: "openai/gpt-5.4", + rawModel: "GPT-5.4", + turnID: "turn-1", + eventIndex: 0, + timestampUnixMs: timestampUnixMs, + input: 120, + cached: 20, + output: 30, + knownCostNanos: 42, + unpricedTokens: 0, + pricingModel: "openai/gpt-5.4", + pricingMode: "standard")] + initialUsage = initialUsage.refreshingCodexWorkspaceUsageFingerprint() + cache.files[path] = initialUsage + let sidecar = CodexWorkspaceUsageSidecar(cacheRoot: env.cacheRoot) + try sidecar.synchronizeSources(cache: cache, catalog: .empty) + + let model = "openai/gpt-5.4" + var changedUsage = try #require(cache.files[path]) + changedUsage.days[dayKey]?[model] = [240, 40, 60] + changedUsage.codexCostNanos?[dayKey]?[model] = 84 + changedUsage.codexRows = [CostUsageScanner.CodexUsageRow( + day: dayKey, + model: model, + rawModel: "GPT-5.4", + turnID: "turn-1", + eventIndex: 0, + timestampUnixMs: timestampUnixMs, + input: 240, + cached: 40, + output: 60, + knownCostNanos: 84, + unpricedTokens: 0, + pricingModel: model, + pricingMode: "priority")] + changedUsage = changedUsage.refreshingCodexWorkspaceUsageFingerprint() + cache.files[path] = changedUsage + try sidecar.synchronizeSources(cache: cache, catalog: .empty) + + let updated = try #require(sidecar.usageCache(roots: [:]).files[path]) + #expect(updated.days[dayKey]?[model] == [240, 40, 60]) + #expect(updated.codexCostNanos?[dayKey]?[model] == 84) + #expect(updated.codexRows?.first?.input == 240) + #expect(updated.codexRows?.first?.knownCostNanos == 84) + #expect(updated.codexRows?.first?.pricingMode == "priority") + + changedUsage.days = [:] + changedUsage.codexCostNanos = [:] + changedUsage.codexRows = [] + changedUsage = changedUsage.refreshingCodexWorkspaceUsageFingerprint() + cache.files[path] = changedUsage + try sidecar.synchronizeSources(cache: cache, catalog: .empty) + + #expect(try sidecar.usageCache(roots: [:]).files[path] == nil) + } +} + +// swiftlint:enable type_body_length diff --git a/Tests/CodexBarTests/CodexModelsAnalyticsParityTests.swift b/Tests/CodexBarTests/CodexModelsAnalyticsParityTests.swift new file mode 100644 index 000000000..6f6ecd4d0 --- /dev/null +++ b/Tests/CodexBarTests/CodexModelsAnalyticsParityTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite("Codex Models analytics parity") +struct CodexModelsAnalyticsParityTests { + @Test + func `per model parity canonicalizes aliases with audited cost`() throws { + let current = DateInterval( + start: Date(timeIntervalSince1970: 1_000_000), + duration: 7 * 24 * 60 * 60) + let previous = DateInterval( + start: current.start.addingTimeInterval(-current.duration), + duration: current.duration) + let currentFragments = [ + self.fragment( + day: current.start, + model: "GPT-5.4", + input: 6, + session: "current", + costNanos: 30), + self.fragment( + day: current.start, + model: "openai/gpt-5.4", + input: 4, + session: "current", + costNanos: 20), + ] + let previousFragments = [self.fragment( + day: previous.start, + model: "gpt-5.4", + input: 5, + session: "previous", + costNanos: 10)] + let currentKnownCost = try #require(Decimal(string: "0.00000005")) + let previousKnownCost = try #require(Decimal(string: "0.00000001")) + let snapshot = CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: currentFragments, previous: previousFragments), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: current, previous: previous), + revision: CodexModelsAnalyticsRevision(generatedAt: current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: 10, + modelIDs: ["gpt-5.4"], + knownCost: currentKnownCost, + pricedTokens: 10, + unpricedTokens: 0, + activeModelCount: 1, + topModelID: "gpt-5.4", + sessionReferenceTotal: 1, + previousTotalTokens: 5, + previousKnownCost: previousKnownCost, + previousUnpricedTokens: 0, + previousSessionReferenceTotal: 1, + currentModels: [CodexModelsLegacyModelBaseline( + modelID: "gpt-5.4", + totalTokens: 10, + knownCost: currentKnownCost, + pricedTokens: 10, + unpricedTokens: 0, + sessionReferences: 1)], + previousModels: [CodexModelsLegacyModelBaseline( + modelID: "gpt-5.4", + totalTokens: 5, + knownCost: previousKnownCost, + pricedTokens: 5, + unpricedTokens: 0, + sessionReferences: 1)]))) + + let row = try #require(snapshot.rows.first) + #expect(row.id == "gpt-5.4") + #expect(row.cost.knownAmount == currentKnownCost) + #expect(row.cost.pricedTokens == 10) + #expect(row.cost.unpricedTokens == 0) + #expect(snapshot.diagnostics.isMatched) + } + + private func fragment( + day: Date, + model: String, + input: Int64, + session: String, + costNanos: Int64) -> CodexModelsUsageFragment + { + CodexModelsUsageFragment( + workspaceID: "workspace", + sessionID: session, + day: day, + rawModelID: model, + inputTokens: input, + cachedInputTokens: 0, + outputTokens: 0, + costNanos: costNanos) + } +} diff --git a/Tests/CodexBarTests/CodexModelsAnalyticsTests.swift b/Tests/CodexBarTests/CodexModelsAnalyticsTests.swift new file mode 100644 index 000000000..568f0da17 --- /dev/null +++ b/Tests/CodexBarTests/CodexModelsAnalyticsTests.swift @@ -0,0 +1,528 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite("Codex Models analytics") +struct CodexModelsAnalyticsTests { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return calendar + }() + + @Test + func `canonical aliases merge while raw aliases remain auditable`() { + let intervals = self.intervals(days: 7) + let current = [ + self.fragment(day: intervals.current.start, model: "GPT-5", input: 10, output: 2, session: "one"), + self.fragment(day: intervals.current.start, model: "gpt-5", input: 7, output: 1, session: "two"), + self.fragment(day: intervals.current.start, model: " OpenAI/GPT-5 ", input: 3, output: 0, session: "three"), + ] + let snapshot = self.build(current: current, previous: [], intervals: intervals, legacyTotal: 23) + + #expect(snapshot.rows.count == 1) + #expect(snapshot.rows[0].id == "gpt-5") + #expect(snapshot.rows[0].rawAliases == [" OpenAI/GPT-5 ", "GPT-5", "gpt-5"]) + #expect(snapshot.rows[0].sessionReferences == 3) + #expect(snapshot.rows[0].associatedSessionIDs == ["one", "three", "two"]) + } + + @Test + func `cached input and reasoning detail are not double counted`() { + let intervals = self.intervals(days: 7) + let fragment = CodexModelsUsageFragment( + workspaceID: "workspace", + sessionID: "one", + day: intervals.current.start, + rawModelID: "model-a", + inputTokens: 100, + cachedInputTokens: 80, + outputTokens: 40, + reasoningTokens: 30, + costNanos: 1_500_000_000) + let snapshot = self.build(current: [fragment], previous: [], intervals: intervals, legacyTotal: 140) + let row = snapshot.rows[0] + + #expect(row.totalTokens == 140) + #expect(row.cachedInputTokens == 80) + #expect(row.reasoningTokens == 30) + #expect(snapshot.invariantViolations().isEmpty) + } + + @Test + func `pricing distinguishes complete partial unavailable and known zero`() throws { + let intervals = self.intervals(days: 7) + let current = [ + self.fragment(day: intervals.current.start, model: "model-a", input: 10, output: 0, costNanos: 0), + self.fragment(day: intervals.current.start, model: "model-b", input: 20, output: 0, costNanos: nil), + CodexModelsUsageFragment( + workspaceID: "workspace", + sessionID: "partial", + day: intervals.current.start, + rawModelID: "model-a", + inputTokens: 25, + cachedInputTokens: 0, + outputTokens: 0, + costNanos: 250_000_000, + unpricedTokens: 20), + ] + let snapshot = self.build(current: current, previous: [], intervals: intervals, legacyTotal: 55) + let priced = try #require(snapshot.rows.first { $0.id == "model-a" }) + let unavailable = try #require(snapshot.rows.first { $0.id == "model-b" }) + + #expect(priced.cost.knownAmount == Decimal(string: "0.25")) + #expect(priced.cost.pricedTokens == 15) + #expect(priced.cost.unpricedTokens == 20) + #expect(unavailable.cost.knownAmount == 0) + #expect(unavailable.cost.pricedTokens == 0) + #expect(unavailable.cost.unpricedTokens == 20) + #expect(snapshot.cost.pricedTokens + snapshot.cost.unpricedTokens == snapshot.totalTokens) + } + + @Test + func `current and previous periods are equal duration across DST`() throws { + let end = try #require(self.calendar.date(from: DateComponents(year: 2026, month: 3, day: 12))) + let currentStart = try #require(self.calendar.date(byAdding: .day, value: -7, to: end)) + let currentInterval = DateInterval(start: currentStart, end: end) + let previousStart = currentStart.addingTimeInterval(-currentInterval.duration) + let intervals = ( + current: currentInterval, + previous: DateInterval(start: previousStart, end: currentStart)) + let current = [self.fragment(day: currentStart, model: "model-a", input: 20, output: 0)] + let previous = [self.fragment(day: previousStart, model: "model-a", input: 10, output: 0)] + let snapshot = self.build(current: current, previous: previous, intervals: intervals, legacyTotal: 20) + + #expect(intervals.current.duration == intervals.previous.duration) + #expect(snapshot.rows[0].tokenComparison == .percent(1)) + } + + @Test + func `indexer periods are adjacent equal seconds across both DST transitions`() throws { + let springSince = try #require(self.calendar.date(from: DateComponents(year: 2026, month: 3, day: 6))) + let springUntil = try #require(self.calendar.date(from: DateComponents(year: 2026, month: 3, day: 12))) + let fallSince = try #require(self.calendar.date(from: DateComponents(year: 2026, month: 10, day: 30))) + let fallUntil = try #require(self.calendar.date(from: DateComponents(year: 2026, month: 11, day: 5))) + + for periods in [ + CodexLocalProjectUsageIndexer.modelsAnalyticsPeriods( + since: springSince, + until: springUntil, + calendar: self.calendar), + CodexLocalProjectUsageIndexer.modelsAnalyticsPeriods( + since: fallSince, + until: fallUntil, + calendar: self.calendar), + ] { + #expect(periods.current.duration == periods.previous.duration) + #expect(periods.previous.end == periods.current.start) + } + + let fallPeriods = CodexLocalProjectUsageIndexer.modelsAnalyticsPeriods( + since: fallSince, + until: fallUntil, + calendar: self.calendar) + let scanStart = CodexLocalProjectUsageIndexer.modelsAnalyticsScanStart( + since: fallSince, + until: fallUntil, + calendar: self.calendar) + let calendarDaySubtraction = try #require( + self.calendar.date(byAdding: .day, value: -7, to: fallPeriods.current.start)) + #expect(calendarDaySubtraction > fallPeriods.previous.start) + #expect(scanStart <= fallPeriods.previous.start) + #expect(fallPeriods.previous.start.timeIntervalSince(scanStart) < 24 * 60 * 60) + } + + @Test + func `timestamp filtering uses half open current and previous boundaries`() { + let intervals = self.intervals(days: 2) + let current = [ + self.fragment( + day: intervals.current.start, + timestamp: intervals.current.start, + model: "model-a", + input: 10, + output: 0), + self.fragment( + day: intervals.current.start, + timestamp: intervals.current.end.addingTimeInterval(-0.001), + model: "model-a", + input: 20, + output: 0), + self.fragment( + day: intervals.current.end, + timestamp: intervals.current.end, + model: "model-a", + input: 40, + output: 0), + ] + let previous = [ + self.fragment( + day: intervals.previous.start, + timestamp: intervals.previous.start, + model: "model-a", + input: 5, + output: 0), + self.fragment( + day: intervals.previous.start, + timestamp: intervals.previous.end.addingTimeInterval(-0.001), + model: "model-a", + input: 7, + output: 0), + self.fragment( + day: intervals.previous.end, + timestamp: intervals.previous.end, + model: "model-a", + input: 9, + output: 0), + ] + let snapshot = self.build(current: current, previous: previous, intervals: intervals, legacyTotal: 30) + + #expect(snapshot.totalTokens == 30) + #expect(snapshot.rows[0].previousTotalTokens == 12) + #expect(snapshot.rows[0].tokenComparison == .percent(1.5)) + } + + @Test + func `session references count distinct session model pairs`() throws { + let intervals = self.intervals(days: 7) + let secondDay = try #require(self.calendar.date(byAdding: .day, value: 1, to: intervals.current.start)) + let current = [ + self.fragment(day: intervals.current.start, model: "model-a", input: 1, output: 0, session: "same"), + self.fragment(day: secondDay, model: "model-a", input: 1, output: 0, session: "same"), + self.fragment(day: secondDay, model: "model-b", input: 1, output: 0, session: "same"), + ] + let snapshot = self.build(current: current, previous: [], intervals: intervals, legacyTotal: 3) + + #expect(snapshot.uniqueSessionCount == 1) + #expect(snapshot.sessionReferenceTotal == 2) + #expect(snapshot.rows.first { $0.id == "model-a" }?.sessionReferences == 1) + #expect(snapshot.rows.first { $0.id == "model-b" }?.sessionReferences == 1) + let allModelsBucket = try #require(snapshot.daily.last) + #expect(allModelsBucket.sessionIDs == ["same"]) + #expect(allModelsBucket.sessionReferenceIDs.count == 2) + #expect(allModelsBucket.sessionReferences == 2) + #expect(snapshot.dailyByModel["model-a"]?.last?.sessionReferences == 1) + #expect(snapshot.dailyByModel["model-b"]?.last?.sessionReferences == 1) + } + + @Test + func `incomplete previous coverage suppresses comparisons and newly active semantics`() { + let intervals = self.intervals(days: 7) + let current = [ + self.fragment(day: intervals.current.start, model: "model-a", input: 20, output: 0, session: "one"), + self.fragment(day: intervals.current.start, model: "model-b", input: 10, output: 0, session: "two"), + ] + let previous = [ + self.fragment(day: intervals.previous.start, model: "model-a", input: 10, output: 0, session: "old"), + ] + let complete = self.build(current: current, previous: previous, intervals: intervals, legacyTotal: 30) + let incomplete = self.build( + current: current, + previous: previous, + intervals: intervals, + legacyTotal: 30, + previousIsComplete: false) + + #expect(complete.previousActiveModelCount == 1) + #expect(complete.newlyActiveModelCount == 1) + #expect(complete.rows.first { $0.id == "model-a" }?.previousTotalTokens == 10) + + #expect(incomplete.currentIsComplete == true) + #expect(incomplete.previousIsComplete == false) + #expect(incomplete.previousActiveModelCount == nil) + #expect(incomplete.newlyActiveModelCount == nil) + #expect(incomplete.previousSessionReferenceTotal == nil) + #expect(incomplete.tokenComparison == .unavailable) + #expect(incomplete.costComparison == .unavailable) + #expect(incomplete.sessionReferenceComparison == .unavailable) + for row in incomplete.rows { + #expect(row.previousTotalTokens == nil) + #expect(row.previousCost == nil) + #expect(row.previousSessionReferences == nil) + #expect(row.tokenComparison == .unavailable) + #expect(row.costComparison == .unavailable) + #expect(row.sessionReferenceComparison == .unavailable) + } + } + + @Test + func `ranking ties are deterministic`() { + let intervals = self.intervals(days: 7) + let current = [ + self.fragment(day: intervals.current.start, model: "model-z", input: 10, output: 0), + self.fragment(day: intervals.current.start, model: "model-a", input: 10, output: 0), + ] + let snapshot = self.build(current: current, previous: [], intervals: intervals, legacyTotal: 20) + #expect(snapshot.rows.map(\.id) == ["model-a", "model-z"]) + } + + @Test + func `dual run diagnostics flag total and identity mismatches`() { + let intervals = self.intervals(days: 7) + let current = [self.fragment(day: intervals.current.start, model: "model-a", input: 10, output: 0)] + let snapshot = CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: []), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline(totalTokens: 11, modelIDs: ["model-b"]))) + + #expect(snapshot.diagnostics.mismatches == ["total_tokens", "model_identities"]) + #expect(snapshot.diagnostics.mismatchDimensions == [.totalTokens, .modelIdentities]) + } + + @Test + func `dual run diagnostics compare current and previous per model raw values`() { + let intervals = self.intervals(days: 7) + let current = [self.fragment( + day: intervals.current.start, + model: "model-a", + input: 10, + output: 0, + session: "current", + costNanos: 100_000_000)] + let previous = [self.fragment( + day: intervals.previous.start, + model: "model-a", + input: 5, + output: 0, + session: "previous", + costNanos: 200_000_000)] + let snapshot = CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: previous), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: 10, + modelIDs: ["model-a"], + currentModels: [CodexModelsLegacyModelBaseline( + modelID: "model-a", + totalTokens: 11, + knownCost: Decimal(string: "0.3"), + pricedTokens: 10, + unpricedTokens: 0, + sessionReferences: 1)], + previousModels: [CodexModelsLegacyModelBaseline( + modelID: "model-a", + totalTokens: 5, + knownCost: Decimal(string: "0.2"), + pricedTokens: 0, + unpricedTokens: 5, + sessionReferences: 2)]))) + + #expect(snapshot.diagnostics.mismatchDimensions == [ + .modelTokens, + .modelKnownCost, + .modelPricingCoverage, + .modelSessionReferences, + ]) + } + + @Test + func `per model parity canonicalizes aliases and preserves unavailable pricing`() { + let intervals = self.intervals(days: 7) + let current = [ + self.fragment( + day: intervals.current.start, + model: "GPT-5", + input: 6, + output: 0, + session: "same", + costNanos: nil), + self.fragment( + day: intervals.current.start, + model: "gpt-5", + input: 4, + output: 0, + session: "same", + costNanos: nil), + ] + let previous = [self.fragment( + day: intervals.previous.start, + model: "OpenAI/GPT-5", + input: 5, + output: 0, + session: "earlier", + costNanos: nil)] + let snapshot = CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: previous), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: 10, + modelIDs: ["gpt-5"], + currentModels: [CodexModelsLegacyModelBaseline( + modelID: "gpt-5", + totalTokens: 10, + pricedTokens: 0, + unpricedTokens: 10, + sessionReferences: 1)], + previousModels: [CodexModelsLegacyModelBaseline( + modelID: "gpt-5", + totalTokens: 5, + pricedTokens: 0, + unpricedTokens: 5, + sessionReferences: 1)]))) + + #expect(snapshot.diagnostics.isMatched) + #expect(snapshot.rows[0].sessionReferences == 1) + #expect(snapshot.rows[0].cost.pricedTokens == 0) + #expect(snapshot.rows[0].cost.unpricedTokens == 10) + } + + @Test + func `per model parity skips periods whose source coverage is incomplete`() { + let intervals = self.intervals(days: 7) + let current = [self.fragment(day: intervals.current.start, model: "model-a", input: 10, output: 0)] + let snapshot = CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: []), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: 10, + modelIDs: ["model-a"], + currentModels: [CodexModelsLegacyModelBaseline( + modelID: "model-a", + totalTokens: 99, + knownCost: 99, + pricedTokens: 0, + unpricedTokens: 99, + sessionReferences: 99)]), + currentIsComplete: false, + previousIsComplete: true)) + + #expect(snapshot.diagnostics.isMatched) + } + + @Test + func `legacy event rows decode without new timestamp or pricing audit fields`() throws { + let data = Data(""" + { + "day": "2026-07-16", + "model": "model-a", + "turnID": "turn-1", + "eventIndex": 4, + "input": 10, + "cached": 3, + "output": 2 + } + """.utf8) + let row = try JSONDecoder().decode(CostUsageScanner.CodexUsageRow.self, from: data) + + #expect(row.rawModel == nil) + #expect(row.timestampUnixMs == nil) + #expect(row.knownCostNanos == nil) + #expect(row.unpricedTokens == nil) + #expect(row.input == 10) + } + + @Test + func `legacy analytics payload decodes without additive comparison and interval fields`() throws { + let intervals = self.intervals(days: 7) + let snapshot = self.build( + current: [self.fragment(day: intervals.current.start, model: "model-a", input: 10, output: 0)], + previous: [], + intervals: intervals, + legacyTotal: 10) + let encoded = try JSONEncoder().encode(snapshot) + var object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + object.removeValue(forKey: "previousActiveModelCount") + object.removeValue(forKey: "currentIsComplete") + object.removeValue(forKey: "previousIsComplete") + object.removeValue(forKey: "newlyActiveModelCount") + object.removeValue(forKey: "previousSessionReferenceTotal") + object.removeValue(forKey: "sessionReferenceComparison") + object["rows"] = try (#require(object["rows"] as? [[String: Any]])).map { value in + var row = value + row.removeValue(forKey: "previousTotalTokens") + row.removeValue(forKey: "previousCost") + row.removeValue(forKey: "previousSessionReferences") + row.removeValue(forKey: "associatedSessionIDs") + return row + } + object["daily"] = try (#require(object["daily"] as? [[String: Any]])).map { value in + var bucket = value + bucket.removeValue(forKey: "interval") + bucket.removeValue(forKey: "sessionReferenceIDs") + return bucket + } + let legacyData = try JSONSerialization.data(withJSONObject: object) + let decoded = try JSONDecoder().decode(CodexModelsAnalyticsSnapshot.self, from: legacyData) + + #expect(decoded.previousActiveModelCount == nil) + #expect(decoded.currentIsComplete == nil) + #expect(decoded.previousIsComplete == nil) + #expect(decoded.newlyActiveModelCount == nil) + #expect(decoded.rows[0].previousTotalTokens == nil) + #expect(decoded.rows[0].associatedSessionIDs == nil) + #expect(decoded.daily[0].interval == nil) + #expect(decoded.daily[0].sessionReferenceIDs == decoded.daily[0].sessionIDs) + #expect(decoded.totalTokens == 10) + } + + @Test + func `feature flag defaults on and supports rollback`() throws { + let name = "CodexModelsAnalyticsTests.featureFlag.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + + #expect(CodexModelsRollout.isEnabled(defaults: defaults)) + defaults.set(false, forKey: CodexModelsRollout.featureFlagKey) + #expect(!CodexModelsRollout.isEnabled(defaults: defaults)) + } + + private func build( + current: [CodexModelsUsageFragment], + previous: [CodexModelsUsageFragment], + intervals: (current: DateInterval, previous: DateInterval), + legacyTotal: Int64, + currentIsComplete: Bool = true, + previousIsComplete: Bool = true) -> CodexModelsAnalyticsSnapshot + { + CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: previous), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: legacyTotal, + modelIDs: Array(Set(current.map(\.rawModelID)))), + currentIsComplete: currentIsComplete, + previousIsComplete: previousIsComplete)) + } + + private func intervals(days: Int) -> (current: DateInterval, previous: DateInterval) { + let end = self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))! + let currentStart = self.calendar.date(byAdding: .day, value: -days, to: end)! + let previousStart = self.calendar.date(byAdding: .day, value: -days, to: currentStart)! + return ( + DateInterval(start: currentStart, end: end), + DateInterval(start: previousStart, end: currentStart)) + } + + private func fragment( + day: Date, + timestamp: Date? = nil, + model: String, + input: Int64, + output: Int64, + session: String = "session", + costNanos: Int64? = 100_000_000) -> CodexModelsUsageFragment + { + CodexModelsUsageFragment( + workspaceID: "workspace", + sessionID: session, + day: day, + timestamp: timestamp, + rawModelID: model, + inputTokens: input, + cachedInputTokens: min(3, input), + outputTokens: output, + costNanos: costNanos) + } +} diff --git a/Tests/CodexBarTests/CodexModelsExportFormattingTests.swift b/Tests/CodexBarTests/CodexModelsExportFormattingTests.swift new file mode 100644 index 000000000..60df95991 --- /dev/null +++ b/Tests/CodexBarTests/CodexModelsExportFormattingTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite("Codex Models export and formatting") +struct CodexModelsExportFormattingTests { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(identifier: "America/Los_Angeles")! + return calendar + }() + + @Test + func `CSV export uses raw precision and explicit unknown cost`() { + let intervals = self.intervals(days: 7) + let current = [self.fragment( + day: intervals.current.start, + model: "model,quoted", + input: 7_100_000_000, + costNanos: nil)] + let snapshot = self.build(current: current, previous: [], intervals: intervals, legacyTotal: 7_100_000_000) + let csv = CodexModelsCSVExporter.export(snapshot: snapshot) + + #expect(csv.contains("7100000000")) + #expect(!csv.contains("7.1B")) + #expect(csv.contains(",0,7100000000,")) + #expect(csv.contains("\"model,quoted\"")) + #expect(csv.contains(",unavailable,")) + } + + @Test + func `CSV export keeps previous unavailable pricing blank and auditable`() throws { + let intervals = self.intervals(days: 7) + let snapshot = self.build( + current: [self.fragment( + day: intervals.current.start, + model: "model-a", + input: 10, + costNanos: 1_000_000_000)], + previous: [self.fragment( + day: intervals.previous.start, + model: "model-a", + input: 8, + costNanos: nil)], + intervals: intervals, + legacyTotal: 10) + + let lines = CodexModelsCSVExporter.export(snapshot: snapshot).split(separator: "\n") + let header = try #require(lines.first).split(separator: ",", omittingEmptySubsequences: false).map(String.init) + let values = try #require(lines.dropFirst().first) + .split(separator: ",", omittingEmptySubsequences: false) + .map(String.init) + let fields = Dictionary(uniqueKeysWithValues: zip(header, values)) + + #expect(fields["reasoning_tokens"]?.isEmpty == true) + #expect(fields["previous_known_cost"]?.isEmpty == true) + #expect(fields["previous_cost_status"] == "unavailable") + #expect(fields["previous_cost_coverage"] == "0") + #expect(fields["previous_priced_tokens"] == "0") + #expect(fields["previous_unpriced_tokens"] == "8") + } + + private func build( + current: [CodexModelsUsageFragment], + previous: [CodexModelsUsageFragment], + intervals: (current: DateInterval, previous: DateInterval), + legacyTotal: Int64) -> CodexModelsAnalyticsSnapshot + { + CodexModelsAnalyticsBuilder().build(CodexModelsAnalyticsRequest( + source: CodexModelsAnalyticsSource(current: current, previous: previous), + scopeID: nil, + periods: CodexModelsAnalyticsPeriods(current: intervals.current, previous: intervals.previous), + revision: CodexModelsAnalyticsRevision(generatedAt: intervals.current.end, indexRevision: "fixture"), + legacy: CodexModelsLegacyBaseline( + totalTokens: legacyTotal, + modelIDs: Array(Set(current.map(\.rawModelID)))))) + } + + private func intervals(days: Int) -> (current: DateInterval, previous: DateInterval) { + let end = self.calendar.date(from: DateComponents(year: 2026, month: 7, day: 16))! + let currentStart = self.calendar.date(byAdding: .day, value: -days, to: end)! + let previousStart = self.calendar.date(byAdding: .day, value: -days, to: currentStart)! + return ( + DateInterval(start: currentStart, end: end), + DateInterval(start: previousStart, end: currentStart)) + } + + private func fragment( + day: Date, + model: String, + input: Int64, + costNanos: Int64?) -> CodexModelsUsageFragment + { + CodexModelsUsageFragment( + workspaceID: "workspace", + sessionID: "session", + day: day, + rawModelID: model, + inputTokens: input, + cachedInputTokens: min(3, input), + outputTokens: 0, + costNanos: costNanos) + } +} diff --git a/Tests/CodexBarTests/CodexModelsPerformanceTests.swift b/Tests/CodexBarTests/CodexModelsPerformanceTests.swift new file mode 100644 index 000000000..4aee7ba34 --- /dev/null +++ b/Tests/CodexBarTests/CodexModelsPerformanceTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +@Suite(.serialized) +struct CodexModelsPerformanceTests { + @Test + func `workspace scale snapshot build stays within end to end budget`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-workspace-performance-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let cacheRoot = root.appendingPathComponent("cache", isDirectory: true) + try FileManager.default.createDirectory(at: sessionsRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + + let options = CostUsageScanner.Options(codexSessionsRoot: sessionsRoot, cacheRoot: cacheRoot) + let end = Date(timeIntervalSince1970: 1_784_160_000) + let currentDay = end.addingTimeInterval(-24 * 60 * 60) + let previousDay = currentDay.addingTimeInterval(-30 * 24 * 60 * 60) + let currentDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: currentDay) + let previousDayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: previousDay) + var cache = CostUsageCache() + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + + for projectIndex in 0..<30 { + let project = root.appendingPathComponent("project-\(projectIndex)", isDirectory: true) + try FileManager.default.createDirectory( + at: project.appendingPathComponent(".git", isDirectory: true), + withIntermediateDirectories: true) + for sessionIndex in 0..<8 { + let sessionID = "project-\(projectIndex)-session-\(sessionIndex)" + cache.files[sessionsRoot.appendingPathComponent("\(sessionID).jsonl").path] = self.workspaceUsage( + sessionID: sessionID, + cwd: project.path, + currentDay: currentDay, + currentDayKey: currentDayKey, + previousDay: previousDay, + previousDayKey: previousDayKey, + seed: projectIndex * 8 + sessionIndex) + } + } + + func build() throws -> CodexLocalProjectUsageSnapshot { + try CodexLocalProjectUsageIndexer.buildSnapshotFromCostCache( + now: end, + historyDays: 30, + since: previousDay.addingTimeInterval(24 * 60 * 60), + until: end, + options: options, + cacheOverride: cache, + catalogOverride: .empty) + } + + _ = try build() + let clock = ContinuousClock() + var durations: [Duration] = [] + var snapshot: CodexLocalProjectUsageSnapshot? + for _ in 0..<3 { + let start = clock.now + snapshot = try build() + durations.append(start.duration(to: clock.now)) + } + + let measured = try #require(snapshot) + let median = durations.sorted()[1] + #expect(median < .seconds(1.5)) + #expect(measured.projects.count == 30) + #expect(measured.sessions.count == 240) + #expect((measured.total.totalTokens ?? 0) > 0) + #expect((measured.modelsAnalytics?.allWorkspaces.rows.count ?? 0) == 4) + #expect((measured.modelsAnalytics?.workspaces.count ?? 0) == 30) + #expect(measured.modelsAnalytics?.workspaces.values.allSatisfy { $0.rows.count == 4 } == true) + #expect(measured.modelsAnalytics?.allWorkspaces.comparison(.tokens) != .unavailable) + } + + // swiftlint:disable:next function_parameter_count + private func workspaceUsage( + sessionID: String, + cwd: String, + currentDay: Date, + currentDayKey: String, + previousDay: Date, + previousDayKey: String, + seed: Int) -> CostUsageFileUsage + { + let models = (0..<4).map { "model-\($0)" } + var days: [String: [String: [Int]]] = [:] + var rows: [CostUsageScanner.CodexUsageRow] = [] + for (index, model) in models.enumerated() { + let input = 100 + seed + index + let cached = input / 4 + let output = input / 5 + days[currentDayKey, default: [:]][model] = [input, cached, output] + days[previousDayKey, default: [:]][model] = [input - 1, cached, output] + rows.append(CostUsageScanner.CodexUsageRow( + day: currentDayKey, + model: model, + turnID: "current-\(index)", + eventIndex: index * 2, + timestampUnixMs: Int64(currentDay.timeIntervalSince1970 * 1000) + Int64(index), + input: input, + cached: cached, + output: output, + knownCostNanos: Int64(input * 1000), + unpricedTokens: 0, + pricingModel: model, + pricingMode: "standard")) + rows.append(CostUsageScanner.CodexUsageRow( + day: previousDayKey, + model: model, + turnID: "previous-\(index)", + eventIndex: index * 2 + 1, + timestampUnixMs: Int64(previousDay.timeIntervalSince1970 * 1000) + Int64(index), + input: input - 1, + cached: cached, + output: output, + knownCostNanos: Int64((input - 1) * 1000), + unpricedTokens: 0, + pricingModel: model, + pricingMode: "standard")) + } + return CostUsageFileUsage( + mtimeUnixMs: 1, + size: 1, + days: days, + parsedBytes: 1, + lastModel: models.last, + lastTotals: nil, + lastCountedTotals: nil, + lastRawTotalsBaseline: nil, + hasDivergentTotals: nil, + lastCodexTurnID: nil, + sessionId: sessionID, + forkedFromId: nil, + codexSession: CostUsageCodexSessionMetadata( + sessionId: sessionID, + forkedFromId: nil, + cwd: cwd, + title: nil, + startedAtUnixMs: Int64(previousDay.timeIntervalSince1970 * 1000), + latestActivityUnixMs: Int64(currentDay.timeIntervalSince1970 * 1000)), + codexCostNanos: nil, + codexPrioritySurchargeNanos: nil, + codexStandardCostNanos: nil, + codexPriorityCostNanos: nil, + codexStandardTokens: nil, + codexPriorityTokens: nil, + codexTurnIDs: nil, + codexRows: rows, + claudeRows: nil) + .refreshingCodexWorkspaceUsageFingerprint() + } +} diff --git a/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift b/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift index fc7f57eb4..91eeebb95 100644 --- a/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift +++ b/Tests/CodexBarTests/CodexSubagentAccountingIntegrationTests.swift @@ -507,7 +507,7 @@ struct CodexSubagentAccountingIntegrationTests { } @Test - func `appended ancestor metadata reclassifies the complete subagent rollout`() throws { + func `bounded append fallback reclassifies the complete subagent rollout`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -535,7 +535,9 @@ struct CodexSubagentAccountingIntegrationTests { var options = CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, claudeProjectsRoots: nil, - cacheRoot: env.cacheRoot) + cacheRoot: env.cacheRoot, + maxCodexSessionFileBytes: 4096, + maxCodexScanBytesPerRefresh: 4096) options.refreshMinIntervalSeconds = 0 let first = CostUsageScanner.loadDailyReport( provider: .codex, @@ -579,6 +581,7 @@ struct CodexSubagentAccountingIntegrationTests { #expect(usage.sessionId == "growing-child") #expect(usage.forkedFromId == "growing-parent") #expect(usage.forkBaselineDependencyKey == CostUsageScanner.codexForkDependencyNotRequiredKey) + #expect(usage.codexScanComplete == true) } private func turnContext(timestamp: String, model: String) -> [String: Any] { diff --git a/Tests/CodexBarTests/CookieHeaderCacheConditionalMutationTests.swift b/Tests/CodexBarTests/CookieHeaderCacheConditionalMutationTests.swift index dd9937107..ab07f9302 100644 --- a/Tests/CodexBarTests/CookieHeaderCacheConditionalMutationTests.swift +++ b/Tests/CodexBarTests/CookieHeaderCacheConditionalMutationTests.swift @@ -246,6 +246,66 @@ struct CookieHeaderCacheConditionalMutationTests { } } + @Test + func `cookie cache persists while Keychain access is disabled`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting("cookie-disabled-\(UUID().uuidString)") { + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor) + #expect(CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + expected: observation, + cookieHeader: "WorkosCursorSessionToken=disabled-keychain", + sourceLabel: "Safari")) + #expect(CookieHeaderCache.load(provider: .cursor)?.cookieHeader == + "WorkosCursorSessionToken=disabled-keychain") + } + } + } + } + + @Test + func `interactive mutation gate still blocks stores while Keychain access is disabled`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting("cookie-disabled-gate-\(UUID().uuidString)") { + let scope = CookieHeaderCache.Scope.providerVariant(UUID().uuidString) + CookieHeaderCache.store( + provider: .cursor, + scope: scope, + cookieHeader: "fixtureSession=original", + sourceLabel: "Original") + let observation = CookieHeaderCache.observeForConditionalMutation( + provider: .cursor, + scope: scope) + let gate = CookieHeaderCache.beginConditionalMutationGate(provider: .cursor, scope: scope) + + #expect(!CookieHeaderCache.storeIfObservationCurrent( + provider: .cursor, + scope: scope, + expected: observation, + cookieHeader: "fixtureSession=background-during-login", + sourceLabel: "Background")) + #expect(CookieHeaderCache.load(provider: .cursor, scope: scope)?.cookieHeader == + "fixtureSession=original") + CookieHeaderCache.endConditionalMutationGate(gate) + } + } + } + } + private func withIsolatedCookieCache(_ operation: () -> T) -> T { KeychainCacheStore.withServiceOverrideForTesting("cookie-conditional-\(UUID().uuidString)") { let legacyBase = FileManager.default.temporaryDirectory diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index 5eee6fdc9..ed6fa6816 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -3,6 +3,33 @@ import Testing @testable import CodexBarCore struct CostUsageCacheTests { + @Test + func `legacy codex token cache decodes without reasoning while current rows round trip it`() throws { + let legacyTotals = try JSONDecoder().decode( + CostUsageCodexTotals.self, + from: Data(#"{"input":10,"cached":2,"output":4}"#.utf8)) + #expect(legacyTotals.reasoning == nil) + + let legacyRow = try JSONDecoder().decode( + CostUsageScanner.CodexUsageRow.self, + from: Data(#"{"day":"2026-07-17","model":"gpt-5.5","input":10,"cached":2,"output":4}"#.utf8)) + #expect(legacyRow.reasoning == nil) + + let currentRow = CostUsageScanner.CodexUsageRow( + day: "2026-07-17", + model: "gpt-5.5", + turnID: "turn", + eventIndex: 1, + input: 10, + cached: 2, + output: 4, + reasoning: 3) + let roundTripped = try JSONDecoder().decode( + CostUsageScanner.CodexUsageRow.self, + from: JSONEncoder().encode(currentRow)) + #expect(roundTripped.reasoning == 3) + } + @Test func `cache file URL uses provider artifact versions`() { let root = URL(fileURLWithPath: "/tmp/codexbar-cost-cache", isDirectory: true) @@ -11,7 +38,7 @@ struct CostUsageCacheTests { let claudeURL = CostUsageCacheIO.cacheFileURL(provider: .claude, cacheRoot: root) let vertexURL = CostUsageCacheIO.cacheFileURL(provider: .vertexai, cacheRoot: root) - #expect(codexURL.lastPathComponent == "codex-v10.json") + #expect(codexURL.lastPathComponent == "codex-v11.json") #expect(claudeURL.lastPathComponent == "claude-v5.json") #expect(vertexURL.lastPathComponent == "vertexai-v5.json") } diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift index b5295f1cd..c202d2015 100644 --- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -285,6 +285,381 @@ struct CostUsagePerformanceGateTests { #expect(catalogLoadCount == 1) } + @Test + func `oversized codex session is fully accounted across bounded refreshes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let files = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 1, turnsPerFile: 8) + let oversizedURL = try #require(files.first) + let metadata = CostUsageScanner.codexFileMetadata(fileURL: oversizedURL) + + var baselineOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.root.appendingPathComponent("baseline-cache"), + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: 0, + maxCodexScanBytesPerRefresh: 0) + baselineOptions.refreshMinIntervalSeconds = 0 + let baseline = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: baselineOptions) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: max(1, metadata.size / 4), + maxCodexScanBytesPerRefresh: max(1, metadata.size / 4)) + options.refreshMinIntervalSeconds = 0 + + var offsets: [Int64] = [] + var report: CostUsageDailyReport? + for _ in 0..<12 { + report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let cached = try #require(CostUsageCacheIO.load( + provider: .codex, + cacheRoot: env.cacheRoot).files.values.first) + offsets.append(cached.parsedBytes ?? 0) + if cached.codexScanComplete == true { + break + } + } + + #expect(offsets.count > 1) + #expect(zip(offsets, offsets.dropFirst()).allSatisfy { $0 <= $1 }) + #expect(offsets.last == metadata.size) + #expect(report?.summary?.totalTokens == baseline.summary?.totalTokens) + #expect(report?.data.map(\.totalTokens) == baseline.data.map(\.totalTokens)) + } + + @Test + func `oversized codex progress survives cache round trip`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let files = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 1, turnsPerFile: 8) + let fileURL = try #require(files.first) + let metadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + let slice = max(1, metadata.size / 4) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: slice, + maxCodexScanBytesPerRefresh: slice) + options.refreshMinIntervalSeconds = 0 + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let cacheData = try Data(contentsOf: CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot)) + let roundTripped = try JSONDecoder().decode(CostUsageCache.self, from: cacheData) + let first = try #require(roundTripped.files.values.first) + let firstOffset = try #require(first.parsedBytes) + #expect(first.codexScanFileId == metadata.fileId) + #expect(first.codexScanTargetSize == metadata.size) + #expect(first.codexScanComplete == false) + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let second = try #require(CostUsageCacheIO.load( + provider: .codex, + cacheRoot: env.cacheRoot).files.values.first) + #expect((second.parsedBytes ?? 0) > firstOffset) + #expect(second.codexScanFileId == metadata.fileId) + #expect(second.codexScanTargetSize == metadata.size) + } + + @Test + func `oversized codex progress restarts when the target size changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let files = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 1, turnsPerFile: 8) + let fileURL = try #require(files.first) + let originalMetadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + let slice = max(1, originalMetadata.size / 4) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: slice, + maxCodexScanBytesPerRefresh: slice) + options.refreshMinIntervalSeconds = 0 + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let first = try #require(CostUsageCacheIO.load( + provider: .codex, + cacheRoot: env.cacheRoot).files.values.first) + #expect(first.parsedBytes == slice) + #expect(first.codexScanComplete == false) + + let original = try String(contentsOf: fileURL, encoding: .utf8) + try (original + String(repeating: " ", count: 512)).write(to: fileURL, atomically: false, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: day.addingTimeInterval(60)], + ofItemAtPath: fileURL.path) + let changedMetadata = CostUsageScanner.codexFileMetadata(fileURL: fileURL) + #expect(changedMetadata.size != originalMetadata.size) + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let restarted = try #require(CostUsageCacheIO.load( + provider: .codex, + cacheRoot: env.cacheRoot).files.values.first) + #expect(restarted.parsedBytes == slice) + #expect(restarted.codexScanTargetSize == changedMetadata.size) + #expect(restarted.codexScanFileId == changedMetadata.fileId) + #expect(restarted.codexScanComplete == false) + } + + @Test + func `single oversized jsonl record resumes without stalling`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso = env.isoString(for: day) + let model = "openai/gpt-5.2-codex" + let contents = [ + #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"long-record"}}"#, + #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"\#(model)"}}"#, + #"{"type":"event_msg","timestamp":"\#(iso)","payload":{"type":"token_count","padding":""# + + String(repeating: "x", count: 4096) + + + #"","info":{"total_token_usage":{"input_tokens":500,"cached_input_tokens":50,"# + + #""output_tokens":25},"model":"\#(model)"}}}"#, + ].joined(separator: "\n") + "\n" + _ = try env.writeCodexSessionFile(day: day, filename: "long-record.jsonl", contents: contents) + + var baselineOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.root.appendingPathComponent("baseline-cache"), + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: 0, + maxCodexScanBytesPerRefresh: 0) + baselineOptions.refreshMinIntervalSeconds = 0 + let baseline = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: baselineOptions) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: 256, + maxCodexScanBytesPerRefresh: 256) + options.refreshMinIntervalSeconds = 0 + + var offsets: [Int64] = [] + var sawPartialRecord = false + var report: CostUsageDailyReport? + for _ in 0..<24 { + report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let cached = try #require(CostUsageCacheIO.load( + provider: .codex, + cacheRoot: env.cacheRoot).files.values.first) + offsets.append(cached.parsedBytes ?? 0) + sawPartialRecord = sawPartialRecord || cached.codexJSONLResumeState != nil + if cached.codexScanComplete == true { + break + } + } + + #expect(sawPartialRecord) + #expect(zip(offsets, offsets.dropFirst()).allSatisfy { $0 < $1 }) + #expect(report?.summary?.totalTokens == baseline.summary?.totalTokens) + } + + @Test + func `codex scan budget never admits more than its remaining allowance`() { + let budget = CostUsageScanner.CodexScanBudget(maxFileBytes: 100, maxBytesPerRefresh: 150) + guard case let .allow(first) = budget.admit(workBytes: 1000) else { + Issue.record("expected first bounded admission") + return + } + #expect(first == 100) + budget.consume(workBytes: first) + + guard case let .allow(second) = budget.admit(workBytes: 1000) else { + Issue.record("expected remaining-budget admission") + return + } + #expect(second == 50) + budget.consume(workBytes: second) + guard case .deferBudget = budget.admit(workBytes: 1) else { + Issue.record("expected exhausted budget to defer") + return + } + #expect(budget.bytesConsumed == 150) + } + + @Test + func `per refresh byte budget defers later dirty files`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let urls = try Self.writeSyntheticCodexCorpus(env: env, day: day, files: 3, turnsPerFile: 3) + // Make deterministic order by newest-first: touch later files later. + let older = try #require(urls.first) + let middle = try #require(urls.dropFirst().first) + let newer = try #require(urls.last) + let olderDate = day.addingTimeInterval(-3600) + let middleDate = day.addingTimeInterval(-1800) + let newerDate = day + try FileManager.default.setAttributes([.modificationDate: olderDate], ofItemAtPath: older.path) + try FileManager.default.setAttributes([.modificationDate: middleDate], ofItemAtPath: middle.path) + try FileManager.default.setAttributes([.modificationDate: newerDate], ofItemAtPath: newer.path) + + let newestMeta = CostUsageScanner.codexFileMetadata(fileURL: newer) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: 64 * 1024 * 1024, + // Enough for the newest file only; remaining dirty files defer. + maxCodexScanBytesPerRefresh: max(1, newestMeta.size), + preferNewestCodexSessionsFirst: true) + options.refreshMinIntervalSeconds = 0 + + _ = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + let cachedNames = Set(cache.files.keys.map { URL(fileURLWithPath: $0).lastPathComponent }) + + #expect(cachedNames.contains(newer.lastPathComponent)) + #expect(!cachedNames.contains(older.lastPathComponent)) + } + + @Test + func `pending work bytes treat fork files as full rescan work`() { + let metadata = CostUsageScanner.CodexFileMetadata( + path: "/tmp/forked.jsonl", + mtimeUnixMs: 2, + size: 1000, + fileId: "1:2") + let cached = CostUsageFileUsage( + mtimeUnixMs: 1, + size: 400, + days: [:], + parsedBytes: 400, + forkedFromId: "parent-session") + #expect(CostUsageScanner.pendingCodexScanWorkBytes(metadata: metadata, cached: cached) == 1000) + } + + @Test + func `pending work bytes charge full file for forced rescans of unchanged cache entries`() { + let metadata = CostUsageScanner.CodexFileMetadata( + path: "/tmp/unchanged.jsonl", + mtimeUnixMs: 42, + size: 2_000_000_000, + fileId: "9:9") + let cached = CostUsageFileUsage( + mtimeUnixMs: 42, + size: 2_000_000_000, + days: ["2026-05-10": ["gpt-5.2-codex": [100, 20, 10]]], + parsedBytes: 2_000_000_000, + sessionId: "session-unchanged") + // keepCached can still reject this (forceFullScan / priority / fork dependency). + // Budget must not report zero pending work or multi-GB forced rescans slip through. + #expect(CostUsageScanner.pendingCodexScanWorkBytes(metadata: metadata, cached: cached) == 2_000_000_000) + } + + @Test + func `oversized parent baseline reads defer for small fork children`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let iso = env.isoString(for: day) + + // Parent is intentionally larger than the per-file budget. + let parentBody = ([ + #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"parent-giant"}}"#, + #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(iso)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":500,"cached_input_tokens":50,"output_tokens":25},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ] + Array(repeating: "x", count: 4096)).joined(separator: "\n") + "\n" + _ = try env.writeCodexSessionFile(day: day, filename: "parent-giant.jsonl", contents: parentBody) + + let childBody = [ + #"{"type":"session_meta","timestamp":"\#(iso)","payload":{"session_id":"child-small","# + + #""forked_from_id":"parent-giant"}}"#, + #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"openai/gpt-5.2-codex"}}"#, + #"{"type":"event_msg","timestamp":"\#(iso)","payload":{"type":"token_count","info":"# + + #"{"total_token_usage":{"input_tokens":600,"cached_input_tokens":60,"output_tokens":30},"# + + #""model":"openai/gpt-5.2-codex"}}}"#, + ].joined(separator: "\n") + "\n" + let childURL = try env.writeCodexSessionFile(day: day, filename: "child-small.jsonl", contents: childBody) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite"), + maxCodexSessionFileBytes: 1024, + maxCodexScanBytesPerRefresh: 64 * 1024 * 1024) + options.refreshMinIntervalSeconds = 0 + + let started = Date() + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let elapsed = Date().timeIntervalSince(started) + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + + #expect(elapsed < 2.0) + #expect(cache.files.keys.contains { URL(fileURLWithPath: $0).lastPathComponent == childURL.lastPathComponent }) + // Child still contributes local tokens while the parent baseline is unresolved. + #expect((report.summary?.totalTokens ?? 0) > 0) + } + private static func writeSyntheticCodexCorpus( env: CostUsageTestEnvironment, day: Date, diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index dd40efcbf..547cf7fc0 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -33,24 +33,30 @@ struct CostUsageScannerBreakdownTests { timestamp: String, model: String, total: Usage? = nil, - last: Usage? = nil) -> [String: Any] + last: Usage? = nil, + totalReasoning: Int? = nil, + lastReasoning: Int? = nil) -> [String: Any] { var info: [String: Any] = [ "model": model, ] if let total { - info["total_token_usage"] = [ + var usage: [String: Any] = [ "input_tokens": total.input, "cached_input_tokens": total.cached, "output_tokens": total.output, ] + usage["reasoning_output_tokens"] = totalReasoning + info["total_token_usage"] = usage } if let last { - info["last_token_usage"] = [ + var usage: [String: Any] = [ "input_tokens": last.input, "cached_input_tokens": last.cached, "output_tokens": last.output, ] + usage["reasoning_output_tokens"] = lastReasoning + info["last_token_usage"] = usage } return [ "type": "event_msg", @@ -1729,6 +1735,32 @@ struct CostUsageScannerBreakdownTests { #expect(parsed.days[dayKey]?[eventModel] == nil) } + @Test + func `codex foundation fallback preserves reasoning output tokens`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 18) + let timestamp = env.isoString(for: day) + // Escaping the root type key bypasses the byte-fast parser. The literal nested marker + // keeps the line eligible for the Foundation fallback prefilter. + let line = #"{"\u0074ype":"event_msg","marker":{"type":"event_msg"},"timestamp":""# + + timestamp + + #"","payload":{"type":"token_count","info":{"model":"gpt-5.5","last_token_usage":{"# + + #""input_tokens":10,"cached_input_tokens":2,"output_tokens":7,"reasoning_output_tokens":4}}}}"# + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "foundation-reasoning.jsonl", + contents: line) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows.first?.output == 7) + #expect(parsed.rows.first?.reasoning == 4) + } + @Test func `codex foundation fallback all blank context clears stale model`() throws { let env = try CostUsageTestEnvironment() @@ -1870,7 +1902,7 @@ struct CostUsageScannerBreakdownTests { #expect(first.data[0].totalTokens == 132) let newCacheURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) - #expect(newCacheURL.lastPathComponent == "codex-v10.json") + #expect(newCacheURL.lastPathComponent == "codex-v11.json") #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) #expect(FileManager.default.fileExists(atPath: oldCacheURL.path)) @@ -2214,6 +2246,99 @@ struct CostUsageScannerBreakdownTests { #expect(parsed.hasInterleavedTotals) } + @Test + func `codex resolved fork subtracts inherited reasoning baseline`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let timestamp = env.isoString(for: day) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "resolved-fork-reasoning.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": timestamp, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": timestamp, + ], + ], + self.codexTurnContext(timestamp: timestamp, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 110, cached: 0, output: 60), + totalReasoning: 24), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + inheritedTotalsResolver: { parentSessionID, _ in + #expect(parentSessionID == "parent-session") + return .resolved(.init(input: 100, cached: 0, output: 50, reasoning: 20)) + }) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows.first?.input == 10) + #expect(parsed.rows.first?.output == 10) + #expect(parsed.rows.first?.reasoning == 4) + } + + @Test + func `codex interleaved containment carries reasoning without adding it to output`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 6, day: 10) + let model = "openai/gpt-5.5" + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "interleaved-reasoning.jsonl", + contents: env.jsonl([ + self.codexTurnContext(timestamp: env.isoString(for: day), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 0, cached: 0, output: 100), + totalReasoning: 60), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 0, cached: 0, output: 50), + totalReasoning: 30), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 0, cached: 0, output: 105), + totalReasoning: 63), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 0, cached: 0, output: 55), + totalReasoning: 33), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(5)), + model: model, + total: (input: 0, cached: 0, output: 110), + totalReasoning: 66), + ])) + + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day)) + + #expect(parsed.rows.map(\.output) == [100, 5, 5]) + #expect(parsed.rows.compactMap(\.reasoning) == [60, 3, 3]) + #expect(parsed.rows.reduce(0) { $0 + $1.output } == 110) + #expect(parsed.rows.compactMap(\.reasoning).reduce(0, +) == 66) + #expect(parsed.hasInterleavedTotals) + } + @Test func `codex alternating repeated snapshots count zero`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageScannerTests.swift b/Tests/CodexBarTests/CostUsageScannerTests.swift index 392bfe7da..1a4ef4b4c 100644 --- a/Tests/CodexBarTests/CostUsageScannerTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerTests.swift @@ -453,6 +453,7 @@ struct CostUsageScannerTests { "input_tokens": 100, "cached_input_tokens": 20, "output_tokens": 10, + "reasoning_output_tokens": 4, ], "model": model, ], @@ -470,6 +471,8 @@ struct CostUsageScannerTests { #expect(first.lastTotals?.input == 100) #expect(first.lastTotals?.cached == 20) #expect(first.lastTotals?.output == 10) + #expect(first.lastTotals?.reasoning == 4) + #expect(first.rows.first?.reasoning == 4) let secondTokenCount: [String: Any] = [ "type": "event_msg", @@ -481,6 +484,7 @@ struct CostUsageScannerTests { "input_tokens": 160, "cached_input_tokens": 40, "output_tokens": 16, + "reasoning_output_tokens": 7, ], "model": model, ], @@ -501,6 +505,7 @@ struct CostUsageScannerTests { #expect(packed[0] == 60) #expect(packed[1] == 20) #expect(packed[2] == 6) + #expect(delta.rows.first?.reasoning == 3) } @Test diff --git a/Tests/CodexBarTests/CurlCaptureParserTests.swift b/Tests/CodexBarTests/CurlCaptureParserTests.swift new file mode 100644 index 000000000..b77cbe182 --- /dev/null +++ b/Tests/CodexBarTests/CurlCaptureParserTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CurlCaptureParserTests { + @Test + func `extracts request URL from standard DevTools capture`() { + let curl = "curl 'https://example.com/api/usage' -H 'Authorization: Bearer fake-token'" + #expect(CurlCaptureParser.requestURL(from: curl)?.absoluteString == "https://example.com/api/usage") + } + + @Test + func `extracts request URL from double quoted and bare captures`() { + #expect(CurlCaptureParser.requestURL(from: "curl \"https://example.com/double\"")?.path == "/double") + #expect(CurlCaptureParser.requestURL(from: "curl https://example.com/bare")?.path == "/bare") + } + + @Test + func `request URL returns nil for option first or malformed captures`() { + #expect(CurlCaptureParser.requestURL(from: "curl --location 'https://example.com'") == nil) + #expect(CurlCaptureParser.requestURL(from: "not-curl 'https://example.com'") == nil) + } + + @Test + func `extracts header fields from double quoted headers`() { + let curl = """ + curl 'https://example.com' --header "Authorization: Bearer fake-token" --header "Cookie: session=abc" + """ + let fields = CurlCaptureParser.headerFields(from: curl) + + #expect(fields.contains("Authorization: Bearer fake-token")) + #expect(fields.contains("Cookie: session=abc")) + } + + @Test + func `extracts header fields from single quoted -H flags`() { + let curl = "curl 'https://example.com' -H 'Authorization: Bearer fake-token'" + let fields = CurlCaptureParser.headerFields(from: curl) + + #expect(fields == ["Authorization: Bearer fake-token"]) + } + + @Test + func `header value lookup is case insensitive`() { + let fields = ["AUTHORIZATION: Bearer fake-token"] + #expect(CurlCaptureParser.headerValue(named: "authorization", in: fields) == "Bearer fake-token") + } + + @Test + func `header value lookup returns nil for missing header`() { + let fields = ["Cookie: session=abc"] + #expect(CurlCaptureParser.headerValue(named: "Authorization", in: fields) == nil) + } + + @Test + func `forwarded headers respects allowlist and drops unlisted headers`() { + let fields = [ + "Authorization: Bearer fake-token", + "Cookie: session=abc", + "X-Not-Allowed: nope", + ] + let allowlist = ["authorization": "Authorization", "cookie": "Cookie"] + let headers = CurlCaptureParser.forwardedHeaders(from: fields, allowlist: allowlist) + + #expect(headers["Authorization"] == "Bearer fake-token") + #expect(headers["Cookie"] == "session=abc") + #expect(headers["X-Not-Allowed"] == nil) + } + + @Test + func `forwarded headers can include authorization when allowlisted`() { + // ZoomMate's allowlist differs from T3 Chat's by deliberately including authorization (design D2). + let fields = ["authorization: Bearer fake-token"] + let allowlist = ["authorization": "Authorization"] + let headers = CurlCaptureParser.forwardedHeaders(from: fields, allowlist: allowlist) + + #expect(headers["Authorization"] == "Bearer fake-token") + } +} diff --git a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift index 458674272..4b272012b 100644 --- a/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift +++ b/Tests/CodexBarTests/DashboardSnapshotBuilderTests.swift @@ -166,6 +166,50 @@ struct DashboardSnapshotBuilderTests { #expect(provider["cost"] is NSNull) } + @Test + func `dashboard labels amp subscription pools as provider specific windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = AmpUsageSnapshot( + freeQuota: nil, + freeUsed: nil, + hourlyReplenishment: nil, + windowHours: nil, + updatedAt: now, + subscription: AmpSubscriptionUsage( + plan: "Megawatt", + otherUsedPercent: 3, + orbUsedPercent: 0, + resetsAt: now.addingTimeInterval(29 * 24 * 60 * 60), + resetDescription: "renews in 29 days")) + .toUsageSnapshot(now: now) + let payload = ProviderPayload( + provider: .amp, + account: nil, + version: nil, + source: "cli", + status: nil, + usage: usage, + credits: nil, + antigravityPlanInfo: nil, + openaiDashboard: nil, + error: nil) + + let snapshot = DashboardSnapshotBuilder.makeSnapshot( + usagePayloads: [payload], + costPayloads: [], + config: CodexBarConfig(providers: [ProviderConfig(id: .amp, enabled: true)]), + identityMode: .redacted, + generatedAt: now, + refreshInterval: 60, + codexBarVersion: nil) + let object = try self.jsonObject(snapshot) + let provider = try #require((object["providers"] as? [[String: Any]])?.first) + let windows = try #require(provider["windows"] as? [[String: Any]]) + + #expect(windows.map { $0["kind"] as? String } == ["other", "orb"]) + #expect(windows.map { $0["label"] as? String } == ["Other usage", "Orb usage"]) + } + @Test func `dashboard identity mode redacted hides local part but keeps domain`() throws { let snapshot = DashboardSnapshotBuilder.makeSnapshot( diff --git a/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_quota_config.json b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_quota_config.json new file mode 100644 index 000000000..ff9d973a8 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_quota_config.json @@ -0,0 +1,27 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "lite": { + "five_hour": 700, + "weekly": 2500 + }, + "standard": { + "five_hour": 3000, + "weekly": 10000 + }, + "pro": { + "five_hour": 12000, + "weekly": 40000 + } + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true +} diff --git a/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_subscription.json b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_subscription.json new file mode 100644 index 000000000..1f0c689c9 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_subscription.json @@ -0,0 +1,22 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "instanceCode": "sfm_tokenplansolo_public_cn-redacted", + "specCode": "pro", + "status": "VALID", + "remainingDays": 29, + "autoRenewFlag": false, + "startTime": 1784622404000, + "endTime": 1787328000000 + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true +} diff --git a/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_usage.json b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_usage.json new file mode 100644 index 000000000..a7f05b3d5 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/AlibabaTokenPlan/personal_usage.json @@ -0,0 +1,19 @@ +{ + "code": "200", + "data": { + "DataV2": { + "data": { + "success": true, + "data": { + "per5HourPercentage": 0.0009973083333333333, + "per5HourResetTime": 1784813220000, + "per1WeekPercentage": 0.0003014725, + "per1WeekResetTime": 1785234900000 + } + }, + "success": true, + "httpStatus": 200 + } + }, + "successResponse": true +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/flat_subscription_summary.json b/Tests/CodexBarTests/Fixtures/QwenCloud/flat_subscription_summary.json new file mode 100644 index 000000000..c771170b5 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/flat_subscription_summary.json @@ -0,0 +1,8 @@ +{ + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 2000, + "TotalSurplusValue": 1500 + } +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/forbidden.json b/Tests/CodexBarTests/Fixtures/QwenCloud/forbidden.json new file mode 100644 index 000000000..61fe920e0 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/forbidden.json @@ -0,0 +1,4 @@ +{ + "statusCode": 403, + "message": "Forbidden" +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/login_required.json b/Tests/CodexBarTests/Fixtures/QwenCloud/login_required.json new file mode 100644 index 000000000..41c636339 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/login_required.json @@ -0,0 +1,5 @@ +{ + "code": "ConsoleNeedLogin", + "message": "You need to log in.", + "successResponse": false +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/nested_equity_list.json b/Tests/CodexBarTests/Fixtures/QwenCloud/nested_equity_list.json new file mode 100644 index 000000000..cdf0d4895 --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/nested_equity_list.json @@ -0,0 +1,21 @@ +{ + "code": "200", + "successResponse": true, + "data": { + "TotalCount": 1, + "Data": [ + { + "InstanceCode": "qwen-token-plan", + "Status": "NORMAL", + "EndTime": 1.701e12, + "EquityList": [ + { + "Type": "CREDITS", + "CycleTotalValue": "1000", + "CycleSurplusValue": "875" + } + ] + } + ] + } +} diff --git a/Tests/CodexBarTests/Fixtures/QwenCloud/no_active_subscription.json b/Tests/CodexBarTests/Fixtures/QwenCloud/no_active_subscription.json new file mode 100644 index 000000000..1708cf20d --- /dev/null +++ b/Tests/CodexBarTests/Fixtures/QwenCloud/no_active_subscription.json @@ -0,0 +1,24 @@ +{ + "requestId": "00000000-0000-4000-8000-000000000001", + "code": "200", + "message": null, + "action": null, + "apiName": null, + "data": { + "RequestId": "00000000-0000-4000-8000-000000000001", + "Message": "Successful!", + "Data": { + "Uid": 7, + "TotalSurplusValue": "0", + "TotalCount": 0, + "TotalValue": "0", + "ProductCode": "sfm_tokenplansolo_public_intl" + }, + "Code": "Success", + "Success": true + }, + "httpStatusCode": "200", + "accessDeniedDetail": null, + "extendedCode": null, + "successResponse": true +} diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift index 73009016d..afe19ac25 100644 --- a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -54,15 +54,6 @@ struct GrokWebBillingFetcherTests { #expect(GrokProviderDescriptor.primaryLabel(resetsAt: nil) == nil) } - @Test - func `cli runtime does not import browser cookies unless explicitly enabled`() { - #expect(GrokWebFetchStrategy.canImportBrowserCookies(runtime: .app, env: [:])) - #expect(!GrokWebFetchStrategy.canImportBrowserCookies(runtime: .cli, env: [:])) - #expect(GrokWebFetchStrategy.canImportBrowserCookies( - runtime: .cli, - env: ["CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT": "1"])) - } - @Test func `web strategy tries later browser session when first cookie is stale`() async throws { let stale = try #require(Self.cookie(name: "sso", value: "stale")) @@ -999,6 +990,142 @@ extension GrokWebBillingFetcherTests { } } +// MARK: - Browser cookie cache + +extension GrokWebBillingFetcherTests { + @Test + func `cli runtime imports browser cookies only when explicitly enabled`() { + #expect(GrokWebFetchStrategy.canImportBrowserCookies(runtime: .app, env: [:])) + #expect(!GrokWebFetchStrategy.canImportBrowserCookies(runtime: .cli, env: [:])) + #expect(GrokWebFetchStrategy.canImportBrowserCookies( + runtime: .cli, + env: ["CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT": "1"])) + let userInitiated = ProviderInteractionContext.$current.withValue(.userInitiated) { + GrokWebFetchStrategy.canImportBrowserCookies(runtime: .cli, env: [:]) + } + #expect(userInitiated) + } + + @Test + func `web strategy is available from a cached browser session`() async { + let service = "com.steipete.codexbar.tests.grok-availability.\(UUID().uuidString)" + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + await KeychainCacheStore.withServiceOverrideForTesting(service) { + await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.store( + provider: .grok, + cookieHeader: "sso=cached-session", + sourceLabel: "Chrome") + let grokHome = FileManager.default.temporaryDirectory + .appendingPathComponent( + "CodexBar-GrokCachedAvailability-\(UUID().uuidString)", + isDirectory: true) + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: ["GROK_HOME": grokHome.path], + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + + #expect(await GrokWebFetchStrategy().isAvailable(context)) + } + } + } + + @Test + func `validated browser session stages a cache replacement for explicit refresh`() async throws { + let service = "com.steipete.codexbar.tests.grok-refresh.\(UUID().uuidString)" + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await KeychainCacheStore.withImplicitTestStoreForTesting { + let gate = try #require(CookieHeaderCache.beginRefreshReadSuppression(provider: .grok)) + defer { CookieHeaderCache.endRefreshReadSuppression(gate) } + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .grok) + let cookie = try #require(Self.cookie(name: "sso", value: "fresh-session")) + let sessions = [GrokCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome")] + + let result = try await GrokWebFetchStrategy.fetchFirstValidCookieSession( + sessions, + cacheObservation: observation) + { _, _ in + GrokWebBillingSnapshot(usedPercent: 7, resetsAt: nil) + } + + #expect(result.0.usedPercent == 7) + #expect(CookieHeaderCache.load(provider: .grok)?.cookieHeader == "sso=fresh-session") + let commit = CookieHeaderCache.commitRefreshReadSuppression(gate) + #expect(commit == CookieRefreshCommitSummary(stagedCount: 1, committedCount: 1, failedCount: 0)) + } + } + } + + @Test + func `cached cookie eviction is limited to authentication failures`() { + #expect(GrokWebFetchStrategy.isCookieAuthenticationFailure( + GrokWebBillingError.requestFailed(401, "expired"))) + #expect(GrokWebFetchStrategy.isCookieAuthenticationFailure( + GrokWebBillingError.rpcFailed(16, "unauthenticated"))) + #expect(!GrokWebFetchStrategy.isCookieAuthenticationFailure( + GrokWebBillingError.requestFailed(503, "unavailable"))) + #expect(!GrokWebFetchStrategy.isCookieAuthenticationFailure( + GrokWebBillingError.parseFailed)) + } + + @Test + func `cached team cookie surfaces trailing authentication failure`() async throws { + var attempts: [String] = [] + + await #expect { + _ = try await GrokWebFetchStrategy.fetchValidCookieHeader( + "sso=stale", + credentials: Self.credentials, + preferTrailingAuthenticationFailure: true) + { _, authCredentials in + if authCredentials != nil { + attempts.append("cookie+bearer") + throw GrokWebBillingError.teamUsageUnsupported + } + attempts.append("cookie-only") + throw GrokWebBillingError.requestFailed(401, "expired") + } + } throws: { error in + GrokWebFetchStrategy.isCookieAuthenticationFailure(error) + } + + #expect(attempts == ["cookie+bearer", "cookie-only"]) + } + + @Test + func `cached team cookie keeps team classification for non-auth trailing errors`() async { + await #expect { + _ = try await GrokWebFetchStrategy.fetchValidCookieHeader( + "sso=team-session", + credentials: Self.credentials, + preferTrailingAuthenticationFailure: true) + { _, authCredentials in + if authCredentials != nil { + throw GrokWebBillingError.teamUsageUnsupported + } + throw GrokWebBillingError.rpcFailed(9, "No personal team") + } + } throws: { error in + if case GrokWebBillingError.teamUsageUnsupported = error { + return true + } + return false + } + } +} + final class GrokWebBillingStubURLProtocol: URLProtocol { nonisolated(unsafe) static var requests: [URLRequest] = [] nonisolated(unsafe) static var requestBodies: [Data?] = [] diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 2cdb60018..ac1a291b8 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -217,6 +217,93 @@ struct KeychainCacheStoreTests { } } + @Test + func `disabled keychain access keeps an in process memory cache`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + let service = "disabled-memory-\(UUID().uuidString)" + let key = KeychainCacheStore.Key(category: "cookie", identifier: "cursor") + let entry = TestEntry(value: "WorkosCursorSessionToken=memory", storedAt: Date(timeIntervalSince1970: 3)) + + KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(KeychainCacheStore.storeResult(key: key, entry: entry)) + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case let .found(loaded): + #expect(loaded == entry) + case .missing, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected in-process memory cache entry") + } + #expect(KeychainCacheStore.keys(category: "cookie").contains(key)) + #expect(KeychainCacheStore.clearResult(key: key) == .removed) + switch KeychainCacheStore.load(key: key, as: TestEntry.self) { + case .missing: + break + case .found, .temporarilyUnavailable, .invalid: + #expect(Bool(false), "Expected memory cache entry to be cleared") + } + } + } + } + } + + @Test + func `disabled keychain access does not retain OAuth entries in memory`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + let service = "disabled-memory-oauth-\(UUID().uuidString)" + let key = KeychainCacheStore.Key.oauth(provider: .claude) + let entry = TestEntry(value: "synthetic-oauth-credential", storedAt: Date(timeIntervalSince1970: 4)) + + KeychainAccessGate.withTaskOverrideForTesting(true) { + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(!KeychainCacheStore.storeResult(key: key, entry: entry)) + #expect(self.loadedEntry(for: key) == nil) + #expect(KeychainCacheStore.keysResult(category: "oauth") == .failed) + } + } + } + } + + @Test + func `toggling Keychain access clears the disabled access memory cache`() { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + defer { + KeychainCacheStore.resetDisabledAccessMemoryStoreForTesting() + KeychainAccessGate.resetOverrideForTesting() + } + + let service = "disabled-memory-toggle-\(UUID().uuidString)" + let key = KeychainCacheStore.Key(category: "cookie", identifier: "cursor") + let entry = TestEntry(value: "WorkosCursorSessionToken=stale", storedAt: Date(timeIntervalSince1970: 4)) + + KeychainAccessGate.isDisabled = true + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(KeychainCacheStore.storeResult(key: key, entry: entry)) + #expect(self.loadedEntry(for: key) == entry) + } + } + + KeychainAccessGate.isDisabled = false + + KeychainCacheStore.withDisabledAccessMemoryStoreForTesting(true) { + KeychainCacheStore.withServiceOverrideForTesting(service) { + #expect(self.loadedEntry(for: key) == nil) + } + } + } + @Test func `cache ACL trusts bundled app and CLI helper`() { let root = URL(fileURLWithPath: "/Applications/QuotaKit.app") diff --git a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift index 9c527c531..436d9ea5c 100644 --- a/Tests/CodexBarTests/KeychainNoUIQueryTests.swift +++ b/Tests/CodexBarTests/KeychainNoUIQueryTests.swift @@ -92,6 +92,16 @@ struct KeychainNoUIQueryTests { #expect(KeychainTestSafety.shouldBlockRealKeychainAccess( processName: "swiftpm-testing-helper", environment: [KeychainTestSafety.allowAccessEnvironmentKey: "1"]) == false) + + #expect(KeychainTestSafety.shouldIsolateUserStateUnderTests( + processName: "swiftpm-testing-helper", + environment: [:])) + #expect(KeychainTestSafety.shouldIsolateUserStateUnderTests( + processName: "CodexBar", + environment: [KeychainAccessGate.disableAccessEnvironmentKey: "1"]) == false) + #expect(KeychainTestSafety.shouldIsolateUserStateUnderTests( + processName: "swiftpm-testing-helper", + environment: [KeychainTestSafety.allowAccessEnvironmentKey: "1"]) == false) } } #endif diff --git a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift index 38d10557c..3df321d88 100644 --- a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift +++ b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift @@ -185,7 +185,8 @@ struct KeychainPromptSafetyAuditTests { @Test func `alibaba safe storage password read uses no UI query`() throws { let importer = try Self.readRepoFile( - "Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanCookieImporter.swift") + "Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/" + + "AliyunOneConsoleChromiumCookieFallbackImporter.swift") let lines = importer.split(separator: "\n", omittingEmptySubsequences: false) guard let readIndex = lines.firstIndex(where: { $0.contains("SecItemCopyMatching") || $0.contains("KeychainSecurity.copyMatching") diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index fc56f41ab..b17a2d919 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -915,7 +915,7 @@ extension KiroStatusProbeTests { "signal.signal(signal.SIGHUP, signal.SIG_IGN); " "signal.signal(signal.SIGTERM, signal.SIG_IGN); " "handle=open(sys.argv[1], \\\"w\\\"); handle.write(str(os.getpid())); handle.close(); " - "os.write(int(sys.argv[2]), b\\\"1\\\"); os.close(int(sys.argv[2])); time.sleep(30)", + "os.write(int(sys.argv[2]), b\\\"1\\\"); os.close(int(sys.argv[2])); time.sleep(60)", os.environ["CODEXBAR_TEST_CHILD_PID_FILE"], str(ready_write), ], @@ -967,7 +967,10 @@ extension KiroStatusProbeTests { // Keep the optional context probe parseable so this timing check covers detached-child cleanup. #expect(snapshot.planName == "KIRO FREE") #expect(snapshot.creditsUsed == 12.50 && snapshot.contextUsage?.totalPercentUsed == 40) - #expect(elapsed < 8, "Kiro usage capture should return promptly even with a detached child, took \(elapsed)s") + // The detached child sleeps 60s; if the probe waited for it, elapsed would be >= 60. The generous + // ceiling only guards against that regression while tolerating heavily loaded CI runners + // (observed 12.6s on a shared GitHub macOS runner for what is normally a sub-second fetch). + #expect(elapsed < 20, "Kiro usage capture should return promptly even with a detached child, took \(elapsed)s") #expect(kill(childPID, 0) == -1) } diff --git a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift index 4502012e0..72e0ff8d9 100644 --- a/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift +++ b/Tests/CodexBarTests/LocalizationLanguageCatalogTests.swift @@ -539,6 +539,7 @@ struct LocalizationLanguageCatalogTests { "menu_bar_layout_token_account", "ory_session_…=…; csrftoken=…", "section_privacy", + "session_quota_estimate_value_format", "tab_menu", ] let unchanged = Set(english.keys.filter { italian[$0] == english[$0] }) diff --git a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift index 842a0c9a2..ccb566534 100644 --- a/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift +++ b/Tests/CodexBarTests/MenuBarMetricWindowResolverTests.swift @@ -218,6 +218,92 @@ struct MenuBarMetricWindowResolverTests { #expect(window?.resetDescription == "Team") } + @Test + func `automatic metric prioritizes exhausted litellm personal budget over active team budget`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Personal"), + secondary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: "Team"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .litellm, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetDescription == "Personal") + #expect(window?.usedPercent == 100) + } + + @Test + func `automatic metric prioritizes exhausted litellm team budget over active personal budget`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: "Personal"), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Team"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .litellm, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetDescription == "Team") + #expect(window?.usedPercent == 100) + } + + @Test + func `automatic metric prioritizes exhausted default secondary window`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: "Primary"), + secondary: RateWindow(usedPercent: 100, windowMinutes: 10080, resetsAt: nil, resetDescription: "Secondary"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .opencode, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetDescription == "Secondary") + #expect(window?.usedPercent == 100) + } + + @Test + func `automatic metric prioritizes exhausted default tertiary window`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: "Primary"), + secondary: RateWindow(usedPercent: 55, windowMinutes: 10080, resetsAt: nil, resetDescription: "Secondary"), + tertiary: RateWindow(usedPercent: 100, windowMinutes: 43200, resetsAt: nil, resetDescription: "Tertiary"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .opencode, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetDescription == "Tertiary") + #expect(window?.usedPercent == 100) + } + + @Test + func `automatic metric keeps default primary order when no window is exhausted`() { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: "Primary"), + secondary: RateWindow(usedPercent: 99, windowMinutes: 10080, resetsAt: nil, resetDescription: "Secondary"), + updatedAt: Date()) + + let window = MenuBarMetricWindowResolver.rateWindow( + preference: .automatic, + provider: .opencode, + snapshot: snapshot, + supportsAverage: false) + + #expect(window?.resetDescription == "Primary") + #expect(window?.usedPercent == 42) + } + @Test func `automatic metric uses constrained antigravity family lane`() { let snapshot = UsageSnapshot( diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index eec9f3308..e41890e11 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -765,48 +765,6 @@ struct MiniMaxMenuCardModelTests { } } -struct ClaudeMenuCardCostTests { - @Test - func `claude extra usage labels monthly denominator as cap`() throws { - let now = Date() - let metadata = try #require(ProviderDefaults.metadata[.claude]) - let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: nil, - tertiary: nil, - providerCost: ProviderCostSnapshot( - used: 5, - limit: 20, - currencyCode: "USD", - period: "Monthly cap", - updatedAt: now), - updatedAt: now, - identity: nil) - - let model = UsageMenuCardView.Model.make(.init( - provider: .claude, - metadata: metadata, - snapshot: snapshot, - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: nil, plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - #expect(model.providerCost?.spendLine == "Monthly cap: $5.00 / $20.00") - } -} - struct MenuCardModelTests { @Test func `builds metrics using remaining percent`() throws { @@ -930,66 +888,6 @@ struct MenuCardModelTests { #expect(model.planText == "Max") } - @Test - func `claude model includes routines bar when present`() throws { - let now = Date() - let identity = ProviderIdentitySnapshot( - providerID: .claude, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "Max") - let snapshot = UsageSnapshot( - primary: RateWindow( - usedPercent: 2, - windowMinutes: nil, - resetsAt: now.addingTimeInterval(3600), - resetDescription: nil), - secondary: RateWindow( - usedPercent: 8, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(7200), - resetDescription: nil), - tertiary: RateWindow( - usedPercent: 16, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(7800), - resetDescription: nil), - extraRateWindows: [ - NamedRateWindow( - id: "claude-routines", - title: "Daily Routines", - window: RateWindow( - usedPercent: 7, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(9200), - resetDescription: nil)), - ], - updatedAt: now, - identity: identity) - let metadata = try #require(ProviderDefaults.metadata[.claude]) - let model = UsageMenuCardView.Model.make(.init( - provider: .claude, - metadata: metadata, - snapshot: snapshot, - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: "codex@example.com", plan: "plus"), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Daily Routines"]) - } - @Test func `shows error subtitle when present`() throws { let metadata = try #require(ProviderDefaults.metadata[.codex]) diff --git a/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift index f5dc97691..e2ea1fb5e 100644 --- a/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift +++ b/Tests/CodexBarTests/MenuCardOverrideIsolationTests.swift @@ -170,10 +170,10 @@ struct MenuCardOverrideIsolationTests { sourceLabel: nil, cacheKey: "bob"))) let weeklyMetric = try #require(model.metrics.first { $0.id == "secondary" }) - let numberText = try #require(weeklyMetric.sessionEquivalentDetail?.numberText) + let leftText = try #require(weeklyMetric.sessionEquivalentDetail?.leftText) - #expect(numberText.hasPrefix("≈8 full 5h windows")) - #expect(!numberText.hasPrefix("≈2 full 5h windows")) + #expect(leftText == "Est. 8 session quotas left") + #expect(leftText != "Est. 2 session quotas left") } @Test diff --git a/Tests/CodexBarTests/OneConsoleJSONTests.swift b/Tests/CodexBarTests/OneConsoleJSONTests.swift new file mode 100644 index 000000000..008e9b7e4 --- /dev/null +++ b/Tests/CodexBarTests/OneConsoleJSONTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OneConsoleJSONTests { + @Test + func `string lookup honors caller key priority across the full tree`() { + let value: [String: Any] = [ + "token": "generic-token", + "data": [ + "secToken": "preferred-sec-token", + ], + ] + + let result = OneConsoleJSON.findFirstString( + forKeys: ["secToken", "token"], + in: value) + + #expect(result == "preferred-sec-token") + } + + @Test + func `lookup skips invalid values before a nested valid value`() { + let value: [String: Any] = [ + "count": "not-a-number", + "data": [ + "count": "42", + ], + ] + + #expect(OneConsoleJSON.findFirstInt(forKeys: ["count"], in: value) == 42) + } + + @Test + func `array lookup preserves key priority and skips invalid values`() { + let value: [String: Any] = [ + "fallback": [1], + "preferred": "not-an-array", + "data": [ + "preferred": [2, 3], + ], + ] + + let result = OneConsoleJSON.findFirstArray( + forKeys: ["preferred", "fallback"], + in: value) as? [Int] + + #expect(result == [2, 3]) + } + + @Test + func `date only string round trips`() throws { + let date = try #require(OneConsoleJSON.date("2026-07-28")) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + + #expect(formatter.string(from: date) == "2026-07-28") + } + + @Test + func `numeric zero date is treated as missing`() { + #expect(OneConsoleJSON.date(0) == nil) + } +} diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index e25548ebb..c93c67cab 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -43,6 +43,7 @@ struct ProviderIconResourcesTests { "wayfinder", "zenmux", "aiand", + "zoommate", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") diff --git a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift index c63835b4b..a743e20c9 100644 --- a/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift +++ b/Tests/CodexBarTests/ProviderPaceCapabilityTests.swift @@ -49,6 +49,23 @@ struct ProviderPaceCapabilityTests { } } + @Test + func `amp monthly pace is limited to subscription windows`() { + let now = Date(timeIntervalSince1970: 1_750_000_000) + let capability = AmpProviderDescriptor.descriptor.pace + let freeTier = Self.window( + minutes: 24 * 60, + resetsAt: now.addingTimeInterval(12 * 60 * 60)) + let subscription = Self.window( + minutes: Self.monthlyWindowSentinelMinutes, + resetsAt: now.addingTimeInterval(20 * 24 * 60 * 60)) + + #expect(!capability.supportsResetWindowPace(window: freeTier, now: now)) + #expect(!capability.usesInferredMonthlyDuration(window: freeTier)) + #expect(capability.supportsResetWindowPace(window: subscription, now: now)) + #expect(capability.usesInferredMonthlyDuration(window: subscription)) + } + private static func window(minutes: Int?, resetsAt: Date?) -> RateWindow { RateWindow( usedPercent: 50, @@ -79,7 +96,7 @@ struct ProviderPaceCapabilityTests { && timeUntilReset <= TimeInterval(windowMinutes) * 60 case .kimi: return window.windowMinutes == self.weeklyWindowMinutes - case .alibaba, .alibabatokenplan, .doubao, .opencodego: + case .alibaba, .alibabatokenplan, .amp, .doubao, .opencodego: return window.windowMinutes == self.monthlyWindowSentinelMinutes default: return false @@ -93,7 +110,7 @@ struct ProviderPaceCapabilityTests { switch provider { case .copilot: window.windowMinutes == nil - case .alibaba, .alibabatokenplan, .doubao, .opencodego: + case .alibaba, .alibabatokenplan, .amp, .doubao, .opencodego: window.windowMinutes == self.monthlyWindowSentinelMinutes default: false diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index dde17045a..077a7e9aa 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -7,6 +7,24 @@ import Testing @MainActor @Suite(.serialized) struct ProviderSettingsDescriptorTests { + @Test + func `provider settings refresh enables explicit browser retry`() async { + var observedInteraction: ProviderInteraction? + var browserRetryAllowed = false + + await KeychainAccessGate.withTaskOverrideForTesting(false) { + await BrowserCookieAccessGate.withDeniedBrowsersForTesting([.chrome]) { + await ProviderSettingsRefreshInteraction.perform { + observedInteraction = ProviderInteractionContext.current + browserRetryAllowed = BrowserCookieAccessGate.shouldAttempt(.chrome) + } + } + } + + #expect(observedInteraction == .userInitiated) + #expect(browserRetryAllowed) + } + @Test func `toggle I ds are unique across providers`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-unique") @@ -307,6 +325,25 @@ struct ProviderSettingsDescriptorTests { #expect(keychainPicker.isEnabled?() ?? true) } + @Test + func `claude daily routines toggle follows global optional usage setting`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-routines") + let context = fixture.settingsContext(provider: .claude) + let toggles = ClaudeProviderImplementation().settingsToggles(context: context) + let routinesToggle = try #require(toggles.first { + $0.id == "claude-daily-routines-usage-visible" + }) + + #expect(routinesToggle.binding.wrappedValue) + #expect(routinesToggle.isEnabled?() == true) + + routinesToggle.binding.wrappedValue = false + #expect(fixture.settings.claudeDailyRoutinesUsageVisible == false) + + fixture.settings.showOptionalCreditsAndExtraUsage = false + #expect(routinesToggle.isEnabled?() == false) + } + @Test func `claude single swap account toggle persists and follows integration visibility`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-swap-single") @@ -527,6 +564,20 @@ struct ProviderSettingsDescriptorTests { } extension ProviderSettingsDescriptorTests { + @Test + func `zoommate presentation surfaces web rather than an undetected version`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-zoommate-presentation") + let metadata = try #require(ProviderDescriptorRegistry.metadata[.zoommate]) + let context = fixture.presentationContext(provider: .zoommate, metadata: metadata) + + let detailLine = ZoomMateProviderImplementation() + .presentation(context: context) + .detailLine(context) + + // Web-cookie provider with versionDetector: nil — must not fall back to "zoommate not detected". + #expect(detailLine == "web") + } + @Test func `devin presentation follows store source label`() throws { let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-devin-presentation") @@ -551,8 +602,10 @@ extension ProviderSettingsDescriptorTests { let implementation = AlibabaTokenPlanProviderImplementation() let pickers = implementation.settingsPickers(context: context) let fields = implementation.settingsFields(context: context) + let regionPicker = try #require(pickers.first(where: { $0.id == "alibaba-token-plan-region" })) #expect(pickers.contains(where: { $0.id == "alibaba-token-plan-cookie-source" })) + #expect(Set(regionPicker.options.map(\.id)) == ["intl", "cn", "intl-personal", "cn-personal"]) #expect(fields.contains(where: { $0.id == "alibaba-token-plan-cookie" })) #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) } diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index c0412ab24..c98e3464c 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -182,6 +182,49 @@ struct ProvidersPaneCoverageTests { #expect(pane._test_menuCardModel(for: .copilot).metrics.map(\.title).contains(budgetTitle)) } + @Test + func `claude provider preview follows daily routines visibility`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-claude-routines-preview") + let store = Self.makeUsageStore(settings: settings) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 30, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 30, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil)), + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil)), + ], + updatedAt: now), + provider: .claude) + let pane = ProvidersPane(settings: settings, store: store) + + #expect(pane._test_menuCardModel(for: .claude).metrics.contains { $0.id == "claude-routines" }) + + settings.claudeDailyRoutinesUsageVisible = false + let hiddenModel = pane._test_menuCardModel(for: .claude) + #expect(!hiddenModel.metrics.contains { $0.id == "claude-routines" }) + #expect(hiddenModel.metrics.contains { $0.id == "claude-weekly-scoped-fable" }) + } + @Test func `codex provider preview follows spark visibility`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-codex-spark-preview") diff --git a/Tests/CodexBarTests/QuotaProviderListTests.swift b/Tests/CodexBarTests/QuotaProviderListTests.swift index b7aee59cc..014f0e5ea 100644 --- a/Tests/CodexBarTests/QuotaProviderListTests.swift +++ b/Tests/CodexBarTests/QuotaProviderListTests.swift @@ -15,7 +15,7 @@ import Testing @Suite("QuotaProviderList contract") struct QuotaProviderListTests { @Test - func `Provider list has expected count (56 after DeepInfra catch-up)`() { + func `Provider list has expected count (58 after Qwen Cloud and ZoomMate catch-up)`() { // 25 base → 27 in iOS 1.5.0 (Abacus + Mistral) → 38 in iOS 1.6.0 // (11 new from Mac v0.24+v0.25) → 40 in iOS 1.7.0 (Moonshot + // AWS Bedrock from upstream v0.26.0) → 45 in iOS 1.8.0 (Grok, @@ -25,10 +25,10 @@ struct QuotaProviderListTests { // (Sakana AI from upstream v0.36.x) → 50 after Qoder from the // same upstream line → 51 after Sub2API → 52 after ZenMux → // 54 after ClinePass and LongCat → 55 after Neuralwatt → 56 after - // DeepInfra. Must stay synced with the iOS-side test in + // DeepInfra, then 58 after Qwen Cloud and ZoomMate. Must stay synced with the iOS-side test in // CodexBarMobileTests/QuotaProviderListTests.swift. ai& is spend-only, // so it intentionally has no quota-transition subscriptions. - #expect(QuotaProviderList.providers.count == 56) + #expect(QuotaProviderList.providers.count == 58) } @Test @@ -110,7 +110,7 @@ struct QuotaProviderListTests { } @Test - func `iOS subscription count is 56 × 3 = 168 (depleted + restored + warning)`() { + func `iOS subscription count is 58 × 3 = 174 (depleted + restored + warning)`() { // 54 → 76 in iOS 1.5.x → 114 in iOS 1.6.0 (38 × 3 after adding // the "warning" state for pre-depletion threshold pushes) → // 120 in iOS 1.7.0 (40 × 3 after the v0.26 catch-up) → @@ -121,14 +121,14 @@ struct QuotaProviderListTests { // 147 in iOS 1.10.0 (49 × 3 after adding Sakana AI) → // 150 after adding Qoder → 153 after adding Sub2API → 156 after adding ZenMux → // 162 after adding ClinePass and LongCat, 165 after Neuralwatt, - // then 168 after DeepInfra. + // then 168 after DeepInfra and 174 after Qwen Cloud + ZoomMate. // If this fails, someone either dropped // a provider or changed the state // matrix without updating the iOS subscription setup in // `QuotaTransitionSubscriptions.makeConfigs()`. let states = ["depleted", "restored", "warning"] let subscriptionCount = QuotaProviderList.providers.count * states.count - #expect(subscriptionCount == 168) + #expect(subscriptionCount == 174) } // MARK: - iOS 1.7.0 / Mac 0.26.2 — v0.26.0 catch-up diff --git a/Tests/CodexBarTests/QuotaWarningPushFireTests.swift b/Tests/CodexBarTests/QuotaWarningPushFireTests.swift index 3a467b04c..06a84692b 100644 --- a/Tests/CodexBarTests/QuotaWarningPushFireTests.swift +++ b/Tests/CodexBarTests/QuotaWarningPushFireTests.swift @@ -11,15 +11,19 @@ import Testing struct QuotaWarningPushFireTests { @MainActor final class QuotaTransitionWriterSpy: QuotaTransitionWriting { + struct WarningWrite { + let provider: UsageProvider + let window: QuotaWarningWindow + let threshold: Int + let accountDisplayName: String? + let windowDisplayLabel: String? + } + private(set) var transitionWrites: [( transition: SessionQuotaTransition, provider: UsageProvider, accountDisplayName: String?)] = [] - private(set) var warningWrites: [( - provider: UsageProvider, - window: QuotaWarningWindow, - threshold: Int, - accountDisplayName: String?)] = [] + private(set) var warningWrites: [WarningWrite] = [] func write( transition: SessionQuotaTransition, @@ -37,9 +41,15 @@ struct QuotaWarningPushFireTests { threshold: Int, accountDisplayName: String?, accountDiscriminator _: String?, - windowID _: String?) + windowID _: String?, + windowDisplayLabel: String?) { - self.warningWrites.append((provider, window, threshold, accountDisplayName)) + self.warningWrites.append(WarningWrite( + provider: provider, + window: window, + threshold: threshold, + accountDisplayName: accountDisplayName, + windowDisplayLabel: windowDisplayLabel)) } } @@ -118,6 +128,37 @@ struct QuotaWarningPushFireTests { #expect(writer.warningWrites.first?.provider == .claude) #expect(writer.warningWrites.first?.window == .session) #expect(writer.warningWrites.first?.threshold == 50) + #expect(writer.warningWrites.first?.windowDisplayLabel == nil) + } + + @Test + func `custom window label propagates to the CKRecord writer`() { + let settings = self.makeSettings(suiteName: "QuotaWarningPushFireTests-window-label") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.notificationPushToiOSEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let writer = QuotaTransitionWriterSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier, + quotaTransitionWriter: writer) + + store.postQuotaWarning( + QuotaWarningEvent( + window: .weekly, + threshold: 50, + currentRemaining: 49, + windowID: "claude-weekly-scoped-fable", + windowDisplayLabel: "Fable only"), + provider: .claude) + + #expect(writer.warningWrites.count == 1) + #expect(writer.warningWrites.first?.window == .weekly) + #expect(writer.warningWrites.first?.windowDisplayLabel == "Fable only") } @Test diff --git a/Tests/CodexBarTests/QwenCloudProviderTests.swift b/Tests/CodexBarTests/QwenCloudProviderTests.swift new file mode 100644 index 000000000..517588fa0 --- /dev/null +++ b/Tests/CodexBarTests/QwenCloudProviderTests.swift @@ -0,0 +1,823 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private func qwenCloudFixture(_ name: String) throws -> Data { + try Data( + contentsOf: #require(Bundle.module.url( + forResource: name, + withExtension: "json", + subdirectory: "Fixtures/QwenCloud"))) +} + +struct QwenCloudSettingsReaderTests { + @Test + func `cookie reads from environment`() { + let cookie = QwenCloudSettingsReader.cookieHeader(environment: [ + QwenCloudSettingsReader.cookieHeaderKey: "\"login_aliyunid_ticket=ticket\"", + ]) + #expect(cookie == "login_aliyunid_ticket=ticket") + } + + @Test + func `quota URL infers HTTPS scheme`() { + let url = QwenCloudSettingsReader.quotaURL(environment: [ + QwenCloudSettingsReader.quotaURLKey: "quota.qwen-cloud.test/data/api.json", + ]) + + #expect(url?.scheme == "https") + #expect(url?.host == "quota.qwen-cloud.test") + } + + @Test + func `quota URL rejects non HTTPS schemes`() { + let httpURL = QwenCloudSettingsReader.quotaURL(environment: [ + QwenCloudSettingsReader.quotaURLKey: "http://quota.qwen-cloud.test/data/api.json", + ]) + + #expect(httpURL == nil) + } + + @Test + func `host override rejects non HTTPS schemes`() { + let httpHost = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "http://home.qwen-cloud.test", + ]) + let httpsHost = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "https://home.qwen-cloud.test", + ]) + + #expect(httpHost == nil) + #expect(httpsHost == "https://home.qwen-cloud.test") + } + + @Test + func `host override normalizes bare hosts to HTTPS`() { + let bareHost = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "home.qwen-cloud.test", + ]) + let bareHostWithPort = QwenCloudSettingsReader.hostOverride(environment: [ + QwenCloudSettingsReader.hostKey: "home.qwen-cloud.test:8443", + ]) + + #expect(bareHost == "https://home.qwen-cloud.test") + #expect(bareHostWithPort == "https://home.qwen-cloud.test:8443") + } + + @Test + func `bare host overrides build valid dashboard and quota URLs`() { + let environment = [QwenCloudSettingsReader.hostKey: "qwen-cloud.test"] + + let dashboard = QwenCloudUsageFetcher.dashboardURL(environment: environment) + #expect(dashboard.scheme == "https") + #expect(dashboard.host == "qwen-cloud.test") + #expect(dashboard.absoluteString.contains("/billing/subscription/token-plan-individual")) + + let quota = QwenCloudUsageFetcher.defaultQuotaURL(environment: environment) + #expect(quota.scheme == "https") + #expect(quota.host == "qwen-cloud.test") + #expect(quota.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + } + + @Test + func `default quota URL targets qwen data gateway usage API`() { + let url = QwenCloudUsageFetcher.defaultQuotaURL + #expect(url.host == "cs-data.qwencloud.com") + #expect(url.absoluteString.removingPercentEncoding?.contains("personal/api/v2/usage") == true) + #expect(url.absoluteString.contains("sfm_bailian")) + } + + @Test + func `dashboard URL targets the individual token plan page`() { + let url = QwenCloudUsageFetcher.dashboardURL + #expect(url.host == "home.qwencloud.com") + #expect(url.absoluteString.contains("/billing/subscription/token-plan-individual")) + } +} + +struct QwenCloudUsageSnapshotTests { + @Test + func `provider labels current quota windows`() { + let metadata = QwenCloudProviderDescriptor.descriptor.metadata + + #expect(metadata.sessionLabel == "5-hour") + #expect(metadata.weeklyLabel == "Weekly") + #if os(macOS) + #expect(metadata.browserCookieOrder == [.chrome]) + #expect(QwenCloudWebFetchStrategy.browserOrder == [.chrome]) + #else + #expect(metadata.browserCookieOrder == nil) + #endif + } + + @Test + func `maps used and total quota to primary window`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset = Date(timeIntervalSince1970: 1_700_100_000) + let snapshot = QwenCloudUsageSnapshot( + planName: "Token Plan", + usedQuota: 250, + totalQuota: 1000, + remainingQuota: nil, + resetsAt: reset, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetsAt == reset) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits used") + #expect(usage.loginMethod(for: .qwencloud) == "Token Plan") + } +} + +@Suite(.serialized) +struct QwenCloudUsageParsingTests { + @Test + func `parses current token plan 5 hour and weekly usage`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let innerJSON = """ + { + "code": 0, + "data": { + "per5HourPercentage": 0.03, + "per5HourResetTime": 1700003600000, + "per1WeekPercentage": 0.01, + "per1WeekResetTime": 1700086400000 + }, + "success": true + } + """ + let payload: [String: Any] = [ + "data": [ + "DataV2": [ + "data": innerJSON, + ], + ], + "httpStatusCode": 200, + ] + let data = try JSONSerialization.data(withJSONObject: payload) + + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 3) + #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_700_003_600)) + #expect(usage.secondary?.usedPercent == 1) + #expect(usage.secondary?.resetsAt == Date(timeIntervalSince1970: 1_700_086_400)) + } + + @Test + func `parses nested equity list token plan payload`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let data = try qwenCloudFixture("nested_equity_list") + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data, now: now) + + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.remainingQuota == 875) + #expect(snapshot.usedQuota == 125) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_701_000_000)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5) + } + + @Test + func `parses flat subscription summary payload`() throws { + let data = try qwenCloudFixture("flat_subscription_summary") + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + + #expect(snapshot.totalQuota == 2000) + #expect(snapshot.remainingQuota == 1500) + #expect(snapshot.usedQuota == 500) + } + + @Test + func `login payload maps to login required`() throws { + let data = try qwenCloudFixture("login_required") + #expect(throws: QwenCloudUsageError.loginRequired) { + try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + } + } + + @Test + func `forbidden payload maps to invalid credentials`() throws { + let data = try qwenCloudFixture("forbidden") + #expect(throws: QwenCloudUsageError.invalidCredentials) { + try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + } + } + + @Test + func `non json payload maps to parse failed`() { + #expect(throws: QwenCloudUsageError.parseFailed("Invalid JSON response")) { + try QwenCloudUsageFetcher.parseUsageSnapshot(from: Data("not-json".utf8)) + } + } + + /// Real-world response shape returned for an authenticated Qwen Cloud account + /// with no active individual token-plan subscription. Captured live against + /// `home.qwencloud.com` (requestId/Uid redacted) — the API returns HTTP 200 + /// with `TotalCount: 0` and zeroed quota fields rather than an error, so the + /// parser must not report a false subscription. Fixture: `Fixtures/QwenCloud/no_active_subscription.json`. + @Test + func `authenticated account with no active subscription reports no quota`() throws { + let data = try qwenCloudFixture("no_active_subscription") + let snapshot = try QwenCloudUsageFetcher.parseUsageSnapshot(from: data) + + // No subscription instance and zero total → no quota window to display. + #expect(snapshot.totalQuota == 0 || snapshot.totalQuota == nil) + #expect(snapshot.usedQuota == nil || snapshot.usedQuota == 0) + #expect(snapshot.remainingQuota == nil || snapshot.remainingQuota == 0) + // The primary rate window must not render a false "100% remaining" bar + // for a non-subscribed account. + #expect(snapshot.toUsageSnapshot().primary == nil) + } +} + +struct QwenCloudCookieHeaderTests { + @Test + func `builds URL scoped headers for API and dashboard`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".qwencloud.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".qwencloud.com"), + self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.aliyun.com"), + ] + + let headers = try #require(QwenCloudCookieHeader.headers(from: cookies)) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("login_current_pk=account")) + #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) + } + + @Test + func `cached headers preserve URL scoping`() throws { + let headers = QwenCloudCookieHeaders( + apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api", + dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard") + + let cached = try #require(QwenCloudCookieHeaders(qwenCloudCachedHeader: headers.cacheQwenCloudCookieHeader())) + + #expect(cached.apiCookieHeader.contains("api_only=api")) + #expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(cached.dashboardCookieHeader.contains("dashboard_only=dashboard")) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } +} + +@Suite(.serialized) +struct QwenCloudFetchTests { + @Test + func `fetches usage with dashboard sec token preflight`() async throws { + let usageAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" + let subscriptionAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription" + let quotaConfigAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config" + var requestedAPIs: [String] = [] + QwenCloudStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "qwen-cloud.test", + url.path == "/billing/subscription/token-plan-individual", + request.httpMethod == "GET" + { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + + if url.host == "qwen-cloud.test", request.httpMethod == "POST" { + let body = Self.requestBodyString(from: request) + let form = try #require(URLComponents(string: "?\(body)")) + let formValues = Dictionary(uniqueKeysWithValues: form.queryItems?.compactMap { item in + item.value.map { (item.name, $0) } + } ?? []) + #expect(formValues["sec_token"] == "qwen-html-token") + #expect(formValues["product"] == "sfm_bailian") + let paramsData = try #require(formValues["params"]?.data(using: .utf8)) + let params = try #require(JSONSerialization.jsonObject(with: paramsData) as? [String: Any]) + let api = try #require(params["Api"] as? String) + let data = try #require(params["Data"] as? [String: Any]) + let cornerstone = try #require(data["cornerstoneParam"] as? [String: Any]) + #expect(cornerstone["consoleSite"] as? String == "QWENCLOUD") + requestedAPIs.append(api) + + let json: String + switch api { + case usageAPI: + json = """ + { + "data": { + "per5HourPercentage": 0.03, + "per5HourResetTime": 1700003600000, + "per1WeekPercentage": 0.01, + "per1WeekResetTime": 1700086400000 + } + } + """ + case subscriptionAPI: + #expect(data["commodityCode"] as? String == "sfm_tokenplansolo_public_intl") + json = #"{"data":{"specCode":"standard","status":"VALID"}}"# + case quotaConfigAPI: + json = """ + { + "data": { + "lite": { "five_hour": 1000, "weekly": 10000 }, + "standard": { "five_hour": 5000, "weekly": 50000 }, + "pro": { "five_hour": 10000, "weekly": 100000 } + } + } + """ + default: + throw URLError(.unsupportedURL) + } + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + defer { + QwenCloudStubURLProtocol.handler = nil + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [QwenCloudStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let transport = ProviderHTTPClient(session: session) + let snapshot = try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + + #expect(requestedAPIs == [usageAPI, subscriptionAPI, quotaConfigAPI]) + #expect(snapshot.planName == "Standard") + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 3) + #expect(snapshot.toUsageSnapshot().primary?.resetDescription == "150 / 5,000 credits used") + #expect(snapshot.toUsageSnapshot().secondary?.usedPercent == 1) + #expect(snapshot.toUsageSnapshot().secondary?.resetDescription == "500 / 50,000 credits used") + } + + @Test + func `login page csrf token maps to login required before API requests`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/billing/subscription/token-plan-individual" { + return Self.makeTransportResponse( + url: url, + body: """ + + Sign in + + + """, + statusCode: 200) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse(url: url, body: "{}", statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + await #expect(throws: QwenCloudUsageError.loginRequired) { + try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=expired", + dashboardCookieHeader: "login_aliyunid_ticket=expired", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + } + } + + @Test + func `dashboard timeout maps to network error when token fallbacks are empty`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/billing/subscription/token-plan-individual" { + throw URLError(.timedOut) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse(url: url, body: "{}", statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + let error = await #expect(throws: QwenCloudUsageError.self) { + try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + } + guard case .networkError = error else { + Issue.record("Expected networkError, got \(String(describing: error))") + return + } + } + + @Test + func `dashboard server failure maps to network error when token fallbacks are empty`() async throws { + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/billing/subscription/token-plan-individual" { + return Self.makeTransportResponse(url: url, body: "Unavailable", statusCode: 503) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse(url: url, body: "{}", statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + let error = await #expect(throws: QwenCloudUsageError.self) { + try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [QwenCloudSettingsReader.hostKey: "https://qwen-cloud.test"], + transport: transport) + } + guard case .networkError = error else { + Issue.record("Expected networkError, got \(String(describing: error))") + return + } + } + + @Test + func `dashboard failure still allows sec token cookie fallback`() async throws { + let dashboardURL = try #require(URL(string: "https://qwen-cloud.test/dashboard")) + let resolver = OneConsoleSECTokenResolver(configuration: .init( + dashboardURL: { _ in dashboardURL }, + userInfoPath: "/tool/user/info.json", + isLoginPage: { _ in false })) + let transport = ProviderHTTPTransportHandler { _ in + throw URLError(.timedOut) + } + + let resolved = try await resolver.resolve( + cookieHeader: "login_aliyunid_ticket=ticket; sec_token=cookie-token", + environment: [:], + transport: transport) + + #expect(resolved.value == "cookie-token") + #expect(resolved.source == .cookie) + } + + @Test + func `user info resolver preserves sec token key priority`() async throws { + let dashboardURL = try #require(URL(string: "https://qwen-cloud.test/dashboard")) + let resolver = OneConsoleSECTokenResolver(configuration: .init( + dashboardURL: { _ in dashboardURL }, + userInfoPath: "/tool/user/info.json", + isLoginPage: { _ in false })) + let transport = ProviderHTTPTransportHandler { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/dashboard" { + return Self.makeTransportResponse(url: url, body: "", statusCode: 200) + } + if url.path == "/tool/user/info.json" { + return Self.makeTransportResponse( + url: url, + body: """ + { + "token": "generic-token", + "data": { + "secToken": "", + "nested": { "secToken": "preferred-sec-token" } + } + } + """, + statusCode: 200) + } + throw URLError(.unsupportedURL) + } + + let resolved = try await resolver.resolve( + cookieHeader: "login_aliyunid_ticket=ticket", + environment: [:], + transport: transport) + + #expect(resolved.value == "preferred-sec-token") + #expect(resolved.source == .userInfo) + } + + @Test + func `redirect routing strips cross origin credentials and blocks preserved bodies`() throws { + let apiURL = try #require(URL(string: "https://cs-data.qwencloud.com/data/api.json")) + let dashboardRedirect = try #require(URL(string: "https://home.qwencloud.com/redirected")) + let crossHostURL = try #require(URL(string: "https://signin.aliyun.com/login")) + let insecureURL = try #require(URL(string: "http://home.qwencloud.com/login")) + let untrustedPortURL = try #require(URL(string: "https://home.qwencloud.com:8443/login")) + let redirectResponse = try #require(HTTPURLResponse( + url: apiURL, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: ["Location": dashboardRedirect.absoluteString])) + let routing = OneConsoleCookieRouting( + apiURL: apiURL, + dashboardURL: dashboardRedirect, + apiCookieHeader: "api_cookie=value", + dashboardCookieHeader: "dashboard_cookie=value") + + var apiRequest = URLRequest(url: apiURL) + apiRequest.httpMethod = "POST" + apiRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + apiRequest.setValue("api_cookie=value", forHTTPHeaderField: "Cookie") + apiRequest.setValue("Bearer secret", forHTTPHeaderField: "Authorization") + apiRequest.setValue("Basic proxy-secret", forHTTPHeaderField: "Proxy-Authorization") + apiRequest.setValue("api-key-secret", forHTTPHeaderField: "x-api-key") + apiRequest.setValue("csrf-secret", forHTTPHeaderField: "x-csrf-token") + apiRequest.setValue("xsrf-secret", forHTTPHeaderField: "x-xsrf-token") + apiRequest.httpBody = Data("sec_token=secret".utf8) + + var crossHostRedirect = URLRequest(url: crossHostURL) + crossHostRedirect.httpMethod = "GET" + crossHostRedirect.setValue("old=value", forHTTPHeaderField: "Cookie") + crossHostRedirect.setValue("Bearer secret", forHTTPHeaderField: "Authorization") + crossHostRedirect.setValue("Basic proxy-secret", forHTTPHeaderField: "Proxy-Authorization") + crossHostRedirect.setValue("api-key-secret", forHTTPHeaderField: "x-api-key") + crossHostRedirect.setValue("csrf-secret", forHTTPHeaderField: "x-csrf-token") + crossHostRedirect.setValue("xsrf-secret", forHTTPHeaderField: "x-xsrf-token") + let routedCrossHost = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: crossHostRedirect)) + #expect(routedCrossHost.value(forHTTPHeaderField: "Cookie") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "Authorization") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "Proxy-Authorization") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "x-api-key") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "x-csrf-token") == nil) + #expect(routedCrossHost.value(forHTTPHeaderField: "x-xsrf-token") == nil) + #expect(routedCrossHost.httpBody == nil) + + var sameHostRedirect = URLRequest(url: dashboardRedirect) + sameHostRedirect.httpMethod = "GET" + sameHostRedirect.setValue("old=value", forHTTPHeaderField: "Cookie") + sameHostRedirect.setValue("csrf-secret", forHTTPHeaderField: "x-csrf-token") + let routedDashboard = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: sameHostRedirect)) + #expect(routedDashboard.value(forHTTPHeaderField: "Cookie") == "dashboard_cookie=value") + #expect(routedDashboard.value(forHTTPHeaderField: "x-csrf-token") == nil) + #expect(routedDashboard.httpBody == nil) + + for statusCode in [307, 308] { + let preservedRedirectResponse = try #require(HTTPURLResponse( + url: apiURL, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Location": crossHostURL.absoluteString])) + + var apiRedirect = URLRequest(url: apiURL) + apiRedirect.httpMethod = "POST" + apiRedirect.httpBody = Data("sec_token=secret".utf8) + let routedAPI = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: preservedRedirectResponse, + to: apiRedirect)) + #expect(routedAPI.value(forHTTPHeaderField: "Cookie") == "api_cookie=value") + #expect(routedAPI.httpBody == Data("sec_token=secret".utf8)) + + var externalPreservedPOST = URLRequest(url: crossHostURL) + externalPreservedPOST.httpMethod = "POST" + externalPreservedPOST.httpBody = Data("sec_token=secret".utf8) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: preservedRedirectResponse, + to: externalPreservedPOST) == nil) + + var dashboardPreservedPOST = URLRequest(url: dashboardRedirect) + dashboardPreservedPOST.httpMethod = "POST" + dashboardPreservedPOST.httpBody = Data("sec_token=secret".utf8) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: preservedRedirectResponse, + to: dashboardPreservedPOST) == nil) + } + + var untrustedPortRedirect = URLRequest(url: untrustedPortURL) + untrustedPortRedirect.setValue("old=value", forHTTPHeaderField: "Cookie") + let routedUntrustedPort = try #require(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: untrustedPortRedirect)) + #expect(routedUntrustedPort.value(forHTTPHeaderField: "Cookie") == nil) + + let insecureRedirect = URLRequest(url: insecureURL) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: redirectResponse, + to: insecureRedirect) == nil) + + let notModified = try #require(HTTPURLResponse( + url: apiURL, + statusCode: 304, + httpVersion: "HTTP/1.1", + headerFields: nil)) + #expect(routing.redirectedRequest( + forRedirectFrom: apiRequest, + response: notModified, + to: crossHostRedirect) == nil) + } + + private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func makeTransportResponse( + url: URL, + body: String, + statusCode: Int) -> (Data, URLResponse) + { + let (response, data) = self.makeResponse(url: url, body: body, statusCode: statusCode) + return (data, response) + } + + private static func requestBodyString(from request: URLRequest) -> String { + if let data = request.httpBody { + return String(data: data, encoding: .utf8) ?? "" + } + if let stream = request.httpBodyStream { + stream.open() + defer { + stream.close() + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + return String(data: data, encoding: .utf8) ?? "" + } + return "" + } +} + +struct QwenCloudCookieImportValidationTests { + #if os(macOS) + @Test + func `accepts passport ticket sessions`() { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".alibabacloud.com"), + ] + #expect(QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `accepts qwen scoped sso sessions`() { + let cookies = [ + self.cookie(name: "qwen_sso_ticket", value: "sso-ticket", domain: ".qwencloud.com"), + ] + #expect(QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `accepts current qwen cloud login tickets`() { + let cookies = [ + self.cookie(name: "login_qwencloud_ticket", value: "ticket", domain: ".qwencloud.com"), + ] + #expect(QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `rejects locale and account cookies without a login ticket`() { + // A browser profile that merely visited qwencloud.com carries locale + // preferences, account-id markers, and CSRF cookies while logged out; + // none of them prove an authenticated session. + let cookies = [ + self.cookie(name: "locale_pref", value: "en-US", domain: ".qwencloud.com"), + self.cookie(name: "login_aliyunid_pk", value: "1234567890", domain: ".qwencloud.com"), + self.cookie(name: "login_current_pk", value: "1234567890", domain: ".home.qwencloud.com"), + self.cookie(name: "sec_token", value: "csrf-token", domain: ".home.qwencloud.com"), + ] + #expect(!QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + @Test + func `rejects sessions without recognized cookies`() { + let cookies = [ + self.cookie(name: "unrelated", value: "x", domain: ".example.com"), + ] + #expect(!QwenCloudCookieImport.isAuthenticatedSession(cookies: cookies)) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } + #endif +} + +final class QwenCloudStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return host == "home.qwencloud.com" || host == "qwen-cloud.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +/// Opt-in live smoke test against the real Qwen Cloud console. +/// +/// Disabled by default so CI / `make test` never touch the network or Keychain. +/// To run it against your own account: +/// 1. Copy a `Cookie:` header from `https://home.qwencloud.com/billing/subscription/token-plan-individual` +/// 2. Temporarily remove the `.disabled(...)` trait below (keep the guard) +/// 3. QWEN_CLOUD_LIVE_TEST=1 QWEN_CLOUD_COOKIE='login_aliyunid_ticket=...; ...' \ +/// swift test --filter QwenCloudLiveSmokeTests +@Suite(.serialized) +struct QwenCloudLiveSmokeTests { + @Test(.disabled("Set QWEN_CLOUD_LIVE_TEST=1 and QWEN_CLOUD_COOKIE to run live Qwen Cloud checks.")) + func `live token plan usage resolves`() async throws { + let environment = ProcessInfo.processInfo.environment + guard environment["QWEN_CLOUD_LIVE_TEST"] == "1" else { return } + guard let cookie = environment["QWEN_CLOUD_COOKIE"], + !cookie.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + Issue.record("QWEN_CLOUD_COOKIE is not set; paste a Cookie header from the Qwen Cloud billing page.") + return + } + + let snapshot = try await QwenCloudUsageFetcher.fetchUsage( + apiCookieHeader: cookie, + environment: environment) + + func describe(_ value: (some Any)?) -> String { + value.map { "\($0)" } ?? "" + } + + print( + """ + [qwen-cloud-live] plan=\(describe(snapshot.planName)) \ + used=\(describe(snapshot.usedQuota)) \ + total=\(describe(snapshot.totalQuota)) \ + remaining=\(describe(snapshot.remainingQuota)) \ + resetsAt=\(describe(snapshot.resetsAt)) + """) + + // An authenticated account must not be treated as logged out. + #expect(snapshot.updatedAt > Date(timeIntervalSince1970: 0)) + // A subscribed account reports a total; a free/empty account may legitimately be nil. + if snapshot.totalQuota == nil { + print("[qwen-cloud-live] No active token-plan total reported (account may have no subscription).") + } else { + #expect((snapshot.totalQuota ?? 0) >= 0) + } + } +} diff --git a/Tests/CodexBarTests/ResetCountdownDayRolloverTests.swift b/Tests/CodexBarTests/ResetCountdownDayRolloverTests.swift new file mode 100644 index 000000000..e0c9a796e --- /dev/null +++ b/Tests/CodexBarTests/ResetCountdownDayRolloverTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ResetCountdownDayRolloverTests { + private static let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func at(hoursFromNow hours: Double) -> Date { + Self.now.addingTimeInterval(hours * 3600) + } + + @Test + func `Windsurf web reset at exactly 24h rolls over to a day`() { + // Was "Resets in 24h 0m"; the day form must be reachable at the 24h boundary. + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } + + @Test + func `Windsurf web reset above 24h shows day and hour`() { + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 25), now: Self.now) + == "Resets in 1d 1h") + } + + @Test + func `Windsurf web reset below 24h stays in hours`() { + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 23), now: Self.now) + == "Resets in 23h 0m") + } + + @Test + func `Windsurf cached reset at exactly 24h rolls over to a day`() { + #expect( + WindsurfCachedPlanInfo.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } + + @Test + func `Zed cycle at exactly 24h rolls over to a day`() { + // Was "Cycle ends in 24h 0m". + #expect( + ZedUsageSnapshot.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Cycle ends in 1d 0h") + } + + @Test + func `JetBrains reset at exactly 24h rolls over to a day`() { + #expect( + JetBrainsStatusSnapshot.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } +} diff --git a/Tests/CodexBarTests/SessionEquivalentForecastIdleTests.swift b/Tests/CodexBarTests/SessionEquivalentForecastIdleTests.swift new file mode 100644 index 000000000..6dc3b6b92 --- /dev/null +++ b/Tests/CodexBarTests/SessionEquivalentForecastIdleTests.swift @@ -0,0 +1,71 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension SessionEquivalentForecastTests { + @MainActor + @Test + func `usage store preserves learned forecast while current session is idle`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let fixture = Self.historyFixture(burns: [4, 8, 6, 10]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: fixture.histories) + let now = fixture.currentSessionReset.addingTimeInterval(-3600) + let session = RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 60, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + + let forecast = try #require(store.sessionEquivalentForecast( + provider: .claude, + sessionWindow: session, + weeklyWindow: weekly, + now: now)) + + #expect(forecast.sampleCount == 4) + #expect(forecast.estimatedWindowsToExhaustWeekly > 0) + } + + @MainActor + @Test + func `idle forecast cache refreshes after a historical session completes`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let fixture = Self.historyFixture(burns: [5, 5, 5]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: fixture.histories) + store.planUtilizationHistoryRevision = 1 + let thirdReset = fixture.currentSessionReset.addingTimeInterval(-5 * 3600) + let beforeReset = thirdReset.addingTimeInterval(-61) + let afterReset = thirdReset.addingTimeInterval(61) + let session = RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + let weekly = RateWindow( + usedPercent: 15, + windowMinutes: 10080, + resetsAt: afterReset.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + + #expect(store.sessionEquivalentForecast( + provider: .claude, + sessionWindow: session, + weeklyWindow: weekly, + now: beforeReset) == nil) + #expect(store._sessionEquivalentHistoryScanCountForTesting == 1) + + let forecast = try #require(store.sessionEquivalentForecast( + provider: .claude, + sessionWindow: session, + weeklyWindow: weekly, + now: afterReset)) + #expect(forecast.sampleCount == 3) + #expect(store._sessionEquivalentHistoryScanCountForTesting == 2) + } +} diff --git a/Tests/CodexBarTests/SessionEquivalentForecastTests.swift b/Tests/CodexBarTests/SessionEquivalentForecastTests.swift index 293942785..f2bcd14f0 100644 --- a/Tests/CodexBarTests/SessionEquivalentForecastTests.swift +++ b/Tests/CodexBarTests/SessionEquivalentForecastTests.swift @@ -155,8 +155,8 @@ struct SessionEquivalentForecastTests { hidePersonalInfo: true) let redactedDetail = try #require(redacted.first?.sessionEquivalentDetail) - #expect(redactedDetail.verdictText == detail.verdictText) - #expect(redactedDetail.numberText == detail.numberText) + #expect(redactedDetail.leftText == detail.leftText) + #expect(redactedDetail.rightText == detail.rightText) } @Test @@ -244,7 +244,7 @@ struct SessionEquivalentForecastTests { } @Test - func `formats verdict first and number second`() { + func `formats session quota estimate and reset windows`() { let early = SessionEquivalentForecast( estimatedWindowsToExhaustWeekly: 4, windowsUntilReset: 9, @@ -261,22 +261,22 @@ struct SessionEquivalentForecastTests { let earlyText = UsagePaceText.sessionEquivalentDetail(forecast: early) let strandedText = UsagePaceText.sessionEquivalentDetail(forecast: stranded) - #expect(earlyText.verdictText == "Weekly can run out ≈5 windows early") - #expect(earlyText.numberText == "≈4 full 5h windows of weekly left · 9 windows until reset") - #expect(earlyText.verdictAccessibilityLabel == "Estimated: Weekly can run out ≈5 windows early") - #expect(strandedText.verdictText == "Weekly cannot run out before reset at this pace") + #expect(earlyText.leftText == "Est. 4 session quotas left") + #expect(earlyText.rightText == "9 windows until reset") + #expect(earlyText.accessibilityLabel == "Est. 4 session quotas left · 9 windows until reset") + #expect(strandedText.leftText == "Est. 10 session quotas left") } @Test - func `formats equality as lasting to reset and pluralizes singular windows`() { + func `formats quota estimates with up to one decimal and pluralizes units`() { let equal = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( estimatedWindowsToExhaustWeekly: 2, windowsUntilReset: 2, sampleCount: 7, weeklyResetsAt: Self.weeklyReset, weeklyUsedPercent: 80)) - let singular = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( - estimatedWindowsToExhaustWeekly: 1, + let roundedSingular = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( + estimatedWindowsToExhaustWeekly: 1.04, windowsUntilReset: 2, sampleCount: 7, weeklyResetsAt: Self.weeklyReset, @@ -288,25 +288,25 @@ struct SessionEquivalentForecastTests { weeklyResetsAt: Self.weeklyReset, weeklyUsedPercent: 14)) - #expect(equal.verdictText == "Weekly cannot run out before reset at this pace") - #expect(singular.numberText == "≈1 full 5h window of weekly left · 2 windows until reset") - #expect(singular.verdictText == "Weekly can run out ≈1 window early") - #expect(close.numberText == "≈8 full 5h windows of weekly left · 9 windows until reset") - #expect(close.verdictText == "Weekly can run out ≈1 window early") + #expect(equal.leftText == "Est. 2 session quotas left") + #expect(roundedSingular.leftText == "Est. 1 session quota left") + #expect(roundedSingular.rightText == "2 windows until reset") + #expect(close.leftText == "Est. 8.6 session quotas left") + #expect(close.rightText == "9 windows until reset") } @Test - func `verdict uses fractional capacity while number line shows full windows`() { + func `formats sub-one quota and singular reset window`() { let detail = UsagePaceText.sessionEquivalentDetail(forecast: SessionEquivalentForecast( - estimatedWindowsToExhaustWeekly: 0.5, - windowsUntilReset: 0, - availableWindowsUntilReset: 0.8, + estimatedWindowsToExhaustWeekly: 0.3, + windowsUntilReset: 1, + availableWindowsUntilReset: 1.8, sampleCount: 7, weeklyResetsAt: Self.weeklyReset, weeklyUsedPercent: 95)) - #expect(detail.verdictText == "Weekly can run out ≈1 window early") - #expect(detail.numberText == "≈0 full 5h windows of weekly left · 0 windows until reset") + #expect(detail.leftText == "Est. 0.3 session quota left") + #expect(detail.rightText == "1 window until reset") } @Test @@ -396,7 +396,7 @@ struct SessionEquivalentForecastTests { sampleCount: 7, weeklyResetsAt: Self.weeklyReset, weeklyUsedPercent: 1)) - #expect(huge.numberText.contains("full 5h windows")) + #expect(huge.leftText == "Est. 1,000,000 session quotas left") } @Test @@ -440,14 +440,14 @@ struct SessionEquivalentForecastTests { planSeries(name: .session, windowMinutes: 300, entries: sessionEntries), planSeries(name: .weekly, windowMinutes: 10080, entries: weeklyEntries), ], - currentSessionResetsAt: fixture.currentSessionReset, + currentSessionResetsAt: nil, now: now) == nil) } @Test func `provider metric shows estimate only on its matching weekly window`() throws { let now = Date(timeIntervalSince1970: 1_900_000_000) - let weeklyReset = now.addingTimeInterval(2 * 24 * 3600) + let weeklyReset = now.addingTimeInterval((7 * 60 + 37) * 60) let snapshot = UsageSnapshot( primary: RateWindow( usedPercent: 20, @@ -455,18 +455,18 @@ struct SessionEquivalentForecastTests { resetsAt: now.addingTimeInterval(3600), resetDescription: nil), secondary: RateWindow( - usedPercent: 60, + usedPercent: 95, windowMinutes: 10080, resetsAt: weeklyReset, resetDescription: nil), updatedAt: now) let metadata = try #require(ProviderDefaults.metadata[.claude]) let forecast = SessionEquivalentForecast( - estimatedWindowsToExhaustWeekly: 4, - windowsUntilReset: 9, + estimatedWindowsToExhaustWeekly: 0.3, + windowsUntilReset: 1, sampleCount: 7, weeklyResetsAt: weeklyReset, - weeklyUsedPercent: 60) + weeklyUsedPercent: 95) let model = UsageMenuCardView.Model.make(.init( provider: .claude, @@ -486,13 +486,24 @@ struct SessionEquivalentForecastTests { tokenCostUsageEnabled: false, showOptionalCreditsAndExtraUsage: true, hidePersonalInfo: false, + weeklyPace: UsagePace( + stage: .onTrack, + deltaPercent: -0.4, + expectedUsedPercent: 95.4, + actualUsedPercent: 95, + etaSeconds: nil, + willLastToReset: true), sessionEquivalentForecast: forecast, now: now)) let sessionMetric = try #require(model.metrics.first { $0.id == "primary" }) let weeklyMetric = try #require(model.metrics.first { $0.id == "secondary" }) #expect(sessionMetric.sessionEquivalentDetail == nil) - #expect(weeklyMetric.sessionEquivalentDetail?.verdictText == "Weekly can run out ≈5 windows early") + #expect(weeklyMetric.percentLabel == "5% left") + #expect(weeklyMetric.detailLeftText == "On pace") + #expect(weeklyMetric.detailRightText == "Lasts until reset") + #expect(weeklyMetric.sessionEquivalentDetail?.leftText == "Est. 0.3 session quota left") + #expect(weeklyMetric.sessionEquivalentDetail?.rightText == "1 window until reset") } @MainActor @@ -752,7 +763,8 @@ extension SessionEquivalentForecastTests { #expect(forecast.estimatedWindowsToExhaustWeekly == 6) #expect(forecast.windowsUntilReset == 14) let detail = UsagePaceText.sessionEquivalentDetail(forecast: forecast) - #expect(detail.verdictText == "Weekly can run out ≈8 windows early") + #expect(detail.leftText == "Est. 6 session quotas left") + #expect(detail.rightText == "14 windows until reset") } @MainActor @@ -1441,7 +1453,7 @@ extension SessionEquivalentForecastTests { updatedAt: now) } - private static func historyFixture(burns: [Double]) + static func historyFixture(burns: [Double]) -> (histories: [PlanUtilizationSeriesHistory], currentSessionReset: Date) { self.historyFixture(samples: burns.map { diff --git a/Tests/CodexBarTests/ShellCommandLocatorProcessTests.swift b/Tests/CodexBarTests/ShellCommandLocatorProcessTests.swift index 28f0ef68d..8749d51f6 100644 --- a/Tests/CodexBarTests/ShellCommandLocatorProcessTests.swift +++ b/Tests/CodexBarTests/ShellCommandLocatorProcessTests.swift @@ -65,6 +65,14 @@ struct ShellCommandLocatorProcessTests { time.sleep(1000) """ + // Pre-warm the interpreter so cold-start latency on loaded CI runners cannot + // consume the probe timeout before the helper children write their PID files. + let warmup = Process() + warmup.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + warmup.arguments = ["-c", "pass"] + try warmup.run() + warmup.waitUntilExit() + let start = Date() let data = ShellCommandLocator.test_runShellCommand( shell: "/usr/bin/python3", @@ -75,6 +83,14 @@ struct ShellCommandLocatorProcessTests { timeout: 5.0) let elapsed = Date().timeIntervalSince(start) + // The PID files are written by detached grandchildren; give the filesystem a + // bounded grace period before reading so slow runners cannot race the writes. + let fileDeadline = Date().addingTimeInterval(10) + while Date() < fileDeadline, + !pidFiles.allSatisfy({ FileManager.default.fileExists(atPath: $0) }) + { + usleep(100_000) + } let pids = try pidFiles.map { file in let pidText = try String(contentsOfFile: file, encoding: .utf8) .trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index e1259d5ff..69aa8b441 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -838,6 +838,7 @@ struct SpendDashboardControllerTests { sessionCostUSD: nil, last30DaysTokens: 10, last30DaysCostUSD: cost, + currencyCode: "USD", daily: [entry], updatedAt: Date(timeIntervalSince1970: 1_784_179_200)) return SpendDashboardModel.ProviderInput( diff --git a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift index 23902d4d7..6fc55887a 100644 --- a/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift +++ b/Tests/CodexBarTests/SpendDashboardTokenProvenanceTests.swift @@ -102,6 +102,7 @@ struct SpendDashboardTokenProvenanceTests { @Test func `cached token account activation does not prove a forced refresh`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) let (settings, store) = Self.makeStore(provider: .mistral) settings.addTokenAccount(provider: .mistral, label: "Fixture", token: "fixture") let account = try #require(settings.effectiveSelectedTokenAccount(for: .mistral)) @@ -127,8 +128,13 @@ struct SpendDashboardTokenProvenanceTests { let controller = SpendDashboardController( userDefaults: settings.userDefaults, requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: mode, + now: now) + }, + nowProvider: { now }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 3) @@ -143,6 +149,7 @@ struct SpendDashboardTokenProvenanceTests { @Test func `forced successful empty publication removes prior spend without warning`() async { + let now = Date(timeIntervalSince1970: 1_784_203_200) let (settings, store) = Self.makeStore(provider: .bedrock) var loadCount = 0 store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in @@ -153,8 +160,13 @@ struct SpendDashboardTokenProvenanceTests { let controller = SpendDashboardController( userDefaults: settings.userDefaults, requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: mode, + now: now) + }, + nowProvider: { now }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } #expect(controller.model.groups.first?.totalCost == 4) @@ -173,6 +185,7 @@ struct SpendDashboardTokenProvenanceTests { @Test func `first open accepts current empty publication without redundant refresh`() async { + let now = Date(timeIntervalSince1970: 1_784_179_200) let (settings, store) = Self.makeStore(provider: .bedrock) var loadCount = 0 store._test_tokenUsageSnapshotLoaderOverride = { _, _, _, _, _ in @@ -184,8 +197,13 @@ struct SpendDashboardTokenProvenanceTests { let controller = SpendDashboardController( userDefaults: settings.userDefaults, requestBuilder: { mode in - await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) - }) + await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: mode, + now: now) + }, + nowProvider: { now }) controller.update(configuration: SpendDashboardSource.configuration(settings: settings, store: store)) await Self.waitUntil { !controller.isRefreshing } @@ -320,6 +338,7 @@ struct SpendDashboardTokenProvenanceTests { sessionCostUSD: cost, last30DaysTokens: 10, last30DaysCostUSD: cost, + currencyCode: "USD", daily: [CostUsageDailyReport.Entry( date: "2026-07-16", inputTokens: 4, diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index df58513d2..f11d7fbfc 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -333,6 +333,102 @@ struct StatusItemControllerMenuTests { #expect(ceil(submenuMenu.size.width) < 310) } + // MARK: - Status component allowlist + + @Test + @MainActor + func `status component allowlist passes through unfiltered for providers without one`() { + let components = [ + ProviderStatusComponent(id: "1", name: "API", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "2", name: "Web App", indicator: .none, status: "operational"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .claude) + + #expect(filtered.map(\.name) == ["API", "Web App"]) + } + + @Test + @MainActor + func `status component allowlist keeps only named components and groups for zoommate`() { + let components = [ + ProviderStatusComponent( + id: "g1", + name: "Zoom Meetings", + indicator: .none, + status: "operational", + children: [ + ProviderStatusComponent( + id: "c1", + name: "Zoom Whiteboard", + indicator: .none, + status: "operational"), + ]), + ProviderStatusComponent(id: "2", name: "ZoomMate", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "3", name: "My Notes", indicator: .none, status: "operational"), + ProviderStatusComponent( + id: "g2", + name: "Zoom Workflows", + indicator: .none, + status: "operational", + children: [ + ProviderStatusComponent( + id: "c2", + name: "Zoom AIC", + indicator: .none, + status: "operational"), + ]), + ProviderStatusComponent(id: "4", name: "Zoom Phone - US", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "5", name: "Zoom Rooms", indicator: .none, status: "operational"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .zoommate) + + #expect(filtered.map(\.name) == ["Zoom Meetings", "ZoomMate", "My Notes", "Zoom Workflows"]) + // Allowlisted groups keep their existing full child list; the allowlist filters at the + // top level only, it does not additionally prune group children. + #expect(filtered.first { $0.name == "Zoom Meetings" }?.children.map(\.name) == ["Zoom Whiteboard"]) + } + + @Test + @MainActor + func `status component allowlist tolerates any subset of named components being absent`() { + // Only two of the four allowlisted names are present; the other two are silently omitted + // without error, and unrelated components remain excluded as usual. + let components = [ + ProviderStatusComponent(id: "2", name: "ZoomMate", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "4", name: "Zoom Phone - US", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "3", name: "My Notes", indicator: .minor, status: "degraded_performance"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .zoommate) + + #expect(filtered.map(\.name) == ["ZoomMate", "My Notes"]) + } + + @Test + @MainActor + func `status component allowlist returns empty list when all named components are absent`() { + // Zoom's status page has been fully restructured and none of the four allowlisted names + // remain; the filter must degrade to an empty list rather than crash, so the existing + // "no components" gate (which shows only the website link) takes over. + let components = [ + ProviderStatusComponent(id: "4", name: "Zoom Phone - US", indicator: .none, status: "operational"), + ProviderStatusComponent(id: "5", name: "Zoom Rooms", indicator: .none, status: "operational"), + ] + + let filtered = StatusItemController.filterStatusComponents(components, for: .zoommate) + + #expect(filtered.isEmpty) + } + + @Test + @MainActor + func `status component allowlist on an empty component list stays empty`() { + let filtered = StatusItemController.filterStatusComponents([], for: .zoommate) + #expect(filtered.isEmpty) + } + @Test @MainActor func `update menu action installs prepared update instead of checking again`() throws { diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift index 91b58919a..02b23dedc 100644 --- a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -86,6 +86,52 @@ struct StatusItemIconObservationSignatureTests { suiteName: "StatusItemIconObservationSignatureTests-snapshot-metadata") defer { controller.releaseStatusItemsForTesting() } + let baseline = controller.storeIconObservationSignature() + #expect(!baseline.contains("icon@example.com")) + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "rotated-account@example.com", + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + let signature = controller.storeIconObservationSignature() + + #expect(signature == baseline) + #expect(!signature.contains("rotated-account@example.com")) + } + + @Test + func `custom account label changes the store icon observation signature`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-custom-account-label", + menuBarLayout: MenuBarLayout(lines: [[.accountLabel]])) + defer { controller.releaseStatusItemsForTesting() } + + let baseline = controller.storeIconObservationSignature() + #expect(!baseline.contains("icon@example.com")) + + store._setSnapshotForTesting( + Self.makeSnapshot( + provider: .codex, + email: "rotated-account@example.com", + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + let signature = controller.storeIconObservationSignature() + + #expect(signature != baseline) + #expect(!signature.contains("rotated-account@example.com")) + } + + @Test + func `hidden custom account label ignores account changes`() { + let (settings, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-hidden-custom-account-label", + menuBarLayout: MenuBarLayout(lines: [[.accountLabel]])) + defer { controller.releaseStatusItemsForTesting() } + settings.hidePersonalInfo = true let baseline = controller.storeIconObservationSignature() store._setSnapshotForTesting( diff --git a/Tests/CodexBarTests/StatusMenuUsageDisplayTests.swift b/Tests/CodexBarTests/StatusMenuUsageDisplayTests.swift index 0ac148de3..6cac6a03d 100644 --- a/Tests/CodexBarTests/StatusMenuUsageDisplayTests.swift +++ b/Tests/CodexBarTests/StatusMenuUsageDisplayTests.swift @@ -30,6 +30,59 @@ extension StatusMenuTests { #expect(usedMetric.percentStyle.rawValue == "used") } + @Test + func `status menu card follows claude daily routines visibility`() throws { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "claude-weekly-scoped-fable", + title: "Fable only", + window: RateWindow( + usedPercent: 30, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil)), + NamedRateWindow( + id: "claude-routines", + title: "Daily Routines", + window: RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7200), + resetDescription: nil)), + ], + updatedAt: now), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.menuCardModel(for: .claude)?.metrics.contains { $0.id == "claude-routines" } == true) + + settings.claudeDailyRoutinesUsageVisible = false + let hiddenModel = try #require(controller.menuCardModel(for: .claude)) + #expect(!hiddenModel.metrics.contains { $0.id == "claude-routines" }) + #expect(hiddenModel.metrics.contains { $0.id == "claude-weekly-scoped-fable" }) + } + @Test func `status menu card follows codex spark visibility`() throws { let settings = self.makeSettings() diff --git a/Tests/CodexBarTests/SyncCoordinatorRateWindowIdentityTests.swift b/Tests/CodexBarTests/SyncCoordinatorRateWindowIdentityTests.swift index 6f3ab8abd..17af0d048 100644 --- a/Tests/CodexBarTests/SyncCoordinatorRateWindowIdentityTests.swift +++ b/Tests/CodexBarTests/SyncCoordinatorRateWindowIdentityTests.swift @@ -23,6 +23,96 @@ extension SyncCoordinatorTests { settings: settings) } + private func syncedProvider( + _ provider: UsageProvider, + snapshot: UsageSnapshot, + suite: String) async throws -> ProviderUsageSnapshot + { + let settings = self.makeRateWindowIdentitySettingsStore(suite: suite) + settings.iCloudSyncEnabled = true + try settings.setProviderEnabled( + provider: provider, + metadata: #require(ProviderDefaults.metadata[provider]), + enabled: true) + + let store = self.makeRateWindowIdentityUsageStore(settings: settings) + store._setSnapshotForTesting(snapshot, provider: provider) + let mock = MockSyncPusher() + let coordinator = SyncCoordinator(store: store, settings: settings, syncManager: mock) + + await coordinator.pushCurrentSnapshot() + + return try #require(mock.lastPerProviderEnvelopes + .first { $0.provider.providerID == provider.rawValue }? + .provider) + } + + @Test + func `amp subscription sync uses dynamic usage lane labels`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let provider = try await self.syncedProvider( + .amp, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: now, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 30, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: now, + resetDescription: nil), + ampUsage: AmpUsageDetails( + individualCredits: nil, + workspaceBalances: [], + subscriptionPlan: "Power"), + updatedAt: now), + suite: "SyncCoord-amp-dynamic-window-labels") + + #expect(provider.rateWindows.map(\.label) == ["Other usage", "Orb usage"]) + } + + @Test + func `alibaba token plan sync uses duration based lane labels`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let provider = try await self.syncedProvider( + .alibabatokenplan, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 5 * 60, + resetsAt: now, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 30, + windowMinutes: 7 * 24 * 60, + resetsAt: now, + resetDescription: nil), + updatedAt: now), + suite: "SyncCoord-alibaba-dynamic-window-labels") + + #expect(provider.rateWindows.map(\.label) == ["5-hour", "7-day"]) + } + + @Test + func `qwen cloud legacy monthly window syncs as 30 day`() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let provider = try await self.syncedProvider( + .qwencloud, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: 30 * 24 * 60, + resetsAt: now, + resetDescription: nil), + secondary: nil, + updatedAt: now), + suite: "SyncCoord-qwen-legacy-window-label") + + #expect(provider.rateWindows.map(\.label) == ["30-day"]) + } + @Test func `kimi per provider rate windows use semantic identities`() async throws { let settings = self.makeRateWindowIdentitySettingsStore(suite: "SyncCoord-kimi-rate-window-identities") diff --git a/Tests/CodexBarTests/SyncCoordinatorTests.swift b/Tests/CodexBarTests/SyncCoordinatorTests.swift index 15f76a918..2507f03f6 100644 --- a/Tests/CodexBarTests/SyncCoordinatorTests.swift +++ b/Tests/CodexBarTests/SyncCoordinatorTests.swift @@ -91,6 +91,52 @@ struct SyncCoordinatorTests { #expect(SyncCoordinator.syncBudgetSnapshot(provider: .cursor, providerCost: balance) != nil) } + @Test + func `Claude prepaid balance syncs without a false zero-limit budget`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let balanceOnly = ProviderCostSnapshot( + used: 0, + limit: 0, + currencyCode: "usd", + period: "Extra usage", + balance: 27.50, + updatedAt: now) + + let extra = try #require(SyncCoordinator.mapClaudeExtraUsage( + provider: .claude, + snapshot: UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + providerCost: balanceOnly)) + + #expect(extra.utilization == nil) + #expect(extra.monthlySpendUSD == nil) + #expect(extra.monthlyLimitUSD == nil) + #expect(extra.balanceUSD == 27.50) + #expect(SyncCoordinator.syncBudgetSnapshot(provider: .claude, providerCost: balanceOnly) == nil) + } + + @Test + func `Claude spend limit keeps its prepaid balance on the sync envelope`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let spendLimit = ProviderCostSnapshot( + used: 30, + limit: 100, + currencyCode: "USD", + period: "This month", + balance: 18.25, + updatedAt: now) + + let extra = try #require(SyncCoordinator.mapClaudeExtraUsage( + provider: .claude, + snapshot: UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + providerCost: spendLimit)) + + #expect(extra.utilization == 30) + #expect(extra.monthlySpendUSD == 30) + #expect(extra.monthlyLimitUSD == 100) + #expect(extra.balanceUSD == 18.25) + #expect(SyncCoordinator.syncBudgetSnapshot(provider: .claude, providerCost: spendLimit) != nil) + } + @Test func `push skipped when sync disabled`() async { let settings = self.makeSettingsStore(suite: "SyncCoord-disabled") diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 69fd77992..ed1114864 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -17,6 +17,10 @@ struct UsagePaceTextTests { "Runs out in %@", "1.5× headroom", "≈ %d%% run-out risk", + "%@ left", + "session quota", + "session quotas", + "session_quota_estimate_value_format", "≈%d full 5h windows of weekly left · %d windows until reset", "Weekly cannot run out before reset at this pace", "Weekly can run out ≈%d windows early", @@ -426,6 +430,29 @@ struct UsagePaceTextTests { } } + @Test + func `session quota estimate template localizes CJK number unit spacing`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let expectations = [ + (language: "zh-Hans", unit: "会话额度", expected: "0.3会话额度"), + (language: "zh-Hant", unit: "工作階段額度", expected: "0.3工作階段額度"), + (language: "ja", unit: "セッション枠", expected: "0.3セッション枠"), + (language: "ko", unit: "세션 할당량", expected: "0.3 세션 할당량"), + ] + + for expectation in expectations { + let url = root.appendingPathComponent( + "Sources/CodexBar/Resources/\(expectation.language).lproj/Localizable.strings") + let table = try Self.readStringsTable(at: url) + let template = try #require(table["session_quota_estimate_value_format"]) + let arguments: [CVarArg] = ["0.3", expectation.unit] + #expect(String(format: template, arguments: arguments) == expectation.expected) + } + } + private static func readStringsTable(at url: URL) throws -> [String: String] { guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { throw NSError( diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 615fac93f..7d2a8c507 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -219,6 +219,8 @@ struct UsageStoreCoverageTests { provider: .amp) let model = ProvidersPane(settings: settings, store: store)._test_menuCardModel(for: .amp) + #expect(model.metrics.map(\.title) == ["Amp Free"]) + #expect(model.metrics.allSatisfy { $0.pacePercent == nil }) #expect(model.creditsText == "Individual credits: $25.64\nWorkspace billing@example.test: $10.22") #expect(model.creditsRemaining == nil) @@ -227,6 +229,37 @@ struct UsageStoreCoverageTests { #expect(redactedModel.creditsText == "Individual credits: $25.64\nWorkspace: $10.22") } + @Test + func `amp subscription pools use their own labels`() { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-amp-subscription") + let store = Self.makeUsageStore(settings: settings) + let now = Date() + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 3, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: now.addingTimeInterval(29 * 24 * 60 * 60), + resetDescription: "renews in 29 days"), + secondary: RateWindow( + usedPercent: 0, + windowMinutes: ProviderPaceCapability.monthlyWindowSentinelMinutes, + resetsAt: now.addingTimeInterval(29 * 24 * 60 * 60), + resetDescription: "renews in 29 days"), + ampUsage: AmpUsageDetails( + individualCredits: nil, + workspaceBalances: [], + subscriptionPlan: "Megawatt"), + updatedAt: now), + provider: .amp) + + let model = ProvidersPane(settings: settings, store: store)._test_menuCardModel(for: .amp) + + #expect(model.metrics.map(\.title) == ["Other usage", "Orb usage"]) + #expect(model.planText == "Megawatt") + } + @Test func `account info caches codex auth parsing until config revision changes`() throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-account-info-cache") diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index aa3952109..5e4fc66dd 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -122,6 +122,50 @@ struct UsageStoreHighestUsageTests { #expect(highest?.usedPercent == 70) } + @Test + func `automatic metric keeps partially exhausted kimi eligible for highest usage`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-kimi-partially-exhausted"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .kimi) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let kimiMeta = registry.metadata[.kimi] { + settings.setProviderEnabled(provider: .kimi, metadata: kimiMeta, enabled: true) + } + + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: "Weekly"), + secondary: RateWindow( + usedPercent: 20, + windowMinutes: 300, + resetsAt: nil, + resetDescription: "5-hour"), + updatedAt: Date()), + provider: .kimi) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .kimi) + #expect(highest?.usedPercent == 100) + } + @Test func `automatic metric ignores antigravity tertiary when compact icon has no quota summary`() { let settings = SettingsStore( diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 857213c4d..03f83bf30 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -664,6 +664,39 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) } + @Test + func `amp subscription quota warnings use pool labels`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-amp") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + func snapshot(used: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: used, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: used, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + ampUsage: AmpUsageDetails( + individualCredits: nil, + workspaceBalances: [], + subscriptionPlan: "Megawatt"), + updatedAt: Date()) + } + store.handleQuotaWarningTransitions(provider: .amp, snapshot: snapshot(used: 40)) + store.handleQuotaWarningTransitions(provider: .amp, snapshot: snapshot(used: 55)) + + #expect(notifier.quotaWarningPosts.map(\.event.windowDisplayLabel) == ["Other usage", "Orb usage"]) + } + @Test func `antigravity quota warnings use named session and weekly durations`() { let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-antigravity") diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift index be9d14b30..351aa9c19 100644 --- a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -425,6 +425,63 @@ struct UsageStoreWidgetSnapshotTests { #expect(entry.tokenUsage?.last30DaysTokens == 42000) } + @Test + func `widget snapshot uses Claude enterprise spend limit instead of placeholder quota`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-claude-enterprise-spend-limit" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let updatedAt = Date(timeIntervalSince1970: 1_800_000_000) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil, + isSyntheticPlaceholder: true), + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 25545.63, + limit: 30000, + currencyCode: "USD", + period: "Monthly cap", + updatedAt: updatedAt), + updatedAt: updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil)), + provider: .claude) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "claude-enterprise-spend-limit-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .claude }) + let row = try #require(entry.usageRows?.first) + #expect(entry.usageRows?.count == 1) + #expect(row.id == "extraUsage") + #expect(row.title == "Monthly cap") + #expect(abs((row.percentLeft ?? 0) - 14.8479) < 0.0001) + #expect(row.window?.isSyntheticPlaceholder == false) + } + @Test(arguments: [true, false]) func `widget snapshot respects extra usage visibility for Devin`(_ showsExtraUsage: Bool) async throws { let suite = "UsageStoreWidgetSnapshotTests-devin-extra-usage-\(showsExtraUsage)" diff --git a/Tests/CodexBarTests/V027SnapshotsCodableTests.swift b/Tests/CodexBarTests/V027SnapshotsCodableTests.swift index f227e0933..444f5b621 100644 --- a/Tests/CodexBarTests/V027SnapshotsCodableTests.swift +++ b/Tests/CodexBarTests/V027SnapshotsCodableTests.swift @@ -75,6 +75,7 @@ struct V027SnapshotsCodableTests { utilization: 42.5, monthlySpendUSD: 42.50, monthlyLimitUSD: 100.00, + balanceUSD: 17.25, isEnabled: true, planTier: "Enterprise", updatedAt: now) @@ -83,6 +84,7 @@ struct V027SnapshotsCodableTests { from: Self.encoder.encode(enabled)) #expect(decoded.utilization == 42.5) #expect(decoded.monthlyLimitUSD == 100.00) + #expect(decoded.balanceUSD == 17.25) #expect(decoded.isEnabled) // Disabled + uncapped (Team without extra usage) @@ -98,6 +100,17 @@ struct V027SnapshotsCodableTests { from: Self.encoder.encode(disabled)) #expect(!dDecoded.isEnabled) #expect(dDecoded.monthlyLimitUSD == nil) + #expect(dDecoded.balanceUSD == nil) + + let legacyJSON = Data( + #""" + {"isEnabled":true,"monthlyLimitUSD":100,"monthlySpendUSD":25,"planTier":"Team", + "updatedAt":"2023-11-14T22:13:20Z","utilization":25} + """# + .utf8) + let legacyDecoded = try Self.decoder.decode(SyncClaudeExtraUsage.self, from: legacyJSON) + #expect(legacyDecoded.monthlySpendUSD == 25) + #expect(legacyDecoded.balanceUSD == nil) } @Test diff --git a/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift b/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift new file mode 100644 index 000000000..545f4a3c6 --- /dev/null +++ b/Tests/CodexBarTests/ZoomMateCookieCacheTests.swift @@ -0,0 +1,360 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Covers the `.auto` cookie-cache handoff: a validated browser session is persisted through +/// `CookieHeaderCache`, and later resolutions (background refreshes, the bundled CLI) run from the +/// cached header without rereading the browser. Modeled on `PerplexityCookieCacheTests`. +@Suite(.serialized) +struct ZoomMateCookieCacheTests { + private static let cachedHeader = "_zm_ssid=fake-session-value; cf_clearance=fake-clearance-value" + private static let cachedHeaders = ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": cachedHeader, + "zoommate.zoom.us": cachedHeader, + ]) + private static let cachedStorage = cachedHeaders.encodedForStorage() ?? "" + + private static func sharedCookieHeaders(_ header: String) -> ZoomMateCookieHeaders { + ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": header, + "zoommate.zoom.us": header, + ]) + } + + /// Minimal unsigned JWT carrying only a far-future `exp` claim, so minted tokens are cacheable. + private static func makeJWT(exp: Int = 9_999_999_999) -> String { + func b64url(_ text: String) -> String { + Data(text.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return "\(b64url("{\"alg\":\"none\"}")).\(b64url("{\"exp\":\(exp)}")).sig" + } + + private static func mintResponseStub( + nak: String, + email: String? = nil, + expectedCookieHeader: String? = nil) -> ProviderHTTPTransportStub + { + ProviderHTTPTransportStub { request in + if let expectedCookieHeader { + #expect(request.value(forHTTPHeaderField: "Cookie") == expectedCookieHeader) + } + let profile = email.map { ", \"user_profile\": {\"email\": \"\($0)\"}" } ?? "" + let body = "{\"success\": true, \"data\": {\"nak\": \"\(nak)\"\(profile)}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + } + + #if os(macOS) + @Test + func `auto mode reuses the cached cookie header without a browser read`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedStorage, + sourceLabel: "Chrome (Test)") + + let jwt = Self.makeJWT() + let stub = Self.mintResponseStub( + nak: jwt, + email: "fake.user@example.com", + expectedCookieHeader: Self.cachedHeader) + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + let context = try await fetcher.resolveRequestContext( + manualCaptureOverride: nil, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + + #expect(context.authorization == "Bearer \(jwt)") + #expect(context.cookieHeaders == Self.cachedHeaders) + #expect(context.accountEmail == "fake.user@example.com") + #expect(context.cacheKey == ZoomMateBearerTokenCache.key(forCookieHeaders: Self.cachedHeaders)) + #expect(await stub.requests().count == 1) // the mint only — no browser import happened + } + + @Test + func `resolution without cache falls back to the browser import path`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedStorage, + sourceLabel: "Chrome (Test)") + + let stub = ProviderHTTPTransportStub { request in + Issue.record("Unexpected network request: \(request.url?.absoluteString ?? "nil")") + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + // The dead-session retry disallows the cache; under the test runner the browser cookie + // store is suppressed, so the fallback surfaces `noSession` without any network traffic. + await #expect { + _ = try await fetcher.resolveRequestContext( + manualCaptureOverride: nil, + allowCachedCookieHeader: false, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.noSession = error else { return false } + return true + } + // Skipping the cache must not mutate it; clearing is the strategy's explicit decision. + #expect(CookieHeaderCache.load(provider: .zoommate)?.cookieHeader == Self.cachedStorage) + } + + @Test + func `rejected cached session surfaces invalidCredentials and leaves the entry intact`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedStorage, + sourceLabel: "Chrome (Test)") + + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + await #expect { + _ = try await fetcher.resolveRequestContext( + manualCaptureOverride: nil, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + // The fetcher never clears the cache itself — the strategy clears and retries once with a + // fresh import, so a transient mis-clear can't wipe a concurrently refreshed entry. + #expect(CookieHeaderCache.load(provider: .zoommate) != nil) + } + + @Test + func `validated browser session is persisted through the cookie cache`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let nak = Self.makeJWT() + let stub = Self.mintResponseStub(nak: nak, expectedCookieHeader: Self.cachedHeader) + + let context = try await ZoomMateUsageFetcher.requestContext( + forCookieHeaders: Self.cachedHeaders, + persistingValidatedHeaderAs: "Chrome (Test)", + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + + let cached = try #require(CookieHeaderCache.load(provider: .zoommate)) + #expect(cached.cookieHeader == Self.cachedStorage) + #expect(cached.sourceLabel == "Chrome (Test)") + // Only the cookie header is persisted — the minted bearer stays in memory. + #expect(!cached.cookieHeader.contains(nak)) + #expect(context.authorization == "Bearer \(nak)") + } + + @Test + func `auto mode continues past a rejected Chrome profile`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let rejectedHeader = "_zm_ssid=fake-rejected-session" + let validHeader = "_zm_ssid=fake-valid-session" + let jwt = Self.makeJWT() + let sessions = [ + ZoomMateCookieImporter.SessionInfo( + cookieHeaders: Self.sharedCookieHeaders(rejectedHeader), + sourceLabel: "Chrome Profile 1"), + ZoomMateCookieImporter.SessionInfo( + cookieHeaders: Self.sharedCookieHeaders(validHeader), + sourceLabel: "Chrome Profile 2"), + ] + let stub = ProviderHTTPTransportStub { request in + let cookieHeader = request.value(forHTTPHeaderField: "Cookie") + if cookieHeader == rejectedHeader { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil)! + return (Data("{}".utf8), response) + } + + #expect(cookieHeader == validHeader) + let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = try await ZoomMateUsageFetcher.requestContext( + forCookieSessions: sessions, + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + + #expect(context.authorization == "Bearer \(jwt)") + #expect(context.cookieHeaders == Self.sharedCookieHeaders(validHeader)) + #expect(await stub.requests().count == 2) + let cached = try #require(CookieHeaderCache.load(provider: .zoommate)) + #expect(cached.cookieHeader == Self.sharedCookieHeaders(validHeader).encodedForStorage()) + #expect(cached.sourceLabel == "Chrome Profile 2") + } + + @Test + func `auto mode does not hide a parse failure behind another Chrome profile`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let sessions = [ + ZoomMateCookieImporter.SessionInfo( + cookieHeaders: Self.sharedCookieHeaders("_zm_ssid=fake-malformed-response-session"), + sourceLabel: "Chrome Profile 1"), + ZoomMateCookieImporter.SessionInfo( + cookieHeaders: Self.sharedCookieHeaders("_zm_ssid=fake-unused-session"), + sourceLabel: "Chrome Profile 2"), + ] + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data("{\"success\": true, \"data\": {}}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.requestContext( + forCookieSessions: sessions, + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + #expect(await stub.requests().count == 1) + #expect(CookieHeaderCache.load(provider: .zoommate) == nil) + } + + @Test + func `failed mint persists nothing`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.requestContext( + forCookieHeaders: Self.cachedHeaders, + persistingValidatedHeaderAs: "Chrome (Test)", + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + #expect(CookieHeaderCache.load(provider: .zoommate) == nil) + } + + @Test + func `already cached header is not re-persisted`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + + let stub = Self.mintResponseStub(nak: Self.makeJWT()) + _ = try await ZoomMateUsageFetcher.requestContext( + forCookieHeaders: Self.cachedHeaders, + persistingValidatedHeaderAs: nil, + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: stub, + logger: nil) + + #expect(CookieHeaderCache.load(provider: .zoommate) == nil) + } + + @Test + func `manual capture mode neither reads nor writes the cookie cache`() async throws { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .zoommate) + KeychainCacheStore.setTestStoreForTesting(false) + } + CookieHeaderCache.store( + provider: .zoommate, + cookieHeader: Self.cachedStorage, + sourceLabel: "Chrome (Test)") + + let curl = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token' -H 'cookie: session=fake-manual-cookie'" + let stub = ProviderHTTPTransportStub { request in + Issue.record("Unexpected network request: \(request.url?.absoluteString ?? "nil")") + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data(), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + let context = try await fetcher.resolveRequestContext( + manualCaptureOverride: curl, + timeout: 1, + logger: nil, + cache: ZoomMateBearerTokenCache(), + transport: stub) + + #expect(context.authorization == "Bearer fake-manual-token") + #expect(context.cookieHeaders.header(forHost: "ai.zoom.us") == "session=fake-manual-cookie") + #expect(context.cookieHeaders.header(forHost: "zoommate.zoom.us") == nil) + #expect(CookieHeaderCache.load(provider: .zoommate)?.cookieHeader == Self.cachedStorage) + } + #endif +} diff --git a/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift b/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift new file mode 100644 index 000000000..76dd28358 --- /dev/null +++ b/Tests/CodexBarTests/ZoomMateCreditsHistoryFetcherTests.swift @@ -0,0 +1,550 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ZoomMateCreditsHistoryFetcherTests { + // Every payload below is generated from synthetic IDs, titles, costs, and timestamps. + private static let now = Date(timeIntervalSince1970: 1_782_800_000) + private static let startTime = Self.now.addingTimeInterval(-30 * 24 * 3600) + + private static func page(records: String, total: Int) -> String { + """ + { "data": { "records": [\(records)], "total": \(total) }, "status_code": 200, "error_message": null } + """ + } + + private static func record( + id: String, + title: String, + cost: Double, + time: String, + isRunning: Bool = false, + isDeleted: Bool = false) -> String + { + """ + {"session_id": "\(id)", "title": "\(title)", "cost": \(cost), "time": "\(time)", + "is_running": \(isRunning), "is_deleted": \(isDeleted)} + """ + } + + @Test + func `decodes a single page fully within the limit`() async throws { + let body = Self.page( + records: [ + Self.record(id: "s1", title: "Task A", cost: 5, time: "2026-06-30T10:00:00Z"), + Self.record(id: "s2", title: "Task B", cost: 3, time: "2026-06-29T10:00:00Z"), + ].joined(separator: ","), + total: 2) + + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.scheme == "https") + #expect(request.url?.host == "ai.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/credits/history") + #expect(request.url?.query?.contains("app_id=demo_app") == true) + #expect(request.url?.query?.contains("page=0") == true) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fake-token") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://zoommate.zoom.us") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext( + authorization: "Bearer fake-token", + headers: ["Origin": "https://attacker.example", "Referer": "https://attacker.example/path"]) + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.count == 2) + let requestCount = await stub.requests().count + #expect(requestCount == 1) + } + + @Test + func `history failover sends only the cookie header scoped to each host`() async throws { + let stub = ProviderHTTPTransportStub { request in + let statusCode = request.url?.host == "ai.zoom.us" ? 503 : 200 + if request.url?.host == "ai.zoom.us" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; ai-only=fake") + } else { + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; mate-only=fake") + } + let body = Self.page(records: "", total: 0) + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return (statusCode == 200 ? Data(body.utf8) : Data(), response) + } + let context = ZoomMateUsageFetcher.RequestContext( + authorization: "Bearer fake-token", + cookieHeaders: ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": "parent=fake; ai-only=fake", + "zoommate.zoom.us": "parent=fake; mate-only=fake", + ])) + + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.isEmpty) + #expect(await stub.requests().count == 2) + } + + @Test + func `paginates across multiple pages until total is satisfied`() async throws { + let stub = ProviderHTTPTransportStub { request in + let query = request.url?.query ?? "" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + if query.contains("page=0") { + let body = Self.page( + records: (0..<50).map { + Self.record(id: "s\($0)", title: "Task \($0)", cost: 1, time: "2026-06-30T10:00:00Z") + }.joined(separator: ","), + total: 55) + return (Data(body.utf8), response) + } + #expect(query.contains("page=1")) + let body = Self.page( + records: (50..<55).map { + Self.record(id: "s\($0)", title: "Task \($0)", cost: 1, time: "2026-06-29T10:00:00Z") + }.joined(separator: ","), + total: 55) + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.count == 55) + let requestCount = await stub.requests().count + #expect(requestCount == 2) + } + + @Test + func `stops pagination early when a page returns no records`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = Self.page(records: "", total: 1000) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.isEmpty) + let requestCount = await stub.requests().count + #expect(requestCount == 1) + } + + @Test + func `stops pagination early when a page is entirely older than startTime`() async throws { + // `total: 1000` implies many more pages exist, but every record on page 0 is already + // older than `startTime` — the defensive date-boundary stop (design.md D2) should break + // before requesting page 1, regardless of what `total`/`maxPages` would otherwise allow. + let staleTime = Self.startTime.addingTimeInterval(-24 * 3600) // 1 day before the window. + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.query?.contains("page=0") == true) + let body = Self.page( + records: (0..<50).map { + Self.record( + id: "s\($0)", + title: "Stale \($0)", + cost: 1, + time: ISO8601DateFormatter().string(from: staleTime)) + }.joined(separator: ","), + total: 1000) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + let snapshot = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + + #expect(snapshot.records.count == 50) + let requestCount = await stub.requests().count + #expect(requestCount == 1) + } + + @Test + func `unauthorized response maps to invalidCredentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{\"detail\": \"unauthorized\"}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `other server error maps to apiError`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data("boom".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.apiError = error else { return false } + return true + } + } + + @Test + func `malformed body surfaces parseFailed`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"unexpected\": true}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateCreditsHistoryFetcher.fetch( + context: context, + startTime: Self.startTime, + endTime: Self.now, + now: Self.now, + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + } + + // MARK: - Daily aggregation + + @Test + func `daily breakdown sums cost per calendar day and sorts ascending`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "A", + cost: 5, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "B", + cost: 3, + time: "2026-06-30T20:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s3", + title: "C", + cost: 2, + time: "2026-06-29T10:00:00Z", + isRunning: false, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) + + #expect(breakdown.count == 2) + #expect(breakdown[0].day == "2026-06-29") + #expect(breakdown[0].totalCreditsUsed == 2) + #expect(breakdown[1].day == "2026-06-30") + #expect(breakdown[1].totalCreditsUsed == 8) + } + + @Test + func `daily breakdown excludes deleted records`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "A", + cost: 5, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "B (deleted)", + cost: 100, + time: "2026-06-30T11:00:00Z", + isRunning: false, + isDeleted: true), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) + + #expect(breakdown.count == 1) + #expect(breakdown[0].totalCreditsUsed == 5) + } + + @Test + func `daily breakdown includes running sessions`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "Still running", + cost: 1.5, + time: "2026-06-30T10:00:00Z", + isRunning: true, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) + + #expect(breakdown.count == 1) + #expect(breakdown[0].totalCreditsUsed == 1.5) + } + + @Test + func `daily breakdown skips records with unparseable time or negative cost`() { + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "Bad time", + cost: 5, + time: "not-a-date", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "Negative cost", + cost: -1, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s3", + title: "Missing time", + cost: 2, + time: nil, + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s4", + title: "Missing cost", + cost: nil, + time: "2026-06-30T10:00:00Z", + isRunning: false, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: Self.now) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now) + + #expect(breakdown.isEmpty) + } + + @Test + func `daily breakdown returns empty for no records`() { + let snapshot = ZoomMateCreditsHistorySnapshot(records: [], updatedAt: Self.now) + #expect(snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: Self.now).isEmpty) + } + + @Test + func `daily breakdown excludes records older than the trailing 30-day window`() throws { + // Fixed `now`; one record just inside the 30-day window, one just outside it. + let fixedNow = try #require(Self.utcCalendar.date(from: DateComponents(year: 2026, month: 7, day: 4, hour: 12))) + let withinWindow = "2026-06-05T10:00:00Z" // 29 days before `now` -> included. + let outsideWindow = "2026-06-03T10:00:00Z" // 31 days before `now` -> excluded. + let records: [ZoomMateCreditHistoryRecord] = [ + ZoomMateCreditHistoryRecord( + sessionID: "s1", + title: "Recent", + cost: 5, + time: withinWindow, + isRunning: false, + isDeleted: false), + ZoomMateCreditHistoryRecord( + sessionID: "s2", + title: "Stale", + cost: 100, + time: outsideWindow, + isRunning: false, + isDeleted: false), + ] + let snapshot = ZoomMateCreditsHistorySnapshot(records: records, updatedAt: fixedNow) + let breakdown = snapshot.dailyBreakdown(calendar: Self.utcCalendar, now: fixedNow) + + #expect(breakdown.count == 1) + #expect(breakdown[0].day == "2026-06-05") + #expect(breakdown[0].totalCreditsUsed == 5) + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + } + + // MARK: - Pacing verdict + + @Test + func `pacing verdict reports onTrack when usage matches elapsed cycle fraction`() throws { + // Cycle: 100,000s long; now is 50,000s in (50% elapsed); used = 50% of budget. + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + + let pace = try #require(status.pacingVerdict(now: Self.now)) + #expect(pace.stage == .onTrack) + } + + @Test + func `pacing verdict reports behind when usage is well below elapsed cycle fraction`() throws { + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 100, + remainingCredit: 900, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + + let pace = try #require(status.pacingVerdict(now: Self.now)) + #expect(pace.stage == .behind || pace.stage == .farBehind || pace.stage == .slightlyBehind) + #expect(pace.deltaPercent < 0) + } + + @Test + func `pacing verdict reports ahead when usage is well above elapsed cycle fraction`() throws { + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 900, + remainingCredit: 100, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + + let pace = try #require(status.pacingVerdict(now: Self.now)) + #expect(pace.stage == .ahead || pace.stage == .farAhead || pace.stage == .slightlyAhead) + #expect(pace.deltaPercent > 0) + } + + @Test + func `pacing verdict is nil for unlimited plans`() { + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(Self.now.addingTimeInterval(-50000).timeIntervalSince1970 * 1000), + cycleEndDate: Int64(Self.now.addingTimeInterval(50000).timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: true) + + #expect(status.pacingVerdict(now: Self.now) == nil) + } + + @Test + func `pacing verdict is nil when cycle dates are missing`() { + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: nil, + cycleEndDate: nil, + isQuotaAvailable: true, + isUnlimited: false) + + #expect(status.pacingVerdict(now: Self.now) == nil) + } + + @Test + func `pacing verdict is nil when budget cap is zero`() { + let status = ZoomMateCreditStatus( + budgetCap: 0, + usedCredit: 0, + remainingCredit: 0, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(Self.now.addingTimeInterval(-50000).timeIntervalSince1970 * 1000), + cycleEndDate: Int64(Self.now.addingTimeInterval(50000).timeIntervalSince1970 * 1000), + isQuotaAvailable: false, + isUnlimited: false) + + #expect(status.pacingVerdict(now: Self.now) == nil) + } + + @Test + func `ZoomMateCreditsHistorySnapshot pacingVerdict delegates to its attached creditStatus`() { + let cycleStart = Self.now.addingTimeInterval(-50000) + let cycleEnd = Self.now.addingTimeInterval(50000) + let status = ZoomMateCreditStatus( + budgetCap: 1000, + usedCredit: 500, + remainingCredit: 500, + overageCredit: 0, + allowOverage: false, + cycleStartDate: Int64(cycleStart.timeIntervalSince1970 * 1000), + cycleEndDate: Int64(cycleEnd.timeIntervalSince1970 * 1000), + isQuotaAvailable: true, + isUnlimited: false) + let snapshot = ZoomMateCreditsHistorySnapshot(records: [], creditStatus: status, updatedAt: Self.now) + + #expect(snapshot.pacingVerdict(now: Self.now)?.stage == .onTrack) + } + + @Test + func `ZoomMateCreditsHistorySnapshot pacingVerdict is nil without an attached creditStatus`() { + let snapshot = ZoomMateCreditsHistorySnapshot(records: [], updatedAt: Self.now) + #expect(snapshot.pacingVerdict(now: Self.now) == nil) + } +} diff --git a/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift b/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift new file mode 100644 index 000000000..238c10397 --- /dev/null +++ b/Tests/CodexBarTests/ZoomMateUsageFetcherTests.swift @@ -0,0 +1,894 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ZoomMateUsageFetcherTests { + private final class MessageRecorder: @unchecked Sendable { + private var messages: [String] = [] + private let lock = NSLock() + + func append(_ message: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.messages.append(message) + } + + func output() -> String { + self.lock.lock() + defer { self.lock.unlock() } + return self.messages.joined(separator: "\n") + } + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private static let now = Date(timeIntervalSince1970: 1_782_800_000) + + private static func sharedCookieHeaders(_ header: String) -> ZoomMateCookieHeaders { + ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": header, + "zoommate.zoom.us": header, + ]) + } + + /// Fully synthetic payload matching the first-party web client's decoded response shape. + private static let sampleResponse = """ + { "data": { "credit_status": { + "budget_cap": 12345.0, "used_credit": 678.0, "remaining_credit": 11667.0, + "overage_credit": 0.0, "allow_overage": false, + "cycle_start_date": 1893456000000, "cycle_end_date": 1896134399000, + "is_quota_available": true, "is_unlimited": false } }, + "status_code": 200, "error_message": null } + """ + + @Test + func `decodes credit status from sample JSON`() throws { + let data = Data(Self.sampleResponse.utf8) + struct Envelope: Decodable { + struct DataBox: Decodable { + let creditStatus: ZoomMateCreditStatus + private enum CodingKeys: String, CodingKey { case creditStatus = "credit_status" } + } + + let data: DataBox + } + let envelope = try JSONDecoder().decode(Envelope.self, from: data) + let status = envelope.data.creditStatus + + #expect(status.budgetCap == 12345) + #expect(status.usedCredit == 678) + #expect(status.remainingCredit == 11667) + #expect(status.isUnlimited == false) + #expect(status.cycleEndDate == 1_896_134_399_000) + } + + @Test + func `maps normal credit usage to primary window`() { + let status = ZoomMateCreditStatus( + budgetCap: 35000, + usedCredit: 942, + remainingCredit: 34058, + overageCredit: 0, + allowOverage: false, + cycleStartDate: 1_782_777_600_000, + cycleEndDate: 1_785_455_999_000, + isQuotaAvailable: true, + isUnlimited: false) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() + + #expect(snapshot.primary != nil) + #expect(abs((snapshot.primary?.usedPercent ?? 0) - 2.691_428_57) < 0.001) + #expect(snapshot.primary?.resetsAt?.timeIntervalSince1970 == Double(1_785_455_999_000) / 1000) + #expect(snapshot.primary?.resetDescription == "Credits") + #expect(snapshot.secondary == nil) + #expect(snapshot.identity?.providerID == .zoommate) + #expect(snapshot.identity?.accountEmail == nil) + } + + @Test + func `unlimited plan reports zero percent and no reset`() { + let status = ZoomMateCreditStatus( + budgetCap: 35000, + usedCredit: 942, + remainingCredit: 34058, + overageCredit: 0, + allowOverage: false, + cycleStartDate: 1_782_777_600_000, + cycleEndDate: 1_785_455_999_000, + isQuotaAvailable: true, + isUnlimited: true) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.primary?.resetsAt == nil) + } + + @Test + func `zero budget cap avoids divide by zero`() { + let status = ZoomMateCreditStatus( + budgetCap: 0, + usedCredit: 0, + remainingCredit: 0, + overageCredit: 0, + allowOverage: false, + cycleStartDate: nil, + cycleEndDate: nil, + isQuotaAvailable: false, + isUnlimited: false) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now).toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.primary?.resetsAt == nil) + } + + @Test + func `fetch sends authorization and decodes credit status`() async throws { + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.scheme == "https") + #expect(request.url?.host == "ai.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/credits/status") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fake-token") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://zoommate.zoom.us") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext( + authorization: "Bearer fake-token", + headers: ["Origin": "https://attacker.example", "Referer": "https://attacker.example/path"]) + let snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( + context: context, + now: Self.now, + transport: stub) + + #expect(snapshot.creditStatus.usedCredit == 678) + } + + @Test + func `unauthorized response is invalid credentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{\"detail\": \"Missing Authorization header\"}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `other server error is apiError`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)! + return (Data("boom".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.apiError = error else { return false } + return true + } + } + + @Test + func `malformed 200 body surfaces parseFailed`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"unexpected\": true}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `manual curl capture extracts authorization and cookie`() throws { + let curl = """ + curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \\ + -H 'authorization: Bearer fake-manual-token' \\ + -H 'cookie: session=fake-cookie-value' \\ + -H 'origin: https://zoommate.zoom.us' \\ + -H 'referer: https://zoommate.zoom.us/' + """ + + let context = try #require(ZoomMateUsageFetcher.requestContext(from: curl)) + #expect(context.authorization == "Bearer fake-manual-token") + #expect(context.cookieHeaders.header(forHost: "ai.zoom.us") == "session=fake-cookie-value") + #expect(context.cookieHeaders.header(forHost: "zoommate.zoom.us") == nil) + #expect(context.preferredHost == "ai.zoom.us") + #expect(context.headers["Origin"] == nil) + #expect(context.headers["Referer"] == nil) + } + + @Test + func `manual curl capture rejects nonofficial and malformed targets`() { + let captures = [ + "curl 'http://ai.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://marketing.zoom.us/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://zoom.us.attacker.com/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://example.com/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/history' -H 'authorization: Bearer fake'", + "curl 'https://ai.zoom.us:444/ai-computer/api/v1/credits/status' -H 'authorization: Bearer fake'", + "curl --location 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake'", + ] + + for capture in captures { + #expect(ZoomMateUsageFetcher.requestContext(from: capture) == nil) + } + } + + @Test + func `manual curl capture accepts either interchangeable first-party host`() throws { + let capture = "curl 'https://zoommate.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token' -H 'cookie: mate-only=fake'" + + let context = try #require(ZoomMateUsageFetcher.requestContext(from: capture)) + #expect(context.authorization == "Bearer fake-manual-token") + #expect(context.cookieHeaders.header(forHost: "ai.zoom.us") == nil) + #expect(context.cookieHeaders.header(forHost: "zoommate.zoom.us") == "mate-only=fake") + #expect(context.preferredHost == "zoommate.zoom.us") + } + + @Test + func `manual ai capture never sends its cookie to zoommate during failover`() async throws { + let capture = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token' -H 'cookie: ai-only=fake'" + let context = try #require(ZoomMateUsageFetcher.requestContext(from: capture)) + let stub = ProviderHTTPTransportStub { request in + let statusCode = request.url?.host == "ai.zoom.us" ? 503 : 200 + if request.url?.host == "ai.zoom.us" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "ai-only=fake") + } else { + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return (statusCode == 200 ? Data(Self.sampleResponse.utf8) : Data(), response) + } + + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + #expect(await stub.requests().count == 2) + } + + @Test + func `manual zoommate capture starts on its host and drops its cookie during failover`() async throws { + let capture = "curl 'https://zoommate.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token' -H 'cookie: mate-only=fake'" + let context = try #require(ZoomMateUsageFetcher.requestContext(from: capture)) + let stub = ProviderHTTPTransportStub { request in + let statusCode = request.url?.host == "zoommate.zoom.us" ? 503 : 200 + if request.url?.host == "zoommate.zoom.us" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "mate-only=fake") + } else { + #expect(request.url?.host == "ai.zoom.us") + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return (statusCode == 200 ? Data(Self.sampleResponse.utf8) : Data(), response) + } + + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + #expect(await stub.requests().count == 2) + } + + @Test + func `credits status fails over to the alternate host on a non-auth failure`() async throws { + let stub = ProviderHTTPTransportStub { request in + if request.url?.host == "ai.zoom.us" { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 503, + httpVersion: nil, + headerFields: nil)! + return (Data(), response) + } + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/credits/status") + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext( + authorization: "Bearer fake-token", + cookieHeaders: ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": "parent=fake; ai-only=fake", + "zoommate.zoom.us": "parent=fake; mate-only=fake", + ])) + let snapshot = try await ZoomMateUsageFetcher.fetchCreditsStatus( + context: context, + now: Self.now, + transport: stub) + + #expect(snapshot.creditStatus.usedCredit == 678) + #expect(await stub.requests().count == 2) + let requests = await stub.requests() + #expect(requests[0].value(forHTTPHeaderField: "Cookie") == "parent=fake; ai-only=fake") + #expect(requests[1].value(forHTTPHeaderField: "Cookie") == "parent=fake; mate-only=fake") + } + + @Test + func `auth rejection does not fail over to the alternate host`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + #expect(await stub.requests().count == 1) + } + + @Test + func `parse failure does not fail over to the alternate host`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"unexpected\": true}".utf8), response) + } + + let context = ZoomMateUsageFetcher.RequestContext(authorization: "Bearer fake-token") + await #expect { + _ = try await ZoomMateUsageFetcher.fetchCreditsStatus(context: context, now: Self.now, transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + #expect(await stub.requests().count == 1) + } + + @Test + func `mint fails over to the alternate host on a non-auth failure`() async throws { + let stub = ProviderHTTPTransportStub { request in + if request.url?.host == "ai.zoom.us" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; ai-only=fake") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 500, + httpVersion: nil, + headerFields: nil)! + return (Data(), response) + } + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/login") + #expect(request.value(forHTTPHeaderField: "Cookie") == "parent=fake; mate-only=fake") + let body = "{\"success\": true, \"data\": {\"nak\": \"fake-minted-jwt\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: ZoomMateCookieHeaders(headersByHost: [ + "ai.zoom.us": "parent=fake; ai-only=fake", + "zoommate.zoom.us": "parent=fake; mate-only=fake", + ]), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(await stub.requests().count == 2) + } + + @Test + func `mint starts with the host that owns a partial cookie map`() async throws { + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.host == "zoommate.zoom.us") + #expect(request.value(forHTTPHeaderField: "Cookie") == "mate-only=fake") + let body = "{\"success\": true, \"data\": {\"nak\": \"fake-minted-jwt\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: ZoomMateCookieHeaders(headersByHost: [ + "zoommate.zoom.us": "mate-only=fake", + ]), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(await stub.requests().count == 1) + } + + @Test + func `host failover preserves cancellation without trying the alternate host`() async { + var attemptedHosts: [String] = [] + + do { + let _: String = try await ZoomMateUsageFetcher.withAPIHostFailover { host in + attemptedHosts.append(host) + throw CancellationError() + } + Issue.record("Expected cancellation") + } catch { + #expect(error is CancellationError) + } + + #expect(attemptedHosts == ["ai.zoom.us"]) + } + + @Test + func `curl capture without authorization header yields nil context`() { + let curl = """ + curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \\ + -H 'cookie: session=fake-cookie-value' + """ + + #expect(ZoomMateUsageFetcher.requestContext(from: curl) == nil) + } + + @Test + func `manual strategy remains available so malformed captures surface an honest error`() async { + let curl = "curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' " + + "-H 'authorization: Bearer fake-manual-token'" + let settings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .manual, + manualCookieHeader: curl)) + + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings))) + + let emptySettings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .manual, + manualCookieHeader: nil)) + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: emptySettings))) + } + + @Test + func `manual mode with an empty or malformed capture returns noCapture`() async { + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + + for capture in ["", "curl 'https://example.com' -H 'authorization: Bearer fake'"] { + await #expect { + _ = try await fetcher.resolveRequestContext( + manualCaptureOverride: capture, + timeout: 1, + logger: nil) + } throws: { error in + guard case ZoomMateUsageError.noCapture = error else { return false } + return true + } + } + } + + @Test + func `auto strategy is available on macOS regardless of a stored manual capture`() async { + let settings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + + #if os(macOS) + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings))) + #else + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings)) == false) + #endif + } + + @Test + func `strategy is unavailable when cookie source is off`() async { + let settings = ProviderSettingsSnapshot.make( + zoommate: ProviderSettingsSnapshot.ZoomMateProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + + #expect(await ZoomMateWebFetchStrategy().isAvailable(Self.makeContext(settings: settings)) == false) + } + + @Test + func `mintBearerToken sends cookie and decodes nak from login bootstrap response`() async throws { + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.host == "ai.zoom.us") + #expect(request.url?.path == "/ai-computer/api/v1/login") + #expect(request.url?.query?.contains("continue=") == true) + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=fake-cookie-value") + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt"}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == nil) + } + + @Test + func `mintBearerToken extracts email from user_profile when present`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt", "user_profile": { + "user_id": "fake-user-id", "email": "fake.user@example.com", "display_name": "Fake User" + }}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == "fake.user@example.com") + } + + @Test + func `mintBearerToken tolerates missing user_profile without throwing`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt"}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == nil) + } + + @Test + func `mintBearerToken tolerates user_profile with missing email without throwing`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = """ + {"success": true, "data": {"nak": "fake-minted-jwt", "user_profile": { + "user_id": "fake-user-id", "display_name": "Fake User" + }}} + """ + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + + let minted = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=fake-cookie-value"), + transport: stub) + + #expect(minted.bearerToken == "fake-minted-jwt") + #expect(minted.accountEmail == nil) + } + + @Test + func `toUsageSnapshot populates identity accountEmail and loginMethod when email is known`() { + let status = ZoomMateCreditStatus( + budgetCap: 35000, + usedCredit: 942, + remainingCredit: 34058, + overageCredit: 0, + allowOverage: false, + cycleStartDate: 1_782_777_600_000, + cycleEndDate: 1_785_455_999_000, + isQuotaAvailable: true, + isUnlimited: false) + let snapshot = ZoomMateUsageSnapshot(creditStatus: status, updatedAt: Self.now) + .toUsageSnapshot(accountEmail: "fake.user@example.com") + + #expect(snapshot.identity?.accountEmail == "fake.user@example.com") + #expect(snapshot.identity?.loginMethod == "Cookie") + } + + @Test + func `mintBearerToken maps unauthorized to invalidCredentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)! + return (Data("{}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=expired"), + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `mintBearerToken surfaces parseFailed when nak is missing`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data("{\"success\": true, \"data\": {}}".utf8), response) + } + + await #expect { + _ = try await ZoomMateUsageFetcher.mintBearerToken( + cookieHeaders: Self.sharedCookieHeaders("session=fake"), + transport: stub) + } throws: { error in + guard case ZoomMateUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `descriptor dashboard URL points to the credit usage pane`() { + #expect( + ZoomMateProviderDescriptor.descriptor.metadata.dashboardURL == + "https://zoommate.zoom.us/#/?settings=credit-usage") + } + + #if os(macOS) + @Test + func `descriptor limits automatic cookie import to Chrome`() throws { + let order = try #require(ZoomMateProviderDescriptor.descriptor.metadata.browserCookieOrder) + #expect(order == [.chrome]) + } + #endif + + @Test + func `credential errors describe distinct recovery actions`() { + #expect(ZoomMateUsageError.noCapture.localizedDescription.contains("ai.zoom.us")) + #expect(ZoomMateUsageError.noSession.localizedDescription.contains("Chrome")) + #expect(ZoomMateUsageError.invalidCredentials.localizedDescription.contains("rejected")) + } + + @Test + func `verbose logs omit captured cookies and bearer tokens`() async throws { + let cookieMarker = "COOKIE_SECRET_MARKER" + let tokenMarker = "TOKEN_SECRET_MARKER" + let nakMarker = "NAK_SECRET_MARKER" + let curl = """ + curl 'https://ai.zoom.us/ai-computer/api/v1/credits/status' \ + -H 'authorization: Bearer \(tokenMarker)' \ + -H 'cookie: session=\(cookieMarker)' + """ + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + let fetcher = ZoomMateUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + let messages = MessageRecorder() + + _ = try await fetcher.fetch( + manualCaptureOverride: curl, + logger: { messages.append($0) }, + transport: stub) + + let output = messages.output() + #expect(!output.contains(cookieMarker)) + #expect(!output.contains(tokenMarker)) + #expect(output.contains("Forwarding captured headers: Cookie")) + + let mintStub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"\(nakMarker)\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=\(cookieMarker)"), + cache: ZoomMateBearerTokenCache(), + timeout: 1, + transport: mintStub, + logger: { messages.append($0) }) + + let mintOutput = messages.output() + #expect(!mintOutput.contains(cookieMarker)) + #expect(!mintOutput.contains(nakMarker)) + } + + // MARK: - Bearer token expiry + in-memory cache + + /// Minimal unsigned JWT carrying only an `exp` claim, for cache-expiry tests. + private static func makeJWT(exp: Int) -> String { + func b64url(_ text: String) -> String { + Data(text.utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + return "\(b64url("{\"alg\":\"none\"}")).\(b64url("{\"exp\":\(exp)}")).sig" + } + + @Test + func `expiry decodes exp claim from a bearer JWT and ignores non-JWT tokens`() { + let jwt = Self.makeJWT(exp: 1_782_800_000) + #expect(ZoomMateUsageFetcher.expiry(fromJWT: jwt) == Date(timeIntervalSince1970: 1_782_800_000)) + // Tolerates an already-prefixed "Bearer " value. + #expect(ZoomMateUsageFetcher.expiry(fromJWT: "Bearer \(jwt)") == Date(timeIntervalSince1970: 1_782_800_000)) + // Opaque (non-JWT) tokens are undatable → nil (caller must not cache them). + #expect(ZoomMateUsageFetcher.expiry(fromJWT: "opaque-token") == nil) + } + + @Test + func `cachedOrMintedToken reuses an in-date token instead of re-minting`() async throws { + let jwt = Self.makeJWT(exp: 9_999_999_999) + let stub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + let cache = ZoomMateBearerTokenCache() + + let first = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + let second = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + + #expect(first.bearerToken == jwt) + #expect(second.bearerToken == jwt) + #expect(await stub.requests().count == 1) // minted once, reused once + } + + @Test + func `cachedOrMintedToken re-mints a token without a decodable expiry`() async throws { + let stub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"opaque-not-a-jwt\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + let cache = ZoomMateBearerTokenCache() + + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + + #expect(await stub.requests().count == 2) // undatable token is never cached + } + + @Test + func `cache serves an in-date entry but withholds one inside the refresh-skew window`() async { + let cache = ZoomMateBearerTokenCache() + let key = ZoomMateBearerTokenCache.key(forCookieHeaders: Self.sharedCookieHeaders("session=abc")) + let now = Date(timeIntervalSince1970: 1_000_000_000) + // Expiry comfortably beyond the 60s skew → served. + await cache.store( + ZoomMateBearerTokenCache.Entry( + token: "t", + accountEmail: nil, + expiry: now.addingTimeInterval(600)), + forKey: key) + #expect(await cache.validEntry(forKey: key, now: now) != nil) + + // Re-store with an expiry only 30s out (inside the 60s skew) → withheld and evicted. + await cache.store( + ZoomMateBearerTokenCache.Entry( + token: "t", + accountEmail: nil, + expiry: now.addingTimeInterval(30)), + forKey: key) + #expect(await cache.validEntry(forKey: key, now: now) == nil) + // Eviction is durable: a later lookup still misses. + #expect(await cache.validEntry(forKey: key, now: now) == nil) + } + + @Test + func `invalidate evicts a cached token so the next call re-mints`() async throws { + let jwt = Self.makeJWT(exp: 9_999_999_999) + let stub = ProviderHTTPTransportStub { request in + let body = "{\"success\": true, \"data\": {\"nak\": \"\(jwt)\"}}" + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (Data(body.utf8), response) + } + let cache = ZoomMateBearerTokenCache() + let key = ZoomMateBearerTokenCache.key(forCookieHeaders: Self.sharedCookieHeaders("session=abc")) + + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + await cache.invalidate(forKey: key) + _ = try await ZoomMateUsageFetcher.cachedOrMintedToken( + cookieHeaders: Self.sharedCookieHeaders("session=abc"), + cache: cache, + timeout: 1, + transport: stub, + logger: nil) + + #expect(await stub.requests().count == 2) + } + + #if os(macOS) + @Test + func `automatic import partitions parent and host-only cookies per destination`() throws { + func cookie(domain: String, name: String) throws -> HTTPCookie { + try #require(HTTPCookie(properties: [ + .domain: domain, + .path: "/", + .name: name, + .value: "fake", + .secure: "TRUE", + ])) + } + + let headers = try ZoomMateCookieImporter.cookieHeaders(from: [ + cookie(domain: ".zoom.us", name: "parent"), + cookie(domain: "zoom.us", name: "parent-host-only"), + cookie(domain: "ai.zoom.us", name: "ai-only"), + cookie(domain: "zoommate.zoom.us", name: "mate-only"), + cookie(domain: "marketing.zoom.us", name: "marketing-only"), + ]) + + #expect(headers.header(forHost: "ai.zoom.us") == "parent=fake; ai-only=fake") + #expect(headers.header(forHost: "zoommate.zoom.us") == "parent=fake; mate-only=fake") + } + + @Test + func `cookie scope filter follows RFC 6265 host-only and domain matching`() { + #expect(ZoomMateCookieImporter.isSendable(cookieDomain: "ai.zoom.us", toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "ai.zoom.us", toHost: "zoommate.zoom.us")) + #expect(ZoomMateCookieImporter.isSendable(cookieDomain: ".zoom.us", toHost: "ai.zoom.us")) + #expect(ZoomMateCookieImporter.isSendable(cookieDomain: ".zoom.us", toHost: "zoommate.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "zoom.us", toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "marketing.zoom.us", toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "zoom.us.attacker.com", toHost: "ai.zoom.us")) + #expect(!ZoomMateCookieImporter.isSendable(cookieDomain: "", toHost: "ai.zoom.us")) + } + #endif + + private static func makeContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: true, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } +} diff --git a/TestsLinux/LLMProxyResetLinuxTests.swift b/TestsLinux/LLMProxyResetLinuxTests.swift new file mode 100644 index 000000000..0307eee93 --- /dev/null +++ b/TestsLinux/LLMProxyResetLinuxTests.swift @@ -0,0 +1,53 @@ +#if os(Linux) +import CodexBarCore +import Foundation +import Testing + +struct LLMProxyResetLinuxTests { + // 2023-11-14T22:13:20Z — the snapshot time treated as "now". + private static let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func nextReset(resetTimes: [String]) throws -> Date? { + let groups = resetTimes + .map { "{ \"remaining_percent\": 50, \"reset_time\": \"\($0)\" }" } + .joined(separator: ", ") + let json = "{ \"providers\": { \"p\": { \"quota_groups\": [ \(groups) ] } } }" + return try LLMProxyUsageFetcher + ._parseSnapshotForTesting(Data(json.utf8), updatedAt: Self.now) + .nextResetAt + } + + private func date(_ year: Int, _ month: Int, _ day: Int) throws -> Date { + try #require(DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: year, month: month, day: day, hour: 0, minute: 0, second: 0).date) + } + + @Test + func `next reset skips already-elapsed reset times`() throws { + // A past reset (stale until the API refreshes) must not be chosen over the soonest upcoming one. + let reset = try self.nextReset(resetTimes: [ + "2023-11-01T00:00:00Z", // past (before now) + "2023-11-20T00:00:00Z", // soonest future + "2023-12-25T00:00:00Z", // later future + ]) + #expect(try abs(#require(reset).timeIntervalSince(self.date(2023, 11, 20))) < 0.001) + } + + @Test + func `all-past reset times yield no next reset`() throws { + let reset = try self.nextReset(resetTimes: [ + "2023-11-01T00:00:00Z", + "2023-10-15T00:00:00Z", + ]) + #expect(reset == nil) + } + + @Test + func `future reset time is preserved`() throws { + let reset = try self.nextReset(resetTimes: ["2023-11-20T00:00:00Z"]) + #expect(try abs(#require(reset).timeIntervalSince(self.date(2023, 11, 20))) < 0.001) + } +} +#endif diff --git a/TestsLinux/ResetCountdownDayRolloverLinuxTests.swift b/TestsLinux/ResetCountdownDayRolloverLinuxTests.swift new file mode 100644 index 000000000..474ca208d --- /dev/null +++ b/TestsLinux/ResetCountdownDayRolloverLinuxTests.swift @@ -0,0 +1,57 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCore + +struct ResetCountdownDayRolloverLinuxTests { + private static let now = Date(timeIntervalSince1970: 1_700_000_000) + + private func at(hoursFromNow hours: Double) -> Date { + Self.now.addingTimeInterval(hours * 3600) + } + + @Test + func `Windsurf web reset at exactly 24h rolls over to a day`() { + // Was "Resets in 24h 0m"; the day form must be reachable at the 24h boundary. + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } + + @Test + func `Windsurf web reset above 24h shows day and hour`() { + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 25), now: Self.now) + == "Resets in 1d 1h") + } + + @Test + func `Windsurf web reset below 24h stays in hours`() { + #expect( + WindsurfGetPlanStatusResponse.formatResetDescription(self.at(hoursFromNow: 23), now: Self.now) + == "Resets in 23h 0m") + } + + @Test + func `Windsurf cached reset at exactly 24h rolls over to a day`() { + #expect( + WindsurfCachedPlanInfo.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } + + @Test + func `Zed cycle at exactly 24h rolls over to a day`() { + // Was "Cycle ends in 24h 0m". + #expect( + ZedUsageSnapshot.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Cycle ends in 1d 0h") + } + + @Test + func `JetBrains reset at exactly 24h rolls over to a day`() { + #expect( + JetBrainsStatusSnapshot.formatResetDescription(self.at(hoursFromNow: 24), now: Self.now) + == "Resets in 1d 0h") + } +} +#endif diff --git a/TestsLinux/ZaiUsedPercentLinuxTests.swift b/TestsLinux/ZaiUsedPercentLinuxTests.swift new file mode 100644 index 000000000..481a475a3 --- /dev/null +++ b/TestsLinux/ZaiUsedPercentLinuxTests.swift @@ -0,0 +1,52 @@ +#if os(Linux) +import CodexBarCore +import Foundation +import Testing + +struct ZaiUsedPercentLinuxTests { + /// usage == nil forces computedUsedPercent to return nil, exercising the raw-percentage fallback. + private func fallbackUsedPercent(_ percentage: Double) -> Double { + ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: nil, + currentValue: nil, + remaining: nil, + percentage: percentage, + usageDetails: [], + nextResetTime: nil).usedPercent + } + + @Test + func `raw percentage fallback clamps above 100`() { + #expect(self.fallbackUsedPercent(150) == 100) + } + + @Test + func `raw percentage fallback clamps below 0`() { + #expect(self.fallbackUsedPercent(-5) == 0) + } + + @Test + func `raw percentage fallback preserves an in-range value`() { + #expect(self.fallbackUsedPercent(42) == 42) + } + + @Test + func `computed path takes precedence and ignores the raw percentage`() { + // usage(limit)=100, currentValue(used)=25 -> computed 25%, so the raw 999 must not leak. + let entry = ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: 100, + currentValue: 25, + remaining: nil, + percentage: 999, + usageDetails: [], + nextResetTime: nil) + #expect(entry.usedPercent == 25) + } +} +#endif diff --git a/docs/KEYCHAIN_FIX.md b/docs/KEYCHAIN_FIX.md index 63157a13c..33335487e 100644 --- a/docs/KEYCHAIN_FIX.md +++ b/docs/KEYCHAIN_FIX.md @@ -96,9 +96,11 @@ This is OS/keychain ACL behavior, not a `ThisDeviceOnly` migration issue. `Advanced -> Disable Keychain access` sets `debugDisableKeychainAccess` and flips `KeychainAccessGate.isDisabled`. Effects: -- Blocks keychain reads/writes in legacy stores. -- Disables keychain-backed cookie auto-import paths. -- Forces cookie source resolution to manual/off where applicable. +- Blocks keychain reads/writes in legacy stores and Claude CLI keychain bootstrap. +- Disables Chromium cookie auto-import paths that require Safe Storage keychain decryption (Safari/Firefox remain eligible). +- Keeps an in-process memory fallback only for `KeychainCacheStore` cookie session caches so Cursor (and other cookie providers) can still reconcile sessions without Keychain persistence. OAuth credential cache entries are never retained by this fallback. +- Clears that in-process fallback whenever Keychain access is toggled, so disabled-mode cookies cannot resurface after re-enabling Keychain. +- Allows Claude Auto **background** CLI when Keychain access is disabled only after a successful user-initiated CLI refresh establishes availability for the current app process. Background Auto never launches `claude auth status --json`; before foreground establishment it falls through without starting any Claude child process. When Keychain remains enabled, background Auto also requires prompt mode **Always**. ## Verification diff --git a/docs/alibaba-token-plan.md b/docs/alibaba-token-plan.md index 1d8884605..5f3a8145a 100644 --- a/docs/alibaba-token-plan.md +++ b/docs/alibaba-token-plan.md @@ -1,5 +1,5 @@ --- -summary: "Alibaba Token Plan provider notes: Bailian cookie auth, subscription summary endpoint, and setup." +summary: "Alibaba Token Plan provider notes: Team and Personal/Solo variants, cookie auth, and setup." read_when: - Adding or modifying the Alibaba Token Plan provider - Debugging Alibaba Token Plan cookie import or subscription summary fetching @@ -8,11 +8,12 @@ read_when: # Alibaba Token Plan Provider -The Alibaba Token Plan provider tracks Bailian token-plan credits from the Alibaba Cloud console. +The Alibaba Token Plan provider tracks Team credits and Personal/Solo rolling-window usage from the Alibaba Cloud console. ## Features - **Token-plan usage display**: Shows used, total, and remaining token-plan credits when Bailian returns quota totals. +- **Personal/Solo windows**: Shows 5-hour and 7-day usage, reset times, and tier-specific quota totals. - **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. - **Expiry awareness**: Shows the nearest token-plan expiration date as the reset time when the subscription summary includes it. @@ -20,27 +21,32 @@ The Alibaba Token Plan provider tracks Bailian token-plan credits from the Aliba 1. Open **Settings -> Providers** 2. Enable **Alibaba Token Plan** -3. Leave **Cookie source** on **Auto** (recommended) +3. Choose the matching **Gateway region** Team or Personal/Solo variant +4. Leave **Cookie source** on **Auto** (recommended) ### Manual cookie import (optional) -1. Open `https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan` -2. Copy a `Cookie:` header from your browser's Network tab +1. Open the Token Plan page using **Open Token Plan** in settings +2. Copy a `Cookie:` header from the quota request in your browser's Network tab. For Personal/Solo, use the + `.../tokenplan/personal/api/v2/usage` request so the header is scoped to the quota host. 3. Paste it into **Alibaba Token Plan -> Cookie source -> Manual** ## How it works -- Fetches `POST https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3&_tag=` -- Sends form-encoded fields for `product=BssOpenAPI-V3`, `action=GetSubscriptionSummary`, `region=cn-beijing`, and `params={"ProductCode":"sfm_tokenplanteams_dp_cn"}` -- Uses Alibaba/Bailian login cookies, with `sec_token` added when it can be resolved from the dashboard page -- Parses `TotalValue`, `TotalSurplusValue`, `TotalCount`, and `NearestExpireDate` from the subscription summary response +- Team variants fetch `GetSubscriptionSummary` from the selected international or mainland console and parse the + credit pool without probing Personal endpoints. +- Personal/Solo variants fetch `usage`, `subscription`, and `quota-config` from the rolling-window API. International + uses `bailian-singapore-cs.alibabacloud.com`; mainland uses `bailian-cs.console.aliyun.com`. +- Browser cookies are rebuilt independently for the dashboard and quota hosts. Personal/Solo requests use the + quota-host cookie header and support cookie-only auth without requiring `sec_token`. +- Config region values are `intl` and `cn` for Team, or `intl-personal` and `cn-personal` for Personal/Solo. - Supports `ALIBABA_TOKEN_PLAN_HOST` and `ALIBABA_TOKEN_PLAN_QUOTA_URL` for testing endpoint overrides ## Limitations - Alibaba Token Plan currently supports the Bailian web-cookie path only - API-key auth, token cost summaries, and automatic status polling are not supported -- The default endpoint is the China mainland Bailian token-plan subscription summary +- Live provider auth is not exercised by the test suite; endpoint changes rely on reporter confirmation after release ## Troubleshooting diff --git a/docs/claude.md b/docs/claude.md index 0b0d21237..0e612db0a 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -67,8 +67,10 @@ Admin API key setup: - Claude CLI Keychain bootstrap/repair fallback: `Claude Code-credentials`. - On Claude Code 2.1.x, `Claude Code-credentials` may contain only MCP server OAuth state (`mcpOAuth`) with no `claudeAiOauth`. QuotaKit treats that as an OAuth configuration error, does not run background delegated `claude /status` refresh, and surfaces re-auth guidance. Use Web or CLI usage source, or restore a valid Claude OAuth keychain entry. See #1844. - Requires `user:profile` scope (CLI tokens with only `user:inference` cannot call usage). -- Endpoint: +- Endpoints: - `GET https://api.anthropic.com/api/oauth/usage` + - `GET https://api.anthropic.com/api/oauth/profile` → account identity used to verify that optional Web enrichment + belongs to the same Claude account. - Headers: - `Authorization: Bearer ` - `anthropic-beta: oauth-2025-04-20` @@ -80,6 +82,9 @@ Admin API key setup: - `seven_day_routines` / `seven_day_cowork` → Daily Routines extra window. - Claude Design/Omelette keys are ignored because Claude Design shares the main Claude usage limit. - `extra_usage` → Extra usage cost (monthly spend/limit). +- Preferences → Providers → Claude → Show Daily Routines usage hides only the Daily Routines row in menus and the + provider preview. The global optional credits and extra usage setting is its master switch. The Claude-specific + setting does not change fetching, history, notifications, widgets, hooks, model-scoped weekly limits, or CLI output. - Successful OAuth login enables Claude and preserves the selected usage source. With the default Auto source, OAuth remains preferred when readable, while CLI/Web fallback stays available when OAuth credentials are not usable. - Plan inference: `subscriptionType` is preferred when present; `rate_limit_tier` falls back to @@ -108,11 +113,13 @@ Admin API key setup: - `GET https://claude.ai/api/organizations` → org UUID. - `GET https://claude.ai/api/organizations/{orgId}/usage` → session/weekly/opus. - `GET https://claude.ai/api/organizations/{orgId}/overage_spend_limit` → Extra usage spend/limit. + - `GET https://claude.ai/api/organizations/{orgId}/prepaid/credits` → remaining Usage credits balance. - `GET https://claude.ai/api/account` → email + plan hints. - Outputs: - Session + weekly + model-specific percent used. - Daily Routines extra window when returned by the usage API. - Extra usage spend/limit (if enabled). + - Remaining Usage credits balance (if enabled). - Account email + inferred plan. ## claude-swap accounts (opt-in) diff --git a/docs/cli.md b/docs/cli.md index 1a97779d0..b8ab7f3a5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -109,7 +109,8 @@ See `docs/configuration.md` for the schema. - Codex web: OpenAI web dashboard (usage limits, credits remaining, code review remaining, usage breakdown). - `--web-timeout ` (default: 60) - `--web-debug-dump-html` (writes HTML snapshots to `/tmp` when data is missing) - - Claude web: claude.ai API (session + weekly usage, plus account metadata when available). + - Claude web: claude.ai API (session + weekly usage, account metadata, and prepaid Usage credits balance when + available). - Command Code web: commandcode.ai browser session cookies on macOS, or a configured manual cookie on Linux, for monthly credit usage. - OpenCode Go auto: local SQLite usage on macOS and Linux, with optional manual-cookie web enrichment. - Kilo auto: app.kilo.ai API first, then CLI auth fallback (`~/.local/share/kilo/auth.json`) on missing/unauthorized API credentials. diff --git a/docs/codex-workspaces.md b/docs/codex-workspaces.md new file mode 100644 index 000000000..20e71f93d --- /dev/null +++ b/docs/codex-workspaces.md @@ -0,0 +1,100 @@ +# Codex Workspaces local index + +Codex Workspaces attributes the existing local Codex cost scan to projects, +sessions, models, and days. This foundation is local-only library behavior; it +does not add a remote API, provider authentication flow, billing interface, or +public CLI JSON contract. + +## Data flow + +`CostUsageScanner` remains authoritative for JSONL parsing, cumulative-token +deltas, fork and subagent accounting, pricing, and incremental cursors. The +Workspaces index combines that scan cache with the read-only Codex thread +catalog: + +1. Scan local rollout JSONL into the Codex v11 cost cache. +2. Read catalog metadata without modifying the Codex catalog. +3. Canonicalize workspace attribution and scope it to the selected Codex home. +4. Publish the complete source state and derived snapshot in one SQLite + transaction. +5. Expose project, session, model, daily, source-status, progress, and CSV + library models to presentation consumers. + +The supported internal presentation boundary is: + +- `CostUsageFetcher.loadCachedCodexLocalProjectUsageSnapshot` +- `CostUsageFetcher.loadCodexLocalProjectUsageSnapshot` +- `CostUsageFetcher.clearCachedCodexLocalProjectUsageSnapshot` +- `CodexLocalProjectUsageSnapshot` +- `CodexLocalProjectUsageIndexProgress` + +## Persistence contracts + +The Codex cost cache is: + +```text +~/Library/Caches/CodexBar/cost-usage/codex-v11.json +``` + +The Workspaces sidecar is: + +```text +~/Library/Caches/CodexBar/local-usage/codex-workspaces-v1.sqlite +``` + +The sidecar currently uses SQLite schema version 5 and snapshot payload format +3. This is the first released Workspaces schema. A database with any other +`PRAGMA user_version` is rejected as incompatible and is never modified. + +### v10 to v11 + +`codex-v10.json` is not migrated in place and is not accepted as a v11 +incremental cursor. Parser and attribution semantics changed, so the first scan +after upgrading performs a one-time rebuild and writes a separate v11 artifact. +The v10 file remains untouched and recoverable. The v11 file is replaced +atomically only after a successful scan; later v11 refreshes can use the normal +incremental cursor. + +### Sidecar rollback + +Publication is transactional: a failed synchronization or snapshot write rolls +back and leaves the previous complete snapshot available. + +## Catalog completeness and last-good data + +Catalog access distinguishes complete, missing, locked, corrupt, and +incompatible states. A complete read replaces catalog metadata for that Codex +home scope. A later same-scope incomplete read reports partial or stale source +status while retaining the last-good catalog attribution and usage snapshot. +Sparse rollout updates do not erase retained titles, workspace paths, or other +catalog metadata. + +Scope identifiers and invalidation fingerprints are hashes; raw Codex-home +paths are not persisted as scope identifiers. Changed and deleted rollout files, +parser or pricing changes, history-window changes, and catalog changes +invalidate only the affected cached state. + +## Cost and display semantics + +Known model cost and unknown-cost coverage are represented separately. Unknown +pricing never becomes a synthetic zero-cost claim. Models analytics, parity +checks, performance telemetry, and CSV serialization operate on the same +persisted snapshot. + +Display-only projections are transient: + +- Hiding estimated cost does not rewrite the sidecar. +- Including or excluding cached input does not rescan or rewrite the sidecar. +- Hiding personal information removes persisted workspace paths, working + directories, workspace names, and session titles from snapshots before they + reach presentation code; it does not rewrite the local sidecar. +- Rankings, totals, charts, and breakdowns must use the same projection. + +## Privacy boundary + +The scanner, catalog reader, cache, sidecar, analytics, and CSV serializer run +locally. They do not upload rollout contents or catalog records. The stored +index can contain local workspace paths, session metadata, token totals, and +derived cost estimates, so runtime evidence must be sanitized before +publication. Remove paths, titles, session IDs, identities, tokens, keys, +network addresses, and database row contents from shared logs and screenshots. diff --git a/docs/codex.md b/docs/codex.md index 6abacc96e..159cda7c1 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -165,8 +165,8 @@ Example: - Native conversation rows reuse the corrected cached per-file totals and existing pricing tables. They are hidden when pi usage joins the aggregate because the native-only rows would not reconcile with the merged total. - Cache: - - Native + merged provider cache: `~/Library/Caches/QuotaKit/cost-usage/codex-v10.json` - - pi session cache: `~/Library/Caches/QuotaKit/cost-usage/pi-sessions-v6.json` + - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/codex-v11.json` + - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v7.json` - Window: configurable 1-365 day rolling history, with a 60s minimum refresh interval. ### Usage & Spend account rows diff --git a/docs/configuration.md b/docs/configuration.md index dfaef4632..686417d93 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -271,7 +271,7 @@ z.ai team accounts also use `usageScope`, `organizationId`, and `workspaceID`; s ## Provider IDs Current IDs (see `Sources/CodexBarCore/Providers/Providers.swift`): -`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `warp`, `openrouter`, `elevenlabs`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`. ## Ordering The order of `providers` controls display/order in the app and CLI. Reorder the array to change ordering. diff --git a/docs/grok.md b/docs/grok.md index 41db96a6c..f961f4898 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -43,8 +43,14 @@ browser session when the CLI surface does not expose billing. browser session, then retries that session with cookies only. - QuotaKit imports Chrome only by default to avoid unrelated browser Keychain prompts. - - CLI/test runtime does not import browser cookies unless - `CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT=1` is set. + - Ordinary CLI/test runtime does not import browser cookies unless + `CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT=1` is set. An explicit + `quotakit cookie refresh --provider grok` also opts in for that refresh. + - Validated sessions are stored in the Keychain-backed cookie cache and are + reused first by later app and CLI fetches, so background work does not + re-open the Chromium Keychain gate. The cached cookie is evicted only on + authentication failures (HTTP 401/403 or gRPC auth statuses); a cached + team-limited session keeps degrading to identity-only data. - `~/.grok/auth.json` is still used for identity and as a last best-effort bearer-only probe after browser sessions fail. Expired tokens are not sent. - Parses the returned protobuf enough to recover used percent and diff --git a/docs/index.html b/docs/index.html index c96bf806b..f5c34660d 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ QuotaKit — every AI coding limit in your menu bar @@ -14,7 +14,7 @@ @@ -85,7 +85,7 @@

Every AI coding limit, in your menu bar.

-

63 providers, one menu bar

+

65 providers, one menu bar

Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.

    diff --git a/docs/llms.txt b/docs/llms.txt index 56e692a6b..d8d59b021 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,9 +1,9 @@ # QuotaKit -A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 63 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 65 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Canonical documentation: -- QuotaKit — every AI coding limit in your menu bar: https://columbus-labs.com/quotakit/mac - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 63 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +- QuotaKit — every AI coding limit in your menu bar: https://columbus-labs.com/quotakit/mac - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 65 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Source: https://github.com/ColumbusLabs/QuotaKit diff --git a/docs/providers.md b/docs/providers.md index 48aeeda1b..9d2e6e46d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -QuotaKit currently registers 63 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +QuotaKit currently registers 65 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -45,6 +45,7 @@ scan fails, while provider/account configuration changes replace obsolete result | OpenCode Go | Unscoped Auto: local SQLite usage (`local`) → web dashboard (`web`). Scoped Auto (selected account/manual cookie/workspace): web → local. Explicit Web: web only. | | Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). | | Alibaba Token Plan | Bailian subscription summary API via browser or manual cookies (`web`). | +| Qwen Cloud | Qwen Cloud 5-hour/weekly Token Plan APIs via browser or manual cookies (`web`). | | Droid/Factory | API key (`FACTORY_API_KEY` / config) → web cookies → stored tokens → local storage → WorkOS cookies (`auto`, `api`, `web`). | | Devin | Chrome localStorage session or manual Bearer token → daily and weekly quota API (`web`). | | z.ai | API token from config/env → quota API (`api`). | @@ -59,6 +60,7 @@ scan fails, while provider/account configuration changes replace obsolete result | JetBrains AI | Local XML quota file (`local`). | | Amp | Local `amp usage` CLI, access-token API, then browser-cookie legacy fallback (`cli`, `api`, `web`). | | T3 Chat | Web tRPC customer-data endpoint via browser cookies (`web`). | +| ZoomMate | Chrome cookie auto-import + cookie-to-token minting, or manual cURL capture, for the credits/status API (`web`). | | Warp | API token (config/env) → GraphQL request limits (`api`). | | ElevenLabs | API key from config/env → subscription usage API (`api`). | | Windsurf | Web session bundle from browser localStorage (`web`) → local SQLite cache (`local`). | @@ -216,13 +218,47 @@ scan fails, while provider/account configuration changes replace obsolete result - Details: `docs/alibaba-coding-plan.md`. ## Alibaba Token Plan -- Web mode posts to the Bailian `GetSubscriptionSummary` endpoint with form-encoded params and optional `sec_token`. +- Explicit Team variants post to `GetSubscriptionSummary`; explicit Personal/Solo variants fetch the 5-hour and + weekly rolling windows plus subscription/quota metadata without probing across plan types. - Cookie sources: browser import (`auto`), manual Cookie header, or `ALIBABA_TOKEN_PLAN_COOKIE`. -- Default quota URL: `https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3`. +- Region values: `intl` / `cn` for Team and `intl-personal` / `cn-personal` for Personal/Solo. +- Personal quota hosts: `bailian-singapore-cs.alibabacloud.com` (international) and + `bailian-cs.console.aliyun.com` (mainland), with cookies scoped independently from the dashboard host. - Host overrides: `ALIBABA_TOKEN_PLAN_HOST` or `ALIBABA_TOKEN_PLAN_QUOTA_URL`. - Status: `https://status.aliyun.com` (link only, no auto-polling). - Details: `docs/alibaba-token-plan.md`. +### Aliyun OneConsole family + +Alibaba Coding Plan, Alibaba Token Plan, and Qwen Cloud all run on the Aliyun OneConsole +backend. They selectively reuse plumbing under +`Sources/CodexBarCore/Providers/Shared/AliyunOneConsole/`: + +- `AliyunOneConsoleCookieImporter` — browser cookie iteration, Chromium fallback, Keychain preflight. + The Alibaba providers and Qwen Cloud supply their own cookie domains and authenticated-session predicate. +- `OneConsoleCookieHeaders` / `OneConsoleCookieHeaderBuilder` — `apiCookieHeader` / `dashboardCookieHeader` + pair with cached-header round-trip, currently used by Alibaba Token Plan and Qwen Cloud. +- `OneConsoleJSON` — recursive expand-embedded-JSON traversal and scalar coercion (`number`, `int`, + `string`, `date`, `percentagePoints`), used by all three providers. +- `OneConsoleSECTokenResolver` — dashboard HTML → cookie → user-info chain, currently used by Qwen Cloud. +- `OneConsoleCookieRouting` — Qwen Cloud's provider-local redirect policy. It pins dashboard/API cookies + to their matching trusted origin, strips credentials from cross-origin GET/HEAD navigation, and blocks + cross-origin redirects that preserve a request body. + +Future OneConsole-based providers can adopt the helpers that match their actual protocol while keeping +provider-specific cookie validation, endpoints, login detection, and error translation at the provider boundary. + +## Qwen Cloud +- Web mode resolves `sec_token` through the dashboard (`home.qwencloud.com`), then posts to the current + individual Token Plan usage, subscription, and quota-configuration APIs on `cs-data.qwencloud.com`. +- Displays 5-hour and weekly consumed percentages, reset times, active tier, and tier-specific credit limits. +- Cookie sources: Chrome import (`auto`), manual Cookie header, or `QWEN_CLOUD_COOKIE`. +- Default data gateway: + `https://cs-data.qwencloud.com/data/api.json?action=IntlBroadScopeAspnGateway&product=sfm_bailian`. +- Host overrides: `QWEN_CLOUD_HOST` or `QWEN_CLOUD_QUOTA_URL` (HTTPS URLs or bare hosts normalized to HTTPS). +- Status: `https://status.alibabacloud.com` (link only, no auto-polling). +- Details: `docs/qwen-cloud.md`. + ## Droid (Factory) - API key from `~/.quotakit/config.json` (`providers[].apiKey`), `FACTORY_API_KEY`, or `~/.factory/.env`. - Web API via Factory cookies, bearer tokens, and WorkOS refresh tokens. @@ -305,6 +341,22 @@ scan fails, while provider/account configuration changes replace obsolete result - Status: none yet. - Details: `docs/t3chat.md`. +## ZoomMate +- Credits API endpoint (`https://ai.zoom.us/ai-computer/api/v1/credits/status`) authenticated via a + bearer token. +- Auto-imports ZoomMate/Zoom session cookies from Chrome, validates and stores the narrowed cookie + header in QuotaKit's Keychain cache, and exchanges it for a short-lived bearer token through + ZoomMate's own cookie-to-token bootstrap endpoint. The bearer remains in memory only and is reused + until it nears expiry. Manual cURL capture is available as an explicit alternative. +- Shows a single "Credits" window: used/remaining credits against a budget cap, resetting at the + billing cycle end, plus an inline Today/30-day credits history chart and pacing verdict (on + track/behind/ahead of budget). +- Status: `https://www.zoomstatus.com/` (Statuspage.io); the component drill-down is filtered to a + named allowlist ("Zoom Meetings", "ZoomMate", "My Notes", "Zoom Workflows", "Zoom Developer + Platform", "Zoom Support", "Zoom Website") rather than showing Zoom's full ~300-component status + page. +- Details: `docs/zoommate.md`. + ## Ollama - Web settings page (`https://ollama.com/settings`) via browser cookies. - Parses Cloud Usage plan badge, session/weekly usage, and reset timestamps. @@ -435,7 +487,10 @@ scan fails, while provider/account configuration changes replace obsolete result - `grok agent stdio` (ACP) JSON-RPC `x.ai/billing` method; requires `grok login` (SuperGrok OAuth/OIDC). - Reads cached credentials from `~/.grok/auth.json` for identity (email, team). - Falls back to grok.com's billing gRPC-web endpoint via Chrome session cookies when the CLI does not expose billing. -- CLI/test runs do not import browser cookies unless `CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT=1` is set. +- Ordinary CLI/test runs do not import browser cookies unless `CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT=1` is set; + `quotakit cookie refresh --provider grok` opts in for its explicit refresh. +- Validated sessions are cached in the Keychain cookie cache and reused before any new browser import; + the cache is evicted only on authentication failures. - Local fallback aggregates `~/.grok/sessions/**/signals.json` token counts when the RPC is unavailable. - Status: link only to `https://status.x.ai` (no auto-polling yet). - Details: `docs/grok.md`. diff --git a/docs/qwen-cloud.md b/docs/qwen-cloud.md new file mode 100644 index 000000000..d2e787f97 --- /dev/null +++ b/docs/qwen-cloud.md @@ -0,0 +1,68 @@ +--- +summary: "Qwen Cloud provider notes: cookie auth, 5-hour and weekly token-plan usage, and setup." +read_when: + - Adding or modifying the Qwen Cloud provider + - Debugging Qwen Cloud cookie import or token-plan usage fetching + - Explaining Qwen Cloud setup and limitations to users +--- + +# Qwen Cloud Provider + +The Qwen Cloud provider tracks the **token plan (individual)** subscription credits from the +Qwen Cloud console (`home.qwencloud.com`), including plans that grant hosted Claude model access. + +## Features + +- **Current quota windows**: Shows 5-hour and weekly usage percentages, reset times, and plan-specific + credit limits from the same APIs used by the Qwen Cloud dashboard. +- **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. +- **Adjustable menu-bar display**: In **Settings → Menu Bar**, add the session/weekly percentage or usage-bar + items and arrange them like any other provider. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Qwen Cloud** +3. Leave **Cookie source** on **Auto** (recommended) + +### Manual cookie import (optional) + +1. Open `https://home.qwencloud.com/billing/subscription/token-plan-individual` +2. Copy a `Cookie:` header from your browser's Network tab +3. Paste it into **Qwen Cloud → Cookie source → Manual** + +## How it works + +- Calls Qwen Cloud's current individual Token Plan APIs through the `sfm_bailian` console gateway: + `personal/api/v2/usage`, `personal/api/v2/subscription`, and `personal/api/v2/quota-config`. +- The usage response supplies the 5-hour and weekly consumed ratios and reset times. The subscription response + identifies the active tier, and quota configuration supplies that tier's numeric credit limits. +- Sends form-encoded fields for `product=sfm_bailian`, `action=IntlBroadScopeAspnGateway`, + `region=ap-southeast-1`, `language=en-US`, a resolved `sec_token`, and the provider-native API payload. +- Uses Qwen Cloud / alibabacloud login cookies, with `sec_token` resolved from the dashboard HTML, + a `sec_token` cookie, or the `/tool/user/info.json` endpoint +- Supports `QWEN_CLOUD_HOST` and `QWEN_CLOUD_QUOTA_URL` for testing endpoint overrides, and + `QWEN_CLOUD_COOKIE` for an environment-supplied cookie header +- Endpoint overrides accept full `https://` URLs or bare hosts (for example, + `QWEN_CLOUD_HOST=home.qwen-cloud.test`), which are normalized to HTTPS; non-HTTPS schemes are rejected + +## Limitations + +- Qwen Cloud currently supports the web-cookie path only +- API-key auth, token cost summaries, and automatic status polling are not supported +- The default endpoint targets the international Qwen Cloud individual Token Plan APIs + +## Troubleshooting + +### "No Qwen Cloud session cookies found in browsers" + +Log in at `https://home.qwencloud.com/billing/subscription/token-plan-individual` in Chrome, then refresh QuotaKit. + +### "Qwen Cloud cookie header is invalid" + +The pasted header is empty or not a valid Cookie header. Re-copy the request from the Token Plan page after +logging in again. + +### "Qwen Cloud login required" + +Your Qwen Cloud session is stale. Sign out and back in on the Qwen Cloud console, then refresh QuotaKit. diff --git a/docs/refactor/claude-current-baseline.md b/docs/refactor/claude-current-baseline.md index c98394da6..49e14ba2d 100644 --- a/docs/refactor/claude-current-baseline.md +++ b/docs/refactor/claude-current-baseline.md @@ -115,9 +115,12 @@ Current routing rules: ## Siloing and web-enrichment baseline -Claude Web enrichment is cost-only when the primary source is OAuth or CLI: +Claude Web enrichment is usage-extra-only when the primary source is OAuth or CLI: -- Web extras may populate `providerCost` when it is missing. +- Web extras may add optional rate windows and may populate `providerCost` when it is missing. +- A matching prepaid balance may enrich an existing `providerCost` without replacing its spend/limit values. +- OAuth enrichment calls `GET /api/oauth/profile`; Web data is merged only when the email or organization UUID + matches the primary Claude account. - Web extras must not replace `accountEmail`, `accountOrganization`, or `loginMethod` from the primary source. - Snapshot identity remains provider-scoped to Claude. diff --git a/docs/social.html b/docs/social.html index 928889f9b..d9faeb2de 100644 --- a/docs/social.html +++ b/docs/social.html @@ -199,7 +199,7 @@

    Every AI coding limit, in your menu bar.

    -

    63 providers·usage windows, credits, resets·one status item each, or merged.

    +

    65 providers·usage windows, credits, resets·one status item each, or merged.

      diff --git a/docs/zoommate.md b/docs/zoommate.md new file mode 100644 index 000000000..ad24d6d1d --- /dev/null +++ b/docs/zoommate.md @@ -0,0 +1,267 @@ +--- +summary: "ZoomMate provider auth, credits endpoint, history/pacing, and status page." +read_when: + - Adding or modifying the ZoomMate provider + - Debugging ZoomMate token import or usage parsing + - Explaining ZoomMate setup +--- + +# ZoomMate Provider + +The ZoomMate provider tracks credit usage against a budget cap from +[zoommate.zoom.us](https://zoommate.zoom.us). ZoomMate is a credits-based quota — there is no +session/weekly rate window, just a single primary "Credits" window. + +QuotaKit's ZoomMate branding uses Zoom's official core palette: Bloom (`#0B5CFF`) as the primary +color, with Dawn (`#B4D0F8`) and Midnight (`#00053D`) as supporting colors. Provenance: +[Zoom Brand Center, Visual identity — Color](https://brand.zoom.com/document/1#/visual-identity/color), +retrieved 2026-07-18 (page last modified 2025-08-28). + +## Setup + +### Automatic (recommended, Chrome only) + +1. Sign in to ZoomMate in Chrome. +2. Enable **ZoomMate** in **Settings → Providers**. **Cookie source** defaults to **Auto**. + +QuotaKit imports your ZoomMate/Zoom session cookies from Chrome's cookie jar (covering +`zoommate.zoom.us`, `ai.zoom.us`, and the parent `zoom.us` domain, where Zoom's SSO session cookies +actually live) and exchanges them for a short-lived bearer token via the same +cookie-to-token bootstrap endpoint ZoomMate's own web app uses internally +(`GET https://ai.zoom.us/ai-computer/api/v1/login/?continue=...`) — no manual paste required. This +automatic path intentionally tries Chrome only to avoid surprise prompts from other browser stores; +other browsers must use manual capture. + +Because the bearer token is minted from your (much longer-lived) session cookies, this keeps working +as long as you stay signed in to ZoomMate in Chrome. A minted token is reused from an in-memory cache +across refreshes until it nears expiry, then re-minted from the same cookies — no re-paste required. +If the Chrome session cookie is missing, QuotaKit reports `noSession`; if Zoom rejects an existing +session, QuotaKit reports `invalidCredentials`. + +Chrome's cookie decryption key lives in the macOS Keychain, so QuotaKit only reads Chrome's cookie +store during user-initiated refreshes (a menu or Settings refresh, or `quotakit cookie`). Once a +fresh import validates — the cookie-to-token mint succeeds — the validated host-scoped cookie headers are saved to +QuotaKit's shared Keychain cookie cache (`com.steipete.codexbar.cache`, account `cookie.zoommate`, +same as other cookie providers) and reused before re-importing from Chrome. Background refreshes and +the bundled `quotakit` CLI run entirely from those cached headers, so they never touch Chrome's cookie +store or trigger a Keychain prompt. If Zoom rejects the cached session, QuotaKit drops it and retries +one fresh Chrome import (user-initiated contexts only; background refreshes report `noSession` until +the next user refresh). + +### Manual + +Set **Cookie source** to **Manual** in the ZoomMate provider settings, then paste a full `curl` +command captured from DevTools: + +1. Open [zoommate.zoom.us](https://zoommate.zoom.us) and sign in. +2. Navigate to the AI credit usage page (Settings → AI credit usage, or equivalent). +3. Open Developer Tools → Network tab. +4. Reload the page and find the `credits/status` request + (`GET https://ai.zoom.us/ai-computer/api/v1/credits/status`). +5. Right-click → Copy → Copy as cURL. +6. Paste the full `curl` command into the **ZoomMate capture** field in QuotaKit settings. + +Unlike some other manual-paste providers, the forwarded header allowlist for ZoomMate **includes +`Authorization`** — that's the required credential (see "Common errors" below). `Cookie` and +selected browser headers may also be forwarded, but `Origin` and `Referer` are always replaced with +the fixed `https://zoommate.zoom.us` value. Captures are accepted only for the HTTPS +`/ai-computer/api/v1/credits/status` endpoint on `ai.zoom.us` or `zoommate.zoom.us` — DevTools may +show the request on either host, since both currently serve the same first-party API. A captured +`Cookie` header is bound to that request's host: QuotaKit tries the captured host first and never +forwards its cookies if a non-auth failure falls back to the sibling host. + +## Token expiry (~hourly) + +The ZoomMate bearer token itself is short-lived — typically about an hour. Session cookies live far +longer, so: + +- **Automatic mode**: no action needed as long as you stay signed in to ZoomMate in Chrome; + QuotaKit mints a bearer token from your session cookies and reuses it (in memory) until it nears + expiry, then re-mints automatically. +- **Manual mode**: re-capture and re-paste a fresh `curl` command when the pasted token expires + (surfaced as `invalidCredentials` — see below). This is expected behavior, not a bug — switching + to Automatic mode avoids it entirely. + +## Auth & privacy + +Automatic mode uses the same first-party ZoomMate web-client flow a signed-in browser uses: +QuotaKit imports Zoom session cookies from the local Chrome cookie jar, partitions them into the +headers a browser would send to each fixed API host, exchanges one of those host-scoped headers for +a short-lived bearer token, and then uses that bearer token for the `credits/status` and +`credits/history` requests. + +The minted bearer token is never persisted — it is held only in a process-lifetime in-memory cache. +The validated host-scoped cookie headers (and only those headers — never the bearer) are persisted to QuotaKit's +existing shared Keychain cookie cache after a successful mint, the same cache and lifecycle other +cookie providers (Claude web, Perplexity, OpenCode, …) already use, so background refreshes and the +bundled CLI can reuse the session without rereading Chrome. ZoomMate logs intentionally omit +cookies, bearer tokens, `nak` values, and raw response bodies. The +cookie read spans the parent `zoom.us` domain because Zoom's SSO session cookies are parent-scoped, +but the imported set is then narrowed to only cookies a browser would actually attach to +`ai.zoom.us` / `zoommate.zoom.us` (RFC 6265 domain-matching), dropping cookies host-scoped to +unrelated `*.zoom.us` siblings that these endpoints never receive. + +All authentication and credit requests use fixed HTTPS URLs on the two first-party API hosts: +automatic mode tries `ai.zoom.us` first, while manual mode starts with the host in the accepted cURL +capture; either mode falls back to the other host on non-auth failures. Each request receives only +the cookies scoped to its destination host. The hosts currently serve the same `/ai-computer/` API +interchangeably and either may retire in the future, so the provider works with both; auth rejections +(401/403) never trigger the fallback since the host answered and the session is the problem. +`zoommate.zoom.us` additionally provides the product UI and +the fixed web-client `continue`, `Origin`, and `Referer` values. The parent `zoom.us` scope is used +only to discover parent-scoped SSO cookies; it does not authorize requests to arbitrary Zoom +subdomains. ZoomMate does not currently expose a +documented public API, so this provider depends on the product's first-party web-client endpoints. + +This cookie-to-bearer shape follows an existing QuotaKit precedent in the Factory provider's WorkOS +cookie exchange. The minted bearer is cached in memory (keyed by a SHA-256 of the cookie session) +and reused until it nears its JWT `exp`; a token whose expiry can't be read is never cached, and a +`401/403` from a downstream request evicts the cached token so the next refresh mints fresh. + +## Data Source + +QuotaKit sends up to a few GET requests per refresh: + +```text +GET https://ai.zoom.us/ai-computer/api/v1/credits/status +GET https://ai.zoom.us/ai-computer/api/v1/credits/history?app_id=demo_app&limit=50&page=&sort_by=time&sort_order=desc&start_time=&end_time= +``` + +(Automatic requests retry once on `zoommate.zoom.us` with the same path if `ai.zoom.us` fails with a +non-auth error. Manual requests start on the captured host and retry the other host without the +captured host's cookies — see "Auth & privacy" above.) + +The `credits/status` response's `data.credit_status` object is decoded into a +`ZoomMateCreditStatus` struct. The `credits/history` request is paginated (looping on `page` until +`page * limit + records.length` reaches the response's flat `data.total`, or a page's records are +entirely older than the requested `start_time`) to cover the last 30 days; real accounts have +modest history (tens of records total), so this is normally 1–2 requests. `app_id` is sent as a +fixed placeholder matching ZoomMate's own web UI — it does not scope the result set to a particular +integration. A failed `credits/history` fetch is non-fatal: it never blocks the primary +`credits/status` snapshot, it just means the history dashboard (below) is omitted for that refresh. + +### Fields mapped to the Credits window + +| Source field | QuotaKit mapping | +|---|---| +| `used_credit` / `budget_cap` | Primary window `usedPercent` (clamped 0–100) | +| `cycle_end_date` | Primary window reset time (epoch ms) | +| `is_unlimited` / `budget_cap <= 0` | `usedPercent` forced to 0, reset countdown omitted | + +There is no secondary window; `resetDescription` is always "Credits". + +### Account identity (Automatic mode only) + +The same login bootstrap response used to mint the bearer token (`GET +.../login/?continue=...`) also carries a `data.user_profile` object with the signed-in +user's account details. QuotaKit reads `user_profile.email` from it and surfaces it as the +provider's `accountEmail` — the same identity field Codex/Claude populate — so the menu card's +account row shows who is signed in, matching those providers' presentation. `loginMethod` is +set to `"Cookie"` whenever an email was resolved this way. This is purely additive enrichment: +a missing/absent `user_profile` or `email` never fails the token mint, it just leaves identity +unset. Manual (`.web` cURL-capture) mode has no equivalent bootstrap call, so `accountEmail` +stays `nil` there. + +## Credits history and pacing + +When ZoomMate is enabled and history data is available, the menu's Credits card shows a Today/30d +credits dashboard and pacing verdict **inline**, directly under the credits progress bar — on both +ZoomMate's own tab and the Overview aggregate view, matching Claude's/Codex's inline dashboards. It +includes: + +- Two KPI tiles, reusing the same `InlineUsageDashboardContent` component Claude/Codex render + their inline dashboards with: **Today** (emphasized — the current calendar day's summed `cost` + from `credits/history`, or 0 if nothing posted yet today) and **30d credits** (the sum across the + full 30-day window). This mirrors Codex's "Today" / "30d cost" tile pair and Claude's "Today" / + "30d spend" tile, swapping the "$"/cost unit for "credits" since ZoomMate has no dollar-cost + concept. +- A row of mini usage bars below the tiles, one per calendar day that has ZoomMate usage + (`credits/history`'s per-event ledger summed by day), capped at the most recent 30 days, rendered + in ZoomMate's brand color (`#0B5CFF`) — matching Codex/Claude's own bar density and per-provider + coloring exactly. +- A pacing line ("Pace: on track" / "Pace: N% ahead of budget" / "Pace: N% behind budget") rendered + as a plain inline-dashboard detail line below the mini-bars. It's computed from `credits/status`'s + `budget_cap`, cumulative `used_credit`, and the current billing cycle's + `cycle_start_date`/`cycle_end_date` — comparing actual usage against the expected + linear-elapsed-fraction of the cycle. This reuses `UsagePace`'s existing stage thresholds (on + track / slightly ahead / ahead / far ahead / slightly behind / behind / far behind) rather than + a ZoomMate-specific scale, so the wording matches other QuotaKit pacing indicators. + +`is_deleted` history records are excluded from both the KPI tiles and the mini-bars; still-running +sessions (`is_running: true`) are included since their `cost` reflects consumption so far. The +30-day window is enforced independently at both fetch time (the request's `start_time`) and +display time (`dailyBreakdown()` filters to the trailing 30 calendar days regardless of what the +fetch returned), so the chart's calendar span is guaranteed either way. The pacing line only needs +the always-fetched `credits/status` snapshot, so it can appear even in refreshes where +`credits/history` fails or returns nothing. The inline section is gated on having either a +non-empty daily breakdown or a computable pacing verdict — an empty/failed history fetch silently +omits the section instead of showing an empty dashboard. + +## Status page + +ZoomMate's descriptor points at Zoom's public status page, +[zoomstatus.com](https://www.zoomstatus.com/), which is an Atlassian Statuspage.io site — the same +platform Claude's status page already uses. This means the overall-status row and its "Updated …" +subtitle work through QuotaKit's existing shared Statuspage.io fetch/parse path with no +ZoomMate-specific fetcher code. + +Zoom's status page lists ~300+ components, dominated by heavy per-region duplication for services +unrelated to ZoomMate (Zoom Phone, Contact Center, and CX each repeated across many regions). +Showing all of them in ZoomMate's status drill-down would be noise, so ZoomMate's component submenu +is filtered to a named allowlist: + +- Zoom Meetings +- ZoomMate +- My Notes +- Zoom Workflows +- Zoom Developer Platform +- Zoom Support +- Zoom Website + +The allowlist matches component/group names exactly (case-sensitive) against whatever the live API +returns, and is tolerant of any subset being renamed or removed on Zoom's side: a missing name is +silently omitted, never an error, and if none of the allowlisted names are present the submenu +falls back to the same "components not loaded" empty state every other provider already has (no +ZoomMate-specific empty-state UI). "Zoom Meetings" and "Zoom Workflows" are themselves groups in +Zoom's data (each with their own child components); an allowlisted group is shown with its full +existing child list when expanded, same as it would be for any other provider. + +This allowlist lives in ZoomMate's provider descriptor metadata. Every other provider has no +descriptor allowlist and keeps showing every component their feed returns, unchanged. + +## CLI + +```bash +quotakit usage --provider zoommate +``` + +The CLI reuses the host-scoped cookie headers cached by a previous validated refresh; it does not read Chrome's +cookie store itself. If no cached session exists yet (`noSession`), refresh once from the app or +seed the cache from the terminal with `quotakit cookie --provider zoommate` (add +`--allow-keychain-prompt` to acknowledge that Chrome cookie decryption may prompt). + +ZoomMate provides no token-cost data and is not yet supported in the QuotaKit widget (this +includes the credits history dashboard and status page above — both are app-menu-only). + +## Common errors + +| Error | Cause | Fix | +|---|---|---| +| `noCapture` | Manual mode is selected but the capture is empty, off-domain, or lacks a parseable `Authorization` header | Paste a fresh cURL capture of the HTTPS `credits/status` request from `ai.zoom.us` or `zoommate.zoom.us` | +| `noSession` | Automatic mode found no cached session and no ZoomMate/Zoom session cookies it may read (background refreshes and the CLI never read Chrome directly) | Sign in to ZoomMate in Chrome and refresh once from the app (or `quotakit cookie --provider zoommate`), or switch to Manual and paste a capture | +| `invalidCredentials` | HTTP 401/403 — the token expired (~hourly) or was revoked | Re-sign-in (auto) or re-paste a fresh capture (manual) | +| `apiError` | Any other non-200 HTTP status | Check ZoomMate's status; retry later | +| `parseFailed` | HTTP 200 body did not contain the expected `credit_status` shape | Open a QuotaKit issue with a redacted response sample | + +## Key files + +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateProviderDescriptor.swift` — provider metadata (including `statusPageURL` and the status-component allowlist) and the unified fetch strategy (calls both `credits/status` and `credits/history`) +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateUsageFetcher.swift` — credits/status request, cURL parsing, and cookie-to-token minting +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCreditsHistoryFetcher.swift` — credits/history request, paginated with a date-boundary stop, and the `ZoomMateCreditsHistorySnapshot` model +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateModels.swift` — response decoding, error taxonomy, window mapping, daily-bucket aggregation (`dailyBreakdown()`), today's-total lookup (`todayCreditsUsed(now:calendar:)`), and pacing verdict computation +- `Sources/CodexBarCore/Providers/ZoomMate/ZoomMateCookieImporter.swift` — Chrome cookie-jar import (macOS only) +- `Sources/CodexBar/InlineUsageDashboardContent.swift` — shared Today/30d KPI-tile + mini-bar view also used by Claude/Codex/OpenRouter/etc.; ZoomMate renders through this same component +- `Sources/CodexBar/MenuCardView.swift` — renders the generic inline-dashboard slot for credits-only stacked cards +- `Sources/CodexBar/StatusItemController+Menu.swift` — `statusComponentsSubmenuProviders` and descriptor-backed `filterStatusComponents` +- `Sources/CodexBar/Providers/ZoomMate/ZoomMateProviderImplementation.swift` — settings pickers and bindings +- `Sources/CodexBar/Providers/ZoomMate/ZoomMateSettingsStore.swift` — cookie source and capture persistence diff --git a/version.env b/version.env index 3e600f3d4..083a4b42d 100644 --- a/version.env +++ b/version.env @@ -5,9 +5,9 @@ MOBILE_VERSION=1.11.3 # until the corresponding release reaches end users (Sparkle / App Store). # Bump this after the merged version is actually live, not at merge time. UPSTREAM_VERSION=v0.43.0 -UPSTREAM_SYNC_DATE=2026-07-28 +UPSTREAM_SYNC_DATE=2026-07-29 # Last upstream commit already merged/reviewed for the daily monitor. # Advance this when an upstream sync PR lands. It is independent of shipped # release tracking above, so the monitor does not reopen stale issues while a # merged upstream sync has not yet shipped to users. -UPSTREAM_MONITOR_BASE=02b4ba278c81e667d2e5587d0ceb9eaf1d83f854 +UPSTREAM_MONITOR_BASE=fcf75fb80457eaf1722194124a614f0ec7ebcde8