diff --git a/CHANGELOG.md b/CHANGELOG.md index 237dc8b04..e3c02ff00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ on Columbus Labs QuotaKit releases and product-facing changes. - 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 `b036579b4`, including safer provider endpoint overrides, Doubao Agent Plan usage, OpenCode Go authoritative web-window overlays, Ollama session recovery, MiniMax Linux API-key support, calendar-correct cost history, and CLI version-path fixes while preserving QuotaKit release ownership, public URLs, config paths, CloudKit setup, and iOS build numbers. - 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 diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index d55143b7c..62e05813f 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -605,6 +605,7 @@ struct UsageMenuCardHeaderSectionView: View { private struct UsageMenuCardUsageContentView: View { let model: UsageMenuCardView.Model let showBottomDivider: Bool + var showsSectionDividers = true @Environment(\.menuItemHighlighted) private var isHighlighted /// Doubao ships Coding Plan and Agent Plan subscriptions, each with personal @@ -644,7 +645,7 @@ private struct UsageMenuCardUsageContentView: View { self.groupHeader("Coding Plan") self.metricRows(split.coding) } - if !split.coding.isEmpty { + if !split.coding.isEmpty, self.showsSectionDividers { Divider() } self.groupHeader("Agent Plan") @@ -653,7 +654,7 @@ private struct UsageMenuCardUsageContentView: View { self.metricRows(self.model.metrics) } if let resetCredits = self.model.codexResetCredits { - if !self.model.metrics.isEmpty { + if !self.model.metrics.isEmpty, self.showsSectionDividers { Divider() } CodexResetCreditsContent(presentation: resetCredits) @@ -681,11 +682,15 @@ struct UsageMenuCardUsageSectionView: View { let showBottomDivider: Bool let bottomPadding: CGFloat let width: CGFloat + var showsSectionDividers = true @Environment(\.menuCardRefreshMonitor) private var refreshMonitor var body: some View { let liveModel = self.liveModel - UsageMenuCardUsageContentView(model: liveModel, showBottomDivider: self.showBottomDivider) + UsageMenuCardUsageContentView( + model: liveModel, + showBottomDivider: self.showBottomDivider, + showsSectionDividers: self.showsSectionDividers) .padding(.horizontal, UsageMenuCardLayout.horizontalPadding) .padding(.top, UsageMenuCardLayout.usageSectionTopPadding) .padding(.bottom, self.bottomPadding) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index d4da449e6..bc4e920b9 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -552,12 +552,14 @@ struct MenuDescriptor { let targetProvider = provider ?? store.enabledProviders().first let metadata = targetProvider.map { store.metadata(for: $0) } let fallbackAccount = targetProvider.map { store.accountInfo(for: $0) } ?? account + let hasAccount = self.hasAccount(for: targetProvider, store: store, account: fallbackAccount) let loginContext = targetProvider.map { ProviderMenuLoginContext( provider: $0, store: store, settings: store.settings, - account: fallbackAccount) + account: fallbackAccount, + hasAccount: hasAccount) } // Show "Add Account" if no account, "Switch Account" if logged in @@ -571,7 +573,6 @@ struct MenuDescriptor { entries.append(.action(override.label, override.action)) } else { let loginAction = self.switchAccountTarget(for: provider, store: store) - let hasAccount = self.hasAccount(for: provider, store: store, account: fallbackAccount) let accountLabel = hasAccount ? L("Switch Account...") : L("Add Account...") entries.append(.action(accountLabel, loginAction)) } @@ -647,11 +648,18 @@ struct MenuDescriptor { private static func hasAccount(for provider: UsageProvider?, store: UsageStore, account: AccountInfo) -> Bool { let target = provider ?? store.enabledProviders().first ?? .codex - if let email = store.snapshot(for: target)?.accountEmail(for: target), + let snapshot = store.snapshot(for: target) + if let email = snapshot?.accountEmail(for: target), !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } + if target == .claude, + snapshot?.identity(for: .claude) != nil, + snapshot?.hasRateLimitWindows == true + { + return true + } let metadata = store.metadata(for: target) if metadata.usesAccountFallback, let fallback = account.email?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index 6f77c775e..397a3409f 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -322,6 +322,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { if self.shouldOpenTerminalForOAuthError(store: context.store) { return ("Open Terminal", .openTerminal(command: "claude")) } + guard !context.hasAccount else { return nil } return (L("Sign in with Claude Code..."), .switchAccount(.claude)) } diff --git a/Sources/CodexBar/Providers/LLMProxy/LLMProxyProviderImplementation.swift b/Sources/CodexBar/Providers/LLMProxy/LLMProxyProviderImplementation.swift index 566e6cfc7..132886fad 100644 --- a/Sources/CodexBar/Providers/LLMProxy/LLMProxyProviderImplementation.swift +++ b/Sources/CodexBar/Providers/LLMProxy/LLMProxyProviderImplementation.swift @@ -18,7 +18,7 @@ struct LLMProxyProviderImplementation: ProviderImplementation { @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { ProviderTokenResolver.llmProxyToken(environment: context.environment) != nil && - LLMProxySettingsReader.baseURL(environment: context.environment) != nil + LLMProxySettingsReader.hasBaseURLOverride(environment: context.environment) } @MainActor diff --git a/Sources/CodexBar/Providers/LiteLLM/LiteLLMProviderImplementation.swift b/Sources/CodexBar/Providers/LiteLLM/LiteLLMProviderImplementation.swift index 5cf08a234..5bbc1e526 100644 --- a/Sources/CodexBar/Providers/LiteLLM/LiteLLMProviderImplementation.swift +++ b/Sources/CodexBar/Providers/LiteLLM/LiteLLMProviderImplementation.swift @@ -18,7 +18,7 @@ struct LiteLLMProviderImplementation: ProviderImplementation { @MainActor func isAvailable(context: ProviderAvailabilityContext) -> Bool { ProviderTokenResolver.liteLLMToken(environment: context.environment) != nil && - LiteLLMSettingsReader.baseURL(environment: context.environment) != nil + LiteLLMSettingsReader.hasBaseURLOverride(environment: context.environment) } @MainActor diff --git a/Sources/CodexBar/Providers/Shared/ProviderMenuContext.swift b/Sources/CodexBar/Providers/Shared/ProviderMenuContext.swift index ac350b05e..d40df25d4 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderMenuContext.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderMenuContext.swift @@ -25,4 +25,5 @@ struct ProviderMenuLoginContext { let store: UsageStore let settings: SettingsStore let account: AccountInfo + let hasAccount: Bool } diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index ab28d1451..ada2187c6 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -22,6 +22,8 @@ extension ProviderSwitcherSelection { } struct OverviewMenuCardRowView: View { + static let showsSectionDividers = false + let model: UsageMenuCardView.Model let storageText: String? let width: CGFloat @@ -31,14 +33,15 @@ struct OverviewMenuCardRowView: View { VStack(alignment: .leading, spacing: 0) { UsageMenuCardHeaderSectionView( model: self.model, - showDivider: self.hasUsageBlock, + showDivider: Self.showsSectionDividers && self.hasUsageBlock, width: self.width) if self.hasUsageBlock { UsageMenuCardUsageSectionView( model: self.model, showBottomDivider: false, bottomPadding: 6, - width: self.width) + width: self.width, + showsSectionDividers: Self.showsSectionDividers) } if let storageText { HStack(alignment: .firstTextBaseline, spacing: 4) { diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index b23bb1bd3..01438cce7 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -55,8 +55,9 @@ extension CodexBarCLI { static func currentVersion( bundle: Bundle = .main, - executablePath: String? = CommandLine.arguments.first) -> String? + executablePath: String? = nil) -> String? { + let executablePath = executablePath ?? Self.runningExecutablePath(bundle: bundle) if let version = self.currentVersion(bundleVersion: nil, executablePath: executablePath) { return version } @@ -65,6 +66,28 @@ extension CodexBarCLI { executablePath: nil) } + static func runningExecutablePath(bundle: Bundle = .main) -> String? { + // Bundle.executableURL keeps this seam deterministic for app bundles and tests; the + // platform-specific fallbacks cover direct standalone launches without a usable bundle. + if let path = bundle.executableURL?.path, !path.isEmpty { + return path + } + + #if canImport(Darwin) + var size: UInt32 = 0 + guard _NSGetExecutablePath(nil, &size) != 0 else { return nil } + var buffer = [Int8](repeating: 0, count: Int(size)) + guard _NSGetExecutablePath(&buffer, &size) == 0 else { return nil } + return String(cString: buffer) + #elseif os(Linux) + let path = "/proc/self/exe" + guard FileManager.default.fileExists(atPath: path) else { return nil } + return URL(fileURLWithPath: path).resolvingSymlinksInPath().path + #else + return nil + #endif + } + static func currentVersion(bundleVersion: String?, executablePath: String?) -> String? { if let executablePath, !executablePath.isEmpty { let executableURL = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath() @@ -90,7 +113,7 @@ extension CodexBarCLI { guard let data = fileManager.contents(atPath: infoURL.path), let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { return nil } - return plist["CFBundleShortVersionString"] as? String + return Self.normalizedBundleVersion(plist["CFBundleShortVersionString"] as? String) } currentURL.deleteLastPathComponent() } diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index 32ce4cfe9..25d2e56e9 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -259,7 +259,9 @@ extension CodexBarCLI { } private static func accountSelections(from accounts: [ProviderTokenAccount]) -> [ProviderTokenAccount?] { - if accounts.isEmpty { return [nil] } + if accounts.isEmpty { + return [nil] + } return accounts.map { Optional($0) } } @@ -679,95 +681,11 @@ extension CodexBarCLI { environment: [String: String]? = nil, settings: ProviderSettingsSnapshot? = nil) -> Bool { - guard provider != .grok, provider != .amp else { - return false - } - if provider == .codex, sourceMode == .auto { - return false - } - if provider == .claude, sourceMode == .auto { - // Claude's cross-platform planner skips its unavailable web step and falls back to the CLI. - return false - } - if provider == .opencodego { - if sourceMode == .auto || settings?.opencodego?.cookieSource == .manual { - return false - } - } - if provider == .commandcode, - settings?.commandcode?.cookieSource == .manual - { - return false - } - if provider == .alibabatokenplan, - settings?.alibabaTokenPlan?.cookieSource == .manual - { - // The Alibaba/Qwen Token Plan fetch is plain URLSession + cookies; only browser - // cookie auto-import needs macOS, so a manual cookie header works off macOS too. - return false - } - #if os(Linux) - if provider == .cursor, - settings?.cursor?.cookieSource != .off - { - // Linux uses Cursor app auth and manual cookies; browser import remains macOS-only. - return false - } - #endif - if provider == .sakana, - sourceMode == .auto || sourceMode == .web, - environment.map({ SakanaSettingsReader.cookieHeader(environment: $0) != nil }) == true - { - 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 - { - let hasEnvironmentToken = environment.map { - ProviderTokenResolver.ollamaToken(environment: $0) != nil - } == true - if settings?.ollama?.cookieSource == .off || hasEnvironmentToken { - return false - } - } - if provider == .kimi, - sourceMode == .auto, - environment.map({ environment in - ProviderTokenResolver.kimiAPIToken(environment: environment) != nil || - KimiSettingsReader.hasKimiCodeCredential(environment: environment) - }) == true - { - return false - } - if provider == .factory, - sourceMode == .auto || sourceMode == .cli, - environment.map({ FactorySettingsReader.apiKey(environment: $0) != nil }) == true - { - // Linux Auto/legacy-cli can use FACTORY_API_KEY without browser cookies. - return false - } - if provider == .mimo, - sourceMode == .auto, - let environment, - MiMoLocalUsageFallback.cacheExists(environment: environment) + if self.webSupportExempt( + sourceMode, + provider: provider, + environment: environment, + settings: settings) { return false } @@ -781,25 +699,110 @@ extension CodexBarCLI { } } - private static func hasZoomMateManualCapture(settings: ProviderSettingsSnapshot?) -> Bool { - settings?.zoommate?.cookieSource == .manual && - CookieHeaderNormalizer.normalize(settings?.zoommate?.manualCookieHeader) != nil + /// Providers that can satisfy a source mode without the macOS-only web/browser path. + private static func webSupportExempt( + _ sourceMode: ProviderSourceMode, + provider: UsageProvider, + environment: [String: String]?, + settings: ProviderSettingsSnapshot?) -> Bool + { + if provider == .grok || provider == .amp { + return true + } + if sourceMode == .auto, provider == .codex || provider == .claude { + // Claude's cross-platform planner skips its unavailable web step and falls back to the CLI. + return true + } + if self.cookieSourceExempt(sourceMode, provider: provider, settings: settings) { + return true + } + return self.credentialExempt( + sourceMode, + provider: provider, + environment: environment, + settings: settings) } - private static func qwenCloudCanFetchWithoutWeb( - sourceMode: ProviderSourceMode, + /// Exemptions granted by a manual cookie header instead of browser auto-import. + private static func cookieSourceExempt( + _ sourceMode: ProviderSourceMode, + provider: UsageProvider, + settings: ProviderSettingsSnapshot?) -> Bool + { + switch provider { + case .opencodego: + return sourceMode == .auto || settings?.opencodego?.cookieSource == .manual + case .commandcode: + return settings?.commandcode?.cookieSource == .manual + case .alibabatokenplan: + // The Alibaba/Qwen Token Plan fetch is plain URLSession + cookies; only browser + // cookie auto-import needs macOS, so a manual cookie header works off macOS too. + return settings?.alibabaTokenPlan?.cookieSource == .manual + case .qoder: + return settings?.qoder?.cookieSource == .manual + case .zoommate: + return settings?.zoommate?.cookieSource == .manual && + CookieHeaderNormalizer.normalize(settings?.zoommate?.manualCookieHeader) != nil + case .cursor: + #if os(Linux) + // Linux uses Cursor app auth and manual cookies; browser import remains macOS-only. + return settings?.cursor?.cookieSource != .off + #else + return false + #endif + default: + return false + } + } + + /// Exemptions granted by an already-configured credential (token, API key, or local cache). + private static func credentialExempt( + _ sourceMode: ProviderSourceMode, + provider: UsageProvider, 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 + switch provider { + case .sakana: + guard sourceMode == .auto || sourceMode == .web else { return false } + return environment.map { SakanaSettingsReader.cookieHeader(environment: $0) != nil } == true + case .qwencloud: + 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 + case .ollama: + guard sourceMode == .auto else { return false } + let hasEnvironmentToken = environment.map { + ProviderTokenResolver.ollamaToken(environment: $0) != nil + } == true + return settings?.ollama?.cookieSource == .off || hasEnvironmentToken + case .kimi: + guard sourceMode == .auto else { return false } + return environment.map { environment in + ProviderTokenResolver.kimiAPIToken(environment: environment) != nil || + KimiSettingsReader.hasKimiCodeCredential(environment: environment) + } == true + case .factory: + // Linux Auto/legacy-cli can use FACTORY_API_KEY without browser cookies. + guard sourceMode == .auto || sourceMode == .cli else { return false } + return environment.map { FactorySettingsReader.apiKey(environment: $0) != nil } == true + case .minimax: + // The MiniMax API fetch is plain HTTPS + Bearer auth, so a configured key works off + // macOS. Standard `sk-api-` keys are the exception: Auto resolves them to the Coding + // Plan web strategy, which still needs the macOS-only web path. + guard sourceMode == .auto, let environment else { return false } + guard MiniMaxAPISettingsReader.apiToken(environment: environment) != nil else { return false } + return MiniMaxAPISettingsReader.apiKeyKind(environment: environment) != .standard + case .mimo: + guard sourceMode == .auto, let environment else { return false } + return MiMoLocalUsageFallback.cacheExists(environment: environment) + default: + return false + } } } diff --git a/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift b/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift index 3deb393dc..3e25b2b47 100644 --- a/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift +++ b/Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift @@ -46,12 +46,18 @@ enum CodexLocalProjectUsageIndexer { 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 } + let since = scannerOptions.calendar.date( + byAdding: .day, + value: -(clampedHistoryDays - 1), + to: now) ?? now + let comparisonSince = self.modelsAnalyticsScanStart( + since: since, + until: until, + calendar: scannerOptions.calendar) progress?(CodexLocalProjectUsageIndexProgress(phase: .scanningLogs)) _ = try CostUsageScanner.loadDailyReportCancellable( @@ -63,7 +69,10 @@ enum CodexLocalProjectUsageIndexer { checkCancellation: checkCancellation) try checkCancellation?() - let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: scannerOptions.cacheRoot) + let cache = CostUsageCacheIO.load( + provider: .codex, + cacheRoot: scannerOptions.cacheRoot, + calendar: scannerOptions.calendar) let catalogResult = CodexThreadCatalogReader.loadResult(options: scannerOptions) let catalog = catalogResult.catalog let sourceStatus = CodexLocalProjectUsageSourceStatus(catalog: catalogResult.completeness) @@ -145,8 +154,14 @@ enum CodexLocalProjectUsageIndexer { 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 range = CostUsageScanner.CostUsageDayRange( + since: since, + until: until, + calendar: options.calendar) + let cache = cacheOverride ?? CostUsageCacheIO.load( + provider: .codex, + cacheRoot: options.cacheRoot, + calendar: options.calendar) let catalog = catalogOverride ?? CodexThreadCatalogReader.load(options: options) let expectedRoots = CostUsageScanner.codexRootsFingerprint(options: options) let scopeSignature = self.stableScopeSignature(options: options) @@ -275,7 +290,8 @@ enum CodexLocalProjectUsageIndexer { since: since, until: until, historyDays: clampedHistoryDays, - generatedAt: now), + generatedAt: now, + calendar: options.calendar), identity: ModelsAnalyticsIdentity( scopeSignature: scopeSignature, rootsFingerprint: rootsFingerprint), @@ -297,6 +313,7 @@ extension CodexLocalProjectUsageIndexer { let until: Date let historyDays: Int let generatedAt: Date + let calendar: Calendar } fileprivate struct ModelsAnalyticsIdentity { @@ -653,19 +670,27 @@ extension CodexLocalProjectUsageIndexer { { let aggregationSignpost = CodexModelsTelemetry.begin("SnapshotAggregation") defer { CodexModelsTelemetry.end("SnapshotAggregation", id: aggregationSignpost) } - let periods = self.modelsAnalyticsPeriods(since: window.since, until: window.until) + let periods = self.modelsAnalyticsPeriods( + since: window.since, + until: window.until, + calendar: window.calendar) let previousInterval = periods.previous let previousRange = CostUsageScanner.CostUsageDayRange( since: previousInterval.start, - until: previousInterval.end.addingTimeInterval(-1)) + until: previousInterval.end.addingTimeInterval(-1), + calendar: window.calendar) 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 currentFragments = self.analyticsFragments( + from: context.currentBuckets.values, + calendar: window.calendar) + let previousFragments = self.analyticsFragments( + from: previousBuckets.values, + calendar: window.calendar) let currentBucketsByProject = Dictionary(grouping: context.currentBuckets.values, by: \.projectId) let previousBucketsByProject = Dictionary(grouping: previousBuckets.values, by: \.projectId) let currentFragmentsByProject = Dictionary(grouping: currentFragments, by: \.workspaceID) @@ -814,14 +839,16 @@ extension CodexLocalProjectUsageIndexer { } fileprivate static func analyticsFragments( - from buckets: Dictionary.Values) -> [CodexModelsUsageFragment] + from buckets: Dictionary.Values, + calendar: Calendar) -> [CodexModelsUsageFragment] { - buckets.flatMap { bucket in + let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: calendar) + return 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 day = calendar.startOfDay(for: timestamp) let inputTokens = max(0, row.input) let outputTokens = max(0, row.output) let totalTokens = Int64(inputTokens + outputTokens) @@ -841,7 +868,8 @@ extension CodexLocalProjectUsageIndexer { } } return bucket.modelDailyTotals.flatMap { day, models -> [CodexModelsUsageFragment] in - guard let date = CostUsageDateParser.parse(day) else { return [] } + guard let parsedDate = CostUsageScanner.parseDayKey(day, calendar: calendar) else { return [] } + let date = calendar.startOfDay(for: parsedDate) return models.map { model, totals in CodexModelsUsageFragment( workspaceID: bucket.projectId, @@ -895,7 +923,7 @@ extension CodexLocalProjectUsageIndexer { } fileprivate static func stableScopeSignature(options: CostUsageScanner.Options) -> String { - CodexLocalDataScope.resolve(options: options).identifier + "\(CodexLocalDataScope.resolve(options: options).identifier)|timeZone=\(options.calendar.timeZone.identifier)" } fileprivate static func rootsFingerprint(_ roots: [String: Int64]) -> [String: Int64] { diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 557357585..226d06136 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -233,10 +233,7 @@ public struct CostUsageFetcher: Sendable { throw CostUsageError.unsupportedProvider(provider) } - let until = now let clampedHistoryDays = max(1, min(365, historyDays)) - // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. - let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now if let remoteSnapshot = try await self.loadRemoteTokenSnapshot( provider: provider, @@ -252,6 +249,8 @@ public struct CostUsageFetcher: Sendable { overrideScannerOptions, provider: provider, codexHomePath: codexHomePath) + // Rolling window is inclusive, so a 30-day display starts 29 days before `now`. + let since = options.calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false await Self.refreshPricingIfAllowed( @@ -276,6 +275,7 @@ public struct CostUsageFetcher: Sendable { if resolvedPiOptions.cacheRoot == nil { resolvedPiOptions.cacheRoot = options.cacheRoot } + resolvedPiOptions.calendar = options.calendar if forceRefresh || bypassScannerDebounce { resolvedPiOptions.refreshMinIntervalSeconds = 0 } @@ -291,7 +291,7 @@ public struct CostUsageFetcher: Sendable { var daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, - until: until, + until: now, now: now, options: scanOptions, checkCancellation: checkCancellation) @@ -307,7 +307,7 @@ public struct CostUsageFetcher: Sendable { daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, - until: until, + until: now, now: now, options: fallback, checkCancellation: checkCancellation) @@ -322,7 +322,8 @@ public struct CostUsageFetcher: Sendable { let cache = CostUsageScanner.codexCache( CostUsageCacheIO.load(provider: .codex, cacheRoot: scanOptions.cacheRoot), scopedTo: roots) - let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let range = CostUsageScanner.CostUsageDayRange( + since: since, until: now, calendar: scanOptions.calendar) projects = CostUsageScanner.buildCodexProjectBreakdownsFromCache( cache: cache, range: range, @@ -337,7 +338,7 @@ public struct CostUsageFetcher: Sendable { let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, since: since, - until: until, + until: now, now: now, options: piOptions, checkCancellation: checkCancellation) @@ -389,6 +390,7 @@ public struct CostUsageFetcher: Sendable { from: scanResult.daily, now: now, historyDays: clampedHistoryDays, + calendar: scanOptions.calendar, projects: scanResult.projects, sessions: scanResult.sessions) } @@ -510,10 +512,16 @@ public struct CostUsageFetcher: Sendable { // cooperative pool alongside the scans themselves. let cachedSnapshot: CachedCodexTokenSnapshotResult?? = try? await CostUsageScanExecutor.run { _ in let clampedHistoryDays = max(1, min(365, historyDays)) - let until = now - let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now - let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) let options = overrideScannerOptions ?? CostUsageScanner.Options() + let until = now + let since = options.calendar.date( + byAdding: .day, + value: -(clampedHistoryDays - 1), + to: now) ?? now + let range = CostUsageScanner.CostUsageDayRange( + since: since, + until: until, + calendar: options.calendar) let roots = CostUsageScanner.codexSessionsRoots(options: options) let cache = CostUsageScanner.codexCache( CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot), @@ -527,7 +535,8 @@ public struct CostUsageFetcher: Sendable { var scanTimes: [Date] = [] var piMerged = false - if !cache.days.isEmpty, + if cache.timeZoneIdentifier == range.calendar.timeZone.identifier, + !cache.days.isEmpty, cache.roots == CostUsageScanner.codexRootsFingerprint(options: options), !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: cache) { @@ -561,7 +570,8 @@ public struct CostUsageFetcher: Sendable { since: since, until: until, now: now, - cacheRoot: options.cacheRoot) + cacheRoot: options.cacheRoot, + calendar: options.calendar) { reports.append(piResult.report) piMerged = true @@ -586,6 +596,7 @@ public struct CostUsageFetcher: Sendable { from: CostUsageDailyReport.merged(reports), now: now, historyDays: clampedHistoryDays, + calendar: options.calendar, projects: Self.mergedProjectBreakdowns(projects), sessions: sessions, updatedAt: scanTimes.min()), @@ -738,6 +749,7 @@ public struct CostUsageFetcher: Sendable { now: Date, historyDays: Int = 30, useCurrentLocalDayForSession: Bool = true, + calendar: Calendar = .current, meteredCostUSD: Double? = nil, credentialScopeFingerprint: String? = nil, historyLabel: String? = nil, @@ -746,7 +758,7 @@ public struct CostUsageFetcher: Sendable { updatedAt: Date? = nil) -> CostUsageTokenSnapshot { let sessionEntry = useCurrentLocalDayForSession - ? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now) + ? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now, calendar: calendar) : CostUsageTokenSnapshot.latestEntry(in: daily.data) let hasHistoricalRows = !daily.data.isEmpty let sessionTokens: Int? = if let sessionEntry { diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index d5b7849b0..88f2760bb 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -1064,7 +1064,14 @@ enum CostUsageBucketInterval { } enum CostUsageLocalDay { + static func gregorianCalendar(matching calendar: Calendar = .current) -> Calendar { + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = calendar.timeZone + return gregorian + } + static func key(from date: Date, calendar: Calendar = .current) -> String { + let calendar = Self.gregorianCalendar(matching: calendar) let components = calendar.dateComponents([.year, .month, .day], from: date) let year = components.year ?? 0 let month = components.month ?? 0 diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 726441f0e..8bd4ed473 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 = "54bb705996cff6d9" + static let value = "36f15d07d58b71f2" } diff --git a/Sources/CodexBarCore/PiSessionCostCache.swift b/Sources/CodexBarCore/PiSessionCostCache.swift index 01eaea3c6..7c4582a81 100644 --- a/Sources/CodexBarCore/PiSessionCostCache.swift +++ b/Sources/CodexBarCore/PiSessionCostCache.swift @@ -2,7 +2,7 @@ import Foundation enum PiSessionCostCacheIO { /// Artifact schema version. Pricing changes are tracked separately by `pricingKey`. - private static let artifactVersion = 7 + private static let artifactVersion = 8 private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -27,11 +27,17 @@ enum PiSessionCostCacheIO { return decoded } - static func save(cache: PiSessionCostCache, cacheRoot: URL? = nil) { + static func save( + cache: PiSessionCostCache, + cacheRoot: URL? = nil, + calendar: Calendar = .current) + { let url = self.cacheFileURL(cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var cache = cache + cache.timeZoneIdentifier = calendar.timeZone.identifier let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) let data = (try? JSONEncoder().encode(cache)) ?? Data() do { @@ -52,11 +58,12 @@ struct PiSessionCostCache: Codable { var lastScanUnixMs: Int64 = 0 var scanSinceKey: String? var scanUntilKey: String? + var timeZoneIdentifier: String? var pricingKey: String? var daysByProvider: [String: [String: [String: PiPackedUsage]]] = [:] var files: [String: PiSessionFileUsage] = [:] - init(version: Int = 7) { + init(version: Int = 8) { self.version = version } } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 159eed0a5..4ecdbd3d6 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -20,6 +20,7 @@ enum PiSessionCostScanner { var piSessionsRoot: URL? var ompSessionsRoot: URL? var cacheRoot: URL? + var calendar: Calendar var refreshMinIntervalSeconds: TimeInterval = 60 var forceRescan: Bool = false @@ -27,12 +28,14 @@ enum PiSessionCostScanner { piSessionsRoot: URL? = nil, ompSessionsRoot: URL? = nil, cacheRoot: URL? = nil, + calendar: Calendar = .current, refreshMinIntervalSeconds: TimeInterval = 60, forceRescan: Bool = false) { self.piSessionsRoot = piSessionsRoot self.ompSessionsRoot = ompSessionsRoot self.cacheRoot = cacheRoot + self.calendar = calendar self.refreshMinIntervalSeconds = refreshMinIntervalSeconds self.forceRescan = forceRescan } @@ -108,8 +111,14 @@ enum PiSessionCostScanner { return CostUsageDailyReport(data: [], summary: nil) } - let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let range = CostUsageScanner.CostUsageDayRange( + since: since, + until: until, + calendar: options.calendar) var cache = PiSessionCostCacheIO.load(cacheRoot: options.cacheRoot) + if cache.timeZoneIdentifier != range.calendar.timeZone.identifier { + cache = PiSessionCostCache() + } let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) let pricingContext = self.pricingContext(now: now, cacheRoot: options.cacheRoot) @@ -125,10 +134,14 @@ enum PiSessionCostScanner { if shouldRefresh { try checkCancellation?() let roots = self.defaultSessionRoots(options: options) - let startCutoff = self.dateFromDayKey(range.scanSinceKey) ?? since + let startCutoff = self.dateFromDayKey(range.scanSinceKey, calendar: range.calendar) ?? since var files: [SessionFileCandidate] = [] for (rootIndex, root) in roots.enumerated() { - for url in self.listPiSessionFiles(root: root, startCutoffLocal: startCutoff) { + for url in self.listPiSessionFiles( + root: root, + startCutoffLocal: startCutoff, + calendar: range.calendar) + { files.append(SessionFileCandidate(url: url, rootIndex: rootIndex)) } } @@ -168,7 +181,10 @@ enum PiSessionCostScanner { cache.pricingKey = pricingContext.pricingKey cache.lastScanUnixMs = nowMs try checkCancellation?() - PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot) + PiSessionCostCacheIO.save( + cache: cache, + cacheRoot: options.cacheRoot, + calendar: range.calendar) } return self.buildReport( @@ -188,14 +204,16 @@ enum PiSessionCostScanner { since: Date, until: Date, now: Date = Date(), - cacheRoot: URL? = nil) -> CostUsageDailyReport? + cacheRoot: URL? = nil, + calendar: Calendar = .current) -> CostUsageDailyReport? { self.loadCachedDailyReportResult( provider: provider, since: since, until: until, now: now, - cacheRoot: cacheRoot)?.report + cacheRoot: cacheRoot, + calendar: calendar)?.report } static func loadCachedDailyReportResult( @@ -203,12 +221,14 @@ enum PiSessionCostScanner { since: Date, until: Date, now: Date = Date(), - cacheRoot: URL? = nil) -> CachedDailyReportResult? + cacheRoot: URL? = nil, + calendar: Calendar = .current) -> CachedDailyReportResult? { guard provider == .codex || provider == .claude else { return nil } - let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until, calendar: calendar) let cache = PiSessionCostCacheIO.load(cacheRoot: cacheRoot) + guard cache.timeZoneIdentifier == range.calendar.timeZone.identifier else { return nil } guard !cache.daysByProvider.isEmpty else { return nil } guard !self.requestedWindowExpandsCache(range: range, cache: cache) else { return nil } @@ -271,7 +291,11 @@ enum PiSessionCostScanner { } } - private static func listPiSessionFiles(root: URL, startCutoffLocal: Date) -> [URL] { + private static func listPiSessionFiles( + root: URL, + startCutoffLocal: Date, + calendar: Calendar) -> [URL] + { guard FileManager.default.fileExists(atPath: root.path) else { return [] } let keys: Set = [.isRegularFileKey, .contentModificationDateKey] @@ -292,7 +316,11 @@ enum PiSessionCostScanner { let startedAt = self.parseSessionStartFromFilename(item.lastPathComponent) let modifiedAt = values?.contentModificationDate if self - .shouldIncludeFile(startedAt: startedAt, modifiedAt: modifiedAt, startCutoffLocal: startCutoffLocal) + .shouldIncludeFile( + startedAt: startedAt, + modifiedAt: modifiedAt, + startCutoffLocal: startCutoffLocal, + calendar: calendar) { output.append(item) } @@ -304,12 +332,13 @@ enum PiSessionCostScanner { private static func shouldIncludeFile( startedAt: Date?, modifiedAt: Date?, - startCutoffLocal: Date) -> Bool + startCutoffLocal: Date, + calendar: Calendar) -> Bool { - if let modifiedAt, self.localMidnight(modifiedAt) >= startCutoffLocal { + if let modifiedAt, self.localMidnight(modifiedAt, calendar: calendar) >= startCutoffLocal { return true } - if let startedAt, self.localMidnight(startedAt) >= startCutoffLocal { + if let startedAt, self.localMidnight(startedAt, calendar: calendar) >= startCutoffLocal { return true } return false @@ -508,7 +537,9 @@ enum PiSessionCostScanner { fallback: currentModelContext) guard let identity else { return } guard let date = self.timestampDate(entry: object, message: message) else { return } - let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: date) + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey( + from: date, + calendar: range.calendar) let usage = self.extractUsage( provider: identity.provider, modelName: identity.modelName, @@ -1039,21 +1070,23 @@ extension PiSessionCostScanner { ?? self.isoFormatterBox.plain.date(from: text) } - private static func localMidnight(_ date: Date) -> Date { - let components = Calendar.current.dateComponents([.year, .month, .day], from: date) - return Calendar.current.date(from: components) ?? date + private static func localMidnight(_ date: Date, calendar: Calendar) -> Date { + let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: calendar) + let components = calendar.dateComponents([.year, .month, .day], from: date) + return calendar.date(from: components) ?? date } - private static func dateFromDayKey(_ key: String) -> Date? { + private static func dateFromDayKey(_ key: String, calendar: Calendar) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3, let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil } + let calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar(matching: calendar) var components = DateComponents() - components.calendar = Calendar.current - components.timeZone = TimeZone.current + components.calendar = calendar + components.timeZone = calendar.timeZone components.year = year components.month = month components.day = day diff --git a/Sources/CodexBarCore/ProviderEndpointOverrideValidator.swift b/Sources/CodexBarCore/ProviderEndpointOverrideValidator.swift index 6b4ee4a68..53bd20c5d 100644 --- a/Sources/CodexBarCore/ProviderEndpointOverrideValidator.swift +++ b/Sources/CodexBarCore/ProviderEndpointOverrideValidator.swift @@ -50,6 +50,14 @@ struct ProviderEndpointOverrideValidator { } func validatedURLAllowingLoopbackHTTP(_ raw: String?) -> URL? { + self.validatedURL(raw, allowingHTTPFor: Self.isLoopbackHost) + } + + func validatedURLAllowingPrivateNetworkHTTP(_ raw: String?) -> URL? { + self.validatedURL(raw, allowingHTTPFor: Self.isPrivateNetworkHost) + } + + private func validatedURL(_ raw: String?, allowingHTTPFor isAllowedHTTPHost: (String) -> Bool) -> URL? { guard let raw, Self.hasExplicitURLScheme(raw), let url = URL(string: raw), @@ -58,7 +66,7 @@ struct ProviderEndpointOverrideValidator { url.user == nil, url.password == nil, let host = self.validatedDecodedHost(for: url, policy: .allowAnyHTTPSHost), - scheme == "https" || Self.isLoopbackHost(host) + scheme == "https" || isAllowedHTTPHost(host) else { return nil } return url } @@ -120,6 +128,84 @@ struct ProviderEndpointOverrideValidator { return first == 127 } + private static func isPrivateNetworkHost(_ host: String) -> Bool { + if self.isLoopbackHost(host) { + return true + } + + let hostname = host.hasSuffix(".") ? String(host.dropLast()) : host + if hostname.hasSuffix(".local"), hostname.count > ".local".count { + return true + } + + if let octets = Self.ipv4Octets(host) { + return octets[0] == 10 || + (octets[0] == 172 && (16...31).contains(octets[1])) || + (octets[0] == 192 && octets[1] == 168) || + (octets[0] == 169 && octets[1] == 254) + } + + guard Self.isValidIPv6Address(host), + let firstGroup = host.split(separator: ":", omittingEmptySubsequences: false).first, + !firstGroup.isEmpty, + let firstValue = UInt16(firstGroup, radix: 16) + else { return false } + + return firstValue & 0xFE00 == 0xFC00 || firstValue & 0xFFC0 == 0xFE80 + } + + private static func ipv4Octets(_ host: String) -> [UInt8]? { + let components = host.split(separator: ".", omittingEmptySubsequences: false) + guard components.count == 4 else { return nil } + + var octets: [UInt8] = [] + octets.reserveCapacity(4) + for component in components { + guard !component.isEmpty, + component.utf8.allSatisfy({ (48...57).contains($0) }), + component == "0" || component.first != "0", + let octet = UInt8(component) + else { return nil } + octets.append(octet) + } + return octets + } + + private static func isValidIPv6Address(_ host: String) -> Bool { + guard host.contains(":") else { return false } + + var address = host + if address.contains(".") { + guard let lastColon = address.lastIndex(of: ":"), + Self.ipv4Octets(String(address[address.index(after: lastColon)...])) != nil + else { return false } + address.replaceSubrange(address.index(after: lastColon)..., with: "0:0") + } + + let compressedParts = address.components(separatedBy: "::") + guard compressedParts.count <= 2 else { return false } + let groupCounts = compressedParts.map(Self.ipv6GroupCount) + guard groupCounts.allSatisfy({ $0 != nil }) else { return false } + let groupCount = groupCounts.compactMap(\.self).reduce(0, +) + + return compressedParts.count == 2 ? groupCount < 8 : groupCount == 8 + } + + private static func ipv6GroupCount(_ part: String) -> Int? { + if part.isEmpty { + return 0 + } + let groups = part.split(separator: ":", omittingEmptySubsequences: false) + guard groups.allSatisfy({ group in + (1...4).contains(group.utf8.count) && group.utf8.allSatisfy(Self.isASCIIHexDigit) + }) else { return nil } + return groups.count + } + + private static func isASCIIHexDigit(_ byte: UInt8) -> Bool { + (48...57).contains(byte) || (65...70).contains(byte) || (97...102).contains(byte) + } + private func hostAuthority(host: String, port: Int?) -> String { let authorityHost = host.contains(":") ? "[\(host)]" : host guard let port else { return authorityHost } diff --git a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift index 53d6a213e..1b7150eb4 100644 --- a/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Doubao/DoubaoUsageFetcher.swift @@ -266,6 +266,12 @@ public struct DoubaoUsageFetcher: Sendable { private static let apiURL = URL(string: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions")! private static let codingPlanAPIURL = URL( string: "https://open.volcengineapi.com/?Action=GetCodingPlanUsage&Version=2024-01-01")! + /// Agent Plan usage lives behind a sibling Volcengine Top OpenAPI action (`GetAFPUsage`, + /// AFP = "Agent Flow Points"), signed with the same AK/SK the Coding Plan path uses. An + /// account holds a Coding Plan *or* an Agent Plan, so `GetCodingPlanUsage` returns no + /// active quota for an Agent Plan account and we fall back to this action. + private static let agentPlanAPIURL = URL( + string: "https://open.volcengineapi.com/?Action=GetAFPUsage&Version=2024-01-01")! /// Closure that runs `arkcli usage plan` and returns raw stdout. public typealias ArkcliRunner = @Sendable () async throws -> Data @@ -354,6 +360,16 @@ public struct DoubaoUsageFetcher: Sendable { } let codingPlanUsage = try self.decodeCodingPlanUsage(from: response.data) + if codingPlanUsage.quotas.isEmpty { + // A 200 with no quota window means the Coding Plan is not active for this account + // (e.g. Status "Reclaimed" after switching to an Agent Plan). Fall back to the + // Agent Plan (AFP) usage before surfacing an empty Coding Plan snapshot. + let agentSnapshot = try await self.fetchAgentPlanUsage( + credentials: credentials, session: transport, date: date) + if agentSnapshot.codingPlanUsage?.quotas.isEmpty == false { + return agentSnapshot + } + } return DoubaoUsageSnapshot( remainingRequests: 0, limitRequests: 0, @@ -363,6 +379,83 @@ public struct DoubaoUsageFetcher: Sendable { codingPlanUsage: codingPlanUsage) } + /// Fetches Agent Plan (AFP) usage via the AK/SK-signed `GetAFPUsage` action. Mirrors the + /// Coding Plan request path; maps the AFP rolling windows onto the same `agent_*` quota + /// levels the arkcli (`.cli`) Agent Plan path already renders, so `.api` and `.cli` show + /// an Agent Plan account identically. + static func fetchAgentPlanUsage( + credentials: DoubaoCodingPlanCredentials, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + date: Date = Date()) async throws -> DoubaoUsageSnapshot + { + let body = Data() + var request = URLRequest(url: self.agentPlanAPIURL) + request.httpMethod = "POST" + request.timeoutInterval = 15 + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Accept") + DoubaoVolcengineSigner.sign( + request: &request, + body: body, + credentials: credentials, + date: date) + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + throw DoubaoUsageError.networkError(error.localizedDescription) + } + guard response.statusCode == 200 else { + let summary = Self.apiErrorSummary(statusCode: response.statusCode, data: response.data) + Self.log.error("Doubao agent plan API returned \(response.statusCode): \(summary)") + throw DoubaoUsageError.apiError(response.statusCode, summary) + } + + let agentPlanUsage = try self.decodeAgentPlanUsage(from: response.data) + return DoubaoUsageSnapshot( + remainingRequests: 0, + limitRequests: 0, + resetTime: nil, + updatedAt: agentPlanUsage.updateTime ?? date, + apiKeyValid: true, + codingPlanUsage: agentPlanUsage) + } + + static func decodeAgentPlanUsage(from data: Data) throws -> DoubaoCodingPlanUsage { + let response: AgentPlanUsageResponse + do { + response = try JSONDecoder().decode(AgentPlanUsageResponse.self, from: data) + } catch { + throw DoubaoUsageError.parseFailed(error.localizedDescription) + } + let result = response.result + var quotas: [DoubaoCodingPlanUsage.Quota] = [] + func appendQuota(_ window: AgentPlanWindowPayload?, level: String) { + guard let window, window.quota > 0 else { return } + let percent = min(100, max(0, window.used / window.quota * 100)) + quotas.append(DoubaoCodingPlanUsage.Quota( + level: level, + percent: percent, + resetTime: self.date(fromEpochMilliseconds: window.resetTime))) + } + // Only the 5-hour/weekly/monthly windows have a renderer slot in `toUsageSnapshot`; + // the daily window (`AFPDaily`) is intentionally skipped to match the arkcli path. + appendQuota(result.fiveHour, level: "agent_5h") + appendQuota(result.weekly, level: "agent_weekly") + appendQuota(result.monthly, level: "agent_monthly") + return DoubaoCodingPlanUsage(status: nil, updateTime: nil, quotas: quotas) + } + + private static func date(fromEpochMilliseconds milliseconds: TimeInterval?) -> Date? { + guard let milliseconds, milliseconds > 0 else { return nil } + return Date(timeIntervalSince1970: milliseconds / 1000) + } + static func decodeCodingPlanUsage(from data: Data) throws -> DoubaoCodingPlanUsage { let response: CodingPlanUsageResponse do { @@ -914,6 +1007,15 @@ public struct DoubaoUsageFetcher: Sendable { case updateTimestamp = "UpdateTimestamp" case quotaUsage = "QuotaUsage" } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.status = try container.decodeIfPresent(String.self, forKey: .status) + self.updateTimestamp = try container.decodeIfPresent(TimeInterval.self, forKey: .updateTimestamp) + // A reclaimed/inactive Coding Plan returns Status only and omits `QuotaUsage`. + // Decode it as no quota rather than a hard failure so the Agent Plan fallback runs. + self.quotaUsage = try container.decodeIfPresent([QuotaPayload].self, forKey: .quotaUsage) ?? [] + } } private struct QuotaPayload: Decodable { @@ -927,4 +1029,38 @@ public struct DoubaoUsageFetcher: Sendable { case resetTimestamp = "ResetTimestamp" } } + + // MARK: - Agent Plan (GetAFPUsage) signed API response + + private struct AgentPlanUsageResponse: Decodable { + let result: AgentPlanResultPayload + + private enum CodingKeys: String, CodingKey { + case result = "Result" + } + } + + private struct AgentPlanResultPayload: Decodable { + let fiveHour: AgentPlanWindowPayload? + let weekly: AgentPlanWindowPayload? + let monthly: AgentPlanWindowPayload? + + private enum CodingKeys: String, CodingKey { + case fiveHour = "AFPFiveHour" + case weekly = "AFPWeekly" + case monthly = "AFPMonthly" + } + } + + private struct AgentPlanWindowPayload: Decodable { + let quota: Double + let used: Double + let resetTime: TimeInterval? + + private enum CodingKeys: String, CodingKey { + case quota = "Quota" + case used = "Used" + case resetTime = "ResetTime" + } + } } diff --git a/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyProviderDescriptor.swift index 4f77c5e5d..4ed16573f 100644 --- a/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyProviderDescriptor.swift @@ -51,7 +51,7 @@ struct LLMProxyAPIFetchStrategy: ProviderFetchStrategy { func isAvailable(_ context: ProviderFetchContext) async -> Bool { ProviderTokenResolver.llmProxyToken(environment: context.env) != nil && - LLMProxySettingsReader.baseURL(environment: context.env) != nil + LLMProxySettingsReader.hasBaseURLOverride(environment: context.env) } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { @@ -59,7 +59,11 @@ struct LLMProxyAPIFetchStrategy: ProviderFetchStrategy { throw LLMProxyUsageError.missingCredentials } guard let baseURL = LLMProxySettingsReader.baseURL(environment: context.env) else { - throw LLMProxyUsageError.missingBaseURL + // Distinguish "never configured" from "configured but rejected" so the user sees + // which one applies instead of the provider silently going unavailable. + throw LLMProxySettingsReader.hasBaseURLOverride(environment: context.env) + ? LLMProxyUsageError.invalidEndpointOverride(LLMProxySettingsReader.baseURLEnvironmentKey) + : LLMProxyUsageError.missingBaseURL } let usage = try await LLMProxyUsageFetcher.fetchUsage(apiKey: apiKey, baseURL: baseURL) return self.makeResult( diff --git a/Sources/CodexBarCore/Providers/LLMProxy/LLMProxySettingsReader.swift b/Sources/CodexBarCore/Providers/LLMProxy/LLMProxySettingsReader.swift index e753752f9..3b0923a6f 100644 --- a/Sources/CodexBarCore/Providers/LLMProxy/LLMProxySettingsReader.swift +++ b/Sources/CodexBarCore/Providers/LLMProxy/LLMProxySettingsReader.swift @@ -14,7 +14,21 @@ public enum LLMProxySettingsReader { environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { return nil } - return URL(string: raw) + // The API key is sent to this URL as a bearer token, so validate it like every other + // provider override. HTTP stays allowed for loopback and private-network proxies; public + // hosts must use HTTPS, and no endpoint may carry embedded credentials. + return ProviderEndpointOverrideValidator().validatedURLAllowingPrivateNetworkHTTP(raw) + } + + /// True when a base URL is configured at all, even if it fails validation. + /// + /// Availability checks use this so a rejected override still reaches the fetch path and + /// surfaces ``LLMProxyUsageError/invalidEndpointOverride(_:)`` instead of silently hiding + /// the provider as unconfigured. + public static func hasBaseURLOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + self.cleaned(environment[self.baseURLEnvironmentKey]) != nil } static func cleaned(_ raw: String?) -> String? { diff --git a/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyUsageFetcher.swift b/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyUsageFetcher.swift index fc9b43517..059e70220 100644 --- a/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/LLMProxy/LLMProxyUsageFetcher.swift @@ -6,6 +6,7 @@ import FoundationNetworking public enum LLMProxyUsageError: LocalizedError, Sendable { case missingCredentials case missingBaseURL + case invalidEndpointOverride(String) case invalidURL case apiError(String) case parseFailed(String) @@ -16,6 +17,9 @@ public enum LLMProxyUsageError: LocalizedError, Sendable { "Missing LLM Proxy API key. Set apiKey in ~/.quotakit/config.json or LLM_PROXY_API_KEY." case .missingBaseURL: "Missing LLM Proxy base URL. Set enterpriseHost in ~/.quotakit/config.json or LLM_PROXY_BASE_URL." + case let .invalidEndpointOverride(key): + "LLM Proxy base URL override \(key) is invalid. Use an HTTPS URL, or plain HTTP for " + + "loopback or private-network addresses and .local hosts, without embedded credentials." case .invalidURL: "LLM Proxy URL is invalid." case let .apiError(message): diff --git a/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift b/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift index 7cfdefc57..e673abfa6 100644 --- a/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMProviderDescriptor.swift @@ -50,7 +50,7 @@ struct LiteLLMAPIFetchStrategy: ProviderFetchStrategy { func isAvailable(_ context: ProviderFetchContext) async -> Bool { ProviderTokenResolver.liteLLMToken(environment: context.env) != nil && - LiteLLMSettingsReader.baseURL(environment: context.env) != nil + LiteLLMSettingsReader.hasBaseURLOverride(environment: context.env) } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { @@ -58,7 +58,11 @@ struct LiteLLMAPIFetchStrategy: ProviderFetchStrategy { throw LiteLLMUsageError.missingCredentials } guard let baseURL = LiteLLMSettingsReader.baseURL(environment: context.env) else { - throw LiteLLMUsageError.missingBaseURL + // Distinguish "never configured" from "configured but rejected" so the user sees + // which one applies instead of the provider silently going unavailable. + throw LiteLLMSettingsReader.hasBaseURLOverride(environment: context.env) + ? LiteLLMUsageError.invalidEndpointOverride(LiteLLMSettingsReader.baseURLEnvironmentKey) + : LiteLLMUsageError.missingBaseURL } let usage = try await LiteLLMUsageFetcher.fetchUsage( apiKey: apiKey, diff --git a/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMSettingsReader.swift b/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMSettingsReader.swift index a060510da..f862f8497 100644 --- a/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMSettingsReader.swift @@ -14,7 +14,21 @@ public enum LiteLLMSettingsReader { environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { guard let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) else { return nil } - return URL(string: raw) + // The API key is sent to this URL as a bearer token, so validate it like every other + // provider override. HTTP stays allowed for loopback and private-network proxies; public + // hosts must use HTTPS, and no endpoint may carry embedded credentials. + return ProviderEndpointOverrideValidator().validatedURLAllowingPrivateNetworkHTTP(raw) + } + + /// True when a base URL is configured at all, even if it fails validation. + /// + /// Availability checks use this so a rejected override still reaches the fetch path and + /// surfaces ``LiteLLMUsageError/invalidEndpointOverride(_:)`` instead of silently hiding + /// the provider as unconfigured. + public static func hasBaseURLOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + self.cleaned(environment[self.baseURLEnvironmentKey]) != nil } static func cleaned(_ raw: String?) -> String? { diff --git a/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMUsageFetcher.swift b/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMUsageFetcher.swift index 4f6a50431..2b102adf5 100644 --- a/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/LiteLLM/LiteLLMUsageFetcher.swift @@ -6,6 +6,7 @@ import FoundationNetworking public enum LiteLLMUsageError: LocalizedError, Sendable { case missingCredentials case missingBaseURL + case invalidEndpointOverride(String) case missingUserID case invalidURL case apiError(String) @@ -17,6 +18,9 @@ public enum LiteLLMUsageError: LocalizedError, Sendable { "Missing LiteLLM API key. Set apiKey in ~/.quotakit/config.json or LITELLM_API_KEY." case .missingBaseURL: "Missing LiteLLM base URL. Set enterpriseHost in ~/.quotakit/config.json or LITELLM_BASE_URL." + case let .invalidEndpointOverride(key): + "LiteLLM base URL override \(key) is invalid. Use an HTTPS URL, or plain HTTP for " + + "loopback or private-network addresses and .local hosts, without embedded credentials." case .missingUserID: "LiteLLM key info did not include a user_id or team_id." case .invalidURL: diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift index ce7292a4b..3d3520bc9 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift @@ -116,9 +116,9 @@ struct OllamaStatusFetchStrategy: ProviderFetchStrategy { && ProviderTokenResolver.ollamaToken(environment: context.env) != nil } - private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + static func manualCookieHeader(from context: ProviderFetchContext) -> String? { guard context.settings?.ollama?.cookieSource == .manual else { return nil } - return CookieHeaderNormalizer.normalize(context.settings?.ollama?.manualCookieHeader) + return context.settings?.ollama?.manualCookieHeader } static func fetchAutomatic( diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index 68aac9840..b7595ab28 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -7,9 +7,11 @@ import FoundationNetworking import SweetCookieKit #endif +let ollamaDefaultSessionCookieName = "__Secure-session" + private let ollamaSessionCookieNames: Set = [ "session", - "__Secure-session", + ollamaDefaultSessionCookieName, "ollama_session", "__Host-ollama_session", "wos-session", @@ -27,11 +29,78 @@ private func isRecognizedOllamaSessionCookieName(_ name: String) -> Bool { } private func hasRecognizedOllamaSessionCookie(in header: String) -> Bool { - CookieHeaderNormalizer.pairs(from: header).contains { pair in + ollamaCookiePairs(from: header).contains { pair in isRecognizedOllamaSessionCookieName(pair.name) } } +func normalizedOllamaTokenAccountHeader(_ token: String, defaultCookieName: String) -> String { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + guard trimmed.rangeOfCharacter(from: .newlines) == nil else { return "" } + + let lowercased = trimmed.lowercased() + let headerValue: String + if lowercased.hasPrefix("cookie:") { + guard let normalized = CookieHeaderNormalizer.normalize(trimmed) else { return "" } + headerValue = normalized + } else if lowercased.hasPrefix("curl ") { + if let unquoted = extractUnquotedOllamaCookieHeader(from: trimmed) { + headerValue = unquoted + } else { + guard let normalized = CookieHeaderNormalizer.normalize(trimmed), normalized != trimmed else { return "" } + headerValue = normalized + } + } else { + headerValue = trimmed + } + + let pairs = ollamaCookiePairs(from: headerValue) + if pairs.contains(where: { $0.name.caseInsensitiveCompare(defaultCookieName) == .orderedSame }) { + return pairs.map { pair in + let name = pair.name.caseInsensitiveCompare(defaultCookieName) == .orderedSame + ? defaultCookieName + : pair.name + return "\(name)=\(pair.value)" + }.joined(separator: "; ") + } + if pairs.contains(where: { isRecognizedOllamaSessionCookieName($0.name) }) { + return headerValue + } + if headerValue.contains(";") { + return headerValue + } + return "\(defaultCookieName)=\(headerValue)" +} + +private func extractUnquotedOllamaCookieHeader(from raw: String) -> String? { + let pattern = #"(?i)(?:^|\s)-H\s*Cookie:\s*([^\s]+)"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: raw, range: NSRange(raw.startIndex.. [(name: String, value: String)] { + header.split(separator: ";").compactMap { part in + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let equalsIndex = trimmed.firstIndex(of: "=") + else { + return nil + } + let name = trimmed[.. Void)? = nil) throws -> String? { - if let override = CookieHeaderNormalizer.normalize(override) { - guard hasRecognizedOllamaSessionCookie(in: override) else { + if let rawOverride = override?.trimmingCharacters(in: .whitespacesAndNewlines), !rawOverride.isEmpty { + let normalized = if rawOverride.rangeOfCharacter(from: .newlines) == nil, + hasRecognizedOllamaSessionCookie(in: rawOverride) + { + rawOverride + } else { + CookieHeaderNormalizer.normalize(rawOverride) ?? rawOverride + } + guard hasRecognizedOllamaSessionCookie(in: normalized) else { logger?("[ollama] Manual cookie header missing recognized session cookie") throw OllamaUsageError.noSessionCookie } logger?("[ollama] Using manual cookie header") - return override + return normalized } if manualCookieMode { throw OllamaUsageError.noSessionCookie diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index c46a6940b..fa76365d0 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -84,39 +84,86 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { let id: String = "opencodego.local" let kind: ProviderFetchKind = .localProbe + typealias LocalSnapshotLoader = @Sendable (ProviderFetchContext) throws -> OpenCodeGoUsageSnapshot + typealias WebUsageOverlayFetcher = @Sendable (ProviderFetchContext, String) async throws + -> OpenCodeGoUsageSnapshot? + + private let localSnapshotLoader: LocalSnapshotLoader + private let webUsageOverlayFetcher: WebUsageOverlayFetcher + + private struct OverlayCookie { + let header: String + let cachedEntry: CookieHeaderCache.Entry? + } + + init( + localSnapshotLoader: @escaping LocalSnapshotLoader = { context in + try OpenCodeGoLocalUsageReader().fetch(historyDays: context.costUsageHistoryDays) + }, + webUsageOverlayFetcher: @escaping WebUsageOverlayFetcher = Self.liveWebUsageOverlay) + { + self.localSnapshotLoader = localSnapshotLoader + self.webUsageOverlayFetcher = webUsageOverlayFetcher + } + func isAvailable(_: ProviderFetchContext) async -> Bool { true } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - let snapshot = try await self.snapshot(context: context) + let (snapshot, overlaid) = try await self.snapshot(context: context) return self.makeResult( usage: snapshot.toUsageSnapshot(), - sourceLabel: "local") + sourceLabel: overlaid ? "local+web" : "local") } func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { error is OpenCodeGoLocalUsageError } - private func snapshot(context: ProviderFetchContext) async throws -> OpenCodeGoUsageSnapshot { - let snapshot = try OpenCodeGoLocalUsageReader().fetch(historyDays: context.costUsageHistoryDays) - guard context.includeOptionalUsage, - context.settings?.opencodego?.cookieSource != .off + private func snapshot(context: ProviderFetchContext) async throws -> (OpenCodeGoUsageSnapshot, Bool) { + let snapshot = try self.localSnapshotLoader(context) + guard context.settings?.opencodego?.cookieSource != .off, + let cookie = Self.cachedOrManualCookie(context: context) else { - return snapshot + return (snapshot, false) } - guard let cookieHeader = Self.cachedOrManualCookieHeader(context: context) else { - return snapshot + // The server knows the real billing-cycle anchors; the local monthly window is only an + // estimate anchored at the earliest local row. Overlay the authoritative web windows + // whenever a session cookie is already available (never a fresh browser import here). + // URLSession reports task cancellation as URLError.cancelled, so normalize it here to + // keep a cancelled refresh from completing with a successful local-only result. + let webSnapshot: OpenCodeGoUsageSnapshot? + do { + webSnapshot = try await self.webUsageOverlayFetcher(context, cookie.header) + } catch OpenCodeGoUsageError.invalidCredentials { + #if os(macOS) + if let cached = cookie.cachedEntry { + _ = CookieHeaderCache.clearIfCurrent(provider: .opencodego, expected: cached) + } + #endif + return (snapshot, false) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + return (snapshot, false) + } + if let webSnapshot { + return (snapshot.applyingWebUsage(webSnapshot), true) } + guard context.includeOptionalUsage else { + return (snapshot, false) + } let workspaceOverride = context.settings?.opencodego?.workspaceID ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] let zenBalanceTask = Task { do { return try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance( - cookieHeader: cookieHeader, + cookieHeader: cookie.header, timeout: context.webTimeout, workspaceIDOverride: workspaceOverride) } catch is CancellationError { @@ -126,17 +173,49 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { } } let zenBalance = try await OpenCodeGoUsageFetcher.completedOptionalZenBalance(from: zenBalanceTask) - return snapshot.withZenBalanceUSD(zenBalance) + return (snapshot.withZenBalanceUSD(zenBalance), false) + } + + static func liveWebUsageOverlay( + context: ProviderFetchContext, + cookieHeader: String) async throws -> OpenCodeGoUsageSnapshot? + { + let workspaceOverride = context.settings?.opencodego?.workspaceID + ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] + do { + return try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + timeout: context.webTimeout, + workspaceIDOverride: workspaceOverride, + includeZenBalance: context.includeOptionalUsage) + } catch OpenCodeGoUsageError.invalidCredentials { + throw OpenCodeGoUsageError.invalidCredentials + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + if Task.isCancelled { + throw CancellationError() + } + return nil + } } - private static func cachedOrManualCookieHeader(context: ProviderFetchContext) -> String? { + private static func cachedOrManualCookie(context: ProviderFetchContext) -> OverlayCookie? { if let settings = context.settings?.opencodego, settings.cookieSource == .manual { - return OpenCodeWebCookieSupport.requestCookieHeader(from: settings.manualCookieHeader) + guard let header = OpenCodeWebCookieSupport.requestCookieHeader(from: settings.manualCookieHeader) else { + return nil + } + return OverlayCookie(header: header, cachedEntry: nil) } #if os(macOS) - guard let cached = CookieHeaderCache.load(provider: .opencodego) else { return nil } - return OpenCodeWebCookieSupport.requestCookieHeader(from: cached.cookieHeader) + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .opencodego) + guard let cached = observation.entry, + let header = OpenCodeWebCookieSupport.requestCookieHeader(from: cached.cookieHeader) + else { return nil } + return OverlayCookie(header: header, cachedEntry: cached) #else return nil #endif diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift index dd865b87f..6f0adc4eb 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -138,6 +138,31 @@ public struct OpenCodeGoUsageSnapshot: Sendable { return copy } + /// Replaces the locally estimated usage windows with the server-reported (authoritative) + /// ones while keeping local-only data such as the daily cost history. The local monthly + /// window is anchored at the earliest local row, which can drift far from the real billing + /// cycle (wrong percentage and reset countdown), so whenever web usage is available its + /// percentages and reset countdowns win. A balance-only web response carries no windows + /// and must not clobber the local estimate. + public func applyingWebUsage(_ web: OpenCodeGoUsageSnapshot) -> OpenCodeGoUsageSnapshot { + guard !web.isBalanceOnly else { + return self.withZenBalanceUSD(web.zenBalanceUSD ?? self.zenBalanceUSD) + } + return OpenCodeGoUsageSnapshot( + hasWeeklyUsage: web.hasWeeklyUsage, + hasMonthlyUsage: web.hasMonthlyUsage, + rollingUsagePercent: web.rollingUsagePercent, + weeklyUsagePercent: web.weeklyUsagePercent, + monthlyUsagePercent: web.monthlyUsagePercent, + rollingResetInSec: web.rollingResetInSec, + weeklyResetInSec: web.weeklyResetInSec, + monthlyResetInSec: web.monthlyResetInSec, + zenBalanceUSD: web.zenBalanceUSD ?? self.zenBalanceUSD, + renewsAt: web.renewsAt ?? self.renewsAt, + daily: self.daily, + updatedAt: self.updatedAt) + } + public func withDaily(_ daily: [CostUsageDailyReport.Entry]) -> OpenCodeGoUsageSnapshot { var copy = self copy.daily = daily diff --git a/Sources/CodexBarCore/Providers/ProviderCookieSettingsResolver.swift b/Sources/CodexBarCore/Providers/ProviderCookieSettingsResolver.swift index 2da267183..8f362ad1d 100644 --- a/Sources/CodexBarCore/Providers/ProviderCookieSettingsResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderCookieSettingsResolver.swift @@ -19,7 +19,7 @@ public enum ProviderCookieSettingsResolver { return ProviderSettingsSnapshot.CookieProviderSettings( cookieSource: support.requiresManualCookieSource ? .manual : configuredSource, manualCookieHeader: TokenAccountSupportCatalog.normalizedCookieHeader( - selectedAccount.token, - support: support)) + for: provider, + token: selectedAccount.token)) } } diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index 16f937cbe..fff32f409 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -98,6 +98,9 @@ public enum TokenAccountSupportCatalog { guard let support = self.support(for: provider) else { return token.trimmingCharacters(in: .whitespacesAndNewlines) } + if provider == .ollama, let cookieName = support.cookieName { + return normalizedOllamaTokenAccountHeader(token, defaultCookieName: cookieName) + } return self.normalizedCookieHeader(token, support: support) } diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 16981836d..eaa949024 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -103,11 +103,11 @@ extension TokenAccountSupportCatalog { cookieName: nil), .ollama: TokenAccountSupport( title: "Session tokens", - subtitle: "Store multiple Ollama Cookie headers.", - placeholder: "Cookie: …", + subtitle: "Store multiple Ollama Cookie headers or session values.", + placeholder: "Cookie header or bare session value", injection: .cookieHeader, requiresManualCookieSource: true, - cookieName: nil), + cookieName: ollamaDefaultSessionCookieName), .abacus: TokenAccountSupport( title: "Session tokens", subtitle: "Store multiple Abacus AI Cookie headers.", diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index b8bed59c8..e9b988571 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -12,8 +12,7 @@ enum CostUsageCacheIO { switch provider { case .codex: 11 - case .claude, .vertexai: - 5 + case .claude, .vertexai: 6 default: 1 } @@ -35,7 +34,8 @@ enum CostUsageCacheIO { static func load( provider: UsageProvider, cacheRoot: URL? = nil, - producerKey: String? = nil) -> CostUsageCache + producerKey: String? = nil, + calendar: Calendar? = nil) -> CostUsageCache { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) @@ -47,6 +47,9 @@ enum CostUsageCacheIO { expectedProducerKey: expectedProducerKey, compatibleProducerKeys: compatibleProducerKeys) { + if let calendar, decoded.timeZoneIdentifier != calendar.timeZone.identifier { + return CostUsageCache() + } return decoded } return CostUsageCache() @@ -73,7 +76,8 @@ enum CostUsageCacheIO { provider: UsageProvider, cache: CostUsageCache, cacheRoot: URL? = nil, - producerKey: String? = nil) + producerKey: String? = nil, + calendar: Calendar = .current) { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() @@ -81,6 +85,7 @@ enum CostUsageCacheIO { var cache = cache cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) + cache.timeZoneIdentifier = calendar.timeZone.identifier let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) let data = (try? JSONEncoder().encode(cache)) ?? Data() @@ -111,6 +116,7 @@ struct CostUsageCache: Codable { var lastScanUnixMs: Int64 = 0 var scanSinceKey: String? var scanUntilKey: String? + var timeZoneIdentifier: String? var codexPricingKey: String? var codexPriorityMetadataKey: String? var codexProjectMetadataVersion: Int? diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index a720f3ecb..d6c5882db 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -1643,7 +1643,7 @@ extension CostUsageScanner { } } - static func parseDayKey(_ key: String) -> Date? { + static func parseDayKey(_ key: String, calendar: Calendar = .current) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3 else { return nil } guard @@ -1652,9 +1652,10 @@ extension CostUsageScanner { let day = Int(parts[2]) else { return nil } + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) var comps = DateComponents() - comps.calendar = Calendar.current - comps.timeZone = TimeZone.current + comps.calendar = calendar + comps.timeZone = calendar.timeZone comps.year = year comps.month = month comps.day = day diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 2f61dabad..e862bb349 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -162,7 +162,8 @@ extension CostUsageScanner { guard let tsText = obj["timestamp"] as? String, let timestamp = Self.dateFromTimestamp(tsText) else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + guard let dayKey = Self.dayKeyFromTimestamp(tsText, calendar: range.calendar) + ?? Self.dayKeyFromParsedISO(tsText, calendar: range.calendar) else { return } guard let message = obj["message"] as? [String: Any] else { return } @@ -656,7 +657,10 @@ extension CostUsageScanner { options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - var cache = CostUsageCacheIO.load(provider: provider, cacheRoot: options.cacheRoot) + var cache = CostUsageCacheIO.load( + provider: provider, + cacheRoot: options.cacheRoot, + calendar: range.calendar) let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) @@ -708,7 +712,11 @@ extension CostUsageScanner { cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs try checkCancellation?() - CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) + CostUsageCacheIO.save( + provider: provider, + cache: cache, + cacheRoot: options.cacheRoot, + calendar: range.calendar) } let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift index 088af5b72..af24e5e88 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift @@ -626,7 +626,8 @@ extension CostUsageScanner { private static func nextDayKey(after dayKey: String) -> String { guard let date = self.localDate(forDayKey: dayKey), - let next = Calendar.current.date(byAdding: .day, value: 1, to: date) + let next = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() + .date(byAdding: .day, value: 1, to: date) else { return dayKey } return CostUsageScanner.CostUsageDayRange.dayKey(from: next) } @@ -644,7 +645,7 @@ extension CostUsageScanner { let day = Int(parts[2]) else { return nil } var components = DateComponents() - components.calendar = Calendar.current + components.calendar = CostUsageScanner.CostUsageDayRange.localGregorianCalendar() components.year = year components.month = month components.day = day diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift index b8bf32153..ead918040 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift @@ -30,7 +30,7 @@ extension CostUsageScanner { CostUsageTimestampParser.parseISO(text) } - static func dayKeyFromTimestamp(_ text: String) -> String? { + static func dayKeyFromTimestamp(_ text: String, calendar: Calendar = .current) -> String? { let bytes = Array(text.utf8) guard bytes.count >= 20 else { return nil } guard bytes[safe: 4] == 45, bytes[safe: 7] == 45 else { return nil } @@ -102,16 +102,17 @@ extension CostUsageScanner { comps.second = second guard let date = comps.date else { return nil } - let local = Calendar.current.dateComponents([.year, .month, .day], from: date) + let local = CostUsageDayRange.localGregorianCalendar(matching: calendar) + .dateComponents([.year, .month, .day], from: date) guard let localYear = local.year, let localMonth = local.month, let localDay = local.day else { return nil } return String(format: "%04d-%02d-%02d", localYear, localMonth, localDay) } - static func dayKeyFromParsedISO(_ text: String) -> String? { + static func dayKeyFromParsedISO(_ text: String, calendar: Calendar = .current) -> String? { guard let date = CostUsageTimestampParser.parseISO(text) else { return nil } - return CostUsageDayRange.dayKey(from: date) + return CostUsageDayRange.dayKey(from: date, calendar: calendar) } private static func parse2(_ bytes: [UInt8], at index: Int) -> Int? { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index df42d4c24..b438912db 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -29,6 +29,7 @@ enum CostUsageScanner { var claudeProjectsRoots: [URL]? var cacheRoot: URL? var codexTraceDatabaseURL: URL? + var calendar: Calendar var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. @@ -47,6 +48,7 @@ enum CostUsageScanner { claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, codexTraceDatabaseURL: URL? = nil, + calendar: Calendar = .current, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false, maxCodexSessionFileBytes: Int64 = 256 * 1024 * 1024, @@ -57,6 +59,7 @@ enum CostUsageScanner { self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot self.codexTraceDatabaseURL = codexTraceDatabaseURL + self.calendar = calendar self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan self.maxCodexSessionFileBytes = max(0, maxCodexSessionFileBytes) @@ -1007,7 +1010,7 @@ enum CostUsageScanner { options: Options = Options(), checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - let range = CostUsageDayRange(since: since, until: until) + let range = CostUsageDayRange(since: since, until: until, calendar: options.calendar) let emptyReport = CostUsageDailyReport(data: [], summary: nil) try checkCancellation?() @@ -1054,21 +1057,25 @@ enum CostUsageScanner { let untilKey: String let scanSinceKey: String let scanUntilKey: String + let calendar: Calendar - init(since: Date, until: Date) { - self.sinceKey = Self.dayKey(from: since) - self.untilKey = Self.dayKey(from: until) - self.scanSinceKey = Self.dayKey(from: Calendar.current.date(byAdding: .day, value: -1, to: since) ?? since) - self.scanUntilKey = Self.dayKey(from: Calendar.current.date(byAdding: .day, value: 1, to: until) ?? until) + init(since: Date, until: Date, calendar: Calendar = .current) { + let calendar = Self.localGregorianCalendar(matching: calendar) + self.calendar = calendar + self.sinceKey = Self.dayKey(from: since, calendar: calendar) + self.untilKey = Self.dayKey(from: until, calendar: calendar) + let scanSince = calendar.date(byAdding: .day, value: -1, to: since) ?? since + let scanUntil = calendar.date(byAdding: .day, value: 1, to: until) ?? until + self.scanSinceKey = Self.dayKey(from: scanSince, calendar: calendar) + self.scanUntilKey = Self.dayKey(from: scanUntil, calendar: calendar) } - static func dayKey(from date: Date) -> String { - let cal = Calendar.current - let comps = cal.dateComponents([.year, .month, .day], from: date) - let y = comps.year ?? 1970 - let m = comps.month ?? 1 - let d = comps.day ?? 1 - return String(format: "%04d-%02d-%02d", y, m, d) + static func localGregorianCalendar(matching calendar: Calendar = .current) -> Calendar { + CostUsageLocalDay.gregorianCalendar(matching: calendar) + } + + static func dayKey(from date: Date, calendar: Calendar = .current) -> String { + CostUsageLocalDay.key(from: date, calendar: calendar) } static func isInRange(dayKey: String, since: String, until: String) -> Bool { @@ -1116,12 +1123,14 @@ enum CostUsageScanner { root: URL, scanSinceKey: String, scanUntilKey: String, - includeRecursive: Bool) -> [URL] + includeRecursive: Bool, + calendar: Calendar = .current) -> [URL] { let partitioned = self.listCodexSessionFilesByDatePartition( root: root, scanSinceKey: scanSinceKey, - scanUntilKey: scanUntilKey) + scanUntilKey: scanUntilKey, + calendar: calendar) let flat = self.listCodexSessionFilesFlat(root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey) let recursive = includeRecursive ? self.listCodexLegacySessionFilesRecursive(root: root) : [] var seen: Set = [] @@ -1207,11 +1216,12 @@ enum CostUsageScanner { } private static func codexPriorityTurnKeys( - _ priorityTurns: [String: CodexPriorityTurnMetadata]) -> [String: String] + _ priorityTurns: [String: CodexPriorityTurnMetadata], + calendar: Calendar) -> [String: String] { var partsByDay: [String: [String]] = [:] for (turnID, turn) in priorityTurns { - guard let dayKey = self.codexPriorityDayKey(turn) else { continue } + guard let dayKey = self.codexPriorityDayKey(turn, calendar: calendar) else { continue } partsByDay[dayKey, default: []].append([ turnID, turn.model ?? "", @@ -1227,22 +1237,30 @@ enum CostUsageScanner { } private static func codexPriorityTurnIDsByDay( - _ priorityTurns: [String: CodexPriorityTurnMetadata]) -> [String: [String]] + _ priorityTurns: [String: CodexPriorityTurnMetadata], + calendar: Calendar) -> [String: [String]] { var out: [String: Set] = [:] for (turnID, turn) in priorityTurns { - guard let dayKey = self.codexPriorityDayKey(turn) else { continue } + guard let dayKey = self.codexPriorityDayKey(turn, calendar: calendar) else { continue } out[dayKey, default: []].insert(turnID) } return out.mapValues { $0.sorted() } } - private static func codexPriorityDayKey(_ turn: CodexPriorityTurnMetadata) -> String? { + private static func codexPriorityDayKey( + _ turn: CodexPriorityTurnMetadata, + calendar: Calendar) -> String? + { guard let timestamp = turn.timestamp else { return nil } let dayKeyFromEpoch = Int64(timestamp).map { - CostUsageDayRange.dayKey(from: Date(timeIntervalSince1970: TimeInterval($0))) + CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval($0)), + calendar: calendar) } - return dayKeyFromEpoch ?? self.dayKeyFromTimestamp(timestamp) ?? self.dayKeyFromParsedISO(timestamp) + return dayKeyFromEpoch + ?? self.dayKeyFromTimestamp(timestamp, calendar: calendar) + ?? self.dayKeyFromParsedISO(timestamp, calendar: calendar) } private static func codexPriorityTurnKeysChanged( @@ -1250,7 +1268,10 @@ enum CostUsageScanner { new: [String: String], range: CostUsageDayRange) -> Bool { - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) where old?[dayKey] != new[dayKey] { return true @@ -1266,7 +1287,11 @@ enum CostUsageScanner { range: CostUsageDayRange) -> Set { var out = Set() - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + { let oldIDs = Set(old?[dayKey] ?? []) let newIDs = Set(new[dayKey] ?? []) if oldIDs != newIDs || oldKeys?[dayKey] != newKeys[dayKey] { @@ -1285,7 +1310,11 @@ enum CostUsageScanner { retainedUntilKey: String) -> [String: String]? { var out = existing ?? [:] - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + { out[dayKey] = new[dayKey] } out = out.filter { key, _ in @@ -1302,7 +1331,11 @@ enum CostUsageScanner { retainedUntilKey: String) -> [String: [String]]? { var out = existing ?? [:] - for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + for dayKey in self.dayKeys( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey, + calendar: range.calendar) + { out[dayKey] = new[dayKey] ?? [] } out = out.filter { key, _ in @@ -1319,14 +1352,19 @@ enum CostUsageScanner { root: URL, scanSinceKey: String, scanUntilKey: String, - modifiedSince: Date) -> [URL] + modifiedSince: Date, + calendar: Calendar = .current) -> [URL] { - let lookbackSinceKey = self.dayKey(scanSinceKey, addingDays: -self.codexActiveSessionLookbackDays) + let lookbackSinceKey = self.dayKey( + scanSinceKey, + addingDays: -self.codexActiveSessionLookbackDays, + calendar: calendar) ?? scanSinceKey let partitioned = self.listCodexSessionFilesByDatePartition( root: root, scanSinceKey: lookbackSinceKey, - scanUntilKey: scanUntilKey) + scanUntilKey: scanUntilKey, + calendar: calendar) let partitionedModified = self.filterRecentlyModified(files: partitioned, modifiedSince: modifiedSince) let legacyRecursive = self.listCodexRecentlyModifiedFilesRecursive(root: root, modifiedSince: modifiedSince) @@ -1352,22 +1390,36 @@ enum CostUsageScanner { value.count == length && value.allSatisfy(\.isNumber) } - private static func dayKey(_ dayKey: String, addingDays days: Int) -> String? { - guard let date = self.parseDayKey(dayKey) else { return nil } - guard let shifted = Calendar.current.date(byAdding: .day, value: days, to: date) else { return nil } - return CostUsageDayRange.dayKey(from: shifted) + private static func dayKey( + _ dayKey: String, + addingDays days: Int, + calendar: Calendar = .current) -> String? + { + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) + guard let date = self.parseDayKey(dayKey, calendar: calendar) else { return nil } + guard let shifted = calendar.date(byAdding: .day, value: days, to: date) else { return nil } + return CostUsageDayRange.dayKey(from: shifted, calendar: calendar) + } + + private static func localStartOfDay(_ dayKey: String, calendar: Calendar) -> Date? { + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) + return self.parseDayKey(dayKey, calendar: calendar).map { calendar.startOfDay(for: $0) } } - private static func dayKeys(sinceKey: String, untilKey: String) -> [String] { - guard let since = self.parseDayKey(sinceKey), - self.parseDayKey(untilKey) != nil + private static func dayKeys( + sinceKey: String, + untilKey: String, + calendar: Calendar = .current) -> [String] + { + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) + guard let since = self.parseDayKey(sinceKey, calendar: calendar), + self.parseDayKey(untilKey, calendar: calendar) != nil else { return sinceKey <= untilKey ? [sinceKey] : [] } var out: [String] = [] var cursor = since - let calendar = Calendar.current - while CostUsageDayRange.dayKey(from: cursor) <= untilKey { - out.append(CostUsageDayRange.dayKey(from: cursor)) + while CostUsageDayRange.dayKey(from: cursor, calendar: calendar) <= untilKey { + out.append(CostUsageDayRange.dayKey(from: cursor, calendar: calendar)) guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } if next <= cursor { break @@ -1410,15 +1462,17 @@ enum CostUsageScanner { private static func listCodexSessionFilesByDatePartition( root: URL, scanSinceKey: String, - scanUntilKey: String) -> [URL] + scanUntilKey: String, + calendar: Calendar = .current) -> [URL] { guard FileManager.default.fileExists(atPath: root.path) else { return [] } + let calendar = CostUsageDayRange.localGregorianCalendar(matching: calendar) var out: [URL] = [] - var date = Self.parseDayKey(scanSinceKey) ?? Date() - let untilDate = Self.parseDayKey(scanUntilKey) ?? date + var date = Self.parseDayKey(scanSinceKey, calendar: calendar) ?? Date() + let untilDate = Self.parseDayKey(scanUntilKey, calendar: calendar) ?? date while date <= untilDate { - let comps = Calendar.current.dateComponents([.year, .month, .day], from: date) + let comps = calendar.dateComponents([.year, .month, .day], from: date) let y = String(format: "%04d", comps.year ?? 1970) let m = String(format: "%02d", comps.month ?? 1) let d = String(format: "%02d", comps.day ?? 1) @@ -1437,7 +1491,7 @@ enum CostUsageScanner { } } - date = Calendar.current.date(byAdding: .day, value: 1, to: date) ?? untilDate.addingTimeInterval(1) + date = calendar.date(byAdding: .day, value: 1, to: date) ?? untilDate.addingTimeInterval(1) } return out @@ -2474,7 +2528,8 @@ enum CostUsageScanner { // 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) + guard let dayKey = Self.dayKeyFromTimestamp(record.timestamp, calendar: range.calendar) + ?? Self.dayKeyFromParsedISO(record.timestamp, calendar: range.calendar) else { return } guard !suppressUnownedCopiedPrefix else { return } @@ -3270,8 +3325,8 @@ enum CostUsageScanner { databaseURL: options.codexTraceDatabaseURL, sinceDayKey: range.scanSinceKey, untilDayKey: range.scanUntilKey) : [:] - let priorityTurnKeys = Self.codexPriorityTurnKeys(priorityTurns) - let priorityTurnIDsByDay = Self.codexPriorityTurnIDsByDay(priorityTurns) + let priorityTurnKeys = Self.codexPriorityTurnKeys(priorityTurns, calendar: range.calendar) + let priorityTurnIDsByDay = Self.codexPriorityTurnIDsByDay(priorityTurns, calendar: range.calendar) let priorityTurnsChanged = shouldInspectPriorityTurns && hasPriorityMetadata && Self.codexPriorityTurnKeysChanged( @@ -3322,6 +3377,21 @@ enum CostUsageScanner { shouldRefresh: shouldRefresh) } + private static func loadCodexCache(options: Options, range: CostUsageDayRange) -> CostUsageCache { + CostUsageCacheIO.load( + provider: .codex, + cacheRoot: options.cacheRoot, + calendar: range.calendar) + } + + private static func saveCodexCache(_ cache: CostUsageCache, options: Options, range: CostUsageDayRange) { + CostUsageCacheIO.save( + provider: .codex, + cache: cache, + cacheRoot: options.cacheRoot, + calendar: range.calendar) + } + // swiftlint:disable:next function_body_length private static func loadCodexDaily( range: CostUsageDayRange, @@ -3329,7 +3399,7 @@ enum CostUsageScanner { options: Options, checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { - var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) + var cache = Self.loadCodexCache(options: options, range: range) let nowMs = Int64(now.timeIntervalSince1970 * 1000) let plan = Self.makeCodexRefreshPlan(cache: cache, range: range, now: now, nowMs: nowMs, options: options) @@ -3342,8 +3412,7 @@ enum CostUsageScanner { let cachedSinceKey = cache.scanSinceKey let cachedUntilKey = cache.scanUntilKey let shouldRunColdCacheLookback = cache.files.isEmpty || plan.rootsChanged - let coldCacheLookbackStart = Self.parseDayKey(range.scanSinceKey) - .map { Calendar.current.startOfDay(for: $0) } + let coldCacheLookbackStart = Self.localStartOfDay(range.scanSinceKey, calendar: options.calendar) var seenPaths: Set = [] var files: [URL] = [] for root in plan.roots { @@ -3351,7 +3420,8 @@ enum CostUsageScanner { root: root, scanSinceKey: range.scanSinceKey, scanUntilKey: range.scanUntilKey, - includeRecursive: options.forceRescan) + includeRecursive: options.forceRescan, + calendar: options.calendar) for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { seenPaths.insert(fileURL.path) files.append(fileURL) @@ -3362,7 +3432,8 @@ enum CostUsageScanner { root: root, scanSinceKey: range.scanSinceKey, scanUntilKey: range.scanUntilKey, - modifiedSince: coldCacheLookbackStart) + modifiedSince: coldCacheLookbackStart, + calendar: options.calendar) for fileURL in recentlyModifiedFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { @@ -3498,7 +3569,7 @@ enum CostUsageScanner { } cache.lastScanUnixMs = nowMs try checkCancellation?() - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) + Self.saveCodexCache(cache, options: options, range: range) } return Self.buildCodexReportFromCache( diff --git a/Tests/CodexBarTests/CLIEntryTests.swift b/Tests/CodexBarTests/CLIEntryTests.swift index 426b3d05a..256ec3b58 100644 --- a/Tests/CodexBarTests/CLIEntryTests.swift +++ b/Tests/CodexBarTests/CLIEntryTests.swift @@ -92,6 +92,45 @@ final class CLIEntryTests: XCTestCase { try self.expectAdjacentVersionFile(raw: "version-3.2.3\n", expected: "version-3.2.3") } + func test_cliVersionFindsAdjacentVersionWhenInvokedViaRelativePathAndSymlink() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("quotakit-cli-version-invocation-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let installURL = root.appendingPathComponent("install/bin", isDirectory: true) + let linksURL = root.appendingPathComponent("links", isDirectory: true) + let workingDirectoryURL = root.appendingPathComponent("work", isDirectory: true) + try FileManager.default.createDirectory(at: installURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: linksURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workingDirectoryURL, withIntermediateDirectories: true) + + let executableURL = installURL.appendingPathComponent("QuotaKitCLI") + try FileManager.default.copyItem(at: Self.cliExecutableURL, to: executableURL) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executableURL.path) + try "8.7.6\n".write( + to: installURL.appendingPathComponent("VERSION"), + atomically: false, + encoding: .utf8) + + XCTAssertEqual( + try Self.runVersionCommand( + executableURL: executableURL, + argv0: "install/bin/QuotaKitCLI", + currentDirectoryURL: workingDirectoryURL), + "QuotaKit 8.7.6\n") + + let symlinkURL = linksURL.appendingPathComponent("quotakit") + try FileManager.default.createSymbolicLink( + atPath: symlinkURL.path, + withDestinationPath: "../install/bin/QuotaKitCLI") + XCTAssertEqual( + try Self.runVersionCommand( + executableURL: symlinkURL, + argv0: "quotakit", + currentDirectoryURL: workingDirectoryURL), + "QuotaKit 8.7.6\n") + } + func test_cliVersionPrefersAdjacentVersionOverStandaloneBundleName() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("quotakit-cli-version-bundle-\(UUID().uuidString)", isDirectory: true) @@ -130,6 +169,54 @@ final class CLIEntryTests: XCTestCase { XCTAssertEqual(CodexBarCLI.currentVersion(bundleVersion: nil, executablePath: helperURL.path), expected) } + private static func runVersionCommand( + executableURL: URL, + argv0: String, + currentDirectoryURL: URL) throws -> String + { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = [ + "-c", + "exec -a \"$1\" \"$2\" --version", + "codexbar-version-test", + argv0, + executableURL.path, + ] + process.currentDirectoryURL = currentDirectoryURL + + 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(bytes: errorOutput, encoding: .utf8) + ?? "CodexBarCLI exited without an error message" + throw NSError(domain: "CLIEntryTests", code: Int(process.terminationStatus), userInfo: [ + NSLocalizedDescriptionKey: message, + ]) + } + guard let text = String(bytes: output, encoding: .utf8) else { + throw NSError(domain: "CLIEntryTests", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "CodexBarCLI produced non-UTF-8 output", + ]) + } + return text + } + + private static var cliExecutableURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent(".build/debug/CodexBarCLI") + } + func test_renderOpenAIWebDashboardTextIncludesSummary() { let event = CreditEvent( date: Date(timeIntervalSince1970: 1_700_000_000), diff --git a/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift b/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift index b15449651..0a2e45436 100644 --- a/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift +++ b/Tests/CodexBarTests/ClaudeWebRecoveryMenuTests.swift @@ -29,6 +29,8 @@ struct ClaudeWebRecoveryMenuTests { source: ClaudeUsageDataSource, cookieSource: ProviderCookieSource = .auto, selectedSessionKey: Bool = false, + authenticatedAccountEmail: String? = nil, + authenticatedOAuthWithoutEmail: Bool = false, attempts: [ProviderFetchAttempt] = []) -> [(String, MenuDescriptor.MenuAction)] { let settings = self.makeSettings() @@ -42,6 +44,25 @@ struct ClaudeWebRecoveryMenuTests { fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + if authenticatedAccountEmail != nil || authenticatedOAuthWithoutEmail { + store._setSnapshotForTesting( + UsageSnapshot( + primary: authenticatedOAuthWithoutEmail + ? RateWindow( + usedPercent: 25, + windowMinutes: 5 * 60, + resetsAt: nil, + resetDescription: nil) + : nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: authenticatedAccountEmail, + accountOrganization: nil, + loginMethod: "Claude Pro")), + provider: .claude) + } store.errors[.claude] = error store.lastFetchAttempts[.claude] = attempts @@ -71,6 +92,30 @@ struct ClaudeWebRecoveryMenuTests { #expect(!actions.contains { $0.0 == "Add Account..." }) } + @Test + func `authenticated Claude account shows switch action instead of sign in`() { + let actions = self.actions( + source: .auto, + authenticatedAccountEmail: "claude@example.com") + + #expect(actions.contains { + $0.0 == "Switch Account..." && $0.1 == .switchAccount(.claude) + }) + #expect(!actions.contains { $0.0 == "Sign in with Claude Code..." }) + } + + @Test + func `email-less Claude OAuth snapshot shows switch action instead of sign in`() { + let actions = self.actions( + source: .oauth, + authenticatedOAuthWithoutEmail: true) + + #expect(actions.contains { + $0.0 == "Switch Account..." && $0.1 == .switchAccount(.claude) + }) + #expect(!actions.contains { $0.0 == "Sign in with Claude Code..." }) + } + @Test func `web session errors show claude relogin action`() { let errors = [ diff --git a/Tests/CodexBarTests/CostUsageCacheTests.swift b/Tests/CodexBarTests/CostUsageCacheTests.swift index ed6fa6816..d06d66c09 100644 --- a/Tests/CodexBarTests/CostUsageCacheTests.swift +++ b/Tests/CodexBarTests/CostUsageCacheTests.swift @@ -39,8 +39,8 @@ struct CostUsageCacheTests { let vertexURL = CostUsageCacheIO.cacheFileURL(provider: .vertexai, cacheRoot: root) #expect(codexURL.lastPathComponent == "codex-v11.json") - #expect(claudeURL.lastPathComponent == "claude-v5.json") - #expect(vertexURL.lastPathComponent == "vertexai-v5.json") + #expect(claudeURL.lastPathComponent == "claude-v6.json") + #expect(vertexURL.lastPathComponent == "vertexai-v6.json") } @Test @@ -86,11 +86,11 @@ struct CostUsageCacheTests { let legacyURL = root .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("pi-sessions-v6.json", isDirectory: false) + .appendingPathComponent("pi-sessions-v7.json", isDirectory: false) try FileManager.default.createDirectory( at: legacyURL.deletingLastPathComponent(), withIntermediateDirectories: true) - var legacy = PiSessionCostCache(version: 6) + var legacy = PiSessionCostCache(version: 7) legacy.lastScanUnixMs = 999 legacy.files = [ "/tmp/session.jsonl": PiSessionFileUsage( @@ -104,7 +104,7 @@ struct CostUsageCacheTests { let loaded = PiSessionCostCacheIO.load(cacheRoot: root) - #expect(loaded.version == 7) + #expect(loaded.version == 8) #expect(loaded.lastScanUnixMs == 0) #expect(loaded.files.isEmpty) } diff --git a/Tests/CodexBarTests/CostUsageCalendarTests.swift b/Tests/CodexBarTests/CostUsageCalendarTests.swift new file mode 100644 index 000000000..5ac618a7f --- /dev/null +++ b/Tests/CodexBarTests/CostUsageCalendarTests.swift @@ -0,0 +1,465 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct CostUsageCalendarTests { + @Test + func `day keys remain Gregorian under a Buddhist calendar`() throws { + let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = bangkok + let date = try #require(gregorian.date(from: DateComponents( + timeZone: bangkok, + year: 2026, + month: 7, + day: 23, + hour: 12))) + + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = bangkok + #expect(buddhist.component(.year, from: date) == 2569) + + let range = CostUsageScanner.CostUsageDayRange(since: date, until: date, calendar: buddhist) + #expect(range.sinceKey == "2026-07-23") + #expect(range.untilKey == "2026-07-23") + #expect(range.scanSinceKey == "2026-07-22") + #expect(range.scanUntilKey == "2026-07-24") + #expect(CostUsageScanner.dayKeyFromTimestamp( + "2026-07-23T05:00:00Z", + calendar: buddhist) == "2026-07-23") + #expect(CostUsageScanner.dayKeyFromParsedISO( + "2026-07-23T05:00:00Z", + calendar: buddhist) == "2026-07-23") + + let parsed = try #require(CostUsageScanner.parseDayKey("2026-07-23", calendar: buddhist)) + #expect(CostUsageScanner.CostUsageDayRange.dayKey( + from: parsed, + calendar: buddhist) == "2026-07-23") + } + + @Test + func `warm cache discovers a new Gregorian partition under a Buddhist calendar`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = bangkok + let firstDay = try #require(gregorian.date(from: DateComponents( + timeZone: bangkok, + year: 2026, + month: 7, + day: 22, + hour: 12))) + let secondDay = try #require(gregorian.date(byAdding: .day, value: 1, to: firstDay)) + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = bangkok + #expect(buddhist.component(.year, from: secondDay) == 2569) + + let firstURL = try Self.writeCodexSession( + env: env, + day: firstDay, + partitionCalendar: gregorian, + filename: "first.jsonl", + tokens: 10) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"), + calendar: buddhist) + options.refreshMinIntervalSeconds = 0 + + let firstReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: firstDay, + until: firstDay, + now: firstDay, + options: options) + #expect(firstReport.data.map(\.date) == ["2026-07-22"]) + #expect(firstReport.data.first?.totalTokens == 10) + + let secondURL = try Self.writeCodexSession( + env: env, + day: secondDay, + partitionCalendar: gregorian, + filename: "second.jsonl", + tokens: 20) + let secondReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: secondDay, + until: secondDay, + now: secondDay, + options: options) + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + + #expect(secondReport.data.map(\.date) == ["2026-07-23"]) + #expect(secondReport.data.first?.totalTokens == 20) + #expect(cache.scanSinceKey == "2026-07-21") + #expect(cache.scanUntilKey == "2026-07-24") + #expect(Set(cache.files.keys.map { URL(fileURLWithPath: $0).standardizedFileURL.path }) == Set([ + firstURL.standardizedFileURL.path, + secondURL.standardizedFileURL.path, + ])) + } + + @Test + func `codex cache re-buckets unchanged files when the time zone changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + let windowStart = try Self.date("2026-07-20T12:00:00Z") + let windowEnd = try Self.date("2026-07-24T12:00:00Z") + _ = try Self.writeCodexSession( + env: env, + day: boundary, + partitionCalendar: utc, + filename: "time-zone-change.jsonl", + tokens: 10) + + let utcReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: utc)) + let utcCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(utcReport.data.map(\.date) == ["2026-07-22"]) + #expect(utcCache.timeZoneIdentifier == utc.timeZone.identifier) + #expect(utcCache.files.values.compactMap(\.sessionId) == ["calendar-time-zone-change.jsonl"]) + + let bangkokReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: bangkok)) + let bangkokCache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + #expect(bangkokReport.data.map(\.date) == ["2026-07-23"]) + #expect(bangkokReport.data.first?.totalTokens == 10) + #expect(bangkokCache.timeZoneIdentifier == "Asia/Bangkok") + } + + @Test + func `codex cache does not mix old and new zones after an append`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + let windowStart = try Self.date("2026-07-20T12:00:00Z") + let windowEnd = try Self.date("2026-07-24T12:00:00Z") + let fileURL = try Self.writeCodexSession( + env: env, + day: boundary, + partitionCalendar: utc, + filename: "time-zone-append.jsonl", + tokens: 10) + + let utcReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: utc)) + #expect(utcReport.data.map(\.date) == ["2026-07-22"]) + + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + try handle.seekToEnd() + let appended = try env.jsonl([ + Self.codexTokenCount( + timestamp: env.isoString(for: boundary.addingTimeInterval(2)), + tokens: 30), + ]) + try handle.write(contentsOf: Data(appended.utf8)) + try handle.close() + + let bangkokReport = CostUsageScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.codexOptions(env: env, calendar: bangkok)) + #expect(bangkokReport.data.map(\.date) == ["2026-07-23"]) + #expect(bangkokReport.data.first?.totalTokens == 30) + } + + @Test + func `fetcher keeps daily project session and cached ranges in the injected zone`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + _ = try Self.writeCodexSession( + env: env, + day: boundary, + partitionCalendar: utc, + filename: "fetcher-time-zone.jsonl", + tokens: 10) + + try await Self.expectFetcherSnapshot( + env: env, + now: boundary, + calendar: utc, + expectedDay: "2026-07-22") + try await Self.expectFetcherSnapshot( + env: env, + now: boundary, + calendar: bangkok, + expectedDay: "2026-07-23") + } + + @Test + func `claude and pi caches re-bucket unchanged files when the time zone changes`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let utc = try Self.calendar(timeZoneIdentifier: "UTC") + let bangkok = try Self.calendar(timeZoneIdentifier: "Asia/Bangkok") + let boundary = try Self.date("2026-07-22T18:00:00Z") + let windowStart = try Self.date("2026-07-20T12:00:00Z") + let windowEnd = try Self.date("2026-07-24T12:00:00Z") + _ = try env.writeClaudeProjectFile( + relativePath: "calendar/session.jsonl", + contents: env.jsonl([ + [ + "type": "assistant", + "timestamp": env.isoString(for: boundary), + "sessionId": "claude-calendar-session", + "requestId": "claude-calendar-request", + "message": [ + "id": "claude-calendar-message", + "model": "claude-sonnet-4-20250514", + "usage": [ + "input_tokens": 10, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 2, + ], + ], + ], + ])) + _ = try env.writePiSessionFile( + relativePath: "calendar/2026-07-22T18-00-00-000Z_session.jsonl", + contents: env.jsonl([ + [ + "type": "message", + "timestamp": env.isoString(for: boundary), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(boundary.timeIntervalSince1970 * 1000), + "usage": [ + "input": 10, + "output": 2, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 12, + ], + ], + ], + ])) + + let utcClaude = CostUsageScanner.loadDailyReport( + provider: .claude, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.claudeOptions(env: env, calendar: utc)) + let bangkokClaude = CostUsageScanner.loadDailyReport( + provider: .claude, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.claudeOptions(env: env, calendar: bangkok)) + #expect(utcClaude.data.map(\.date) == ["2026-07-22"]) + #expect(bangkokClaude.data.map(\.date) == ["2026-07-23"]) + #expect(CostUsageCacheIO.load( + provider: .claude, + cacheRoot: env.cacheRoot).timeZoneIdentifier == "Asia/Bangkok") + + let utcPi = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.piOptions(env: env, calendar: utc)) + let bangkokPi = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: windowStart, + until: windowEnd, + now: windowEnd, + options: Self.piOptions(env: env, calendar: bangkok)) + #expect(utcPi.data.map(\.date) == ["2026-07-22"]) + #expect(bangkokPi.data.map(\.date) == ["2026-07-23"]) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).timeZoneIdentifier == "Asia/Bangkok") + } + + private static func expectFetcherSnapshot( + env: CostUsageTestEnvironment, + now: Date, + calendar: Calendar, + expectedDay: String) async throws + { + let options = Self.codexOptions(env: env, calendar: calendar) + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: now, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + #expect(snapshot.daily.map(\.date) == [expectedDay]) + #expect(snapshot.projects.flatMap(\.daily).map(\.date) == [expectedDay]) + #expect(snapshot.sessions.map(\.sessionID) == ["calendar-fetcher-time-zone.jsonl"]) + #expect(snapshot.sessionTokens == 10) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: now, + historyDays: 1, + scannerOptions: options) + #expect(cached?.daily.map(\.date) == [expectedDay]) + #expect(cached?.projects.flatMap(\.daily).map(\.date) == [expectedDay]) + #expect(cached?.sessions.map(\.sessionID) == ["calendar-fetcher-time-zone.jsonl"]) + #expect(cached?.sessionTokens == 10) + + let staleProjectSnapshot = await CostUsageFetcher.loadCachedCodexLocalProjectUsageSnapshot( + now: now, + historyDays: 1, + hidePersonalInfo: false, + scannerOptions: options) + #expect(staleProjectSnapshot == nil) + + let projectSnapshot = try await CostUsageFetcher.loadCodexLocalProjectUsageSnapshot( + now: now, + forceRefresh: true, + historyDays: 1, + hidePersonalInfo: false, + scannerOptions: options) + #expect(projectSnapshot.daily.map(\.day) == [expectedDay]) + #expect(projectSnapshot.projects.flatMap(\.daily).map(\.day) == [expectedDay]) + #expect(projectSnapshot.sessions.flatMap(\.daily).map(\.day) == [expectedDay]) + #expect(projectSnapshot.scopeSignature.contains("timeZone=\(calendar.timeZone.identifier)")) + + let cachedProjectSnapshot = await CostUsageFetcher.loadCachedCodexLocalProjectUsageSnapshot( + now: now, + historyDays: 1, + hidePersonalInfo: false, + scannerOptions: options) + #expect(cachedProjectSnapshot?.daily.map(\.day) == [expectedDay]) + } + + private static func codexOptions( + env: CostUsageTestEnvironment, + calendar: Calendar) -> CostUsageScanner.Options + { + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"), + calendar: calendar) + options.refreshMinIntervalSeconds = 0 + return options + } + + private static func claudeOptions( + env: CostUsageTestEnvironment, + calendar: Calendar) -> CostUsageScanner.Options + { + var options = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + calendar: calendar) + options.refreshMinIntervalSeconds = 0 + return options + } + + private static func piOptions( + env: CostUsageTestEnvironment, + calendar: Calendar) -> PiSessionCostScanner.Options + { + PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + calendar: calendar, + refreshMinIntervalSeconds: 0) + } + + private static func calendar(timeZoneIdentifier: String) throws -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: timeZoneIdentifier)) + return calendar + } + + private static func date(_ text: String) throws -> Date { + try #require(ISO8601DateFormatter().date(from: text)) + } + + private static func codexTokenCount( + timestamp: String, + tokens: Int, + model: String = "openai/gpt-5.4") -> [String: Any] + { + [ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ] + } + + private static func writeCodexSession( + env: CostUsageTestEnvironment, + day: Date, + partitionCalendar: Calendar, + filename: String, + tokens: Int) throws -> URL + { + let components = partitionCalendar.dateComponents([.year, .month, .day], from: day) + let directory = env.codexSessionsRoot + .appendingPathComponent(String(format: "%04d", components.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", components.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", components.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let model = "openai/gpt-5.4" + let url = directory.appendingPathComponent(filename, isDirectory: false) + try env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day), + "payload": [ + "id": "calendar-\(filename)", + "cwd": env.root.appendingPathComponent("calendar-project", isDirectory: true).path, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + Self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + tokens: tokens, + model: model), + ]).write(to: url, atomically: true, encoding: .utf8) + return url + } +} diff --git a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift index 94b640ef1..e7b150ec9 100644 --- a/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift +++ b/Tests/CodexBarTests/CostUsageWindowSummaryTests.swift @@ -42,6 +42,48 @@ struct CostUsageWindowSummaryTests { #expect(summary.totalRequests == nil) } + @Test + func `comparison summaries keep Gregorian entries under a Buddhist calendar`() throws { + let bangkok = try #require(TimeZone(identifier: "Asia/Bangkok")) + var gregorian = Calendar(identifier: .gregorian) + gregorian.timeZone = bangkok + let now = try #require(gregorian.date(from: DateComponents( + timeZone: bangkok, + year: 2026, + month: 7, + day: 23, + hour: 12))) + var buddhist = Calendar(identifier: .buddhist) + buddhist.timeZone = bangkok + #expect(buddhist.component(.year, from: now) == 2569) + + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 900, + last30DaysCostUSD: 9, + historyDays: 90, + daily: [ + Self.entry(day: "2026-06-23", cost: 1, tokens: 100, requests: 1), + Self.entry(day: "2026-06-24", cost: 4, tokens: 400, requests: 4), + Self.entry(day: "2026-07-16", cost: 1, tokens: 100, requests: 1), + Self.entry(day: "2026-07-17", cost: 2, tokens: 200, requests: 2), + Self.entry(day: "2026-07-23", cost: 3, tokens: 300, requests: 3), + ], + updatedAt: now) + + let summaries = snapshot.comparisonSummaries(periods: [7, 30], calendar: buddhist) + let sevenDays = try #require(summaries.first { $0.days == 7 }) + let thirtyDays = try #require(summaries.first { $0.days == 30 }) + + #expect(sevenDays.entryCount == 2) + #expect(sevenDays.totalCostUSD == 5) + #expect(sevenDays.totalTokens == 500) + #expect(thirtyDays.entryCount == 4) + #expect(thirtyDays.totalCostUSD == 10) + #expect(thirtyDays.totalTokens == 1000) + } + private static func snapshot(historyDays: Int) -> CostUsageTokenSnapshot { CostUsageTokenSnapshot( sessionTokens: 500, diff --git a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift index 4524d2593..cd913e600 100644 --- a/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DoubaoUsageFetcherTests.swift @@ -1058,3 +1058,215 @@ private actor DoubaoScriptedTransport: ProviderHTTPTransport { } } } + +struct DoubaoAgentPlanUsageTests { + @Test + func `reclaimed coding plan decodes as empty quotas rather than throwing`() throws { + // An account that switched from Coding Plan to Agent Plan returns Status only, with no + // `QuotaUsage` key. That must not be a decode failure (it previously surfaced as + // "Failed to parse Doubao response") so the Agent Plan fallback can run. + let data = Data(#"{"Result":{"Status":"Reclaimed","UpdateTimestamp":1785322689}}"#.utf8) + let usage = try DoubaoUsageFetcher.decodeCodingPlanUsage(from: data) + #expect(usage.quotas.isEmpty) + #expect(usage.status == "Reclaimed") + } + + @Test + func `agent plan usage maps AFP windows onto agent rate windows`() throws { + // Real GetAFPUsage body for an Agent Plan "medium" account. ResetTime is epoch + // milliseconds; -1 marks a window with no active reset (zero usage). + let data = Data( + """ + { + "Result": { + "PlanType": "medium", + "AFPFiveHour": {"Quota": 10000, "Used": 0, "ResetTime": -1}, + "AFPWeekly": {"Quota": 35000, "Used": 8750, "ResetTime": 1785686400000}, + "AFPMonthly": {"Quota": 100000, "Used": 25000, "ResetTime": 1787846399000}, + "AFPDaily": {"Quota": 50000, "Used": 0, "ResetTime": 1785340800000} + } + } + """.utf8) + let usage = try DoubaoUsageFetcher.decodeAgentPlanUsage(from: data) + let snapshot = usage.toUsageSnapshot(updatedAt: Date(timeIntervalSince1970: 1_785_300_000)) + + // Coding Plan windows stay empty; the Agent Plan renders through the extra windows, + // matching the arkcli (`.cli`) path so both sources look identical. + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.tertiary == nil) + + let extra = snapshot.extraRateWindows ?? [] + let fiveHour = extra.first { $0.id == "doubao-agent-session" } + let weekly = extra.first { $0.id == "doubao-agent-weekly" } + let monthly = extra.first { $0.id == "doubao-agent-monthly" } + + #expect(fiveHour?.title == "5-hour") + #expect(fiveHour?.window.usedPercent == 0) + #expect(fiveHour?.window.resetsAt == nil) + + #expect(weekly?.title == "Weekly") + #expect(weekly?.window.usedPercent == 25) // 8750 / 35000 + #expect(weekly?.window.resetsAt == Date(timeIntervalSince1970: 1_785_686_400)) + + #expect(monthly?.title == "Monthly") + #expect(monthly?.window.usedPercent == 25) // 25000 / 100000 + #expect(monthly?.window.resetsAt == Date(timeIntervalSince1970: 1_787_846_399)) + + // `AFPDaily` has no renderer slot and must not leak into the windows. + #expect(extra.count == 3) + } + + @Test + func `coding plan fetch falls back to agent plan when coding plan is reclaimed`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: #"{"Result":{"Status":"Reclaimed","UpdateTimestamp":1785322689}}"#), + .rawResponse( + statusCode: 200, + body: """ + { + "Result": { + "PlanType": "medium", + "AFPFiveHour": {"Quota": 10000, "Used": 0, "ResetTime": -1}, + "AFPWeekly": {"Quota": 35000, "Used": 0, "ResetTime": 1785686400000}, + "AFPMonthly": {"Quota": 100000, "Used": 0, "ResetTime": 1787846399000} + } + } + """), + ]) + let credentials = DoubaoCodingPlanCredentials( + accessKeyID: "AKLTTEST", + secretAccessKey: "secret", + region: "cn-beijing") + + let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: credentials, + session: transport, + date: Date(timeIntervalSince1970: 1_781_654_400)) + + // Both the Coding Plan probe and the Agent Plan fallback were issued, and the + // fallback's second request targeted the GetAFPUsage action. + #expect(await transport.requestCount() == 2) + let request = await transport.lastCapturedRequest() + #expect(request?.url == "https://open.volcengineapi.com/?Action=GetAFPUsage&Version=2024-01-01") + + let usage = snapshot.toUsageSnapshot() + let extra = usage.extraRateWindows ?? [] + #expect(extra.contains { $0.id == "doubao-agent-weekly" && $0.window.usedPercent == 0 }) + #expect(extra.contains { $0.id == "doubao-agent-monthly" }) + } + + @Test + func `coding plan fetch keeps active coding plan without probing agent plan`() async throws { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: """ + { + "Result": { + "Status": "Running", + "UpdateTimestamp": 1782226444, + "QuotaUsage": [ + {"Level": "session", "Percent": 12.5, "ResetTimestamp": 1782226478} + ] + } + } + """), + ]) + let credentials = DoubaoCodingPlanCredentials( + accessKeyID: "AKLTTEST", + secretAccessKey: "secret", + region: "cn-beijing") + + let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: credentials, + session: transport, + date: Date(timeIntervalSince1970: 1_781_654_400)) + + // An active Coding Plan is returned as-is; no second (Agent Plan) request is made. + #expect(await transport.requestCount() == 1) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5) + } + + @Test + func `agent plan fallback surfaces access denied`() async { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: #"{"Result":{"Status":"Reclaimed"}}"#), + .rawResponse( + statusCode: 403, + body: #"{"ResponseMetadata":{"Error":{"Code":"AccessDenied","Message":"not authorized"}}}"#), + ]) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: Self.credentials, + session: transport) + } throws: { error in + guard case let DoubaoUsageError.apiError(code, message) = error else { return false } + return code == 403 && message.contains("AccessDenied") + } + } + + @Test + func `agent plan fallback surfaces malformed response`() async { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: #"{"Result":{"Status":"Reclaimed"}}"#), + .rawResponse(statusCode: 200, body: #"{"Result":null}"#), + ]) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: Self.credentials, + session: transport) + } throws: { error in + guard case .parseFailed = error as? DoubaoUsageError else { return false } + return true + } + } + + @Test + func `agent plan fallback surfaces transport failure`() async { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: #"{"Result":{"Status":"Reclaimed"}}"#), + .failure(URLError(.timedOut)), + ]) + + await #expect { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: Self.credentials, + session: transport) + } throws: { error in + guard case let DoubaoUsageError.networkError(message) = error else { return false } + return !message.isEmpty + } + } + + @Test + func `agent plan fallback propagates cancellation`() async { + let transport = DoubaoScriptedTransport(results: [ + .rawResponse( + statusCode: 200, + body: #"{"Result":{"Status":"Reclaimed"}}"#), + .cancellation, + ]) + + await #expect(throws: CancellationError.self) { + _ = try await DoubaoUsageFetcher.fetchCodingPlanUsage( + credentials: Self.credentials, + session: transport) + } + } + + private static let credentials = DoubaoCodingPlanCredentials( + accessKeyID: "AKLTTEST", + secretAccessKey: "secret", + region: "cn-beijing") +} diff --git a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift index 55aba9b4c..776d92d3f 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift @@ -622,6 +622,52 @@ struct OllamaUsageFetcherRetryMappingTests { #expect(recorder.count == 1) } + @Test + func `token account header survives the manual strategy boundary`() { + let header = "__Secure-session=my-cookie:session=abc" + let context = self.makeContext( + sourceMode: .auto, + settings: ProviderSettingsSnapshot.make( + ollama: .init(cookieSource: .manual, manualCookieHeader: header))) + + #expect(OllamaStatusFetchStrategy.manualCookieHeader(from: context) == header) + } + + @Test + func `token account session value reaches outgoing cookie header`() async throws { + defer { OllamaRetryMappingStubURLProtocol.handler = nil } + + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "account-token", + addedAt: 0, + lastUsed: nil) + let settings = ProviderCookieSettingsResolver.resolve( + provider: .ollama, + configuredSource: .auto, + configuredHeader: nil, + selectedAccount: account) + OllamaRetryMappingStubURLProtocol.handler = { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "__Secure-session=account-token") + let url = try #require(request.url) + let body = """ +
+ Session usage + 1.2% used + Weekly usage + 3.4% used +
+ """ + return Self.makeResponse(url: url, body: body, statusCode: 200) + } + + let fetcher = self.makeCookieFetcher() + _ = try await fetcher.fetch( + cookieHeaderOverride: settings.manualCookieHeader, + manualCookieMode: true) + } + @Test func `temporary session is finished after a transport failure`() async { defer { OllamaRetryMappingStubURLProtocol.handler = nil } diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index 9e3ade6d9..4d968f15d 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -108,6 +108,212 @@ struct OllamaUsageFetcherTests { #expect(resolved?.contains("__Secure-session=abc") == true) } + @Test + func `raw ollama token account becomes a secure session cookie`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "account-token", + addedAt: 0, + lastUsed: nil) + let settings = ProviderCookieSettingsResolver.resolve( + provider: .ollama, + configuredSource: .auto, + configuredHeader: nil, + selectedAccount: account) + + #expect(settings.cookieSource == .manual) + #expect(settings.manualCookieHeader == "__Secure-session=account-token") + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: settings.manualCookieHeader, + manualCookieMode: true) + #expect(resolved == "__Secure-session=account-token") + } + + @Test + func `padded ollama token account becomes a secure session cookie`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: " \n opaque-session== \t", + addedAt: 0, + lastUsed: nil) + let settings = ProviderCookieSettingsResolver.resolve( + provider: .ollama, + configuredSource: .auto, + configuredHeader: nil, + selectedAccount: account) + + #expect(settings.manualCookieHeader == "__Secure-session=opaque-session==") + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: settings.manualCookieHeader, + manualCookieMode: true) + #expect(resolved == "__Secure-session=opaque-session==") + } + + @Test + func `empty ollama token account does not synthesize a session cookie`() { + let header = normalizedOllamaTokenAccountHeader( + " \n\t ", + defaultCookieName: "__Secure-session") + + #expect(header.isEmpty) + } + + @Test + func `ollama token account preserves unrecognized multi cookie header`() { + let header = "theme=dark; locale=en" + let normalized = normalizedOllamaTokenAccountHeader( + header, + defaultCookieName: "__Secure-session") + + #expect(normalized == header) + } + + @Test + func `ollama token account normalizes explicit cookie header`() throws { + let header = "Cookie: __Secure-session=opaque-session==" + let normalized = normalizedOllamaTokenAccountHeader( + header, + defaultCookieName: "__Secure-session") + + #expect(normalized == "__Secure-session=opaque-session==") + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: normalized, + manualCookieMode: true) + #expect(resolved == "__Secure-session=opaque-session==") + } + + @Test(arguments: ["opaque-cookie:value", "prefixCOOKIE:value"]) + func `cookie marker inside ollama session value is not treated as a header`(token: String) { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: token) + + #expect(normalized == "__Secure-session=\(token)") + } + + @Test + func `lowercase secure session cookie name is canonicalized`() { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: "__secure-session=abc") + + #expect(normalized == "__Secure-session=abc") + } + + @Test + func `unknown single cookie shape is treated as an opaque session value`() { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: "foo=bar") + + #expect(normalized == "__Secure-session=foo=bar") + } + + @Test + func `embedded cookie marker in session value is preserved as value data`() { + let token = "my-cookie:session=abc" + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: token) + + let expected = "__Secure-session=\(token)" + #expect(normalized == expected) + let resolved = try? OllamaUsageFetcher.resolveManualCookieHeader( + override: normalized, + manualCookieMode: true) + #expect(resolved == expected) + } + + @Test(arguments: ["abc123", "opaque-session=="]) + func `cookie prefixed bare value becomes a secure session cookie`(value: String) throws { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: "Cookie: \(value)") + let expected = "__Secure-session=\(value)" + + #expect(normalized == expected) + #expect(try OllamaUsageFetcher.resolveManualCookieHeader( + override: normalized, + manualCookieMode: true) == expected) + } + + @Test(arguments: [ + "curl https://ollama.com -H 'Cookie: __Secure-session=abc'", + "curl https://ollama.com -H Cookie:__Secure-session=abc", + "curl https://ollama.com --cookie '__Secure-session=abc'", + "curl https://ollama.com -b'__Secure-session=abc'", + ]) + func `ollama token account retains supported curl cookie forms`(token: String) throws { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: token) + + #expect(normalized == "__Secure-session=abc") + #expect(try OllamaUsageFetcher.resolveManualCookieHeader( + override: normalized, + manualCookieMode: true) == "__Secure-session=abc") + } + + @Test + func `mixed ollama header canonicalizes default cookie regardless of order`() { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: "wos-session=old; __secure-session=current") + + #expect(normalized == "wos-session=old; __Secure-session=current") + } + + @Test + func `ollama token account rejects multiline opaque values`() { + let normalized = TokenAccountSupportCatalog.normalizedCookieHeader( + for: .ollama, + token: "abc\r\nX-Test: injected") + + #expect(normalized.isEmpty) + } + + @Test + func `ollama token account preserves secure session cookie header`() { + let header = "__Secure-session=opaque-session==" + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: header, + addedAt: 0, + lastUsed: nil) + let settings = ProviderCookieSettingsResolver.resolve( + provider: .ollama, + configuredSource: .auto, + configuredHeader: nil, + selectedAccount: account) + + #expect(settings.manualCookieHeader == header) + } + + @Test + func `ollama token account preserves another recognized cookie header`() throws { + let header = "wos-session=account-token" + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: header, + addedAt: 0, + lastUsed: nil) + let settings = ProviderCookieSettingsResolver.resolve( + provider: .ollama, + configuredSource: .auto, + configuredHeader: nil, + selectedAccount: account) + + #expect(settings.manualCookieHeader == header) + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: settings.manualCookieHeader, + manualCookieMode: true) + #expect(resolved == header) + } + @Test func `manual mode accepts workos session cookie header`() throws { let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( diff --git a/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift new file mode 100644 index 000000000..817ee64e6 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift @@ -0,0 +1,322 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct OpenCodeGoWebOverlayTests { + private static let updatedAt = Date(timeIntervalSince1970: 1_784_836_525) + private static let renewsAt = Date(timeIntervalSince1970: 1_786_550_400) + + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Value] = [] + + func append(_ value: Value) { + self.lock.lock() + defer { self.lock.unlock() } + self.storage.append(value) + } + + var values: [Value] { + self.lock.lock() + defer { self.lock.unlock() } + return self.storage + } + } + + 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 func dailyEntry() -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: "2026-07-20", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 748, + costUSD: 11.52, + modelsUsed: nil, + modelBreakdowns: nil) + } + + /// Mirrors the mis-anchored local estimate: earliest local row far before the real billing + /// cycle, so the monthly window sums more than the $60 plan limit and clamps to 100%. + private static func localEstimate(zenBalanceUSD: Double? = nil) -> OpenCodeGoUsageSnapshot { + OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 0, + weeklyUsagePercent: 49.4, + monthlyUsagePercent: 100, + rollingResetInSec: 18000, + weeklyResetInSec: 266_400, + monthlyResetInSec: 266_400, + zenBalanceUSD: zenBalanceUSD, + daily: [self.dailyEntry()], + updatedAt: self.updatedAt) + } + + private static func webUsage(zenBalanceUSD: Double? = nil) -> OpenCodeGoUsageSnapshot { + OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 0, + weeklyUsagePercent: 52, + monthlyUsagePercent: 64, + rollingResetInSec: 18000, + weeklyResetInSec: 266_400, + monthlyResetInSec: 1_539_000, + zenBalanceUSD: zenBalanceUSD, + renewsAt: self.renewsAt, + updatedAt: self.updatedAt.addingTimeInterval(2)) + } + + private func makeContext( + includeOptionalUsage: Bool = true, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + includeOptionalUsage: includeOptionalUsage, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + private func makeManualCookieSettings() -> ProviderSettingsSnapshot { + ProviderSettingsSnapshot.make(opencodego: .init( + cookieSource: .manual, + manualCookieHeader: "auth=test", + workspaceID: nil)) + } + + @Test + func `overlay replaces estimated windows with server values and keeps local daily`() { + let merged = Self.localEstimate().applyingWebUsage(Self.webUsage(zenBalanceUSD: 42.5)) + + #expect(merged.rollingUsagePercent == 0) + #expect(merged.weeklyUsagePercent == 52) + #expect(merged.monthlyUsagePercent == 64) + #expect(merged.monthlyResetInSec == 1_539_000) + #expect(merged.hasWeeklyUsage) + #expect(merged.hasMonthlyUsage) + #expect(merged.zenBalanceUSD == 42.5) + #expect(merged.renewsAt == Self.renewsAt) + #expect(merged.daily.count == 1) + #expect(merged.daily.first?.costUSD == 11.52) + #expect(merged.updatedAt == Self.updatedAt) + #expect(!merged.isBalanceOnly) + } + + @Test + func `overlay keeps local zen balance when web usage has none`() { + let merged = Self.localEstimate(zenBalanceUSD: 7.25).applyingWebUsage(Self.webUsage()) + + #expect(merged.zenBalanceUSD == 7.25) + #expect(merged.monthlyUsagePercent == 64) + } + + @Test + func `overlay keeps local renewal date when web usage has none`() { + let local = Self.localEstimate() + let merged = local.applyingWebUsage(Self.webUsage()) + + #expect(merged.renewsAt == Self.renewsAt) + let webWithoutRenewal = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 1, + weeklyUsagePercent: 2, + monthlyUsagePercent: 3, + rollingResetInSec: 1, + weeklyResetInSec: 2, + monthlyResetInSec: 3, + renewsAt: nil, + updatedAt: Self.updatedAt) + #expect(local.applyingWebUsage(webWithoutRenewal).renewsAt == nil) + } + + @Test + func `balance only web response keeps local windows and adopts balance`() { + let web = OpenCodeGoUsageSnapshot.zenBalanceOnly(balanceUSD: 42.5, updatedAt: Self.updatedAt) + let merged = Self.localEstimate().applyingWebUsage(web) + + #expect(merged.monthlyUsagePercent == 100) + #expect(merged.monthlyResetInSec == 266_400) + #expect(merged.zenBalanceUSD == 42.5) + #expect(merged.daily.count == 1) + #expect(!merged.isBalanceOnly) + } + + @Test + func `overlaid snapshot projects server monthly window into usage snapshot`() { + let merged = Self.localEstimate().applyingWebUsage(Self.webUsage()) + let usage = merged.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.secondary?.usedPercent == 52) + #expect(usage.tertiary?.usedPercent == 64) + #expect(usage.tertiary?.resetsAt == Self.updatedAt.addingTimeInterval(1_539_000)) + #expect(usage.opencodegoUsage?.daily.count == 1) + #expect(usage.extraRateWindows?.contains { $0.id == "renewal" } == true) + } + + @Test + func `local strategy overlays authoritative web usage when a cookie is configured`() async throws { + let observedCookies = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + observedCookies.append(cookieHeader) + return Self.webUsage(zenBalanceUSD: 42.5) + }) + + let result = try await strategy.fetch(self.makeContext(settings: self.makeManualCookieSettings())) + + #expect(result.sourceLabel == "local+web") + #expect(observedCookies.values == ["auth=test"]) + #expect(result.usage.tertiary?.usedPercent == 64) + #expect(result.usage.secondary?.usedPercent == 52) + #expect(result.usage.opencodegoUsage?.daily.count == 1) + #expect(result.usage.providerCost?.used == 42.5) + } + + @Test + func `local strategy keeps local estimate when web overlay is unavailable`() async throws { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in nil }) + + let result = try await strategy.fetch(self.makeContext( + includeOptionalUsage: false, + settings: self.makeManualCookieSettings())) + + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + } + + @Test + func `local strategy does not consult web usage when cookies are disabled`() async throws { + let webCalls = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + webCalls.append(cookieHeader) + return Self.webUsage() + }) + let settings = ProviderSettingsSnapshot.make(opencodego: .init( + cookieSource: .off, + manualCookieHeader: nil, + workspaceID: nil)) + + let result = try await strategy.fetch(self.makeContext(settings: settings)) + + #expect(webCalls.values.isEmpty) + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + } + + @Test + func `local strategy propagates cancellation from the web overlay`() async { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw CancellationError() }) + + await #expect(throws: CancellationError.self) { + try await strategy.fetch(self.makeContext(settings: self.makeManualCookieSettings())) + } + } + + @Test + func `local strategy propagates url session cancellation from the web overlay`() async { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw URLError(.cancelled) }) + + await #expect(throws: CancellationError.self) { + try await strategy.fetch(self.makeContext(settings: self.makeManualCookieSettings())) + } + } + + #if os(macOS) + @Test + func `local strategy evicts cached cookie after authentication failure`() async throws { + try await self.withCachedCookie { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw OpenCodeGoUsageError.invalidCredentials }) + + let result = try await strategy.fetch(self.makeContext(includeOptionalUsage: false)) + + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + #expect(CookieHeaderCache.load(provider: .opencodego) == nil) + } + } + + @Test + func `local strategy retains cached cookie after transport failure`() async throws { + try await self.withCachedCookie { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw URLError(.timedOut) }) + + let result = try await strategy.fetch(self.makeContext(includeOptionalUsage: false)) + + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + #expect(CookieHeaderCache.load(provider: .opencodego)?.cookieHeader == "auth=cached-session") + } + } + + @Test + func `local strategy reuses valid cached cookie`() async throws { + try await self.withCachedCookie { + let observedCookies = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + observedCookies.append(cookieHeader) + return Self.webUsage() + }) + + let result = try await strategy.fetch(self.makeContext(includeOptionalUsage: false)) + + #expect(result.sourceLabel == "local+web") + #expect(result.usage.tertiary?.usedPercent == 64) + #expect(observedCookies.values == ["auth=cached-session"]) + #expect(CookieHeaderCache.load(provider: .opencodego)?.cookieHeader == "auth=cached-session") + } + } + + private func withCachedCookie(_ operation: () async throws -> T) async rethrows -> T { + let service = "com.steipete.codexbar.tests.opencodego-overlay.\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + CookieHeaderCache.store( + provider: .opencodego, + cookieHeader: "auth=cached-session", + sourceLabel: "Chrome") + return try await operation() + } + } + } + #endif +} diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift index bb8814c38..677b807fb 100644 --- a/Tests/CodexBarTests/PiSessionCostScannerTests.swift +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -750,8 +750,8 @@ struct PiSessionCostScannerTests { #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] - #expect(newCacheURL.lastPathComponent == "pi-sessions-v7.json") - #expect(newCache.version == 7) + #expect(newCacheURL.lastPathComponent == "pi-sessions-v8.json") + #expect(newCache.version == 8) #expect(rebuilt?.usageSampleCount == 1) #expect(rebuilt?.costSampleCount == 1) #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) @@ -859,7 +859,7 @@ struct PiSessionCostScannerTests { let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] - #expect(newCache.version == 7) + #expect(newCache.version == 8) #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 4f30e2d67..45d5b6e11 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -1581,8 +1581,8 @@ extension StatusMenuTests { controller.menuWillOpen(menu) let ids = self.representedIDs(in: menu) let overviewRows = ids.filter { $0.hasPrefix("overviewRow-") } - #expect(overviewRows.count == enabledProviders.count && ids.contains("menuCard") == false) - #expect(enabledProviders.allSatisfy { overviewRows.contains("overviewRow-\($0.rawValue)") }) + #expect(Set(overviewRows) == Set(enabledProviders.map { "overviewRow-\($0.rawValue)" })) + #expect(menu.items.count(where: \.isSeparatorItem) == overviewRows.count + 1) } @Test diff --git a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift index 1893cff4e..b5bf15688 100644 --- a/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift +++ b/Tests/CodexBarTests/UsageMenuCardLayoutTests.swift @@ -8,6 +8,11 @@ import Testing struct UsageMenuCardLayoutTests { private static let heightTolerance: CGFloat = 1 + @Test + func `overview groups provider content without section dividers`() { + #expect(OverviewMenuCardRowView.showsSectionDividers == false) + } + @Test func `header only menu card keeps comfortable padding`() { let model = Self.model() diff --git a/TestsLinux/MiniMaxLinuxTests.swift b/TestsLinux/MiniMaxLinuxTests.swift new file mode 100644 index 000000000..610258cc0 --- /dev/null +++ b/TestsLinux/MiniMaxLinuxTests.swift @@ -0,0 +1,74 @@ +#if os(Linux) +import Foundation +import Testing +@testable import CodexBarCLI +@testable import CodexBarCore + +struct MiniMaxLinuxTests { + @Test + func `coding plan API key does not require macOS web support`() { + // A coding-plan key resolves to the plain HTTPS + Bearer API strategy, so it must be + // usable off macOS (matches the Factory/Kimi credential exemptions). + #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .minimax, + environment: [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-test"])) + } + + @Test + func `standard API key still requires web support because Auto resolves to the coding plan page`() { + // `MiniMaxAPIFetchStrategy` refuses standard `sk-api-` keys, so Auto falls back to the + // Coding Plan web strategy. Exempting it here would only produce `noAvailableStrategy`. + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .minimax, + environment: [MiniMaxAPISettingsReader.apiTokenKey: "sk-api-test"])) + } + + @Test + func `auto without an API key still requires web support off macOS`() { + // Without a credential the only remaining Auto path is the web/cookie one, which + // genuinely needs macOS — the gate must still fire. + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .auto, + provider: .minimax, + environment: [:])) + } + + @Test + func `explicit web source still requires web support even with an API key`() { + // The exemption is scoped to Auto; asking for the web source explicitly must not + // be silently redirected to the API path. + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .minimax, + environment: [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-test"])) + } + + @Test + func `exempted coding plan key actually resolves a linux capable strategy`() async { + // Gate agreement is not enough: the Auto plan must contain a strategy that can run + // without the macOS web path, otherwise the exemption ends in `noAvailableStrategy`. + let env = [MiniMaxAPISettingsReader.codingPlanAPITokenKey: "sk-cp-test"] + let browserDetection = BrowserDetection(cacheTTL: 0) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: ProviderSettingsSnapshot.make(), + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + let strategies = await ProviderDescriptorRegistry + .descriptor(for: .minimax) + .fetchPlan + .pipeline + .resolveStrategies(context) + #expect(strategies.contains { $0.id == "minimax.api" }) + } +} +#endif diff --git a/TestsLinux/ProviderEndpointOverrideSecurityLinuxTests.swift b/TestsLinux/ProviderEndpointOverrideSecurityLinuxTests.swift index 713637f11..1a482a5c6 100644 --- a/TestsLinux/ProviderEndpointOverrideSecurityLinuxTests.swift +++ b/TestsLinux/ProviderEndpointOverrideSecurityLinuxTests.swift @@ -125,6 +125,131 @@ struct ProviderEndpointOverrideSecurityLinuxTests { #expect(MiMoSettingsReader.apiURL(environment: [MiMoSettingsReader.apiURLKey: "mimo-proxy.test/api/v1"]) .absoluteString == "https://mimo-proxy.test/api/v1") } + + // MARK: - LiteLLM / LLM Proxy + + // Both send their API key to the configured base URL as a bearer token. HTTPS works everywhere; + // HTTP is limited to loopback and explicitly private-network destinations. + + @Test + func liteLLMRejectsRemoteHTTPBaseURLBeforeSendingKey() { + for endpoint in Self.publicHTTPEndpoints { + #expect(LiteLLMSettingsReader.baseURL( + environment: [LiteLLMSettingsReader.baseURLEnvironmentKey: endpoint]) == nil) + } + } + + @Test + func liteLLMRejectsBaseURLWithEmbeddedCredentials() { + #expect(LiteLLMSettingsReader.baseURL( + environment: [LiteLLMSettingsReader.baseURLEnvironmentKey: "https://user@attacker.test"]) == nil) + } + + @Test + func liteLLMAcceptsHTTPSAndPrivateNetworkHTTPBaseURLs() { + #expect(LiteLLMSettingsReader.baseURL( + environment: [LiteLLMSettingsReader.baseURLEnvironmentKey: "https://litellm.example.com"])? + .absoluteString == "https://litellm.example.com") + for endpoint in Self.privateHTTPEndpoints { + #expect(LiteLLMSettingsReader.baseURL( + environment: [LiteLLMSettingsReader.baseURLEnvironmentKey: endpoint])?.absoluteString == endpoint) + } + } + + @Test + func llmProxyRejectsRemoteHTTPBaseURLBeforeSendingKey() { + for endpoint in Self.publicHTTPEndpoints { + #expect(LLMProxySettingsReader.baseURL( + environment: [LLMProxySettingsReader.baseURLEnvironmentKey: endpoint]) == nil) + } + } + + @Test + func llmProxyRejectsBaseURLWithEmbeddedCredentials() { + #expect(LLMProxySettingsReader.baseURL( + environment: [LLMProxySettingsReader.baseURLEnvironmentKey: "https://user@attacker.test"]) == nil) + } + + @Test + func rejectedBaseURLStaysConfiguredSoTheErrorCanSurface() { + // A rejected override must not read as "never configured": the strategy stays available so + // the fetch path can report invalidEndpointOverride instead of the provider going missing. + let liteLLM = [LiteLLMSettingsReader.baseURLEnvironmentKey: "http://attacker.test"] + #expect(LiteLLMSettingsReader.baseURL(environment: liteLLM) == nil) + #expect(LiteLLMSettingsReader.hasBaseURLOverride(environment: liteLLM)) + #expect(!LiteLLMSettingsReader.hasBaseURLOverride(environment: [:])) + + let llmProxy = [LLMProxySettingsReader.baseURLEnvironmentKey: "http://attacker.test"] + #expect(LLMProxySettingsReader.baseURL(environment: llmProxy) == nil) + #expect(LLMProxySettingsReader.hasBaseURLOverride(environment: llmProxy)) + #expect(!LLMProxySettingsReader.hasBaseURLOverride(environment: [:])) + } + + @Test + func rejectedOverrideErrorNamesTheSettingAndTheRule() { + // The message has to tell the user which key to fix and what shape is accepted. + let liteLLM = LiteLLMUsageError + .invalidEndpointOverride(LiteLLMSettingsReader.baseURLEnvironmentKey).errorDescription ?? "" + #expect(liteLLM.contains("LITELLM_BASE_URL")) + #expect(liteLLM.contains("HTTPS")) + #expect(liteLLM.contains("private-network")) + #expect(liteLLM.contains(".local")) + + let llmProxy = LLMProxyUsageError + .invalidEndpointOverride(LLMProxySettingsReader.baseURLEnvironmentKey).errorDescription ?? "" + #expect(llmProxy.contains("LLM_PROXY_BASE_URL")) + #expect(llmProxy.contains("HTTPS")) + #expect(llmProxy.contains("private-network")) + #expect(llmProxy.contains(".local")) + } + + @Test + func llmProxyAcceptsHTTPSAndPrivateNetworkHTTPBaseURLs() { + #expect(LLMProxySettingsReader.baseURL( + environment: [LLMProxySettingsReader.baseURLEnvironmentKey: "https://proxy.example.com"])? + .absoluteString == "https://proxy.example.com") + for endpoint in Self.privateHTTPEndpoints { + #expect(LLMProxySettingsReader.baseURL( + environment: [LLMProxySettingsReader.baseURLEnvironmentKey: endpoint])?.absoluteString == endpoint) + } + } + + @Test + func sharedLoopbackOnlyValidatorStillRejectsPrivateNetworkHTTP() { + let validator = ProviderEndpointOverrideValidator() + #expect(validator.validatedURLAllowingLoopbackHTTP("http://127.0.0.1:4000") != nil) + #expect(validator.validatedURLAllowingLoopbackHTTP("http://192.168.1.10:4000") == nil) + #expect(validator.validatedURLAllowingLoopbackHTTP("http://[fd00::1]:4000") == nil) + #expect(validator.validatedURLAllowingLoopbackHTTP("http://proxy.local:4000") == nil) + } + + private static let privateHTTPEndpoints = [ + "http://localhost:4000", + "http://127.0.0.1:4000", + "http://[::1]:4000", + "http://10.255.255.255:4000", + "http://172.16.0.1:4000", + "http://172.31.255.255:4000", + "http://192.168.1.10:4000", + "http://169.254.10.20:4000", + "http://[fc00::1]:4000", + "http://[fdff:ffff::1]:4000", + "http://[fe80::1]:4000", + "http://[febf:ffff::1]:4000", + "http://proxy.local:4000", + "http://proxy.local.:4000", + ] + + private static let publicHTTPEndpoints = [ + "http://attacker.test:4000", + "http://8.8.8.8:4000", + "http://172.15.255.255:4000", + "http://172.32.0.0:4000", + "http://169.253.255.255:4000", + "http://192.169.0.1:4000", + "http://[2606:4700:4700::1111]:4000", + "http://[fec0::1]:4000", + ] } private struct FailingTransport: ProviderHTTPTransport { diff --git a/docs/litellm.md b/docs/litellm.md index b93edfc2c..ca09484ef 100644 --- a/docs/litellm.md +++ b/docs/litellm.md @@ -30,6 +30,12 @@ export LITELLM_BASE_URL=https://litellm.example.com `LITELLM_BASE_URL` may include `/v1`; QuotaKit strips that suffix before calling LiteLLM management endpoints. +The base URL must use HTTPS unless it names a loopback or private-network address, or a `.local` mDNS host, +and must not embed credentials because the API key is sent to it as a bearer token. Plain HTTP remains +available for self-hosted proxies on loopback, RFC 1918, link-local, and IPv6 unique-local networks. A base +URL that does not meet these rules is rejected, and the provider reports that `LITELLM_BASE_URL` is invalid +instead of fetching. + ## Data Source The provider calls: diff --git a/docs/llm-proxy.md b/docs/llm-proxy.md index a8fa7bbc6..2fca5cbdd 100644 --- a/docs/llm-proxy.md +++ b/docs/llm-proxy.md @@ -30,6 +30,12 @@ Set the base URL with `LLM_PROXY_BASE_URL`, or add `enterpriseHost` to the provi The base URL may point at either the service root or `/v1`; QuotaKit normalizes both to `/v1/quota-stats`. +The base URL must use HTTPS unless it names a loopback or private-network address, or a `.local` mDNS host, +and must not embed credentials because the API key is sent to it as a bearer token. Plain HTTP remains +available for self-hosted proxies on loopback, RFC 1918, link-local, and IPv6 unique-local networks. A base +URL that does not meet these rules is rejected, and the provider reports that `LLM_PROXY_BASE_URL` is invalid +instead of fetching. + ## Menu display - Primary: lowest remaining quota group, rendered as percent used. diff --git a/docs/opencode.md b/docs/opencode.md index cbf4df8fa..b7a395296 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -32,6 +32,10 @@ read_when: - OpenCode Go unscoped Auto mode tries quota windows and daily cost history derived from local `opencode-go` assistant costs first, then falls back to web usage when local history is unavailable. Auto stays web-first when a token account, manual cookie, or workspace override scopes the request, because local history is device-wide. +- The local monthly window is an estimate anchored at the earliest local row and can drift from the real billing + cycle. When a cached or manual session cookie is available, the local strategy overlays the server-reported + rolling/weekly/monthly percentages and reset countdowns (plus Zen balance) onto the local snapshot, keeping the + local daily cost history. This path never triggers a fresh browser import. - OpenCode Go cost history chart: `opencode.ai` has no daily-granularity endpoint, so per-day cost/request buckets come from local `opencode-go` assistant costs in `opencode.db`, keyed by device-local calendar day. Successful web usage remains workspace-scoped and is never blended with device-wide local costs, so it does not show cost history. diff --git a/version.env b/version.env index 083a4b42d..b26316330 100644 --- a/version.env +++ b/version.env @@ -10,4 +10,4 @@ UPSTREAM_SYNC_DATE=2026-07-29 # 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=fcf75fb80457eaf1722194124a614f0ec7ebcde8 +UPSTREAM_MONITOR_BASE=b036579b4b055bac16033f74481ee1ff26cc1317