diff --git a/.agents/skills/release-codexbar/SKILL.md b/.agents/skills/release-codexbar/SKILL.md new file mode 100644 index 000000000..2d823e80d --- /dev/null +++ b/.agents/skills/release-codexbar/SKILL.md @@ -0,0 +1,141 @@ +--- +name: release-codexbar +description: "CodexBar release: versioning, notarization, appcast, Homebrew, post-release bump." +--- + +# CodexBar Release + +Use for releasing signed/notarized macOS apps, especially repos with Sparkle appcasts and Homebrew casks. + +## Start + +1. Work from the app repo unless asked otherwise. +2. Check repo state, current version, latest tag/release, and release docs/scripts. +3. Confirm `CHANGELOG.md` is complete, user-facing, deduped, and dated for the release. +4. Prefer the repo release script; patch small script/test blockers instead of bypassing the release path. +5. Never print key material. Keep 1Password references and local key paths as references only. +6. Load `$release-private` if it exists before resolving Peter-owned credential locators. + +## Key Material + +Use `$one-password` for secret handling. `op` only in tmux/persistent shell; no broad `env`, `set`, `export -p`, or secret scans. + +Known App Store Connect shape: + +- fields: `private_key_p8`, `key_id`, `issuer_id` +- keep all three fields from the same 1Password item; do not mix with stale values from `~/.profile` +- resolve Peter-owned item refs from `$release-private` + +Known Sparkle key: + +- resolve the private key file from `$release-private` +- pass as `SPARKLE_PRIVATE_KEY_FILE` + +Safe env file pattern: + +```text +APP_STORE_CONNECT_API_KEY_P8=<1Password ref from release-private> +APP_STORE_CONNECT_KEY_ID=<1Password ref from release-private> +APP_STORE_CONNECT_ISSUER_ID=<1Password ref from release-private> +SPARKLE_PRIVATE_KEY_FILE= +``` + +Run with `op run --account my.1password.com --env-file -- "#, + #"(?is)]*>.*?"#, + #"(?is)"#, + #"<[^>]+>"#, + #"\s+"#, + ] + + return patterns.enumerated().reduce(html) { result, item in + let replacement = item.offset == patterns.count - 1 ? " " : "" + return result.replacingOccurrences( + of: item.element, + with: replacement, + options: .regularExpression) + } + .trimmingCharacters(in: .whitespacesAndNewlines) + } } -struct MiniMaxCodingPlanPayload: Decodable, Sendable { +struct MiniMaxCodingPlanPayload: Decodable { let baseResp: MiniMaxBaseResponse? let data: MiniMaxCodingPlanData @@ -346,7 +535,7 @@ struct MiniMaxCodingPlanPayload: Decodable, Sendable { } } -struct MiniMaxCodingPlanData: Decodable, Sendable { +struct MiniMaxCodingPlanData: Decodable { let baseResp: MiniMaxBaseResponse? let currentSubscribeTitle: String? let planName: String? @@ -377,36 +566,54 @@ struct MiniMaxCodingPlanData: Decodable, Sendable { } } -struct MiniMaxComboCard: Decodable, Sendable { +struct MiniMaxComboCard: Decodable { let title: String? } -struct MiniMaxModelRemains: Decodable, Sendable { +struct MiniMaxModelRemains: Decodable { + let modelName: String? let currentIntervalTotalCount: Int? let currentIntervalUsageCount: Int? let startTime: Int? let endTime: Int? let remainsTime: Int? + let currentWeeklyTotalCount: Int? + let currentWeeklyUsageCount: Int? + let weeklyStartTime: Int? + let weeklyEndTime: Int? + let weeklyRemainsTime: Int? private enum CodingKeys: String, CodingKey { + case modelName = "model_name" case currentIntervalTotalCount = "current_interval_total_count" case currentIntervalUsageCount = "current_interval_usage_count" case startTime = "start_time" case endTime = "end_time" case remainsTime = "remains_time" + case currentWeeklyTotalCount = "current_weekly_total_count" + case currentWeeklyUsageCount = "current_weekly_usage_count" + case weeklyStartTime = "weekly_start_time" + case weeklyEndTime = "weekly_end_time" + case weeklyRemainsTime = "weekly_remains_time" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + self.modelName = try container.decodeIfPresent(String.self, forKey: .modelName) self.currentIntervalTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalTotalCount) self.currentIntervalUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentIntervalUsageCount) self.startTime = MiniMaxDecoding.decodeInt(container, forKey: .startTime) self.endTime = MiniMaxDecoding.decodeInt(container, forKey: .endTime) self.remainsTime = MiniMaxDecoding.decodeInt(container, forKey: .remainsTime) + self.currentWeeklyTotalCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyTotalCount) + self.currentWeeklyUsageCount = MiniMaxDecoding.decodeInt(container, forKey: .currentWeeklyUsageCount) + self.weeklyStartTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyStartTime) + self.weeklyEndTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyEndTime) + self.weeklyRemainsTime = MiniMaxDecoding.decodeInt(container, forKey: .weeklyRemainsTime) } } -struct MiniMaxBaseResponse: Decodable, Sendable { +struct MiniMaxBaseResponse: Decodable { let statusCode: Int? let statusMessage: String? @@ -422,6 +629,53 @@ struct MiniMaxBaseResponse: Decodable, Sendable { } } +// MARK: - Multi-Service API Response Structures + +struct MiniMaxMultiServicePayload: Decodable { + let data: MiniMaxMultiServiceData +} + +struct MiniMaxMultiServiceData: Decodable { + let services: [MiniMaxServiceItem] +} + +struct MiniMaxServiceItem: Decodable { + let serviceType: String? + let windowType: String? + let timeRange: String? + let usage: Int? + let limit: Int? + let percent: Double? + + private enum CodingKeys: String, CodingKey { + case serviceType = "service_type" + case windowType = "window_type" + case timeRange = "time_range" + case usage + case limit + case percent + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.serviceType = try container.decodeIfPresent(String.self, forKey: .serviceType) + self.windowType = try container.decodeIfPresent(String.self, forKey: .windowType) + self.timeRange = try container.decodeIfPresent(String.self, forKey: .timeRange) + self.usage = MiniMaxDecoding.decodeInt(container, forKey: .usage) + self.limit = MiniMaxDecoding.decodeInt(container, forKey: .limit) + // Handle both Double and String for percent (flexible parsing) + if let percentDouble = try? container.decodeIfPresent(Double.self, forKey: .percent) { + self.percent = percentDouble + } else if let percentString = try? container.decodeIfPresent(String.self, forKey: .percent), + let percentValue = Double(percentString) + { + self.percent = percentValue + } else { + self.percent = nil + } + } +} + enum MiniMaxDecoding { static func decodeInt(_ container: KeyedDecodingContainer, forKey key: K) -> Int? { if let value = try? container.decodeIfPresent(Int.self, forKey: key) { @@ -439,6 +693,23 @@ enum MiniMaxDecoding { } return nil } + + static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: K) -> Double? { + if let value = try? container.decodeIfPresent(Double.self, forKey: key) { + return value + } + if let value = try? container.decodeIfPresent(Int.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(Int64.self, forKey: key) { + return Double(value) + } + if let value = try? container.decodeIfPresent(String.self, forKey: key) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Double(trimmed) + } + return nil + } } enum MiniMaxUsageParser { @@ -447,6 +718,11 @@ enum MiniMaxUsageParser { return try decoder.decode(MiniMaxCodingPlanPayload.self, from: data) } + static func decodeMultiServicePayload(data: Data) throws -> MiniMaxMultiServicePayload { + let decoder = JSONDecoder() + return try decoder.decode(MiniMaxMultiServicePayload.self, from: data) + } + static func decodePayload(json: [String: Any]) throws -> MiniMaxCodingPlanPayload { let normalized = self.normalizeCodingPlanPayload(json) let data = try JSONSerialization.data(withJSONObject: normalized, options: []) @@ -454,6 +730,15 @@ enum MiniMaxUsageParser { } static func parseCodingPlanRemains(data: Data, now: Date = Date()) throws -> MiniMaxUsageSnapshot { + do { + if let multiServiceSnapshot = try self.parseMultiService(data: data, now: now) { + return multiServiceSnapshot + } + } catch { + // Log multi-service parsing failure but continue to single-service parsing + MiniMaxUsageFetcher.log.debug("MiniMax multi-service parsing failed: \(error.localizedDescription)") + } + let payload = try self.decodePayload(data: data) return try self.parseCodingPlanRemains(payload: payload, now: now) } @@ -498,29 +783,64 @@ enum MiniMaxUsageParser { throw MiniMaxUsageError.apiError(message) } - guard let first = payload.data.modelRemains.first else { + guard !payload.data.modelRemains.isEmpty else { throw MiniMaxUsageError.parseFailed("Missing coding plan data.") } - let total = first.currentIntervalTotalCount - let remaining = first.currentIntervalUsageCount + // Convert model_remains to services array for multi-service UI display + var services: [MiniMaxServiceUsage] = [] + for item in payload.data.modelRemains { + guard let modelName = item.modelName else { continue } + let serviceTypeIdentifier = self.mapModelNameToServiceType(modelName: modelName) + + if let intervalService = self.makeServiceUsage( + ServiceUsageInput( + serviceType: serviceTypeIdentifier, + windowTypeOverride: nil, + total: item.currentIntervalTotalCount, + remaining: item.currentIntervalUsageCount, + start: item.startTime, + end: item.endTime, + remainsTime: item.remainsTime), + now: now) + { + services.append(intervalService) + } + + // current_weekly_usage_count is also REMAINING quota; render only when weekly quota is real. + if self.isTextGenerationModelName(modelName), + let weeklyService = self.makeServiceUsage( + ServiceUsageInput( + serviceType: serviceTypeIdentifier, + windowTypeOverride: "Weekly", + total: item.currentWeeklyTotalCount, + remaining: item.currentWeeklyUsageCount, + start: item.weeklyStartTime, + end: item.weeklyEndTime, + remainsTime: item.weeklyRemainsTime), + now: now) + { + services.append(weeklyService) + } + } + + // Use first service for backward compatibility fields + let first = payload.data.modelRemains.first + let total = first?.currentIntervalTotalCount + let remaining = first?.currentIntervalUsageCount let usedPercent = self.usedPercent(total: total, remaining: remaining) let windowMinutes = self.windowMinutes( - start: self.dateFromEpoch(first.startTime), - end: self.dateFromEpoch(first.endTime)) + start: self.dateFromEpoch(first?.startTime), + end: self.dateFromEpoch(first?.endTime)) let resetsAt = self.resetsAt( - end: self.dateFromEpoch(first.endTime), - remains: first.remainsTime, + end: self.dateFromEpoch(first?.endTime), + remains: first?.remainsTime, now: now) let planName = self.parsePlanName(data: payload.data) - if planName == nil, total == nil, usedPercent == nil { - throw MiniMaxUsageError.parseFailed("Missing coding plan data.") - } - let currentPrompts: Int? = if let total, let remaining { max(0, total - remaining) } else { @@ -535,7 +855,8 @@ enum MiniMaxUsageParser { windowMinutes: windowMinutes, usedPercent: usedPercent, resetsAt: resetsAt, - updatedAt: now) + updatedAt: now, + services: services.isEmpty ? nil : services) } private static func usedPercent(total: Int?, remaining: Int?) -> Double? { @@ -786,7 +1107,7 @@ enum MiniMaxUsageParser { if let tzHint = timeZoneHint?.trimmingCharacters(in: .whitespacesAndNewlines), !tzHint.isEmpty { - formatter.timeZone = TimeZone(identifier: tzHint) + formatter.timeZone = self.timeZone(from: tzHint) } formatter.locale = Locale(identifier: "en_US_POSIX") @@ -816,6 +1137,33 @@ enum MiniMaxUsageParser { return 0 } + private static func timeZone(from hint: String) -> TimeZone? { + let trimmed = hint.trimmingCharacters(in: .whitespacesAndNewlines) + if let timeZone = TimeZone(identifier: trimmed) { + return timeZone + } + + let pattern = #"(?i)^(?:UTC|GMT)\s*([+-])\s*(\d{1,2})(?::?(\d{2}))?$"# + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: trimmed, range: NSRange(trimmed.startIndex..., in: trimmed)), + let signRange = Range(match.range(at: 1), in: trimmed), + let hourRange = Range(match.range(at: 2), in: trimmed) + else { + return nil + } + + let sign = trimmed[signRange] == "-" ? -1 : 1 + let hours = Int(trimmed[hourRange]) ?? 0 + let minutes = if match.range(at: 3).location != NSNotFound, + let minuteRange = Range(match.range(at: 3), in: trimmed) + { + Int(trimmed[minuteRange]) ?? 0 + } else { + 0 + } + return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60))) + } + private static func seconds(from value: Double, unit: String) -> TimeInterval { let lower = unit.lowercased() if lower.hasPrefix("d") { return value * 24 * 60 * 60 } @@ -855,6 +1203,282 @@ enum MiniMaxUsageParser { return String(text[captureRange]).trimmingCharacters(in: .whitespacesAndNewlines) } } + + // MARK: - Multi-Service Parsing + + private static func parseMultiService(data: Data, now: Date) throws -> MiniMaxUsageSnapshot? { + let payload = try self.decodeMultiServicePayload(data: data) + + guard !payload.data.services.isEmpty else { + return nil + } + + var services: [MiniMaxServiceUsage] = [] + for item in payload.data.services { + guard let serviceType = item.serviceType, + let windowType = item.windowType, + let timeRange = item.timeRange, + let usage = item.usage, + let limit = item.limit, + limit > 0 + else { + continue + } + + var percent = item.percent ?? 0.0 + if item.percent == nil, limit > 0 { + percent = Double(usage) / Double(limit) * 100.0 + } + + let resetsAt = self.parseResetsAtFromTimeRange(timeRange: timeRange, windowType: windowType, now: now) + let resetDescription = self.resetDescription( + for: windowType, + timeRange: timeRange, + now: now, + resetsAt: resetsAt) + + let serviceTypeIdentifier: String = if serviceType.lowercased().contains("text"), + serviceType.lowercased().contains("generation") + { + "text-generation" + } else if serviceType.lowercased().contains("text"), serviceType.lowercased().contains("speech") { + "text-to-speech" + } else if serviceType.lowercased().contains("image") { + "image" + } else { + serviceType.lowercased() + .replacingOccurrences(of: " ", with: "-") + .replacingOccurrences(of: "_", with: "-") + } + + let serviceUsage = MiniMaxServiceUsage( + serviceType: serviceTypeIdentifier, + windowType: windowType, + timeRange: timeRange, + usage: usage, + limit: limit, + percent: min(100.0, max(0.0, percent)), + resetsAt: resetsAt, + resetDescription: resetDescription) + services.append(serviceUsage) + } + + if services.isEmpty { + return nil + } + + let planName = self.extractPlanNameFromServices(services: payload.data.services) + + return MiniMaxUsageSnapshot( + planName: planName, + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now, + services: services) + } + + private static func parseResetsAtFromTimeRange(timeRange: String, windowType: String, now: Date) -> Date? { + let lowerWindow = windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + if lowerWindow == "today" { + let components = timeRange.split(separator: "-", maxSplits: 1) + guard components.count == 2 else { return nil } + + let endTimeStr = String(components[1].trimmingCharacters(in: .whitespacesAndNewlines)) + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + + return formatter.date(from: endTimeStr) + } + + if lowerWindow.contains("hour") || lowerWindow.contains("h") { + let timeComponents = timeRange.split(separator: "-") + guard timeComponents.count >= 2 else { return nil } + + let endTimePart = String(timeComponents[1]) + let endTimeClean = endTimePart.replacingOccurrences(of: "\\(.*\\)", with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + return self.dateForTime(endTimeClean, timeZoneHint: "UTC+8", now: now) + } + + return nil + } + + private static func resetDescription( + for windowType: String, + timeRange: String, + now: Date, + resetsAt: Date?) -> String + { + if let resetsAt, resetsAt > now { + let interval = resetsAt.timeIntervalSince(now) + if interval < 60 { + return "Resets in \(Int(interval)) seconds" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "Resets in \(minutes) minute\(minutes == 1 ? "" : "s")" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "Resets in \(hours) hour\(hours == 1 ? "" : "s")" + } else { + let days = Int(interval / 86400) + return "Resets in \(days) day\(days == 1 ? "" : "s")" + } + } + + return "\(windowType): \(timeRange)" + } + + private static func extractPlanNameFromServices(services: [MiniMaxServiceItem]) -> String? { + for service in services { + if let serviceType = service.serviceType, + serviceType.lowercased().contains("pro") || serviceType.lowercased().contains("max") + { + return serviceType + } + } + + return nil + } + + private static func parseWindowInfo( + startTime: Date?, + endTime: Date?, + now: Date) -> (windowType: String, timeRange: String) + { + guard let startTime, let endTime else { + return (windowType: "Unknown", timeRange: "N/A") + } + + let durationSeconds = endTime.timeIntervalSince(startTime) + let durationHours = durationSeconds / 3600 + + // Determine window type based on duration + let windowType = if durationHours >= 23, durationHours <= 25 { + "Today" + } else if durationHours >= 4, durationHours <= 6 { + "5 hours" + } else if durationHours >= 1, durationHours < 23 { + "\(Int(durationHours)) hours" + } else { + "Custom" + } + + // Format time range + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current + let startStr = formatter.string(from: startTime) + let endStr = formatter.string(from: endTime) + + let timeRange = "\(startStr)-\(endStr)(UTC+8)" + + return (windowType: windowType, timeRange: timeRange) + } + + private struct ServiceUsageInput { + let serviceType: String + let windowTypeOverride: String? + let total: Int? + let remaining: Int? + let start: Int? + let end: Int? + let remainsTime: Int? + } + + private static func makeServiceUsage(_ input: ServiceUsageInput, now: Date) -> MiniMaxServiceUsage? { + guard let total = input.total, total > 0, let remaining = input.remaining else { return nil } + let used = max(0, total - remaining) + if used == 0, total == 0 { return nil } + + let startTime = self.dateFromEpoch(input.start) + let endTime = self.dateFromEpoch(input.end) + var (windowType, timeRange) = self.parseWindowInfo(startTime: startTime, endTime: endTime, now: now) + if let windowTypeOverride = input.windowTypeOverride { windowType = windowTypeOverride } + if windowType.lowercased() == "weekly", + let weeklyRange = self.formatMiniMaxDateTimeRange(startTime: startTime, endTime: endTime) + { + timeRange = weeklyRange + } + + let resetsAt = self.resetsAt(end: endTime, remains: input.remainsTime, now: now) + let resetDescription = self.resetDescription( + for: windowType, + timeRange: timeRange, + now: now, + resetsAt: resetsAt) + + let percent = Double(used) / Double(total) * 100.0 + return MiniMaxServiceUsage( + serviceType: input.serviceType, + windowType: windowType, + timeRange: timeRange, + usage: used, + limit: total, + percent: min(100.0, max(0.0, percent)), + resetsAt: resetsAt, + resetDescription: resetDescription) + } + + private static func mapModelNameToServiceType(modelName: String) -> String { + // Text Generation (文本生成): M2.7, M2.7-highspeed, MiniMax-M*, etc. + if self.isTextGenerationModelName(modelName) { + return "Text Generation" + } + + let lower = modelName.lowercased() + + // Text to Speech (语音合成): speech-hd, Speech 2.8, etc. + if lower.contains("speech") { + return "Text to Speech" + } + + // Image to Video Fast (图生视频 Fast): Hailuo-2.3-Fast + if lower.contains("hailuo"), lower.contains("fast") { + return "Image to Video" + } + + // Text to Video (文生视频): Hailuo-2.3 (non-Fast) + if lower.contains("hailuo") { + return "Text to Video" + } + + // Image Generation (图像生成): image-01, image-02, etc. + if lower.hasPrefix("image-") { + return "Image Generation" + } + + // Music Generation (音乐生成): music-2.5, etc. + if lower.contains("music") { + return "Music Generation" + } + + // Default: use model name as-is + return modelName + } + + private static func isTextGenerationModelName(_ modelName: String) -> Bool { + let lower = modelName.lowercased() + return lower.contains("minimax-m") || lower.hasPrefix("m2.") + } + + private static func formatMiniMaxDateTimeRange(startTime: Date?, endTime: Date?) -> String? { + guard let startTime, let endTime else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") + formatter.dateFormat = "MM/dd HH:mm" + let start = formatter.string(from: startTime) + let end = formatter.string(from: endTime) + return "\(start) - \(end)(UTC+8)" + } } public enum MiniMaxUsageError: LocalizedError, Sendable, Equatable { diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift index 09ed671e2..f66de9d23 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageSnapshot.swift @@ -9,6 +9,41 @@ public struct MiniMaxUsageSnapshot: Sendable { public let usedPercent: Double? public let resetsAt: Date? public let updatedAt: Date + public let services: [MiniMaxServiceUsage]? + public let billingSummary: MiniMaxBillingSummary? + + public var primaryService: MiniMaxServiceUsage? { + // Priority: "Text Generation" > first service + if let services = self.services, !services.isEmpty { + if let textGenService = services.first(where: { $0.displayName == "Text Generation" }) { + return textGenService + } + return services.first + } + return nil + } + + public var secondaryService: MiniMaxServiceUsage? { + // Return second service for RateWindow.secondary if exists + guard let services = self.services, services.count >= 2 else { return nil } + // If we have Text Generation as primary, get the next non-Text Generation service + if let textGenIndex = services.firstIndex(where: { $0.displayName == "Text Generation" }) { + // If Text Generation is first, secondary is second + if textGenIndex == 0 { + return services[1] + } + // If Text Generation is not first, secondary could be first or second depending on count + return services[0] + } + // No Text Generation found, just return second service + return services[1] + } + + public var tertiaryService: MiniMaxServiceUsage? { + // Return third service for RateWindow.tertiary if exists + guard let services = self.services, services.count >= 3 else { return nil } + return services[2] + } public init( planName: String?, @@ -18,7 +53,9 @@ public struct MiniMaxUsageSnapshot: Sendable { windowMinutes: Int?, usedPercent: Double?, resetsAt: Date?, - updatedAt: Date) + updatedAt: Date, + services: [MiniMaxServiceUsage]? = nil, + billingSummary: MiniMaxBillingSummary? = nil) { self.planName = planName self.availablePrompts = availablePrompts @@ -28,11 +65,52 @@ public struct MiniMaxUsageSnapshot: Sendable { self.usedPercent = usedPercent self.resetsAt = resetsAt self.updatedAt = updatedAt + self.services = services + self.billingSummary = billingSummary + } + + public func withBillingSummary(_ billingSummary: MiniMaxBillingSummary?) -> MiniMaxUsageSnapshot { + MiniMaxUsageSnapshot( + planName: self.planName, + availablePrompts: self.availablePrompts, + currentPrompts: self.currentPrompts, + remainingPrompts: self.remainingPrompts, + windowMinutes: self.windowMinutes, + usedPercent: self.usedPercent, + resetsAt: self.resetsAt, + updatedAt: self.updatedAt, + services: self.services, + billingSummary: billingSummary) } } extension MiniMaxUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { + // If we have services array, use that for multi-service support + if let services = self.services, !services.isEmpty { + let primaryWindow = self.rateWindow(for: self.primaryService) + let secondaryWindow = self.rateWindow(for: self.secondaryService) + let tertiaryWindow = self.rateWindow(for: self.tertiaryService) + + let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (planName?.isEmpty ?? true) ? nil : planName + let identity = ProviderIdentitySnapshot( + providerID: .minimax, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: primaryWindow, + secondary: secondaryWindow, + tertiary: tertiaryWindow, + providerCost: nil, + minimaxUsage: self, + updatedAt: self.updatedAt, + identity: identity) + } + + // Fallback to single-service mode for backward compatibility let used = max(0, min(100, self.usedPercent ?? 0)) let resetDescription = self.limitDescription() let primary = RateWindow( @@ -59,6 +137,16 @@ extension MiniMaxUsageSnapshot { identity: identity) } + private func rateWindow(for service: MiniMaxServiceUsage?) -> RateWindow? { + guard let service else { return nil } + let windowMinutes = self.windowMinutes(for: service) + return RateWindow( + usedPercent: max(0, min(100, service.percent)), + windowMinutes: windowMinutes, + resetsAt: service.resetsAt, + resetDescription: service.resetDescription) + } + private func limitDescription() -> String? { guard let availablePrompts, availablePrompts > 0 else { return self.windowDescription() @@ -82,4 +170,31 @@ extension MiniMaxUsageSnapshot { } return "\(windowMinutes) \(windowMinutes == 1 ? "minute" : "minutes")" } + + private func windowMinutes(for service: MiniMaxServiceUsage) -> Int? { + let windowType = service.windowType.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + + // Handle "Today" case - 24 hours = 1440 minutes + if windowType == "today" { + return 24 * 60 + } + + // Handle time duration formats like "5 hours", "30 minutes", etc. + let components = windowType.split(separator: " ") + guard components.count >= 2 else { return nil } + + guard let value = Int(components[0]) else { return nil } + let unit = components[1].lowercased() + + switch unit { + case "hour", "hours", "h", "hr", "hrs": + return value * 60 + case "minute", "minutes", "min", "mins", "m": + return value + case "day", "days", "d": + return value * 24 * 60 + default: + return nil + } + } } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift new file mode 100644 index 000000000..0bd65cb44 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralCookieImporter.swift @@ -0,0 +1,101 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +private let mistralCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.mistral]?.browserCookieOrder ?? Browser.defaultImportOrder + +public enum MistralCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["mistral.ai", "admin.mistral.ai", "auth.mistral.ai"] + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + + /// Extracts the CSRF token from the `csrftoken` cookie for the `X-CSRFTOKEN` header. + public var csrfToken: String? { + self.cookies.first { $0.name == "csrftoken" }?.value + } + } + + /// Returns `true` if any cookie name starts with `ory_session_` (the Ory Kratos session cookie). + private static func hasSessionCookie(_ cookies: [HTTPCookie]) -> Bool { + cookies.contains { $0.name.hasPrefix("ory_session_") } + } + + public static func importSession( + browserDetection: BrowserDetection, + preferredBrowsers: [Browser] = [.chrome], + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let log: (String) -> Void = { msg in logger?("[mistral-cookie] \(msg)") } + let installedBrowsers = preferredBrowsers.isEmpty + ? mistralCookieImportOrder.cookieImportCandidates(using: browserDetection) + : preferredBrowsers.cookieImportCandidates(using: browserDetection) + + for browserSource in installedBrowsers { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try Self.cookieClient.records( + matching: query, + in: browserSource, + logger: log) + for source in sources where !source.records.isEmpty { + let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + if !httpCookies.isEmpty { + guard Self.hasSessionCookie(httpCookies) else { + log("Skipping \(source.label) cookies: missing ory_session_* cookie") + continue + } + log("Found \(httpCookies.count) Mistral cookies in \(source.label)") + return SessionInfo(cookies: httpCookies, sourceLabel: source.label) + } + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + throw MistralCookieImportError.noCookies + } + + public static func hasSession( + browserDetection: BrowserDetection, + preferredBrowsers: [Browser] = [.chrome], + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession( + browserDetection: browserDetection, + preferredBrowsers: preferredBrowsers, + logger: logger) + return true + } catch { + return false + } + } +} + +enum MistralCookieImportError: LocalizedError { + case noCookies + + var errorDescription: String? { + switch self { + case .noCookies: + "No Mistral session cookies found in browsers." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift new file mode 100644 index 000000000..1c8ab4ae4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralErrors.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum MistralUsageError: LocalizedError, Sendable { + case missingCookie + case invalidCredentials + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCookie: + "No Mistral session cookies found in browsers." + case .invalidCredentials: + "Mistral session expired or invalid (HTTP 401/403)." + case let .apiError(detail): + "Mistral API error: \(detail)" + case let .parseFailed(detail): + "Failed to parse Mistral billing response: \(detail)" + } + } +} + +enum MistralSettingsError: LocalizedError { + case missingCookie + case invalidCookie + + var errorDescription: String? { + switch self { + case .missingCookie: + "No Mistral session cookies found in browsers." + case .invalidCookie: + "Mistral cookie header is invalid or missing ory_session cookie." + } + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift new file mode 100644 index 000000000..1470a7688 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -0,0 +1,250 @@ +import Foundation + +// MARK: - API Response Models + +/// Top-level response from `GET https://admin.mistral.ai/api/billing/v2/usage`. +struct MistralBillingResponse: Codable { + let completion: MistralModelUsageCategory? + let ocr: MistralModelUsageCategory? + let connectors: MistralModelUsageCategory? + let librariesApi: MistralLibrariesUsageCategory? + let fineTuning: MistralFineTuningCategory? + let audio: MistralModelUsageCategory? + let vibeUsage: Double? + let date: String? + let previousMonth: String? + let nextMonth: String? + let startDate: String? + let endDate: String? + let currency: String? + let currencySymbol: String? + let prices: [MistralPrice]? + + enum CodingKeys: String, CodingKey { + case completion, ocr, connectors, audio, date, currency, prices + case librariesApi = "libraries_api" + case fineTuning = "fine_tuning" + case vibeUsage = "vibe_usage" + case previousMonth = "previous_month" + case nextMonth = "next_month" + case startDate = "start_date" + case endDate = "end_date" + case currencySymbol = "currency_symbol" + } +} + +struct MistralModelUsageCategory: Codable { + let models: [String: MistralModelUsageData]? +} + +struct MistralLibrariesUsageCategory: Codable { + let pages: MistralModelUsageCategory? + let tokens: MistralModelUsageCategory? +} + +struct MistralFineTuningCategory: Codable { + let training: [String: MistralModelUsageData]? + let storage: [String: MistralModelUsageData]? +} + +struct MistralModelUsageData: Codable { + let input: [MistralUsageEntry]? + let output: [MistralUsageEntry]? + let cached: [MistralUsageEntry]? +} + +struct MistralUsageEntry: Codable { + let usageType: String? + let eventType: String? + let billingMetric: String? + let billingDisplayName: String? + let billingGroup: String? + let timestamp: String? + let value: Int? + let valuePaid: Int? + + enum CodingKeys: String, CodingKey { + case timestamp, value + case usageType = "usage_type" + case eventType = "event_type" + case billingMetric = "billing_metric" + case billingDisplayName = "billing_display_name" + case billingGroup = "billing_group" + case valuePaid = "value_paid" + } +} + +struct MistralPrice: Codable { + let eventType: String? + let billingMetric: String? + let billingGroup: String? + let price: String? + + enum CodingKeys: String, CodingKey { + case price + case eventType = "event_type" + case billingMetric = "billing_metric" + case billingGroup = "billing_group" + } +} + +// MARK: - Intermediate Snapshot + +public struct MistralDailyUsageBucket: Codable, Equatable, Sendable, Identifiable { + public struct ModelBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let cost: Double + public let inputTokens: Int + public let cachedTokens: Int + public let outputTokens: Int + + public var id: String { + self.name + } + + public var totalTokens: Int { + self.inputTokens + self.cachedTokens + self.outputTokens + } + + public init(name: String, cost: Double, inputTokens: Int, cachedTokens: Int, outputTokens: Int) { + self.name = name + self.cost = cost + self.inputTokens = inputTokens + self.cachedTokens = cachedTokens + self.outputTokens = outputTokens + } + } + + public let day: String + public let cost: Double + public let inputTokens: Int + public let cachedTokens: Int + public let outputTokens: Int + public let models: [ModelBreakdown] + + public var id: String { + self.day + } + + public var totalTokens: Int { + self.inputTokens + self.cachedTokens + self.outputTokens + } + + public init( + day: String, + cost: Double, + inputTokens: Int, + cachedTokens: Int, + outputTokens: Int, + models: [ModelBreakdown]) + { + self.day = day + self.cost = cost + self.inputTokens = inputTokens + self.cachedTokens = cachedTokens + self.outputTokens = outputTokens + self.models = models + } +} + +public struct MistralUsageSnapshot: Codable, Sendable { + public let totalCost: Double + public let currency: String + public let currencySymbol: String + public let totalInputTokens: Int + public let totalOutputTokens: Int + public let totalCachedTokens: Int + public let modelCount: Int + public let daily: [MistralDailyUsageBucket] + public let startDate: Date? + public let endDate: Date? + public let updatedAt: Date + + public init( + totalCost: Double, + currency: String, + currencySymbol: String, + totalInputTokens: Int, + totalOutputTokens: Int, + totalCachedTokens: Int, + modelCount: Int, + daily: [MistralDailyUsageBucket] = [], + startDate: Date?, + endDate: Date?, + updatedAt: Date) + { + self.totalCost = totalCost + self.currency = currency + self.currencySymbol = currencySymbol + self.totalInputTokens = totalInputTokens + self.totalOutputTokens = totalOutputTokens + self.totalCachedTokens = totalCachedTokens + self.modelCount = modelCount + self.daily = daily.sorted { $0.day < $1.day } + self.startDate = startDate + self.endDate = endDate + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + // Negative totalCost means a refund/credit adjustment; clamp to zero rather than + // showing a confusing negative amount in the menu bar. + let spendText = if self.totalCost > 0 { + "\(self.currencySymbol)\(String(format: "%.4f", self.totalCost)) this month" + } else { + "\(self.currencySymbol)0.0000 this month" + } + let identity = ProviderIdentitySnapshot( + providerID: .mistral, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API spend: \(spendText)") + return UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: nil, + mistralUsage: self, + updatedAt: self.updatedAt, + identity: identity) + } + + public func toCostUsageTokenSnapshot(historyDays: Int = 30) -> CostUsageTokenSnapshot { + let clampedHistoryDays = max(1, min(365, historyDays)) + let selected = self.daily + let entries = selected.map { bucket in + let modelBreakdowns = bucket.models.map { + CostUsageDailyReport.ModelBreakdown( + modelName: $0.name, + costUSD: max($0.cost, 0), + totalTokens: $0.totalTokens) + } + let modelsUsed = bucket.models.map(\.name) + return CostUsageDailyReport.Entry( + date: bucket.day, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cachedTokens, + cacheCreationTokens: nil, + totalTokens: bucket.totalTokens, + costUSD: max(bucket.cost, 0), + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + let latest = selected.last + let totalCost = max(self.totalCost, 0) + let totalTokens = selected.isEmpty + ? self.totalInputTokens + self.totalCachedTokens + self.totalOutputTokens + : selected.reduce(0) { $0 + $1.totalTokens } + let tokens = totalTokens > 0 ? totalTokens : nil + return CostUsageTokenSnapshot( + sessionTokens: latest?.totalTokens, + sessionCostUSD: latest.map { max($0.cost, 0) }, + last30DaysTokens: tokens, + last30DaysCostUSD: totalCost, + currencyCode: self.currency, + historyDays: selected.isEmpty ? clampedHistoryDays : max(1, min(365, selected.count)), + historyLabel: "This month", + daily: entries, + updatedAt: self.updatedAt) + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift new file mode 100644 index 000000000..b1af52984 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift @@ -0,0 +1,121 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum MistralProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .mistral, + metadata: ProviderMetadata( + id: .mistral, + displayName: "Mistral", + sessionLabel: "Monthly", + weeklyLabel: "", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Mistral usage", + cliName: "mistral", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://admin.mistral.ai/organization/usage", + statusPageURL: nil, + statusLinkURL: "https://status.mistral.ai"), + branding: ProviderBranding( + iconStyle: .mistral, + iconResourceName: "ProviderIcon-mistral", + color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: true, + noDataMessage: { "Mistral cost history needs a billing web session." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MistralWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "mistral", + aliases: ["mistral-ai"], + versionDetector: nil)) + } +} + +struct MistralWebFetchStrategy: ProviderFetchStrategy { + let id: String = "mistral.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.mistral?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let cookieSource = context.settings?.mistral?.cookieSource ?? .auto + do { + let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: true) + let snapshot = try await MistralUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: context.webTimeout) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } catch MistralUsageError.invalidCredentials where cookieSource != .manual { + #if os(macOS) + CookieHeaderCache.clear(provider: .mistral) + let (cookieHeader, csrfToken) = try Self.resolveCookieHeader(context: context, allowCached: false) + let snapshot = try await MistralUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + csrfToken: csrfToken, + timeout: context.webTimeout) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + #else + throw MistralUsageError.invalidCredentials + #endif + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveCookieHeader( + context: ProviderFetchContext, + allowCached: Bool) throws -> (cookieHeader: String, csrfToken: String?) + { + if let settings = context.settings?.mistral, settings.cookieSource == .manual { + if let header = CookieHeaderNormalizer.normalize(settings.manualCookieHeader) { + let pairs = CookieHeaderNormalizer.pairs(from: header) + let hasSessionCookie = pairs.contains { $0.name.hasPrefix("ory_session_") } + if hasSessionCookie { + let csrfToken = pairs.first { $0.name == "csrftoken" }?.value + return (header, csrfToken) + } + } + throw MistralSettingsError.invalidCookie + } + + #if os(macOS) + if allowCached, + let cached = CookieHeaderCache.load(provider: .mistral), + !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + let pairs = CookieHeaderNormalizer.pairs(from: cached.cookieHeader) + let csrfToken = pairs.first { $0.name == "csrftoken" }?.value + return (cached.cookieHeader, csrfToken) + } + let session = try MistralCookieImporter.importSession(browserDetection: context.browserDetection) + CookieHeaderCache.store( + provider: .mistral, + cookieHeader: session.cookieHeader, + sourceLabel: session.sourceLabel) + return (session.cookieHeader, session.csrfToken) + #else + throw MistralSettingsError.missingCookie + #endif + } +} diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift new file mode 100644 index 000000000..996658610 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Mistral/MistralUsageFetcher.swift @@ -0,0 +1,389 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum MistralUsageFetcher { + private static let baseURL = URL(string: "https://admin.mistral.ai")! + + public static func fetchUsage( + cookieHeader: String, + csrfToken: String?, + timeout: TimeInterval = 15) async throws -> MistralUsageSnapshot + { + let now = Date() + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let month = calendar.component(.month, from: now) + let year = calendar.component(.year, from: now) + + let usagePath = self.baseURL.appendingPathComponent("/api/billing/v2/usage") + var components = URLComponents(url: usagePath, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "month", value: "\(month)"), + URLQueryItem(name: "year", value: "\(year)"), + ] + guard let url = components.url else { + throw MistralUsageError.apiError("Failed to construct URL") + } + + var request = URLRequest(url: url, timeoutInterval: timeout) + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("https://admin.mistral.ai/organization/usage", forHTTPHeaderField: "Referer") + request.setValue("https://admin.mistral.ai", forHTTPHeaderField: "Origin") + if let csrfToken { + request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN") + } + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + + switch response.statusCode { + case 200: + break + case 401, 403: + throw MistralUsageError.invalidCredentials + default: + let body = String(data: data.prefix(200), encoding: .utf8) ?? "" + throw MistralUsageError.apiError("HTTP \(response.statusCode): \(body)") + } + + return try Self.parseResponse(data: data, updatedAt: now) + } + + static func parseResponse(data: Data, updatedAt: Date) throws -> MistralUsageSnapshot { + let decoder = JSONDecoder() + let billing: MistralBillingResponse + do { + billing = try decoder.decode(MistralBillingResponse.self, from: data) + } catch { + throw MistralUsageError.parseFailed(error.localizedDescription) + } + + let prices = Self.buildPriceIndex(billing.prices ?? []) + var totalCost: Double = 0 + var totalInput = 0 + var totalOutput = 0 + var totalCached = 0 + var modelCount = 0 + var daily: [String: DailyAccumulator] = [:] + + // Aggregate completion tokens + if let models = billing.completion?.models { + for (modelName, modelData) in models { + modelCount += 1 + let (input, output, cached, cost) = Self.aggregateModel(modelData, prices: prices) + totalInput += input + totalOutput += output + totalCached += cached + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: true) + } + } + + // Aggregate OCR, connectors, audio if present + for category in [billing.ocr, billing.connectors, billing.audio] { + if let models = category?.models { + for (modelName, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: false) + } + } + } + + // Aggregate libraries_api (pages + tokens) + if let models = billing.librariesApi?.pages?.models { + for (modelName, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: false) + } + } + if let models = billing.librariesApi?.tokens?.models { + for (modelName, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: true) + } + } + + // Aggregate fine_tuning (training + storage) + for models in [billing.fineTuning?.training, billing.fineTuning?.storage] { + if let models { + for (modelName, modelData) in models { + let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) + totalCost += cost + Self.addDailyEntries( + modelName: modelName, + data: modelData, + prices: prices, + daily: &daily, + countsTokens: false) + } + } + } + + let currency = billing.currency ?? "EUR" + let currencySymbol = billing.currencySymbol ?? "€" + + let startDate = billing.startDate.flatMap { Self.parseDate($0) } + let endDate = billing.endDate.flatMap { Self.parseDate($0) } + + return MistralUsageSnapshot( + totalCost: totalCost, + currency: currency, + currencySymbol: currencySymbol, + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + totalCachedTokens: totalCached, + modelCount: modelCount, + daily: daily.values.map { $0.makeBucket() }, + startDate: startDate, + endDate: endDate, + updatedAt: updatedAt) + } + + // MARK: - Private Helpers + + private static func buildPriceIndex(_ prices: [MistralPrice]) -> [String: Double] { + var index: [String: Double] = [:] + for price in prices { + guard let metric = price.billingMetric, + let group = price.billingGroup, + let priceStr = price.price, + let value = Double(priceStr) + else { continue } + let key = "\(metric)::\(group)" + index[key] = value + } + return index + } + + private static func aggregateModel( + _ data: MistralModelUsageData, + prices: [String: Double]) -> (input: Int, output: Int, cached: Int, cost: Double) + { + var totalInput = 0 + var totalOutput = 0 + var totalCached = 0 + var totalCost: Double = 0 + + for entry in data.input ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalInput += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + for entry in data.output ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalOutput += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + for entry in data.cached ?? [] { + let tokens = entry.valuePaid ?? entry.value ?? 0 + totalCached += tokens + if let metric = entry.billingMetric, let group = entry.billingGroup { + let pricePerToken = prices["\(metric)::\(group)"] ?? 0 + totalCost += Double(tokens) * pricePerToken + } + } + + return (totalInput, totalOutput, totalCached, totalCost) + } + + private static func addDailyEntries( + modelName: String, + data: MistralModelUsageData, + prices: [String: Double], + daily: inout [String: DailyAccumulator], + countsTokens: Bool) + { + self.addDaily( + entries: data.input ?? [], + context: DailyEntryContext( + kind: .input, + modelName: modelName, + prices: prices, + countsTokens: countsTokens), + daily: &daily) + self.addDaily( + entries: data.output ?? [], + context: DailyEntryContext( + kind: .output, + modelName: modelName, + prices: prices, + countsTokens: countsTokens), + daily: &daily) + self.addDaily( + entries: data.cached ?? [], + context: DailyEntryContext( + kind: .cached, + modelName: modelName, + prices: prices, + countsTokens: countsTokens), + daily: &daily) + } + + fileprivate enum TokenKind { + case input + case cached + case output + } + + private static func addDaily( + entries: [MistralUsageEntry], + context: DailyEntryContext, + daily: inout [String: DailyAccumulator]) + { + for entry in entries { + guard let day = dayKey(from: entry.timestamp) else { continue } + let units = entry.valuePaid ?? entry.value ?? 0 + let cost = Self.cost(for: entry, units: units, prices: context.prices) + var accumulator = daily[day] ?? DailyAccumulator(day: day) + accumulator.add( + modelName: Self.displayModelName(context.modelName, entry: entry), + kind: context.kind, + units: units, + cost: cost, + countsTokens: context.countsTokens) + daily[day] = accumulator + } + } + + private static func cost(for entry: MistralUsageEntry, units: Int, prices: [String: Double]) -> Double { + guard let metric = entry.billingMetric, let group = entry.billingGroup else { return 0 } + return Double(units) * (prices["\(metric)::\(group)"] ?? 0) + } + + private static func displayModelName(_ raw: String, entry: MistralUsageEntry) -> String { + if let display = entry.billingDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines), + !display.isEmpty + { + return display + } + return raw.split(separator: "::").first.map(String.init) ?? raw + } + + private static func dayKey(from timestamp: String?) -> String? { + guard let trimmed = timestamp?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + if trimmed.count >= 10 { + return String(trimmed.prefix(10)) + } + return nil + } + + private static func parseDate(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } +} + +private struct DailyEntryContext { + let kind: MistralUsageFetcher.TokenKind + let modelName: String + let prices: [String: Double] + let countsTokens: Bool +} + +private struct DailyAccumulator { + let day: String + var cost: Double = 0 + var inputTokens = 0 + var cachedTokens = 0 + var outputTokens = 0 + var models: [String: ModelAccumulator] = [:] + + mutating func add( + modelName: String, + kind: MistralUsageFetcher.TokenKind, + units: Int, + cost: Double, + countsTokens: Bool) + { + self.cost += cost + var model = self.models[modelName] ?? ModelAccumulator(name: modelName) + model.cost += cost + guard countsTokens else { + self.models[modelName] = model + return + } + switch kind { + case .input: + self.inputTokens += units + model.inputTokens += units + case .cached: + self.cachedTokens += units + model.cachedTokens += units + case .output: + self.outputTokens += units + model.outputTokens += units + } + self.models[modelName] = model + } + + func makeBucket() -> MistralDailyUsageBucket { + MistralDailyUsageBucket( + day: self.day, + cost: self.cost, + inputTokens: self.inputTokens, + cachedTokens: self.cachedTokens, + outputTokens: self.outputTokens, + models: self.models.values + .map { $0.makeBreakdown() } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + }) + } +} + +private struct ModelAccumulator { + let name: String + var cost: Double = 0 + var inputTokens = 0 + var cachedTokens = 0 + var outputTokens = 0 + + func makeBreakdown() -> MistralDailyUsageBucket.ModelBreakdown { + MistralDailyUsageBucket.ModelBreakdown( + name: self.name, + cost: self.cost, + inputTokens: self.inputTokens, + cachedTokens: self.cachedTokens, + outputTokens: self.outputTokens) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift new file mode 100644 index 000000000..61703f138 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift @@ -0,0 +1,71 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum MoonshotProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .moonshot, + metadata: ProviderMetadata( + id: .moonshot, + displayName: "Moonshot / Kimi API", + sessionLabel: "Balance", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Moonshot / Kimi API balance", + cliName: "moonshot", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://platform.moonshot.ai/console/account", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .kimi, + iconResourceName: "ProviderIcon-kimi", + color: ProviderColor(red: 32 / 255, green: 93 / 255, blue: 235 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Moonshot / Kimi API cost summary is not available." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MoonshotAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "moonshot", + aliases: [], + versionDetector: nil)) + } +} + +struct MoonshotAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "moonshot.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw MoonshotUsageError.missingCredentials + } + let region = + context.settings?.moonshot?.region ?? MoonshotSettingsReader.region(environment: context.env) + let usage = try await MoonshotUsageFetcher.fetchUsage(apiKey: apiKey, region: region) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.moonshotToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift new file mode 100644 index 000000000..420e3ff1e --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotRegion.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum MoonshotRegion: String, CaseIterable, Sendable { + case international + case china + + private static let balancePath = "v1/users/me/balance" + + public var displayName: String { + switch self { + case .international: + "International (api.moonshot.ai)" + case .china: + "China (api.moonshot.cn)" + } + } + + public var apiBaseURLString: String { + switch self { + case .international: + "https://api.moonshot.ai" + case .china: + "https://api.moonshot.cn" + } + } + + public var balanceURL: URL { + URL(string: self.apiBaseURLString)!.appendingPathComponent(Self.balancePath) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift new file mode 100644 index 000000000..b01db6eca --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct MoonshotSettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = [ + "MOONSHOT_API_KEY", + "MOONSHOT_KEY", + ] + public static let regionEnvironmentKey = "MOONSHOT_REGION" + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + + return nil + } + + public static func region( + environment: [String: String] = ProcessInfo.processInfo.environment) -> MoonshotRegion + { + guard let raw = environment[self.regionEnvironmentKey] else { + return .international + } + let cleaned = Self.cleaned(raw).lowercased() + return MoonshotRegion(rawValue: cleaned) ?? .international + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) + || (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift new file mode 100644 index 000000000..04a137d0d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift @@ -0,0 +1,161 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct MoonshotUsageSnapshot: Sendable { + public let summary: MoonshotUsageSummary + + public init(summary: MoonshotUsageSummary) { + self.summary = summary + } + + public func toUsageSnapshot() -> UsageSnapshot { + self.summary.toUsageSnapshot() + } +} + +public struct MoonshotUsageSummary: Sendable { + public let availableBalance: Double + public let voucherBalance: Double + public let cashBalance: Double + public let updatedAt: Date + + public init( + availableBalance: Double, voucherBalance: Double, cashBalance: Double, updatedAt: Date) + { + self.availableBalance = availableBalance + self.voucherBalance = voucherBalance + self.cashBalance = cashBalance + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let balance = UsageFormatter.usdString(self.availableBalance) + let loginMethod: String + if self.cashBalance < 0 { + let deficit = UsageFormatter.usdString(abs(self.cashBalance)) + loginMethod = "Balance: \(balance) · \(deficit) in deficit" + } else { + loginMethod = "Balance: \(balance)" + } + let identity = ProviderIdentitySnapshot( + providerID: .moonshot, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +private struct MoonshotBalanceResponse: Decodable { + let code: Int + let data: MoonshotBalanceData + let scode: String + let status: Bool +} + +private struct MoonshotBalanceData: Decodable { + let availableBalance: Double + let voucherBalance: Double + let cashBalance: Double + + private enum CodingKeys: String, CodingKey { + case availableBalance = "available_balance" + case voucherBalance = "voucher_balance" + case cashBalance = "cash_balance" + } +} + +public enum MoonshotUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Moonshot API key." + case let .networkError(message): + "Moonshot network error: \(message)" + case let .apiError(message): + "Moonshot API error: \(message)" + case let .parseFailed(message): + "Failed to parse Moonshot response: \(message)" + } + } +} + +public struct MoonshotUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.moonshotUsage) + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + apiKey: String, + region: MoonshotRegion = .international, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MoonshotUsageSnapshot + { + let cleaned = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { + throw MoonshotUsageError.missingCredentials + } + + var request = URLRequest(url: self.resolveBalanceURL(region: region)) + request.httpMethod = "GET" + request.setValue("Bearer \(cleaned)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { + throw MoonshotUsageError.networkError("Invalid response") + } catch { + throw error + } + + guard response.statusCode == 200 else { + Self.log.error("Moonshot API returned HTTP \(response.statusCode)") + throw MoonshotUsageError.apiError("HTTP \(response.statusCode)") + } + + let summary = try self.parseSummary(data: response.data) + return MoonshotUsageSnapshot(summary: summary) + } + + public static func resolveBalanceURL(region: MoonshotRegion) -> URL { + region.balanceURL + } + + static func _parseSummaryForTesting(_ data: Data) throws -> MoonshotUsageSummary { + try self.parseSummary(data: data) + } + + private static func parseSummary(data: Data) throws -> MoonshotUsageSummary { + let response: MoonshotBalanceResponse + do { + response = try JSONDecoder().decode(MoonshotBalanceResponse.self, from: data) + } catch { + throw MoonshotUsageError.parseFailed(error.localizedDescription) + } + + guard response.code == 0, response.status else { + throw MoonshotUsageError.apiError("code \(response.code), scode \(response.scode)") + } + + return MoonshotUsageSummary( + availableBalance: response.data.availableBalance, + voucherBalance: response.data.voucherBalance, + cashBalance: response.data.cashBalance, + updatedAt: Date()) + } +} diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift index 4702609fa..e13a18c73 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaProviderDescriptor.swift @@ -32,12 +32,32 @@ public enum OllamaProviderDescriptor { supportsTokenCost: false, noDataMessage: { "Ollama cost summary is not supported." }), fetchPlan: ProviderFetchPlan( - sourceModes: [.auto, .web], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OllamaStatusFetchStrategy()] })), + sourceModes: [.auto, .web, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "ollama", versionDetector: nil)) } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + switch context.sourceMode { + case .web: + return [OllamaStatusFetchStrategy()] + case .api: + return [OllamaAPIFetchStrategy()] + case .cli, .oauth: + return [] + case .auto: + break + } + if context.settings?.ollama?.cookieSource == .off { + return [OllamaAPIFetchStrategy()] + } + if ProviderTokenResolver.ollamaToken(environment: context.env) != nil { + return [OllamaStatusFetchStrategy(), OllamaAPIFetchStrategy()] + } + return [OllamaStatusFetchStrategy()] + } } struct OllamaStatusFetchStrategy: ProviderFetchStrategy { @@ -65,8 +85,9 @@ struct OllamaStatusFetchStrategy: ProviderFetchStrategy { sourceLabel: "web") } - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + && ProviderTokenResolver.ollamaToken(environment: context.env) != nil } private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { @@ -74,3 +95,30 @@ struct OllamaStatusFetchStrategy: ProviderFetchStrategy { return CookieHeaderNormalizer.normalize(context.settings?.ollama?.manualCookieHeader) } } + +struct OllamaAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "ollama.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw OllamaUsageError.missingAPIKey + } + let snapshot = try await OllamaAPIUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.ollamaToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index 9e5c4b068..6b58dd804 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -9,6 +9,7 @@ import SweetCookieKit private let ollamaSessionCookieNames: Set = [ "session", + "__Secure-session", "ollama_session", "__Host-ollama_session", "__Secure-next-auth.session-token", @@ -29,18 +30,24 @@ private func hasRecognizedOllamaSessionCookie(in header: String) -> Bool { } public enum OllamaUsageError: LocalizedError, Sendable { + case missingAPIKey case notLoggedIn case invalidCredentials + case apiUnauthorized case parseFailed(String) case networkError(String) case noSessionCookie public var errorDescription: String? { switch self { + case .missingAPIKey: + "Missing Ollama API key. Set apiKey in ~/.codexbar/config.json or OLLAMA_API_KEY." case .notLoggedIn: "Not logged in to Ollama. Please log in via ollama.com/settings." case .invalidCredentials: "Ollama session cookie expired. Please log in again." + case .apiUnauthorized: + "Ollama API key is invalid or expired." case let .parseFailed(message): "Could not parse Ollama usage: \(message)" case let .networkError(message): @@ -59,6 +66,7 @@ public enum OllamaCookieImporter { private static let cookieClient = BrowserCookieClient() private static let cookieDomains = ["ollama.com", "www.ollama.com"] static let defaultPreferredBrowsers: [Browser] = [.chrome] + static let defaultAllowFallbackBrowsers = true public struct SessionInfo: Sendable { public let cookies: [HTTPCookie] @@ -199,7 +207,7 @@ public enum OllamaCookieImporter { for browserSource in browserSources { do { let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: logger) @@ -228,12 +236,12 @@ public struct OllamaUsageFetcher: Sendable { private static let settingsURL = URL(string: "https://ollama.com/settings")! @MainActor private static var recentDumps: [String] = [] - private struct CookieCandidate: Sendable { + private struct CookieCandidate { let cookieHeader: String let sourceLabel: String } - enum RetryableParseFailure: Error, Sendable { + enum RetryableParseFailure: Error { case missingUsageData } @@ -368,7 +376,11 @@ public struct OllamaUsageFetcher: Sendable { return [CookieCandidate(cookieHeader: manualHeader, sourceLabel: "manual cookie header")] } #if os(macOS) - let sessions = try OllamaCookieImporter.importSessions(browserDetection: self.browserDetection, logger: logger) + let sessions = try OllamaCookieImporter.importSessions( + browserDetection: self.browserDetection, + preferredBrowsers: OllamaCookieImporter.defaultPreferredBrowsers, + allowFallbackBrowsers: OllamaCookieImporter.defaultAllowFallbackBrowsers, + logger: logger) return sessions.map { session in CookieCandidate(cookieHeader: session.cookieHeader, sourceLabel: session.sourceLabel) } @@ -450,7 +462,11 @@ public struct OllamaUsageFetcher: Sendable { return manualHeader } #if os(macOS) - let session = try OllamaCookieImporter.importSession(browserDetection: self.browserDetection, logger: logger) + let session = try OllamaCookieImporter.importSession( + browserDetection: self.browserDetection, + preferredBrowsers: OllamaCookieImporter.defaultPreferredBrowsers, + allowFallbackBrowsers: OllamaCookieImporter.defaultAllowFallbackBrowsers, + logger: logger) logger?("[ollama] Using cookies from \(session.sourceLabel)") return session.cookieHeader #else @@ -508,13 +524,10 @@ public struct OllamaUsageFetcher: Sendable { request.setValue(Self.settingsURL.absoluteString, forHTTPHeaderField: "referer") let session = self.makeURLSession(diagnostics) - let (data, response) = try await session.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw OllamaUsageError.networkError("Invalid response") - } + let httpResponse = try await session.response(for: request) let responseInfo = ResponseInfo( statusCode: httpResponse.statusCode, - url: httpResponse.url?.absoluteString ?? "unknown") + url: httpResponse.response.url?.absoluteString ?? "unknown") guard httpResponse.statusCode == 200 else { if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { @@ -523,7 +536,7 @@ public struct OllamaUsageFetcher: Sendable { throw OllamaUsageError.networkError("HTTP \(httpResponse.statusCode)") } - let html = String(data: data, encoding: .utf8) ?? "" + let html = String(data: httpResponse.data, encoding: .utf8) ?? "" return (html, responseInfo) } @@ -568,7 +581,7 @@ public struct OllamaUsageFetcher: Sendable { } } - private struct ResponseInfo: Sendable { + private struct ResponseInfo { let statusCode: Int let url: String } @@ -612,3 +625,117 @@ public struct OllamaUsageFetcher: Sendable { return host.hasSuffix(".ollama.com") } } + +public struct OllamaAPISettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = [ + "OLLAMA_API_KEY", + "OLLAMA_KEY", + ] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let value = self.cleaned(environment[key]), !value.isEmpty else { continue } + return value + } + return nil + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +public struct OllamaAPIUsageSnapshot: Sendable { + public let modelCount: Int + public let updatedAt: Date + + public init(modelCount: Int, updatedAt: Date) { + self.modelCount = modelCount + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .ollama, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API key")) + } +} + +public enum OllamaAPIUsageFetcher { + public static let tagsURL = URL(string: "https://ollama.com/api/tags")! + private static let timeoutSeconds: TimeInterval = 20 + + public static func fetchUsage( + apiKey: String, + tagsURL: URL = Self.tagsURL, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> OllamaAPIUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw OllamaUsageError.missingAPIKey + } + + var request = URLRequest(url: tagsURL) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodexBar/1.0", forHTTPHeaderField: "User-Agent") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw OllamaUsageError.networkError(error.localizedDescription) + } + + switch response.statusCode { + case 200: + return try Self.parseTags(data: response.data, now: now) + case 401, 403: + throw OllamaUsageError.apiUnauthorized + default: + throw OllamaUsageError.networkError("HTTP \(response.statusCode)") + } + } + + static func _parseTagsForTesting(_ data: Data, now: Date = Date()) throws -> OllamaAPIUsageSnapshot { + try self.parseTags(data: data, now: now) + } + + private static func parseTags(data: Data, now: Date) throws -> OllamaAPIUsageSnapshot { + do { + let response = try JSONDecoder().decode(TagsResponse.self, from: data) + return OllamaAPIUsageSnapshot(modelCount: response.models.count, updatedAt: now) + } catch { + throw OllamaUsageError.parseFailed(error.localizedDescription) + } + } + + private struct TagsResponse: Decodable { + let models: [Model] + } + + private struct Model: Decodable {} +} diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift index f93f35864..53d3cf51f 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift @@ -2,13 +2,14 @@ import Foundation enum OllamaUsageParser { private static let primaryUsageLabels = ["Session usage", "Hourly usage"] + private static let usageLabels = primaryUsageLabels + ["Weekly usage"] - enum ParseFailure: Sendable, Equatable { + enum ParseFailure: Equatable { case notLoggedIn case missingUsageData } - enum ClassifiedParseResult: Sendable { + enum ClassifiedParseResult { case success(OllamaUsageSnapshot) case failure(ParseFailure) } @@ -44,12 +45,14 @@ enum OllamaUsageParser { weeklyUsedPercent: weekly?.usedPercent, sessionResetsAt: session?.resetsAt, weeklyResetsAt: weekly?.resetsAt, + sessionWindowMinutes: session?.windowMinutes, updatedAt: now)) } - private struct UsageBlock: Sendable { + private struct UsageBlock { let usedPercent: Double let resetsAt: Date? + let windowMinutes: Int? } private static func parsePlanName(_ html: String) -> String? { @@ -72,11 +75,15 @@ enum OllamaUsageParser { private static func parseUsageBlock(label: String, html: String) -> UsageBlock? { guard let labelRange = html.range(of: label) else { return nil } let tail = String(html[labelRange.upperBound...]) - let window = String(tail.prefix(800)) + let window = self.usageBlockWindow(after: label, in: tail) guard let usedPercent = self.parsePercent(in: window) else { return nil } let resetsAt = self.parseISODate(in: window) - return UsageBlock(usedPercent: usedPercent, resetsAt: resetsAt) + let windowMinutes = label == "Session usage" ? 5 * 60 : nil + return UsageBlock( + usedPercent: usedPercent, + resetsAt: resetsAt, + windowMinutes: windowMinutes) } private static func parseUsageBlock(labels: [String], html: String) -> UsageBlock? { @@ -88,6 +95,16 @@ enum OllamaUsageParser { return nil } + private static func usageBlockWindow(after label: String, in tail: String) -> String { + let maxLength = 4000 + let boundary = self.usageLabels + .filter { $0 != label } + .compactMap { tail.range(of: $0)?.lowerBound } + .min() + let bounded = boundary.map { String(tail[..<$0]) } ?? String(tail.prefix(maxLength)) + return String(bounded.prefix(maxLength)) + } + private static func parsePercent(in text: String) -> Double? { let usedPattern = #"([0-9]+(?:\.[0-9]+)?)\s*%\s*used"# if let raw = self.firstCapture(in: text, pattern: usedPattern, options: [.caseInsensitive]) { diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift index 002f78d81..d3c7e3273 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift @@ -7,6 +7,7 @@ public struct OllamaUsageSnapshot: Sendable { public let weeklyUsedPercent: Double? public let sessionResetsAt: Date? public let weeklyResetsAt: Date? + public let sessionWindowMinutes: Int? public let updatedAt: Date public init( @@ -16,6 +17,7 @@ public struct OllamaUsageSnapshot: Sendable { weeklyUsedPercent: Double?, sessionResetsAt: Date?, weeklyResetsAt: Date?, + sessionWindowMinutes: Int? = nil, updatedAt: Date) { self.planName = planName @@ -24,16 +26,17 @@ public struct OllamaUsageSnapshot: Sendable { self.weeklyUsedPercent = weeklyUsedPercent self.sessionResetsAt = sessionResetsAt self.weeklyResetsAt = weeklyResetsAt + self.sessionWindowMinutes = sessionWindowMinutes self.updatedAt = updatedAt } } extension OllamaUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - let sessionWindow = self.makeWindow( + let sessionWindow = self.makeSessionWindow( usedPercent: self.sessionUsedPercent, resetsAt: self.sessionResetsAt) - let weeklyWindow = self.makeWindow( + let weeklyWindow = self.makeWeeklyWindow( usedPercent: self.weeklyUsedPercent, resetsAt: self.weeklyResetsAt) @@ -54,12 +57,22 @@ extension OllamaUsageSnapshot { identity: identity) } - private func makeWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? { + private func makeSessionWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? { guard let usedPercent else { return nil } let clamped = min(100, max(0, usedPercent)) return RateWindow( usedPercent: clamped, - windowMinutes: nil, + windowMinutes: self.sessionWindowMinutes, + resetsAt: resetsAt, + resetDescription: nil) + } + + private func makeWeeklyWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? { + guard let usedPercent else { return nil } + let clamped = min(100, max(0, usedPercent)) + return RateWindow( + usedPercent: clamped, + windowMinutes: 7 * 24 * 60, resetsAt: resetsAt, resetDescription: nil) } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPICreditBalanceFetcher.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPICreditBalanceFetcher.swift new file mode 100644 index 000000000..f35db443a --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPICreditBalanceFetcher.swift @@ -0,0 +1,185 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct OpenAIAPICreditGrant: Decodable, Sendable { + public let grantAmount: Double? + public let usedAmount: Double? + public let expiresAt: Date? + + private enum CodingKeys: String, CodingKey { + case grantAmount = "grant_amount" + case usedAmount = "used_amount" + case expiresAt = "expires_at" + } +} + +public struct OpenAIAPICreditGrantsList: Decodable, Sendable { + public let data: [OpenAIAPICreditGrant] +} + +public struct OpenAIAPICreditGrantsResponse: Decodable, Sendable { + public let totalGranted: Double + public let totalUsed: Double + public let totalAvailable: Double + public let grants: OpenAIAPICreditGrantsList? + + private enum CodingKeys: String, CodingKey { + case totalGranted = "total_granted" + case totalUsed = "total_used" + case totalAvailable = "total_available" + case grants + } +} + +public enum OpenAIAPICreditBalanceError: LocalizedError, Sendable, Equatable { + case missingCredentials + case networkError(String) + case apiError(Int) + case forbidden + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing OpenAI API key." + case let .networkError(message): + "OpenAI API credit balance network error: \(message)" + case let .apiError(statusCode): + "OpenAI API credit balance error: HTTP \(statusCode)" + case .forbidden: + "OpenAI API credit balance endpoint returned HTTP 403. Use a legacy/user API key with billing access; " + + "project keys may not expose credit grants." + case let .parseFailed(message): + "Failed to parse OpenAI API credit balance: \(message)" + } + } +} + +public struct OpenAIAPICreditBalanceSnapshot: Sendable { + public let totalGranted: Double + public let totalUsed: Double + public let totalAvailable: Double + public let nextGrantExpiry: Date? + public let updatedAt: Date + + public init( + totalGranted: Double, + totalUsed: Double, + totalAvailable: Double, + nextGrantExpiry: Date?, + updatedAt: Date = Date()) + { + self.totalGranted = totalGranted + self.totalUsed = totalUsed + self.totalAvailable = totalAvailable + self.nextGrantExpiry = nextGrantExpiry + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let usedPercent: Double = if self.totalGranted > 0 { + min(100, max(0, (self.totalUsed / self.totalGranted) * 100)) + } else { + self.totalAvailable > 0 ? 0 : 100 + } + + let primary = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: self.nextGrantExpiry, + resetDescription: "\(Self.formatUSD(self.totalAvailable)) available") + + return UsageSnapshot( + primary: primary, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: max(0, self.totalUsed), + limit: max(0, self.totalGranted), + currencyCode: "USD", + period: "API credits", + resetsAt: self.nextGrantExpiry, + updatedAt: self.updatedAt), + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .openai, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "API balance: \(Self.formatUSD(self.totalAvailable))")) + } + + private static func formatUSD(_ value: Double) -> String { + UsageFormatter.currencyString(max(0, value), currencyCode: "USD") + } +} + +public enum OpenAIAPICreditBalanceFetcher { + public static let creditGrantsURL = URL(string: "https://api.openai.com/v1/dashboard/billing/credit_grants")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchBalance( + apiKey: String, + url: URL = Self.creditGrantsURL, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date()) async throws -> OpenAIAPICreditBalanceSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw OpenAIAPICreditBalanceError.missingCredentials + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch { + throw OpenAIAPICreditBalanceError.networkError(error.localizedDescription) + } + + guard response.statusCode != 403 else { + throw OpenAIAPICreditBalanceError.forbidden + } + guard response.statusCode == 200 else { + throw OpenAIAPICreditBalanceError.apiError(response.statusCode) + } + + return try self.parseSnapshot(response.data, now: now) + } + + public static func _parseSnapshotForTesting( + _ data: Data, + now: Date = Date()) throws -> OpenAIAPICreditBalanceSnapshot + { + try self.parseSnapshot(data, now: now) + } + + private static func parseSnapshot(_ data: Data, now: Date) throws -> OpenAIAPICreditBalanceSnapshot { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + + let decoded: OpenAIAPICreditGrantsResponse + do { + decoded = try decoder.decode(OpenAIAPICreditGrantsResponse.self, from: data) + } catch { + throw OpenAIAPICreditBalanceError.parseFailed(error.localizedDescription) + } + + let nextExpiry = decoded.grants?.data + .compactMap(\.expiresAt) + .filter { $0 > now } + .min() + + return OpenAIAPICreditBalanceSnapshot( + totalGranted: decoded.totalGranted, + totalUsed: decoded.totalUsed, + totalAvailable: decoded.totalAvailable, + nextGrantExpiry: nextExpiry, + updatedAt: now) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift new file mode 100644 index 000000000..009b369f7 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift @@ -0,0 +1,134 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum OpenAIAPIProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .openai, + metadata: ProviderMetadata( + id: .openai, + displayName: "OpenAI", + sessionLabel: "Spend", + weeklyLabel: "Requests", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show OpenAI usage", + cliName: "openai", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://platform.openai.com/usage", + statusPageURL: "https://status.openai.com"), + branding: ProviderBranding( + iconStyle: .openai, + iconResourceName: "ProviderIcon-codex", + color: ProviderColor(red: 0.06, green: 0.51, blue: 0.43)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: true, + noDataMessage: { "OpenAI usage needs an Admin API key for organization usage." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenAIAPIBalanceFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "openai", + aliases: ["openai-api"], + versionDetector: nil)) + } +} + +struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy { + let id: String = "openai.api.balance" + let kind: ProviderFetchKind = .apiToken + let usageFetcher: @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot + let balanceFetcher: @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot + + init( + usageFetcher: @escaping @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot = + OpenAIAPIBalanceFetchStrategy.fetchUsage(credential:days:), + balanceFetcher: @escaping @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot = { apiKey in + try await OpenAIAPICreditBalanceFetcher.fetchBalance(apiKey: apiKey) + }) + { + self.usageFetcher = usageFetcher + self.balanceFetcher = balanceFetcher + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + OpenAIAPIUsageCredential(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let credential = OpenAIAPIUsageCredential(environment: context.env) else { + throw OpenAIAPISettingsError.missingToken + } + + do { + let usage = try await self.usageFetcher(credential, context.costUsageHistoryDays) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: credential.sourceLabel) + } catch { + let usageError = error + if !credential.allowsLegacyBalanceFallback { + throw usageError + } + // Preserve the older balance-only path for unscoped keys and Admin API outages. + do { + let balance = try await self.balanceFetcher(credential.apiKey) + return self.makeResult( + usage: balance.toUsageSnapshot(), + sourceLabel: "billing-api") + } catch { + if (usageError as? OpenAIAPIUsageError)?.isCredentialRejected != true { + throw usageError + } + throw error + } + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func fetchUsage( + credential: OpenAIAPIUsageCredential, + days: Int) async throws -> OpenAIAPIUsageSnapshot + { + try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: credential.apiKey, + projectID: credential.projectID, + historyDays: days) + } +} + +struct OpenAIAPIUsageCredential: Equatable { + let apiKey: String + let projectID: String? + let usesAdminKey: Bool + + init?(environment: [String: String]) { + if let adminKey = OpenAIAPISettingsReader.adminAPIKey(environment: environment) { + self.apiKey = adminKey + self.usesAdminKey = true + } else if let apiKey = OpenAIAPISettingsReader.apiKey(environment: environment) { + self.apiKey = apiKey + self.usesAdminKey = false + } else { + return nil + } + self.projectID = OpenAIAPISettingsReader.projectID(environment: environment) + } + + var sourceLabel: String { + self.projectID == nil ? "admin-api" : "admin-api:project" + } + + var allowsLegacyBalanceFallback: Bool { + self.projectID == nil || !self.usesAdminKey + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift new file mode 100644 index 000000000..a641872d1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift @@ -0,0 +1,52 @@ +import Foundation + +public enum OpenAIAPISettingsReader { + public static let adminAPIKeyEnvironmentKey = "OPENAI_ADMIN_KEY" + public static let apiKeyEnvironmentKey = "OPENAI_API_KEY" + public static let projectIDEnvironmentKey = "OPENAI_PROJECT_ID" + public static let apiKeyEnvironmentKeys = [ + Self.adminAPIKeyEnvironmentKey, + Self.apiKeyEnvironmentKey, + ] + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + for key in self.apiKeyEnvironmentKeys { + if let token = self.cleaned(environment[key]) { return token } + } + return nil + } + + public static func adminAPIKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.adminAPIKeyEnvironmentKey]) + } + + public static func projectID(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.projectIDEnvironmentKey]) + } + + static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} + +public enum OpenAIAPISettingsError: LocalizedError, Sendable { + case missingToken + + public var errorDescription: String? { + switch self { + case .missingToken: + "OpenAI API key not configured. Set OPENAI_API_KEY or configure an API key in Settings." + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift new file mode 100644 index 000000000..135702829 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift @@ -0,0 +1,396 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum OpenAIAPIUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case networkError(String) + case apiError(endpoint: String, statusCode: Int) + case parseFailed(endpoint: String, message: String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing OpenAI Admin API key." + case let .networkError(message): + "OpenAI API usage network error: \(message)" + case let .apiError(endpoint, statusCode): + "OpenAI API usage \(endpoint) error: HTTP \(statusCode)" + case let .parseFailed(endpoint, message): + "Failed to parse OpenAI API usage \(endpoint): \(message)" + } + } + + var isCredentialRejected: Bool { + switch self { + case let .apiError(_, statusCode): + statusCode == 401 || statusCode == 403 + default: + false + } + } +} + +public enum OpenAIAPIUsageFetcher { + public static let organizationCostsURL = URL(string: "https://api.openai.com/v1/organization/costs")! + public static let organizationCompletionsUsageURL = + URL(string: "https://api.openai.com/v1/organization/usage/completions")! + + private static let maxDailyBucketLimit = 31 + private static let timeoutSeconds: TimeInterval = 20 + + private struct EndpointRequestContext { + let apiKey: String + let projectID: String? + let transport: any ProviderHTTPTransport + let retryPolicy: ProviderHTTPRetryPolicy + } + + private struct UsageEndpoint { + let name: String + let baseURL: URL + let queryItems: [URLQueryItem] + let decodeBuckets: (Data) throws -> [Bucket] + } + + private typealias SnapshotMetadata = (now: Date, calendar: Calendar, historyDays: Int, projectID: String?) + + public static func fetchUsage( + apiKey: String, + projectID: String? = nil, + costsURL: URL = Self.organizationCostsURL, + completionsURL: URL = Self.organizationCompletionsUsageURL, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + now: Date = Date(), + historyDays: Int = 30, + retryPolicy: ProviderHTTPRetryPolicy = .transientIdempotent) async throws -> OpenAIAPIUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw OpenAIAPIUsageError.missingCredentials + } + let normalizedProjectID = OpenAIAPISettingsReader.cleaned(projectID) + + let calendar = Self.utcCalendar + let clampedHistoryDays = max(1, min(365, historyDays)) + let ranges = Self.dailyRanges(now: now, calendar: calendar, historyDays: clampedHistoryDays) + let requestContext = EndpointRequestContext( + apiKey: trimmed, + projectID: normalizedProjectID, + transport: transport, + retryPolicy: retryPolicy) + let costs = try await Self.fetchBuckets( + endpoint: Self.costsEndpoint(baseURL: costsURL), + context: requestContext, + ranges: ranges) + let completions = try await Self.fetchBuckets( + endpoint: Self.completionsEndpoint(baseURL: completionsURL), + context: requestContext, + ranges: ranges) + + return Self.makeSnapshot( + costs: costs, + completions: completions, + metadata: SnapshotMetadata( + now: now, + calendar: calendar, + historyDays: clampedHistoryDays, + projectID: normalizedProjectID)) + } + + static func _parseSnapshotForTesting( + costs: Data, + completions: Data, + now: Date, + calendar: Calendar = Self.utcCalendar, + historyDays: Int = 30, + projectID: String? = nil) throws -> OpenAIAPIUsageSnapshot + { + try self.makeSnapshot( + costs: self.decodeCosts(costs).data, + completions: self.decodeCompletions(completions).data, + metadata: SnapshotMetadata( + now: now, + calendar: calendar, + historyDays: historyDays, + projectID: OpenAIAPISettingsReader.cleaned(projectID))) + } + + private static func costsEndpoint(baseURL: URL) -> UsageEndpoint { + UsageEndpoint( + name: "costs", + baseURL: baseURL, + queryItems: [URLQueryItem(name: "group_by", value: "line_item")], + decodeBuckets: { try self.decodeCosts($0).data }) + } + + private static func completionsEndpoint( + baseURL: URL) -> UsageEndpoint + { + UsageEndpoint( + name: "completions", + baseURL: baseURL, + queryItems: [URLQueryItem(name: "group_by", value: "model")], + decodeBuckets: { try self.decodeCompletions($0).data }) + } + + private static func fetchBuckets( + endpoint: UsageEndpoint, + context: EndpointRequestContext, + ranges: [DateRange]) async throws -> [Bucket] + { + var buckets: [Bucket] = [] + for range in ranges { + let url = Self.url( + baseURL: endpoint.baseURL, + range: range, + queryItems: endpoint.queryItems + Self.projectQueryItems(projectID: context.projectID)) + let data = try await Self.fetchData( + url: url, + apiKey: context.apiKey, + endpoint: endpoint.name, + transport: context.transport, + retryPolicy: context.retryPolicy) + try buckets.append(contentsOf: endpoint.decodeBuckets(data)) + } + return buckets + } + + private static func projectQueryItems(projectID: String?) -> [URLQueryItem] { + guard let projectID else { return [] } + return [URLQueryItem(name: "project_ids", value: projectID)] + } + + private static func fetchData( + url: URL, + apiKey: String, + endpoint: String, + transport: any ProviderHTTPTransport, + retryPolicy: ProviderHTTPRetryPolicy) async throws -> Data + { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = Self.timeoutSeconds + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request, retryPolicy: retryPolicy) + } catch { + throw OpenAIAPIUsageError.networkError(error.localizedDescription) + } + + guard response.statusCode == 200 else { + throw OpenAIAPIUsageError.apiError(endpoint: endpoint, statusCode: response.statusCode) + } + return response.data + } + + private static func decodeCosts(_ data: Data) throws -> CostsResponse { + do { + return try JSONDecoder().decode(CostsResponse.self, from: data) + } catch { + throw OpenAIAPIUsageError.parseFailed(endpoint: "costs", message: error.localizedDescription) + } + } + + private static func decodeCompletions(_ data: Data) throws -> CompletionsUsageResponse { + do { + return try JSONDecoder().decode(CompletionsUsageResponse.self, from: data) + } catch { + throw OpenAIAPIUsageError.parseFailed(endpoint: "completions", message: error.localizedDescription) + } + } + + private static func makeSnapshot( + costs: [OpenAICostBucket], + completions: [OpenAICompletionsUsageBucket], + metadata: SnapshotMetadata) -> OpenAIAPIUsageSnapshot + { + var accumulators: [Int: DailyAccumulator] = [:] + + for bucket in costs { + var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator( + startTime: bucket.startTime, + endTime: bucket.endTime) + for result in bucket.results { + let value = result.amount?.value ?? 0 + accumulator.costUSD += value + let lineItem = Self.displayName(result.lineItem, fallback: "API") + accumulator.lineItems[lineItem, default: 0] += value + } + accumulators[bucket.startTime] = accumulator + } + + for bucket in completions { + var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator( + startTime: bucket.startTime, + endTime: bucket.endTime) + for result in bucket.results { + let input = result.inputTokens ?? 0 + let cached = result.inputCachedTokens ?? 0 + let output = result.outputTokens ?? 0 + let audioInput = result.inputAudioTokens ?? 0 + let audioOutput = result.outputAudioTokens ?? 0 + let requests = result.numModelRequests ?? 0 + let totalTokens = input + output + audioInput + audioOutput + accumulator.requests += requests + accumulator.inputTokens += input + audioInput + accumulator.cachedInputTokens += cached + accumulator.outputTokens += output + audioOutput + accumulator.totalTokens += totalTokens + let modelName = Self.displayName(result.model, fallback: "Responses and Chat Completions") + accumulator.models[modelName, default: ModelAccumulator()].add( + requests: requests, + inputTokens: input + audioInput, + cachedInputTokens: cached, + outputTokens: output + audioOutput, + totalTokens: totalTokens) + } + accumulators[bucket.startTime] = accumulator + } + + let daily = accumulators.values + .filter { $0.startDate <= metadata.now } + .sorted { $0.startTime < $1.startTime } + .map { $0.makeBucket(calendar: metadata.calendar) } + return OpenAIAPIUsageSnapshot( + daily: daily, + updatedAt: metadata.now, + historyDays: metadata.historyDays, + projectID: metadata.projectID) + } + + private static func displayName(_ raw: String?, fallback: String) -> String { + guard let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return fallback + } + return trimmed + } + + private static func url(baseURL: URL, range: DateRange, queryItems extraItems: [URLQueryItem]) -> URL { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "start_time", value: String(range.startTime)), + URLQueryItem(name: "end_time", value: String(range.endTime)), + URLQueryItem(name: "bucket_width", value: "1d"), + URLQueryItem(name: "limit", value: String(range.limit)), + ] + extraItems + return components.url! + } + + private static func dailyRanges(now: Date, calendar: Calendar, historyDays: Int) -> [DateRange] { + let today = calendar.startOfDay(for: now) + let clampedHistoryDays = max(1, min(365, historyDays)) + var cursor = calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: today) ?? today + var remainingDays = clampedHistoryDays + var ranges: [DateRange] = [] + ranges.reserveCapacity(Int(ceil(Double(clampedHistoryDays) / Double(Self.maxDailyBucketLimit)))) + + while remainingDays > 0 { + let chunkDays = min(Self.maxDailyBucketLimit, remainingDays) + let end = calendar.date(byAdding: .day, value: chunkDays, to: cursor) ?? cursor + ranges.append(DateRange( + startTime: Int(cursor.timeIntervalSince1970), + endTime: Int(end.timeIntervalSince1970), + limit: chunkDays)) + cursor = end + remainingDays -= chunkDays + } + return ranges + } + + private static var utcCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + } +} + +private struct DateRange { + let startTime: Int + let endTime: Int + let limit: Int +} + +private struct DailyAccumulator { + let startTime: Int + let endTime: Int + var costUSD: Double = 0 + var requests: Int = 0 + var inputTokens: Int = 0 + var cachedInputTokens: Int = 0 + var outputTokens: Int = 0 + var totalTokens: Int = 0 + var lineItems: [String: Double] = [:] + var models: [String: ModelAccumulator] = [:] + + var startDate: Date { + Date(timeIntervalSince1970: TimeInterval(self.startTime)) + } + + func makeBucket(calendar: Calendar) -> OpenAIAPIUsageSnapshot.DailyBucket { + OpenAIAPIUsageSnapshot.DailyBucket( + day: Self.dayKey(from: self.startDate, calendar: calendar), + startTime: self.startDate, + endTime: Date(timeIntervalSince1970: TimeInterval(self.endTime)), + costUSD: self.costUSD, + requests: self.requests, + inputTokens: self.inputTokens, + cachedInputTokens: self.cachedInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens, + lineItems: self.lineItems + .map { OpenAIAPIUsageSnapshot.LineItemBreakdown(name: $0.key, costUSD: $0.value) } + .sorted { + if $0.costUSD == $1.costUSD { return $0.name < $1.name } + return $0.costUSD > $1.costUSD + }, + models: self.models + .map { $0.value.makeModel(name: $0.key) } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + }) + } + + private static func dayKey(from date: Date, calendar: Calendar) -> String { + let comps = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", comps.year ?? 0, comps.month ?? 0, comps.day ?? 0) + } +} + +private struct ModelAccumulator { + var requests = 0 + var inputTokens = 0 + var cachedInputTokens = 0 + var outputTokens = 0 + var totalTokens = 0 + + mutating func add( + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.requests += requests + self.inputTokens += inputTokens + self.cachedInputTokens += cachedInputTokens + self.outputTokens += outputTokens + self.totalTokens += totalTokens + } + + func makeModel(name: String) -> OpenAIAPIUsageSnapshot.ModelBreakdown { + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: name, + requests: self.requests, + inputTokens: self.inputTokens, + cachedInputTokens: self.cachedInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift new file mode 100644 index 000000000..02ad04854 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift @@ -0,0 +1,106 @@ +import Foundation + +struct CostsResponse: Decodable { + let data: [OpenAICostBucket] +} + +struct OpenAICostBucket: Decodable { + let startTime: Int + let endTime: Int + let results: [OpenAICostResult] + + private enum CodingKeys: String, CodingKey { + case startTime = "start_time" + case endTime = "end_time" + case results + } +} + +struct OpenAICostResult: Decodable { + struct Amount: Decodable { + let value: Double? + let currency: String? + + private enum CodingKeys: String, CodingKey { + case value + case currency + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.value = try container.decodeFlexibleDoubleIfPresent(forKey: .value) + self.currency = try container.decodeIfPresent(String.self, forKey: .currency) + } + } + + let amount: Amount? + let lineItem: String? + + private enum CodingKeys: String, CodingKey { + case amount + case lineItem = "line_item" + } +} + +struct CompletionsUsageResponse: Decodable { + let data: [OpenAICompletionsUsageBucket] +} + +struct OpenAICompletionsUsageBucket: Decodable { + let startTime: Int + let endTime: Int + let results: [OpenAICompletionsUsageResult] + + private enum CodingKeys: String, CodingKey { + case startTime = "start_time" + case endTime = "end_time" + case results + } +} + +struct OpenAICompletionsUsageResult: Decodable { + let inputTokens: Int? + let inputCachedTokens: Int? + let inputAudioTokens: Int? + let outputTokens: Int? + let outputAudioTokens: Int? + let numModelRequests: Int? + let model: String? + + private enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case inputCachedTokens = "input_cached_tokens" + case inputAudioTokens = "input_audio_tokens" + case outputTokens = "output_tokens" + case outputAudioTokens = "output_audio_tokens" + case numModelRequests = "num_model_requests" + case model + } +} + +extension KeyedDecodingContainer { + fileprivate func decodeFlexibleDoubleIfPresent(forKey key: Key) throws -> Double? { + guard self.contains(key), try !self.decodeNil(forKey: key) else { + return nil + } + + if let value = try? self.decode(Double.self, forKey: key) { + return value + } + + if let rawValue = try? self.decode(String.self, forKey: key) { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + if let value = Double(trimmed) { + return value + } + } + + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Expected a number or numeric string for \(key.stringValue)") + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift new file mode 100644 index 000000000..9f741ea99 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -0,0 +1,280 @@ +import Foundation + +public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { + public struct DailyBucket: Codable, Equatable, Sendable, Identifiable { + public let day: String + public let startTime: Date + public let endTime: Date + public let costUSD: Double + public let requests: Int + public let inputTokens: Int + public let cachedInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + public let lineItems: [LineItemBreakdown] + public let models: [ModelBreakdown] + + public var id: String { + self.day + } + + public init( + day: String, + startTime: Date, + endTime: Date, + costUSD: Double, + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int, + lineItems: [LineItemBreakdown], + models: [ModelBreakdown]) + { + self.day = day + self.startTime = startTime + self.endTime = endTime + self.costUSD = costUSD + self.requests = requests + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + self.lineItems = lineItems + self.models = models + } + } + + public struct LineItemBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let costUSD: Double + + public var id: String { + self.name + } + + public init(name: String, costUSD: Double) { + self.name = name + self.costUSD = costUSD + } + } + + public struct ModelBreakdown: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let requests: Int + public let inputTokens: Int + public let cachedInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + + public var id: String { + self.name + } + + public init( + name: String, + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.name = name + self.requests = requests + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + } + + public struct Summary: Equatable, Sendable { + public let costUSD: Double + public let requests: Int + public let inputTokens: Int + public let cachedInputTokens: Int + public let outputTokens: Int + public let totalTokens: Int + + public init( + costUSD: Double, + requests: Int, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + totalTokens: Int) + { + self.costUSD = costUSD + self.requests = requests + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + } + + public let daily: [DailyBucket] + public let updatedAt: Date + public let historyDays: Int + public let projectID: String? + + public init(daily: [DailyBucket], updatedAt: Date, historyDays: Int = 30, projectID: String? = nil) { + self.daily = daily.sorted { $0.startTime < $1.startTime } + self.updatedAt = updatedAt + self.historyDays = max(1, min(365, historyDays)) + self.projectID = OpenAIAPISettingsReader.cleaned(projectID) + } + + public var last30Days: Summary { + self.summary(days: self.historyDays) + } + + public var historyWindowLabel: String { + self.historyDays == 1 ? "Today" : "\(self.historyDays)d" + } + + public var historyWindowPeriodLabel: String { + self.historyDays == 1 ? "Today" : "Last \(self.historyDays) days" + } + + public var last7Days: Summary { + self.summary(days: 7) + } + + public var latestDay: Summary { + self.summary(days: 1) + } + + public func summary(days: Int) -> Summary { + let selected = self.daily.suffix(max(1, days)) + return Summary( + costUSD: selected.reduce(0) { $0 + $1.costUSD }, + requests: selected.reduce(0) { $0 + $1.requests }, + inputTokens: selected.reduce(0) { $0 + $1.inputTokens }, + cachedInputTokens: selected.reduce(0) { $0 + $1.cachedInputTokens }, + outputTokens: selected.reduce(0) { $0 + $1.outputTokens }, + totalTokens: selected.reduce(0) { $0 + $1.totalTokens }) + } + + public var topModels: [ModelBreakdown] { + var totals: [String: ModelAccumulator] = [:] + for day in self.daily { + for model in day.models { + totals[model.name, default: ModelAccumulator()].add(model) + } + } + return totals + .map { name, total in total.makeModel(name: name) } + .sorted { + if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } + return $0.totalTokens > $1.totalTokens + } + } + + public var topLineItems: [LineItemBreakdown] { + var totals: [String: Double] = [:] + for day in self.daily { + for item in day.lineItems { + totals[item.name, default: 0] += item.costUSD + } + } + return totals + .map { LineItemBreakdown(name: $0.key, costUSD: $0.value) } + .sorted { + if $0.costUSD == $1.costUSD { return $0.name < $1.name } + return $0.costUSD > $1.costUSD + } + } + + public func toUsageSnapshot() -> UsageSnapshot { + let total = self.last30Days + return UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: total.costUSD, + limit: 0, + currencyCode: "USD", + period: self.historyWindowPeriodLabel, + updatedAt: self.updatedAt), + openAIAPIUsage: self, + updatedAt: self.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .openai, + accountEmail: nil, + accountOrganization: self.identityAccountOrganization, + loginMethod: self.identityLoginMethod)) + } + + private var identityLoginMethod: String { + guard let projectID else { return "Admin API" } + return "Admin API: \(projectID)" + } + + private var identityAccountOrganization: String? { + guard let projectID else { return nil } + return "Project: \(projectID)" + } + + public func toCostUsageTokenSnapshot() -> CostUsageTokenSnapshot { + let daily = self.daily.map { bucket in + let modelBreakdowns = bucket.models.map { + CostUsageDailyReport.ModelBreakdown( + modelName: $0.name, + costUSD: nil, + totalTokens: $0.totalTokens, + requestCount: $0.requests) + } + let modelsUsed = bucket.models.map(\.name) + return CostUsageDailyReport.Entry( + date: bucket.day, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cachedInputTokens, + cacheCreationTokens: nil, + totalTokens: bucket.totalTokens, + requestCount: bucket.requests, + costUSD: bucket.costUSD, + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + let latest = self.latestDay + let total = self.last30Days + return CostUsageTokenSnapshot( + sessionTokens: latest.totalTokens, + sessionCostUSD: latest.costUSD, + sessionRequests: latest.requests, + last30DaysTokens: total.totalTokens, + last30DaysCostUSD: total.costUSD, + last30DaysRequests: total.requests, + historyDays: self.historyDays, + daily: daily, + updatedAt: self.updatedAt) + } + + private struct ModelAccumulator { + var requests = 0 + var inputTokens = 0 + var cachedInputTokens = 0 + var outputTokens = 0 + var totalTokens = 0 + + mutating func add(_ model: ModelBreakdown) { + self.requests += model.requests + self.inputTokens += model.inputTokens + self.cachedInputTokens += model.cachedInputTokens + self.outputTokens += model.outputTokens + self.totalTokens += model.totalTokens + } + + func makeModel(name: String) -> ModelBreakdown { + ModelBreakdown( + name: name, + requests: self.requests, + inputTokens: self.inputTokens, + cachedInputTokens: self.cachedInputTokens, + outputTokens: self.outputTokens, + totalTokens: self.totalTokens) + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift index d91261fdb..0532d8cae 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeCookieImporter.swift @@ -37,7 +37,7 @@ public enum OpenCodeCookieImporter { for browserSource in installedBrowsers { do { let query = BrowserCookieQuery(domains: self.cookieDomains) - let sources = try Self.cookieClient.records( + let sources = try Self.cookieClient.codexBarRecords( matching: query, in: browserSource, logger: log) diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift index 18803a46c..eb3d3fb82 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeProviderDescriptor.swift @@ -84,35 +84,14 @@ struct OpenCodeUsageFetchStrategy: ProviderFetchStrategy { } private static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { - if let settings = context.settings?.opencode, settings.cookieSource == .manual { - if let header = CookieHeaderNormalizer.normalize(settings.manualCookieHeader) { - let pairs = CookieHeaderNormalizer.pairs(from: header) - let hasAuthCookie = pairs.contains { pair in - pair.name == "auth" || pair.name == "__Host-auth" - } - if hasAuthCookie { - return header - } - } - throw OpenCodeSettingsError.invalidCookie - } - - #if os(macOS) - if allowCached, - let cached = CookieHeaderCache.load(provider: .opencode), - !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return cached.cookieHeader - } - let session = try OpenCodeCookieImporter.importSession(browserDetection: context.browserDetection) - CookieHeaderCache.store( - provider: .opencode, - cookieHeader: session.cookieHeader, - sourceLabel: session.sourceLabel) - return session.cookieHeader - #else - throw OpenCodeSettingsError.missingCookie - #endif + try OpenCodeWebCookieSupport.resolveCookieHeader( + context: OpenCodeWebCookieSupport.Context( + settings: context.settings?.opencode, + provider: .opencode, + browserDetection: context.browserDetection, + allowCached: allowCached), + invalidCookie: OpenCodeSettingsError.invalidCookie, + missingCookie: OpenCodeSettingsError.missingCookie) } } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index ed27f1d4f..71aa42719 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -62,6 +62,10 @@ public struct OpenCodeUsageFetcher: Sendable { "renewAt", "renew_at", ] + private static let renewAtKeys = [ + "renewAt", + "renew_at", + ] private static func makeISO8601Formatter() -> ISO8601DateFormatter { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -83,25 +87,32 @@ public struct OpenCodeUsageFetcher: Sendable { cookieHeader: String, timeout: TimeInterval, now: Date = Date(), - workspaceIDOverride: String? = nil) async throws -> OpenCodeUsageSnapshot + workspaceIDOverride: String? = nil, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> OpenCodeUsageSnapshot { + guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { + throw OpenCodeUsageError.invalidCredentials + } let workspaceID: String = if let override = self.normalizeWorkspaceID(workspaceIDOverride) { override } else { try await self.fetchWorkspaceID( - cookieHeader: cookieHeader, - timeout: timeout) + cookieHeader: requestCookieHeader, + timeout: timeout, + transport: transport) } let subscriptionText = try await self.fetchSubscriptionInfo( workspaceID: workspaceID, - cookieHeader: cookieHeader, - timeout: timeout) + cookieHeader: requestCookieHeader, + timeout: timeout, + transport: transport) return try self.parseSubscription(text: subscriptionText, now: now) } private static func fetchWorkspaceID( cookieHeader: String, - timeout: TimeInterval) async throws -> String + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> String { let text = try await self.fetchServerText( request: ServerRequest( @@ -110,7 +121,8 @@ public struct OpenCodeUsageFetcher: Sendable { method: "GET", referer: self.baseURL), cookieHeader: cookieHeader, - timeout: timeout) + timeout: timeout, + transport: transport) if self.looksSignedOut(text: text) { throw OpenCodeUsageError.invalidCredentials } @@ -127,7 +139,8 @@ public struct OpenCodeUsageFetcher: Sendable { method: "POST", referer: self.baseURL), cookieHeader: cookieHeader, - timeout: timeout) + timeout: timeout, + transport: transport) if self.looksSignedOut(text: fallback) { throw OpenCodeUsageError.invalidCredentials } @@ -147,7 +160,8 @@ public struct OpenCodeUsageFetcher: Sendable { private static func fetchSubscriptionInfo( workspaceID: String, cookieHeader: String, - timeout: TimeInterval) async throws -> String + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> String { let referer = URL(string: "https://opencode.ai/workspace/\(workspaceID)/billing") ?? self.baseURL let text = try await self.fetchServerText( @@ -157,7 +171,8 @@ public struct OpenCodeUsageFetcher: Sendable { method: "GET", referer: referer), cookieHeader: cookieHeader, - timeout: timeout) + timeout: timeout, + transport: transport) if self.looksSignedOut(text: text) { throw OpenCodeUsageError.invalidCredentials } @@ -178,7 +193,8 @@ public struct OpenCodeUsageFetcher: Sendable { method: "POST", referer: referer), cookieHeader: cookieHeader, - timeout: timeout) + timeout: timeout, + transport: transport) if self.looksSignedOut(text: fallback) { throw OpenCodeUsageError.invalidCredentials } @@ -207,7 +223,7 @@ public struct OpenCodeUsageFetcher: Sendable { private static func missingSubscriptionDataError(workspaceID: String) -> OpenCodeUsageError { OpenCodeUsageError.apiError( "No subscription usage data was returned for workspace \(workspaceID). " + - "This usually means this workspace does not have OpenCode Black usage data.") + "This usually means this workspace does not have OpenCode subscription quota data available.") } private static func normalizeWorkspaceID(_ raw: String?) -> String? { @@ -236,7 +252,8 @@ public struct OpenCodeUsageFetcher: Sendable { private static func fetchServerText( request serverRequest: ServerRequest, cookieHeader: String, - timeout: TimeInterval) async throws -> String + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> String { let url = self.serverRequestURL( serverID: serverRequest.serverID, @@ -260,28 +277,33 @@ public struct OpenCodeUsageFetcher: Sendable { urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") } - let (data, response) = try await URLSession.shared.data(for: urlRequest) - guard let httpResponse = response as? HTTPURLResponse else { + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: urlRequest) + } catch let error as URLError where error.code == .badServerResponse { throw OpenCodeUsageError.networkError("Invalid response") + } catch { + throw error } - guard httpResponse.statusCode == 200 else { - let bodyText = String(data: data, encoding: .utf8) ?? "" - let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? "unknown" - Self.log.error("OpenCode returned \(httpResponse.statusCode) (type=\(contentType) length=\(data.count))") + guard response.statusCode == 200 else { + let bodyText = String(data: response.data, encoding: .utf8) ?? "" + let contentType = response.response.value(forHTTPHeaderField: "Content-Type") ?? "unknown" + Self.log + .error("OpenCode returned \(response.statusCode) (type=\(contentType) length=\(response.data.count))") if self.looksSignedOut(text: bodyText) { throw OpenCodeUsageError.invalidCredentials } - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + if response.statusCode == 401 || response.statusCode == 403 { throw OpenCodeUsageError.invalidCredentials } if let message = self.extractServerErrorMessage(from: bodyText) { - throw OpenCodeUsageError.apiError("HTTP \(httpResponse.statusCode): \(message)") + throw OpenCodeUsageError.apiError("HTTP \(response.statusCode): \(message)") } - throw OpenCodeUsageError.apiError("HTTP \(httpResponse.statusCode)") + throw OpenCodeUsageError.apiError("HTTP \(response.statusCode)") } - guard let text = String(data: data, encoding: .utf8) else { + guard let text = String(data: response.data, encoding: .utf8) else { throw OpenCodeUsageError.parseFailed("Response was not UTF-8.") } return text @@ -428,7 +450,12 @@ public struct OpenCodeUsageFetcher: Sendable { private static func looksSignedOut(text: String) -> Bool { let lower = text.lowercased() - if lower.contains("login") || lower.contains("sign in") || lower.contains("auth/authorize") { + if lower.contains("login") || + lower.contains("sign in") || + lower.contains("auth/authorize") || + lower.contains("not associated with an account") || + lower.contains("actor of type \"public\"") + { return true } return false @@ -479,24 +506,33 @@ public struct OpenCodeUsageFetcher: Sendable { private static func parseUsageJSON(object: Any, now: Date) -> OpenCodeUsageSnapshot? { guard let dict = object as? [String: Any] else { return nil } - if let snapshot = self.parseUsageDictionary(dict, now: now) { + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) + if let snapshot = self.parseUsageDictionary(dict, now: now, inheritedRenewsAt: renewsAt) { return snapshot } for key in ["data", "result", "usage", "billing", "payload"] { if let nested = dict[key] as? [String: Any], - let snapshot = self.parseUsageDictionary(nested, now: now) + let snapshot = self.parseUsageDictionary(nested, now: now, inheritedRenewsAt: renewsAt) { return snapshot } } - return self.parseUsageNested(dict, now: now, depth: 0) + if let snapshot = self.parseUsageNested(dict, now: now, depth: 0, inheritedRenewsAt: renewsAt) { + return snapshot + } + return self.parseUsageFromCandidates(object: object, now: now, inheritedRenewsAt: renewsAt) } - private static func parseUsageDictionary(_ dict: [String: Any], now: Date) -> OpenCodeUsageSnapshot? { + private static func parseUsageDictionary( + _ dict: [String: Any], + now: Date, + inheritedRenewsAt: Date?) -> OpenCodeUsageSnapshot? + { + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) ?? inheritedRenewsAt if let usage = dict["usage"] as? [String: Any], - let snapshot = self.parseUsageDictionary(usage, now: now) + let snapshot = self.parseUsageDictionary(usage, now: now, inheritedRenewsAt: renewsAt) { return snapshot } @@ -508,14 +544,20 @@ public struct OpenCodeUsageFetcher: Sendable { let weekly = weeklyKeys.compactMap { dict[$0] as? [String: Any] }.first if let rolling, let weekly { - return self.buildSnapshot(rolling: rolling, weekly: weekly, now: now) + return self.buildSnapshot(rolling: rolling, weekly: weekly, now: now, renewsAt: renewsAt) } return nil } - private static func parseUsageNested(_ dict: [String: Any], now: Date, depth: Int) -> OpenCodeUsageSnapshot? { + private static func parseUsageNested( + _ dict: [String: Any], + now: Date, + depth: Int, + inheritedRenewsAt: Date?) -> OpenCodeUsageSnapshot? + { if depth > 3 { return nil } + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) ?? inheritedRenewsAt var rolling: [String: Any]? var weekly: [String: Any]? @@ -529,15 +571,18 @@ public struct OpenCodeUsageFetcher: Sendable { } } - if let rolling, let weekly, - let snapshot = self.buildSnapshot(rolling: rolling, weekly: weekly, now: now) - { - return snapshot + if let rolling, let weekly { + let snapshot = self.buildSnapshot(rolling: rolling, weekly: weekly, now: now, renewsAt: renewsAt) + if let snapshot { return snapshot } } for value in dict.values { if let sub = value as? [String: Any], - let snapshot = self.parseUsageNested(sub, now: now, depth: depth + 1) + let snapshot = self.parseUsageNested( + sub, + now: now, + depth: depth + 1, + inheritedRenewsAt: renewsAt) { return snapshot } @@ -546,7 +591,11 @@ public struct OpenCodeUsageFetcher: Sendable { return nil } - private static func parseUsageFromCandidates(object: Any, now: Date) -> OpenCodeUsageSnapshot? { + private static func parseUsageFromCandidates( + object: Any, + now: Date, + inheritedRenewsAt: Date? = nil) -> OpenCodeUsageSnapshot? + { let candidates = self.collectWindowCandidates(object: object, now: now) guard !candidates.isEmpty else { return nil } @@ -573,15 +622,18 @@ public struct OpenCodeUsageFetcher: Sendable { guard let rolling, let weekly else { return nil } + let renewsAt = self.dateValue(from: self.value(from: object as? [String: Any] ?? [:], keys: self.renewAtKeys)) + ?? inheritedRenewsAt return OpenCodeUsageSnapshot( rollingUsagePercent: rolling.percent, weeklyUsagePercent: weekly.percent, rollingResetInSec: rolling.resetInSec, weeklyResetInSec: weekly.resetInSec, + renewsAt: renewsAt, updatedAt: now) } - private struct WindowCandidate: Sendable { + private struct WindowCandidate { let id: UUID let percent: Double let resetInSec: Int @@ -656,7 +708,8 @@ public struct OpenCodeUsageFetcher: Sendable { private static func buildSnapshot( rolling: [String: Any], weekly: [String: Any], - now: Date) -> OpenCodeUsageSnapshot? + now: Date, + renewsAt: Date? = nil) -> OpenCodeUsageSnapshot? { guard let rollingWindow = self.parseWindow(rolling, now: now), let weeklyWindow = self.parseWindow(weekly, now: now) @@ -669,6 +722,7 @@ public struct OpenCodeUsageFetcher: Sendable { weeklyUsagePercent: weeklyWindow.percent, rollingResetInSec: rollingWindow.resetInSec, weeklyResetInSec: weeklyWindow.resetInSec, + renewsAt: renewsAt, updatedAt: now) } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift index aa1d87f28..672812525 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageSnapshot.swift @@ -5,6 +5,7 @@ public struct OpenCodeUsageSnapshot: Sendable { public let weeklyUsagePercent: Double public let rollingResetInSec: Int public let weeklyResetInSec: Int + public let renewsAt: Date? public let updatedAt: Date public init( @@ -12,12 +13,14 @@ public struct OpenCodeUsageSnapshot: Sendable { weeklyUsagePercent: Double, rollingResetInSec: Int, weeklyResetInSec: Int, + renewsAt: Date? = nil, updatedAt: Date) { self.rollingUsagePercent = rollingUsagePercent self.weeklyUsagePercent = weeklyUsagePercent self.rollingResetInSec = rollingResetInSec self.weeklyResetInSec = weeklyResetInSec + self.renewsAt = renewsAt self.updatedAt = updatedAt } @@ -36,9 +39,20 @@ public struct OpenCodeUsageSnapshot: Sendable { resetsAt: weeklyReset, resetDescription: nil) + var extraWindows: [NamedRateWindow]? + if let renewsAt = self.renewsAt { + let renewalWindow = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: renewsAt, + resetDescription: nil) + extraWindows = [NamedRateWindow(id: "renewal", title: "Renews", window: renewalWindow)] + } + return UsageSnapshot( primary: primary, secondary: secondary, + extraRateWindows: extraWindows, updatedAt: self.updatedAt, identity: nil) } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift new file mode 100644 index 000000000..5d239b08d --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeWebCookieSupport.swift @@ -0,0 +1,62 @@ +import Foundation + +enum OpenCodeWebCookieSupport { + private static let requestCookieNames: Set = ["auth", "__Host-auth"] + + struct Context { + let settings: ProviderSettingsSnapshot.OpenCodeProviderSettings? + let provider: UsageProvider + let browserDetection: BrowserDetection + let allowCached: Bool + } + + static func requestCookieHeader(from rawHeader: String?) -> String? { + CookieHeaderNormalizer.filteredHeader(from: rawHeader, allowedNames: self.requestCookieNames) + } + + static func resolveCookieHeader( + context: Context, + invalidCookie: @autoclosure () -> Error, + missingCookie: @autoclosure () -> Error) throws -> String + { + if let settings = context.settings, settings.cookieSource == .manual { + if let header = self.requestCookieHeader(from: settings.manualCookieHeader) { + return header + } + throw invalidCookie() + } + + #if os(macOS) + if context.allowCached, + let cached = CookieHeaderCache.load(provider: context.provider), + let header = self.requestCookieHeader(from: cached.cookieHeader) + { + return header + } + let session = try OpenCodeCookieImporter.importSession( + browserDetection: context.browserDetection, + preferredBrowsers: self.automaticImportOrder(provider: context.provider)) + guard let header = self.requestCookieHeader(from: session.cookieHeader) else { + throw missingCookie() + } + CookieHeaderCache.store( + provider: context.provider, + cookieHeader: header, + sourceLabel: session.sourceLabel) + return header + #else + throw missingCookie() + #endif + } + + #if os(macOS) + static func automaticImportOrder(provider: UsageProvider) -> BrowserCookieImportOrder { + if provider == .opencodego, + let order = ProviderDefaults.metadata[provider]?.browserCookieOrder + { + return order + } + return [.chrome] + } + #endif +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift new file mode 100644 index 000000000..13098a445 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift @@ -0,0 +1,320 @@ +import Foundation + +#if os(macOS) +import SQLite3 + +public enum OpenCodeGoLocalUsageError: LocalizedError, Sendable, Equatable { + case notDetected + case historyUnavailable(String) + case sqliteFailed(String) + + public var errorDescription: String? { + switch self { + case .notDetected: + "OpenCode Go not detected. Log in with OpenCode Go or use it locally first." + case let .historyUnavailable(message): + "OpenCode Go local usage history is unavailable: \(message)" + case let .sqliteFailed(message): + "SQLite error reading OpenCode Go usage: \(message)" + } + } +} + +public struct OpenCodeGoLocalUsageReader: Sendable { + private static let fiveHours: TimeInterval = 5 * 60 * 60 + private static let week: TimeInterval = 7 * 24 * 60 * 60 + private static let limits = (session: 12.0, weekly: 30.0, monthly: 60.0) + + private let authURL: URL + private let databaseURL: URL + + public init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { + let openCodeDirectory = homeDirectory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + self.authURL = openCodeDirectory.appendingPathComponent("auth.json", isDirectory: false) + self.databaseURL = openCodeDirectory.appendingPathComponent("opencode.db", isDirectory: false) + } + + public init(authURL: URL, databaseURL: URL) { + self.authURL = authURL + self.databaseURL = databaseURL + } + + public func fetch(now: Date = Date()) throws -> OpenCodeGoUsageSnapshot { + let hasAuth = Self.hasAuthKey(at: self.authURL) + guard FileManager.default.fileExists(atPath: self.databaseURL.path) else { + if hasAuth { + throw OpenCodeGoLocalUsageError.historyUnavailable("database not found") + } + throw OpenCodeGoLocalUsageError.notDetected + } + + let rows = try self.readRows() + guard hasAuth || !rows.isEmpty else { + throw OpenCodeGoLocalUsageError.notDetected + } + guard !rows.isEmpty else { + throw OpenCodeGoLocalUsageError.historyUnavailable("no local usage rows") + } + return Self.snapshot(rows: rows, now: now) + } + + private func readRows() throws -> [UsageRow] { + var db: OpaquePointer? + guard sqlite3_open_v2(self.databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + sqlite3_close(db) + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let sql = self.hasTable(named: "part", db: db) ? Self.messageAndPartUsageSQL : Self.messageUsageSQL + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + defer { sqlite3_finalize(stmt) } + + var rows: [UsageRow] = [] + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + + let createdMs = sqlite3_column_int64(stmt, 0) + let cost = sqlite3_column_double(stmt, 1) + guard createdMs > 0, cost >= 0, cost.isFinite else { continue } + rows.append(UsageRow(createdMs: createdMs, cost: cost)) + } + return rows + } + + private func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static let messageUsageSQL = """ + SELECT + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + """ + + private static let messageAndPartUsageSQL = """ + WITH message_costs AS ( + SELECT + id AS messageID, + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + ) + SELECT createdMs, cost + FROM message_costs + UNION ALL + SELECT + CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.time_created) AS INTEGER) + AS createdMs, + CAST(json_extract(p.data, '$.cost') AS REAL) AS cost + FROM part p + JOIN message m ON m.id = p.message_id + WHERE json_valid(p.data) + AND json_valid(m.data) + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.cost') IN ('integer', 'real') + AND json_extract(m.data, '$.providerID') = 'opencode-go' + AND json_extract(m.data, '$.role') = 'assistant' + AND NOT EXISTS ( + SELECT 1 + FROM message_costs + WHERE message_costs.messageID = p.message_id + ) + """ + + private struct UsageRow { + let createdMs: Int64 + let cost: Double + } + + private static func hasAuthKey(at url: URL) -> Bool { + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let entry = object["opencode-go"] as? [String: Any], + let key = entry["key"] as? String + else { + return false + } + return !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private static func snapshot(rows: [UsageRow], now: Date) -> OpenCodeGoUsageSnapshot { + let nowMs = Int64(now.timeIntervalSince1970 * 1000) + let sessionStart = nowMs - Int64(Self.fiveHours * 1000) + let weekStart = self.startOfUTCWeek(now: now).timeIntervalSince1970 * 1000 + let weekStartMs = Int64(weekStart) + let weekEndMs = weekStartMs + Int64(Self.week * 1000) + let earliestMs = rows.map(\.createdMs).min() + let monthBounds = self.monthBounds(now: now, anchorMs: earliestMs) + + let sessionCost = self.sum(rows: rows, startMs: sessionStart, endMs: nowMs) + let weeklyCost = self.sum(rows: rows, startMs: weekStartMs, endMs: weekEndMs) + let monthlyCost = self.sum(rows: rows, startMs: monthBounds.startMs, endMs: monthBounds.endMs) + + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: self.percent(used: sessionCost, limit: self.limits.session), + weeklyUsagePercent: self.percent(used: weeklyCost, limit: self.limits.weekly), + monthlyUsagePercent: self.percent(used: monthlyCost, limit: self.limits.monthly), + rollingResetInSec: self.rollingReset(rows: rows, nowMs: nowMs), + weeklyResetInSec: max(0, Int((weekEndMs - nowMs) / 1000)), + monthlyResetInSec: max(0, Int((monthBounds.endMs - nowMs) / 1000)), + updatedAt: now) + } + + private static func sum(rows: [UsageRow], startMs: Int64, endMs: Int64) -> Double { + rows.reduce(0) { total, row in + guard row.createdMs >= startMs, row.createdMs < endMs else { return total } + return total + row.cost + } + } + + private static func percent(used: Double, limit: Double) -> Double { + guard used.isFinite, limit > 0 else { return 0 } + let value = max(0, min(100, used / limit * 100)) + return (value * 10).rounded() / 10 + } + + private static func rollingReset(rows: [UsageRow], nowMs: Int64) -> Int { + let sessionStart = nowMs - Int64(Self.fiveHours * 1000) + let oldest = rows + .filter { $0.createdMs >= sessionStart && $0.createdMs < nowMs } + .map(\.createdMs) + .min() ?? nowMs + return max(0, Int((oldest + Int64(Self.fiveHours * 1000) - nowMs) / 1000)) + } + + private static func startOfUTCWeek(now: Date) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current + calendar.firstWeekday = 2 + calendar.minimumDaysInFirstWeek = 4 + let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now) + return calendar.date(from: components) ?? now + } + + private static func monthBounds(now: Date, anchorMs: Int64?) -> (startMs: Int64, endMs: Int64) { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current + + guard let anchorMs else { + let start = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) ?? now + let end = calendar.date(byAdding: .month, value: 1, to: start) ?? start + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + + let anchor = Date(timeIntervalSince1970: TimeInterval(anchorMs) / 1000) + let anchorComponents = calendar.dateComponents([.day, .hour, .minute, .second, .nanosecond], from: anchor) + let nowComponents = calendar.dateComponents([.year, .month], from: now) + + var startMonthComponents = nowComponents + var start = self.anchoredMonth(calendar: calendar, month: startMonthComponents, anchor: anchorComponents) + if start > now { + guard let previous = calendar.date(byAdding: .month, value: -1, to: start) else { + let end = self.anchoredMonth( + calendar: calendar, + month: self.monthComponents(after: startMonthComponents, calendar: calendar), + anchor: anchorComponents) + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + startMonthComponents = calendar.dateComponents([.year, .month], from: previous) + start = self.anchoredMonth(calendar: calendar, month: startMonthComponents, anchor: anchorComponents) + } + let end = self.anchoredMonth( + calendar: calendar, + month: self.monthComponents(after: startMonthComponents, calendar: calendar), + anchor: anchorComponents) + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + + private static func monthComponents(after month: DateComponents, calendar: Calendar) -> DateComponents { + let monthStart = calendar.date(from: month) ?? Date() + let nextMonth = calendar.date(byAdding: .month, value: 1, to: monthStart) ?? monthStart + return calendar.dateComponents([.year, .month], from: nextMonth) + } + + private static func anchoredMonth( + calendar: Calendar, + month: DateComponents, + anchor: DateComponents) -> Date + { + var components = DateComponents() + components.calendar = calendar + components.timeZone = calendar.timeZone + components.year = month.year + components.month = month.month + components.day = anchor.day + components.hour = anchor.hour + components.minute = anchor.minute + components.second = anchor.second + components.nanosecond = anchor.nanosecond + + if let date = calendar.date(from: components), + calendar.component(.month, from: date) == month.month + { + return date + } + + components.day = calendar.range(of: .day, in: .month, for: calendar.date(from: month) ?? Date())?.count + return calendar.date(from: components) ?? Date() + } +} + +#else + +public enum OpenCodeGoLocalUsageError: LocalizedError, Sendable, Equatable { + case notSupported + + public var errorDescription: String? { + "OpenCode Go local usage is only supported on macOS." + } +} + +public struct OpenCodeGoLocalUsageReader: Sendable { + public init(homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser) {} + public init(authURL _: URL, databaseURL _: URL) {} + + public func fetch(now _: Date = Date()) throws -> OpenCodeGoUsageSnapshot { + throw OpenCodeGoLocalUsageError.notSupported + } +} + +#endif diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift new file mode 100644 index 000000000..51b2ac417 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -0,0 +1,193 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum OpenCodeGoProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .opencodego, + metadata: ProviderMetadata( + id: .opencodego, + displayName: "OpenCode Go", + sessionLabel: "5-hour", + weeklyLabel: "Weekly", + opusLabel: "Monthly", + supportsOpus: true, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show OpenCode Go usage", + cliName: "opencodego", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://opencode.ai", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .opencodego, + iconResourceName: "ProviderIcon-opencodego", + color: ProviderColor(red: 59 / 255, green: 130 / 255, blue: 246 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "OpenCode Go cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), + cli: ProviderCLIConfig( + name: "opencodego", + versionDetector: nil)) + } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + if context.sourceMode == .web { + return [OpenCodeGoUsageFetchStrategy()] + } + return [ + OpenCodeGoUsageFetchStrategy(), + OpenCodeGoLocalUsageFetchStrategy(), + ] + } +} + +struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { + let id: String = "opencodego.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.snapshot(context: context) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "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() + guard context.includeOptionalUsage, + context.settings?.opencodego?.cookieSource != .off + else { + return snapshot + } + + guard let cookieHeader = Self.cachedOrManualCookieHeader(context: context) else { + return snapshot + } + + let workspaceOverride = context.settings?.opencodego?.workspaceID + ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] + let zenBalanceTask = Task { + do { + return try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance( + cookieHeader: cookieHeader, + timeout: context.webTimeout, + workspaceIDOverride: workspaceOverride) + } catch is CancellationError { + throw CancellationError() + } catch { + return nil + } + } + let zenBalance = try await OpenCodeGoUsageFetcher.completedOptionalZenBalance(from: zenBalanceTask) + return snapshot.withZenBalanceUSD(zenBalance) + } + + private static func cachedOrManualCookieHeader(context: ProviderFetchContext) -> String? { + if let settings = context.settings?.opencodego, settings.cookieSource == .manual { + return OpenCodeWebCookieSupport.requestCookieHeader(from: settings.manualCookieHeader) + } + + #if os(macOS) + guard let cached = CookieHeaderCache.load(provider: .opencodego) else { return nil } + return OpenCodeWebCookieSupport.requestCookieHeader(from: cached.cookieHeader) + #else + return nil + #endif + } +} + +struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { + let id: String = "opencodego.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.opencodego?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let workspaceOverride = context.settings?.opencodego?.workspaceID + ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] + let cookieSource = context.settings?.opencodego?.cookieSource ?? .auto + do { + let cookieHeader = try Self.resolveCookieHeader(context: context, allowCached: true) + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + timeout: context.webTimeout, + workspaceIDOverride: workspaceOverride, + includeZenBalance: context.includeOptionalUsage) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } catch OpenCodeGoUsageError.invalidCredentials where cookieSource != .manual { + #if os(macOS) + CookieHeaderCache.clear(provider: .opencodego) + let cookieHeader = try Self.resolveCookieHeader(context: context, allowCached: false) + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + timeout: context.webTimeout, + workspaceIDOverride: workspaceOverride, + includeZenBalance: context.includeOptionalUsage) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + #else + throw OpenCodeGoUsageError.invalidCredentials + #endif + } + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + guard context.sourceMode == .auto else { return false } + return switch error { + case OpenCodeGoSettingsError.missingCookie, + OpenCodeGoSettingsError.invalidCookie, + OpenCodeGoUsageError.invalidCredentials: + true + default: + false + } + } + + static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { + try OpenCodeWebCookieSupport.resolveCookieHeader( + context: OpenCodeWebCookieSupport.Context( + settings: context.settings?.opencodego, + provider: .opencodego, + browserDetection: context.browserDetection, + allowCached: allowCached), + invalidCookie: OpenCodeGoSettingsError.invalidCookie, + missingCookie: OpenCodeGoSettingsError.missingCookie) + } +} + +enum OpenCodeGoSettingsError: LocalizedError { + case missingCookie + case invalidCookie + + var errorDescription: String? { + switch self { + case .missingCookie: + "No OpenCode Go session cookies found in browsers." + case .invalidCookie: + "OpenCode Go cookie header is invalid." + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift new file mode 100644 index 000000000..70e18ec04 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -0,0 +1,909 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum OpenCodeGoUsageError: LocalizedError { + case invalidCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidCredentials: + "OpenCode Go session cookie is invalid or expired." + case let .networkError(message): + "OpenCode Go network error: \(message)" + case let .apiError(message): + "OpenCode Go API error: \(message)" + case let .parseFailed(message): + "OpenCode Go parse error: \(message)" + } + } +} + +public struct OpenCodeGoUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.opencodeGoUsage) + private static let baseURL = URL(string: "https://opencode.ai")! + private static let serverURL = URL(string: "https://opencode.ai/_server")! + private static let workspacesServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f" + + private static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + + private final class RedirectGuardDelegate: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) + { + guard OpenCodeGoUsageFetcher.allowsRedirect( + from: task.originalRequest?.url, + to: request.url) + else { + completionHandler(nil) + return + } + completionHandler(request) + } + } + + private struct ServerRequest { + let serverID: String + let args: String? + let method: String + let referer: URL + } + + private static let percentKeys = [ + "usagePercent", + "usedPercent", + "percentUsed", + "percent", + "usage_percent", + "used_percent", + "utilization", + "utilizationPercent", + "utilization_percent", + "usage", + ] + private static let resetInKeys = [ + "resetInSec", + "resetInSeconds", + "resetSeconds", + "reset_sec", + "reset_in_sec", + "resetsInSec", + "resetsInSeconds", + "resetIn", + "resetSec", + ] + private static let resetAtKeys = [ + "resetAt", + "resetsAt", + "reset_at", + "resets_at", + "nextReset", + "next_reset", + "renewAt", + "renew_at", + ] + private static let renewAtKeys = [ + "renewAt", + "renew_at", + ] + private static let redirectGuardDelegate = RedirectGuardDelegate() + private static let redirectGuardSession: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + return URLSession( + configuration: configuration, + delegate: OpenCodeGoUsageFetcher.redirectGuardDelegate, + delegateQueue: nil) + }() + + public static func fetchUsage( + cookieHeader: String, + timeout: TimeInterval, + now: Date = Date(), + workspaceIDOverride: String? = nil, + includeZenBalance: Bool = true, + session: URLSession? = nil) async throws -> OpenCodeGoUsageSnapshot + { + let session = session ?? self.redirectGuardSession + guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { + throw OpenCodeGoUsageError.invalidCredentials + } + let workspaceID: String = if let override = self.normalizeWorkspaceID(workspaceIDOverride) { + override + } else { + try await self.fetchWorkspaceID( + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + } + let subscriptionText: String + do { + subscriptionText = try await self.fetchUsagePage( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + } catch { + throw error + } + let snapshot = try self.parseSubscription(text: subscriptionText, now: now) + let zenBalanceTask = includeZenBalance ? Task { + try await self.fetchOptionalZenBalance( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: min(timeout, self.optionalZenBalanceTimeout), + session: session) + } : nil + guard let zenBalanceTask else { + return snapshot + } + let zenBalance = try await self.completedOptionalZenBalance(from: zenBalanceTask) + return snapshot.withZenBalanceUSD(zenBalance) + } + + static func fetchOptionalZenBalance( + cookieHeader: String, + timeout: TimeInterval, + workspaceIDOverride: String? = nil, + session: URLSession? = nil) async throws -> Double? + { + let session = session ?? self.redirectGuardSession + guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { + throw OpenCodeGoUsageError.invalidCredentials + } + let workspaceID: String = if let override = self.normalizeWorkspaceID(workspaceIDOverride) { + override + } else { + try await self.fetchWorkspaceID( + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + } + return try await self.fetchOptionalZenBalance( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: min(timeout, self.optionalZenBalanceTimeout), + session: session) + } + + static func allowsRedirect(from sourceURL: URL?, to destinationURL: URL?) -> Bool { + guard let sourceHost = sourceURL?.host?.lowercased(), + let destinationHost = destinationURL?.host?.lowercased(), + sourceHost == destinationHost, + destinationURL?.scheme?.lowercased() == "https" + else { return false } + return true + } + + public static func dashboardURL(workspaceID raw: String?) -> URL { + guard let workspaceID = self.normalizeWorkspaceID(raw), + let url = URL(string: "\(self.baseURL.absoluteString)/workspace/\(workspaceID)/go") + else { + return self.baseURL + } + return url + } +} + +extension OpenCodeGoUsageFetcher { + private static func fetchWorkspaceID( + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> String + { + let text = try await self.fetchServerText( + request: ServerRequest( + serverID: self.workspacesServerID, + args: nil, + method: "GET", + referer: self.baseURL), + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + if self.looksSignedOut(text: text) { + throw OpenCodeGoUsageError.invalidCredentials + } + var ids = self.parseWorkspaceIDs(text: text) + if ids.isEmpty { + ids = self.parseWorkspaceIDsFromJSON(text: text) + } + if ids.isEmpty { + Self.log.error("OpenCode Go workspace ids missing after GET; retrying with POST.") + let fallback = try await self.fetchServerText( + request: ServerRequest( + serverID: self.workspacesServerID, + args: "[]", + method: "POST", + referer: self.baseURL), + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + if self.looksSignedOut(text: fallback) { + throw OpenCodeGoUsageError.invalidCredentials + } + ids = self.parseWorkspaceIDs(text: fallback) + if ids.isEmpty { + ids = self.parseWorkspaceIDsFromJSON(text: fallback) + } + if ids.isEmpty { + throw OpenCodeGoUsageError.parseFailed("Missing workspace id.") + } + return ids[0] + } + return ids[0] + } + + static func normalizeWorkspaceID(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("wrk_"), trimmed.count > 4 { + return trimmed + } + if let url = URL(string: trimmed) { + let parts = url.pathComponents + if let index = parts.firstIndex(of: "workspace"), + parts.count > index + 1 + { + let candidate = parts[index + 1] + if candidate.hasPrefix("wrk_"), candidate.count > 4 { + return candidate + } + } + } + if let match = trimmed.range(of: #"wrk_[A-Za-z0-9]+"#, options: .regularExpression) { + return String(trimmed[match]) + } + return nil + } + + static func parseWorkspaceIDs(text: String) -> [String] { + let pattern = #"id\s*:\s*\"(wrk_[^\"]+)\""# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] } + let nsrange = NSRange(text.startIndex.. [String] { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + return [] + } + var results: [String] = [] + self.collectWorkspaceIDs(object: object, out: &results) + return results + } + + private static func collectWorkspaceIDs(object: Any, out: inout [String]) { + if let dict = object as? [String: Any] { + for (_, value) in dict { + self.collectWorkspaceIDs(object: value, out: &out) + } + return + } + if let array = object as? [Any] { + for value in array { + self.collectWorkspaceIDs(object: value, out: &out) + } + return + } + if let string = object as? String, + string.hasPrefix("wrk_"), + !out.contains(string) + { + out.append(string) + } + } + + private static func fetchUsagePage( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> String + { + let url = URL(string: "https://opencode.ai/workspace/\(workspaceID)/go") ?? self.baseURL + let text = try await self.fetchPageText( + url: url, + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + if self.looksSignedOut(text: text) { + throw OpenCodeGoUsageError.invalidCredentials + } + guard self.parseSubscriptionJSON(text: text, now: Date()) != nil || + self.extractDouble( + pattern: #"rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, + text: text) != nil + else { + Self.log.error("OpenCode Go usage page payload missing usage fields.") + throw OpenCodeGoUsageError.parseFailed("Missing usage fields.") + } + return text + } + + static func parseSubscription(text: String, now: Date) throws -> OpenCodeGoUsageSnapshot { + if let snapshot = self.parseSubscriptionJSON(text: text, now: now) { + return snapshot + } + + guard let rollingPercent = self.extractDouble( + pattern: #"rollingUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, + text: text), + let rollingReset = self.extractInt( + pattern: #"rollingUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, + text: text), + let weeklyPercent = self.extractDouble( + pattern: #"weeklyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, + text: text), + let weeklyReset = self.extractInt( + pattern: #"weeklyUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, + text: text) + else { + throw OpenCodeGoUsageError.parseFailed("Missing usage fields.") + } + + let monthlyPercent = self.extractDouble( + pattern: #"monthlyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#, + text: text) + let monthlyReset = self.extractInt( + pattern: #"monthlyUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#, + text: text) + + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: monthlyPercent != nil || monthlyReset != nil, + rollingUsagePercent: rollingPercent, + weeklyUsagePercent: weeklyPercent, + monthlyUsagePercent: monthlyPercent ?? 0, + rollingResetInSec: rollingReset, + weeklyResetInSec: weeklyReset, + monthlyResetInSec: monthlyReset ?? 0, + updatedAt: now) + } + + private static func parseSubscriptionJSON(text: String, now: Date) -> OpenCodeGoUsageSnapshot? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []), + let dict = object as? [String: Any] + else { + return nil + } + + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) + if let snapshot = self.parseUsageDictionary(dict, now: now, inheritedRenewsAt: renewsAt) { + return snapshot + } + for key in ["data", "result", "usage", "billing", "payload"] { + if let nested = dict[key] as? [String: Any], + let snapshot = self.parseUsageDictionary(nested, now: now, inheritedRenewsAt: renewsAt) + { + return snapshot + } + } + if let snapshot = self.parseUsageNested(dict, now: now, depth: 0, inheritedRenewsAt: renewsAt) { + return snapshot + } + return self.parseUsageFromCandidates(object: object, now: now, inheritedRenewsAt: renewsAt) + } + + private static func parseUsageDictionary( + _ dict: [String: Any], + now: Date, + inheritedRenewsAt: Date?) -> OpenCodeGoUsageSnapshot? + { + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) ?? inheritedRenewsAt + if let usage = dict["usage"] as? [String: Any], + let snapshot = self.parseUsageDictionary(usage, now: now, inheritedRenewsAt: renewsAt) + { + return snapshot + } + + let rollingKeys = ["rollingUsage", "rolling", "rolling_usage", "rollingWindow", "rolling_window"] + let weeklyKeys = ["weeklyUsage", "weekly", "weekly_usage", "weeklyWindow", "weekly_window"] + let monthlyKeys = ["monthlyUsage", "monthly", "monthly_usage", "monthlyWindow", "monthly_window"] + + let rolling = self.firstDict(from: dict, keys: rollingKeys) + let weekly = self.firstDict(from: dict, keys: weeklyKeys) + let monthly = self.firstDict(from: dict, keys: monthlyKeys) + + guard let rolling, let weekly else { return nil } + + return self.buildSnapshot(rolling: rolling, weekly: weekly, monthly: monthly, now: now, renewsAt: renewsAt) + } + + private static func parseUsageNested( + _ dict: [String: Any], + now: Date, + depth: Int, + inheritedRenewsAt: Date?) -> OpenCodeGoUsageSnapshot? + { + if depth > 3 { return nil } + let renewsAt = self.dateValue(from: self.value(from: dict, keys: self.renewAtKeys)) ?? inheritedRenewsAt + var rolling: [String: Any]? + var weekly: [String: Any]? + var monthly: [String: Any]? + + for (key, value) in dict { + guard let sub = value as? [String: Any] else { continue } + let lower = key.lowercased() + if lower.contains("rolling") || lower.contains("hour") || lower.contains("5h") || lower.contains("5-hour") { + rolling = sub + } else if lower.contains("weekly") || lower.contains("week") { + weekly = sub + } else if lower.contains("monthly") || lower.contains("month") { + monthly = sub + } + } + + if let rolling, let weekly { + let snapshot = self.buildSnapshot( + rolling: rolling, + weekly: weekly, + monthly: monthly, + now: now, + renewsAt: renewsAt) + if let snapshot { return snapshot } + } + + for value in dict.values { + if let sub = value as? [String: Any], + let snapshot = self.parseUsageNested( + sub, + now: now, + depth: depth + 1, + inheritedRenewsAt: renewsAt) + { + return snapshot + } + } + + return nil + } + + private static func parseUsageFromCandidates( + object: Any, + now: Date, + inheritedRenewsAt: Date? = nil) -> OpenCodeGoUsageSnapshot? + { + let candidates = self.collectWindowCandidates(object: object, now: now) + guard !candidates.isEmpty else { return nil } + + let rollingCandidates = candidates.filter { candidate in + candidate.pathLower.contains("rolling") || + candidate.pathLower.contains("hour") || + candidate.pathLower.contains("5h") || + candidate.pathLower.contains("5-hour") + } + let weeklyCandidates = candidates.filter { candidate in + candidate.pathLower.contains("weekly") || + candidate.pathLower.contains("week") + } + let monthlyCandidates = candidates.filter { candidate in + candidate.pathLower.contains("monthly") || + candidate.pathLower.contains("month") + } + + let rolling = self.pickCandidate( + preferred: rollingCandidates, + fallback: candidates, + pickShorter: true) + let weekly = self.pickCandidate( + from: weeklyCandidates.filter { candidate in + candidate.id != rolling?.id + }, + pickShorter: false) + let monthly = self.pickCandidate( + from: monthlyCandidates.filter { candidate in + candidate.id != rolling?.id && candidate.id != weekly?.id + }, + pickShorter: false) + + guard let rolling, let weekly else { return nil } + + let renewsAt = self.dateValue(from: self.value(from: object as? [String: Any] ?? [:], keys: self.renewAtKeys)) + ?? inheritedRenewsAt + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: monthly != nil, + rollingUsagePercent: rolling.percent, + weeklyUsagePercent: weekly.percent, + monthlyUsagePercent: monthly?.percent ?? 0, + rollingResetInSec: rolling.resetInSec, + weeklyResetInSec: weekly.resetInSec, + monthlyResetInSec: monthly?.resetInSec ?? 0, + renewsAt: renewsAt, + updatedAt: now) + } + + private struct WindowCandidate { + let id: UUID + let percent: Double + let resetInSec: Int + let pathLower: String + } + + private static func collectWindowCandidates(object: Any, now: Date) -> [WindowCandidate] { + var candidates: [WindowCandidate] = [] + self.collectWindowCandidates(object: object, now: now, path: [], out: &candidates) + return candidates + } + + private static func collectWindowCandidates( + object: Any, + now: Date, + path: [String], + out: inout [WindowCandidate]) + { + if let dict = object as? [String: Any] { + if let window = self.parseWindow(dict, now: now) { + let pathLower = path.joined(separator: ".").lowercased() + out.append(WindowCandidate( + id: UUID(), + percent: window.percent, + resetInSec: window.resetInSec, + pathLower: pathLower)) + } + for (key, value) in dict { + self.collectWindowCandidates(object: value, now: now, path: path + [key], out: &out) + } + return + } + + if let array = object as? [Any] { + for (index, value) in array.enumerated() { + self.collectWindowCandidates( + object: value, + now: now, + path: path + ["[\(index)]"], + out: &out) + } + } + } + + private static func pickCandidate( + preferred: [WindowCandidate], + fallback: [WindowCandidate], + pickShorter: Bool, + excluding excluded: UUID? = nil) -> WindowCandidate? + { + let filteredPreferred = preferred.filter { $0.id != excluded } + if let picked = self.pickCandidate(from: filteredPreferred, pickShorter: pickShorter) { + return picked + } + let filteredFallback = fallback.filter { $0.id != excluded } + return self.pickCandidate(from: filteredFallback, pickShorter: pickShorter) + } + + private static func pickCandidate(from candidates: [WindowCandidate], pickShorter: Bool) -> WindowCandidate? { + guard !candidates.isEmpty else { return nil } + let comparator: (WindowCandidate, WindowCandidate) -> Bool = { lhs, rhs in + if pickShorter { + if lhs.resetInSec == rhs.resetInSec { return lhs.percent > rhs.percent } + return lhs.resetInSec < rhs.resetInSec + } + if lhs.resetInSec == rhs.resetInSec { return lhs.percent > rhs.percent } + return lhs.resetInSec > rhs.resetInSec + } + return candidates.min(by: comparator) + } + + private static func firstDict(from dict: [String: Any], keys: [String]) -> [String: Any]? { + for key in keys { + if let value = dict[key] as? [String: Any] { + return value + } + } + return nil + } + + private static func buildSnapshot( + rolling: [String: Any], + weekly: [String: Any], + monthly: [String: Any]?, + now: Date, + renewsAt: Date? = nil) -> OpenCodeGoUsageSnapshot? + { + guard let rollingWindow = self.parseWindow(rolling, now: now), + let weeklyWindow = self.parseWindow(weekly, now: now) + else { + return nil + } + + let monthlyWindow = monthly.flatMap { self.parseWindow($0, now: now) } + + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: monthlyWindow != nil, + rollingUsagePercent: rollingWindow.percent, + weeklyUsagePercent: weeklyWindow.percent, + monthlyUsagePercent: monthlyWindow?.percent ?? 0, + rollingResetInSec: rollingWindow.resetInSec, + weeklyResetInSec: weeklyWindow.resetInSec, + monthlyResetInSec: monthlyWindow?.resetInSec ?? 0, + renewsAt: renewsAt, + updatedAt: now) + } + + private static func parseWindow(_ dict: [String: Any], now: Date) -> (percent: Double, resetInSec: Int)? { + var percent: Double? + + for key in self.percentKeys { + if let value = self.doubleValue(from: dict[key]) { + percent = value + break + } + } + + if percent == nil { + let usedKeys = ["used", "usage", "consumed", "count", "usedTokens"] + let limitKeys = ["limit", "total", "quota", "max", "cap", "tokenLimit"] + var used: Double? + for key in usedKeys { + if let value = self.doubleValue(from: dict[key]) { + used = value + break + } + } + var limit: Double? + for key in limitKeys { + if let value = self.doubleValue(from: dict[key]) { + limit = value + break + } + } + if let used, let limit, limit > 0 { + percent = (used / limit) * 100 + } + } + + guard var resolvedPercent = percent else { return nil } + if resolvedPercent <= 1.0, resolvedPercent >= 0 { + resolvedPercent *= 100 + } + resolvedPercent = max(0, min(100, resolvedPercent)) + + var resetInSec: Int? + for key in self.resetInKeys { + if let value = self.intValue(from: dict[key]) { + resetInSec = value + break + } + } + + if resetInSec == nil { + for key in self.resetAtKeys { + if let resetAt = self.dateValue(from: dict[key]) { + resetInSec = max(0, Int(resetAt.timeIntervalSince(now))) + break + } + } + } + + let resolvedReset = max(0, resetInSec ?? 0) + return (resolvedPercent, resolvedReset) + } + + private static func fetchServerText( + request serverRequest: ServerRequest, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> String + { + let url = self.serverRequestURL( + serverID: serverRequest.serverID, + args: serverRequest.args, + method: serverRequest.method) + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = serverRequest.method + urlRequest.timeoutInterval = timeout + urlRequest.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + urlRequest.setValue(serverRequest.serverID, forHTTPHeaderField: "X-Server-Id") + urlRequest.setValue("server-fn:\(UUID().uuidString)", forHTTPHeaderField: "X-Server-Instance") + urlRequest.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + urlRequest.setValue(self.baseURL.absoluteString, forHTTPHeaderField: "Origin") + urlRequest.setValue(serverRequest.referer.absoluteString, forHTTPHeaderField: "Referer") + urlRequest.setValue("text/javascript, application/json;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept") + if serverRequest.method.uppercased() != "GET", + let args = serverRequest.args + { + urlRequest.httpBody = args.data(using: .utf8) + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let httpResponse = try await session.response(for: urlRequest) + + guard httpResponse.statusCode == 200 else { + let bodyText = String(data: httpResponse.data, encoding: .utf8) ?? "" + let contentType = httpResponse.response.value(forHTTPHeaderField: "Content-Type") ?? "unknown" + let dataLength = httpResponse.data.count + Self.log.error( + "OpenCode Go returned \(httpResponse.statusCode) (type=\(contentType) length=\(dataLength))") + if self.looksSignedOut(text: bodyText) { + throw OpenCodeGoUsageError.invalidCredentials + } + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw OpenCodeGoUsageError.invalidCredentials + } + if let message = self.extractServerErrorMessage(from: bodyText) { + throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode): \(message)") + } + throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + + guard let text = String(data: httpResponse.data, encoding: .utf8) else { + throw OpenCodeGoUsageError.parseFailed("Response was not UTF-8.") + } + return text + } + + static func fetchPageText( + url: URL, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> String + { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue( + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + forHTTPHeaderField: "Accept") + + let httpResponse = try await session.response(for: request) + guard httpResponse.statusCode == 200 else { + let bodyText = String(data: httpResponse.data, encoding: .utf8) ?? "" + if self.looksSignedOut(text: bodyText) { + throw OpenCodeGoUsageError.invalidCredentials + } + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw OpenCodeGoUsageError.invalidCredentials + } + if let message = self.extractServerErrorMessage(from: bodyText) { + throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode): \(message)") + } + throw OpenCodeGoUsageError.apiError("HTTP \(httpResponse.statusCode)") + } + guard let text = String(data: httpResponse.data, encoding: .utf8) else { + throw OpenCodeGoUsageError.parseFailed("Response was not UTF-8.") + } + return text + } + + private static func serverRequestURL(serverID: String, args: String?, method: String) -> URL { + guard method.uppercased() == "GET" else { + return self.serverURL + } + + var components = URLComponents(url: self.serverURL, resolvingAgainstBaseURL: false) + var queryItems = [URLQueryItem(name: "id", value: serverID)] + if let args, !args.isEmpty { + queryItems.append(URLQueryItem(name: "args", value: args)) + } + components?.queryItems = queryItems + return components?.url ?? self.serverURL + } + + static func looksSignedOut(text: String) -> Bool { + let lower = text.lowercased() + return lower.contains("login") || + lower.contains("sign in") || + lower.contains("auth/authorize") || + lower.contains("not associated with an account") || + lower.contains("actor of type \"public\"") + } + + private static func extractServerErrorMessage(from text: String) -> String? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + if let match = text.range(of: #"(?i)([^<]+)"#, options: .regularExpression) { + return String(text[match].dropFirst(7).dropLast(8)).trimmingCharacters(in: .whitespacesAndNewlines) + } + return nil + } + + guard let dict = object as? [String: Any] else { return nil } + if let message = dict["message"] as? String, !message.isEmpty { + return message + } + if let error = dict["error"] as? String, !error.isEmpty { + return error + } + if let detail = dict["detail"] as? String, !detail.isEmpty { + return detail + } + return nil + } + + private static func extractDouble(pattern: String, text: String) -> Double? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let nsrange = NSRange(text.startIndex.. Int? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let nsrange = NSRange(text.startIndex.. Double? { + switch value { + case let number as Double: + number + case let number as NSNumber: + number.doubleValue + case let string as String: + Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + nil + } + } + + private static func intValue(from value: Any?) -> Int? { + switch value { + case let number as Int: + number + case let number as NSNumber: + number.intValue + case let string as String: + Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + nil + } + } + + private static func value(from dict: [String: Any], keys: [String]) -> Any? { + for key in keys { + if let value = dict[key] { + return value + } + } + return nil + } + + private static func dateValue(from value: Any?) -> Date? { + guard let value else { return nil } + if let number = self.doubleValue(from: value) { + if number > 1_000_000_000_000 { + return Date(timeIntervalSince1970: number / 1000) + } + if number > 1_000_000_000 { + return Date(timeIntervalSince1970: number) + } + } + if let string = value as? String { + if let number = Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) { + return self.dateValue(from: number) + } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let parsed = formatter.date(from: string) { + return parsed + } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift new file mode 100644 index 000000000..5f4156ca8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -0,0 +1,105 @@ +import Foundation + +public struct OpenCodeGoUsageSnapshot: Sendable { + public let hasMonthlyUsage: Bool + public let rollingUsagePercent: Double + public let weeklyUsagePercent: Double + public let monthlyUsagePercent: Double + public let rollingResetInSec: Int + public let weeklyResetInSec: Int + public let monthlyResetInSec: Int + public let zenBalanceUSD: Double? + public let renewsAt: Date? + public let updatedAt: Date + + public init( + hasMonthlyUsage: Bool, + rollingUsagePercent: Double, + weeklyUsagePercent: Double, + monthlyUsagePercent: Double, + rollingResetInSec: Int, + weeklyResetInSec: Int, + monthlyResetInSec: Int, + zenBalanceUSD: Double? = nil, + renewsAt: Date? = nil, + updatedAt: Date) + { + self.hasMonthlyUsage = hasMonthlyUsage + self.rollingUsagePercent = rollingUsagePercent + self.weeklyUsagePercent = weeklyUsagePercent + self.monthlyUsagePercent = monthlyUsagePercent + self.rollingResetInSec = rollingResetInSec + self.weeklyResetInSec = weeklyResetInSec + self.monthlyResetInSec = monthlyResetInSec + self.zenBalanceUSD = zenBalanceUSD + self.renewsAt = renewsAt + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let rollingReset = self.updatedAt.addingTimeInterval(TimeInterval(self.rollingResetInSec)) + let weeklyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.weeklyResetInSec)) + + let primary = RateWindow( + usedPercent: self.rollingUsagePercent, + windowMinutes: 5 * 60, + resetsAt: rollingReset, + resetDescription: nil) + let secondary = RateWindow( + usedPercent: self.weeklyUsagePercent, + windowMinutes: 7 * 24 * 60, + resetsAt: weeklyReset, + resetDescription: nil) + let tertiary: RateWindow? + if self.hasMonthlyUsage { + let monthlyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.monthlyResetInSec)) + tertiary = RateWindow( + usedPercent: self.monthlyUsagePercent, + windowMinutes: 30 * 24 * 60, + resetsAt: monthlyReset, + resetDescription: nil) + } else { + tertiary = nil + } + + var extraWindows: [NamedRateWindow]? + if let renewsAt = self.renewsAt { + let renewalWindow = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: renewsAt, + resetDescription: nil) + extraWindows = [NamedRateWindow(id: "renewal", title: "Renews", window: renewalWindow)] + } + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + extraRateWindows: extraWindows, + providerCost: self.zenBalanceUSD.map { + ProviderCostSnapshot( + used: $0, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: self.updatedAt) + }, + updatedAt: self.updatedAt, + identity: nil) + } + + public func withZenBalanceUSD(_ balance: Double?) -> OpenCodeGoUsageSnapshot { + OpenCodeGoUsageSnapshot( + hasMonthlyUsage: self.hasMonthlyUsage, + rollingUsagePercent: self.rollingUsagePercent, + weeklyUsagePercent: self.weeklyUsagePercent, + monthlyUsagePercent: self.monthlyUsagePercent, + rollingResetInSec: self.rollingResetInSec, + weeklyResetInSec: self.weeklyResetInSec, + monthlyResetInSec: self.monthlyResetInSec, + zenBalanceUSD: balance, + renewsAt: self.renewsAt, + updatedAt: self.updatedAt) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift new file mode 100644 index 000000000..70b013611 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceFetcher.swift @@ -0,0 +1,86 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +extension OpenCodeGoUsageFetcher { + static let optionalZenBalanceTimeout: TimeInterval = 5 + static let optionalZenBalanceJoinGrace: Duration = .milliseconds(250) + + public static func zenDashboardURL(workspaceID raw: String?) -> URL { + guard let workspaceID = self.normalizeWorkspaceID(raw), + let url = URL(string: "https://opencode.ai/workspace/\(workspaceID)") + else { + return URL(string: "https://opencode.ai")! + } + return url + } + + static func fetchOptionalZenBalance( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> Double? + { + do { + let balance = try await self.fetchZenBalance( + workspaceID: workspaceID, + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + try Task.checkCancellation() + return balance + } catch is CancellationError { + throw CancellationError() + } catch { + if Task.isCancelled { + throw CancellationError() + } + return nil + } + } + + static func completedOptionalZenBalance(from task: Task) async throws -> Double? { + try await withThrowingTaskGroup(of: Double?.self) { group in + group.addTask { + try await task.value + } + group.addTask { + try await Task.sleep(for: self.optionalZenBalanceJoinGrace) + return nil + } + + let result = try await group.next() + group.cancelAll() + guard let value = result else { + task.cancel() + return nil + } + if value == nil { + task.cancel() + } + return value + } + } + + static func parseZenBalance(text: String) -> Double? { + OpenCodeGoZenBalanceParser.parse(text: text) + } + + private static func fetchZenBalance( + workspaceID: String, + cookieHeader: String, + timeout: TimeInterval, + session: URLSession) async throws -> Double? + { + let text = try await self.fetchPageText( + url: self.zenDashboardURL(workspaceID: workspaceID), + cookieHeader: cookieHeader, + timeout: timeout, + session: session) + if self.looksSignedOut(text: text) { + throw OpenCodeGoUsageError.invalidCredentials + } + return self.parseZenBalance(text: text) + } +} diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift new file mode 100644 index 000000000..78b70b0d0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoZenBalanceParser.swift @@ -0,0 +1,95 @@ +import Foundation + +enum OpenCodeGoZenBalanceParser { + static func parse(text: String) -> Double? { + if let value = self.parseJSON(text: text) { + return value + } + let localizedPattern = [ + #"(?i)(?:current\s+balance|zen\s+balance|現在の残高)"#, + #"[^$]{0,80}\$\s*([0-9][0-9,]*(?:\.[0-9]+)?)"#, + ].joined() + if let value = self.extractDollarValue(pattern: localizedPattern, text: text) { + return value + } + let nearbyPattern = #"(?i)(?:balance|残高)[\s\S]{0,120}?\$\s*([0-9][0-9,]*(?:\.[0-9]+)?)"# + return self.extractDollarValue(pattern: nearbyPattern, text: text) + } + + private static func parseJSON(text: String) -> Double? { + guard let data = text.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data, options: []) + else { + return nil + } + return self.findBalanceValue(in: object, path: []) + } + + private static func findBalanceValue(in object: Any, path: [String]) -> Double? { + if let dict = object as? [String: Any] { + for (key, value) in dict { + let nextPath = path + [key] + if self.isExplicitBalanceAmountKey(key), + let number = self.doubleValue(from: value) + { + return number + } + if let found = self.findBalanceValue(in: value, path: nextPath) { + return found + } + } + return nil + } + if let array = object as? [Any] { + for (index, value) in array.enumerated() { + if let found = self.findBalanceValue(in: value, path: path + ["[\(index)]"]) { + return found + } + } + } + return nil + } + + private static func isExplicitBalanceAmountKey(_ key: String) -> Bool { + let normalized = key + .lowercased() + .filter { $0.isLetter || $0.isNumber } + return [ + "zenbalance", + "zencurrentbalance", + "currentbalance", + "currentbalanceusd", + "balanceusd", + "usdbalance", + ].contains(normalized) + } + + private static func extractDollarValue(pattern: String, text: String) -> Double? { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let nsrange = NSRange(text.startIndex.. Double? { + switch value { + case is Bool: + nil + case let number as Double: + number + case let number as NSNumber: + number.doubleValue + case let string as String: + Double( + string + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: ",", with: "")) + default: + nil + } + } +} diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift index e5e3f4d78..1072ebe08 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterSettingsReader.swift @@ -28,8 +28,7 @@ public enum OpenRouterSettingsReader { if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { - value.removeFirst() - value.removeLast() + value = String(value.dropFirst().dropLast()) } value = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index fbcc3e44c..b37544dcc 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -45,11 +45,20 @@ public struct OpenRouterKeyData: Decodable, Sendable { public let limit: Double? /// Current usage public let usage: Double? + /// API key usage for the current UTC day. + public let usageDaily: Double? + /// API key usage for the current UTC week. + public let usageWeekly: Double? + /// API key usage for the current UTC month. + public let usageMonthly: Double? private enum CodingKeys: String, CodingKey { case rateLimit = "rate_limit" case limit case usage + case usageDaily = "usage_daily" + case usageWeekly = "usage_weekly" + case usageMonthly = "usage_monthly" } } @@ -59,6 +68,11 @@ public struct OpenRouterRateLimit: Codable, Sendable { public let requests: Int /// Interval for the rate limit (e.g., "10s", "1m") public let interval: String + + public init(requests: Int, interval: String) { + self.requests = requests + self.interval = interval + } } public enum OpenRouterKeyQuotaStatus: String, Codable, Sendable { @@ -76,6 +90,9 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { public let keyDataFetched: Bool public let keyLimit: Double? public let keyUsage: Double? + public let keyUsageDaily: Double? + public let keyUsageWeekly: Double? + public let keyUsageMonthly: Double? public let rateLimit: OpenRouterRateLimit? public let updatedAt: Date @@ -87,6 +104,9 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { keyDataFetched: Bool = false, keyLimit: Double? = nil, keyUsage: Double? = nil, + keyUsageDaily: Double? = nil, + keyUsageWeekly: Double? = nil, + keyUsageMonthly: Double? = nil, rateLimit: OpenRouterRateLimit?, updatedAt: Date) { @@ -94,9 +114,13 @@ public struct OpenRouterUsageSnapshot: Codable, Sendable { self.totalUsage = totalUsage self.balance = balance self.usedPercent = usedPercent - self.keyDataFetched = keyDataFetched || keyLimit != nil || keyUsage != nil + self.keyDataFetched = keyDataFetched || keyLimit != nil || keyUsage != nil || + keyUsageDaily != nil || keyUsageWeekly != nil || keyUsageMonthly != nil self.keyLimit = keyLimit self.keyUsage = keyUsage + self.keyUsageDaily = keyUsageDaily + self.keyUsageWeekly = keyUsageWeekly + self.keyUsageMonthly = keyUsageMonthly self.rateLimit = rateLimit self.updatedAt = updatedAt } @@ -216,21 +240,17 @@ public struct OpenRouterUsageFetcher: Sendable { let title = Self.sanitizedHeaderValue(environment[self.clientTitleEnvKey]) ?? Self.defaultClientTitle request.setValue(title, forHTTPHeaderField: "X-Title") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw OpenRouterUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { let errorSummary = LogRedactor.redact(Self.sanitizedResponseBodySummary(data)) if Self.debugFullErrorBodiesEnabled(environment: environment), let debugBody = Self.redactedDebugResponseBody(data) { Self.log.debug("OpenRouter non-200 body (redacted): \(LogRedactor.redact(debugBody))") } - Self.log.error("OpenRouter API returned \(httpResponse.statusCode): \(errorSummary)") - throw OpenRouterUsageError.apiError("HTTP \(httpResponse.statusCode)") + Self.log.error("OpenRouter API returned \(response.statusCode): \(errorSummary)") + throw OpenRouterUsageError.apiError("HTTP \(response.statusCode)") } do { @@ -252,6 +272,9 @@ public struct OpenRouterUsageFetcher: Sendable { keyDataFetched: keyFetch.fetched, keyLimit: keyFetch.data?.limit, keyUsage: keyFetch.data?.usage, + keyUsageDaily: keyFetch.data?.usageDaily, + keyUsageWeekly: keyFetch.data?.usageWeekly, + keyUsageMonthly: keyFetch.data?.usageMonthly, rateLimit: keyFetch.data?.rateLimit, updatedAt: Date()) } catch let error as DecodingError { @@ -266,7 +289,7 @@ public struct OpenRouterUsageFetcher: Sendable { } /// Fetches key quota/rate-limit info from /key endpoint - private struct OpenRouterKeyFetchResult: Sendable { + private struct OpenRouterKeyFetchResult { let data: OpenRouterKeyData? let fetched: Bool } @@ -323,16 +346,13 @@ public struct OpenRouterUsageFetcher: Sendable { request.timeoutInterval = timeoutSeconds do { - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { return OpenRouterKeyFetchResult(data: nil, fetched: false) } let decoder = JSONDecoder() - let keyResponse = try decoder.decode(OpenRouterKeyResponse.self, from: data) + let keyResponse = try decoder.decode(OpenRouterKeyResponse.self, from: response.data) return OpenRouterKeyFetchResult(data: keyResponse.data, fetched: true) } catch { Self.log.debug("Failed to fetch OpenRouter /key enrichment: \(error.localizedDescription)") diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift new file mode 100644 index 000000000..35baf9350 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum PerplexityAPIError: LocalizedError, Sendable, Equatable { + case missingToken + case invalidCookie + case invalidToken + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingToken: + "Perplexity session token is missing. Please log into Perplexity in your browser." + case .invalidCookie: + "Perplexity manual cookie header is empty or invalid." + case .invalidToken: + "Perplexity session token is invalid or expired. Please log in again." + case let .networkError(message): + "Perplexity network error: \(message)" + case let .apiError(message): + "Perplexity API error: \(message)" + case let .parseFailed(message): + "Failed to parse Perplexity usage data: \(message)" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift new file mode 100644 index 000000000..1c66a95f2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift @@ -0,0 +1,129 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct PerplexityCookieOverride: Sendable { + public let name: String + public let token: String + public let requestCookieNames: [String] + + public init(name: String, token: String, requestCookieNames: [String]? = nil) { + self.name = name + self.token = token + self.requestCookieNames = requestCookieNames ?? [name] + } +} + +public enum PerplexityCookieHeader { + public static let defaultSessionCookieName = "__Secure-next-auth.session-token" + public static let supportedSessionCookieNames = [ + "__Secure-authjs.session-token", + "authjs.session-token", + "__Secure-next-auth.session-token", + "next-auth.session-token", + ] + + public static func resolveCookieOverride(context: ProviderFetchContext) -> PerplexityCookieOverride? { + if let settings = context.settings?.perplexity, settings.cookieSource == .manual { + if let manual = settings.manualCookieHeader, !manual.isEmpty { + return self.override(from: manual) + } + } + return nil + } + + public static func override(from raw: String?) -> PerplexityCookieOverride? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + + // Accept bare token value + if !raw.contains("="), !raw.contains(";") { + return PerplexityCookieOverride( + name: self.defaultSessionCookieName, + token: raw, + requestCookieNames: self.supportedSessionCookieNames) + } + + // Extract a supported session cookie from a full cookie string. + if let cookie = self.extractSessionCookie(from: raw) { + return cookie + } + + return nil + } + + static func sessionCookie(from cookies: [HTTPCookie]) -> PerplexityCookieOverride? { + self.extractSessionCookie(from: cookies.map { (name: $0.name, value: $0.value) }) + } + + private static func extractSessionCookie(from raw: String) -> PerplexityCookieOverride? { + let pairs = raw.split(separator: ";") + var cookies: [(name: String, value: String)] = [] + for pair in pairs { + let trimmed = pair.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + guard let separator = trimmed.firstIndex(of: "=") else { continue } + let key = String(trimmed[.. PerplexityCookieOverride? { + var cookieMap: [String: (name: String, value: String)] = [:] + var chunkedCookies: [String: [Int: (name: String, value: String)]] = [:] + + for cookie in cookies { + let loweredName = cookie.name.lowercased() + cookieMap[loweredName] = cookie + + for expected in self.supportedSessionCookieNames { + let loweredExpected = expected.lowercased() + let prefix = "\(loweredExpected)." + guard loweredName.hasPrefix(prefix) else { continue } + let suffix = String(loweredName.dropFirst(prefix.count)) + guard let index = Int(suffix) else { continue } + chunkedCookies[loweredExpected, default: [:]][index] = cookie + } + } + + for expected in self.supportedSessionCookieNames { + let loweredExpected = expected.lowercased() + if let match = cookieMap[loweredExpected] { + return PerplexityCookieOverride(name: match.name, token: match.value) + } + if let chunked = self.reassembleChunkedSessionCookie(from: chunkedCookies[loweredExpected]) { + return chunked + } + } + return nil + } + + private static func reassembleChunkedSessionCookie( + from chunks: [Int: (name: String, value: String)]?) -> PerplexityCookieOverride? + { + guard let chunks, + let firstChunk = chunks[0], + let maxIndex = chunks.keys.max() + else { + return nil + } + + var tokenParts: [String] = [] + tokenParts.reserveCapacity(maxIndex + 1) + for index in 0...maxIndex { + guard let chunk = chunks[index] else { return nil } + tokenParts.append(chunk.value) + } + + guard let suffixStart = firstChunk.name.lastIndex(of: ".") else { return nil } + let baseName = String(firstChunk.name[.. Void)?) throws -> SessionInfo)? + nonisolated(unsafe) static var importSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])? + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public init(cookies: [HTTPCookie], sourceLabel: String) { + self.cookies = cookies + self.sourceLabel = sourceLabel + } + + public var sessionCookie: PerplexityCookieOverride? { + PerplexityCookieHeader.sessionCookie(from: self.cookies) + } + + public var sessionToken: String? { + self.sessionCookie?.token + } + } + + public static func importSessions( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + if let cached = self.cachedImportSessions() { + return cached + } + if let override = self.importSessionsOverrideForTesting { + let sessions = try override(browserDetection, logger) + self.storeImportSessions(sessions) + return sessions + } + if let override = self.importSessionOverrideForTesting { + let session = try override(browserDetection, logger) + let sessions = [session] + self.storeImportSessions(sessions) + return sessions + } + + var sessions: [SessionInfo] = [] + let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection) + for browserSource in candidates { + do { + let perSource = try self.importSessions(from: browserSource, logger: logger) + sessions.append(contentsOf: perSource) + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + self.emit( + "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)", + logger: logger) + } + } + + guard !sessions.isEmpty else { + throw PerplexityCookieImportError.noCookies + } + self.storeImportSessions(sessions) + return sessions + } + + public static func importSessions( + from browserSource: Browser, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let log: (String) -> Void = { msg in self.emit(msg, logger: logger) } + let sources = try Self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + + var sessions: [SessionInfo] = [] + let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id }) + let sortedGroups = grouped.values.sorted { lhs, rhs in + self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs) + } + + for group in sortedGroups where !group.isEmpty { + let label = self.mergedLabel(for: group) + let mergedRecords = self.mergeRecords(group) + guard !mergedRecords.isEmpty else { continue } + let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin) + guard !httpCookies.isEmpty else { continue } + + let session = SessionInfo(cookies: httpCookies, sourceLabel: label) + guard let sessionCookie = session.sessionCookie else { + continue + } + + log("Found \(sessionCookie.name) cookie in \(label)") + sessions.append(session) + } + return sessions + } + + public static func importSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) + guard let first = sessions.first else { + throw PerplexityCookieImportError.noCookies + } + return first + } + + public static func hasSession( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) -> Bool + { + do { + _ = try self.importSession(browserDetection: browserDetection, logger: logger) + return true + } catch { + return false + } + } + + static func invalidateImportSessionCache() { + self.importSessionCache.invalidate() + } + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[perplexity-cookie] \(message)") + self.log.debug(message) + } + + private static func cachedImportSessions(now: Date = Date()) -> [SessionInfo]? { + self.importSessionCache.load(now: now) + } + + private static func storeImportSessions(_ sessions: [SessionInfo], now: Date = Date()) { + self.importSessionCache.store(sessions, now: now) + } + + private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { + guard let base = sources.map(\.label).min() else { return "Unknown" } + if base.hasSuffix(" (Network)") { + return String(base.dropLast(" (Network)".count)) + } + return base + } + + private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] { + let sortedSources = sources.sorted { lhs, rhs in + self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind) + } + var mergedByKey: [String: BrowserCookieRecord] = [:] + for source in sortedSources { + for record in source.records { + let key = self.recordKey(record) + if let existing = mergedByKey[key] { + if self.shouldReplace(existing: existing, candidate: record) { + mergedByKey[key] = record + } + } else { + mergedByKey[key] = record + } + } + } + return Array(mergedByKey.values) + } + + private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int { + switch kind { + case .network: 0 + case .primary: 1 + case .safari: 2 + } + } + + private static func recordKey(_ record: BrowserCookieRecord) -> String { + "\(record.name)|\(record.domain)|\(record.path)" + } + + private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool { + switch (existing.expires, candidate.expires) { + case let (lhs?, rhs?): rhs > lhs + case (nil, .some): true + case (.some, nil): false + case (nil, nil): false + } + } + + private final class ImportSessionCache: @unchecked Sendable { + private let ttl: TimeInterval + private let lock = NSLock() + private var entry: (sessions: [SessionInfo], expiresAt: Date)? + + init(ttl: TimeInterval) { + self.ttl = ttl + } + + func load(now: Date) -> [SessionInfo]? { + self.lock.lock() + defer { self.lock.unlock() } + guard let entry = self.entry else { return nil } + guard entry.expiresAt > now else { + self.entry = nil + return nil + } + return entry.sessions + } + + func store(_ sessions: [SessionInfo], now: Date) { + self.lock.lock() + defer { self.lock.unlock() } + self.entry = (sessions: sessions, expiresAt: now.addingTimeInterval(self.ttl)) + } + + func invalidate() { + self.lock.lock() + defer { self.lock.unlock() } + self.entry = nil + } + } +} + +enum PerplexityCookieImportError: LocalizedError { + case noCookies + + var errorDescription: String? { + switch self { + case .noCookies: + "No Perplexity session cookies found in browsers. Please log into perplexity.ai." + } + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityModels.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityModels.swift new file mode 100644 index 000000000..ae4ae592b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityModels.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct PerplexityCreditsResponse: Codable { + public let balanceCents: Double + public let renewalDateTs: TimeInterval + public let currentPeriodPurchasedCents: Double + public let creditGrants: [PerplexityCreditGrant] + public let totalUsageCents: Double + + enum CodingKeys: String, CodingKey { + case balanceCents = "balance_cents" + case renewalDateTs = "renewal_date_ts" + case currentPeriodPurchasedCents = "current_period_purchased_cents" + case creditGrants = "credit_grants" + case totalUsageCents = "total_usage_cents" + } +} + +public struct PerplexityCreditGrant: Codable { + public let type: String + public let amountCents: Double + public let expiresAtTs: TimeInterval? + + enum CodingKeys: String, CodingKey { + case type + case amountCents = "amount_cents" + case expiresAtTs = "expires_at_ts" + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift new file mode 100644 index 000000000..1a6da7c1b --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift @@ -0,0 +1,238 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum PerplexityProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .perplexity, + metadata: ProviderMetadata( + id: .perplexity, + displayName: "Perplexity", + sessionLabel: "Credits", + weeklyLabel: "Bonus credits", + opusLabel: "Purchased", + supportsOpus: true, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Perplexity usage", + cliName: "perplexity", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://www.perplexity.ai/account/usage", + statusPageURL: nil, + statusLinkURL: "https://status.perplexity.com/"), + branding: ProviderBranding( + iconStyle: .perplexity, + iconResourceName: "ProviderIcon-perplexity", + color: ProviderColor(red: 32 / 255, green: 178 / 255, blue: 170 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Perplexity cost tracking is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [PerplexityWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "perplexity", + aliases: [], + versionDetector: nil)) + } +} + +struct PerplexityWebFetchStrategy: ProviderFetchStrategy { + private enum SessionCookieSource { + case manual + case cache + case browser + case environment + + var shouldCacheAfterFetch: Bool { + self == .browser + } + } + + private struct ResolvedSessionCookie { + let value: PerplexityCookieOverride + let source: SessionCookieSource + } + + private struct SessionFetchResult { + let snapshot: PerplexityUsageSnapshot + let cookie: PerplexityCookieOverride + } + + let id: String = "perplexity.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.settings?.perplexity?.cookieSource != .off else { return false } + if context.settings?.perplexity?.cookieSource == .manual { return true } + + // Priority order mirrors resolveSessionCookie: manual override → cache → browser import → env var + if PerplexityCookieHeader.resolveCookieOverride(context: context) != nil { + return true + } + + if CookieHeaderCache.load(provider: .perplexity) != nil { + return true + } + + #if os(macOS) + if context.settings?.perplexity?.cookieSource != .off { + if PerplexityCookieImporter.hasSession() { return true } + } + #endif + + if PerplexitySettingsReader.sessionToken(environment: context.env) != nil { + return true + } + + return false + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let resolvedCookies = try self.resolveSessionCookies(context: context) + guard !resolvedCookies.isEmpty else { + throw PerplexityAPIError.missingToken + } + var sawInvalidToken = false + + for resolvedCookie in resolvedCookies { + do { + let result = try await self.fetchSnapshot(using: resolvedCookie) + self.cacheSessionCookieIfNeeded(resolvedCookie, usedCookie: result.cookie, sourceLabel: "web") + return self.makeResult( + usage: result.snapshot.toUsageSnapshot(), + sourceLabel: "web") + } catch PerplexityAPIError.invalidToken { + sawInvalidToken = true + if resolvedCookie.source == .cache { + CookieHeaderCache.clear(provider: .perplexity) + } + continue + } + } + + if sawInvalidToken { + throw PerplexityAPIError.invalidToken + } + throw PerplexityAPIError.missingToken + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + if case PerplexityAPIError.missingToken = error { return false } + if case PerplexityAPIError.invalidCookie = error { return false } + if case PerplexityAPIError.invalidToken = error { return false } + return true + } + + private func resolveSessionCookies(context: ProviderFetchContext) throws -> [ResolvedSessionCookie] { + guard context.settings?.perplexity?.cookieSource != .off else { return [] } + + if context.settings?.perplexity?.cookieSource == .manual { + guard let override = PerplexityCookieHeader.resolveCookieOverride(context: context) else { + throw PerplexityAPIError.invalidCookie + } + return [ResolvedSessionCookie(value: override, source: .manual)] + } + + var cookies: [ResolvedSessionCookie] = [] + + // Try cached cookie before expensive browser import + if let cached = CookieHeaderCache.load(provider: .perplexity) { + if let override = PerplexityCookieHeader.override(from: cached.cookieHeader) { + cookies.append(ResolvedSessionCookie(value: override, source: .cache)) + } + } + + cookies.append(contentsOf: self.resolveSessionCookiesFromBrowserOrEnv(context: context)) + return self.deduplicatedSessionCookies(cookies) + } + + private func resolveSessionCookiesFromBrowserOrEnv( + context: ProviderFetchContext, + preferEnvironment: Bool = false) -> [ResolvedSessionCookie] + { + guard context.settings?.perplexity?.cookieSource != .off else { return [] } + var cookies: [ResolvedSessionCookie] = [] + + if preferEnvironment, + let cookie = PerplexitySettingsReader.sessionCookieOverride(environment: context.env) + { + cookies.append(ResolvedSessionCookie(value: cookie, source: .environment)) + } + + // Try browser cookie import when auto mode is enabled + #if os(macOS) + do { + let sessions = try PerplexityCookieImporter.importSessions() + cookies.append(contentsOf: sessions.compactMap { session in + guard let cookie = session.sessionCookie else { return nil } + return ResolvedSessionCookie(value: cookie, source: .browser) + }) + } catch { + // No browser cookies found + } + #endif + + // Fall back to environment + if !preferEnvironment, + let cookie = PerplexitySettingsReader.sessionCookieOverride(environment: context.env) + { + cookies.append(ResolvedSessionCookie(value: cookie, source: .environment)) + } + return self.deduplicatedSessionCookies(cookies) + } + + private func deduplicatedSessionCookies(_ cookies: [ResolvedSessionCookie]) -> [ResolvedSessionCookie] { + var deduplicated: [ResolvedSessionCookie] = [] + for cookie in cookies { + if deduplicated.contains(where: { self.isEquivalentCookie($0.value, cookie.value) }) { + continue + } + deduplicated.append(cookie) + } + return deduplicated + } + + private func cacheSessionCookieIfNeeded( + _ cookie: ResolvedSessionCookie, + usedCookie: PerplexityCookieOverride, + sourceLabel: String) + { + guard cookie.source.shouldCacheAfterFetch else { return } + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(usedCookie.name)=\(usedCookie.token)", + sourceLabel: sourceLabel) + } + + private func fetchSnapshot(using cookie: ResolvedSessionCookie) async throws -> SessionFetchResult { + var lastInvalidToken = false + for cookieName in cookie.value.requestCookieNames { + do { + let snapshot = try await PerplexityUsageFetcher.fetchCredits( + sessionToken: cookie.value.token, + cookieName: cookieName) + return SessionFetchResult( + snapshot: snapshot, + cookie: PerplexityCookieOverride(name: cookieName, token: cookie.value.token)) + } catch PerplexityAPIError.invalidToken { + lastInvalidToken = true + continue + } + } + + if lastInvalidToken { + throw PerplexityAPIError.invalidToken + } + throw PerplexityAPIError.missingToken + } + + private func isEquivalentCookie(_ lhs: PerplexityCookieOverride, _ rhs: PerplexityCookieOverride) -> Bool { + lhs.token == rhs.token && lhs.requestCookieNames == rhs.requestCookieNames + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift new file mode 100644 index 000000000..ef1952b5a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum PerplexitySettingsReader { + public static func sessionCookieOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) -> PerplexityCookieOverride? + { + let raw = environment["PERPLEXITY_SESSION_TOKEN"] + ?? environment["perplexity_session_token"] + if let token = self.cleaned(raw) { return PerplexityCookieHeader.override(from: token) } + + // PERPLEXITY_COOKIE may be a full Cookie header string; preserve the matching session cookie name. + if let cookieRaw = environment["PERPLEXITY_COOKIE"] { + return PerplexityCookieHeader.override(from: self.cleaned(cookieRaw)) + } + return nil + } + + public static func sessionToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.sessionCookieOverride(environment: environment)?.token + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift new file mode 100644 index 000000000..84e735e26 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift @@ -0,0 +1,71 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct PerplexityUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.perplexityAPI) + private static let creditsURL = + URL(string: "https://www.perplexity.ai/rest/billing/credits?version=2.18&source=default")! + @TaskLocal static var fetchCreditsOverride: + (@Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot)? + + /// Testing hook: parse a raw JSON response without making network calls. + public static func _parseResponseForTesting(_ data: Data, now: Date = Date()) throws -> PerplexityUsageSnapshot { + do { + let decoded = try JSONDecoder().decode(PerplexityCreditsResponse.self, from: data) + return PerplexityUsageSnapshot(response: decoded, now: now) + } catch { + throw PerplexityAPIError.parseFailed(error.localizedDescription) + } + } + + public static func fetchCredits( + sessionToken: String, + cookieName: String = PerplexityCookieHeader.defaultSessionCookieName, + now: Date = Date()) async throws -> PerplexityUsageSnapshot + { + if let override = self.fetchCreditsOverride { + return try await override(sessionToken, cookieName, now) + } + + var request = URLRequest(url: self.creditsURL) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue( + "\(cookieName)=\(sessionToken)", + forHTTPHeaderField: "Cookie") + request.setValue("https://www.perplexity.ai", forHTTPHeaderField: "Origin") + request.setValue("https://www.perplexity.ai/account/usage", forHTTPHeaderField: "Referer") + let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let statusCode = response.statusCode + let body = String(data: data, encoding: .utf8) ?? "" + let truncated = body.count > 200 ? String(body.prefix(200)) + "…" : body + Self.log.error("Perplexity API returned \(statusCode): \(truncated)") + if statusCode == 401 || statusCode == 403 { + throw PerplexityAPIError.invalidToken + } + throw PerplexityAPIError.apiError("HTTP \(statusCode)") + } + + do { + let decoded = try JSONDecoder().decode(PerplexityCreditsResponse.self, from: data) + let snapshot = PerplexityUsageSnapshot(response: decoded, now: now) + Self.log.debug( + "Perplexity credits parsed balance=\(snapshot.balanceCents) totalUsage=\(snapshot.totalUsageCents)") + return snapshot + } catch { + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "" + Self.log.error("Perplexity parse failed: \(error) — response: \(preview)") + throw PerplexityAPIError.parseFailed(error.localizedDescription) + } + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift new file mode 100644 index 000000000..48e4cf9cc --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift @@ -0,0 +1,134 @@ +import Foundation + +public struct PerplexityUsageSnapshot: Sendable { + public let recurringTotal: Double + public let recurringUsed: Double + public let promoTotal: Double + public let promoUsed: Double + public let purchasedTotal: Double + public let purchasedUsed: Double + public let balanceCents: Double + public let totalUsageCents: Double + public let renewalDate: Date + public let promoExpiration: Date? + public let updatedAt: Date + + public init(response: PerplexityCreditsResponse, now: Date) { + let recurring = response.creditGrants.filter { $0.type == "recurring" } + let promotional = response.creditGrants.filter { + $0.type == "promotional" && ($0.expiresAtTs ?? .infinity) > now.timeIntervalSince1970 + } + let purchased = response.creditGrants.filter { $0.type == "purchased" } + + // All timestamps from the Perplexity API are Unix seconds (verified Feb 2026). + let recurringSum = max(0, recurring.reduce(0.0) { $0 + $1.amountCents }) + let promoSum = max(0, promotional.reduce(0.0) { $0 + $1.amountCents }) + // Purchased credits may appear in the top-level field, in the credit_grants + // array (type == "purchased"), or both. Take whichever is larger to avoid + // double-counting while still catching either source. + let purchasedFromGrants = max(0, purchased.reduce(0.0) { $0 + $1.amountCents }) + let purchasedFromField = max(0, response.currentPeriodPurchasedCents) + let purchasedSum = max(purchasedFromGrants, purchasedFromField) + + // Waterfall attribution: recurring → purchased → promotional + var remaining = response.totalUsageCents + let usedFromRecurring = min(remaining, recurringSum); remaining -= usedFromRecurring + let usedFromPurchased = min(remaining, purchasedSum); remaining -= usedFromPurchased + let usedFromPromo = min(remaining, promoSum) + + self.recurringTotal = recurringSum + self.recurringUsed = usedFromRecurring + self.promoTotal = promoSum + self.promoUsed = usedFromPromo + self.purchasedTotal = purchasedSum + self.purchasedUsed = usedFromPurchased + self.balanceCents = response.balanceCents + self.totalUsageCents = response.totalUsageCents + self.renewalDate = Date(timeIntervalSince1970: response.renewalDateTs) + self.promoExpiration = promotional + .compactMap { $0.expiresAtTs.map { Date(timeIntervalSince1970: $0) } } + .min() + self.updatedAt = now + } + + /// Infer plan name from recurring credit allotment. + /// Free = 0, Pro = small pool (~500–1000), Max = 10,000+. + public var planName: String? { + if self.recurringTotal <= 0 { return nil } + if self.recurringTotal < 5000 { return "Pro" } + return "Max" + } + + private static let promoExpiryFormatter: DateFormatter = { + let fmt = DateFormatter() + fmt.dateFormat = "MMM d" + return fmt + }() +} + +extension PerplexityUsageSnapshot { + public func toUsageSnapshot() -> UsageSnapshot { + // Primary: recurring (monthly) credits + let hasFallbackCredits = self.promoTotal > 0 || self.purchasedTotal > 0 + let primaryWindow: RateWindow? = { + if self.recurringTotal > 0 { + let primaryPercent = min(100, max(0, self.recurringUsed / self.recurringTotal * 100)) + return RateWindow( + usedPercent: primaryPercent, + windowMinutes: nil, + resetsAt: self.renewalDate, + resetDescription: "\(Int(self.recurringUsed.rounded()))/\(Int(self.recurringTotal)) credits") + } + if hasFallbackCredits { + // When recurring is absent but bonus/purchased credits remain, omit the fake 0/0 primary lane + // so automatic menu-bar rendering can fall through to the usable pool. + return nil + } + return RateWindow( + usedPercent: 100, + windowMinutes: nil, + resetsAt: self.renewalDate, + resetDescription: "0/0 credits") + }() + + // Secondary: promotional bonus credits — always shown. + // usedPercent=100 when promoTotal==0 so the bar renders empty rather than full. + let promoPercent = self.promoTotal > 0 + ? min(100, max(0, self.promoUsed / self.promoTotal * 100)) + : 100.0 + var promoDesc = "\(Int(promoUsed.rounded()))/\(Int(self.promoTotal)) bonus" + if let expiry = promoExpiration { + promoDesc += " \u{00b7} exp. \(Self.promoExpiryFormatter.string(from: expiry))" + } + let secondary = RateWindow( + usedPercent: promoPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: promoDesc) + + // Tertiary: on-demand purchased credits — always shown. + // usedPercent=100 when purchasedTotal==0 so the bar renders empty rather than full. + let purchasedPercent = self.purchasedTotal > 0 + ? min(100, max(0, self.purchasedUsed / self.purchasedTotal * 100)) + : 100.0 + let tertiary = RateWindow( + usedPercent: purchasedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(Int(purchasedUsed.rounded()))/\(Int(self.purchasedTotal)) credits") + + let identity = ProviderIdentitySnapshot( + providerID: .perplexity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: planName) + + return UsageSnapshot( + primary: primaryWindow, + secondary: secondary, + tertiary: tertiary, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift b/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift index 5b2048236..f3009d24c 100644 --- a/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift +++ b/Sources/CodexBarCore/Providers/ProviderCandidateRetryRunner.swift @@ -1,6 +1,6 @@ import Foundation -enum ProviderCandidateRetryRunnerError: Error, Sendable { +enum ProviderCandidateRetryRunnerError: Error { case noCandidates } diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index d7a3669d4..21e058dfc 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -54,15 +54,21 @@ public enum ProviderDescriptorRegistry { private static let store = Store() private static let descriptorsByID: [UsageProvider: ProviderDescriptor] = [ .codex: CodexProviderDescriptor.descriptor, + .openai: OpenAIAPIProviderDescriptor.descriptor, + .azureopenai: AzureOpenAIProviderDescriptor.descriptor, .claude: ClaudeProviderDescriptor.descriptor, .cursor: CursorProviderDescriptor.descriptor, .opencode: OpenCodeProviderDescriptor.descriptor, + .opencodego: OpenCodeGoProviderDescriptor.descriptor, + .alibaba: AlibabaCodingPlanProviderDescriptor.descriptor, + .alibabatokenplan: AlibabaTokenPlanProviderDescriptor.descriptor, .factory: FactoryProviderDescriptor.descriptor, .gemini: GeminiProviderDescriptor.descriptor, .antigravity: AntigravityProviderDescriptor.descriptor, .copilot: CopilotProviderDescriptor.descriptor, .zai: ZaiProviderDescriptor.descriptor, .minimax: MiniMaxProviderDescriptor.descriptor, + .manus: ManusProviderDescriptor.descriptor, .kimi: KimiProviderDescriptor.descriptor, .kilo: KiloProviderDescriptor.descriptor, .kiro: KiroProviderDescriptor.descriptor, @@ -70,11 +76,31 @@ public enum ProviderDescriptorRegistry { .augment: AugmentProviderDescriptor.descriptor, .jetbrains: JetBrainsProviderDescriptor.descriptor, .kimik2: KimiK2ProviderDescriptor.descriptor, + .moonshot: MoonshotProviderDescriptor.descriptor, .amp: AmpProviderDescriptor.descriptor, + .t3chat: T3ChatProviderDescriptor.descriptor, .ollama: OllamaProviderDescriptor.descriptor, .synthetic: SyntheticProviderDescriptor.descriptor, .openrouter: OpenRouterProviderDescriptor.descriptor, + .elevenlabs: ElevenLabsProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, + .windsurf: WindsurfProviderDescriptor.descriptor, + .perplexity: PerplexityProviderDescriptor.descriptor, + .mimo: MiMoProviderDescriptor.descriptor, + .doubao: DoubaoProviderDescriptor.descriptor, + .abacus: AbacusProviderDescriptor.descriptor, + .mistral: MistralProviderDescriptor.descriptor, + .deepseek: DeepSeekProviderDescriptor.descriptor, + .codebuff: CodebuffProviderDescriptor.descriptor, + .crof: CrofProviderDescriptor.descriptor, + .venice: VeniceProviderDescriptor.descriptor, + .commandcode: CommandCodeProviderDescriptor.descriptor, + .stepfun: StepFunProviderDescriptor.descriptor, + .bedrock: BedrockProviderDescriptor.descriptor, + .grok: GrokProviderDescriptor.descriptor, + .groq: GroqProviderDescriptor.descriptor, + .llmproxy: LLMProxyProviderDescriptor.descriptor, + .deepgram: DeepgramProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift new file mode 100644 index 000000000..4ce2fad2f --- /dev/null +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -0,0 +1,415 @@ +import Foundation + +public struct ProviderDiagnosticBatchExport: Codable, Sendable { + public let schemaVersion: String + public let timestamp: Date + public let diagnostics: [ProviderDiagnosticExport] + + public init( + schemaVersion: String = "1.0", + timestamp: Date, + diagnostics: [ProviderDiagnosticExport]) + { + self.schemaVersion = schemaVersion + self.timestamp = timestamp + self.diagnostics = diagnostics + } +} + +public struct ProviderDiagnosticExport: Codable, Sendable { + public let schemaVersion: String + public let timestamp: Date + public let provider: String + public let displayName: String + public let source: String + public let sourceMode: String + public let auth: ProviderDiagnosticAuthSummary + public let usage: ProviderDiagnosticUsageSummary? + public let fetchAttempts: [ProviderDiagnosticFetchAttempt] + public let error: ProviderDiagnosticError? + public let settings: ProviderDiagnosticSettingsSummary + public let details: ProviderDiagnosticDetails? + + public init( + schemaVersion: String = "1.0", + timestamp: Date, + provider: String, + displayName: String, + source: String, + sourceMode: String, + auth: ProviderDiagnosticAuthSummary, + usage: ProviderDiagnosticUsageSummary?, + fetchAttempts: [ProviderDiagnosticFetchAttempt], + error: ProviderDiagnosticError?, + settings: ProviderDiagnosticSettingsSummary, + details: ProviderDiagnosticDetails?) + { + self.schemaVersion = schemaVersion + self.timestamp = timestamp + self.provider = provider + self.displayName = displayName + self.source = source + self.sourceMode = sourceMode + self.auth = auth + self.usage = usage + self.fetchAttempts = fetchAttempts + self.error = error + self.settings = settings + self.details = details + } +} + +public struct ProviderDiagnosticAuthSummary: Codable, Sendable { + public let configured: Bool + public let modes: [String] + + public init(configured: Bool, modes: [String]) { + self.configured = configured + self.modes = modes + } + + public func resolved(with outcome: ProviderFetchOutcome) -> ProviderDiagnosticAuthSummary { + var resolvedModes = self.modes + if outcome.isSuccess { + for attempt in outcome.attempts where attempt.wasAvailable { + let mode = ProviderDiagnosticFetchAttempt.kindLabel(attempt.kind) + if !resolvedModes.contains(mode) { + resolvedModes.append(mode) + } + } + } + let configured = self.configured || outcome.isSuccess + return ProviderDiagnosticAuthSummary(configured: configured, modes: resolvedModes) + } +} + +public struct ProviderDiagnosticUsageSummary: Codable, Sendable { + public let updatedAt: Date + public let windows: [ProviderDiagnosticRateWindow] + public let extraWindowCount: Int + public let providerCostPresent: Bool + public let providerSpecificData: [String] + + public init(from snapshot: UsageSnapshot) { + var windows: [ProviderDiagnosticRateWindow] = [] + if let primary = snapshot.primary { + windows.append(ProviderDiagnosticRateWindow(label: "primary", window: primary)) + } + if let secondary = snapshot.secondary { + windows.append(ProviderDiagnosticRateWindow(label: "secondary", window: secondary)) + } + if let tertiary = snapshot.tertiary { + windows.append(ProviderDiagnosticRateWindow(label: "tertiary", window: tertiary)) + } + for extra in snapshot.extraRateWindows ?? [] { + windows.append(ProviderDiagnosticRateWindow(label: extra.title, window: extra.window)) + } + + var providerSpecificData: [String] = [] + if snapshot.kiroUsage != nil { providerSpecificData.append("kiroUsage") } + if snapshot.zaiUsage != nil { providerSpecificData.append("zaiUsage") } + if snapshot.minimaxUsage != nil { providerSpecificData.append("minimaxUsage") } + if snapshot.deepseekUsage != nil { providerSpecificData.append("deepseekUsage") } + if snapshot.openRouterUsage != nil { providerSpecificData.append("openRouterUsage") } + if snapshot.openAIAPIUsage != nil { providerSpecificData.append("openAIAPIUsage") } + if snapshot.claudeAdminAPIUsage != nil { providerSpecificData.append("claudeAdminAPIUsage") } + if snapshot.mistralUsage != nil { providerSpecificData.append("mistralUsage") } + if snapshot.deepgramUsage != nil { providerSpecificData.append("deepgramUsage") } + if snapshot.cursorRequests != nil { providerSpecificData.append("cursorRequests") } + + self.updatedAt = snapshot.updatedAt + self.windows = windows + self.extraWindowCount = snapshot.extraRateWindows?.count ?? 0 + self.providerCostPresent = snapshot.providerCost != nil + self.providerSpecificData = providerSpecificData.sorted() + } +} + +public struct ProviderDiagnosticRateWindow: Codable, Sendable { + public let label: String + public let usedPercent: Double + public let windowMinutes: Int? + public let resetsAt: Date? + public let hasResetDescription: Bool + public let nextRegenPercent: Double? + + public init(label: String, window: RateWindow) { + self.label = label + self.usedPercent = window.usedPercent + self.windowMinutes = window.windowMinutes + self.resetsAt = window.resetsAt + self.hasResetDescription = window.resetDescription?.isEmpty == false + self.nextRegenPercent = window.nextRegenPercent + } +} + +public struct ProviderDiagnosticFetchAttempt: Codable, Sendable { + public let kind: String + public let wasAvailable: Bool + public let errorCategory: String? + + public init( + kind: String, + wasAvailable: Bool, + errorCategory: String?) + { + self.kind = kind + self.wasAvailable = wasAvailable + self.errorCategory = errorCategory + } + + public init(from attempt: ProviderFetchAttempt) { + self.kind = Self.kindLabel(attempt.kind) + self.wasAvailable = attempt.wasAvailable + self.errorCategory = attempt.errorDescription.map(Self.errorCategoryLabel) + } + + public static func kindLabel(_ kind: ProviderFetchKind) -> String { + switch kind { + case .cli: "cli" + case .web: "web" + case .oauth: "oauth" + case .apiToken: "api" + case .localProbe: "local" + case .webDashboard: "web" + } + } + + public static func errorCategoryLabel(_ description: String?) -> String { + guard let desc = description?.lowercased() else { return "unknown" } + if desc.contains("network") || desc.contains("timeout") || desc.contains("connection") { + return "network" + } + if desc.contains("auth") || desc.contains("credential") || desc.contains("token") || desc.contains("cookie") || + desc.contains("api key") || desc.contains("key not configured") || desc.contains("missing key") + { + return "auth" + } + if desc.contains("source") || desc.contains("not supported") || desc.contains("unavailable") { + return "configuration" + } + if desc.contains("api") || desc.contains("http") || desc.contains("404") || desc.contains("403") { + return "api" + } + if desc.contains("parse") || desc.contains("format") || desc.contains("decode") { + return "parse" + } + return "unknown" + } +} + +public struct ProviderDiagnosticError: Codable, Sendable { + public let category: String + public let safeDescription: String + + public init(category: String, safeDescription: String) { + self.category = category + self.safeDescription = safeDescription + } + + public init(from error: Error, authConfigured: Bool) { + self.category = Self.errorCategory(error, authConfigured: authConfigured) + self.safeDescription = Self.safeDescription(category: self.category) + } + + private static func errorCategory(_ error: Error, authConfigured: Bool) -> String { + if case ProviderFetchError.noAvailableStrategy = error { + return authConfigured ? "configuration" : "auth" + } + if let minimaxError = error as? MiniMaxUsageError { + switch minimaxError { + case .networkError: return "network" + case .invalidCredentials: return "auth" + case .apiError: return "api" + case .parseFailed: return "parse" + } + } + if error is MiniMaxSettingsError || error is MiniMaxAPISettingsError { return "auth" } + return ProviderDiagnosticFetchAttempt.errorCategoryLabel(error.localizedDescription) + } + + private static func safeDescription(category: String) -> String { + switch category { + case "network": + "Network error - check your connection" + case "auth": + "Authentication or setup issue - check provider credentials" + case "api": + "API error - service returned an unexpected response" + case "parse": + "Parse error - unexpected response format" + case "configuration": + "Configuration issue - check provider source and settings" + default: + "An unexpected error occurred" + } + } +} + +public struct ProviderDiagnosticSettingsSummary: Codable, Sendable { + public let sourceMode: String + public let apiRegion: String? + + public init(sourceMode: ProviderSourceMode, apiRegion: String? = nil) { + self.sourceMode = sourceMode.rawValue + self.apiRegion = apiRegion + } +} + +public enum ProviderDiagnosticDetails: Codable, Sendable { + case minimax(MiniMaxDiagnosticDetails) + + private enum CodingKeys: String, CodingKey { + case type + case minimax + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "minimax": + self = try .minimax(container.decode(MiniMaxDiagnosticDetails.self, forKey: .minimax)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown provider diagnostic detail type") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .minimax(details): + try container.encode("minimax", forKey: .type) + try container.encode(details, forKey: .minimax) + } + } +} + +public struct MiniMaxDiagnosticDetails: Codable, Sendable { + public let planName: String? + public let availablePrompts: Int? + public let currentPrompts: Int? + public let remainingPrompts: Int? + public let windowMinutes: Int? + public let usedPercent: Double? + public let resetsAt: Date? + public let services: [MiniMaxDiagnosticServiceUsage]? + public let billingSummaryPresent: Bool + + public init(from snapshot: MiniMaxUsageSnapshot) { + self.planName = snapshot.planName + self.availablePrompts = snapshot.availablePrompts + self.currentPrompts = snapshot.currentPrompts + self.remainingPrompts = snapshot.remainingPrompts + self.windowMinutes = snapshot.windowMinutes + self.usedPercent = snapshot.usedPercent + self.resetsAt = snapshot.resetsAt + self.services = snapshot.services?.map { MiniMaxDiagnosticServiceUsage(from: $0) } + self.billingSummaryPresent = snapshot.billingSummary != nil + } +} + +public struct MiniMaxDiagnosticServiceUsage: Codable, Sendable { + public let displayName: String + public let percent: Double + public let windowType: String + public let resetsAt: Date? + public let hasResetDescription: Bool + + public init(from service: MiniMaxServiceUsage) { + self.displayName = service.displayName + self.percent = service.percent + self.windowType = service.windowType + self.resetsAt = service.resetsAt + self.hasResetDescription = !service.resetDescription.isEmpty + } +} + +public enum ProviderDiagnosticExportBuilder { + public struct Input: Sendable { + public let provider: UsageProvider + public let descriptor: ProviderDescriptor + public let outcome: ProviderFetchOutcome + public let sourceMode: ProviderSourceMode + public let settings: ProviderSettingsSnapshot? + public let auth: ProviderDiagnosticAuthSummary + + public init( + provider: UsageProvider, + descriptor: ProviderDescriptor, + outcome: ProviderFetchOutcome, + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot?, + auth: ProviderDiagnosticAuthSummary) + { + self.provider = provider + self.descriptor = descriptor + self.outcome = outcome + self.sourceMode = sourceMode + self.settings = settings + self.auth = auth + } + } + + public static func build(_ input: Input) -> ProviderDiagnosticExport { + let resolvedAuth = input.auth.resolved(with: input.outcome) + let usage = input.outcome.usageSnapshot.map { ProviderDiagnosticUsageSummary(from: $0) } + let error = input.outcome.failureError + .map { ProviderDiagnosticError(from: $0, authConfigured: resolvedAuth.configured) } + let settingsSummary = ProviderDiagnosticSettingsSummary( + sourceMode: input.sourceMode, + apiRegion: Self.safeAPIRegion(provider: input.provider, settings: input.settings)) + + return ProviderDiagnosticExport( + timestamp: Date(), + provider: input.provider.rawValue, + displayName: input.descriptor.metadata.displayName, + source: input.outcome.sourceLabel, + sourceMode: input.sourceMode.rawValue, + auth: resolvedAuth, + usage: usage, + fetchAttempts: input.outcome.attempts.map { ProviderDiagnosticFetchAttempt(from: $0) }, + error: error, + settings: settingsSummary, + details: Self.details(provider: input.provider, outcome: input.outcome)) + } + + private static func safeAPIRegion(provider: UsageProvider, settings: ProviderSettingsSnapshot?) -> String? { + guard provider == .minimax else { return nil } + return settings?.minimax?.apiRegion.rawValue ?? "global" + } + + private static func details(provider: UsageProvider, outcome: ProviderFetchOutcome) -> ProviderDiagnosticDetails? { + guard provider == .minimax, + let usage = outcome.usageSnapshot?.minimaxUsage + else { + return nil + } + return .minimax(MiniMaxDiagnosticDetails(from: usage)) + } +} + +extension ProviderFetchOutcome { + fileprivate var isSuccess: Bool { + guard case .success = self.result else { return false } + return true + } + + fileprivate var sourceLabel: String { + guard case let .success(result) = result else { return "failed" } + return result.sourceLabel + } + + fileprivate var usageSnapshot: UsageSnapshot? { + guard case let .success(result) = result else { return nil } + return result.usage + } + + fileprivate var failureError: Error? { + guard case let .failure(error) = result else { return nil } + return error + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index cadbdc813..e99a2c69f 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -18,9 +18,13 @@ public enum ProviderSourceMode: String, CaseIterable, Sendable, Codable { } public struct ProviderFetchContext: Sendable { + public typealias TokenAccountTokenUpdater = @Sendable (UsageProvider, UUID, String) async -> Void + public typealias ProviderManualTokenUpdater = @Sendable (UsageProvider, String) async -> Void + public let runtime: ProviderRuntime public let sourceMode: ProviderSourceMode public let includeCredits: Bool + public let includeOptionalUsage: Bool public let webTimeout: TimeInterval public let webDebugDumpHTML: Bool public let verbose: Bool @@ -29,11 +33,16 @@ public struct ProviderFetchContext: Sendable { public let fetcher: UsageFetcher public let claudeFetcher: any ClaudeUsageFetching public let browserDetection: BrowserDetection + public let selectedTokenAccountID: UUID? + public let tokenAccountTokenUpdater: TokenAccountTokenUpdater? + public let providerManualTokenUpdater: ProviderManualTokenUpdater? + public let costUsageHistoryDays: Int public init( runtime: ProviderRuntime, sourceMode: ProviderSourceMode, includeCredits: Bool, + includeOptionalUsage: Bool = true, webTimeout: TimeInterval, webDebugDumpHTML: Bool, verbose: Bool, @@ -41,11 +50,16 @@ public struct ProviderFetchContext: Sendable { settings: ProviderSettingsSnapshot?, fetcher: UsageFetcher, claudeFetcher: any ClaudeUsageFetching, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + selectedTokenAccountID: UUID? = nil, + tokenAccountTokenUpdater: TokenAccountTokenUpdater? = nil, + providerManualTokenUpdater: ProviderManualTokenUpdater? = nil, + costUsageHistoryDays: Int = 30) { self.runtime = runtime self.sourceMode = sourceMode self.includeCredits = includeCredits + self.includeOptionalUsage = includeOptionalUsage self.webTimeout = webTimeout self.webDebugDumpHTML = webDebugDumpHTML self.verbose = verbose @@ -54,6 +68,10 @@ public struct ProviderFetchContext: Sendable { self.fetcher = fetcher self.claudeFetcher = claudeFetcher self.browserDetection = browserDetection + self.selectedTokenAccountID = selectedTokenAccountID + self.tokenAccountTokenUpdater = tokenAccountTokenUpdater + self.providerManualTokenUpdater = providerManualTokenUpdater + self.costUsageHistoryDays = max(1, min(365, costUsageHistoryDays)) } } @@ -162,6 +180,7 @@ public struct ProviderFetchPipeline: Sendable { let strategies = await self.resolveStrategies(context) var attempts: [ProviderFetchAttempt] = [] attempts.reserveCapacity(strategies.count) + var lastAvailableError: Error? for strategy in strategies { let available = await strategy.isAvailable(context) @@ -184,6 +203,7 @@ public struct ProviderFetchPipeline: Sendable { errorDescription: nil)) return ProviderFetchOutcome(result: .success(result), attempts: attempts) } catch { + lastAvailableError = error attempts.append(ProviderFetchAttempt( strategyID: strategy.id, kind: strategy.kind, @@ -196,7 +216,7 @@ public struct ProviderFetchPipeline: Sendable { } } - let error = ProviderFetchError.noAvailableStrategy(provider) + let error = lastAvailableError ?? ProviderFetchError.noAvailableStrategy(provider) return ProviderFetchOutcome(result: .failure(error), attempts: attempts) } } diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index e9bf84f9e..71a29e7f5 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -8,16 +8,28 @@ public struct ProviderSettingsSnapshot: Sendable { claude: ClaudeProviderSettings? = nil, cursor: CursorProviderSettings? = nil, opencode: OpenCodeProviderSettings? = nil, + opencodego: OpenCodeProviderSettings? = nil, + alibaba: AlibabaCodingPlanProviderSettings? = nil, + alibabaTokenPlan: AlibabaTokenPlanProviderSettings? = nil, factory: FactoryProviderSettings? = nil, minimax: MiniMaxProviderSettings? = nil, + manus: ManusProviderSettings? = nil, zai: ZaiProviderSettings? = nil, copilot: CopilotProviderSettings? = nil, kilo: KiloProviderSettings? = nil, kimi: KimiProviderSettings? = nil, augment: AugmentProviderSettings? = nil, + moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings? = nil, + t3chat: T3ChatProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, - jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot + jetbrains: JetBrainsProviderSettings? = nil, + windsurf: WindsurfProviderSettings? = nil, + perplexity: PerplexityProviderSettings? = nil, + mimo: MiMoProviderSettings? = nil, + abacus: AbacusProviderSettings? = nil, + mistral: MistralProviderSettings? = nil, + stepfun: StepFunProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: debugMenuEnabled, @@ -26,31 +38,52 @@ public struct ProviderSettingsSnapshot: Sendable { claude: claude, cursor: cursor, opencode: opencode, + opencodego: opencodego, + alibaba: alibaba, + alibabaTokenPlan: alibabaTokenPlan, factory: factory, minimax: minimax, + manus: manus, zai: zai, copilot: copilot, kilo: kilo, kimi: kimi, augment: augment, + moonshot: moonshot, amp: amp, + t3chat: t3chat, ollama: ollama, - jetbrains: jetbrains) + jetbrains: jetbrains, + windsurf: windsurf, + perplexity: perplexity, + mimo: mimo, + abacus: abacus, + mistral: mistral, + stepfun: stepfun) } public struct CodexProviderSettings: Sendable { public let usageDataSource: CodexUsageDataSource public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? + public let managedAccountStoreUnreadable: Bool + public let managedAccountTargetUnavailable: Bool + public let dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] public init( usageDataSource: CodexUsageDataSource, cookieSource: ProviderCookieSource, - manualCookieHeader: String?) + manualCookieHeader: String?, + managedAccountStoreUnreadable: Bool = false, + managedAccountTargetUnavailable: Bool = false, + dashboardAuthorityKnownOwners: [CodexDashboardKnownOwnerCandidate] = []) { self.usageDataSource = usageDataSource self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader + self.managedAccountStoreUnreadable = managedAccountStoreUnreadable + self.managedAccountTargetUnavailable = managedAccountTargetUnavailable + self.dashboardAuthorityKnownOwners = dashboardAuthorityKnownOwners } } @@ -59,17 +92,20 @@ public struct ProviderSettingsSnapshot: Sendable { public let webExtrasEnabled: Bool public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? + public let organizationID: String? public init( usageDataSource: ClaudeUsageDataSource, webExtrasEnabled: Bool, cookieSource: ProviderCookieSource, - manualCookieHeader: String?) + manualCookieHeader: String?, + organizationID: String? = nil) { self.usageDataSource = usageDataSource self.webExtrasEnabled = webExtrasEnabled self.cookieSource = cookieSource self.manualCookieHeader = manualCookieHeader + self.organizationID = organizationID } } @@ -95,6 +131,32 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct AlibabaCodingPlanProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + public let apiRegion: AlibabaCodingPlanAPIRegion + + public init( + cookieSource: ProviderCookieSource = .auto, + manualCookieHeader: String? = nil, + apiRegion: AlibabaCodingPlanAPIRegion = .international) + { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + self.apiRegion = apiRegion + } + } + + public struct AlibabaTokenPlanProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource = .auto, manualCookieHeader: String? = nil) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct FactoryProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -121,6 +183,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct ManusProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct ZaiProviderSettings: Sendable { public let apiRegion: ZaiAPIRegion @@ -130,7 +202,13 @@ public struct ProviderSettingsSnapshot: Sendable { } public struct CopilotProviderSettings: Sendable { - public init() {} + public let apiToken: String? + public let enterpriseHost: String? + + public init(apiToken: String? = nil, enterpriseHost: String? = nil) { + self.apiToken = apiToken + self.enterpriseHost = enterpriseHost + } } public struct KiloProviderSettings: Sendable { @@ -163,6 +241,14 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct MoonshotProviderSettings: Sendable { + public let region: MoonshotRegion? + + public init(region: MoonshotRegion? = nil) { + self.region = region + } + } + public struct JetBrainsProviderSettings: Sendable { public let ideBasePath: String? @@ -181,6 +267,26 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct T3ChatProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct CommandCodeProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + public struct OllamaProviderSettings: Sendable { public let cookieSource: ProviderCookieSource public let manualCookieHeader: String? @@ -191,22 +297,110 @@ public struct ProviderSettingsSnapshot: Sendable { } } + public struct WindsurfProviderSettings: Sendable { + public let usageDataSource: WindsurfUsageDataSource + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init( + usageDataSource: WindsurfUsageDataSource, + cookieSource: ProviderCookieSource, + manualCookieHeader: String?) + { + self.usageDataSource = usageDataSource + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct PerplexityProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct MiMoProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct AbacusProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct MistralProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + } + + public struct StepFunProviderSettings: Sendable { + public let cookieSource: ProviderCookieSource + public let manualToken: String + public let username: String + public let password: String + + public init( + cookieSource: ProviderCookieSource = .auto, + manualToken: String = "", + username: String = "", + password: String = "") + { + self.cookieSource = cookieSource + self.manualToken = manualToken + self.username = username + self.password = password + } + } + public let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? public let claude: ClaudeProviderSettings? public let cursor: CursorProviderSettings? public let opencode: OpenCodeProviderSettings? + public let opencodego: OpenCodeProviderSettings? + public let alibaba: AlibabaCodingPlanProviderSettings? + public let alibabaTokenPlan: AlibabaTokenPlanProviderSettings? public let factory: FactoryProviderSettings? public let minimax: MiniMaxProviderSettings? + public let manus: ManusProviderSettings? public let zai: ZaiProviderSettings? public let copilot: CopilotProviderSettings? public let kilo: KiloProviderSettings? public let kimi: KimiProviderSettings? public let augment: AugmentProviderSettings? + public let moonshot: MoonshotProviderSettings? public let amp: AmpProviderSettings? + public let t3chat: T3ChatProviderSettings? + public let commandcode: CommandCodeProviderSettings? public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? + public let windsurf: WindsurfProviderSettings? + public let perplexity: PerplexityProviderSettings? + public let mimo: MiMoProviderSettings? + public let abacus: AbacusProviderSettings? + public let mistral: MistralProviderSettings? + public let stepfun: StepFunProviderSettings? public var jetbrainsIDEBasePath: String? { self.jetbrains?.ideBasePath @@ -219,16 +413,29 @@ public struct ProviderSettingsSnapshot: Sendable { claude: ClaudeProviderSettings?, cursor: CursorProviderSettings?, opencode: OpenCodeProviderSettings?, + opencodego: OpenCodeProviderSettings?, + alibaba: AlibabaCodingPlanProviderSettings?, + alibabaTokenPlan: AlibabaTokenPlanProviderSettings? = nil, factory: FactoryProviderSettings?, minimax: MiniMaxProviderSettings?, + manus: ManusProviderSettings?, zai: ZaiProviderSettings?, copilot: CopilotProviderSettings?, kilo: KiloProviderSettings?, kimi: KimiProviderSettings?, augment: AugmentProviderSettings?, + moonshot: MoonshotProviderSettings? = nil, amp: AmpProviderSettings?, + t3chat: T3ChatProviderSettings? = nil, + commandcode: CommandCodeProviderSettings? = nil, ollama: OllamaProviderSettings?, - jetbrains: JetBrainsProviderSettings? = nil) + jetbrains: JetBrainsProviderSettings? = nil, + windsurf: WindsurfProviderSettings? = nil, + perplexity: PerplexityProviderSettings? = nil, + mimo: MiMoProviderSettings? = nil, + abacus: AbacusProviderSettings? = nil, + mistral: MistralProviderSettings? = nil, + stepfun: StepFunProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive @@ -236,16 +443,29 @@ public struct ProviderSettingsSnapshot: Sendable { self.claude = claude self.cursor = cursor self.opencode = opencode + self.opencodego = opencodego + self.alibaba = alibaba + self.alibabaTokenPlan = alibabaTokenPlan self.factory = factory self.minimax = minimax + self.manus = manus self.zai = zai self.copilot = copilot self.kilo = kilo self.kimi = kimi self.augment = augment + self.moonshot = moonshot self.amp = amp + self.t3chat = t3chat + self.commandcode = commandcode self.ollama = ollama self.jetbrains = jetbrains + self.windsurf = windsurf + self.perplexity = perplexity + self.mimo = mimo + self.abacus = abacus + self.mistral = mistral + self.stepfun = stepfun } } @@ -254,16 +474,29 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case claude(ProviderSettingsSnapshot.ClaudeProviderSettings) case cursor(ProviderSettingsSnapshot.CursorProviderSettings) case opencode(ProviderSettingsSnapshot.OpenCodeProviderSettings) + case opencodego(ProviderSettingsSnapshot.OpenCodeProviderSettings) + case alibaba(ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings) + case alibabaTokenPlan(ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings) case factory(ProviderSettingsSnapshot.FactoryProviderSettings) case minimax(ProviderSettingsSnapshot.MiniMaxProviderSettings) + case manus(ProviderSettingsSnapshot.ManusProviderSettings) case zai(ProviderSettingsSnapshot.ZaiProviderSettings) case copilot(ProviderSettingsSnapshot.CopilotProviderSettings) case kilo(ProviderSettingsSnapshot.KiloProviderSettings) case kimi(ProviderSettingsSnapshot.KimiProviderSettings) case augment(ProviderSettingsSnapshot.AugmentProviderSettings) + case moonshot(ProviderSettingsSnapshot.MoonshotProviderSettings) case amp(ProviderSettingsSnapshot.AmpProviderSettings) + case t3chat(ProviderSettingsSnapshot.T3ChatProviderSettings) + case commandcode(ProviderSettingsSnapshot.CommandCodeProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) + case windsurf(ProviderSettingsSnapshot.WindsurfProviderSettings) + case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings) + case mimo(ProviderSettingsSnapshot.MiMoProviderSettings) + case abacus(ProviderSettingsSnapshot.AbacusProviderSettings) + case mistral(ProviderSettingsSnapshot.MistralProviderSettings) + case stepfun(ProviderSettingsSnapshot.StepFunProviderSettings) } public struct ProviderSettingsSnapshotBuilder: Sendable { @@ -273,38 +506,65 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var claude: ProviderSettingsSnapshot.ClaudeProviderSettings? public var cursor: ProviderSettingsSnapshot.CursorProviderSettings? public var opencode: ProviderSettingsSnapshot.OpenCodeProviderSettings? + public var opencodego: ProviderSettingsSnapshot.OpenCodeProviderSettings? + public var alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings? + public var alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings? public var factory: ProviderSettingsSnapshot.FactoryProviderSettings? public var minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings? + public var manus: ProviderSettingsSnapshot.ManusProviderSettings? public var zai: ProviderSettingsSnapshot.ZaiProviderSettings? public var copilot: ProviderSettingsSnapshot.CopilotProviderSettings? public var kilo: ProviderSettingsSnapshot.KiloProviderSettings? public var kimi: ProviderSettingsSnapshot.KimiProviderSettings? public var augment: ProviderSettingsSnapshot.AugmentProviderSettings? + public var moonshot: ProviderSettingsSnapshot.MoonshotProviderSettings? public var amp: ProviderSettingsSnapshot.AmpProviderSettings? + public var t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings? + public var commandcode: ProviderSettingsSnapshot.CommandCodeProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? + public var windsurf: ProviderSettingsSnapshot.WindsurfProviderSettings? + public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? + public var mimo: ProviderSettingsSnapshot.MiMoProviderSettings? + public var abacus: ProviderSettingsSnapshot.AbacusProviderSettings? + public var mistral: ProviderSettingsSnapshot.MistralProviderSettings? + public var stepfun: ProviderSettingsSnapshot.StepFunProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive } + // swiftlint:disable:next cyclomatic_complexity public mutating func apply(_ contribution: ProviderSettingsSnapshotContribution) { switch contribution { case let .codex(value): self.codex = value case let .claude(value): self.claude = value case let .cursor(value): self.cursor = value case let .opencode(value): self.opencode = value + case let .opencodego(value): self.opencodego = value + case let .alibaba(value): self.alibaba = value + case let .alibabaTokenPlan(value): self.alibabaTokenPlan = value case let .factory(value): self.factory = value case let .minimax(value): self.minimax = value + case let .manus(value): self.manus = value case let .zai(value): self.zai = value case let .copilot(value): self.copilot = value case let .kilo(value): self.kilo = value case let .kimi(value): self.kimi = value case let .augment(value): self.augment = value + case let .moonshot(value): self.moonshot = value case let .amp(value): self.amp = value + case let .t3chat(value): self.t3chat = value + case let .commandcode(value): self.commandcode = value case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value + case let .windsurf(value): self.windsurf = value + case let .perplexity(value): self.perplexity = value + case let .mimo(value): self.mimo = value + case let .abacus(value): self.abacus = value + case let .mistral(value): self.mistral = value + case let .stepfun(value): self.stepfun = value } } @@ -316,15 +576,28 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { claude: self.claude, cursor: self.cursor, opencode: self.opencode, + opencodego: self.opencodego, + alibaba: self.alibaba, + alibabaTokenPlan: self.alibabaTokenPlan, factory: self.factory, minimax: self.minimax, + manus: self.manus, zai: self.zai, copilot: self.copilot, kilo: self.kilo, kimi: self.kimi, augment: self.augment, + moonshot: self.moonshot, amp: self.amp, + t3chat: self.t3chat, + commandcode: self.commandcode, ollama: self.ollama, - jetbrains: self.jetbrains) + jetbrains: self.jetbrains, + windsurf: self.windsurf, + perplexity: self.perplexity, + mimo: self.mimo, + abacus: self.abacus, + mistral: self.mistral, + stepfun: self.stepfun) } } diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index ada9fac8d..cd555a6bd 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -26,6 +26,24 @@ public enum ProviderTokenResolver { self.syntheticResolution(environment: environment)?.token } + public static func openAIAPIToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.openAIAPIResolution(environment: environment)?.token + } + + public static func azureOpenAIToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.azureOpenAIResolution(environment: environment)?.token + } + + public static func claudeAdminAPIToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.claudeAdminAPIResolution(environment: environment)?.token + } + public static func copilotToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.copilotResolution(environment: environment)?.token } @@ -34,6 +52,10 @@ public enum ProviderTokenResolver { self.minimaxTokenResolution(environment: environment)?.token } + public static func alibabaToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.alibabaTokenResolution(environment: environment)?.token + } + public static func minimaxCookie(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { self.minimaxCookieResolution(environment: environment)?.token } @@ -46,6 +68,14 @@ public enum ProviderTokenResolver { self.kimiK2Resolution(environment: environment)?.token } + public static func moonshotToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.moonshotResolution(environment: environment)?.token + } + + public static func ollamaToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.ollamaResolution(environment: environment)?.token + } + public static func kiloToken( environment: [String: String] = ProcessInfo.processInfo.environment, authFileURL: URL? = nil) -> String? @@ -61,6 +91,103 @@ public enum ProviderTokenResolver { self.openRouterResolution(environment: environment)?.token } + public static func elevenLabsToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.elevenLabsResolution(environment: environment)?.token + } + + public static func groqToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.groqResolution(environment: environment)?.token + } + + public static func llmProxyToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.llmProxyResolution(environment: environment)?.token + } + + public static func perplexitySessionToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.perplexityResolution(environment: environment)?.token + } + + public static func deepseekToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.deepseekResolution(environment: environment)?.token + } + + public static func crofToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.crofResolution(environment: environment)?.token + } + + public static func veniceToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.veniceResolution(environment: environment)?.token + } + + public static func stepfunToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.stepfunResolution(environment: environment)?.token + } + + public static func doubaoToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.doubaoResolution(environment: environment)?.token + } + + public static func bedrockAccessKeyID( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.bedrockResolution(environment: environment)?.token + } + + public static func bedrockResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(BedrockSettingsReader.accessKeyID(environment: environment)) + } + + public static func deepseekResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(DeepSeekSettingsReader.apiKey(environment: environment)) + } + + public static func crofResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(CrofSettingsReader.apiKey(environment: environment)) + } + + public static func veniceResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(VeniceSettingsReader.apiKey(environment: environment)) + } + + public static func codebuffToken( + environment: [String: String] = ProcessInfo.processInfo.environment, + authFileURL: URL? = nil) -> String? + { + self.codebuffResolution(environment: environment, authFileURL: authFileURL)?.token + } + + public static func stepfunResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(StepFunSettingsReader.token(environment: environment)) + } + + public static func doubaoResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(DoubaoSettingsReader.apiKey(environment: environment)) + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -73,6 +200,24 @@ public enum ProviderTokenResolver { self.resolveEnv(SyntheticSettingsReader.apiKey(environment: environment)) } + public static func openAIAPIResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(OpenAIAPISettingsReader.apiKey(environment: environment)) + } + + public static func azureOpenAIResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(AzureOpenAISettingsReader.apiKey(environment: environment)) + } + + public static func claudeAdminAPIResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(ClaudeAdminAPISettingsReader.apiKey(environment: environment)) + } + public static func copilotResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -85,6 +230,12 @@ public enum ProviderTokenResolver { self.resolveEnv(MiniMaxAPISettingsReader.apiToken(environment: environment)) } + public static func alibabaTokenResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(AlibabaCodingPlanSettingsReader.apiToken(environment: environment)) + } + public static func minimaxCookieResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -116,6 +267,18 @@ public enum ProviderTokenResolver { self.resolveEnv(KimiK2SettingsReader.apiKey(environment: environment)) } + public static func moonshotResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(MoonshotSettingsReader.apiKey(environment: environment)) + } + + public static func ollamaResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(OllamaAPISettingsReader.apiKey(environment: environment)) + } + public static func kiloResolution( environment: [String: String] = ProcessInfo.processInfo.environment, authFileURL: URL? = nil) -> ProviderTokenResolution? @@ -141,6 +304,74 @@ public enum ProviderTokenResolver { self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment)) } + public static func elevenLabsResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(ElevenLabsSettingsReader.apiKey(environment: environment)) + } + + public static func groqResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(GroqSettingsReader.apiKey(environment: environment)) + } + + public static func llmProxyResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(LLMProxySettingsReader.apiKey(environment: environment)) + } + + public enum DeepgramCredentialKind: Sendable { + case apiKey + case projectID + } + + public static func deepgramResolution( + type: DeepgramCredentialKind, + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + switch type { + case .apiKey: + self.resolveEnv(DeepgramSettingsReader.apiKey(environment: environment))?.token + + case .projectID: + self.resolveEnv(DeepgramSettingsReader.projectID(environment: environment))?.token + } + } + + public static func codebuffResolution( + environment: [String: String] = ProcessInfo.processInfo.environment, + authFileURL: URL? = nil) -> ProviderTokenResolution? + { + if let resolution = self.resolveEnv(CodebuffSettingsReader.apiKey(environment: environment)) { + return resolution + } + if let token = CodebuffSettingsReader.authToken(authFileURL: authFileURL) { + return ProviderTokenResolution(token: token, source: .authFile) + } + return nil + } + + public static func perplexityResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + if let resolution = self.resolveEnv(PerplexitySettingsReader.sessionToken(environment: environment)) { + return resolution + } + #if os(macOS) + do { + let session = try PerplexityCookieImporter.importSession() + if let token = session.sessionToken { + return ProviderTokenResolution(token: token, source: .environment) + } + } catch { + // No browser cookies found, continue to fallback + } + #endif + return nil + } + private static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil @@ -149,8 +380,7 @@ public enum ProviderTokenResolver { if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { - value.removeFirst() - value.removeLast() + value = String(value.dropFirst().dropLast()) } value = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift index 15dd131da..4d133b2a4 100644 --- a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift +++ b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift @@ -1,3 +1,8 @@ +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif import Foundation public enum ProviderVersionDetector { @@ -10,7 +15,8 @@ public enum ProviderVersionDetector { options: TTYCommandRunner.Options( timeout: 5.0, extraArgs: ["--allowed-tools", "", "--version"], - initialDelay: 0.0)).text + initialDelay: 0.0, + useClaudeProbeWorkingDirectory: true)).text let trimmed = TextParsing.stripANSICodes(out).trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } catch { @@ -45,13 +51,19 @@ public enum ProviderVersionDetector { return nil } - private static func run(path: String, args: [String]) -> String? { + static func run(path: String, args: [String], timeout: TimeInterval = 2.0) -> String? { let proc = Process() proc.executableURL = URL(fileURLWithPath: path) proc.arguments = args let out = Pipe() proc.standardOutput = out proc.standardError = Pipe() + proc.standardInput = nil + + let exitSemaphore = DispatchSemaphore(value: 0) + proc.terminationHandler = { _ in + exitSemaphore.signal() + } do { try proc.run() @@ -59,19 +71,9 @@ public enum ProviderVersionDetector { return nil } - let deadline = Date().addingTimeInterval(2.0) - while proc.isRunning, Date() < deadline { - usleep(50000) - } - if proc.isRunning { - proc.terminate() - let killDeadline = Date().addingTimeInterval(0.5) - while proc.isRunning, Date() < killDeadline { - usleep(20000) - } - if proc.isRunning { - kill(proc.processIdentifier, SIGKILL) - } + let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success + if !didExit, !Self.forceExit(proc, exitSemaphore: exitSemaphore) { + return nil } let data = out.fileHandleForReading.readDataToEndOfFile() @@ -82,4 +84,17 @@ public enum ProviderVersionDetector { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } + + private static func forceExit(_ proc: Process, exitSemaphore: DispatchSemaphore) -> Bool { + guard proc.isRunning else { return true } + + proc.terminate() + if exitSemaphore.wait(timeout: .now() + 0.5) == .success { + return true + } + + guard proc.isRunning else { return true } + kill(proc.processIdentifier, SIGKILL) + return exitSemaphore.wait(timeout: .now() + 1.0) == .success + } } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index f48eefe43..e71f36370 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -4,15 +4,21 @@ import SweetCookieKit // swiftformat:disable sortDeclarations public enum UsageProvider: String, CaseIterable, Sendable, Codable { case codex + case openai + case azureopenai case claude case cursor case opencode + case opencodego + case alibaba + case alibabatokenplan case factory case gemini case antigravity case copilot case zai case minimax + case manus case kimi case kilo case kiro @@ -20,24 +26,48 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case augment case jetbrains case kimik2 + case moonshot case amp + case t3chat case ollama case synthetic case warp case openrouter + case elevenlabs + case windsurf + case perplexity + case mimo + case doubao + case abacus + case mistral + case deepseek + case codebuff + case crof + case venice + case commandcode + case stepfun + case bedrock + case grok + case groq + case llmproxy + case deepgram } // swiftformat:enable sortDeclarations public enum IconStyle: Sendable, CaseIterable { case codex + case openai case claude case zai case minimax + case manus case gemini case antigravity case cursor case opencode + case opencodego + case alibaba case factory case copilot case kimi @@ -47,11 +77,31 @@ public enum IconStyle: Sendable, CaseIterable { case vertexai case augment case jetbrains + case moonshot case amp + case t3chat case ollama case synthetic case warp case openrouter + case elevenlabs + case windsurf + case perplexity + case mimo + case doubao + case abacus + case mistral + case deepseek + case codebuff + case crof + case venice + case commandcode + case stepfun + case bedrock + case grok + case groq + case llmproxy + case deepgram case combined } @@ -72,6 +122,8 @@ public struct ProviderMetadata: Sendable { public let browserCookieOrder: BrowserCookieImportOrder? public let dashboardURL: String? public let subscriptionDashboardURL: String? + /// Provider-specific release notes or changelog URL for CLI/provider updates. + public let changelogURL: String? /// Statuspage.io base URL for incident polling (append /api/v2/status.json). public let statusPageURL: String? /// Browser-only status link (no API polling); used when statusPageURL is nil. @@ -96,6 +148,7 @@ public struct ProviderMetadata: Sendable { browserCookieOrder: BrowserCookieImportOrder? = nil, dashboardURL: String?, subscriptionDashboardURL: String? = nil, + changelogURL: String? = nil, statusPageURL: String?, statusLinkURL: String? = nil, statusWorkspaceProductID: String? = nil) @@ -116,6 +169,7 @@ public struct ProviderMetadata: Sendable { self.browserCookieOrder = browserCookieOrder self.dashboardURL = dashboardURL self.subscriptionDashboardURL = subscriptionDashboardURL + self.changelogURL = changelogURL self.statusPageURL = statusPageURL self.statusLinkURL = statusLinkURL self.statusWorkspaceProductID = statusWorkspaceProductID @@ -136,4 +190,34 @@ public enum ProviderBrowserCookieDefaults { nil #endif } + + /// Safari first for Cursor: active sessions often live only there, and Chromium profiles may carry stale tokens. + public static var cursorCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.safari] + Browser.defaultImportOrder.filter { $0 != .safari } + #else + nil + #endif + } + + /// Preserve the legacy Codex prompt behavior: prefer Safari/Chrome/Firefox before + /// probing additional Chromium variants that may trigger Safe Storage prompts. + public static var codexCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + let preferredPrefix: [Browser] = [.safari, .chrome, .firefox] + return preferredPrefix + Browser.defaultImportOrder.filter { !preferredPrefix.contains($0) } + #else + nil + #endif + } + + /// Grok is normally signed in through Chrome; keep this narrow so CLI/live probes do not touch + /// unrelated browser keychains. + public static var grokCookieImportOrder: BrowserCookieImportOrder? { + #if os(macOS) + [.chrome] + #else + nil + #endif + } } diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift new file mode 100644 index 000000000..5724db3b9 --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -0,0 +1,316 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum StepFunProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .stepfun, + metadata: ProviderMetadata( + id: .stepfun, + displayName: "StepFun", + sessionLabel: "5h Window", + weeklyLabel: "Weekly Window", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show StepFun usage", + cliName: "stepfun", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://platform.stepfun.com/plan-usage", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .stepfun, + iconResourceName: "ProviderIcon-stepfun", + color: ProviderColor(red: 0.13, green: 0.59, blue: 0.95)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "StepFun per-day cost history is not available via API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [StepFunWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "stepfun", + aliases: ["step-fun", "sf"], + versionDetector: nil)) + } +} + +struct StepFunWebFetchStrategy: ProviderFetchStrategy { + let id: String = "stepfun.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + context.settings?.stepfun?.cookieSource != .off + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + do { + let resolved = try await Self.resolveToken(context: context, allowCached: true) + let usage = try await StepFunUsageFetcher.fetchUsage(token: resolved.token) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } catch let error where Self.isAuthenticationFailure(error) { + return try await self.recoverFromAuthenticationFailure(context: context, originalError: error) + } + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + // MARK: - Token Resolution + + private struct ResolvedToken { + let token: String + let source: TokenSource + } + + private enum TokenSource { + case manual + case cached + case settingsLogin + case environmentToken + case environmentLogin + } + + private static func resolveToken( + context: ProviderFetchContext, + allowCached: Bool) async throws -> ResolvedToken + { + let settings = context.settings?.stepfun + + // 1. Manual mode: use the token directly from settings + if settings?.cookieSource == .manual { + let manualToken = settings?.manualToken ?? "" + guard !manualToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw StepFunUsageError.missingToken + } + return ResolvedToken( + token: StepFunTokenNormalizer.normalize(manualToken), + source: .manual) + } + + // 2. Cached token from previous login + if allowCached, let cached = CookieHeaderCache.load(provider: .stepfun) { + return ResolvedToken( + token: StepFunTokenNormalizer.normalize(cached.cookieHeader), + source: .cached) + } + + // 3. Username + password from Settings UI → perform full login flow + // (register device → sign in by password → get Oasis-Token) + if let settings, !settings.username.isEmpty, !settings.password.isEmpty { + let token = try await StepFunUsageFetcher.login( + username: settings.username, + password: settings.password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return ResolvedToken(token: token, source: .settingsLogin) + } + + // 4. Direct token from env var + if let token = StepFunSettingsReader.token(environment: context.env) { + return ResolvedToken(token: token, source: .environmentToken) + } + + // 5. Username + password from env vars → perform full login flow + if let username = StepFunSettingsReader.username(environment: context.env), + let password = StepFunSettingsReader.password(environment: context.env) + { + let token = try await StepFunUsageFetcher.login(username: username, password: password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return ResolvedToken(token: token, source: .environmentLogin) + } + + throw StepFunUsageError.missingCredentials + } + + private func recoverFromAuthenticationFailure( + context: ProviderFetchContext, + originalError: Error) async throws -> ProviderFetchResult + { + let resolved = try await Self.resolveToken(context: context, allowCached: true) + let refreshed: String + do { + refreshed = try await StepFunUsageFetcher.refreshToken(token: resolved.token) + } catch { + if let fallback = try await Self.resolvedTokenWithoutStaleCache(context: context, source: resolved.source) { + do { + let usage = try await StepFunUsageFetcher.fetchUsage(token: fallback.token) + await Self.persistRecoveredToken(fallback.token, source: fallback.source, context: context) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } catch { + if !Self.isAuthenticationFailure(error) { + throw error + } + } + } + if let loginToken = try await Self.loginTokenIfAvailable(context: context, source: resolved.source) { + let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } + throw Self.actionableAuthenticationError(for: resolved.source, originalError: originalError) + } + + await Self.persistRecoveredToken(refreshed, source: resolved.source, context: context) + + do { + let usage = try await StepFunUsageFetcher.fetchUsage(token: refreshed) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } catch let retryError where Self.isAuthenticationFailure(retryError) { + if let loginToken = try await Self.loginTokenIfAvailable(context: context, source: resolved.source) { + let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } + throw Self.actionableAuthenticationError(for: resolved.source, originalError: originalError) + } + } + + private static func resolvedTokenWithoutStaleCache( + context: ProviderFetchContext, + source: TokenSource) async throws -> ResolvedToken? + { + guard case .cached = source else { return nil } + CookieHeaderCache.clear(provider: .stepfun) + do { + return try await self.resolveToken(context: context, allowCached: false) + } catch StepFunUsageError.missingCredentials { + return nil + } catch StepFunUsageError.missingToken { + return nil + } + } + + private static func loginTokenIfAvailable( + context: ProviderFetchContext, + source: TokenSource) async throws -> String? + { + if case .manual = source { + return nil + } + + let settings = context.settings?.stepfun + if settings?.cookieSource != .manual, + let settings, + !settings.username.isEmpty, + !settings.password.isEmpty + { + CookieHeaderCache.clear(provider: .stepfun) + let token = try await StepFunUsageFetcher.login( + username: settings.username, + password: settings.password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return token + } + + if let username = StepFunSettingsReader.username(environment: context.env), + let password = StepFunSettingsReader.password(environment: context.env) + { + CookieHeaderCache.clear(provider: .stepfun) + let token = try await StepFunUsageFetcher.login(username: username, password: password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return token + } + + return nil + } + + private static func persistRecoveredToken( + _ token: String, + source: TokenSource, + context: ProviderFetchContext) async + { + switch source { + case .cached, .settingsLogin, .environmentLogin: + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "refresh") + case .manual: + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { + await context.providerManualTokenUpdater?(.stepfun, token) + return + } + await updater(.stepfun, accountID, token) + case .environmentToken: + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { return } + await updater(.stepfun, accountID, token) + } + } + + private static func isAuthenticationFailure(_ error: Error) -> Bool { + guard case let StepFunUsageError.apiError(message) = error else { + return false + } + let lower = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return lower.contains("401") || + lower.contains("403") || + lower.contains("unauthorized") || + lower.contains("unauthenticated") || + lower.contains("invalid credentials") || + lower.contains("invalid token") || + lower.contains("token expired") || + lower.contains("expired token") + } + + private static func actionableAuthenticationError( + for source: TokenSource, + originalError: Error) -> StepFunUsageError + { + let suffix = switch source { + case .manual: + "Refresh the Oasis-Token, or switch StepFun to auto auth with username/password." + case .environmentToken: + "Refresh STEPFUN_TOKEN, or configure STEPFUN_USERNAME and STEPFUN_PASSWORD." + case .cached, .settingsLogin, .environmentLogin: + "Refresh the StepFun credentials and try again." + } + return .apiError("\(Self.authenticationFailureMessage(originalError)). \(suffix)") + } + + private static func authenticationFailureMessage(_ error: Error) -> String { + if case let StepFunUsageError.apiError(message) = error { + return message + } + return error.localizedDescription + } +} + +// MARK: - Token Normalizer + +public enum StepFunTokenNormalizer { + /// Normalize a StepFun token value — extracts the Oasis-Token from a cookie header + /// or returns the raw token value if it's not a cookie header. + public static func normalize(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + + // If it looks like a cookie header, extract Oasis-Token + if trimmed.contains("Oasis-Token=") { + let parts = trimmed.components(separatedBy: "Oasis-Token=") + if parts.count > 1 { + let afterToken = parts[1] + return afterToken.components(separatedBy: ";").first? + .trimmingCharacters(in: .whitespaces) ?? afterToken + } + } + + return trimmed + } +} diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift new file mode 100644 index 000000000..b8a497a6f --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift @@ -0,0 +1,38 @@ +import Foundation + +public struct StepFunSettingsReader: Sendable { + public static let usernameEnvironmentKey = "STEPFUN_USERNAME" + public static let passwordEnvironmentKey = "STEPFUN_PASSWORD" + public static let tokenEnvironmentKey = "STEPFUN_TOKEN" + + public static func username( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.usernameEnvironmentKey]) + } + + public static func password( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.passwordEnvironmentKey]) + } + + public static func token( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.cleaned(environment[self.tokenEnvironmentKey]) + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift new file mode 100644 index 000000000..9c8bca186 --- /dev/null +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -0,0 +1,554 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - API response types + +/// A flexible number type that can decode from both JSON integers and floats. +/// The StepFun API returns `five_hour_usage_left_rate: 1` (int) or `0.99781543` (float). +public struct StepFunFlexibleNumber: Decodable, Sendable { + public let value: Double + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let intVal = try? container.decode(Int.self) { + self.value = Double(intVal) + } else if let doubleVal = try? container.decode(Double.self) { + self.value = doubleVal + } else { + self.value = 0 + } + } + + public init(_ value: Double) { + self.value = value + } +} + +/// A flexible timestamp type that can decode from both JSON strings and integers. +/// The StepFun API returns timestamps as strings like `"1777528800"`. +public struct StepFunFlexibleTimestamp: Decodable, Sendable { + public let value: Int64 + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let strVal = try? container.decode(String.self), let parsed = Int64(strVal) { + self.value = parsed + } else if let intVal = try? container.decode(Int64.self) { + self.value = intVal + } else { + self.value = 0 + } + } + + public init(_ value: Int64) { + self.value = value + } +} + +public struct StepFunRateLimitResponse: Decodable, Sendable { + public let status: Int? + public let code: Int? + public let message: String? + public let desc: String? + public let fiveHourUsageLeftRate: StepFunFlexibleNumber? + public let weeklyUsageLeftRate: StepFunFlexibleNumber? + public let fiveHourUsageResetTime: StepFunFlexibleTimestamp? + public let weeklyUsageResetTime: StepFunFlexibleTimestamp? + + enum CodingKeys: String, CodingKey { + case status + case code + case message + case desc + case fiveHourUsageLeftRate = "five_hour_usage_left_rate" + case weeklyUsageLeftRate = "weekly_usage_left_rate" + case fiveHourUsageResetTime = "five_hour_usage_reset_time" + case weeklyUsageResetTime = "weekly_usage_reset_time" + } + + public var isSuccess: Bool { + self.status == 1 + } +} + +// MARK: - Plan status response types + +struct StepFunPlanStatusResponse: Decodable { + let status: Int? + let subscription: StepFunSubscription? + + var planName: String? { + self.subscription?.name?.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +struct StepFunSubscription: Decodable { + let name: String? + let planType: Int? + let planStatus: Int? + + enum CodingKeys: String, CodingKey { + case name + case planType = "plan_type" + case planStatus = "status" + } +} + +// MARK: - Auth response types + +struct StepFunRegisterDeviceResponse: Decodable { + let accessToken: StepFunTokenPair? + let refreshToken: StepFunTokenPair? +} + +struct StepFunLoginResponse: Decodable { + let accessToken: StepFunTokenPair? + let refreshToken: StepFunTokenPair? +} + +struct StepFunRefreshTokenResponse: Decodable { + let accessToken: StepFunTokenPair? + let refreshToken: StepFunTokenPair? +} + +struct StepFunTokenPair: Decodable { + let raw: String +} + +// MARK: - Domain snapshot + +public struct StepFunUsageSnapshot: Sendable { + public let fiveHourUsageLeftRate: Double + public let weeklyUsageLeftRate: Double + public let fiveHourUsageResetTime: Date + public let weeklyUsageResetTime: Date + public let planName: String? + public let updatedAt: Date + + public init( + fiveHourUsageLeftRate: Double, + weeklyUsageLeftRate: Double, + fiveHourUsageResetTime: Date, + weeklyUsageResetTime: Date, + planName: String? = nil, + updatedAt: Date) + { + self.fiveHourUsageLeftRate = fiveHourUsageLeftRate + self.weeklyUsageLeftRate = weeklyUsageLeftRate + self.fiveHourUsageResetTime = fiveHourUsageResetTime + self.weeklyUsageResetTime = weeklyUsageResetTime + self.planName = planName + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + // Five-hour window: primary + let fiveHourUsedPercent = max(0, min(100, (1.0 - self.fiveHourUsageLeftRate) * 100)) + let fiveHourResetDescription = UsageFormatter.resetDescription(from: self.fiveHourUsageResetTime) + let fiveHourWindow = RateWindow( + usedPercent: fiveHourUsedPercent, + windowMinutes: 300, + resetsAt: self.fiveHourUsageResetTime, + resetDescription: fiveHourResetDescription) + + // Weekly window: secondary + let weeklyUsedPercent = max(0, min(100, (1.0 - self.weeklyUsageLeftRate) * 100)) + let weeklyResetDescription = UsageFormatter.resetDescription(from: self.weeklyUsageResetTime) + let weeklyWindow = RateWindow( + usedPercent: weeklyUsedPercent, + windowMinutes: 10080, + resetsAt: self.weeklyUsageResetTime, + resetDescription: weeklyResetDescription) + + let trimmedPlan = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) + let loginMethod = (trimmedPlan?.isEmpty ?? true) ? "password" : trimmedPlan + + let identity = ProviderIdentitySnapshot( + providerID: .stepfun, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + + return UsageSnapshot( + primary: fiveHourWindow, + secondary: weeklyWindow, + tertiary: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +// MARK: - Errors + +public enum StepFunUsageError: LocalizedError, Sendable { + case missingCredentials + case missingToken + case networkError(String) + case apiError(String) + case parseFailed(String) + case loginFailed(String) + case tokenRefreshFailed(String) + case deviceRegistrationFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing StepFun username or password. Set STEPFUN_USERNAME and STEPFUN_PASSWORD environment variables." + case .missingToken: + "Missing StepFun authentication token." + case let .networkError(message): + "StepFun network error: \(message)" + case let .apiError(message): + "StepFun API error: \(message)" + case let .parseFailed(message): + "Failed to parse StepFun response: \(message)" + case let .loginFailed(message): + "StepFun login failed: \(message)" + case let .tokenRefreshFailed(message): + "StepFun token refresh failed: \(message)" + case let .deviceRegistrationFailed(message): + "StepFun device registration failed: \(message)" + } + } +} + +// MARK: - Fetcher + +public struct StepFunUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.stepfunUsage) + private static let platformURL = URL(string: "https://platform.stepfun.com")! + private static let apiURL = + URL(string: "https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/QueryStepPlanRateLimit")! + private static let planStatusURL = + URL(string: "https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/GetStepPlanStatus")! + private static let registerDeviceURL = + URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RegisterDevice")! + private static let loginURL = + URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/SignInByPassword")! + private static let refreshTokenURL = + URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RefreshToken")! + private static let timeoutSeconds: TimeInterval = 15 + + private static let webID = "c8a1002d2c457e758785a9979832217c7c0b884c" + private static let appID = "10300" + + private static let baseHeaders: [String: String] = [ + "content-type": "application/json", + "oasis-appid": appID, + "oasis-platform": "web", + "oasis-webid": webID, + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36", + ] + + // MARK: - Public API + + /// Perform the full login flow (username + password → Oasis-Token) and return the token. + /// Does NOT fetch usage — the caller should cache the token and then call `fetchUsage(token:)`. + public static func login(username: String, password: String) async throws -> String { + try await self.fullLogin(username: username, password: password) + } + + /// Refresh an existing Oasis-Token and return a fresh access + refresh token pair. + public static func refreshToken(token: String) async throws -> String { + try await self.refreshOasisToken(token: token) + } + + /// Fetch usage data using an existing Oasis-Token (from env var or cached). + public static func fetchUsage(token: String) async throws -> StepFunUsageSnapshot { + guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw StepFunUsageError.missingToken + } + return try await self.queryUsage(token: token) + } + + /// Full login flow: username + password → token, then fetch usage. + public static func fetchUsage(username: String, password: String) async throws -> StepFunUsageSnapshot { + let token = try await self.fullLogin(username: username, password: password) + return try await self.queryUsage(token: token) + } + + // MARK: - Login + + private static func fullLogin(username: String, password: String) async throws -> String { + // Step 1: Get INGRESSCOOKIE by visiting the platform homepage + let (ingressCookie, _) = try await self.getIngressCookie() + + // Step 2: RegisterDevice → get anonymous token + let anonToken = try await self.registerDevice(ingressCookie: ingressCookie) + + // Step 3: SignInByPassword → get authenticated token + return try await self.signInByPassword( + username: username, + password: password, + ingressCookie: ingressCookie, + anonToken: anonToken) + } + + private static func getIngressCookie() async throws -> (String, HTTPURLResponse) { + var request = URLRequest(url: self.platformURL) + request.httpMethod = "GET" + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let httpResponse = response.response + + // Extract INGRESSCOOKIE from Set-Cookie headers + let setCookieHeaders = httpResponse.allHeaderFields.filter { ($0.key as? String)?.lowercased() == "set-cookie" } + var ingressCookie = "" + for (_, value) in setCookieHeaders { + let cookieString = "\(value)" + if cookieString.contains("INGRESSCOOKIE=") { + let parts = cookieString.components(separatedBy: "INGRESSCOOKIE=") + if parts.count > 1 { + let valuePart = parts[1].components(separatedBy: ";").first ?? "" + ingressCookie = valuePart.trimmingCharacters(in: .whitespaces) + } + } + } + + // Also check cookies from the URLSession cookie store + if ingressCookie.isEmpty { + let cookies = HTTPCookieStorage.shared.cookies(for: self.platformURL) ?? [] + for cookie in cookies where cookie.name == "INGRESSCOOKIE" { + ingressCookie = cookie.value + break + } + } + + guard !ingressCookie.isEmpty else { + throw StepFunUsageError.loginFailed("Could not obtain INGRESSCOOKIE") + } + + return (ingressCookie, httpResponse) + } + + private static func registerDevice(ingressCookie: String) async throws -> String { + var request = URLRequest(url: self.registerDeviceURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue("INGRESSCOOKIE=\(ingressCookie)", forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("StepFun RegisterDevice returned \(response.statusCode): \(body)") + throw StepFunUsageError.deviceRegistrationFailed("HTTP \(response.statusCode)") + } + + let decoded: StepFunRegisterDeviceResponse + do { + decoded = try JSONDecoder().decode(StepFunRegisterDeviceResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed("RegisterDevice response: \(error.localizedDescription)") + } + + guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { + throw StepFunUsageError.deviceRegistrationFailed("No access token in RegisterDevice response") + } + + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) + } + + private static func signInByPassword( + username: String, + password: String, + ingressCookie: String, + anonToken: String) async throws -> String + { + var request = URLRequest(url: self.loginURL) + request.httpMethod = "POST" + let body: [String: String] = ["username": username, "password": password] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue( + "Oasis-Token=\(anonToken); Oasis-Webid=\(self.webID); INGRESSCOOKIE=\(ingressCookie)", + forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("StepFun SignInByPassword returned \(response.statusCode): \(body)") + throw StepFunUsageError.loginFailed("HTTP \(response.statusCode)") + } + + let decoded: StepFunLoginResponse + do { + decoded = try JSONDecoder().decode(StepFunLoginResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed("SignInByPassword response: \(error.localizedDescription)") + } + + guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { + throw StepFunUsageError.loginFailed("No access token in login response") + } + + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) + } + + private static func refreshOasisToken(token: String) async throws -> String { + let normalized = StepFunTokenNormalizer.normalize(token) + guard !normalized.isEmpty else { + throw StepFunUsageError.missingToken + } + + var request = URLRequest(url: self.refreshTokenURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue(normalized, forHTTPHeaderField: "Oasis-Token") + request.setValue( + "Oasis-Token=\(normalized); Oasis-Webid=\(self.webID)", + forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("StepFun RefreshToken returned \(response.statusCode): \(body)") + throw StepFunUsageError.tokenRefreshFailed("HTTP \(response.statusCode)") + } + + let decoded: StepFunRefreshTokenResponse + do { + decoded = try JSONDecoder().decode(StepFunRefreshTokenResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed("RefreshToken response: \(error.localizedDescription)") + } + + guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { + throw StepFunUsageError.tokenRefreshFailed("No access token in refresh response") + } + + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) + } + + private static func combinedToken(accessToken: String, refreshToken: String?) -> String { + guard let refreshToken, !refreshToken.isEmpty else { + return accessToken + } + return "\(accessToken)...\(refreshToken)" + } + + // MARK: - Query usage + + private static func queryUsage(token: String) async throws -> StepFunUsageSnapshot { + var request = URLRequest(url: self.apiURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue("Oasis-Token=\(token); Oasis-Webid=\(self.webID)", forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("StepFun API returned \(response.statusCode): \(body)") + throw StepFunUsageError.apiError("HTTP \(response.statusCode)") + } + + if let jsonString = String(data: data, encoding: .utf8) { + Self.log.debug("StepFun API response: \(jsonString)") + } + + var snapshot = try self.parseSnapshot(data: data) + + // Fetch plan name in parallel is not needed — just do it sequentially. + // If plan status fails, we still return usage data without plan name. + if let planName = try? await self.queryPlanStatus(token: token) { + snapshot = StepFunUsageSnapshot( + fiveHourUsageLeftRate: snapshot.fiveHourUsageLeftRate, + weeklyUsageLeftRate: snapshot.weeklyUsageLeftRate, + fiveHourUsageResetTime: snapshot.fiveHourUsageResetTime, + weeklyUsageResetTime: snapshot.weeklyUsageResetTime, + planName: planName, + updatedAt: snapshot.updatedAt) + } + + return snapshot + } + + // MARK: - Plan Status + + private static func queryPlanStatus(token: String) async throws -> String? { + var request = URLRequest(url: self.planStatusURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue("Oasis-Token=\(token); Oasis-Webid=\(self.webID)", forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { + Self.log.debug("StepFun plan status request failed, skipping plan name") + return nil + } + + let decoded: StepFunPlanStatusResponse + do { + decoded = try JSONDecoder().decode(StepFunPlanStatusResponse.self, from: response.data) + } catch { + Self.log.debug("StepFun plan status parse failed: \(error.localizedDescription)") + return nil + } + + return decoded.planName + } + + public static func _parseSnapshotForTesting(_ data: Data) throws -> StepFunUsageSnapshot { + try self.parseSnapshot(data: data) + } + + private static func parseSnapshot(data: Data) throws -> StepFunUsageSnapshot { + let decoded: StepFunRateLimitResponse + do { + decoded = try JSONDecoder().decode(StepFunRateLimitResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed(error.localizedDescription) + } + + guard decoded.isSuccess else { + let msg = [decoded.message, decoded.desc] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? decoded.code.map(String.init) ?? "unknown" + throw StepFunUsageError.apiError(msg) + } + + guard let fiveHourRate = decoded.fiveHourUsageLeftRate, + let weeklyRate = decoded.weeklyUsageLeftRate, + let fiveHourReset = decoded.fiveHourUsageResetTime, + let weeklyReset = decoded.weeklyUsageResetTime + else { + throw StepFunUsageError.parseFailed("Missing usage rate or reset time fields") + } + + return StepFunUsageSnapshot( + fiveHourUsageLeftRate: fiveHourRate.value, + weeklyUsageLeftRate: weeklyRate.value, + fiveHourUsageResetTime: Date(timeIntervalSince1970: TimeInterval(fiveHourReset.value)), + weeklyUsageResetTime: Date(timeIntervalSince1970: TimeInterval(weeklyReset.value)), + updatedAt: Date()) + } +} diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift index 550ab9190..b0501408d 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift @@ -10,12 +10,12 @@ public enum SyntheticProviderDescriptor { metadata: ProviderMetadata( id: .synthetic, displayName: "Synthetic", - sessionLabel: "Quota", - weeklyLabel: "Usage", - opusLabel: nil, - supportsOpus: false, + sessionLabel: "Five-hour quota", + weeklyLabel: "Weekly tokens", + opusLabel: "Search hourly", + supportsOpus: true, supportsCredits: false, - creditsHint: "", + creditsHint: "Weekly token quota regenerates continuously.", toggleTitle: "Show Synthetic usage", cliName: "synthetic", defaultEnabled: false, diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticSettingsReader.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticSettingsReader.swift index a6134f883..14e07f430 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticSettingsReader.swift @@ -18,8 +18,7 @@ public struct SyntheticSettingsReader: Sendable { if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { - value.removeFirst() - value.removeLast() + value = String(value.dropFirst().dropLast()) } value = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift index 198c42c25..a1ff84f95 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift @@ -9,29 +9,45 @@ public struct SyntheticQuotaEntry: Sendable { public let windowMinutes: Int? public let resetsAt: Date? public let resetDescription: String? + public let nextRegenPercent: Double? + public let cost: ProviderCostSnapshot? public init( label: String?, usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, - resetDescription: String?) + resetDescription: String?, + nextRegenPercent: Double? = nil, + cost: ProviderCostSnapshot? = nil) { self.label = label self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.nextRegenPercent = nextRegenPercent + self.cost = cost } } public struct SyntheticUsageSnapshot: Sendable { public let quotas: [SyntheticQuotaEntry] + /// Slot-identified lanes for the known Synthetic response shape: [rolling-5h, weekly, search-hourly]. + /// When set, `toUsageSnapshot` maps slot 0 → primary, slot 1 → secondary, slot 2 → tertiary, + /// so a missing lane stays nil instead of promoting the next lane into the wrong UI label. + public let slottedQuotas: [SyntheticQuotaEntry?]? public let planName: String? public let updatedAt: Date - public init(quotas: [SyntheticQuotaEntry], planName: String?, updatedAt: Date) { + public init( + quotas: [SyntheticQuotaEntry], + slottedQuotas: [SyntheticQuotaEntry?]? = nil, + planName: String?, + updatedAt: Date) + { self.quotas = quotas + self.slottedQuotas = slottedQuotas self.planName = planName self.updatedAt = updatedAt } @@ -39,11 +55,13 @@ public struct SyntheticUsageSnapshot: Sendable { extension SyntheticUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - let primaryEntry = self.quotas.first - let secondaryEntry = self.quotas.dropFirst().first + let slots = self.slottedQuotas + ?? [self.quotas.first, self.quotas.dropFirst().first, self.quotas.dropFirst(2).first] + let entries: [SyntheticQuotaEntry?] = (0..<3).map { slots.indices.contains($0) ? slots[$0] : nil } - let primary = primaryEntry.map(Self.rateWindow(for:)) - let secondary = secondaryEntry.map(Self.rateWindow(for:)) + let primary = entries[0].map(Self.rateWindow(for:)) + let secondary = entries[1].map(Self.rateWindow(for:)) + let tertiary = entries[2].map(Self.rateWindow(for:)) let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -56,8 +74,8 @@ extension SyntheticUsageSnapshot { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: nil, - providerCost: nil, + tertiary: tertiary, + providerCost: self.quotas.first(where: { $0.cost != nil })?.cost, updatedAt: self.updatedAt, identity: identity) } @@ -67,7 +85,8 @@ extension SyntheticUsageSnapshot { usedPercent: quota.usedPercent, windowMinutes: quota.windowMinutes, resetsAt: quota.resetsAt, - resetDescription: quota.resetDescription) + resetDescription: quota.resetDescription, + nextRegenPercent: quota.nextRegenPercent) } } @@ -85,19 +104,15 @@ public struct SyntheticUsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw SyntheticUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" - Self.log.error("Synthetic API returned \(httpResponse.statusCode): \(errorMessage)") - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + Self.log.error("Synthetic API returned \(response.statusCode): \(errorMessage)") + if response.statusCode == 401 || response.statusCode == 403 { throw SyntheticUsageError.invalidCredentials } - throw SyntheticUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)") + throw SyntheticUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)") } do { @@ -147,20 +162,46 @@ enum SyntheticUsageParser { }() let planName = self.planName(from: root) - let quotaObjects = self.quotaObjects(from: root) - let quotas = quotaObjects.compactMap { self.parseQuota($0) } + if let slots = self.prioritizedQuotaSlots(from: root) { + let slotted: [SyntheticQuotaEntry?] = slots.map { $0.flatMap(self.parseQuota) } + let flat = slotted.compactMap(\.self) + guard !flat.isEmpty else { + throw SyntheticUsageError.parseFailed("Missing quota data.") + } + return SyntheticUsageSnapshot( + quotas: flat, + slottedQuotas: slotted, + planName: planName, + updatedAt: now) + } + + let quotas = self.fallbackQuotaObjects(from: root).compactMap(self.parseQuota) guard !quotas.isEmpty else { throw SyntheticUsageError.parseFailed("Missing quota data.") } - return SyntheticUsageSnapshot( quotas: quotas, planName: planName, updatedAt: now) } - private static func quotaObjects(from root: [String: Any]) -> [[String: Any]] { + /// Returns slot-positional quota payloads `[rolling-5h, weekly, search-hourly]` when the known Synthetic + /// response shape is detected. Missing lanes stay nil in their slot so downstream code doesn't shift + /// labels. Returns nil if none of the known keys appear, so the fallback path runs. + private static func prioritizedQuotaSlots(from root: [String: Any]) -> [[String: Any]?]? { + let dataDict = root["data"] as? [String: Any] + let rolling = self.namedQuota(root["rollingFiveHourLimit"], label: "Rolling five-hour limit") + ?? self.namedQuota(dataDict?["rollingFiveHourLimit"], label: "Rolling five-hour limit") + let weekly = self.namedQuota(root["weeklyTokenLimit"], label: "Weekly token limit") + ?? self.namedQuota(dataDict?["weeklyTokenLimit"], label: "Weekly token limit") + let searchHourly = self.namedQuota((root["search"] as? [String: Any])?["hourly"], label: "Search hourly") + ?? self.namedQuota((dataDict?["search"] as? [String: Any])?["hourly"], label: "Search hourly") + let slots: [[String: Any]?] = [rolling, weekly, searchHourly] + return slots.contains(where: { $0 != nil }) ? slots : nil + } + + private static func fallbackQuotaObjects(from root: [String: Any]) -> [[String: Any]] { let dataDict = root["data"] as? [String: Any] let candidates: [Any?] = [ root["quotas"], @@ -179,14 +220,8 @@ enum SyntheticUsageParser { ] for candidate in candidates { - if let array = candidate as? [[String: Any]] { return array } - if let array = candidate as? [Any] { - let dicts = array.compactMap { $0 as? [String: Any] } - if !dicts.isEmpty { return dicts } - } - if let dict = candidate as? [String: Any], self.isQuotaPayload(dict) { - return [dict] - } + let quotas = self.extractQuotaObjects(from: candidate) + if !quotas.isEmpty { return quotas } } return [] } @@ -239,14 +274,22 @@ enum SyntheticUsageParser { let windowMinutes = windowMinutes(from: payload) let resetsAt = self.firstDate(in: payload, keys: self.resetKeys) + // Leave resetDescription nil when resetsAt is set so the UI rebuilds the countdown each render + // against the current clock instead of freezing a stale "in Xm" string at parse time. let resetDescription = resetsAt == nil ? self.windowDescription(minutes: windowMinutes) : nil + let cost = self.providerCost(from: payload, usedPercent: clamped, resetsAt: resetsAt) + let nextRegenPercent = self.normalizedPercent( + self.firstDouble(in: payload, keys: Self.tickPercentKeys)) + return SyntheticQuotaEntry( label: label, usedPercent: clamped, windowMinutes: windowMinutes, resetsAt: resetsAt, - resetDescription: resetDescription) + resetDescription: resetDescription, + nextRegenPercent: nextRegenPercent, + cost: cost) } private static func isQuotaPayload(_ payload: [String: Any]) -> Bool { @@ -271,9 +314,78 @@ enum SyntheticUsageParser { if let seconds = self.firstDouble(in: payload, keys: windowSecondsKeys) { return Int((seconds / 60).rounded()) } + if let text = self.firstString(in: payload, keys: windowStringKeys) { + return self.windowMinutes(fromText: text) + } return nil } + private static func namedQuota(_ candidate: Any?, label: String) -> [String: Any]? { + guard var payload = candidate as? [String: Any], self.isQuotaPayload(payload) else { return nil } + if payload["label"] == nil, payload["name"] == nil { + payload["label"] = label + } + return payload + } + + private static func extractQuotaObjects(from candidate: Any?) -> [[String: Any]] { + switch candidate { + case let array as [[String: Any]]: + var nestedQuotas: [[String: Any]] = [] + for entry in array { + if self.isQuotaPayload(entry) { + nestedQuotas.append(entry) + } else { + nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: entry)) + } + } + return nestedQuotas + case let array as [Any]: + return array.flatMap { self.extractQuotaObjects(from: $0) } + case let dict as [String: Any]: + if self.isQuotaPayload(dict) { + return [dict] + } + var nestedQuotas: [[String: Any]] = [] + for key in dict.keys.sorted() { + nestedQuotas.append(contentsOf: self.extractQuotaObjects(from: dict[key])) + } + return nestedQuotas + default: + return [] + } + } + + /// Parses durations like `"5hr"`, `"30min"`, `"2 days"`. Suffixes are sorted longest-first so + /// multi-letter units always win over their single-letter aliases — no ordering surprises if a + /// future unit shares a trailing letter with another. + static func windowMinutes(fromText text: String) -> Int? { + let normalized = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: "") + guard !normalized.isEmpty else { return nil } + + for (suffix, multiplier) in Self.windowSuffixMultipliers { + guard normalized.hasSuffix(suffix) else { continue } + let valueText = String(normalized.dropLast(suffix.count)) + guard let value = Double(valueText), value > 0 else { return nil } + return Int((value * multiplier).rounded()) + } + return nil + } + + private static let windowSuffixMultipliers: [(suffix: String, multiplier: Double)] = { + let raw: [(String, Double)] = [ + ("minutes", 1), ("minute", 1), ("mins", 1), ("min", 1), ("m", 1), + ("hours", 60), ("hour", 60), ("hrs", 60), ("hr", 60), ("h", 60), + ("days", 24 * 60), ("day", 24 * 60), ("d", 24 * 60), + ] + return raw + .sorted { $0.0.count > $1.0.count } + .map { (suffix: $0.0, multiplier: $0.1) } + }() + private static func windowDescription(minutes: Int?) -> String? { guard let minutes, minutes > 0 else { return nil } let dayMinutes = 24 * 60 @@ -288,6 +400,57 @@ enum SyntheticUsageParser { return "\(minutes) minute\(minutes == 1 ? "" : "s") window" } + private static func providerCost( + from payload: [String: Any], + usedPercent: Double, + resetsAt: Date?) -> ProviderCostSnapshot? + { + guard let limit = self.firstCurrency(in: payload, keys: self.costLimitKeys) else { return nil } + + let remaining = self.firstCurrency(in: payload, keys: self.costRemainingKeys) + let usedFromPayload = self.firstCurrency(in: payload, keys: self.costUsedKeys) + let nextRegenAmount = self.firstCurrency(in: payload, keys: self.regenAmountKeys) + let used = if let usedFromPayload { + usedFromPayload + } else if let remaining { + max(0, limit - remaining) + } else { + (usedPercent.clamped(to: 0...100) / 100) * limit + } + + return ProviderCostSnapshot( + used: used, + limit: limit, + currencyCode: "USD", + period: "Weekly", + resetsAt: resetsAt, + nextRegenAmount: nextRegenAmount, + updatedAt: Date()) + } + + private static func firstCurrency(in payload: [String: Any], keys: [String]) -> Double? { + for key in keys { + guard let value = payload[key] else { continue } + if let text = value as? String, + let parsed = self.parseCurrency(text) + { + return parsed + } + if let number = self.doubleValue(value) { + return number + } + } + return nil + } + + private static func parseCurrency(_ text: String) -> Double? { + let cleaned = text + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "$", with: "") + .replacingOccurrences(of: ",", with: "") + return Double(cleaned) + } + private static func normalizedPercent(_ value: Double?) -> Double? { guard let value else { return nil } if value <= 1 { return value * 100 } @@ -423,6 +586,13 @@ enum SyntheticUsageParser { private static let limitKeys = [ "limit", + "messageLimit", + "message_limit", + "messages", + "maxRequests", + "max_requests", + "requestLimit", + "request_limit", "quota", "max", "total", @@ -433,6 +603,10 @@ enum SyntheticUsageParser { private static let usedKeys = [ "used", "usage", + "usedMessages", + "used_messages", + "messagesUsed", + "messages_used", "requests", "requestCount", "request_count", @@ -456,6 +630,10 @@ enum SyntheticUsageParser { "renew_at", "renewsAt", "renews_at", + "nextTickAt", + "next_tick_at", + "nextRegenAt", + "next_regen_at", "periodEnd", "period_end", "expiresAt", @@ -464,6 +642,33 @@ enum SyntheticUsageParser { "end_at", ] + private static let regenAmountKeys = [ + "nextRegenCredits", + "next_regen_credits", + ] + + private static let tickPercentKeys = [ + "tickPercent", + "tick_percent", + "nextTickPercent", + "next_tick_percent", + ] + + private static let costLimitKeys = [ + "maxCredits", + "max_credits", + ] + + private static let costRemainingKeys = [ + "remainingCredits", + "remaining_credits", + ] + + private static let costUsedKeys = [ + "usedCredits", + "used_credits", + ] + private static let windowMinutesKeys = [ "windowMinutes", "window_minutes", @@ -491,6 +696,15 @@ enum SyntheticUsageParser { "periodSeconds", "period_seconds", ] + + private static let windowStringKeys = [ + "window", + "windowLabel", + "window_label", + "period", + "periodLabel", + "period_label", + ] } public enum SyntheticUsageError: LocalizedError, Sendable { diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift new file mode 100644 index 000000000..9317b4073 --- /dev/null +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatProviderDescriptor.swift @@ -0,0 +1,85 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum T3ChatProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .t3chat, + metadata: ProviderMetadata( + id: .t3chat, + displayName: "T3 Chat", + sessionLabel: "Base", + weeklyLabel: "Overage", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show T3 Chat usage", + cliName: "t3chat", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://t3.chat/settings/customization", + subscriptionDashboardURL: "https://t3.chat/settings/subscription", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .t3chat, + iconResourceName: "ProviderIcon-t3chat", + color: ProviderColor(red: 245 / 255, green: 102 / 255, blue: 71 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "T3 Chat cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [T3ChatWebFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "t3chat", + aliases: ["t3-chat", "t3"], + versionDetector: nil)) + } +} + +struct T3ChatWebFetchStrategy: ProviderFetchStrategy { + let id: String = "t3chat.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + let cookieSource = context.settings?.t3chat?.cookieSource ?? .auto + guard cookieSource != .off else { return false } + if cookieSource == .manual { + return T3ChatUsageFetcher.requestContext(from: context.settings?.t3chat?.manualCookieHeader) != nil + } + #if os(macOS) + return true + #else + return false + #endif + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let fetcher = T3ChatUsageFetcher(browserDetection: context.browserDetection) + let manual = Self.manualCookieHeader(from: context) + let logger: ((String) -> Void)? = context.verbose + ? { msg in CodexBarLog.logger(LogCategories.t3chat).verbose(msg) } + : nil + let snapshot = try await fetcher.fetch( + cookieHeaderOverride: manual, + timeout: context.webTimeout, + logger: logger) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func manualCookieHeader(from context: ProviderFetchContext) -> String? { + guard context.settings?.t3chat?.cookieSource == .manual else { return nil } + return context.settings?.t3chat?.manualCookieHeader + } +} diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift new file mode 100644 index 000000000..bddc960e4 --- /dev/null +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageFetcher.swift @@ -0,0 +1,366 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if os(macOS) +import SweetCookieKit +#endif + +#if os(macOS) +private let t3ChatCookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.t3chat]?.browserCookieOrder ?? Browser.defaultImportOrder + +public enum T3ChatCookieImporter { + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["t3.chat", "www.t3.chat"] + + public struct SessionInfo: Sendable { + public let cookieHeader: String + public let sourceLabel: String + + public init(cookieHeader: String, sourceLabel: String) { + self.cookieHeader = cookieHeader + self.sourceLabel = sourceLabel + } + } + + public static func importSession( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) throws -> SessionInfo + { + let log: (String) -> Void = { msg in logger?("[t3chat-cookie] \(msg)") } + let installed = t3ChatCookieImportOrder.cookieImportCandidates(using: browserDetection) + + for browserSource in installed { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try self.cookieClient.codexBarRecords( + matching: query, + in: browserSource, + logger: log) + for source in sources where !source.records.isEmpty { + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + guard !cookies.isEmpty else { continue } + let names = cookies.map(\.name).joined(separator: ", ") + log("\(source.label) cookies: \(names)") + let header = cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + return SessionInfo(cookieHeader: header, sourceLabel: source.label) + } + } catch { + BrowserCookieAccessGate.recordIfNeeded(error) + log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") + } + } + + throw T3ChatUsageError.noSessionCookie + } +} +#endif + +public struct T3ChatUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.t3chat) + private static let baseURL = URL(string: "https://t3.chat")! + private static let refererURL = URL(string: "https://t3.chat/settings/customization")! + /// Browser fingerprint defaults are only fallbacks; full cURL captures override these forwarded headers. + private static let userAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" + /// Captured from T3 Chat's getCustomerData tRPC request shape in May 2026. + private static let input = #"{"0":{"json":{"sessionId":null},"meta":{"values":{"sessionId":["undefined"]}}}}"# + private static let forwardedManualHeaders = [ + "accept": "Accept", + "accept-language": "Accept-Language", + "cache-control": "Cache-Control", + "pragma": "Pragma", + "priority": "Priority", + "referer": "Referer", + "sec-fetch-dest": "Sec-Fetch-Dest", + "sec-fetch-mode": "Sec-Fetch-Mode", + "sec-fetch-site": "Sec-Fetch-Site", + "trpc-accept": "trpc-accept", + "user-agent": "User-Agent", + "x-client-context": "x-client-context", + "x-deployment-id": "X-Deployment-Id", + "x-trpc-batch": "x-trpc-batch", + "x-trpc-source": "x-trpc-source", + ] + + public struct RequestContext: Sendable { + public let cookieHeader: String + public let headers: [String: String] + + public init(cookieHeader: String, headers: [String: String] = [:]) { + self.cookieHeader = cookieHeader + self.headers = headers + } + } + + public let browserDetection: BrowserDetection + + public init(browserDetection: BrowserDetection) { + self.browserDetection = browserDetection + } + + public func fetch( + cookieHeaderOverride: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot + { + let log: (String) -> Void = { msg in logger?("[t3chat] \(msg)") } + let context = try await self.resolveRequestContext(override: cookieHeaderOverride, logger: log) + if let logger { + let names = CookieHeaderNormalizer.pairs(from: context.cookieHeader).map(\.name) + if !names.isEmpty { + logger("[t3chat] Cookie names: \(names.joined(separator: ", "))") + } + if !context.headers.isEmpty { + let headerNames = context.headers.keys.sorted().joined(separator: ", ") + logger("[t3chat] Forwarding captured headers: \(headerNames)") + } + } + return try await Self.fetchCustomerData( + context: context, + timeout: timeout, + now: now, + transport: transport) + } + + public func debugRawProbe(cookieHeaderOverride: String? = nil) async -> String { + let stamp = ISO8601DateFormatter().string(from: Date()) + var lines: [String] = [] + lines.append("=== T3 Chat Debug Probe @ \(stamp) ===") + lines.append("") + + do { + let snapshot = try await self.fetch( + cookieHeaderOverride: cookieHeaderOverride, + logger: { msg in lines.append(msg) }) + lines.append("") + lines.append("Fetch Success") + lines.append("subTier=\(snapshot.customerData.subTier ?? "nil")") + lines.append("usageBand=\(snapshot.customerData.usageBand ?? "nil")") + lines + .append( + "usageFourHourPercentage=\(snapshot.customerData.usageFourHourPercentage?.description ?? "nil")") + lines.append("usageMonthPercentage=\(snapshot.customerData.usageMonthPercentage?.description ?? "nil")") + lines.append("usagePeriodPercentage=\(snapshot.customerData.usagePeriodPercentage?.description ?? "nil")") + lines + .append( + "usageFourHourNextResetAt=\(snapshot.customerData.usageFourHourNextResetAt?.description ?? "nil")") + lines.append("billingNextResetAt=\(snapshot.customerData.billingNextResetAt?.description ?? "nil")") + } catch { + lines.append("") + lines.append("Probe Failed: \(error.localizedDescription)") + } + + return lines.joined(separator: "\n") + } + + public static func fetchCustomerData( + cookieHeader: String, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot + { + guard let normalizedCookieHeader = CookieHeaderNormalizer.normalize(cookieHeader) else { + throw T3ChatUsageError.noSessionCookie + } + return try await self.fetchCustomerData( + context: RequestContext(cookieHeader: normalizedCookieHeader), + timeout: timeout, + now: now, + transport: transport) + } + + public static func fetchCustomerData( + context: RequestContext, + timeout: TimeInterval = 15, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot + { + guard let normalizedCookieHeader = CookieHeaderNormalizer.normalize(context.cookieHeader) else { + throw T3ChatUsageError.noSessionCookie + } + + let url = try self.customerDataURL() + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + self.applyDefaultHeaders(to: &request) + for (name, value) in context.headers { + request.setValue(value, forHTTPHeaderField: name) + } + request.setValue(self.baseURL.absoluteString, forHTTPHeaderField: "Origin") + request.setValue(normalizedCookieHeader, forHTTPHeaderField: "Cookie") + + let response = try await transport.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data.prefix(200), encoding: .utf8) ?? "" + Self.log.error("T3 Chat API returned \(response.statusCode): \(body)") + if response.statusCode == 401 || response.statusCode == 403 { + throw T3ChatUsageError.invalidCredentials + } + if response.statusCode == 429, + response.response.value(forHTTPHeaderField: "x-vercel-mitigated") == "challenge" + { + throw T3ChatUsageError.vercelChallenge + } + throw T3ChatUsageError.apiError("HTTP \(response.statusCode)") + } + + do { + return try T3ChatUsageParser.parseJSONLines(data, now: now) + } catch { + let preview = String(data: data.prefix(500), encoding: .utf8) ?? "" + Self.log.error("T3 Chat parse failed: \(error.localizedDescription) response=\(preview)") + throw error + } + } + + private func resolveRequestContext( + override: String?, + logger: ((String) -> Void)?) async throws -> RequestContext + { + if let override = Self.requestContext(from: override) { + let source = override.headers.isEmpty ? "manual cookie header" : "manual cURL capture" + logger?("[t3chat] Using \(source)") + return override + } + + #if os(macOS) + let session = try T3ChatCookieImporter.importSession( + browserDetection: self.browserDetection, + logger: logger) + logger?("[t3chat] Using cookies from \(session.sourceLabel)") + return RequestContext(cookieHeader: session.cookieHeader) + #else + throw T3ChatUsageError.noSessionCookie + #endif + } + + static func requestContext(from raw: String?) -> RequestContext? { + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + let headerFields = Self.headerFields(from: raw) + guard let cookieHeader = Self.cookieHeader(from: headerFields) ?? CookieHeaderNormalizer.normalize(raw) else { + return nil + } + let headers = Self.forwardedHeaders(from: headerFields) + return RequestContext(cookieHeader: cookieHeader, headers: headers) + } + + private static func applyDefaultHeaders(to request: inout URLRequest) { + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue("application/jsonl", forHTTPHeaderField: "trpc-accept") + request.setValue("web-client", forHTTPHeaderField: "x-trpc-source") + request.setValue("true", forHTTPHeaderField: "x-trpc-batch") + request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language") + request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") + request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer") + request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest") + request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode") + request.setValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site") + request.setValue("u=4", forHTTPHeaderField: "Priority") + request.setValue("no-cache", forHTTPHeaderField: "Pragma") + request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") + } + + private static func forwardedHeaders(from fields: [String]) -> [String: String] { + var headers: [String: String] = [:] + for field in fields { + guard let colon = field.firstIndex(of: ":") else { continue } + let rawName = field[.. String? { + for field in fields { + guard let colon = field.firstIndex(of: ":") else { continue } + let rawName = field[.. [String] { + var fields: [String] = [] + let pattern = + #"(?s)(?:^|\s)(?:-H|--header)(?:\s+|=|(?=['"$]))"# + + #"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|"((?:\\.|[^"])*)"|(\S+))"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return fields } + let range = NSRange(raw.startIndex.. String? { + guard match.numberOfRanges > index, + let range = Range(match.range(at: index), in: raw) + else { + return nil + } + return String(raw[range]) + } + + private static func unescapeShellSegment(_ raw: String, ansi: Bool) -> String { + var output = "" + var index = raw.startIndex + while index < raw.endIndex { + guard raw[index] == "\\" else { + output.append(raw[index]) + index = raw.index(after: index) + continue + } + let next = raw.index(after: index) + guard next < raw.endIndex else { return output } + switch raw[next] { + case "n" where ansi: + output.append("\n") + case "r" where ansi: + output.append("\r") + case "t" where ansi: + output.append("\t") + case "\n": + break + default: + output.append(raw[next]) + } + index = raw.index(after: next) + } + return output + } + + private static func customerDataURL() throws -> URL { + var components = URLComponents(string: "https://t3.chat/api/trpc/getCustomerData")! + components.queryItems = [ + URLQueryItem(name: "batch", value: "1"), + URLQueryItem(name: "input", value: self.input), + ] + guard let url = components.url else { + throw T3ChatUsageError.apiError("Failed to build customer data URL.") + } + return url + } +} diff --git a/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageSnapshot.swift b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageSnapshot.swift new file mode 100644 index 000000000..e75c05e7e --- /dev/null +++ b/Sources/CodexBarCore/Providers/T3Chat/T3ChatUsageSnapshot.swift @@ -0,0 +1,180 @@ +import Foundation + +public enum T3ChatUsageError: LocalizedError, Sendable { + case noSessionCookie + case invalidCredentials + case vercelChallenge + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .noSessionCookie: + "No T3 Chat cookies found. Please log in to t3.chat in your browser." + case .invalidCredentials: + "T3 Chat session cookie is invalid or expired." + case .vercelChallenge: + "T3 Chat returned a Vercel security challenge. Paste the full browser cURL request, " + + "not just the Cookie header." + case let .apiError(message): + "T3 Chat API error: \(message)" + case let .parseFailed(message): + "Could not parse T3 Chat usage: \(message)" + } + } +} + +public struct T3ChatSubscription: Decodable, Sendable { + public let productId: String? + public let productName: String? + public let status: String? + public let currentPeriodStart: TimeInterval? + public let currentPeriodEnd: TimeInterval? + public let canceledAt: TimeInterval? + public let trialEndsAt: TimeInterval? +} + +public struct T3ChatCustomerData: Decodable, Sendable { + public let subTier: String? + public let subscription: T3ChatSubscription? + public let lifetimeBalance: Double? + public let usageBand: String? + public let billingNextResetAt: TimeInterval? + public let usageFourHourPercentage: Double? + public let usageMonthPercentage: Double? + public let usageFourHourNextResetAt: TimeInterval? + public let usagePeriodPercentage: Double? + public let usageWindowNextResetAt: TimeInterval? + + public var planName: String? { + let raw = self.subscription?.productName ?? self.subTier + guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + return raw.split(separator: "-").map { part in + part.prefix(1).uppercased() + String(part.dropFirst()) + }.joined(separator: " ") + } +} + +public struct T3ChatUsageSnapshot: Sendable { + public let customerData: T3ChatCustomerData + public let updatedAt: Date + + public init(customerData: T3ChatCustomerData, updatedAt: Date) { + self.customerData = customerData + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let baseReset = Self.date(fromMilliseconds: self.customerData.usageFourHourNextResetAt) + ?? Self.date(fromMilliseconds: self.customerData.usageWindowNextResetAt) + // billingNextResetAt tracks the usage window reset, not the overage billing period. + // If subscription metadata is absent, leave the overage reset unknown instead of showing the base reset. + let overageReset = Self.date(fromMilliseconds: self.customerData.subscription?.currentPeriodEnd) + + let primary = RateWindow( + usedPercent: Self.percent(self.customerData.usageFourHourPercentage), + windowMinutes: 4 * 60, + resetsAt: baseReset, + resetDescription: Self.description(label: "Base", usageBand: self.customerData.usageBand)) + + let secondaryPercent = self.customerData.usageMonthPercentage + ?? self.customerData.usagePeriodPercentage + let secondary = RateWindow( + usedPercent: Self.percent(secondaryPercent), + windowMinutes: nil, + resetsAt: overageReset, + resetDescription: "Overage") + + let identity = ProviderIdentitySnapshot( + providerID: .t3chat, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.customerData.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: self.updatedAt, + identity: identity) + } + + private static func percent(_ raw: Double?) -> Double { + min(100, max(0, raw ?? 0)) + } + + private static func date(fromMilliseconds raw: TimeInterval?) -> Date? { + guard let raw, raw > 0 else { return nil } + // T3 Chat currently returns JavaScript epoch milliseconds, while some subscription fields may be seconds. + let seconds = raw > 10_000_000_000 ? raw / 1000 : raw + return Date(timeIntervalSince1970: seconds) + } + + private static func description(label: String, usageBand: String?) -> String { + guard let usageBand = usageBand?.trimmingCharacters(in: .whitespacesAndNewlines), + !usageBand.isEmpty + else { + return label + } + return "\(label) - \(usageBand)" + } +} + +public enum T3ChatUsageParser { + public static func parseJSONLines(_ data: Data, now: Date = Date()) throws -> T3ChatUsageSnapshot { + guard let text = String(data: data, encoding: .utf8) else { + throw T3ChatUsageError.parseFailed("Response is not UTF-8.") + } + return try self.parseJSONLines(text, now: now) + } + + public static func parseJSONLines(_ text: String, now: Date = Date()) throws -> T3ChatUsageSnapshot { + let lines = text.split(whereSeparator: \.isNewline) + for line in lines { + guard let data = String(line).data(using: .utf8) else { continue } + guard let object = try? JSONSerialization.jsonObject(with: data) else { continue } + guard let customerObject = self.findCustomerData(in: object) else { continue } + let customerData = try self.decodeCustomerData(customerObject) + return T3ChatUsageSnapshot(customerData: customerData, updatedAt: now) + } + + throw T3ChatUsageError.parseFailed("Missing customer data object.") + } + + private static func findCustomerData(in object: Any) -> [String: Any]? { + if let dictionary = object as? [String: Any] { + if dictionary["usageFourHourPercentage"] != nil || + dictionary["usageMonthPercentage"] != nil || + dictionary["subscription"] != nil && dictionary["usageBand"] != nil + { + return dictionary + } + + for value in dictionary.values { + if let found = self.findCustomerData(in: value) { + return found + } + } + } + + if let array = object as? [Any] { + for value in array { + if let found = self.findCustomerData(in: value) { + return found + } + } + } + + return nil + } + + private static func decodeCustomerData(_ object: [String: Any]) throws -> T3ChatCustomerData { + do { + let data = try JSONSerialization.data(withJSONObject: object, options: []) + return try JSONDecoder().decode(T3ChatCustomerData.self, from: data) + } catch { + throw T3ChatUsageError.parseFailed(error.localizedDescription) + } + } +} diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift new file mode 100644 index 000000000..b44db67cd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift @@ -0,0 +1,70 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum VeniceProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .venice, + metadata: ProviderMetadata( + id: .venice, + displayName: "Venice", + sessionLabel: "Balance", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Venice usage", + cliName: "venice", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://venice.ai/settings/api", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .venice, + iconResourceName: "ProviderIcon-venice", + color: ProviderColor(red: 0.2, green: 0.6, blue: 1.0)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Venice per-day cost history is not available via API." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [VeniceAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "venice", + aliases: ["ven"], + versionDetector: nil)) + } +} + +struct VeniceAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "venice.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw VeniceUsageError.missingCredentials + } + let usage = try await VeniceUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.veniceToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceSettingsReader.swift b/Sources/CodexBarCore/Providers/Venice/VeniceSettingsReader.swift new file mode 100644 index 000000000..fcfffa0c3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Venice/VeniceSettingsReader.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct VeniceSettingsReader: Sendable { + public static let apiKeyEnvironmentKey = "VENICE_API_KEY" + public static let apiKeyEnvironmentKeys = [Self.apiKeyEnvironmentKey, "VENICE_KEY"] + + public static func apiKey( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + for key in self.apiKeyEnvironmentKeys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + continue + } + let cleaned = Self.cleaned(raw) + if !cleaned.isEmpty { + return cleaned + } + } + return nil + } + + private static func cleaned(_ raw: String) -> String { + var value = raw + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value = String(value.dropFirst().dropLast()) + } + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift b/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift new file mode 100644 index 000000000..0326a40dd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift @@ -0,0 +1,236 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// MARK: - API response types + +public struct VeniceBalanceResponse: Decodable, Sendable { + public let canConsume: Bool + public let consumptionCurrency: String? + public let balances: VeniceBalances + public let diemEpochAllocation: Double? + + enum CodingKeys: String, CodingKey { + case canConsume + case consumptionCurrency + case balances + case diemEpochAllocation + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.canConsume = try container.decode(Bool.self, forKey: .canConsume) + self.consumptionCurrency = try container.decodeIfPresent(String.self, forKey: .consumptionCurrency) + self.balances = try container.decode(VeniceBalances.self, forKey: .balances) + self.diemEpochAllocation = try container.decodeFlexibleDoubleIfPresent(forKey: .diemEpochAllocation) + } +} + +public struct VeniceBalances: Decodable, Sendable { + public let diem: Double? + public let usd: Double? + + enum CodingKeys: String, CodingKey { + case diem + case usd + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.diem = try container.decodeFlexibleDoubleIfPresent(forKey: .diem) + self.usd = try container.decodeFlexibleDoubleIfPresent(forKey: .usd) + } +} + +// MARK: - Domain snapshot + +public struct VeniceUsageSnapshot: Sendable { + public let canConsume: Bool + public let consumptionCurrency: String? + public let diemBalance: Double? + public let usdBalance: Double? + public let diemEpochAllocation: Double? + public let updatedAt: Date + + public init( + canConsume: Bool, + consumptionCurrency: String?, + diemBalance: Double?, + usdBalance: Double?, + diemEpochAllocation: Double?, + updatedAt: Date) + { + self.canConsume = canConsume + self.consumptionCurrency = consumptionCurrency + self.diemBalance = diemBalance + self.usdBalance = usdBalance + self.diemEpochAllocation = diemEpochAllocation + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let balanceDetail: String + let usedPercent: Double + let activeCurrency = self.consumptionCurrency?.uppercased() + + if !self.canConsume { + balanceDetail = "Balance unavailable for API calls" + usedPercent = 100 + } else if activeCurrency == "USD", let usd = self.usdBalance, usd > 0 { + let usdStr = String(format: "%.2f", usd) + balanceDetail = "$\(usdStr) USD remaining" + usedPercent = 0 + } else if activeCurrency != "USD", let diem = self.diemBalance, let allocation = self.diemEpochAllocation, + allocation > 0 + { + // DIEM balance with epoch allocation + let remaining = diem + let usedAmount = allocation - remaining + let used = clamp(usedAmount / allocation * 100, min: 0, max: 100) + usedPercent = used + let allocationStr = String(format: "%.2f", allocation) + let remainingStr = String(format: "%.2f", remaining) + balanceDetail = "DIEM \(remainingStr) / \(allocationStr) epoch allocation" + } else if activeCurrency == "DIEM", let diem = self.diemBalance, diem > 0 { + let diemStr = String(format: "%.2f", diem) + balanceDetail = "DIEM \(diemStr) remaining" + usedPercent = 0 + } else if let diem = self.diemBalance, diem > 0 { + // DIEM balance without allocation + let diemStr = String(format: "%.2f", diem) + balanceDetail = "DIEM \(diemStr) remaining" + usedPercent = 0 + } else if let usd = self.usdBalance, usd > 0 { + // USD balance + let usdStr = String(format: "%.2f", usd) + balanceDetail = "$\(usdStr) USD remaining" + usedPercent = 0 + } else { + balanceDetail = "No Venice API balance available" + usedPercent = 100 + } + + let identity = ProviderIdentitySnapshot( + providerID: .venice, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let balanceWindow = RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: balanceDetail) + + return UsageSnapshot( + primary: balanceWindow, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +// MARK: - Errors + +public enum VeniceUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Venice API key." + case let .networkError(message): + "Venice network error: \(message)" + case let .apiError(message): + "Venice API error: \(message)" + case let .parseFailed(message): + "Failed to parse Venice response: \(message)" + } + } +} + +// MARK: - Fetcher + +public struct VeniceUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.veniceUsage) + private static let balanceURL = URL(string: "https://api.venice.ai/api/v1/billing/balance")! + private static let timeoutSeconds: TimeInterval = 15 + + public static func fetchUsage(apiKey: String) async throws -> VeniceUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw VeniceUsageError.missingCredentials + } + + var request = URLRequest(url: self.balanceURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { + Self.log.error("Venice API returned \(response.statusCode)") + throw VeniceUsageError.apiError("HTTP \(response.statusCode)") + } + + return try Self.parseSnapshot(data: response.data) + } + + static func _parseSnapshotForTesting(_ data: Data) throws -> VeniceUsageSnapshot { + try self.parseSnapshot(data: data) + } + + private static func parseSnapshot(data: Data) throws -> VeniceUsageSnapshot { + let decoded: VeniceBalanceResponse + do { + decoded = try JSONDecoder().decode(VeniceBalanceResponse.self, from: data) + } catch { + throw VeniceUsageError.parseFailed(error.localizedDescription) + } + + return VeniceUsageSnapshot( + canConsume: decoded.canConsume, + consumptionCurrency: decoded.consumptionCurrency, + diemBalance: decoded.balances.diem, + usdBalance: decoded.balances.usd, + diemEpochAllocation: decoded.diemEpochAllocation, + updatedAt: Date()) + } +} + +// MARK: - Helper + +private func clamp(_ value: Double, min: Double, max: Double) -> Double { + Swift.min(Swift.max(value, min), max) +} + +extension KeyedDecodingContainer { + fileprivate func decodeFlexibleDoubleIfPresent(forKey key: K) throws -> Double? { + if try self.decodeNil(forKey: key) { + return nil + } + if let value = try? self.decode(Double.self, forKey: key) { + return value + } + if let stringValue = try? self.decode(String.self, forKey: key) { + let trimmed = stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let parsed = Double(trimmed) { + return parsed + } + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Expected a numeric string for \(key.stringValue), got '\(stringValue)'") + } + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Expected a number or numeric string for \(key.stringValue)") + } +} diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift index f1400800a..4356d24fb 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIOAuthCredentials.swift @@ -55,10 +55,28 @@ public enum VertexAIOAuthCredentialsError: LocalizedError, Sendable { } public enum VertexAIOAuthCredentialsStore { - private static var credentialsFilePath: URL { + #if DEBUG + @TaskLocal static var gcloudAccessTokenOverrideForTesting: (@Sendable ([String: String]) async throws -> String)? + #endif + + private struct ServiceAccountMetadata { + let email: String + let projectId: String? + } + + private static func credentialsFilePath( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { + if let path = environment["GOOGLE_APPLICATION_CREDENTIALS"]?.trimmingCharacters( + in: .whitespacesAndNewlines), + !path.isEmpty + { + return URL(fileURLWithPath: path) + } + let home = FileManager.default.homeDirectoryForCurrentUser // gcloud application default credentials location - if let configDir = ProcessInfo.processInfo.environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( + if let configDir = environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( in: .whitespacesAndNewlines), !configDir.isEmpty { @@ -71,9 +89,11 @@ public enum VertexAIOAuthCredentialsStore { .appendingPathComponent("application_default_credentials.json") } - private static var projectFilePath: URL { + private static func projectFilePath( + environment: [String: String] = ProcessInfo.processInfo.environment) -> URL + { let home = FileManager.default.homeDirectoryForCurrentUser - if let configDir = ProcessInfo.processInfo.environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( + if let configDir = environment["CLOUDSDK_CONFIG"]?.trimmingCharacters( in: .whitespacesAndNewlines), !configDir.isEmpty { @@ -88,28 +108,88 @@ public enum VertexAIOAuthCredentialsStore { .appendingPathComponent("config_default") } - public static func load() throws -> VertexAIOAuthCredentials { - let url = self.credentialsFilePath + public static func hasCredentials( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + let url = self.credentialsFilePath(environment: environment) + guard FileManager.default.fileExists(atPath: url.path), + let data = try? Data(contentsOf: url), + let json = try? self.parseJSONObject(data: data) + else { + return false + } + + if self.parseServiceAccountMetadata(json: json) != nil { + return true + } + + return (try? self.parseUserCredentials(json: json, environment: environment)) != nil + } + + public static func load( + environment: [String: String] = ProcessInfo.processInfo.environment) throws -> VertexAIOAuthCredentials + { + let url = self.credentialsFilePath(environment: environment) + guard FileManager.default.fileExists(atPath: url.path) else { + throw VertexAIOAuthCredentialsError.notFound + } + + let data = try Data(contentsOf: url) + return try self.parse(data: data, environment: environment) + } + + public static func loadForFetch( + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> VertexAIOAuthCredentials + { + let url = self.credentialsFilePath(environment: environment) guard FileManager.default.fileExists(atPath: url.path) else { throw VertexAIOAuthCredentialsError.notFound } let data = try Data(contentsOf: url) - return try self.parse(data: data) + let json = try self.parseJSONObject(data: data) + if let serviceAccount = self.parseServiceAccountMetadata(json: json) { + let token = try await self.printAccessToken(environment: environment) + return VertexAIOAuthCredentials( + accessToken: token, + refreshToken: "", + clientId: "", + clientSecret: "", + projectId: serviceAccount.projectId ?? self.loadProjectId(environment: environment), + email: serviceAccount.email, + expiryDate: Date().addingTimeInterval(50 * 60)) + } + + return try self.parseUserCredentials(json: json, environment: environment) } public static func parse(data: Data) throws -> VertexAIOAuthCredentials { + try self.parse(data: data, environment: ProcessInfo.processInfo.environment) + } + + public static func parse( + data: Data, + environment: [String: String]) throws -> VertexAIOAuthCredentials + { + let json = try self.parseJSONObject(data: data) + return try self.parseUserCredentials(json: json, environment: environment) + } + + private static func parseJSONObject(data: Data) throws -> [String: Any] { guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw VertexAIOAuthCredentialsError.decodeFailed("Invalid JSON") } + return json + } + private static func parseUserCredentials( + json: [String: Any], + environment: [String: String]) throws -> VertexAIOAuthCredentials + { // Check for service account credentials - if json["client_email"] is String, - json["private_key"] is String - { - // Service account - use JWT for access token (simplified) + if self.parseServiceAccountMetadata(json: json) != nil { throw VertexAIOAuthCredentialsError.decodeFailed( - "Service account credentials not yet supported. Use `gcloud auth application-default login`.") + "Service account credentials require `gcloud auth application-default print-access-token`.") } // User credentials from gcloud auth application-default login @@ -127,7 +207,7 @@ public enum VertexAIOAuthCredentialsStore { let accessToken = json["access_token"] as? String ?? "" // Try to get project ID from gcloud config - let projectId = Self.loadProjectId() + let projectId = Self.loadProjectId(environment: environment) // Try to extract email from ID token if present let email = Self.extractEmailFromIdToken(json["id_token"] as? String) @@ -154,12 +234,56 @@ public enum VertexAIOAuthCredentialsStore { // The refresh happens on each app launch if needed } - private static func loadProjectId() -> String? { - let configPath = self.projectFilePath - guard let content = try? String(contentsOf: configPath, encoding: .utf8) else { + private static func parseServiceAccountMetadata(json: [String: Any]) -> ServiceAccountMetadata? { + guard let email = (json["client_email"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !email.isEmpty, + let privateKey = (json["private_key"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !privateKey.isEmpty + else { return nil } + let projectId = (json["project_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return ServiceAccountMetadata( + email: email, + projectId: projectId?.isEmpty == false ? projectId : nil) + } + + private static func printAccessToken(environment: [String: String]) async throws -> String { + #if DEBUG + if let override = self.gcloudAccessTokenOverrideForTesting { + let token = try await override(environment) + return try self.cleanAccessToken(token) + } + #endif + + let env = TTYCommandRunner.enrichedEnvironment(baseEnv: environment) + let result = try await SubprocessRunner.run( + binary: "/usr/bin/env", + arguments: ["gcloud", "auth", "application-default", "print-access-token"], + environment: env, + timeout: 20, + label: "vertexai-gcloud-adc-token") + return try self.cleanAccessToken(result.stdout) + } + + private static func cleanAccessToken(_ token: String) throws -> String { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw VertexAIOAuthCredentialsError.missingTokens + } + return trimmed + } + + private static func loadProjectId(environment: [String: String]) -> String? { + let configPath = self.projectFilePath(environment: environment) + guard let content = try? String(contentsOf: configPath, encoding: .utf8) else { + return environment["GOOGLE_CLOUD_PROJECT"] + ?? environment["GCLOUD_PROJECT"] + ?? environment["CLOUDSDK_CORE_PROJECT"] + } + // Parse INI-style config for project for line in content.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -172,9 +296,9 @@ public enum VertexAIOAuthCredentialsStore { } // Try environment variable - return ProcessInfo.processInfo.environment["GOOGLE_CLOUD_PROJECT"] - ?? ProcessInfo.processInfo.environment["GCLOUD_PROJECT"] - ?? ProcessInfo.processInfo.environment["CLOUDSDK_CORE_PROJECT"] + return environment["GOOGLE_CLOUD_PROJECT"] + ?? environment["GCLOUD_PROJECT"] + ?? environment["CLOUDSDK_CORE_PROJECT"] } private static func extractEmailFromIdToken(_ token: String?) -> String? { diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift index b5e8e3f68..206fc3060 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift @@ -49,12 +49,10 @@ public enum VertexAITokenRefresher { request.httpBody = bodyString.data(using: .utf8) do { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw RefreshError.invalidResponse("No HTTP response") - } + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data - if http.statusCode == 400 || http.statusCode == 401 { + if response.statusCode == 400 || response.statusCode == 401 { if let errorCode = Self.extractErrorCode(from: data) { switch errorCode.lowercased() { case "invalid_grant": @@ -68,8 +66,8 @@ public enum VertexAITokenRefresher { throw RefreshError.expired } - guard http.statusCode == 200 else { - throw RefreshError.invalidResponse("Status \(http.statusCode)") + guard response.statusCode == 200 else { + throw RefreshError.invalidResponse("Status \(response.statusCode)") } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift index 2c9da2033..e3ad58b3a 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift @@ -215,20 +215,15 @@ public enum VertexAIUsageFetcher { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 30 - let data: Data - let response: URLResponse + let response: ProviderHTTPResponse do { - (data, response) = try await URLSession.shared.data(for: request) + response = try await ProviderHTTPClient.shared.response(for: request) } catch { throw VertexAIFetchError.networkError(error) } - guard let http = response as? HTTPURLResponse else { - throw VertexAIFetchError.invalidResponse("No HTTP response") - } - - switch http.statusCode { + switch response.statusCode { case 401: throw VertexAIFetchError.unauthorized case 403: @@ -236,11 +231,11 @@ public enum VertexAIUsageFetcher { case 200: break default: - let body = String(data: data, encoding: .utf8) ?? "" - throw VertexAIFetchError.invalidResponse("HTTP \(http.statusCode): \(body)") + let body = String(data: response.data, encoding: .utf8) ?? "" + throw VertexAIFetchError.invalidResponse("HTTP \(response.statusCode): \(body)") } - let decoded = try JSONDecoder().decode(MonitoringTimeSeriesResponse.self, from: data) + let decoded = try JSONDecoder().decode(MonitoringTimeSeriesResponse.self, from: response.data) if let series = decoded.timeSeries { allSeries.append(contentsOf: series) } diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift index f81e0d1f2..715aac444 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIProviderDescriptor.swift @@ -45,12 +45,12 @@ struct VertexAIOAuthFetchStrategy: ProviderFetchStrategy { let id: String = "vertexai.oauth" let kind: ProviderFetchKind = .oauth - func isAvailable(_: ProviderFetchContext) async -> Bool { - (try? VertexAIOAuthCredentialsStore.load()) != nil + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + VertexAIOAuthCredentialsStore.hasCredentials(environment: context.env) } - func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { - var credentials = try VertexAIOAuthCredentialsStore.load() + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + var credentials = try await VertexAIOAuthCredentialsStore.loadForFetch(environment: context.env) // Refresh token if expired if credentials.needsRefresh { diff --git a/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift b/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift index cd3c639c1..afe5df0c3 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpSettingsReader.swift @@ -28,8 +28,7 @@ public struct WarpSettingsReader: Sendable { if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { - value.removeFirst() - value.removeLast() + value = String(value.dropFirst().dropLast()) } return value.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift index 79ec2e8f0..0114d81c4 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift @@ -206,16 +206,12 @@ public struct WarpUsageFetcher: Sendable { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw WarpUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { - let summary = Self.apiErrorSummary(statusCode: httpResponse.statusCode, data: data) - Self.log.error("Warp API returned \(httpResponse.statusCode): \(summary)") - throw WarpUsageError.apiError(httpResponse.statusCode, summary) + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let summary = Self.apiErrorSummary(statusCode: response.statusCode, data: data) + Self.log.error("Warp API returned \(response.statusCode): \(summary)") + throw WarpUsageError.apiError(response.statusCode, summary) } do { @@ -293,13 +289,13 @@ public struct WarpUsageFetcher: Sendable { bonusNextExpirationRemaining: bonus.nextExpirationRemaining) } - private struct BonusGrant: Sendable { + private struct BonusGrant { let granted: Int let remaining: Int let expiration: Date? } - private struct BonusSummary: Sendable { + private struct BonusSummary { let remaining: Int let total: Int let nextExpiration: Date? diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift new file mode 100644 index 000000000..47a3504ee --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift @@ -0,0 +1,254 @@ +import Foundation +#if os(macOS) +import SweetCookieKit +#endif + +#if os(macOS) +enum WindsurfDevinSessionImporter { + nonisolated(unsafe) static var importSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? + nonisolated(unsafe) static var importPreferredSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? + nonisolated(unsafe) static var importFallbackSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) -> [SessionInfo])? + static let defaultPreferredBrowsers: [Browser] = [.chrome] + static let fallbackBrowsers: [Browser] = [ + .chromeBeta, + .chromeCanary, + .edge, + .edgeBeta, + .edgeCanary, + .brave, + .braveBeta, + .braveNightly, + .vivaldi, + .arc, + .arcBeta, + .arcCanary, + .dia, + .chatgptAtlas, + .chromium, + .helium, + ] + + struct SessionInfo: Equatable { + let session: WindsurfDevinSessionAuth + let sourceLabel: String + } + + static func importSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importSessionsOverrideForTesting { + return override(browserDetection, logger) + } + + let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } + let preferredSessions = self.importSessions( + browserDetection: browserDetection, + browsers: self.defaultPreferredBrowsers, + logger: log) + if !preferredSessions.isEmpty { + return preferredSessions + } + + log("No Windsurf devin session found in Chrome; trying fallback Chromium browsers") + let sessions = self.importSessions( + browserDetection: browserDetection, + browsers: self.fallbackBrowsersExcluding(self.defaultPreferredBrowsers), + logger: log) + + if sessions.isEmpty { + log("No Windsurf devin session found in browser local storage") + } + + return sessions + } + + static func importPreferredSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importPreferredSessionsOverrideForTesting { + return override(browserDetection, logger) + } + let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } + return self.importSessions( + browserDetection: browserDetection, + browsers: self.defaultPreferredBrowsers, + logger: log) + } + + static func importFallbackSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) -> [SessionInfo] + { + if let override = self.importFallbackSessionsOverrideForTesting { + return override(browserDetection, logger) + } + let log: (String) -> Void = { msg in logger?("[windsurf-storage] \(msg)") } + return self.importSessions( + browserDetection: browserDetection, + browsers: self.fallbackBrowsersExcluding(self.defaultPreferredBrowsers), + logger: log) + } + + static func fallbackBrowsersExcluding(_ preferredBrowsers: [Browser]) -> [Browser] { + let preferred = Set(preferredBrowsers) + return self.fallbackBrowsers.filter { !preferred.contains($0) } + } + + static func deduplicateSessions(_ sessions: [SessionInfo]) -> [SessionInfo] { + var deduplicated: [SessionInfo] = [] + var seenSessionTokens = Set() + + for session in sessions { + guard seenSessionTokens.insert(session.session.sessionToken).inserted else { continue } + deduplicated.append(session) + } + + return deduplicated + } + + static func session(from storage: [String: String], sourceLabel: String) -> SessionInfo? { + guard let sessionToken = storage["devin_session_token"], + let auth1Token = storage["devin_auth1_token"], + let accountID = storage["devin_account_id"], + let primaryOrgID = storage["devin_primary_org_id"] + else { + return nil + } + + return SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: sessionToken, + auth1Token: auth1Token, + accountID: accountID, + primaryOrgID: primaryOrgID), + sourceLabel: sourceLabel) + } + + static func decodedStorageValue(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return trimmed } + + if let data = trimmed.data(using: .utf8), + let decoded = try? JSONDecoder().decode(String.self, from: data) + { + return decoded.trimmingCharacters(in: .whitespacesAndNewlines) + } + + return trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + struct LocalStorageCandidate { + let label: String + let url: URL + } + + private static func importSessions( + browserDetection: BrowserDetection, + browsers: [Browser], + logger: @escaping (String) -> Void) -> [SessionInfo] + { + var sessions: [SessionInfo] = [] + let candidates = self.chromeLocalStorageCandidates( + browserDetection: browserDetection, + browsers: browsers) + if !candidates.isEmpty { + logger("Chrome local storage candidates: \(candidates.count)") + } + + for candidate in candidates { + let storage = self.readLocalStorage(from: candidate.url, logger: logger) + guard let session = self.session(from: storage, sourceLabel: candidate.label) else { continue } + logger("Found Windsurf devin session in \(candidate.label)") + sessions.append(session) + } + + return self.deduplicateSessions(sessions) + } + + static func chromeLocalStorageCandidates( + browserDetection: BrowserDetection, + browsers: [Browser]) -> [LocalStorageCandidate] + { + let installedBrowsers = browsers.browsersWithProfileData(using: browserDetection) + let roots = ChromiumProfileLocator + .roots(for: installedBrowsers, homeDirectories: BrowserCookieClient.defaultHomeDirectories()) + .map { (url: $0.url, labelPrefix: $0.labelPrefix) } + + var candidates: [LocalStorageCandidate] = [] + for root in roots { + candidates.append(contentsOf: self.chromeProfileLocalStorageDirs( + root: root.url, + labelPrefix: root.labelPrefix)) + } + return candidates + } + + private static func chromeProfileLocalStorageDirs(root: URL, labelPrefix: String) -> [LocalStorageCandidate] { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + else { return [] } + + let profileDirs = entries.filter { url in + guard let isDir = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory), isDir else { + return false + } + let name = url.lastPathComponent + return name == "Default" || name.hasPrefix("Profile ") || name.hasPrefix("user-") + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + + return profileDirs.compactMap { dir in + let levelDBURL = dir.appendingPathComponent("Local Storage").appendingPathComponent("leveldb") + guard FileManager.default.fileExists(atPath: levelDBURL.path) else { return nil } + let label = "\(labelPrefix) \(dir.lastPathComponent)" + return LocalStorageCandidate(label: label, url: levelDBURL) + } + } + + private static func readLocalStorage( + from levelDBURL: URL, + logger: ((String) -> Void)? = nil) -> [String: String] + { + var storage: [String: String] = [:] + + let entries = SweetCookieKit.ChromiumLocalStorageReader.readEntries( + for: "https://windsurf.com", + in: levelDBURL, + logger: logger) + + for entry in entries where Self.targetKeys.contains(entry.key) { + storage[entry.key] = self.decodedStorageValue(entry.value) + } + + if storage.count == Self.targetKeys.count { + return storage + } + + let textEntries = SweetCookieKit.ChromiumLocalStorageReader.readTextEntries( + in: levelDBURL, + logger: logger) + + for entry in textEntries { + guard storage[entry.key] == nil, Self.targetKeys.contains(entry.key) else { continue } + storage[entry.key] = self.decodedStorageValue(entry.value) + } + + return storage + } + + private static let targetKeys: Set = [ + "devin_session_token", + "devin_auth1_token", + "devin_account_id", + "devin_primary_org_id", + ] +} +#endif diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift new file mode 100644 index 000000000..377fbc398 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift @@ -0,0 +1,101 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum WindsurfProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .windsurf, + metadata: ProviderMetadata( + id: .windsurf, + displayName: "Windsurf", + sessionLabel: "Daily", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Windsurf usage", + cliName: "windsurf", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://windsurf.com/subscription/usage", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .windsurf, + iconResourceName: "ProviderIcon-windsurf", + color: ProviderColor(red: 52 / 255, green: 232 / 255, blue: 187 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Windsurf cost summary is not supported." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in + [WindsurfWebFetchStrategy(), WindsurfLocalFetchStrategy()] + })), + cli: ProviderCLIConfig( + name: "windsurf", + versionDetector: nil)) + } +} + +struct WindsurfWebFetchStrategy: ProviderFetchStrategy { + let id: String = "windsurf.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.sourceMode.usesWeb else { return false } + guard context.settings?.windsurf?.cookieSource != .off else { return false } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + #if os(macOS) + let cookieSource = context.settings?.windsurf?.cookieSource ?? .auto + let manualToken = Self.manualToken(from: context) + let usage = try await WindsurfWebFetcher.fetchUsage( + browserDetection: context.browserDetection, + cookieSource: cookieSource, + manualSessionInput: manualToken, + timeout: context.webTimeout, + logger: context.verbose ? { print($0) } : nil) + return self.makeResult(usage: usage, sourceLabel: "windsurf-web") + #else + throw WindsurfStatusProbeError.notSupported + #endif + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + context.sourceMode == .auto + } + + private static func manualToken(from context: ProviderFetchContext) -> String? { + guard context.settings?.windsurf?.cookieSource == .manual else { return nil } + let header = context.settings?.windsurf?.manualCookieHeader ?? "" + return header.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : header + } +} + +struct WindsurfLocalFetchStrategy: ProviderFetchStrategy { + let id: String = "windsurf.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + context.sourceMode != .web + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let probe = WindsurfStatusProbe() + let planInfo = try probe.fetch() + let usage = planInfo.toUsageSnapshot() + return self.makeResult( + usage: usage, + sourceLabel: "local") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift new file mode 100644 index 000000000..73f863ec8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift @@ -0,0 +1,281 @@ +import Foundation + +// MARK: - Cached Plan Info (Codable) + +public struct WindsurfCachedPlanInfo: Codable, Sendable { + public let planName: String? + public let startTimestamp: Int64? + public let endTimestamp: Int64? + public let usage: Usage? + public let quotaUsage: QuotaUsage? + + public struct Usage: Codable, Sendable { + public let messages: Int? + public let usedMessages: Int? + public let remainingMessages: Int? + public let flowActions: Int? + public let usedFlowActions: Int? + public let remainingFlowActions: Int? + public let flexCredits: Int? + public let usedFlexCredits: Int? + public let remainingFlexCredits: Int? + } + + public struct QuotaUsage: Codable, Sendable { + public let dailyRemainingPercent: Double? + public let weeklyRemainingPercent: Double? + public let dailyResetAtUnix: Int64? + public let weeklyResetAtUnix: Int64? + } +} + +// MARK: - Errors & Probe + +#if os(macOS) + +import SQLite3 + +public enum WindsurfStatusProbeError: LocalizedError, Sendable, Equatable { + case dbNotFound(String) + case sqliteFailed(String) + case noData + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case let .dbNotFound(path): + "Windsurf database not found at \(path). Ensure Windsurf is installed and has been launched at least once." + case let .sqliteFailed(message): + "SQLite error reading Windsurf data: \(message)" + case .noData: + "No plan data found in Windsurf database. Sign in to Windsurf first." + case let .parseFailed(message): + "Could not parse Windsurf plan data: \(message)" + } + } +} + +// MARK: - Probe + +public struct WindsurfStatusProbe: Sendable { + private static let defaultDBPath: String = { + let home = NSHomeDirectory() + return "\(home)/Library/Application Support/Windsurf/User/globalStorage/state.vscdb" + }() + + private static let query = "SELECT value FROM ItemTable WHERE key = 'windsurf.settings.cachedPlanInfo' LIMIT 1;" + + private let dbPath: String + + public init(dbPath: String? = nil) { + self.dbPath = dbPath ?? Self.defaultDBPath + } + + public func fetch() throws -> WindsurfCachedPlanInfo { + guard FileManager.default.fileExists(atPath: self.dbPath) else { + throw WindsurfStatusProbeError.dbNotFound(self.dbPath) + } + + var db: OpaquePointer? + guard sqlite3_open_v2(self.dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + sqlite3_close(db) + throw WindsurfStatusProbeError.sqliteFailed(message) + } + defer { sqlite3_close(db) } + + sqlite3_busy_timeout(db, 250) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, Self.query, -1, &stmt, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw WindsurfStatusProbeError.sqliteFailed(message) + } + defer { sqlite3_finalize(stmt) } + + let stepResult = sqlite3_step(stmt) + guard stepResult == SQLITE_ROW else { + if stepResult == SQLITE_DONE { + throw WindsurfStatusProbeError.noData + } + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw WindsurfStatusProbeError.sqliteFailed(message) + } + + guard let jsonString = Self.decodeSQLiteValue(stmt: stmt, index: 0) else { + throw WindsurfStatusProbeError.noData + } + guard let jsonData = jsonString.data(using: .utf8) else { + throw WindsurfStatusProbeError.parseFailed("Invalid UTF-8 encoding") + } + + do { + return try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: jsonData) + } catch { + throw WindsurfStatusProbeError.parseFailed(error.localizedDescription) + } + } + + private static func decodeSQLiteValue(stmt: OpaquePointer?, index: Int32) -> String? { + switch sqlite3_column_type(stmt, index) { + case SQLITE_TEXT: + guard let c = sqlite3_column_text(stmt, index) else { return nil } + return String(cString: c) + case SQLITE_BLOB: + guard let bytes = sqlite3_column_blob(stmt, index) else { return nil } + let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) + // VSCode/Windsurf state.vscdb schema declares value as BLOB; + // only accept decodes that still parse as JSON to avoid UTF-16 mojibake. + return self.decodeJSONBlob(data) + default: + return nil + } + } + + private static func decodeJSONBlob(_ data: Data) -> String? { + for encoding in [String.Encoding.utf8, .utf16LittleEndian] { + guard let decoded = String(data: data, encoding: encoding) else { continue } + let trimmed = decoded.trimmingCharacters(in: .controlCharacters) + guard let jsonData = trimmed.data(using: .utf8), + (try? JSONSerialization.jsonObject(with: jsonData)) != nil + else { + continue + } + return trimmed + } + return nil + } +} + +#else + +// MARK: - Windsurf (Unsupported) + +public enum WindsurfStatusProbeError: LocalizedError, Sendable, Equatable { + case notSupported + + public var errorDescription: String? { + "Windsurf is only supported on macOS." + } +} + +public struct WindsurfStatusProbe: Sendable { + public init(dbPath _: String? = nil) {} + + public func fetch() throws -> WindsurfCachedPlanInfo { + throw WindsurfStatusProbeError.notSupported + } +} + +#endif + +// MARK: - Conversion to UsageSnapshot + +extension WindsurfCachedPlanInfo { + public func toUsageSnapshot() -> UsageSnapshot { + var primary: RateWindow? + var secondary: RateWindow? + + if let quota = self.quotaUsage { + // Primary: daily usage (usedPercent = 100 - dailyRemainingPercent) + if let daily = quota.dailyRemainingPercent { + let resetDate = quota.dailyResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + primary = RateWindow( + usedPercent: max(0, min(100, 100 - daily)), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + + // Secondary: weekly usage + if let weekly = quota.weeklyRemainingPercent { + let resetDate = quota.weeklyResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + secondary = RateWindow( + usedPercent: max(0, min(100, 100 - weekly)), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + } + + if primary == nil, let usage = self.usage { + primary = Self.makeUsageWindow( + used: usage.usedMessages, + remaining: usage.remainingMessages, + total: usage.messages, + unit: "messages") + } + + if secondary == nil, let usage = self.usage { + secondary = Self.makeUsageWindow( + used: usage.usedFlowActions, + remaining: usage.remainingFlowActions, + total: usage.flowActions, + unit: "flow actions") + } + + // Identity + var orgDescription: String? + if let endTimestamp = self.endTimestamp { + let endDate = Date(timeIntervalSince1970: TimeInterval(endTimestamp) / 1000) + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + orgDescription = "Expires \(formatter.string(from: endDate))" + } + + let identity = ProviderIdentitySnapshot( + providerID: .windsurf, + accountEmail: nil, + accountOrganization: orgDescription, + loginMethod: self.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: Date(), + identity: identity) + } + + private static func makeUsageWindow( + used rawUsed: Int?, + remaining rawRemaining: Int?, + total rawTotal: Int?, + unit: String) -> RateWindow? + { + guard let total = rawTotal, total > 0 else { return nil } + let inferredUsed = rawUsed ?? rawRemaining.map { max(0, total - $0) } + guard let used = inferredUsed else { return nil } + let clampedUsed = max(0, min(total, used)) + let usedPercent = max(0, min(100, Double(clampedUsed) / Double(total) * 100)) + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(clampedUsed) / \(total) \(unit)") + } + + private static func formatResetDescription(_ date: Date?) -> String? { + guard let date else { return nil } + let now = Date() + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "Expired" } + + let hours = Int(interval / 3600) + let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) + + if hours > 24 { + let days = hours / 24 + let remainingHours = hours % 24 + return "Resets in \(days)d \(remainingHours)h" + } else if hours > 0 { + return "Resets in \(hours)h \(minutes)m" + } else { + return "Resets in \(minutes)m" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift new file mode 100644 index 000000000..1330be598 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfUsageDataSource.swift @@ -0,0 +1,27 @@ +import Foundation + +public enum WindsurfUsageDataSource: String, CaseIterable, Identifiable, Sendable { + case auto + case web + case cli + + public var id: String { + self.rawValue + } + + public var displayName: String { + switch self { + case .auto: "Auto" + case .web: "Web API (IndexedDB)" + case .cli: "Local (SQLite cache)" + } + } + + public var sourceLabel: String { + switch self { + case .auto: "auto" + case .web: "web" + case .cli: "cli" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift new file mode 100644 index 000000000..e936389bd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift @@ -0,0 +1,678 @@ +import Foundation + +// MARK: - API Response Model + +public struct WindsurfGetPlanStatusResponse: Sendable, Equatable { + public let planStatus: PlanStatus? + + public struct PlanStatus: Sendable, Equatable { + public let planInfo: PlanInfo? + public let planStart: Date? + public let planEnd: Date? + public let dailyQuotaRemainingPercent: Int? + public let weeklyQuotaRemainingPercent: Int? + public let dailyQuotaResetAtUnix: Int64? + public let weeklyQuotaResetAtUnix: Int64? + public let topUpStatus: TopUpStatus? + public let gracePeriodStatus: Int? + + public struct PlanInfo: Sendable, Equatable { + public let planName: String? + public let teamsTier: Int? + } + + public struct TopUpStatus: Sendable, Equatable { + public let topUpTransactionStatus: Int? + } + } +} + +// MARK: - Conversion to UsageSnapshot + +extension WindsurfGetPlanStatusResponse { + public func toUsageSnapshot() -> UsageSnapshot { + var primary: RateWindow? + var secondary: RateWindow? + + if let status = self.planStatus { + if let daily = status.dailyQuotaRemainingPercent { + let resetDate = status.dailyQuotaResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + primary = RateWindow( + usedPercent: max(0, min(100, 100 - Double(daily))), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + + if let weekly = status.weeklyQuotaRemainingPercent { + let resetDate = status.weeklyQuotaResetAtUnix.map { + Date(timeIntervalSince1970: TimeInterval($0)) + } + secondary = RateWindow( + usedPercent: max(0, min(100, 100 - Double(weekly))), + windowMinutes: nil, + resetsAt: resetDate, + resetDescription: Self.formatResetDescription(resetDate)) + } + } + + var orgDescription: String? + if let endDate = self.planStatus?.planEnd { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + orgDescription = "Expires \(formatter.string(from: endDate))" + } + + let identity = ProviderIdentitySnapshot( + providerID: .windsurf, + accountEmail: nil, + accountOrganization: orgDescription, + loginMethod: self.planStatus?.planInfo?.planName) + + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: Date(), + identity: identity) + } + + private static func formatResetDescription(_ date: Date?) -> String? { + guard let date else { return nil } + let now = Date() + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "Expired" } + + let hours = Int(interval / 3600) + let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60) + + if hours > 24 { + let days = hours / 24 + let remainingHours = hours % 24 + return "Resets in \(days)d \(remainingHours)h" + } else if hours > 0 { + return "Resets in \(hours)h \(minutes)m" + } else { + return "Resets in \(minutes)m" + } + } +} + +// MARK: - Session Material + +#if os(macOS) + +struct WindsurfDevinSessionAuth: Codable, Equatable { + let sessionToken: String + let auth1Token: String + let accountID: String + let primaryOrgID: String +} + +public enum WindsurfWebFetcherError: LocalizedError, Sendable { + case noSessionData + case invalidManualSession(String) + case apiCallFailed(String) + + public var errorDescription: String? { + switch self { + case .noSessionData: + "No Windsurf web session found in Chromium localStorage. Sign in to windsurf.com in Chrome or Edge first." + case let .invalidManualSession(message): + "Invalid Windsurf session payload: \(message)" + case let .apiCallFailed(message): + "Windsurf API call failed: \(message)" + } + } +} + +public enum WindsurfWebFetcher { + private static let windsurfOrigin = "https://windsurf.com" + private static let windsurfProfileReferer = "https://windsurf.com/profile" + private static let getPlanStatusURL = "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus" + + public static func fetchUsage( + browserDetection: BrowserDetection, + cookieSource: ProviderCookieSource = .auto, + manualSessionInput: String? = nil, + timeout: TimeInterval = 15, + logger: ((String) -> Void)? = nil, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> UsageSnapshot + { + let log: (String) -> Void = { msg in logger?("[windsurf-web] \(msg)") } + + if cookieSource == .manual { + guard let manualSessionInput, + !manualSessionInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + throw WindsurfWebFetcherError.invalidManualSession("empty input") + } + log("Using manual Windsurf session bundle") + let auth = try self.parseManualSessionInput(manualSessionInput) + let response = try await self.fetchPlanStatus(auth: auth, timeout: timeout, transport: transport) + return response.toUsageSnapshot() + } + + guard cookieSource != .off else { + throw WindsurfWebFetcherError.noSessionData + } + + let preferredSessionInfos = WindsurfDevinSessionImporter.importPreferredSessions( + browserDetection: browserDetection, + logger: logger) + let sessionInfos = preferredSessionInfos.isEmpty + ? WindsurfDevinSessionImporter.importFallbackSessions( + browserDetection: browserDetection, + logger: logger) + : preferredSessionInfos + guard !sessionInfos.isEmpty else { + throw WindsurfWebFetcherError.noSessionData + } + + do { + return try await self.fetchUsage( + sessionInfos: sessionInfos, + timeout: timeout, + logger: log, + transport: transport) + } catch { + guard !preferredSessionInfos.isEmpty, self.isRecoverableImportedSessionError(error) else { + throw error + } + } + + log("Chrome Windsurf sessions failed; trying fallback Chromium browser sessions") + let fallbackSessionInfos = WindsurfDevinSessionImporter.importFallbackSessions( + browserDetection: browserDetection, + logger: logger) + guard !fallbackSessionInfos.isEmpty else { + throw WindsurfWebFetcherError.noSessionData + } + return try await self.fetchUsage( + sessionInfos: fallbackSessionInfos, + timeout: timeout, + logger: log, + transport: transport) + } + + static func parseManualSessionInput(_ raw: String) throws -> WindsurfDevinSessionAuth { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw WindsurfWebFetcherError.invalidManualSession("empty input") + } + + if let auth = self.parseJSONSessionInput(trimmed) { + return auth + } + + if let auth = self.parseKeyValueSessionInput(trimmed) { + return auth + } + + throw WindsurfWebFetcherError.invalidManualSession( + "expected JSON with devin_session_token, devin_auth1_token, devin_account_id, and devin_primary_org_id") + } + + private static func parseJSONSessionInput(_ raw: String) -> WindsurfDevinSessionAuth? { + guard let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return nil + } + return self.sessionAuth(from: json) + } + + private static func parseKeyValueSessionInput(_ raw: String) -> WindsurfDevinSessionAuth? { + let separators = CharacterSet(charactersIn: "\n,;") + let segments = raw + .trimmingCharacters(in: CharacterSet(charactersIn: "{}")) + .components(separatedBy: separators) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + var values: [String: String] = [:] + for segment in segments { + let delimiter: Character? = segment.contains("=") ? "=" : (segment.contains(":") ? ":" : nil) + guard let delimiter, let index = segment.firstIndex(of: delimiter) else { continue } + let key = String(segment[.. Bool { + guard case let WindsurfWebFetcherError.apiCallFailed(message) = error else { + return false + } + + return ["HTTP 400", "HTTP 401", "HTTP 403"].contains { message.hasPrefix($0) } + } + + private static func fetchUsage( + sessionInfos: [WindsurfDevinSessionImporter.SessionInfo], + timeout: TimeInterval, + logger log: (String) -> Void, + transport: any ProviderHTTPTransport) async throws -> UsageSnapshot + { + var lastError: Error? + for sessionInfo in sessionInfos { + do { + log("Using devin session from \(sessionInfo.sourceLabel)") + let response = try await self.fetchPlanStatus( + auth: sessionInfo.session, + timeout: timeout, + transport: transport) + return response.toUsageSnapshot() + } catch { + guard self.isRecoverableImportedSessionError(error) else { + throw error + } + lastError = error + log("Windsurf devin session from \(sessionInfo.sourceLabel) failed; trying next imported session") + } + } + + throw lastError ?? WindsurfWebFetcherError.noSessionData + } + + private static func sessionAuth(from values: [String: Any]) -> WindsurfDevinSessionAuth? { + func stringValue(for keys: [String]) -> String? { + for key in keys { + if let value = values[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } + + guard let sessionToken = stringValue(for: ["devin_session_token", "devinSessionToken", "sessionToken"]), + let auth1Token = stringValue(for: ["devin_auth1_token", "devinAuth1Token", "auth1Token"]), + let accountID = stringValue(for: ["devin_account_id", "devinAccountId", "accountID", "accountId"]), + let primaryOrgID = stringValue(for: [ + "devin_primary_org_id", + "devinPrimaryOrgId", + "primaryOrgID", + "primaryOrgId", + ]) + else { + return nil + } + + return WindsurfDevinSessionAuth( + sessionToken: sessionToken, + auth1Token: auth1Token, + accountID: accountID, + primaryOrgID: primaryOrgID) + } + + private static func fetchPlanStatus( + auth: WindsurfDevinSessionAuth, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> WindsurfGetPlanStatusResponse + { + guard let url = URL(string: self.getPlanStatusURL) else { + throw WindsurfWebFetcherError.apiCallFailed("Invalid GetPlanStatus URL") + } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.httpMethod = "POST" + request.setValue("application/proto", forHTTPHeaderField: "Content-Type") + request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + self.applyWindsurfHeaders(to: &request, auth: auth) + request.httpBody = WindsurfPlanStatusProtoCodec.encodeRequest( + authToken: auth.sessionToken, + includeTopUpStatus: true) + + let response: ProviderHTTPResponse + do { + response = try await transport.response(for: request) + } catch let error as URLError where error.code == .badServerResponse { + throw WindsurfWebFetcherError.apiCallFailed("Invalid response") + } catch { + throw error + } + + guard response.statusCode == 200 else { + let body = String(data: response.data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let snippet = if let body, !body.isEmpty { + ": \(body.prefix(200))" + } else { + ": " + } + throw WindsurfWebFetcherError.apiCallFailed("HTTP \(response.statusCode)\(snippet)") + } + + do { + return try WindsurfPlanStatusProtoCodec.decodeResponse(response.data) + } catch { + throw WindsurfWebFetcherError.apiCallFailed("Parse error: \(error.localizedDescription)") + } + } + + private static func applyWindsurfHeaders(to request: inout URLRequest, auth: WindsurfDevinSessionAuth) { + request.setValue(self.windsurfOrigin, forHTTPHeaderField: "Origin") + request.setValue(self.windsurfProfileReferer, forHTTPHeaderField: "Referer") + request.setValue(auth.sessionToken, forHTTPHeaderField: "x-auth-token") + request.setValue(auth.sessionToken, forHTTPHeaderField: "x-devin-session-token") + request.setValue(auth.auth1Token, forHTTPHeaderField: "x-devin-auth1-token") + request.setValue(auth.accountID, forHTTPHeaderField: "x-devin-account-id") + request.setValue(auth.primaryOrgID, forHTTPHeaderField: "x-devin-primary-org-id") + } +} + +enum WindsurfPlanStatusProtoCodec { + /// Field numbers come from Windsurf's bundled protobuf metadata in + /// `/Applications/Windsurf.app/.../extension.js` and were re-verified against live browser traffic on 2026-04-17. + struct Request: Equatable { + let authToken: String + let includeTopUpStatus: Bool + } + + static func encodeRequest(authToken: String, includeTopUpStatus: Bool) -> Data { + var data = Data() + self.appendFieldKey(1, wireType: .lengthDelimited, to: &data) + self.appendString(authToken, to: &data) + self.appendFieldKey(2, wireType: .varint, to: &data) + self.appendVarint(includeTopUpStatus ? 1 : 0, to: &data) + return data + } + + static func decodeRequest(_ data: Data) throws -> Request { + var reader = ProtoReader(data: data) + var authToken: String? + var includeTopUpStatus = false + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .lengthDelimited): + authToken = try reader.readString() + case (2, .varint): + includeTopUpStatus = try reader.readVarint() != 0 + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + guard let authToken else { + throw WindsurfProtoError.missingField("auth_token") + } + + return Request(authToken: authToken, includeTopUpStatus: includeTopUpStatus) + } + + static func decodeResponse(_ data: Data) throws -> WindsurfGetPlanStatusResponse { + var reader = ProtoReader(data: data) + var planStatus: WindsurfGetPlanStatusResponse.PlanStatus? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .lengthDelimited): + planStatus = try self.decodePlanStatus(from: reader.readLengthDelimitedData()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse(planStatus: planStatus) + } + + private static func decodePlanStatus(from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus { + var reader = ProtoReader(data: data) + var planInfo: WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo? + var planStart: Date? + var planEnd: Date? + var dailyQuotaRemainingPercent: Int? + var weeklyQuotaRemainingPercent: Int? + var dailyQuotaResetAtUnix: Int64? + var weeklyQuotaResetAtUnix: Int64? + var topUpStatus: WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus? + var gracePeriodStatus: Int? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .lengthDelimited): + planInfo = try self.decodePlanInfo(from: reader.readLengthDelimitedData()) + case (2, .lengthDelimited): + planStart = try self.decodeTimestamp(from: reader.readLengthDelimitedData()) + case (3, .lengthDelimited): + planEnd = try self.decodeTimestamp(from: reader.readLengthDelimitedData()) + case (10, .lengthDelimited): + topUpStatus = try self.decodeTopUpStatus(from: reader.readLengthDelimitedData()) + case (12, .varint): + gracePeriodStatus = try Int(reader.readVarint()) + case (14, .varint): + dailyQuotaRemainingPercent = try Int(reader.readVarint()) + case (15, .varint): + weeklyQuotaRemainingPercent = try Int(reader.readVarint()) + case (17, .varint): + dailyQuotaResetAtUnix = try Int64(reader.readVarint()) + case (18, .varint): + weeklyQuotaResetAtUnix = try Int64(reader.readVarint()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse.PlanStatus( + planInfo: planInfo, + planStart: planStart, + planEnd: planEnd, + dailyQuotaRemainingPercent: dailyQuotaRemainingPercent, + weeklyQuotaRemainingPercent: weeklyQuotaRemainingPercent, + dailyQuotaResetAtUnix: dailyQuotaResetAtUnix, + weeklyQuotaResetAtUnix: weeklyQuotaResetAtUnix, + topUpStatus: topUpStatus, + gracePeriodStatus: gracePeriodStatus) + } + + private static func decodePlanInfo( + from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo + { + var reader = ProtoReader(data: data) + var planName: String? + var teamsTier: Int? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .varint): + teamsTier = try Int(reader.readVarint()) + case (2, .lengthDelimited): + planName = try reader.readString() + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse.PlanStatus.PlanInfo(planName: planName, teamsTier: teamsTier) + } + + private static func decodeTopUpStatus( + from data: Data) throws -> WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus + { + var reader = ProtoReader(data: data) + var topUpTransactionStatus: Int? + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .varint): + topUpTransactionStatus = try Int(reader.readVarint()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + return WindsurfGetPlanStatusResponse.PlanStatus.TopUpStatus( + topUpTransactionStatus: topUpTransactionStatus) + } + + private static func decodeTimestamp(from data: Data) throws -> Date { + var reader = ProtoReader(data: data) + var seconds: Int64 = 0 + var nanos: Int32 = 0 + + while let field = try reader.nextField() { + switch (field.number, field.wireType) { + case (1, .varint): + seconds = try Int64(reader.readVarint()) + case (2, .varint): + nanos = try Int32(reader.readVarint()) + default: + try reader.skipFieldBody(wireType: field.wireType) + } + } + + let timeInterval = TimeInterval(seconds) + (TimeInterval(nanos) / 1_000_000_000) + return Date(timeIntervalSince1970: timeInterval) + } + + private static func appendString(_ string: String, to data: inout Data) { + let encoded = Data(string.utf8) + self.appendVarint(UInt64(encoded.count), to: &data) + data.append(encoded) + } + + private static func appendFieldKey(_ fieldNumber: Int, wireType: ProtoWireType, to data: inout Data) { + self.appendVarint(UInt64((fieldNumber << 3) | Int(wireType.rawValue)), to: &data) + } + + private static func appendVarint(_ value: UInt64, to data: inout Data) { + var remaining = value + while remaining >= 0x80 { + data.append(UInt8((remaining & 0x7F) | 0x80)) + remaining >>= 7 + } + data.append(UInt8(remaining)) + } +} + +enum WindsurfProtoError: LocalizedError { + case truncated + case invalidWireType(UInt64) + case invalidUTF8 + case missingField(String) + case unsupportedWireType(ProtoWireType) + case malformedFieldKey + + var errorDescription: String? { + switch self { + case .truncated: + "truncated protobuf payload" + case let .invalidWireType(rawValue): + "invalid wire type \(rawValue)" + case .invalidUTF8: + "invalid UTF-8 string" + case let .missingField(name): + "missing protobuf field \(name)" + case let .unsupportedWireType(type): + "unsupported protobuf wire type \(type.rawValue)" + case .malformedFieldKey: + "malformed protobuf field key" + } + } +} + +enum ProtoWireType: UInt64 { + case varint = 0 + case fixed64 = 1 + case lengthDelimited = 2 + case startGroup = 3 + case endGroup = 4 + case fixed32 = 5 +} + +private struct ProtoField { + let number: Int + let wireType: ProtoWireType +} + +private struct ProtoReader { + private let bytes: [UInt8] + private var index: Int = 0 + + init(data: Data) { + self.bytes = Array(data) + } + + mutating func nextField() throws -> ProtoField? { + guard self.index < self.bytes.count else { return nil } + let key = try self.readVarint() + let number = Int(key >> 3) + guard number > 0 else { + throw WindsurfProtoError.malformedFieldKey + } + guard let wireType = ProtoWireType(rawValue: key & 0x07) else { + throw WindsurfProtoError.invalidWireType(key & 0x07) + } + return ProtoField(number: number, wireType: wireType) + } + + mutating func readVarint() throws -> UInt64 { + var result: UInt64 = 0 + var shift: UInt64 = 0 + + while self.index < self.bytes.count { + let byte = self.bytes[self.index] + self.index += 1 + + result |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { + return result + } + + shift += 7 + if shift >= 64 { + throw WindsurfProtoError.truncated + } + } + + throw WindsurfProtoError.truncated + } + + mutating func readLengthDelimitedData() throws -> Data { + let length = try Int(self.readVarint()) + guard length >= 0, self.index + length <= self.bytes.count else { + throw WindsurfProtoError.truncated + } + + let chunk = Data(self.bytes[self.index..<(self.index + length)]) + self.index += length + return chunk + } + + mutating func readString() throws -> String { + let data = try self.readLengthDelimitedData() + guard let string = String(data: data, encoding: .utf8) else { + throw WindsurfProtoError.invalidUTF8 + } + return string + } + + mutating func skipFieldBody(wireType: ProtoWireType) throws { + switch wireType { + case .varint: + _ = try self.readVarint() + case .fixed64: + guard self.index + 8 <= self.bytes.count else { throw WindsurfProtoError.truncated } + self.index += 8 + case .lengthDelimited: + _ = try self.readLengthDelimitedData() + case .fixed32: + guard self.index + 4 <= self.bytes.count else { throw WindsurfProtoError.truncated } + self.index += 4 + case .startGroup, .endGroup: + throw WindsurfProtoError.unsupportedWireType(wireType) + } + } +} + +#endif diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift index 18d5a15e9..7a7943286 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiAPIRegion.swift @@ -5,6 +5,7 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable { case bigmodelCN = "bigmodel-cn" private static let quotaPath = "api/monitor/usage/quota/limit" + private static let modelUsagePath = "api/monitor/usage/model-usage" public var displayName: String { switch self { @@ -27,4 +28,8 @@ public enum ZaiAPIRegion: String, CaseIterable, Sendable { public var quotaLimitURL: URL { URL(string: self.baseURLString)!.appendingPathComponent(Self.quotaPath) } + + public var modelUsageURL: URL { + URL(string: self.baseURLString)!.appendingPathComponent(Self.modelUsagePath) + } } diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift index 430066a10..2c600a3d4 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiProviderDescriptor.swift @@ -12,8 +12,8 @@ public enum ZaiProviderDescriptor { displayName: "z.ai", sessionLabel: "Tokens", weeklyLabel: "MCP", - opusLabel: nil, - supportsOpus: false, + opusLabel: "5-hour", + supportsOpus: true, supportsCredits: false, creditsHint: "", toggleTitle: "Show z.ai usage", @@ -53,7 +53,7 @@ struct ZaiAPIFetchStrategy: ProviderFetchStrategy { throw ZaiSettingsError.missingToken } let region = context.settings?.zai?.apiRegion ?? .global - let usage = try await ZaiUsageFetcher.fetchUsage( + let usage = try await ZaiUsageFetcher.fetchUsageWithModelUsage( apiKey: apiKey, region: region, environment: context.env) diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift index 4df2223e3..637913797 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiSettingsReader.swift @@ -38,8 +38,7 @@ public struct ZaiSettingsReader: Sendable { if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { - value.removeFirst() - value.removeLast() + value = String(value.dropFirst().dropLast()) } value = value.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 1592a6181..eb45fc579 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -15,6 +15,7 @@ public enum ZaiLimitUnit: Int, Sendable { case days = 1 case hours = 3 case minutes = 5 + case weeks = 6 } /// A single limit entry from the z.ai API @@ -69,6 +70,8 @@ extension ZaiLimitEntry { return self.number * 60 case .days: return self.number * 24 * 60 + case .weeks: + return self.number * 7 * 24 * 60 case .unknown: return nil } @@ -80,6 +83,7 @@ extension ZaiLimitEntry { case .minutes: "minute" case .hours: "hour" case .days: "day" + case .weeks: "week" case .unknown: nil } guard let unitLabel else { return nil } @@ -92,6 +96,10 @@ extension ZaiLimitEntry { return "\(description) window" } + var isMCPMonthlyMarker: Bool { + self.type == .timeLimit && self.unit == .minutes && self.number == 1 + } + private var computedUsedPercent: Double? { guard let limit = self.usage, limit > 0 else { return nil } @@ -129,14 +137,26 @@ public struct ZaiUsageDetail: Sendable, Codable { /// Complete z.ai usage response public struct ZaiUsageSnapshot: Sendable { public let tokenLimit: ZaiLimitEntry? + /// Shorter-window TOKENS_LIMIT (e.g. 5-hour), present only when the API returns two TOKENS_LIMIT entries. + public let sessionTokenLimit: ZaiLimitEntry? public let timeLimit: ZaiLimitEntry? public let planName: String? + public let modelUsage: ZaiModelUsageData? public let updatedAt: Date - public init(tokenLimit: ZaiLimitEntry?, timeLimit: ZaiLimitEntry?, planName: String?, updatedAt: Date) { + public init( + tokenLimit: ZaiLimitEntry?, + sessionTokenLimit: ZaiLimitEntry? = nil, + timeLimit: ZaiLimitEntry?, + planName: String?, + modelUsage: ZaiModelUsageData? = nil, + updatedAt: Date) + { self.tokenLimit = tokenLimit + self.sessionTokenLimit = sessionTokenLimit self.timeLimit = timeLimit self.planName = planName + self.modelUsage = modelUsage self.updatedAt = updatedAt } @@ -150,13 +170,13 @@ extension ZaiUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { let primaryLimit = self.tokenLimit ?? self.timeLimit let secondaryLimit = (self.tokenLimit != nil && self.timeLimit != nil) ? self.timeLimit : nil - let primary = primaryLimit.map { Self.rateWindow(for: $0) } ?? RateWindow( usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = secondaryLimit.map { Self.rateWindow(for: $0) } + let tertiary = self.sessionTokenLimit.map { Self.rateWindow(for: $0) } let planName = self.planName?.trimmingCharacters(in: .whitespacesAndNewlines) let loginMethod = (planName?.isEmpty ?? true) ? nil : planName @@ -168,7 +188,7 @@ extension ZaiUsageSnapshot { return UsageSnapshot( primary: primary, secondary: secondary, - tertiary: nil, + tertiary: tertiary, providerCost: nil, zaiUsage: self, updatedAt: self.updatedAt, @@ -184,6 +204,9 @@ extension ZaiUsageSnapshot { } private static func resetDescription(for limit: ZaiLimitEntry) -> String? { + if limit.isMCPMonthlyMarker { + return "Monthly" + } if let label = limit.windowLabel { return label } @@ -303,16 +326,12 @@ public struct ZaiUsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization") request.setValue("application/json", forHTTPHeaderField: "accept") - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw ZaiUsageError.networkError("Invalid response") - } - - guard httpResponse.statusCode == 200 else { + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" - Self.log.error("z.ai API returned \(httpResponse.statusCode): \(errorMessage)") - throw ZaiUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)") + Self.log.error("z.ai API returned \(response.statusCode): \(errorMessage)") + throw ZaiUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)") } // Some upstream issues (wrong endpoint/region/proxy) can yield HTTP 200 with an empty body. @@ -364,24 +383,41 @@ public struct ZaiUsageFetcher: Sendable { throw ZaiUsageError.parseFailed("Missing data") } - var tokenLimit: ZaiLimitEntry? + var tokenLimits: [ZaiLimitEntry] = [] var timeLimit: ZaiLimitEntry? for limit in responseData.limits { if let entry = limit.toLimitEntry() { switch entry.type { case .tokensLimit: - tokenLimit = entry + tokenLimits.append(entry) case .timeLimit: timeLimit = entry } } } + // Multiple TOKENS_LIMIT entries: shortest window → sessionTokenLimit (tertiary), + // longest → tokenLimit (primary). + let tokenLimit: ZaiLimitEntry? + let sessionTokenLimit: ZaiLimitEntry? + if tokenLimits.count >= 2 { + let sorted = tokenLimits.sorted { + ($0.windowMinutes ?? Int.max) < ($1.windowMinutes ?? Int.max) + } + sessionTokenLimit = sorted.first + tokenLimit = sorted.last + } else { + tokenLimit = tokenLimits.first + sessionTokenLimit = nil + } + return ZaiUsageSnapshot( tokenLimit: tokenLimit, + sessionTokenLimit: sessionTokenLimit, timeLimit: timeLimit, planName: responseData.planName, + modelUsage: nil, updatedAt: Date()) } @@ -402,6 +438,279 @@ public struct ZaiUsageFetcher: Sendable { } } +// MARK: - Model Usage Data + +/// Per-model hourly token usage from the z.ai model-usage API +public struct ZaiModelUsageData: Sendable { + public let xTime: [String] + public let modelDataList: [ZaiModelDataItem] + + public init(xTime: [String], modelDataList: [ZaiModelDataItem]) { + self.xTime = xTime + self.modelDataList = modelDataList + } + + public var modelNames: [String] { + self.modelDataList.compactMap(\.modelName) + } +} + +public struct ZaiModelDataItem: Sendable { + public let modelName: String? + public let tokensUsage: [Int?] + + public init(modelName: String?, tokensUsage: [Int?]) { + self.modelName = modelName + self.tokensUsage = tokensUsage + } +} + +// MARK: - Hourly Chart Data + +public enum ZaiHourlyRange: Equatable, Sendable { + case today(referenceDate: Date) + case last24h + + public var isToday: Bool { + if case .today = self { return true } + return false + } +} + +public struct ZaiHourlyBar: Sendable { + public let label: String + public let segments: [(model: String, tokens: Int)] + + public init(label: String, segments: [(model: String, tokens: Int)]) { + self.label = label + self.segments = segments + } + + public var totalTokens: Int { + self.segments.reduce(0) { $0 + $1.tokens } + } +} + +public enum ZaiHourlyBars: Sendable { + public static func from(modelData: ZaiModelUsageData, range: ZaiHourlyRange, now: Date = Date()) -> [ZaiHourlyBar] { + let calendar = Calendar.current + let referenceDate: Date = switch range { + case let .today(ref): ref + case .last24h: now + } + + let todayStart = calendar.startOfDay(for: referenceDate) + let cutoff: Date = switch range { + case .today: todayStart + case .last24h: calendar.date(byAdding: .hour, value: -24, to: now) ?? now + } + + var bars: [ZaiHourlyBar] = [] + for (index, timeString) in modelData.xTime.enumerated() { + guard let hourDate = parseHourDate(timeString) else { continue } + + if hourDate < cutoff { continue } + + var segments: [(model: String, tokens: Int)] = [] + for item in modelData.modelDataList { + guard index < item.tokensUsage.count, + let tokenCount = item.tokensUsage[index], tokenCount > 0 + else { continue } + segments.append((model: item.modelName ?? "Unknown", tokens: tokenCount)) + } + + let total = segments.reduce(0) { $0 + $1.tokens } + guard total > 0 else { continue } + + let label = self.formatHourLabel(hourDate: hourDate) + bars.append(ZaiHourlyBar(label: label, segments: segments)) + } + + return bars + } + + public static func parseHourDate(_ string: String) -> Date? { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.date(from: string) + } + + private static func formatHourLabel(hourDate: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: hourDate) + } +} + +// MARK: - Model Usage Fetcher Extension + +extension ZaiUsageFetcher { + /// Fetches hourly model usage data for the last 24 hours + public static func fetchModelUsage( + apiKey: String, + region: ZaiAPIRegion = .global, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ZaiModelUsageData + { + guard !apiKey.isEmpty else { + throw ZaiUsageError.invalidCredentials + } + + let baseURL: URL = if let host = ZaiSettingsReader.apiHost(environment: environment), + let resolved = Self.modelUsageURL(baseURLString: host) + { + resolved + } else { + region.modelUsageURL + } + + let now = Date() + let calendar = Calendar.current + guard let startDate = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: now)) else { + throw ZaiUsageError.parseFailed("Invalid date calculation") + } + + let startComponents = calendar.dateComponents([.year, .month, .day, .hour], from: startDate) + let endComponents = calendar.dateComponents([.year, .month, .day, .hour], from: now) + let startTime = String( + format: "%04d-%02d-%02d %02d:00:00", + startComponents.year!, + startComponents.month!, + startComponents.day!, + startComponents.hour!) + let endTime = String( + format: "%04d-%02d-%02d %02d:59:59", + endComponents.year!, + endComponents.month!, + endComponents.day!, + endComponents.hour!) + + guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else { + throw ZaiUsageError.networkError("Invalid URL") + } + components.queryItems = [ + URLQueryItem(name: "startTime", value: startTime), + URLQueryItem(name: "endTime", value: endTime), + ] + + guard let requestURL = components.url else { + throw ZaiUsageError.networkError("Invalid URL") + } + + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + Self.log.error("z.ai model-usage API returned \(response.statusCode): \(errorMessage)") + throw ZaiUsageError.apiError("HTTP \(response.statusCode): \(errorMessage)") + } + + guard !data.isEmpty else { return ZaiModelUsageData(xTime: [], modelDataList: []) } + + return try Self.parseModelUsage(from: data) + } + + static func parseModelUsage(from data: Data) throws -> ZaiModelUsageData { + let decoder = JSONDecoder() + let apiResponse = try decoder.decode(ZaiModelUsageAPIResponse.self, from: data) + + guard apiResponse.isSuccess else { + throw ZaiUsageError.apiError(apiResponse.msg) + } + + guard let responseData = apiResponse.data else { + return ZaiModelUsageData(xTime: [], modelDataList: []) + } + + let items = responseData.modelDataList?.map { raw in + ZaiModelDataItem( + modelName: raw.modelName, + tokensUsage: raw.tokensUsage ?? []) + } ?? [] + + return ZaiModelUsageData( + xTime: responseData.xTime ?? [], + modelDataList: items) + } + + /// Fetches required quota data and attaches optional model usage when available. + public static func fetchUsageWithModelUsage( + apiKey: String, + region: ZaiAPIRegion = .global, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ZaiUsageSnapshot + { + let snapshot = try await Self.fetchUsage(apiKey: apiKey, region: region, environment: environment) + let modelUsage: ZaiModelUsageData? + do { + modelUsage = try await Self.fetchModelUsage(apiKey: apiKey, region: region, environment: environment) + } catch { + Self.log.info("z.ai model usage fetch failed (non-fatal): \(error.localizedDescription)") + modelUsage = nil + } + + guard modelUsage != nil else { return snapshot } + + return ZaiUsageSnapshot( + tokenLimit: snapshot.tokenLimit, + sessionTokenLimit: snapshot.sessionTokenLimit, + timeLimit: snapshot.timeLimit, + planName: snapshot.planName, + modelUsage: modelUsage, + updatedAt: snapshot.updatedAt) + } + + private static func modelUsageURL(baseURLString: String) -> URL? { + guard let cleaned = ZaiSettingsReader.cleaned(baseURLString) else { return nil } + let path = "api/monitor/usage/model-usage" + + if let url = URL(string: cleaned), url.scheme != nil { + if url.path.isEmpty || url.path == "/" { + return url.appendingPathComponent(path) + } + return url + } + guard let base = URL(string: "https://\(cleaned)") else { return nil } + if base.path.isEmpty || base.path == "/" { + return base.appendingPathComponent(path) + } + return base + } +} + +// MARK: - Model Usage API Response (private) + +private struct ZaiModelUsageAPIResponse: Decodable { + let code: Int + let msg: String + let data: ZaiModelUsageRawData? + let success: Bool + + var isSuccess: Bool { + self.success && self.code == 200 + } +} + +private struct ZaiModelUsageRawData: Decodable { + let xTime: [String]? + let modelDataList: [ZaiModelDataItemRaw]? + + enum CodingKeys: String, CodingKey { + case xTime = "x_time" + case modelDataList + } +} + +private struct ZaiModelDataItemRaw: Decodable { + let modelName: String? + let tokensUsage: [Int?]? +} + /// Errors that can occur during z.ai usage fetching public enum ZaiUsageError: LocalizedError, Sendable { case invalidCredentials diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index 378ad3939..37ec660a9 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -12,6 +12,7 @@ public struct TokenAccountSupport: Sendable { public let injection: TokenAccountInjection public let requiresManualCookieSource: Bool public let cookieName: String? + public let environmentKeysToScrub: [String] public init( title: String, @@ -19,7 +20,8 @@ public struct TokenAccountSupport: Sendable { placeholder: String, injection: TokenAccountInjection, requiresManualCookieSource: Bool, - cookieName: String?) + cookieName: String?, + environmentKeysToScrub: [String] = []) { self.title = title self.subtitle = subtitle @@ -27,6 +29,7 @@ public struct TokenAccountSupport: Sendable { self.injection = injection self.requiresManualCookieSource = requiresManualCookieSource self.cookieName = cookieName + self.environmentKeysToScrub = environmentKeysToScrub } } @@ -42,15 +45,42 @@ public enum TokenAccountSupportCatalog { return [key: token] case .cookieHeader: if provider == .claude, - let normalized = self.normalizedClaudeOAuthToken(token), - self.isClaudeOAuthToken(normalized) + case let route = ClaudeCredentialRouting.resolve(tokenAccountToken: token, manualCookieHeader: nil) { - return [ClaudeOAuthCredentialsStore.environmentTokenKey: normalized] + switch route { + case let .oauth(accessToken): + return [ClaudeOAuthCredentialsStore.environmentTokenKey: accessToken] + case let .adminAPIKey(apiKey): + return [ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey: apiKey] + case .none, .webCookie: + break + } } return nil } } + public static func scrubEnvironmentForSelectedAccount( + _ environment: inout [String: String], + provider: UsageProvider, + token _: String) + { + guard let support = self.support(for: provider) else { return } + switch support.injection { + case let .environment(key): + environment.removeValue(forKey: key) + for key in support.environmentKeysToScrub { + environment.removeValue(forKey: key) + } + case .cookieHeader: + guard provider == .claude else { return } + environment.removeValue(forKey: ClaudeOAuthCredentialsStore.environmentTokenKey) + for key in ClaudeAdminAPISettingsReader.apiKeyEnvironmentKeys { + environment.removeValue(forKey: key) + } + } + } + public static func normalizedCookieHeader(for provider: UsageProvider, token: String) -> String { guard let support = self.support(for: provider) else { return token.trimmingCharacters(in: .whitespacesAndNewlines) @@ -59,23 +89,7 @@ public enum TokenAccountSupportCatalog { } public static func isClaudeOAuthToken(_ token: String) -> Bool { - guard let trimmed = self.normalizedClaudeOAuthToken(token) else { return false } - let lower = trimmed.lowercased() - if lower.contains("cookie:") || trimmed.contains("=") { - return false - } - return lower.hasPrefix("sk-ant-oat") - } - - private static func normalizedClaudeOAuthToken(_ token: String) -> String? { - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let lower = trimmed.lowercased() - if lower.hasPrefix("bearer ") { - return trimmed.dropFirst("bearer ".count) - .trimmingCharacters(in: .whitespacesAndNewlines) - } - return trimmed + ClaudeCredentialRouting.resolve(tokenAccountToken: token, manualCookieHeader: nil).isOAuth } public static func normalizedCookieHeader(_ token: String, support: TokenAccountSupport) -> String { diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 2a1d0f1d4..12ec1cee5 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -2,13 +2,35 @@ import Foundation extension TokenAccountSupportCatalog { static let supportByProvider: [UsageProvider: TokenAccountSupport] = [ + .openai: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple OpenAI API keys.", + placeholder: "sk-admin-...", + injection: .environment(key: OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil, + environmentKeysToScrub: [OpenAIAPISettingsReader.projectIDEnvironmentKey]), .claude: TokenAccountSupport( - title: "Session tokens", - subtitle: "Store Claude sessionKey cookies or OAuth access tokens.", - placeholder: "Paste sessionKey or OAuth token…", + title: "Claude credentials", + subtitle: "Store Claude sessionKey cookies, OAuth tokens, or Anthropic Admin API keys.", + placeholder: "Paste sessionKey, OAuth token, or sk-ant-admin…", injection: .cookieHeader, requiresManualCookieSource: true, cookieName: "sessionKey"), + .deepseek: TokenAccountSupport( + title: "API tokens", + subtitle: "Store multiple DeepSeek API keys.", + placeholder: "Paste API key…", + injection: .environment(key: DeepSeekSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .antigravity: TokenAccountSupport( + title: "Google accounts", + subtitle: "Store multiple Antigravity Google OAuth accounts for quick switching.", + placeholder: "Antigravity OAuth credentials JSON", + injection: .environment(key: AntigravityOAuthCredentialsStore.environmentCredentialsKey), + requiresManualCookieSource: false, + cookieName: nil), .zai: TokenAccountSupport( title: "API tokens", subtitle: "Stored in the CodexBar config file.", @@ -30,13 +52,20 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), - .factory: TokenAccountSupport( + .opencodego: TokenAccountSupport( title: "Session tokens", - subtitle: "Store multiple Factory Cookie headers.", + subtitle: "Store multiple OpenCode Go Cookie headers.", placeholder: "Cookie: …", injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .factory: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Factory Cookie or Authorization headers.", + placeholder: "Cookie: … or Authorization: Bearer …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), .minimax: TokenAccountSupport( title: "Session tokens", subtitle: "Store multiple MiniMax Cookie headers.", @@ -44,6 +73,13 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .manus: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Manus session_id cookies.", + placeholder: "session_id=…", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: "session_id"), .augment: TokenAccountSupport( title: "Session tokens", subtitle: "Store multiple Augment Cookie headers.", @@ -58,5 +94,61 @@ extension TokenAccountSupportCatalog { injection: .cookieHeader, requiresManualCookieSource: true, cookieName: nil), + .abacus: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Abacus AI Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), + .mistral: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple Mistral Cookie headers.", + placeholder: "Cookie: …", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), + .copilot: TokenAccountSupport( + title: "GitHub accounts", + subtitle: "Sign in with multiple GitHub accounts via OAuth.", + placeholder: "Paste GitHub token…", + injection: .environment(key: "COPILOT_API_TOKEN"), + requiresManualCookieSource: false, + cookieName: nil), + .venice: TokenAccountSupport( + title: "API tokens", + subtitle: "Store multiple Venice API keys.", + placeholder: "Paste API key…", + injection: .environment(key: VeniceSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .elevenlabs: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple ElevenLabs API keys.", + placeholder: "Paste API key…", + injection: .environment(key: ElevenLabsSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .groq: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple Groq API keys.", + placeholder: "Paste Groq API key…", + injection: .environment(key: GroqSettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .llmproxy: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple LLM Proxy API keys.", + placeholder: "Paste proxy API key…", + injection: .environment(key: LLMProxySettingsReader.apiKeyEnvironmentKey), + requiresManualCookieSource: false, + cookieName: nil), + .stepfun: TokenAccountSupport( + title: "Session tokens", + subtitle: "Store multiple StepFun Oasis-Token values.", + placeholder: "Oasis-Token=…", + injection: .cookieHeader, + requiresManualCookieSource: true, + cookieName: nil), ] } diff --git a/Sources/CodexBarCore/TokenAccounts.swift b/Sources/CodexBarCore/TokenAccounts.swift index 519386aec..73110d28d 100644 --- a/Sources/CodexBarCore/TokenAccounts.swift +++ b/Sources/CodexBarCore/TokenAccounts.swift @@ -6,18 +6,53 @@ public struct ProviderTokenAccount: Codable, Identifiable, Sendable { public let token: String public let addedAt: TimeInterval public let lastUsed: TimeInterval? + /// Stable provider-specific identity (e.g. GitHub `login`) used for + /// re-auth deduplication. Optional so legacy accounts keep working. + public let externalIdentifier: String? + /// Optional provider-specific organization/workspace target. Claude web + /// sessionKey accounts use this to disambiguate linked Anthropic emails. + public let organizationID: String? - public init(id: UUID, label: String, token: String, addedAt: TimeInterval, lastUsed: TimeInterval?) { + enum CodingKeys: String, CodingKey { + case id + case label + case token + case addedAt + case lastUsed + case externalIdentifier + case organizationID = "organizationId" + } + + public init( + id: UUID, + label: String, + token: String, + addedAt: TimeInterval, + lastUsed: TimeInterval?, + externalIdentifier: String? = nil, + organizationID: String? = nil) + { self.id = id self.label = label self.token = token self.addedAt = addedAt self.lastUsed = lastUsed + self.externalIdentifier = externalIdentifier + self.organizationID = organizationID } public var displayName: String { self.label } + + public var sanitizedOrganizationID: String? { + Self.clean(self.organizationID) + } + + private static func clean(_ raw: String?) -> String? { + let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty ?? true) ? nil : trimmed + } } public struct ProviderTokenAccountData: Codable, Sendable { diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index f2370e9ca..471f1147f 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -6,17 +6,49 @@ public struct RateWindow: Codable, Equatable, Sendable { public let resetsAt: Date? /// Optional textual reset description (used by Claude CLI UI scrape). public let resetDescription: String? + /// Optional percent restored on the next regeneration tick for providers with rolling recovery. + public let nextRegenPercent: Double? - public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?, resetDescription: String?) { + public init( + usedPercent: Double, + windowMinutes: Int?, + resetsAt: Date?, + resetDescription: String?, + nextRegenPercent: Double? = nil) + { self.usedPercent = usedPercent self.windowMinutes = windowMinutes self.resetsAt = resetsAt self.resetDescription = resetDescription + self.nextRegenPercent = nextRegenPercent } public var remainingPercent: Double { max(0, 100 - self.usedPercent) } + + public func backfillingResetTime(from cached: RateWindow?, now: Date = .init()) -> RateWindow { + if self.resetsAt != nil { return self } + guard let cachedReset = cached?.resetsAt, cachedReset > now else { return self } + return RateWindow( + usedPercent: self.usedPercent, + windowMinutes: self.windowMinutes ?? cached?.windowMinutes, + resetsAt: cachedReset, + resetDescription: self.resetDescription ?? cached?.resetDescription, + nextRegenPercent: self.nextRegenPercent) + } +} + +public struct NamedRateWindow: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let window: RateWindow + + public init(id: String, title: String, window: RateWindow) { + self.id = id + self.title = title + self.window = window + } } public struct ProviderIdentitySnapshot: Codable, Sendable { @@ -51,10 +83,17 @@ public struct UsageSnapshot: Codable, Sendable { public let primary: RateWindow? public let secondary: RateWindow? public let tertiary: RateWindow? + public let extraRateWindows: [NamedRateWindow]? public let providerCost: ProviderCostSnapshot? + public let kiroUsage: KiroUsageDetails? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? + public let deepseekUsage: DeepSeekUsageSummary? public let openRouterUsage: OpenRouterUsageSnapshot? + public let openAIAPIUsage: OpenAIAPIUsageSnapshot? + public let claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? + public let mistralUsage: MistralUsageSnapshot? + public let deepgramUsage: DeepgramUsageSnapshot? public let cursorRequests: CursorRequestUsage? public let updatedAt: Date public let identity: ProviderIdentitySnapshot? @@ -63,8 +102,14 @@ public struct UsageSnapshot: Codable, Sendable { case primary case secondary case tertiary + case extraRateWindows case providerCost + case kiroUsage case openRouterUsage + case openAIAPIUsage + case claudeAdminAPIUsage + case mistralUsage + case deepgramUsage case updatedAt case identity case accountEmail @@ -76,10 +121,17 @@ public struct UsageSnapshot: Codable, Sendable { primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow? = nil, + extraRateWindows: [NamedRateWindow]? = nil, + kiroUsage: KiroUsageDetails? = nil, providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, + deepseekUsage: DeepSeekUsageSummary? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, + openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, + claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? = nil, + mistralUsage: MistralUsageSnapshot? = nil, + deepgramUsage: DeepgramUsageSnapshot? = nil, cursorRequests: CursorRequestUsage? = nil, updatedAt: Date, identity: ProviderIdentitySnapshot? = nil) @@ -87,10 +139,17 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = primary self.secondary = secondary self.tertiary = tertiary + self.extraRateWindows = extraRateWindows + self.kiroUsage = kiroUsage self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage + self.deepseekUsage = deepseekUsage self.openRouterUsage = openRouterUsage + self.openAIAPIUsage = openAIAPIUsage + self.claudeAdminAPIUsage = claudeAdminAPIUsage + self.mistralUsage = mistralUsage + self.deepgramUsage = deepgramUsage self.cursorRequests = cursorRequests self.updatedAt = updatedAt self.identity = identity @@ -101,10 +160,19 @@ public struct UsageSnapshot: Codable, Sendable { self.primary = try container.decodeIfPresent(RateWindow.self, forKey: .primary) self.secondary = try container.decodeIfPresent(RateWindow.self, forKey: .secondary) self.tertiary = try container.decodeIfPresent(RateWindow.self, forKey: .tertiary) + self.extraRateWindows = try container.decodeIfPresent([NamedRateWindow].self, forKey: .extraRateWindows) self.providerCost = try container.decodeIfPresent(ProviderCostSnapshot.self, forKey: .providerCost) + self.kiroUsage = try container.decodeIfPresent(KiroUsageDetails.self, forKey: .kiroUsage) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time + self.deepseekUsage = nil // Not persisted, fetched fresh each time self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) + self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) + self.claudeAdminAPIUsage = try container.decodeIfPresent( + ClaudeAdminAPIUsageSnapshot.self, + forKey: .claudeAdminAPIUsage) + self.mistralUsage = try container.decodeIfPresent(MistralUsageSnapshot.self, forKey: .mistralUsage) + self.deepgramUsage = try container.decodeIfPresent(DeepgramUsageSnapshot.self, forKey: .deepgramUsage) self.cursorRequests = nil // Not persisted, fetched fresh each time self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) if let identity = try container.decodeIfPresent(ProviderIdentitySnapshot.self, forKey: .identity) { @@ -131,8 +199,14 @@ public struct UsageSnapshot: Codable, Sendable { try container.encode(self.primary, forKey: .primary) try container.encode(self.secondary, forKey: .secondary) try container.encode(self.tertiary, forKey: .tertiary) + try container.encodeIfPresent(self.extraRateWindows, forKey: .extraRateWindows) try container.encodeIfPresent(self.providerCost, forKey: .providerCost) + try container.encodeIfPresent(self.kiroUsage, forKey: .kiroUsage) try container.encodeIfPresent(self.openRouterUsage, forKey: .openRouterUsage) + try container.encodeIfPresent(self.openAIAPIUsage, forKey: .openAIAPIUsage) + try container.encodeIfPresent(self.claudeAdminAPIUsage, forKey: .claudeAdminAPIUsage) + try container.encodeIfPresent(self.mistralUsage, forKey: .mistralUsage) + try container.encodeIfPresent(self.deepgramUsage, forKey: .deepgramUsage) try container.encode(self.updatedAt, forKey: .updatedAt) try container.encodeIfPresent(self.identity, forKey: .identity) try container.encodeIfPresent(self.identity?.accountEmail, forKey: .accountEmail) @@ -145,20 +219,51 @@ public struct UsageSnapshot: Codable, Sendable { return identity } + public func automaticPerplexityWindow() -> RateWindow? { + let fallbackWindows = self.orderedPerplexityFallbackWindows() + guard let primary = self.primary else { + return fallbackWindows.first + } + if primary.remainingPercent > 0 || fallbackWindows.isEmpty { + return primary + } + return fallbackWindows.first + } + + public func orderedPerplexityDisplayWindows() -> [RateWindow] { + let fallbackWindows = self.orderedPerplexityFallbackWindows() + guard let primary = self.primary else { + return fallbackWindows + } + if primary.remainingPercent > 0 || fallbackWindows.isEmpty { + return [primary] + fallbackWindows + } + return fallbackWindows + [primary] + } + public func switcherWeeklyWindow(for provider: UsageProvider, showUsed: Bool) -> RateWindow? { switch provider { case .factory: // Factory prefers secondary window return self.secondary ?? self.primary + case .perplexity: + return self.automaticPerplexityWindow() case .cursor: - // Cursor: fall back to On-Demand when Plan is exhausted (only in "show remaining" mode). - // In "show used" mode, keep showing primary so 100% used Plan is visible. + // Cursor: fall back to on-demand budget when the included plan is exhausted (only in + // "show remaining" mode). The secondary/tertiary lanes are Total/Auto/API breakdowns, + // not extra capacity, so they should not replace the remaining paid quota indicator. if !showUsed, let primary = self.primary, primary.remainingPercent <= 0, - let secondary = self.secondary + let providerCost = self.providerCost, + providerCost.limit > 0 { - return secondary + let usedPercent = max(0, min(100, (providerCost.used / providerCost.limit) * 100)) + return RateWindow( + usedPercent: usedPercent, + windowMinutes: nil, + resetsAt: providerCost.resetsAt, + resetDescription: nil) } return self.primary ?? self.secondary default: @@ -178,16 +283,32 @@ public struct UsageSnapshot: Codable, Sendable { self.identity(for: provider)?.loginMethod } + public var hasRateLimitWindows: Bool { + self.primary != nil || self.secondary != nil || self.tertiary != nil || + !(self.extraRateWindows?.isEmpty ?? true) + } + + public func rateLimitsUnavailable(for provider: UsageProvider) -> Bool { + UsageLimitsAvailability.resolve(provider: provider, snapshot: self).isUnavailable + } + /// Keep this initializer-style copy in sync with UsageSnapshot fields so relabeling/scoping never drops data. public func withIdentity(_ identity: ProviderIdentitySnapshot?) -> UsageSnapshot { UsageSnapshot( primary: self.primary, secondary: self.secondary, tertiary: self.tertiary, + extraRateWindows: self.extraRateWindows, + kiroUsage: self.kiroUsage, providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, + deepseekUsage: self.deepseekUsage, openRouterUsage: self.openRouterUsage, + openAIAPIUsage: self.openAIAPIUsage, + claudeAdminAPIUsage: self.claudeAdminAPIUsage, + mistralUsage: self.mistralUsage, + deepgramUsage: self.deepgramUsage, cursorRequests: self.cursorRequests, updatedAt: self.updatedAt, identity: identity) @@ -199,18 +320,79 @@ public struct UsageSnapshot: Codable, Sendable { if scopedIdentity.providerID == identity.providerID { return self } return self.withIdentity(scopedIdentity) } + + public func backfillingResetTimes(from cached: UsageSnapshot?, now: Date = .init()) -> UsageSnapshot { + guard let cached else { return self } + guard Self.identitiesMatch(self.identity, cached.identity) else { return self } + let primary = self.primary?.backfillingResetTime(from: cached.primary, now: now) + let secondary = self.secondary?.backfillingResetTime(from: cached.secondary, now: now) + let tertiary = self.tertiary?.backfillingResetTime(from: cached.tertiary, now: now) + if primary == self.primary, secondary == self.secondary, tertiary == self.tertiary { + return self + } + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + extraRateWindows: self.extraRateWindows, + providerCost: self.providerCost, + zaiUsage: self.zaiUsage, + minimaxUsage: self.minimaxUsage, + deepseekUsage: self.deepseekUsage, + openRouterUsage: self.openRouterUsage, + openAIAPIUsage: self.openAIAPIUsage, + claudeAdminAPIUsage: self.claudeAdminAPIUsage, + mistralUsage: self.mistralUsage, + deepgramUsage: self.deepgramUsage, + cursorRequests: self.cursorRequests, + updatedAt: self.updatedAt, + identity: self.identity) + } + + private func orderedPerplexityFallbackWindows() -> [RateWindow] { + let fallbackWindows = [self.tertiary, self.secondary].compactMap(\.self) + let usableFallback = fallbackWindows.filter { $0.remainingPercent > 0 } + let exhaustedFallback = fallbackWindows.filter { $0.remainingPercent <= 0 } + return usableFallback + exhaustedFallback + } + + private static func identitiesMatch(_ lhs: ProviderIdentitySnapshot?, _ rhs: ProviderIdentitySnapshot?) -> Bool { + if lhs == nil, rhs == nil { return true } + guard let lhs, let rhs else { return false } + let lhsEmail = lhs.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + let rhsEmail = rhs.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) + if let lhsEmail, let rhsEmail, !lhsEmail.isEmpty, !rhsEmail.isEmpty { + return lhsEmail == rhsEmail + } + return true + } } public struct AccountInfo: Equatable, Sendable { public let email: String? public let plan: String? + public var hasIdentity: Bool { + self.email?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false || + self.plan?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + public init(email: String?, plan: String?) { self.email = email self.plan = plan } } +public struct CodexCLIAccountSnapshot: Sendable { + public let usage: UsageSnapshot? + public let credits: CreditsSnapshot? + + public init(usage: UsageSnapshot?, credits: CreditsSnapshot?) { + self.usage = usage + self.credits = credits + } +} + public enum UsageError: LocalizedError, Sendable { case noSessions case noRateLimitsFound @@ -226,6 +408,40 @@ public enum UsageError: LocalizedError, Sendable { "Could not parse Codex session log." } } + + public static func isNoRateLimitsFoundDescription(_ text: String?) -> Bool { + text?.trimmingCharacters(in: .whitespacesAndNewlines) == UsageError.noRateLimitsFound.errorDescription + } +} + +public enum UsageLimitsAvailability: Equatable, Sendable { + case available + case unavailable + + public var isUnavailable: Bool { + self == .unavailable + } + + public static func resolve( + provider: UsageProvider, + snapshot: UsageSnapshot?, + account: AccountInfo? = nil, + lastErrorDescription: String? = nil) -> Self + { + guard provider == .codex else { return .available } + + if let snapshot { + guard snapshot.identity(for: provider) != nil else { return .available } + return snapshot.hasRateLimitWindows ? .available : .unavailable + } + + guard UsageError.isNoRateLimitsFoundDescription(lastErrorDescription), + account?.hasIdentity == true + else { + return .available + } + return .unavailable + } } // MARK: - Codex RPC client (local process) @@ -272,6 +488,7 @@ private struct RPCRateLimitSnapshot: Decodable, Encodable { let primary: RPCRateLimitWindow? let secondary: RPCRateLimitWindow? let credits: RPCCreditsSnapshot? + let planType: String? } private struct RPCRateLimitWindow: Decodable, Encodable { @@ -286,10 +503,25 @@ private struct RPCCreditsSnapshot: Decodable, Encodable { let balance: String? } -private enum RPCWireError: Error, LocalizedError { +private struct RPCRateLimitsErrorBody: Decodable { + let email: String? + let planType: String? + let rateLimit: CodexUsageResponse.RateLimitDetails? + let credits: CodexUsageResponse.CreditDetails? + + enum CodingKeys: String, CodingKey { + case email + case planType = "plan_type" + case rateLimit = "rate_limit" + case credits + } +} + +enum RPCWireError: Error, LocalizedError { case startFailed(String) case requestFailed(String) case malformed(String) + case timeout(method: String) var errorDescription: String? { switch self { @@ -299,10 +531,19 @@ private enum RPCWireError: Error, LocalizedError { "Codex connection failed: \(message)" case let .malformed(message): "Codex returned invalid data: \(message)" + case let .timeout(method): + "Codex RPC timed out waiting for `\(method)` reply." } } } +typealias CodexExecutableResolver = @Sendable (_ environment: [String: String], _ executable: String) -> String? + +let defaultCodexExecutableResolver: CodexExecutableResolver = { environment, executable in + BinaryLocator.resolveCodexBinary(env: environment) + ?? TTYCommandRunner.which(executable) +} + /// RPC helper used on background tasks; safe because we confine it to the owning task. private final class CodexRPCClient: @unchecked Sendable { private static let log = CodexBarLog.logger(LogCategories.codexRPC) @@ -313,6 +554,8 @@ private final class CodexRPCClient: @unchecked Sendable { private let stdoutLineStream: AsyncStream private let stdoutLineContinuation: AsyncStream.Continuation private var nextID = 1 + private let initializeTimeoutSeconds: TimeInterval + private let requestTimeoutSeconds: TimeInterval private final class LineBuffer: @unchecked Sendable { private let lock = NSLock() @@ -343,23 +586,27 @@ private final class CodexRPCClient: @unchecked Sendable { init( executable: String = "codex", - arguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"]) throws + arguments: [String] = ["-s", "read-only", "-a", "untrusted", "app-server"], + environment: [String: String] = ProcessInfo.processInfo.environment, + initializeTimeoutSeconds: TimeInterval = 8.0, + requestTimeoutSeconds: TimeInterval = 3.0, + resolveExecutable: CodexExecutableResolver = defaultCodexExecutableResolver) throws { + self.initializeTimeoutSeconds = initializeTimeoutSeconds + self.requestTimeoutSeconds = requestTimeoutSeconds var stdoutContinuation: AsyncStream.Continuation! self.stdoutLineStream = AsyncStream { continuation in stdoutContinuation = continuation } self.stdoutLineContinuation = stdoutContinuation - let resolvedExec = BinaryLocator.resolveCodexBinary() - ?? TTYCommandRunner.which(executable) + let resolvedExec = resolveExecutable(environment, executable) guard let resolvedExec else { Self.log.warning("Codex RPC binary not found", metadata: ["binary": executable]) - throw RPCWireError.startFailed( - "Codex CLI not found. Install with `npm i -g @openai/codex` (or bun) then relaunch CodexBar.") + throw CodexStatusProbeError.codexNotInstalled } - var env = ProcessInfo.processInfo.environment + var env = environment env["PATH"] = PathBuilder.effectivePATH( purposes: [.rpc, .nodeTooling], env: env) @@ -371,12 +618,19 @@ private final class CodexRPCClient: @unchecked Sendable { self.process.standardOutput = self.stdoutPipe self.process.standardError = self.stderrPipe + if let message = CodexCLILaunchGate.shared.backgroundSkipMessage(binary: resolvedExec) { + Self.log.warning("Codex RPC launch skipped after recent launch failure", metadata: ["binary": resolvedExec]) + throw RPCWireError.startFailed(message) + } + do { try self.process.run() Self.log.debug("Codex RPC started", metadata: ["binary": resolvedExec]) } catch { - Self.log.warning("Codex RPC failed to start", metadata: ["error": error.localizedDescription]) - throw RPCWireError.startFailed(error.localizedDescription) + let message = error.localizedDescription + let throttled = CodexCLILaunchGate.shared.recordLaunchFailure(binary: resolvedExec, message: message) + Self.log.warning("Codex RPC failed to start", metadata: ["error": message]) + throw RPCWireError.startFailed(throttled ?? message) } let stdoutHandle = self.stdoutPipe.fileHandleForReading @@ -416,7 +670,8 @@ private final class CodexRPCClient: @unchecked Sendable { func initialize(clientName: String, clientVersion: String) async throws { _ = try await self.request( method: "initialize", - params: ["clientInfo": ["name": clientName, "version": clientVersion]]) + params: ["clientInfo": ["name": clientName, "version": clientVersion]], + timeout: self.initializeTimeoutSeconds) try self.sendNotification(method: "initialized") } @@ -439,26 +694,72 @@ private final class CodexRPCClient: @unchecked Sendable { // MARK: - JSON-RPC helpers - private func request(method: String, params: [String: Any]? = nil) async throws -> [String: Any] { + private struct SendableJSONMessage: @unchecked Sendable { + let value: [String: Any] + } + + private func request( + method: String, + params: [String: Any]? = nil, + timeout: TimeInterval? = nil) async throws -> [String: Any] + { let id = self.nextID self.nextID += 1 try self.sendRequest(id: id, method: method, params: params) - while true { - let message = try await self.readNextMessage() + let resolvedTimeout = timeout ?? self.requestTimeoutSeconds + let wrapped = try await self.withTimeout(seconds: resolvedTimeout, method: method) { + while true { + let message = try await self.readNextMessage() - if message["id"] == nil, let methodName = message["method"] as? String { - Self.debugWriteStderr("[codex notify] \(methodName)\n") - continue - } + if message["id"] == nil, let methodName = message["method"] as? String { + Self.debugWriteStderr("[codex notify] \(methodName)\n") + continue + } - guard let messageID = self.jsonID(message["id"]), messageID == id else { continue } + guard let messageID = self.jsonID(message["id"]), messageID == id else { continue } + + if let error = message["error"] as? [String: Any], let messageText = error["message"] as? String { + throw RPCWireError.requestFailed(messageText) + } + + return SendableJSONMessage(value: message) + } + } + return wrapped.value + } - if let error = message["error"] as? [String: Any], let messageText = error["message"] as? String { - throw RPCWireError.requestFailed(messageText) + private func withTimeout( + seconds: TimeInterval, + method: String, + body: @escaping @Sendable () async throws -> T) async throws -> T + { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await body() + } + group.addTask { [weak self] in + try await Task.sleep(for: .seconds(seconds)) + self?.terminateProcessForTimeout(method: method) + throw RPCWireError.timeout(method: method) } + do { + guard let result = try await group.next() else { + throw RPCWireError.timeout(method: method) + } + group.cancelAll() + return result + } catch { + group.cancelAll() + throw error + } + } + } - return message + private func terminateProcessForTimeout(method: String) { + if self.process.isRunning { + Self.log.warning("Codex RPC timed out on `\(method)`; terminating process") + self.process.terminate() } } @@ -514,118 +815,104 @@ private final class CodexRPCClient: @unchecked Sendable { public struct UsageFetcher: Sendable { private let environment: [String: String] + private let initializeTimeoutSeconds: TimeInterval + private let requestTimeoutSeconds: TimeInterval + private let codexExecutableResolver: CodexExecutableResolver public init(environment: [String: String] = ProcessInfo.processInfo.environment) { self.environment = environment + self.initializeTimeoutSeconds = 8.0 + self.requestTimeoutSeconds = 3.0 + self.codexExecutableResolver = defaultCodexExecutableResolver LoginShellPathCache.shared.captureOnce() } - public func loadLatestUsage(keepCLISessionsAlive: Bool = false) async throws -> UsageSnapshot { - try await self.withFallback( - primary: self.loadRPCUsage, - secondary: { try await self.loadTTYUsage(keepCLISessionsAlive: keepCLISessionsAlive) }) + init( + environment: [String: String], + initializeTimeoutSeconds: TimeInterval, + requestTimeoutSeconds: TimeInterval, + codexExecutableResolver: @escaping CodexExecutableResolver = defaultCodexExecutableResolver) + { + self.environment = environment + self.initializeTimeoutSeconds = initializeTimeoutSeconds + self.requestTimeoutSeconds = requestTimeoutSeconds + self.codexExecutableResolver = codexExecutableResolver + LoginShellPathCache.shared.captureOnce() } - private func loadRPCUsage() async throws -> UsageSnapshot { - let rpc = try CodexRPCClient() - defer { rpc.shutdown() } - - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - // The app-server answers on a single stdout stream, so keep requests - // serialized to avoid starving one reader when multiple awaiters race - // for the same pipe. - let limits = try await rpc.fetchRateLimits().rateLimits - let account = try? await rpc.fetchAccount() - - guard let primary = Self.makeWindow(from: limits.primary), - let secondary = Self.makeWindow(from: limits.secondary) - else { + public func loadLatestUsage(keepCLISessionsAlive: Bool = false) async throws -> UsageSnapshot { + _ = keepCLISessionsAlive + guard let usage = try await self.loadLatestCLIAccountSnapshot().usage else { throw UsageError.noRateLimitsFound } - - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: account?.account.flatMap { details in - if case let .chatgpt(email, _) = details { email } else { nil } - }, - accountOrganization: nil, - loginMethod: account?.account.flatMap { details in - if case let .chatgpt(_, plan) = details { plan } else { nil } - }) - return UsageSnapshot( - primary: primary, - secondary: secondary, - tertiary: nil, - updatedAt: Date(), - identity: identity) + return usage } - private func loadTTYUsage(keepCLISessionsAlive: Bool) async throws -> UsageSnapshot { - let status = try await CodexStatusProbe(keepCLISessionsAlive: keepCLISessionsAlive).fetch() - guard let fiveLeft = status.fiveHourPercentLeft, let weekLeft = status.weeklyPercentLeft else { - throw UsageError.noRateLimitsFound + public func loadLatestCLIAccountSnapshot() async throws -> CodexCLIAccountSnapshot { + let rpc = try CodexRPCClient( + environment: self.environment, + initializeTimeoutSeconds: self.initializeTimeoutSeconds, + requestTimeoutSeconds: self.requestTimeoutSeconds, + resolveExecutable: self.codexExecutableResolver) + defer { rpc.shutdown() } + do { + try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") + // The app-server answers on a single stdout stream, so keep requests + // serialized to avoid starving one reader when multiple awaiters race + // for the same pipe. + let limits = try await rpc.fetchRateLimits().rateLimits + let account = try? await rpc.fetchAccount() + let rateLimitsPlan = Self.normalizedCodexAccountField(limits.planType) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: account?.account.flatMap { details in + if case let .chatgpt(email, _) = details { email } else { nil } + }, + accountOrganization: nil, + loginMethod: account?.account.flatMap { details in + if case let .chatgpt(_, plan) = details { plan } else { nil } + } ?? rateLimitsPlan) + let credits = Self.makeCredits(from: limits.credits) + let shouldReturnUnavailableUsage = credits == nil || rateLimitsPlan != nil + let usage = CodexReconciledState.fromCLI( + primary: Self.makeWindow(from: limits.primary), + secondary: Self.makeWindow(from: limits.secondary), + identity: identity)? + .toUsageSnapshot() + ?? (shouldReturnUnavailableUsage ? Self.emptyCodexUsageSnapshotIfIdentified(identity: identity) : nil) + guard usage != nil || credits != nil else { + throw UsageError.noRateLimitsFound + } + return CodexCLIAccountSnapshot( + usage: usage, + credits: credits) + } catch { + let usage = Self.recoverUsageFromRPCError(error) + let credits = Self.recoverCreditsFromRPCError(error) + if usage != nil || credits != nil { + return CodexCLIAccountSnapshot( + usage: usage, + credits: credits) + } + throw error } - - let primary = RateWindow( - usedPercent: max(0, 100 - Double(fiveLeft)), - windowMinutes: 300, - resetsAt: nil, - resetDescription: status.fiveHourResetDescription) - let secondary = RateWindow( - usedPercent: max(0, 100 - Double(weekLeft)), - windowMinutes: 10080, - resetsAt: nil, - resetDescription: status.weeklyResetDescription) - - return UsageSnapshot( - primary: primary, - secondary: secondary, - tertiary: nil, - updatedAt: Date(), - identity: nil) } public func loadLatestCredits(keepCLISessionsAlive: Bool = false) async throws -> CreditsSnapshot { - try await self.withFallback( - primary: self.loadRPCCredits, - secondary: { try await self.loadTTYCredits(keepCLISessionsAlive: keepCLISessionsAlive) }) - } - - private func loadRPCCredits() async throws -> CreditsSnapshot { - let rpc = try CodexRPCClient() - defer { rpc.shutdown() } - try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") - let limits = try await rpc.fetchRateLimits().rateLimits - guard let credits = limits.credits else { throw UsageError.noRateLimitsFound } - let remaining = Self.parseCredits(credits.balance) - return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) - } - - private func loadTTYCredits(keepCLISessionsAlive: Bool) async throws -> CreditsSnapshot { - let status = try await CodexStatusProbe(keepCLISessionsAlive: keepCLISessionsAlive).fetch() - guard let credits = status.credits else { throw UsageError.noRateLimitsFound } - return CreditsSnapshot(remaining: credits, events: [], updatedAt: Date()) - } - - private func withFallback( - primary: @escaping () async throws -> T, - secondary: @escaping () async throws -> T) async throws -> T - { - do { - return try await primary() - } catch let primaryError { - do { - return try await secondary() - } catch { - // Preserve the original failure so callers see the primary path error. - throw primaryError - } + _ = keepCLISessionsAlive + guard let credits = try await self.loadLatestCLIAccountSnapshot().credits else { + throw UsageError.noRateLimitsFound } + return credits } public func debugRawRateLimits() async -> String { do { - let rpc = try CodexRPCClient() + let rpc = try CodexRPCClient( + environment: self.environment, + initializeTimeoutSeconds: self.initializeTimeoutSeconds, + requestTimeoutSeconds: self.requestTimeoutSeconds, + resolveExecutable: self.codexExecutableResolver) defer { rpc.shutdown() } try await rpc.initialize(clientName: "codexbar", clientVersion: "0.5.4") let limits = try await rpc.fetchRateLimits() @@ -637,30 +924,30 @@ public struct UsageFetcher: Sendable { } public func loadAccountInfo() -> AccountInfo { - // Keep using auth.json for quick startup (non-blocking, no RPC spin-up required). - let authURL = URL(fileURLWithPath: self.environment["CODEX_HOME"] ?? "\(NSHomeDirectory())/.codex") - .appendingPathComponent("auth.json") - guard let data = try? Data(contentsOf: authURL), - let auth = try? JSONDecoder().decode(AuthFile.self, from: data), - let idToken = auth.tokens?.idToken - else { - return AccountInfo(email: nil, plan: nil) - } + let account = self.loadAuthBackedCodexAccount() + return AccountInfo(email: account.email, plan: account.plan) + } - guard let payload = UsageFetcher.parseJWT(idToken) else { - return AccountInfo(email: nil, plan: nil) + public func loadAuthBackedCodexAccount() -> CodexAuthBackedAccount { + guard let credentials = try? CodexOAuthCredentialsStore.load(env: self.environment) else { + return CodexAuthBackedAccount(identity: .unresolved, email: nil, plan: nil) } - let authDict = payload["https://api.openai.com/auth"] as? [String: Any] - let profileDict = payload["https://api.openai.com/profile"] as? [String: Any] - - let plan = (authDict?["chatgpt_plan_type"] as? String) - ?? (payload["chatgpt_plan_type"] as? String) - - let email = (payload["email"] as? String) - ?? (profileDict?["email"] as? String) - - return AccountInfo(email: email, plan: plan) + let payload = credentials.idToken.flatMap(Self.parseJWT) + let authDict = payload?["https://api.openai.com/auth"] as? [String: Any] + let profileDict = payload?["https://api.openai.com/profile"] as? [String: Any] + + let email = Self.normalizedCodexAccountField( + (payload?["email"] as? String) ?? (profileDict?["email"] as? String)) + let plan = Self.normalizedCodexAccountField( + (authDict?["chatgpt_plan_type"] as? String) ?? (payload?["chatgpt_plan_type"] as? String)) + let accountId = Self.normalizedCodexAccountField( + credentials.accountId + ?? (authDict?["chatgpt_account_id"] as? String) + ?? (payload?["chatgpt_account_id"] as? String)) + let identity = CodexIdentityResolver.resolve(accountId: accountId, email: email) + + return CodexAuthBackedAccount(identity: identity, email: email, plan: plan) } // MARK: - Helpers @@ -676,11 +963,133 @@ public struct UsageFetcher: Sendable { resetDescription: resetDescription) } + private static func makeWindow(from response: CodexUsageResponse.WindowSnapshot?) -> RateWindow? { + guard let response else { return nil } + let resetsAtDate = Date(timeIntervalSince1970: TimeInterval(response.resetAt)) + return RateWindow( + usedPercent: Double(response.usedPercent), + windowMinutes: response.limitWindowSeconds / 60, + resetsAt: resetsAtDate, + resetDescription: UsageFormatter.resetDescription(from: resetsAtDate)) + } + + private static func makeTTYWindow( + percentLeft: Int?, + windowMinutes: Int, + resetsAt: Date?, + resetDescription: String?) -> RateWindow? + { + guard let percentLeft else { return nil } + return RateWindow( + usedPercent: max(0, 100 - Double(percentLeft)), + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: resetDescription) + } + private static func parseCredits(_ balance: String?) -> Double { guard let balance, let val = Double(balance) else { return 0 } return val } + private static func makeCredits(from rpc: RPCCreditsSnapshot?) -> CreditsSnapshot? { + guard let rpc else { return nil } + return CreditsSnapshot(remaining: self.parseCredits(rpc.balance), events: [], updatedAt: Date()) + } + + private static func emptyCodexUsageSnapshotIfIdentified(identity: ProviderIdentitySnapshot) -> UsageSnapshot? { + guard identity.accountEmail != nil || identity.loginMethod != nil else { return nil } + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: identity) + } + + private static func recoverUsageFromRPCError(_ error: Error) -> UsageSnapshot? { + guard let body = self.decodeRateLimitsErrorBody(from: error) else { return nil } + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: self.normalizedCodexAccountField(body.email), + accountOrganization: nil, + loginMethod: self.normalizedCodexAccountField(body.planType)) + guard let state = CodexReconciledState.fromCLI( + primary: self.makeWindow(from: body.rateLimit?.primaryWindow), + secondary: self.makeWindow(from: body.rateLimit?.secondaryWindow), + identity: identity) + else { + return nil + } + if body.rateLimit?.hasWindowDecodeFailure == true, + state.session == nil + { + return nil + } + return state.toUsageSnapshot() + } + + private static func recoverCreditsFromRPCError(_ error: Error) -> CreditsSnapshot? { + guard let credits = self.decodeRateLimitsErrorBody(from: error)?.credits else { return nil } + guard let remaining = credits.balance else { return nil } + return CreditsSnapshot(remaining: remaining, events: [], updatedAt: Date()) + } + + private static func decodeRateLimitsErrorBody(from error: Error) -> RPCRateLimitsErrorBody? { + guard case let RPCWireError.requestFailed(message) = error else { return nil } + guard let json = self.extractJSONObject(after: "body=", in: message) else { return nil } + guard let data = json.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(RPCRateLimitsErrorBody.self, from: data) + } + + private static func extractJSONObject(after marker: String, in text: String) -> String? { + guard let markerRange = text.range(of: marker) else { return nil } + let suffix = text[markerRange.upperBound...] + guard let start = suffix.firstIndex(of: "{") else { return nil } + + var depth = 0 + var inString = false + var isEscaped = false + + for index in suffix[start...].indices { + let character = suffix[index] + + if inString { + if isEscaped { + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + inString = false + } + continue + } + + switch character { + case "\"": + inString = true + case "{": + depth += 1 + case "}": + depth -= 1 + if depth == 0 { + return String(suffix[start...index]) + } + default: + break + } + } + + return nil + } + + private static func normalizedCodexAccountField(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + public static func parseJWT(_ token: String) -> [String: Any]? { let parts = token.split(separator: ".") guard parts.count >= 2 else { return nil } @@ -698,8 +1107,68 @@ public struct UsageFetcher: Sendable { } } -/// Minimal auth.json struct preserved from previous implementation -private struct AuthFile: Decodable { - struct Tokens: Decodable { let idToken: String? } - let tokens: Tokens? +#if DEBUG +extension UsageFetcher { + static func _mapCodexRPCLimitsForTesting( + primary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, + secondary: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)?, + planType: String? = nil) throws -> UsageSnapshot + { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: self.normalizedCodexAccountField(planType)) + guard let state = CodexReconciledState.fromCLI( + primary: primary.map(self.makeTestingWindow), + secondary: secondary.map(self.makeTestingWindow), + identity: identity) + else { + if let usage = self.emptyCodexUsageSnapshotIfIdentified(identity: identity) { + return usage + } + throw UsageError.noRateLimitsFound + } + return state.toUsageSnapshot() + } + + static func _mapCodexStatusForTesting(_ status: CodexStatusSnapshot) throws -> UsageSnapshot { + guard let state = CodexReconciledState.fromCLI( + primary: self.makeTTYWindow( + percentLeft: status.fiveHourPercentLeft, + windowMinutes: 300, + resetsAt: status.fiveHourResetsAt, + resetDescription: status.fiveHourResetDescription), + secondary: self.makeTTYWindow( + percentLeft: status.weeklyPercentLeft, + windowMinutes: 10080, + resetsAt: status.weeklyResetsAt, + resetDescription: status.weeklyResetDescription), + identity: nil) + else { + throw UsageError.noRateLimitsFound + } + return state.toUsageSnapshot() + } + + public static func _recoverCodexRPCUsageFromErrorForTesting(_ message: String) -> UsageSnapshot? { + self.recoverUsageFromRPCError(RPCWireError.requestFailed(message)) + } + + public static func _recoverCodexRPCCreditsFromErrorForTesting(_ message: String) -> CreditsSnapshot? { + self.recoverCreditsFromRPCError(RPCWireError.requestFailed(message)) + } + + private static func makeTestingWindow( + _ value: (usedPercent: Double, windowMinutes: Int, resetsAt: Int?)) + -> RateWindow + { + let resetsAt = value.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + return RateWindow( + usedPercent: value.usedPercent, + windowMinutes: value.windowMinutes, + resetsAt: resetsAt, + resetDescription: resetsAt.map { UsageFormatter.resetDescription(from: $0) }) + } } +#endif diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 226d43569..9f0b1b641 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -6,10 +6,75 @@ public enum ResetTimeDisplayStyle: String, Codable, Sendable { } public enum UsageFormatter { + private final class BundleToken {} + + private static let localizationLock = NSLock() + private nonisolated(unsafe) static var localizationProvider: (@Sendable (String) -> String)? + private nonisolated(unsafe) static var localeProvider: (@Sendable () -> Locale)? + + public static func setLocalizationProvider(_ provider: @escaping @Sendable (String) -> String) { + self.localizationLock.lock() + self.localizationProvider = provider + self.localizationLock.unlock() + } + + public static func clearLocalizationProvider() { + self.localizationLock.lock() + self.localizationProvider = nil + self.localizationLock.unlock() + } + + public static func setLocaleProvider(_ provider: @escaping @Sendable () -> Locale) { + self.localizationLock.lock() + self.localeProvider = provider + self.localizationLock.unlock() + } + + public static func clearLocaleProvider() { + self.localizationLock.lock() + self.localeProvider = nil + self.localizationLock.unlock() + } + + private static func currentLocale() -> Locale { + self.localizationLock.lock() + let provider = self.localeProvider + self.localizationLock.unlock() + return provider?() ?? Locale(identifier: "en_US_POSIX") + } + + private static func localized(_ key: String) -> String { + self.localizationLock.lock() + let provider = self.localizationProvider + self.localizationLock.unlock() + if let provider { + return provider(key) + } + let coreBundle = Bundle(for: BundleToken.self) + let coreValue = NSLocalizedString(key, tableName: "Localizable", bundle: coreBundle, value: key, comment: "") + if coreValue != key { return coreValue } + + let mainValue = NSLocalizedString(key, tableName: "Localizable", bundle: .main, value: key, comment: "") + if mainValue != key { return mainValue } + + switch key { + case "usage_percent_suffix_left": return "left" + case "usage_percent_suffix_used": return "used" + default: return key + } + } + + private static func localized(_ key: String, _ args: CVarArg...) -> String { + let format = self.localized(key) + return String(format: format, locale: self.currentLocale(), arguments: args) + } + public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { let percent = showUsed ? used : remaining let clamped = min(100, max(0, percent)) - let suffix = showUsed ? "used" : "left" + let suffix = showUsed + ? self.localized("usage_percent_suffix_used") + : self.localized("usage_percent_suffix_left") return String(format: "%.0f%% %@", clamped, suffix) } @@ -37,14 +102,14 @@ public enum UsageFormatter { // Human-friendly phrasing: today / tomorrow / date+time. let calendar = Calendar.current if calendar.isDate(date, inSameDayAs: now) { - return date.formatted(date: .omitted, time: .shortened) + return date.formatted(.dateTime.hour().minute().locale(self.currentLocale())) } if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(date, inSameDayAs: tomorrow) { - return "tomorrow, \(date.formatted(date: .omitted, time: .shortened))" + return "tomorrow, \(date.formatted(.dateTime.hour().minute().locale(self.currentLocale())))" } - return date.formatted(date: .abbreviated, time: .shortened) + return date.formatted(.dateTime.month(.abbreviated).day().hour().minute().locale(self.currentLocale())) } public static func resetLine( @@ -53,17 +118,30 @@ public enum UsageFormatter { now: Date = .init()) -> String? { if let date = window.resetsAt { - let text = style == .countdown - ? self.resetCountdownDescription(from: date, now: now) - : self.resetDescription(from: date, now: now) - return "Resets \(text)" + if style == .countdown { + let countdown = self.resetCountdownDescription(from: date, now: now) + if countdown == "now" { + return self.localized("Resets now") + } + if countdown.hasPrefix("in ") { + return self.localized("Resets in %@", String(countdown.dropFirst(3))) + } + return self.localized("Resets %@", countdown) + } + let text = self.resetDescription(from: date, now: now) + return self.localized("Resets %@", text) } if let desc = window.resetDescription { let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - if trimmed.lowercased().hasPrefix("resets") { return trimmed } - return "Resets \(trimmed)" + if trimmed.lowercased().hasPrefix("resets in ") { + return self.localized("Resets in %@", String(trimmed.dropFirst("Resets in ".count))) + } + if trimmed.lowercased().hasPrefix("resets ") { + return self.localized("Resets %@", String(trimmed.dropFirst("Resets ".count))) + } + return self.localized("Resets %@", trimmed) } return nil } @@ -71,24 +149,27 @@ public enum UsageFormatter { public static func updatedString(from date: Date, now: Date = .init()) -> String { let delta = now.timeIntervalSince(date) if abs(delta) < 60 { - return "Updated just now" + return self.localized("Updated just now") } if let hours = Calendar.current.dateComponents([.hour], from: date, to: now).hour, hours < 24 { #if os(macOS) let rel = RelativeDateTimeFormatter() + rel.locale = self.currentLocale() rel.unitsStyle = .abbreviated - return "Updated \(rel.localizedString(for: date, relativeTo: now))" + return self.localized("Updated %@", rel.localizedString(for: date, relativeTo: now)) #else let seconds = max(0, Int(now.timeIntervalSince(date))) if seconds < 3600 { let minutes = max(1, seconds / 60) - return "Updated \(minutes)m ago" + return self.localized("Updated %@m ago", String(minutes)) } let wholeHours = max(1, seconds / 3600) - return "Updated \(wholeHours)h ago" + return self.localized("Updated %@h ago", String(wholeHours)) #endif } else { - return "Updated \(date.formatted(date: .omitted, time: .shortened))" + return self.localized( + "Updated %@", + date.formatted(.dateTime.hour().minute().locale(self.currentLocale()))) } } @@ -99,7 +180,15 @@ public enum UsageFormatter { // Use explicit locale for consistent formatting on all systems number.locale = Locale(identifier: "en_US_POSIX") let formatted = number.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) - return "\(formatted) left" + return self.localized("%@ left", formatted) + } + + public static func kiroCreditNumber(_ value: Double) -> String { + let rounded = value.rounded() + if abs(value - rounded) < 0.005 { + return String(format: "%.0f", rounded) + } + return String(format: "%.2f", value) } /// Formats a USD value with proper negative handling and thousand separators. @@ -108,6 +197,18 @@ public enum UsageFormatter { value.formatted(.currency(code: "USD").locale(Locale(identifier: "en_US"))) } + public static let costEstimateHint = "Estimated from local logs · may differ from your bill" + + public static func costEstimateHint(provider: UsageProvider) -> String { + switch provider { + case .claude: + "Estimated from local Claude logs at API rates; token totals include cache read/write tokens " + + "and may differ from Claude Code /status." + default: + self.costEstimateHint + } + } + /// Formats a currency value with the specified currency code. /// Uses FormatStyle with explicit en_US locale to ensure consistent formatting /// regardless of the user's system locale (e.g., pt-BR users see $54.72 not US$ 54,72). @@ -145,6 +246,25 @@ public enum UsageFormatter { return formatter.string(from: NSNumber(value: value)) ?? "\(value)" } + public static func byteCountString(_ bytes: Int64) -> String { + let sign = bytes < 0 ? "-" : "" + let absBytes = Double(Swift.abs(bytes)) + let units: [(threshold: Double, divisor: Double, suffix: String)] = [ + (1024 * 1024 * 1024, 1024 * 1024 * 1024, "GB"), + (1024 * 1024, 1024 * 1024, "MB"), + (1024, 1024, "KB"), + ] + + for unit in units where absBytes >= unit.threshold { + let scaled = absBytes / unit.divisor + let format = scaled >= 10 || scaled.rounded(.towardZero) == scaled ? "%.0f" : "%.1f" + let formatted = String(format: format, scaled) + return "\(sign)\(formatted) \(unit.suffix)" + } + + return "\(bytes) B" + } + public static func creditEventSummary(_ event: CreditEvent) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -206,6 +326,26 @@ public enum UsageFormatter { return cleaned.isEmpty ? raw : cleaned } + public static func modelCostDetail( + _ model: String, + costUSD: Double?, + totalTokens: Int? = nil, + currencyCode: String = "USD") -> String? + { + let costDetail: String? = if let label = CostUsagePricing.codexDisplayLabel(model: model) { + label + } else if let costUSD { + self.currencyString(costUSD, currencyCode: currencyCode) + } else { + nil + } + + let tokenDetail = totalTokens.map(self.tokenCountString) + let parts = [costDetail, tokenDetail].compactMap(\.self) + guard !parts.isEmpty else { return nil } + return parts.joined(separator: " · ") + } + /// Cleans a provider plan string: strip ANSI/bracket noise, drop boilerplate words, collapse whitespace, and /// ensure a leading capital if the result starts lowercase. public static func cleanPlanName(_ text: String) -> String { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift index e56131a41..effe1478d 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCache.swift @@ -1,6 +1,17 @@ import Foundation enum CostUsageCacheIO { + private static func artifactVersion(for provider: UsageProvider) -> Int { + switch provider { + case .codex: + 8 + case .claude, .vertexai: + 2 + default: + 1 + } + } + private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! return root.appendingPathComponent("CodexBar", isDirectory: true) @@ -8,44 +19,80 @@ enum CostUsageCacheIO { static func cacheFileURL(provider: UsageProvider, cacheRoot: URL? = nil) -> URL { let root = cacheRoot ?? self.defaultCacheRoot() + let artifactVersion = self.artifactVersion(for: provider) return root .appendingPathComponent("cost-usage", isDirectory: true) - .appendingPathComponent("\(provider.rawValue)-v1.json", isDirectory: false) + .appendingPathComponent("\(provider.rawValue)-v\(artifactVersion).json", isDirectory: false) } - static func load(provider: UsageProvider, cacheRoot: URL? = nil) -> CostUsageCache { + static func load( + provider: UsageProvider, + cacheRoot: URL? = nil, + producerKey: String? = nil) -> CostUsageCache + { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) - if let decoded = self.loadCache(at: url) { return decoded } + let expectedProducerKey = producerKey ?? self.currentProducerKey(provider: provider) + if let decoded = self.loadCache(at: url, expectedProducerKey: expectedProducerKey) { return decoded } return CostUsageCache() } - private static func loadCache(at url: URL) -> CostUsageCache? { + private static func loadCache(at url: URL, expectedProducerKey: String?) -> CostUsageCache? { guard let data = try? Data(contentsOf: url) else { return nil } guard let decoded = try? JSONDecoder().decode(CostUsageCache.self, from: data) else { return nil } guard decoded.version == 1 else { return nil } + if let expectedProducerKey { + guard decoded.producerKey == expectedProducerKey else { return nil } + } return decoded } - static func save(provider: UsageProvider, cache: CostUsageCache, cacheRoot: URL? = nil) { + static func save( + provider: UsageProvider, + cache: CostUsageCache, + cacheRoot: URL? = nil, + producerKey: String? = nil) + { let url = self.cacheFileURL(provider: provider, cacheRoot: cacheRoot) let dir = url.deletingLastPathComponent() try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + var cache = cache + cache.producerKey = producerKey ?? self.currentProducerKey(provider: provider) + let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) let data = (try? JSONEncoder().encode(cache)) ?? Data() do { try data.write(to: tmp, options: [.atomic]) - _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } else { + try FileManager.default.moveItem(at: tmp, to: url) + } } catch { try? FileManager.default.removeItem(at: tmp) } } + + static func currentProducerKey( + provider: UsageProvider, + parserHash: String = CodexParserHash.value) -> String? + { + guard provider == .codex else { return nil } + return "\(provider.rawValue):cu:p\(parserHash)" + } } -struct CostUsageCache: Codable, Sendable { +struct CostUsageCache: Codable { var version: Int = 1 + var producerKey: String? var lastScanUnixMs: Int64 = 0 + var scanSinceKey: String? + var scanUntilKey: String? + var codexPricingKey: String? + var codexPriorityMetadataKey: String? + var codexPriorityTurnKeys: [String: String]? + var codexPriorityTurnIDsByDay: [String: [String]]? /// filePath -> file usage var files: [String: CostUsageFileUsage] = [:] @@ -57,17 +104,31 @@ struct CostUsageCache: Codable, Sendable { var roots: [String: Int64]? } -struct CostUsageFileUsage: Codable, Sendable { +struct CostUsageFileUsage: Codable { var mtimeUnixMs: Int64 var size: Int64 var days: [String: [String: [Int]]] var parsedBytes: Int64? var lastModel: String? var lastTotals: CostUsageCodexTotals? + var lastCountedTotals: CostUsageCodexTotals? + var lastRawTotalsBaseline: CostUsageCodexTotals? + var hasDivergentTotals: Bool? + var lastCodexTurnID: String? var sessionId: String? + var forkedFromId: String? + var codexCostNanos: [String: [String: Int64]]? + var codexPrioritySurchargeNanos: [String: [String: Int64]]? + var codexStandardCostNanos: [String: [String: Int64]]? + var codexPriorityCostNanos: [String: [String: Int64]]? + var codexStandardTokens: [String: [String: Int]]? + var codexPriorityTokens: [String: [String: Int]]? + var codexTurnIDs: [String]? + var codexRows: [CostUsageScanner.CodexUsageRow]? + var claudeRows: [CostUsageScanner.ClaudeUsageRow]? } -struct CostUsageCodexTotals: Codable, Sendable { +struct CostUsageCodexTotals: Codable { var input: Int var cached: Int var output: Int diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift index 5105f9759..7e13a9183 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift @@ -1,7 +1,7 @@ import Foundation enum CostUsageJsonl { - struct Line: Sendable { + struct Line { let bytes: Data let wasTruncated: Bool } @@ -14,6 +14,25 @@ enum CostUsageJsonl { prefixBytes: Int, onLine: (Line) -> Void) throws -> Int64 + { + try self.scan( + fileURL: fileURL, + offset: offset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + checkCancellation: nil, + onLine: onLine) + } + + @discardableResult + static func scan( + fileURL: URL, + offset: Int64 = 0, + maxLineBytes: Int, + prefixBytes: Int, + checkCancellation: (() throws -> Void)? = nil, + onLine: (Line) -> Void) throws + -> Int64 { let handle = try FileHandle(forReadingFrom: fileURL) defer { try? handle.close() } @@ -29,16 +48,18 @@ enum CostUsageJsonl { var truncated = false var bytesRead: Int64 = 0 - func appendSegment(_ segment: Data.SubSequence) { - guard !segment.isEmpty else { return } - lineBytes += segment.count - guard !truncated else { return } + func appendSegment(_ bytes: UnsafePointer, count: Int) { + guard count > 0 else { return } + lineBytes += count + if current.count < prefixBytes { + let appendCount = min(prefixBytes - current.count, count) + if appendCount > 0 { + current.append(bytes, count: appendCount) + } + } if lineBytes > maxLineBytes || lineBytes > prefixBytes { truncated = true - current.removeAll(keepingCapacity: true) - return } - current.append(contentsOf: segment) } func flushLine() { @@ -51,22 +72,32 @@ enum CostUsageJsonl { } while true { + try checkCancellation?() let chunk = try handle.read(upToCount: 256 * 1024) ?? Data() if chunk.isEmpty { flushLine() break } + try checkCancellation?() bytesRead += Int64(chunk.count) - var segmentStart = chunk.startIndex - while let nl = chunk[segmentStart...].firstIndex(of: 0x0A) { - appendSegment(chunk[segmentStart.. String { + var parts = ["priorityInputTokenLimit=\(self.codexPriorityInputTokenLimit)"] + for model in self.codex.keys.sorted() { + guard let pricing = self.codex[model] else { continue } + parts.append([ + "model=\(model)", + self.optionalPricingFingerprint(pricing.inputCostPerToken), + self.optionalPricingFingerprint(pricing.outputCostPerToken), + self.optionalPricingFingerprint(pricing.cacheReadInputCostPerToken), + pricing.displayLabel ?? "nil", + pricing.thresholdTokens.map(String.init) ?? "nil", + self.optionalPricingFingerprint(pricing.inputCostPerTokenAboveThreshold), + self.optionalPricingFingerprint(pricing.outputCostPerTokenAboveThreshold), + self.optionalPricingFingerprint(pricing.cacheReadInputCostPerTokenAboveThreshold), + self.optionalPricingFingerprint(pricing.priorityInputCostPerToken), + self.optionalPricingFingerprint(pricing.priorityOutputCostPerToken), + self.optionalPricingFingerprint(pricing.priorityCacheReadInputCostPerToken), + ].joined(separator: "|")) + } + return parts.joined(separator: "\n") + } + + private static func optionalPricingFingerprint(_ value: Double?) -> String { + guard let value else { return "nil" } + return String(format: "%.17g", value) + } + private static let claude: [String: ClaudePricing] = [ "claude-haiku-4-5-20251001": ClaudePricing( inputCostPerToken: 1e-6, @@ -112,6 +265,16 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: nil, cacheCreationInputCostPerTokenAboveThreshold: nil, cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-opus-4-7": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, @@ -122,6 +285,16 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: 2.25e-5, cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6, cacheReadInputCostPerTokenAboveThreshold: 6e-7), + "claude-sonnet-4-6": ClaudePricing( + inputCostPerToken: 3e-6, + outputCostPerToken: 1.5e-5, + cacheCreationInputCostPerToken: 3.75e-6, + cacheReadInputCostPerToken: 3e-7, + thresholdTokens: 200_000, + inputCostPerTokenAboveThreshold: 6e-6, + outputCostPerTokenAboveThreshold: 2.25e-5, + cacheCreationInputCostPerTokenAboveThreshold: 7.5e-6, + cacheReadInputCostPerTokenAboveThreshold: 6e-7), "claude-sonnet-4-5-20250929": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, @@ -164,18 +337,33 @@ enum CostUsagePricing { cacheReadInputCostPerTokenAboveThreshold: 6e-7), ] + private static let codexModelsDevProviderID = "openai" + private static let claudeModelsDevProviderID = "anthropic" + static func normalizeCodexModel(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("openai/") { trimmed = String(trimmed.dropFirst("openai/".count)) } - if let codexRange = trimmed.range(of: "-codex") { - let base = String(trimmed[.. String? { + let key = self.normalizeCodexModel(model) + return self.codex[key]?.displayLabel + } + static func normalizeClaudeModel(_ raw: String) -> String { var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("anthropic.") { @@ -205,14 +393,110 @@ enum CostUsagePricing { return trimmed } - static func codexCostUSD(model: String, inputTokens: Int, cachedInputTokens: Int, outputTokens: Int) -> Double? { + static func codexCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? + { let key = self.normalizeCodexModel(model) + if let lookup = self.modelsDevLookup( + providerID: self.codexModelsDevProviderID, + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.codexCostUSD( + pricing: lookup.pricing, + thresholdTokens: self.codex[key]?.thresholdTokens, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + guard let pricing = self.codex[key] else { return nil } + return self.codexCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + + static func codexPriorityCostUSD( + model: String, + inputTokens: Int, + cachedInputTokens: Int = 0, + outputTokens: Int) -> Double? + { + let key = self.normalizeCodexModel(model) + guard let pricing = self.codex[key], + let priorityInputCostPerToken = pricing.priorityInputCostPerToken, + let priorityOutputCostPerToken = pricing.priorityOutputCostPerToken + else { return nil } + if max(0, inputTokens) > self.codexPriorityInputTokenLimit { + return nil + } + + let priorityPricing = CodexPricing( + inputCostPerToken: priorityInputCostPerToken, + outputCostPerToken: priorityOutputCostPerToken, + cacheReadInputCostPerToken: pricing.priorityCacheReadInputCostPerToken, + displayLabel: nil) + return self.codexCostUSD( + pricing: priorityPricing, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) + } + + private static func codexCostUSD( + pricing: CodexPricing, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int) -> Double + { let cached = min(max(0, cachedInputTokens), max(0, inputTokens)) let nonCached = max(0, inputTokens - cached) - return Double(nonCached) * pricing.inputCostPerToken - + Double(cached) * pricing.cacheReadInputCostPerToken - + Double(max(0, outputTokens)) * pricing.outputCostPerToken + let cachedRate = pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken + + let usesLongContextRates = pricing.thresholdTokens.map { max(0, inputTokens) > $0 } ?? false + let inputRate = usesLongContextRates + ? pricing.inputCostPerTokenAboveThreshold ?? pricing.inputCostPerToken + : pricing.inputCostPerToken + let cachedInputRate = usesLongContextRates + ? pricing.cacheReadInputCostPerTokenAboveThreshold ?? cachedRate + : cachedRate + let outputRate = usesLongContextRates + ? pricing.outputCostPerTokenAboveThreshold ?? pricing.outputCostPerToken + : pricing.outputCostPerToken + + return (Double(nonCached) * inputRate) + + (Double(cached) * cachedInputRate) + + (Double(max(0, outputTokens)) * outputRate) + } + + private static func codexCostUSD( + pricing: ModelsDevPricingInfo, + thresholdTokens: Int? = nil, + inputTokens: Int, + cachedInputTokens: Int, + outputTokens: Int) -> Double + { + self.codexCostUSD( + pricing: CodexPricing( + inputCostPerToken: pricing.inputCostPerToken, + outputCostPerToken: pricing.outputCostPerToken, + cacheReadInputCostPerToken: pricing.cacheReadInputCostPerToken, + displayLabel: nil, + thresholdTokens: thresholdTokens ?? pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens) } static func claudeCostUSD( @@ -220,11 +504,41 @@ enum CostUsagePricing { inputTokens: Int, cacheReadInputTokens: Int, cacheCreationInputTokens: Int, - outputTokens: Int) -> Double? + outputTokens: Int, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> Double? { + if let lookup = self.modelsDevLookup( + providerID: self.claudeModelsDevProviderID, + model: model, + catalog: modelsDevCatalog, + cacheRoot: modelsDevCacheRoot) + { + return self.claudeCostUSD( + pricing: lookup.pricing, + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + cacheCreationInputTokens: cacheCreationInputTokens, + outputTokens: outputTokens) + } + let key = self.normalizeClaudeModel(model) guard let pricing = self.claude[key] else { return nil } + return self.claudeCostUSD( + pricing: pricing, + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + cacheCreationInputTokens: cacheCreationInputTokens, + outputTokens: outputTokens) + } + private static func claudeCostUSD( + pricing: ClaudePricing, + inputTokens: Int, + cacheReadInputTokens: Int, + cacheCreationInputTokens: Int, + outputTokens: Int) -> Double + { func tiered(_ tokens: Int, base: Double, above: Double?, threshold: Int?) -> Double { guard let threshold, let above else { return Double(tokens) * base } let below = min(tokens, threshold) @@ -253,4 +567,48 @@ enum CostUsagePricing { above: pricing.outputCostPerTokenAboveThreshold, threshold: pricing.thresholdTokens) } + + private static func claudeCostUSD( + pricing: ModelsDevPricingInfo, + inputTokens: Int, + cacheReadInputTokens: Int, + cacheCreationInputTokens: Int, + outputTokens: Int) -> Double + { + self.claudeCostUSD( + pricing: ClaudePricing( + inputCostPerToken: pricing.inputCostPerToken, + outputCostPerToken: pricing.outputCostPerToken, + cacheCreationInputCostPerToken: pricing.cacheCreationInputCostPerToken ?? pricing.inputCostPerToken, + cacheReadInputCostPerToken: pricing.cacheReadInputCostPerToken ?? pricing.inputCostPerToken, + thresholdTokens: pricing.thresholdTokens, + inputCostPerTokenAboveThreshold: pricing.inputCostPerTokenAboveThreshold, + outputCostPerTokenAboveThreshold: pricing.outputCostPerTokenAboveThreshold, + cacheCreationInputCostPerTokenAboveThreshold: pricing.cacheCreationInputCostPerTokenAboveThreshold, + cacheReadInputCostPerTokenAboveThreshold: pricing.cacheReadInputCostPerTokenAboveThreshold), + inputTokens: inputTokens, + cacheReadInputTokens: cacheReadInputTokens, + cacheCreationInputTokens: cacheCreationInputTokens, + outputTokens: outputTokens) + } + + static func modelsDevCatalog(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCatalog? { + ModelsDevCache.load(now: now, cacheRoot: cacheRoot).artifact?.catalog + } + + private static func modelsDevLookup( + providerID: String, + model: String, + catalog: ModelsDevCatalog?, + cacheRoot: URL?) -> ModelsDevPricingLookup? + { + if let catalog { + return catalog.pricing(providerID: providerID, modelID: model) + } + + return ModelsDevPricingPipeline.lookup( + providerID: providerID, + modelID: model, + cacheRoot: cacheRoot) + } } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift new file mode 100644 index 000000000..64d351eb9 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -0,0 +1,1197 @@ +import Foundation + +extension CostUsageScanner { + static func codexRowsByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: [CodexUsageRow]]] + { + var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] + for usage in cache.files.values { + for row in usage.codexRows ?? [] { + guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) + else { continue } + rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) + } + } + return rowsByDayModel + } + + static func codexRowsByDayModel( + rows: [CodexUsageRow], + range: CostUsageDayRange) -> [String: [String: [CodexUsageRow]]] + { + var rowsByDayModel: [String: [String: [CodexUsageRow]]] = [:] + for row in rows { + guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) + else { continue } + rowsByDayModel[row.day, default: [:]][row.model, default: []].append(row) + } + return rowsByDayModel + } + + static func codexCostNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int64]] + { + self.codexNanosByDayModel(cache: cache, range: range) { $0.codexCostNanos } + } + + static func codexPrioritySurchargeNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int64]] + { + self.codexNanosByDayModel(cache: cache, range: range) { $0.codexPrioritySurchargeNanos } + } + + static func codexStandardCostNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int64]] + { + self.codexNanosByDayModel(cache: cache, range: range) { $0.codexStandardCostNanos } + } + + static func codexPriorityCostNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int64]] + { + self.codexNanosByDayModel(cache: cache, range: range) { $0.codexPriorityCostNanos } + } + + static func codexStandardTokensByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int]] + { + self.codexIntByDayModel(cache: cache, range: range) { $0.codexStandardTokens } + } + + static func codexPriorityTokensByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange) -> [String: [String: Int]] + { + self.codexIntByDayModel(cache: cache, range: range) { $0.codexPriorityTokens } + } + + static func codexNanosByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange, + keyPath: (CostUsageFileUsage) -> [String: [String: Int64]]?) -> [String: [String: Int64]] + { + var out: [String: [String: Int64]] = [:] + for usage in cache.files.values { + for (day, models) in keyPath(usage) ?? [:] { + guard CostUsageDayRange.isInRange(dayKey: day, since: range.sinceKey, until: range.untilKey) + else { continue } + for (model, value) in models { + out[day, default: [:]][model, default: 0] += value + } + } + } + return out + } + + static func codexIntByDayModel( + cache: CostUsageCache, + range: CostUsageDayRange, + keyPath: (CostUsageFileUsage) -> [String: [String: Int]]?) -> [String: [String: Int]] + { + var out: [String: [String: Int]] = [:] + for usage in cache.files.values { + for (day, models) in keyPath(usage) ?? [:] { + guard CostUsageDayRange.isInRange(dayKey: day, since: range.sinceKey, until: range.untilKey) + else { continue } + for (model, value) in models { + out[day, default: [:]][model, default: 0] += value + } + } + } + return out + } + + static func codexRowsCostUSD( + rows: [CodexUsageRow], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + var total: Double = 0 + var seen = false + for row in rows { + guard let cost = CostUsagePricing.codexCostUSD( + model: row.model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } + total += cost + seen = true + } + return seen ? total : nil + } + + static func codexPrioritySurchargeUSD( + rows: [CodexUsageRow], + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> Double? + { + var total: Double = 0 + var seen = false + for row in rows { + guard let turnID = row.turnID, let priorityMetadata = priorityTurns[turnID] else { continue } + let pricedModel = Self.codexPriorityPricingModel(for: row, priorityMetadata: priorityMetadata) + guard let baseCost = CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot), + let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + else { continue } + total += max(priorityCost - baseCost, 0) + seen = true + } + return seen ? total : nil + } + + private static func codexPriorityPricingModel( + for row: CodexUsageRow, + priorityMetadata: CodexPriorityTurnMetadata) -> String + { + guard let model = priorityMetadata.model, + CostUsagePricing.codexPriorityCostUSD( + model: model, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) != nil + else { return row.model } + return model + } + + struct CodexRowCostBreakdown { + var standardCostUSD: Double = 0 + var priorityCostUSD: Double = 0 + var standardTokens: Int = 0 + var priorityTokens: Int = 0 + var sawStandardCost = false + var sawPriorityCost = false + + var optionalStandardCostUSD: Double? { + self.sawStandardCost ? self.standardCostUSD : nil + } + + var optionalPriorityCostUSD: Double? { + self.sawPriorityCost ? self.priorityCostUSD : nil + } + + var optionalStandardTokens: Int? { + self.standardTokens > 0 ? self.standardTokens : nil + } + + var optionalPriorityTokens: Int? { + self.priorityTokens > 0 ? self.priorityTokens : nil + } + + var totalCostUSD: Double? { + guard self.sawStandardCost || self.sawPriorityCost else { return nil } + return self.standardCostUSD + self.priorityCostUSD + } + + var hasModeSplit: Bool { + self.sawPriorityCost || self.priorityTokens > 0 + } + } + + static func codexRowCostBreakdown( + rows: [CodexUsageRow], + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> CodexRowCostBreakdown + { + var breakdown = CodexRowCostBreakdown() + for row in rows { + let tokenCount = row.input + row.output + let priorityMetadata = row.turnID.flatMap { priorityTurns[$0] } + let isPriority = priorityMetadata != nil + if isPriority { + breakdown.priorityTokens += tokenCount + } else { + breakdown.standardTokens += tokenCount + } + let pricedModel = priorityMetadata.map { Self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } + ?? row.model + + let baseCost = CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + if isPriority, let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + { + breakdown.priorityCostUSD += max(priorityCost, baseCost ?? priorityCost) + breakdown.sawPriorityCost = true + } else if isPriority, let baseCost { + breakdown.priorityCostUSD += baseCost + breakdown.sawPriorityCost = true + } else if let baseCost { + breakdown.standardCostUSD += baseCost + breakdown.sawStandardCost = true + } + } + return breakdown + } + + // MARK: - File cache construction + + static func makeFileUsage( + mtimeUnixMs: Int64, + size: Int64, + days: [String: [String: [Int]]], + parsedBytes: Int64?, + lastModel: String? = nil, + lastTotals: CostUsageCodexTotals? = nil, + lastCountedTotals: CostUsageCodexTotals? = nil, + lastRawTotalsBaseline: CostUsageCodexTotals? = nil, + hasDivergentTotals: Bool? = nil, + lastCodexTurnID: String? = nil, + sessionId: String? = nil, + forkedFromId: String? = nil, + codexCostNanos: [String: [String: Int64]]? = nil, + codexPrioritySurchargeNanos: [String: [String: Int64]]? = nil, + codexStandardCostNanos: [String: [String: Int64]]? = nil, + codexPriorityCostNanos: [String: [String: Int64]]? = nil, + codexStandardTokens: [String: [String: Int]]? = nil, + codexPriorityTokens: [String: [String: Int]]? = nil, + codexTurnIDs: [String]? = nil, + codexRows: [CodexUsageRow]? = nil, + claudeRows: [ClaudeUsageRow]? = nil) -> CostUsageFileUsage + { + CostUsageFileUsage( + mtimeUnixMs: mtimeUnixMs, + size: size, + days: days, + parsedBytes: parsedBytes, + lastModel: lastModel, + lastTotals: lastTotals, + lastCountedTotals: lastCountedTotals, + lastRawTotalsBaseline: lastRawTotalsBaseline, + hasDivergentTotals: hasDivergentTotals, + lastCodexTurnID: lastCodexTurnID, + sessionId: sessionId, + forkedFromId: forkedFromId, + codexCostNanos: codexCostNanos, + codexPrioritySurchargeNanos: codexPrioritySurchargeNanos, + codexStandardCostNanos: codexStandardCostNanos, + codexPriorityCostNanos: codexPriorityCostNanos, + codexStandardTokens: codexStandardTokens, + codexPriorityTokens: codexPriorityTokens, + codexTurnIDs: codexTurnIDs, + codexRows: codexRows, + claudeRows: claudeRows) + } + + static func needsCodexCostCache(_ usage: CostUsageFileUsage) -> Bool { + !(usage.codexRows?.isEmpty ?? true) + && (usage.codexCostNanos == nil || self.needsCodexModeSplitCache(usage)) + } + + static func needsCodexCostCache(_ usage: CostUsageFileUsage, range: CostUsageDayRange) -> Bool { + guard let rows = usage.codexRows, !rows.isEmpty else { return false } + return rows.contains { + CostUsageDayRange.isInRange(dayKey: $0.day, since: range.sinceKey, until: range.untilKey) + } && (usage.codexCostNanos == nil || Self.needsCodexModeSplitCache(usage)) + } + + static func needsCodexModeSplitCache(_ usage: CostUsageFileUsage) -> Bool { + usage.codexStandardCostNanos == nil + || usage.codexPriorityCostNanos == nil + || usage.codexStandardTokens == nil + || usage.codexPriorityTokens == nil + } + + static func codexFileUsageWithCostCache( + _ usage: CostUsageFileUsage, + context: CodexFileScanContext) -> CostUsageFileUsage + { + guard let rows = usage.codexRows, !rows.isEmpty else { return usage } + var migratedRows: [CodexUsageRow] = [] + var retainedRows: [CodexUsageRow] = [] + for row in rows { + if CostUsageDayRange.isInRange( + dayKey: row.day, + since: context.range.scanSinceKey, + until: context.range.scanUntilKey) + { + migratedRows.append(row) + } else { + retainedRows.append(row) + } + } + guard !migratedRows.isEmpty else { return usage } + + let splitMaps = Self.codexModeSplitMaps( + rows: migratedRows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + var updated = usage + updated.codexCostNanos = Self.mergeMissingCostMaps( + usage.codexCostNanos, + Self.codexCostNanos( + rows: migratedRows, + range: context.range, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + updated.codexPrioritySurchargeNanos = Self.mergeMissingCostMaps( + usage.codexPrioritySurchargeNanos, + Self.codexPrioritySurchargeNanos( + rows: migratedRows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + updated.codexStandardCostNanos = Self.mergeMissingCostMaps( + usage.codexStandardCostNanos, + splitMaps.standardCostNanos) + updated.codexPriorityCostNanos = Self.mergeMissingCostMaps( + usage.codexPriorityCostNanos, + splitMaps.priorityCostNanos) + updated.codexStandardTokens = Self.mergeMissingIntMaps( + usage.codexStandardTokens, + splitMaps.standardTokens) + updated.codexPriorityTokens = Self.mergeMissingIntMaps( + usage.codexPriorityTokens, + splitMaps.priorityTokens) + updated.codexTurnIDs = Self.mergeCodexTurnIDs(usage.codexTurnIDs, rows: migratedRows) + updated.codexRows = retainedRows.isEmpty ? nil : retainedRows + return updated + } + + static func codexMergedCostMap( + _ existing: [String: [String: Int64]]?, + deltaRows: [CodexUsageRow], + context: CodexFileScanContext) -> [String: [String: Int64]]? + { + self.mergeCostMaps( + existing, + self.codexCostNanos( + rows: deltaRows, + range: context.range, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + } + + static func codexMergedPrioritySurchargeMap( + _ existing: [String: [String: Int64]]?, + deltaRows: [CodexUsageRow], + context: CodexFileScanContext) -> [String: [String: Int64]]? + { + self.mergeCostMaps( + existing, + self.codexPrioritySurchargeNanos( + rows: deltaRows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)) + } + + static func codexCostNanos( + rows: [CodexUsageRow], + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> [String: [String: Int64]]? + { + let rowsByDayModel = Self.codexRowsByDayModel(rows: rows, range: range) + var out: [String: [String: Int64]] = [:] + for (day, models) in rowsByDayModel { + for (model, rows) in models { + guard let cost = Self.codexRowsCostUSD( + rows: rows, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } + out[day, default: [:]][model] = Int64((cost * Self.costScale).rounded()) + } + } + return out.isEmpty ? nil : out + } + + static func codexPrioritySurchargeNanos( + rows: [CodexUsageRow], + range: CostUsageDayRange, + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> [String: [String: Int64]]? + { + guard !priorityTurns.isEmpty else { return nil } + let rowsByDayModel = Self.codexRowsByDayModel(rows: rows, range: range) + var out: [String: [String: Int64]] = [:] + for (day, models) in rowsByDayModel { + for (model, rows) in models { + guard let surcharge = Self.codexPrioritySurchargeUSD( + rows: rows, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + else { continue } + out[day, default: [:]][model] = Int64((surcharge * Self.costScale).rounded()) + } + } + return out.isEmpty ? nil : out + } + + static func codexModeSplitMaps( + rows: [CodexUsageRow], + range: CostUsageDayRange, + priorityTurns: [String: CodexPriorityTurnMetadata], + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?) -> ( + standardCostNanos: [String: [String: Int64]]?, + priorityCostNanos: [String: [String: Int64]]?, + standardTokens: [String: [String: Int]]?, + priorityTokens: [String: [String: Int]]?) + { + var standardCostNanos: [String: [String: Int64]] = [:] + var priorityCostNanos: [String: [String: Int64]] = [:] + var standardTokens: [String: [String: Int]] = [:] + var priorityTokens: [String: [String: Int]] = [:] + + for row in rows { + guard CostUsageDayRange.isInRange(dayKey: row.day, since: range.sinceKey, until: range.untilKey) + else { continue } + + let tokenCount = row.input + row.output + let priorityMetadata = row.turnID.flatMap { priorityTurns[$0] } + let pricedModel = priorityMetadata.map { Self.codexPriorityPricingModel(for: row, priorityMetadata: $0) } + ?? row.model + let isPriority = priorityMetadata != nil + + if isPriority { + priorityTokens[row.day, default: [:]][row.model, default: 0] += tokenCount + } else { + standardTokens[row.day, default: [:]][row.model, default: 0] += tokenCount + } + + let baseCost = CostUsagePricing.codexCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + + if isPriority, let priorityCost = CostUsagePricing.codexPriorityCostUSD( + model: pricedModel, + inputTokens: row.input, + cachedInputTokens: row.cached, + outputTokens: row.output) + { + priorityCostNanos[row.day, default: [:]][row.model, default: 0] += Int64( + (max(priorityCost, baseCost ?? priorityCost) * Self.costScale).rounded()) + } else if isPriority, let baseCost { + priorityCostNanos[row.day, default: [:]][row.model, default: 0] += Int64( + (baseCost * Self.costScale).rounded()) + } else if let baseCost { + standardCostNanos[row.day, default: [:]][row.model, default: 0] += Int64( + (baseCost * Self.costScale).rounded()) + } + } + + return ( + standardCostNanos.isEmpty ? nil : standardCostNanos, + priorityCostNanos.isEmpty ? nil : priorityCostNanos, + standardTokens.isEmpty ? nil : standardTokens, + priorityTokens.isEmpty ? nil : priorityTokens) + } + + static func codexTurnIDs(rows: [CodexUsageRow]) -> [String]? { + let ids = Set(rows.compactMap(\.turnID)) + return ids.sorted() + } + + static func mergeCodexTurnIDs(_ existing: [String]?, rows: [CodexUsageRow]) -> [String]? { + var ids = Set(existing ?? []) + ids.formUnion(rows.compactMap(\.turnID)) + return ids.sorted() + } + + static func mergeCostMaps( + _ existing: [String: [String: Int64]]?, + _ delta: [String: [String: Int64]]?) -> [String: [String: Int64]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models { + out[day, default: [:]][model, default: 0] += value + } + } + return out.isEmpty ? nil : out + } + + static func mergeMissingCostMaps( + _ existing: [String: [String: Int64]]?, + _ delta: [String: [String: Int64]]?) -> [String: [String: Int64]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models where out[day]?[model] == nil { + out[day, default: [:]][model] = value + } + } + return out.isEmpty ? nil : out + } + + static func mergeIntMaps( + _ existing: [String: [String: Int]]?, + _ delta: [String: [String: Int]]?) -> [String: [String: Int]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models { + out[day, default: [:]][model, default: 0] += value + } + } + return out.isEmpty ? nil : out + } + + static func mergeMissingIntMaps( + _ existing: [String: [String: Int]]?, + _ delta: [String: [String: Int]]?) -> [String: [String: Int]]? + { + var out = existing ?? [:] + for (day, models) in delta ?? [:] { + for (model, value) in models where out[day]?[model] == nil { + out[day, default: [:]][model] = value + } + } + return out.isEmpty ? nil : out + } + + static func costMapOutsideScanWindow( + _ map: [String: [String: Int64]]?, + range: CostUsageDayRange) -> [String: [String: Int64]]? + { + let filtered = (map ?? [:]).filter { + !CostUsageDayRange.isInRange(dayKey: $0.key, since: range.scanSinceKey, until: range.scanUntilKey) + } + return filtered.isEmpty ? nil : filtered + } + + static func intMapOutsideScanWindow( + _ map: [String: [String: Int]]?, + range: CostUsageDayRange) -> [String: [String: Int]]? + { + let filtered = (map ?? [:]).filter { + !CostUsageDayRange.isInRange(dayKey: $0.key, since: range.scanSinceKey, until: range.scanUntilKey) + } + return filtered.isEmpty ? nil : filtered + } + + // MARK: - File scan orchestration + + struct CodexFileMetadata { + let path: String + let mtimeUnixMs: Int64 + let size: Int64 + let fileId: String? + } + + struct CodexFileScanInput { + let fileURL: URL + let metadata: CodexFileMetadata + let cached: CostUsageFileUsage? + } + + static func codexFileMetadata(fileURL: URL) -> CodexFileMetadata { + let path = fileURL.path + let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] + let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 + let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 + return CodexFileMetadata( + path: path, + mtimeUnixMs: Int64(mtime * 1000), + size: size, + fileId: Self.fileIdentityString(fileURL: fileURL)) + } + + static func dropCachedCodexFile( + path: String, + cached: CostUsageFileUsage?, + cache: inout CostUsageCache) + { + if let cached { + self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) + } + cache.files.removeValue(forKey: path) + } + + static func rememberScannedCodexFile( + fileURL: URL, + metadata: CodexFileMetadata, + sessionId: String?, + context: CodexFileScanContext, + state: inout CodexScanState) + { + if let sessionId { + state.seenSessionIds.insert(sessionId) + context.resources.fileIndex.remember(fileURL: fileURL, sessionId: sessionId) + } + if let fileId = metadata.fileId { + state.seenFileIds.insert(fileId) + } + } + + static func keepCachedCodexFileIfFresh( + input: CodexFileScanInput, + context: CodexFileScanContext, + cache: inout CostUsageCache, + state: inout CodexScanState) -> Bool + { + guard let cached = input.cached else { return false } + let needsSessionId = cached.sessionId == nil + guard cached.mtimeUnixMs == input.metadata.mtimeUnixMs, + cached.size == input.metadata.size, + !needsSessionId, + !context.forceFullScan + else { return false } + + guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } + + if Self.needsCodexCostCache(cached, range: context.range) { + cache.files[input.metadata.path] = Self.codexFileUsageWithCostCache(cached, context: context) + } + Self.rememberScannedCodexFile( + fileURL: input.fileURL, + metadata: input.metadata, + sessionId: cached.sessionId, + context: context, + state: &state) + return true + } + + static func cachedCodexFileNeedsPriorityRescan( + _ cached: CostUsageFileUsage, + context: CodexFileScanContext) -> Bool + { + if cached.codexTurnIDs == nil { + return context.requiresTurnIDCache + } + guard !context.changedPriorityTurnIDs.isEmpty else { return false } + return !(Set(cached.codexTurnIDs ?? []).isDisjoint(with: context.changedPriorityTurnIDs)) + } + + static func appendCodexFileIncrementIfPossible( + input: CodexFileScanInput, + context: CodexFileScanContext, + cache: inout CostUsageCache, + state: inout CodexScanState) throws -> Bool + { + try context.checkCancellation?() + guard let cached = input.cached, cached.sessionId != nil, !context.forceFullScan else { return false } + guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } + let startOffset = cached.parsedBytes ?? cached.size + let initialCountedTotals = cached.lastCountedTotals ?? cached.lastTotals + let initialRawTotalsBaseline = cached.lastRawTotalsBaseline ?? cached.lastTotals + let canIncremental = input.metadata.size > cached.size && startOffset > 0 + && startOffset <= input.metadata.size + && initialCountedTotals != nil + && cached.forkedFromId == nil + guard canIncremental else { return false } + + let delta = try Self.parseCodexFileCancellable( + fileURL: input.fileURL, + range: context.range, + startOffset: startOffset, + initialModel: cached.lastModel, + initialTotals: initialCountedTotals, + initialRawTotalsBaseline: initialRawTotalsBaseline, + initialHasDivergentTotals: cached.hasDivergentTotals ?? (cached.lastTotals == nil), + initialCodexTurnID: cached.lastCodexTurnID, + checkCancellation: context.checkCancellation) + if delta.forkedFromId != nil { + return false + } + let sessionId = delta.sessionId ?? cached.sessionId + if let sessionId, state.seenSessionIds.contains(sessionId) { + Self.dropCachedCodexFile(path: input.metadata.path, cached: cached, cache: &cache) + return true + } + + let migratedCached = Self.codexFileUsageWithCostCache(cached, context: context) + if !delta.days.isEmpty { + Self.applyFileDays(cache: &cache, fileDays: delta.days, sign: 1) + } + + var mergedDays = migratedCached.days + Self.mergeFileDays(existing: &mergedDays, delta: delta.days) + let splitMaps = Self.codexModeSplitMaps( + rows: delta.rows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + cache.files[input.metadata.path] = Self.makeFileUsage( + mtimeUnixMs: input.metadata.mtimeUnixMs, + size: input.metadata.size, + days: mergedDays, + parsedBytes: delta.parsedBytes, + lastModel: delta.lastModel, + lastTotals: delta.lastTotals, + lastCountedTotals: delta.lastCountedTotals, + lastRawTotalsBaseline: delta.lastRawTotalsBaseline, + hasDivergentTotals: delta.hasDivergentTotals, + lastCodexTurnID: delta.lastCodexTurnID, + sessionId: sessionId, + forkedFromId: delta.forkedFromId ?? migratedCached.forkedFromId, + codexCostNanos: Self.codexMergedCostMap( + migratedCached.codexCostNanos, + deltaRows: delta.rows, + context: context), + codexPrioritySurchargeNanos: Self.codexMergedPrioritySurchargeMap( + migratedCached.codexPrioritySurchargeNanos, + deltaRows: delta.rows, + context: context), + codexStandardCostNanos: Self.mergeCostMaps( + migratedCached.codexStandardCostNanos, + splitMaps.standardCostNanos), + codexPriorityCostNanos: Self.mergeCostMaps( + migratedCached.codexPriorityCostNanos, + splitMaps.priorityCostNanos), + codexStandardTokens: Self.mergeIntMaps( + migratedCached.codexStandardTokens, + splitMaps.standardTokens), + codexPriorityTokens: Self.mergeIntMaps( + migratedCached.codexPriorityTokens, + splitMaps.priorityTokens), + codexTurnIDs: Self.mergeCodexTurnIDs(migratedCached.codexTurnIDs, rows: delta.rows), + codexRows: migratedCached.codexRows) + Self.rememberScannedCodexFile( + fileURL: input.fileURL, + metadata: input.metadata, + sessionId: sessionId, + context: context, + state: &state) + return true + } + + static func rescanCodexFile( + input: CodexFileScanInput, + context: CodexFileScanContext, + cache: inout CostUsageCache, + state: inout CodexScanState) throws + { + try context.checkCancellation?() + if let cached = input.cached { + self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) + } + let migratedCached = input.cached.map { Self.codexFileUsageWithCostCache($0, context: context) } + var usageDays = context.dropDeferredCodexRows + ? [:] + : Self.fileDaysOutsideScanWindow(migratedCached?.days ?? [:], range: context.range) + + let parsed = try Self.parseCodexFileCancellable( + fileURL: input.fileURL, + range: context.range, + inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:), + checkCancellation: context.checkCancellation) + let sessionId = parsed.sessionId ?? input.cached?.sessionId + if let sessionId, state.seenSessionIds.contains(sessionId) { + cache.files.removeValue(forKey: input.metadata.path) + return + } + Self.mergeFileDays(existing: &usageDays, delta: parsed.days) + let splitMaps = Self.codexModeSplitMaps( + rows: parsed.rows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot) + + cache.files[input.metadata.path] = Self.makeFileUsage( + mtimeUnixMs: input.metadata.mtimeUnixMs, + size: input.metadata.size, + days: usageDays, + parsedBytes: parsed.parsedBytes, + lastModel: parsed.lastModel, + lastTotals: parsed.lastTotals, + lastCountedTotals: parsed.lastCountedTotals, + lastRawTotalsBaseline: parsed.lastRawTotalsBaseline, + hasDivergentTotals: parsed.hasDivergentTotals, + lastCodexTurnID: parsed.lastCodexTurnID, + sessionId: sessionId, + forkedFromId: parsed.forkedFromId, + codexCostNanos: Self.mergeCostMaps( + context.dropDeferredCodexRows + ? nil + : Self.costMapOutsideScanWindow(migratedCached?.codexCostNanos, range: context.range), + Self.codexCostNanos( + rows: parsed.rows, + range: context.range, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)), + codexPrioritySurchargeNanos: Self.mergeCostMaps( + context.dropDeferredCodexRows + ? nil + : Self.costMapOutsideScanWindow(migratedCached?.codexPrioritySurchargeNanos, range: context.range), + Self.codexPrioritySurchargeNanos( + rows: parsed.rows, + range: context.range, + priorityTurns: context.resources.priorityTurns, + modelsDevCatalog: context.resources.modelsDevCatalog, + modelsDevCacheRoot: context.resources.modelsDevCacheRoot)), + codexStandardCostNanos: Self.mergeCostMaps( + context.dropDeferredCodexRows + ? nil + : Self.costMapOutsideScanWindow(migratedCached?.codexStandardCostNanos, range: context.range), + splitMaps.standardCostNanos), + codexPriorityCostNanos: Self.mergeCostMaps( + context.dropDeferredCodexRows + ? nil + : Self.costMapOutsideScanWindow(migratedCached?.codexPriorityCostNanos, range: context.range), + splitMaps.priorityCostNanos), + codexStandardTokens: Self.mergeIntMaps( + context.dropDeferredCodexRows + ? nil + : Self.intMapOutsideScanWindow(migratedCached?.codexStandardTokens, range: context.range), + splitMaps.standardTokens), + codexPriorityTokens: Self.mergeIntMaps( + context.dropDeferredCodexRows + ? nil + : Self.intMapOutsideScanWindow(migratedCached?.codexPriorityTokens, range: context.range), + splitMaps.priorityTokens), + codexTurnIDs: context.dropDeferredCodexRows + ? Self.codexTurnIDs(rows: parsed.rows) + : Self.mergeCodexTurnIDs(migratedCached?.codexTurnIDs, rows: parsed.rows), + codexRows: context.dropDeferredCodexRows ? nil : migratedCached?.codexRows) + Self.applyFileDays(cache: &cache, fileDays: cache.files[input.metadata.path]?.days ?? [:], sign: 1) + Self.rememberScannedCodexFile( + fileURL: input.fileURL, + metadata: input.metadata, + sessionId: sessionId, + context: context, + state: &state) + } + + static func mergeFileDays( + existing: inout [String: [String: [Int]]], + delta: [String: [String: [Int]]]) + { + for (day, models) in delta { + var dayModels = existing[day] ?? [:] + for (model, packed) in models { + let existingPacked = dayModels[model] ?? [] + let merged = self.addPacked(a: existingPacked, b: packed, sign: 1) + if merged.allSatisfy({ $0 == 0 }) { + dayModels.removeValue(forKey: model) + } else { + dayModels[model] = merged + } + } + + if dayModels.isEmpty { + existing.removeValue(forKey: day) + } else { + existing[day] = dayModels + } + } + } + + static func fileDaysOutsideScanWindow( + _ days: [String: [String: [Int]]], + range: CostUsageDayRange) -> [String: [String: [Int]]] + { + days.filter { + !CostUsageDayRange.isInRange(dayKey: $0.key, since: range.scanSinceKey, until: range.scanUntilKey) + } + } + + static func applyFileDays(cache: inout CostUsageCache, fileDays: [String: [String: [Int]]], sign: Int) { + for (day, models) in fileDays { + var dayModels = cache.days[day] ?? [:] + for (model, packed) in models { + let existing = dayModels[model] ?? [] + let merged = self.addPacked(a: existing, b: packed, sign: sign) + if merged.allSatisfy({ $0 == 0 }) { + dayModels.removeValue(forKey: model) + } else { + dayModels[model] = merged + } + } + + if dayModels.isEmpty { + cache.days.removeValue(forKey: day) + } else { + cache.days[day] = dayModels + } + } + } + + static func pruneDays(cache: inout CostUsageCache, sinceKey: String, untilKey: String) { + for key in cache.days.keys where !CostUsageDayRange.isInRange(dayKey: key, since: sinceKey, until: untilKey) { + cache.days.removeValue(forKey: key) + } + } + + static func pruneForceRescanFilesOutsideWindow( + cache: inout CostUsageCache, + range: CostUsageDayRange, + isForceRescan: Bool) + { + guard isForceRescan else { return } + for key in cache.files.keys { + guard let old = cache.files[key] else { continue } + guard !old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + else { continue } + Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) + cache.files.removeValue(forKey: key) + } + } + + static func requestedWindowExpandsCache(range: CostUsageDayRange, cache: CostUsageCache) -> Bool { + guard let cachedSince = cache.scanSinceKey, + let cachedUntil = cache.scanUntilKey + else { + return cache.lastScanUnixMs != 0 || !cache.files.isEmpty || !cache.days.isEmpty + } + return range.scanSinceKey < cachedSince || range.scanUntilKey > cachedUntil + } + + static func addPacked(a: [Int], b: [Int], sign: Int) -> [Int] { + let len = max(a.count, b.count) + var out: [Int] = Array(repeating: 0, count: len) + for idx in 0.. CostUsageDailyReport + { + var entries: [CostUsageDailyReport.Entry] = [] + var totalInput = 0 + var totalOutput = 0 + var totalTokens = 0 + var totalCost: Double = 0 + var costSeen = false + + let dayKeys = cache.days.keys.sorted().filter { + CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) + } + let costNanosByDayModel = self.codexCostNanosByDayModel(cache: cache, range: range) + let prioritySurchargeNanosByDayModel = self.codexPrioritySurchargeNanosByDayModel(cache: cache, range: range) + let standardCostNanosByDayModel = self.codexStandardCostNanosByDayModel(cache: cache, range: range) + let priorityCostNanosByDayModel = self.codexPriorityCostNanosByDayModel(cache: cache, range: range) + let standardTokensByDayModel = self.codexStandardTokensByDayModel(cache: cache, range: range) + let priorityTokensByDayModel = self.codexPriorityTokensByDayModel(cache: cache, range: range) + + let hasCodexRows = cache.files.values.contains { + !($0.codexRows?.isEmpty ?? true) + } + let rowsByDayModel = hasCodexRows ? self.codexRowsByDayModel(cache: cache, range: range) : [:] + + for day in dayKeys { + guard let models = cache.days[day] else { continue } + let modelNames = models.keys.sorted() + + var dayInput = 0 + var dayOutput = 0 + var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] + var dayCost: Double = 0 + var dayCostSeen = false + + for model in modelNames { + let packed = models[model] ?? [0, 0, 0] + let input = packed[safe: 0] ?? 0 + let cached = packed[safe: 1] ?? 0 + let output = packed[safe: 2] ?? 0 + let totalTokens = input + output + + dayInput += input + dayOutput += output + + let rows = rowsByDayModel[day]?[model] + let rowCostBreakdown = rows.map { + self.codexRowCostBreakdown( + rows: $0, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + } + let cachedBaseCost = costNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } + let rowTotalCost = cachedBaseCost == nil ? rowCostBreakdown?.totalCostUSD : nil + let standardCost = standardCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardCostUSD : nil) + let priorityCost = priorityCostNanosByDayModel[day]?[model].map { Double($0) / Self.costScale } + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityCostUSD : nil) + let splitTotalCost: Double? = if standardCost != nil || priorityCost != nil { + (standardCost ?? 0) + (priorityCost ?? 0) + } else { + nil + } + var cost = splitTotalCost + ?? cachedBaseCost + ?? rowTotalCost + ?? CostUsagePricing.codexCostUSD( + model: model, + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + if splitTotalCost == nil, + let surchargeNanos = prioritySurchargeNanosByDayModel[day]?[model], + cachedBaseCost != nil + { + cost = (cost ?? 0) + (Double(surchargeNanos) / Self.costScale) + } else if splitTotalCost == nil, + rowTotalCost == nil, + !priorityTurns.isEmpty, + let rows, + let surcharge = self.codexPrioritySurchargeUSD( + rows: rows, + priorityTurns: priorityTurns, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + { + cost = (cost ?? 0) + surcharge + } + let standardModeTokens = standardTokensByDayModel[day]?[model] + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalStandardTokens : nil) + let priorityModeTokens = priorityTokensByDayModel[day]?[model] + ?? (rowCostBreakdown?.hasModeSplit == true ? rowCostBreakdown?.optionalPriorityTokens : nil) + let hasModeSplit = priorityCost != nil || priorityModeTokens != nil + breakdown.append( + CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: totalTokens, + standardCostUSD: hasModeSplit ? standardCost : nil, + priorityCostUSD: hasModeSplit ? priorityCost : nil, + standardTokens: hasModeSplit ? standardModeTokens : nil, + priorityTokens: hasModeSplit ? priorityModeTokens : nil)) + if let cost { + dayCost += cost + dayCostSeen = true + } + } + + let dayTotal = dayInput + dayOutput + let entryCost = dayCostSeen ? dayCost : nil + entries.append(CostUsageDailyReport.Entry( + date: day, + inputTokens: dayInput, + outputTokens: dayOutput, + totalTokens: dayTotal, + costUSD: entryCost, + modelsUsed: modelNames, + modelBreakdowns: Self.sortedModelBreakdowns(breakdown))) + + totalInput += dayInput + totalOutput += dayOutput + totalTokens += dayTotal + if let entryCost { + totalCost += entryCost + costSeen = true + } + } + + let summary: CostUsageDailyReport.Summary? = entries.isEmpty + ? nil + : CostUsageDailyReport.Summary( + totalInputTokens: totalInput, + totalOutputTokens: totalOutput, + totalTokens: totalTokens, + totalCostUSD: costSeen ? totalCost : nil) + + return CostUsageDailyReport(data: entries, summary: summary) + } + + static func sortedModelBreakdowns(_ breakdowns: [CostUsageDailyReport.ModelBreakdown]) + -> [CostUsageDailyReport.ModelBreakdown] + { + breakdowns.sorted { lhs, rhs in + let lhsCost = lhs.costUSD ?? -1 + let rhsCost = rhs.costUSD ?? -1 + if lhsCost != rhsCost { + return lhsCost > rhsCost + } + + let lhsTokens = lhs.totalTokens ?? -1 + let rhsTokens = rhs.totalTokens ?? -1 + if lhsTokens != rhsTokens { + return lhsTokens > rhsTokens + } + + return lhs.modelName > rhs.modelName + } + } + + static func parseDayKey(_ key: String) -> Date? { + let parts = key.split(separator: "-") + guard parts.count == 3 else { return nil } + guard + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + + var comps = DateComponents() + comps.calendar = Calendar.current + comps.timeZone = TimeZone.current + comps.year = year + comps.month = month + comps.day = day + comps.hour = 12 + return comps.date + } +} + +extension Data { + func containsAscii(_ needle: String) -> Bool { + guard let n = needle.data(using: .utf8) else { return false } + return self.range(of: n) != nil + } +} + +extension [Int] { + subscript(safe index: Int) -> Int? { + if index < 0 { return nil } + if index >= self.count { return nil } + return self[index] + } +} + +extension [UInt8] { + subscript(safe index: Int) -> UInt8? { + if index < 0 { return nil } + if index >= self.count { return nil } + return self[index] + } +} + +extension CostUsageFileUsage { + func touchesCodexScanWindow(sinceKey: String, untilKey: String) -> Bool { + self.days.keys.contains { + CostUsageScanner.CostUsageDayRange.isInRange(dayKey: $0, since: sinceKey, until: untilKey) + } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 5e32060b0..3d1578e38 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -35,105 +35,296 @@ extension CostUsageScanner { fileURL: URL, range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter, - startOffset: Int64 = 0) -> ClaudeParseResult + startOffset: Int64 = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult { - var days: [String: [String: [Int]]] = [:] - // Track seen message+request IDs to deduplicate streaming chunks within a JSONL file. - // Claude emits multiple lines per message with cumulative usage, so we only count once. - var seenKeys: Set = [] + ( + try? self.parseClaudeFileCancellable( + fileURL: fileURL, + range: range, + providerFilter: providerFilter, + startOffset: startOffset, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot, + checkCancellation: nil)) ?? ClaudeParseResult(days: [:], rows: [], parsedBytes: startOffset) + } + static func parseClaudeFileCancellable( + fileURL: URL, + range: CostUsageDayRange, + providerFilter: ClaudeLogProviderFilter, + startOffset: Int64 = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + checkCancellation: CancellationCheck? = nil) throws -> ClaudeParseResult + { struct ClaudeTokens: Sendable { let input: Int let cacheRead: Int let cacheCreate: Int let output: Int let costNanos: Int + let costPriced: Bool } - func add(dayKey: String, model: String, tokens: ClaudeTokens) { + func add(dayKey: String, model: String, tokens: ClaudeTokens, days: inout [String: [String: [Int]]]) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) else { return } let normModel = CostUsagePricing.normalizeClaudeModel(model) var dayModels = days[dayKey] ?? [:] - var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0] + var packed = dayModels[normModel] ?? [0, 0, 0, 0, 0, 0, 0] packed[0] = (packed[safe: 0] ?? 0) + tokens.input packed[1] = (packed[safe: 1] ?? 0) + tokens.cacheRead packed[2] = (packed[safe: 2] ?? 0) + tokens.cacheCreate packed[3] = (packed[safe: 3] ?? 0) + tokens.output packed[4] = (packed[safe: 4] ?? 0) + tokens.costNanos + packed[5] = (packed[safe: 5] ?? 0) + 1 + packed[6] = (packed[safe: 6] ?? 0) + (tokens.costPriced ? 1 : 0) dayModels[normModel] = packed days[dayKey] = dayModels } + func toInt(_ v: Any?) -> Int { + if let n = v as? NSNumber { return n.intValue } + return 0 + } + + func toBool(_ value: Any?) -> Bool { + if let bool = value as? Bool { return bool } + if let number = value as? NSNumber { return number.boolValue } + return false + } + + let pathRole = Self.claudePathRole(fileURL: fileURL) + var keyedRows: [String: ClaudeUsageRow] = [:] + var unkeyedRows: [ClaudeUsageRow] = [] + let maxLineBytes = 512 * 1024 // Keep the full line so usage at the tail isn't dropped on large tool outputs. let prefixBytes = maxLineBytes let costScale = 1_000_000_000.0 - let parsedBytes = (try? CostUsageJsonl.scan( - fileURL: fileURL, - offset: startOffset, - maxLineBytes: maxLineBytes, - prefixBytes: prefixBytes, - onLine: { line in - guard !line.bytes.isEmpty else { return } - guard !line.wasTruncated else { return } - guard line.bytes.containsAscii(#""type":"assistant""#) else { return } - guard line.bytes.containsAscii(#""usage""#) else { return } - - guard - let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], - let type = obj["type"] as? String, - type == "assistant" - else { return } - guard Self.matchesClaudeProviderFilter(obj: obj, filter: providerFilter) else { return } - - guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return } - - guard let message = obj["message"] as? [String: Any] else { return } - guard let model = message["model"] as? String else { return } - guard let usage = message["usage"] as? [String: Any] else { return } - - // Deduplicate by message.id + requestId (streaming chunks have same usage). - let messageId = message["id"] as? String - let requestId = obj["requestId"] as? String - if let messageId, let requestId { - let key = "\(messageId):\(requestId)" - if seenKeys.contains(key) { return } - seenKeys.insert(key) + let parsedBytes: Int64 + do { + parsedBytes = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: startOffset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + checkCancellation: checkCancellation, + onLine: { line in + guard !line.bytes.isEmpty else { return } + guard !line.wasTruncated else { return } + guard line.bytes.containsAscii(#""type":"assistant""#) else { return } + guard line.bytes.containsAscii(#""usage""#) else { return } + + autoreleasepool { + guard + let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], + let type = obj["type"] as? String, + type == "assistant" + else { return } + guard Self.matchesClaudeProviderFilter(obj: obj, filter: providerFilter) else { return } + + guard let tsText = obj["timestamp"] as? String else { return } + guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + else { return } + + guard let message = obj["message"] as? [String: Any] else { return } + guard let model = message["model"] as? String else { return } + guard let usage = message["usage"] as? [String: Any] else { return } + + let input = max(0, toInt(usage["input_tokens"])) + let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"])) + let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) + let output = max(0, toInt(usage["output_tokens"])) + if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } + + let cost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: input, + cacheReadInputTokens: cacheRead, + cacheCreationInputTokens: cacheCreate, + outputTokens: output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 + let tokens = ClaudeTokens( + input: input, + cacheRead: cacheRead, + cacheCreate: cacheCreate, + output: output, + costNanos: costNanos, + costPriced: cost != nil) + + guard CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + else { return } + + let messageId = message["id"] as? String + let requestId = obj["requestId"] as? String + let sessionId = obj["sessionId"] as? String + ?? obj["session_id"] as? String + ?? (obj["metadata"] as? [String: Any])?["sessionId"] as? String + ?? (message["metadata"] as? [String: Any])?["sessionId"] as? String + let normalizedModel = CostUsagePricing.normalizeClaudeModel(model) + let row = ClaudeUsageRow( + dayKey: dayKey, + model: normalizedModel, + sessionId: sessionId, + messageId: messageId, + requestId: requestId, + isSidechain: toBool(obj["isSidechain"]), + pathRole: pathRole, + input: tokens.input, + cacheRead: tokens.cacheRead, + cacheCreate: tokens.cacheCreate, + output: tokens.output, + costNanos: tokens.costNanos, + costPriced: tokens.costPriced) + + // Streaming chunks share message.id + requestId inside a file. + // Keep overwriting so the final cumulative chunk wins. + if let messageId, let requestId { + let key = "\(messageId):\(requestId)" + keyedRows[key] = row + } else { + // Older logs omit IDs; treat each line as distinct to avoid dropping usage. + unkeyedRows.append(row) + } + } + }) + } catch is CancellationError { + throw CancellationError() + } catch { + parsedBytes = startOffset + } + + let rows = keyedRows.keys.sorted().compactMap { keyedRows[$0] } + unkeyedRows + var days: [String: [String: [Int]]] = [:] + for row in rows { + let tokens = ClaudeTokens( + input: row.input, + cacheRead: row.cacheRead, + cacheCreate: row.cacheCreate, + output: row.output, + costNanos: row.costNanos, + costPriced: row.costPriced ?? (row.costNanos > 0)) + add(dayKey: row.dayKey, model: row.model, tokens: tokens, days: &days) + } + + return ClaudeParseResult(days: days, rows: rows, parsedBytes: parsedBytes) + } + + private static func claudePathRole(fileURL: URL) -> ClaudePathRole { + fileURL.path.contains("/subagents/") ? .subagent : .parent + } + + private static func claudeCanonicalRowKey(_ row: ClaudeUsageRow) -> String? { + guard let messageId = row.messageId, let requestId = row.requestId else { + return nil + } + return "\(messageId):\(requestId)" + } + + private static func mergeClaudeRows(existing: [ClaudeUsageRow], delta: [ClaudeUsageRow]) -> [ClaudeUsageRow] { + var keyedRows: [String: ClaudeUsageRow] = [:] + var unkeyedRows: [ClaudeUsageRow] = [] + + for row in existing { + if let key = Self.claudeInFileKey(row) { + keyedRows[key] = row + } else { + unkeyedRows.append(row) + } + } + for row in delta { + if let key = Self.claudeInFileKey(row) { + keyedRows[key] = row + } else { + unkeyedRows.append(row) + } + } + + return keyedRows.keys.sorted().compactMap { keyedRows[$0] } + unkeyedRows + } + + private static func claudeInFileKey(_ row: ClaudeUsageRow) -> String? { + guard let messageId = row.messageId, let requestId = row.requestId else { return nil } + return "\(messageId):\(requestId)" + } + + private static func claudeRowWins( + lhs: (path: String, row: ClaudeUsageRow), + rhs: (path: String, row: ClaudeUsageRow)) -> Bool + { + if lhs.row.isSidechain != rhs.row.isSidechain { + return rhs.row.isSidechain + } + if lhs.row.pathRole != rhs.row.pathRole { + return rhs.row.pathRole == .subagent + } + return lhs.path < rhs.path + } + + private static func rebuildClaudeDays(cache: inout CostUsageCache) { + var days: [String: [String: [Int]]] = [:] + var winners: [String: (path: String, row: ClaudeUsageRow)] = [:] + + func addRow(_ row: ClaudeUsageRow) { + var dayModels = days[row.dayKey] ?? [:] + var packed = dayModels[row.model] ?? [0, 0, 0, 0, 0, 0, 0] + packed[0] = (packed[safe: 0] ?? 0) + row.input + packed[1] = (packed[safe: 1] ?? 0) + row.cacheRead + packed[2] = (packed[safe: 2] ?? 0) + row.cacheCreate + packed[3] = (packed[safe: 3] ?? 0) + row.output + packed[4] = (packed[safe: 4] ?? 0) + row.costNanos + packed[5] = (packed[safe: 5] ?? 0) + 1 + packed[6] = (packed[safe: 6] ?? 0) + ((row.costPriced ?? (row.costNanos > 0)) ? 1 : 0) + dayModels[row.model] = packed + days[row.dayKey] = dayModels + } + + for path in cache.files.keys.sorted() { + guard let rows = cache.files[path]?.claudeRows else { continue } + for row in rows { + guard let canonicalKey = Self.claudeCanonicalRowKey(row) else { + addRow(row) + continue + } + let candidate = (path: path, row: row) + if let existing = winners[canonicalKey] { + if Self.claudeRowWins(lhs: candidate, rhs: existing) { + winners[canonicalKey] = candidate + } } else { - // Older logs omit IDs; treat each line as distinct to avoid dropping usage. + winners[canonicalKey] = candidate } + } + } - func toInt(_ v: Any?) -> Int { - if let n = v as? NSNumber { return n.intValue } - return 0 - } + for winner in winners.values { + addRow(winner.row) + } - let input = max(0, toInt(usage["input_tokens"])) - let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"])) - let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) - let output = max(0, toInt(usage["output_tokens"])) - if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } + cache.days = days + } - let cost = CostUsagePricing.claudeCostUSD( - model: model, - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreate, - outputTokens: output) - let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 - let tokens = ClaudeTokens( - input: input, - cacheRead: cacheRead, - cacheCreate: cacheCreate, - output: output, - costNanos: costNanos) - add(dayKey: dayKey, model: model, tokens: tokens) - })) ?? startOffset - - return ClaudeParseResult(days: days, parsedBytes: parsedBytes) + private static func makeClaudeFileUsage( + mtimeMs: Int64, + size: Int64, + rows: [ClaudeUsageRow], + parsedBytes: Int64?) -> CostUsageFileUsage + { + makeFileUsage( + mtimeUnixMs: mtimeMs, + size: size, + days: [:], + parsedBytes: parsedBytes, + claudeRows: rows) } private static let vertexProviderKeys: Set = [ @@ -260,17 +451,31 @@ extension CostUsageScanner { private final class ClaudeScanState { var cache: CostUsageCache - var rootCache: [String: Int64] var touched: Set let range: CostUsageDayRange let providerFilter: ClaudeLogProviderFilter - - init(cache: CostUsageCache, range: CostUsageDayRange, providerFilter: ClaudeLogProviderFilter) { + let forceFullScan: Bool + let modelsDevCatalog: ModelsDevCatalog? + let modelsDevCacheRoot: URL? + let checkCancellation: CancellationCheck? + + init( + cache: CostUsageCache, + range: CostUsageDayRange, + providerFilter: ClaudeLogProviderFilter, + forceFullScan: Bool, + modelsDevCatalog: ModelsDevCatalog?, + modelsDevCacheRoot: URL?, + checkCancellation: CancellationCheck?) + { self.cache = cache - self.rootCache = cache.roots ?? [:] self.touched = [] self.range = range self.providerFilter = providerFilter + self.forceFullScan = forceFullScan + self.modelsDevCatalog = modelsDevCatalog + self.modelsDevCacheRoot = modelsDevCacheRoot + self.checkCancellation = checkCancellation } } @@ -278,119 +483,87 @@ extension CostUsageScanner { url: URL, size: Int64, mtimeMs: Int64, - state: ClaudeScanState) + state: ClaudeScanState) throws { + try state.checkCancellation?() let path = url.path state.touched.insert(path) if let cached = state.cache.files[path], cached.mtimeUnixMs == mtimeMs, - cached.size == size + cached.size == size, + !state.forceFullScan { return } - if let cached = state.cache.files[path] { + if let cached = state.cache.files[path], !state.forceFullScan { let startOffset = cached.parsedBytes ?? cached.size let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size + && cached.claudeRows != nil if canIncremental { - let delta = Self.parseClaudeFile( + let delta = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, providerFilter: state.providerFilter, - startOffset: startOffset) - if !delta.days.isEmpty { - Self.applyFileDays(cache: &state.cache, fileDays: delta.days, sign: 1) - } - - var mergedDays = cached.days - Self.mergeFileDays(existing: &mergedDays, delta: delta.days) - state.cache.files[path] = Self.makeFileUsage( - mtimeUnixMs: mtimeMs, + startOffset: startOffset, + modelsDevCatalog: state.modelsDevCatalog, + modelsDevCacheRoot: state.modelsDevCacheRoot, + checkCancellation: state.checkCancellation) + let mergedRows = Self.mergeClaudeRows(existing: cached.claudeRows ?? [], delta: delta.rows) + state.cache.files[path] = Self.makeClaudeFileUsage( + mtimeMs: mtimeMs, size: size, - days: mergedDays, + rows: mergedRows, parsedBytes: delta.parsedBytes) return } - - Self.applyFileDays(cache: &state.cache, fileDays: cached.days, sign: -1) } - let parsed = Self.parseClaudeFile( + let parsed = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, - providerFilter: state.providerFilter) - let usage = Self.makeFileUsage( - mtimeUnixMs: mtimeMs, + providerFilter: state.providerFilter, + modelsDevCatalog: state.modelsDevCatalog, + modelsDevCacheRoot: state.modelsDevCacheRoot, + checkCancellation: state.checkCancellation) + let usage = Self.makeClaudeFileUsage( + mtimeMs: mtimeMs, size: size, - days: parsed.days, + rows: parsed.rows, parsedBytes: parsed.parsedBytes) state.cache.files[path] = usage - Self.applyFileDays(cache: &state.cache, fileDays: usage.days, sign: 1) } private static func scanClaudeRoot( root: URL, - state: ClaudeScanState) + state: ClaudeScanState) throws { + try state.checkCancellation?() let rootPath = root.path let rootCandidates = Self.claudeRootCandidates(for: rootPath) let prefixes = Set(rootCandidates).map { path in path.hasSuffix("/") ? path : "\(path)/" } let rootExists = rootCandidates.contains { FileManager.default.fileExists(atPath: $0) } - let canonicalRootPath = rootCandidates.first(where: { - FileManager.default.fileExists(atPath: $0) - }) ?? rootPath guard rootExists else { let stale = state.cache.files.keys.filter { path in prefixes.contains(where: { path.hasPrefix($0) }) } for path in stale { - if let old = state.cache.files[path] { - Self.applyFileDays(cache: &state.cache, fileDays: old.days, sign: -1) - } state.cache.files.removeValue(forKey: path) } - for candidate in rootCandidates { - state.rootCache.removeValue(forKey: candidate) - } - return - } - - let rootAttrs = (try? FileManager.default.attributesOfItem(atPath: canonicalRootPath)) ?? [:] - let rootMtime = (rootAttrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let rootMtimeMs = Int64(rootMtime * 1000) - let cachedRootMtime = rootCandidates.compactMap { state.rootCache[$0] }.first - let canSkipEnumeration = cachedRootMtime == rootMtimeMs && rootMtimeMs > 0 - - if canSkipEnumeration { - let cachedPaths = state.cache.files.keys.filter { path in - prefixes.contains(where: { path.hasPrefix($0) }) - } - for path in cachedPaths { - guard FileManager.default.fileExists(atPath: path) else { - if let old = state.cache.files[path] { - Self.applyFileDays(cache: &state.cache, fileDays: old.days, sign: -1) - } - state.cache.files.removeValue(forKey: path) - continue - } - let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] - let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 - if size <= 0 { continue } - let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let mtimeMs = Int64(mtime * 1000) - Self.processClaudeFile( - url: URL(fileURLWithPath: path), - size: size, - mtimeMs: mtimeMs, - state: state) - } return } + // Always enumerate the directory tree. The per-file mtime/size cache in + // processClaudeFile already skips unchanged files, so the only cost here is + // the directory walk itself. The previous root-mtime optimization skipped + // enumeration entirely when the root directory mtime was unchanged, but on + // POSIX systems a directory mtime only updates for direct child changes — + // not for files created or modified inside subdirectories. This caused new + // session logs to go undetected until the cache was manually cleared. let keys: [URLResourceKey] = [ .isRegularFileKey, .contentModificationDateKey, @@ -404,6 +577,7 @@ extension CostUsageScanner { else { return } for case let url as URL in enumerator { + try state.checkCancellation?() guard url.pathExtension.lowercased() == "jsonl" else { continue } guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue } guard values.isRegularFile == true else { continue } @@ -412,32 +586,33 @@ extension CostUsageScanner { let mtime = values.contentModificationDate?.timeIntervalSince1970 ?? 0 let mtimeMs = Int64(mtime * 1000) - Self.processClaudeFile( + try Self.processClaudeFile( url: url, size: size, mtimeMs: mtimeMs, state: state) } - if rootMtimeMs > 0 { - state.rootCache[canonicalRootPath] = rootMtimeMs - for candidate in rootCandidates where candidate != canonicalRootPath { - state.rootCache.removeValue(forKey: candidate) - } - } + // Root mtime caching removed — see comment above. } static func loadClaudeDaily( provider: UsageProvider, range: CostUsageDayRange, now: Date, - options: Options) -> CostUsageDailyReport + options: Options, + checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { var cache = CostUsageCacheIO.load(provider: provider, cacheRoot: options.cacheRoot) let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) - let shouldRefresh = refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs + let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache) + let shouldRefresh = options.forceRescan + || windowExpanded + || refreshMs == 0 + || cache.lastScanUnixMs == 0 + || nowMs - cache.lastScanUnixMs > refreshMs let roots = self.defaultClaudeProjectsRoots(options: options) let providerFilter = options.claudeLogProviderFilter @@ -445,39 +620,57 @@ extension CostUsageScanner { var touched: Set = [] if shouldRefresh { + try checkCancellation?() if options.forceRescan { cache = CostUsageCache() } - let scanState = ClaudeScanState(cache: cache, range: range, providerFilter: providerFilter) + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + let scanState = ClaudeScanState( + cache: cache, + range: range, + providerFilter: providerFilter, + forceFullScan: options.forceRescan || windowExpanded, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot, + checkCancellation: checkCancellation) for root in roots { - Self.scanClaudeRoot( + try Self.scanClaudeRoot( root: root, state: scanState) } + try checkCancellation?() cache = scanState.cache touched = scanState.touched - cache.roots = scanState.rootCache.isEmpty ? nil : scanState.rootCache + cache.roots = nil for key in cache.files.keys where !touched.contains(key) { - if let old = cache.files[key] { - Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) - } cache.files.removeValue(forKey: key) } + Self.rebuildClaudeDays(cache: &cache) Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + cache.scanSinceKey = range.scanSinceKey + cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs + try checkCancellation?() CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) } - return Self.buildClaudeReportFromCache(cache: cache, range: range) + let modelsDevCatalog = CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: options.cacheRoot) + return Self.buildClaudeReportFromCache( + cache: cache, + range: range, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot) } private static func buildClaudeReportFromCache( cache: CostUsageCache, - range: CostUsageDayRange) -> CostUsageDailyReport + range: CostUsageDayRange, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil) -> CostUsageDailyReport { var entries: [CostUsageDailyReport.Entry] = [] var totalInput = 0 @@ -513,6 +706,10 @@ extension CostUsageScanner { let cacheCreate = packed[safe: 2] ?? 0 let output = packed[safe: 3] ?? 0 let cachedCost = packed[safe: 4] ?? 0 + let sampleCount = packed[safe: 5] ?? 0 + let pricedSampleCount = packed[safe: 6] ?? 0 + let hasCompleteCachedCost = sampleCount > 0 && pricedSampleCount == sampleCount + let totalTokens = input + cacheRead + cacheCreate + output // Cache tokens are tracked separately; totalTokens includes input + cache. dayInput += input @@ -520,23 +717,28 @@ extension CostUsageScanner { dayCacheCreate += cacheCreate dayOutput += output - let cost = cachedCost > 0 - ? Double(cachedCost) / costScale - : CostUsagePricing.claudeCostUSD( - model: model, - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreate, - outputTokens: output) - breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost)) + let currentPricingCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: input, + cacheReadInputTokens: cacheRead, + cacheCreationInputTokens: cacheCreate, + outputTokens: output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + // Cached costs are accumulated per request, which preserves Claude long-context threshold boundaries. + let cost = hasCompleteCachedCost ? Double(cachedCost) / costScale : currentPricingCost + breakdown.append( + CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: cost, + totalTokens: totalTokens)) if let cost { dayCost += cost dayCostSeen = true } } - breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) } - let top = Array(breakdown.prefix(3)) + let sortedBreakdown = Self.sortedModelBreakdowns(breakdown) let dayTotal = dayInput + dayCacheRead + dayCacheCreate + dayOutput let entryCost = dayCostSeen ? dayCost : nil @@ -549,7 +751,7 @@ extension CostUsageScanner { totalTokens: dayTotal, costUSD: entryCost, modelsUsed: modelNames, - modelBreakdowns: top)) + modelBreakdowns: sortedBreakdown)) totalInput += dayInput totalOutput += dayOutput diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift new file mode 100644 index 000000000..9583a0c1c --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexPriority.swift @@ -0,0 +1,207 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#endif + +extension CostUsageScanner { + struct CodexPriorityTurnMetadata: Codable, Equatable { + var threadID: String? + var turnID: String + var model: String? + var timestamp: String? + } + + private static let requestMarker = "websocket request:" + + static func defaultCodexPriorityDatabaseURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("logs_2.sqlite", isDirectory: false) + } + + static func codexPriorityTurns( + databaseURL: URL? = nil, + sinceDayKey: String? = nil, + untilDayKey: String? = nil) -> [String: CodexPriorityTurnMetadata] + { + let url = databaseURL ?? self.defaultCodexPriorityDatabaseURL() + guard FileManager.default.fileExists(atPath: url.path) else { return [:] } + + #if canImport(SQLite3) + var db: OpaquePointer? + guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(db) + return [:] + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let query = if sinceDayKey != nil || untilDayKey != nil { + """ + select ts, feedback_log_body + from logs + where ts >= ? and ts < ? + and (feedback_log_body like '%websocket request:%' + or feedback_log_body like '%response.completed%') + """ + } else { + """ + select ts, feedback_log_body + from logs + where feedback_log_body like '%websocket request:%' + or feedback_log_body like '%response.completed%' + """ + } + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { return [:] } + defer { sqlite3_finalize(stmt) } + + if sinceDayKey != nil || untilDayKey != nil { + let start = self.epochSeconds(forDayKey: sinceDayKey ?? "0000-01-01") ?? 0 + let end = self.epochSeconds(forDayKey: self.nextDayKey(after: untilDayKey ?? "9999-12-30")) + ?? Int64.max + sqlite3_bind_int64(stmt, 1, start) + sqlite3_bind_int64(stmt, 2, end) + } + + var turns: [String: CodexPriorityTurnMetadata] = [:] + var completedModelsByTurnID: [String: String] = [:] + while sqlite3_step(stmt) == SQLITE_ROW { + let timestamp = self.timestamp(stmt: stmt, index: 0) + guard self.timestamp(timestamp, isInRangeSince: sinceDayKey, until: untilDayKey), + let body = self.text(stmt: stmt, index: 1) + else { continue } + if let completed = self.parseCodexCompletedTraceRow(body: body) { + completedModelsByTurnID[completed.turnID] = completed.model + if var existing = turns[completed.turnID] { + existing.model = completed.model + turns[completed.turnID] = existing + } + continue + } + guard var parsed = self.parseCodexPriorityTraceRow(timestamp: timestamp, body: body) + else { continue } + if let completedModel = completedModelsByTurnID[parsed.turnID] { + parsed.model = completedModel + } + turns[parsed.turnID] = parsed + } + return turns + #else + return [:] + #endif + } + + static func parseCodexPriorityTraceRow(timestamp: String?, body: String) -> CodexPriorityTurnMetadata? { + guard let markerRange = body.range(of: self.requestMarker) else { return nil } + let prefix = String(body[.. (turnID: String, model: String)? { + let marker = "websocket event:" + guard let markerRange = body.range(of: marker) else { return nil } + let prefix = String(body[.. String? { + guard let range = text.range(of: "\(name)=") else { return nil } + let tail = text[range.upperBound...] + let value = tail.prefix { char in + !char.isWhitespace && char != "," && char != "]" && char != ")" + } + return value.isEmpty ? nil : String(value) + } + + #if canImport(SQLite3) + private static func text(stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL, + let cString = sqlite3_column_text(stmt, index) + else { return nil } + return String(cString: cString) + } + + private static func timestamp(stmt: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } + if sqlite3_column_type(stmt, index) == SQLITE_INTEGER { + return String(sqlite3_column_int64(stmt, index)) + } + return self.text(stmt: stmt, index: index) + } + #endif + + private static func timestamp(_ timestamp: String?, isInRangeSince since: String?, until: String?) -> Bool { + guard since != nil || until != nil else { return true } + guard let dayKey = self.dayKey(fromTimestamp: timestamp) else { return false } + if let since, dayKey < since { return false } + if let until, dayKey > until { return false } + return true + } + + private static func dayKey(fromTimestamp timestamp: String?) -> String? { + guard let timestamp else { return nil } + if let seconds = Int64(timestamp) { + return CostUsageScanner.CostUsageDayRange.dayKey( + from: Date(timeIntervalSince1970: TimeInterval(seconds))) + } + let dayKey = timestamp.prefix(10) + return dayKey.count == 10 ? String(dayKey) : nil + } + + 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) + else { return dayKey } + return CostUsageScanner.CostUsageDayRange.dayKey(from: next) + } + + private static func epochSeconds(forDayKey dayKey: String) -> Int64? { + guard let date = self.localDate(forDayKey: dayKey) else { return nil } + return Int64(date.timeIntervalSince1970) + } + + private static func localDate(forDayKey dayKey: String) -> Date? { + let parts = dayKey.split(separator: "-") + guard parts.count == 3, + let year = Int(parts[0]), + let month = Int(parts[1]), + let day = Int(parts[2]) + else { return nil } + var components = DateComponents() + components.calendar = Calendar.current + components.year = year + components.month = month + components.day = day + return components.date + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift new file mode 100644 index 000000000..2c2f7554d --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CodexTruncatedPrefix.swift @@ -0,0 +1,121 @@ +import Foundation + +extension CostUsageScanner { + static func extractCodexTurnContextModel(from bytes: Data) -> String? { + guard let text = truncatedUTF8String(from: bytes) else { return nil } + let object = text[...] + guard Self.extractJSONStringField("type", from: object, atDepth: 1) == "turn_context", + let payloadText = Self.extractJSONObjectField("payload", from: object, atDepth: 1) + else { return nil } + + let payloadModel = Self.extractJSONStringField("model", from: payloadText, atDepth: 1) + ?? Self.extractJSONStringField("model_name", from: payloadText, atDepth: 1) + if let payloadModel { return payloadModel } + + guard let infoText = Self.extractJSONObjectField("info", from: payloadText, atDepth: 1) else { return nil } + return Self.extractJSONStringField("model", from: infoText, atDepth: 1) + ?? Self.extractJSONStringField("model_name", from: infoText, atDepth: 1) + } + + private static func truncatedUTF8String(from bytes: Data) -> String? { + for dropCount in 0...min(4, bytes.count) { + let end = bytes.count - dropCount + if let text = String(bytes: bytes.prefix(end), encoding: .utf8) { + return text + } + } + return nil + } + + private static func extractJSONStringField( + _ field: String, + from text: Substring, + atDepth targetDepth: Int) -> String? + { + self.extractJSONField(field, from: text, atDepth: targetDepth) { text, index in + guard index < text.endIndex, text[index] == "\"" else { return nil } + let value = Self.parseJSONString(in: text, index: &index) + return value?.isEmpty == true ? nil : value + } + } + + private static func extractJSONObjectField( + _ field: String, + from text: Substring, + atDepth targetDepth: Int) -> Substring? + { + self.extractJSONField(field, from: text, atDepth: targetDepth) { text, index in + guard index < text.endIndex, text[index] == "{" else { return nil } + return text[index...] + } + } + + private static func extractJSONField( + _ field: String, + from text: Substring, + atDepth targetDepth: Int, + parseValue: (Substring, inout String.Index) -> T?) -> T? + { + var index = text.startIndex + var depth = 0 + + while index < text.endIndex { + let character = text[index] + if character == "{" { + depth += 1 + text.formIndex(after: &index) + } else if character == "}" { + depth -= 1 + text.formIndex(after: &index) + } else if character == "\"" { + var valueIndex = index + guard let key = Self.parseJSONString(in: text, index: &valueIndex) else { return nil } + defer { index = valueIndex } + guard depth == targetDepth, key == field else { continue } + + Self.skipJSONWhitespace(in: text, index: &valueIndex) + guard valueIndex < text.endIndex, text[valueIndex] == ":" else { continue } + + text.formIndex(after: &valueIndex) + Self.skipJSONWhitespace(in: text, index: &valueIndex) + if let value = parseValue(text, &valueIndex) { + return value + } + } else { + text.formIndex(after: &index) + } + } + + return nil + } + + private static func parseJSONString(in text: Substring, index: inout String.Index) -> String? { + guard index < text.endIndex, text[index] == "\"" else { return nil } + text.formIndex(after: &index) + var value = "" + var isEscaped = false + while index < text.endIndex { + let character = text[index] + if isEscaped { + value.append(character) + isEscaped = false + } else if character == "\\" { + isEscaped = true + } else if character == "\"" { + text.formIndex(after: &index) + return value + } else { + value.append(character) + } + text.formIndex(after: &index) + } + + return nil + } + + private static func skipJSONWhitespace(in text: Substring, index: inout String.Index) { + while index < text.endIndex, text[index].isWhitespace { + text.formIndex(after: &index) + } + } +} diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift index e7cda6310..b8bf32153 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Timestamp.swift @@ -26,6 +26,10 @@ private enum CostUsageTimestampParser { } extension CostUsageScanner { + static func dateFromTimestamp(_ text: String) -> Date? { + CostUsageTimestampParser.parseISO(text) + } + static func dayKeyFromTimestamp(_ text: String) -> String? { let bytes = Array(text.utf8) guard bytes.count >= 20 else { return nil } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index a5ef942b5..09e2014e7 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -1,16 +1,29 @@ +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif import Foundation +// swiftlint:disable type_body_length file_length enum CostUsageScanner { - enum ClaudeLogProviderFilter: Sendable { + typealias CancellationCheck = () throws -> Void + + static let log = CodexBarLog.logger(LogCategories.tokenCost) + static let codexActiveSessionLookbackDays = 30 + static let costScale = 1_000_000_000.0 + + enum ClaudeLogProviderFilter { case all case vertexAIOnly case excludeVertexAI } - struct Options: Sendable { + struct Options { var codexSessionsRoot: URL? var claudeProjectsRoots: [URL]? var cacheRoot: URL? + var codexTraceDatabaseURL: URL? var refreshMinIntervalSeconds: TimeInterval = 60 var claudeLogProviderFilter: ClaudeLogProviderFilter = .all /// Force a full rescan, ignoring per-file cache and incremental offsets. @@ -20,65 +33,431 @@ enum CostUsageScanner { codexSessionsRoot: URL? = nil, claudeProjectsRoots: [URL]? = nil, cacheRoot: URL? = nil, + codexTraceDatabaseURL: URL? = nil, claudeLogProviderFilter: ClaudeLogProviderFilter = .all, forceRescan: Bool = false) { self.codexSessionsRoot = codexSessionsRoot self.claudeProjectsRoots = claudeProjectsRoots self.cacheRoot = cacheRoot + self.codexTraceDatabaseURL = codexTraceDatabaseURL self.claudeLogProviderFilter = claudeLogProviderFilter self.forceRescan = forceRescan } } - struct CodexParseResult: Sendable { + struct CodexParseResult { let days: [String: [String: [Int]]] - let parsedBytes: Int64 + var parsedBytes: Int64 let lastModel: String? let lastTotals: CostUsageCodexTotals? + let lastCountedTotals: CostUsageCodexTotals? + let lastRawTotalsBaseline: CostUsageCodexTotals? + let hasDivergentTotals: Bool + let lastCodexTurnID: String? let sessionId: String? + let forkedFromId: String? + let rows: [CodexUsageRow] } - private struct CodexScanState { + struct CodexUsageRow: Codable, Equatable { + let day: String + let model: String + let turnID: String? + let input: Int + let cached: Int + let output: Int + } + + struct CodexScanState { var seenSessionIds: Set = [] var seenFileIds: Set = [] } - struct ClaudeParseResult: Sendable { + private struct CodexTimestampedTotals { + let timestamp: String + let date: Date? + let totals: CostUsageCodexTotals + } + + enum CodexForkBaseline { + case resolved(CostUsageCodexTotals?) + case unresolved + } + + private static func codexTotalsEqual(_ lhs: CostUsageCodexTotals?, _ rhs: CostUsageCodexTotals?) -> Bool { + lhs?.input == rhs?.input && lhs?.cached == rhs?.cached && lhs?.output == rhs?.output + } + + private static func codexTotalsAtLeast(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { + lhs.input >= rhs.input && lhs.cached >= rhs.cached && lhs.output >= rhs.output + } + + private static func codexTotalsAtMost(_ lhs: CostUsageCodexTotals, _ rhs: CostUsageCodexTotals) -> Bool { + lhs.input <= rhs.input && lhs.cached <= rhs.cached && lhs.output <= rhs.output + } + + private static func codexShouldPreferTotalDelta( + rawBaseline: CostUsageCodexTotals?, + currentTotal: CostUsageCodexTotals, + totalDelta: CostUsageCodexTotals, + lastDelta: CostUsageCodexTotals, + sawDivergentTotals: Bool) -> Bool + { + guard !sawDivergentTotals, let rawBaseline else { return false } + return Self.codexTotalsAtLeast(currentTotal, rawBaseline) + && Self.codexTotalsAtMost(totalDelta, lastDelta) + } + + private static func codexAddTotals( + _ lhs: CostUsageCodexTotals, + _ rhs: CostUsageCodexTotals) -> CostUsageCodexTotals + { + CostUsageCodexTotals( + input: lhs.input + rhs.input, + cached: lhs.cached + rhs.cached, + output: lhs.output + rhs.output) + } + + private static func codexMinTotals( + _ lhs: CostUsageCodexTotals, + _ rhs: CostUsageCodexTotals) -> CostUsageCodexTotals + { + CostUsageCodexTotals( + input: min(lhs.input, rhs.input), + cached: min(lhs.cached, rhs.cached), + output: min(lhs.output, rhs.output)) + } + + private static func codexTotalDelta( + from baseline: CostUsageCodexTotals?, + to current: CostUsageCodexTotals) -> CostUsageCodexTotals + { + let baseline = baseline ?? .init(input: 0, cached: 0, output: 0) + return CostUsageCodexTotals( + input: max(0, current.input - baseline.input), + cached: max(0, current.cached - baseline.cached), + output: max(0, current.output - baseline.output)) + } + + private static func codexDivergentTotalDelta( + rawBaseline: CostUsageCodexTotals?, + countedBaseline: CostUsageCodexTotals?, + current: CostUsageCodexTotals) -> CostUsageCodexTotals + { + let rawBaseline = rawBaseline ?? .init(input: 0, cached: 0, output: 0) + let countedBaseline = countedBaseline ?? .init(input: 0, cached: 0, output: 0) + + func delta(raw: Int, counted: Int, current: Int) -> Int { + if current >= raw { + return max(0, current - raw) + } + return max(0, current - counted) + } + + return CostUsageCodexTotals( + input: delta(raw: rawBaseline.input, counted: countedBaseline.input, current: current.input), + cached: delta(raw: rawBaseline.cached, counted: countedBaseline.cached, current: current.cached), + output: delta(raw: rawBaseline.output, counted: countedBaseline.output, current: current.output)) + } + + struct CodexScanResources { + let fileIndex: CodexSessionFileIndex + let inheritedResolver: CodexInheritedTotalsResolver + let modelsDevCatalog: ModelsDevCatalog? + let modelsDevCacheRoot: URL? + let priorityTurns: [String: CodexPriorityTurnMetadata] + } + + struct CodexFileScanContext { + let range: CostUsageDayRange + let forceFullScan: Bool + let dropDeferredCodexRows: Bool + let requiresTurnIDCache: Bool + let changedPriorityTurnIDs: Set + let resources: CodexScanResources + let checkCancellation: CancellationCheck? + } + + struct CodexRefreshPlan { + let refreshMs: Int64 + let roots: [URL] + let rootsFingerprint: [String: Int64] + let rootsChanged: Bool + let windowExpanded: Bool + let needsCostCacheMigration: Bool + let modelsDevCatalog: ModelsDevCatalog? + let codexPricingKey: String + let codexPriorityMetadataKey: String + let hasPriorityMetadata: Bool + let priorityTurns: [String: CodexPriorityTurnMetadata] + let priorityTurnKeys: [String: String] + let priorityTurnIDsByDay: [String: [String]] + let pricingChanged: Bool + let priorityMetadataChanged: Bool + let priorityTurnsChanged: Bool + let needsTurnIDCacheMigration: Bool + let changedPriorityTurnIDs: Set + let shouldRefresh: Bool + } + + final class CodexSessionFileIndex { + private let files: [URL] + private let filePaths: Set + private let roots: [URL] + private let checkCancellation: CancellationCheck? + private var nextUnindexedFile = 0 + private var didIndexRoots = false + private var fileURLBySessionId: [String: URL] = [:] + private var missingSessionIds: Set = [] + + init( + files: [URL], + roots: [URL], + cachedSessionFiles: [String: URL] = [:], + checkCancellation: CancellationCheck? = nil) + { + self.files = files + self.filePaths = Set(files.map(\.path)) + self.roots = roots + self.fileURLBySessionId = cachedSessionFiles + self.checkCancellation = checkCancellation + } + + func remember(fileURL: URL, sessionId: String?) { + guard let sessionId, !sessionId.isEmpty else { return } + self.fileURLBySessionId[sessionId] = fileURL + } + + func fileURL(for sessionId: String) throws -> URL? { + if let cached = self.fileURLBySessionId[sessionId] { + return cached + } + if self.missingSessionIds.contains(sessionId) { + return nil + } + + while self.nextUnindexedFile < self.files.count { + try self.checkCancellation?() + let fileURL = self.files[self.nextUnindexedFile] + self.nextUnindexedFile += 1 + guard let indexedSessionId = try CostUsageScanner.parseCodexSessionIdentifier( + fileURL: fileURL, + checkCancellation: self.checkCancellation) + else { + continue + } + self.fileURLBySessionId[indexedSessionId] = fileURL + if indexedSessionId == sessionId { + return fileURL + } + } + + if !self.didIndexRoots { + try self.indexRoots() + if let indexed = self.fileURLBySessionId[sessionId] { + return indexed + } + } + + self.missingSessionIds.insert(sessionId) + return nil + } + + private func indexRoots() throws { + self.didIndexRoots = true + guard !self.roots.isEmpty else { return } + for root in self.roots { + try self.checkCancellation?() + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { continue } + + while let fileURL = enumerator.nextObject() as? URL { + try self.checkCancellation?() + guard fileURL.pathExtension.lowercased() == "jsonl" else { continue } + guard !self.filePaths.contains(fileURL.path) else { continue } + guard let indexedSessionId = try CostUsageScanner.parseCodexSessionIdentifier( + fileURL: fileURL, + checkCancellation: self.checkCancellation) + else { + continue + } + self.fileURLBySessionId[indexedSessionId] = fileURL + } + } + } + } + + final class CodexInheritedTotalsResolver { + private let fileIndex: CodexSessionFileIndex + private let checkCancellation: CancellationCheck? + private var snapshotsBySessionId: [String: [CodexTimestampedTotals]] = [:] + + init(fileIndex: CodexSessionFileIndex, checkCancellation: CancellationCheck?) { + self.fileIndex = fileIndex + self.checkCancellation = checkCancellation + } + + func inheritedTotals(for sessionId: String, atOrBefore cutoffTimestamp: String) throws -> CodexForkBaseline { + guard !cutoffTimestamp.isEmpty else { + CostUsageScanner.log.warning( + "Codex cost usage fork timestamp missing; treating parent baseline as unresolved", + metadata: ["sessionId": sessionId]) + return .unresolved + } + let cutoffDate = CostUsageScanner.dateFromTimestamp(cutoffTimestamp) + if cutoffDate == nil { + CostUsageScanner.log.warning( + "Codex cost usage could not parse fork timestamp; falling back to lexical comparison", + metadata: ["sessionId": sessionId, "timestamp": cutoffTimestamp]) + } + guard let snapshots = try self.snapshots(for: sessionId) else { return .unresolved } + var inherited: CostUsageCodexTotals? + for snapshot in snapshots { + let isAtOrBefore: Bool = if let snapshotDate = snapshot.date, let cutoffDate { + snapshotDate <= cutoffDate + } else { + snapshot.timestamp <= cutoffTimestamp + } + if isAtOrBefore { + inherited = snapshot.totals + } + } + return .resolved(inherited) + } + + private func snapshots(for sessionId: String) throws -> [CodexTimestampedTotals]? { + if let cached = self.snapshotsBySessionId[sessionId] { + return cached + } + try self.checkCancellation?() + guard let fileURL = try self.fileIndex.fileURL(for: sessionId) else { + CostUsageScanner.log.warning( + "Codex cost usage parent session file not found", + metadata: ["sessionId": sessionId]) + return nil + } + let parsed = try CostUsageScanner.parseCodexTokenSnapshots( + fileURL: fileURL, + checkCancellation: self.checkCancellation) + guard let parsedSessionId = parsed.sessionId else { + CostUsageScanner.log.warning( + "Codex cost usage parent session missing session metadata", + metadata: ["sessionId": sessionId, "path": fileURL.path]) + return nil + } + if parsedSessionId != sessionId { + CostUsageScanner.log.warning( + "Codex cost usage parent session resolved to mismatched session id", + metadata: [ + "requestedSessionId": sessionId, + "resolvedSessionId": parsedSessionId, + "path": fileURL.path, + ]) + return nil + } + self.snapshotsBySessionId[sessionId] = parsed.snapshots + return parsed.snapshots + } + } + + struct ClaudeParseResult { let days: [String: [String: [Int]]] + let rows: [ClaudeUsageRow] let parsedBytes: Int64 } + enum ClaudePathRole: String, Codable { + case parent + case subagent + } + + struct ClaudeUsageRow: Codable { + let dayKey: String + let model: String + let sessionId: String? + let messageId: String? + let requestId: String? + let isSidechain: Bool + let pathRole: ClaudePathRole + let input: Int + let cacheRead: Int + let cacheCreate: Int + let output: Int + let costNanos: Int + let costPriced: Bool? + } + static func loadDailyReport( provider: UsageProvider, since: Date, until: Date, now: Date = Date(), options: Options = Options()) -> CostUsageDailyReport + { + ( + try? self.loadDailyReportCancellable( + provider: provider, + since: since, + until: until, + now: now, + options: options, + checkCancellation: nil)) ?? CostUsageDailyReport(data: [], summary: nil) + } + + static func loadDailyReportCancellable( + provider: UsageProvider, + since: Date, + until: Date, + now: Date = Date(), + options: Options = Options(), + checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { let range = CostUsageDayRange(since: since, until: until) let emptyReport = CostUsageDailyReport(data: [], summary: nil) + try checkCancellation?() switch provider { case .codex: - return self.loadCodexDaily(range: range, now: now, options: options) + return try self.loadCodexDaily( + range: range, + now: now, + options: options, + checkCancellation: checkCancellation) case .claude: - return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options) + return try self.loadClaudeDaily( + provider: .claude, + range: range, + now: now, + options: options, + checkCancellation: checkCancellation) case .vertexai: var filtered = options if filtered.claudeLogProviderFilter == .all { filtered.claudeLogProviderFilter = .vertexAIOnly } - return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) - case .zai, .gemini, .antigravity, .cursor, .opencode, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp: + return try self.loadClaudeDaily( + provider: .vertexai, + range: range, + now: now, + options: filtered, + checkCancellation: checkCancellation) + case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, + .alibabatokenplan, .factory, + .copilot, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, + .t3chat, .synthetic, .openrouter, .elevenlabs, .warp, .perplexity, .mimo, .doubao, .abacus, .mistral, + .deepseek, .codebuff, .crof, .windsurf, .venice, .commandcode, .stepfun, .bedrock, .grok, .groq, + .llmproxy, .deepgram: return emptyReport } } // MARK: - Day keys - struct CostUsageDayRange: Sendable { + struct CostUsageDayRange { let sinceKey: String let untilKey: String let scanSinceKey: String @@ -135,21 +514,313 @@ enum CostUsageScanner { .appendingPathComponent("archived_sessions", isDirectory: true) } - private static func listCodexSessionFiles(root: URL, scanSinceKey: String, scanUntilKey: String) -> [URL] { + private static func listCodexSessionFiles( + root: URL, + scanSinceKey: String, + scanUntilKey: String, + includeRecursive: Bool) -> [URL] + { let partitioned = self.listCodexSessionFilesByDatePartition( root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey) let flat = self.listCodexSessionFilesFlat(root: root, scanSinceKey: scanSinceKey, scanUntilKey: scanUntilKey) + let recursive = includeRecursive ? self.listCodexLegacySessionFilesRecursive(root: root) : [] var seen: Set = [] var out: [URL] = [] - for item in partitioned + flat where !seen.contains(item.path) { + for item in partitioned + flat + recursive where !seen.contains(item.path) { seen.insert(item.path) out.append(item) } return out } + private static func cachedCodexSessionFiles( + cache: CostUsageCache, + range: CostUsageDayRange, + roots: [URL]) -> [URL] + { + cache.files.compactMap { path, usage in + let hasRelevantDay = usage.days.keys.contains { + CostUsageDayRange.isInRange(dayKey: $0, since: range.scanSinceKey, until: range.scanUntilKey) + } + guard hasRelevantDay else { return nil } + guard FileManager.default.fileExists(atPath: path) else { return nil } + let fileURL = URL(fileURLWithPath: path) + guard Self.isWithinCodexRoots(fileURL: fileURL, roots: roots) else { return nil } + return fileURL + } + } + + private static func cachedCodexSessionIndex(cache: CostUsageCache, roots: [URL]) -> [String: URL] { + var out: [String: URL] = [:] + for (path, usage) in cache.files { + guard let sessionId = usage.sessionId, !sessionId.isEmpty else { continue } + guard FileManager.default.fileExists(atPath: path) else { continue } + let fileURL = URL(fileURLWithPath: path) + guard Self.isWithinCodexRoots(fileURL: fileURL, roots: roots) else { continue } + out[sessionId] = fileURL + } + return out + } + + private static func codexRootsFingerprint(_ roots: [URL]) -> [String: Int64] { + var out: [String: Int64] = [:] + for root in roots { + out[root.standardizedFileURL.path] = 0 + } + return out + } + + private static func codexPricingKey(modelsDevArtifact: ModelsDevCacheArtifact?) -> String { + guard let modelsDevArtifact else { + let fingerprint = CostUsagePricing.codexBuiltInPricingFingerprint() + return "builtin-\(Self.sha256Hex(Data(fingerprint.utf8)))" + } + let fingerprint = self.modelsDevPricingFingerprint(modelsDevArtifact.catalog) + return "models-dev-v\(modelsDevArtifact.version)-\(Self.sha256Hex(Data(fingerprint.utf8)))" + } + + private static func modelsDevPricingFingerprint(_ catalog: ModelsDevCatalog) -> String { + var parts: [String] = [] + for providerID in catalog.providers.keys.sorted() { + guard let provider = catalog.providers[providerID] else { continue } + parts.append("provider=\(providerID)|\(provider.id ?? "")") + for modelKey in provider.models.keys.sorted() { + guard let model = provider.models[modelKey] else { continue } + let cost = model.cost + let contextOver200K = cost?.contextOver200K + parts.append([ + "model=\(modelKey)", + model.id, + Self.optionalDoubleFingerprint(cost?.input), + Self.optionalDoubleFingerprint(cost?.output), + Self.optionalDoubleFingerprint(cost?.cacheRead), + Self.optionalDoubleFingerprint(cost?.cacheWrite), + Self.optionalDoubleFingerprint(contextOver200K?.input), + Self.optionalDoubleFingerprint(contextOver200K?.output), + Self.optionalDoubleFingerprint(contextOver200K?.cacheRead), + Self.optionalDoubleFingerprint(contextOver200K?.cacheWrite), + model.limit?.context.map(String.init) ?? "nil", + ].joined(separator: "|")) + } + } + return parts.joined(separator: "\n") + } + + private static func optionalDoubleFingerprint(_ value: Double?) -> String { + guard let value else { return "nil" } + return String(format: "%.17g", value) + } + + private static func codexPriorityMetadataKey(databaseURL: URL?) -> String { + let url = databaseURL ?? self.defaultCodexPriorityDatabaseURL() + let path = url.standardizedFileURL.path + return FileManager.default.fileExists(atPath: path) ? "sqlite:\(path)" : "missing:\(path)" + } + + private static func codexPriorityMetadataChanged(old: String?, new: String) -> Bool { + guard let old, old != new else { return false } + return new.hasPrefix("sqlite:") + } + + private static func codexPriorityTurnKeys( + _ priorityTurns: [String: CodexPriorityTurnMetadata]) -> [String: String] + { + var partsByDay: [String: [String]] = [:] + for (turnID, turn) in priorityTurns { + guard let dayKey = self.codexPriorityDayKey(turn) else { continue } + partsByDay[dayKey, default: []].append([ + turnID, + turn.model ?? "", + turn.timestamp ?? "", + turn.threadID ?? "", + ].joined(separator: "|")) + } + var out: [String: String] = [:] + for (dayKey, parts) in partsByDay { + out[dayKey] = self.sha256Hex(Data(parts.sorted().joined(separator: "\n").utf8)) + } + return out + } + + private static func codexPriorityTurnIDsByDay( + _ priorityTurns: [String: CodexPriorityTurnMetadata]) -> [String: [String]] + { + var out: [String: Set] = [:] + for (turnID, turn) in priorityTurns { + guard let dayKey = self.codexPriorityDayKey(turn) else { continue } + out[dayKey, default: []].insert(turnID) + } + return out.mapValues { $0.sorted() } + } + + private static func codexPriorityDayKey(_ turn: CodexPriorityTurnMetadata) -> String? { + guard let timestamp = turn.timestamp else { return nil } + let dayKeyFromEpoch = Int64(timestamp).map { + CostUsageDayRange.dayKey(from: Date(timeIntervalSince1970: TimeInterval($0))) + } + return dayKeyFromEpoch ?? self.dayKeyFromTimestamp(timestamp) ?? self.dayKeyFromParsedISO(timestamp) + } + + private static func codexPriorityTurnKeysChanged( + old: [String: String]?, + new: [String: String], + range: CostUsageDayRange) -> Bool + { + for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + where old?[dayKey] != new[dayKey] + { + return true + } + return false + } + + private static func changedPriorityTurnIDs( + old: [String: [String]]?, + new: [String: [String]], + oldKeys: [String: String]?, + newKeys: [String: String], + range: CostUsageDayRange) -> Set + { + var out = Set() + for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + let oldIDs = Set(old?[dayKey] ?? []) + let newIDs = Set(new[dayKey] ?? []) + if oldIDs != newIDs || oldKeys?[dayKey] != newKeys[dayKey] { + out.formUnion(oldIDs) + out.formUnion(newIDs) + } + } + return out + } + + private static func mergePriorityTurnKeys( + existing: [String: String]?, + new: [String: String], + range: CostUsageDayRange, + retainedSinceKey: String, + retainedUntilKey: String) -> [String: String]? + { + var out = existing ?? [:] + for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + out[dayKey] = new[dayKey] + } + out = out.filter { key, _ in + CostUsageDayRange.isInRange(dayKey: key, since: retainedSinceKey, until: retainedUntilKey) + } + return out.isEmpty ? nil : out + } + + private static func mergePriorityTurnIDsByDay( + existing: [String: [String]]?, + new: [String: [String]], + range: CostUsageDayRange, + retainedSinceKey: String, + retainedUntilKey: String) -> [String: [String]]? + { + var out = existing ?? [:] + for dayKey in self.dayKeys(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) { + out[dayKey] = new[dayKey] ?? [] + } + out = out.filter { key, _ in + CostUsageDayRange.isInRange(dayKey: key, since: retainedSinceKey, until: retainedUntilKey) + } + return out.isEmpty ? nil : out + } + + private static func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private static func listCodexRecentlyModifiedFiles( + root: URL, + scanSinceKey: String, + scanUntilKey: String, + modifiedSince: Date) -> [URL] + { + let lookbackSinceKey = self.dayKey(scanSinceKey, addingDays: -self.codexActiveSessionLookbackDays) + ?? scanSinceKey + let partitioned = self.listCodexSessionFilesByDatePartition( + root: root, + scanSinceKey: lookbackSinceKey, + scanUntilKey: scanUntilKey) + let partitionedModified = self.filterRecentlyModified(files: partitioned, modifiedSince: modifiedSince) + + let legacyRecursive = self.listCodexRecentlyModifiedFilesRecursive(root: root, modifiedSince: modifiedSince) + var seen = Set(partitionedModified.map(\.path)) + var out = partitionedModified + for fileURL in legacyRecursive where !seen.contains(fileURL.path) { + seen.insert(fileURL.path) + out.append(fileURL) + } + return out + } + + private static func filterRecentlyModified(files: [URL], modifiedSince: Date) -> [URL] { + files.filter { fileURL in + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]) + guard values?.isRegularFile == true else { return false } + guard let modifiedAt = values?.contentModificationDate else { return false } + return modifiedAt >= modifiedSince + } + } + + private static func isDatePartitionComponent(_ value: String, length: Int) -> Bool { + 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 dayKeys(sinceKey: String, untilKey: String) -> [String] { + guard let since = self.parseDayKey(sinceKey), + self.parseDayKey(untilKey) != 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)) + guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break } + if next <= cursor { break } + cursor = next + } + return out + } + + private static func listCodexRecentlyModifiedFilesRecursive(root: URL, modifiedSince: Date) -> [URL] { + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { return [] } + + var out: [URL] = [] + while let fileURL = enumerator.nextObject() as? URL { + guard fileURL.pathExtension.lowercased() == "jsonl" else { continue } + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey]) + guard values?.isRegularFile == true else { continue } + guard let modifiedAt = values?.contentModificationDate, modifiedAt >= modifiedSince else { continue } + out.append(fileURL) + } + return out + } + + private static func isWithinCodexRoots(fileURL: URL, roots: [URL]) -> Bool { + let filePath = fileURL.standardizedFileURL.path + return roots.contains { root in + let rootPath = root.standardizedFileURL.path + if filePath == rootPath { return true } + let prefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" + return filePath.hasPrefix(prefix) + } + } + private static func listCodexSessionFilesByDatePartition( root: URL, scanSinceKey: String, @@ -206,6 +877,36 @@ enum CostUsageScanner { return out } + private static func listCodexLegacySessionFilesRecursive(root: URL) -> [URL] { + guard FileManager.default.fileExists(atPath: root.path) else { return [] } + let rootPath = root.standardizedFileURL.path + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants]) + else { return [] } + + var out: [URL] = [] + while let item = enumerator.nextObject() as? URL { + if Self.isCodexDatePartitionAncestor(item, rootPath: rootPath) { + enumerator.skipDescendants() + continue + } + guard item.pathExtension.lowercased() == "jsonl" else { continue } + out.append(item) + } + return out + } + + private static func isCodexDatePartitionAncestor(_ url: URL, rootPath: String) -> Bool { + let path = url.standardizedFileURL.path + guard path.hasPrefix(rootPath + "/") else { return false } + let relative = String(path.dropFirst(rootPath.count + 1)) + let parts = relative.split(separator: "/") + guard parts.count == 1 else { return false } + return Self.isDatePartitionComponent(String(parts[0]), length: 4) + } + private static let codexFilenameDateRegex = try? NSRegularExpression(pattern: "(\\d{4}-\\d{2}-\\d{2})") private static func dayKeyFromFilename(_ filename: String) -> String? { @@ -216,7 +917,7 @@ enum CostUsageScanner { return String(filename[matchRange]) } - private static func fileIdentityString(fileURL: URL) -> String? { + static func fileIdentityString(fileURL: URL) -> String? { guard let values = try? fileURL.resourceValues(forKeys: [.fileResourceIdentifierKey]) else { return nil } guard let identifier = values.fileResourceIdentifier else { return nil } if let data = identifier as? Data { @@ -225,18 +926,304 @@ enum CostUsageScanner { return String(describing: identifier) } + private struct CodexSessionMetadata { + let sessionId: String? + let forkedFromId: String? + let forkTimestamp: String? + } + + private static func codexForkParentId(from payload: [String: Any]?) -> String? { + guard let payload else { return nil } + for key in ["forked_from_id", "forkedFromId", "parent_session_id", "parentSessionId"] { + guard let value = payload[key] as? String else { continue } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return nil + } + + private static func parseCodexSessionIdentifier( + fileURL: URL, + checkCancellation: CancellationCheck? = nil) throws -> String? + { + try self.parseCodexSessionMetadata(fileURL: fileURL, checkCancellation: checkCancellation)?.sessionId + } + + private static func parseCodexSessionMetadata( + fileURL: URL, + checkCancellation: CancellationCheck? = nil) throws -> CodexSessionMetadata? + { + let handle: FileHandle + do { + handle = try FileHandle(forReadingFrom: fileURL) + } catch { + self.log.warning( + "Codex cost usage failed to open session file for session id parsing", + metadata: ["path": fileURL.path, "error": error.localizedDescription]) + return nil + } + defer { try? handle.close() } + + var buffer = Data() + let newline = Data([0x0A]) + + func parseSessionMetadata(from lineData: Data) -> CodexSessionMetadata? { + guard !lineData.isEmpty else { return nil } + return autoreleasepool { + guard let obj = (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] + else { return nil } + guard obj["type"] as? String == "session_meta" else { return nil } + let payload = obj["payload"] as? [String: Any] + return CodexSessionMetadata( + sessionId: payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String, + forkedFromId: Self.codexForkParentId(from: payload), + forkTimestamp: payload?["timestamp"] as? String + ?? obj["timestamp"] as? String) + } + } + + do { + while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty { + try checkCancellation?() + buffer.append(chunk) + while let newlineRange = buffer.range(of: newline) { + let lineData = buffer.subdata(in: 0.. ( + sessionId: String?, + snapshots: [CodexTimestampedTotals]) + { + var sessionId: String? + var previousTotals: CostUsageCodexTotals? + var rawTotalsBaseline: CostUsageCodexTotals? + var sawDivergentTotals = false + var snapshots: [CodexTimestampedTotals] = [] + var warnedAboutUnparsedTimestamp = false + + func parsedSnapshotDate(timestamp: String) -> Date? { + let date = Self.dateFromTimestamp(timestamp) + if date == nil, !warnedAboutUnparsedTimestamp { + warnedAboutUnparsedTimestamp = true + self.log.warning( + "Codex cost usage could not parse parent token snapshot timestamp; " + + "falling back to lexical comparison", + metadata: ["path": fileURL.path, "timestamp": timestamp]) + } + return date + } + + do { + _ = try CostUsageJsonl.scan( + fileURL: fileURL, + maxLineBytes: 512 * 1024, + prefixBytes: 512 * 1024, + checkCancellation: checkCancellation, + onLine: { line in + guard !line.bytes.isEmpty, !line.wasTruncated else { return } + autoreleasepool { + guard let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] + else { return } + + if obj["type"] as? String == "session_meta" { + let payload = obj["payload"] as? [String: Any] + if sessionId == nil { + sessionId = payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String + } + return + } + + guard obj["type"] as? String == "event_msg" else { return } + guard let payload = obj["payload"] as? [String: Any] else { return } + guard payload["type"] as? String == "token_count" else { return } + guard let info = payload["info"] as? [String: Any] else { return } + guard let timestamp = obj["timestamp"] as? String else { return } + + func toInt(_ value: Any?) -> Int { + if let number = value as? NSNumber { return number.intValue } + return 0 + } + + let total = info["total_token_usage"] as? [String: Any] + let last = info["last_token_usage"] as? [String: Any] + + if let last { + let rawDelta = CostUsageCodexTotals( + input: max(0, toInt(last["input_tokens"])), + cached: max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])), + output: max(0, toInt(last["output_tokens"]))) + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + var countedDelta = rawDelta + + if let total { + let rawTotals = CostUsageCodexTotals( + input: toInt(total["input_tokens"]), + cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), + output: toInt(total["output_tokens"])) + let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: rawTotals) + if Self.codexShouldPreferTotalDelta( + rawBaseline: rawTotalsBaseline, + currentTotal: rawTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + countedDelta = totalDelta + } + let next = Self.codexAddTotals(base, countedDelta) + previousTotals = next + rawTotalsBaseline = rawTotals + if !Self.codexTotalsEqual(rawTotals, next) { + sawDivergentTotals = true + } + } else { + let next = Self.codexAddTotals(base, countedDelta) + previousTotals = next + rawTotalsBaseline = next + } + + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: previousTotals ?? base)) + } else if let total { + let next = CostUsageCodexTotals( + input: toInt(total["input_tokens"]), + cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), + output: toInt(total["output_tokens"])) + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: next) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: next) + let base = previousTotals ?? .init(input: 0, cached: 0, output: 0) + let countedTotals = Self.codexAddTotals(base, delta) + previousTotals = countedTotals + rawTotalsBaseline = next + if !Self.codexTotalsEqual(next, countedTotals) { + sawDivergentTotals = true + } + snapshots.append(CodexTimestampedTotals( + timestamp: timestamp, + date: parsedSnapshotDate(timestamp: timestamp), + totals: countedTotals)) + } + } + }) + } catch is CancellationError { + throw CancellationError() + } catch { + self.log.warning( + "Codex cost usage failed while scanning parent token snapshots", + metadata: ["path": fileURL.path, "error": error.localizedDescription]) + } + + return (sessionId, snapshots) + } + static func parseCodexFile( fileURL: URL, range: CostUsageDayRange, startOffset: Int64 = 0, initialModel: String? = nil, - initialTotals: CostUsageCodexTotals? = nil) -> CodexParseResult + initialTotals: CostUsageCodexTotals? = nil, + initialRawTotalsBaseline: CostUsageCodexTotals? = nil, + initialHasDivergentTotals: Bool = false, + initialCodexTurnID: String? = nil, + inheritedTotalsResolver: ((String, String) -> CodexForkBaseline)? = nil) -> CodexParseResult + { + let throwingResolver: ((String, String) throws -> CodexForkBaseline)? = inheritedTotalsResolver + .map { resolver in + { sessionId, timestamp in resolver(sessionId, timestamp) } + } + return ( + try? Self.parseCodexFileCancellable( + fileURL: fileURL, + range: range, + startOffset: startOffset, + initialModel: initialModel, + initialTotals: initialTotals, + initialRawTotalsBaseline: initialRawTotalsBaseline, + initialHasDivergentTotals: initialHasDivergentTotals, + initialCodexTurnID: initialCodexTurnID, + inheritedTotalsResolver: throwingResolver, + checkCancellation: nil)) ?? CodexParseResult( + days: [:], + parsedBytes: startOffset, + lastModel: initialModel, + lastTotals: initialTotals, + lastCountedTotals: initialTotals, + lastRawTotalsBaseline: initialRawTotalsBaseline, + hasDivergentTotals: initialHasDivergentTotals, + lastCodexTurnID: initialCodexTurnID, + sessionId: nil, + forkedFromId: nil, + rows: []) + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + static func parseCodexFileCancellable( + fileURL: URL, + range: CostUsageDayRange, + startOffset: Int64 = 0, + initialModel: String? = nil, + initialTotals: CostUsageCodexTotals? = nil, + initialRawTotalsBaseline: CostUsageCodexTotals? = nil, + initialHasDivergentTotals: Bool = false, + initialCodexTurnID: String? = nil, + inheritedTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, + checkCancellation: CancellationCheck? = nil) throws -> CodexParseResult { var currentModel = initialModel var previousTotals = initialTotals var sessionId: String? + var forkedFromId: String? + var inheritedTotals: CostUsageCodexTotals? + var remainingInheritedTotals: CostUsageCodexTotals? + var forkBaselineResolved = false + var hasUnresolvedForkBaseline = false + var unresolvedForkTotalWatermark: CostUsageCodexTotals? + var currentTurnID = initialCodexTurnID + var rawTotalsBaseline = initialRawTotalsBaseline ?? initialTotals + var sawDivergentTotals = initialHasDivergentTotals + var deferredError: Error? var days: [String: [String: [Int]]] = [:] + var rows: [CodexUsageRow] = [] func add(dayKey: String, model: String, input: Int, cached: Int, output: Int) { guard CostUsageDayRange.isInRange(dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) @@ -252,484 +1239,652 @@ enum CostUsageScanner { days[dayKey] = dayModels } - let maxLineBytes = 256 * 1024 - let prefixBytes = 32 * 1024 - - let parsedBytes = (try? CostUsageJsonl.scan( - fileURL: fileURL, - offset: startOffset, - maxLineBytes: maxLineBytes, - prefixBytes: prefixBytes, - onLine: { line in - guard !line.bytes.isEmpty else { return } - guard !line.wasTruncated else { return } - - guard - line.bytes.containsAscii(#""type":"event_msg""#) - || line.bytes.containsAscii(#""type":"turn_context""#) - || line.bytes.containsAscii(#""type":"session_meta""#) - else { return } - - if line.bytes.containsAscii(#""type":"event_msg""#), !line.bytes.containsAscii(#""token_count""#) { - return - } + func resolveForkBaseline(parentSessionId: String, forkedAt: String) throws { + guard !forkBaselineResolved else { return } + guard let inheritedTotalsResolver else { return } + forkBaselineResolved = true + switch try inheritedTotalsResolver(parentSessionId, forkedAt) { + case let .resolved(totals): + inheritedTotals = totals + remainingInheritedTotals = totals + hasUnresolvedForkBaseline = false + case .unresolved: + hasUnresolvedForkBaseline = true + } + } - guard - let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], - let type = obj["type"] as? String - else { return } - - if type == "session_meta" { - if sessionId == nil { - let payload = obj["payload"] as? [String: Any] - sessionId = payload?["session_id"] as? String - ?? payload?["sessionId"] as? String - ?? payload?["id"] as? String - ?? obj["session_id"] as? String - ?? obj["sessionId"] as? String - ?? obj["id"] as? String - } - return - } + let maxLineBytes = 256 * 1024 + let prefixBytes = maxLineBytes - guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) else { return } + if startOffset == 0, + let metadata = try Self.parseCodexSessionMetadata( + fileURL: fileURL, + checkCancellation: checkCancellation) + { + sessionId = metadata.sessionId + forkedFromId = metadata.forkedFromId + if let forkedFromId = metadata.forkedFromId, + inheritedTotals == nil + { + let forkedAt = metadata.forkTimestamp ?? "" + try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: forkedAt) + } + } - if type == "turn_context" { - if let payload = obj["payload"] as? [String: Any] { - if let model = payload["model"] as? String { - currentModel = model - } else if let info = payload["info"] as? [String: Any], let model = info["model"] as? String { + var parsedBytes: Int64 + do { + parsedBytes = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: startOffset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + checkCancellation: checkCancellation, + onLine: { line in + if deferredError != nil { return } + guard !line.bytes.isEmpty else { return } + if line.wasTruncated { + // `turn_context` can carry very large prompts, but its model usually appears near the start. + if let model = Self.extractCodexTurnContextModel(from: line.bytes) { currentModel = model } + return } - return - } - guard type == "event_msg" else { return } - guard let payload = obj["payload"] as? [String: Any] else { return } - guard (payload["type"] as? String) == "token_count" else { return } + guard + line.bytes.containsAscii(#""type":"event_msg""#) + || line.bytes.containsAscii(#""type":"turn_context""#) + || line.bytes.containsAscii(#""type":"session_meta""#) + else { return } + + if line.bytes.containsAscii(#""type":"event_msg""#), + !line.bytes.containsAscii(#""token_count""#), + !line.bytes.containsAscii(#""task_started""#) + { + return + } - let info = payload["info"] as? [String: Any] - let modelFromInfo = info?["model"] as? String - ?? info?["model_name"] as? String - ?? payload["model"] as? String - ?? obj["model"] as? String - let model = modelFromInfo ?? currentModel ?? "gpt-5" + autoreleasepool { + guard + let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], + let type = obj["type"] as? String + else { return } + + if type == "session_meta" { + let payload = obj["payload"] as? [String: Any] + if sessionId == nil { + sessionId = payload?["session_id"] as? String + ?? payload?["sessionId"] as? String + ?? payload?["id"] as? String + ?? obj["session_id"] as? String + ?? obj["sessionId"] as? String + ?? obj["id"] as? String + } + if forkedFromId == nil { + forkedFromId = Self.codexForkParentId(from: payload) + } + if let forkedFromId { + let forkedAt = payload?["timestamp"] as? String + ?? obj["timestamp"] as? String + ?? "" + do { + try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: forkedAt) + } catch { + deferredError = error + return + } + } + return + } - func toInt(_ v: Any?) -> Int { - if let n = v as? NSNumber { return n.intValue } - return 0 - } + guard let tsText = obj["timestamp"] as? String else { return } + guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + else { return } + + if type == "turn_context" { + if let payload = obj["payload"] as? [String: Any] { + if let model = payload["model"] as? String { + currentModel = model + } else if let info = payload["info"] as? [String: Any], + let model = info["model"] as? String + { + currentModel = model + } + } + return + } - let total = (info?["total_token_usage"] as? [String: Any]) - let last = (info?["last_token_usage"] as? [String: Any]) - - var deltaInput = 0 - var deltaCached = 0 - var deltaOutput = 0 - - if let total { - let input = toInt(total["input_tokens"]) - let cached = toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]) - let output = toInt(total["output_tokens"]) - - let prev = previousTotals - deltaInput = max(0, input - (prev?.input ?? 0)) - deltaCached = max(0, cached - (prev?.cached ?? 0)) - deltaOutput = max(0, output - (prev?.output ?? 0)) - previousTotals = CostUsageCodexTotals(input: input, cached: cached, output: output) - } else if let last { - deltaInput = max(0, toInt(last["input_tokens"])) - deltaCached = max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])) - deltaOutput = max(0, toInt(last["output_tokens"])) - } else { - return - } + guard type == "event_msg" else { return } + guard let payload = obj["payload"] as? [String: Any] else { return } + if (payload["type"] as? String) == "task_started" { + currentTurnID = Self.codexTurnID(from: payload) + return + } + guard (payload["type"] as? String) == "token_count" else { return } + + let info = payload["info"] as? [String: Any] + let modelFromInfo = info?["model"] as? String + ?? info?["model_name"] as? String + ?? payload["model"] as? String + ?? obj["model"] as? String + let model = currentModel ?? modelFromInfo ?? "gpt-5" + + func toInt(_ v: Any?) -> Int { + if let n = v as? NSNumber { return n.intValue } + return 0 + } + + func tokenTotals(_ usage: [String: Any]) -> CostUsageCodexTotals { + CostUsageCodexTotals( + input: max(0, toInt(usage["input_tokens"])), + cached: max(0, toInt(usage["cached_input_tokens"] ?? usage["cache_read_input_tokens"])), + output: max(0, toInt(usage["output_tokens"]))) + } - if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } - let cachedClamp = min(deltaCached, deltaInput) - add(dayKey: dayKey, model: model, input: deltaInput, cached: cachedClamp, output: deltaOutput) - })) ?? startOffset + let total = (info?["total_token_usage"] as? [String: Any]) + let last = (info?["last_token_usage"] as? [String: Any]) + + var deltaInput = 0 + var deltaCached = 0 + var deltaOutput = 0 + + func adjustedLastDelta(_ rawDelta: CostUsageCodexTotals) -> CostUsageCodexTotals { + guard var remaining = remainingInheritedTotals else { return rawDelta } + + let adjusted = CostUsageCodexTotals( + input: max(0, rawDelta.input - remaining.input), + cached: max(0, rawDelta.cached - remaining.cached), + output: max(0, rawDelta.output - remaining.output)) + + remaining.input = max(0, remaining.input - rawDelta.input) + remaining.cached = max(0, remaining.cached - rawDelta.cached) + remaining.output = max(0, remaining.output - rawDelta.output) + remainingInheritedTotals = if remaining.input == 0, remaining.cached == 0, + remaining.output == 0 + { + nil + } else { + remaining + } + + return adjusted + } + + let handledUnresolvedForkTotal = hasUnresolvedForkBaseline && total != nil + if hasUnresolvedForkBaseline, let total { + let currentRawTotals = tokenTotals(total) + defer { + unresolvedForkTotalWatermark = currentRawTotals + } + guard let last, + let watermark = unresolvedForkTotalWatermark + else { + return + } + + let rawLastDelta = tokenTotals(last) + let rawTotalDelta = Self.codexTotalDelta(from: watermark, to: currentRawTotals) + let adjustedDelta = Self.codexMinTotals(rawLastDelta, rawTotalDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, adjustedDelta) + rawTotalsBaseline = previousTotals + } + + if !handledUnresolvedForkTotal, + let total, + forkedFromId != nil, + !hasUnresolvedForkBaseline + { + let rawTotals = tokenTotals(total) + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal, let last { + let rawDelta = CostUsageCodexTotals( + input: max(0, toInt(last["input_tokens"])), + cached: max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])), + output: max(0, toInt(last["output_tokens"]))) + let hadRemainingInheritedTotals = remainingInheritedTotals != nil + var adjustedDelta = adjustedLastDelta(rawDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + + if let total, !hasUnresolvedForkBaseline { + let rawTotals = tokenTotals(total) + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let totalDelta = Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + if !hadRemainingInheritedTotals, + Self.codexShouldPreferTotalDelta( + rawBaseline: rawTotalsBaseline, + currentTotal: currentTotals, + totalDelta: totalDelta, + lastDelta: rawDelta, + sawDivergentTotals: sawDivergentTotals) + { + adjustedDelta = totalDelta + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + remainingInheritedTotals = nil + } + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(currentTotals, countedTotals) { + sawDivergentTotals = true + } + } else { + let countedTotals = Self.codexAddTotals(prev, adjustedDelta) + previousTotals = countedTotals + rawTotalsBaseline = countedTotals + } + } else if !handledUnresolvedForkTotal, let total { + let rawTotals = tokenTotals(total) + + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal { + return + } + + if deltaInput == 0, deltaCached == 0, deltaOutput == 0 { return } + let cachedClamp = min(deltaCached, deltaInput) + let normModel = CostUsagePricing.normalizeCodexModel(model) + add( + dayKey: dayKey, + model: normModel, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput) + if CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + { + rows.append(CodexUsageRow( + day: dayKey, + model: normModel, + turnID: Self.codexTurnID(from: payload) ?? currentTurnID, + input: deltaInput, + cached: cachedClamp, + output: deltaOutput)) + } + } + }) + if let deferredError { + throw deferredError + } + } catch is CancellationError { + throw CancellationError() + } catch { + self.log.warning( + "Codex cost usage failed while scanning session file", + metadata: ["path": fileURL.path, "error": error.localizedDescription]) + parsedBytes = startOffset + } return CodexParseResult( days: days, parsedBytes: parsedBytes, lastModel: currentModel, - lastTotals: previousTotals, - sessionId: sessionId) + lastTotals: sawDivergentTotals && !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) + ? nil + : previousTotals, + lastCountedTotals: previousTotals, + lastRawTotalsBaseline: rawTotalsBaseline, + hasDivergentTotals: sawDivergentTotals && !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals), + lastCodexTurnID: currentTurnID, + sessionId: sessionId, + forkedFromId: forkedFromId, + rows: rows) + } + + private static func codexTurnID(from payload: [String: Any]) -> String? { + if let turnID = payload["turn_id"] as? String ?? payload["turnId"] as? String ?? payload["id"] as? String { + return turnID + } + if let info = payload["info"] as? [String: Any] { + return info["turn_id"] as? String ?? info["turnId"] as? String ?? info["id"] as? String + } + return nil } private static func scanCodexFile( fileURL: URL, - range: CostUsageDayRange, + context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) + state: inout CodexScanState) throws { - let path = fileURL.path - let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] - let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 - let mtimeMs = Int64(mtime * 1000) - let fileId = Self.fileIdentityString(fileURL: fileURL) - - func dropCachedFile(_ cached: CostUsageFileUsage?) { - if let cached { - Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) - } - cache.files.removeValue(forKey: path) - } - - if let fileId, state.seenFileIds.contains(fileId) { - dropCachedFile(cache.files[path]) + try context.checkCancellation?() + let metadata = Self.codexFileMetadata(fileURL: fileURL) + if let fileId = metadata.fileId, state.seenFileIds.contains(fileId) { + Self.dropCachedCodexFile(path: metadata.path, cached: cache.files[metadata.path], cache: &cache) return } - let cached = cache.files[path] + let cached = cache.files[metadata.path] if let cachedSessionId = cached?.sessionId, state.seenSessionIds.contains(cachedSessionId) { - dropCachedFile(cached) + Self.dropCachedCodexFile(path: metadata.path, cached: cached, cache: &cache) return } - let needsSessionId = cached != nil && cached?.sessionId == nil - if let cached, - cached.mtimeUnixMs == mtimeMs, - cached.size == size, - !needsSessionId - { - if let cachedSessionId = cached.sessionId { - state.seenSessionIds.insert(cachedSessionId) - } - if let fileId { - state.seenFileIds.insert(fileId) - } + let input = CodexFileScanInput(fileURL: fileURL, metadata: metadata, cached: cached) + if Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { return } - - if let cached, cached.sessionId != nil { - let startOffset = cached.parsedBytes ?? cached.size - let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size - && cached.lastTotals != nil - if canIncremental { - let delta = Self.parseCodexFile( - fileURL: fileURL, - range: range, - startOffset: startOffset, - initialModel: cached.lastModel, - initialTotals: cached.lastTotals) - let sessionId = delta.sessionId ?? cached.sessionId - if let sessionId, state.seenSessionIds.contains(sessionId) { - dropCachedFile(cached) - return - } - - if !delta.days.isEmpty { - Self.applyFileDays(cache: &cache, fileDays: delta.days, sign: 1) - } - - var mergedDays = cached.days - Self.mergeFileDays(existing: &mergedDays, delta: delta.days) - cache.files[path] = Self.makeFileUsage( - mtimeUnixMs: mtimeMs, - size: size, - days: mergedDays, - parsedBytes: delta.parsedBytes, - lastModel: delta.lastModel, - lastTotals: delta.lastTotals, - sessionId: sessionId) - if let sessionId { - state.seenSessionIds.insert(sessionId) - } - if let fileId { - state.seenFileIds.insert(fileId) - } - return - } - } - - if let cached { - Self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) - } - - let parsed = Self.parseCodexFile(fileURL: fileURL, range: range) - let sessionId = parsed.sessionId ?? cached?.sessionId - if let sessionId, state.seenSessionIds.contains(sessionId) { - cache.files.removeValue(forKey: path) + if try Self.appendCodexFileIncrementIfPossible(input: input, context: context, cache: &cache, state: &state) { return } + try Self.rescanCodexFile(input: input, context: context, cache: &cache, state: &state) + } - let usage = Self.makeFileUsage( - mtimeUnixMs: mtimeMs, - size: size, - days: parsed.days, - parsedBytes: parsed.parsedBytes, - lastModel: parsed.lastModel, - lastTotals: parsed.lastTotals, - sessionId: sessionId) - cache.files[path] = usage - Self.applyFileDays(cache: &cache, fileDays: usage.days, sign: 1) - if let sessionId { - state.seenSessionIds.insert(sessionId) - } - if let fileId { - state.seenFileIds.insert(fileId) + private static func makeCodexRefreshPlan( + cache: CostUsageCache, + range: CostUsageDayRange, + now: Date, + nowMs: Int64, + options: Options) -> CodexRefreshPlan + { + let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) + let roots = self.codexSessionsRoots(options: options) + let rootsFingerprint = Self.codexRootsFingerprint(roots) + let rootsChanged = cache.roots != rootsFingerprint + let windowExpanded = Self.requestedWindowExpandsCache(range: range, cache: cache) + let needsCostCacheMigration = cache.files.values.contains { Self.needsCodexCostCache($0, range: range) } + let modelsDevLoad = ModelsDevCache.load(now: now, cacheRoot: options.cacheRoot) + let modelsDevCatalog = modelsDevLoad.artifact?.catalog + let codexPricingKey = Self.codexPricingKey(modelsDevArtifact: modelsDevLoad.artifact) + let codexPriorityMetadataKey = Self.codexPriorityMetadataKey(databaseURL: options.codexTraceDatabaseURL) + let hasPriorityMetadata = codexPriorityMetadataKey.hasPrefix("sqlite:") + let pricingChanged = cache.codexPricingKey != nil && cache.codexPricingKey != codexPricingKey + let priorityMetadataChanged = Self.codexPriorityMetadataChanged( + old: cache.codexPriorityMetadataKey, + new: codexPriorityMetadataKey) + let needsTurnIDCacheMigration = hasPriorityMetadata && cache.files.values.contains { + $0.codexTurnIDs == nil && $0.touchesCodexScanWindow( + sinceKey: range.scanSinceKey, + untilKey: range.scanUntilKey) } + let shouldInspectPriorityTurns = options.forceRescan + || windowExpanded + || rootsChanged + || needsCostCacheMigration + || needsTurnIDCacheMigration + || pricingChanged + || priorityMetadataChanged + || refreshMs == 0 + || cache.lastScanUnixMs == 0 + || nowMs - cache.lastScanUnixMs > refreshMs + let priorityTurns = shouldInspectPriorityTurns ? Self.codexPriorityTurns( + databaseURL: options.codexTraceDatabaseURL, + sinceDayKey: range.scanSinceKey, + untilDayKey: range.scanUntilKey) : [:] + let priorityTurnKeys = Self.codexPriorityTurnKeys(priorityTurns) + let priorityTurnIDsByDay = Self.codexPriorityTurnIDsByDay(priorityTurns) + let priorityTurnsChanged = shouldInspectPriorityTurns + && hasPriorityMetadata + && Self.codexPriorityTurnKeysChanged( + old: cache.codexPriorityTurnKeys, + new: priorityTurnKeys, + range: range) + let changedPriorityTurnIDs = shouldInspectPriorityTurns && hasPriorityMetadata + ? Self.changedPriorityTurnIDs( + old: cache.codexPriorityTurnIDsByDay, + new: priorityTurnIDsByDay, + oldKeys: cache.codexPriorityTurnKeys, + newKeys: priorityTurnKeys, + range: range) + : [] + let shouldRefresh = options.forceRescan + || windowExpanded + || rootsChanged + || needsCostCacheMigration + || needsTurnIDCacheMigration + || pricingChanged + || priorityMetadataChanged + || priorityTurnsChanged + || refreshMs == 0 + || cache.lastScanUnixMs == 0 + || nowMs - cache.lastScanUnixMs > refreshMs + + return CodexRefreshPlan( + refreshMs: refreshMs, + roots: roots, + rootsFingerprint: rootsFingerprint, + rootsChanged: rootsChanged, + windowExpanded: windowExpanded, + needsCostCacheMigration: needsCostCacheMigration, + modelsDevCatalog: modelsDevCatalog, + codexPricingKey: codexPricingKey, + codexPriorityMetadataKey: codexPriorityMetadataKey, + hasPriorityMetadata: hasPriorityMetadata, + priorityTurns: priorityTurns, + priorityTurnKeys: priorityTurnKeys, + priorityTurnIDsByDay: priorityTurnIDsByDay, + pricingChanged: pricingChanged, + priorityMetadataChanged: priorityMetadataChanged, + priorityTurnsChanged: priorityTurnsChanged, + needsTurnIDCacheMigration: needsTurnIDCacheMigration, + changedPriorityTurnIDs: changedPriorityTurnIDs, + shouldRefresh: shouldRefresh) } - private static func loadCodexDaily(range: CostUsageDayRange, now: Date, options: Options) -> CostUsageDailyReport { + private static func loadCodexDaily( + range: CostUsageDayRange, + now: Date, + options: Options, + checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport + { var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) let nowMs = Int64(now.timeIntervalSince1970 * 1000) + let plan = Self.makeCodexRefreshPlan(cache: cache, range: range, now: now, nowMs: nowMs, options: options) - let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) - let shouldRefresh = refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs - - let roots = self.codexSessionsRoots(options: options) - var seenPaths: Set = [] - var files: [URL] = [] - for root in roots { - let rootFiles = Self.listCodexSessionFiles( - root: root, - scanSinceKey: range.scanSinceKey, - scanUntilKey: range.scanUntilKey) - for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { - seenPaths.insert(fileURL.path) - files.append(fileURL) - } - } - let filePathsInScan = Set(files.map(\.path)) - - if shouldRefresh { + if plan.shouldRefresh { + try checkCancellation?() if options.forceRescan { cache = CostUsageCache() } - var scanState = CodexScanState() - for fileURL in files { - Self.scanCodexFile( - fileURL: fileURL, - range: range, - cache: &cache, - state: &scanState) - } - for key in cache.files.keys where !filePathsInScan.contains(key) { - if let old = cache.files[key] { - Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) + 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) } + var seenPaths: Set = [] + var files: [URL] = [] + for root in plan.roots { + let rootFiles = Self.listCodexSessionFiles( + root: root, + scanSinceKey: range.scanSinceKey, + scanUntilKey: range.scanUntilKey, + includeRecursive: options.forceRescan) + for fileURL in rootFiles.sorted(by: { $0.path < $1.path }) where !seenPaths.contains(fileURL.path) { + seenPaths.insert(fileURL.path) + files.append(fileURL) } - cache.files.removeValue(forKey: key) - } - Self.pruneDays(cache: &cache, sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) - cache.lastScanUnixMs = nowMs - CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) - } - - return Self.buildCodexReportFromCache(cache: cache, range: range) - } - - private static func buildCodexReportFromCache( - cache: CostUsageCache, - range: CostUsageDayRange) -> CostUsageDailyReport - { - var entries: [CostUsageDailyReport.Entry] = [] - var totalInput = 0 - var totalOutput = 0 - var totalTokens = 0 - var totalCost: Double = 0 - var costSeen = false - - let dayKeys = cache.days.keys.sorted().filter { - CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) - } - - for day in dayKeys { - guard let models = cache.days[day] else { continue } - let modelNames = models.keys.sorted() - - var dayInput = 0 - var dayOutput = 0 - - var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] - var dayCost: Double = 0 - var dayCostSeen = false - - for model in modelNames { - let packed = models[model] ?? [0, 0, 0] - let input = packed[safe: 0] ?? 0 - let cached = packed[safe: 1] ?? 0 - let output = packed[safe: 2] ?? 0 - - dayInput += input - dayOutput += output - - let cost = CostUsagePricing.codexCostUSD( - model: model, - inputTokens: input, - cachedInputTokens: cached, - outputTokens: output) - breakdown.append(CostUsageDailyReport.ModelBreakdown(modelName: model, costUSD: cost)) - if let cost { - dayCost += cost - dayCostSeen = true + if shouldRunColdCacheLookback, let coldCacheLookbackStart { + let recentlyModifiedFiles = Self.listCodexRecentlyModifiedFiles( + root: root, + scanSinceKey: range.scanSinceKey, + scanUntilKey: range.scanUntilKey, + modifiedSince: coldCacheLookbackStart) + for fileURL in recentlyModifiedFiles.sorted(by: { $0.path < $1.path }) + where !seenPaths.contains(fileURL.path) + { + seenPaths.insert(fileURL.path) + files.append(fileURL) + } } } - breakdown.sort { lhs, rhs in (rhs.costUSD ?? -1) < (lhs.costUSD ?? -1) } - let top = Array(breakdown.prefix(3)) - - let dayTotal = dayInput + dayOutput - let entryCost = dayCostSeen ? dayCost : nil - entries.append(CostUsageDailyReport.Entry( - date: day, - inputTokens: dayInput, - outputTokens: dayOutput, - totalTokens: dayTotal, - costUSD: entryCost, - modelsUsed: modelNames, - modelBreakdowns: top)) - - totalInput += dayInput - totalOutput += dayOutput - totalTokens += dayTotal - if let entryCost { - totalCost += entryCost - costSeen = true + for fileURL in Self.cachedCodexSessionFiles(cache: cache, range: range, roots: plan.roots) + .sorted(by: { $0.path < $1.path }) + where !seenPaths.contains(fileURL.path) + { + seenPaths.insert(fileURL.path) + files.append(fileURL) } - } - let summary: CostUsageDailyReport.Summary? = entries.isEmpty - ? nil - : CostUsageDailyReport.Summary( - totalInputTokens: totalInput, - totalOutputTokens: totalOutput, - totalTokens: totalTokens, - totalCostUSD: costSeen ? totalCost : nil) + let filePathsInScan = Set(files.map(\.path)) - return CostUsageDailyReport(data: entries, summary: summary) - } - - // MARK: - Shared cache mutations + var scanState = CodexScanState() + let fileIndex = CodexSessionFileIndex( + files: files, + roots: plan.roots, + cachedSessionFiles: Self.cachedCodexSessionIndex(cache: cache, roots: plan.roots), + checkCancellation: checkCancellation) + let inheritedResolver = CodexInheritedTotalsResolver( + fileIndex: fileIndex, + checkCancellation: checkCancellation) + let resources = CodexScanResources( + fileIndex: fileIndex, + inheritedResolver: inheritedResolver, + modelsDevCatalog: plan.modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot, + priorityTurns: plan.priorityTurns) + for fileURL in files { + try Self.scanCodexFile( + fileURL: fileURL, + context: CodexFileScanContext( + range: range, + forceFullScan: options + .forceRescan || plan.windowExpanded || plan.pricingChanged || plan.priorityMetadataChanged, + dropDeferredCodexRows: options.forceRescan || plan.pricingChanged || plan + .priorityMetadataChanged + || plan.needsTurnIDCacheMigration, + requiresTurnIDCache: plan.needsTurnIDCacheMigration, + changedPriorityTurnIDs: plan.changedPriorityTurnIDs, + resources: resources, + checkCancellation: checkCancellation), + cache: &cache, + state: &scanState) + } + try checkCancellation?() - static func makeFileUsage( - mtimeUnixMs: Int64, - size: Int64, - days: [String: [String: [Int]]], - parsedBytes: Int64?, - lastModel: String? = nil, - lastTotals: CostUsageCodexTotals? = nil, - sessionId: String? = nil) -> CostUsageFileUsage - { - CostUsageFileUsage( - mtimeUnixMs: mtimeUnixMs, - size: size, - days: days, - parsedBytes: parsedBytes, - lastModel: lastModel, - lastTotals: lastTotals, - sessionId: sessionId) - } + Self.pruneForceRescanFilesOutsideWindow( + cache: &cache, + range: range, + isForceRescan: options.forceRescan) - static func mergeFileDays( - existing: inout [String: [String: [Int]]], - delta: [String: [String: [Int]]]) - { - for (day, models) in delta { - var dayModels = existing[day] ?? [:] - for (model, packed) in models { - let existingPacked = dayModels[model] ?? [] - let merged = Self.addPacked(a: existingPacked, b: packed, sign: 1) - if merged.allSatisfy({ $0 == 0 }) { - dayModels.removeValue(forKey: model) - } else { - dayModels[model] = merged - } + let shouldDropAllUnscannedFiles = options.forceRescan || plan.rootsChanged || cache.files.isEmpty + for key in cache.files.keys where !filePathsInScan.contains(key) { + guard let old = cache.files[key] else { continue } + let shouldDrop = shouldDropAllUnscannedFiles || + old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + guard shouldDrop else { continue } + Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) + cache.files.removeValue(forKey: key) } - if dayModels.isEmpty { - existing.removeValue(forKey: day) - } else { - existing[day] = dayModels - } - } - } - - static func applyFileDays(cache: inout CostUsageCache, fileDays: [String: [String: [Int]]], sign: Int) { - for (day, models) in fileDays { - var dayModels = cache.days[day] ?? [:] - for (model, packed) in models { - let existing = dayModels[model] ?? [] - let merged = Self.addPacked(a: existing, b: packed, sign: sign) - if merged.allSatisfy({ $0 == 0 }) { - dayModels.removeValue(forKey: model) - } else { - dayModels[model] = merged + if !shouldDropAllUnscannedFiles { + for key in cache.files.keys { + guard let old = cache.files[key] else { continue } + guard old.touchesCodexScanWindow(sinceKey: range.scanSinceKey, untilKey: range.scanUntilKey) + else { continue } + guard FileManager.default.fileExists(atPath: key) else { + Self.applyFileDays(cache: &cache, fileDays: old.days, sign: -1) + cache.files.removeValue(forKey: key) + continue + } } } - if dayModels.isEmpty { - cache.days.removeValue(forKey: day) - } else { - cache.days[day] = dayModels + let shouldRetainWiderWindow = !options.forceRescan && !plan.pricingChanged && !plan + .priorityMetadataChanged && !plan.needsTurnIDCacheMigration + let retainedSinceKey = shouldRetainWiderWindow + ? [cachedSinceKey, range.scanSinceKey].compactMap(\.self).min() ?? range.scanSinceKey + : range.scanSinceKey + let retainedUntilKey = shouldRetainWiderWindow + ? [cachedUntilKey, range.scanUntilKey].compactMap(\.self).max() ?? range.scanUntilKey + : range.scanUntilKey + Self.pruneDays(cache: &cache, sinceKey: retainedSinceKey, untilKey: retainedUntilKey) + cache.roots = plan.rootsFingerprint + cache.scanSinceKey = retainedSinceKey + cache.scanUntilKey = retainedUntilKey + cache.codexPricingKey = plan.codexPricingKey + cache.codexPriorityMetadataKey = plan.codexPriorityMetadataKey + if plan.hasPriorityMetadata { + cache.codexPriorityTurnKeys = Self.mergePriorityTurnKeys( + existing: shouldRetainWiderWindow ? cache.codexPriorityTurnKeys : nil, + new: plan.priorityTurnKeys, + range: range, + retainedSinceKey: retainedSinceKey, + retainedUntilKey: retainedUntilKey) + cache.codexPriorityTurnIDsByDay = Self.mergePriorityTurnIDsByDay( + existing: shouldRetainWiderWindow ? cache.codexPriorityTurnIDsByDay : nil, + new: plan.priorityTurnIDsByDay, + range: range, + retainedSinceKey: retainedSinceKey, + retainedUntilKey: retainedUntilKey) } + cache.lastScanUnixMs = nowMs + try checkCancellation?() + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) } - } - - static func pruneDays(cache: inout CostUsageCache, sinceKey: String, untilKey: String) { - for key in cache.days.keys where !CostUsageDayRange.isInRange(dayKey: key, since: sinceKey, until: untilKey) { - cache.days.removeValue(forKey: key) - } - } - static func addPacked(a: [Int], b: [Int], sign: Int) -> [Int] { - let len = max(a.count, b.count) - var out: [Int] = Array(repeating: 0, count: len) - for idx in 0.. Date? { - let parts = key.split(separator: "-") - guard parts.count == 3 else { return nil } - guard - let y = Int(parts[0]), - let m = Int(parts[1]), - let d = Int(parts[2]) - else { return nil } - - var comps = DateComponents() - comps.calendar = Calendar.current - comps.timeZone = TimeZone.current - comps.year = y - comps.month = m - comps.day = d - comps.hour = 12 - return comps.date - } -} - -extension Data { - func containsAscii(_ needle: String) -> Bool { - guard let n = needle.data(using: .utf8) else { return false } - return self.range(of: n) != nil - } -} - -extension [Int] { - subscript(safe index: Int) -> Int? { - if index < 0 { return nil } - if index >= self.count { return nil } - return self[index] + return Self.buildCodexReportFromCache( + cache: cache, + range: range, + modelsDevCatalog: plan.modelsDevCatalog, + modelsDevCacheRoot: options.cacheRoot, + priorityTurns: plan.priorityTurns) } } -extension [UInt8] { - subscript(safe index: Int) -> UInt8? { - if index < 0 { return nil } - if index >= self.count { return nil } - return self[index] - } -} +// swiftlint:enable type_body_length diff --git a/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift new file mode 100644 index 000000000..87801eb67 --- /dev/null +++ b/Sources/CodexBarCore/Vendored/CostUsage/ModelsDevPricing.swift @@ -0,0 +1,473 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +struct ModelsDevPricingInfo: Codable, Equatable { + var providerID: String + var providerName: String? + var modelID: String + var modelName: String? + var inputCostPerToken: Double + var outputCostPerToken: Double + var cacheReadInputCostPerToken: Double? + var cacheCreationInputCostPerToken: Double? + var contextWindow: Int? + var thresholdTokens: Int? + var inputCostPerTokenAboveThreshold: Double? + var outputCostPerTokenAboveThreshold: Double? + var cacheReadInputCostPerTokenAboveThreshold: Double? + var cacheCreationInputCostPerTokenAboveThreshold: Double? +} + +struct ModelsDevPricingLookup: Equatable { + var pricing: ModelsDevPricingInfo + var normalizedModelID: String +} + +struct ModelsDevCatalog: Codable, Equatable { + var providers: [String: ModelsDevProvider] + + init(providers: [String: ModelsDevProvider]) { + self.providers = providers + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ModelsDevAnyCodingKey.self) + if let providersKey = ModelsDevAnyCodingKey(stringValue: "providers"), + let decoded = try? container.decode([String: ModelsDevProvider].self, forKey: providersKey) + { + self.providers = decoded.reduce(into: [:]) { result, item in + var provider = item.value + provider.mapKey = provider.mapKey ?? item.key + let providerID = ModelsDevProvider.normalizeProviderID(provider.id ?? item.key) + result[providerID] = provider + } + return + } + + var providers: [String: ModelsDevProvider] = [:] + + for key in container.allKeys { + guard var provider = try? container.decode(ModelsDevProvider.self, forKey: key) else { continue } + provider.mapKey = key.stringValue + let providerID = ModelsDevProvider.normalizeProviderID(provider.id ?? key.stringValue) + providers[providerID] = provider + } + + self.providers = providers + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: ModelsDevAnyCodingKey.self) + try container.encode(self.providers, forKey: ModelsDevAnyCodingKey(stringValue: "providers")!) + } + + func pricing(providerID rawProviderID: String, modelID rawModelID: String) -> ModelsDevPricingLookup? { + let providerID = ModelsDevProvider.normalizeProviderID(rawProviderID) + return self.providers[providerID]?.pricing(modelID: rawModelID) + } + + func containsProviderIDs(_ providerIDs: some Sequence) -> Bool { + providerIDs.allSatisfy { self.providers.keys.contains(ModelsDevProvider.normalizeProviderID($0)) } + } + + func containsProviderModels(from cachedCatalog: ModelsDevCatalog) -> Bool { + cachedCatalog.providers.allSatisfy { providerID, cachedProvider in + guard let provider = self.providers[ModelsDevProvider.normalizeProviderID(providerID)] else { return false } + return cachedProvider.models.values + .filter(\.isPriceable) + .allSatisfy { provider.containsModel(matching: $0) } + } + } +} + +private struct ModelsDevAnyCodingKey: CodingKey { + var intValue: Int? + var stringValue: String + + init?(intValue: Int) { + self.intValue = intValue + self.stringValue = String(intValue) + } + + init?(stringValue: String) { + self.intValue = nil + self.stringValue = stringValue + } +} + +struct ModelsDevProvider: Codable, Equatable { + var id: String? + var name: String? + var models: [String: ModelsDevModel] + var mapKey: String? + + private enum CodingKeys: String, CodingKey { + case id + case name + case models + } + + init(id: String?, name: String?, models: [String: ModelsDevModel], mapKey: String? = nil) { + self.id = id + self.name = name + self.models = models + self.mapKey = mapKey + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: .id) + self.name = try container.decodeIfPresent(String.self, forKey: .name) + + let modelContainer = try container.nestedContainer(keyedBy: ModelsDevAnyCodingKey.self, forKey: .models) + var models: [String: ModelsDevModel] = [:] + for key in modelContainer.allKeys { + guard let model = try? modelContainer.decode(ModelsDevModel.self, forKey: key) else { continue } + models[key.stringValue] = model + } + self.models = models + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.id, forKey: .id) + try container.encodeIfPresent(self.name, forKey: .name) + try container.encode(self.models, forKey: .models) + } + + static func normalizeProviderID(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + func pricing(modelID rawModelID: String) -> ModelsDevPricingLookup? { + let candidates = ModelsDevModelIDNormalizer.candidates(rawModelID) + for candidate in candidates { + if let model = self.models[candidate], + let pricing = model.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) + { + return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: candidate) + } + } + + for candidate in candidates { + if let match = self.models.values.first(where: { $0.normalizedID == candidate }), + let pricing = match.pricing(providerID: self.id ?? self.mapKey ?? "", providerName: self.name) + { + return ModelsDevPricingLookup(pricing: pricing, normalizedModelID: match.normalizedID) + } + } + + return nil + } + + func containsModel(matching cachedModel: ModelsDevModel) -> Bool { + self.pricing(modelID: cachedModel.id) != nil + } +} + +struct ModelsDevModel: Codable, Equatable { + var id: String + var name: String? + var cost: ModelsDevCost? + var limit: ModelsDevLimit? + + var normalizedID: String { + ModelsDevModelIDNormalizer.normalize(self.id) + } + + var isPriceable: Bool { + self.cost?.input != nil && self.cost?.output != nil + } + + func pricing(providerID: String, providerName: String?) -> ModelsDevPricingInfo? { + guard let input = self.cost?.input, let output = self.cost?.output else { return nil } + + // models.dev publishes USD per 1M tokens. CodexBar cost math uses USD per token. + let unit = 1_000_000.0 + let contextOver200K = self.cost?.contextOver200K + return ModelsDevPricingInfo( + providerID: ModelsDevProvider.normalizeProviderID(providerID), + providerName: providerName, + modelID: self.id, + modelName: self.name, + inputCostPerToken: input / unit, + outputCostPerToken: output / unit, + cacheReadInputCostPerToken: self.cost?.cacheRead.map { $0 / unit }, + cacheCreationInputCostPerToken: self.cost?.cacheWrite.map { $0 / unit }, + contextWindow: self.limit?.context, + thresholdTokens: contextOver200K == nil ? nil : 200_000, + inputCostPerTokenAboveThreshold: contextOver200K?.input.map { $0 / unit }, + outputCostPerTokenAboveThreshold: contextOver200K?.output.map { $0 / unit }, + cacheReadInputCostPerTokenAboveThreshold: contextOver200K?.cacheRead.map { $0 / unit }, + cacheCreationInputCostPerTokenAboveThreshold: contextOver200K?.cacheWrite.map { $0 / unit }) + } +} + +struct ModelsDevCost: Codable, Equatable { + var input: Double? + var output: Double? + var cacheRead: Double? + var cacheWrite: Double? + var contextOver200K: ModelsDevContextOver200KCost? + + private enum CodingKeys: String, CodingKey { + case input + case output + case cacheRead = "cache_read" + case cacheWrite = "cache_write" + case contextOver200K = "context_over_200k" + } +} + +struct ModelsDevContextOver200KCost: Codable, Equatable { + var input: Double? + var output: Double? + var cacheRead: Double? + var cacheWrite: Double? + + private enum CodingKeys: String, CodingKey { + case input + case output + case cacheRead = "cache_read" + case cacheWrite = "cache_write" + } +} + +struct ModelsDevLimit: Codable, Equatable { + var context: Int? +} + +enum ModelsDevModelIDNormalizer { + static func normalize(_ raw: String) -> String { + raw.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func candidates(_ raw: String) -> [String] { + var candidates: [String] = [] + + func append(_ value: String) { + let normalized = self.normalize(value) + guard !normalized.isEmpty, !candidates.contains(normalized) else { return } + candidates.append(normalized) + } + + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + append(trimmed) + + if trimmed.hasPrefix("openai/") { + append(String(trimmed.dropFirst("openai/".count))) + } + + if trimmed.hasPrefix("anthropic.") { + append(String(trimmed.dropFirst("anthropic.".count))) + } + + if let lastDot = trimmed.lastIndex(of: "."), + trimmed.contains("claude-") + { + let tail = String(trimmed[trimmed.index(after: lastDot)...]) + if tail.hasPrefix("claude-") { + append(tail) + } + } + + var index = 0 + while index < candidates.count { + let candidate = candidates[index] + if let atSign = candidate.firstIndex(of: "@") { + let base = String(candidate[.. URL { + let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + return root.appendingPathComponent("CodexBar", isDirectory: true) + } + + static func cacheFileURL(cacheRoot: URL? = nil) -> URL { + let root = cacheRoot ?? self.defaultCacheRoot() + return root + .appendingPathComponent("model-pricing", isDirectory: true) + .appendingPathComponent("models-dev-v\(Self.artifactVersion).json", isDirectory: false) + } + + static func load(now: Date = Date(), cacheRoot: URL? = nil) -> ModelsDevCacheLoadResult { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + guard let data = try? Data(contentsOf: url) else { + return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .unreadable) + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let decoded = try? decoder.decode(ModelsDevCacheArtifact.self, from: data) else { + return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .invalidJSON) + } + guard decoded.version == Self.artifactVersion else { + return ModelsDevCacheLoadResult(artifact: nil, isStale: true, error: .invalidVersion) + } + + return ModelsDevCacheLoadResult( + artifact: decoded, + isStale: now.timeIntervalSince(decoded.fetchedAt) > Self.ttlSeconds, + error: nil) + } + + static func save(catalog: ModelsDevCatalog, fetchedAt: Date = Date(), cacheRoot: URL? = nil) { + let artifact = ModelsDevCacheArtifact( + version: Self.artifactVersion, + fetchedAt: fetchedAt, + catalog: catalog) + self.save(artifact: artifact, cacheRoot: cacheRoot) + } + + static func save(artifact: ModelsDevCacheArtifact, cacheRoot: URL? = nil) { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(artifact) else { return } + + let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) + do { + try data.write(to: tmp, options: [.atomic]) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } else { + try FileManager.default.moveItem(at: tmp, to: url) + } + } catch { + try? FileManager.default.removeItem(at: tmp) + } + } +} + +protocol ModelsDevHTTPTransport: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +struct URLSessionModelsDevTransport: ModelsDevHTTPTransport { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await URLSession.shared.data(for: request) + } +} + +struct ModelsDevClient { + enum Error: Swift.Error, Equatable { + case invalidResponse + case httpStatus(Int) + case invalidJSON + } + + var url: URL + var transport: any ModelsDevHTTPTransport + + init( + url: URL = URL(string: "https://models.dev/api.json")!, + transport: any ModelsDevHTTPTransport = URLSessionModelsDevTransport()) + { + self.url = url + self.transport = transport + } + + func fetchCatalog() async throws -> ModelsDevCatalog { + var request = URLRequest(url: self.url) + request.httpMethod = "GET" + request.timeoutInterval = 20 + + let (data, response) = try await self.transport.data(for: request) + guard let http = response as? HTTPURLResponse else { throw Error.invalidResponse } + guard (200..<300).contains(http.statusCode) else { throw Error.httpStatus(http.statusCode) } + + do { + return try JSONDecoder().decode(ModelsDevCatalog.self, from: data) + } catch { + throw Error.invalidJSON + } + } +} + +enum ModelsDevPricingPipeline { + static func lookup( + providerID: String, + modelID: String, + now: Date = Date(), + cacheRoot: URL? = nil) -> ModelsDevPricingLookup? + { + ModelsDevCache.load(now: now, cacheRoot: cacheRoot) + .artifact? + .catalog + .pricing(providerID: providerID, modelID: modelID) + } + + static func refreshIfNeeded( + now: Date = Date(), + cacheRoot: URL? = nil, + client: ModelsDevClient = ModelsDevClient()) async + { + let load = ModelsDevCache.load(now: now, cacheRoot: cacheRoot) + guard load.isStale else { return } + + do { + let catalog = try await client.fetchCatalog() + if let oldCatalog = load.artifact?.catalog, + !catalog.containsProviderModels(from: oldCatalog) + { + return + } + ModelsDevCache.save(catalog: catalog, fetchedAt: now, cacheRoot: cacheRoot) + } catch { + // Best-effort refresh only. Future scanner integration should keep using the last valid cache. + } + } +} diff --git a/Sources/CodexBarCore/WeeklyProjection.swift b/Sources/CodexBarCore/WeeklyProjection.swift deleted file mode 100644 index 1ac1b249b..000000000 --- a/Sources/CodexBarCore/WeeklyProjection.swift +++ /dev/null @@ -1,190 +0,0 @@ -import Foundation - -public struct WeeklyProjectionPoint: Sendable { - public let dayOfWeek: Int // 1=Mon ... 7=Sun - public let dayLabel: String // "Mon", "Tue", ... - public let thisWeekValue: Double? // actual data point - public let lastWeekValue: Double? // comparison point - public let projectedValue: Double? // extrapolated (future days only) - - public init( - dayOfWeek: Int, - dayLabel: String, - thisWeekValue: Double?, - lastWeekValue: Double?, - projectedValue: Double?) - { - self.dayOfWeek = dayOfWeek - self.dayLabel = dayLabel - self.thisWeekValue = thisWeekValue - self.lastWeekValue = lastWeekValue - self.projectedValue = projectedValue - } -} - -public struct WeeklyProjection: Sendable { - public let points: [WeeklyProjectionPoint] - public let metric: Metric - public let thisWeekTotal: Double? - public let lastWeekTotal: Double? - public let projectedEndOfWeek: Double? - public let changePercent: Double? // week-over-week change - - public enum Metric: Sendable { - case percentage - case tokens - case cost - } - - public init( - points: [WeeklyProjectionPoint], - metric: Metric, - thisWeekTotal: Double?, - lastWeekTotal: Double?, - projectedEndOfWeek: Double?, - changePercent: Double?) - { - self.points = points - self.metric = metric - self.thisWeekTotal = thisWeekTotal - self.lastWeekTotal = lastWeekTotal - self.projectedEndOfWeek = projectedEndOfWeek - self.changePercent = changePercent - } - - public static func compute( - currentWeek: [WeeklyUsageRecord], - previousWeek: [WeeklyUsageRecord], - now: Date = Date()) -> WeeklyProjection - { - var cal = Calendar(identifier: .iso8601) - cal.timeZone = TimeZone.current - let todayDayOfWeek = cal.component(.weekday, from: now) - // Convert from Calendar weekday (1=Sun...7=Sat) to ISO 8601 (1=Mon...7=Sun) - let isoDayOfWeek = todayDayOfWeek == 1 ? 7 : todayDayOfWeek - 1 - - let dayLabels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - - // Build lookup dictionaries by dayOfWeek - let currentByDay = Dictionary(uniqueKeysWithValues: currentWeek.map { ($0.dayOfWeek, $0) }) - let previousByDay = Dictionary(uniqueKeysWithValues: previousWeek.map { ($0.dayOfWeek, $0) }) - - // Determine which metric to use: prefer percentage, fall back to tokens, then cost - let metric = Self.detectMetric(currentWeek: currentWeek, previousWeek: previousWeek) - - // Compute average daily rate from this week's data. - // For percentage (cumulative): daily rate = latestValue / daysElapsed - // For tokens/cost (non-cumulative daily values): daily rate = mean of values - let currentValues = currentWeek.compactMap { Self.value(for: $0, metric: metric) } - let daysWithData = currentValues.count - let latestValue = currentWeek - .max { $0.dayOfWeek < $1.dayOfWeek } - .flatMap { Self.value(for: $0, metric: metric) } - let avgDailyRate: Double? = if daysWithData > 0 { - switch metric { - case .percentage: - (latestValue ?? 0) / Double(daysWithData) - case .tokens, .cost: - currentValues.reduce(0, +) / Double(daysWithData) - } - } else { - nil - } - - var points: [WeeklyProjectionPoint] = [] - for day in 1...7 { - let label = dayLabels[day - 1] - let currentRecord = currentByDay[day] - let previousRecord = previousByDay[day] - - let thisWeekValue = currentRecord.flatMap { Self.value(for: $0, metric: metric) } - let lastWeekValue = previousRecord.flatMap { Self.value(for: $0, metric: metric) } - - // Only project future days (after today) - let projectedValue: Double? = if day > isoDayOfWeek, let avgDailyRate { - switch metric { - case .percentage: - // Cumulative: project what the running total will be on that day - (latestValue ?? 0) + avgDailyRate * Double(day - isoDayOfWeek) - case .tokens, .cost: - // Non-cumulative: flat daily rate per future day - avgDailyRate - } - } else { - nil - } - - points.append(WeeklyProjectionPoint( - dayOfWeek: day, - dayLabel: label, - thisWeekValue: thisWeekValue, - lastWeekValue: lastWeekValue, - projectedValue: projectedValue)) - } - - // Compute totals - let thisWeekTotal = Self.total(for: currentWeek, metric: metric) - let lastWeekTotal = Self.total(for: previousWeek, metric: metric) - - // Project end-of-week - let projectedEndOfWeek: Double? = if let thisWeekTotal, let avgDailyRate { - thisWeekTotal + avgDailyRate * Double(max(0, 7 - isoDayOfWeek)) - } else { - nil - } - - // Week-over-week change - let changePercent: Double? = if let thisWeekTotal, let lastWeekTotal, lastWeekTotal > 0 { - ((thisWeekTotal - lastWeekTotal) / lastWeekTotal) * 100 - } else { - nil - } - - return WeeklyProjection( - points: points, - metric: metric, - thisWeekTotal: thisWeekTotal, - lastWeekTotal: lastWeekTotal, - projectedEndOfWeek: projectedEndOfWeek, - changePercent: changePercent) - } - - // MARK: - Private - - private static func detectMetric( - currentWeek: [WeeklyUsageRecord], - previousWeek: [WeeklyUsageRecord]) -> Metric - { - let allRecords = currentWeek + previousWeek - let hasPercent = allRecords.contains { $0.weeklyUsedPercent != nil } - if hasPercent { return .percentage } - let hasTokens = allRecords.contains { $0.totalTokens != nil } - if hasTokens { return .tokens } - return .cost - } - - private static func value(for record: WeeklyUsageRecord, metric: Metric) -> Double? { - switch metric { - case .percentage: - record.weeklyUsedPercent - case .tokens: - record.totalTokens.map(Double.init) - case .cost: - record.costUSD - } - } - - private static func total(for records: [WeeklyUsageRecord], metric: Metric) -> Double? { - let values = records.compactMap { Self.value(for: $0, metric: metric) } - guard !values.isEmpty else { return nil } - switch metric { - case .percentage: - // For percentage, use the latest (highest dayOfWeek) value as the "total" - return records - .max { $0.dayOfWeek < $1.dayOfWeek } - .flatMap { Self.value(for: $0, metric: metric) } - case .tokens, .cost: - return values.reduce(0, +) - } - } -} diff --git a/Sources/CodexBarCore/WeeklyUsageHistory.swift b/Sources/CodexBarCore/WeeklyUsageHistory.swift deleted file mode 100644 index b45cd837b..000000000 --- a/Sources/CodexBarCore/WeeklyUsageHistory.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -/// A single daily snapshot combining API percentage data and local token data for weekly tracking. -public struct WeeklyUsageRecord: Codable, Sendable, Equatable { - public let dayKey: String // "2026-02-11" - public let provider: UsageProvider - public let weekNumber: Int // ISO 8601 week-of-year - public let weekYear: Int // ISO 8601 year-for-week-of-year - public let dayOfWeek: Int // 1=Mon ... 7=Sun - - // From RateWindow (percentage-based, from API) - public let weeklyUsedPercent: Double? - public let sessionUsedPercent: Double? - public let weeklyResetsAt: Date? - - // From CostUsageTokenSnapshot (token-based, from local logs) - public let totalTokens: Int? - public let inputTokens: Int? - public let outputTokens: Int? - public let costUSD: Double? - - public let recordedAt: Date - - public init( - dayKey: String, - provider: UsageProvider, - weekNumber: Int, - weekYear: Int, - dayOfWeek: Int, - weeklyUsedPercent: Double?, - sessionUsedPercent: Double?, - weeklyResetsAt: Date?, - totalTokens: Int?, - inputTokens: Int?, - outputTokens: Int?, - costUSD: Double?, - recordedAt: Date) - { - self.dayKey = dayKey - self.provider = provider - self.weekNumber = weekNumber - self.weekYear = weekYear - self.dayOfWeek = dayOfWeek - self.weeklyUsedPercent = weeklyUsedPercent - self.sessionUsedPercent = sessionUsedPercent - self.weeklyResetsAt = weeklyResetsAt - self.totalTokens = totalTokens - self.inputTokens = inputTokens - self.outputTokens = outputTokens - self.costUSD = costUSD - self.recordedAt = recordedAt - } -} - -/// Container for weekly usage records with query helpers. -public struct WeeklyUsageReport: Codable, Sendable { - public var records: [WeeklyUsageRecord] - - public init(records: [WeeklyUsageRecord] = []) { - self.records = records - } - - public func currentWeek(now: Date = Date()) -> [WeeklyUsageRecord] { - let cal = Calendar(identifier: .iso8601) - let year = cal.component(.yearForWeekOfYear, from: now) - let week = cal.component(.weekOfYear, from: now) - return self.week(year: year, number: week) - } - - public func previousWeek(now: Date = Date()) -> [WeeklyUsageRecord] { - let cal = Calendar(identifier: .iso8601) - guard let oneWeekAgo = cal.date(byAdding: .weekOfYear, value: -1, to: now) else { return [] } - let year = cal.component(.yearForWeekOfYear, from: oneWeekAgo) - let week = cal.component(.weekOfYear, from: oneWeekAgo) - return self.week(year: year, number: week) - } - - public func week(year: Int, number: Int) -> [WeeklyUsageRecord] { - self.records.filter { $0.weekYear == year && $0.weekNumber == number } - .sorted { $0.dayOfWeek < $1.dayOfWeek } - } -} diff --git a/Sources/CodexBarCore/WeeklyUsageHistoryStore.swift b/Sources/CodexBarCore/WeeklyUsageHistoryStore.swift deleted file mode 100644 index f6e61a2de..000000000 --- a/Sources/CodexBarCore/WeeklyUsageHistoryStore.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation - -public enum WeeklyUsageHistoryStore { - private static let directoryName = "usage-history" - - public static func load(provider: UsageProvider) -> WeeklyUsageReport? { - guard let url = self.fileURL(for: provider) else { return nil } - guard let data = try? Data(contentsOf: url) else { return nil } - return try? self.decoder.decode(WeeklyUsageReport.self, from: data) - } - - public static func save(record: WeeklyUsageRecord) { - let provider = record.provider - var report = self.load(provider: provider) ?? WeeklyUsageReport() - - // Upsert by dayKey (last-write-wins for same day) - if let index = report.records.firstIndex(where: { $0.dayKey == record.dayKey }) { - report.records[index] = record - } else { - report.records.append(record) - } - - guard let url = self.fileURL(for: provider) else { return } - do { - let data = try self.encoder.encode(report) - try data.write(to: url, options: [.atomic]) - } catch { - return - } - } - - public static func prune(provider: UsageProvider, keepingWeeks: Int = 12) { - guard var report = self.load(provider: provider) else { return } - let cal = Calendar(identifier: .iso8601) - let now = Date() - guard let cutoff = cal.date(byAdding: .weekOfYear, value: -keepingWeeks, to: now) else { return } - let cutoffYear = cal.component(.yearForWeekOfYear, from: cutoff) - let cutoffWeek = cal.component(.weekOfYear, from: cutoff) - - let before = report.records.count - report.records.removeAll { record in - if record.weekYear < cutoffYear { return true } - if record.weekYear == cutoffYear, record.weekNumber < cutoffWeek { return true } - return false - } - - guard report.records.count != before else { return } - guard let url = self.fileURL(for: provider) else { return } - do { - let data = try self.encoder.encode(report) - try data.write(to: url, options: [.atomic]) - } catch { - return - } - } - - // MARK: - Private - - private static func fileURL(for provider: UsageProvider) -> URL? { - let fm = FileManager.default - let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? fm.temporaryDirectory - let dir = base - .appendingPathComponent("CodexBar", isDirectory: true) - .appendingPathComponent(self.directoryName, isDirectory: true) - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent("\(provider.rawValue)-weekly-v1.json", isDirectory: false) - } - - private static var encoder: JSONEncoder { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - return encoder - } - - private static var decoder: JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return decoder - } -} diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index 25a2f85b9..17affa8b1 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -1,12 +1,25 @@ import Foundation public struct WidgetSnapshot: Codable, Sendable { + public struct WidgetUsageRowSnapshot: Codable, Equatable, Sendable { + public let id: String + public let title: String + public let percentLeft: Double? + + public init(id: String, title: String, percentLeft: Double?) { + self.id = id + self.title = title + self.percentLeft = percentLeft + } + } + public struct ProviderEntry: Codable, Sendable { public let provider: UsageProvider public let updatedAt: Date public let primary: RateWindow? public let secondary: RateWindow? public let tertiary: RateWindow? + public let usageRows: [WidgetUsageRowSnapshot]? public let creditsRemaining: Double? public let codeReviewRemainingPercent: Double? public let tokenUsage: TokenUsageSummary? @@ -18,6 +31,7 @@ public struct WidgetSnapshot: Codable, Sendable { primary: RateWindow?, secondary: RateWindow?, tertiary: RateWindow?, + usageRows: [WidgetUsageRowSnapshot]? = nil, creditsRemaining: Double?, codeReviewRemainingPercent: Double?, tokenUsage: TokenUsageSummary?, @@ -28,6 +42,7 @@ public struct WidgetSnapshot: Codable, Sendable { self.primary = primary self.secondary = secondary self.tertiary = tertiary + self.usageRows = usageRows self.creditsRemaining = creditsRemaining self.codeReviewRemainingPercent = codeReviewRemainingPercent self.tokenUsage = tokenUsage @@ -40,17 +55,54 @@ public struct WidgetSnapshot: Codable, Sendable { public let sessionTokens: Int? public let last30DaysCostUSD: Double? public let last30DaysTokens: Int? + public let currencyCode: String + public let sessionLabel: String + public let last30DaysLabel: String public init( sessionCostUSD: Double?, sessionTokens: Int?, last30DaysCostUSD: Double?, - last30DaysTokens: Int?) + last30DaysTokens: Int?, + currencyCode: String = "USD", + sessionLabel: String = "Today", + last30DaysLabel: String = "30d") { self.sessionCostUSD = sessionCostUSD self.sessionTokens = sessionTokens self.last30DaysCostUSD = last30DaysCostUSD self.last30DaysTokens = last30DaysTokens + self.currencyCode = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "USD" + : currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + self.sessionLabel = sessionLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "Today" + : sessionLabel + self.last30DaysLabel = last30DaysLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "30d" + : last30DaysLabel + } + + private enum CodingKeys: String, CodingKey { + case sessionCostUSD + case sessionTokens + case last30DaysCostUSD + case last30DaysTokens + case currencyCode + case sessionLabel + case last30DaysLabel + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + sessionCostUSD: container.decodeIfPresent(Double.self, forKey: .sessionCostUSD), + sessionTokens: container.decodeIfPresent(Int.self, forKey: .sessionTokens), + last30DaysCostUSD: container.decodeIfPresent(Double.self, forKey: .last30DaysCostUSD), + last30DaysTokens: container.decodeIfPresent(Int.self, forKey: .last30DaysTokens), + currencyCode: container.decodeIfPresent(String.self, forKey: .currencyCode) ?? "USD", + sessionLabel: container.decodeIfPresent(String.self, forKey: .sessionLabel) ?? "Today", + last30DaysLabel: container.decodeIfPresent(String.self, forKey: .last30DaysLabel) ?? "30d") } } @@ -99,17 +151,16 @@ public struct WidgetSnapshot: Codable, Sendable { } public enum WidgetSnapshotStore { - public static let appGroupID = "group.com.steipete.codexbar" - private static let filename = "widget-snapshot.json" + private static let filename = AppGroupSupport.widgetSnapshotFilename public static func load(bundleID: String? = Bundle.main.bundleIdentifier) -> WidgetSnapshot? { - guard let url = self.snapshotURL(bundleID: bundleID) else { return nil } + let url = self.snapshotURL(bundleID: bundleID) guard let data = try? Data(contentsOf: url) else { return nil } return try? self.decoder.decode(WidgetSnapshot.self, from: data) } public static func save(_ snapshot: WidgetSnapshot, bundleID: String? = Bundle.main.bundleIdentifier) { - guard let url = self.snapshotURL(bundleID: bundleID) else { return } + let url = self.snapshotURL(bundleID: bundleID) do { let data = try self.encoder.encode(snapshot) try data.write(to: url, options: [.atomic]) @@ -118,32 +169,12 @@ public enum WidgetSnapshotStore { } } - private static func snapshotURL(bundleID: String?) -> URL? { - let fm = FileManager.default - let groupID = self.groupID(for: bundleID) - #if os(macOS) - if let groupID, let container = fm.containerURL(forSecurityApplicationGroupIdentifier: groupID) { - return container.appendingPathComponent(self.filename, isDirectory: false) - } - #endif - - let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? fm.temporaryDirectory - let dir = base.appendingPathComponent("CodexBar", isDirectory: true) - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent(self.filename, isDirectory: false) + private static func snapshotURL(bundleID: String?) -> URL { + AppGroupSupport.snapshotURL(bundleID: bundleID) } public static func appGroupID(for bundleID: String?) -> String? { - self.groupID(for: bundleID) - } - - private static func groupID(for bundleID: String?) -> String? { - guard let bundleID, !bundleID.isEmpty else { return self.appGroupID } - if bundleID.contains(".debug") { - return "group.com.steipete.codexbar.debug" - } - return self.appGroupID + AppGroupSupport.currentGroupID(for: bundleID) } private static var encoder: JSONEncoder { @@ -163,7 +194,7 @@ public enum WidgetSelectionStore { private static let selectedProviderKey = "widgetSelectedProvider" public static func loadSelectedProvider(bundleID: String? = Bundle.main.bundleIdentifier) -> UsageProvider? { - guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return nil } + let defaults = self.sharedDefaults(bundleID: bundleID) guard let raw = defaults.string(forKey: self.selectedProviderKey) else { return nil } return UsageProvider(rawValue: raw) } @@ -172,12 +203,11 @@ public enum WidgetSelectionStore { _ provider: UsageProvider, bundleID: String? = Bundle.main.bundleIdentifier) { - guard let defaults = self.sharedDefaults(bundleID: bundleID) else { return } + let defaults = self.sharedDefaults(bundleID: bundleID) defaults.set(provider.rawValue, forKey: self.selectedProviderKey) } - private static func sharedDefaults(bundleID: String?) -> UserDefaults? { - guard let groupID = WidgetSnapshotStore.appGroupID(for: bundleID) else { return nil } - return UserDefaults(suiteName: groupID) + private static func sharedDefaults(bundleID: String?) -> UserDefaults { + AppGroupSupport.sharedDefaults(bundleID: bundleID) ?? .standard } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index eb0d00574..aa3c5cb34 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -7,12 +7,15 @@ enum ProviderChoice: String, AppEnum { case codex case claude case gemini + case alibaba + case alibabatokenplan case antigravity case zai case copilot case minimax case kilo case opencode + case opencodego static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") @@ -20,12 +23,15 @@ enum ProviderChoice: String, AppEnum { .codex: DisplayRepresentation(title: "Codex"), .claude: DisplayRepresentation(title: "Claude"), .gemini: DisplayRepresentation(title: "Gemini"), + .alibaba: DisplayRepresentation(title: "Alibaba"), + .alibabatokenplan: DisplayRepresentation(title: "Alibaba Token Plan"), .antigravity: DisplayRepresentation(title: "Antigravity"), .zai: DisplayRepresentation(title: "z.ai"), .copilot: DisplayRepresentation(title: "Copilot"), .minimax: DisplayRepresentation(title: "MiniMax"), .kilo: DisplayRepresentation(title: "Kilo"), .opencode: DisplayRepresentation(title: "OpenCode"), + .opencodego: DisplayRepresentation(title: "OpenCode Go"), ] var provider: UsageProvider { @@ -33,12 +39,15 @@ enum ProviderChoice: String, AppEnum { case .codex: .codex case .claude: .claude case .gemini: .gemini + case .alibaba: .alibaba + case .alibabatokenplan: .alibabatokenplan case .antigravity: .antigravity case .zai: .zai case .copilot: .copilot case .minimax: .minimax case .kilo: .kilo case .opencode: .opencode + case .opencodego: .opencodego } } @@ -46,15 +55,21 @@ enum ProviderChoice: String, AppEnum { init?(provider: UsageProvider) { switch provider { case .codex: self = .codex + case .openai: return nil // OpenAI not yet supported in widgets + case .azureopenai: return nil // Azure OpenAI not yet supported in widgets case .claude: self = .claude case .gemini: self = .gemini + case .alibaba: self = .alibaba + case .alibabatokenplan: self = .alibabatokenplan case .antigravity: self = .antigravity case .cursor: return nil // Cursor not yet supported in widgets case .opencode: self = .opencode + case .opencodego: self = .opencodego case .zai: self = .zai case .factory: return nil // Factory not yet supported in widgets case .copilot: self = .copilot case .minimax: self = .minimax + case .manus: return nil // Manus not yet supported in widgets case .vertexai: return nil // Vertex AI not yet supported in widgets case .kilo: self = .kilo case .kiro: return nil // Kiro not yet supported in widgets @@ -62,11 +77,31 @@ enum ProviderChoice: String, AppEnum { case .jetbrains: return nil // JetBrains not yet supported in widgets case .kimi: return nil // Kimi not yet supported in widgets case .kimik2: return nil // Kimi K2 not yet supported in widgets + case .moonshot: return nil // Moonshot not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets + case .t3chat: return nil // T3 Chat not yet supported in widgets case .ollama: return nil // Ollama not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets + case .elevenlabs: return nil // ElevenLabs not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets + case .windsurf: return nil // Windsurf not yet supported in widgets + case .perplexity: return nil // Perplexity not yet supported in widgets + case .mimo: return nil // Xiaomi MiMo not yet supported in widgets + case .doubao: return nil // Doubao not yet supported in widgets + case .abacus: return nil // Abacus AI not yet supported in widgets + case .mistral: return nil // Mistral not yet supported in widgets + case .deepseek: return nil // DeepSeek not yet supported in widgets + case .codebuff: return nil // Codebuff not yet supported in widgets + case .crof: return nil // Crof not yet supported in widgets + case .venice: return nil // Venice not yet supported in widgets + case .commandcode: return nil // CommandCode not yet supported in widgets + case .stepfun: return nil // StepFun not yet supported in widgets + case .bedrock: return nil // Bedrock not yet supported in widgets + case .grok: return nil // Grok not yet supported in widgets + case .groq: return nil // Groq not yet supported in widgets + case .llmproxy: return nil // LLM Proxy not yet supported in widgets + case .deepgram: return nil // Deepgram not yet supported in widgets } } } @@ -89,7 +124,7 @@ struct ProviderSelectionIntent: AppIntent, WidgetConfigurationIntent { static let title: LocalizedStringResource = "Provider" static let description = IntentDescription("Select the provider to display in the widget.") - @Parameter(title: "Provider") + @Parameter(title: "Provider", default: .codex) var provider: ProviderChoice init() { @@ -121,10 +156,10 @@ struct CompactMetricSelectionIntent: AppIntent, WidgetConfigurationIntent { static let title: LocalizedStringResource = "Provider + Metric" static let description = IntentDescription("Select the provider and metric to display.") - @Parameter(title: "Provider") + @Parameter(title: "Provider", default: .codex) var provider: ProviderChoice - @Parameter(title: "Metric") + @Parameter(title: "Metric", default: .credits) var metric: CompactMetric init() { @@ -174,7 +209,7 @@ struct CodexBarTimelineProvider: AppIntentTimelineProvider { in context: Context) async -> Timeline { let provider = configuration.provider.provider - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarWidgetEntry(date: Date(), provider: provider, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) return Timeline(entries: [entry], policy: .after(refresh)) @@ -203,7 +238,7 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { } private func makeEntry() -> CodexBarSwitcherEntry { - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let providers = self.availableProviders(from: snapshot) let stored = WidgetSelectionStore.loadSelectedProvider() let selected = providers.first { $0 == stored } ?? providers.first ?? .codex @@ -218,6 +253,10 @@ struct CodexBarSwitcherTimelineProvider: TimelineProvider { } private func availableProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] { + Self.supportedProviders(from: snapshot) + } + + static func supportedProviders(from snapshot: WidgetSnapshot) -> [UsageProvider] { let enabled = snapshot.enabledProviders let providers = enabled.isEmpty ? snapshot.entries.map(\.provider) : enabled let supported = providers.filter { ProviderChoice(provider: $0) != nil } @@ -236,10 +275,11 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { func snapshot(for configuration: CompactMetricSelectionIntent, in context: Context) async -> CodexBarCompactEntry { let provider = configuration.provider.provider + let metric = configuration.metric return CodexBarCompactEntry( date: Date(), provider: provider, - metric: configuration.metric, + metric: metric, snapshot: WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot()) } @@ -248,11 +288,12 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { in context: Context) async -> Timeline { let provider = configuration.provider.provider - let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.snapshot() + let metric = configuration.metric + let snapshot = WidgetSnapshotStore.load() ?? WidgetPreviewData.emptySnapshot() let entry = CodexBarCompactEntry( date: Date(), provider: provider, - metric: configuration.metric, + metric: metric, snapshot: snapshot) let refresh = Date().addingTimeInterval(30 * 60) return Timeline(entries: [entry], policy: .after(refresh)) @@ -260,6 +301,10 @@ struct CodexBarCompactTimelineProvider: AppIntentTimelineProvider { } enum WidgetPreviewData { + static func emptySnapshot() -> WidgetSnapshot { + WidgetSnapshot(entries: [], enabledProviders: [], generatedAt: Date()) + } + static func snapshot() -> WidgetSnapshot { let primary = RateWindow(usedPercent: 35, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 4h") let secondary = RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets in 3d") diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index fbb8c5d9c..68fd97228 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -184,13 +184,19 @@ private struct CompactMetricView: View { let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" return (value, "Credits left", nil) case .todayCost: - let value = self.entry.tokenUsage?.sessionCostUSD.map(WidgetFormat.usd) ?? "—" + let value = self.entry.tokenUsage.map { token in + token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" + } ?? "—" let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) - return (value, "Today cost", detail) + let label = self.entry.tokenUsage.map { "\($0.sessionLabel) cost" } ?? "Today cost" + return (value, label, detail) case .last30DaysCost: - let value = self.entry.tokenUsage?.last30DaysCostUSD.map(WidgetFormat.usd) ?? "—" + let value = self.entry.tokenUsage.map { token in + token.last30DaysCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" + } ?? "—" let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) - return (value, "30d cost", detail) + let label = self.entry.tokenUsage.map { "\($0.last30DaysLabel) cost" } ?? "30d cost" + return (value, label, detail) } } } @@ -258,15 +264,21 @@ private struct ProviderSwitchChip: View { private var shortLabel: String { switch self.provider { case .codex: "Codex" + case .openai: "OpenAI" + case .azureopenai: "Azure OpenAI" case .claude: "Claude" case .gemini: "Gemini" case .antigravity: "Anti" case .cursor: "Cursor" case .opencode: "OpenCode" + case .opencodego: "OpenCode Go" + case .alibaba: "Alibaba" + case .alibabatokenplan: "Token Plan" case .zai: "z.ai" case .factory: "Droid" case .copilot: "Copilot" case .minimax: "MiniMax" + case .manus: "Manus" case .vertexai: "Vertex" case .kilo: "Kilo" case .kiro: "Kiro" @@ -274,11 +286,31 @@ private struct ProviderSwitchChip: View { case .jetbrains: "JetBrains" case .kimi: "Kimi" case .kimik2: "Kimi K2" + case .moonshot: "Moonshot" case .amp: "Amp" + case .t3chat: "T3 Chat" case .ollama: "Ollama" case .synthetic: "Synthetic" case .openrouter: "OpenRouter" + case .elevenlabs: "ElevenLabs" case .warp: "Warp" + case .windsurf: "Windsurf" + case .perplexity: "Pplx" + case .mimo: "MiMo" + case .doubao: "Doubao" + case .abacus: "Abacus" + case .mistral: "Mistral" + case .deepseek: "DeepSeek" + case .codebuff: "Codebuff" + case .crof: "Crof" + case .venice: "Venice" + case .commandcode: "Command Code" + case .stepfun: "StepFun" + case .bedrock: "Bedrock" + case .grok: "Grok" + case .groq: "Groq" + case .llmproxy: "LLM Proxy" + case .deepgram: "Deepgram" } } } @@ -288,14 +320,12 @@ private struct SwitcherSmallUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", @@ -311,21 +341,22 @@ private struct SwitcherMediumUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let credits = entry.creditsRemaining { ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) } } } @@ -336,14 +367,12 @@ private struct SwitcherLargeUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", @@ -356,13 +385,17 @@ private struct SwitcherLargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", + title: token.last30DaysLabel, value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens)) + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) @@ -377,14 +410,12 @@ private struct SmallUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", @@ -402,21 +433,22 @@ private struct MediumUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let credits = entry.creditsRemaining { ValueLine(title: "Credits", value: WidgetFormat.credits(credits)) } if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) } } .padding(12) @@ -429,14 +461,12 @@ private struct LargeUsageView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HeaderView(provider: self.entry.provider, updatedAt: self.entry.updatedAt) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.sessionLabel ?? "Session", - percentLeft: self.entry.primary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) - UsageBarRow( - title: ProviderDefaults.metadata[self.entry.provider]?.weeklyLabel ?? "Weekly", - percentLeft: self.entry.secondary?.remainingPercent, - color: WidgetColors.color(for: self.entry.provider)) + ForEach(WidgetUsageRow.rows(for: self.entry)) { row in + UsageBarRow( + title: row.title, + percentLeft: row.percentLeft, + color: WidgetColors.color(for: self.entry.provider)) + } if let codeReview = entry.codeReviewRemainingPercent { UsageBarRow( title: "Code review", @@ -449,13 +479,17 @@ private struct LargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", + title: token.last30DaysLabel, value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens)) + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) @@ -465,6 +499,39 @@ private struct LargeUsageView: View { } } +struct WidgetUsageRow: Identifiable, Equatable { + let id: String + let title: String + let percentLeft: Double? + + static func rows(for entry: WidgetSnapshot.ProviderEntry) -> [WidgetUsageRow] { + if let usageRows = entry.usageRows { + return usageRows.map { row in + WidgetUsageRow(id: row.id, title: row.title, percentLeft: row.percentLeft) + } + } + + let metadata = ProviderDefaults.metadata[entry.provider] + var rows = [ + WidgetUsageRow( + id: "primary", + title: metadata?.sessionLabel ?? "Session", + percentLeft: entry.primary?.remainingPercent), + WidgetUsageRow( + id: "secondary", + title: metadata?.weeklyLabel ?? "Weekly", + percentLeft: entry.secondary?.remainingPercent), + ] + if metadata?.supportsOpus == true { + rows.append(WidgetUsageRow( + id: "tertiary", + title: metadata?.opusLabel ?? "Opus", + percentLeft: entry.tertiary?.remainingPercent)) + } + return rows.filter { $0.percentLeft != nil } + } +} + private struct HistoryView: View { let entry: WidgetSnapshot.ProviderEntry let isLarge: Bool @@ -476,11 +543,17 @@ private struct HistoryView: View { .frame(height: self.isLarge ? 90 : 60) if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", - value: WidgetFormat.costAndTokens(cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) + title: token.last30DaysLabel, + value: WidgetFormat.costAndTokens( + cost: token.last30DaysCostUSD, + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } .padding(12) @@ -576,6 +649,10 @@ enum WidgetColors { switch provider { case .codex: Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) + case .openai: + Color(red: 15 / 255, green: 130 / 255, blue: 110 / 255) + case .azureopenai: + Color(red: 0, green: 120 / 255, blue: 212 / 255) case .claude: Color(red: 204 / 255, green: 124 / 255, blue: 94 / 255) case .gemini: @@ -586,6 +663,10 @@ enum WidgetColors { Color(red: 0 / 255, green: 191 / 255, blue: 165 / 255) // #00BFA5 - Cursor teal case .opencode: Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) + case .opencodego: + Color(red: 59 / 255, green: 130 / 255, blue: 246 / 255) + case .alibaba, .alibabatokenplan: + Color(red: 1.0, green: 106 / 255, blue: 0) case .zai: Color(red: 232 / 255, green: 90 / 255, blue: 106 / 255) case .factory: @@ -594,6 +675,8 @@ enum WidgetColors { Color(red: 168 / 255, green: 85 / 255, blue: 247 / 255) // Purple case .minimax: Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) + case .manus: + Color(red: 24 / 255, green: 24 / 255, blue: 24 / 255) case .vertexai: Color(red: 66 / 255, green: 133 / 255, blue: 244 / 255) // Google Blue case .kilo: @@ -608,16 +691,56 @@ enum WidgetColors { Color(red: 254 / 255, green: 96 / 255, blue: 60 / 255) // Kimi orange case .kimik2: Color(red: 76 / 255, green: 0 / 255, blue: 255 / 255) // Kimi K2 purple + case .moonshot: + Color(red: 32 / 255, green: 93 / 255, blue: 235 / 255) case .amp: Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red + case .t3chat: + Color(red: 245 / 255, green: 102 / 255, blue: 71 / 255) case .ollama: Color(red: 32 / 255, green: 32 / 255, blue: 32 / 255) // Ollama charcoal case .synthetic: Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal case .openrouter: Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple + case .elevenlabs: + Color(red: 235 / 255, green: 235 / 255, blue: 230 / 255) case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) + case .windsurf: + Color(red: 52 / 255, green: 232 / 255, blue: 187 / 255) // Windsurf #34e8bb + case .perplexity: + Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal + case .mimo: + Color(red: 1.0, green: 105 / 255, blue: 0) + case .doubao: + Color(red: 45 / 255, green: 136 / 255, blue: 255 / 255) // Doubao blue + case .abacus: + Color(red: 56 / 255, green: 189 / 255, blue: 248 / 255) + case .mistral: + Color(red: 255 / 255, green: 80 / 255, blue: 15 / 255) // Mistral orange + case .deepseek: + Color(red: 82 / 255, green: 125 / 255, blue: 240 / 255) + case .codebuff: + Color(red: 68 / 255, green: 255 / 255, blue: 0 / 255) // Codebuff lime + case .crof: + Color(red: 46 / 255, green: 171 / 255, blue: 148 / 255) + case .venice: + Color(red: 51 / 255, green: 153 / 255, blue: 1.0) + case .commandcode: + Color(red: 0, green: 0, blue: 0) + case .stepfun: + Color(red: 255 / 255, green: 140 / 255, blue: 0 / 255) // StepFun orange + case .bedrock: + Color(red: 255 / 255, green: 153 / 255, blue: 0 / 255) // AWS orange + case .grok: + Color(red: 16 / 255, green: 163 / 255, blue: 127 / 255) // Grok teal + case .groq: + Color(red: 245 / 255, green: 104 / 255, blue: 68 / 255) + case .llmproxy: + Color(red: 36 / 255, green: 180 / 255, blue: 126 / 255) + case .deepgram: + Color(red: 10 / 255, green: 18 / 255, blue: 27 / 255) } } } @@ -636,21 +759,21 @@ enum WidgetFormat { return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) } - static func costAndTokens(cost: Double?, tokens: Int?) -> String { - let costText = cost.map(self.usd) ?? "—" + static func costAndTokens(cost: Double?, tokens: Int?, currencyCode: String = "USD") -> String { + let costText = cost.map { self.currency($0, code: currencyCode) } ?? "—" if let tokens { return "\(costText) · \(self.tokenCount(tokens))" } return costText } - static func usd(_ value: Double) -> String { + static func currency(_ value: Double, code: String) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency - formatter.currencyCode = "USD" + formatter.currencyCode = code formatter.maximumFractionDigits = 2 formatter.minimumFractionDigits = 2 - return formatter.string(from: NSNumber(value: value)) ?? String(format: "$%.2f", value) + return formatter.string(from: NSNumber(value: value)) ?? "\(code) \(String(format: "%.2f", value))" } static func tokenCount(_ value: Int) -> String { @@ -663,6 +786,7 @@ enum WidgetFormat { static func relativeDate(_ date: Date) -> String { let formatter = RelativeDateTimeFormatter() + formatter.locale = Locale(identifier: "en_US") formatter.unitsStyle = .short return formatter.localizedString(for: date, relativeTo: Date()) } diff --git a/Tests/CodexBarTests/AbacusProviderTests.swift b/Tests/CodexBarTests/AbacusProviderTests.swift new file mode 100644 index 000000000..8aca07d2b --- /dev/null +++ b/Tests/CodexBarTests/AbacusProviderTests.swift @@ -0,0 +1,289 @@ +import Foundation +import Testing +@testable import CodexBarCore + +// MARK: - Descriptor Tests + +struct AbacusDescriptorTests { + @Test + func `descriptor has correct identity`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.id == .abacus) + #expect(descriptor.metadata.displayName == "Abacus AI") + #expect(descriptor.metadata.cliName == "abacusai") + } + + @Test + func `descriptor does not expose a separate credits panel`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.supportsCredits == false) + #expect(meta.supportsOpus == false) + } + + @Test + func `descriptor is not primary provider`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.isPrimaryProvider == false) + #expect(meta.defaultEnabled == false) + } + + @Test + func `descriptor supports auto and web source modes`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.fetchPlan.sourceModes.contains(.auto)) + #expect(descriptor.fetchPlan.sourceModes.contains(.web)) + } + + @Test + func `descriptor has no version detector`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.cli.versionDetector == nil) + } + + @Test + func `descriptor does not support token cost`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.tokenCost.supportsTokenCost == false) + } + + @Test + func `cli aliases include abacus-ai`() { + let descriptor = AbacusProviderDescriptor.descriptor + #expect(descriptor.cli.aliases.contains("abacus-ai")) + } + + @Test + func `dashboard url points to compute points page`() { + let meta = AbacusProviderDescriptor.descriptor.metadata + #expect(meta.dashboardURL?.contains("compute-points") == true) + } +} + +// MARK: - Usage Snapshot Conversion Tests + +struct AbacusUsageSnapshotTests { + @Test + func `converts full snapshot to usage snapshot`() throws { + let resetDate = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = AbacusUsageSnapshot( + creditsUsed: 250, + creditsTotal: 1000, + resetsAt: resetDate, + planName: "Pro") + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary != nil) + #expect(abs((usage.primary?.usedPercent ?? 0) - 25.0) < 0.01) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits") + #expect(usage.primary?.resetsAt == resetDate) + // Window derived from actual billing cycle (1 calendar month before resetDate) + let cycleStart = try #require(Calendar.current.date(byAdding: .month, value: -1, to: resetDate)) + let expectedMinutes = Int(resetDate.timeIntervalSince(cycleStart) / 60) + #expect(usage.primary?.windowMinutes == expectedMinutes) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.identity?.providerID == .abacus) + #expect(usage.identity?.loginMethod == "Pro") + } + + @Test + func `handles zero usage`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 500, + resetsAt: nil, + planName: "Basic") + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + #expect(usage.primary?.resetDescription == "0 / 500 credits") + } + + @Test + func `handles full usage`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 1000, + creditsTotal: 1000, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(abs((usage.primary?.usedPercent ?? 0) - 100.0) < 0.01) + #expect(usage.primary?.resetDescription == "1,000 / 1,000 credits") + } + + @Test + func `handles nil credits gracefully`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: nil, + creditsTotal: nil, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + #expect(usage.primary?.resetDescription == nil) + } + + @Test + func `handles nil total with non-nil used`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 100, + creditsTotal: nil, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + } + + @Test + func `handles zero total credits`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 0, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0.0) + } + + @Test + func `formats large credit values with comma grouping`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 12345, + creditsTotal: 50000, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "12,345 / 50,000 credits") + } + + @Test + func `formats fractional credit values`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 42.5, + creditsTotal: 100, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "42.5 / 100 credits") + } + + @Test + func `window minutes represents monthly cycle`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 100, + resetsAt: nil, + planName: nil) + + let usage = snapshot.toUsageSnapshot() + // 30 days * 24 hours * 60 minutes = 43200 + #expect(usage.primary?.windowMinutes == 43200) + } + + @Test + func `identity has no email or organization`() { + let snapshot = AbacusUsageSnapshot( + creditsUsed: 0, + creditsTotal: 100, + resetsAt: nil, + planName: "Pro") + + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.accountEmail == nil) + #expect(usage.identity?.accountOrganization == nil) + } +} + +// MARK: - Error Description Tests + +struct AbacusErrorTests { + @Test + func `noSessionCookie error mentions login`() { + let error = AbacusUsageError.noSessionCookie + #expect(error.errorDescription?.contains("log in") == true) + } + + @Test + func `sessionExpired error mentions expired`() { + let error = AbacusUsageError.sessionExpired + #expect(error.errorDescription?.contains("expired") == true) + } + + @Test + func `networkError includes message`() { + let error = AbacusUsageError.networkError("HTTP 500") + #expect(error.errorDescription?.contains("HTTP 500") == true) + } + + @Test + func `parseFailed includes message`() { + let error = AbacusUsageError.parseFailed("Invalid JSON") + #expect(error.errorDescription?.contains("Invalid JSON") == true) + } + + @Test + func `unauthorized error mentions login`() { + let error = AbacusUsageError.unauthorized + #expect(error.errorDescription?.contains("log in") == true) + } +} + +// MARK: - Error Classification Tests + +struct AbacusErrorClassificationTests { + @Test + func `unauthorized is recoverable and auth related`() { + let error = AbacusUsageError.unauthorized + #expect(error.isRecoverable == true) + #expect(error.isAuthRelated == true) + } + + @Test + func `sessionExpired is recoverable and auth related`() { + let error = AbacusUsageError.sessionExpired + #expect(error.isRecoverable == true) + #expect(error.isAuthRelated == true) + } + + @Test + func `parseFailed is not recoverable`() { + let error = AbacusUsageError.parseFailed("bad json") + #expect(error.isRecoverable == false) + #expect(error.isAuthRelated == false) + #expect(error.shouldTryNextImportedSession == true) + #expect(error.shouldClearCachedCookie == true) + } + + @Test + func `networkError is not recoverable`() { + let error = AbacusUsageError.networkError("timeout") + #expect(error.isRecoverable == false) + #expect(error.isAuthRelated == false) + #expect(error.shouldTryNextImportedSession == true) + #expect(error.shouldClearCachedCookie == false) + } + + @Test + func `noSessionCookie is not recoverable`() { + let error = AbacusUsageError.noSessionCookie + #expect(error.isRecoverable == false) + #expect(error.isAuthRelated == false) + #expect(error.shouldTryNextImportedSession == false) + #expect(error.shouldClearCachedCookie == false) + } + + @Test + func `auth failures continue imported session scanning`() { + #expect(AbacusUsageError.unauthorized.shouldTryNextImportedSession == true) + #expect(AbacusUsageError.sessionExpired.shouldTryNextImportedSession == true) + #expect(AbacusUsageError.unauthorized.shouldClearCachedCookie == true) + #expect(AbacusUsageError.sessionExpired.shouldClearCachedCookie == true) + } +} diff --git a/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift new file mode 100644 index 000000000..9bf9952ae --- /dev/null +++ b/Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) +import SweetCookieKit + +struct AlibabaCodingPlanCookieImporterTests { + @Test + func `domain matching requires exact or label bounded suffix`() { + #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("console.aliyun.com")) + #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain(".modelstudio.console.alibabacloud.com")) + #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("foo.aliyun.com")) + #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("evilaliyun.com") == false) + #expect(AlibabaCodingPlanCookieImporter.matchesCookieDomain("notalibabacloud.com") == false) + } + + @Test + func `cookie import candidates honor provided browser order`() throws { + BrowserCookieAccessGate.resetForTesting() + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let firefoxProfile = temp + .appendingPathComponent("Library") + .appendingPathComponent("Application Support") + .appendingPathComponent("Firefox") + .appendingPathComponent("Profiles") + .appendingPathComponent("abc.default-release") + try FileManager.default.createDirectory(at: firefoxProfile, withIntermediateDirectories: true) + FileManager.default.createFile( + atPath: firefoxProfile.appendingPathComponent("cookies.sqlite").path, + contents: Data()) + + let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let importOrder: BrowserCookieImportOrder = [.firefox, .safari, .chrome] + + let candidates = AlibabaCodingPlanCookieImporter.cookieImportCandidates( + browserDetection: detection, + importOrder: importOrder) + + let expected: [Browser] = [.firefox, .safari] + #expect(candidates == expected) + } + + @Test + func `default cookie import candidates skip keychain browsers during tests`() throws { + BrowserCookieAccessGate.resetForTesting() + + let temp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let chromeProfile = temp + .appendingPathComponent("Library") + .appendingPathComponent("Application Support") + .appendingPathComponent("Google") + .appendingPathComponent("Chrome") + .appendingPathComponent("Default") + try FileManager.default.createDirectory(at: chromeProfile, withIntermediateDirectories: true) + let cookiesDir = chromeProfile.appendingPathComponent("Network") + try FileManager.default.createDirectory(at: cookiesDir, withIntermediateDirectories: true) + FileManager.default.createFile( + atPath: cookiesDir.appendingPathComponent("Cookies").path, + contents: Data()) + + let detection = BrowserDetection(homeDirectory: temp.path, cacheTTL: 0) + let candidates = AlibabaCodingPlanCookieImporter.cookieImportCandidates(browserDetection: detection) + + #expect(candidates.first == .safari) + #expect(candidates.contains(.chrome) == false) + } +} + +#else + +struct AlibabaCodingPlanCookieImporterTests { + @Test + func `non mac OS placeholder`() { + #expect(true) + } +} + +#endif diff --git a/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift new file mode 100644 index 000000000..25dc0537b --- /dev/null +++ b/Tests/CodexBarTests/AlibabaCodingPlanProviderTests.swift @@ -0,0 +1,987 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AlibabaCodingPlanSettingsReaderTests { + @Test + func `api token reads from environment`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: ["ALIBABA_CODING_PLAN_API_KEY": "abc123"]) + #expect(token == "abc123") + } + + @Test + func `api token reads qwen alias from environment`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: ["ALIBABA_QWEN_API_KEY": "qwen123"]) + #expect(token == "qwen123") + } + + @Test + func `api token reads dashscope alias from environment`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: ["DASHSCOPE_API_KEY": "dashscope123"]) + #expect(token == "dashscope123") + } + + @Test + func `api token prefers coding plan key over aliases`() { + let token = AlibabaCodingPlanSettingsReader.apiToken(environment: [ + "ALIBABA_CODING_PLAN_API_KEY": "coding-plan", + "ALIBABA_QWEN_API_KEY": "qwen", + "DASHSCOPE_API_KEY": "dashscope", + ]) + #expect(token == "coding-plan") + } + + @Test + func `api token strips quotes`() { + let token = AlibabaCodingPlanSettingsReader + .apiToken(environment: ["ALIBABA_CODING_PLAN_API_KEY": "\"token-xyz\""]) + #expect(token == "token-xyz") + } + + @Test + func `quota URL infers scheme`() { + let url = AlibabaCodingPlanSettingsReader + .quotaURL(environment: [AlibabaCodingPlanSettingsReader + .quotaURLKey: "modelstudio.console.alibabacloud.com/data/api.json"]) + #expect(url?.absoluteString == "https://modelstudio.console.alibabacloud.com/data/api.json") + } + + @Test + func `missing cookie error includes access hint when present`() { + let error = AlibabaCodingPlanSettingsError + .missingCookie(details: "Safari cookie file exists but is not readable.") + #expect(error.errorDescription?.contains("Safari cookie file exists but is not readable.") == true) + } +} + +struct AlibabaCodingPlanUsageSnapshotTests { + @Test + func `maps usage snapshot windows`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset5h = Date(timeIntervalSince1970: 1_700_000_300) + let resetWeek = Date(timeIntervalSince1970: 1_700_010_000) + let resetMonth = Date(timeIntervalSince1970: 1_700_100_000) + let snapshot = AlibabaCodingPlanUsageSnapshot( + planName: "Pro", + fiveHourUsedQuota: 20, + fiveHourTotalQuota: 100, + fiveHourNextRefreshTime: reset5h, + weeklyUsedQuota: 120, + weeklyTotalQuota: 400, + weeklyNextRefreshTime: resetWeek, + monthlyUsedQuota: 500, + monthlyTotalQuota: 2000, + monthlyNextRefreshTime: resetMonth, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 20) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.secondary?.usedPercent == 30) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.tertiary?.usedPercent == 25) + #expect(usage.tertiary?.windowMinutes == 43200) + #expect(usage.loginMethod(for: .alibaba) == "Pro") + } + + @Test + func `shifts primary reset forward when backend reset is not future`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let stalePrimaryReset = Date(timeIntervalSince1970: 1_699_999_900) + let snapshot = AlibabaCodingPlanUsageSnapshot( + planName: "Lite", + fiveHourUsedQuota: 70, + fiveHourTotalQuota: 1200, + fiveHourNextRefreshTime: stalePrimaryReset, + weeklyUsedQuota: 80, + weeklyTotalQuota: 9000, + weeklyNextRefreshTime: Date(timeIntervalSince1970: 1_700_010_000), + monthlyUsedQuota: 80, + monthlyTotalQuota: 18000, + monthlyNextRefreshTime: Date(timeIntervalSince1970: 1_700_100_000), + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetsAt == stalePrimaryReset.addingTimeInterval(TimeInterval(5 * 60 * 60))) + } +} + +struct AlibabaCodingPlanUsageParsingTests { + @Test + func `parses quota payload`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { "planName": "Alibaba Coding Plan Pro" } + ], + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 52, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000, + "perWeekUsedQuota": 800, + "perWeekTotalQuota": 5000, + "perWeekQuotaNextRefreshTime": 1700100000000, + "perBillMonthUsedQuota": 1200, + "perBillMonthTotalQuota": 20000, + "perBillMonthQuotaNextRefreshTime": 1701000000000 + } + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Alibaba Coding Plan Pro") + #expect(snapshot.fiveHourUsedQuota == 52) + #expect(snapshot.fiveHourTotalQuota == 1000) + #expect(snapshot.weeklyTotalQuota == 5000) + #expect(snapshot.monthlyTotalQuota == 20000) + #expect(snapshot.fiveHourNextRefreshTime == Date(timeIntervalSince1970: 1_700_000_300)) + } + + @Test + func `multi instance quota payload uses selected active instance plan name`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Expired Starter", + "status": "EXPIRED", + "endTime": "2025-04-01 17:00", + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 7, + "per5HourTotalQuota": 100, + "per5HourQuotaNextRefreshTime": 1700000100000 + } + }, + { + "planName": "Active Pro", + "status": "VALID", + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 52, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000 + } + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Active Pro") + #expect(snapshot.fiveHourUsedQuota == 52) + #expect(snapshot.fiveHourTotalQuota == 1000) + #expect(snapshot.fiveHourNextRefreshTime == Date(timeIntervalSince1970: 1_700_000_300)) + } + + @Test + func `missing quota data without positive active signal fails`() { + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { "planName": "Alibaba Coding Plan Pro" } + ] + }, + "status_code": 0 + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.self) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `plan usage without positive active proof fails`() { + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Alibaba Coding Plan Pro", + "planUsage": "18%" + } + ] + }, + "status_code": 0 + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.self) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `parses wrapped JSON string payload`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let inner = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Coding Plan Lite", + "status": "VALID", + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 0, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000 + } + } + ] + }, + "statusCode": 200 + } + """ + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "\"", with: "\\\"") + + let wrapped = """ + { + "successResponse": { + "body": "\(inner)" + } + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(wrapped.utf8), now: now) + + #expect(snapshot.planName == "Coding Plan Lite") + #expect(snapshot.fiveHourTotalQuota == 1000) + #expect(snapshot.fiveHourUsedQuota == 0) + } + + @Test + func `plan usage fallback stays visible but non quantitative`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Coding Plan Lite", + "status": "VALID", + "planUsage": "0%", + "endTime": "2026-04-01 17:00" + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Coding Plan Lite") + #expect(snapshot.fiveHourUsedQuota == nil) + #expect(snapshot.fiveHourTotalQuota == nil) + #expect(snapshot.fiveHourNextRefreshTime == nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibaba) == "Coding Plan Lite") + } + + @Test + func `falls back to active plan when quota and usage missing`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Coding Plan Lite", + "status": "VALID", + "endTime": "2026-04-01 17:00" + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Coding Plan Lite") + #expect(snapshot.fiveHourUsedQuota == nil) + #expect(snapshot.fiveHourTotalQuota == nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibaba) == "Coding Plan Lite") + } + + @Test + func `future end time counts as positive active signal`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Coding Plan Lite", + "endTime": "2030-04-01 17:00" + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Coding Plan Lite") + #expect(snapshot.fiveHourUsedQuota == nil) + #expect(snapshot.weeklyTotalQuota == nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibaba) == "Coding Plan Lite") + } + + @Test + func `multi instance fallback uses selected active instance plan name`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Expired Starter", + "status": "EXPIRED", + "endTime": "2025-04-01 17:00" + }, + { + "planName": "Active Pro", + "status": "VALID", + "planUsage": "42%" + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Active Pro") + #expect(snapshot.fiveHourUsedQuota == nil) + #expect(snapshot.fiveHourTotalQuota == nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibaba) == "Active Pro") + } + + @Test + func `active instance without quota does not borrow quota from another instance`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Expired Starter", + "status": "EXPIRED", + "endTime": "2025-04-01 17:00", + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 7, + "per5HourTotalQuota": 100, + "per5HourQuotaNextRefreshTime": 1700000100000 + } + }, + { + "planName": "Active Pro", + "status": "VALID" + } + ] + }, + "status_code": 0 + } + """ + + let snapshot = try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "Active Pro") + #expect(snapshot.fiveHourUsedQuota == nil) + #expect(snapshot.fiveHourTotalQuota == nil) + #expect(snapshot.fiveHourNextRefreshTime == nil) + } + + @Test + func `payload level active proof does not label first instance when no instance is active`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "data": { + "status": "VALID", + "codingPlanInstanceInfos": [ + { + "planName": "Expired Starter", + "status": "EXPIRED", + "endTime": "2025-04-01 17:00" + }, + { + "planName": "No Proof Pro" + } + ] + }, + "status_code": 0 + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.self) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + } + } + + @Test + func `does not fallback for inactive plan without quota`() { + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { + "planName": "Coding Plan Lite", + "status": "EXPIRED" + } + ] + }, + "status_code": 0 + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.self) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `console need login payload maps to login required`() { + let json = """ + { + "code": "ConsoleNeedLogin", + "message": "You need to log in.", + "requestId": "abc", + "successResponse": false + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.loginRequired) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `console need login payload maps to unavailable API key mode`() { + let json = """ + { + "code": "ConsoleNeedLogin", + "message": "You need to log in.", + "requestId": "abc", + "successResponse": false + } + """ + + #expect(throws: AlibabaCodingPlanUsageError.apiKeyUnavailableInRegion) { + try AlibabaCodingPlanUsageFetcher.parseUsageSnapshot( + from: Data(json.utf8), + authMode: .apiKey) + } + } +} + +@Suite(.serialized) +struct AlibabaCodingPlanFallbackTests { + 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 func makeContext( + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot? = nil, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: browserDetection) + } + + @Test + func `falls back on TLS failure in auto mode`() { + let strategy = AlibabaCodingPlanWebFetchStrategy() + let context = self.makeContext(sourceMode: .auto) + #expect(strategy.shouldFallback(on: URLError(.secureConnectionFailed), context: context)) + } + + @Test + func `does not fallback on TLS failure when source forced to web`() { + let strategy = AlibabaCodingPlanWebFetchStrategy() + let context = self.makeContext(sourceMode: .web) + #expect(strategy.shouldFallback(on: URLError(.secureConnectionFailed), context: context) == false) + } + + @Test + func `auto mode does not borrow manual cookie authority when browser import fails`() { + let strategy = AlibabaCodingPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: "session=manual-cookie", + apiRegion: .international)) + let context = self.makeContext(sourceMode: .auto, settings: settings) + + CookieHeaderCache.clear(provider: .alibaba) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie() + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + } + + do { + _ = try AlibabaCodingPlanWebFetchStrategy.resolveCookieHeader(context: context, allowCached: false) + Issue.record("Expected auto mode to fail instead of borrowing the manual cookie header") + } catch let error as AlibabaCodingPlanSettingsError { + guard case .missingCookie = error else { + Issue.record("Expected missingCookie, got \(error)") + return + } + #expect(strategy.shouldFallback(on: error, context: context)) + } catch { + Issue.record("Expected AlibabaCodingPlanSettingsError, got \(error)") + } + } + + @Test + func `auto mode skips web when no alibaba session is available`() async { + let strategy = AlibabaCodingPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibaba: ProviderSettingsSnapshot.AlibabaCodingPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil, + apiRegion: .international)) + let context = self.makeContext( + sourceMode: .auto, + settings: settings, + env: [AlibabaCodingPlanSettingsReader.apiTokenKey: "token-abc"]) + + CookieHeaderCache.clear(provider: .alibaba) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie() + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + } + + #expect(await strategy.isAvailable(context) == false) + } +} + +struct AlibabaCodingPlanRegionTests { + @Test + func `defaults to international endpoint`() { + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: [:]) + #expect(url.host == "modelstudio.console.alibabacloud.com") + #expect(url.path == "/data/api.json") + } + + @Test + func `uses china mainland host`() { + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .chinaMainland, environment: [:]) + #expect(url.host == "bailian.console.aliyun.com") + } + + @Test + func `host override wins for quota URL`() { + let env = [AlibabaCodingPlanSettingsReader.hostKey: "custom.aliyun.com"] + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env) + #expect(url.host == "custom.aliyun.com") + #expect(url.path == "/data/api.json") + } + + @Test + func `host override uses selected region for quota URL`() { + let env = [AlibabaCodingPlanSettingsReader.hostKey: "custom.aliyun.com"] + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .chinaMainland, environment: env) + #expect(url.host == "custom.aliyun.com") + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let currentRegion = components?.queryItems?.first(where: { $0.name == "currentRegionId" })?.value + #expect(currentRegion == AlibabaCodingPlanAPIRegion.chinaMainland.currentRegionID) + } + + @Test + func `bare host override builds console dashboard URL`() { + let env = [AlibabaCodingPlanSettingsReader.hostKey: "custom.aliyun.com"] + let url = AlibabaCodingPlanUsageFetcher.resolveConsoleDashboardURL(region: .international, environment: env) + #expect(url.scheme == "https") + #expect(url.host == "custom.aliyun.com") + #expect(url.path == AlibabaCodingPlanAPIRegion.international.dashboardURL.path) + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let tab = components?.queryItems?.first(where: { $0.name == "tab" })?.value + #expect(tab == "coding-plan") + } + + @Test + func `quota url override beats host`() { + let env = [AlibabaCodingPlanSettingsReader.quotaURLKey: "https://example.com/custom/quota"] + let url = AlibabaCodingPlanUsageFetcher.resolveQuotaURL(region: .international, environment: env) + #expect(url.absoluteString == "https://example.com/custom/quota") + } +} + +@Suite(.serialized) +struct AlibabaCodingPlanUsageFetcherRequestTests { + @Test + func `api401 maps to invalid credentials`() async throws { + let registered = URLProtocol.registerClass(AlibabaUsageFetcherStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(AlibabaUsageFetcherStubURLProtocol.self) + } + AlibabaUsageFetcherStubURLProtocol.handler = nil + } + + AlibabaUsageFetcherStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + return Self.makeResponse(url: url, body: #"{"message":"unauthorized"}"#, statusCode: 401) + } + + await #expect(throws: AlibabaCodingPlanUsageError.invalidCredentials) { + _ = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + apiKey: "cpk-test", + region: .chinaMainland, + environment: [AlibabaCodingPlanSettingsReader.quotaURLKey: "https://alibaba-api.test/data/api.json"]) + } + } + + @Test + func `cookie SEC token fallback survives user info request failure`() async throws { + let registered = URLProtocol.registerClass(AlibabaConsoleSECTokenStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(AlibabaConsoleSECTokenStubURLProtocol.self) + } + AlibabaConsoleSECTokenStubURLProtocol.handler = nil + } + + AlibabaConsoleSECTokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "modelstudio.console.alibabacloud.com", request.httpMethod == "GET" { + return Self.makeResponse(url: url, body: "", statusCode: 200) + } + + if url.host == "modelstudio.console.alibabacloud.com", url.path == "/tool/user/info.json" { + throw URLError(.timedOut) + } + + if url.host == "bailian-singapore-cs.alibabacloud.com", request.httpMethod == "POST" { + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=cookie-sec-token")) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { "planName": "Alibaba Coding Plan Pro", "status": "VALID" } + ], + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 52, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000 + } + }, + "status_code": 0 + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + + let snapshot = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + cookieHeader: "sec_token=cookie-sec-token; login_aliyunid_ticket=ticket; login_aliyunid_pk=user", + region: .international, + environment: [:], + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.planName == "Alibaba Coding Plan Pro") + #expect(snapshot.fiveHourUsedQuota == 52) + #expect(snapshot.fiveHourTotalQuota == 1000) + } + + @Test + func `host override applies to user info SEC token fallback`() async throws { + let registered = URLProtocol.registerClass(AlibabaConsoleSECTokenStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(AlibabaConsoleSECTokenStubURLProtocol.self) + } + AlibabaConsoleSECTokenStubURLProtocol.handler = nil + } + + AlibabaConsoleSECTokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + #expect(url.host == "alibaba-proxy.test") + + if request.httpMethod == "GET", url.path == AlibabaCodingPlanAPIRegion.international.dashboardURL.path { + return Self.makeResponse(url: url, body: "", statusCode: 200) + } + + if request.httpMethod == "GET", url.path == "/tool/user/info.json" { + return Self.makeResponse( + url: url, + body: #"{"data":{"secToken":"override-sec-token"}}"#, + statusCode: 200) + } + + if request.httpMethod == "POST", url.path == "/data/api.json" { + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=override-sec-token")) + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { "planName": "Alibaba Coding Plan Pro", "status": "VALID" } + ], + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 21, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000 + } + }, + "status_code": 0 + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + + let snapshot = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + cookieHeader: "sec_token=cookie-sec-token; login_aliyunid_ticket=ticket; login_aliyunid_pk=user", + region: .international, + environment: [AlibabaCodingPlanSettingsReader.hostKey: "https://alibaba-proxy.test"], + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.planName == "Alibaba Coding Plan Pro") + #expect(snapshot.fiveHourUsedQuota == 21) + #expect(snapshot.fiveHourTotalQuota == 1000) + } + + @Test + func `console request body uses region specific metadata`() async throws { + let registered = URLProtocol.registerClass(AlibabaConsoleSECTokenStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(AlibabaConsoleSECTokenStubURLProtocol.self) + } + AlibabaConsoleSECTokenStubURLProtocol.handler = nil + } + + AlibabaConsoleSECTokenStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if request.httpMethod == "GET", url.path == AlibabaCodingPlanAPIRegion.chinaMainland.dashboardURL.path { + return Self.makeResponse(url: url, body: "", statusCode: 200) + } + + if request.httpMethod == "GET", url.path == "/tool/user/info.json" { + return Self.makeResponse(url: url, body: #"{"data":{"secToken":"cn-sec-token"}}"#, statusCode: 200) + } + + if request.httpMethod == "POST", url.path == "/data/api.json" { + let body = Self.requestBodyString(from: request) + let params = try #require(Self.requestParamsDictionary(from: body)) + let data = try #require(params["Data"] as? [String: Any]) + let cornerstone = try #require(data["cornerstoneParam"] as? [String: Any]) + #expect(cornerstone["domain"] as? String == AlibabaCodingPlanAPIRegion.chinaMainland.consoleDomain) + #expect(cornerstone["consoleSite"] as? String == AlibabaCodingPlanAPIRegion.chinaMainland.consoleSite) + #expect( + cornerstone["feURL"] as? String + == AlibabaCodingPlanAPIRegion.chinaMainland.dashboardURL.absoluteString) + + let json = """ + { + "data": { + "codingPlanInstanceInfos": [ + { "planName": "Alibaba Coding Plan Pro", "status": "VALID" } + ], + "codingPlanQuotaInfo": { + "per5HourUsedQuota": 21, + "per5HourTotalQuota": 1000, + "per5HourQuotaNextRefreshTime": 1700000300000 + } + }, + "status_code": 0 + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + + let snapshot = try await AlibabaCodingPlanUsageFetcher.fetchUsage( + cookieHeader: "sec_token=cookie-sec-token; login_aliyunid_ticket=ticket; login_aliyunid_pk=user", + region: .chinaMainland, + environment: [:], + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.planName == "Alibaba Coding Plan Pro") + #expect(snapshot.fiveHourUsedQuota == 21) + #expect(snapshot.fiveHourTotalQuota == 1000) + } + + private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func requestBodyString(from request: URLRequest) -> String { + if let data = request.httpBody { + return String(data: data, encoding: .utf8) ?? "" + } + + guard let stream = request.httpBodyStream else { + return "" + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let count = stream.read(buffer, maxLength: bufferSize) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + + return String(data: data, encoding: .utf8) ?? "" + } + + private static func requestParamsDictionary(from body: String) -> [String: Any]? { + guard let components = URLComponents(string: "https://example.invalid/?\(body)"), + let params = components.queryItems?.first(where: { $0.name == "params" })?.value, + let data = params.data(using: .utf8) + else { + return nil + } + + let object = try? JSONSerialization.jsonObject(with: data, options: []) + return object as? [String: Any] + } +} + +final class AlibabaUsageFetcherStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "alibaba-api.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} + +final class AlibabaConsoleSECTokenStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return [ + "alibaba-proxy.test", + "modelstudio.console.alibabacloud.com", + "bailian-singapore-cs.alibabacloud.com", + "bailian.console.aliyun.com", + "bailian-cs.console.aliyun.com", + "bailian-beijing-cs.aliyuncs.com", + ].contains(host) + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift new file mode 100644 index 000000000..4916dd0e5 --- /dev/null +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -0,0 +1,721 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AlibabaTokenPlanSettingsReaderTests { + @Test + func `cookie reads from environment`() { + let cookie = AlibabaTokenPlanSettingsReader.cookieHeader(environment: [ + AlibabaTokenPlanSettingsReader.cookieHeaderKey: "\"login_aliyunid_ticket=ticket\"", + ]) + #expect(cookie == "login_aliyunid_ticket=ticket") + } + + @Test + func `quota URL infers HTTPS scheme`() { + let url = AlibabaTokenPlanSettingsReader.quotaURL(environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "quota.token-plan.test/data/api.json", + ]) + + #expect(url?.scheme == "https") + #expect(url?.host == "quota.token-plan.test") + } + + @Test + func `quota URL rejects non HTTPS schemes`() { + let httpURL = AlibabaTokenPlanSettingsReader.quotaURL(environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "http://quota.token-plan.test/data/api.json", + ]) + let ftpURL = AlibabaTokenPlanSettingsReader.quotaURL(environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "ftp://quota.token-plan.test/data/api.json", + ]) + + #expect(httpURL == nil) + #expect(ftpURL == nil) + } + + @Test + func `host override rejects non HTTPS schemes`() { + let httpHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [ + AlibabaTokenPlanSettingsReader.hostKey: "http://dashboard.token-plan.test", + ]) + let httpsHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [ + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ]) + let bareHost = AlibabaTokenPlanSettingsReader.hostOverride(environment: [ + AlibabaTokenPlanSettingsReader.hostKey: "dashboard.token-plan.test", + ]) + + #expect(httpHost == nil) + #expect(httpsHost == "https://dashboard.token-plan.test") + #expect(bareHost == "dashboard.token-plan.test") + } + + @Test + func `default quota URL targets subscription summary API`() { + let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL + #expect(url.host == "bailian.console.aliyun.com") + #expect(url.absoluteString.contains("GetSubscriptionSummary")) + #expect(url.absoluteString.contains("BssOpenAPI-V3")) + } +} + +struct AlibabaTokenPlanCookieHeaderTests { + @Test + func `builds URL scoped headers for API and dashboard`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), + self.cookie(name: "sec_token", value: "shared", domain: ".console.aliyun.com"), + self.cookie(name: "sec_token", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.alibabacloud.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies)) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("login_current_pk=account")) + #expect(headers.apiCookieHeader.contains("sec_token=dashboard")) + #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) + #expect(headers.dashboardCookieHeader.contains("sec_token=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) + } + + @Test + func `cached token plan headers preserve URL scoping`() throws { + let headers = AlibabaTokenPlanCookieHeaders( + apiCookieHeader: "login_aliyunid_ticket=ticket; api_only=api", + dashboardCookieHeader: "login_aliyunid_ticket=ticket; dashboard_only=dashboard") + + let cached = try #require(AlibabaTokenPlanCookieHeaders(cachedHeader: headers.cacheCookieHeader)) + + #expect(cached.apiCookieHeader.contains("api_only=api")) + #expect(!cached.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(cached.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!cached.dashboardCookieHeader.contains("api_only=api")) + } + + @Test + func `builds headers from environment scoped URLs`() throws { + let cookies = [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), + self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"), + self.cookie(name: "prod_dashboard_only", value: "prod-dashboard", domain: "bailian.console.aliyun.com"), + ] + + let headers = try #require(AlibabaTokenPlanCookieHeader.headers( + from: cookies, + environment: [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test/data/api.json", + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ])) + + #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) + #expect(headers.apiCookieHeader.contains("api_only=api")) + #expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard")) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } +} + +struct AlibabaTokenPlanUsageSnapshotTests { + @Test + func `maps used and total quota to primary window`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let reset = Date(timeIntervalSince1970: 1_700_100_000) + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: 250, + totalQuota: 1000, + remainingQuota: nil, + resetsAt: reset, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.resetsAt == reset) + #expect(usage.primary?.resetDescription == "250 / 1,000 credits used") + #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") + } + + @Test + func `does not create primary window from balance only`() { + let snapshot = AlibabaTokenPlanUsageSnapshot( + planName: "TOKEN PLAN", + usedQuota: nil, + totalQuota: nil, + remainingQuota: 700, + resetsAt: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.loginMethod(for: .alibabatokenplan) == "TOKEN PLAN") + } +} + +@Suite(.serialized) +struct AlibabaTokenPlanUsageParsingTests { + @Test + func `parses subscription summary payload`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 875, + "NearestExpireDate": 1701000000000 + }, + "Code": "200" + } + """ + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8), now: now) + + #expect(snapshot.planName == "TOKEN PLAN") + #expect(snapshot.usedQuota == 125) + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.remainingQuota == 875) + #expect(snapshot.resetsAt == Date(timeIntervalSince1970: 1_701_000_000)) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5) + } + + @Test + func `parses nested subscription summary body`() throws { + let body = """ + { + "success": true, + "data": { + "totalCount": 1, + "totalSurplusValue": 750, + "totalValue": 1000 + } + } + """ + let payload = ["successResponse": ["body": body]] + let data = try JSONSerialization.data(withJSONObject: payload) + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: data) + + #expect(snapshot.planName == "TOKEN PLAN") + #expect(snapshot.usedQuota == 250) + #expect(snapshot.remainingQuota == 750) + #expect(snapshot.totalQuota == 1000) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 25) + } + + @Test + func `empty subscription summary stays visible without quota window`() throws { + let json = """ + { + "Success": true, + "Data": { + "TotalCount": 0 + } + } + """ + + let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + #expect(snapshot.planName == nil) + #expect(snapshot.totalQuota == nil) + #expect(snapshot.toUsageSnapshot().primary == nil) + } + + @Test + func `login payload maps to login required`() { + let json = """ + { + "code": "ConsoleNeedLogin", + "message": "You need to log in.", + "successResponse": false + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `nested unsuccessful subscription summary maps to API error`() throws { + let body = """ + { + "success": false, + "message": "Subscription lookup failed" + } + """ + let payload = ["successResponse": ["body": body]] + let data = try JSONSerialization.data(withJSONObject: payload) + + #expect(throws: AlibabaTokenPlanUsageError.apiError("Subscription lookup failed")) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: data) + } + } + + @Test + func `forbidden payload maps to invalid credentials`() { + let json = """ + { + "statusCode": 403, + "message": "Forbidden" + } + """ + + #expect(throws: AlibabaTokenPlanUsageError.invalidCredentials) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + } + } + + @Test + func `html login payload maps to login required`() { + let html = """ + + Please login to Alibaba Cloud + + """ + + #expect(throws: AlibabaTokenPlanUsageError.loginRequired) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(html.utf8)) + } + } + + @Test + func `non json payload maps to parse failed`() { + #expect(throws: AlibabaTokenPlanUsageError.parseFailed("Invalid JSON response")) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data("not-json".utf8)) + } + } + + @Test + func `cookie only request continues without SEC token`() async throws { + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "alibaba-token-plan.test", request.httpMethod == "GET" { + return Self.makeResponse(url: url, body: "", statusCode: 200) + } + + if url.host == "alibaba-token-plan.test", request.httpMethod == "POST" { + #expect(request.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket; raw_only=keep") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://bailian.console.aliyun.com") + #expect(request.value(forHTTPHeaderField: "Referer") == AlibabaTokenPlanUsageFetcher.dashboardURL + .absoluteString) + let body = Self.requestBodyString(from: request) + #expect(!body.contains("sec_token=")) + #expect(body.contains("GetSubscriptionSummary")) + #expect(body.contains("BssOpenAPI-V3")) + #expect(body.contains("ProductCode")) + #expect(body.contains("sfm_tokenplanteams_dp_cn")) + let json = """ + { + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 900 + } + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", + dashboardCookieHeader: "login_aliyunid_ticket=ticket; raw_only=keep", + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://alibaba-token-plan.test"], + session: session) + + #expect(snapshot.planName == "TOKEN PLAN") + } + + @Test + func `SEC token preflight uses injected session`() async throws { + AlibabaTokenPlanStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + + if url.host == "session-token.test", request.httpMethod == "GET" { + return Self.makeResponse( + url: url, + body: "", + statusCode: 200) + } + + if url.host == "session-token.test", request.httpMethod == "POST" { + let body = Self.requestBodyString(from: request) + #expect(body.contains("sec_token=session-html-token")) + let json = """ + { + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 900 + } + } + """ + return Self.makeResponse(url: url, body: json, statusCode: 200) + } + + throw URLError(.unsupportedURL) + } + defer { + AlibabaTokenPlanStubURLProtocol.handler = nil + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + let session = URLSession(configuration: configuration) + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "login_aliyunid_ticket=ticket", + dashboardCookieHeader: "login_aliyunid_ticket=ticket", + environment: [AlibabaTokenPlanSettingsReader.hostKey: "https://session-token.test"], + session: session) + + #expect(snapshot.planName == "TOKEN PLAN") + } + + @Test + func `redirect preserves cookie only for same host HTTPS requests`() throws { + let sourceURL = try #require(URL(string: "https://bailian.console.aliyun.com/data/api.json")) + let sameHostURL = try #require(URL(string: "https://bailian.console.aliyun.com/redirected")) + let crossHostURL = try #require(URL(string: "https://signin.aliyun.com/login")) + let insecureURL = try #require(URL(string: "http://bailian.console.aliyun.com/redirected")) + let response = try #require(HTTPURLResponse( + url: sourceURL, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: nil)) + + var sameHostRequest = URLRequest(url: sameHostURL) + sameHostRequest.setValue("old=value", forHTTPHeaderField: "Cookie") + let sameHostRedirect = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: sameHostRequest, + cookieHeader: "login_aliyunid_ticket=ticket")) + #expect(sameHostRedirect.value(forHTTPHeaderField: "Cookie") == "login_aliyunid_ticket=ticket") + + var crossHostRequest = URLRequest(url: crossHostURL) + crossHostRequest.setValue("old=value", forHTTPHeaderField: "Cookie") + let crossHostRedirect = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: crossHostRequest, + cookieHeader: "login_aliyunid_ticket=ticket")) + #expect(crossHostRedirect.value(forHTTPHeaderField: "Cookie") == nil) + + let insecureRedirect = AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: URLRequest(url: insecureURL), + cookieHeader: "login_aliyunid_ticket=ticket") + #expect(insecureRedirect == nil) + } + + @Test + func `dashboard redirect preserves dashboard cookie header`() throws { + let sourceURL = try #require(URL(string: "https://bailian.console.aliyun.com/cn-beijing")) + let targetURL = try #require(URL(string: "https://bailian.console.aliyun.com/redirected")) + let response = try #require(HTTPURLResponse( + url: sourceURL, + statusCode: 302, + httpVersion: "HTTP/1.1", + headerFields: nil)) + var request = URLRequest(url: targetURL) + request.setValue("api_only=wrong", forHTTPHeaderField: "Cookie") + + let redirected = try #require(AlibabaTokenPlanUsageFetcher.redirectedRequest( + response: response, + request: request, + cookieHeader: "dashboard_only=keep")) + + #expect(redirected.value(forHTTPHeaderField: "Cookie") == "dashboard_only=keep") + } + + private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + private static func requestBodyString(from request: URLRequest) -> String { + if let data = request.httpBody { + return String(data: data, encoding: .utf8) ?? "" + } + if let stream = request.httpBodyStream { + stream.open() + defer { + stream.close() + } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + return String(data: data, encoding: .utf8) ?? "" + } + return "" + } +} + +@Suite(.serialized) +struct AlibabaTokenPlanWebStrategyTests { + 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 + } + } + + @Test + func `auto web strategy surfaces cookie import errors`() async throws { + let strategy = AlibabaTokenPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + CookieHeaderCache.clear(provider: .alibabatokenplan) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie( + details: "macOS Keychain denied access to Chrome Safe Storage.") + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + } + + #expect(await strategy.isAvailable(context)) + + do { + _ = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeader(context: context, allowCached: false) + Issue.record("Expected cookie import failure to be surfaced") + } catch let error as AlibabaTokenPlanSettingsError { + guard case let .missingCookie(details) = error else { + Issue.record("Expected missingCookie, got \(error)") + return + } + #expect(details == "macOS Keychain denied access to Chrome Safe Storage.") + #expect(error.localizedDescription.contains("Alibaba Token Plan")) + #expect(!error.localizedDescription.contains("Alibaba Coding Plan")) + } + } + + @Test + func `auto web strategy imports subscription scoped token plan cookies`() throws { + let strategy = AlibabaTokenPlanWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + CookieHeaderCache.clear(provider: .alibabatokenplan) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), + self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "bailian.console.aliyun.com"), + self.cookie( + name: "modelstudio_only", + value: "modelstudio", + domain: "modelstudio.console.alibabacloud.com"), + self.cookie(name: "alibabacloud_only", value: "cloud", domain: ".alibabacloud.com"), + ], + sourceLabel: "Chrome Default") + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + CookieHeaderCache.clear(provider: .alibabatokenplan) + } + + let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) + + #expect(headers.apiCookieHeader == headers.dashboardCookieHeader) + #expect(headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) + #expect(!headers.apiCookieHeader.contains("alibabacloud_only=cloud")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) + #expect(!headers.dashboardCookieHeader.contains("alibabacloud_only=cloud")) + + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + throw AlibabaCodingPlanSettingsError.missingCookie(details: "unexpected import") + } + let cachedHeaders = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders( + context: context, + allowCached: true) + #expect(cachedHeaders.apiCookieHeader == headers.apiCookieHeader) + #expect(cachedHeaders.dashboardCookieHeader == headers.dashboardCookieHeader) + #expect(strategy.id == "alibaba-token-plan.web") + } + + @Test + func `auto web strategy scopes imported cookies to environment overrides`() throws { + let settings = ProviderSettingsSnapshot.make( + alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let environment = [ + AlibabaTokenPlanSettingsReader.quotaURLKey: "https://quota.token-plan.test/data/api.json", + AlibabaTokenPlanSettingsReader.hostKey: "https://dashboard.token-plan.test", + ] + let context = ProviderFetchContext( + runtime: .cli, + sourceMode: .web, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: settings, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + CookieHeaderCache.clear(provider: .alibabatokenplan) + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in + AlibabaCodingPlanCookieImporter.SessionInfo( + cookies: [ + self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), + self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), + self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"), + self.cookie( + name: "prod_dashboard_only", + value: "prod-dashboard", + domain: "bailian.console.aliyun.com"), + ], + sourceLabel: "Chrome Default") + } + defer { + AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = nil + CookieHeaderCache.clear(provider: .alibabatokenplan) + } + + let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) + + #expect(headers.apiCookieHeader.contains("api_only=api")) + #expect(!headers.apiCookieHeader.contains("prod_api_only=prod-api")) + #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.dashboardCookieHeader.contains("prod_dashboard_only=prod-dashboard")) + } + + private func cookie( + name: String, + value: String, + domain: String, + path: String = "/", + expires: Date = Date(timeIntervalSinceNow: 3600)) -> HTTPCookie + { + HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: name, + .value: value, + .expires: expires, + .secure: true, + ])! + } +} + +final class AlibabaTokenPlanStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + guard let host = request.url?.host else { return false } + return host == "bailian.console.aliyun.com" || + host == "alibaba-token-plan.test" || + host == "session-token.test" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift index 8a4e8506c..135f58ee4 100644 --- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift @@ -2,24 +2,23 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct AmpUsageFetcherTests { @Test - func attachesCookieForAmpHosts() { + func `attaches cookie for amp hosts`() { #expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com/settings"))) #expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://www.ampcode.com"))) #expect(AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://app.ampcode.com/path"))) } @Test - func rejectsNonAmpHosts() { + func `rejects non amp hosts`() { #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://example.com"))) #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "https://ampcode.com.evil.com"))) #expect(!AmpUsageFetcher.shouldAttachCookie(to: nil)) } @Test - func detectsLoginRedirects() throws { + func `detects login redirects`() throws { let signIn = try #require(URL(string: "https://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) #expect(AmpUsageFetcher.isLoginRedirect(signIn)) @@ -34,7 +33,7 @@ struct AmpUsageFetcherTests { } @Test - func ignoresNonLoginURLs() throws { + func `ignores non login UR ls`() throws { let settings = try #require(URL(string: "https://ampcode.com/settings")) #expect(!AmpUsageFetcher.isLoginRedirect(settings)) diff --git a/Tests/CodexBarTests/AmpUsageParserTests.swift b/Tests/CodexBarTests/AmpUsageParserTests.swift index 89ab59bdc..380b06e49 100644 --- a/Tests/CodexBarTests/AmpUsageParserTests.swift +++ b/Tests/CodexBarTests/AmpUsageParserTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct AmpUsageParserTests { @Test - func parsesFreeTierUsageFromSettingsHTML() throws { + func `parses free tier usage from settings HTML`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """ + +
Coding Plan
+ + """ + + #expect(!MiniMaxUsageFetcher._looksSignedOutForTesting(html: html)) + } + + @Test + func `signed out check still detects visible login copy`() { + let html = """ + + +
Log in
+ + """ + + #expect(MiniMaxUsageFetcher._looksSignedOutForTesting(html: html)) + } + + @Test + func `parses planName from concrete fields in remains response`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + + // 1. plan_name + let jsonPlanName = """ + { + "base_resp": { "status_code": 0 }, + "plan_name": "MiniMax Star", + "model_remains": [{"model_name": "abab6.5"}] + } + """ + let snapshot1 = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(jsonPlanName.utf8), now: now) + #expect(snapshot1.planName == "MiniMax Star") + + // 2. current_plan_title + let jsonCurrentPlan = """ + { + "base_resp": { "status_code": 0 }, + "current_plan_title": "Coding Plan Pro", + "model_remains": [{"model_name": "abab6.5"}] + } + """ + let snapshot2 = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(jsonCurrentPlan.utf8), now: now) + #expect(snapshot2.planName == "Coding Plan Pro") + + // 3. current_subscribe_title + let jsonSubscribe = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [{"model_name": "abab6.5"}] + } + """ + let snapshot3 = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(jsonSubscribe.utf8), now: now) + #expect(snapshot3.planName == "Max") + + // 4. combo_title + let jsonCombo = """ + { + "base_resp": { "status_code": 0 }, + "combo_title": "Combo Star", + "model_remains": [{"model_name": "abab6.5"}] + } + """ + let snapshot4 = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(jsonCombo.utf8), now: now) + #expect(snapshot4.planName == "Combo Star") + + // 5. current_combo_card.title + let jsonComboCard = """ + { + "base_resp": { "status_code": 0 }, + "current_combo_card": { "title": "Card Title" }, + "model_remains": [{"model_name": "abab6.5"}] + } + """ + let snapshot5 = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(jsonComboCard.utf8), now: now) + #expect(snapshot5.planName == "Card Title") + } + + @Test + func `toUsageSnapshot maps planName to loginMethod`() { + let now = Date() + let snapshot1 = MiniMaxUsageSnapshot( + planName: "MiniMax Star", + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now) + let usage1 = snapshot1.toUsageSnapshot() + #expect(usage1.identity?.loginMethod == "MiniMax Star") + + let snapshot2 = MiniMaxUsageSnapshot( + planName: nil, + availablePrompts: nil, + currentPrompts: nil, + remainingPrompts: nil, + windowMinutes: nil, + usedPercent: nil, + resetsAt: nil, + updatedAt: now) + let usage2 = snapshot2.toUsageSnapshot() + #expect(usage2.identity?.loginMethod == nil) + } + + @Test + func `parses coding plan snapshot`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """
Coding Plan
@@ -85,7 +283,7 @@ struct MiniMaxUsageParserTests { } @Test - func parsesCodingPlanRemainsResponse() throws { + func `parses coding plan remains response`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let start = 1_700_000_000_000 let end = start + 5 * 60 * 60 * 1000 @@ -118,7 +316,159 @@ struct MiniMaxUsageParserTests { } @Test - func parsesCodingPlanRemainsFromDataWrapper() throws { + func `parses model remains services using used quota semantics`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "current_subscribe_title": "Max", + "model_remains": [ + { + "model_name": "M2.7-highspeed", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "remains_time": 240000 + } + ] + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let service = try #require(snapshot.services?.first) + + #expect(service.displayName == "Text Generation") + #expect(service.usage == 750) + #expect(service.remaining == 250) + #expect(service.limit == 1000) + #expect(service.percent == 75) + #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 75) + } + + @Test + func `text generation includes weekly window when provided`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let weekStart = start - 2 * 24 * 60 * 60 * 1000 + let weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "current_weekly_total_count": 6000, + "current_weekly_usage_count": 5376, + "weekly_start_time": \(weekStart), + "weekly_end_time": \(weekEnd) + } + ] + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + #expect(services.count == 2) + #expect(services[0].serviceType == "Text Generation") + #expect(services[0].windowType == "5 hours") + #expect(services[1].serviceType == "Text Generation") + #expect(services[1].windowType == "Weekly") + #expect(services[1].usage == 624) + #expect(services[1].limit == 6000) + #expect(services[1].timeRange.contains("/")) + #expect(services[1].timeRange.contains("UTC+8")) + #expect(!services[1].timeRange.hasPrefix("10:00-10:00")) + } + + @Test + func `legacy plan hides weekly when weekly total is missing or zero`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let start = 1_700_000_000_000 + let end = start + 5 * 60 * 60 * 1000 + let json = """ + { + "base_resp": { "status_code": 0 }, + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 1000, + "current_interval_usage_count": 250, + "start_time": \(start), + "end_time": \(end), + "current_weekly_total_count": 0, + "current_weekly_usage_count": 0 + } + ] + } + """ + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + #expect(services.count == 1) + #expect(services[0].windowType == "5 hours") + } + + @Test + func `parses multi service payload and utc offset reset`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 8 * 3600)) + let now = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 3, + day: 25, + hour: 11, + minute: 0))) + let expectedReset = try #require(calendar.date(from: DateComponents( + year: 2026, + month: 3, + day: 25, + hour: 15, + minute: 0))) + let json = """ + { + "data": { + "services": [ + { + "service_type": "Text Generation", + "window_type": "5 hours", + "time_range": "10:00-15:00(UTC+8)", + "usage": 2, + "limit": 10 + }, + { + "service_type": "Image", + "window_type": "Today", + "time_range": "2026/03/25 00:00 - 2026/03/26 00:00", + "usage": "5", + "limit": "50", + "percent": "10" + } + ] + } + } + """ + + let snapshot = try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8), now: now) + let services = try #require(snapshot.services) + + #expect(services.count == 2) + #expect(services[0].usage == 2) + #expect(services[0].remaining == 8) + #expect(services[0].percent == 20) + #expect(services[0].resetsAt == expectedReset) + #expect(services[1].usage == 5) + #expect(services[1].remaining == 45) + #expect(services[1].percent == 10) + } + + @Test + func `parses coding plan remains from data wrapper`() throws { let now = Date(timeIntervalSince1970: 1_700_000_100) let start = 1_700_000_000_000 let end = start + 5 * 60 * 60 * 1000 @@ -154,7 +504,7 @@ struct MiniMaxUsageParserTests { } @Test - func parsesCodingPlanFromNextData() throws { + func `parses coding plan from next data`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let start = 1_700_000_000_000 let end = start + 5 * 60 * 60 * 1000 @@ -198,7 +548,7 @@ struct MiniMaxUsageParserTests { } @Test - func parsesHTMLWithUsedPrefixAndResetTime() throws { + func `parses HTML with used prefix and reset time`() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "UTC") ?? .current let now = try #require(calendar.date(from: DateComponents(year: 2025, month: 1, day: 1, hour: 10, minute: 0))) @@ -226,7 +576,7 @@ struct MiniMaxUsageParserTests { } @Test - func throwsOnMissingCookieResponse() { + func `throws on missing cookie response`() { let json = """ { "base_resp": { "status_code": 1004, "status_msg": "cookie is missing, log in again" } @@ -239,7 +589,7 @@ struct MiniMaxUsageParserTests { } @Test - func throwsOnStringStatusCodeWhenLoggedOut() { + func `throws on string status code when logged out`() { let json = """ { "base_resp": { "status_code": "1004", "status_msg": "login required" } @@ -252,7 +602,7 @@ struct MiniMaxUsageParserTests { } @Test - func throwsOnErrorInDataWrapper() { + func `throws on error in data wrapper`() { let json = """ { "data": { @@ -265,12 +615,385 @@ struct MiniMaxUsageParserTests { try MiniMaxUsageParser.parseCodingPlanRemains(data: Data(json.utf8)) } } + + @Test + func `billing history aggregates records locally`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let json = """ + { + "base_resp": { "status_code": 0 }, + "consume_token_sum": 999999, + "total_cnt": 4, + "charge_records": [ + { + "consume_token": 1000, + "consume_cash_after_voucher": 1.25, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1" + }, + { + "consume_token": "2000", + "consume_cash": "2.50", + "ymd": "2026-05-16", + "method": "chat", + "model": "MiniMax-M2" + }, + { + "consume_input_token": 1200, + "consume_output_token": 1800, + "ymd": "2026-04-18", + "method": "audio", + "model": "speech-2.8" + }, + { + "consume_token": 4000, + "ymd": "2026-04-17", + "method": "old", + "model": "ignored" + } + ] + } + """ + + let summary = try MiniMaxBillingHistoryParser.parse( + data: Data(json.utf8), + now: now, + calendar: calendar) + + #expect(summary.todayTokens == 1000) + #expect(summary.last30DaysTokens == 6000) + #expect(summary.todayCash == 1.25) + #expect(summary.last30DaysCash == 3.75) + #expect(summary.daily.map(\.day) == ["2026-04-18", "2026-05-16", "2026-05-17"]) + #expect(summary.topMethods.first?.name == "audio") + #expect(summary.topModels.first?.name == "speech-2.8") + } + + @Test + func `billing history preserves date only days in local calendar`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: -7 * 60 * 60)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let json = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 1, + "charge_records": [ + { + "consume_token": 1234, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1" + } + ] + } + """ + + let summary = try MiniMaxBillingHistoryParser.parse( + data: Data(json.utf8), + now: now, + calendar: calendar) + + #expect(summary.todayTokens == 1234) + #expect(summary.daily.map(\.day) == ["2026-05-17"]) + } + + @Test + func `billing history filters failed records`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let json = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 5, + "charge_records": [ + { + "consume_token": 1000, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1", + "result": "SUCCESS" + }, + { + "consume_token": 2000, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1", + "result": "FAILED" + }, + { + "consume_token": 3000, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1", + "status": "fail" + }, + { + "consume_token": 4000, + "ymd": "2026-05-17", + "method": "audio", + "model": "speech-2.8" + }, + { + "consume_token": 5000, + "ymd": "2026-05-17", + "method": "video", + "model": "video-1", + "status": 0 + } + ] + } + """ + + let summary = try MiniMaxBillingHistoryParser.parse( + data: Data(json.utf8), + now: now, + calendar: calendar) + + // Only SUCCESS (1000) and missing/empty result status (4000) should be included. + // FAILED (2000), status "fail" (3000), and numeric status 0 (5000) should be skipped. + #expect(summary.todayTokens == 5000) + #expect(summary.last30DaysTokens == 5000) + #expect(summary.daily.map(\.day) == ["2026-05-17"]) + + // Top methods should aggregate only SUCCESS/missing records. + #expect(summary.topMethods.count == 2) + #expect(summary.topMethods[0].name == "audio") + #expect(summary.topMethods[0].tokens == 4000) + #expect(summary.topMethods[1].name == "chat") + #expect(summary.topMethods[1].tokens == 1000) + } + + @Test + func `web usage fetch attaches billing history when available`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + #expect(url.path == "/account/amount") + #expect(url.query?.contains("aggregate=false") == true) + #expect(request.value(forHTTPHeaderField: "Cookie") == "HERTZ-SESSION=abc") + let body = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 1, + "charge_records": [ + { + "consume_token": 1234, + "ymd": "2026-05-17", + "method": "chat", + "model": "MiniMax-M1" + } + ] + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + + #expect(snapshot.currentPrompts == 2) + #expect(snapshot.billingSummary?.todayTokens == 1234) + #expect(snapshot.billingSummary?.last30DaysTokens == 1234) + } + + @Test + func `web usage fetch keeps paginating billing history until 30 day cutoff`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + let page = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first { $0.name == "page" }? + .value ?? "1" + let recordDay = page == "3" ? "2026-04-17" : "2026-05-17" + let records = (0..<100) + .map { _ in + """ + {"consume_token":1,"ymd":"\(recordDay)","method":"chat","model":"MiniMax-M1"} + """ + } + .joined(separator: ",") + let body = """ + { + "base_resp": { "status_code": 0 }, + "total_cnt": 250, + "charge_records": [\(records)] + } + """ + return Self.httpResponse(url: url, body: body, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + + let billingRequests = await transport.requests().filter { $0.url?.path == "/account/amount" } + #expect(billingRequests.count == 3) + #expect(snapshot.billingSummary?.last30DaysTokens == 200) + } + + @Test + func `web usage fetch skips billing history when optional usage is disabled`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + #expect(url.path.contains("coding-plan")) + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + includeBillingHistory: false, + session: transport, + now: now) + + let requests = await transport.requests() + #expect(snapshot.currentPrompts == 2) + #expect(snapshot.billingSummary == nil) + #expect(requests.count == 1) + } + + @Test + func `web usage fetch keeps quota when billing history is forbidden`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + return Self.httpResponse(url: url, body: "{}", statusCode: 403, contentType: "application/json") + } + + let snapshot = try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + + #expect(snapshot.currentPrompts == 2) + #expect(snapshot.billingSummary == nil) + } + + @Test + func `web usage fetch preserves stale bearer failure during billing history`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer stale") + return Self.httpResponse(url: url, body: "{}", statusCode: 403, contentType: "application/json") + } + + await #expect(throws: MiniMaxUsageError.invalidCredentials) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + authorizationToken: "stale", + region: .global, + environment: [:], + session: transport, + now: now) + } + } + + @Test + func `web usage fetch preserves billing history cancellation`() async throws { + let now = try #require(ISO8601DateFormatter().date(from: "2026-05-17T12:00:00Z")) + let transport = ProviderHTTPTransportStub { request in + let url = try #require(request.url) + if url.path.contains("coding-plan") { + return Self.httpResponse( + url: url, + body: Self.codingPlanJSON, + contentType: "application/json") + } + throw CancellationError() + } + + await #expect(throws: CancellationError.self) { + try await MiniMaxUsageFetcher.fetchUsage( + cookieHeader: "HERTZ-SESSION=abc", + region: .global, + environment: [:], + session: transport, + now: now) + } + } + + private static let codingPlanJSON = """ + { + "base_resp": { "status_code": 0 }, + "data": { + "plan_name": "Max", + "model_remains": [ + { + "model_name": "MiniMax-M1", + "current_interval_total_count": 10, + "current_interval_usage_count": 8, + "start_time": 1779019200, + "end_time": 1779037200, + "remains_time": 3600 + } + ] + } + } + """ + + private static func httpResponse( + url: URL, + body: String, + statusCode: Int = 200, + contentType: String) -> (Data, URLResponse) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (Data(body.utf8), response) + } } -@Suite struct MiniMaxAPIRegionTests { @Test - func defaultsToGlobalHosts() { + func `defaults to global hosts`() { let codingPlan = MiniMaxUsageFetcher.resolveCodingPlanURL(region: .global, environment: [:]) let remains = MiniMaxUsageFetcher.resolveRemainsURL(region: .global, environment: [:]) #expect(codingPlan.host == "platform.minimax.io") @@ -278,7 +1001,7 @@ struct MiniMaxAPIRegionTests { } @Test - func usesChinaMainlandHosts() { + func `uses china mainland hosts`() { let codingPlan = MiniMaxUsageFetcher.resolveCodingPlanURL(region: .chinaMainland, environment: [:]) let remains = MiniMaxUsageFetcher.resolveRemainsURL(region: .chinaMainland, environment: [:]) #expect(codingPlan.host == "platform.minimaxi.com") @@ -287,7 +1010,7 @@ struct MiniMaxAPIRegionTests { } @Test - func hostOverrideWinsForRemainsAndCodingPlan() { + func `host override wins for remains and coding plan`() { let env = [MiniMaxSettingsReader.hostKey: "api.minimaxi.com"] let codingPlan = MiniMaxUsageFetcher.resolveCodingPlanURL(region: .global, environment: env) let remains = MiniMaxUsageFetcher.resolveRemainsURL(region: .global, environment: env) @@ -296,14 +1019,24 @@ struct MiniMaxAPIRegionTests { } @Test - func remainsUrlOverrideBeatsHost() { + func `billing history url uses account amount endpoint`() { + let url = MiniMaxUsageFetcher.resolveBillingHistoryURL(region: .chinaMainland, environment: [:], page: 2) + #expect(url.host == "platform.minimaxi.com") + #expect(url.path == "/account/amount") + #expect(url.query?.contains("page=2") == true) + #expect(url.query?.contains("limit=100") == true) + #expect(url.query?.contains("aggregate=false") == true) + } + + @Test + func `remains url override beats host`() { let env = [MiniMaxSettingsReader.remainsURLKey: "https://platform.minimaxi.com/custom/remains"] let remains = MiniMaxUsageFetcher.resolveRemainsURL(region: .global, environment: env) #expect(remains.absoluteString == "https://platform.minimaxi.com/custom/remains") } @Test - func originUsesCodingPlanOverrideHost() { + func `origin uses coding plan override host`() { let env = [MiniMaxSettingsReader.codingPlanURLKey: "https://api.minimaxi.com/custom/path?cycle_type=3"] let codingPlan = MiniMaxUsageFetcher.resolveCodingPlanURL(region: .global, environment: env) let origin = MiniMaxUsageFetcher.originURL(from: codingPlan) @@ -311,7 +1044,7 @@ struct MiniMaxAPIRegionTests { } @Test - func originStripsHostOverridePath() { + func `origin strips host override path`() { let env = [MiniMaxSettingsReader.hostKey: "https://api.minimaxi.com/custom/path"] let codingPlan = MiniMaxUsageFetcher.resolveCodingPlanURL(region: .global, environment: env) let origin = MiniMaxUsageFetcher.originURL(from: codingPlan) diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift new file mode 100644 index 000000000..b38017536 --- /dev/null +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -0,0 +1,400 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct MistralUsageParserTests { + // swiftlint:disable line_length + + private static let novemberResponseJSON = """ + {"completion":{"models":{"mistral-large-latest::mistral-large-2411":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"input","timestamp":"2025-11-14","value":11121,"value_paid":11121}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_display_name":"mistral-large-latest","billing_group":"output","timestamp":"2025-11-14","value":1115,"value_paid":1115}]},"mistral-small-latest::mistral-small-2506":{"input":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-14","value":20,"value_paid":20},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"input","timestamp":"2025-11-24","value":100,"value_paid":100}],"output":[{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-14","value":500,"value_paid":500},{"usage_type":"usage","event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_display_name":"mistral-small-latest","billing_group":"output","timestamp":"2025-11-24","value":2482,"value_paid":2482}]}}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2025-11-01T00:00:00Z","previous_month":"2025-10","next_month":"2025-12","start_date":"2025-11-01T00:00:00Z","end_date":"2025-11-30T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"input","price":"0.0000017000"},{"event_type":"api_tokens","billing_metric":"mistral-large-2411","billing_group":"output","price":"0.0000051000"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"input","price":"8.50E-8"},{"event_type":"api_tokens","billing_metric":"mistral-small-2506","billing_group":"output","price":"2.550E-7"}]} + """ + + private static let emptyResponseJSON = """ + {"completion":{"models":{}},"ocr":{"models":{}},"connectors":{"models":{}},"libraries_api":{"pages":{"models":{}},"tokens":{"models":{}}},"fine_tuning":{"training":{},"storage":{}},"audio":{"models":{}},"vibe_usage":0.0,"date":"2026-02-01T00:00:00Z","previous_month":"2026-01","next_month":"2026-03","start_date":"2026-02-01T00:00:00Z","end_date":"2026-02-28T23:59:59.999Z","currency":"EUR","currency_symbol":"\\u20ac","prices":[]} + """ + + // swiftlint:enable line_length + + @Test + func `parses response with usage data and computes token totals`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + // mistral-large input: 11121, mistral-small input: 20+100=120 + #expect(snapshot.totalInputTokens == 11121 + 120) + // mistral-large output: 1115, mistral-small output: 500+2482=2982 + #expect(snapshot.totalOutputTokens == 1115 + 2982) + #expect(snapshot.totalCachedTokens == 0) + #expect(snapshot.modelCount == 2) + #expect(snapshot.currency == "EUR") + #expect(snapshot.currencySymbol == "€") + #expect(snapshot.daily.map(\.day) == ["2025-11-14", "2025-11-24"]) + #expect(snapshot.daily.first?.totalTokens == 11121 + 1115 + 20 + 500) + #expect(snapshot.daily.first?.models.first?.name == "mistral-large-latest") + } + + @Test + func `computes cost from tokens and prices`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + // mistral-large-2411 input: 11121 * 0.0000017 = 0.0189057 + // mistral-large-2411 output: 1115 * 0.0000051 = 0.0056865 + // mistral-small-2506 input: 120 * 0.000000085 = 0.0000102 + // mistral-small-2506 output: 2982 * 0.000000255 = 0.00076041 + let expectedCost = 0.0189057 + 0.0056865 + 0.0000102 + 0.00076041 + #expect(abs(snapshot.totalCost - expectedCost) < 0.0001) + #expect(snapshot.totalCost > 0) + } + + @Test + func `parses empty response with no usage`() throws { + let data = try #require(Self.emptyResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + #expect(snapshot.totalInputTokens == 0) + #expect(snapshot.totalOutputTokens == 0) + #expect(snapshot.totalCost == 0) + #expect(snapshot.modelCount == 0) + #expect(snapshot.currency == "EUR") + } + + @Test + func `daily spend keeps non token Mistral units out of token totals`() throws { + let json = """ + { + "libraries_api": { + "pages": { + "models": { + "mistral-ocr-latest": { + "input": [ + { + "billing_metric": "pages", + "billing_display_name": "OCR pages", + "billing_group": "input", + "timestamp": "2025-11-15", + "value": 42, + "value_paid": 42 + } + ] + } + } + } + }, + "currency": "EUR", + "currency_symbol": "€", + "prices": [ + { + "billing_metric": "pages", + "billing_group": "input", + "price": "0.01" + } + ] + } + """ + let snapshot = try MistralUsageFetcher.parseResponse(data: Data(json.utf8), updatedAt: Date()) + + #expect(abs(snapshot.totalCost - 0.42) < 0.0001) + #expect(snapshot.totalInputTokens == 0) + #expect(abs((snapshot.daily.first?.cost ?? 0) - 0.42) < 0.0001) + #expect(snapshot.daily.first?.totalTokens == 0) + #expect(abs((snapshot.daily.first?.models.first?.cost ?? 0) - 0.42) < 0.0001) + #expect(snapshot.daily.first?.models.first?.totalTokens == 0) + } + + @Test + func `parses dates from response`() throws { + let data = try #require(Self.novemberResponseJSON.data(using: .utf8)) + let snapshot = try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + + #expect(snapshot.startDate != nil) + #expect(snapshot.endDate != nil) + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + if let start = snapshot.startDate { + #expect(calendar.component(.month, from: start) == 11) + #expect(calendar.component(.year, from: start) == 2025) + } + } + + @Test + func `throws parseFailed for invalid JSON`() { + let data = Data("not json".utf8) + #expect(throws: MistralUsageError.self) { + try MistralUsageFetcher.parseResponse(data: data, updatedAt: Date()) + } + } +} + +struct MistralUsageSnapshotConversionTests { + @Test + func `converts cost into text only current month api spend`() { + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: Date(), + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.identity?.providerID == .mistral) + #expect(usage.identity?.loginMethod == "API spend: €1.2345 this month") + #expect(usage.providerCost == nil) + } + + @Test + func `converts zero cost into zero spend text`() { + let snapshot = MistralUsageSnapshot( + totalCost: 0, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCachedTokens: 0, + modelCount: 0, + startDate: nil, + endDate: nil, + updatedAt: Date()) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.identity?.loginMethod == "API spend: $0.0000 this month") + } + + @Test + func `converts billing usage into cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: 1.75, + currency: "eur", + currencySymbol: "€", + totalInputTokens: 300, + totalOutputTokens: 150, + totalCachedTokens: 50, + modelCount: 2, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 1.5, + inputTokens: 100, + cachedTokens: 20, + outputTokens: 50, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: 1.5, + inputTokens: 100, + cachedTokens: 20, + outputTokens: 50), + ]), + MistralDailyUsageBucket( + day: "2023-11-15", + cost: 0.25, + inputTokens: 200, + cachedTokens: 30, + outputTokens: 100, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-small", + cost: 0.25, + inputTokens: 200, + cachedTokens: 30, + outputTokens: 100), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 1) + #expect(cost.currencyCode == "EUR") + #expect(cost.historyLabel == "This month") + #expect(cost.historyDays == 2) + #expect(cost.sessionCostUSD == 0.25) + #expect(cost.sessionTokens == 330) + #expect(cost.last30DaysCostUSD == 1.75) + #expect(cost.last30DaysTokens == 500) + #expect(cost.daily.count == 2) + #expect(cost.daily.last?.modelsUsed == ["mistral-small"]) + } + + @Test + func `clamps negative billing adjustments in cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: -2, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 25, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: -1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: -1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.sessionCostUSD == 0) + #expect(cost.last30DaysCostUSD == 0) + #expect(cost.daily.first?.costUSD == 0) + #expect(cost.daily.first?.modelBreakdowns?.first?.costUSD == 0) + } + + @Test + func `preserves net monthly cost when billing includes credits`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: 8, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 25, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 10, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25, + models: []), + MistralDailyUsageBucket( + day: "2023-11-15", + cost: -2, + inputTokens: 0, + cachedTokens: 0, + outputTokens: 0, + models: []), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.last30DaysCostUSD == 8) + #expect(cost.sessionCostUSD == 0) + #expect(cost.daily.map(\.costUSD) == [10, 0]) + } +} + +struct MistralStrategyTests { + 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 func makeContext( + sourceMode: ProviderSourceMode = .auto, + settings: ProviderSettingsSnapshot? = nil, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: browserDetection) + } + + @Test + func `strategy is unavailable when cookie source is off`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == false) + } + + @Test + func `strategy is available when cookie source is auto`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == true) + } + + @Test + func `strategy is available when cookie source is manual`() async { + let settings = ProviderSettingsSnapshot.make( + mistral: ProviderSettingsSnapshot.MistralProviderSettings( + cookieSource: .manual, + manualCookieHeader: "ory_session_x=abc; csrftoken=xyz")) + let context = self.makeContext(settings: settings) + let strategy = MistralWebFetchStrategy() + + let available = await strategy.isAvailable(context) + #expect(available == true) + } + + @Test + func `strategy never falls back (single strategy provider)`() { + let strategy = MistralWebFetchStrategy() + let context = self.makeContext() + let shouldFallback = strategy.shouldFallback( + on: MistralUsageError.invalidCredentials, + context: context) + #expect(shouldFallback == false) + } + + @Test + func `descriptor metadata is correct`() { + let descriptor = MistralProviderDescriptor.descriptor + #expect(descriptor.id == .mistral) + #expect(descriptor.metadata.displayName == "Mistral") + #expect(descriptor.metadata.cliName == "mistral") + #expect(descriptor.metadata.defaultEnabled == false) + #expect(descriptor.cli.name == "mistral") + #expect(descriptor.fetchPlan.sourceModes == [.auto, .web]) + #expect(descriptor.branding.iconResourceName == "ProviderIcon-mistral") + #expect(descriptor.tokenCost.supportsTokenCost) + } +} diff --git a/Tests/CodexBarTests/ModelsDevPricingTests.swift b/Tests/CodexBarTests/ModelsDevPricingTests.swift new file mode 100644 index 000000000..3a4d1807d --- /dev/null +++ b/Tests/CodexBarTests/ModelsDevPricingTests.swift @@ -0,0 +1,592 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import CodexBarCore + +struct ModelsDevPricingTests { + @Test + func `parses models dev subset`() throws { + let catalog = try Self.fixtureCatalog() + + #expect(catalog.providers["openai"]?.name == "OpenAI") + #expect(catalog.providers["anthropic"]?.models["claude-sonnet-4-6"]?.cost?.cacheWrite == 3.75) + #expect(catalog.providers["anthropic"]?.models["claude-sonnet-4-6"]?.limit?.context == 1_000_000) + } + + @Test + func `looks up pricing by provider and model`() throws { + let catalog = try Self.fixtureCatalog() + + let openAI = try #require(catalog.pricing(providerID: "openai", modelID: "shared-model")) + let anthropic = try #require(catalog.pricing(providerID: "anthropic", modelID: "shared-model")) + + #expect(openAI.pricing.inputCostPerToken == 1 / 1_000_000.0) + #expect(openAI.pricing.outputCostPerToken == 2 / 1_000_000.0) + #expect(anthropic.pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(anthropic.pricing.outputCostPerToken == 4 / 1_000_000.0) + } + + @Test + func `does not fall back across providers`() throws { + let catalog = try Self.fixtureCatalog() + + #expect(catalog.pricing(providerID: "openai", modelID: "claude-sonnet-4-6") == nil) + #expect(catalog.pricing(providerID: "anthropic", modelID: "gpt-4o-mini") == nil) + } + + @Test + func `supports provider scoped alias normalization`() throws { + let catalog = try Self.fixtureCatalog() + + let anthropic = try #require(catalog.pricing( + providerID: "anthropic", + modelID: "anthropic.us-east-1.claude-sonnet-4-6-v1:0")) + let vertex = try #require(catalog.pricing( + providerID: "google-vertex-anthropic", + modelID: "claude-sonnet-4-6")) + + #expect(anthropic.normalizedModelID == "claude-sonnet-4-6") + #expect(vertex.normalizedModelID == "claude-sonnet-4-6@default") + #expect(vertex.pricing.inputCostPerToken == 3.1 / 1_000_000.0) + } + + @Test + func `converts models dev per million token prices to per token prices`() throws { + let pricing = try #require(try Self.fixtureCatalog().pricing( + providerID: "anthropic", + modelID: "claude-sonnet-4-6")? + .pricing) + + #expect(pricing.inputCostPerToken == 3 / 1_000_000.0) + #expect(pricing.outputCostPerToken == 15 / 1_000_000.0) + #expect(pricing.cacheReadInputCostPerToken == 0.3 / 1_000_000.0) + #expect(pricing.cacheCreationInputCostPerToken == 3.75 / 1_000_000.0) + #expect(pricing.thresholdTokens == 200_000) + #expect(pricing.inputCostPerTokenAboveThreshold == 6 / 1_000_000.0) + #expect(pricing.outputCostPerTokenAboveThreshold == 22.5 / 1_000_000.0) + #expect(pricing.cacheReadInputCostPerTokenAboveThreshold == 0.6 / 1_000_000.0) + #expect(pricing.cacheCreationInputCostPerTokenAboveThreshold == 7.5 / 1_000_000.0) + } + + @Test + func `stale cache is still readable`() throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let load = ModelsDevCache.load( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root) + + #expect(load.artifact != nil) + #expect(load.isStale) + #expect(load.error == nil) + } + + @Test + func `pipeline lookup reads cached pricing`() throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `network failure preserves last valid cache`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport(result: .failure(MockError.failed)))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `refresh preserves cache when fetched catalog drops cached provider`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let partialCatalog = Data(""" + { + "openai": { "id": 7, "models": [] }, + "anthropic": { + "id": "anthropic", + "models": { + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((partialCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `refresh preserves cache when fetched catalog drops cached model`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let partialCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4-6@default": { + "id": "claude-sonnet-4-6@default", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((partialCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `refresh updates cache when fetched catalog renames model key but keeps id`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let renamedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini-renamed": { + "id": "gpt-4o-mini", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4-6-renamed": { + "id": "claude-sonnet-4-6@default", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((renamedCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.normalizedModelID == "gpt-4o-mini") + #expect(lookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `refresh preserves cache when fetched matching model is not priceable`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let partialCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4-6@default": { + "id": "claude-sonnet-4-6@default", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((partialCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 0.15 / 1_000_000.0) + } + + @Test + func `refresh updates cache when fetched catalog canonicalizes alias model id`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: old, cacheRoot: root) + + let canonicalizedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "anthropic": { + "id": "anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + }, + "shared-model": { + "id": "shared-model", + "cost": { "input": 99, "output": 99 } + } + } + }, + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((canonicalizedCatalog, Self.response(status: 200)))))) + + let defaultLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "claude-sonnet-4-6@default", + cacheRoot: root)) + let baseLookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "claude-sonnet-4-6", + cacheRoot: root)) + + #expect(defaultLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + #expect(baseLookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `refresh preserves cache when fetched catalog only has different pinned snapshot`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4@20250101": { + "id": "claude-sonnet-4@20250101", + "cost": { "input": 3, "output": 15 } + } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "google-vertex-anthropic": { + "id": "google-vertex-anthropic", + "models": { + "claude-sonnet-4@20250201": { + "id": "claude-sonnet-4@20250201", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "google-vertex-anthropic", + modelID: "claude-sonnet-4@20250101", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 3 / 1_000_000.0) + } + + @Test + func `refresh ignores unpriceable models in old cache continuity check`() async throws { + let root = try Self.cacheRoot() + let old = Date(timeIntervalSince1970: 1) + let cachedCatalog = try Self.catalog(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 0.15, "output": 0.6 } + }, + "unpriced-preview": { + "id": "unpriced-preview" + } + } + } + } + """) + ModelsDevCache.save(catalog: cachedCatalog, fetchedAt: old, cacheRoot: root) + + let fetchedCatalog = Data(""" + { + "openai": { + "id": "openai", + "models": { + "gpt-4o-mini": { + "id": "gpt-4o-mini", + "cost": { "input": 99, "output": 99 } + } + } + } + } + """.utf8) + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(timeIntervalSince1970: 1 + ModelsDevCache.ttlSeconds + 1), + cacheRoot: root, + client: ModelsDevClient(transport: MockTransport( + result: .success((fetchedCatalog, Self.response(status: 200)))))) + + let lookup = try #require(ModelsDevPricingPipeline.lookup( + providerID: "openai", + modelID: "gpt-4o-mini", + cacheRoot: root)) + + #expect(lookup.pricing.inputCostPerToken == 99 / 1_000_000.0) + } + + @Test + func `fresh cache does not refresh`() async throws { + let root = try Self.cacheRoot() + try ModelsDevCache.save(catalog: Self.fixtureCatalog(), fetchedAt: Date(), cacheRoot: root) + let transport = TrackingTransport(result: .failure(MockError.failed)) + + await ModelsDevPricingPipeline.refreshIfNeeded( + now: Date(), + cacheRoot: root, + client: ModelsDevClient(transport: transport)) + + #expect(transport.calls == 0) + } + + @Test + func `corrupt cache is ignored safely`() throws { + let root = try Self.cacheRoot() + let url = ModelsDevCache.cacheFileURL(cacheRoot: root) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("not json".utf8).write(to: url) + + let load = ModelsDevCache.load(cacheRoot: root) + + #expect(load.artifact == nil) + #expect(load.isStale) + #expect(load.error == .invalidJSON) + } + + @Test + func `client fetches with mock transport`() async throws { + let data = try Self.fixtureData() + let client = ModelsDevClient(transport: MockTransport(result: .success((data, Self.response(status: 200))))) + + let catalog = try await client.fetchCatalog() + + #expect(catalog.providers["google-vertex-anthropic"]?.models["claude-sonnet-4-6@default"]?.cost?.input == 3.1) + } + + @Test + func `client reports http and json failures`() async throws { + let data = try Self.fixtureData() + let httpClient = ModelsDevClient(transport: MockTransport(result: .success((data, Self.response(status: 500))))) + let jsonClient = ModelsDevClient(transport: MockTransport( + result: .success((Data("not json".utf8), Self.response(status: 200))))) + + await #expect(throws: ModelsDevClient.Error.httpStatus(500)) { + _ = try await httpClient.fetchCatalog() + } + await #expect(throws: ModelsDevClient.Error.invalidJSON) { + _ = try await jsonClient.fetchCatalog() + } + } + + private static func fixtureData() throws -> Data { + let url = try #require(Bundle.module.url( + forResource: "models-dev-subset", + withExtension: "json", + subdirectory: "Fixtures")) + return try Data(contentsOf: url) + } + + private static func fixtureCatalog() throws -> ModelsDevCatalog { + try JSONDecoder().decode(ModelsDevCatalog.self, from: self.fixtureData()) + } + + private static func catalog(_ json: String) throws -> ModelsDevCatalog { + try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } + + private static func cacheRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-modelsdev-tests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private static func response(status: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: URL(string: "https://models.dev/api.json")!, + statusCode: status, + httpVersion: nil, + headerFields: nil)! + } +} + +private enum MockError: Error { + case failed +} + +private struct MockTransport: ModelsDevHTTPTransport { + let result: Result<(Data, URLResponse), Error> + + func data(for _: URLRequest) async throws -> (Data, URLResponse) { + try self.result.get() + } +} + +private final class TrackingTransport: ModelsDevHTTPTransport, @unchecked Sendable { + private(set) var calls = 0 + let result: Result<(Data, URLResponse), Error> + + init(result: Result<(Data, URLResponse), Error>) { + self.result = result + } + + func data(for _: URLRequest) async throws -> (Data, URLResponse) { + self.calls += 1 + return try self.result.get() + } +} diff --git a/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift b/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift new file mode 100644 index 000000000..3188f5dd6 --- /dev/null +++ b/Tests/CodexBarTests/MoonshotSettingsReaderTests.swift @@ -0,0 +1,68 @@ +import CodexBarCore +import Testing + +struct MoonshotSettingsReaderTests { + @Test + func `api key prefers MOONSHOT API KEY`() { + let env = [ + "MOONSHOT_API_KEY": "primary-token", + "MOONSHOT_KEY": "fallback-token", + ] + + #expect(MoonshotSettingsReader.apiKey(environment: env) == "primary-token") + } + + @Test + func `api key strips quotes`() { + let env = ["MOONSHOT_KEY": "\"quoted-token\""] + + #expect(MoonshotSettingsReader.apiKey(environment: env) == "quoted-token") + } + + @Test + func `region parses china`() { + let env = ["MOONSHOT_REGION": "china"] + + #expect(MoonshotSettingsReader.region(environment: env) == .china) + } + + @Test + func `default settings snapshot does not mask environment region`() { + let settings = ProviderSettingsSnapshot.MoonshotProviderSettings() + + #expect(settings.region == nil) + } + + @Test + func `region defaults to international for unknown values`() { + let env = ["MOONSHOT_REGION": "moon"] + + #expect(MoonshotSettingsReader.region(environment: env) == .international) + } +} + +struct MoonshotProviderTokenResolverTests { + @Test + func `resolves from environment`() { + let env = ["MOONSHOT_API_KEY": "env-token"] + let resolution = ProviderTokenResolver.moonshotResolution(environment: env) + + #expect(resolution?.token == "env-token") + #expect(resolution?.source == .environment) + } + + @Test + func `uses kimi branding icon`() { + let branding = MoonshotProviderDescriptor.descriptor.branding + + #expect(branding.iconStyle == .kimi) + #expect(branding.iconResourceName == "ProviderIcon-kimi") + } + + @Test + func `dashboard url opens account console`() { + #expect( + MoonshotProviderDescriptor.descriptor.metadata.dashboardURL + == "https://platform.moonshot.ai/console/account") + } +} diff --git a/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift b/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift new file mode 100644 index 000000000..7d69af2af --- /dev/null +++ b/Tests/CodexBarTests/MoonshotUsageFetcherTests.swift @@ -0,0 +1,220 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct MoonshotUsageFetcherTests { + @Test + func `parses documented response`() throws { + let json = """ + { + "code": 0, + "data": { + "available_balance": 49.58, + "voucher_balance": 50.00, + "cash_balance": 12.34 + }, + "scode": "0x0", + "status": true + } + """ + + let summary = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + + #expect(summary.availableBalance == 49.58) + #expect(summary.voucherBalance == 50.00) + #expect(summary.cashBalance == 12.34) + + let usage = MoonshotUsageSnapshot(summary: summary).toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.loginMethod(for: .moonshot) == "Balance: $49.58") + } + + @Test + func `negative cash balance is surfaced as deficit`() throws { + let json = """ + { + "code": 0, + "data": { + "available_balance": 49.58, + "voucher_balance": 50.00, + "cash_balance": -0.42 + }, + "scode": "0x0", + "status": true + } + """ + + let summary = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + let usage = MoonshotUsageSnapshot(summary: summary).toUsageSnapshot() + + #expect(summary.cashBalance == -0.42) + #expect(usage.loginMethod(for: .moonshot)?.contains("in deficit") == true) + } + + @Test + func `invalid root returns parse error`() { + let json = """ + [{ "available_balance": 1 }] + """ + + #expect { + _ = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + } throws: { error in + guard case MoonshotUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `api code failure returns api error`() { + let json = """ + { + "code": 401, + "data": { + "available_balance": 0, + "voucher_balance": 0, + "cash_balance": 0 + }, + "scode": "unauthorized", + "status": false + } + """ + + #expect { + _ = try MoonshotUsageFetcher._parseSummaryForTesting(Data(json.utf8)) + } throws: { error in + guard case let MoonshotUsageError.apiError(message) = error else { return false } + return message == "code 401, scode unauthorized" + } + } + + @Test + func `international host uses moonshot ai`() { + let url = MoonshotUsageFetcher.resolveBalanceURL(region: .international) + + #expect(url.absoluteString == "https://api.moonshot.ai/v1/users/me/balance") + } + + @Test + func `china host uses moonshot cn`() { + let url = MoonshotUsageFetcher.resolveBalanceURL(region: .china) + + #expect(url.absoluteString == "https://api.moonshot.cn/v1/users/me/balance") + } + + @Test + func `fetch usage sends bearer token and bounded request`() async throws { + defer { + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MoonshotStubURLProtocol.self] + let session = URLSession(configuration: config) + + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(url.absoluteString == "https://api.moonshot.cn/v1/users/me/balance") + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer live-token") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.timeoutInterval == 15) + + let body = """ + { + "code": 0, + "data": { + "available_balance": 9.87, + "voucher_balance": 1.23, + "cash_balance": 8.64 + }, + "scode": "0x0", + "status": true + } + """ + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, Data(body.utf8)) + } + + let snapshot = try await MoonshotUsageFetcher.fetchUsage( + apiKey: " live-token ", + region: .china, + session: session) + + #expect(MoonshotStubURLProtocol.requests.count == 1) + #expect(snapshot.summary.availableBalance == 9.87) + #expect(snapshot.toUsageSnapshot().loginMethod(for: .moonshot) == "Balance: $9.87") + } + + @Test + func `fetch usage surfaces http failure without leaking body`() async throws { + defer { + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = nil + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MoonshotStubURLProtocol.self] + let session = URLSession(configuration: config) + + MoonshotStubURLProtocol.requests = [] + MoonshotStubURLProtocol.handler = { request in + let url = try #require(request.url) + let response = HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (response, Data(#"{"error":"secret-ish provider body"}"#.utf8)) + } + + await #expect { + _ = try await MoonshotUsageFetcher.fetchUsage( + apiKey: "live-token", + session: session) + } throws: { error in + guard case let MoonshotUsageError.apiError(message) = error else { return false } + return message == "HTTP 401" + } + } +} + +final class MoonshotStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var requests: [URLRequest] = [] + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with _: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift index ceff20813..cb42bccce 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherRetryMappingTests.swift @@ -4,8 +4,115 @@ import Testing @Suite(.serialized) struct OllamaUsageFetcherRetryMappingTests { + private func makeContext( + sourceMode: ProviderSourceMode, + env: [String: String] = [:], + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `api key reader trims configured environment key`() { + let token = OllamaAPISettingsReader.apiKey(environment: ["OLLAMA_API_KEY": " 'ollama-test' "]) + + #expect(token == "ollama-test") + } + + @Test + func `api tags response maps to API key identity`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = try OllamaAPIUsageFetcher._parseTagsForTesting( + Data(#"{"models":[{"name":"gpt-oss:120b"}]}"#.utf8), + now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.modelCount == 1) + #expect(usage.primary == nil) + #expect(usage.identity?.providerID == .ollama) + #expect(usage.identity?.loginMethod == "API key") + #expect(usage.updatedAt == now) + } + + @Test + func `auto mode keeps web quota strategy before api key verification`() async { + let descriptor = OllamaProviderDescriptor.makeDescriptor() + let context = self.makeContext( + sourceMode: .auto, + env: ["OLLAMA_API_KEY": "ollama-test"], + settings: ProviderSettingsSnapshot.make( + ollama: .init(cookieSource: .auto, manualCookieHeader: nil))) + + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["ollama.web", "ollama.api"]) + } + + @Test + func `auto mode uses api only when ollama cookies are off`() async { + let descriptor = OllamaProviderDescriptor.makeDescriptor() + let context = self.makeContext( + sourceMode: .auto, + env: ["OLLAMA_API_KEY": "ollama-test"], + settings: ProviderSettingsSnapshot.make( + ollama: .init(cookieSource: .off, manualCookieHeader: nil))) + + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(context) + + #expect(strategies.map(\.id) == ["ollama.api"]) + } + + @Test + func `web strategy falls back to api key in auto mode`() { + let context = self.makeContext( + sourceMode: .auto, + env: ["OLLAMA_API_KEY": "ollama-test"]) + let strategy = OllamaStatusFetchStrategy() + + #expect(strategy.shouldFallback(on: OllamaUsageError.parseFailed("missing"), context: context)) + } + + @Test + func `api fetch sends bearer token and rejects unauthorized key`() async throws { + let url = try #require(URL(string: "https://ollama.com/api/tags")) + let transport = ProviderHTTPTransportHandler { request in + #expect(request.url == url) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer ollama-test") + let response = HTTPURLResponse( + url: url, + statusCode: 401, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (Data("{}".utf8), response) + } + + do { + _ = try await OllamaAPIUsageFetcher.fetchUsage(apiKey: "ollama-test", transport: transport) + Issue.record("Expected unauthorized API error") + } catch let error as OllamaUsageError { + guard case .apiUnauthorized = error else { + Issue.record("Expected apiUnauthorized, got \(error)") + return + } + } catch { + Issue.record("Expected OllamaUsageError.apiUnauthorized, got \(error)") + } + } + @Test - func missingUsageShapeSurfacesPublicParseFailedMessage() async { + func `missing usage shape surfaces public parse failed message`() async { defer { OllamaRetryMappingStubURLProtocol.handler = nil } OllamaRetryMappingStubURLProtocol.handler = { request in diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index 8020638f8..e7dde89ff 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -2,24 +2,23 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct OllamaUsageFetcherTests { @Test - func attachesCookieForOllamaHosts() { + func `attaches cookie for ollama hosts`() { #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com/settings"))) #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://www.ollama.com"))) #expect(OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://app.ollama.com/path"))) } @Test - func rejectsNonOllamaHosts() { + func `rejects non ollama hosts`() { #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://example.com"))) #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "https://ollama.com.evil.com"))) #expect(!OllamaUsageFetcher.shouldAttachCookie(to: nil)) } @Test - func manualModeWithoutValidHeaderThrowsNoSessionCookie() { + func `manual mode without valid header throws no session cookie`() { do { _ = try OllamaUsageFetcher.resolveManualCookieHeader( override: nil, @@ -33,7 +32,7 @@ struct OllamaUsageFetcherTests { } @Test - func autoModeWithoutHeaderDoesNotForceManualError() throws { + func `auto mode without header does not force manual error`() throws { let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( override: nil, manualCookieMode: false) @@ -41,7 +40,7 @@ struct OllamaUsageFetcherTests { } @Test - func manualModeWithoutRecognizedSessionCookieThrowsNoSessionCookie() { + func `manual mode without recognized session cookie throws no session cookie`() { do { _ = try OllamaUsageFetcher.resolveManualCookieHeader( override: "analytics_session_id=noise; theme=dark", @@ -55,7 +54,7 @@ struct OllamaUsageFetcherTests { } @Test - func manualModeWithRecognizedSessionCookieAcceptsHeader() throws { + func `manual mode with recognized session cookie accepts header`() throws { let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( override: "next-auth.session-token.0=abc; theme=dark", manualCookieMode: true) @@ -63,7 +62,15 @@ struct OllamaUsageFetcherTests { } @Test - func retryPolicyRetriesOnlyForAuthErrors() { + func `manual mode accepts secure session cookie header`() throws { + let resolved = try OllamaUsageFetcher.resolveManualCookieHeader( + override: "__Secure-session=abc; theme=dark", + manualCookieMode: true) + #expect(resolved?.contains("__Secure-session=abc") == true) + } + + @Test + func `retry policy retries only for auth errors`() { #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.invalidCredentials)) #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate(after: OllamaUsageError.notLoggedIn)) #expect(OllamaUsageFetcher.shouldRetryWithNextCookieCandidate( @@ -77,12 +84,13 @@ struct OllamaUsageFetcherTests { #if os(macOS) @Test - func cookieImporterDefaultsToChromeFirst() { + func `cookie importer defaults to chrome first`() { #expect(OllamaCookieImporter.defaultPreferredBrowsers == [.chrome]) + #expect(OllamaCookieImporter.defaultAllowFallbackBrowsers) } @Test - func cookieSelectorSkipsSessionLikeNoiseAndFindsRecognizedCookie() throws { + func `cookie selector skips session like noise and finds recognized cookie`() throws { let first = OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], sourceLabel: "Profile A") @@ -95,7 +103,7 @@ struct OllamaUsageFetcherTests { } @Test - func cookieSelectorThrowsWhenNoRecognizedSessionCookieExists() { + func `cookie selector throws when no recognized session cookie exists`() { let candidates = [ OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], @@ -116,7 +124,7 @@ struct OllamaUsageFetcherTests { } @Test - func cookieSelectorAcceptsChunkedNextAuthSessionTokenCookie() throws { + func `cookie selector accepts chunked next auth session token cookie`() throws { let candidate = OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "next-auth.session-token.0", value: "chunk0")], sourceLabel: "Profile C") @@ -126,7 +134,17 @@ struct OllamaUsageFetcherTests { } @Test - func cookieSelectorKeepsRecognizedCandidatesInOrder() throws { + func `cookie selector accepts secure session cookie`() throws { + let candidate = OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "__Secure-session", value: "auth")], + sourceLabel: "Profile D") + + let selected = try OllamaCookieImporter.selectSessionInfo(from: [candidate]) + #expect(selected.sourceLabel == "Profile D") + } + + @Test + func `cookie selector keeps recognized candidates in order`() throws { let first = OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "session", value: "stale")], sourceLabel: "Chrome Profile A") @@ -142,7 +160,7 @@ struct OllamaUsageFetcherTests { } @Test - func cookieSelectorDoesNotFallbackWhenFallbackDisabled() { + func `cookie selector does not fallback when fallback disabled`() { let preferred = [ OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], @@ -168,7 +186,7 @@ struct OllamaUsageFetcherTests { } @Test - func cookieSelectorFallsBackToNonChromeCandidateWhenFallbackEnabled() throws { + func `cookie selector falls back to non chrome candidate when fallback enabled`() throws { let preferred = [ OllamaCookieImporter.SessionInfo( cookies: [Self.makeCookie(name: "analytics_session_id", value: "noise")], @@ -187,6 +205,21 @@ struct OllamaUsageFetcherTests { #expect(selected.sourceLabel == "Safari Profile") } + @Test + func `cookie selector can fall back to comet secure session cookie`() throws { + let fallback = [ + OllamaCookieImporter.SessionInfo( + cookies: [Self.makeCookie(name: "__Secure-session", value: "auth")], + sourceLabel: "Comet Profile"), + ] + + let selected = try OllamaCookieImporter.selectSessionInfoWithFallback( + preferredCandidates: [], + allowFallbackBrowsers: true, + loadFallbackCandidates: { fallback }) + #expect(selected.sourceLabel == "Comet Profile") + } + private static func makeCookie( name: String, value: String, diff --git a/Tests/CodexBarTests/OllamaUsageParserTests.swift b/Tests/CodexBarTests/OllamaUsageParserTests.swift index e01d167ae..bdec92606 100644 --- a/Tests/CodexBarTests/OllamaUsageParserTests.swift +++ b/Tests/CodexBarTests/OllamaUsageParserTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct OllamaUsageParserTests { @Test - func parsesCloudUsageFromSettingsHTML() throws { + func `parses cloud usage from settings HTML`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """
@@ -44,10 +43,12 @@ struct OllamaUsageParserTests { let usage = snapshot.toUsageSnapshot() #expect(usage.identity?.loginMethod == "free") #expect(usage.identity?.accountEmail == "user@example.com") + #expect(usage.primary?.windowMinutes == 5 * 60) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) } @Test - func missingUsageThrowsParseFailed() { + func `missing usage throws parse failed`() { let html = "No usage here. login status unknown." #expect { @@ -59,7 +60,7 @@ struct OllamaUsageParserTests { } @Test - func classifiedParseMissingUsageReturnsTypedFailure() { + func `classified parse missing usage returns typed failure`() { let html = "No usage here. login status unknown." let result = OllamaUsageParser.parseClassified(html: html) @@ -72,7 +73,7 @@ struct OllamaUsageParserTests { } @Test - func signedOutThrowsNotLoggedIn() { + func `signed out throws not logged in`() { let html = """ @@ -94,7 +95,7 @@ struct OllamaUsageParserTests { } @Test - func classifiedParseSignedOutReturnsTypedFailure() { + func `classified parse signed out returns typed failure`() { let html = """ @@ -117,7 +118,7 @@ struct OllamaUsageParserTests { } @Test - func genericSignInTextWithoutAuthMarkersThrowsParseFailed() { + func `generic sign in text without auth markers throws parse failed`() { let html = """ @@ -137,7 +138,7 @@ struct OllamaUsageParserTests { } @Test - func parsesHourlyUsageAsPrimaryWindow() throws { + func `parses hourly usage as primary window`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """
@@ -154,10 +155,39 @@ struct OllamaUsageParserTests { #expect(snapshot.sessionUsedPercent == 2.5) #expect(snapshot.weeklyUsedPercent == 4.2) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + } + + @Test + func `weekly usage parser finds reset timestamp in long usage block`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let filler = String(repeating: "", count: 40) + let html = """ +
+ Session usage + 0.1% used + Weekly usage + 0.7% used + \(filler) +
Resets in 2 days
+
+ """ + + let snapshot = try OllamaUsageParser.parse(html: html, now: now) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let expectedWeekly = formatter.date(from: "2026-02-02T00:00:00Z") + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetsAt == nil) + #expect(usage.secondary?.resetsAt == expectedWeekly) } @Test - func parsesUsageWhenUsedIsCapitalized() throws { + func `parses usage when used is capitalized`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let html = """
diff --git a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift new file mode 100644 index 000000000..88860dd5f --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift @@ -0,0 +1,217 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenAIAPICreditBalanceTests { + private func makeContext( + apiKey: String = "sk-test", + usesAdminKey: Bool = false, + projectID: String? = nil, + selectedTokenAccountID: UUID? = nil, + historyDays: Int = 30) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + let apiKeyEnvironmentKey = usesAdminKey + ? OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey + : OpenAIAPISettingsReader.apiKeyEnvironmentKey + var env = [apiKeyEnvironmentKey: apiKey] + if let projectID { + env[OpenAIAPISettingsReader.projectIDEnvironmentKey] = projectID + } + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID, + costUsageHistoryDays: historyDays) + } + + @Test + func `prefers admin key environment variable`() { + let token = OpenAIAPISettingsReader.apiKey(environment: [ + "OPENAI_API_KEY": "sk-project", + "OPENAI_ADMIN_KEY": "sk-admin", + ]) + + #expect(token == "sk-admin") + } + + @Test + func `parses credit grants balance`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let json = """ + { + "object": "credit_summary", + "total_granted": 25.5, + "total_used": 7.25, + "total_available": 18.25, + "grants": { + "object": "list", + "data": [ + { + "grant_amount": 10.0, + "used_amount": 1.0, + "effective_at": 1690000000, + "expires_at": 1800000000 + } + ] + } + } + """ + + let snapshot = try OpenAIAPICreditBalanceFetcher._parseSnapshotForTesting(Data(json.utf8), now: now) + + #expect(snapshot.totalGranted == 25.5) + #expect(snapshot.totalUsed == 7.25) + #expect(snapshot.totalAvailable == 18.25) + #expect(snapshot.nextGrantExpiry == Date(timeIntervalSince1970: 1_800_000_000)) + } + + @Test + func `maps balance to usage snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let balance = OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 40, + totalAvailable: 60, + nextGrantExpiry: Date(timeIntervalSince1970: 1_800_000_000), + updatedAt: now) + + let usage = balance.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 40) + #expect(usage.primary?.resetDescription == "$60.00 available") + #expect(usage.providerCost?.used == 40) + #expect(usage.providerCost?.limit == 100) + #expect(usage.identity?.providerID == .openai) + #expect(usage.identity?.loginMethod == "API balance: $60.00") + } + + @Test + func `falls back to legacy billing when admin usage rejects credentials`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { _, _ in + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { _ in + OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext()) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + } + + @Test + func `legacy API key without project ID falls back to legacy billing`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == nil) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "sk-test") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext()) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + + @Test + func `selected token account uses scrubbed final environment for legacy fallback`() async throws { + let accountID = UUID() + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "account-token") + #expect(credential.projectID == nil) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "account-token") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext( + apiKey: "account-token", + usesAdminKey: true, + selectedTokenAccountID: accountID)) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + + @Test + func `preserves admin usage error when legacy fallback also fails`() async { + let usageFailure = OpenAIAPIUsageError.parseFailed(endpoint: "costs", message: "changed") + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { _, _ in throw usageFailure }, + balanceFetcher: { _ in throw OpenAIAPICreditBalanceError.forbidden }) + + do { + _ = try await strategy.fetch(self.makeContext()) + Issue.record("Expected admin usage failure") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @Test + func `falls back to credit balance when admin usage endpoint is unavailable`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == nil) + #expect(historyDays == 90) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 500) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "sk-test") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext(historyDays: 90)) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.providerCost?.used == 25) + #expect(result.usage.providerCost?.limit == 100) + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift new file mode 100644 index 000000000..b13eabe47 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift @@ -0,0 +1,166 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct OpenAIAPIMenuCardModelTests { + @Test + func `admin usage model shows summaries and spend without fake quota bars`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 12.5, + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500, + lineItems: [ + OpenAIAPIUsageSnapshot.LineItemBreakdown(name: "Text tokens", costUSD: 12.5), + ], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500), + ]), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: apiUsage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.openAIAPIUsage != nil) + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$12.50") + #expect(model.inlineUsageDashboard?.kpis.last?.title == "Requests") + #expect(model.inlineUsageDashboard?.kpis.last?.value == "40") + #expect(model.inlineUsageDashboard?.points.count == 1) + #expect(model.inlineUsageDashboard?.detailLines.contains("30d requests: 40 requests") == true) + #expect(model.providerCost == nil) + #expect(model.usageNotes.contains { $0.contains("Today: $12.50") }) + #expect(model.usageNotes.contains("Top model: gpt-5.2")) + #expect(model.creditsText == nil) + #expect(model.planText == "Admin API") + } + + @Test + func `admin usage dashboard ignores stale token snapshot after fallback refresh`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let staleTokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 1500, + sessionCostUSD: 12.5, + last30DaysTokens: 1500, + last30DaysCostUSD: 12.5, + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-14", + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + costUSD: 12.5, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: staleTokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.tokenUsage == nil) + } + + @Test + func `admin usage model can show cost card summary`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 12.5, + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500, + lineItems: [], + models: []), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: apiUsage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: apiUsage.toCostUsageTokenSnapshot(), + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(ProviderDescriptorRegistry.descriptor(for: .openai).tokenCost.supportsTokenCost) + #expect(model.tokenUsage?.sessionLine == "Today: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.monthLine == "Last 30 days: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.hintLine == "Reported by OpenAI Admin API organization usage.") + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift b/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift new file mode 100644 index 000000000..a8a0f3f51 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift @@ -0,0 +1,334 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI +@testable import CodexBarCore + +@Suite(.serialized) +struct OpenAIAPIProjectScopeTests { + @Test + @MainActor + func `token account strips configured project in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "OpenAIAPIProjectScopeTests-app") + settings.openAIAPIKey = "config-token" + settings.openAIAPIProjectID = "proj_config" + settings.addTokenAccount(provider: .openai, label: "Configured account", token: "first-account-token") + settings.addTokenAccount(provider: .openai, label: "Selected account", token: "selected-account-token") + let selectedAccount = settings.tokenAccounts(for: .openai)[1] + + let env = ProviderRegistry.makeEnvironment( + base: [OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env"], + provider: .openai, + settings: settings, + tokenOverride: TokenAccountOverride(provider: .openai, account: selectedAccount)) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "selected-account-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "config-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "first-account-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil) + } + + @Test + func `token account strips configured project in CLI environment builder`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Project account", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + let accounts = ProviderTokenAccountData(version: 1, accounts: [account], activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .openai, + apiKey: "config-token", + workspaceID: "proj_config", + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + + let env = tokenContext.environment( + base: [OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env"], + provider: .openai, + account: account) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "account-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "config-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil) + } + + @Test + @MainActor + func `configured app project scopes admin usage strategy`() async throws { + let settings = Self.makeSettingsStore(suite: "OpenAIAPIProjectScopeTests-configured-project") + settings.openAIAPIKey = "config-token" + settings.openAIAPIProjectID = "proj_config" + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .openai, + settings: settings, + tokenOverride: nil) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "config-token") + #expect(credential.projectID == "proj_config") + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Configured project usage should not fetch legacy organization balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext(env: env)) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_config") + #expect(result.usage.identity?.accountOrganization == "Project: proj_config") + } + + @Test + func `legacy API key environment can scope admin usage by project`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-admin-legacy") + #expect(credential.projectID == "proj_legacy") + #expect(credential.usesAdminKey == false) + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Legacy OPENAI_API_KEY project usage should not fetch unscoped balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-admin-legacy", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_legacy", + ])) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_legacy") + #expect(result.usage.identity?.accountOrganization == "Project: proj_legacy") + } + + @Test + func `ambient project with legacy API key preserves billing fallback`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-ambient") + #expect(credential.projectID == "proj_ambient") + #expect(credential.usesAdminKey == false) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "sk-ambient") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-ambient", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_ambient", + ])) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + + @Test + func `project filtered admin usage does not fall back on service failure`() async { + let usageFailure = OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 500) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + #expect(credential.usesAdminKey == true) + #expect(historyDays == 30) + throw usageFailure + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage must not fall back to organization balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + do { + _ = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + Issue.record("Expected project-filtered admin usage failure.") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @Test + func `project filtered admin usage does not fall back on credential rejection`() async { + let usageFailure = OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + #expect(historyDays == 30) + throw usageFailure + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage must fail closed instead of showing unscoped balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + do { + _ = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + Issue.record("Expected project-filtered admin credential failure.") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @Test + func `project filtered admin usage reports project source label`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, _ in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage should not fetch legacy balance after admin usage succeeds.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_abc") + #expect(result.usage.identity?.accountOrganization == "Project: proj_abc") + } + + @Test + func `project scope follows final environment even when selected account flag is present`() async throws { + let accountID = UUID() + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_env") + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Final project-scoped environments should not fetch legacy balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env", + ], + selectedTokenAccountID: accountID)) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_env") + #expect(result.usage.identity?.accountOrganization == "Project: proj_env") + } + + private static func makeContext( + env: [String: String], + selectedTokenAccountID: UUID? = nil, + historyDays: Int = 30) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID, + costUsageHistoryDays: historyDays) + } + + @MainActor + private static func makeSettingsStore(suite: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift b/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift new file mode 100644 index 000000000..002d2bd9c --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift @@ -0,0 +1,53 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `open AI API usage submenu ignores stale token snapshot without current admin usage`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .openai + + let registry = ProviderRegistry.shared + let metadata = try #require(registry.metadata[.openai]) + settings.setProviderEnabled(provider: .openai, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + provider: .openai) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.makeOpenAIAPIUsageSubmenu(provider: .openai) == nil) + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift new file mode 100644 index 000000000..df6f513b6 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift @@ -0,0 +1,376 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenAIAPIUsageFetcherTests { + @Test + func `parses admin costs and completions usage into daily summaries`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let costs = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 12.50, "currency": "usd" }, + "line_item": "Text tokens" + }, + { + "object": "organization.costs.result", + "amount": { "value": "2.25", "currency": "usd" }, + "line_item": "Web search tool calls" + } + ] + }, + { + "object": "bucket", + "start_time": 1700086400, + "end_time": 1700172800, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 4.00, "currency": "usd" }, + "line_item": "Text tokens" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + let completions = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 1000, + "input_cached_tokens": 250, + "output_tokens": 500, + "num_model_requests": 7, + "model": "gpt-5.2" + }, + { + "object": "organization.usage.completions.result", + "input_tokens": 300, + "output_tokens": 200, + "num_model_requests": 3, + "model": "gpt-5.2-codex" + } + ] + }, + { + "object": "bucket", + "start_time": 1700086400, + "end_time": 1700172800, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 200, + "output_tokens": 100, + "num_model_requests": 2, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + + let snapshot = try OpenAIAPIUsageFetcher._parseSnapshotForTesting( + costs: Data(costs.utf8), + completions: Data(completions.utf8), + now: now, + historyDays: 90) + + #expect(snapshot.historyDays == 90) + #expect(snapshot.historyWindowLabel == "90d") + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[0].costUSD == 14.75) + #expect(snapshot.daily[0].requests == 10) + #expect(snapshot.daily[0].totalTokens == 2000) + #expect(snapshot.daily[0].cachedInputTokens == 250) + #expect(snapshot.daily[0].lineItems.first?.name == "Text tokens") + #expect(snapshot.last30Days.costUSD == 18.75) + #expect(snapshot.last30Days.requests == 12) + #expect(snapshot.last30Days.totalTokens == 2300) + #expect(snapshot.topModels.first?.name == "gpt-5.2") + #expect(snapshot.topModels.first?.totalTokens == 1800) + } + + @Test + func `admin usage fetch pages long history within endpoint bucket limit`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (emptyPage, response) + } + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 90) + + let requests = await transport.requests() + let limits = requests.compactMap { request -> Int? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let raw = components.queryItems?.first(where: { $0.name == "limit" })?.value + else { return nil } + return Int(raw) + } + + #expect(snapshot.historyDays == 90) + #expect(requests.count == 6) + #expect(limits == [31, 31, 28, 31, 31, 28]) + #expect(limits.allSatisfy { $0 <= 31 }) + } + + @Test + func `admin usage filters costs and completions by project`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (emptyPage, response) + } + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + projectID: " proj_abc ", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1) + + let requests = await transport.requests() + let projectIDs = requests.compactMap { request -> String? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + return components.queryItems?.first(where: { $0.name == "project_ids" })?.value + } + let groupBys = requests.compactMap { request -> String? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + return components.queryItems?.first(where: { $0.name == "group_by" })?.value + } + + #expect(snapshot.projectID == "proj_abc") + #expect(snapshot.toUsageSnapshot().identity?.accountOrganization == "Project: proj_abc") + #expect(requests.count == 2) + #expect(projectIDs == ["proj_abc", "proj_abc"]) + #expect(groupBys == ["line_item", "model"]) + } + + @Test + func `admin usage retries transient completions failure once`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let completions = Data(""" + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 10, + "output_tokens": 5, + "num_model_requests": 1, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """.utf8) + let transport = OpenAIAdminUsageRetryScript(costs: emptyPage, completions: completions) + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1, + retryPolicy: ProviderHTTPRetryPolicy(maxRetries: 1, baseDelaySeconds: 0, maxDelaySeconds: 0)) + + #expect(snapshot.latestDay.totalTokens == 15) + #expect(snapshot.latestDay.requests == 1) + #expect(await transport.completionsRequestCount() == 2) + } + + @Test + func `maps admin usage to openai usage snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 8.5, + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250, + lineItems: [], + models: []), + ], + updatedAt: now) + + let usage = apiUsage.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.providerCost?.used == 8.5) + #expect(usage.providerCost?.limit == 0) + #expect(usage.providerCost?.period == "Last 30 days") + #expect(usage.openAIAPIUsage?.last30Days.requests == 42) + #expect(usage.identity?.loginMethod == "Admin API") + } + + @Test + func `maps project scoped admin usage to cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-13", + startTime: now.addingTimeInterval(-86400), + endTime: now, + costUSD: 2.25, + requests: 3, + inputTokens: 300, + cachedInputTokens: 100, + outputTokens: 200, + totalTokens: 500, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 3, + inputTokens: 300, + cachedInputTokens: 100, + outputTokens: 200, + totalTokens: 500), + ]), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 8.5, + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2-codex", + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250), + ]), + ], + updatedAt: now, + historyDays: 7, + projectID: " proj_abc ") + + let usage = apiUsage.toUsageSnapshot() + let snapshot = apiUsage.toCostUsageTokenSnapshot() + + #expect(apiUsage.projectID == "proj_abc") + #expect(usage.identity?.loginMethod == "Admin API: proj_abc") + #expect(usage.identity?.accountOrganization == "Project: proj_abc") + #expect(snapshot.historyDays == 7) + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.sessionCostUSD == 8.5) + #expect(snapshot.sessionTokens == 1250) + #expect(snapshot.sessionRequests == 42) + #expect(snapshot.last30DaysCostUSD == 10.75) + #expect(snapshot.last30DaysTokens == 1750) + #expect(snapshot.last30DaysRequests == 45) + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[1].cacheReadTokens == 400) + #expect(snapshot.daily[1].requestCount == 42) + #expect(snapshot.daily[1].modelBreakdowns?.first?.requestCount == 42) + #expect(snapshot.daily[1].modelBreakdowns?.first?.modelName == "gpt-5.2-codex") + } +} + +private actor OpenAIAdminUsageRetryScript: ProviderHTTPTransport { + private let costs: Data + private let completions: Data + private var completionsRequests = 0 + + init(costs: Data, completions: Data) { + self.costs = costs + self.completions = completions + } + + func completionsRequestCount() -> Int { + self.completionsRequests + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + if url.path.contains("/usage/completions") { + self.completionsRequests += 1 + if self.completionsRequests == 1 { + return (Data(), HTTPURLResponse( + url: url, + statusCode: 503, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + return (self.completions, HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + + return (self.costs, HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift index cad959884..357d49f91 100644 --- a/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardBrowserCookieImporterTests.swift @@ -1,10 +1,10 @@ -import CodexBarCore +import Foundation import Testing +@testable import CodexBarCore -@Suite struct OpenAIDashboardBrowserCookieImporterTests { @Test - func mismatchErrorMentionsSourceLabel() { + func `mismatch error mentions source label`() { let err = OpenAIDashboardBrowserCookieImporter.ImportError.noMatchingAccount( found: [ .init(sourceLabel: "Safari", email: "a@example.com"), @@ -14,4 +14,16 @@ struct OpenAIDashboardBrowserCookieImporterTests { #expect(msg.contains("Safari=a@example.com")) #expect(msg.contains("Chrome=b@example.com")) } + + @Test + func `timed out persistent validation keeps verified session`() { + #expect(OpenAIDashboardBrowserCookieImporter.shouldTrustVerifiedSession( + afterPersistFailure: URLError(.timedOut))) + } + + @Test + func `non-timeout persistent validation failures are not trusted`() { + #expect(!OpenAIDashboardBrowserCookieImporter.shouldTrustVerifiedSession( + afterPersistFailure: OpenAIDashboardBrowserCookieImporter.ImportError.dashboardStillRequiresLogin)) + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift index d8f0a7f4b..e75da4dc5 100644 --- a/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardFetcherCreditsWaitTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct OpenAIDashboardFetcherCreditsWaitTests { @Test - func waitsAfterScrollRequest() { + func `waits after scroll request`() { let now = Date() let shouldWait = OpenAIDashboardFetcher.shouldWaitForCreditsHistory(.init( now: now, @@ -18,7 +17,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func waitsBrieflyWhenHeaderVisibleButTableEmpty() { + func `waits briefly when header visible but table empty`() { let now = Date() let visibleAt = now.addingTimeInterval(-1.0) let shouldWait = OpenAIDashboardFetcher.shouldWaitForCreditsHistory(.init( @@ -32,7 +31,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func stopsWaitingAfterHeaderHasBeenVisibleLongEnough() { + func `stops waiting after header has been visible long enough`() { let now = Date() let visibleAt = now.addingTimeInterval(-3.0) let shouldWait = OpenAIDashboardFetcher.shouldWaitForCreditsHistory(.init( @@ -46,7 +45,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func waitsBrieflyAfterFirstDashboardSignalEvenWhenHeaderNotPresentYet() { + func `waits briefly after first dashboard signal even when header not present yet`() { let now = Date() let startedAt = now.addingTimeInterval(-2.0) let shouldWait = OpenAIDashboardFetcher.shouldWaitForCreditsHistory(.init( @@ -60,7 +59,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func stopsWaitingEventuallyWhenHeaderNeverAppears() { + func `stops waiting eventually when header never appears`() { let now = Date() let startedAt = now.addingTimeInterval(-7.0) let shouldWait = OpenAIDashboardFetcher.shouldWaitForCreditsHistory(.init( @@ -74,14 +73,106 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func sanitizedTimeoutPreservesPositiveCallerDeadline() { + func `usage breakdown recovery waits briefly after chart classification error`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForUsageBreakdownRecovery(.init( + now: now, + errorFirstSeenAt: now.addingTimeInterval(-1.0))) + #expect(shouldWait == true) + } + + @Test + func `usage breakdown recovery stops blocking partial snapshots`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForUsageBreakdownRecovery(.init( + now: now, + errorFirstSeenAt: now.addingTimeInterval(-5.0))) + #expect(shouldWait == false) + } + + @Test + func `probe waits briefly after reaching usage route without email or dashboard signals`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now.addingTimeInterval(-1.0), + dashboardSignalSeenAt: nil, + signedInEmail: nil, + hasDashboardSignal: false)) + #expect(shouldWait == true) + } + + @Test + func `probe waits briefly for email after dashboard signals appear`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now.addingTimeInterval(-3.0), + dashboardSignalSeenAt: now.addingTimeInterval(-1.0), + signedInEmail: nil, + hasDashboardSignal: true)) + #expect(shouldWait == true) + } + + @Test + func `probe stops waiting once signed in email is available`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now.addingTimeInterval(-0.2), + dashboardSignalSeenAt: now.addingTimeInterval(-0.2), + signedInEmail: "user@example.com", + hasDashboardSignal: true)) + #expect(shouldWait == false) + } + + @Test + func `probe handoff preserves page only after confirmed signed in email`() { + let result = OpenAIDashboardFetcher.ProbeResult( + href: "https://chatgpt.com/codex/cloud/settings/analytics#usage", + loginRequired: false, + workspacePicker: false, + cloudflareInterstitial: false, + signedInEmail: "user@example.com", + bodyText: "Credits remaining 42") + + #expect(OpenAIDashboardFetcher.shouldPreserveLoadedPageAfterProbe(result)) + } + + @Test + func `probe handoff does not preserve timed out usage page without email`() { + let result = OpenAIDashboardFetcher.ProbeResult( + href: "https://chatgpt.com/codex/cloud/settings/analytics#usage", + loginRequired: false, + workspacePicker: false, + cloudflareInterstitial: false, + signedInEmail: nil, + bodyText: "Codex Analytics") + + #expect(!OpenAIDashboardFetcher.shouldPreserveLoadedPageAfterProbe(result)) + } + + @Test + func `probe grace restarts after route reload resets readiness timestamps`() { + let now = Date() + let shouldWait = OpenAIDashboardFetcher.shouldWaitForProbeReadiness(.init( + now: now, + usageRouteSeenAt: now, + dashboardSignalSeenAt: nil, + signedInEmail: nil, + hasDashboardSignal: false)) + #expect(shouldWait == true) + } + + @Test + func `sanitized timeout preserves positive caller deadline`() { #expect(OpenAIDashboardFetcher.sanitizedTimeout(60) == 60) #expect(OpenAIDashboardFetcher.sanitizedTimeout(25) == 25) #expect(OpenAIDashboardFetcher.sanitizedTimeout(0.5) == 0.5) } @Test - func sanitizedTimeoutFallsBackForInvalidValues() { + func `sanitized timeout falls back for invalid values`() { #expect(OpenAIDashboardFetcher.sanitizedTimeout(0) == 1) #expect(OpenAIDashboardFetcher.sanitizedTimeout(-5) == 1) #expect(OpenAIDashboardFetcher.sanitizedTimeout(.infinity) == 1) @@ -89,7 +180,7 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func deadlineStartsAtCallStartAndRemainingTimeoutShrinksFromThere() { + func `deadline starts at call start and remaining timeout shrinks from there`() { let start = Date(timeIntervalSinceReferenceDate: 1000) let deadline = OpenAIDashboardFetcher.deadline(startingAt: start, timeout: 15) @@ -102,11 +193,122 @@ struct OpenAIDashboardFetcherCreditsWaitTests { } @Test - func remainingTimeoutDoesNotGoNegative() { + func `remaining timeout does not go negative`() { let deadline = Date(timeIntervalSinceReferenceDate: 2000) let remaining = OpenAIDashboardFetcher.remainingTimeout( until: deadline, now: deadline.addingTimeInterval(3)) #expect(remaining == 0) } + + @Test + func `usage route matcher accepts legacy settings route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/settings/usage")) + } + + @Test + func `usage route matcher accepts cloud settings route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage")) + } + + @Test + func `usage route matcher accepts analytics route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics")) + } + + @Test + func `usage route matcher accepts analytics usage hash route`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics#usage")) + } + + @Test + func `usage route matcher accepts trailing slash variants`() { + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/settings/usage/")) + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/usage/")) + #expect(OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex/cloud/settings/analytics/")) + } + + @Test + func `usage route matcher rejects unrelated routes`() { + #expect(!OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/")) + #expect(!OpenAIDashboardFetcher.isUsageRoute("https://chatgpt.com/codex")) + #expect(!OpenAIDashboardFetcher.isUsageRoute(nil)) + } + + @Test + func `dashboard requests prefer English localization`() throws { + let url = try #require(URL(string: "https://chatgpt.com/codex/cloud/settings/analytics#usage")) + let request = OpenAIDashboardFetcher.usageURLRequest(url: url) + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `usage api request carries cookies and English localization`() { + let request = OpenAIDashboardFetcher.dashboardUsageAPIRequest(cookieHeader: "a=b") + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/wham/usage") + #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `identity api request carries cookies and English localization`() throws { + let url = try #require(URL(string: "https://chatgpt.com/backend-api/me")) + let request = OpenAIDashboardFetcher.dashboardIdentityAPIRequest(url: url, cookieHeader: "a=b") + + #expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/me") + #expect(request.value(forHTTPHeaderField: "Cookie") == "a=b") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9") + } + + @Test + func `usage api data maps language independent rate limits and credits`() throws { + let json = """ + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { + "used_percent": 12, + "reset_at": 1700003600, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 34, + "reset_at": 1700604800, + "limit_window_seconds": 604800 + } + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": 42.5 + } + } + """ + let response = try CodexOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8)) + let data = OpenAIDashboardFetcher.dashboardAPIData(from: response) + + #expect(data.primaryLimit?.usedPercent == 12) + #expect(data.primaryLimit?.windowMinutes == 300) + #expect(data.secondaryLimit?.usedPercent == 34) + #expect(data.secondaryLimit?.windowMinutes == 10080) + #expect(data.creditsRemaining == 42.5) + #expect(data.accountPlan == "pro") + #expect(data.hasUsageData) + } + + @Test + func `find first email searches nested api payloads`() { + let json = """ + { + "accounts": [ + { "profile": { "name": "Test" } }, + { "profile": { "email": "nested@example.com" } } + ] + } + """ + + #expect(OpenAIDashboardFetcher.findFirstEmail(inJSONData: Data(json.utf8)) == "nested@example.com") + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift new file mode 100644 index 000000000..b6547e14f --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardModelsTests.swift @@ -0,0 +1,94 @@ +import CodexBarCore +import Foundation +import Testing + +struct OpenAIDashboardModelsTests { + @Test + func `removes skill usage services from usage breakdown`() { + let breakdown = [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 10), + OpenAIDashboardServiceUsage(service: "Skillusage:imagegen", creditsUsed: 7), + OpenAIDashboardServiceUsage(service: " skillusage:github:github ", creditsUsed: 2), + ], + totalCreditsUsed: 19), + OpenAIDashboardDailyBreakdown( + day: "2026-04-29", + services: [ + OpenAIDashboardServiceUsage(service: "Skillusage:deep Research", creditsUsed: 3), + ], + totalCreditsUsed: 3), + ] + + let filtered = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) + + #expect(filtered == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "Desktop App", creditsUsed: 10), + ], + totalCreditsUsed: 10), + ]) + } + + @Test + func `snapshot initializer sanitizes usage breakdown`() { + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "CLI", creditsUsed: 4), + OpenAIDashboardServiceUsage(service: "Skillusage:pdf Renderer", creditsUsed: 6), + ], + totalCreditsUsed: 10), + ], + creditsPurchaseURL: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.usageBreakdown == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-30", + services: [ + OpenAIDashboardServiceUsage(service: "CLI", creditsUsed: 4), + ], + totalCreditsUsed: 4), + ]) + } + + @Test + func `snapshot decoder drops empty zero usage buckets`() throws { + let json = """ + { + "signedInEmail": "codex@example.com", + "codeReviewRemainingPercent": null, + "creditEvents": [], + "dailyBreakdown": [], + "usageBreakdown": [ + { "day": "2026-04-30", "services": [], "totalCreditsUsed": 0 }, + { "day": "2026-04-29", "services": [], "totalCreditsUsed": 4 } + ], + "creditsPurchaseURL": null, + "updatedAt": "2026-04-30T19:27:07Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) + + #expect(snapshot.usageBreakdown == [ + OpenAIDashboardDailyBreakdown( + day: "2026-04-29", + services: [], + totalCreditsUsed: 4), + ]) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index c1045f5da..0ee8e0e70 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -3,23 +3,46 @@ import Testing import WebKit @testable import CodexBarCore -@Suite +@Suite(.serialized) struct OpenAIDashboardNavigationDelegateTests { - @Test("ignores NSURLErrorCancelled") - func ignoresCancelledNavigationError() { + final class DelegateBox: @unchecked Sendable { + var delegate: NavigationDelegate? + } + + @MainActor + private func waitForResult( + _ result: @escaping () -> Result?, + timeout: TimeInterval = NavigationDelegate.postCommitSuccessDelay + 10.0) async -> Result? + { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let result = result() { return result } + try? await Task.sleep(nanoseconds: 50_000_000) + } + return result() + } + + @Test + func `ignores NSURLErrorCancelled`() { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) #expect(NavigationDelegate.shouldIgnoreNavigationError(error)) } - @Test("does not ignore non-cancelled URL errors") - func doesNotIgnoreOtherURLErrors() { + @Test + func `ignores WebKit frame load interrupted by policy change`() { + let error = NSError(domain: "WebKitErrorDomain", code: 102) + #expect(NavigationDelegate.shouldIgnoreNavigationError(error)) + } + + @Test + func `does not ignore non-cancelled URL errors`() { let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) #expect(!NavigationDelegate.shouldIgnoreNavigationError(error)) } @MainActor - @Test("cancelled failure is ignored until finish") - func cancelledFailureIsIgnoredUntilFinish() { + @Test + func `cancelled failure is ignored until finish`() { let webView = WKWebView() var result: Result? let delegate = NavigationDelegate { result = $0 } @@ -37,8 +60,54 @@ struct OpenAIDashboardNavigationDelegateTests { } @MainActor - @Test("cancelled provisional failure is ignored until real failure") - func cancelledProvisionalFailureIsIgnoredUntilRealFailure() { + @Test + func `commit completes navigation successfully after grace period`() async { + let webView = WKWebView() + var result: Result? + let box = DelegateBox() + box.delegate = NavigationDelegate { result = $0 } + + box.delegate?.webView(webView, didCommit: nil) + #expect(result == nil) + + let completed = await self.waitForResult { result } + box.delegate = nil + + switch completed { + case .success?: + #expect(Bool(true)) + default: + #expect(Bool(false)) + } + } + + @MainActor + @Test + func `post commit failure wins before delayed success`() async { + let webView = WKWebView() + var result: Result? + let box = DelegateBox() + box.delegate = NavigationDelegate { result = $0 } + + box.delegate?.webView(webView, didCommit: nil) + let timeout = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut) + box.delegate?.webView(webView, didFail: nil, withError: timeout) + + let completed = await self.waitForResult { result } + box.delegate = nil + + switch completed { + case let .failure(error as NSError)?: + #expect(error.domain == NSURLErrorDomain) + #expect(error.code == NSURLErrorTimedOut) + default: + #expect(Bool(false)) + } + } + + @MainActor + @Test + func `cancelled provisional failure is ignored until real failure`() { let webView = WKWebView() var result: Result? let delegate = NavigationDelegate { result = $0 } @@ -62,16 +131,43 @@ struct OpenAIDashboardNavigationDelegateTests { } @MainActor - @Test("navigation timeout fails with timed out error") - func navigationTimeoutFailsWithTimedOutError() async { + @Test + func `frame load interrupted provisional failure is ignored until finish`() { + let webView = WKWebView() var result: Result? let delegate = NavigationDelegate { result = $0 } - delegate.armTimeout(seconds: 0.01) - try? await Task.sleep(for: .milliseconds(30)) + delegate.webView( + webView, + didFailProvisionalNavigation: nil, + withError: NSError(domain: "WebKitErrorDomain", code: 102)) + #expect(result == nil) + + delegate.webView(webView, didFinish: nil) + + switch result { + case .success?: + #expect(Bool(true)) + default: + #expect(Bool(false)) + } + } + + @Test + func `navigation timeout fails with timed out error`() async { + let result = await withCheckedContinuation { (continuation: CheckedContinuation, Never>) in + Task { @MainActor in + let box = DelegateBox() + box.delegate = NavigationDelegate { result in + continuation.resume(returning: result) + box.delegate = nil + } + box.delegate?.armTimeout(seconds: 0.01) + } + } switch result { - case let .failure(error as URLError)?: + case let .failure(error as URLError): #expect(error.code == .timedOut) default: #expect(Bool(false)) diff --git a/Tests/CodexBarTests/OpenAIDashboardOffscreenHostTests.swift b/Tests/CodexBarTests/OpenAIDashboardOffscreenHostTests.swift index 74d1de6a6..b39e5a571 100644 --- a/Tests/CodexBarTests/OpenAIDashboardOffscreenHostTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardOffscreenHostTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct OpenAIDashboardOffscreenHostTests { @Test - func offscreenHostFrameOnlyIntersectsByASliver() { + func `offscreen host frame only intersects by A sliver`() { let visibleFrame = CGRect(x: 0, y: 0, width: 1000, height: 800) let frame = OpenAIDashboardFetcher.offscreenHostWindowFrame(for: visibleFrame) let intersection = frame.intersection(visibleFrame) @@ -19,7 +18,7 @@ struct OpenAIDashboardOffscreenHostTests { } @Test - func offscreenHostAlphaValueIsNonZeroButTiny() { + func `offscreen host alpha value is non zero but tiny`() { let alpha = OpenAIDashboardFetcher.offscreenHostAlphaValue() #expect(alpha > 0) #expect(alpha <= 0.001) diff --git a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift index c84391dfb..acb41ae82 100644 --- a/Tests/CodexBarTests/OpenAIDashboardParserTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardParserTests.swift @@ -3,10 +3,9 @@ import Foundation import Testing @testable import CodexBar -@Suite struct OpenAIDashboardParserTests { @Test - func parsesSignedInEmailFromClientBootstrapHTML() { + func `parses signed in email from client bootstrap HTML`() { let html = """ @@ -22,26 +21,52 @@ struct OpenAIDashboardParserTests { } @Test - func parsesCodeReviewRemainingPercent_inline() { + func `parses code review remaining percent inline`() { let body = "Balance\nCode review 42% remaining\nCredits remaining 291" #expect(OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: body) == 42) } @Test - func parsesCodeReviewRemainingPercent_multiline() { + func `parses code review remaining percent multiline`() { let body = "Balance\nCode review\n100% remaining\nWeekly usage limit\n0% remaining" #expect(OpenAIDashboardParser.parseCodeReviewRemainingPercent(bodyText: body) == 100) } @Test - func parsesCreditsRemaining() { + func `parses code review limit with reset`() { + let body = """ + Balance + Code review + 42% remaining + Resets tomorrow at 2:15 PM + """ + let limit = OpenAIDashboardParser.parseCodeReviewLimit(bodyText: body) + #expect(abs((limit?.usedPercent ?? 0) - 58) < 0.001) + #expect(limit?.resetDescription?.lowercased().contains("resets") == true) + } + + @Test + func `parses core review limit with reset`() { + let body = """ + Balance + Core review + 42% remaining + Resets tomorrow at 2:15 PM + """ + let limit = OpenAIDashboardParser.parseCodeReviewLimit(bodyText: body) + #expect(abs((limit?.usedPercent ?? 0) - 58) < 0.001) + #expect(limit?.resetDescription?.lowercased().contains("resets") == true) + } + + @Test + func `parses credits remaining`() { let body = "Balance\nCredits remaining 1,234.56\nUsage" let value = OpenAIDashboardParser.parseCreditsRemaining(bodyText: body) #expect(abs((value ?? 0) - 1234.56) < 0.001) } @Test - func parsesRateLimits() { + func `parses rate limits`() { let body = """ Usage limits 5h limit @@ -60,7 +85,18 @@ struct OpenAIDashboardParserTests { } @Test - func parsesPlanFromClientBootstrap() { + func `parses spaced five hour limit label`() { + let body = """ + Limite 5 h + 72 % restant + """ + let limits = OpenAIDashboardParser.parseRateLimits(bodyText: body) + #expect(abs((limits.primary?.usedPercent ?? 0) - 28) < 0.001) + #expect(limits.primary?.windowMinutes == 300) + } + + @Test + func `parses plan from client bootstrap`() { let html = """ @@ -74,7 +110,21 @@ struct OpenAIDashboardParserTests { } @Test - func parsesCreditEventsFromTableRows() { + func `parses prolite plan from client bootstrap`() { + let html = """ + + + + + + """ + #expect(OpenAIDashboardParser.parsePlanFromHTML(html: html) == "Pro 5x") + } + + @Test + func `parses credit events from table rows`() { let rows: [[String]] = [ ["Dec 18, 2025", "CLI", "397.205 credits"], ["Dec 17, 2025", "GitHub Code Review", "506.235 credits"], @@ -88,7 +138,27 @@ struct OpenAIDashboardParserTests { } @Test - func buildsDailyBreakdownFromEvents() throws { + func `parses credit event amount with localized credit label`() { + let rows: [[String]] = [ + ["Dec 18, 2025", "CLI", "397,205 crédits"], + ] + let events = OpenAIDashboardParser.parseCreditEvents(rows: rows) + #expect(events.count == 1) + #expect(abs((events.first?.creditsUsed ?? 0) - 397.205) < 0.0001) + } + + @Test + func `parses credit event amount with english comma thousands`() { + let rows: [[String]] = [ + ["Dec 18, 2025", "CLI", "1,234 credits"], + ] + let events = OpenAIDashboardParser.parseCreditEvents(rows: rows) + #expect(events.count == 1) + #expect(events.first?.creditsUsed == 1234) + } + + @Test + func `builds daily breakdown from events`() throws { let calendar = Calendar(identifier: .gregorian) var components = DateComponents() components.calendar = calendar @@ -116,7 +186,7 @@ struct OpenAIDashboardParserTests { } @Test - func decodesSnapshotWithoutUsageBreakdownField() throws { + func `decodes snapshot without usage breakdown field`() throws { let json = """ { "signedInEmail": "user@example.com", @@ -131,4 +201,50 @@ struct OpenAIDashboardParserTests { let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) #expect(snapshot.usageBreakdown.isEmpty) } + + @Test + func `weekly only dashboard usage projects into secondary slot`() { + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 25, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: "pro", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let usage = snapshot.toUsageSnapshot(provider: .codex) + + #expect(usage?.primary == nil) + #expect(usage?.secondary?.usedPercent == 25) + #expect(usage?.secondary?.windowMinutes == 10080) + #expect(usage?.identity?.providerID == .codex) + #expect(usage?.identity?.accountEmail == "user@example.com") + } + + @Test + func `dashboard usage projection returns nil when all limits are absent`() { + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: "pro", + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(snapshot.toUsageSnapshot(provider: .codex) == nil) + } } diff --git a/Tests/CodexBarTests/OpenAIDashboardScrapeScriptTests.swift b/Tests/CodexBarTests/OpenAIDashboardScrapeScriptTests.swift new file mode 100644 index 000000000..56901c24b --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardScrapeScriptTests.swift @@ -0,0 +1,236 @@ +#if os(macOS) +import Foundation +import Testing +import WebKit +@testable import CodexBarCore + +@MainActor +@Suite(.serialized) +struct OpenAIDashboardScrapeScriptTests { + @Test + func `scraper returns structured account fields without full html`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.bootstrapAccountHTML, baseURL: nil) + try await Self.waitForFixture(webView, elementID: "account-fixture") + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + + #expect(dict["bodyHTML"] == nil) + #expect(dict["signedInEmail"] as? String == "user@example.com") + #expect(dict["authStatus"] as? String == "logged_in") + #expect(dict["accountPlan"] as? String == "Pro 5x") + } + + @Test + func `usage breakdown scraper ignores neighboring client charts`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.multiChartHTML, baseURL: nil) + try await Self.waitForFixture(webView) + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + let debug = dict["usageBreakdownDebug"] as? String + let raw = try #require(dict["usageBreakdownJSON"] as? String, "debug: \(debug ?? "nil")") + let decoded = try JSONDecoder().decode([OpenAIDashboardDailyBreakdown].self, from: Data(raw.utf8)) + + #expect(decoded.count == 1) + #expect(decoded.first?.day == "2026-05-01") + #expect(decoded.first?.totalCreditsUsed == 30) + #expect((decoded.first?.services.map(\.service) ?? []) == ["Desktop", "CLI"]) + } + + @Test + func `usage breakdown scraper reports wrong chart instead of accepting it`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.clientOnlyChartHTML, baseURL: nil) + try await Self.waitForFixture(webView, elementID: "client-chart") + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + + #expect((dict["usageBreakdownJSON"] as? String) == nil) + #expect((dict["usageBreakdownError"] as? String)?.contains("Threads and turns by client") == true) + } + + @Test + func `usage breakdown scraper rejects non english chart titles`() async throws { + if Self.shouldSkipOnCI() { return } + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + _ = webView.loadHTMLString(Self.localizedUsageChartHTML, baseURL: nil) + try await Self.waitForFixture(webView) + + let any = try await webView.evaluateJavaScript(openAIDashboardScrapeScript) + let dict = try #require(any as? [String: Any]) + + #expect((dict["usageBreakdownJSON"] as? String) == nil) + #expect( + (dict["usageBreakdownError"] as? String)? + .contains("No English usage breakdown chart title found") == true) + } + + private static func shouldSkipOnCI() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" + } + + private static func waitForFixture(_ webView: WKWebView, elementID: String = "usage-chart") async throws { + let deadline = Date().addingTimeInterval(2) + while Date() < deadline { + let loaded = try? await webView.evaluateJavaScript( + "document.getElementById('\(elementID)') !== null") as? Bool + if loaded == true { return } + try await Task.sleep(for: .milliseconds(50)) + } + } + + private static let bootstrapAccountHTML = """ + + +
Usage limits
+ + + + + """ + + private static let multiChartHTML = """ + + +
+

Usage breakdown

+
+

Personal usage

+
+ Daily threads by client + + + + + +
+
+

Product activity

+ + + Daily threads by client + + + + + +
+
+

Tokens by model

+ + + + + +
+ + + + """ + + private static let clientOnlyChartHTML = """ + + +
+

Threads and turns by client

+ + + + + +
+ + + + """ + + private static let localizedUsageChartHTML = """ + + +
+

Desglose de uso

+ + + + + +
+ + + + """ +} +#endif diff --git a/Tests/CodexBarTests/OpenAIDashboardSparkTests.swift b/Tests/CodexBarTests/OpenAIDashboardSparkTests.swift new file mode 100644 index 000000000..45ad343f8 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardSparkTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Dashboard-path coverage for Codex `additional_rate_limits` (e.g. GPT-5.3-Codex-Spark): the +/// OpenAI web dashboard usage API decodes the same `wham/usage` JSON as the OAuth path, so Spark +/// limits must survive the `dashboardAPIData -> DashboardSnapshotComponents -> OpenAIDashboardSnapshot +/// -> fromAttachedDashboard -> UsageSnapshot.extraRateWindows` chain without disturbing the +/// existing primary/weekly/credits/plan mapping. +struct OpenAIDashboardSparkTests { + private static func response(from json: String) throws -> CodexUsageResponse { + try JSONDecoder().decode(CodexUsageResponse.self, from: Data(json.utf8)) + } + + @Test + func `dashboard api data maps additional spark limit into extra windows`() throws { + let json = """ + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 }, + "secondary_window": { "used_percent": 43, "reset_at": 1767407914, "limit_window_seconds": 604800 } + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "primary_window": { "used_percent": 30, "reset_at": 1766948068, "limit_window_seconds": 18000 }, + "secondary_window": { "used_percent": 100, "reset_at": 1767407914, "limit_window_seconds": 604800 } + } + } + ] + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + // Primary/weekly/credits/plan continue to map exactly as before. + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.secondaryLimit?.usedPercent == 43) + #expect(apiData.accountPlan == "pro") + // Spark surfaces with stable ids/titles for the additional 5-hour and weekly windows. + #expect(apiData.extraRateWindows.count == 2) + let spark = try #require(apiData.extraRateWindows.first) + #expect(spark.id == "codex-spark") + #expect(spark.title == "Codex Spark 5-hour") + #expect(spark.window.usedPercent == 30) + #expect(spark.window.windowMinutes == 300) + #expect(spark.window.resetsAt != nil) + let weekly = try #require(apiData.extraRateWindows.last) + #expect(weekly.id == "codex-spark-weekly") + #expect(weekly.title == "Codex Spark Weekly") + #expect(weekly.window.usedPercent == 100) + #expect(weekly.window.windowMinutes == 10080) + #expect(weekly.window.resetsAt != nil) + } + + @Test + func `dashboard api data has empty extra windows when additional limits are absent`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 } + } + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.extraRateWindows.isEmpty) + } + + @Test + func `dashboard api data tolerates non array additional limits while keeping primary`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 } + }, + "additional_rate_limits": "unexpected" + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.extraRateWindows.isEmpty) + } + + @Test + func `dashboard api data keeps valid spark when a malformed sibling is present`() throws { + // Lossy per-element decode (shared with the OAuth path via CodexUsageResponse) means a single + // malformed entry cannot discard its valid siblings. + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 } + }, + "additional_rate_limits": [ + "garbage-not-an-object", + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "primary_window": { "used_percent": 30, "reset_at": 1766948068, "limit_window_seconds": 18000 } + } + }, + 42 + ] + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.extraRateWindows.count == 1) + #expect(apiData.extraRateWindows.first?.id == "codex-spark") + #expect(apiData.extraRateWindows.first?.window.usedPercent == 30) + } + + @Test + func `dashboard snapshot exposes extra rate windows via to usage snapshot`() throws { + let now = Date(timeIntervalSince1970: 1_766_948_000) + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondaryLimit: RateWindow( + usedPercent: 43, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "codex-spark", + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil)), + NamedRateWindow( + id: "codex-spark-weekly", + title: "Codex Spark Weekly", + window: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now) + + let usage = try #require(snapshot.toUsageSnapshot(provider: .codex)) + // Primary/weekly behavior preserved. + #expect(usage.primary?.usedPercent == 22) + #expect(usage.secondary?.usedPercent == 43) + // Spark surfaces through UsageSnapshot.extraRateWindows for dashboard-source users. + let extras = try #require(usage.extraRateWindows) + #expect(extras.map(\.id) == ["codex-spark", "codex-spark-weekly"]) + #expect(extras.first?.window.usedPercent == 30) + #expect(extras.last?.window.usedPercent == 100) + } + + @Test + func `dashboard snapshot codable round trips extra rate windows`() throws { + let now = Date(timeIntervalSince1970: 1_766_948_000) + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: nil, + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + extraRateWindows: [ + NamedRateWindow( + id: "codex-spark", + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil)), + NamedRateWindow( + id: "codex-spark-weekly", + title: "Codex Spark Weekly", + window: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let data = try encoder.encode(snapshot) + let decoded = try decoder.decode(OpenAIDashboardSnapshot.self, from: data) + #expect(decoded.extraRateWindows?.map(\.id) == ["codex-spark", "codex-spark-weekly"]) + #expect(decoded.extraRateWindows?.first?.window.usedPercent == 30) + #expect(decoded.extraRateWindows?.last?.window.usedPercent == 100) + } + + @Test + func `dashboard snapshot decoder preserves absence of extra rate windows`() throws { + // Older cached snapshots predate the field; decoding such payloads must yield nil and never + // throw, so existing dashboard caches keep working. + let json = """ + { + "signedInEmail": "codex@example.com", + "codeReviewRemainingPercent": null, + "creditEvents": [], + "dailyBreakdown": [], + "usageBreakdown": [], + "creditsPurchaseURL": null, + "updatedAt": "2026-04-30T19:27:07Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) + #expect(snapshot.extraRateWindows == nil) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift index c53cb7394..fb88a4103 100644 --- a/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardWebViewCacheTests.swift @@ -12,10 +12,16 @@ import WebKit @MainActor @Suite(.serialized) struct OpenAIDashboardWebViewCacheTests { + private func shouldSkipOnCI() -> Bool { + let env = ProcessInfo.processInfo.environment + return env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" + } + // MARK: - Data Store Identity Tests - @Test("WKWebsiteDataStore should return same instance for same email") - func dataStoreReturnsSameInstance() { + @Test + func `WKWebsiteDataStore should return same instance for same email`() { + if self.shouldSkipOnCI() { return } OpenAIDashboardWebsiteDataStore.clearCacheForTesting() let store1 = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: "test@example.com") @@ -34,8 +40,9 @@ struct OpenAIDashboardWebViewCacheTests { // MARK: - WebView Reuse Tests - @Test("WebView should be cached after release, not destroyed") - func webViewCachedAfterRelease() async throws { + @Test + func `WebView should be cached after release, not destroyed`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -67,8 +74,9 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } - @Test("Different data stores should have separate cached WebViews") - func separateCachesPerDataStore() async throws { + @Test + func `Different data stores should have separate cached WebViews`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store1 = WKWebsiteDataStore.nonPersistent() let store2 = WKWebsiteDataStore.nonPersistent() @@ -98,53 +106,146 @@ struct OpenAIDashboardWebViewCacheTests { // MARK: - Idle Timeout / Pruning Tests - @Test("WebView should be pruned after idle timeout") - func webViewPrunedAfterIdleTimeout() async throws { + @Test + func `WebView should be pruned after idle timeout`() { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() - let url = try #require(URL(string: "about:blank")) - - // Acquire and release - let lease = try await cache.acquire( - websiteDataStore: store, - usageURL: url, - logger: nil) - lease.release() + cache.cacheEntryForTesting(websiteDataStore: store) #expect(cache.hasCachedEntry(for: store), "Should be cached immediately after release") - // Simulate time passing beyond idle timeout (10 minutes + buffer) - let futureTime = Date().addingTimeInterval(11 * 60) + // Simulate time passing beyond the configured idle timeout. + let futureTime = Date().addingTimeInterval(cache.idleTimeoutForTesting + 5) cache.pruneForTesting(now: futureTime) #expect(!cache.hasCachedEntry(for: store), "Should be pruned after idle timeout") #expect(cache.entryCount == 0, "Should have no cached entries after prune") } - @Test("Recently used WebView should not be pruned") - func recentlyUsedWebViewNotPruned() async throws { + @Test + func `Recently used WebView should not be pruned`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + + // Simulate time passing comfortably within the configured idle timeout. + let nearFutureTime = Date().addingTimeInterval(max(1, cache.idleTimeoutForTesting / 2)) + cache.pruneForTesting(now: nearFutureTime) + + #expect(cache.hasCachedEntry(for: store), "Should still be cached within idle timeout") + cache.clearAllForTesting() + } + + @Test + func `Preserved page handoff is consumed only once`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting)) + + #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed") + #expect(cache.consumePreservedPageForTesting(websiteDataStore: store), "First acquire should reuse handoff") + #expect( + !cache.consumePreservedPageForTesting(websiteDataStore: store), + "Second acquire should not keep reusing preserved page") + + cache.clearAllForTesting() + } + + @Test + func `Expired preserved page is cleared before idle eviction`() { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + cache.cacheEntryForTesting(websiteDataStore: store) + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(1)) + + let afterExpiry = Date().addingTimeInterval(cache.preservedPageHandoffTimeoutForTesting + 1) + cache.pruneForTesting(now: afterExpiry) + + #expect(!cache.hasPreservedPageForTesting(for: store), "Expired preserved page should be cleared") + #expect(cache.hasCachedEntry(for: store), "Entry should remain cached after page handoff expires") + + cache.clearAllForTesting() + } + + @Test + func `Preserved page expiry is scheduled without future cache activity`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + let webView = cache.cacheEntryForTesting(websiteDataStore: store) + + _ = webView.loadHTMLString("alive", baseURL: nil) + try? await Task.sleep(for: .milliseconds(150)) + + cache.markPreservedPageForTesting( + websiteDataStore: store, + expiresAt: Date().addingTimeInterval(0.2)) + + #expect(cache.hasPreservedPageForTesting(for: store), "Expected preserved page handoff to be armed") + + var bodyText: String? + let deadline = Date().addingTimeInterval(2) + repeat { + try? await Task.sleep(for: .milliseconds(100)) + bodyText = try await webView.evaluateJavaScript( + "document.body ? String(document.body.innerText || '') : ''") as? String + } while (cache.hasPreservedPageForTesting(for: store) || bodyText?.isEmpty != true) && Date() < deadline + + #expect(!cache.hasPreservedPageForTesting(for: store), "Expected scheduled expiry to clear preserved page") + #expect(bodyText?.isEmpty == true, "Expected scheduled expiry to detach the preserved page to about:blank") + + cache.clearAllForTesting() + } + + @Test + func `Reused page reset clears one shot scraper globals`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) - // Acquire and release let lease = try await cache.acquire( websiteDataStore: store, usageURL: url, logger: nil) - lease.release() - // Simulate time passing within idle timeout (5 minutes) - let nearFutureTime = Date().addingTimeInterval(5 * 60) - cache.pruneForTesting(now: nearFutureTime) + _ = try await lease.webView.evaluateJavaScript( + """ + window.__codexbarDidScrollToCredits = true; + window.__codexbarUsageBreakdownJSON = '[{"day":"2026-04-19"}]'; + window.__codexbarUsageBreakdownDebug = 'debug'; + true; + """) - #expect(cache.hasCachedEntry(for: store), "Should still be cached within idle timeout") + #expect(await cache.resetReusablePageStateForTesting(lease.webView)) + + let reset = try await lease.webView.evaluateJavaScript( + """ + typeof window.__codexbarDidScrollToCredits === 'undefined' && + typeof window.__codexbarUsageBreakdownJSON === 'undefined' && + typeof window.__codexbarUsageBreakdownDebug === 'undefined' + """) as? Bool + + #expect(reset == true, "Expected one-shot scraper globals to be cleared before reuse") + + lease.release() + cache.clearAllForTesting() } // MARK: - Eviction Tests - @Test("Evict should remove specific WebView from cache") - func evictRemovesSpecificWebView() async throws { + @Test + func `Evict should remove specific WebView from cache`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store1 = WKWebsiteDataStore.nonPersistent() let store2 = WKWebsiteDataStore.nonPersistent() @@ -168,10 +269,55 @@ struct OpenAIDashboardWebViewCacheTests { cache.clearAllForTesting() } + @Test + func `Evicted WebView should not be reused on next acquire`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let lease1 = try await cache.acquire(websiteDataStore: store, usageURL: url, logger: nil) + let webView1 = lease1.webView + lease1.release() + + cache.evict(websiteDataStore: store) + + let lease2 = try await cache.acquire(websiteDataStore: store, usageURL: url, logger: nil) + let webView2 = lease2.webView + + #expect(webView1 !== webView2, "Acquire after eviction should create a fresh WebView") + + lease2.release() + cache.clearAllForTesting() + } + + @Test + func `Evict all should remove every cached WebView`() async throws { + if self.shouldSkipOnCI() { return } + let cache = OpenAIDashboardWebViewCache() + let store1 = WKWebsiteDataStore.nonPersistent() + let store2 = WKWebsiteDataStore.nonPersistent() + let url = try #require(URL(string: "about:blank")) + + let lease1 = try await cache.acquire(websiteDataStore: store1, usageURL: url, logger: nil) + lease1.release() + let lease2 = try await cache.acquire(websiteDataStore: store2, usageURL: url, logger: nil) + lease2.release() + + #expect(cache.entryCount == 2, "Should have two cached entries") + + cache.evictAll() + + #expect(cache.entryCount == 0, "Evict all should remove every cached entry") + #expect(!cache.hasCachedEntry(for: store1), "First store should be evicted") + #expect(!cache.hasCachedEntry(for: store2), "Second store should be evicted") + } + // MARK: - Busy WebView Tests - @Test("Busy WebView should create temporary WebView for concurrent access") - func busyWebViewCreatesTemporary() async throws { + @Test + func `Busy WebView should create temporary WebView for concurrent access`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -205,8 +351,9 @@ struct OpenAIDashboardWebViewCacheTests { // MARK: - Network Traffic Regression Prevention - @Test("Multiple sequential fetches should reuse same WebView (network optimization)") - func sequentialFetchesReuseWebView() async throws { + @Test + func `Multiple sequential fetches should reuse same WebView (network optimization)`() async throws { + if self.shouldSkipOnCI() { return } let cache = OpenAIDashboardWebViewCache() let store = WKWebsiteDataStore.nonPersistent() let url = try #require(URL(string: "about:blank")) @@ -239,8 +386,9 @@ struct OpenAIDashboardWebViewCacheTests { // MARK: - Integration Test with Real Data Store Factory - @Test("Sequential fetches with OpenAIDashboardWebsiteDataStore should reuse WebView") - func sequentialFetchesWithRealDataStoreFactory() async throws { + @Test + func `Sequential fetches with OpenAIDashboardWebsiteDataStore should reuse WebView`() async throws { + if self.shouldSkipOnCI() { return } OpenAIDashboardWebsiteDataStore.clearCacheForTesting() let cache = OpenAIDashboardWebViewCache() let url = try #require(URL(string: "about:blank")) diff --git a/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift b/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift index 7113583f5..b3a29b839 100644 --- a/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift +++ b/Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct OpenAIWebAccountSwitchTests { @Test - func clearsDashboardWhenCodexEmailChanges() { + func `clears dashboard when codex email changes`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "OpenAIWebAccountSwitchTests-clears"), zaiTokenStore: NoopZaiTokenStore(), @@ -36,7 +35,7 @@ struct OpenAIWebAccountSwitchTests { } @Test - func keepsDashboardWhenCodexEmailStaysSame() { + func `keeps dashboard when codex email stays same`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "OpenAIWebAccountSwitchTests-keeps"), zaiTokenStore: NoopZaiTokenStore(), diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift new file mode 100644 index 000000000..eead8bb84 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +@testable import CodexBar + +struct OpenAIWebRefreshGateTests { + @Test + func `Battery saver keeps background OpenAI web refreshes off`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: false)) + + #expect(shouldRun == false) + } + + @Test + func `Disabling battery saver restores normal OpenAI web refreshes`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false)) + + #expect(shouldRun == true) + } + + @Test + func `Manual refresh still forces OpenAI web refreshes with battery saver enabled`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: true)) + + #expect(shouldRun == true) + } + + @Test + func `Battery saver stale-submenu refresh respects the cooldown`() { + let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: true) + + #expect(shouldForce == false) + } + + @Test + func `Normal stale-submenu refresh still forces when battery saver is off`() { + let shouldForce = UsageStore.forceOpenAIWebRefreshForStaleRequest(batterySaverEnabled: false) + + #expect(shouldForce == true) + } + + @Test + func `Recent successful dashboard refresh stays throttled`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-60), + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test + func `Recent failed dashboard refresh also stays throttled`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: false, + lastError: "login required", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test + func `Force refresh bypasses throttle after failures`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: true, + accountDidChange: false, + lastError: "login required", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } + + @Test + func `Account switches bypass the prior-attempt cooldown`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebRefresh(.init( + force: false, + accountDidChange: true, + lastError: "mismatch", + lastSnapshotAt: nil, + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift new file mode 100644 index 000000000..ca060b0a3 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -0,0 +1,299 @@ +#if os(macOS) + +import Foundation +import SQLite3 +import Testing +@testable import CodexBarCore + +struct OpenCodeGoLocalUsageReaderTests { + @Test + func `reads local OpenCode Go history into usage windows`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-05T12:00:00.000Z"), + cost: 6.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-02-25T07:53:16.000Z"), + cost: 2.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 30) + #expect(snapshot.monthlyUsagePercent == 18.3) + #expect(snapshot.rollingResetInSec == 14400) + #expect(snapshot.weeklyResetInSec == 216_000) + #expect(snapshot.monthlyResetInSec == 1_626_796) + } + + @Test + func `auth without history falls through to web strategy`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.historyUnavailable("database not found")) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + @Test + func `auth with unreadable history falls through to web strategy`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + var db: OpaquePointer? + guard sqlite3_open(env.databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + sqlite3_close(db) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.self) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + @Test + func `monthly window keeps original anchor after shorter month clamp`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-01-31T00:00:00.000Z"), + cost: 1.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-29T10:00:00.000Z"), + cost: 6.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-29T12:00:00.000Z")) / 1000) + let snapshot = try reader.fetch(now: now) + + #expect(snapshot.monthlyUsagePercent == 10) + #expect(snapshot.monthlyResetInSec == 129_600) + } + + @Test + func `reads step finish parts when message only stores metadata`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: nil) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 10) + #expect(snapshot.monthlyUsagePercent == 5) + } + + @Test + func `does not double count step finish parts when message has cost`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 10) + #expect(snapshot.monthlyUsagePercent == 5) + } + + @Test + func `missing auth and history is not detected`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.notDetected) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + private static func makeEnvironment() throws -> (root: URL, authURL: URL, databaseURL: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodeGoLocalUsageReaderTests-\(UUID().uuidString)", isDirectory: true) + let directory = root + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return ( + root, + directory.appendingPathComponent("auth.json", isDirectory: false), + directory.appendingPathComponent("opencode.db", isDirectory: false)) + } + + private static func writeAuth(to url: URL) throws { + let data = Data(#"{"opencode-go":{"type":"api-key","key":"go-key"}}"#.utf8) + try data.write(to: url) + } + + private static func createDatabase(at url: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try Self.exec( + db: db, + sql: """ + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + """) + } + + @discardableResult + private static func insertMessage(databaseURL: URL, createdMs: Int64, cost: Double?) throws -> String { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let messageID = UUID().uuidString + var payload: [String: Any] = [ + "providerID": "opencode-go", + "role": "assistant", + "time": ["created": createdMs], + ] + if let cost { + payload["cost"] = cost + } + let data = try JSONSerialization.data(withJSONObject: payload) + let json = String(data: data, encoding: .utf8) ?? "{}" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, messageID, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_text(stmt, 3, json, -1, transient) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_int64(stmt, 5, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + return messageID + } + + private static func insertStepFinishPart( + databaseURL: URL, + messageID: String, + createdMs: Int64, + cost: Double) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let payload: [String: Any] = [ + "type": "step-finish", + "cost": cost, + "tokens": ["input": 1, "output": 1, "total": 2], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let json = String(data: data, encoding: .utf8) ?? "{}" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO part (id, message_id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient) + sqlite3_bind_text(stmt, 2, messageID, -1, transient) + sqlite3_bind_text(stmt, 3, "session-1", -1, transient) + sqlite3_bind_text(stmt, 4, json, -1, transient) + sqlite3_bind_int64(stmt, 5, createdMs) + sqlite3_bind_int64(stmt, 6, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else { + sqlite3_free(message) + throw SQLiteTestError.exec + } + } + + private static func ms(_ iso: String) -> Int64 { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return Int64((formatter.date(from: iso)?.timeIntervalSince1970 ?? 0) * 1000) + } + + private enum SQLiteTestError: Error { + case open + case prepare + case step + case exec + } +} + +#endif diff --git a/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift b/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift new file mode 100644 index 000000000..a73d650b9 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoMenuCardModelTests.swift @@ -0,0 +1,89 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct OpenCodeGoMenuCardModelTests { + @Test + func `zen balance renders as optional balance`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 98.76, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: now), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost?.title == "Zen balance") + #expect(model.providerCost?.spendLine == "Balance: $98.76") + #expect(model.providerCost?.percentUsed == nil) + #expect(model.providerCost?.percentLine == nil) + } + + @Test + func `zen balance hides when optional usage is disabled`() throws { + let now = Date() + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 98.76, + limit: 0, + currencyCode: "USD", + period: "Zen balance", + updatedAt: now), + updatedAt: now, + identity: nil) + let metadata = try #require(ProviderDefaults.metadata[.opencodego]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .opencodego, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.providerCost == nil) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift new file mode 100644 index 000000000..4c4d4d398 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodeGoProviderStrategyTests { + 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 func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext { + let env: [String: String] = [:] + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + @Test + func `auto source prefers web before local fallback`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext()) + + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + } + + @Test + func `web source does not include local fallback`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext(sourceMode: .web)) + + #expect(strategies.map(\.id) == ["opencodego.web"]) + } + + @Test + func `web strategy falls back to local only for auth setup failures in auto mode`() { + let strategy = OpenCodeGoUsageFetchStrategy() + let autoContext = self.makeContext() + let webContext = self.makeContext(sourceMode: .web) + + #expect(strategy.shouldFallback(on: OpenCodeGoSettingsError.missingCookie, context: autoContext)) + #expect(strategy.shouldFallback(on: OpenCodeGoSettingsError.invalidCookie, context: autoContext)) + #expect(strategy.shouldFallback(on: OpenCodeGoUsageError.invalidCredentials, context: autoContext)) + #expect(!strategy.shouldFallback(on: OpenCodeGoUsageError.networkError("timeout"), context: autoContext)) + #expect(!strategy.shouldFallback(on: OpenCodeGoSettingsError.missingCookie, context: webContext)) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift new file mode 100644 index 000000000..131bfddd1 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift @@ -0,0 +1,578 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct OpenCodeGoUsageFetcherErrorTests { + @Test + func `dashboard URL uses normalized workspace ID`() { + #expect( + OpenCodeGoUsageFetcher.dashboardURL(workspaceID: "https://opencode.ai/workspace/wrk_abc123/go") + .absoluteString == "https://opencode.ai/workspace/wrk_abc123/go") + #expect( + OpenCodeGoUsageFetcher.dashboardURL(workspaceID: "workspace=wrk_def456") + .absoluteString == "https://opencode.ai/workspace/wrk_def456/go") + #expect( + OpenCodeGoUsageFetcher.dashboardURL(workspaceID: nil) + .absoluteString == "https://opencode.ai") + } + + private struct UsageWindow { + let percent: Double + let resetInSec: Int + } + + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OpenCodeGoStubURLProtocol.self] + return URLSession(configuration: config) + } + + @Test + func `redirect guard allows only same-host https redirects`() { + #expect(OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "https://opencode.ai/workspace/wrk_TEST123/go"))) + + #expect(!OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "https://evil.example/steal"))) + + #expect(!OpenCodeGoUsageFetcher.allowsRedirect( + from: URL(string: "https://opencode.ai/_server"), + to: URL(string: "http://opencode.ai/insecure"))) + } + + @Test + func `extracts api error from detail field`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + let body = #"{"detail":"Workspace missing"}"# + return Self.makeResponse(url: url, body: body, statusCode: 500, contentType: "application/json") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + Issue.record("Expected OpenCodeGoUsageError.apiError") + } catch let error as OpenCodeGoUsageError { + switch error { + case let .apiError(message): + #expect(message.contains("HTTP 500")) + #expect(message.contains("Workspace missing")) + default: + Issue.record("Expected apiError, got: \(error)") + } + } + } + + @Test + func `workspace get missing ids falls back to post before loading go page`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var methods: [String] = [] + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + + let workspaceServerID = "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f" + if url.query?.contains(workspaceServerID) == true, + request.httpMethod?.uppercased() == "GET" + { + return Self.makeResponse( + url: url, + body: #"{"ok":true}"#, + statusCode: 200, + contentType: "application/json") + } + + if url.path == "/_server", + request.httpMethod?.uppercased() == "POST", + request.value(forHTTPHeaderField: "X-Server-Id") == workspaceServerID + { + return Self.makeResponse( + url: url, + body: #"{"data":[{"id":"wrk_TEST123"}]}"#, + statusCode: 200, + contentType: "application/json") + } + + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 22, resetInSec: 300), + weekly: UsageWindow(percent: 44, resetInSec: 3600), + monthly: UsageWindow(percent: 55, resetInSec: 7200)), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + session: self.makeSession()) + + #expect(snapshot.rollingUsagePercent == 22) + #expect(snapshot.weeklyUsagePercent == 44) + #expect(snapshot.monthlyUsagePercent == 55) + #expect(methods == ["GET", "POST", "GET", "GET"]) + } + + @Test + func `workspace get public actor error is treated as invalid credentials without post retry`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var methods: [String] = [] + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + let body = [ + #";0x00000263;((self.$R=self.$R||{})["server-fn:test"]=[],"#, + #"($R=>$R[0]=Object.assign(new Error("actor of type \"public\" is not associated with an account"),"#, + #"{stack:"Error: actor of type \"public\" is not associated with an account"}))"#, + #"($R["server-fn:test"]))"#, + ].joined() + return Self.makeResponse( + url: url, + body: body, + statusCode: 200, + contentType: "text/javascript") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + session: self.makeSession()) + Issue.record("Expected OpenCodeGoUsageError.invalidCredentials") + } catch let error as OpenCodeGoUsageError { + switch error { + case .invalidCredentials: + break + default: + Issue.record("Expected invalidCredentials, got: \(error)") + } + } + + #expect(methods == ["GET"]) + } + + @Test + func `go page missing usage fields returns parse failed without post retry`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var methods: [String] = [] + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + return Self.makeResponse( + url: url, + body: "opencodeNo usage yet", + statusCode: 200, + contentType: "text/html") + } + + do { + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + Issue.record("Expected OpenCodeGoUsageError.parseFailed") + } catch let error as OpenCodeGoUsageError { + switch error { + case let .parseFailed(message): + #expect(message.contains("Missing usage fields")) + default: + Issue.record("Expected parseFailed, got: \(error)") + } + } + + #expect(methods == ["GET"]) + } + + @Test + func `normalizes workspace override from URL into go page path`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var observedPaths: [String] = [] + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedPaths.append(url.path) + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_URL123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "https://opencode.ai/workspace/wrk_URL123/billing", + session: self.makeSession()) + + #expect(observedPaths == ["/workspace/wrk_URL123/go", "/workspace/wrk_URL123"]) + } + + @Test + func `fetcher attaches optional zen balance from workspace root`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(snapshot.zenBalanceUSD == 98.76) + #expect(snapshot.toUsageSnapshot().providerCost?.period == "Zen balance") + } + + @Test + func `optional zen balance helper uses normalized cookie and workspace override`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var observedCookie: String? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedCookie = request.value(forHTTPHeaderField: "Cookie") + #expect(url.path == "/workspace/wrk_TEST123") + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + + let balance = try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance( + cookieHeader: "provider=google; auth=test", + timeout: 2, + workspaceIDOverride: "https://opencode.ai/workspace/wrk_TEST123/go", + session: self.makeSession()) + + #expect(balance == 98.76) + #expect(observedCookie == "auth=test") + } + + @Test + func `optional zen balance failure does not fail subscription usage`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var rootTimeout: TimeInterval? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + rootTimeout = request.timeoutInterval + throw URLError(.timedOut) + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.zenBalanceUSD == nil) + #expect(rootTimeout == 5) + } + + @Test + func `optional zen balance does not stall subscription usage`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + Thread.sleep(forTimeInterval: 1) + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let start = ContinuousClock.now + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + let elapsed = start.duration(to: ContinuousClock.now) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.zenBalanceUSD == nil) + #expect(elapsed < .milliseconds(700)) + } + + @Test + func `optional zen balance can be skipped by settings`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var observedPaths: [String] = [] + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedPaths.append(url.path) + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let snapshot = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + includeZenBalance: false, + session: self.makeSession()) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.zenBalanceUSD == nil) + #expect(observedPaths == ["/workspace/wrk_TEST123/go"]) + } + + @Test + func `optional zen balance cancellation propagates`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + let rootStarted = AsyncStream.makeStream(of: Void.self) + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.path == "/workspace/wrk_TEST123" { + rootStarted.continuation.yield(()) + Thread.sleep(forTimeInterval: 0.2) + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + let task = Task { + try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 60, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + } + + let started = await withTaskGroup(of: Bool.self) { group in + group.addTask { + var iterator = rootStarted.stream.makeAsyncIterator() + return await iterator.next() != nil + } + group.addTask { + try? await Task.sleep(for: .seconds(2)) + return false + } + let result = await group.next() ?? false + group.cancelAll() + return result + } + #expect(started) + task.cancel() + + do { + _ = try await task.value + Issue.record("Expected cancellation to propagate.") + } catch is CancellationError { + // Expected. + } catch { + Issue.record("Expected CancellationError, got: \(error)") + } + } + + @Test + func `fetcher sends only auth cookie to opencode host`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var observedCookie: String? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedCookie = request.value(forHTTPHeaderField: "Cookie") + return Self.makeResponse( + url: url, + body: Self.goUsagePageHTML( + workspaceID: "wrk_TEST123", + rolling: UsageWindow(percent: 17, resetInSec: 600), + weekly: UsageWindow(percent: 75, resetInSec: 7200), + monthly: nil), + statusCode: 200, + contentType: "text/html") + } + + _ = try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: "provider=google; auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(observedCookie == "auth=test") + } + + private static func goUsagePageHTML( + workspaceID: String, + rolling: UsageWindow, + weekly: UsageWindow, + monthly: UsageWindow?) -> String + { + let monthlyField: String? = if let monthly { + #"monthlyUsage:{status:"ok",resetInSec:\#(monthly.resetInSec),usagePercent:\#(monthly.percent)}"# + } else { + nil + } + + let usageFields = [ + #"rollingUsage:{status:"ok",resetInSec:\#(rolling.resetInSec),usagePercent:\#(rolling.percent)}"#, + #"weeklyUsage:{status:"ok",resetInSec:\#(weekly.resetInSec),usagePercent:\#(weekly.percent)}"#, + monthlyField, + ] + .compactMap(\.self) + .joined(separator: ",") + + return """ + + + + + + + """ + } + + private static func makeResponse( + url: URL, + body: String, + statusCode: Int, + contentType: String) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (response, Data(body.utf8)) + } +} + +final class OpenCodeGoStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "opencode.ai" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift new file mode 100644 index 000000000..e11374747 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift @@ -0,0 +1,446 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodeGoUsageParserTests { + @Test + func `parses workspace ids`() { + let text = ";0x00000089;((self.$R=self.$R||{})[\"codexbar\"]=[]," + + "($R=>$R[0]=[$R[1]={id:\"wrk_01K6AR1ZET89H8NB691FQ2C2VB\",name:\"Default\",slug:null}])" + + "($R[\"codexbar\"]))" + let ids = OpenCodeGoUsageFetcher.parseWorkspaceIDs(text: text) + #expect(ids == ["wrk_01K6AR1ZET89H8NB691FQ2C2VB"]) + } + + @Test + func `parses subscription usage from seroval response`() throws { + let text = + "$R[16]($R[30],$R[41]={rollingUsage:$R[42]={status:\"ok\",resetInSec:5944,usagePercent:17}," + + "weeklyUsage:$R[43]={status:\"ok\",resetInSec:278201,usagePercent:75}," + + "monthlyUsage:$R[44]={status:\"ok\",resetInSec:880201,usagePercent:91}});" + let now = Date(timeIntervalSince1970: 0) + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 17) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 91) + #expect(snapshot.rollingResetInSec == 5944) + #expect(snapshot.weeklyResetInSec == 278_201) + #expect(snapshot.monthlyResetInSec == 880_201) + } + + @Test + func `parses zen balance from workspace page text`() { + let text = """ +
+

現在の残高 $1,234.56

+

Claude Opus and GPT-5 models enabled

+
+ """ + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == 1234.56) + } + + @Test + func `parses zen balance from nested JSON`() throws { + let payload: [String: Any] = [ + "data": [ + "billing": [ + "balanceEnabled": true, + "zenBalance": "1,042.75", + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == 1042.75) + } + + @Test + func `zen balance parser ignores metadata before amount`() throws { + let payload: [String: Any] = [ + "data": [ + "billing": [ + "balanceUpdatedAt": 1_800_000_000, + "balanceRefreshInterval": 60, + "zenBalance": "42.50", + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == 42.50) + } + + @Test + func `parses subscription usage from live go page hydration`() throws { + let rollingResetInSec = 17591 + let weeklyResetInSec = 444_552 + let monthlyResetInSec = 2_591_424 + let text = + "_$HY.r[\"lite.subscription.get[\\\"wrk_LIVE123\\\"]\"]=$R[17]=$R[2]($R[18]={p:0,s:0,f:0});" + + "$R[24]($R[18],$R[27]={mine:!0,useBalance:!1," + + "rollingUsage:$R[28]={status:\"ok\",resetInSec:\(rollingResetInSec),usagePercent:0}," + + "weeklyUsage:$R[29]={status:\"ok\",resetInSec:\(weeklyResetInSec),usagePercent:0}," + + "monthlyUsage:$R[30]={status:\"ok\",resetInSec:\(monthlyResetInSec),usagePercent:0}});" + let now = Date(timeIntervalSince1970: 0) + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 0) + #expect(snapshot.weeklyUsagePercent == 0) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 0) + #expect(snapshot.rollingResetInSec == rollingResetInSec) + #expect(snapshot.weeklyResetInSec == weeklyResetInSec) + #expect(snapshot.monthlyResetInSec == monthlyResetInSec) + } + + @Test + func `parses subscription from JSON with reset at and ratio percentages`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let rollingResetAt = now.addingTimeInterval(3600) + let monthlyResetAt = now.addingTimeInterval(86400) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "usage": [ + "rollingUsage": [ + "usagePercent": 0.25, + "resetAt": formatter.string(from: rollingResetAt), + ], + "weeklyUsage": [ + "usagePercent": 75, + "resetInSec": 7200, + ], + "monthlyUsage": [ + "usagePercent": 0.9, + "resetAt": formatter.string(from: monthlyResetAt), + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 75) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 90) + #expect(snapshot.rollingResetInSec == 3600) + #expect(snapshot.weeklyResetInSec == 7200) + #expect(snapshot.monthlyResetInSec == 86400) + } + + @Test + func `computes usage percent from totals and treats monthly as optional`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "rollingUsage": [ + "used": 25, + "limit": 100, + "resetInSec": 600, + ], + "weeklyUsage": [ + "used": 50, + "limit": 200, + "resetInSec": 3600, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 25) + #expect(snapshot.hasMonthlyUsage == false) + #expect(snapshot.monthlyUsagePercent == 0) + #expect(snapshot.monthlyResetInSec == 0) + #expect(usage.tertiary == nil) + } + + @Test + func `snapshot exposes zen balance as provider cost`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: false, + rollingUsagePercent: 10, + weeklyUsagePercent: 20, + monthlyUsagePercent: 0, + rollingResetInSec: 600, + weeklyResetInSec: 3600, + monthlyResetInSec: 0, + zenBalanceUSD: 12.34, + updatedAt: now) + + let usage = snapshot.toUsageSnapshot() + + #expect(usage.providerCost?.period == "Zen balance") + #expect(usage.providerCost?.used == 12.34) + #expect(usage.providerCost?.limit == 0) + #expect(usage.providerCost?.currencyCode == "USD") + } + + @Test + func `zen balance parser ignores balance flags without amounts`() throws { + let payload: [String: Any] = [ + "billing": [ + "balanceEnabled": true, + "useBalance": false, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(OpenCodeGoUsageFetcher.parseZenBalance(text: text) == nil) + } + + @Test + func `parses subscription from nested candidate windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "windows": [ + "primaryWindow": [ + "used": 15, + "limit": 100, + "resetInSec": 600, + ], + "weeklyQuota": [ + "used": 80, + "limit": 200, + "resetInSec": 7200, + ], + "monthlyBucket": [ + "used": 90, + "limit": 300, + "resetInSec": 86400, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 15) + #expect(snapshot.weeklyUsagePercent == 40) + #expect(snapshot.hasMonthlyUsage == true) + #expect(snapshot.monthlyUsagePercent == 30) + #expect(snapshot.monthlyResetInSec == 86400) + } + + @Test + func `candidate fallback does not fabricate weekly from non weekly windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "windows": [ + "primaryWindow": [ + "used": 15, + "limit": 100, + "resetInSec": 600, + ], + "monthlyBucket": [ + "used": 90, + "limit": 300, + "resetInSec": 86400, + ], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + #expect(throws: OpenCodeGoUsageError.self) { + _ = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + } + } + + @Test + func `clamps invalid percentages`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "rollingUsage": [ + "usagePercent": 150, + "resetInSec": 60, + ], + "weeklyUsage": [ + "usagePercent": -10, + "resetInSec": 120, + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.rollingUsagePercent == 100) + #expect(snapshot.weeklyUsagePercent == 0) + } + + @Test + func `parse subscription throws when required fields are missing`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let text = "{\"monthlyUsage\":{\"usagePercent\":50,\"resetInSec\":123}}" + + #expect(throws: OpenCodeGoUsageError.self) { + _ = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + } + } + + @Test + func `renewsAt parses from ISO8601 renewAt key`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + "renewAt": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt != nil) + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `renewsAt parses from renew_at key`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + "renew_at": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt != nil) + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `renewsAt is nil when absent`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == nil) + } + + @Test + func `top level renewAt is preserved for nested usage object`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renewAt": formatter.string(from: renewAt), + "usage": [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `top level renew_at is preserved for nested usage object`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renew_at": formatter.string(from: renewAt), + "usage": [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `child renewAt overrides parent renewAt`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let parentRenewAt = now.addingTimeInterval(86400 * 30) + let childRenewAt = now.addingTimeInterval(86400 * 45) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renewAt": formatter.string(from: parentRenewAt), + "usage": [ + "renewAt": formatter.string(from: childRenewAt), + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == childRenewAt) + } + + @Test + func `toUsageSnapshot includes renewal NamedRateWindow when renewsAt present`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "monthlyUsage": ["usagePercent": 25, "resetInSec": 7200], + "renewAt": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.extraRateWindows != nil) + #expect(usage.extraRateWindows?.count == 1) + #expect(usage.extraRateWindows?[0].id == "renewal") + #expect(usage.extraRateWindows?[0].title == "Renews") + #expect(usage.extraRateWindows?[0].window.resetsAt == renewAt) + } +} diff --git a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift index d64ffb06b..c934d3520 100644 --- a/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageFetcherErrorTests.swift @@ -4,13 +4,15 @@ import Testing @Suite(.serialized) struct OpenCodeUsageFetcherErrorTests { + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [OpenCodeStubURLProtocol.self] + return URLSession(configuration: config) + } + @Test - func extractsApiErrorFromUppercaseHTMLTitle() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + func `extracts api error from uppercase HTML title`() async throws { defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -24,7 +26,8 @@ struct OpenCodeUsageFetcherErrorTests { _ = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) Issue.record("Expected OpenCodeUsageError.apiError") } catch let error as OpenCodeUsageError { switch error { @@ -38,12 +41,8 @@ struct OpenCodeUsageFetcherErrorTests { } @Test - func extractsApiErrorFromDetailField() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + func `extracts api error from detail field`() async throws { defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -57,7 +56,8 @@ struct OpenCodeUsageFetcherErrorTests { _ = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) Issue.record("Expected OpenCodeUsageError.apiError") } catch let error as OpenCodeUsageError { switch error { @@ -71,12 +71,8 @@ struct OpenCodeUsageFetcherErrorTests { } @Test - func subscriptionGetNullSkipsPostAndReturnsGracefulError() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + func `subscription get null skips post and returns graceful error`() async throws { defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -103,7 +99,8 @@ struct OpenCodeUsageFetcherErrorTests { _ = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) Issue.record("Expected OpenCodeUsageError.apiError") } catch let error as OpenCodeUsageError { switch error { @@ -123,12 +120,8 @@ struct OpenCodeUsageFetcherErrorTests { } @Test - func subscriptionGetPayloadDoesNotFallbackToPost() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + func `subscription get payload does not fallback to post`() async throws { defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) - } OpenCodeStubURLProtocol.handler = nil } @@ -149,7 +142,8 @@ struct OpenCodeUsageFetcherErrorTests { let snapshot = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) #expect(snapshot.rollingUsagePercent == 17) #expect(snapshot.weeklyUsagePercent == 75) @@ -157,12 +151,49 @@ struct OpenCodeUsageFetcherErrorTests { } @Test - func subscriptionGetMissingFieldsFallsBackToPost() async throws { - let registered = URLProtocol.registerClass(OpenCodeStubURLProtocol.self) + func `workspace get public actor error is treated as invalid credentials without post retry`() async throws { defer { - if registered { - URLProtocol.unregisterClass(OpenCodeStubURLProtocol.self) + OpenCodeStubURLProtocol.handler = nil + } + + var methods: [String] = [] + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + methods.append(request.httpMethod ?? "GET") + let body = [ + #";0x00000263;((self.$R=self.$R||{})["server-fn:test"]=[],"#, + #"($R=>$R[0]=Object.assign(new Error("actor of type \"public\" is not associated with an account"),"#, + #"{stack:"Error: actor of type \"public\" is not associated with an account"}))"#, + #"($R["server-fn:test"]))"#, + ].joined() + return Self.makeResponse( + url: url, + body: body, + statusCode: 200, + contentType: "text/javascript") + } + + do { + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "auth=test", + timeout: 2, + session: self.makeSession()) + Issue.record("Expected OpenCodeUsageError.invalidCredentials") + } catch let error as OpenCodeUsageError { + switch error { + case .invalidCredentials: + break + default: + Issue.record("Expected invalidCredentials, got: \(error)") } + } + + #expect(methods == ["GET"]) + } + + @Test + func `subscription get missing fields falls back to post`() async throws { + defer { OpenCodeStubURLProtocol.handler = nil } @@ -195,13 +226,43 @@ struct OpenCodeUsageFetcherErrorTests { let snapshot = try await OpenCodeUsageFetcher.fetchUsage( cookieHeader: "auth=test", timeout: 2, - workspaceIDOverride: "wrk_TEST123") + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) #expect(snapshot.rollingUsagePercent == 22) #expect(snapshot.weeklyUsagePercent == 44) #expect(methods == ["GET", "POST"]) } + @Test + func `fetcher sends only auth cookie to opencode host`() async throws { + defer { + OpenCodeStubURLProtocol.handler = nil + } + + var observedCookie: String? + OpenCodeStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedCookie = request.value(forHTTPHeaderField: "Cookie") + + let body = """ + { + "rollingUsage": { "usagePercent": 17, "resetInSec": 600 }, + "weeklyUsage": { "usagePercent": 75, "resetInSec": 7200 } + } + """ + return Self.makeResponse(url: url, body: body, statusCode: 200, contentType: "application/json") + } + + _ = try await OpenCodeUsageFetcher.fetchUsage( + cookieHeader: "provider=google; auth=test", + timeout: 2, + workspaceIDOverride: "wrk_TEST123", + session: self.makeSession()) + + #expect(observedCookie == "auth=test") + } + private static func makeResponse( url: URL, body: String, diff --git a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift index fe7e1a345..ab7359cb1 100644 --- a/Tests/CodexBarTests/OpenCodeUsageParserTests.swift +++ b/Tests/CodexBarTests/OpenCodeUsageParserTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct OpenCodeUsageParserTests { @Test - func parsesWorkspaceIDs() { + func `parses workspace I ds`() { let text = ";0x00000089;((self.$R=self.$R||{})[\"codexbar\"]=[]," + "($R=>$R[0]=[$R[1]={id:\"wrk_01K6AR1ZET89H8NB691FQ2C2VB\",name:\"Default\",slug:null}])" + "($R[\"codexbar\"]))" @@ -14,7 +13,7 @@ struct OpenCodeUsageParserTests { } @Test - func parsesSubscriptionUsage() throws { + func `parses subscription usage`() throws { let text = "$R[16]($R[30],$R[41]={rollingUsage:$R[42]={status:\"ok\",resetInSec:5944,usagePercent:17}," + "weeklyUsage:$R[43]={status:\"ok\",resetInSec:278201,usagePercent:75}});" let now = Date(timeIntervalSince1970: 0) @@ -26,7 +25,7 @@ struct OpenCodeUsageParserTests { } @Test - func parsesSubscriptionFromJSONWithResetAt() throws { + func `parses subscription from JSON with reset at`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let resetAt = now.addingTimeInterval(3600) let formatter = ISO8601DateFormatter() @@ -55,7 +54,7 @@ struct OpenCodeUsageParserTests { } @Test - func parsesSubscriptionFromCandidateWindows() throws { + func `parses subscription from candidate windows`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let payload: [String: Any] = [ "windows": [ @@ -81,7 +80,7 @@ struct OpenCodeUsageParserTests { } @Test - func computesUsagePercentFromTotals() throws { + func `computes usage percent from totals`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let payload: [String: Any] = [ "rollingUsage": [ @@ -105,7 +104,7 @@ struct OpenCodeUsageParserTests { } @Test - func parseSubscriptionThrowsWhenFieldsMissing() { + func `parse subscription throws when fields missing`() { let now = Date(timeIntervalSince1970: 1_700_000_000) let text = "{\"ok\":true}" @@ -113,4 +112,148 @@ struct OpenCodeUsageParserTests { _ = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) } } + + @Test + func `renewsAt parses from ISO8601 renewAt key`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "renewAt": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt != nil) + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `renewsAt parses from renew_at key`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "renew_at": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt != nil) + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `renewsAt is nil when absent`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == nil) + } + + @Test + func `top level renewAt is preserved for nested usage object`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renewAt": formatter.string(from: renewAt), + "usage": [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `top level renew_at is preserved for nested usage object`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renew_at": formatter.string(from: renewAt), + "usage": [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == renewAt) + } + + @Test + func `child renewAt overrides parent renewAt`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let parentRenewAt = now.addingTimeInterval(86400 * 30) + let childRenewAt = now.addingTimeInterval(86400 * 45) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "renewAt": formatter.string(from: parentRenewAt), + "usage": [ + "renewAt": formatter.string(from: childRenewAt), + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + ], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + + #expect(snapshot.renewsAt == childRenewAt) + } + + @Test + func `toUsageSnapshot includes renewal NamedRateWindow when renewsAt present`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let renewAt = now.addingTimeInterval(86400 * 30) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let payload: [String: Any] = [ + "rollingUsage": ["usagePercent": 10, "resetInSec": 600], + "weeklyUsage": ["usagePercent": 50, "resetInSec": 3600], + "renewAt": formatter.string(from: renewAt), + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let text = String(data: data, encoding: .utf8) ?? "" + + let snapshot = try OpenCodeUsageFetcher.parseSubscription(text: text, now: now) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.extraRateWindows != nil) + #expect(usage.extraRateWindows?.count == 1) + #expect(usage.extraRateWindows?[0].id == "renewal") + #expect(usage.extraRateWindows?[0].title == "Renews") + #expect(usage.extraRateWindows?[0].window.resetsAt == renewAt) + } } diff --git a/Tests/CodexBarTests/OpenCodeWebCookieSupportTests.swift b/Tests/CodexBarTests/OpenCodeWebCookieSupportTests.swift new file mode 100644 index 000000000..be73d4e50 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeWebCookieSupportTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import CodexBarCore + +struct OpenCodeWebCookieSupportTests { + @Test + func `request cookie header keeps only opencode auth cookies`() { + let header = OpenCodeWebCookieSupport.requestCookieHeader( + from: "provider=google; auth=session123; theme=dark; __Host-auth=host456") + + #expect(header == "auth=session123; __Host-auth=host456") + } + + @Test + func `request cookie header returns nil when auth cookie is missing`() { + let header = OpenCodeWebCookieSupport.requestCookieHeader(from: "provider=google; theme=dark") + + #expect(header == nil) + } +} diff --git a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift index 25ec01bcf..c81141498 100644 --- a/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift +++ b/Tests/CodexBarTests/OpenRouterUsageStatsTests.swift @@ -5,7 +5,7 @@ import Testing @Suite(.serialized) struct OpenRouterUsageStatsTests { @Test - func toUsageSnapshot_usesKeyQuotaForPrimaryWindow() { + func `to usage snapshot uses key quota for primary window`() { let snapshot = OpenRouterUsageSnapshot( totalCredits: 50, totalUsage: 45.3895596325, @@ -25,7 +25,7 @@ struct OpenRouterUsageStatsTests { } @Test - func toUsageSnapshot_withoutValidKeyLimitOmitsPrimaryWindow() { + func `to usage snapshot without valid key limit omits primary window`() { let snapshot = OpenRouterUsageSnapshot( totalCredits: 50, totalUsage: 45.3895596325, @@ -43,7 +43,7 @@ struct OpenRouterUsageStatsTests { } @Test - func toUsageSnapshot_whenNoLimitConfiguredOmitsPrimaryAndMarksNoLimit() { + func `to usage snapshot when no limit configured omits primary and marks no limit`() { let snapshot = OpenRouterUsageSnapshot( totalCredits: 50, totalUsage: 45.3895596325, @@ -62,7 +62,7 @@ struct OpenRouterUsageStatsTests { } @Test - func sanitizers_redactSensitiveTokenShapes() { + func `sanitizers redact sensitive token shapes`() { let body = """ {"error":"bad token sk-or-v1-abc123","token":"secret-token","authorization":"Bearer sk-or-v1-xyz789"} """ @@ -82,7 +82,7 @@ struct OpenRouterUsageStatsTests { } @Test - func non200FetchThrowsGenericHTTPErrorWithoutBodyDetails() async throws { + func `non200 fetch throws generic HTTP error without body details`() async throws { let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self) defer { if registered { @@ -114,7 +114,7 @@ struct OpenRouterUsageStatsTests { } @Test - func fetchUsage_setsCreditsTimeoutAndClientHeaders() async throws { + func `fetch usage sets credits timeout and client headers`() async throws { let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self) defer { if registered { @@ -133,7 +133,16 @@ struct OpenRouterUsageStatsTests { let body = #"{"data":{"total_credits":100,"total_usage":40}}"# return Self.makeResponse(url: url, body: body, statusCode: 200) case "/api/v1/key": - let body = #"{"data":{"limit":20,"usage":0.5,"rate_limit":{"requests":120,"interval":"10s"}}}"# + let body = #""" + {"data":{ + "limit":20, + "usage":0.5, + "usage_daily":0.12, + "usage_weekly":0.74, + "usage_monthly":4.56, + "rate_limit":{"requests":120,"interval":"10s"} + }} + """# return Self.makeResponse(url: url, body: body, statusCode: 200) default: return Self.makeResponse(url: url, body: "{}", statusCode: 404) @@ -153,13 +162,16 @@ struct OpenRouterUsageStatsTests { #expect(usage.keyDataFetched) #expect(usage.keyLimit == 20) #expect(usage.keyUsage == 0.5) + #expect(usage.keyUsageDaily == 0.12) + #expect(usage.keyUsageWeekly == 0.74) + #expect(usage.keyUsageMonthly == 4.56) #expect(usage.keyRemaining == 19.5) #expect(usage.keyUsedPercent == 2.5) #expect(usage.keyQuotaStatus == .available) } @Test - func fetchUsage_whenKeyEndpointFailsMarksQuotaUnavailable() async throws { + func `fetch usage when key endpoint fails marks quota unavailable`() async throws { let registered = URLProtocol.registerClass(OpenRouterStubURLProtocol.self) defer { if registered { @@ -190,7 +202,7 @@ struct OpenRouterUsageStatsTests { } @Test - func usageSnapshot_roundTripPersistsOpenRouterUsageMetadata() throws { + func `usage snapshot round trip persists open router usage metadata`() throws { let openRouter = OpenRouterUsageSnapshot( totalCredits: 50, totalUsage: 45.3895596325, @@ -199,6 +211,9 @@ struct OpenRouterUsageStatsTests { keyDataFetched: true, keyLimit: nil, keyUsage: nil, + keyUsageDaily: 0.12, + keyUsageWeekly: 0.74, + keyUsageMonthly: 4.56, rateLimit: nil, updatedAt: Date(timeIntervalSince1970: 1_739_841_600)) let snapshot = openRouter.toUsageSnapshot() @@ -209,6 +224,9 @@ struct OpenRouterUsageStatsTests { #expect(decoded.openRouterUsage?.keyDataFetched == true) #expect(decoded.openRouterUsage?.keyQuotaStatus == .noLimitConfigured) + #expect(decoded.openRouterUsage?.keyUsageDaily == 0.12) + #expect(decoded.openRouterUsage?.keyUsageWeekly == 0.74) + #expect(decoded.openRouterUsage?.keyUsageMonthly == 4.56) } private static func makeResponse( diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index 014dae6e0..7ac329e0c 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -1,12 +1,11 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore -@Suite struct PathBuilderTests { @Test - func mergesLoginShellPathWhenAvailable() { + func `merges login shell path when available`() { let seeded = PathBuilder.effectivePATH( purposes: [.rpc], env: ["PATH": "/custom/bin:/usr/bin"], @@ -15,7 +14,7 @@ struct PathBuilderTests { } @Test - func fallsBackToExistingPathWhenNoLoginPath() { + func `falls back to existing path when no login path`() { let seeded = PathBuilder.effectivePATH( purposes: [.tty], env: ["PATH": "/custom/bin:/usr/bin"], @@ -24,7 +23,7 @@ struct PathBuilderTests { } @Test - func usesFallbackWhenNoPathAvailable() { + func `uses fallback when no path available`() { let seeded = PathBuilder.effectivePATH( purposes: [.tty], env: [:], @@ -33,7 +32,7 @@ struct PathBuilderTests { } @Test - func debugSnapshotAsyncMatchesSync() async { + func `debug snapshot async matches sync`() async { let env = [ "CODEX_CLI_PATH": "/usr/bin/true", "CLAUDE_CLI_PATH": "/usr/bin/true", @@ -46,7 +45,62 @@ struct PathBuilderTests { } @Test - func resolvesCodexFromEnvOverride() { + func `shell runner drains noisy stdout and stderr`() throws { + let script = """ + i=0 + while [ "$i" -lt 4000 ]; do + printf 'out-%04d\\n' "$i" + printf 'err-%04d\\n' "$i" >&2 + i=$((i + 1)) + done + printf '__CODEXBAR_DONE__\\n' + """ + let data = try #require(ShellCommandLocator.test_runShellCommand( + shell: "/bin/sh", + arguments: ["-c", script], + timeout: 4.0)) + let output = try #require(String(data: data, encoding: .utf8)) + + #expect(output.contains("out-3999")) + #expect(output.contains("__CODEXBAR_DONE__")) + } + + @Test + func `shell runner terminates background children after normal exit`() throws { + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-shell-runner-\(UUID().uuidString)") + .path + let escapedMarker = Self.shellSingleQuoted(marker) + let script = """ + ( + trap '' TERM + touch \(escapedMarker) + while :; do sleep 1; done + ) & + printf '%s\\n' "$!" + """ + let data = try #require(ShellCommandLocator.test_runShellCommand( + shell: "/bin/sh", + arguments: ["-c", script], + timeout: 2.0)) + let pidText = try #require(String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)) + let pid = try #require(pid_t(pidText)) + + defer { + kill(pid, SIGKILL) + try? FileManager.default.removeItem(atPath: marker) + } + + let deadline = Date().addingTimeInterval(2.0) + while kill(pid, 0) == 0, Date() < deadline { + usleep(50000 as useconds_t) + } + + #expect(kill(pid, 0) != 0) + } + + @Test + func `resolves codex from env override`() { let overridePath = "/custom/bin/codex" let fm = MockFileManager(executables: [overridePath]) @@ -59,7 +113,7 @@ struct PathBuilderTests { } @Test - func resolvesCodexFromLoginPath() { + func `resolves codex from login path`() { let fm = MockFileManager(executables: ["/login/bin/codex"]) let resolved = BinaryLocator.resolveCodexBinary( env: ["PATH": "/env/bin"], @@ -70,7 +124,7 @@ struct PathBuilderTests { } @Test - func resolvesCodexFromEnvPath() { + func `resolves codex from env path`() { let fm = MockFileManager(executables: ["/env/bin/codex"]) let resolved = BinaryLocator.resolveCodexBinary( env: ["PATH": "/env/bin:/usr/bin"], @@ -81,7 +135,213 @@ struct PathBuilderTests { } @Test - func resolvesCodexFromInteractiveShell() { + func `skips blocked codex path and falls back to signed app binary`() { + let blockedPath = "/usr/local/bin/codex" + let appPath = "/Applications/Codex.app/Contents/Resources/codex" + let fm = MockFileManager(executables: [blockedPath, appPath]) + var checked: [String] = [] + + let resolved = BinaryLocator.resolveCodexBinary( + env: ["PATH": "/usr/local/bin"], + loginPATH: nil, + commandV: { _, _, _, _ in nil }, + aliasResolver: { _, _, _, _, _ in nil }, + launchCandidateFilter: { path, _ in + checked.append(path) + return path != blockedPath + }, + fileManager: fm, + home: "/Users/test") + + #expect(resolved == appPath) + #expect(checked == [blockedPath, appPath]) + } + + @Test + func `explicit codex override bypasses launch candidate fallback`() { + let overridePath = "/custom/bin/codex" + let appPath = "/Applications/Codex.app/Contents/Resources/codex" + let fm = MockFileManager(executables: [overridePath, appPath]) + var checked: [String] = [] + + let resolved = BinaryLocator.resolveCodexBinary( + env: ["CODEX_CLI_PATH": overridePath], + loginPATH: nil, + launchCandidateFilter: { path, _ in + checked.append(path) + return false + }, + fileManager: fm, + home: "/Users/test") + + #expect(resolved == overridePath) + #expect(checked.isEmpty) + } + + @Test + func `Codex CLI strategy availability uses filtered binary resolution`() { + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let unavailable = CodexCLIUsageStrategy.resolvedBinary( + env: ["PATH": "/missing/bin", "SHELL": "/bin/sh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: MockFileManager(executables: []), + home: "/home/test") + #expect(unavailable == nil) + + let available = CodexCLIUsageStrategy.resolvedBinary( + env: ["PATH": "/tools/bin", "SHELL": "/bin/sh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: MockFileManager(executables: ["/tools/bin/codex"]), + home: "/home/test") + #expect(available == "/tools/bin/codex") + } + + #if os(macOS) + @Test + func `Codex launch preflight allows quarantined notarized native binary`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/Applications/Codex.app/Contents/Resources/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { _ in "accepted\nsource=Notarized Developer ID" }, + isMachOExecutable: { _ in true }) + + #expect(allowed) + } + + @Test + func `Codex launch preflight blocks malware attribute before assessment`() { + var assessed = false + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/Applications/Codex.app/Contents/Resources/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.malware" }, + spctlAssessment: { _ in + assessed = true + return "accepted\nsource=Notarized Developer ID" + }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + #expect(!assessed) + } + + @Test + func `Codex launch preflight blocks quarantined script without native assessment`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { _ in nil }, + isMachOExecutable: { _ in false }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight blocks revoked assessment`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/Applications/Codex.app/Contents/Resources/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { _ in "rejected\nCSSMERR_TP_CERT_REVOKED" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight blocks generic Gatekeeper rejection`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, _ in false }, + spctlAssessment: { _ in "rejected\nsource=no usable signature" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight allows valid signed command line binary assessment`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected (the code is valid but does not seem to be an app)" }, + isMachOExecutable: { _ in true }) + + #expect(allowed) + } + + @Test + func `Codex launch preflight blocks revoked assessment even with non app rejection text`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { _ in + """ + rejected (the code is valid but does not seem to be an app) + CSSMERR_TP_CERT_REVOKED + """ + }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores benign text in path when verdict is generic rejection`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/code is valid but does not seem to be an app/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected\nsource=no usable signature" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores benign text before verdict separator`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/x: code is valid but does not seem to be an app/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected\nsource=no usable signature" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores blocked words in accepted path and source fields`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/rejected/quarantine/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in + """ + \(path): accepted + source=revoked quarantine marker + origin=malware test fixture + """ + }, + isMachOExecutable: { _ in true }) + + #expect(allowed) + } + #endif + + @Test + func `resolves codex from interactive shell`() { let fm = MockFileManager(executables: ["/shell/bin/codex"]) let commandV: (String, String?, TimeInterval, FileManager) -> String? = { tool, shell, timeout, fileManager in #expect(tool == "codex") @@ -101,7 +361,7 @@ struct PathBuilderTests { } @Test - func resolvesClaudeFromInteractiveShell() { + func `resolves claude from interactive shell`() { let fm = MockFileManager(executables: ["/shell/bin/claude"]) let commandV: (String, String?, TimeInterval, FileManager) -> String? = { tool, shell, timeout, fileManager in #expect(tool == "claude") @@ -121,7 +381,7 @@ struct PathBuilderTests { } @Test - func resolvesGeminiFromInteractiveShell() { + func `resolves gemini from interactive shell`() { let fm = MockFileManager(executables: ["/shell/bin/gemini"]) let commandV: (String, String?, TimeInterval, FileManager) -> String? = { tool, shell, timeout, fileManager in #expect(tool == "gemini") @@ -141,7 +401,7 @@ struct PathBuilderTests { } @Test - func resolvesClaudeFromLoginPath() { + func `resolves claude from login path`() { let fm = MockFileManager(executables: ["/login/bin/claude"]) let resolved = BinaryLocator.resolveClaudeBinary( env: ["PATH": "/env/bin"], @@ -152,7 +412,7 @@ struct PathBuilderTests { } @Test - func resolvesClaudeFromAliasWhenOtherLookupsFail() { + func `resolves claude from alias when other lookups fail`() { let aliasPath = "/home/test/.claude/local/bin/claude" let fm = MockFileManager(executables: [aliasPath]) var aliasCalled = false @@ -182,7 +442,7 @@ struct PathBuilderTests { } @Test - func resolvesCodexFromAliasWhenOtherLookupsFail() { + func `resolves codex from alias when other lookups fail`() { let aliasPath = "/home/test/.codex/bin/codex" let fm = MockFileManager(executables: [aliasPath]) var aliasCalled = false @@ -212,7 +472,134 @@ struct PathBuilderTests { } @Test - func skipsAliasWhenCommandVResolves() { + func `resolves claude from well-known cmux path when shell lookups fail`() { + let cmuxPath = "/Applications/cmux.app/Contents/Resources/bin/claude" + let fm = MockFileManager(executables: [cmuxPath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == cmuxPath) + } + + @Test + func `resolves claude from well-known home dir path`() { + let homePath = "/Users/test/.claude/bin/claude" + let fm = MockFileManager(executables: [homePath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == homePath) + } + + @Test + func `resolves claude from native installer path`() { + let nativePath = "/Users/test/.local/bin/claude" + let fm = MockFileManager(executables: [nativePath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == nativePath) + } + + @Test + func `prefers migrated local claude path over legacy home dir path`() { + let migratedPath = "/Users/test/.claude/local/claude" + let legacyPath = "/Users/test/.claude/bin/claude" + let fm = MockFileManager(executables: [migratedPath, legacyPath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == migratedPath) + } + + @Test + func `prefers user managed well-known path over cmux path`() { + let homePath = "/Users/test/.claude/bin/claude" + let cmuxPath = "/Applications/cmux.app/Contents/Resources/bin/claude" + let fm = MockFileManager(executables: [homePath, cmuxPath]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == homePath) + } + + @Test + func `prefers homebrew arm path over usr local fallback`() { + let fm = MockFileManager(executables: [ + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + ]) + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in nil } + let aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = { _, _, _, _, _ in nil } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + aliasResolver: aliasResolver, + fileManager: fm, + home: "/Users/test") + #expect(resolved == "/opt/homebrew/bin/claude") + } + + @Test + func `prefers well-known paths over interactive shell lookup`() { + let shellPath = "/custom/bin/claude" + let cmuxPath = "/Applications/cmux.app/Contents/Resources/bin/claude" + let fm = MockFileManager(executables: [shellPath, cmuxPath]) + var shellLookupCalled = false + let commandV: (String, String?, TimeInterval, FileManager) -> String? = { _, _, _, _ in + shellLookupCalled = true + return shellPath + } + + let resolved = BinaryLocator.resolveClaudeBinary( + env: ["SHELL": "/bin/zsh"], + loginPATH: nil, + commandV: commandV, + fileManager: fm, + home: "/Users/test") + #expect(!shellLookupCalled) + #expect(resolved == cmuxPath) + } + + @Test + func `skips alias when command V resolves`() { let path = "/shell/bin/claude" let fm = MockFileManager(executables: [path]) var aliasCalled = false @@ -235,6 +622,10 @@ struct PathBuilderTests { #expect(!aliasCalled) #expect(resolved == path) } + + private static func shellSingleQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'" + } } private final class MockFileManager: FileManager { diff --git a/Tests/CodexBarTests/PerplexityCookieCacheTests.swift b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift new file mode 100644 index 000000000..a734d9edf --- /dev/null +++ b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift @@ -0,0 +1,203 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PerplexityCookieCacheTests { + private static let testToken = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.fake-test-token" + private static let testCookieName = PerplexityCookieHeader.defaultSessionCookieName + + 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 + } + } + + // MARK: - Cache round-trip + + @Test + func `cache round trip produces valid cookie override`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + let cached = CookieHeaderCache.load(provider: .perplexity) + #expect(cached != nil) + #expect(cached?.sourceLabel == "web") + + let override = PerplexityCookieHeader.override(from: cached?.cookieHeader) + #expect(override?.name == Self.testCookieName) + #expect(override?.token == Self.testToken) + } + + // MARK: - isAvailable returns true when cache has entry + + @Test + func `is available returns true when cache populated`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + // With no cache and no other sources, load should return nil + let beforeStore = CookieHeaderCache.load(provider: .perplexity) + #expect(beforeStore == nil) + + // After storing, cache should be available + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + let afterStore = CookieHeaderCache.load(provider: .perplexity) + #expect(afterStore != nil) + } + + // MARK: - Cache cleared on invalidToken + + @Test + func `cache cleared on invalid token`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + // Verify it's cached + #expect(CookieHeaderCache.load(provider: .perplexity) != nil) + + // Simulate what fetch() does on invalidToken: clear the cache + CookieHeaderCache.clear(provider: .perplexity) + + #expect(CookieHeaderCache.load(provider: .perplexity) == nil) + } + + // MARK: - Cache NOT cleared on non-auth errors + + @Test + func `cache not cleared on network error`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + // Simulate a networkError — cache should NOT be cleared + let error = PerplexityAPIError.networkError("timeout") + switch error { + case .invalidToken: + CookieHeaderCache.clear(provider: .perplexity) + default: + break // non-auth errors do not clear cache + } + + #expect(CookieHeaderCache.load(provider: .perplexity) != nil) + } + + @Test + func `cache not cleared on API error`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + // Simulate an apiError (e.g. HTTP 500) — cache should NOT be cleared + let error = PerplexityAPIError.apiError("HTTP 500") + switch error { + case .invalidToken: + CookieHeaderCache.clear(provider: .perplexity) + default: + break // non-auth errors do not clear cache + } + + #expect(CookieHeaderCache.load(provider: .perplexity) != nil) + } + + // MARK: - Bare token stored as default cookie name + + @Test + func `bare token round trips with default cookie name`() { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + // Store with default cookie name format + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=\(Self.testToken)", + sourceLabel: "web") + + let cached = CookieHeaderCache.load(provider: .perplexity) + let override = PerplexityCookieHeader.override(from: cached?.cookieHeader) + #expect(override?.name == Self.testCookieName) + #expect(override?.token == Self.testToken) + } + + @Test + func `off mode ignores cached session cookie`() async { + KeychainCacheStore.setTestStoreForTesting(true) + defer { + CookieHeaderCache.clear(provider: .perplexity) + KeychainCacheStore.setTestStoreForTesting(false) + } + + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(Self.testCookieName)=cached-token", + sourceLabel: "web") + + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + + #expect(await strategy.isAvailable(context) == false) + } +} diff --git a/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift new file mode 100644 index 000000000..39b7d2a52 --- /dev/null +++ b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PerplexityCookieHeaderTests { + @Test + func `bare token uses default session cookie name`() { + let override = PerplexityCookieHeader.override(from: "abc123") + #expect(override?.name == PerplexityCookieHeader.defaultSessionCookieName) + #expect(override?.token == "abc123") + #expect(override?.requestCookieNames == PerplexityCookieHeader.supportedSessionCookieNames) + } + + @Test + func `extracts secure next auth session cookie from header`() { + let header = "foo=bar; __Secure-next-auth.session-token=token-a; baz=qux" + let override = PerplexityCookieHeader.override(from: header) + #expect(override?.name == "__Secure-next-auth.session-token") + #expect(override?.token == "token-a") + } + + @Test + func `extracts auth JS session cookie from header`() { + let header = "foo=bar; __Secure-authjs.session-token=token-b; baz=qux" + let override = PerplexityCookieHeader.override(from: header) + #expect(override?.name == "__Secure-authjs.session-token") + #expect(override?.token == "token-b") + } + + @Test + func `prefers auth JS session cookie when both names exist`() { + let header = """ + __Secure-next-auth.session-token=legacy-token; __Secure-authjs.session-token=live-token + """ + let override = PerplexityCookieHeader.override(from: header) + #expect(override?.name == "__Secure-authjs.session-token") + #expect(override?.token == "live-token") + } + + @Test + func `reassembles chunked next auth session cookie from header`() { + let header = """ + foo=bar; __Secure-next-auth.session-token.1=chunk-b; __Secure-next-auth.session-token.0=chunk-a + """ + let override = PerplexityCookieHeader.override(from: header) + #expect(override?.name == "__Secure-next-auth.session-token") + #expect(override?.token == "chunk-achunk-b") + } + + @Test + func `reassembles chunked auth JS session cookie from header`() { + let header = "foo=bar; authjs.session-token.0=chunk-a; authjs.session-token.1=chunk-b" + let override = PerplexityCookieHeader.override(from: header) + #expect(override?.name == "authjs.session-token") + #expect(override?.token == "chunk-achunk-b") + } + + @Test + func `unsupported cookie header returns nil`() { + let override = PerplexityCookieHeader.override(from: "foo=bar; hello=world") + #expect(override == nil) + } + + #if os(macOS) + @Test + func `importer session info reassembles chunked session cookies`() throws { + let cookies = try [ + #require(self.makeCookie(name: "__Secure-authjs.session-token.0", value: "chunk-a")), + #require(self.makeCookie(name: "__Secure-authjs.session-token.1", value: "chunk-b")), + ] + let session = PerplexityCookieImporter.SessionInfo(cookies: cookies, sourceLabel: "Chrome") + + #expect(session.sessionCookie?.name == "__Secure-authjs.session-token") + #expect(session.sessionCookie?.token == "chunk-achunk-b") + } + #endif + + #if os(macOS) + private func makeCookie(name: String, value: String) -> HTTPCookie? { + HTTPCookie(properties: [ + .domain: "www.perplexity.ai", + .path: "/", + .name: name, + .value: value, + .secure: "TRUE", + ]) + } + #endif +} diff --git a/Tests/CodexBarTests/PerplexityProviderTests.swift b/Tests/CodexBarTests/PerplexityProviderTests.swift new file mode 100644 index 000000000..b81a46844 --- /dev/null +++ b/Tests/CodexBarTests/PerplexityProviderTests.swift @@ -0,0 +1,397 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PerplexityProviderTests { + private static let now = Date(timeIntervalSince1970: 1_740_000_000) + + private final class LockedArray: @unchecked Sendable { + private let lock = NSLock() + private var values: [Element] = [] + + func append(_ value: Element) { + self.lock.lock() + defer { self.lock.unlock() } + self.values.append(value) + } + + func snapshot() -> [Element] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } + } + + private final class LockedCounter: @unchecked Sendable { + private let lock = NSLock() + private var value: Int = 0 + + func increment() { + self.lock.lock() + defer { self.lock.unlock() } + self.value += 1 + } + + func snapshot() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + + 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 func makeContext( + settings: ProviderSettingsSnapshot?, + env: [String: String] = [:]) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + private func stubSnapshot(now: Date = Self.now) -> PerplexityUsageSnapshot { + PerplexityUsageSnapshot( + response: PerplexityCreditsResponse( + balanceCents: 500, + renewalDateTs: now.addingTimeInterval(3600).timeIntervalSince1970, + currentPeriodPurchasedCents: 0, + creditGrants: [ + PerplexityCreditGrant(type: "recurring", amountCents: 1000, expiresAtTs: nil), + ], + totalUsageCents: 500), + now: now) + } + + private func withIsolatedCacheStore(operation: () async throws -> T) async rethrows -> T { + let service = "perplexity-provider-tests-\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + return try await operation() + } + } + + @Test + func `off mode ignores environment session cookie`() async { + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) + + #expect(await strategy.isAvailable(context) == false) + } + + @Test + func `manual mode invalid cookie does not fall back to cache or environment`() async { + await self.withIsolatedCacheStore { + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(PerplexityCookieHeader.defaultSessionCookieName)=cached-token", + sourceLabel: "web") + + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .manual, + manualCookieHeader: "foo=bar")) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in + self.stubSnapshot() + } + + do { + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + Issue.record("Expected invalid manual-cookie error instead of falling back to cache/environment") + } catch let error as PerplexityAPIError { + #expect(error == .invalidCookie) + } catch { + Issue.record("Expected PerplexityAPIError.invalidCookie, got \(error)") + } + } + } + + @Test + func `environment token does not populate browser cookie cache`() async throws { + try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + throw PerplexityCookieImportError.noCookies + } + defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in + self.stubSnapshot() + } + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(CookieHeaderCache.load(provider: .perplexity) == nil) + } + } + + @Test + func `manual token does not populate browser cookie cache`() async throws { + try await self.withIsolatedCacheStore { + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .manual, + manualCookieHeader: "authjs.session-token=manual-token")) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in + self.stubSnapshot() + } + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(CookieHeaderCache.load(provider: .perplexity) == nil) + } + } + + @Test + func `bare environment token falls back to auth JS cookie name`() async throws { + try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + throw PerplexityCookieImportError.noCookies + } + defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let attemptedCookieNames = LockedArray() + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_SESSION_TOKEN": "env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, cookieName, _ in + #expect(token == "env-token") + attemptedCookieNames.append(cookieName) + if cookieName == "authjs.session-token" { + return self.stubSnapshot() + } + throw PerplexityAPIError.invalidToken + } + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(attemptedCookieNames.snapshot() == [ + "__Secure-authjs.session-token", + "authjs.session-token", + ]) + } + } + + @Test + func `valid environment cookie wins after invalid browser session`() async throws { + try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + let cookie = try #require(HTTPCookie(properties: [ + .domain: "www.perplexity.ai", + .path: "/", + .name: PerplexityCookieHeader.defaultSessionCookieName, + .value: "browser-token", + .secure: "TRUE", + ])) + return PerplexityCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome") + } + defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let attemptedTokens = LockedArray() + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext( + settings: settings, + env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, _, _ in + attemptedTokens.append(token) + if token == "browser-token" { + throw PerplexityAPIError.invalidToken + } + if token == "env-token" { + return self.stubSnapshot() + } + Issue.record("Unexpected token \(token)") + throw PerplexityAPIError.invalidToken + } + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(attemptedTokens.snapshot() == ["browser-token", "env-token"]) + } + } + + @Test + func `later browser session wins after earlier imported session fails auth`() async throws { + try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.importSessionsOverrideForTesting = { _, _ in + let staleCookie = try #require(HTTPCookie(properties: [ + .domain: "www.perplexity.ai", + .path: "/", + .name: "__Secure-authjs.session-token", + .value: "stale-browser-token", + .secure: "TRUE", + ])) + let liveCookie = try #require(HTTPCookie(properties: [ + .domain: "www.perplexity.ai", + .path: "/", + .name: "__Secure-authjs.session-token", + .value: "live-browser-token", + .secure: "TRUE", + ])) + return [ + PerplexityCookieImporter.SessionInfo(cookies: [staleCookie], sourceLabel: "Chrome"), + PerplexityCookieImporter.SessionInfo(cookies: [liveCookie], sourceLabel: "Safari"), + ] + } + defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let attemptedTokens = LockedArray() + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, _, _ in + attemptedTokens.append(token) + if token == "stale-browser-token" { + throw PerplexityAPIError.invalidToken + } + if token == "live-browser-token" { + return self.stubSnapshot() + } + Issue.record("Unexpected token \(token)") + throw PerplexityAPIError.invalidToken + } + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(attemptedTokens.snapshot() == ["stale-browser-token", "live-browser-token"]) + } + } + + @Test + func `auto mode reuses browser import between availability and fetch`() async throws { + try await self.withIsolatedCacheStore { + let importCount = LockedCounter() + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + importCount.increment() + let cookie = try #require(HTTPCookie(properties: [ + .domain: "www.perplexity.ai", + .path: "/", + .name: PerplexityCookieHeader.defaultSessionCookieName, + .value: "browser-token", + .secure: "TRUE", + ])) + return PerplexityCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome") + } + defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil + PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + let fetchOverride: @Sendable (String, String, Date) async throws + -> PerplexityUsageSnapshot = { token, _, _ in + #expect(token == "browser-token") + return self.stubSnapshot() + } + + #expect(await strategy.isAvailable(context)) + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + + #expect(importCount.snapshot() == 1) + } + } +} diff --git a/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift b/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift new file mode 100644 index 000000000..2f7b1ff7a --- /dev/null +++ b/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift @@ -0,0 +1,38 @@ +import Testing +@testable import CodexBarCore + +struct PerplexitySettingsReaderTests { + @Test + func `PERPLEXITY_COOKIE preserves the original supported cookie name`() { + let override = PerplexitySettingsReader.sessionCookieOverride(environment: [ + "PERPLEXITY_COOKIE": "authjs.session-token=env-token", + ]) + + #expect(override?.name == "authjs.session-token") + #expect(override?.token == "env-token") + #expect(PerplexitySettingsReader.sessionToken(environment: [ + "PERPLEXITY_COOKIE": "authjs.session-token=env-token", + ]) == "env-token") + } + + @Test + func `PERPLEXITY_COOKIE reassembles chunked session cookies`() { + let override = PerplexitySettingsReader.sessionCookieOverride(environment: [ + "PERPLEXITY_COOKIE": "authjs.session-token.0=chunk-a; authjs.session-token.1=chunk-b", + ]) + + #expect(override?.name == "authjs.session-token") + #expect(override?.token == "chunk-achunk-b") + } + + @Test + func `PERPLEXITY_SESSION_TOKEN tries all supported cookie names`() { + let override = PerplexitySettingsReader.sessionCookieOverride(environment: [ + "PERPLEXITY_SESSION_TOKEN": "env-token", + ]) + + #expect(override?.name == PerplexityCookieHeader.defaultSessionCookieName) + #expect(override?.token == "env-token") + #expect(override?.requestCookieNames == PerplexityCookieHeader.supportedSessionCookieNames) + } +} diff --git a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift new file mode 100644 index 000000000..0afd0cbcc --- /dev/null +++ b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift @@ -0,0 +1,362 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PerplexityUsageFetcherTests { + // Fixed "now" so expiry comparisons are deterministic + private static let now = Date(timeIntervalSince1970: 1_740_000_000) // Feb 20, 2026 + private static let futureTs: TimeInterval = 1_750_000_000 // ~Jun 2025, after now + private static let pastTs: TimeInterval = 1_700_000_000 // ~Nov 2023, before now + private static let renewalTs: TimeInterval = 1_743_000_000 // ~Mar 26, 2026 + + // MARK: - JSON Parsing + + @Test + func `parses full response with recurring and promotional credits`() throws { + let json = """ + { + "balance_cents": 7250, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }, + { "type": "promotional", "amount_cents": 20000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 2750 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + #expect(snapshot.recurringTotal == 10000) + #expect(snapshot.recurringUsed == 2750) + #expect(snapshot.promoTotal == 20000) + #expect(snapshot.promoUsed == 0) + #expect(snapshot.purchasedTotal == 0) + #expect(snapshot.purchasedUsed == 0) + #expect(snapshot.balanceCents == 7250) + #expect(snapshot.totalUsageCents == 2750) + #expect(abs(snapshot.renewalDate.timeIntervalSince1970 - Self.renewalTs) < 1) + } + + @Test + func `waterfall attribution recurring then purchased then promo`() throws { + // Usage exceeds recurring, spills into purchased, then promo + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 3000, + "credit_grants": [ + { "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) }, + { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 9000 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + #expect(snapshot.recurringUsed == 5000) // recurring fully consumed + #expect(snapshot.purchasedUsed == 3000) // purchased fully consumed + #expect(snapshot.promoUsed == 1000) // 9000 - 5000 - 3000 = 1000 from promo + } + + @Test + func `expired promotional grants are excluded`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }, + { "type": "promotional", "amount_cents": 5000, "expires_at_ts": \(Self.pastTs) } + ], + "total_usage_cents": 1000 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + #expect(snapshot.promoTotal == 0) // expired grant excluded + #expect(snapshot.promoUsed == 0) + #expect(snapshot.promoExpiration == nil) + } + + @Test + func `empty credit grants produces zero recurring`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [], + "total_usage_cents": 0 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + #expect(snapshot.recurringTotal == 0) + #expect(snapshot.promoTotal == 0) + #expect(snapshot.purchasedTotal == 0) + #expect(snapshot.planName == nil) + } + + @Test + func `malformed JSON throws parse failed`() { + let json = """ + { "balance_cents": "not a number", "credit_grants": null } + """ + #expect { + _ = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + } throws: { error in + guard case PerplexityAPIError.parseFailed = error else { return false } + return true + } + } + + // MARK: - Plan Name Inference + + @Test + func `plan name inference`() throws { + func makeSnapshot(recurringCents: Double) throws -> PerplexityUsageSnapshot { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": \(recurringCents), "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 0 + } + """ + return try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + } + + #expect(try makeSnapshot(recurringCents: 0).planName == nil) + #expect(try makeSnapshot(recurringCents: 500).planName == "Pro") + #expect(try makeSnapshot(recurringCents: 1000).planName == "Pro") + #expect(try makeSnapshot(recurringCents: 10000).planName == "Max") + } + + // MARK: - toUsageSnapshot + + @Test + func `to usage snapshot always has secondary and tertiary`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 0 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + .toUsageSnapshot() + + // secondary and tertiary always present even when no promo/purchased credits + #expect(snapshot.secondary != nil) + #expect(snapshot.tertiary != nil) + } + + @Test + func `to usage snapshot zero recurring bar is fully depleted`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [], + "total_usage_cents": 0 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + .toUsageSnapshot() + let primary = try #require(snapshot.primary) + + // No recurring credits → bar renders as empty (100% used), not full (0% used) + #expect(primary.usedPercent == 100.0) + } + + @Test + func `to usage snapshot omits primary when only fallback credits remain`() throws { + let json = """ + { + "balance_cents": 6000, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 2000, + "credit_grants": [ + { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 0 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + .toUsageSnapshot() + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary?.usedPercent == 0.0) + #expect(snapshot.tertiary?.usedPercent == 0.0) + } + + @Test + func `to usage snapshot empty pools bars are fully depleted`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 0 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + .toUsageSnapshot() + let secondary = try #require(snapshot.secondary) + let tertiary = try #require(snapshot.tertiary) + + // Empty pools render as 100% used (empty bar) not 0% used (full bar) + #expect(secondary.usedPercent == 100.0) + #expect(tertiary.usedPercent == 100.0) + } + + // MARK: - Purchased credits from credit_grants + + @Test + func `purchased credits from credit grants array`() throws { + // Purchased credits appear as credit_grant type="purchased" instead of + // current_period_purchased_cents. The snapshot should pick them up. + let json = """ + { + "balance_cents": 23065, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }, + { "type": "purchased", "amount_cents": 40000 }, + { "type": "promotional", "amount_cents": 55000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 81935 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + #expect(snapshot.recurringTotal == 10000) + #expect(snapshot.purchasedTotal == 40000) + #expect(snapshot.promoTotal == 55000) + + // Waterfall: recurring eats 10000, purchased eats 40000, promo eats 31935 + #expect(snapshot.recurringUsed == 10000) + #expect(snapshot.purchasedUsed == 40000) + #expect(snapshot.promoUsed == 31935) + } + + @Test + func `purchased credits prefer grants over field when both present`() throws { + // When both current_period_purchased_cents AND credit_grants type="purchased" + // are provided, the larger value wins. + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 3000, + "credit_grants": [ + { "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) }, + { "type": "purchased", "amount_cents": 8000 }, + { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 14000 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + // Purchased should use max(8000, 3000) = 8000 + #expect(snapshot.purchasedTotal == 8000) + // Waterfall: 5000 recurring + 8000 purchased + 1000 promo = 14000 + #expect(snapshot.recurringUsed == 5000) + #expect(snapshot.purchasedUsed == 8000) + #expect(snapshot.promoUsed == 1000) + } + + @Test + func `purchased credits from field when no grant type`() throws { + // Legacy path: current_period_purchased_cents is set but no "purchased" grant + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 3000, + "credit_grants": [ + { "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) }, + { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 9000 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + + // Still picks up purchased from the top-level field + #expect(snapshot.purchasedTotal == 3000) + #expect(snapshot.recurringUsed == 5000) + #expect(snapshot.purchasedUsed == 3000) + #expect(snapshot.promoUsed == 1000) + } + + @Test + func `real world max plan with all three pools`() throws { + // Real-world scenario: Max plan, 10k recurring + 40k purchased + 55k bonus + // Total 105,000 available, 23,065 remaining → 81,935 used + let json = """ + { + "balance_cents": 23065, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }, + { "type": "purchased", "amount_cents": 40000 }, + { "type": "promotional", "amount_cents": 55000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 81935 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + let usage = snapshot.toUsageSnapshot() + + // Primary (recurring): fully consumed → 100% + let primary = try #require(usage.primary) + #expect(primary.usedPercent == 100.0) + + // Tertiary (purchased): fully consumed → 100% + let tertiary = try #require(usage.tertiary) + #expect(tertiary.usedPercent == 100.0) + + // Secondary (bonus): 31935/55000 ≈ 58.06% used → ~42% remaining + let secondary = try #require(usage.secondary) + let expectedPromoPercent = 31935.0 / 55000.0 * 100.0 + #expect(abs(secondary.usedPercent - expectedPromoPercent) < 0.1) + } + + @Test + func `to usage snapshot primary percent matches usage`() throws { + let json = """ + { + "balance_cents": 0, + "renewal_date_ts": \(Self.renewalTs), + "current_period_purchased_cents": 0, + "credit_grants": [ + { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) } + ], + "total_usage_cents": 2500 + } + """ + let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) + .toUsageSnapshot() + let primary = try #require(snapshot.primary) + + #expect(primary.usedPercent == 25.0) + } +} diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift new file mode 100644 index 000000000..1a063ce78 --- /dev/null +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -0,0 +1,676 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiSessionCostScannerTests { + @Test + func `pi scanner maps assistant usage to codex and claude reports`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let codexDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let claudeDay = try env.makeLocalNoon(year: 2026, month: 4, day: 3) + + let codexEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: codexDay), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(codexDay.timeIntervalSince1970 * 1000), + "usage": [ + "input": 120, + "output": 30, + "cacheRead": 10, + "cacheWrite": 5, + "totalTokens": 165, + ], + ], + ] + let claudeEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: claudeDay), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "anthropic.foo.claude-sonnet-4-6-v1:0", + "timestamp": Int(claudeDay.timeIntervalSince1970 * 1000), + "usage": [ + "input": 80, + "output": 20, + "cacheRead": 4, + "cacheWrite": 6, + "totalTokens": 110, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "nested/run-0/2026-04-02T10-00-00-000Z_test.jsonl", + contents: env.jsonl([codexEntry, claudeEntry])) + + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + + let codexReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: codexDay, + until: claudeDay, + now: claudeDay, + options: options) + let expectedCodexCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 135, + cachedInputTokens: 10, + outputTokens: 30) + #expect(codexReport.data.count == 1) + #expect(codexReport.data.first?.date == "2026-04-02") + #expect(codexReport.data.first?.totalTokens == 165) + #expect(abs((codexReport.data.first?.costUSD ?? 0) - (expectedCodexCost ?? 0)) < 0.000001) + #expect(codexReport.data.first?.modelBreakdowns?.map(\.modelName) == ["gpt-5.4"]) + + let claudeReport = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: codexDay, + until: claudeDay, + now: claudeDay, + options: options) + let expectedClaudeCost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 80, + cacheReadInputTokens: 4, + cacheCreationInputTokens: 6, + outputTokens: 20) + #expect(claudeReport.data.count == 1) + #expect(claudeReport.data.first?.date == "2026-04-03") + #expect(claudeReport.data.first?.totalTokens == 110) + #expect(abs((claudeReport.data.first?.costUSD ?? 0) - (expectedClaudeCost ?? 0)) < 0.000001) + #expect(claudeReport.data.first?.modelBreakdowns?.map(\.modelName) == ["claude-sonnet-4-6"]) + } + + @Test + func `pi scanner uses model change fallback and assistant timestamp day`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let sessionStart = try env.makeLocalNoon(year: 2026, month: 4, day: 1) + let assistantDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + + let modelChange: [String: Any] = [ + "type": "model_change", + "timestamp": env.isoString(for: sessionStart), + "provider": "openai-codex", + "modelId": "openai/gpt-5.3-codex", + ] + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: sessionStart), + "message": [ + "role": "assistant", + "timestamp": Int(assistantDay.timeIntervalSince1970 * 1000), + "usage": [ + "input": 20, + "cacheRead": 2, + "output": 20, + "totalTokens": 42, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-04-01T09-00-00-000Z_test.jsonl", + contents: env.jsonl([modelChange, assistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: sessionStart, + until: assistantDay, + now: assistantDay, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + let expectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.3-codex", + inputTokens: 22, + cachedInputTokens: 2, + outputTokens: 20) + #expect(report.data.count == 1) + #expect(report.data.first?.date == "2026-04-02") + #expect(report.data.first?.totalTokens == 42) + #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["gpt-5.3-codex"]) + } + + @Test + func `pi scanner refreshes appended file without duplicating existing usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 4) + let firstTimestamp = Int(day.timeIntervalSince1970 * 1000) + let secondTimestamp = Int(day.addingTimeInterval(60).timeIntervalSince1970 * 1000) + + let firstAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": firstTimestamp, + "usage": [ + "input": 10, + "output": 5, + "totalTokens": 15, + ], + ], + ] + let secondAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": secondTimestamp, + "usage": [ + "input": 20, + "output": 10, + "totalTokens": 30, + ], + ], + ] + + let url = try env.writePiSessionFile( + relativePath: "2026-04-04T10-00-00-000Z_test.jsonl", + contents: env.jsonl([firstAssistant])) + + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let firstReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let firstExpectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 10, + cachedInputTokens: 0, + outputTokens: 5) + #expect(firstReport.data.count == 1) + #expect(firstReport.data.first?.totalTokens == 15) + #expect(abs((firstReport.data.first?.costUSD ?? 0) - (firstExpectedCost ?? 0)) < 0.000001) + + try env.jsonl([firstAssistant, secondAssistant]).write(to: url, atomically: true, encoding: .utf8) + + let secondReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let secondExpectedCost = (firstExpectedCost ?? 0) + (CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 20, + cachedInputTokens: 0, + outputTokens: 10) ?? 0) + #expect(secondReport.data.count == 1) + #expect(secondReport.data.first?.totalTokens == 45) + #expect(abs((secondReport.data.first?.costUSD ?? 0) - secondExpectedCost) < 0.000001) + } + + @Test + func `pi scanner ignores explicit unsupported provider even with fallback context`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 5) + let modelChange: [String: Any] = [ + "type": "model_change", + "timestamp": env.isoString(for: day), + "provider": "openai-codex", + "modelId": "gpt-5.4", + ] + let unsupportedAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openrouter", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 99, + "output": 1, + "totalTokens": 100, + ], + ], + ] + let fallbackAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day.addingTimeInterval(60)), + "message": [ + "role": "assistant", + "timestamp": Int(day.addingTimeInterval(60).timeIntervalSince1970 * 1000), + "usage": [ + "input": 10, + "output": 5, + "totalTokens": 15, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-04-05T10-00-00-000Z_test.jsonl", + contents: env.jsonl([modelChange, unsupportedAssistant, fallbackAssistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 15) + #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["gpt-5.4"]) + } + + @Test + func `pi scanner force rescan bypasses stale same size metadata cache`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 6) + let assistantOne: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 10, + "output": 5, + "totalTokens": 15, + ], + ], + ] + let assistantTwo: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 20, + "output": 5, + "totalTokens": 25, + ], + ], + ] + + let firstContents = try env.jsonl([assistantOne]) + let secondContents = try env.jsonl([assistantTwo]) + #expect(firstContents.utf8.count == secondContents.utf8.count) + + let url = try env.writePiSessionFile( + relativePath: "2026-04-06T10-00-00-000Z_test.jsonl", + contents: firstContents) + let originalModifiedAt = try #require( + FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) + + let cachedOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let firstReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: cachedOptions) + #expect(firstReport.data.first?.totalTokens == 15) + + try secondContents.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: originalModifiedAt], ofItemAtPath: url.path) + + let staleReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: cachedOptions) + #expect(staleReport.data.first?.totalTokens == 15) + + let refreshedReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + forceRescan: true)) + #expect(refreshedReport.data.first?.totalTokens == 25) + } + + @Test + func `pi scanner derives cost when explicit cost is absent`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 7) + let assistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 70, + "cacheRead": 4, + "cacheWrite": 6, + "output": 19, + "totalTokens": 99, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-04-07T10-00-00-000Z_test.jsonl", + contents: env.jsonl([assistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + let expectedCost = CostUsagePricing.claudeCostUSD( + model: "claude-sonnet-4-6", + inputTokens: 70, + cacheReadInputTokens: 4, + cacheCreationInputTokens: 6, + outputTokens: 19) + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 99) + #expect(abs((report.data.first?.costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) + } + + @Test + func `pi scanner preserves per-message threshold pricing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 9) + let model = "claude-sonnet-4-6" + let firstAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + let secondAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.addingTimeInterval(1).timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-05-09T10-00-00-000Z_threshold.jsonl", + contents: env.jsonl([firstAssistant, secondAssistant])) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + let expectedRequestCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 150_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let aggregateCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 300_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let expectedCost = expectedRequestCost * 2 + + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 300_000) + #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) + #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) + #expect(abs((report.data.first?.modelBreakdowns?.first?.costUSD ?? 0) - expectedCost) < 0.000001) + } + + @Test + func `pi scanner ignores v1 cache missing usage sample counts`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 10) + let model = "claude-sonnet-4-6" + let firstAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + let secondAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": model, + "timestamp": Int(day.addingTimeInterval(1).timeIntervalSince1970 * 1000), + "usage": [ + "input": 150_000, + "output": 0, + "totalTokens": 150_000, + ], + ], + ] + + let fileURL = try env.writePiSessionFile( + relativePath: "2026-05-10T10-00-00-000Z_threshold.jsonl", + contents: env.jsonl([firstAssistant, secondAssistant])) + let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let mtime = try #require(attrs[.modificationDate] as? Date) + let size = try #require((attrs[.size] as? NSNumber)?.int64Value) + + let requestCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 150_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let aggregateCost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: 300_000, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 0, + modelsDevCacheRoot: env.cacheRoot) ?? 0 + let aggregatePacked = PiPackedUsage( + inputTokens: 300_000, + totalTokens: 300_000, + costNanos: Int64((aggregateCost * 1_000_000_000).rounded()), + costSampleCount: 2, + usageSampleCount: nil) + let dayKey = "2026-05-10" + let contributions = [ + UsageProvider.claude.rawValue: [ + dayKey: [ + model: aggregatePacked, + ], + ], + ] + let oldFileUsage = PiSessionFileUsage( + mtimeUnixMs: Int64(mtime.timeIntervalSince1970 * 1000), + size: size, + parsedBytes: size, + lastModelContext: nil, + contributions: contributions) + var oldCache = PiSessionCostCache(version: 1) + oldCache.lastScanUnixMs = Int64(day.timeIntervalSince1970 * 1000) + oldCache.scanSinceKey = dayKey + oldCache.scanUntilKey = dayKey + oldCache.daysByProvider = contributions + oldCache.files = [fileURL.path: oldFileUsage] + let oldCacheURL = env.cacheRoot + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("pi-sessions-v1.json", isDirectory: false) + try FileManager.default.createDirectory( + at: oldCacheURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try JSONEncoder().encode(oldCache).write(to: oldCacheURL) + + let report = PiSessionCostScanner.loadDailyReport( + provider: .claude, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600)) + + let expectedCost = requestCost * 2 + #expect(report.data.count == 1) + #expect(report.data.first?.totalTokens == 300_000) + #expect(abs((report.data.first?.costUSD ?? 0) - expectedCost) < 0.000001) + #expect(abs((report.data.first?.costUSD ?? 0) - aggregateCost) > 0.000001) + + let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + let rebuilt = newCache.daysByProvider[UsageProvider.claude.rawValue]?[dayKey]?[model] + #expect(newCache.version == 2) + #expect(rebuilt?.usageSampleCount == 2) + #expect(rebuilt?.costSampleCount == 2) + } + + @Test + func `pi scanner reparses unchanged cached file when scan window expands`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let oldDay = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let newDay = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let oldAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: oldDay), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(oldDay.timeIntervalSince1970 * 1000), + "usage": [ + "input": 10, + "output": 5, + "totalTokens": 15, + ], + ], + ] + let newAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: newDay), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(newDay.timeIntervalSince1970 * 1000), + "usage": [ + "input": 20, + "output": 10, + "totalTokens": 30, + ], + ], + ] + + _ = try env.writePiSessionFile( + relativePath: "2026-04-08T10-00-00-000Z_test.jsonl", + contents: env.jsonl([oldAssistant, newAssistant])) + + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600) + let narrowReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: newDay, + until: newDay, + now: newDay, + options: options) + #expect(narrowReport.data.map(\.date) == ["2026-04-08"]) + #expect(narrowReport.data.first?.totalTokens == 30) + + let expandedReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: oldDay, + until: newDay, + now: newDay.addingTimeInterval(1), + options: options) + #expect(expandedReport.data.map(\.date) == ["2026-04-02", "2026-04-08"]) + #expect(expandedReport.summary?.totalTokens == 45) + } +} diff --git a/Tests/CodexBarTests/PopupLocalizationTests.swift b/Tests/CodexBarTests/PopupLocalizationTests.swift new file mode 100644 index 000000000..8389645f9 --- /dev/null +++ b/Tests/CodexBarTests/PopupLocalizationTests.swift @@ -0,0 +1,183 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct PopupLocalizationTests { + @Test + func `descriptor account labels use selected localization`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let suite = "PopupLocalizationTests-descriptor" + let settings = try Self.makeSettingsStore(suite: suite) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "free")), + provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = Self.textLines(from: descriptor) + + #expect(lines.contains("帳號: codex@example.com")) + #expect(lines.contains("方案: Free")) + #expect(!lines.contains("Account: codex@example.com")) + #expect(!lines.contains("Plan: Free")) + } + } + + @Test + func `inline dashboard labels use selected localization`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let usage = OpenRouterUsageSnapshot( + totalCredits: 100, + totalUsage: 40, + balance: 60, + usedPercent: 40, + keyDataFetched: true, + keyLimit: 25, + keyUsage: 10, + keyUsageDaily: 1.25, + keyUsageWeekly: 7.5, + keyUsageMonthly: 18.75, + rateLimit: OpenRouterRateLimit(requests: 100, interval: "10s"), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let dashboard = try #require(model.inlineUsageDashboard) + + #expect(dashboard.kpis.map(\.title) == ["餘額", "今天", "週", "月"]) + #expect(dashboard.points.map(\.label) == ["今天", "週", "月"]) + #expect(dashboard.detailLines.contains("速率限制: 100 / 10s")) + } + } + + @Test + func `cookie source dynamic subtitles use selected localization`() { + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let subtitle = ProviderCookieSourceUI.subtitle( + source: .manual, + keychainDisabled: false, + auto: "Automatically imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from T3 Chat settings.", + off: "T3 Chat cookies are disabled.") + let disabledSubtitle = ProviderCookieSourceUI.subtitle( + source: .manual, + keychainDisabled: true, + auto: "Automatically imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from T3 Chat settings.", + off: "T3 Chat cookies are disabled.") + let jsonBundleSubtitle = ProviderCookieSourceUI.subtitle( + source: .manual, + keychainDisabled: false, + auto: "Automatically imports browser cookies.", + manual: "Paste the localStorage JSON bundle from Windsurf session.", + off: "Windsurf cookies are disabled.") + + #expect(subtitle.contains("貼上")) + #expect(!subtitle.contains("Paste a Cookie")) + #expect(disabledSubtitle.contains("鑰匙圈")) + #expect(!disabledSubtitle.contains("Keychain access")) + #expect(jsonBundleSubtitle.contains("來自 Windsurf session 的 localStorage JSON")) + } + } + + @Test + func `provider organization entries preserve provider supplied text`() throws { + let settings = try Self.makeSettingsStore(suite: "PopupLocalizationTests-organizations") + settings.kiloKnownOrganizations = [ + KiloOrganization(id: "org_cost", name: "Cost", role: "Today"), + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let context = ProviderSettingsContext( + provider: .kilo, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + let descriptor = try #require(KiloProviderImplementation().settingsOrganizations(context: context)) + let orgEntry = try #require(descriptor.entries().first { $0.id == "org_cost" }) + + #expect(orgEntry.title == "Cost") + #expect(orgEntry.localizesTitle == false) + #expect(orgEntry.subtitle == "Today") + #expect(orgEntry.localizesSubtitle == false) + } + + private static func makeSettingsStore(suite: String) throws -> SettingsStore { + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + return settings + } + + private static func textLines(from descriptor: MenuDescriptor) -> [String] { + descriptor.sections.flatMap(\.entries).compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + } +} diff --git a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift index bb6d8f43a..a7b19b7bd 100644 --- a/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift +++ b/Tests/CodexBarTests/PreferencesPaneSmokeTests.swift @@ -4,10 +4,10 @@ import Testing @testable import CodexBar @MainActor -@Suite +@Suite(.serialized) struct PreferencesPaneSmokeTests { @Test - func buildsPreferencePanesWithDefaultSettings() { + func `builds preference panes with default settings`() { let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-default") let store = Self.makeUsageStore(settings: settings) @@ -22,11 +22,11 @@ struct PreferencesPaneSmokeTests { } @Test - func buildsPreferencePanesWithToggledSettings() { + func `builds preference panes with toggled settings`() { let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-toggled") settings.menuBarShowsBrandIconWithPercent = true settings.menuBarShowsHighestUsage = true - settings.showAllTokenAccountsInMenu = true + settings.multiAccountMenuLayout = .stacked settings.hidePersonalInfo = true settings.resetTimesShowAbsolute = true settings.debugDisableKeychainAccess = true @@ -44,6 +44,41 @@ struct PreferencesPaneSmokeTests { _ = AboutPane(updater: DisabledUpdaterController()).body } + @Test + func `overview provider limit text formats numeric limit as object argument`() { + let text = DisplayPane.overviewProviderLimitText(limit: 3) + + #expect(text.contains("3")) + #expect(!text.contains("%@")) + } + + @Test + func `language preference updates global localization resolver`() { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + if let previousAppleLanguages { + UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages") + } else { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } + } + + let settings = Self.makeSettingsStore(suite: "PreferencesPaneSmokeTests-language") + + settings.appLanguage = "zh-Hans" + + #expect(UserDefaults.standard.string(forKey: "appLanguage") == "zh-Hans") + #expect(L("tab_general") == "通用") + #expect(L("quota_warning_notifications_title") == "配额预警通知") + #expect(L("show_provider_storage_usage_title") == "显示提供商存储用量") + } + private static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift b/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift index 4632d1b95..efe66f1c8 100644 --- a/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift +++ b/Tests/CodexBarTests/ProviderCandidateRetryRunnerTests.swift @@ -1,7 +1,6 @@ import Testing @testable import CodexBarCore -@Suite struct ProviderCandidateRetryRunnerTests { private enum TestError: Error, Equatable { case retryable(Int) @@ -9,7 +8,7 @@ struct ProviderCandidateRetryRunnerTests { } @Test - func retriesThenSucceeds() async throws { + func `retries then succeeds`() async throws { let candidates = [1, 2, 3] var attempted: [Int] = [] var retried: [Int] = [] @@ -39,7 +38,7 @@ struct ProviderCandidateRetryRunnerTests { } @Test - func nonRetryableFailsImmediately() async { + func `non retryable fails immediately`() async { let candidates = [1, 2, 3] var attempted: [Int] = [] var retried: [Int] = [] @@ -71,7 +70,7 @@ struct ProviderCandidateRetryRunnerTests { } @Test - func exhaustedRetryableThrowsLastError() async { + func `exhausted retryable throws last error`() async { let candidates = [1, 2] var attempted: [Int] = [] var retried: [Int] = [] @@ -103,7 +102,7 @@ struct ProviderCandidateRetryRunnerTests { } @Test - func emptyCandidatesThrowsNoCandidates() async { + func `empty candidates throws no candidates`() async { do { let candidates: [Int] = [] _ = try await ProviderCandidateRetryRunner.run( diff --git a/Tests/CodexBarTests/ProviderChangelogLinkTests.swift b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift new file mode 100644 index 000000000..a1923a4ab --- /dev/null +++ b/Tests/CodexBarTests/ProviderChangelogLinkTests.swift @@ -0,0 +1,79 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct ProviderChangelogLinkTests { + @Test + func `known CLI providers declare changelog URLs`() { + let metadata = ProviderDefaults.metadata + + #expect(metadata[.codex]?.changelogURL == "https://github.com/openai/codex/releases") + #expect(metadata[.claude]?.changelogURL == "https://github.com/anthropics/claude-code/releases") + #expect(metadata[.gemini]?.changelogURL == "https://github.com/google-gemini/gemini-cli/releases") + } + + @Test + func `provider menu hides changelog action until enabled`() { + let codexDescriptor = self.makeDescriptor( + provider: .codex, + suite: "ProviderChangelogLinkTests-codex-default") + #expect(!self.actionTitles(from: codexDescriptor).contains("Changelog")) + } + + @Test + func `provider menu shows changelog action only when setting and URL are present`() { + let codexDescriptor = self.makeDescriptor( + provider: .codex, + suite: "ProviderChangelogLinkTests-codex", + changelogLinksEnabled: true) + #expect(self.actionTitles(from: codexDescriptor).contains("Changelog")) + + let openRouterDescriptor = self.makeDescriptor( + provider: .openrouter, + suite: "ProviderChangelogLinkTests-openrouter", + changelogLinksEnabled: true) + #expect(!self.actionTitles(from: openRouterDescriptor).contains("Changelog")) + } + + private func makeDescriptor( + provider: UsageProvider, + suite: String, + changelogLinksEnabled: Bool = false) -> MenuDescriptor + { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.providerChangelogLinksEnabled = changelogLinksEnabled + + let fetcher = UsageFetcher(environment: [:]) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + + return MenuDescriptor.build( + provider: provider, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: true) + } + + private func actionTitles(from descriptor: MenuDescriptor) -> [String] { + descriptor.sections + .flatMap(\.entries) + .compactMap { entry -> String? in + guard case let .action(title, _) = entry else { return nil } + return title + } + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 702a50843..c2bf3a36e 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -1,10 +1,9 @@ import CodexBarCore import Testing -@Suite struct ProviderConfigEnvironmentTests { @Test - func appliesAPIKeyOverrideForZai() { + func `applies API key override for zai`() { let config = ProviderConfig(id: .zai, apiKey: "z-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], @@ -15,7 +14,7 @@ struct ProviderConfigEnvironmentTests { } @Test - func appliesAPIKeyOverrideForWarp() { + func `applies API key override for warp`() { let config = ProviderConfig(id: .warp, apiKey: "w-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], @@ -30,7 +29,7 @@ struct ProviderConfigEnvironmentTests { } @Test - func appliesAPIKeyOverrideForOpenRouter() { + func `applies API key override for open router`() { let config = ProviderConfig(id: .openrouter, apiKey: "or-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], @@ -41,7 +40,304 @@ struct ProviderConfigEnvironmentTests { } @Test - func appliesAPIKeyOverrideForKilo() { + func `applies API key override for doubao`() { + let config = ProviderConfig(id: .doubao, apiKey: "db-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .doubao, + config: config) + + #expect(env[DoubaoSettingsReader.apiKeyEnvironmentKeys[0]] == "db-token") + #expect(ProviderTokenResolver.doubaoToken(environment: env) == "db-token") + } + + func `applies API key override for moonshot`() { + let config = ProviderConfig(id: .moonshot, apiKey: "moon-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .moonshot, + config: config) + + let key = MoonshotSettingsReader.apiKeyEnvironmentKeys.first + #expect(key != nil) + guard let key else { return } + + #expect(env[key] == "moon-token") + } + + @Test + func `applies API key override for elevenlabs`() { + let config = ProviderConfig(id: .elevenlabs, apiKey: "xi-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .elevenlabs, + config: config) + + #expect(env[ElevenLabsSettingsReader.apiKeyEnvironmentKey] == "xi-token") + #expect(ProviderTokenResolver.elevenLabsToken(environment: env) == "xi-token") + } + + @Test + func `applies API key override for groq`() { + let config = ProviderConfig(id: .groq, apiKey: "gsk-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .groq, + config: config) + + #expect(env[GroqSettingsReader.apiKeyEnvironmentKey] == "gsk-token") + #expect(ProviderTokenResolver.groqToken(environment: env) == "gsk-token") + } + + @Test + func `applies LLM Proxy config overrides`() { + let config = ProviderConfig( + id: .llmproxy, + apiKey: "proxy-token", + enterpriseHost: "https://proxy.example.com") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .llmproxy, + config: config) + + #expect(env[LLMProxySettingsReader.apiKeyEnvironmentKey] == "proxy-token") + #expect(env[LLMProxySettingsReader.baseURLEnvironmentKey] == "https://proxy.example.com") + #expect(ProviderTokenResolver.llmProxyToken(environment: env) == "proxy-token") + } + + @Test + func `openai config override uses preferred admin key environment`() { + let config = ProviderConfig(id: .openai, apiKey: "config-openai-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "env-admin-token", + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "env-api-token", + ], + provider: .openai, + config: config) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "config-openai-token") + #expect(env[OpenAIAPISettingsReader.apiKeyEnvironmentKey] == "env-api-token") + #expect(ProviderTokenResolver.openAIAPIToken(environment: env) == "config-openai-token") + } + + @Test + func `openai config override applies project ID without replacing environment key`() { + let config = ProviderConfig(id: .openai, workspaceID: "proj_config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "env-admin-token", + ], + provider: .openai, + config: config) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "env-admin-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == "proj_config") + #expect(OpenAIAPISettingsReader.projectID(environment: env) == "proj_config") + } + + @Test + func `applies Azure OpenAI config overrides`() { + let config = ProviderConfig( + id: .azureopenai, + apiKey: "config-azure-token", + workspaceID: "chat-prod", + enterpriseHost: "https://example-resource.openai.azure.com") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [ + AzureOpenAISettingsReader.apiKeyEnvironmentKey: "env-azure-token", + AzureOpenAISettingsReader.endpointEnvironmentKey: "https://env-resource.openai.azure.com", + AzureOpenAISettingsReader.deploymentNameEnvironmentKey: "env-deployment", + ], + provider: .azureopenai, + config: config) + + #expect(env[AzureOpenAISettingsReader.apiKeyEnvironmentKey] == "config-azure-token") + #expect(env[AzureOpenAISettingsReader.endpointEnvironmentKey] == "https://example-resource.openai.azure.com") + #expect(env[AzureOpenAISettingsReader.deploymentNameEnvironmentKey] == "chat-prod") + #expect(ProviderTokenResolver.azureOpenAIToken(environment: env) == "config-azure-token") + #expect(AzureOpenAISettingsReader.deploymentName(environment: env) == "chat-prod") + } + + @Test + func `bedrock config maps AWS credential fields`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + cookieHeader: "legacy-cookie-secret", + region: "us-west-2") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "secret") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "us-west-2") + #expect(!env.values.contains("legacy-cookie-secret")) + } + + @Test + func `bedrock config merges secret and region without replacing environment access key`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: nil, + secretKey: "config-secret", + region: "eu-central-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [BedrockSettingsReader.accessKeyIDKey: "env-access"], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "env-access") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "config-secret") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "eu-central-1") + #expect(BedrockSettingsReader.hasCredentials(environment: env)) + } + + @Test + func `bedrock merged static credentials win over inherited AWS_PROFILE`() { + let config = ProviderConfig( + id: .bedrock, + secretKey: "config-secret", + region: "eu-central-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.profileKey: "work", + BedrockSettingsReader.accessKeyIDKey: "env-access", + ], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "env-access") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "config-secret") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "eu-central-1") + #expect(BedrockSettingsReader.authMode(environment: env) == .keys) + } + + @Test + func `bedrock profile mode projects AWS_PROFILE without saved static keys`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + region: "eu-west-1", + awsProfile: "work", + awsAuthMode: "profile") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == "profile") + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "eu-west-1") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == nil) + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == nil) + } + + @Test + func `bedrock config without explicit mode preserves env profile inference`() { + let config = ProviderConfig(id: .bedrock, region: "us-east-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [BedrockSettingsReader.profileKey: "work"], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == nil) + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(BedrockSettingsReader.authMode(environment: env) == .profile) + } + + @Test + func `bedrock saved static keys survive base AWS_PROFILE when auth mode is unset`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIASAVED", + secretKey: "saved-secret", + region: "us-east-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [BedrockSettingsReader.profileKey: "work"], + provider: .bedrock, + config: config) + // Upgrade path: saved keys win over an inherited AWS_PROFILE, no silent switch. + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIASAVED") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "saved-secret") + #expect(BedrockSettingsReader.authMode(environment: env) == .keys) + } + + @Test + func `bedrock profile mode preserves inherited static credentials for environment source profiles`() { + let config = ProviderConfig(id: .bedrock, awsProfile: "work", awsAuthMode: "profile") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.accessKeyIDKey: "AKIAINHERITED", + BedrockSettingsReader.secretAccessKeyKey: "inherited-secret", + BedrockSettingsReader.sessionTokenKey: "inherited-token", + ], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIAINHERITED") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "inherited-secret") + #expect(env[BedrockSettingsReader.sessionTokenKey] == "inherited-token") + #expect(env[BedrockSettingsReader.profileKey] == "work") + } + + @Test + func `bedrock env profile mode does not project saved static credentials`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIASAVED", + secretKey: "saved-secret") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + ], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.authModeKey] == "profile") + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == nil) + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == nil) + } + + @Test + func `bedrock keys mode still projects static credentials`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + region: "us-west-2", + awsAuthMode: "keys") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == "keys") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "secret") + #expect(env[BedrockSettingsReader.profileKey] == nil) + } + + @Test + func `ignores legacy API key override for deepseek`() { + let config = ProviderConfig(id: .deepseek, apiKey: "ds-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .deepseek, + config: config) + + let key = DeepSeekSettingsReader.apiKeyEnvironmentKeys.first + #expect(key != nil) + guard let key else { return } + + #expect(env[key] == nil) + #expect(ProviderTokenResolver.deepseekToken(environment: env) == nil) + } + + @Test + func `applies API key override for kilo`() { let config = ProviderConfig(id: .kilo, apiKey: "kilo-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [:], @@ -53,7 +349,7 @@ struct ProviderConfigEnvironmentTests { } @Test - func openRouterConfigOverrideWinsOverEnvironmentToken() { + func `open router config override wins over environment token`() { let config = ProviderConfig(id: .openrouter, apiKey: "config-token") let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [OpenRouterSettingsReader.envKey: "env-token"], @@ -65,7 +361,85 @@ struct ProviderConfigEnvironmentTests { } @Test - func leavesEnvironmentWhenAPIKeyMissing() { + func `deepseek config override leaves environment token alone`() { + let config = ProviderConfig(id: .deepseek, apiKey: "config-token") + let envKey = DeepSeekSettingsReader.apiKeyEnvironmentKeys[0] + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [envKey: "env-token"], + provider: .deepseek, + config: config) + + #expect(env[envKey] == "env-token") + #expect(ProviderTokenResolver.deepseekToken(environment: env) == "env-token") + } + + @Test + func `applies API key override for codebuff`() { + let config = ProviderConfig(id: .codebuff, apiKey: "cb-config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .codebuff, + config: config) + + #expect(env[CodebuffSettingsReader.apiTokenKey] == "cb-config-token") + #expect( + ProviderTokenResolver.codebuffToken(environment: env, authFileURL: nil) + == "cb-config-token") + } + + @Test + func `applies API key override for deepgram`() { + let config = ProviderConfig(id: .deepgram, apiKey: "dg-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .deepgram, + config: config) + + #expect(env[DeepgramSettingsReader.apiKeyEnvironmentKey] == "dg-token") + #expect(ProviderTokenResolver.deepgramResolution( + type: .apiKey, + environment: env) + == "dg-token") + } + + @Test + func `applies Deepgram project ID override from provider config`() { + let config = ProviderConfig(id: .deepgram, workspaceID: "proj-123") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [:], + provider: .deepgram, + config: config) + + #expect(env[DeepgramSettingsReader.projectIDEnvironmentKey] == "proj-123") + } + + @Test + func `Deepgram project ID config overrides environment`() { + let config = ProviderConfig(id: .deepgram, workspaceID: "config-project") + let env = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: [DeepgramSettingsReader.projectIDEnvironmentKey: "env-project"], + provider: .deepgram, + config: config) + + #expect(env[DeepgramSettingsReader.projectIDEnvironmentKey] == "config-project") + } + + @Test + func `codebuff config override leaves environment token alone`() { + let config = ProviderConfig(id: .codebuff, apiKey: "config-token") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [CodebuffSettingsReader.apiTokenKey: "env-token"], + provider: .codebuff, + config: config) + + #expect(env[CodebuffSettingsReader.apiTokenKey] == "env-token") + #expect( + ProviderTokenResolver.codebuffToken(environment: env, authFileURL: nil) + == "env-token") + } + + @Test + func `leaves environment when API key missing`() { let config = ProviderConfig(id: .zai, apiKey: nil) let env = ProviderConfigEnvironment.applyAPIKeyOverride( base: [ZaiSettingsReader.apiTokenKey: "existing"], diff --git a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift new file mode 100644 index 000000000..71065bdff --- /dev/null +++ b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift @@ -0,0 +1,313 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ProviderDiagnosticExportTests { + @Test + func `generic diagnostic export encodes safe provider envelope`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let export = ProviderDiagnosticExport( + timestamp: now, + provider: "openai", + displayName: "OpenAI", + source: "api", + sourceMode: "auto", + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["api"]), + usage: ProviderDiagnosticUsageSummary(from: UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: "raw local text"), + secondary: nil, + updatedAt: now)), + fetchAttempts: [ + ProviderDiagnosticFetchAttempt( + kind: "api", + wasAvailable: true, + errorCategory: nil), + ], + error: nil, + settings: ProviderDiagnosticSettingsSummary(sourceMode: .auto), + details: nil) + + let json = try self.json(export) + + #expect(json.contains("\"provider\"")) + #expect(json.contains("\"openai\"")) + #expect(json.contains("\"auth\"")) + #expect(json.contains("\"hasResetDescription\"")) + #expect(!json.contains("sk-cp-")) + #expect(!json.contains("sk-api-")) + #expect(!json.contains("Bearer")) + #expect(!json.contains("raw local text")) + #expect(!json.contains("errorMessage")) + #expect(!json.contains("localizedDescription")) + } + + @Test + func `raw error text never appears in encoded JSON`() throws { + let export = ProviderDiagnosticExport( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + provider: "minimax", + displayName: "MiniMax", + source: "failed", + sourceMode: "auto", + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["api"]), + usage: nil, + fetchAttempts: [ + ProviderDiagnosticFetchAttempt( + kind: "api", + wasAvailable: true, + errorCategory: "network"), + ], + error: ProviderDiagnosticError( + category: "network", + safeDescription: "Network error - check your connection"), + settings: ProviderDiagnosticSettingsSummary(sourceMode: .auto, apiRegion: "global"), + details: nil) + + let json = try self.json(export) + + #expect(!json.contains("connection refused")) + #expect(!json.contains("network probe")) + #expect(!json.contains("not safe to expose")) + #expect(!json.contains("localizedDescription")) + #expect(!json.contains("raw")) + #expect(!json.contains("errorMessage")) + #expect(json.contains("errorCategory")) + #expect(json.contains("\"network\"")) + } + + @Test + func `diagnostic error maps MiniMaxUsageError categories safely`() { + let networkError = MiniMaxUsageError.networkError("connection refused") + let invalidCreds = MiniMaxUsageError.invalidCredentials + let apiError = MiniMaxUsageError.apiError("HTTP 404") + let parseError = MiniMaxUsageError.parseFailed("unexpected") + + let diagNetwork = ProviderDiagnosticError(from: networkError, authConfigured: true) + #expect(diagNetwork.category == "network") + #expect(!diagNetwork.safeDescription.contains("connection refused")) + + let diagCreds = ProviderDiagnosticError(from: invalidCreds, authConfigured: true) + #expect(diagCreds.category == "auth") + + let diagAPI = ProviderDiagnosticError(from: apiError, authConfigured: true) + #expect(diagAPI.category == "api") + + let diagParse = ProviderDiagnosticError(from: parseError, authConfigured: true) + #expect(diagParse.category == "parse") + } + + @Test + func `no available strategy maps missing auth to auth category`() { + let error = ProviderFetchError.noAvailableStrategy(.minimax) + let diag = ProviderDiagnosticError(from: error, authConfigured: false) + + #expect(diag.category == "auth") + #expect(diag.safeDescription.contains("Authentication")) + } + + @Test + func `available failed strategy does not imply auth is configured`() { + let outcome = ProviderFetchOutcome( + result: .failure(ProviderFetchError.noAvailableStrategy(.antigravity)), + attempts: [ + ProviderFetchAttempt( + strategyID: "antigravity.local", + kind: .localProbe, + wasAvailable: true, + errorDescription: "unauthenticated local probe"), + ]) + + let summary = ProviderDiagnosticAuthSummary(configured: false, modes: []).resolved(with: outcome) + + #expect(!summary.configured) + #expect(summary.modes.isEmpty) + } + + @Test + func `fetch attempt error maps to safe category, never raw text`() { + let attemptWithRawError = ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: "MiniMax API timeout after 30 seconds - connection refused for host platform.minimax.io") + let diagAttempt = ProviderDiagnosticFetchAttempt(from: attemptWithRawError) + #expect(diagAttempt.kind == "api") + #expect(diagAttempt.wasAvailable == true) + let errorCategoryOne = diagAttempt.errorCategory + #expect(errorCategoryOne == "network") + let cat1 = errorCategoryOne ?? "" + #expect(!cat1.contains("timeout")) + #expect(!cat1.contains("connection refused")) + #expect(!cat1.contains("platform.minimax.io")) + + let attemptWithAuthError = ProviderFetchAttempt( + strategyID: "minimax.web", + kind: .web, + wasAvailable: false, + errorDescription: "invalid auth token cookie HERTZ-SESSION=abc123") + let diagAuthAttempt = ProviderDiagnosticFetchAttempt(from: attemptWithAuthError) + #expect(diagAuthAttempt.wasAvailable == false) + let errorCategoryTwo = diagAuthAttempt.errorCategory + #expect(errorCategoryTwo == "auth") + let cat2 = errorCategoryTwo ?? "" + #expect(!cat2.contains("HERTZ-SESSION")) + } + + @Test + func `missing api key setup errors map to auth before api`() { + let category = ProviderDiagnosticFetchAttempt.errorCategoryLabel( + "Azure OpenAI API key not configured. Set AZURE_OPENAI_API_KEY.") + + #expect(category == "auth") + } + + @Test + func `MiniMax details map from MiniMaxUsageSnapshot correctly`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now, + services: nil) + + let details = MiniMaxDiagnosticDetails(from: snapshot) + #expect(details.planName == "Max") + #expect(details.availablePrompts == 1000) + #expect(details.currentPrompts == 250) + #expect(details.remainingPrompts == 750) + #expect(details.windowMinutes == 300) + #expect(details.usedPercent == 25) + } + + @Test + func `service usage maps from MiniMaxServiceUsage correctly`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let service = MiniMaxServiceUsage( + serviceType: "Text Generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 750, + limit: 1000, + percent: 75, + resetsAt: now.addingTimeInterval(18000), + resetDescription: "5 hours") + + let diagService = MiniMaxDiagnosticServiceUsage(from: service) + #expect(diagService.displayName == "Text Generation") + #expect(diagService.percent == 75) + #expect(diagService.windowType == "5 hours") + #expect(diagService.hasResetDescription == true) + + let json = try self.json(diagService) + #expect(json.contains("hasResetDescription")) + #expect(!json.contains("resetDescription")) + } + + @Test + func `builder creates generic safe diagnostic with error on failure`() { + let outcome = ProviderFetchOutcome( + result: .failure(MiniMaxUsageError.networkError("timeout")), + attempts: [ + ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: "timeout"), + ]) + + let diag = ProviderDiagnosticExportBuilder.build(.init( + provider: .minimax, + descriptor: ProviderDescriptorRegistry.descriptor(for: .minimax), + outcome: outcome, + sourceMode: .auto, + settings: nil, + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["apiToken"]))) + + #expect(diag.provider == "minimax") + #expect(diag.source == "failed") + #expect(diag.auth.configured == true) + #expect(diag.usage == nil) + #expect(diag.error != nil) + #expect(diag.error?.category == "network") + #expect(diag.fetchAttempts.count == 1) + #expect(diag.fetchAttempts[0].errorCategory == "network") + } + + @Test + func `builder creates generic safe diagnostic with MiniMax details on success`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now) + + let result = ProviderFetchResult( + usage: UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + minimaxUsage: snapshot, + updatedAt: now), + credits: nil, + dashboard: nil, + sourceLabel: "api", + strategyID: "minimax.api", + strategyKind: .apiToken) + + let outcome = ProviderFetchOutcome( + result: .success(result), + attempts: [ + ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: nil), + ]) + + let diag = ProviderDiagnosticExportBuilder.build(.init( + provider: .minimax, + descriptor: ProviderDescriptorRegistry.descriptor(for: .minimax), + outcome: outcome, + sourceMode: .auto, + settings: nil, + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["apiToken"]))) + + #expect(diag.provider == "minimax") + #expect(diag.source == "api") + #expect(diag.auth.configured == true) + #expect(diag.usage != nil) + #expect(diag.error == nil) + + guard case let .minimax(details) = diag.details else { + Issue.record("Expected MiniMax diagnostic details") + return + } + #expect(details.planName == "Max") + } + + private func json(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(value) + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/Tests/CodexBarTests/ProviderHTTPClientTests.swift b/Tests/CodexBarTests/ProviderHTTPClientTests.swift new file mode 100644 index 000000000..c07b43e06 --- /dev/null +++ b/Tests/CodexBarTests/ProviderHTTPClientTests.swift @@ -0,0 +1,208 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ProviderHTTPClientTests { + @Test + func `default client configuration fails blocked connections promptly`() { + let configuration = ProviderHTTPClient.defaultConfiguration() + + #expect(configuration.timeoutIntervalForRequest == 30) + #expect(configuration.timeoutIntervalForResource == 90) + #if !os(Linux) + #expect(configuration.waitsForConnectivity == false) + #endif + } + + @Test + func `client loads requests through an injected session`() async throws { + StubURLProtocol.requests = [] + StubURLProtocol.handler = { request in + StubURLProtocol.requests.append(request) + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"])! + return (Data(#"{"ok":true}"#.utf8), response) + } + defer { + StubURLProtocol.handler = nil + StubURLProtocol.requests = [] + } + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubURLProtocol.self] + let client = ProviderHTTPClient(session: URLSession(configuration: configuration)) + let request = try URLRequest(url: #require(URL(string: "https://example.com/status"))) + + let (data, response) = try await client.data(for: request) + + let body = try #require(String(data: data, encoding: .utf8)) + #expect(body == #"{"ok":true}"#) + #expect((response as? HTTPURLResponse)?.statusCode == 200) + #expect(StubURLProtocol.requests.count == 1) + #expect(StubURLProtocol.requests.first?.url?.host == "example.com") + } + + @Test + func `response helper unwraps HTTP responses`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 204, + httpVersion: "HTTP/1.1", + headerFields: ["X-Test": "ok"])! + return (Data("done".utf8), response) + } + let request = try URLRequest(url: #require(URL(string: "https://example.com/ok"))) + + let response = try await transport.response(for: request) + + #expect(response.statusCode == 204) + #expect(response.response.value(forHTTPHeaderField: "X-Test") == "ok") + #expect(String(data: response.data, encoding: .utf8) == "done") + } + + @Test + func `response helper rejects non HTTP responses`() async throws { + let transport = ProviderHTTPTransportHandler { request in + let response = URLResponse( + url: request.url ?? URL(string: "https://example.com/not-http")!, + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil) + return (Data(), response) + } + let request = try URLRequest(url: #require(URL(string: "https://example.com/not-http"))) + + await #expect(throws: URLError.self) { + _ = try await transport.response(for: request) + } + } + + @Test + func `response helper retries transient HTTP status once`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [503, 200]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/retry"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 200) + #expect(await script.requestCount() == 2) + } + + @Test + func `response helper retries transient URL error once`() async throws { + let script = ScriptedHTTPTransport(results: [ + .failure(URLError(.timedOut)), + .success(200), + ]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/retry-error"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 200) + #expect(await script.requestCount() == 2) + } + + @Test + func `response helper does not retry non idempotent methods`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [503, 200]) + var request = try URLRequest(url: #require(URL(string: "https://example.com/post"))) + request.httpMethod = "POST" + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 503) + #expect(await script.requestCount() == 1) + } + + @Test + func `response helper does not retry auth failures`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [403, 200]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/forbidden"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 403) + #expect(await script.requestCount() == 1) + } +} + +extension ProviderHTTPRetryPolicy { + fileprivate static let testOneRetry = ProviderHTTPRetryPolicy( + maxRetries: 1, + baseDelaySeconds: 0, + maxDelaySeconds: 0) +} + +private actor ScriptedHTTPTransport: ProviderHTTPTransport { + enum Result { + case success(Int) + case failure(URLError) + } + + private var results: [Result] + private var requests: [URLRequest] = [] + + init(statusCodes: [Int]) { + self.results = statusCodes.map(Result.success) + } + + init(results: [Result]) { + self.results = results + } + + func requestCount() -> Int { + self.requests.count + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.requests.append(request) + let next = self.results.isEmpty ? .success(200) : self.results.removeFirst() + switch next { + case let .success(statusCode): + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (Data(#"{"ok":true}"#.utf8), response) + case let .failure(error): + throw error + } + } +} + +final class StubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (Data, URLResponse))? + nonisolated(unsafe) static var requests: [URLRequest] = [] + + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.cannotLoadFromNetwork)) + return + } + + do { + let (data, response) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/ProviderHTTPTransportStub.swift b/Tests/CodexBarTests/ProviderHTTPTransportStub.swift new file mode 100644 index 000000000..1c01e75cc --- /dev/null +++ b/Tests/CodexBarTests/ProviderHTTPTransportStub.swift @@ -0,0 +1,20 @@ +import Foundation +@testable import CodexBarCore + +actor ProviderHTTPTransportStub: ProviderHTTPTransport { + private let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) + private var recordedRequests: [URLRequest] = [] + + init(handler: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse)) { + self.handler = handler + } + + func requests() -> [URLRequest] { + self.recordedRequests + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.recordedRequests.append(request) + return try await self.handler(request) + } +} diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 7ecbe7b26..53bcbf11f 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -3,10 +3,9 @@ import Foundation import Testing @MainActor -@Suite struct ProviderIconResourcesTests { @Test - func providerIconSVGsExist() throws { + func `provider icon SV gs exist`() throws { let root = try Self.repoRoot() let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) @@ -17,10 +16,21 @@ struct ProviderIconResourcesTests { "minimax", "cursor", "opencode", + "opencodego", + "alibaba", "gemini", "antigravity", "factory", "copilot", + "crof", + "commandcode", + "t3chat", + "kimi", + "bedrock", + "elevenlabs", + "groq", + "llmproxy", + "deepgram", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") @@ -33,6 +43,16 @@ struct ProviderIconResourcesTests { } } + @Test + func `groq and grok provider icons are distinct`() throws { + let root = try Self.repoRoot() + let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) + let groq = try String(contentsOf: resources.appending(path: "ProviderIcon-groq.svg"), encoding: .utf8) + let grok = try String(contentsOf: resources.appending(path: "ProviderIcon-grok.svg"), encoding: .utf8) + + #expect(groq != grok) + } + private static func repoRoot() throws -> URL { var dir = URL(filePath: #filePath).deletingLastPathComponent() for _ in 0..<12 { diff --git a/Tests/CodexBarTests/ProviderLabelMetadataCharacterizationTests.swift b/Tests/CodexBarTests/ProviderLabelMetadataCharacterizationTests.swift new file mode 100644 index 000000000..9d3c01e9b --- /dev/null +++ b/Tests/CodexBarTests/ProviderLabelMetadataCharacterizationTests.swift @@ -0,0 +1,63 @@ +import CodexBarCore +import Testing + +struct ProviderLabelMetadataCharacterizationTests { + // MARK: - Label non-empty constraints + + @Test + func `displayName is non-empty for all providers`() { + for descriptor in ProviderDescriptorRegistry.all { + #expect( + !descriptor.metadata.displayName.isEmpty, + "Provider \(descriptor.id.rawValue) has empty displayName.") + } + } + + @Test + func `sessionLabel is non-empty for all providers`() { + for descriptor in ProviderDescriptorRegistry.all { + #expect( + !descriptor.metadata.sessionLabel.isEmpty, + "Provider \(descriptor.id.rawValue) has empty sessionLabel.") + } + } + + // MARK: - Known empty weeklyLabel exceptions + + @Test + func `weeklyLabel empty providers are explicitly characterized`() { + // Allowlist of providers known to have empty weeklyLabel on current main. + // If a new provider is added with empty weeklyLabel, this test fails and + // requires a deliberate decision to add it here — preventing silent regressions. + let knownEmptyWeeklyLabelProviders: Set = [.mistral] + for descriptor in ProviderDescriptorRegistry.all where descriptor.metadata.weeklyLabel.isEmpty { + #expect( + knownEmptyWeeklyLabelProviders.contains(descriptor.id), + "Provider \(descriptor.id.rawValue) has empty weeklyLabel and is not in the known exception list.") + } + } + + // MARK: - Invariant: supportsOpus implies opusLabel + + @Test + func `supportsOpus providers declare non-empty opusLabel`() { + for descriptor in ProviderDescriptorRegistry.all where descriptor.metadata.supportsOpus { + #expect( + descriptor.metadata.opusLabel != nil && !descriptor.metadata.opusLabel!.isEmpty, + "Provider \(descriptor.id.rawValue) has supportsOpus=true but opusLabel is nil or empty.") + } + } + + // MARK: - opusLabel structural constraint + + @Test + func `opusLabel is nil or non-empty`() { + for descriptor in ProviderDescriptorRegistry.all { + if let opusLabel = descriptor.metadata.opusLabel { + #expect( + !opusLabel.isEmpty, + "Provider \(descriptor.id.rawValue) has empty opusLabel string instead of nil.") + } + } + } +} diff --git a/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift b/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift index c4f3f2367..3ca12b233 100644 --- a/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift +++ b/Tests/CodexBarTests/ProviderMetadataStatusLinkTests.swift @@ -1,10 +1,9 @@ import Testing @testable import CodexBarCore -@Suite struct ProviderMetadataStatusLinkTests { @Test - func workspaceStatusLinkMatchesProductID() { + func `workspace status link matches product ID`() { for (provider, meta) in ProviderDefaults.metadata { guard let productID = meta.statusWorkspaceProductID else { continue } let expected = "https://www.google.com/appsstatus/dashboard/products/\(productID)/history" @@ -13,4 +12,13 @@ struct ProviderMetadataStatusLinkTests { "Expected \(provider.rawValue) statusLinkURL to be \(expected)") } } + + @Test + func `kimi K2 metadata does not present legacy endpoint as official`() throws { + let meta = try #require(ProviderDefaults.metadata[.kimik2]) + + #expect(meta.displayName == "Kimi K2 (unofficial)") + #expect(meta.toggleTitle == "Show unofficial Kimi K2 usage") + #expect(meta.dashboardURL == nil) + } } diff --git a/Tests/CodexBarTests/ProviderRegistryTests.swift b/Tests/CodexBarTests/ProviderRegistryTests.swift index 9bb79053d..7206bda85 100644 --- a/Tests/CodexBarTests/ProviderRegistryTests.swift +++ b/Tests/CodexBarTests/ProviderRegistryTests.swift @@ -1,10 +1,9 @@ import CodexBarCore import Testing -@Suite struct ProviderRegistryTests { @Test - func descriptorRegistryIsCompleteAndDeterministic() { + func `descriptor registry is complete and deterministic`() { let descriptors = ProviderDescriptorRegistry.all let ids = descriptors.map(\.id) @@ -19,7 +18,7 @@ struct ProviderRegistryTests { } @Test - func minimaxSortsAfterZaiInRegistry() { + func `minimax sorts after zai in registry`() { let ids = ProviderDescriptorRegistry.all.map(\.id) guard let zaiIndex = ids.firstIndex(of: .zai), let minimaxIndex = ids.firstIndex(of: .minimax) diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 7a1654adb..43ae2fe0e 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -5,63 +5,16 @@ import Testing @testable import CodexBar @MainActor -@Suite struct ProviderSettingsDescriptorTests { @Test - func toggleIDsAreUniqueAcrossProviders() throws { - let suite = "ProviderSettingsDescriptorTests-unique" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - var statusByID: [String: String] = [:] - var lastRunAtByID: [String: Date] = [:] + func `toggle I ds are unique across providers`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-unique") var seenToggleIDs: Set = [] var seenActionIDs: Set = [] var seenPickerIDs: Set = [] for provider in UsageProvider.allCases { - let context = ProviderSettingsContext( - provider: provider, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { id in statusByID[id] }, - setStatusText: { id, text in - if let text { - statusByID[id] = text - } else { - statusByID.removeValue(forKey: id) - } - }, - lastAppActiveRunAt: { id in lastRunAtByID[id] }, - setLastAppActiveRunAt: { id, date in - if let date { - lastRunAtByID[id] = date - } else { - lastRunAtByID.removeValue(forKey: id) - } - }, - requestConfirmation: { _ in }) - + let context = fixture.settingsContext(provider: provider) let impl = try #require(ProviderCatalog.implementation(for: provider)) let toggles = impl.settingsToggles(context: context) for toggle in toggles { @@ -83,40 +36,24 @@ struct ProviderSettingsDescriptorTests { } @Test - func codexExposesUsageAndCookiePickers() throws { - let suite = "ProviderSettingsDescriptorTests-codex" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + func `openai exposes project id setting`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-openai-project") + let context = fixture.settingsContext(provider: .openai) - let context = ProviderSettingsContext( - provider: .codex, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + let fields = OpenAIAPIProviderImplementation().settingsFields(context: context) + let project = try #require(fields.first(where: { $0.id == "openai-project-id" })) + project.binding.wrappedValue = "proj_abc" + + #expect(project.title == "Project ID") + #expect(project.subtitle.contains(OpenAIAPISettingsReader.projectIDEnvironmentKey)) + #expect(fixture.settings.openAIAPIProjectID == "proj_abc") + #expect(fixture.settings.providerConfig(for: .openai)?.sanitizedWorkspaceID == "proj_abc") + } + + @Test + func `codex exposes usage and cookie pickers`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex") + let context = fixture.settingsContext(provider: .codex) let pickers = CodexProviderImplementation().settingsPickers(context: context) let toggles = CodexProviderImplementation().settingsToggles(context: context) @@ -126,44 +63,35 @@ struct ProviderSettingsDescriptorTests { } @Test - func claudeExposesUsageAndCookiePickers() throws { - let suite = "ProviderSettingsDescriptorTests-claude" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = false - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + func `codex exposes open AI web extras toggle as default off opt in`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex-openai-toggle") + let context = fixture.settingsContext(provider: .codex) + + let toggles = CodexProviderImplementation().settingsToggles(context: context) + let extrasToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-extras" })) + #expect(extrasToggle.binding.wrappedValue == false) + #expect(extrasToggle.subtitle.contains("Optional.")) + #expect(extrasToggle.subtitle.contains("Turn this on")) + + let batterySaverToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-battery-saver" })) + #expect(batterySaverToggle.binding.wrappedValue == false) + #expect(batterySaverToggle.isVisible?() == false) + + fixture.settings.openAIWebAccessEnabled = true + #expect(batterySaverToggle.isVisible?() == true) + } + + @Test + func `claude exposes usage and cookie pickers`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude") + fixture.settings.debugDisableKeychainAccess = false + let context = fixture.settingsContext(provider: .claude) - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) #expect(pickers.contains(where: { $0.id == "claude-usage-source" })) #expect(pickers.contains(where: { $0.id == "claude-cookie-source" })) + let toggles = ClaudeProviderImplementation().settingsToggles(context: context) + #expect(!toggles.contains(where: { $0.id == "claude-peak-hours" })) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) let optionIDs = Set(keychainPicker.options.map(\.id)) #expect(optionIDs.contains(ClaudeOAuthKeychainPromptMode.never.rawValue)) @@ -173,43 +101,12 @@ struct ProviderSettingsDescriptorTests { } @Test - func claudePromptPolicyPickerHiddenWhenExperimentalReaderSelected() throws { - let suite = "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = false - settings.claudeOAuthKeychainReadStrategy = .securityCLI - - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + func `claude prompt policy picker hidden when experimental reader selected`() throws { + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental") + fixture.settings.debugDisableKeychainAccess = false + fixture.settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental + let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) @@ -217,41 +114,10 @@ struct ProviderSettingsDescriptorTests { } @Test - func claudeKeychainPromptPolicyPickerDisabledWhenGlobalKeychainDisabled() throws { - let suite = "ProviderSettingsDescriptorTests-claude-keychain-disabled" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = true - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + func `claude keychain prompt policy picker disabled when global keychain disabled`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-keychain-disabled") + fixture.settings.debugDisableKeychainAccess = true + let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) @@ -261,16 +127,9 @@ struct ProviderSettingsDescriptorTests { } @Test - func claudeWebExtrasAutoDisablesWhenLeavingCLI() throws { - let suite = "ProviderSettingsDescriptorTests-claude-invariant" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + func `claude web extras auto disables when leaving CLI`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-invariant") + let settings = fixture.settings settings.debugMenuEnabled = true settings.claudeUsageDataSource = .cli settings.claudeWebExtrasEnabled = true @@ -280,48 +139,134 @@ struct ProviderSettingsDescriptorTests { } @Test - func kiloExposesUsageSourcePickerAndApiFieldOnly() throws { - let suite = "ProviderSettingsDescriptorTests-kilo" + func `kilo exposes usage source picker and api field only`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-kilo") + let context = fixture.settingsContext(provider: .kilo) + + let implementation = KiloProviderImplementation() + let toggles = implementation.settingsToggles(context: context) + let pickers = implementation.settingsPickers(context: context) + let fields = implementation.settingsFields(context: context) + + #expect(toggles.isEmpty) + #expect(pickers.contains(where: { $0.id == "kilo-usage-source" })) + #expect(fields.contains(where: { $0.id == "kilo-api-key" })) + } + + @Test + func `deepgram exposes api key and project id fields`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepgram") + let context = fixture.settingsContext(provider: .deepgram) + + let implementation = DeepgramProviderImplementation() + let fields = implementation.settingsFields(context: context) + + #expect(fields.contains(where: { $0.id == "deepgram-api-key" })) + #expect(fields.contains(where: { $0.id == "deepgram-project-id" })) + + // Basic presence checks for Deepgram settings fields (layout copied from OpenRouter) + _ = try #require(fields.first(where: { $0.id == "deepgram-project-id" })) + _ = try #require(fields.first(where: { $0.id == "deepgram-api-key" })) + } + + @Test + func `alibaba presentation follows store source label`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-presentation") + let metadata = try #require(ProviderDescriptorRegistry.metadata[.alibaba]) + let context = fixture.presentationContext(provider: .alibaba, metadata: metadata) + + let detailLine = AlibabaCodingPlanProviderImplementation() + .presentation(context: context) + .detailLine(context) + + #expect(detailLine == fixture.store.sourceLabel(for: .alibaba)) + } + + @Test + func `alibaba token plan settings expose cookie controls`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-token-plan-settings") + fixture.settings.alibabaTokenPlanCookieSource = .manual + let context = fixture.settingsContext(provider: .alibabatokenplan) + let implementation = AlibabaTokenPlanProviderImplementation() + let pickers = implementation.settingsPickers(context: context) + let fields = implementation.settingsFields(context: context) + + #expect(pickers.contains(where: { $0.id == "alibaba-token-plan-cookie-source" })) + #expect(fields.contains(where: { $0.id == "alibaba-token-plan-cookie" })) + #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) + } + + private func makeSettingsFixture(suite: String) throws -> ProviderSettingsFixture { let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) let settings = SettingsStore( userDefaults: defaults, - configStore: configStore, + configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + return ProviderSettingsFixture(settings: settings, store: store) + } - let context = ProviderSettingsContext( - provider: .kilo, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + private struct ProviderSettingsFixture { + let settings: SettingsStore + let store: UsageStore + private let state = ProviderSettingsContextState() - let implementation = KiloProviderImplementation() - let toggles = implementation.settingsToggles(context: context) - let pickers = implementation.settingsPickers(context: context) - let fields = implementation.settingsFields(context: context) + @MainActor + func settingsContext(provider: UsageProvider) -> ProviderSettingsContext { + let settings = self.settings + let store = self.store + let state = self.state + return ProviderSettingsContext( + provider: provider, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { id in state.statusByID[id] }, + setStatusText: { id, text in + if let text { + state.statusByID[id] = text + } else { + state.statusByID.removeValue(forKey: id) + } + }, + lastAppActiveRunAt: { id in state.lastRunAtByID[id] }, + setLastAppActiveRunAt: { id, date in + if let date { + state.lastRunAtByID[id] = date + } else { + state.lastRunAtByID.removeValue(forKey: id) + } + }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } - #expect(toggles.isEmpty) - #expect(pickers.contains(where: { $0.id == "kilo-usage-source" })) - #expect(fields.contains(where: { $0.id == "kilo-api-key" })) + @MainActor + func presentationContext(provider: UsageProvider, metadata: ProviderMetadata) -> ProviderPresentationContext { + ProviderPresentationContext( + provider: provider, + settings: self.settings, + store: self.store, + metadata: metadata) + } + } + + private final class ProviderSettingsContextState { + var statusByID: [String: String] = [:] + var lastRunAtByID: [String: Date] = [:] } } diff --git a/Tests/CodexBarTests/ProviderStorageFootprintTests.swift b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift new file mode 100644 index 000000000..0a903e95e --- /dev/null +++ b/Tests/CodexBarTests/ProviderStorageFootprintTests.swift @@ -0,0 +1,410 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ProviderStorageFootprintTests { + @Test + func `scanner sums nested regular files and skips symlink targets`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let nested = root.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try Data(repeating: 1, count: 5).write(to: root.appendingPathComponent("a.jsonl")) + try Data(repeating: 2, count: 7).write(to: nested.appendingPathComponent("b.jsonl")) + + let external = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: external) } + let target = external.appendingPathComponent("outside.bin") + try Data(repeating: 3, count: 100).write(to: target) + let link = root.appendingPathComponent("linked.bin") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + + let footprint = ProviderStorageScanner().scan(provider: .codex, candidatePaths: [root.path]) + + #expect(footprint.totalBytes == 12) + #expect(footprint.paths == [root.path]) + #expect(footprint.missingPaths.isEmpty) + #expect(footprint.components.map(\.name) == ["nested", "a.jsonl"]) + #expect(footprint.components.map(\.totalBytes) == [7, 5]) + } + + @Test + func `scanner does not follow symlinked candidate roots`() throws { + let root = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + + let target = root.appendingPathComponent("target", isDirectory: true) + try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + try Data(repeating: 1, count: 64).write(to: target.appendingPathComponent("session.jsonl")) + let symlinkRoot = root.appendingPathComponent("codex-link", isDirectory: true) + try FileManager.default.createSymbolicLink(at: symlinkRoot, withDestinationURL: target) + + let footprint = ProviderStorageScanner().scan(provider: .codex, candidatePaths: [symlinkRoot.path]) + + #expect(footprint.paths == [symlinkRoot.path]) + #expect(footprint.totalBytes == 0) + #expect(footprint.components.isEmpty) + } + + @Test + func `scanner records missing paths without failing`() throws { + let root = try Self.makeTemporaryDirectory() + let missing = root.appendingPathComponent("missing") + defer { try? FileManager.default.removeItem(at: root) } + + let footprint = ProviderStorageScanner().scan(provider: .claude, candidatePaths: [missing.path]) + + #expect(footprint.totalBytes == 0) + #expect(footprint.paths.isEmpty) + #expect(footprint.missingPaths == [missing.path]) + } + + @Test + func `codex path catalog uses CODEX_HOME and managed homes`() { + let managed = ManagedCodexAccount( + id: UUID(), + email: "user@example.com", + managedHomePath: "/tmp/codex-managed-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: nil) + + let paths = ProviderStoragePathCatalog.candidatePaths( + for: .codex, + environment: ["CODEX_HOME": "/tmp/codex-home"], + managedCodexAccounts: [managed]) + + #expect(paths == ["/tmp/codex-home", "/tmp/codex-managed-home"]) + } + + @Test + func `codex path catalog falls back to default home`() { + let paths = ProviderStoragePathCatalog.candidatePaths(for: .codex, environment: [:]) + let expected = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".codex", isDirectory: true) + .path + + #expect(paths.first == expected) + } + + @Test + func `claude recommendations use documented cleanup categories`() { + let root = "/Users/test/.claude" + let footprint = ProviderStorageFootprint( + provider: .claude, + totalBytes: 28, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 10), + .init(path: "\(root)/file-history", totalBytes: 8), + .init(path: "\(root)/paste-cache", totalBytes: 6), + .init(path: "\(root)/settings.json", totalBytes: 4), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let recommendations = footprint.cleanupRecommendations + + #expect(recommendations.map(\.path) == [ + "\(root)/projects", + "\(root)/file-history", + "\(root)/paste-cache", + ]) + #expect(recommendations[0].consequence.contains("resume")) + #expect(recommendations.allSatisfy { $0.riskLevel == .manualCleanup }) + } + + @Test + func `codex recommendations stay under known homes and exclude auth and config`() { + let root = "/Users/test/.codex" + let footprint = ProviderStorageFootprint( + provider: .codex, + totalBytes: 51, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/sessions", totalBytes: 20), + .init(path: "\(root)/archived_sessions", totalBytes: 15), + .init(path: "\(root)/log", totalBytes: 12), + .init(path: "\(root)/logs_2.sqlite", totalBytes: 11), + .init(path: "\(root)/cache", totalBytes: 10), + .init(path: "\(root)/shell_snapshots", totalBytes: 9), + .init(path: "\(root)/auth.json", totalBytes: 4), + .init(path: "\(root)/config.toml", totalBytes: 2), + .init(path: "/tmp/outside/sessions", totalBytes: 99), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let recommendations = footprint.cleanupRecommendations + + #expect(recommendations.map(\.path) == [ + "\(root)/sessions", + "\(root)/archived_sessions", + "\(root)/cache", + "\(root)/log", + "\(root)/logs_2.sqlite", + "\(root)/shell_snapshots", + ]) + #expect(recommendations.map(\.bytes) == [20, 15, 10, 12, 11, 9]) + } + + @Test + func `unknown provider storage returns no cleanup recommendations`() { + let footprint = ProviderStorageFootprint( + provider: .gemini, + totalBytes: 10, + paths: ["/Users/test/.gemini"], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "/Users/test/.gemini/cache", totalBytes: 10), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + #expect(footprint.cleanupRecommendations.isEmpty) + } + + @Test + @MainActor + func `overview row carries storage text outside provider detail model`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: nil) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let overview = OverviewMenuCardRowView(model: model, storageText: "1.5 GB", width: 310) + let detail = UsageMenuCardView(model: model, width: 310) + + #expect(overview.storageText == "1.5 GB") + #expect(detail.model.provider == UsageProvider.claude) + } + + @Test + @MainActor + func `storage detail view exposes cleanup recommendations while overview remains number only`() throws { + let root = "/Users/test/.claude" + let footprint = ProviderStorageFootprint( + provider: .claude, + totalBytes: 10, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 10), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let detailView = StorageBreakdownMenuView(footprint: footprint, width: 310) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: Date(timeIntervalSince1970: 0))) + let overview = OverviewMenuCardRowView(model: model, storageText: "10 B", width: 310) + + #expect(detailView.cleanupRecommendations.map(\.path) == ["\(root)/projects"]) + #expect(overview.storageText == "10 B") + } + + @Test + @MainActor + func `storage detail view exposes copyable exact paths`() { + let root = "/Users/test/.claude" + let footprint = ProviderStorageFootprint( + provider: .claude, + totalBytes: 110, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 100), + .init(path: "\(root)/file-history", totalBytes: 10), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + let detailView = StorageBreakdownMenuView(footprint: footprint, width: 310) + + #expect(detailView.copyablePaths.contains("\(root)/projects")) + #expect(detailView.copyablePaths.contains("\(root)/file-history")) + } + + @Test + @MainActor + func `storage path copy button writes exact path to pasteboard`() { + let path = "/Users/test/.claude/projects/example" + StoragePathCopyButton.copyToPasteboard(path) + + #expect(NSPasteboard.general.string(forType: .string) == path) + } + + @Test + @MainActor + func `manual storage refresh updates deleted provider data`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + let sessions = codexHome.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessions, withIntermediateDirectories: true) + try Data(repeating: 1, count: 32).write(to: sessions.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-storage-refresh-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + settings.providerStorageFootprintsEnabled = true + store.managedCodexAccountsForStorageOverride = [] + + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex)?.totalBytes == 32) + + try FileManager.default.removeItem(at: sessions) + await store.refreshStorageFootprintsForOverviewNow() + + #expect(store.storageFootprint(for: .codex)?.totalBytes == 0) + #expect(store.storageFootprintText(for: .codex) == "No local data found") + } + + @Test + @MainActor + func `storage refresh is opt in and clears stale footprints when disabled`() async throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + try Data(repeating: 1, count: 16).write(to: codexHome.appendingPathComponent("session.jsonl")) + + let suite = "ProviderStorageFootprintTests-storage-opt-in-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + if let codexMetadata = ProviderDefaults.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + store.managedCodexAccountsForStorageOverride = [] + + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex) == nil) + + settings.providerStorageFootprintsEnabled = true + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex)?.totalBytes == 16) + + settings.providerStorageFootprintsEnabled = false + await store.refreshStorageFootprintsForOverviewNow() + #expect(store.storageFootprint(for: .codex) == nil) + #expect(store.providerStorageFootprints.isEmpty) + } + + @Test + @MainActor + func `forced scheduled storage refresh does not restart identical in flight scan`() throws { + let home = try Self.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: home) } + + let codexHome = home.appendingPathComponent(".codex", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + + let suite = "ProviderStorageFootprintTests-storage-in-flight-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + environmentBase: ["CODEX_HOME": codexHome.path]) + settings.providerStorageFootprintsEnabled = true + store.storageRefreshGeneration = 41 + store.storageRefreshInFlightSignature = "codex=\(codexHome.path)" + store.storageRefreshTask = Task.detached { + try? await Task.sleep(for: .seconds(30)) + } + defer { + store.storageRefreshTask?.cancel() + store.storageRefreshTask = nil + store.storageRefreshInFlightSignature = nil + } + + store.scheduleStorageFootprintRefresh(for: [.codex], force: true) + + #expect(store.storageRefreshGeneration == 41) + } + + private static func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("ProviderStorageFootprintTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} diff --git a/Tests/CodexBarTests/ProviderToggleStoreTests.swift b/Tests/CodexBarTests/ProviderToggleStoreTests.swift index 6de1adfd7..ce4f812af 100644 --- a/Tests/CodexBarTests/ProviderToggleStoreTests.swift +++ b/Tests/CodexBarTests/ProviderToggleStoreTests.swift @@ -3,10 +3,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct ProviderToggleStoreTests { @Test - func defaultsMatchMetadata() throws { + func `defaults match metadata`() throws { let defaults = try #require(UserDefaults(suiteName: "ProviderToggleStoreTests-defaults")) defaults.removePersistentDomain(forName: "ProviderToggleStoreTests-defaults") let store = ProviderToggleStore(userDefaults: defaults) @@ -19,7 +18,7 @@ struct ProviderToggleStoreTests { } @Test - func persistsChanges() throws { + func `persists changes`() throws { let suite = "ProviderToggleStoreTests-persist" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -35,7 +34,7 @@ struct ProviderToggleStoreTests { } @Test - func purgesLegacyKeys() throws { + func `purges legacy keys`() throws { let suite = "ProviderToggleStoreTests-purge" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/ProviderTokenResolverTests.swift b/Tests/CodexBarTests/ProviderTokenResolverTests.swift index fcfc5bd74..9477d0c31 100644 --- a/Tests/CodexBarTests/ProviderTokenResolverTests.swift +++ b/Tests/CodexBarTests/ProviderTokenResolverTests.swift @@ -2,10 +2,9 @@ import CodexBarCore import Foundation import Testing -@Suite struct ProviderTokenResolverTests { @Test - func zaiResolutionUsesEnvironmentToken() { + func `zai resolution uses environment token`() { let env = [ZaiSettingsReader.apiTokenKey: "token"] let resolution = ProviderTokenResolver.zaiResolution(environment: env) #expect(resolution?.token == "token") @@ -13,14 +12,14 @@ struct ProviderTokenResolverTests { } @Test - func copilotResolutionTrimsToken() { + func `copilot resolution trims token`() { let env = ["COPILOT_API_TOKEN": " token "] let resolution = ProviderTokenResolver.copilotResolution(environment: env) #expect(resolution?.token == "token") } @Test - func warpResolutionUsesEnvironmentToken() { + func `warp resolution uses environment token`() { let env = ["WARP_API_KEY": "wk-test-token"] let resolution = ProviderTokenResolver.warpResolution(environment: env) #expect(resolution?.token == "wk-test-token") @@ -28,21 +27,35 @@ struct ProviderTokenResolverTests { } @Test - func warpResolutionTrimsToken() { + func `warp resolution trims token`() { let env = ["WARP_API_KEY": " wk-token "] let resolution = ProviderTokenResolver.warpResolution(environment: env) #expect(resolution?.token == "wk-token") } @Test - func warpResolutionReturnsNilWhenMissing() { + func `warp resolution returns nil when missing`() { let env: [String: String] = [:] let resolution = ProviderTokenResolver.warpResolution(environment: env) #expect(resolution == nil) } @Test - func kiloResolutionPrefersEnvironmentOverAuthFile() throws { + func `doubao resolution uses first supported environment token`() { + let env = ["ARK_API_KEY": "ark-token"] + let resolution = ProviderTokenResolver.doubaoResolution(environment: env) + #expect(resolution?.token == "ark-token") + #expect(resolution?.source == .environment) + } + + @Test + func `doubao settings reader trims quoted token`() { + let env = ["DOUBAO_API_KEY": " 'doubao-token' "] + #expect(DoubaoSettingsReader.apiKey(environment: env) == "doubao-token") + } + + @Test + func `kilo resolution prefers environment over auth file`() throws { let fileURL = try self.makeKiloAuthFile(contents: #"{"kilo":{"access":"file-token"}}"#) defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } @@ -54,7 +67,7 @@ struct ProviderTokenResolverTests { } @Test - func kiloResolutionFallsBackToAuthFile() throws { + func `kilo resolution falls back to auth file`() throws { let fileURL = try self.makeKiloAuthFile(contents: #"{"kilo":{"access":"file-token"}}"#) defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } @@ -65,7 +78,7 @@ struct ProviderTokenResolverTests { } @Test - func kiloResolutionReturnsNilForMalformedAuthFile() throws { + func `kilo resolution returns nil for malformed auth file`() throws { let fileURL = try self.makeKiloAuthFile(contents: #"{not-json}"#) defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } @@ -81,4 +94,53 @@ struct ProviderTokenResolverTests { try contents.write(to: fileURL, atomically: true, encoding: .utf8) return fileURL } + + @Test + func `codebuff resolution prefers environment over credentials file`() throws { + let fileURL = try self.makeCodebuffCredentialsFile( + contents: #"{"authToken":"file-token"}"#) + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let env = [CodebuffSettingsReader.apiTokenKey: "env-token"] + let resolution = ProviderTokenResolver.codebuffResolution( + environment: env, + authFileURL: fileURL) + + #expect(resolution?.token == "env-token") + #expect(resolution?.source == .environment) + } + + @Test + func `codebuff resolution falls back to credentials file`() throws { + let fileURL = try self.makeCodebuffCredentialsFile( + contents: #"{"authToken":"file-token","fingerprintId":"fp"}"#) + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let resolution = ProviderTokenResolver.codebuffResolution( + environment: [:], + authFileURL: fileURL) + + #expect(resolution?.token == "file-token") + #expect(resolution?.source == .authFile) + } + + @Test + func `codebuff resolution returns nil for malformed credentials file`() throws { + let fileURL = try self.makeCodebuffCredentialsFile(contents: #"{not-json}"#) + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + + let resolution = ProviderTokenResolver.codebuffResolution( + environment: [:], + authFileURL: fileURL) + #expect(resolution == nil) + } + + private func makeCodebuffCredentialsFile(contents: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("credentials.json", isDirectory: false) + try contents.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } } diff --git a/Tests/CodexBarTests/ProviderVersionDetectorTests.swift b/Tests/CodexBarTests/ProviderVersionDetectorTests.swift new file mode 100644 index 000000000..331948deb --- /dev/null +++ b/Tests/CodexBarTests/ProviderVersionDetectorTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import CodexBarCore + +final class ProviderVersionDetectorTests: XCTestCase { + func test_run_returnsFirstLineForSuccessfulCommand() { + let version = ProviderVersionDetector.run( + path: "/bin/sh", + args: ["-c", "printf 'gemini 1.2.3\\nextra\\n'"], + timeout: 1.0) + + XCTAssertEqual(version, "gemini 1.2.3") + } + + func test_run_returnsNilAfterTimeout() { + let start = Date() + let version = ProviderVersionDetector.run( + path: "/bin/sh", + args: ["-c", "sleep 5"], + timeout: 0.1) + let duration = Date().timeIntervalSince(start) + + XCTAssertNil(version) + XCTAssertLessThan(duration, 2.0) + } +} diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index e5ea668de..b2629ecea 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -1,13 +1,13 @@ import CodexBarCore import Foundation +import SwiftUI import Testing @testable import CodexBar @MainActor -@Suite struct ProvidersPaneCoverageTests { @Test - func exercisesProvidersPaneViews() { + func `exercises providers pane views`() { let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests") let store = Self.makeUsageStore(settings: settings) @@ -15,36 +15,349 @@ struct ProvidersPaneCoverageTests { } @Test - func openRouterMenuBarMetricPicker_showsOnlyAutomaticAndPrimary() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") + func `claude token account descriptor shows organization field`() throws { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-claude-org-field") let store = Self.makeUsageStore(settings: settings) let pane = ProvidersPane(settings: settings, store: store) - let picker = pane._test_menuBarMetricPicker(for: .openrouter) - #expect(picker?.options.map(\.id) == [ + let claudeDescriptor = try #require(pane._test_tokenAccountDescriptor(for: .claude)) + #expect(claudeDescriptor.showsOrganizationField) + + let copilotDescriptor = try #require(pane._test_tokenAccountDescriptor(for: .copilot)) + #expect(!copilotDescriptor.showsOrganizationField) + } + + @Test + func `open router menu bar metric picker shows only automatic and primary`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .openrouter) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + MenuBarMetricPreference.primary.rawValue, + ]) + #expect(picker?.options.map(\.title) == [ + "Automatic", + "Primary (API key limit)", + ]) + } + } + + @Test + func `deepseek menu bar metric picker shows balance only copy`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-deepseek-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .deepseek) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows the DeepSeek balance in the menu bar.") + } + } + + @Test + func `moonshot menu bar metric picker shows balance only copy`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-moonshot-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .moonshot) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows the Moonshot / Kimi API balance in the menu bar.") + } + } + + @Test + func `mistral menu bar metric picker shows spend only copy`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-mistral-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .mistral) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows current-month Mistral API spend in the menu bar.") + } + } + + @Test + func `kimi k2 menu bar metric picker shows credits only copy`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-kimik2-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .kimik2) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows Kimi K2 API-key credits in the menu bar.") + } + } + + @Test + func `cursor menu bar metric picker omits tertiary api lane when snapshot has no api metric`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(!ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + } + + @Test + func `cursor menu bar metric picker includes tertiary api lane when snapshot has api metric`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } + #expect(tertiaryOption?.title == "Tertiary (API)") + } + } + + @Test + func `cursor menu bar metric picker omits extra usage when on demand budget is missing`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-no-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(!ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + } + + @Test + func `cursor menu bar metric picker includes extra usage when on demand budget is available`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 15, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + let option = picker?.options.first { $0.id == MenuBarMetricPreference.extraUsage.rawValue } + #expect(option?.title == "Extra usage") + } + } + + @Test + func `claude menu bar metric picker includes extra usage when spend limit is available`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-claude-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 67.03, + limit: 1000, + currencyCode: "USD", + period: "Spend limit", + updatedAt: Date()), + updatedAt: Date()), + provider: .claude) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .claude) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + } + + @Test + func `zai menu bar metric picker omits tertiary lane when snapshot has no 5-hour metric`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-no-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .zai) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids == [ MenuBarMetricPreference.automatic.rawValue, MenuBarMetricPreference.primary.rawValue, - ]) - #expect(picker?.options.map(\.title) == [ - "Automatic", - "Primary (API key limit)", + MenuBarMetricPreference.secondary.rawValue, ]) } @Test - func providerDetailPlanRow_formatsOpenRouterAsBalance() { - let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") + func `zai menu bar metric picker includes tertiary 5-hour lane when snapshot has it`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .zai) + let pane = ProvidersPane(settings: settings, store: store) - #expect(row?.label == "Balance") - #expect(row?.value == "$4.61") + let picker = pane._test_menuBarMetricPicker(for: .zai) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } + #expect(tertiaryOption?.title == "Tertiary (5-hour)") + } } @Test - func providerDetailPlanRow_keepsPlanLabelForNonOpenRouter() { - let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro") + func `gemini menu bar metric picker omits tertiary lane`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-gemini-no-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .gemini) + let ids = picker?.options.map(\.id) ?? [] + #expect(!ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + } + + @Test + func `provider detail plan row formats open router as balance`() { + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") + + #expect(row?.label == "Balance") + #expect(row?.value == "$4.61") + } + } + + @Test + func `provider detail plan row formats moonshot as balance`() { + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .moonshot, planText: "Balance: $49.58") + + #expect(row?.label == "Balance") + #expect(row?.value == "$49.58") + } + } + + @Test + func `provider detail plan row keeps plan label for non open router`() { + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro") + + #expect(row?.label == "Plan") + #expect(row?.value == "Pro") + } + } + + @Test + func `opencode manual cookie source hides cached browser trailing text`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-opencode-manual") + let store = Self.makeUsageStore(settings: settings) + settings.opencodeCookieSource = .manual + CookieHeaderCache.store(provider: .opencode, cookieHeader: "auth=cache", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .opencode) } + + let pane = ProvidersPane(settings: settings, store: store) + let picker = pane._test_settingsPickers(for: .opencode).first { $0.id == "opencode-cookie-source" } - #expect(row?.label == "Plan") - #expect(row?.value == "Pro") + #expect(picker?.dynamicSubtitle?() == "Paste a Cookie header captured from the billing page.") + #expect(picker?.trailingText?() == nil) + } + + @Test + func `opencode go manual cookie source hides cached browser trailing text`() { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-opencodego-manual") + let store = Self.makeUsageStore(settings: settings) + settings.opencodegoCookieSource = .manual + CookieHeaderCache.store(provider: .opencodego, cookieHeader: "auth=cache", sourceLabel: "Chrome") + defer { CookieHeaderCache.clear(provider: .opencodego) } + + let pane = ProvidersPane(settings: settings, store: store) + let picker = pane._test_settingsPickers(for: .opencodego).first { $0.id == "opencodego-cookie-source" } + + #expect(picker?.dynamicSubtitle?() == "Paste a Cookie header captured from the billing page.") + #expect(picker?.trailingText?() == nil) + } + + @Test + func `codex providers pane uses managed account fallback instead of ambient account`() throws { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-codex-managed-fallback") + let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + defer { + try? FileManager.default.removeItem(at: ambientHome) + try? FileManager.default.removeItem(at: managedHome) + } + + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "ambient@example.com", plan: "plus") + try Self.writeCodexAuthFile(homeURL: managedHome, email: "managed@example.com", plan: "enterprise") + let managedAccountID = UUID() + settings.codexActiveSource = .managedAccount(id: managedAccountID) + settings._test_activeManagedCodexAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + + let store = UsageStore( + fetcher: UsageFetcher(environment: ["CODEX_HOME": ambientHome.path]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: nil), + provider: .codex) + + let pane = ProvidersPane(settings: settings, store: store) + let model = pane._test_menuCardModel(for: .codex) + + #expect(model.email == "managed@example.com") + #expect(model.planText == "Enterprise") } private static func makeSettingsStore(suite: String) -> SettingsStore { @@ -78,4 +391,38 @@ struct ProvidersPaneCoverageTests { browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) } + + private static func withEnglishLocalization(perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue("en", operation: body) + } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let auth = [ + "tokens": [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan), + ], + ] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } } diff --git a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift new file mode 100644 index 000000000..b0bd32c7f --- /dev/null +++ b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift @@ -0,0 +1,152 @@ +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct QuotaWarningNotificationLogicTests { + @Test + func `quota warning copy includes current remaining and threshold`() { + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 20, + currentRemaining: 12.4) + + #expect(copy.title == "Codex session quota low") + #expect(copy.body == "12% left. Reached your 20% session warning threshold.") + } + } + + @Test + func `quota warning copy clamps current remaining`() { + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .weekly, + threshold: 50, + currentRemaining: -3) + + #expect(copy.title == "Codex weekly quota low") + #expect(copy.body == "0% left. Reached your 50% weekly warning threshold.") + } + } + + @Test + func `quota warning copy includes account when provided`() { + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 50, + currentRemaining: 45, + accountDisplayName: "person@example.com") + + #expect(copy.title == "Codex session quota low") + #expect(copy.body == "Account person@example.com. 45% left. Reached your 50% session warning threshold.") + } + } + + @Test + func `quota warning copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 50, + currentRemaining: 45, + accountDisplayName: "person@example.com") + + #expect(copy.title == "Codex 工作階段配額偏低") + #expect(copy.body == "帳號 person@example.com。剩餘 45%。已達到 50% 工作階段提醒門檻。") + } + } + + @Test + func `does nothing without crossing`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 60, + currentRemaining: 55, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == nil) + } + + @Test + func `detects downward crossing`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 55, + currentRemaining: 45, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 50) + } + + @Test + func `skips already fired thresholds`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 55, + currentRemaining: 45, + thresholds: [50, 20], + alreadyFired: [50]) + + #expect(crossed == nil) + } + + @Test + func `chooses most severe threshold when crossing several at once`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 80, + currentRemaining: 10, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 20) + } + + @Test + func `startup below threshold warns once at most severe threshold`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: nil, + currentRemaining: 10, + thresholds: [50, 20], + alreadyFired: []) + + #expect(crossed == 20) + } + + @Test + func `warning marks threshold and higher thresholds fired`() { + let fired = QuotaWarningNotificationLogic.firedThresholdsAfterWarning( + threshold: 20, + thresholds: [50, 20]) + + #expect(fired == [50, 20]) + } + + @Test + func `recovery clears only thresholds below current remaining`() { + let cleared = QuotaWarningNotificationLogic.thresholdsToClear( + currentRemaining: 30, + alreadyFired: [50, 20]) + + #expect(cleared == [20]) + } + + @Test + func `zero threshold does not post quota warning`() { + let crossed = QuotaWarningNotificationLogic.crossedThreshold( + previousRemaining: 10, + currentRemaining: 0, + thresholds: [10, 0], + alreadyFired: [10]) + + #expect(crossed == nil) + #expect(QuotaWarningNotificationLogic.firedThresholdsAfterWarning(threshold: 10, thresholds: [10, 0]) == [10]) + } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } +} diff --git a/Tests/CodexBarTests/ResetTimeBackfillTests.swift b/Tests/CodexBarTests/ResetTimeBackfillTests.swift new file mode 100644 index 000000000..7cdbf152f --- /dev/null +++ b/Tests/CodexBarTests/ResetTimeBackfillTests.swift @@ -0,0 +1,122 @@ +import CodexBarCore +import Foundation +import XCTest + +final class ResetTimeBackfillTests: XCTestCase { + func test_backfillsMissingResetMetadataFromCachedWindow() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let cached = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: reset, + resetDescription: "Resets in 1h", + nextRegenPercent: 9) + let fresh = RateWindow( + usedPercent: 62, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil, + nextRegenPercent: 4) + + let result = fresh.backfillingResetTime(from: cached, now: now) + + XCTAssertEqual(result.usedPercent, 62) + XCTAssertEqual(result.windowMinutes, 300) + XCTAssertEqual(result.resetsAt, reset) + XCTAssertEqual(result.resetDescription, "Resets in 1h") + XCTAssertEqual(result.nextRegenPercent, 4) + } + + func test_skipsExpiredCachedReset() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let cached = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(-60), + resetDescription: "Expired") + let fresh = RateWindow(usedPercent: 62, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + + let result = fresh.backfillingResetTime(from: cached, now: now) + + XCTAssertNil(result.resetsAt) + XCTAssertNil(result.windowMinutes) + XCTAssertNil(result.resetDescription) + } + + func test_snapshotBackfillPreservesCurrentSnapshotFields() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let reset = now.addingTimeInterval(3600) + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "peter@example.com", + accountOrganization: "Org", + loginMethod: "OAuth") + let cached = UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: 300, resetsAt: reset, resetDescription: "Soon"), + secondary: nil, + updatedAt: now.addingTimeInterval(-300), + identity: identity) + let extra = NamedRateWindow( + id: "overflow", + title: "Overflow", + window: RateWindow( + usedPercent: 12, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil, + nextRegenPercent: 2)) + let fresh = UsageSnapshot( + primary: RateWindow( + usedPercent: 66, + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil, + nextRegenPercent: 7), + secondary: nil, + extraRateWindows: [extra], + cursorRequests: CursorRequestUsage(used: 10, limit: 50), + updatedAt: now, + identity: identity) + + let result = fresh.backfillingResetTimes(from: cached, now: now) + + XCTAssertEqual(result.primary?.resetsAt, reset) + XCTAssertEqual(result.primary?.usedPercent, 66) + XCTAssertEqual(result.primary?.nextRegenPercent, 7) + XCTAssertEqual(result.extraRateWindows?.first?.id, "overflow") + XCTAssertEqual(result.extraRateWindows?.first?.window.nextRegenPercent, 2) + XCTAssertEqual(result.cursorRequests?.used, 10) + XCTAssertEqual(result.identity?.accountEmail, "peter@example.com") + } + + func test_snapshotBackfillSkipsDifferentAccounts() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let cached = UsageSnapshot( + primary: RateWindow( + usedPercent: 40, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: "Soon"), + secondary: nil, + updatedAt: now.addingTimeInterval(-300), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "old@example.com", + accountOrganization: nil, + loginMethod: nil)) + let fresh = UsageSnapshot( + primary: RateWindow(usedPercent: 66, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "new@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let result = fresh.backfillingResetTimes(from: cached, now: now) + + XCTAssertNil(result.primary?.resetsAt) + } +} diff --git a/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift b/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift index 96cbcc52e..2c43d3179 100644 --- a/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift +++ b/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift @@ -1,36 +1,64 @@ import Testing @testable import CodexBar -@Suite +@Suite(.serialized) struct SessionQuotaNotificationLogicTests { @Test - func doesNothingWithoutPreviousValue() { + func `does nothing without previous value`() { let transition = SessionQuotaNotificationLogic.transition(previousRemaining: nil, currentRemaining: 0) #expect(transition == .none) } @Test - func detectsDepletedTransition() { + func `detects depleted transition`() { let transition = SessionQuotaNotificationLogic.transition(previousRemaining: 12, currentRemaining: 0) #expect(transition == .depleted) } @Test - func detectsRestoredTransition() { + func `detects restored transition`() { let transition = SessionQuotaNotificationLogic.transition(previousRemaining: 0, currentRemaining: 5) #expect(transition == .restored) } @Test - func ignoresNonTransitions() { + func `ignores non transitions`() { #expect(SessionQuotaNotificationLogic.transition(previousRemaining: 0, currentRemaining: 0) == .none) #expect(SessionQuotaNotificationLogic.transition(previousRemaining: 10, currentRemaining: 10) == .none) #expect(SessionQuotaNotificationLogic.transition(previousRemaining: 10, currentRemaining: 9) == .none) } @Test - func treatsTinyPositiveRemainingAsDepleted() { + func `treats tiny positive remaining as depleted`() { let transition = SessionQuotaNotificationLogic.transition(previousRemaining: 0, currentRemaining: 0.00001) #expect(transition == .none) } + + @Test + func `depleted notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = SessionQuotaNotificationLogic.notificationCopy( + transition: .depleted, + providerName: "Codex") + + #expect(copy.title == "Codex 工作階段已用完") + #expect(copy.body == "剩餘 0%。恢復可用時會再通知。") + } + } + + @Test + func `restored notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = SessionQuotaNotificationLogic.notificationCopy( + transition: .restored, + providerName: "Codex") + + #expect(copy.title == "Codex 工作階段已恢復") + #expect(copy.body == "工作階段配額已恢復可用。") + } + } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } } diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index 7ec91584c..39f50263f 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -4,24 +4,59 @@ import Testing @testable import CodexBar @MainActor -@Suite struct SettingsStoreAdditionalTests { @Test - func menuBarMetricPreferenceHandlesZaiAndAverage() { + func `menu bar metric preference handles zai and average`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-metric") + #expect(settings.menuBarMetricPreference(for: .zai) == .automatic) + settings.setMenuBarMetricPreference(.average, for: .zai) - #expect(settings.menuBarMetricPreference(for: .zai) == .primary) + #expect(settings.menuBarMetricPreference(for: .zai) == .automatic) + + settings.setMenuBarMetricPreference(.secondary, for: .zai) + #expect(settings.menuBarMetricPreference(for: .zai) == .secondary) + + settings.setMenuBarMetricPreference(.tertiary, for: .zai) + #expect(settings.menuBarMetricPreference(for: .zai) == .tertiary) + #expect(settings.menuBarMetricPreference(for: .zai, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsTertiary(for: .zai, snapshot: nil) == false) settings.setMenuBarMetricPreference(.average, for: .codex) #expect(settings.menuBarMetricPreference(for: .codex) == .automatic) settings.setMenuBarMetricPreference(.average, for: .gemini) #expect(settings.menuBarMetricPreference(for: .gemini) == .average) + + settings.setMenuBarMetricPreference(.tertiary, for: .codex) + #expect(settings.menuBarMetricPreference(for: .codex) == .automatic) + + settings.setMenuBarMetricPreference(.tertiary, for: .cursor) + #expect(settings.menuBarMetricPreference(for: .cursor) == .tertiary) + #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsTertiary(for: .cursor, snapshot: nil) == false) + + settings.setMenuBarMetricPreference(.extraUsage, for: .cursor) + #expect(settings.menuBarMetricPreference(for: .cursor) == .extraUsage) + #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsExtraUsage(for: .cursor, snapshot: nil) == false) + + settings.setMenuBarMetricPreference(.extraUsage, for: .claude) + #expect(settings.menuBarMetricPreference(for: .claude) == .extraUsage) + #expect(settings.menuBarMetricPreference(for: .claude, snapshot: nil) == .automatic) + #expect(settings.menuBarMetricSupportsExtraUsage(for: .claude, snapshot: nil) == false) + + settings.setMenuBarMetricPreference(.tertiary, for: .perplexity) + #expect(settings.menuBarMetricPreference(for: .perplexity) == .tertiary) + #expect(settings.menuBarMetricPreference(for: .perplexity, snapshot: nil) == .tertiary) + #expect(settings.menuBarMetricSupportsTertiary(for: .perplexity, snapshot: nil)) + + settings.setMenuBarMetricPreference(.tertiary, for: .gemini) + #expect(settings.menuBarMetricPreference(for: .gemini) == .automatic) } @Test - func menuBarMetricPreferenceRestrictsOpenRouterToAutomaticOrPrimary() { + func `menu bar metric preference restricts open router to automatic or primary`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-openrouter-metric") settings.setMenuBarMetricPreference(.secondary, for: .openrouter) @@ -32,10 +67,29 @@ struct SettingsStoreAdditionalTests { settings.setMenuBarMetricPreference(.primary, for: .openrouter) #expect(settings.menuBarMetricPreference(for: .openrouter) == .primary) + + settings.setMenuBarMetricPreference(.tertiary, for: .openrouter) + #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) + + settings.setMenuBarMetricPreference(.extraUsage, for: .openrouter) + #expect(settings.menuBarMetricPreference(for: .openrouter) == .automatic) + } + + @Test + func `menu bar metric preference restricts text only balance providers to automatic`() { + let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-text-only-metric") + + for provider in [UsageProvider.deepseek, .mistral, .kimik2] { + settings.setMenuBarMetricPreference(.primary, for: provider) + #expect(settings.menuBarMetricPreference(for: provider) == .automatic) + + settings.setMenuBarMetricPreference(.secondary, for: provider) + #expect(settings.menuBarMetricPreference(for: provider) == .automatic) + } } @Test - func minimaxAuthModeUsesStoredValues() { + func `minimax auth mode uses stored values`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-minimax") settings.minimaxAPIToken = "sk-api-test-token" settings.minimaxCookieHeader = "cookie=value" @@ -47,7 +101,7 @@ struct SettingsStoreAdditionalTests { } @Test - func tokenAccountsSetManualCookieSourceWhenRequired() { + func `token accounts set manual cookie source when required`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-token-accounts") settings.addTokenAccount(provider: .claude, label: "Primary", token: "token-1") @@ -57,7 +111,7 @@ struct SettingsStoreAdditionalTests { } @Test - func ollamaTokenAccountsSetManualCookieSourceWhenRequired() { + func `ollama token accounts set manual cookie source when required`() { let settings = Self.makeSettingsStore(suite: "SettingsStoreAdditionalTests-ollama-token-accounts") settings.addTokenAccount(provider: .ollama, label: "Primary", token: "session=token-1") @@ -67,7 +121,7 @@ struct SettingsStoreAdditionalTests { } @Test - func detectsTokenCostUsageSourcesFromFilesystem() throws { + func `detects token cost usage sources from filesystem`() throws { let fm = FileManager.default let root = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let sessions = root.appendingPathComponent("sessions", isDirectory: true) diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index b26289e4f..44a3e3e40 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct SettingsStoreCoverageTests { @Test - func providerOrderingAndCaching() throws { + func `provider ordering and caching`() throws { let suite = "SettingsStoreCoverageTests-ordering" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -37,7 +36,22 @@ struct SettingsStoreCoverageTests { } @Test - func menuBarMetricPreferencesAndDisplayModes() { + func `disabling selected provider clears menu selection`() throws { + let settings = Self.makeSettingsStore() + let metadata = ProviderRegistry.shared.metadata + + try settings.setProviderEnabled(provider: .codex, metadata: #require(metadata[.codex]), enabled: true) + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: true) + settings.selectedMenuProvider = .claude + + try settings.setProviderEnabled(provider: .claude, metadata: #require(metadata[.claude]), enabled: false) + + #expect(settings.selectedMenuProvider == nil) + #expect(settings.enabledProvidersOrdered(metadataByProvider: metadata) == [.codex]) + } + + @Test + func `menu bar metric preferences and display modes`() { let settings = Self.makeSettingsStore() settings.setMenuBarMetricPreference(.average, for: .codex) @@ -48,7 +62,7 @@ struct SettingsStoreCoverageTests { #expect(settings.menuBarMetricSupportsAverage(for: .gemini)) settings.setMenuBarMetricPreference(.secondary, for: .zai) - #expect(settings.menuBarMetricPreference(for: .zai) == .primary) + #expect(settings.menuBarMetricPreference(for: .zai) == .secondary) settings.menuBarDisplayMode = .pace #expect(settings.menuBarDisplayMode == .pace) @@ -61,7 +75,40 @@ struct SettingsStoreCoverageTests { } @Test - func tokenAccountMutationsApplySideEffects() { + func `multi account menu layout persists and bridges legacy show all token accounts`() throws { + let suite = "SettingsStoreCoverageTests-multi-account-layout" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let initial = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(initial.multiAccountMenuLayout == .segmented) + + initial.multiAccountMenuLayout = .stacked + #expect(defaults.string(forKey: "multiAccountMenuLayout") == MultiAccountMenuLayout.stacked.rawValue) + #expect(initial.showAllTokenAccountsInMenu) + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.multiAccountMenuLayout == .stacked) + reloaded.showAllTokenAccountsInMenu = false + #expect(reloaded.multiAccountMenuLayout == .segmented) + } + + @Test + func `legacy show all token accounts migrates to stacked layout`() throws { + let suite = "SettingsStoreCoverageTests-legacy-token-account-layout" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "showAllTokenAccountsInMenu") + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + #expect(settings.multiAccountMenuLayout == .stacked) + } + + @Test + func `token account mutations apply side effects`() { let settings = Self.makeSettingsStore() settings.addTokenAccount(provider: .claude, label: "Primary", token: "token") @@ -83,7 +130,159 @@ struct SettingsStoreCoverageTests { } @Test - func tokenCostUsageSourceDetection() throws { + func `token account update preserves identity and selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "token-2") + settings.setActiveTokenAccountIndex(0, for: .copilot) + + let original = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.updateTokenAccount( + provider: .copilot, + accountID: original.id, + label: "Primary (Pro)", + token: "token-1b") + + let updated = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(updated.id == original.id) + #expect(updated.label == "Primary (Pro)") + #expect(updated.token == "token-1b") + #expect(settings.tokenAccounts(for: .copilot).count == 2) + } + + @Test + func `copilot token accounts clear legacy api key fallback`() throws { + let settings = Self.makeSettingsStore() + settings.copilotAPIToken = "legacy-token" + + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "token-1") + + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == "token-1") + + settings.copilotAPIToken = "legacy-token" + let account = try #require(settings.selectedTokenAccount(for: .copilot)) + settings.removeTokenAccount(provider: .copilot, accountID: account.id) + + #expect(settings.tokenAccounts(for: .copilot).isEmpty) + #expect(settings.copilotAPIToken.isEmpty) + #expect(settings.copilotSettingsSnapshot(tokenOverride: nil).apiToken == nil) + } + + @Test + func `copilot enterprise host persists in provider config`() throws { + let suite = "SettingsStoreCoverageTests-copilot-enterprise-host" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + + first.copilotEnterpriseHost = "https://octocorp.ghe.com/login" + #expect(first.copilotEnterpriseHost == "https://octocorp.ghe.com/login") + #expect(first.copilotSettingsSnapshot(tokenOverride: nil).enterpriseHost == "octocorp.ghe.com") + + let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(second.copilotEnterpriseHost == "https://octocorp.ghe.com/login") + + second.copilotEnterpriseHost = "github.com" + #expect(second.copilotEnterpriseHost == "github.com") + #expect(second.copilotSettingsSnapshot(tokenOverride: nil).enterpriseHost == nil) + } + + @Test + func `removing another token account preserves active selection`() throws { + let settings = Self.makeSettingsStore() + + settings.addTokenAccount(provider: .copilot, label: "A", token: "token-a") + settings.addTokenAccount(provider: .copilot, label: "B", token: "token-b") + settings.addTokenAccount(provider: .copilot, label: "C", token: "token-c") + settings.setActiveTokenAccountIndex(1, for: .copilot) + + let activeBefore = try #require(settings.selectedTokenAccount(for: .copilot)) + let accountToRemove = try #require(settings.tokenAccounts(for: .copilot).first) + settings.removeTokenAccount(provider: .copilot, accountID: accountToRemove.id) + + let activeAfter = try #require(settings.selectedTokenAccount(for: .copilot)) + #expect(activeAfter.id == activeBefore.id) + #expect(activeAfter.label == "B") + #expect(settings.tokenAccounts(for: .copilot).map(\.label) == ["B", "C"]) + } + + @Test + func `claude snapshot uses OAuth routing for OAuth token accounts`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount(provider: .claude, label: "OAuth", token: "Bearer sk-ant-oat-account-token") + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.cookieSource == .off) + #expect(snapshot.manualCookieHeader?.isEmpty == true) + } + + @Test + func `claude snapshot uses manual cookie routing for session key accounts`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount(provider: .claude, label: "Cookie", token: "sk-ant-session-token") + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "sessionKey=sk-ant-session-token") + } + + @Test + func `claude snapshot normalizes config manual cookie input through shared route`() { + let settings = Self.makeSettingsStore() + settings.claudeCookieSource = .manual + settings.claudeCookieHeader = "Cookie: sessionKey=sk-ant-session-token; foo=bar" + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.usageDataSource == .auto) + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "sessionKey=sk-ant-session-token; foo=bar") + } + + @Test + func `claude snapshot does not fall back to config cookie for malformed selected token account`() { + let settings = Self.makeSettingsStore() + settings.claudeCookieSource = .manual + settings.claudeCookieHeader = "Cookie: sessionKey=sk-ant-config-cookie" + settings.addTokenAccount(provider: .claude, label: "Malformed", token: "Cookie:") + + let snapshot = settings.claudeSettingsSnapshot(tokenOverride: nil) + + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader?.isEmpty == true) + } + + @Test + func `opencode go token accounts force manual cookie routing`() { + let settings = Self.makeSettingsStore() + settings.addTokenAccount(provider: .opencodego, label: "Go", token: "auth=go-cookie") + + let snapshot = settings.opencodegoSettingsSnapshot(tokenOverride: nil) + + #expect(settings.opencodegoCookieSource == .manual) + #expect(snapshot.cookieSource == .manual) + #expect(snapshot.manualCookieHeader == "auth=go-cookie") + } + + @Test + func `opencode go snapshot preserves nil workspace id when settings are unset`() { + let settings = Self.makeSettingsStore() + + let snapshot = settings.opencodegoSettingsSnapshot(tokenOverride: nil) + + #expect(settings.opencodegoWorkspaceID.isEmpty) + #expect(snapshot.workspaceID == nil) + } + + @Test + func `token cost usage source detection`() throws { let fileManager = FileManager.default let root = fileManager.temporaryDirectory.appendingPathComponent( "token-cost-\(UUID().uuidString)", @@ -111,7 +310,7 @@ struct SettingsStoreCoverageTests { } @Test - func ensureTokenLoadersExecute() { + func `ensure token loaders execute`() { let settings = Self.makeSettingsStore() settings.ensureZaiAPITokenLoaded() @@ -136,7 +335,7 @@ struct SettingsStoreCoverageTests { } @Test - func keychainDisableForcesManualCookieSources() throws { + func `keychain disable forces manual cookie sources`() throws { let suite = "SettingsStoreCoverageTests-keychain" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -154,13 +353,13 @@ struct SettingsStoreCoverageTests { } @Test - func claudeKeychainPromptMode_defaultsToOnlyOnUserAction() { + func `claude keychain prompt mode defaults to only on user action`() { let settings = Self.makeSettingsStore() #expect(settings.claudeOAuthKeychainPromptMode == .onlyOnUserAction) } @Test - func claudeKeychainPromptMode_persistsAcrossStoreReload() throws { + func `claude keychain prompt mode persists across store reload`() throws { let suite = "SettingsStoreCoverageTests-claude-keychain-prompt-mode" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -177,7 +376,7 @@ struct SettingsStoreCoverageTests { } @Test - func claudeKeychainPromptMode_invalidRawFallsBackToOnlyOnUserAction() throws { + func `claude keychain prompt mode invalid raw falls back to only on user action`() throws { let suite = "SettingsStoreCoverageTests-claude-keychain-prompt-mode-invalid" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -189,30 +388,30 @@ struct SettingsStoreCoverageTests { } @Test - func claudeKeychainReadStrategy_defaultsToSecurityFramework() { + func `claude keychain read strategy defaults to security CLI experimental`() { let settings = Self.makeSettingsStore() - #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) } @Test - func claudeKeychainReadStrategy_persistsAcrossStoreReload() throws { + func `claude keychain read strategy persists across store reload`() throws { let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) - first.claudeOAuthKeychainReadStrategy = .securityCLI + first.claudeOAuthKeychainReadStrategy = .securityCLIExperimental #expect( defaults.string(forKey: "claudeOAuthKeychainReadStrategy") - == ClaudeOAuthKeychainReadStrategy.securityCLI.rawValue) + == ClaudeOAuthKeychainReadStrategy.securityCLIExperimental.rawValue) let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) - #expect(second.claudeOAuthKeychainReadStrategy == .securityCLI) + #expect(second.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) } @Test - func claudeKeychainReadStrategy_invalidRawFallsBackToSecurityFramework() throws { + func `claude keychain read strategy invalid raw falls back to security framework`() throws { let suite = "SettingsStoreCoverageTests-claude-keychain-read-strategy-invalid" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -224,15 +423,98 @@ struct SettingsStoreCoverageTests { } @Test - func claudePromptFreeCredentialsToggle_mapsToReadStrategy() { + func `claude prompt free credentials toggle maps to read strategy`() { let settings = Self.makeSettingsStore() - #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == false) - - settings.claudeOAuthPromptFreeCredentialsEnabled = true - #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLI) + #expect(settings.claudeOAuthPromptFreeCredentialsEnabled == true) settings.claudeOAuthPromptFreeCredentialsEnabled = false #expect(settings.claudeOAuthKeychainReadStrategy == .securityFramework) + + settings.claudeOAuthPromptFreeCredentialsEnabled = true + #expect(settings.claudeOAuthKeychainReadStrategy == .securityCLIExperimental) + } + + @Test + func `upsert antigravity oauth account adds and updates active token account`() throws { + let settings = Self.makeSettingsStore() + let first = AntigravityOAuthCredentials( + accessToken: "first-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: "user@example.com") + let updated = AntigravityOAuthCredentials( + accessToken: "updated-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_100), + email: "user@example.com") + + settings.upsertAntigravityOAuthAccount(first) + settings.upsertAntigravityOAuthAccount(updated) + + let accounts = settings.tokenAccounts(for: .antigravity) + #expect(accounts.count == 1) + let account = try #require(accounts.first) + #expect(account.label == "user@example.com") + #expect(account.externalIdentifier == "user@example.com") + #expect(settings.selectedTokenAccount(for: .antigravity)?.id == account.id) + + let decoded = try #require(AntigravityOAuthCredentialsStore.credentials(fromTokenAccountValue: account.token)) + #expect(decoded.accessToken == "updated-access") + } + + @Test + func `upsert antigravity oauth account does not merge missing email accounts by fallback label`() { + let settings = Self.makeSettingsStore() + let first = AntigravityOAuthCredentials( + accessToken: "first-access", + refreshToken: "first-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_000), + email: nil) + let second = AntigravityOAuthCredentials( + accessToken: "second-access", + refreshToken: "second-refresh", + expiryDate: Date(timeIntervalSince1970: 1_700_000_100), + email: nil) + + settings.upsertAntigravityOAuthAccount(first) + settings.upsertAntigravityOAuthAccount(second) + + let accounts = settings.tokenAccounts(for: .antigravity) + #expect(accounts.count == 2) + #expect(accounts.map(\.label) == ["Google Account 1", "Google Account 2"]) + #expect(settings.selectedTokenAccount(for: .antigravity)?.id == accounts.last?.id) + } + + @Test + func `weekly progress work days defaults to nil and persists across store reload`() throws { + let suite = "SettingsStoreCoverageTests-weekly-progress-work-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let fresh = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(fresh.weeklyProgressWorkDays == nil) + + fresh.weeklyProgressWorkDays = 5 + #expect(defaults.object(forKey: "weeklyProgressWorkDays") as? Int == 5) + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.weeklyProgressWorkDays == 5) + + fresh.weeklyProgressWorkDays = 4 + #expect(reloaded.weeklyProgressWorkDays == 5) + + let reloaded2 = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded2.weeklyProgressWorkDays == 4) + + reloaded2.weeklyProgressWorkDays = 7 + let reloaded3 = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded3.weeklyProgressWorkDays == 7) + + reloaded3.weeklyProgressWorkDays = nil + #expect(defaults.object(forKey: "weeklyProgressWorkDays") == nil) + let reloaded4 = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded4.weeklyProgressWorkDays == nil) } private static func makeSettingsStore(suiteName: String = "SettingsStoreCoverageTests") -> SettingsStore { diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 04ca55516..7c3b70790 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -4,11 +4,29 @@ import Observation import Testing @testable import CodexBar +@Suite(.serialized) @MainActor -@Suite +// swiftlint:disable:next type_body_length struct SettingsStoreTests { + private final class ObservationFlag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { + self.lock.lock() + self.value = true + self.lock.unlock() + } + + func get() -> Bool { + self.lock.lock() + defer { self.lock.unlock() } + return self.value + } + } + @Test - func defaultRefreshFrequencyIsFiveMinutes() throws { + func `default refresh frequency is five minutes`() throws { let suite = "SettingsStoreTests-default" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -22,10 +40,29 @@ struct SettingsStoreTests { #expect(store.refreshFrequency == .fiveMinutes) #expect(store.refreshFrequency.seconds == 300) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) } @Test - func persistsRefreshFrequencyAcrossInstances() throws { + func `repairs unrecognized refresh frequency raw value`() throws { + let suite = "SettingsStoreTests-invalid-refresh" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set("legacyValue", forKey: "refreshFrequency") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.refreshFrequency == .fiveMinutes) + #expect(defaults.string(forKey: "refreshFrequency") == RefreshFrequency.fiveMinutes.rawValue) + } + + @Test + func `persists refresh frequency across instances`() throws { let suite = "SettingsStoreTests-persist" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -50,7 +87,84 @@ struct SettingsStoreTests { } @Test - func persistsSelectedMenuProviderAcrossInstances() throws { + func `weekly confetti setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-weekly-confetti" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.confettiOnWeeklyLimitResetsEnabled == false) + storeA.confettiOnWeeklyLimitResetsEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.confettiOnWeeklyLimitResetsEnabled == true) + } + + @Test + func `provider storage setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-provider-storage" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.providerStorageFootprintsEnabled == false) + #expect(defaultsA.bool(forKey: "providerStorageFootprintsEnabled") == false) + storeA.providerStorageFootprintsEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.providerStorageFootprintsEnabled == true) + } + + @Test + func `provider changelog links setting defaults off and persists`() throws { + let suite = "SettingsStoreTests-provider-changelog-links" + let defaultsA = try #require(UserDefaults(suiteName: suite)) + defaultsA.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let storeA = SettingsStore( + userDefaults: defaultsA, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeA.providerChangelogLinksEnabled == false) + #expect(defaultsA.bool(forKey: "providerChangelogLinksEnabled") == false) + storeA.providerChangelogLinksEnabled = true + + let defaultsB = try #require(UserDefaults(suiteName: suite)) + let storeB = SettingsStore( + userDefaults: defaultsB, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(storeB.providerChangelogLinksEnabled == true) + } + + @Test + func `persists selected menu provider across instances`() throws { let suite = "SettingsStoreTests-selectedMenuProvider" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -74,7 +188,7 @@ struct SettingsStoreTests { } @Test - func persistsMergedMenuLastSelectedWasOverviewAcrossInstances() throws { + func `persists merged menu last selected was overview across instances`() throws { let suite = "SettingsStoreTests-merged-last-overview" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -98,7 +212,7 @@ struct SettingsStoreTests { } @Test - func mergedOverviewSelectedProvidersPersistsAndNormalizesAcrossInstances() throws { + func `merged overview selected providers persists and normalizes across instances`() throws { let suite = "SettingsStoreTests-merged-overview-selection" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -123,7 +237,7 @@ struct SettingsStoreTests { } @Test - func mergedOverviewSelectedProvidersIgnoresInvalidRawValues() throws { + func `merged overview selected providers ignores invalid raw values`() throws { let suite = "SettingsStoreTests-merged-overview-invalid-raw" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -139,7 +253,7 @@ struct SettingsStoreTests { } @Test - func resolvedMergedOverviewProvidersDefaultsToFirstThreeWhenSelectionEmpty() throws { + func `resolved merged overview providers defaults to first three when selection empty`() throws { let suite = "SettingsStoreTests-merged-overview-default-first-three" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -157,7 +271,7 @@ struct SettingsStoreTests { } @Test - func resolvedMergedOverviewProvidersHonorsExplicitEmptySelection() throws { + func `resolved merged overview providers honors explicit empty selection`() throws { let suite = "SettingsStoreTests-merged-overview-explicit-empty" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -176,7 +290,7 @@ struct SettingsStoreTests { } @Test - func resolvedMergedOverviewProvidersUsesProviderOrderNotSelectionOrder() throws { + func `resolved merged overview providers uses provider order not selection order`() throws { let suite = "SettingsStoreTests-merged-overview-order" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -195,7 +309,7 @@ struct SettingsStoreTests { } @Test - func reconcileMergedOverviewSelectionRemovesUnavailableWithoutAutoFill() throws { + func `reconcile merged overview selection removes unavailable without auto fill`() throws { let suite = "SettingsStoreTests-merged-overview-reconcile" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -216,7 +330,7 @@ struct SettingsStoreTests { } @Test - func reconcileMergedOverviewSelectionDoesNotClobberStoredPreferenceWhenThreeOrFewer() throws { + func `reconcile merged overview selection does not clobber stored preference when three or fewer`() throws { let suite = "SettingsStoreTests-merged-overview-three-or-fewer" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -237,7 +351,9 @@ struct SettingsStoreTests { } @Test - func reconcileMergedOverviewSelectionIgnoresStaleSubsetWithoutPersistingAutoFillWhenThreeOrFewer() throws { + func `reconcile merged overview selection ignores stale subset without persisting auto fill when three or fewer`() + throws + { let suite = "SettingsStoreTests-merged-overview-three-or-fewer-subset" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -258,7 +374,7 @@ struct SettingsStoreTests { } @Test - func mergedOverviewSelectionAllowsDeselectingProvidersWhenThreeOrFewer() throws { + func `merged overview selection allows deselecting providers when three or fewer`() throws { let suite = "SettingsStoreTests-merged-overview-deselect-three-or-fewer" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -282,7 +398,7 @@ struct SettingsStoreTests { } @Test - func mergedOverviewSelectionAppliesWhenSameActiveSetIsReordered() throws { + func `merged overview selection applies when same active set is reordered`() throws { let suite = "SettingsStoreTests-merged-overview-ordered-context" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -306,7 +422,7 @@ struct SettingsStoreTests { } @Test - func mergedOverviewSelectionAllowsDeselectingProvidersWhenMoreThanThreeActive() throws { + func `merged overview selection allows deselecting providers when more than three active`() throws { let suite = "SettingsStoreTests-merged-overview-deselect-subset" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -330,7 +446,7 @@ struct SettingsStoreTests { } @Test - func reconcileMergedOverviewSelectionPreservesStoredSubsetWhenActiveDropsToThreeOrFewer() throws { + func `reconcile merged overview selection preserves stored subset when active drops to three or fewer`() throws { let suite = "SettingsStoreTests-merged-overview-preserve-subset-across-drop" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -364,7 +480,7 @@ struct SettingsStoreTests { } @Test - func reconcileMergedOverviewSelectionClearsPreferenceWhenNoProvidersActive() throws { + func `reconcile merged overview selection clears preference when no providers active`() throws { let suite = "SettingsStoreTests-merged-overview-clear-on-empty-active" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -390,7 +506,7 @@ struct SettingsStoreTests { } @Test - func persistsOpenCodeWorkspaceIDAcrossInstances() throws { + func `persists open code workspace ID across instances`() throws { let suite = "SettingsStoreTests-opencode-workspace" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -412,7 +528,7 @@ struct SettingsStoreTests { } @Test - func defaultsSessionQuotaNotificationsToEnabled() throws { + func `defaults session quota notifications to enabled`() throws { let key = "sessionQuotaNotificationsEnabled" let suite = "SettingsStoreTests-sessionQuotaNotifications" let defaults = try #require(UserDefaults(suiteName: suite)) @@ -428,7 +544,145 @@ struct SettingsStoreTests { } @Test - func defaultsClaudeUsageSourceToAuto() throws { + func `defaults quota warnings to disabled with global thresholds and sound`() throws { + let suite = "SettingsStoreTests-quota-warning-defaults" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.quotaWarningNotificationsEnabled == false) + #expect(store.quotaWarningThresholds == [50, 20]) + #expect(store.quotaWarningWindowEnabled(.session) == true) + #expect(store.quotaWarningWindowEnabled(.weekly) == true) + #expect(store.quotaWarningSoundEnabled == true) + #expect(store.quotaWarningMarkersVisible == true) + #expect(defaults.array(forKey: "quotaWarningThresholds") as? [Int] == [50, 20]) + #expect(defaults.object(forKey: "quotaWarningSessionEnabled") as? Bool == true) + #expect(defaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool == true) + #expect(defaults.bool(forKey: "quotaWarningSoundEnabled") == true) + #expect(defaults.object(forKey: "quotaWarningMarkersVisible") as? Bool == true) + } + + @Test + func `global quota warning windows persist independently`() throws { + let suite = "SettingsStoreTests-quota-warning-window-enabled" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.setQuotaWarningWindowEnabled(.weekly, enabled: false) + + #expect(store.quotaWarningWindowEnabled(.session) == true) + #expect(store.quotaWarningWindowEnabled(.weekly) == false) + #expect(defaults.object(forKey: "quotaWarningWeeklyEnabled") as? Bool == false) + } + + @Test + func `sanitizes invalid quota warning thresholds from defaults`() throws { + let suite = "SettingsStoreTests-quota-warning-sanitize" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set([120, 20, 20, -5, 50], forKey: "quotaWarningThresholds") + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.quotaWarningThresholds == [99, 50, 20, 0]) + #expect(defaults.array(forKey: "quotaWarningThresholds") as? [Int] == [99, 50, 20, 0]) + } + + @Test + func `quota warning threshold pair resolves blanks and clamps bounds`() { + #expect(QuotaWarningThresholds.resolved(upper: nil, lower: nil) == [50, 20]) + #expect(QuotaWarningThresholds.resolved(upper: nil, lower: 10) == [50, 10]) + #expect(QuotaWarningThresholds.resolved(upper: 10, lower: nil) == [10, 0]) + #expect(QuotaWarningThresholds.resolved(upper: 120, lower: -5) == [99, 0]) + } + + @Test + func `provider quota warning override resolves before global thresholds`() throws { + let suite = "SettingsStoreTests-quota-warning-provider-override" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + store.quotaWarningThresholds = [50, 20] + + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + store.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: [10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .weekly) == [50, 20]) + + store.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: nil) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [50, 20]) + } + + @Test + func `global quota warning thresholds resolve independently by window`() throws { + let suite = "SettingsStoreTests-quota-warning-window-thresholds" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.setQuotaWarningThresholds(.session, thresholds: [25]) + store.setQuotaWarningThresholds(.weekly, thresholds: [75, 10]) + + #expect(store.quotaWarningThresholds(.session) == [25]) + #expect(store.quotaWarningThresholds(.weekly) == [75, 10]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .session) == [25]) + #expect(store.resolvedQuotaWarningThresholds(provider: .codex, window: .weekly) == [75, 10]) + } + + @Test + func `provider quota warning windows override global enablement independently`() throws { + let suite = "SettingsStoreTests-quota-warning-provider-window-override" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.setQuotaWarningWindowEnabled(.weekly, enabled: false) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == false) + + store.setQuotaWarningWindowEnabled(provider: .codex, window: .weekly, enabled: true) + store.setQuotaWarningWindowEnabled(provider: .codex, window: .session, enabled: false) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == true) + #expect(store.quotaWarningEnabled(provider: .codex, window: .session) == false) + #expect(store.hasQuotaWarningOverride(provider: .codex, window: .weekly) == true) + #expect(store.hasQuotaWarningOverride(provider: .codex, window: .session) == true) + + store.setQuotaWarningWindowEnabled(provider: .codex, window: .weekly, enabled: nil) + #expect(store.quotaWarningEnabled(provider: .codex, window: .weekly) == false) + } + + @Test + func `defaults claude usage source to auto`() throws { let suite = "SettingsStoreTests-claude-source" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -444,7 +698,7 @@ struct SettingsStoreTests { } @Test - func defaultsCodexUsageSourceToAuto() throws { + func `defaults codex usage source to auto`() throws { let suite = "SettingsStoreTests-codex-source" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -460,7 +714,7 @@ struct SettingsStoreTests { } @Test - func defaultsKiloUsageSourceToAuto() throws { + func `defaults kilo usage source to auto`() throws { let suite = "SettingsStoreTests-kilo-source" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -476,7 +730,7 @@ struct SettingsStoreTests { } @Test - func persistsKiloUsageSourceAcrossInstances() throws { + func `persists kilo usage source across instances`() throws { let suite = "SettingsStoreTests-kilo-source-persist" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -500,7 +754,7 @@ struct SettingsStoreTests { } @Test - func kiloExtrasOnlyApplyInAutoMode() throws { + func `kilo extras only apply in auto mode`() throws { let suite = "SettingsStoreTests-kilo-extras" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -523,7 +777,7 @@ struct SettingsStoreTests { @Test @MainActor - func applyExternalConfigDoesNotBroadcast() throws { + func `apply external config does not broadcast`() throws { let suite = "SettingsStoreTests-external-config" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -567,7 +821,7 @@ struct SettingsStoreTests { } @Test - func persistsZaiAPIRegionAcrossInstances() throws { + func `persists zai API region across instances`() throws { let suite = "SettingsStoreTests-zai-region" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -589,7 +843,7 @@ struct SettingsStoreTests { } @Test - func persistsMiniMaxAPIRegionAcrossInstances() throws { + func `persists mini max API region across instances`() throws { let suite = "SettingsStoreTests-minimax-region" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -611,13 +865,86 @@ struct SettingsStoreTests { } @Test - func defaultsOpenAIWebAccessToEnabled() throws { + func `defaults open AI web access to disabled`() throws { let suite = "SettingsStoreTests-openai-web" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) defaults.set(false, forKey: "debugDisableKeychainAccess") let configStore = testConfigStore(suiteName: suite) + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == false) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + #expect(store.codexCookieSource == .off) + } + + @Test + func `infers open AI web access enabled for legacy configured codex cookies`() throws { + let suite = "SettingsStoreTests-openai-web-legacy" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.removeObject(forKey: "openAIWebAccessEnabled") + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig(providers: [ + ProviderConfig(id: .codex, cookieSource: .auto), + ])) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == true) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + #expect(store.codexCookieSource == .auto) + } + + @Test + func `imports legacy open AI web access defaults key`() throws { + let suite = "SettingsStoreTests-openai-web-legacy-key" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.removeObject(forKey: "openAIWebAccessEnabled") + defaults.set(false, forKey: "openAIWebAccess") + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig(providers: [ + ProviderConfig(id: .codex, cookieSource: .auto), + ])) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebAccessEnabled == false) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + } + + @Test + func `infers open AI web access enabled for legacy codex config with implicit auto cookies`() throws { + let suite = "SettingsStoreTests-openai-web-legacy-implicit-auto" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.removeObject(forKey: "openAIWebAccessEnabled") + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + try configStore.save(CodexBarConfig(providers: [ + ProviderConfig(id: .codex), + ])) + let store = SettingsStore( userDefaults: defaults, configStore: configStore, @@ -626,11 +953,62 @@ struct SettingsStoreTests { #expect(store.openAIWebAccessEnabled == true) #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + #expect(store.openAIWebBatterySaverEnabled == false) + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + #expect(store.codexCookieSource == .auto) + } + + @Test + func `disabling open AI web access turns codex cookie source off`() throws { + let suite = "SettingsStoreTests-openai-web-toggle" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + store.codexCookieSource = .auto #expect(store.codexCookieSource == .auto) + + store.openAIWebAccessEnabled = false + #expect(store.codexCookieSource == .off) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == false) + + store.openAIWebAccessEnabled = true + #expect(store.codexCookieSource == .auto) + #expect(defaults.bool(forKey: "openAIWebAccessEnabled") == true) + } + + @Test + func `open AI web battery saver persists separately from extras availability`() throws { + let suite = "SettingsStoreTests-openai-web-battery-saver" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "debugDisableKeychainAccess") + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + #expect(store.openAIWebBatterySaverEnabled == false) + + store.openAIWebBatterySaverEnabled = false + #expect(defaults.bool(forKey: "openAIWebBatterySaverEnabled") == false) + + store.openAIWebAccessEnabled = true + #expect(store.openAIWebBatterySaverEnabled == false) } @Test - func menuObservationTokenUpdatesOnDefaultsChange() async throws { + func `menu observation token updates on defaults change`() async throws { let suite = "SettingsStoreTests-observation-defaults" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -642,24 +1020,83 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - var didChange = false + let didChange = ObservationFlag() withObservationTracking { _ = store.menuObservationToken } onChange: { - Task { @MainActor in - didChange = true - } + didChange.set() } store.statusChecksEnabled.toggle() try? await Task.sleep(nanoseconds: 50_000_000) - #expect(didChange == true) + #expect(didChange.get() == true) } @Test - func configBackedSettingsTriggerObservation() async throws { + func `menu observation token updates on per-window quota threshold changes`() async throws { + let suite = "SettingsStoreTests-observation-quota-threshold-windows" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + func expectObservation( + for window: QuotaWarningWindow, + thresholds: [Int]) async + { + let didChange = ObservationFlag() + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.setQuotaWarningThresholds(window, thresholds: thresholds) + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) + } + + await expectObservation(for: .session, thresholds: [70, 30]) + await expectObservation(for: .weekly, thresholds: [80, 40]) + } + + @Test + func `menu observation token updates on weekly progress work days changes`() async throws { + let suite = "SettingsStoreTests-observation-weekly-progress-work-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.weeklyProgressWorkDays = 5 + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) + } + + @Test + func `config backed settings trigger observation`() async throws { let suite = "SettingsStoreTests-observation-config" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -671,24 +1108,49 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - var didChange = false + let didChange = ObservationFlag() withObservationTracking { _ = store.codexCookieSource } onChange: { - Task { @MainActor in - didChange = true - } + didChange.set() } store.codexCookieSource = .manual try? await Task.sleep(nanoseconds: 50_000_000) - #expect(didChange == true) + #expect(didChange.get() == true) } @Test - func providerOrder_defaultsToAllCases() throws { + func `menu observation token updates on codex active source change`() async throws { + let suite = "SettingsStoreTests-observation-codex-active-source" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.codexActiveSource = .liveSystem + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) + } + + @Test + func `provider order defaults to all cases`() throws { let suite = "SettingsStoreTests-providerOrder-default" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -704,7 +1166,7 @@ struct SettingsStoreTests { } @Test - func providerOrder_persistsAndAppendsNewProviders() throws { + func `provider order persists and appends new providers`() throws { let suite = "SettingsStoreTests-providerOrder-persist" let defaultsA = try #require(UserDefaults(suiteName: suite)) defaultsA.removePersistentDomain(forName: suite) @@ -723,30 +1185,9 @@ struct SettingsStoreTests { zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) - #expect(storeA.orderedProviders() == [ - .gemini, - .codex, - .claude, - .cursor, - .opencode, - .factory, - .antigravity, - .copilot, - .zai, - .minimax, - .kimi, - .kilo, - .kiro, - .vertexai, - .augment, - .jetbrains, - .kimik2, - .amp, - .ollama, - .synthetic, - .warp, - .openrouter, - ]) + let legacyOrder: [UsageProvider] = [.gemini, .codex] + let appendedProviders = UsageProvider.allCases.filter { !legacyOrder.contains($0) } + #expect(storeA.orderedProviders() == legacyOrder + appendedProviders) // Move one provider; ensure it's persisted across instances. let antigravityIndex = try #require(storeA.orderedProviders().firstIndex(of: .antigravity)) @@ -761,4 +1202,47 @@ struct SettingsStoreTests { #expect(storeB.orderedProviders().first == .antigravity) } + + @Test + func `setting alibaba API key enables provider`() throws { + let suite = "SettingsStoreTests-alibaba-enable-on-token" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let metadata = try #require(ProviderDescriptorRegistry.metadata[.alibaba]) + store.setProviderEnabled(provider: .alibaba, metadata: metadata, enabled: false) + + store.alibabaCodingPlanAPIToken = "cpk-test-token" + + #expect(store.isProviderEnabled(provider: .alibaba, metadata: metadata)) + } + + @Test + func `alibaba provider auto enables on startup when token exists`() throws { + let suite = "SettingsStoreTests-alibaba-auto-enable-startup" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .alibaba, enabled: false, apiKey: "cpk-startup-token"), + ]) + try configStore.save(config) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let metadata = try #require(ProviderDescriptorRegistry.metadata[.alibaba]) + #expect(store.isProviderEnabled(provider: .alibaba, metadata: metadata)) + } } diff --git a/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift b/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift new file mode 100644 index 000000000..eafebe544 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemAnimationCodexCreditsTests.swift @@ -0,0 +1,61 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusItemAnimationCodexCreditsTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + @Test + func `codex icon keeps credits only rendering when usage is missing`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-only-icon"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(nil, provider: .codex) + store.credits = CreditsSnapshot(remaining: 80, events: [], updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + controller.applyIcon(for: .codex, phase: nil) + + guard let image = controller.statusItems[.codex]?.button?.image else { + #expect(Bool(false)) + return + } + let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + #expect(rep != nil) + guard let rep else { return } + + let creditsOnlyAlpha = (rep.colorAt(x: 18, y: 17) ?? .clear).alphaComponent + #expect(creditsOnlyAlpha > 0.05) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift new file mode 100644 index 000000000..fc064a9d3 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -0,0 +1,186 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +struct StatusItemAnimationSignatureTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + @Test + func `merged render signature changes when unified icon style changes`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-style-signature" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(store.enabledProvidersForDisplay() == [.codex, .synthetic]) + #expect(store.enabledProviders() == [.codex, .synthetic]) + #expect(store.iconStyle == .combined) + controller.applyIcon(phase: nil) + let combinedSignature = controller.lastAppliedMergedIconRenderSignature + + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: false) + } + + #expect(store.enabledProvidersForDisplay() == [.codex]) + #expect(store.enabledProviders() == [.codex]) + #expect(store.iconStyle == .codex) + controller.applyIcon(phase: nil) + let codexSignature = controller.lastAppliedMergedIconRenderSignature + + #expect(combinedSignature != nil) + #expect(codexSignature != nil) + #expect(combinedSignature != codexSignature) + #expect(codexSignature?.contains("style=codex") == true) + } + + @Test + func `merged icon follows overview provider order when first overview provider is loading`() { + let suite = "StatusItemAnimationSignatureTests-merged-overview-provider-order" + let defaults = UserDefaults(suiteName: suite) + defaults?.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults ?? .standard, + configStore: testConfigStore(suiteName: "StatusItemAnimationSignatureTests-merged-overview-provider-order"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = true + settings.menuBarShowsBrandIconWithPercent = false + settings.setProviderOrder([.cursor, .codex, .claude]) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .claude) + + #expect(store.enabledProvidersForDisplay().prefix(3) == [.cursor, .codex, .claude]) + #expect(settings.resolvedMergedOverviewProviders(activeProviders: store.enabledProvidersForDisplay()) == [ + .cursor, + .codex, + .claude, + ]) + + controller.applyIcon(phase: nil) + + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=cursor") == true) + } + + @Test + func `split provider icon skips unchanged render signature`() throws { + let suite = "StatusItemAnimationSignatureTests-split-provider-signature" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(controller.applyIcon(for: .codex, phase: nil) == false) + #expect(controller.applyIcon(for: .codex, phase: nil) == true) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()), + provider: .codex) + + #expect(controller.applyIcon(for: .codex, phase: nil) == false) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index 8bc69d465..ba9bc457d 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -4,7 +4,8 @@ import Testing @testable import CodexBar @MainActor -@Suite +@Suite(.serialized) +// swiftlint:disable:next type_body_length struct StatusItemAnimationTests { private func maxAlpha(in rep: NSBitmapImageRep) -> CGFloat { var maxAlpha: CGFloat = 0 @@ -20,15 +21,13 @@ struct StatusItemAnimationTests { } private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } @Test - func mergedIconLoadingAnimationTracksSelectedProviderOnly() { + func `merged icon loading animation tracks selected provider only`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-merged"), zaiTokenStore: NoopZaiTokenStore(), @@ -42,9 +41,10 @@ struct StatusItemAnimationTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) } + settings.openRouterAPIToken = "or-token" if let geminiMeta = registry.metadata[.gemini] { settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) } @@ -58,6 +58,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -73,7 +74,7 @@ struct StatusItemAnimationTests { } @Test - func mergedIconLoadingAnimationDoesNotFlipLayoutWhenWeeklyHitsZero() { + func `merged icon loading animation does not flip layout when weekly hits zero`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-weekly"), zaiTokenStore: NoopZaiTokenStore(), @@ -88,9 +89,10 @@ struct StatusItemAnimationTests { if let codexMeta = registry.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) } + settings.openRouterAPIToken = "or-token" let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -109,6 +111,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Enter loading state: no data, no stale error. store._setSnapshotForTesting(nil, provider: .codex) @@ -138,7 +141,7 @@ struct StatusItemAnimationTests { } @Test - func warpNoBonusLayoutIsPreservedInShowUsedModeWhenBonusIsExhausted() { + func `warp no bonus layout is preserved in show used mode when bonus is exhausted`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-warp-no-bonus-used"), zaiTokenStore: NoopZaiTokenStore(), @@ -163,6 +166,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Primary used=10%. Bonus exhausted: used=100% (remaining=0%). let snapshot = UsageSnapshot( @@ -191,7 +195,7 @@ struct StatusItemAnimationTests { } @Test - func warpBonusLaneIsPreservedInShowUsedModeWhenBonusIsUnused() { + func `warp bonus lane is preserved in show used mode when bonus is unused`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-warp-unused-bonus-used"), zaiTokenStore: NoopZaiTokenStore(), @@ -216,6 +220,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } // Bonus exists but is unused: used=0% (remaining=100%). let snapshot = UsageSnapshot( @@ -244,7 +249,139 @@ struct StatusItemAnimationTests { } @Test - func menuBarPercentUsesConfiguredMetric() { + func `open router without key limit uses meter icon when brand percent is disabled`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-openrouter-no-limit-meter"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) + } + settings.openRouterAPIToken = "or-token" + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45, + balance: 5, + usedPercent: 90, + keyDataFetched: true, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + controller.applyIcon(for: .openrouter, phase: nil) + + guard let image = controller.statusItems[.openrouter]?.button?.image else { + #expect(Bool(false)) + return + } + + #expect(image.size.width == 18) + #expect(image.size.height == 18) + #expect(snapshot.openRouterUsage?.keyQuotaStatus == .noLimitConfigured) + #expect(controller.statusItems[.openrouter]?.button?.title.isEmpty == true) + #expect(MenuBarDisplayText.percentText(window: snapshot.primary, showUsed: false) == nil) + + // With no key limit, the primary bar has no fill — just the dim track. + // A brand logo would be fully opaque here; the track is not. + let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + #expect(rep != nil) + if let rep { + let alpha = (rep.colorAt(x: 8, y: 25) ?? .clear).alphaComponent + #expect(alpha < 0.5) + } + } + + @Test + func `open router key data not fetched still uses meter icon when brand percent is disabled`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-openrouter-no-fetch-meter"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) + } + settings.openRouterAPIToken = "or-token" + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let snapshot = OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 45, + balance: 5, + usedPercent: 90, + keyDataFetched: false, + keyLimit: nil, + keyUsage: nil, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + controller.applyIcon(for: .openrouter, phase: nil) + + guard let image = controller.statusItems[.openrouter]?.button?.image else { + #expect(Bool(false)) + return + } + + #expect(image.size.width == 18) + #expect(image.size.height == 18) + #expect(snapshot.openRouterUsage?.keyQuotaStatus == .unavailable) + + // Even with no key data, OpenRouter still renders a meter rather than the brand logo. + // A brand logo would be fully opaque here; the unfilled track is not. + let rep = image.representations.compactMap { $0 as? NSBitmapImageRep }.first(where: { + $0.pixelsWide == 36 && $0.pixelsHigh == 36 + }) + #expect(rep != nil) + if let rep { + let alpha = (rep.colorAt(x: 8, y: 25) ?? .clear).alphaComponent + #expect(alpha < 0.5) + } + } + + @Test + func `menu bar percent uses configured metric`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-metric"), zaiTokenStore: NoopZaiTokenStore()) @@ -268,6 +405,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -283,7 +421,7 @@ struct StatusItemAnimationTests { } @Test - func menuBarPercentAutomaticPrefersRateLimitForKimi() { + func `menu bar percent automatic prefers rate limit for kimi`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-kimi-automatic"), zaiTokenStore: NoopZaiTokenStore()) @@ -322,7 +460,7 @@ struct StatusItemAnimationTests { } @Test - func menuBarPercentUsesAverageForGemini() { + func `menu bar percent uses average for gemini`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-average"), zaiTokenStore: NoopZaiTokenStore()) @@ -346,6 +484,7 @@ struct StatusItemAnimationTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -361,7 +500,370 @@ struct StatusItemAnimationTests { } @Test - func menuBarDisplayTextFormatsPercentAndPace() { + func `menu bar percent automatic keeps gemini primary over higher tertiary`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-gemini-automatic-primary"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .gemini + settings.setMenuBarMetricPreference(.automatic, for: .gemini) + + let registry = ProviderRegistry.shared + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 95, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .gemini) + store._setErrorForTesting(nil, provider: .gemini) + + let window = controller.menuBarMetricWindow(for: .gemini, snapshot: snapshot) + + #expect(window?.usedPercent == 20) + } + + @Test + func `menu bar percent automatic picks highest cursor lane including api`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-cursor-automatic-tertiary"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .cursor + settings.setMenuBarMetricPreference(.automatic, for: .cursor) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 90) + } + + @Test + func `menu bar percent automatic falls back to purchased perplexity lane when bonus is exhausted`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-perplexity-automatic-purchased"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .perplexity + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .perplexity) + store._setErrorForTesting(nil, provider: .perplexity) + + let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot) + + #expect(window?.usedPercent == 20) + } + + @Test + func `menu bar percent automatic falls through after recurring perplexity credits are exhausted`() { + let settings = SettingsStore( + configStore: testConfigStore( + suiteName: "StatusItemAnimationTests-perplexity-automatic-recurring-exhausted"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .perplexity + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 32, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .perplexity) + store._setErrorForTesting(nil, provider: .perplexity) + + let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot) + + #expect(window?.usedPercent == 32) + } + + @Test + func `menu bar percent automatic prefers purchased perplexity credits before bonus`() { + let settings = SettingsStore( + configStore: testConfigStore( + suiteName: "StatusItemAnimationTests-perplexity-automatic-purchased-before-bonus"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .perplexity + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .perplexity) + store._setErrorForTesting(nil, provider: .perplexity) + + let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot) + + #expect(window?.usedPercent == 45) + } + + @Test + func `menu bar percent primary preference stays on recurring perplexity credits`() { + let settings = SettingsStore( + configStore: testConfigStore( + suiteName: "StatusItemAnimationTests-perplexity-primary-recurring-exhausted"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .perplexity + settings.setMenuBarMetricPreference(.primary, for: .perplexity) + + let registry = ProviderRegistry.shared + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 32, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .perplexity) + store._setErrorForTesting(nil, provider: .perplexity) + + let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot) + + #expect(window?.usedPercent == 100) + } + + @Test + func `menu bar percent tertiary preference uses purchased perplexity lane`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-perplexity-tertiary-pref"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .perplexity + settings.setMenuBarMetricPreference(.tertiary, for: .perplexity) + + let registry = ProviderRegistry.shared + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 28, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .perplexity) + store._setErrorForTesting(nil, provider: .perplexity) + + let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot) + + #expect(window?.usedPercent == 28) + } + + @Test + func `menu bar percent tertiary preference uses api lane for cursor`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-cursor-tertiary-pref"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .cursor + settings.setMenuBarMetricPreference(.tertiary, for: .cursor) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 72) + } + + @Test + func `menu bar tertiary preference falls back to automatic when cursor api lane is missing`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemAnimationTests-cursor-tertiary-missing-api"), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .cursor + settings.setMenuBarMetricPreference(.tertiary, for: .cursor) + + let registry = ProviderRegistry.shared + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 72) + } + + @Test + func `menu bar display text formats percent and pace`() { let now = Date(timeIntervalSince1970: 0) let percentWindow = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let paceWindow = RateWindow( @@ -393,53 +895,8 @@ struct StatusItemAnimationTests { } @Test - func menuBarDisplayTextUsesWeeklyPace() { - let now = Date(timeIntervalSince1970: 0) - // 300-minute window, 2h remaining => 3h elapsed, expected=60%, actual=30% => -30% - let paceWindow = RateWindow( - usedPercent: 30, - windowMinutes: 300, - resetsAt: now.addingTimeInterval(2 * 3600), - resetDescription: nil) - - let weeklyPace = UsagePace.weekly(window: paceWindow, now: now, defaultWindowMinutes: 300) - let paceText = MenuBarDisplayText.paceText(pace: weeklyPace) - - // Pace should produce a value (300-minute window) - #expect(paceText != nil) - } - - @Test - func menuBarDisplayTextPassesPaceThrough() { - let now = Date(timeIntervalSince1970: 0) + func `menu bar display text falls back to percent when pace unavailable`() { let percentWindow = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - let paceWindow = RateWindow( - usedPercent: 30, - windowMinutes: 300, - resetsAt: now.addingTimeInterval(2 * 3600), - resetDescription: nil) - - let pace = UsagePace.weekly(window: paceWindow, now: now, defaultWindowMinutes: 300) - let bothResult = MenuBarDisplayText.displayText( - mode: .both, - percentWindow: percentWindow, - pace: pace, - showUsed: true) - - // With pace available, both mode should show percent and pace - #expect(bothResult != nil) - #expect(bothResult?.contains("%") == true) - } - - @Test - func menuBarDisplayTextHidesWhenPaceUnavailable() { - let now = Date(timeIntervalSince1970: 0) - let percentWindow = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - let paceWindow = RateWindow( - usedPercent: 30, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(60 * 60 * 24 * 6), - resetDescription: nil) let pace = MenuBarDisplayText.displayText( mode: .pace, @@ -451,46 +908,13 @@ struct StatusItemAnimationTests { showUsed: true) #expect(pace == nil) - #expect(both == nil) + // "Both" mode falls back to percent-only when pace is unavailable + #expect(both == "40%") } @Test - func timeWindowSettingsRoundTrip() { - // Clean slate for defaults check - UserDefaults.standard.removeObject(forKey: "menuBarPercentTimeWindow") - UserDefaults.standard.removeObject(forKey: "menuBarPaceTimeWindow") - - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "StatusItemAnimationTests-timewindow"), - zaiTokenStore: NoopZaiTokenStore()) - - // Defaults - #expect(settings.menuBarPercentTimeWindow == .session) - #expect(settings.menuBarPaceTimeWindow == .weekly) - - // Set to non-default values - settings.menuBarPercentTimeWindow = .weekly - settings.menuBarPaceTimeWindow = .session - - #expect(settings.menuBarPercentTimeWindow == .weekly) - #expect(settings.menuBarPaceTimeWindow == .session) - - // Verify persisted in UserDefaults - let percentRaw = settings.userDefaults.string(forKey: "menuBarPercentTimeWindow") - let paceRaw = settings.userDefaults.string(forKey: "menuBarPaceTimeWindow") - #expect(percentRaw == "weekly") - #expect(paceRaw == "session") - } - - @Test - func menuBarDisplayTextRequiresProvidedPaceForCodex() { - let now = Date(timeIntervalSince1970: 0) + func `menu bar display text falls back to percent when pace nil for codex`() { let percentWindow = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - let paceWindow = RateWindow( - usedPercent: 30, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(60 * 60 * 24 * 6), - resetDescription: nil) let pace = MenuBarDisplayText.displayText( mode: .pace, @@ -504,11 +928,12 @@ struct StatusItemAnimationTests { showUsed: true) #expect(pace == nil) - #expect(both == nil) + // "Both" mode falls back to percent-only when pace is unavailable + #expect(both == "40%") } @Test - func menuBarDisplayTextUsesCreditsWhenCodexWeeklyIsExhausted() { + func `menu bar display text uses credits when codex weekly is exhausted`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-fallback"), zaiTokenStore: NoopZaiTokenStore()) @@ -554,7 +979,7 @@ struct StatusItemAnimationTests { } @Test - func menuBarDisplayTextUsesCreditsWhenCodexSessionIsExhausted() { + func `menu bar display text uses credits when codex session is exhausted`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-credits-fallback-session"), zaiTokenStore: NoopZaiTokenStore()) @@ -600,7 +1025,7 @@ struct StatusItemAnimationTests { } @Test - func menuBarDisplayTextShowsZeroPercentForKiloZeroTotalEdge() { + func `menu bar display text shows zero percent for kilo zero total edge`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusItemAnimationTests-kilo-zero-edge"), zaiTokenStore: NoopZaiTokenStore(), @@ -646,7 +1071,7 @@ struct StatusItemAnimationTests { } @Test - func brandImageWithStatusOverlayReturnsOriginalImageWhenNoIssue() { + func `brand image with status overlay returns original image when no issue`() { let brand = NSImage(size: NSSize(width: 16, height: 16)) brand.isTemplate = true @@ -656,13 +1081,13 @@ struct StatusItemAnimationTests { } @Test - func brandImageWithStatusOverlayDrawsIssueMark() throws { + func `brand image with status overlay draws issue mark`() throws { let size = NSSize(width: 16, height: 16) - let brand = NSImage(size: size, flipped: false) { rect in - NSColor.clear.setFill() - NSBezierPath(rect: rect).fill() - return true - } + let brand = NSImage(size: size) + brand.lockFocus() + NSColor.clear.setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() + brand.unlockFocus() brand.isTemplate = true let baselineData = try #require(brand.tiffRepresentation) diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift new file mode 100644 index 000000000..1f88a7230 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -0,0 +1,443 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusItemBalanceDisplayTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + @Test + func `menu bar display text uses open router balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-openrouter-balance", + provider: .openrouter) + settings.setMenuBarMetricPreference(.automatic, for: .openrouter) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.openRouterSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + let displayText = controller.menuBarDisplayText(for: .openrouter, snapshot: snapshot) + + #expect(displayText == "$12.34") + } + + @Test + func `menu bar display text respects open router primary metric preference`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-openrouter-primary-metric", + provider: .openrouter) + settings.setMenuBarMetricPreference(.primary, for: .openrouter) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.openRouterSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .openrouter) + store._setErrorForTesting(nil, provider: .openrouter) + + let displayText = controller.menuBarDisplayText(for: .openrouter, snapshot: snapshot) + + #expect(displayText == "25%") + } + + @Test + func `menu bar display text uses deepseek balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-deepseek-balance", + provider: .deepseek) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "$9.32 (Paid: $9.32 / Granted: $0.00)"), + secondary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .deepseek) + store._setErrorForTesting(nil, provider: .deepseek) + + let displayText = controller.menuBarDisplayText(for: .deepseek, snapshot: snapshot) + + #expect(displayText == "$9.32") + } + + @Test + func `menu bar display text uses moonshot balance`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-moonshot-balance", + provider: .moonshot) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .moonshot, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Balance: $49.58 · $0.42 in deficit")) + + store._setSnapshotForTesting(snapshot, provider: .moonshot) + store._setErrorForTesting(nil, provider: .moonshot) + + let displayText = controller.menuBarDisplayText(for: .moonshot, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(displayText == "$49.58") + } + + @Test + func `menu bar display text uses mistral current month api spend`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-mistral-spend", + provider: .mistral) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = MistralUsageSnapshot( + totalCost: 1.2345, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 10000, + totalOutputTokens: 5000, + totalCachedTokens: 0, + modelCount: 2, + startDate: nil, + endDate: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .mistral) + store._setErrorForTesting(nil, provider: .mistral) + + let displayText = controller.menuBarDisplayText(for: .mistral, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(snapshot.identity?.loginMethod == "API spend: €1.2345 this month") + #expect(displayText == "€1.2345") + } + + @Test + func `menu bar display text uses kimi k2 api key credits`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kimik2-credits", + provider: .kimik2) + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = KimiK2UsageSummary( + consumed: 75, + remaining: 1234.5, + averageTokens: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kimik2) + store._setErrorForTesting(nil, provider: .kimik2) + + let displayText = controller.menuBarDisplayText(for: .kimik2, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(snapshot.identity?.loginMethod == "Credits: 1234.5 left") + #expect(displayText == "1234.5") + } + + @Test + func `kiro menu bar automatic uses credits left`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-automatic", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .automatic + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83") + } + + @Test + func `kiro menu bar credits and percent combines values`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-both", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .creditsAndPercent + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83 · 0%") + } + + @Test + func `kiro menu bar hidden suppresses text value`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-hidden", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .hidden + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == nil) + } + + @Test + func `kiro menu bar used and total formats credits`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-used-total", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .usedAndTotal + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "0.17 / 50") + } + + @Test + func `kiro menu bar overage credits mode shows overage credits when exhausted`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-credits", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.exhaustedKiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "40.29 over") + } + + @Test + func `kiro menu bar overage cost mode shows cost when exhausted`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-cost", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.exhaustedKiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "$1.61 over") + } + + @Test + func `kiro menu bar overage credits and cost mode shows both when exhausted`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-both", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.exhaustedKiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "40.29 · $1.61") + } + + @Test + func `kiro menu bar overage mode keeps credits left before exhaustion`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-not-exhausted", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.kiroSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "49.83") + } + + @Test + func `kiro menu bar overage mode ignores disabled overage values`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-overage-disabled", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .overageCreditsAndCostWhenExhausted + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = Self.exhaustedKiroSnapshot(overagesStatus: "Disabled") + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "0") + } + + @Test + func `kiro managed plan display falls back to percent`() { + let settings = self.makeSettings( + suiteName: "StatusItemBalanceDisplayTests-kiro-managed", + provider: .kiro) + settings.kiroMenuBarDisplayMode = .automatic + settings.usageBarsShowUsed = false + let (store, controller) = self.makeStoreAndController(settings: settings) + let snapshot = KiroUsageSnapshot( + planName: "Q Developer Pro", + creditsUsed: 0, + creditsTotal: 0, + creditsPercent: 0, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + resetsAt: nil, + updatedAt: Date()).toUsageSnapshot() + + store._setSnapshotForTesting(snapshot, provider: .kiro) + store._setErrorForTesting(nil, provider: .kiro) + + let displayText = controller.menuBarDisplayText(for: .kiro, snapshot: snapshot) + + #expect(displayText == "100%") + } + + @Test + func `mistral primary window is nil even when billing end date is set`() { + let endDate = Date(timeIntervalSinceNow: 3600) + let snapshot = MistralUsageSnapshot( + totalCost: 0.5, + currency: "USD", + currencySymbol: "$", + totalInputTokens: 1000, + totalOutputTokens: 500, + totalCachedTokens: 0, + modelCount: 1, + startDate: nil, + endDate: endDate, + updatedAt: Date()).toUsageSnapshot() + + // Mistral doesn't expose a reset time — primary is always nil. + #expect(snapshot.primary == nil) + } + + @Test + func `button title spacing only applies when image is present`() { + #expect(StatusItemController.buttonTitle("42%", hasImage: true) == " 42%") + #expect(StatusItemController.buttonTitle("42%", hasImage: false) == "42%") + #expect(StatusItemController.buttonTitle(nil, hasImage: true).isEmpty) + #expect(StatusItemController.buttonTitle("", hasImage: true).isEmpty) + } + + private func makeSettings(suiteName: String, provider: UsageProvider) -> SettingsStore { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = provider + settings.menuBarDisplayMode = .both + settings.usageBarsShowUsed = true + + let registry = ProviderRegistry.shared + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: true) + } + return settings + } + + private func makeStoreAndController(settings: SettingsStore) -> (UsageStore, StatusItemController) { + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + return (store, controller) + } + + private static func openRouterSnapshot() -> UsageSnapshot { + OpenRouterUsageSnapshot( + totalCredits: 50, + totalUsage: 37.66, + balance: 12.34, + usedPercent: 75.32, + keyLimit: 20, + keyUsage: 5, + rateLimit: nil, + updatedAt: Date()).toUsageSnapshot() + } + + private static func kiroSnapshot() -> UsageSnapshot { + KiroUsageSnapshot( + planName: "KIRO FREE", + accountEmail: "person@example.com", + authMethod: "Google", + creditsUsed: 0.17, + creditsTotal: 50, + creditsPercent: 0, + bonusCreditsUsed: 45.53, + bonusCreditsTotal: 2000, + bonusExpiryDays: 19, + overagesStatus: "Disabled", + manageURL: "https://app.kiro.dev/account/usage", + contextUsage: KiroContextUsageSnapshot( + totalPercentUsed: 1.3, + contextFilesPercent: 0.5, + toolsPercent: 0.8, + kiroResponsesPercent: 0, + promptsPercent: 0), + resetsAt: Date(), + updatedAt: Date()).toUsageSnapshot() + } + + private static func exhaustedKiroSnapshot(overagesStatus: String = "Enabled billed at $0.04 per request") + -> UsageSnapshot + { + KiroUsageSnapshot( + planName: "KIRO FREE", + accountEmail: "person@example.com", + authMethod: "Google", + creditsUsed: 50, + creditsTotal: 50, + creditsPercent: 100, + bonusCreditsUsed: nil, + bonusCreditsTotal: nil, + bonusExpiryDays: nil, + overagesStatus: overagesStatus, + overageCreditsUsed: 40.29, + estimatedOverageCostUSD: 1.61, + manageURL: "https://app.kiro.dev/account/usage", + resetsAt: Date(), + updatedAt: Date()).toUsageSnapshot() + } +} diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 976a0d965..9e3f2d3de 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -1,30 +1,66 @@ +import AppKit import CodexBarCore import Foundation import Testing @testable import CodexBar -@Suite struct StatusItemControllerMenuTests { - private func makeSnapshot(primary: RateWindow?, secondary: RateWindow?) -> UsageSnapshot { - UsageSnapshot(primary: primary, secondary: secondary, updatedAt: Date()) + @MainActor + private final class RecordingUpdater: UpdaterProviding { + var automaticallyChecksForUpdates = false + var automaticallyDownloadsUpdates = false + let isAvailable = true + let unavailableReason: String? = nil + let updateStatus = UpdateStatus(isUpdateReady: true) + var checkForUpdatesCount = 0 + var installUpdateCount = 0 + + func checkForUpdates(_ sender: Any?) { + _ = sender + self.checkForUpdatesCount += 1 + } + + func installUpdate() { + self.installUpdateCount += 1 + } + } + + private func makeSnapshot( + primary: RateWindow?, + secondary: RateWindow?, + tertiary: RateWindow? = nil, + providerCost: ProviderCostSnapshot? = nil) + -> UsageSnapshot + { + UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + providerCost: providerCost, + updatedAt: Date()) } @Test - func cursorSwitcherFallsBackToSecondaryWhenPlanExhaustedAndShowingRemaining() { + func `cursor switcher falls back to on demand budget when plan exhausted and showing remaining`() { let primary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = RateWindow(usedPercent: 36, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - let snapshot = self.makeSnapshot(primary: primary, secondary: secondary) + let providerCost = ProviderCostSnapshot( + used: 12, + limit: 200, + currencyCode: "USD", + updatedAt: Date()) + let snapshot = self.makeSnapshot(primary: primary, secondary: secondary, providerCost: providerCost) let percent = StatusItemController.switcherWeeklyMetricPercent( for: .cursor, snapshot: snapshot, showUsed: false) - #expect(percent == 64) + #expect(percent == 94) } @Test - func cursorSwitcherUsesPrimaryWhenShowingUsed() { + func `cursor switcher uses primary when showing used`() { let primary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = RateWindow(usedPercent: 36, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let snapshot = self.makeSnapshot(primary: primary, secondary: secondary) @@ -38,7 +74,7 @@ struct StatusItemControllerMenuTests { } @Test - func cursorSwitcherKeepsPrimaryWhenRemainingIsPositive() { + func `cursor switcher keeps primary when remaining is positive`() { let primary = RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let secondary = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let snapshot = self.makeSnapshot(primary: primary, secondary: secondary) @@ -52,57 +88,77 @@ struct StatusItemControllerMenuTests { } @Test - func openRouterBrandFallbackEnabledWhenNoKeyLimitConfigured() { - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45, - balance: 5, - usedPercent: 90, - keyDataFetched: true, - keyLimit: nil, - keyUsage: nil, - rateLimit: nil, - updatedAt: Date()).toUsageSnapshot() - - #expect(StatusItemController.shouldUseOpenRouterBrandFallback( - provider: .openrouter, - snapshot: snapshot)) - #expect(MenuBarDisplayText.percentText(window: snapshot.primary, showUsed: false) == nil) + func `cursor switcher does not treat auto lane as extra remaining quota`() { + let primary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let secondary = RateWindow(usedPercent: 36, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let snapshot = self.makeSnapshot(primary: primary, secondary: secondary) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .cursor, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 0) + } + + @Test + func `perplexity switcher falls back after recurring credits are exhausted`() { + let primary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let secondary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let tertiary = RateWindow(usedPercent: 24, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + let snapshot = self.makeSnapshot(primary: primary, secondary: secondary, tertiary: tertiary) + + let percent = StatusItemController.switcherWeeklyMetricPercent( + for: .perplexity, + snapshot: snapshot, + showUsed: false) + + #expect(percent == 76) } @Test - func openRouterBrandFallbackDisabledWhenKeyQuotaFetchUnavailable() { - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45, - balance: 5, - usedPercent: 90, - keyDataFetched: false, - keyLimit: nil, - keyUsage: nil, - rateLimit: nil, - updatedAt: Date()).toUsageSnapshot() - - #expect(!StatusItemController.shouldUseOpenRouterBrandFallback( - provider: .openrouter, - snapshot: snapshot)) + @MainActor + func `menu card width stays at base width when menu accessories are present`() { + let shortcutMenu = NSMenu() + let refreshItem = NSMenuItem(title: "Refresh", action: nil, keyEquivalent: "r") + shortcutMenu.addItem(refreshItem) + #expect(ceil(shortcutMenu.size.width) < 310) + + let submenuMenu = NSMenu() + let parentItem = NSMenuItem(title: "Session", action: nil, keyEquivalent: "") + parentItem.submenu = NSMenu(title: "Session") + submenuMenu.addItem(parentItem) + #expect(ceil(submenuMenu.size.width) < 310) } @Test - func openRouterBrandFallbackDisabledWhenKeyQuotaAvailable() { - let snapshot = OpenRouterUsageSnapshot( - totalCredits: 50, - totalUsage: 45, - balance: 5, - usedPercent: 90, - keyLimit: 20, - keyUsage: 2, - rateLimit: nil, - updatedAt: Date()).toUsageSnapshot() - - #expect(!StatusItemController.shouldUseOpenRouterBrandFallback( - provider: .openrouter, - snapshot: snapshot)) - #expect(snapshot.primary?.usedPercent == 10) + @MainActor + func `update menu action installs prepared update instead of checking again`() throws { + let suite = "StatusItemControllerMenuTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let updater = RecordingUpdater() + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: updater, + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + controller.installUpdate() + + #expect(updater.installUpdateCount == 1) + #expect(updater.checkForUpdatesCount == 0) } } diff --git a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift new file mode 100644 index 000000000..73a427366 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift @@ -0,0 +1,71 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemControllerShutdownTests { + @Test + func `app shutdown closes tracked menus and removes status items`() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = !SettingsStore.isRunningTests + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + if let codexMetadata = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + if let claudeMetadata = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.menuRefreshTasks[key] = Task { try? await Task.sleep(for: .seconds(30)) } + + #expect(controller.openMenus[key] === menu) + #expect(controller.statusItem.menu != nil) + + controller.prepareForAppShutdown() + controller.prepareForAppShutdown() + + #expect(controller.hasPreparedForAppShutdown) + #expect(controller.openMenus.isEmpty) + #expect(controller.menuRefreshTasks.isEmpty) + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + #expect(controller.providerMenus.isEmpty) + #expect(controller.mergedMenu == nil) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusItemControllerShutdownTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift new file mode 100644 index 000000000..86d247d9d --- /dev/null +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -0,0 +1,238 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemControllerSplitLifecycleTests { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeStatusBarForTesting() -> NSStatusBar { + .system + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusItemControllerSplitLifecycleTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func containsHostingView(_ view: NSView) -> Bool { + if String(describing: type(of: view)).contains("NSHostingView") { + return true + } + return view.subviews.contains { self.containsHostingView($0) } + } + + private func makeSplitController() throws -> (SettingsStore, StatusItemController) { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.providerDetectionCompleted = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: false) + } + } + try settings.setProviderEnabled(provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(registry.metadata[.claude]), + enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + return (settings, controller) + } + + @Test + func `merged mode removes split provider status items`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.statusItems[.codex] != nil) + #expect(controller.statusItems[.claude] != nil) + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + + #expect(controller.statusItem.isVisible == true) + #expect(controller.statusItems.isEmpty) + } + + @Test + func `menu bar icons stay appkit hosted`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let codexButton = try #require(controller.statusItems[.codex]?.button) + #expect(codexButton.image != nil) + #expect(!self.containsHostingView(codexButton)) + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + + let mergedButton = try #require(controller.statusItem.button) + #expect(mergedButton.image != nil) + #expect(!self.containsHostingView(mergedButton)) + } + + @Test + func `status items publish stable non persistent manager identity`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let codexButton = try #require(controller.statusItems[.codex]?.button) + let claudeButton = try #require(controller.statusItems[.claude]?.button) + + #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) + #expect(controller.statusItems[.codex]?.autosaveName.hasPrefix("CodexBar.") == false) + #expect(controller.statusItems[.claude]?.autosaveName.hasPrefix("CodexBar.") == false) + #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") + #expect(codexButton.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + #expect(claudeButton.accessibilityIdentifier() == "CodexBar.StatusItem.claude") + #expect(controller.statusItem.button?.accessibilityTitle() == "CodexBar") + #expect(codexButton.accessibilityTitle() == "CodexBar") + #expect(claudeButton.accessibilityTitle() == "CodexBar") + } + + @Test + func `status item defaults repair removes stale hidden Control Center keys once`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-repair-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "NSStatusItem VisibleCC Item-0") + defaults.set(0, forKey: "NSStatusItem VisibleCC Item-12") + defaults.set(false, forKey: "NSStatusItem VisibleCC codexbar-merged") + defaults.set(true, forKey: "NSStatusItem VisibleCC Item-1") + defaults.set(false, forKey: "NSStatusItem VisibleCC com.apple.clock") + defer { + defaults.removePersistentDomain(forName: suite) + } + + let repairedKeys = MenuBarStatusItemDefaultsRepair.repairHiddenVisibilityDefaultsIfNeeded(defaults: defaults) + + #expect(repairedKeys == [ + "NSStatusItem VisibleCC Item-0", + "NSStatusItem VisibleCC Item-12", + "NSStatusItem VisibleCC codexbar-merged", + ]) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-0") == nil) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-12") == nil) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC codexbar-merged") == nil) + #expect(defaults.bool(forKey: "NSStatusItem VisibleCC Item-1")) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC com.apple.clock") != nil) + + defaults.set(false, forKey: "NSStatusItem VisibleCC Item-2") + #expect(MenuBarStatusItemDefaultsRepair.repairHiddenVisibilityDefaultsIfNeeded(defaults: defaults).isEmpty) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-2") != nil) + } + + @Test + func `non destructive visibility refresh preserves split provider status items`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let oldCodexItem = try #require(controller.statusItems[.codex]) + let oldClaudeItem = try #require(controller.statusItems[.claude]) + let oldCodexButton = try #require(oldCodexItem.button) + + controller.refreshExistingStatusItemsForVisibilityRecovery() + + let newCodexItem = try #require(controller.statusItems[.codex]) + let newClaudeItem = try #require(controller.statusItems[.claude]) + #expect(newCodexItem === oldCodexItem) + #expect(newClaudeItem === oldClaudeItem) + #expect(newCodexItem.button === oldCodexButton) + #expect(!newCodexItem.autosaveName.hasPrefix("CodexBar.")) + #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + } + + @Test + func `non destructive visibility refresh preserves merged status item`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + let oldMergedItem = controller.statusItem + let oldMergedButton = try #require(controller.statusItem.button) + + controller.refreshExistingStatusItemsForVisibilityRecovery() + + #expect(controller.statusItem === oldMergedItem) + #expect(controller.statusItem.button === oldMergedButton) + #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) + #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") + } + + @Test + func `recreation produces immediately healthy snapshots for synchronous guidance check`() throws { + // verifyScreenChangeRecoveryIfNeeded does a synchronous re-check immediately after + // the single recreation to decide whether to show macOS 26 Allow-in-Menu-Bar guidance. + // AppKit must materialise the button and window before returning from + // recreateStatusItemsForVisibilityRecovery, so the item must not appear blocked at + // that point. Only a genuine system-level block would leave it blocked — which is + // exactly the case where guidance is useful. + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + controller.recreateStatusItemsForVisibilityRecovery() + + let allItems = [controller.statusItem] + Array(controller.statusItems.values) + let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(allItems) + #expect(!MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(snapshots)) + } + + @Test + func `visibility recovery recreates split provider status items`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let oldCodexItem = try #require(controller.statusItems[.codex]) + controller.recreateStatusItemsForVisibilityRecovery() + + let newCodexItem = try #require(controller.statusItems[.codex]) + #expect(newCodexItem !== oldCodexItem) + #expect(!newCodexItem.autosaveName.hasPrefix("CodexBar.")) + #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + } + + @Test + func `visibility recovery renders replacement merged status item`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + let renderedSignature = try #require(controller.lastAppliedMergedIconRenderSignature) + + controller.lastAppliedMergedIconRenderSignature = renderedSignature + controller.recreateStatusItemsForVisibilityRecovery() + + let mergedButton = try #require(controller.statusItem.button) + #expect(mergedButton.image != nil) + #expect(!controller.statusItem.autosaveName.hasPrefix("CodexBar.")) + #expect(mergedButton.accessibilityIdentifier() == "CodexBar.StatusItem") + } +} diff --git a/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift new file mode 100644 index 000000000..3ef14df71 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift @@ -0,0 +1,160 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusItemExtraUsageMetricTests { + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + @Test + func `menu bar extra usage preference uses cursor on demand budget`() { + let (store, controller) = self.makeCursorController(suiteName: "StatusItemExtraUsageMetricTests-budget") + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 15, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 15) + } + + @Test + func `menu bar extra usage preference falls back to automatic when cursor on demand budget is missing`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-missing-budget", + provider: .cursor) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let window = controller.menuBarMetricWindow(for: .cursor, snapshot: snapshot) + + #expect(window?.usedPercent == 72) + } + + @Test + func `menu bar extra usage preference shows currency spend text for cursor when provider cost exists`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-cursor-spend-text", + provider: .cursor) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "$12.34") + } + + @Test + func `menu bar extra usage preference shows currency spend text for claude when provider cost exists`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-claude-spend-text", + provider: .claude) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 88.8, + limit: 200, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + #expect(displayText == "$88.80") + } + + @Test + func `menu bar extra usage preference falls back to existing percent text when provider cost is unavailable`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-fallback-percent", + provider: .cursor) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "72%") + } + + private func makeCursorController(suiteName: String) -> (UsageStore, StatusItemController) { + self.makeController(suiteName: suiteName, provider: .cursor) + } + + private func makeController(suiteName: String, provider: UsageProvider) -> (UsageStore, StatusItemController) { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = provider + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.extraUsage, for: provider) + + let registry = ProviderRegistry.shared + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + return (store, controller) + } +} diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift new file mode 100644 index 000000000..6660c264e --- /dev/null +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -0,0 +1,89 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemIconObservationSignatureTests { + private func makeController(suiteName: String) -> (SettingsStore, UsageStore, StatusItemController) { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = true + settings.refreshFrequency = .manual + settings.menuBarShowsBrandIconWithPercent = false + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(Self.makeSnapshot(provider: .codex, email: "icon@example.com"), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + return (settings, store, controller) + } + + @Test + func `store icon observation signature ignores refresh and status metadata churn`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-refresh-metadata") + defer { controller.releaseStatusItemsForTesting() } + + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "initial", + updatedAt: Date(timeIntervalSince1970: 10)) + let baseline = controller.storeIconObservationSignature() + + store.isRefreshing = true + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "same indicator, newer timestamp", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() == baseline) + } + + @Test + func `store icon observation signature changes when status indicator changes`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-status-indicator") + defer { controller.releaseStatusItemsForTesting() } + + store.statuses[.codex] = ProviderStatus( + indicator: .none, + description: "initial", + updatedAt: Date(timeIntervalSince1970: 10)) + let baseline = controller.storeIconObservationSignature() + + store.statuses[.codex] = ProviderStatus( + indicator: .major, + description: "major outage", + updatedAt: Date(timeIntervalSince1970: 20)) + + #expect(controller.storeIconObservationSignature() != baseline) + } + + private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 100), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "plus")) + } +} diff --git a/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift b/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift new file mode 100644 index 000000000..58d126e37 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemPurchaseURLTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import CodexBar + +struct StatusItemPurchaseURLTests { + @Test + @MainActor + func `purchase URL accepts ChatGPT hosts`() { + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://chatgpt.com/settings/billing") + == "https://chatgpt.com/settings/billing") + #expect( + StatusItemController + .sanitizedCreditsPurchaseURL("https://chatgpt.com/usage/credits?token=secret#fragment") + == "https://chatgpt.com/usage/credits") + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://team.chatgpt.com/settings/billing") + == "https://team.chatgpt.com/settings/billing") + } + + @Test + @MainActor + func `purchase URL rejects lookalike hosts`() { + #expect( + StatusItemController + .sanitizedCreditsPurchaseURL("https://chatgpt.com.evil.example/settings/billing") == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://evil-chatgpt.com/settings/billing") + == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://notchatgpt.com/settings/billing") + == nil) + } + + @Test + @MainActor + func `purchase URL rejects non HTTPS and unrelated paths`() { + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("http://chatgpt.com/settings/billing") + == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://chatgpt.com/backend-api/accounts") + == nil) + #expect( + StatusItemController.sanitizedCreditsPurchaseURL("https://chatgpt.com/backend-api/settings-token") + == nil) + #expect(StatusItemController.sanitizedCreditsPurchaseURL("not a url") == nil) + } +} diff --git a/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift b/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift new file mode 100644 index 000000000..abf10e6d4 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemQuotaWarningFlashTests.swift @@ -0,0 +1,98 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemQuotaWarningFlashTests { + private func makeStatusBarForTesting() -> NSStatusBar { + NSStatusBar.system + } + + @Test + func `quota warning flash state lasts for configured duration`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemQuotaWarningFlashTests-duration"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let now = Date() + controller.startQuotaWarningFlash(provider: .codex, postedAt: now) + + #expect(controller.quotaWarningFlashActive(provider: .codex, now: now.addingTimeInterval(59)) == true) + #expect(controller.quotaWarningFlashActive(provider: .codex, now: now.addingTimeInterval(61)) == false) + } + + @Test + func `quota warning flash image draws non template red overlay`() throws { + let size = NSSize(width: 16, height: 16) + let base = NSImage(size: size) + base.lockFocus() + NSColor.black.setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() + base.unlockFocus() + base.isTemplate = true + + let output = StatusItemController.quotaWarningFlashImage(base: base) + let outputData = try #require(output.tiffRepresentation) + let outputRep = try #require(NSBitmapImageRep(data: outputData)) + let center = try #require(outputRep.colorAt(x: 8, y: 8)) + + #expect(output.isTemplate == false) + #expect(center.redComponent > center.blueComponent) + } + + @Test + func `merged icon render signature includes quota warning flash for selected provider`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "StatusItemQuotaWarningFlashTests-merged"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let openRouterMeta = registry.metadata[.openrouter] { + settings.setProviderEnabled(provider: .openrouter, metadata: openRouterMeta, enabled: true) + } + settings.openRouterAPIToken = "or-token" + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + controller.startQuotaWarningFlash(provider: .codex) + + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("warningFlash=1") == true) + } +} diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift new file mode 100644 index 000000000..cf65a2102 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherPresentationTests.swift @@ -0,0 +1,279 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusMenuCodexSwitcherPresentationTests { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuCodexSwitcherPresentationTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeManagedAccountStoreURL(accounts: [ManagedCodexAccount]) throws -> URL { + let storeURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = FileManagedCodexAccountStore(fileURL: storeURL) + try store.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: accounts)) + return storeURL + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + private func snapshot(email: String, percent: Double = 12) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(300), + resetDescription: nil), + secondary: RateWindow( + usedPercent: percent, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Plus")) + } + + @Test + func `codex account ordering keeps workspace groups contiguous`() { + let teamActive = CodexVisibleAccount( + id: "team-a-active", + email: "active@example.com", + workspaceLabel: "Team A", + workspaceAccountID: "team-a", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: true, + isLive: false, + canReauthenticate: true, + canRemove: true) + let teamHighQuota = CodexVisibleAccount( + id: "team-b-high-quota", + email: "high@example.com", + workspaceLabel: "Team B", + workspaceAccountID: "team-b", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let teamSibling = CodexVisibleAccount( + id: "team-a-sibling", + email: "sibling@example.com", + workspaceLabel: "Team A", + workspaceAccountID: "team-a", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true) + let accounts = [teamActive, teamHighQuota, teamSibling] + let snapshots = [ + CodexAccountUsageSnapshot( + account: teamActive, + snapshot: self.snapshot(email: teamActive.email, percent: 95), + error: nil, + sourceLabel: "test"), + CodexAccountUsageSnapshot( + account: teamHighQuota, + snapshot: self.snapshot(email: teamHighQuota.email, percent: 10), + error: nil, + sourceLabel: "test"), + CodexAccountUsageSnapshot( + account: teamSibling, + snapshot: self.snapshot(email: teamSibling.email, percent: 20), + error: nil, + sourceLabel: "test"), + ] + + let ordered = CodexAccountPresentationOrdering.orderedAccounts( + accounts, + snapshots: snapshots, + activeVisibleAccountID: teamActive.id) + + #expect(ordered.map(\.id) == ["team-a-active", "team-a-sibling", "team-b-high-quota"]) + #expect(ordered.codexWorkspaceSections().map(\.title) == ["Team A", "Team B"]) + #expect(ordered.codexWorkspaceSections().first?.accounts.map(\.id) == ["team-a-active", "team-a-sibling"]) + } + + @Test + func `codex stacked menu orders by quota and groups workspaces`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let lowID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let highID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let low = ManagedCodexAccount( + id: lowID, + email: "low@example.com", + workspaceLabel: "Team Low", + workspaceAccountID: "team-low", + managedHomePath: "/tmp/low-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let high = ManagedCodexAccount( + id: highID, + email: "high@example.com", + workspaceLabel: "Team High", + workspaceAccountID: "team-high", + managedHomePath: "/tmp/high-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [low, high]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "active@example.com", + workspaceLabel: "Personal", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.codexAccountSnapshots = settings.codexVisibleAccountProjection.visibleAccounts.map { account in + let usedPercent = switch account.email { + case "high@example.com": + 10.0 + case "low@example.com": + 80.0 + default: + 95.0 + } + return CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(email: account.email, percent: usedPercent), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let display = try #require(controller.codexAccountMenuDisplay(for: .codex)) + #expect(display.accounts.map(\.email) == ["active@example.com", "high@example.com", "low@example.com"]) + #expect(display.workspaceSections.map(\.title) == ["Personal", "Team High", "Team Low"]) + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + #expect(self.representedIDs(in: menu).count(where: { $0.hasPrefix("codexWorkspace-") }) == 3) + } + + @Test + func `codex stacked menu surfaces account health labels`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let visibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.email == "managed@example.com" }) + + #expect(CodexAccountHealth.status(for: visibleAccount, error: "401 Unauthorized") + .label == "Needs re-auth") + } + + @Test + func `codex account snapshot store hydrates current visible accounts`() { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: fileURL) } + + let account = CodexVisibleAccount( + id: "active@example.com", + email: "active@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false) + let store = FileCodexAccountUsageSnapshotStore(fileURL: fileURL) + store.store([ + CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(email: account.email, percent: 17), + error: nil, + sourceLabel: "test"), + ]) + + let hydrated = store.load(for: [account]) + + #expect(hydrated.map(\.id) == [account.id]) + #expect(hydrated.first?.snapshot?.primary?.usedPercent == 17) + #expect(hydrated.first?.account.email == account.email) + } +} diff --git a/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift new file mode 100644 index 000000000..4ad6ce803 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCodexSwitcherTests.swift @@ -0,0 +1,1320 @@ +import AppKit +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite(.serialized) +@MainActor +struct StatusMenuCodexSwitcherTests { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuCodexSwitcherTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeStatusBarForTesting() -> NSStatusBar { + .system + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func makeManagedAccountStoreURL(accounts: [ManagedCodexAccount]) throws -> URL { + let storeURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = FileManagedCodexAccountStore(fileURL: storeURL) + try store.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: accounts)) + return storeURL + } + + private func actionLabels(in descriptor: MenuDescriptor) -> [String] { + descriptor.sections.flatMap(\.entries).compactMap { entry in + guard case let .action(label, _) = entry else { return nil } + return label + } + } + + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + private func snapshot(email: String, percent: Double = 12) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(300), + resetDescription: nil), + secondary: RateWindow( + usedPercent: percent, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Plus")) + } + + private func selectCodexVisibleAccountForStatusMenu( + id: String, + settings: SettingsStore, + store: UsageStore) -> Task? + { + guard settings.selectCodexVisibleAccount(id: id) else { return nil } + _ = store.prepareCodexAccountScopedRefreshIfNeeded() + return Task { @MainActor in + await store.refreshCodexAccountScopedState(allowDisabled: true) + } + } + + private func installBlockingCodexProvider(on store: UsageStore, blocker: BlockingStatusMenuCodexFetchStrategy) { + let baseSpec = store.providerSpecs[.codex]! + store.providerSpecs[.codex] = Self.makeCodexProviderSpec(baseSpec: baseSpec) { + try await blocker.awaitResult() + } + } + + private static func makeCodexProviderSpec( + baseSpec: ProviderSpec, + loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec + { + let baseDescriptor = baseSpec.descriptor + let strategy = StatusMenuTestCodexFetchStrategy(loader: loader) + let descriptor = ProviderDescriptor( + id: .codex, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli, .oauth], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli) + return ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + } + + @Test + func `codex menu shows account switcher and add account action for multiple visible accounts`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let projection = settings.codexVisibleAccountProjection + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + + #expect(projection.visibleAccounts.map(\.email) == ["live@example.com", "managed@example.com"]) + #expect(projection.activeVisibleAccountID == "live@example.com") + let actionLabels = self.actionLabels(in: descriptor) + #expect(actionLabels.contains("Add Account...")) + #expect(actionLabels.contains("Switch Account...") == false) + } + + @Test + func `codex menu hides account switcher when only one visible account exists`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "solo@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + defer { settings._test_liveSystemCodexAccount = nil } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false) + + #expect(settings.codexVisibleAccountProjection.visibleAccounts.map(\.email) == ["solo@example.com"]) + #expect(self.actionLabels(in: descriptor).contains("Add Account...")) + } + + @Test + func `codex segmented multi account layout shows account switcher`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first != nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard"]) + } + + @Test + func `merged codex menu smart refresh keeps account switcher visible`() throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.multiAccountMenuLayout = .segmented + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 1) + + controller.menuContentVersion &+= 1 + controller.refreshOpenMenusIfNeeded() + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 1) + + settings._test_liveSystemCodexAccount = nil + controller.menuContentVersion &+= 1 + controller.refreshOpenMenusIfNeeded() + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 1) + + controller.menuDidClose(menu) + controller.menuContentVersion &+= 1 + controller.menuWillOpen(menu) + + #expect(menu.items.count(where: { $0.view is CodexAccountSwitcherView }) == 0) + } + + @Test + func `codex menu can select preserved switcher row during transient account projection`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .managedAccount(id: managedAccountID) + + let liveAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.selectionSource == .liveSystem }) + settings._test_liveSystemCodexAccount = nil + + #expect(settings.codexVisibleAccountProjection.visibleAccounts.map(\.email) == ["managed@example.com"]) + settings.selectDisplayedCodexVisibleAccount(liveAccount) + #expect(settings.codexActiveSource == .liveSystem) + } + + @Test + func `codex stacked multi account layout shows account cards`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let projection = settings.codexVisibleAccountProjection + store.codexAccountSnapshots = projection.visibleAccounts.enumerated().map { index, account in + CodexAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(email: account.email, percent: Double(10 + index)), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first == nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard-0", "menuCard-1"]) + } + + @Test + func `codex stacked multi account layout shows account cards before per account snapshots load`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "live@example.com"), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + #expect(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first == nil) + #expect(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") } == ["menuCard-0", "menuCard-1"]) + } + + @Test + func `codex switcher suppresses personal labels while preserving team workspace tooltips`() { + let accounts = [ + CodexVisibleAccount( + id: "live:provider:account-personal", + email: "pl.fr@yandex.com", + workspaceLabel: "Personal", + workspaceAccountID: "account-personal", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email: "pl.fr@yandex.com", + workspaceLabel: "IDconcepts", + workspaceAccountID: "account-team", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { _ in }) + + let titles = view._test_buttonTitles() + let toolTips = view._test_buttonToolTips() + + #expect(titles.count == 2) + #expect(titles[0] != titles[1]) + #expect(titles.allSatisfy { $0.lowercased().contains("pl.") }) + #expect(titles[0].contains("|") == false) + #expect(titles[0].lowercased().contains("pers") == false) + #expect(titles[1].lowercased().contains("id")) + #expect(toolTips == accounts.map(\.menuDisplayName)) + #expect(accounts[0].displayName == "pl.fr@yandex.com — Personal") + #expect(accounts[0].menuDisplayName == "pl.fr@yandex.com") + } + + @Test + func `codex switcher reports fixed menu width for long account labels`() { + let accounts = [ + CodexVisibleAccount( + id: "live:provider:account-personal", + email: "managed-account-with-a-very-long-name@example.com", + workspaceLabel: nil, + workspaceAccountID: "account-managed", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email: "steipete-with-a-very-long-label@gmail.com", + workspaceLabel: nil, + workspaceAccountID: "account-gmail", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 310, + onSelect: { _ in }) + + #expect(view.frame.width == 310) + #expect(view.intrinsicContentSize.width == 310) + #expect(view.fittingSize.width == 310) + } + + @Test + func `codex switcher middle truncates long account emails`() { + let accounts = [ + CodexVisibleAccount( + id: "live:provider:account-personal", + email: "local-person-with-an-extremely-long-name@example.com", + workspaceLabel: nil, + workspaceAccountID: "account-managed", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + email: "second-person-with-an-extremely-long-name@example.com", + workspaceLabel: nil, + workspaceAccountID: "account-gmail", + storedAccountID: UUID(), + selectionSource: .managedAccount(id: UUID()), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { _ in }) + let titles = view._test_buttonTitles() + + #expect(titles.count == 2) + #expect(titles[0].hasPrefix("local")) + #expect(titles[0].contains("…")) + #expect(titles[0].hasSuffix(".com")) + #expect(titles[1].hasPrefix("second")) + #expect(titles[1].contains("…")) + #expect(titles[1].hasSuffix(".com")) + } + + @Test + func `codex account switcher passes the selected displayed account`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + view._test_selectAccount(id: "managed@example.com") + + #expect(selectedAccount == accounts[1]) + } + + @Test + func `codex menu switcher selection activates the visible managed account`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + #expect(settings.selectCodexVisibleAccount(id: "managed@example.com")) + + #expect(settings.codexActiveSource == .managedAccount(id: managedAccountID)) + } + + @Test + func `codex menu switcher clears stale account state on the first click`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = false + settings.codexCookieSource = .off + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) + } + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "live@example.com", + accountOrganization: nil, + loginMethod: "Pro")), + provider: .codex) + store.lastCodexAccountScopedRefreshGuard = store + .currentCodexAccountScopedRefreshGuard(preferCurrentSnapshot: false) + + let blocker = BlockingStatusMenuCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + + let refreshTask = try #require( + self.selectCodexVisibleAccountForStatusMenu( + id: "managed@example.com", + settings: settings, + store: store)) + + await blocker.waitUntilStarted() + #expect(settings.codexActiveSource == .managedAccount(id: managedAccountID)) + #expect(store.snapshots[.codex] == nil) + + await blocker.resume(with: .success( + UsageSnapshot( + primary: RateWindow(usedPercent: 9, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "managed@example.com", + accountOrganization: nil, + loginMethod: "Pro")))) + for _ in 0..<10 where store.snapshots[.codex]?.accountEmail(for: .codex) != "managed@example.com" { + try? await Task.sleep(for: .milliseconds(20)) + } + await refreshTask.value + #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "managed@example.com") + } + + @Test + func `codex account state disables add account while managed authentication is in flight`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + defer { settings._test_liveSystemCodexAccount = nil } + + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let runner = BlockingManagedCodexLoginRunnerForStatusMenuTests() + let service = ManagedCodexAccountService( + store: InMemoryManagedCodexAccountStoreForStatusMenuTests(), + homeFactory: TestManagedCodexHomeFactoryForStatusMenuTests(root: root), + loginRunner: runner, + identityReader: StubManagedCodexIdentityReaderForStatusMenuTests(email: "managed@example.com"), + workspaceResolver: StubManagedCodexWorkspaceResolverForStatusMenuTests()) + let coordinator = ManagedCodexAccountCoordinator(service: service) + let authTask = Task { try await coordinator.authenticateManagedAccount() } + await runner.waitUntilStarted() + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let pane = ProvidersPane( + settings: settings, + store: store, + managedCodexAccountCoordinator: coordinator) + let state = try #require(pane._test_codexAccountsSectionState()) + + #expect(state.canAddAccount == false) + #expect(state.isAuthenticatingManagedAccount) + #expect(state.addAccountTitle == "Adding Account…") + + await runner.resume() + _ = try await authTask.value + } + + @Test + func `codex account state disables add account when managed store is unreadable`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings._test_unreadableManagedCodexAccountStore = true + defer { + settings._test_liveSystemCodexAccount = nil + settings._test_unreadableManagedCodexAccountStore = false + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + let state = try #require(pane._test_codexAccountsSectionState()) + + #expect(state.hasUnreadableManagedAccountStore) + #expect(state.canAddAccount == false) + } + + @Test + func `codex menu switcher can select managed row when same email rows split by identity`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let managedHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "same@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "same@example.com", + plan: "pro", + accountID: "account-managed") + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + try? FileManager.default.removeItem(at: managedHome) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "SAME@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .emailOnly(normalizedEmail: "same@example.com")) + settings.codexActiveSource = .liveSystem + + let projection = settings.codexVisibleAccountProjection + #expect(projection.visibleAccounts.count == 2) + let managedVisibleAccount = try #require(projection.visibleAccounts + .first { $0.storedAccountID == managedAccountID }) + + #expect(settings.selectCodexVisibleAccount(id: managedVisibleAccount.id)) + #expect(settings.codexActiveSource == .managedAccount(id: managedAccountID)) + } +} + +extension StatusMenuCodexSwitcherTests { + @Test + func `codex account switcher swallows child button hit testing for first click`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { _ in }) + + #expect(view.acceptsFirstMouse(for: nil) == true) + #expect(view._test_hitTestSwallowsChildButton(id: "managed@example.com") == true) + #expect(view._test_toolTipAfterHitTest(id: "managed@example.com") == "managed@example.com") + } + + @Test + func `codex account switcher routes runtime click path to selected account`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + #expect(view._test_simulateRuntimeClick(id: "managed@example.com") == true) + #expect(selectedAccount == accounts[1]) + } + + @Test + func `codex account switcher runtime click resolves second row buttons`() throws { + let managedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let secondManagedID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-222222222222")) + let accounts = [ + CodexVisibleAccount( + id: "live@example.com", + email: "live@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: false), + CodexVisibleAccount( + id: "managed@example.com", + email: "managed@example.com", + storedAccountID: managedID, + selectionSource: .managedAccount(id: managedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + CodexVisibleAccount( + id: "team@example.com", + email: "team@example.com", + storedAccountID: secondManagedID, + selectionSource: .managedAccount(id: secondManagedID), + isActive: false, + isLive: false, + canReauthenticate: true, + canRemove: true), + CodexVisibleAccount( + id: "second-row@example.com", + email: "second-row@example.com", + storedAccountID: nil, + selectionSource: .liveSystem, + isActive: false, + isLive: true, + canReauthenticate: true, + canRemove: false), + ] + var selectedAccount: CodexVisibleAccount? + let view = CodexAccountSwitcherView( + accounts: accounts, + selectedAccountID: accounts.first?.id, + width: 220, + onSelect: { selectedAccount = $0 }) + + #expect(view._test_simulateRuntimeClick(id: "second-row@example.com") == true) + #expect(selectedAccount == accounts[3]) + } +} + +@MainActor +extension StatusMenuCodexSwitcherTests { + @Test + func `codex account switch defers open menu rebuild until after switcher action`() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.multiAccountMenuLayout = .segmented + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "live@example.com", percent: 11), provider: .codex) + store.lastCodexAccountScopedRefreshGuard = store.currentCodexAccountScopedRefreshGuard( + preferCurrentSnapshot: false) + let blocker = BlockingStatusMenuCodexFetchStrategy() + self.installBlockingCodexProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try #require(menu.items.compactMap { $0.view as? CodexAccountSwitcherView }.first) + let managedVisibleAccount = try #require(settings.codexVisibleAccountProjection.visibleAccounts + .first { $0.storedAccountID == managedAccountID }) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + switcher._test_selectAccount(id: managedVisibleAccount.id) + + #expect(rebuildCount == 0) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + #expect(rebuildCount == 1) + + await blocker.waitUntilStarted() + await blocker.resume(with: .success(self.snapshot(email: "managed@example.com", percent: 17))) + } + + @Test + func `codex stacked refresh discards selected outcome when visible selection changes mid flight`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyCodex(settings) + + let managedAccountID = try #require(UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-111111111111")) + let managedAccount = ManagedCodexAccount( + id: managedAccountID, + email: "managed@example.com", + managedHomePath: "/tmp/managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 2) + let storeURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) + defer { + settings._test_managedCodexAccountStoreURL = nil + settings._test_liveSystemCodexAccount = nil + try? FileManager.default.removeItem(at: storeURL) + } + + settings._test_managedCodexAccountStoreURL = storeURL + settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date()) + settings.codexActiveSource = .liveSystem + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting(self.snapshot(email: "managed@example.com", percent: 77), provider: .codex) + + let projection = settings.codexVisibleAccountProjection + let originalVisibleAccountID = projection.activeVisibleAccountID + let originalSelectionSource = originalVisibleAccountID.flatMap { + projection.source(forVisibleAccountID: $0) + } + #expect(store.codexVisibleSelectionStillMatches( + originalVisibleAccountID: originalVisibleAccountID, + originalSelectionSource: originalSelectionSource)) + + #expect(settings.selectCodexVisibleAccount(id: "managed@example.com")) + + #expect(settings.codexActiveSource == .managedAccount(id: managedAccountID)) + #expect(store.codexVisibleSelectionStillMatches( + originalVisibleAccountID: originalVisibleAccountID, + originalSelectionSource: originalSelectionSource) == false) + #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "managed@example.com") + #expect(store.snapshots[.codex]?.primary?.usedPercent == 77) + } + + private static func writeCodexAuthFile( + homeURL: URL, + email: String, + plan: String, + accountID: String? = nil) throws + { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + var tokens: [String: Any] = [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan, accountID: accountID), + ] + if let accountID { + tokens["account_id"] = accountID + } + let auth = ["tokens": tokens] + let data = try JSONSerialization.data(withJSONObject: auth) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String, accountID: String? = nil) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + var payloadObject: [String: Any] = [ + "email": email, + "chatgpt_plan_type": plan, + ] + if let accountID { + payloadObject["https://api.openai.com/auth"] = [ + "chatgpt_account_id": accountID, + ] + } + let payload = (try? JSONSerialization.data(withJSONObject: payloadObject)) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } +} + +private struct StatusMenuTestCodexFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable () async throws -> UsageSnapshot + + var id: String { + "status-menu-test-codex" + } + + var kind: ProviderFetchKind { + .cli + } + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.loader() + return self.makeResult(usage: snapshot, sourceLabel: "status-menu-test-codex") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private actor BlockingStatusMenuCodexFetchStrategy { + private var waiters: [CheckedContinuation, Never>] = [] + private var startedWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var startCount = 0 + + func awaitResult() async throws -> UsageSnapshot { + let result = await withCheckedContinuation { continuation in + self.waiters.append(continuation) + self.startCount += 1 + self.resumeStartedWaitersIfReady() + } + return try result.get() + } + + func waitUntilStarted() async { + await self.waitForStartCount(1) + } + + func waitForStartCount(_ count: Int) async { + if self.startCount >= count { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append((count, continuation)) + } + } + + func resume(with result: Result) { + self.waiters.forEach { $0.resume(returning: result) } + self.waiters.removeAll() + } + + private func resumeStartedWaitersIfReady() { + let readyWaiters = self.startedWaiters.filter { self.startCount >= $0.count } + self.startedWaiters.removeAll { self.startCount >= $0.count } + readyWaiters.forEach { $0.continuation.resume() } + } +} + +private actor BlockingManagedCodexLoginRunnerForStatusMenuTests: ManagedCodexLoginRunning { + private var waiters: [CheckedContinuation] = [] + private var startedWaiters: [CheckedContinuation] = [] + private var didStart = false + + func run(homePath _: String, timeout _: TimeInterval) async -> CodexLoginRunner.Result { + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + self.didStart = true + self.startedWaiters.forEach { $0.resume() } + self.startedWaiters.removeAll() + } + } + + func waitUntilStarted() async { + if self.didStart { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append(continuation) + } + } + + func resume() { + let result = CodexLoginRunner.Result(outcome: .success, output: "ok") + self.waiters.forEach { $0.resume(returning: result) } + self.waiters.removeAll() + } +} + +private final class InMemoryManagedCodexAccountStoreForStatusMenuTests: ManagedCodexAccountStoring, +@unchecked Sendable { + private var snapshot = ManagedCodexAccountSet(version: 1, accounts: []) + + func loadAccounts() throws -> ManagedCodexAccountSet { + self.snapshot + } + + func storeAccounts(_ accounts: ManagedCodexAccountSet) throws { + self.snapshot = accounts + } + + func ensureFileExists() throws -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } +} + +private struct StubManagedCodexWorkspaceResolverForStatusMenuTests: ManagedCodexWorkspaceResolving { + func resolveWorkspaceIdentity( + homePath _: String, + providerAccountID _: String) async -> CodexOpenAIWorkspaceIdentity? + { + nil + } +} + +private struct TestManagedCodexHomeFactoryForStatusMenuTests: ManagedCodexHomeProducing { + let root: URL + + func makeHomeURL() -> URL { + self.root.appendingPathComponent(UUID().uuidString, isDirectory: true) + } + + func validateManagedHomeForDeletion(_ url: URL) throws { + try ManagedCodexHomeFactory(root: self.root).validateManagedHomeForDeletion(url) + } +} + +private struct StubManagedCodexIdentityReaderForStatusMenuTests: ManagedCodexIdentityReading { + let email: String + + func loadAccountIdentity(homePath _: String) throws -> CodexAuthBackedAccount { + CodexAuthBackedAccount( + identity: CodexIdentityResolver.resolve(accountId: nil, email: self.email), + email: self.email, + plan: "Pro") + } +} diff --git a/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift new file mode 100644 index 000000000..b1f73389a --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuCostMenuCardTests.swift @@ -0,0 +1,46 @@ +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuCostMenuCardTests { + @Test + func `cost menu fallback keeps visible details in attributed title`() { + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $74.83 - 87M tokens", + monthLine: "Last 30 days: $4,279.64 - 5.7B tokens", + hintLine: "Costs are estimated from local usage.", + errorLine: "Cost refresh failed.", + errorCopyText: nil) + + let visibleLines = StatusItemController.costMenuVisibleDetailLines(tokenUsage: tokenUsage) + #expect(visibleLines == [ + "Today: $74.83 - 87M tokens", + "Last 30 days: $4,279.64 - 5.7B tokens", + "Cost refresh failed.", + ]) + + let fallbackTitle = StatusItemController.costMenuFallbackAttributedTitle(visibleDetailLines: visibleLines) + #expect(fallbackTitle.string.contains("Cost")) + #expect(fallbackTitle.string.contains("Today: $74.83 - 87M tokens")) + #expect(fallbackTitle.string.contains("Last 30 days: $4,279.64 - 5.7B tokens")) + #expect(fallbackTitle.string.contains("Cost refresh failed.")) + } + + @Test + func `cost menu tooltip preserves hint and error details`() { + let tokenUsage = UsageMenuCardView.Model.TokenUsageSection( + sessionLine: "Today: $1.00", + monthLine: "Last 30 days: $9.00", + hintLine: "Costs are estimated from local usage.", + errorLine: "Cost refresh failed.", + errorCopyText: nil) + + #expect(StatusItemController.costMenuTooltipLines(tokenUsage: tokenUsage) == [ + "Today: $1.00", + "Last 30 days: $9.00", + "Costs are estimated from local usage.", + "Cost refresh failed.", + ]) + } +} diff --git a/Tests/CodexBarTests/StatusMenuHighlightTests.swift b/Tests/CodexBarTests/StatusMenuHighlightTests.swift new file mode 100644 index 000000000..96fccbb56 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHighlightTests.swift @@ -0,0 +1,55 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +extension StatusMenuTests { + final class HighlightProbeView: NSView, MenuCardHighlighting { + private(set) var states: [Bool] = [] + + func setHighlighted(_ highlighted: Bool) { + self.states.append(highlighted) + } + } + + @Test + func `menu highlight updates only previous and current custom rows`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let firstView = HighlightProbeView() + let secondView = HighlightProbeView() + let thirdView = HighlightProbeView() + let first = NSMenuItem() + first.view = firstView + first.isEnabled = true + let second = NSMenuItem() + second.view = secondView + second.isEnabled = true + let third = NSMenuItem() + third.view = thirdView + third.isEnabled = true + menu.addItem(first) + menu.addItem(second) + menu.addItem(third) + + controller.menu(menu, willHighlight: first) + controller.menu(menu, willHighlight: second) + controller.menu(menu, willHighlight: second) + + #expect(firstView.states == [true, false]) + #expect(secondView.states == [true]) + #expect(thirdView.states.isEmpty) + } +} diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift new file mode 100644 index 000000000..845c2ab6c --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -0,0 +1,127 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuHostedSubmenuRefreshTests { + @Test + func `open parent menu defers data rebuild until next open`() throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.costUsageEnabled = true + Self.enableOnlyClaude(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + Self.seedClaudeSnapshots(in: store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let parentKey = ObjectIdentifier(menu) + controller.openMenus[parentKey] = menu + controller.menuVersions[parentKey] = controller.menuContentVersion + + let costItem = try #require(menu.items.first { ($0.representedObject as? String) == "menuCardCost" }) + #expect(costItem.view == nil) + let submenu = try #require(costItem.submenu) + let submenuAction = try #require(costItem.action) + #expect(NSStringFromSelector(submenuAction) == "submenuAction:") + #expect((costItem.target as? NSMenu) === submenu) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.minimumWidth >= StatusItemController.menuCardBaseWidth) + #expect(submenu.items.first?.view == nil) + + StatusItemController.setMenuRefreshEnabledForTesting(true) + controller.menuWillOpen(submenu) + let submenuKey = ObjectIdentifier(submenu) + #expect(controller.openMenus[submenuKey] === submenu) + #expect(submenu.items.first?.view != nil) + + let oldParentVersion = try #require(controller.menuVersions[parentKey]) + controller.menuContentVersion &+= 1 + controller.refreshOpenMenusIfNeeded() + #expect(controller.menuVersions[parentKey] == oldParentVersion) + + controller.menuDidClose(submenu) + #expect(controller.openMenus[submenuKey] == nil) + + #expect(controller.menuVersions[parentKey] == oldParentVersion) + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + #expect(controller.menuVersions[parentKey] == controller.menuContentVersion) + } + + private static func makeSettings() -> SettingsStore { + let suite = "StatusMenuHostedSubmenuRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func enableOnlyClaude(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: false) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + } + + private static func seedClaudeSnapshots(in store: UsageStore) { + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Team")) + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .claude) + } +} diff --git a/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift b/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift new file mode 100644 index 000000000..4a78b364c --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift @@ -0,0 +1,134 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuLocalizationRefreshTests { + @Test + func `open merged menu refreshes localized switcher and cost title when language changes`() async { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + if let previousAppleLanguages { + UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages") + } else { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } + } + + Self.disableMenuCardsForTesting() + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.switcherShowsIcons = false + settings.selectedMenuProvider = .codex + settings.costUsageEnabled = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: Self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + CodexBarLocalizationOverride.$appLanguage.withValue("es") { + controller.menuWillOpen(menu) + } + controller.openMenus[ObjectIdentifier(menu)] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + + #expect(Self.switcherButtons(in: menu).first?.title == "Resumen") + #expect(menu.items.first(where: { $0.representedObject as? String == "menuCardCost" })?.title == "Coste") + + let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView + let initialSwitcherID = initialSwitcher.map(ObjectIdentifier.init) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + settings.appLanguage = "en" + controller.handleProviderConfigChange(reason: "appLanguage") + } + + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + #expect(rebuildCount == 1) + let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView + #expect(Self.switcherButtons(in: menu).first?.title == "Overview") + #expect(menu.items.first(where: { $0.representedObject as? String == "menuCardCost" })?.title == "Cost") + if let initialSwitcherID, let updatedSwitcher { + #expect(initialSwitcherID != ObjectIdentifier(updatedSwitcher)) + } + } + + private static func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private static func makeStatusBarForTesting() -> NSStatusBar { + .system + } + + private static func makeSettings() -> SettingsStore { + let suite = "StatusMenuLocalizationRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func switcherButtons(in menu: NSMenu) -> [NSButton] { + guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] } + return switcherView.subviews + .compactMap { $0 as? NSButton } + .sorted { $0.tag < $1.tag } + } +} diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift new file mode 100644 index 000000000..aeec4c1c8 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -0,0 +1,198 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `store observation marks open menu stale without rebuilding during tracking`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + + let openedVersion = controller.menuVersions[key] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 33, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")), + provider: .codex) + + for _ in 0..<20 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + #expect(rebuildCount == 0) + } + + @Test + func `explicit store actions refresh a visible open menu`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + + let openedVersion = controller.menuVersions[key] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(rebuildCount == 1) + #expect(controller.menuVersions[key] != openedVersion) + } + + @Test + func `repeated explicit store actions coalesce to one open menu rebuild`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + controller.refreshOpenMenusAfterExplicitStoreAction() + controller.refreshOpenMenusAfterExplicitStoreAction() + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `plain open menu refresh preserves pending switcher hosted submenu cleanup`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + controller.refreshOpenMenuIfStillVisible(menu, provider: .codex) + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + } +} diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift new file mode 100644 index 000000000..6b90c164e --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -0,0 +1,64 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `overview rows expose provider detail submenus`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .openai + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .openai || provider == .codex + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 9, + requests: 12, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let openAIRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-openai" + }) + #expect(openAIRow.submenu?.items.contains { + ($0.representedObject as? String) == StatusItemController.costHistoryChartID + } == true) + } +} diff --git a/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift new file mode 100644 index 000000000..dc5208375 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuPersistentRefreshTests.swift @@ -0,0 +1,196 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +private final class RefreshShortcutRecorder: StatusItemMenuPersistentActionDelegate { + var refreshCount = 0 + var settingsCount = 0 + var quitCount = 0 + var navigationDirections: [StatusItemMenuProviderNavigationDirection] = [] + + func performPersistentRefreshAction() { + self.refreshCount += 1 + } + + func performPersistentSettingsAction() { + self.settingsCount += 1 + } + + func performPersistentQuitAction() { + self.quitCount += 1 + } + + func performProviderNavigation(_ direction: StatusItemMenuProviderNavigationDirection) { + self.navigationDirections.append(direction) + } +} + +@MainActor +private final class UpdateReadyUpdater: UpdaterProviding { + var automaticallyChecksForUpdates = false + var automaticallyDownloadsUpdates = false + let isAvailable = true + let unavailableReason: String? = nil + let updateStatus = UpdateStatus(isUpdateReady: true) + + func checkForUpdates(_: Any?) {} + func installUpdate() {} +} + +@MainActor +@Suite(.serialized) +struct StatusMenuPersistentRefreshTests { + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuPersistentRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeController( + settings: SettingsStore, + updater: UpdaterProviding = DisabledUpdaterController()) -> StatusItemController + { + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + return StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: updater, + preferencesSelection: PreferencesSelection(), + statusBar: .system) + } + + @Test + func `refresh menu item is view backed so mouse activation keeps the menu open`() throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings) + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let refreshItem = try #require(menu.items.first { $0.title == "Refresh" }) + #expect(refreshItem.action == nil) + #expect(refreshItem.target == nil) + #expect(refreshItem.view != nil) + #expect(refreshItem.keyEquivalent == "r") + #expect(refreshItem.keyEquivalentModifierMask == [.command]) + } + + @Test + func `meta menu actions use the same stable row implementation`() throws { + let settings = self.makeSettings() + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let controller = self.makeController(settings: settings, updater: UpdateReadyUpdater()) + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + for title in ["Update ready, restart now?", "Refresh", "Settings...", "About CodexBar", "Quit"] { + let item = try #require(menu.items.first { $0.title == title }) + #expect(item.view is PersistentMenuActionItemView) + #expect(item.view?.frame.height == PersistentMenuActionItemView.rowHeight) + if title == "Refresh" { + #expect(item.action == nil) + #expect(item.target == nil) + } else { + #expect(item.action != nil) + #expect(item.target === controller) + } + } + } + + @Test + func `refresh menu item view keeps fixed metrics while highlighted`() { + let views = [ + PersistentMenuActionItemView( + title: "Refresh", + systemImageName: "arrow.clockwise", + shortcutText: "⌘R", + width: 320, + onClick: {}), + PersistentMenuActionItemView( + title: "Settings...", + systemImageName: "gearshape", + shortcutText: "⌘,", + width: 320, + onClick: {}), + PersistentMenuActionItemView( + title: "About CodexBar", + systemImageName: "info.circle", + shortcutText: nil, + width: 320, + onClick: {}), + PersistentMenuActionItemView( + title: "Quit", + systemImageName: nil, + shortcutText: nil, + width: 320, + onClick: {}), + ] + + for view in views { + self.assertStableMetrics(view) + } + } + + private func assertStableMetrics(_ view: PersistentMenuActionItemView) { + #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) + #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) + #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) + + view.setFrameSize(NSSize(width: 360, height: 44)) + #expect(view.frame.width == 360) + #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) + + view.setHighlighted(true) + #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) + #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) + #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) + + view.setHighlighted(false) + #expect(view.frame.height == PersistentMenuActionItemView.rowHeight) + #expect(view.intrinsicContentSize.height == PersistentMenuActionItemView.rowHeight) + #expect(view.fittingSize.height == PersistentMenuActionItemView.rowHeight) + } + + @Test + func `status item menu intercepts persistent shortcuts without native item selection`() throws { + let menu = StatusItemMenu() + let recorder = RefreshShortcutRecorder() + menu.persistentActionDelegate = recorder + + #expect(try menu.performKeyEquivalent(with: self.keyEvent("r", keyCode: 15)) == true) + #expect(try menu.performKeyEquivalent(with: self.keyEvent(",", keyCode: 43)) == true) + #expect(try menu.performKeyEquivalent(with: self.keyEvent("q", keyCode: 12)) == true) + + #expect(recorder.refreshCount == 1) + #expect(recorder.settingsCount == 1) + #expect(recorder.quitCount == 1) + } + + private func keyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode)) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift new file mode 100644 index 000000000..9fe93b78d --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -0,0 +1,655 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherClickTests { + private func makeStatusBarForTesting() -> NSStatusBar { + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuSwitcherClickTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private func makeInstalledSwitcherShortcutMonitor() -> (controller: StatusItemController, menu: StatusItemMenu) { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = StatusItemMenu() + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let switcherItem = NSMenuItem() + switcherItem.view = switcher + menu.addItem(switcherItem) + menu.addItem(.separator()) + + controller.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) + return (controller, menu) + } + + @Test + func `merged switcher routes runtime clicks after overview round-trip`() throws { + // Regression test for #867: after Provider → Overview, subsequent runtime clicks on a + // sub-provider tab dropped through NSButton's tracking and never updated state. + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + // Step 1: provider → Overview via the runtime click path. + let switcher1 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher1._test_simulateRuntimeClick(buttonTag: 0)) + #expect(settings.mergedMenuLastSelectedWasOverview == true) + + // Step 2: Overview → provider via the runtime click path. Tag 2 is the second provider + // (claude) since tag 0 is Overview and tag 1 is the first provider. + let switcher2 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher2._test_simulateRuntimeClick(buttonTag: 2)) + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + + // Step 3: provider → Overview again. + let switcher3 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher3._test_simulateRuntimeClick(buttonTag: 0)) + #expect(settings.mergedMenuLastSelectedWasOverview == true) + + // Step 4: Overview → other provider. This is the click that previously got dropped. + let switcher4 = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher4._test_simulateRuntimeClick(buttonTag: 1)) + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .codex) + } + + @Test + func `merged switcher switches provider while overview chart submenu is open`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .openai + settings.mergedMenuLastSelectedWasOverview = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .openai || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 9, + requests: 12, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.openMenus[ObjectIdentifier(menu)] = menu + + let openAIRow = try #require(menu.items.first { + ($0.representedObject as? String) == "overviewRow-openai" + }) + let submenu = try #require(openAIRow.submenu) + controller.openMenus[ObjectIdentifier(submenu)] = submenu + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + #expect(rebuildCount == 1) + #expect(controller.openMenus[ObjectIdentifier(submenu)] == nil) + + let ids = menu.items.compactMap { $0.representedObject as? String } + #expect(ids.contains("menuCard")) + #expect(ids.contains(where: { $0.hasPrefix("overviewRow-") }) == false) + } + + @Test + func `merged switcher handles left and right arrow keyboard navigation`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: [.codex, .claude]) + settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: [.codex, .claude]) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.menuWillOpen(menu) + #expect(menu.items.first?.view is ProviderSwitcherView) + + #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 124)) == true) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + + #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 123)) == true) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .codex) + } + + @Test + func `merged switcher handles command number shortcuts in visible order`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.menuWillOpen(menu) + #expect(menu.items.first?.view is ProviderSwitcherView) + + #expect(try controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("3", keyCode: 20), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + + #expect(try controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("1", keyCode: 18), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == true) + #expect(settings.selectedMenuProvider == .claude) + + #expect(try !controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("9", keyCode: 25), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == true) + #expect(settings.selectedMenuProvider == .claude) + } + + @Test + func `provider shortcut monitor is removed when tracked menu closes after switcher rebuild`() { + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.providerSwitcherShortcutEventMonitor != nil) + #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) + + menu.removeAllItems() + controller.menuDidClose(menu) + + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.providerSwitcherShortcutMenuID == nil) + } + + @Test + func `switcher shortcut monitor is removed from direct close cleanup`() { + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.providerSwitcherShortcutEventMonitor != nil) + #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) + + controller.forgetClosedMenu(menu) + + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.providerSwitcherShortcutMenuID == nil) + } + + @Test + func `switcher hover styling keeps layout stable`() { + let view = ProviderSwitcherView( + providers: [.codex, .claude, .cursor, .factory, .zai, .minimax, .alibaba], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + + let initialSize = view.intrinsicContentSize + let initialFrames = view._test_buttonFrames() + + view._test_setHoveredButtonTag(3) + view._test_setHoveredButtonTag(6) + view._test_setHoveredButtonTag(nil as Int?) + + #expect(view.intrinsicContentSize == initialSize) + #expect(view._test_buttonFrames() == initialFrames) + } + + @Test + func `switcher quota indicator preserves remaining percentage`() throws { + let view = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + switch provider { + case .claude: + 5 + case .grok: + 95 + default: + nil + } + }, + onSelect: { _ in }) + + let fillRatios = view._test_quotaIndicatorFillRatios() + #expect(fillRatios.count == 2) + let lowRemainingRatio = try #require(fillRatios.first) + let highRemainingRatio = try #require(fillRatios.last) + #expect(lowRemainingRatio < highRemainingRatio) + } + + @Test + func `switcher quota indicator refresh updates fill ratios`() throws { + var claudeRemaining = 5.0 + var grokRemaining = 95.0 + let view = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + switch provider { + case .claude: + claudeRemaining + case .grok: + grokRemaining + default: + nil + } + }, + onSelect: { _ in }) + + let initialRatios = view._test_quotaIndicatorFillRatios() + let initialLow = try #require(initialRatios.first) + let initialHigh = try #require(initialRatios.last) + + claudeRemaining = 80 + grokRemaining = 12 + view.updateQuotaIndicators() + + let updatedRatios = view._test_quotaIndicatorFillRatios() + let updatedLow = try #require(updatedRatios.first) + let updatedHigh = try #require(updatedRatios.last) + #expect(updatedLow > initialLow) + #expect(updatedHigh < initialHigh) + } + + @Test + func `switcher quota indicator renders zero remaining empty`() { + var grokRemaining = 50.0 + let view = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + switch provider { + case .claude: + 100 + case .grok: + grokRemaining + default: + nil + } + }, + onSelect: { _ in }) + + grokRemaining = 0 + view.updateQuotaIndicators() + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + let fillRatios = view._test_quotaIndicatorFillRatios() + let fillFrames = view._test_quotaIndicatorFillFrames() + #expect(fillRatios.last == 0) + #expect(fillFrames.last?.width == 0) + } + + @Test + func `switcher quota indicator disappears when remaining becomes unavailable`() throws { + var grokRemaining: Double? = 50 + let noQuotaView = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let view = ProviderSwitcherView( + providers: [.claude, .grok], + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { provider in + switch provider { + case .claude: + 100 + case .grok: + grokRemaining + default: + nil + } + }, + onSelect: { _ in }) + #expect(view._test_quotaIndicatorFillRatios().count == 2) + let noQuotaHeight = try #require(noQuotaView._test_buttonFittingSizes().last?.height) + let quotaHeight = try #require(view._test_buttonFittingSizes().last?.height) + #expect(quotaHeight > noQuotaHeight) + + grokRemaining = nil + view.updateQuotaIndicators() + + #expect(view._test_quotaIndicatorFillRatios().count == 1) + let removedQuotaHeight = try #require(view._test_buttonFittingSizes().last?.height) + #expect(removedQuotaHeight == noQuotaHeight) + } + + @Test + func `text only switcher quota bars reserve title space`() throws { + let providers: [UsageProvider] = [.claude, .grok] + let textOnlyWithoutQuota = ProviderSwitcherView( + providers: providers, + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: false, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let textOnlyWithQuota = ProviderSwitcherView( + providers: providers, + selected: .provider(.claude), + includesOverview: false, + width: 180, + showsIcons: false, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in 50 }, + onSelect: { _ in }) + + let withoutQuotaHeight = try #require(textOnlyWithoutQuota._test_buttonFittingSizes().first?.height) + let withQuotaHeight = try #require(textOnlyWithQuota._test_buttonFittingSizes().first?.height) + #expect(withQuotaHeight > withoutQuotaHeight) + } + + @Test + func `multi row switcher quota bars stay inside bounds`() { + let view = ProviderSwitcherView( + providers: [.codex, .claude, .cursor, .factory, .zai, .minimax, .alibaba], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in 50 }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + for frame in view._test_buttonFrames() { + #expect(frame.minY >= 0) + #expect(frame.maxY <= view.bounds.maxY) + } + } + + private static func arrowKeyEvent(keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode)) + } + + private static func commandKeyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode)) + } + + @Test + func `multi-row switcher uses compact height and stays inside bounds`() { + // 14 providers + Overview forces the four-row path and includes multi-word titles. + let view = ProviderSwitcherView( + providers: [ + .codex, + .claude, + .cursor, + .factory, + .zai, + .minimax, + .alibaba, + .opencodego, + .grok, + .groq, + .gemini, + .openrouter, + .perplexity, + .kiro, + ], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in 50 }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + // All buttons must stay within switcher bounds (no vertical overflow). + for frame in view._test_buttonFrames() { + #expect(frame.minY >= 0) + #expect(frame.maxY <= view.bounds.maxY) + } + + #expect(view._test_rowCount() == 4) + #expect(view._test_rowHeight() == 44) + #expect(view.bounds.height == 188) + } +} diff --git a/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift new file mode 100644 index 000000000..4d71f58e1 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift @@ -0,0 +1,103 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuSwitcherRefreshTests { + @Test + func `merged provider switch rebuilds stale width switcher rows`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + Self.enableCodexAndClaude(settings) + + let activeProviders: [UsageProvider] = [.codex, .claude] + _ = settings.setMergedOverviewProviderSelection( + provider: .codex, + isSelected: false, + activeProviders: activeProviders) + _ = settings.setMergedOverviewProviderSelection( + provider: .claude, + isSelected: false, + activeProviders: activeProviders) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.isRefreshing = true + defer { store.isRefreshing = false } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + #expect(controller.openMenus[ObjectIdentifier(menu)] === menu) + + let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + initialSwitcher.frame.size.width = 250 + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let nextProviderButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off }) + #expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: nextProviderButton.tag) == true) + + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + #expect(rebuildCount == 1) + let updatedSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView) + #expect(updatedSwitcher.frame.width == 310) + #expect(Self.switcherButtons(in: menu).first { $0.tag == nextProviderButton.tag }?.state == .on) + } + + private static func makeSettings() -> SettingsStore { + let suite = "StatusMenuSwitcherRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func enableCodexAndClaude(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + } + + private static func switcherButtons(in menu: NSMenu) -> [NSButton] { + guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] } + return switcherView.subviews + .compactMap { $0 as? NSButton } + .sorted { $0.tag < $1.tag } + } +} diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 3f04c3ff7..cd4e5e913 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -4,22 +4,20 @@ import Testing @testable import CodexBar @MainActor -@Suite +@Suite(.serialized) struct StatusMenuTests { - private func disableMenuCardsForTesting() { + func disableMenuCardsForTesting() { StatusItemController.menuCardRenderingEnabled = false - StatusItemController.menuRefreshEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) } - private func makeStatusBarForTesting() -> NSStatusBar { - let env = ProcessInfo.processInfo.environment - if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { - return .system - } - return NSStatusBar() + func makeStatusBarForTesting() -> NSStatusBar { + // Use the real system status bar in tests. Creating standalone NSStatusBar instances + // has caused AppKit teardown crashes under swiftpm-testing-helper. + .system } - private func makeSettings() -> SettingsStore { + func makeSettings() -> SettingsStore { let suite = "StatusMenuTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) @@ -31,6 +29,44 @@ struct StatusMenuTests { syntheticTokenStore: NoopSyntheticTokenStore()) } + func makeCodexStore(settings: SettingsStore, dashboardAuthorized: Bool) -> UsageStore { + let now = Date() + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")), + provider: .codex) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "other@example.com", + codeReviewRemainingPercent: 88, + codeReviewLimit: RateWindow( + usedPercent: 12, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: now) + store.openAIDashboardAttachmentAuthorized = dashboardAuthorized + store.openAIDashboardRequiresLogin = false + return store + } + private func switcherButtons(in menu: NSMenu) -> [NSButton] { guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] } return switcherView.subviews @@ -43,7 +79,93 @@ struct StatusMenuTests { } @Test - func remembersProviderWhenMenuOpens() { + func `alibaba dashboard action follows selected region`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.providerDetectionCompleted = true + settings.alibabaCodingPlanAPIRegion = .chinaMainland + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + #expect(controller.dashboardURL(for: .alibaba) == AlibabaCodingPlanAPIRegion.chinaMainland.dashboardURL) + } + + @Test + func `opencode go dashboard action follows configured workspace`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.opencodegoWorkspaceID = "https://opencode.ai/workspace/wrk_abc123/go" + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + #expect(controller.dashboardURL(for: .opencodego)? + .absoluteString == "https://opencode.ai/workspace/wrk_abc123/go") + } + + @Test + func `claude subscription dashboard action opens usage page`() { + for plan in ["Claude Pro", "Claude Team"] { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 12, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: nil, + accountOrganization: nil, + loginMethod: plan)), + provider: .claude) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + #expect(controller.dashboardURL(for: .claude)?.absoluteString == "https://claude.ai/settings/usage") + } + } + + @Test + func `remembers provider when menu opens`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -70,6 +192,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let claudeMenu = controller.makeMenu() controller.menuWillOpen(claudeMenu) @@ -87,7 +210,7 @@ struct StatusMenuTests { } @Test - func mergedMenuOpenDoesNotPersistResolvedProviderWhenSelectionIsNil() { + func `merged menu open does not persist resolved provider when selection is nil`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -96,16 +219,12 @@ struct StatusMenuTests { settings.selectedMenuProvider = nil let registry = ProviderRegistry.shared - var enabledProviders: [UsageProvider] = [] + let selectedProviders: Set = [.codex, .claude] for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } - let shouldEnable = enabledProviders.count < 2 + let shouldEnable = selectedProviders.contains(provider) settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) - if shouldEnable { - enabledProviders.append(provider) - } } - #expect(enabledProviders.count == 2) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) @@ -116,6 +235,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let expectedResolved = store.enabledProviders().first ?? .codex #expect(store.enabledProviders().count > 1) @@ -128,13 +248,102 @@ struct StatusMenuTests { } @Test - func mergedMenuRefreshUsesResolvedEnabledProviderWhenPersistedSelectionIsDisabled() { + func `shortcut closes tracked menu instead of queueing another open`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + #expect(controller.openMenus[key] != nil) + + #expect(controller.closeOpenMenusFromShortcutIfNeeded() == true) + #expect(controller.openMenus.isEmpty) + #expect(controller.menuRefreshTasks.isEmpty) + #expect(controller.closeOpenMenusFromShortcutIfNeeded() == false) + } + + @Test + func `open menu defers store data refresh until next open`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + let openedVersion = controller.menuVersions[key] + + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 11, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(1800), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")), + provider: .codex) + + for _ in 0..<50 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + + let staleVersion = controller.menuContentVersion + controller.refreshOpenMenusIfNeeded() + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + controller.menuDidClose(menu) + controller.menuWillOpen(menu) + #expect(controller.menuVersions[key] == staleVersion) + } + + @Test + func `merged menu refresh uses resolved enabled provider when selection is cleared`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -166,6 +375,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let expectedResolved = store.enabledProviders().first ?? .codex #expect(store.enabledProviders().count > 1) @@ -185,7 +395,7 @@ struct StatusMenuTests { controller.menuWillOpen(menu) #expect(controller.lastMenuProvider == expectedResolved) - #expect(settings.selectedMenuProvider == .codex) + #expect(settings.selectedMenuProvider == nil) #expect(hasOpenAIWebSubmenus(menu) == false) controller.menuContentVersion &+= 1 @@ -195,7 +405,123 @@ struct StatusMenuTests { } @Test - func openMergedMenuRebuildsSwitcherWhenUsageBarsModeChanges() { + func `delayed menu refresh skips when refresh disabled during delay`() async { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + StatusItemController.setMenuOpenRefreshDelayForTesting(.milliseconds(50)) + defer { + StatusItemController.resetMenuOpenRefreshDelayForTesting() + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + var delayedRefreshWakeCount = 0 + + await withStatusItemControllerForTesting( + store: store, + settings: settings, + fetcher: fetcher, + statusBar: self.makeStatusBarForTesting()) + { controller in + controller.onDelayedMenuRefreshAttemptForTesting = { + delayedRefreshWakeCount += 1 + } + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + StatusItemController.setMenuRefreshEnabledForTesting(false) + try? await Task.sleep(for: .milliseconds(180)) + } + + #expect(delayedRefreshWakeCount == 0) + } + + @Test + func `login state callbacks do not attach menus after release`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + controller.releaseStatusItemsForTesting() + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + + controller.activeLoginProvider = .codex + let loginTask = Task {} + controller.loginTask = loginTask + loginTask.cancel() + controller.loginTask = nil + controller.activeLoginProvider = nil + + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + } + + @Test + func `display only dashboard does not show code review in status menu card`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let model = try #require(controller.menuCardModel(for: .codex)) + #expect(model.metrics.contains { $0.id == "code-review" } == false) + } + + @Test + func `display only dashboard does not show code review in providers pane`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let pane = ProvidersPane(settings: settings, store: store) + + let model = pane._test_menuCardModel(for: .codex) + #expect(model.metrics.contains { $0.id == "code-review" } == false) + } + + @Test + func `attached dashboard still shows code review in providers pane`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: true) + let pane = ProvidersPane(settings: settings, store: store) + + let model = pane._test_menuCardModel(for: .codex) + #expect(model.metrics.contains { $0.id == "code-review" && $0.percent == 88 }) + } + + @Test + func `open merged menu rebuilds switcher when usage bars mode changes`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -226,6 +552,9 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) + controller.openMenus[ObjectIdentifier(menu)] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(initialSwitcher != nil) @@ -233,6 +562,11 @@ struct StatusMenuTests { settings.usageBarsShowUsed = true controller.handleProviderConfigChange(reason: "usageBarsShowUsed") + for _ in 0..<20 + where initialSwitcherID == (menu.items.first?.view as? ProviderSwitcherView).map(ObjectIdentifier.init) + { + await Task.yield() + } let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(updatedSwitcher != nil) @@ -242,7 +576,7 @@ struct StatusMenuTests { } @Test - func mergedSwitcherIncludesOverviewTabWhenMultipleProvidersEnabled() { + func `merged switcher includes overview tab when multiple providers enabled`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -272,13 +606,13 @@ struct StatusMenuTests { controller.menuWillOpen(menu) let buttons = self.switcherButtons(in: menu) - #expect(buttons.count == store.enabledProviders().count + 1) + #expect(buttons.count == store.enabledProvidersForDisplay().count + 1) #expect(buttons.contains(where: { $0.tag == 0 })) #expect(buttons.first(where: { $0.state == .on })?.tag == 2) } @Test - func mergedSwitcherOverviewSelectionPersistsWithoutOverwritingProviderSelection() { + func `merged switcher overview selection persists without overwriting provider selection`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -323,7 +657,7 @@ struct StatusMenuTests { } @Test - func openMenuRebuildsSwitcherWhenOverviewAvailabilityChanges() { + func `open menu rebuilds switcher when overview availability changes`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -361,6 +695,9 @@ struct StatusMenuTests { let menu = controller.makeMenu() controller.menuWillOpen(menu) + controller.openMenus[ObjectIdentifier(menu)] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } let initialButtons = self.switcherButtons(in: menu) #expect(initialButtons.count == activeProviders.count) @@ -370,14 +707,15 @@ struct StatusMenuTests { isSelected: true, activeProviders: activeProviders) controller.menuContentVersion &+= 1 - controller.refreshOpenMenusIfNeeded() + controller.menuDidClose(menu) + controller.menuWillOpen(menu) let updatedButtons = self.switcherButtons(in: menu) #expect(updatedButtons.count == activeProviders.count + 1) } @Test - func overviewTabOmitsContextualProviderActions() { + func `overview tab omits contextual provider actions`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -411,13 +749,72 @@ struct StatusMenuTests { #expect(!titles.contains("Switch Account...")) #expect(!titles.contains("Usage Dashboard")) #expect(!titles.contains("Status Page")) + #expect(titles.contains("Refresh")) #expect(titles.contains("Settings...")) #expect(titles.contains("About CodexBar")) #expect(titles.contains("Quit")) + + let refreshItem = menu.items.first { $0.title == "Refresh" } + #expect(refreshItem != nil) + #expect(refreshItem?.keyEquivalent == "r") + #expect(refreshItem?.keyEquivalentModifierMask == [.command]) + + let settingsItem = menu.items.first { $0.title == "Settings..." } + #expect(settingsItem != nil) + #expect(settingsItem?.keyEquivalent == ",") + #expect(settingsItem?.keyEquivalentModifierMask == [.command]) + + let quitItem = menu.items.first { $0.title == "Quit" } + #expect(quitItem != nil) + #expect(quitItem?.keyEquivalent == "q") + #expect(quitItem?.keyEquivalentModifierMask == [.command]) + } +} + +@MainActor +extension StatusMenuTests { + @Test + func `status blurb uses wrapped view-backed menu item`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = true + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let statusText = "An SSL error has occurred and a secure connection to the server cannot be made." + store.statuses[.codex] = ProviderStatus( + indicator: .critical, + description: statusText, + updatedAt: nil) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu(for: .codex) + controller.menuWillOpen(menu) + + let statusItem = menu.items.first(where: { $0.toolTip == statusText }) + #expect(statusItem != nil) + #expect(statusItem?.view != nil) + #expect(statusItem?.title.isEmpty == true) + #expect(statusItem?.view?.frame.width == 310) } @Test - func providerToggleUpdatesStatusItemVisibility() { + func `provider toggle updates status item visibility`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -445,6 +842,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } #expect(controller.statusItems[.claude]?.isVisible == true) @@ -452,11 +850,56 @@ struct StatusMenuTests { settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) } controller.handleProviderConfigChange(reason: "test") - #expect(controller.statusItems[.claude]?.isVisible == false) + #expect(controller.statusItems[.claude] == nil) + } + + @Test + func `provider config changes preserve status item instances`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.providerDetectionCompleted = true + + let registry = ProviderRegistry.shared + try settings.setProviderEnabled(provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(registry.metadata[.claude]), + enabled: true) + try settings.setProviderEnabled( + provider: .gemini, + metadata: #require(registry.metadata[.gemini]), + enabled: false) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let codexItem = try #require(controller.statusItems[.codex]) + #expect(!controller.statusItem.autosaveName.hasPrefix("codexbar-")) + #expect(!codexItem.autosaveName.hasPrefix("codexbar-")) + + try settings.setProviderEnabled( + provider: .gemini, + metadata: #require(registry.metadata[.gemini]), + enabled: true) + controller.handleProviderConfigChange(reason: "test") + + #expect(controller.statusItems[.codex] === codexItem) + #expect(controller.statusItems[.codex]?.autosaveName.hasPrefix("codexbar-") == false) + #expect(controller.statusItems[.gemini]?.autosaveName.hasPrefix("codexbar-") == false) } @Test - func hidesOpenAIWebSubmenusWhenNoHistory() { + func `hides open AI web submenus when no history`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -493,6 +936,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -502,7 +946,169 @@ struct StatusMenuTests { } @Test - func showsOpenAIWebSubmenusWhenHistoryExists() throws { + func `hides open AI web submenus when open AI web extras disabled`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = false + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let titles = Set(menu.items.map(\.title)) + #expect(!titles.contains("Credits history")) + #expect(!titles.contains("Usage breakdown")) + } + + @Test + func `hosted chart submenu matches widened parent menu width`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let event = CreditEvent(date: Date(), service: "CLI", creditsUsed: 1) + let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30) + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "user@example.com", + codeReviewRemainingPercent: 100, + creditEvents: [event], + dailyBreakdown: breakdown, + usageBreakdown: breakdown, + creditsPurchaseURL: nil, + updatedAt: Date()) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let parentMenu = NSMenu() + parentMenu.autoenablesItems = false + let wideItem = NSMenuItem(title: String(repeating: "W", count: 60), action: nil, keyEquivalent: "") + parentMenu.addItem(wideItem) + + let submenu = controller.makeHostedSubviewPlaceholderMenu(chartID: StatusItemController.usageBreakdownChartID) + let submenuItem = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") + submenuItem.submenu = submenu + parentMenu.addItem(submenuItem) + + let parentWidth = ceil(parentMenu.size.width) + #expect(parentWidth > 310) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + + let chartItem = submenu.items.first + #expect(chartItem?.representedObject as? String == StatusItemController.usageBreakdownChartID) + #expect(chartItem?.view != nil) + #expect(abs((chartItem?.view?.frame.width ?? 0) - parentWidth) <= 0.5) + } + + @Test + func `hosted storage submenu is height capped and scroll enabled`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.providerStorageFootprintsEnabled = true + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let root = "/Users/test/.claude" + store.providerStorageFootprints[.claude] = ProviderStorageFootprint( + provider: .claude, + totalBytes: 1_756_000_000, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [ + .init(path: "\(root)/projects", totalBytes: 1_500_000_000), + .init(path: "\(root)/file-history", totalBytes: 103_000_000), + .init(path: "\(root)/telemetry", totalBytes: 51_000_000), + .init(path: "\(root)/plugins", totalBytes: 33_000_000), + .init(path: "\(root)/history.jsonl", totalBytes: 3_800_000), + .init(path: "\(root)/shell-snapshots", totalBytes: 1_500_000), + .init(path: "\(root)/plans", totalBytes: 1_100_000), + .init(path: "\(root)/paste-cache", totalBytes: 541_000), + .init(path: "\(root)/session-env", totalBytes: 208_000), + .init(path: "\(root)/todos", totalBytes: 6700), + ], + updatedAt: Date(timeIntervalSince1970: 0)) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let submenu = NSMenu() + let didAppend = controller.appendStorageBreakdownItem(to: submenu, provider: .claude, width: 310) + + #expect(didAppend) + let item = submenu.items.first + #expect(item?.isEnabled == true) + #expect((item?.view?.frame.height ?? 0) <= 620) + } + + @Test + func `shows open AI web submenus when history exists`() throws { self.disableMenuCardsForTesting() let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusMenuTests-history"), @@ -512,6 +1118,7 @@ struct StatusMenuTests { settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex + settings.openAIWebAccessEnabled = true let registry = ProviderRegistry.shared if let codexMeta = registry.metadata[.codex] { @@ -546,6 +1153,8 @@ struct StatusMenuTests { usageBreakdown: breakdown, creditsPurchaseURL: nil, updatedAt: Date()) + store.openAIDashboardAttachmentAuthorized = true + store.openAIDashboardRequiresLogin = false let controller = StatusItemController( store: store, @@ -554,6 +1163,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -568,7 +1178,60 @@ struct StatusMenuTests { } @Test - func showsCreditsBeforeCostInCodexMenuCardSections() throws { + func `shows open AI API usage chart submenu without codex web history`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.selectedMenuProvider = .openai + + let registry = ProviderRegistry.shared + let metadata = try #require(registry.metadata[.openai]) + settings.setProviderEnabled(provider: .openai, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 9, + requests: 12, + inputTokens: 100, + cachedInputTokens: 0, + outputTokens: 50, + totalTokens: 150, + lineItems: [], + models: []), + ], + updatedAt: now) + store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .openai) + controller.menuWillOpen(menu) + let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } + + #expect(usageItem?.submenu?.items + .contains { ($0.representedObject as? String) == StatusItemController.costHistoryChartID } == true) + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardHeader" } == false) + #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardExtraUsage" } == false) + } + + @Test + func `shows credits before cost in codex menu card sections`() throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -599,6 +1262,8 @@ struct StatusMenuTests { usageBreakdown: [], creditsPurchaseURL: nil, updatedAt: Date()) + store.openAIDashboardAttachmentAuthorized = true + store.openAIDashboardRequiresLogin = false store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( sessionTokens: 123, sessionCostUSD: 0.12, @@ -623,6 +1288,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -635,7 +1301,66 @@ struct StatusMenuTests { } @Test - func showsExtraUsageForClaudeWhenUsingMenuCardSections() { + func `hosted cost submenu preserves provider context after empty hydration`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .codex) + #expect(submenu.autoenablesItems == false) + #expect(submenu.items.first?.isEnabled == true) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.title == "No data available") + #expect(submenu.items.first?.toolTip == UsageProvider.codex.rawValue) + + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + + controller.hydrateHostedSubviewMenuIfNeeded(submenu) + #expect(submenu.items.count == 1) + #expect(submenu.items.first?.title != "No data available") + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.isEnabled == true) + } + + @Test + func `shows extra usage for claude when using menu card sections`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -701,6 +1426,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -709,7 +1435,7 @@ struct StatusMenuTests { } @Test - func showsVertexCostWhenUsageErrorPresent() { + func `shows vertex cost when usage error present`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -756,6 +1482,7 @@ struct StatusMenuTests { updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -766,7 +1493,7 @@ struct StatusMenuTests { extension StatusMenuTests { @Test - func overviewTabRendersOverviewRowsForAllActiveProvidersWhenThreeOrFewer() { + func `overview tab renders overview rows for all active providers when three or fewer`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -805,7 +1532,7 @@ extension StatusMenuTests { } @Test - func overviewTabHonorsStoredSubsetWhenThreeOrFewer() { + func `overview tab honors stored subset when three or fewer`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -848,7 +1575,7 @@ extension StatusMenuTests { } @Test - func overviewTabWithExplicitEmptySelectionIsHiddenAndShowsProviderDetail() { + func `overview tab with explicit empty selection is hidden and shows provider detail`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -883,7 +1610,7 @@ extension StatusMenuTests { let ids = self.representedIDs(in: menu) let switcherButtons = self.switcherButtons(in: menu) - #expect(switcherButtons.count == store.enabledProviders().count) + #expect(switcherButtons.count == store.enabledProvidersForDisplay().count) #expect(switcherButtons.contains(where: { $0.title == "Overview" }) == false) #expect(switcherButtons.contains(where: { $0.state == .on && $0.tag == 0 })) #expect(ids.contains("menuCard")) @@ -893,9 +1620,9 @@ extension StatusMenuTests { } @Test - func overviewRowsKeepMenuItemActionInRenderedMode() throws { + func `overview rows keep menu item action in rendered mode`() throws { StatusItemController.menuCardRenderingEnabled = true - StatusItemController.menuRefreshEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) defer { self.disableMenuCardsForTesting() } let settings = self.makeSettings() @@ -933,7 +1660,7 @@ extension StatusMenuTests { } @Test - func selectingOverviewRowSwitchesToProviderDetail() throws { + func `selecting overview row switches to provider detail`() throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false diff --git a/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift new file mode 100644 index 000000000..49053cc93 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuTokenAccountSwitcherTests.swift @@ -0,0 +1,423 @@ +import AppKit +import CodexBarCore +import Foundation +import XCTest +@testable import CodexBar + +@MainActor +final class StatusMenuTokenAccountSwitcherTests: XCTestCase { + private func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private func makeStatusBarForTesting() -> NSStatusBar { + let env = ProcessInfo.processInfo.environment + if env["GITHUB_ACTIONS"] == "true" || env["CI"] == "true" { + return .system + } + return NSStatusBar() + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusMenuTokenAccountSwitcherTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + return settings + } + + private func enableOnlyClaude(_ settings: SettingsStore) { + self.enableOnly(.claude, settings) + } + + private func enableOnly(_ enabledProvider: UsageProvider, _ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == enabledProvider) + } + } + + private func representedIDs(in menu: NSMenu) -> [String] { + menu.items.compactMap { $0.representedObject as? String } + } + + private func installBlockingClaudeProvider(on store: UsageStore, blocker: BlockingTokenAccountFetchStrategy) { + let baseSpec = store.providerSpecs[.claude]! + store.providerSpecs[.claude] = Self.makeClaudeProviderSpec(baseSpec: baseSpec) { + try await blocker.awaitResult() + } + } + + private static func makeClaudeProviderSpec( + baseSpec: ProviderSpec, + loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec + { + let baseDescriptor = baseSpec.descriptor + let strategy = StatusMenuTokenAccountFetchStrategy(loader: loader) + let descriptor = ProviderDescriptor( + id: .claude, + metadata: baseDescriptor.metadata, + branding: baseDescriptor.branding, + tokenCost: baseDescriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .cli, .oauth], + pipeline: ProviderFetchPipeline { _ in [strategy] }), + cli: baseDescriptor.cli) + return ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + } + + private func snapshot(percent: Double = 12) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: percent, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(300), + resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "OAuth")) + } + + func test_tokenAccountMenuSelectionRefreshesProviderWhileGlobalRefreshIsActive() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let refreshTask = Task { @MainActor in + await store.refresh() + } + await blocker.waitUntilStarted(count: 1) + XCTAssertTrue(store.isRefreshing) + + let menu = controller.makeMenu() + defer { withExtendedLifetime(menu) {} } + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + await blocker.waitUntilStarted(count: 2) + XCTAssertEqual(settings.tokenAccountsData(for: .claude)?.clampedActiveIndex(), 1) + + await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await selectionTask.value + await refreshTask.value + let startedCallCount = await blocker.startedCallCount() + XCTAssertGreaterThanOrEqual(startedCallCount, 2) + } + + func test_multiAccountSegmentedLayoutShowsCopilotSwitcher() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .segmented + self.enableOnly(.copilot, settings) + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "gh_primary") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "gh_secondary") + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + _ = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard"]) + } + + func test_multiAccountStackedLayoutShowsCopilotCards() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnly(.copilot, settings) + settings.addTokenAccount(provider: .copilot, label: "Primary", token: "gh_primary") + settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "gh_secondary") + let accounts = settings.tokenAccounts(for: .copilot) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store.accountSnapshots[.copilot] = accounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(10 + index)), + error: nil, + sourceLabel: "test") + } + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard-0", "menuCard-1"]) + } + + func test_multiAccountStackedRefreshStartsAccountFetchesConcurrently() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnlyClaude(settings) + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + + let refreshTask = Task { @MainActor in + await store.refreshProvider(.claude) + } + + await blocker.waitUntilStarted(count: 2) + let startedBeforeResume = await blocker.startedCallCount() + XCTAssertEqual(startedBeforeResume, 2) + + await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await refreshTask.value + XCTAssertEqual(store.accountSnapshots[.claude]?.count, 2) + } + + func test_multiAccountStackedLayoutIgnoresStaleSnapshotsAndKeepsMenuCapped() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.multiAccountMenuLayout = .stacked + self.enableOnly(.copilot, settings) + for index in 0..<8 { + settings.addTokenAccount(provider: .copilot, label: "Account \(index)", token: "gh_\(index)") + } + settings.setActiveTokenAccountIndex(7, for: .copilot) + let accounts = settings.tokenAccounts(for: .copilot) + let staleAccounts = (0..<2).map { index in + ProviderTokenAccount( + id: UUID(), + label: "Removed \(index)", + token: "stale_\(index)", + addedAt: TimeInterval(index), + lastUsed: nil) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let staleSnapshots = staleAccounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(70 + index)), + error: nil, + sourceLabel: "stale") + } + let currentSnapshots = accounts.enumerated().map { index, account in + TokenAccountUsageSnapshot( + account: account, + snapshot: self.snapshot(percent: Double(10 + index)), + error: nil, + sourceLabel: "current") + } + store.accountSnapshots[.copilot] = staleSnapshots + currentSnapshots + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu(for: .copilot) + controller.menuWillOpen(menu) + + XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + XCTAssertEqual( + self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, + ["menuCard-0", "menuCard-1", "menuCard-2", "menuCard-3", "menuCard-4", "menuCard-5"]) + } + + func test_tokenAccountSwitchDefersOpenMenuRebuildUntilAfterSwitcherAction() async throws { + self.disableMenuCardsForTesting() + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.setMenuRefreshEnabledForTesting(false) } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.multiAccountMenuLayout = .segmented + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .claude || provider == .codex) + } + settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary") + settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary") + settings.setActiveTokenAccountIndex(0, for: .claude) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let blocker = BlockingTokenAccountFetchStrategy() + self.installBlockingClaudeProvider(on: store, blocker: blocker) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first) + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + let selectionTask = try XCTUnwrap(switcher._test_select(index: 1)) + + XCTAssertEqual(rebuildCount, 0) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + XCTAssertEqual(rebuildCount, 1) + + await blocker.waitUntilStarted(count: 1) + await blocker.resumeAll(with: .success(self.snapshot(percent: 17))) + await selectionTask.value + } +} + +private struct StatusMenuTokenAccountFetchStrategy: ProviderFetchStrategy { + let loader: @Sendable () async throws -> UsageSnapshot + + var id: String { + "status-menu-token-account-test" + } + + var kind: ProviderFetchKind { + .cli + } + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.loader() + return self.makeResult(usage: snapshot, sourceLabel: "status-menu-token-account-test") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +private actor BlockingTokenAccountFetchStrategy { + private var waiters: [CheckedContinuation, Never>] = [] + private var startedWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var resolvedResult: Result? + private var startedCount = 0 + + func awaitResult() async throws -> UsageSnapshot { + if let resolvedResult { + self.startedCount += 1 + self.resumeStartedWaiters() + return try resolvedResult.get() + } + let result = await withCheckedContinuation { continuation in + self.waiters.append(continuation) + self.startedCount += 1 + self.resumeStartedWaiters() + } + return try result.get() + } + + func waitUntilStarted(count: Int) async { + if self.startedCount >= count { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append((count: count, continuation: continuation)) + } + } + + func startedCallCount() -> Int { + self.startedCount + } + + func resumeAll(with result: Result) { + self.resolvedResult = result + self.waiters.forEach { $0.resume(returning: result) } + self.waiters.removeAll() + } + + private func resumeStartedWaiters() { + let ready = self.startedWaiters.filter { self.startedCount >= $0.count } + self.startedWaiters.removeAll { self.startedCount >= $0.count } + ready.forEach { $0.continuation.resume() } + } +} diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index b999019e4..dde1026a4 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -3,10 +3,9 @@ import Foundation import Testing @testable import CodexBar -@Suite struct StatusProbeTests { @Test - func parseCodexStatus() throws { + func `parse codex status`() throws { let sample = """ Model: gpt Credits: 980 credits @@ -20,20 +19,55 @@ struct StatusProbeTests { } @Test - func parseCodexStatusWithAnsiAndResets() throws { + func `parse codex status with ansi and resets`() throws { + let now = try #require( + Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 11, + day: 26, + hour: 8, + minute: 0))) let sample = """ \u{001B}[38;5;245mCredits:\u{001B}[0m 557 credits 5h limit: [█████ ] 50% left (resets 09:01) Weekly limit: [███████ ] 85% left (resets 04:01 on 27 Nov) """ - let snap = try CodexStatusProbe.parse(text: sample) + let snap = try CodexStatusProbe.parse(text: sample, now: now) #expect(snap.credits == 557) #expect(snap.fiveHourPercentLeft == 50) #expect(snap.weeklyPercentLeft == 85) + #expect(snap.fiveHourResetsAt == Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 11, + day: 26, + hour: 9, + minute: 1))) + #expect(snap.weeklyResetsAt == Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 11, + day: 27, + hour: 4, + minute: 1))) + } + + @Test + func `parse codex status with weekly only line`() throws { + let sample = """ + Model: gpt + Credits: 980 credits + Weekly limit: [##] 25% left + """ + let snap = try CodexStatusProbe.parse(text: sample) + #expect(snap.credits == 980) + #expect(snap.fiveHourPercentLeft == nil) + #expect(snap.weeklyPercentLeft == 25) } @Test - func parseClaudeStatus() throws { + func `parse claude status`() throws { let sample = """ Settings: Status Config Usage (tab to cycle) @@ -64,7 +98,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusWithANSI() throws { + func `parse claude status with ANSI`() throws { let sample = """ \u{001B}[35mCurrent session\u{001B}[0m 40% used (Resets 11am) @@ -86,7 +120,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusLegacyOpusLabel() throws { + func `parse claude status legacy opus label`() throws { let sample = """ Current session 12% used (Resets 11am) @@ -107,7 +141,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusRemainingKeyword() throws { + func `parse claude status remaining keyword`() throws { let sample = """ Current session 12% remaining (Resets 11am) @@ -120,7 +154,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusEnterpriseSessionOnly() throws { + func `parse claude status enterprise session only`() throws { let sample = """ Current session █ 2% used @@ -134,7 +168,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusResetMappings_withCRLineEndings() throws { + func `parse claude status reset mappings with CR line endings`() throws { let sample = "Current session\r" + "██████████████████████████████████████████████████ 17% used\r" + @@ -156,7 +190,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusResetMappings_doesNotPromoteWeeklyResetToSession() throws { + func `parse claude status reset mappings does not promote weekly reset to session`() throws { let sample = """ Current session ██████████████████████████████████████████████████ 17% used @@ -172,7 +206,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusWithPlanAndAnsiNoise() throws { + func `parse claude status with plan and ansi noise`() throws { let sample = """ Settings: Status Config Usage @@ -197,7 +231,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatusWithExtraUsageSection() throws { + func `parse claude status with extra usage section`() throws { let sample = """ Settings: Status Config Usage (tab to cycle) @@ -225,7 +259,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatus_ignoresStatusBarContextPercent() throws { + func `parse claude status ignores status bar context percent`() throws { let sample = """ Claude Code v2.1.29 22:47 | | Opus 4.5 | default | ░░░░░░░░░░ 0% ◯ /ide for Visual Studio Code @@ -254,7 +288,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatus_loadingPanelDoesNotReportZeroPercent() { + func `parse claude status loading panel surfaces loading stall`() { let sample = """ Claude Code v2.1.29 22:47 | | Opus 4.5 | default | ░░░░░░░░░░ 0% ◯ /ide for Visual Studio Code @@ -267,7 +301,8 @@ struct StatusProbeTests { do { _ = try ClaudeStatusProbe.parse(text: sample) #expect(Bool(false), "Parsing should fail while /usage is still loading") - } catch ClaudeStatusProbeError.parseFailed { + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("loading")) return } catch ClaudeStatusProbeError.timedOut { return @@ -277,7 +312,34 @@ struct StatusProbeTests { } @Test - func parseClaudeStatus_statusOnlyOutputDoesNotFallbackToZero() { + func `parse claude retained usage panel classifies latest loading panel`() { + let sample = """ + Settings: Status Config Usage (tab to cycle) + Current session + ███████▌15%used + Resets 11:30pm (Asia/Calcutta) + + Current week (all models) + █▌ 3% used + Resets Feb 12 at 1:30pm (Asia/Calcutta) + + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + """ + + do { + _ = try ClaudeStatusProbe.parse(text: sample) + #expect(Bool(false), "Parsing should fail while the latest /usage panel is still loading") + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("loading")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func `parse claude status status only output does not fallback to zero`() { let sample = """ Claude Code v2.1.32 01:07 | | Opus 4.6 | default | ░░░░░░░░░░ 0% left @@ -298,7 +360,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatus_placeholderUsageWindowDoesNotUseStatusBarPercent() { + func `parse claude status placeholder usage window does not use status bar percent`() { let sample = """ Claude Code v2.1.32 01:07 | | Opus 4.6 | default | ░░░░░░░░░░ 0% left @@ -321,7 +383,7 @@ struct StatusProbeTests { } @Test - func parseClaudeStatus_compactMarkersStillParse() throws { + func `parse claude status compact markers still parse`() throws { let sample = """ Settings:StatusConfigUsage(←/→ortabtocycle) Loadingusagedata… @@ -340,10 +402,12 @@ struct StatusProbeTests { #expect(snap.sessionPercentLeft == 94) #expect(snap.weeklyPercentLeft == 96) #expect(snap.opusPercentLeft == 99) + #expect(snap.secondaryResetDescription == "ResetsFeb12at1:29pm(Asia/Calcutta)") + #expect(snap.opusResetDescription == "ResetsFeb12at1:29pm(Asia/Calcutta)") } @Test - func parseClaudeStatusWithBracketPlanNoiseNoEsc() throws { + func `parse claude status with bracket plan noise no esc`() throws { let sample = """ Login method: [22m Claude Max Account Account: user@example.com @@ -362,7 +426,7 @@ struct StatusProbeTests { } @Test - func surfacesClaudeTokenExpired() { + func `surfaces claude token expired`() { let sample = """ Settings: Status Config Usage @@ -385,7 +449,7 @@ struct StatusProbeTests { } @Test - func surfacesClaudeRateLimited_compactUsageError() { + func `surfaces claude rate limited compact usage error`() { let sample = """ Settings:StatusConfigUsage(←/→ortabtocycle) Error:Failedtoloadusagedata:{"error":{"message":"Ratelimited.Pleasetryagainlater.","type":"rate_limit_error"}} @@ -404,7 +468,7 @@ struct StatusProbeTests { } @Test - func surfacesClaudeFolderTrustPrompt() { + func `surfaces claude folder trust prompt`() { let sample = """ Do you trust the files in this folder? @@ -424,7 +488,7 @@ struct StatusProbeTests { } @Test - func surfacesClaudeFolderTrustPrompt_withCRLFAndSpaces() { + func `surfaces claude folder trust prompt with CRLF and spaces`() { let sample = "Do you trust the files in this folder?\r\n\r\n/Users/example/My Project\r\n" do { @@ -439,7 +503,7 @@ struct StatusProbeTests { } @Test - func surfacesClaudeFolderTrustPrompt_withoutFolderPath() { + func `surfaces claude folder trust prompt without folder path`() { let sample = """ Do you trust the files in this folder? """ @@ -457,7 +521,78 @@ struct StatusProbeTests { } @Test - func parsesClaudeResetTimeOnly() throws { + func `surfaces claude subscription notice without quota data`() { + let sample = """ + You are currently using your subscription to power your Claude Code usage + """ + + do { + _ = try ClaudeStatusProbe.parse(text: sample) + #expect(Bool(false), "Parsing should fail for subscription notice without quota data") + } catch let ClaudeStatusProbeError.parseFailed(message) { + let lower = message.lowercased() + #expect(lower.contains("subscription")) + #expect(!lower.contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func `parse claude status subscription notice is distinct from loading stall`() { + let subscriptionOnly = "You are currently using your subscription to power your Claude Code usage" + let loadingOnly = """ + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + """ + + do { + _ = try ClaudeStatusProbe.parse(text: subscriptionOnly) + #expect(Bool(false), "Subscription notice should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(subMessage) { + #expect(!subMessage.lowercased().contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error for subscription: \(error)") + } + + do { + _ = try ClaudeStatusProbe.parse(text: loadingOnly) + #expect(Bool(false), "Loading panel should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(loadMessage) { + #expect(loadMessage.lowercased().contains("loading")) + } catch { + #expect(Bool(false), "Unexpected error for loading: \(error)") + } + } + + @Test + func `parse claude status mixed loading and subscription notice surfaces subscription error`() { + // PTY capture containing both an intermediate "Loading usage data…" panel and the final + // Claude CLI 2.1.148 subscription notice. The subscription error must be surfaced, not + // the still-loading stall, so the UI shows the precise subscription message. + let mixedCapture = """ + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + + You are currently using your subscription to power your Claude Code usage + """ + + do { + _ = try ClaudeStatusProbe.parse(text: mixedCapture) + #expect(Bool(false), "Parsing should fail for mixed loading+subscription capture") + } catch let ClaudeStatusProbeError.parseFailed(message) { + let lower = message.lowercased() + #expect(lower.contains("subscription")) + #expect(!lower.contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error for mixed capture: \(error)") + } + } + + @Test + func `parses claude reset time only`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) let parsed = ClaudeStatusProbe.parseResetDate(from: "Resets 12:59pm (Europe/Helsinki)", now: now) let tz = try #require(TimeZone(identifier: "Europe/Helsinki")) @@ -471,7 +606,7 @@ struct StatusProbeTests { } @Test - func parsesClaudeResetDateAndTime() throws { + func `parses claude reset date and time`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) let parsed = ClaudeStatusProbe.parseResetDate(from: "Resets Dec 9, 8:59am (Europe/Helsinki)", now: now) var calendar = Calendar(identifier: .gregorian) @@ -487,7 +622,7 @@ struct StatusProbeTests { } @Test - func parsesClaudeResetWithDotSeparatedTime() throws { + func `parses claude reset with dot separated time`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) let parsed = ClaudeStatusProbe.parseResetDate(from: "Resets Dec 9 at 5.27am (UTC)", now: now) var calendar = Calendar(identifier: .gregorian) @@ -497,7 +632,7 @@ struct StatusProbeTests { } @Test - func parsesClaudeResetWithCompactTimes() throws { + func `parses claude reset with compact times`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) let parsedTimeOnly = ClaudeStatusProbe.parseResetDate(from: "Resets 1pm (UTC)", now: now) var calendar = Calendar(identifier: .gregorian) @@ -521,7 +656,17 @@ struct StatusProbeTests { } @Test - func liveCodexStatus() async throws { + func `parses claude reset with compact date and time no spaces`() throws { + let now = Date(timeIntervalSince1970: 1_773_097_200) // Mar 10, 2026 12:00:00 UTC + let parsed = ClaudeStatusProbe.parseResetDate(from: "ResetsMar13at12:30pm(Asia/Calcutta)", now: now) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Calcutta")) + let expected = calendar.date(from: DateComponents(year: 2026, month: 3, day: 13, hour: 12, minute: 30)) + #expect(parsed == expected) + } + + @Test + func `live codex status`() async throws { guard ProcessInfo.processInfo.environment["LIVE_CODEX_STATUS"] == "1" else { return } let probe = CodexStatusProbe() diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift new file mode 100644 index 000000000..2c1ee8503 --- /dev/null +++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift @@ -0,0 +1,799 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct StepFunSettingsReaderTests { + @Test + func `reads STEPFUN_TOKEN`() { + let env = ["STEPFUN_TOKEN": "some-oasis-token-value"] + #expect(StepFunSettingsReader.token(environment: env) == "some-oasis-token-value") + } + + @Test + func `reads STEPFUN_USERNAME`() { + let env = ["STEPFUN_USERNAME": "user@example.com"] + #expect(StepFunSettingsReader.username(environment: env) == "user@example.com") + } + + @Test + func `reads STEPFUN_PASSWORD`() { + let env = ["STEPFUN_PASSWORD": "secret123"] + #expect(StepFunSettingsReader.password(environment: env) == "secret123") + } + + @Test + func `trims whitespace from token`() { + let env = ["STEPFUN_TOKEN": " some-token "] + #expect(StepFunSettingsReader.token(environment: env) == "some-token") + } + + @Test + func `strips double quotes from token`() { + let env = ["STEPFUN_TOKEN": "\"some-token\""] + #expect(StepFunSettingsReader.token(environment: env) == "some-token") + } + + @Test + func `strips single quotes from token`() { + let env = ["STEPFUN_TOKEN": "'some-token'"] + #expect(StepFunSettingsReader.token(environment: env) == "some-token") + } + + @Test + func `returns nil when no env vars present`() { + #expect(StepFunSettingsReader.token(environment: [:]) == nil) + #expect(StepFunSettingsReader.username(environment: [:]) == nil) + #expect(StepFunSettingsReader.password(environment: [:]) == nil) + } + + @Test + func `returns nil for empty values`() { + let env = ["STEPFUN_TOKEN": "", "STEPFUN_USERNAME": "", "STEPFUN_PASSWORD": ""] + #expect(StepFunSettingsReader.token(environment: env) == nil) + #expect(StepFunSettingsReader.username(environment: env) == nil) + #expect(StepFunSettingsReader.password(environment: env) == nil) + } + + @Test + func `returns nil for whitespace-only values`() { + let env = ["STEPFUN_TOKEN": " "] + #expect(StepFunSettingsReader.token(environment: env) == nil) + } +} + +struct StepFunProviderTokenResolverTests { + @Test + func `resolves token from environment`() { + let env = ["STEPFUN_TOKEN": "my-test-token"] + let resolution = ProviderTokenResolver.stepfunResolution(environment: env) + #expect(resolution?.token == "my-test-token") + #expect(resolution?.source == .environment) + } + + @Test + func `returns nil when token absent`() { + let resolution = ProviderTokenResolver.stepfunResolution(environment: [:]) + #expect(resolution == nil) + } +} + +struct StepFunUsageFetcherParsingTests { + @Test + func `parses real API response format with string timestamps and integer rates`() throws { + // This matches the actual StepFun API response format: + // - timestamps as strings (e.g. "1777528800") + // - rates can be integers (e.g. 1) or floats (e.g. 0.99781543) + let json = """ + { + "status": 1, + "desc": "", + "five_hour_usage_left_rate": 1, + "five_hour_usage_reset_time": "1777528800", + "weekly_usage_left_rate": 0.99781543, + "weekly_usage_reset_time": "1777899600" + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + + #expect(snapshot.fiveHourUsageLeftRate == 1.0) + #expect(snapshot.weeklyUsageLeftRate > 0.997 && snapshot.weeklyUsageLeftRate < 0.998) + } + + @Test + func `parses response with float rates and integer timestamps`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.75, + "weekly_usage_left_rate": 0.5, + "five_hour_usage_reset_time": 1746000000, + "weekly_usage_reset_time": 1746500000 + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + + #expect(snapshot.fiveHourUsageLeftRate == 0.75) + #expect(snapshot.weeklyUsageLeftRate == 0.5) + } + + @Test + func `throws on failed API status`() { + let json = """ + { + "status": 0, + "message": "Unauthorized", + "five_hour_usage_left_rate": 0.75, + "weekly_usage_left_rate": 0.5, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_reset_time": "1746500000" + } + """ + let data = Data(json.utf8) + #expect(throws: StepFunUsageError.self) { + try StepFunUsageFetcher._parseSnapshotForTesting(data) + } + } + + @Test + func `throws on missing fields`() { + let json = """ + { + "status": 1 + } + """ + let data = Data(json.utf8) + #expect(throws: StepFunUsageError.self) { + try StepFunUsageFetcher._parseSnapshotForTesting(data) + } + } + + @Test + func `throws on invalid JSON`() { + let data = Data("not json".utf8) + #expect(throws: StepFunUsageError.self) { + try StepFunUsageFetcher._parseSnapshotForTesting(data) + } + } + + @Test + func `snapshot maps to UsageSnapshot correctly`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.8, + "weekly_usage_left_rate": 0.6, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_reset_time": "1746500000" + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + let usage = snapshot.toUsageSnapshot() + + // Five-hour window: 20% used (1.0 - 0.8) + let primaryUsed = usage.primary?.usedPercent ?? 0 + #expect(primaryUsed > 19.9 && primaryUsed < 20.1) + + // Weekly window: 40% used (1.0 - 0.6) + let secondaryUsed = usage.secondary?.usedPercent ?? 0 + #expect(secondaryUsed > 39.9 && secondaryUsed < 40.1) + #expect(usage.secondary?.windowMinutes == 10080) + + // Identity + #expect(usage.identity?.providerID == .stepfun) + #expect(usage.identity?.loginMethod == "password") + } + + @Test + func `clamps used percent to 0-100 range`() throws { + let json = """ + { + "status": 1, + "five_hour_usage_left_rate": 0.0, + "weekly_usage_left_rate": 1, + "five_hour_usage_reset_time": "1746000000", + "weekly_usage_reset_time": "1746500000" + } + """ + let data = Data(json.utf8) + let snapshot = try StepFunUsageFetcher._parseSnapshotForTesting(data) + let usage = snapshot.toUsageSnapshot() + + // 0% remaining → 100% used + #expect(usage.primary?.usedPercent == 100.0) + // 100% remaining → 0% used (integer 1 parsed as 1.0) + #expect(usage.secondary?.usedPercent == 0.0) + } +} + +struct StepFunTokenNormalizerTests { + @Test + func `extracts Oasis-Token from cookie header`() { + let input = "Oasis-Token=abc123...def456; Oasis-Webid=someid" + #expect(StepFunTokenNormalizer.normalize(input) == "abc123...def456") + } + + @Test + func `returns raw value when not a cookie header`() { + let input = "abc123...def456" + #expect(StepFunTokenNormalizer.normalize(input) == "abc123...def456") + } + + @Test + func `returns empty for empty string`() { + #expect(StepFunTokenNormalizer.normalize("").isEmpty) + } + + @Test + func `trims whitespace`() { + #expect(StepFunTokenNormalizer.normalize(" token123 ") == "token123") + } +} + +@Suite(.serialized) +struct StepFunTokenRefreshTests { + 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 + } + } + + @Test + func `refresh token returns combined token pair`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + #expect(request.url?.path.contains("RefreshToken") == true) + #expect(request.value(forHTTPHeaderField: "Oasis-Token") == "old-access...old-refresh") + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("old-access...old-refresh") == true) + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + let token = try await StepFunUsageFetcher.refreshToken(token: "old-access...old-refresh") + #expect(token == "new-access...new-refresh") + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `manual token auth failure refreshes token account and retries usage`() async throws { + let accountID = UUID() + let updateRecorder = StepFunTokenUpdateRecorder() + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("old-access...old-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("new-access...new-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + #expect(request.value(forHTTPHeaderField: "Oasis-Token") == "old-access...old-refresh") + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + selectedTokenAccountID: accountID, + tokenUpdater: { provider, updatedAccountID, token in + #expect(provider == .stepfun) + #expect(updatedAccountID == accountID) + await updateRecorder.record(token) + }) + + let result = try await StepFunWebFetchStrategy().fetch(context) + + #expect(result.usage.identity?.loginMethod == "Plus") + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + let updatedToken = await updateRecorder.recordedToken() + #expect(updatedToken == "new-access...new-refresh") + } + } + + @Test + func `manual token auth failure refreshes settings token and retries usage`() async throws { + let updateRecorder = StepFunTokenUpdateRecorder() + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("new-access...new-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + manualTokenUpdater: { provider, token in + #expect(provider == .stepfun) + await updateRecorder.record(token) + }) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + let updatedToken = await updateRecorder.recordedToken() + #expect(updatedToken == "new-access...new-refresh") + } + } + + @Test + func `stale cached token falls back to configured env token`() async throws { + CookieHeaderCache.store(provider: .stepfun, cookieHeader: "stale-access...stale-refresh", sourceLabel: "test") + defer { CookieHeaderCache.clear(provider: .stepfun) } + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("stale-access...stale-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("env-access...env-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings(cookieSource: .auto)) + let context = self.makeContext( + settings: settings, + env: ["STEPFUN_TOKEN": "env-access...env-refresh"]) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + #expect(CookieHeaderCache.load(provider: .stepfun) == nil) + } + } + + @Test + func `stale cached and env tokens fall back to env login credentials`() async throws { + CookieHeaderCache.store(provider: .stepfun, cookieHeader: "stale-access...stale-refresh", sourceLabel: "test") + defer { CookieHeaderCache.clear(provider: .stepfun) } + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.isEmpty || path == "/" { + return Self.jsonResponse( + for: request, + body: "{}", + headers: ["Set-Cookie": "INGRESSCOOKIE=ingress-cookie; Path=/"]) + } + + if path.contains("RegisterDevice") { + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "anon-access"}, + "refreshToken": {"raw": "anon-refresh"} + } + """) + } + + if path.contains("SignInByPassword") { + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "login-access"}, + "refreshToken": {"raw": "login-refresh"} + } + """) + } + + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("stale-access...stale-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + if call == 2 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("env-access...env-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("login-access...login-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings(cookieSource: .auto)) + let context = self.makeContext( + settings: settings, + env: [ + "STEPFUN_TOKEN": "env-access...env-refresh", + "STEPFUN_USERNAME": "user@example.com", + "STEPFUN_PASSWORD": "password", + ]) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 3) + #expect(recorder.refreshCallCount == 1) + #expect(CookieHeaderCache.load(provider: .stepfun)?.cookieHeader == "login-access...login-refresh") + } + } + + @Test + func `post refresh non auth usage failure is not rewritten as auth guidance`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + return Self.jsonResponse(for: request, statusCode: 500, body: #"{"error":"temporary"}"#) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext(settings: settings) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected post-refresh usage failure") + } catch let StepFunUsageError.apiError(message) { + #expect(message == "HTTP 500") + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `manual token refresh failure does not fall back to ambient env credentials`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + _ = recorder.recordUsageCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + Issue.record("Manual token recovery should not call login endpoint: \(path)") + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + env: [ + "STEPFUN_USERNAME": "someone@example.com", + "STEPFUN_PASSWORD": "secret", + ]) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected manual token auth failure") + } catch let StepFunUsageError.apiError(message) { + #expect(message.contains("Refresh the Oasis-Token")) + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 1) + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `non auth token wording does not trigger refresh recovery`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + _ = recorder.recordUsageCall() + return Self.jsonResponse( + for: request, + body: #"{"status":0,"message":"token plan status temporarily unavailable"}"#) + } + + Issue.record("Non-auth usage error should not call recovery endpoint: \(path)") + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext(settings: settings) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected provider API error") + } catch let StepFunUsageError.apiError(message) { + #expect(message == "token plan status temporarily unavailable") + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 1) + #expect(recorder.refreshCallCount == 0) + } + } + + private func makeContext( + settings: ProviderSettingsSnapshot?, + env: [String: String] = [:], + selectedTokenAccountID: UUID? = nil, + tokenUpdater: ProviderFetchContext.TokenAccountTokenUpdater? = nil, + manualTokenUpdater: ProviderFetchContext.ProviderManualTokenUpdater? = nil) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + selectedTokenAccountID: selectedTokenAccountID, + tokenAccountTokenUpdater: tokenUpdater, + providerManualTokenUpdater: manualTokenUpdater) + } + + private func withStubProtocol( + _ body: (StepFunRequestRecorder) async throws -> Void) async throws + { + let recorder = StepFunRequestRecorder() + let registered = URLProtocol.registerClass(StepFunStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(StepFunStubURLProtocol.self) + } + StepFunStubURLProtocol.handler = nil + } + try await body(recorder) + } + + private static func usageResponse(for request: URLRequest) -> (HTTPURLResponse, Data) { + self.jsonResponse( + for: request, + body: """ + { + "status": 1, + "five_hour_usage_left_rate": 0.8, + "weekly_usage_left_rate": 0.6, + "five_hour_usage_reset_time": "1777528800", + "weekly_usage_reset_time": "1777899600" + } + """) + } + + private static func jsonResponse( + for request: URLRequest, + statusCode: Int = 200, + body: String, + headers: [String: String] = ["Content-Type": "application/json"]) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: headers)! + return (response, Data(body.utf8)) + } +} + +private actor StepFunTokenUpdateRecorder { + private var token: String? + + func record(_ token: String) { + self.token = token + } + + func recordedToken() -> String? { + self.token + } +} + +private final class StepFunRequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var usageCalls = 0 + private var refreshCalls = 0 + + var usageCallCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.usageCalls + } + + var refreshCallCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.refreshCalls + } + + func recordUsageCall() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + self.usageCalls += 1 + return self.usageCalls + } + + func recordRefreshCall() { + self.lock.lock() + defer { self.lock.unlock() } + self.refreshCalls += 1 + } +} + +private final class StepFunStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "platform.stepfun.com" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/SubprocessRunnerTests.swift b/Tests/CodexBarTests/SubprocessRunnerTests.swift index 116d5da5d..9767b3221 100644 --- a/Tests/CodexBarTests/SubprocessRunnerTests.swift +++ b/Tests/CodexBarTests/SubprocessRunnerTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct SubprocessRunnerTests { @Test - func readsLargeStdoutWithoutDeadlock() async throws { + func `reads large stdout without deadlock`() async throws { let result = try await SubprocessRunner.run( binary: "/usr/bin/python3", arguments: ["-c", "print('x' * 1_000_000)"], @@ -16,4 +15,150 @@ struct SubprocessRunnerTests { #expect(result.stdout.count >= 1_000_000) #expect(result.stderr.isEmpty) } + + /// Regression test for #474: a hung subprocess must be killed and throw `.timedOut` + /// instead of blocking indefinitely. + /// + /// This test was previously deleted (commit 3961770) because `waitUntilExit()` blocked + /// the cooperative thread pool, starving the timeout task. The fix moves blocking calls + /// to `DispatchQueue.global()`, making this test reliable. + @Test + func `throws timed out when process hangs`() async throws { + let start = Date() + do { + _ = try await SubprocessRunner.run( + binary: "/bin/sleep", + arguments: ["5"], + environment: ProcessInfo.processInfo.environment, + timeout: 1, + label: "hung-process-test") + Issue.record("Expected SubprocessRunnerError.timedOut but no error was thrown") + } catch let error as SubprocessRunnerError { + guard case let .timedOut(label) = error else { + Issue.record("Expected .timedOut, got \(error)") + return + } + #expect(label == "hung-process-test") + } catch { + Issue.record("Expected SubprocessRunnerError.timedOut, got unexpected error: \(error)") + } + + let elapsed = Date().timeIntervalSince(start) + // Must complete in well under 5s (the sleep duration). Allow generous bound for CI. + #expect(elapsed < 3, "Timeout should fire in ~1s, not wait for process to exit naturally") + } + + /// Multiple concurrent hung subprocesses must all time out independently, proving that + /// one blocked subprocess does not starve the timeout mechanism of others. + /// This is the core scenario that caused the original permanent-refresh-stall bug. + @Test + func `concurrent hung processes all time out`() async { + let start = Date() + let count = 8 + + await withTaskGroup(of: Void.self) { group in + for i in 0.. UsageMenuCardView.Model + { + let identity = ProviderIdentitySnapshot( + providerID: .synthetic, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: nil, + providerCost: providerCost, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.synthetic]) + return UsageMenuCardView.Model.make(.init( + provider: .synthetic, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + @Test + func `rolling regen text uses parsed tickPercent not hardcoded fallback`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let primary = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil, + nextRegenPercent: 2) + let model = try Self.makeModel(primary: primary, now: now) + let metric = try #require(model.metrics.first) + // 50% used / 2% per tick = 25 ticks to full. + #expect(metric.detailRightText == "Full in ~25 regens") + #expect(metric.detailLeftText == "52% after next regen") + } + + @Test + func `rolling regen omits Synthetic-specific text when tickPercent is missing`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let primary = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(900), + resetDescription: nil, + nextRegenPercent: nil) + let model = try Self.makeModel(primary: primary, now: now) + let metric = try #require(model.metrics.first) + // Without nextRegenPercent we no longer assert a regen-specific label; + // the renderer must not fabricate ticks-to-full from a guessed rate. + #expect(metric.detailRightText?.contains("regen") != true) + } + + @Test + func `weekly regen text near full reports both labels consistently`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let secondary = RateWindow( + usedPercent: 1, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil) + let cost = ProviderCostSnapshot( + used: 0.36, + limit: 36, + currencyCode: "USD", + period: "Weekly", + resetsAt: now.addingTimeInterval(3600), + nextRegenAmount: 0.72, + updatedAt: now) + let model = try Self.makeModel( + primary: nil, + secondary: secondary, + providerCost: cost, + now: now) + let weekly = try #require(model.metrics.first(where: { $0.id == "secondary" })) + // used=$0.36 / nextRegen=$0.72 = 0.5 ticks → between 0.1 and 1.5 → "Full in ~1 regen". + #expect(weekly.detailRightText == "Full in ~1 regen") + // remaining 99% + 2% next regen caps at 100% → "100% after next regen". + #expect(weekly.detailLeftText == "100% after next regen") + } +} diff --git a/Tests/CodexBarTests/SyntheticProviderTests.swift b/Tests/CodexBarTests/SyntheticProviderTests.swift index aad33712b..abe9765b0 100644 --- a/Tests/CodexBarTests/SyntheticProviderTests.swift +++ b/Tests/CodexBarTests/SyntheticProviderTests.swift @@ -2,25 +2,23 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct SyntheticSettingsReaderTests { @Test - func apiKeyReadsFromEnvironment() { + func `api key reads from environment`() { let token = SyntheticSettingsReader.apiKey(environment: ["SYNTHETIC_API_KEY": "abc123"]) #expect(token == "abc123") } @Test - func apiKeyStripsQuotes() { + func `api key strips quotes`() { let token = SyntheticSettingsReader.apiKey(environment: ["SYNTHETIC_API_KEY": "\"token-xyz\""]) #expect(token == "token-xyz") } } -@Suite struct SyntheticUsageSnapshotTests { @Test - func mapsUsageSnapshotWindows() throws { + func `maps usage snapshot windows`() throws { let json = """ { "plan": "Starter", @@ -41,7 +39,7 @@ struct SyntheticUsageSnapshotTests { } @Test - func parsesSubscriptionQuota() throws { + func `parses subscription quota`() throws { let json = """ { "subscription": { @@ -63,4 +61,167 @@ struct SyntheticUsageSnapshotTests { #expect(usage.primary?.resetsAt == expectedReset) #expect(usage.loginMethod(for: .synthetic) == nil) } + + @Test + func `parses nested subscription pack quota`() throws { + let json = """ + { + "subscription": { + "packs": 2, + "rateLimit": { + "messages": 1000, + "requests": 250, + "period": "5hr", + "resetsAt": "2026-04-16T18:00:00Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let expectedReset = try #require(formatter.date(from: "2026-04-16T18:00:00Z")) + + #expect(usage.primary?.usedPercent == 25) + #expect(usage.primary?.windowMinutes == 300) + #expect(usage.primary?.resetsAt == expectedReset) + } + + @Test + func `parses live root level rolling and weekly quotas`() throws { + let json = """ + { + "subscription": { + "limit": 750, + "requests": 0, + "renewsAt": "2026-04-17T08:35:49.493Z" + }, + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.05884722222223, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "tickPercent": 0.05, + "remaining": 750, + "max": 750, + "limited": false + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let expectedPrimaryReset = try #require(formatter.date(from: "2026-04-17T03:44:11Z")) + let expectedSecondaryReset = try #require(formatter.date(from: "2026-04-17T05:19:30Z")) + let expectedTertiaryReset = try #require(fractionalFormatter.date(from: "2026-04-17T04:30:01.494Z")) + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.primary?.resetsAt == expectedPrimaryReset) + #expect(usage.primary?.resetDescription == nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 1.9411527777777715) < 0.001) + #expect(usage.secondary?.resetsAt == expectedSecondaryReset) + #expect(usage.secondary?.resetDescription == nil) + #expect(usage.tertiary?.usedPercent == 0.8) + #expect(usage.tertiary?.resetsAt == expectedTertiaryReset) + #expect(usage.providerCost?.limit == 36) + #expect(abs((usage.providerCost?.used ?? 0) - 0.7) < 0.0001) + #expect(usage.providerCost?.nextRegenAmount == 0.72) + } + + @Test + func `parses rolling lane tickPercent into primary nextRegenPercent`() throws { + let json = """ + { + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "tickPercent": 0.05, + "remaining": 750, + "max": 750, + "limited": false + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.nextRegenPercent == 5.0) + } + + @Test + func `omits nextRegenPercent when rolling lane lacks tickPercent`() throws { + let json = """ + { + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "remaining": 750, + "max": 750 + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.nextRegenPercent == nil) + } + + @Test + func `parses time string suffixes covering minutes hours and days`() { + #expect(SyntheticUsageParser.windowMinutes(fromText: "5min") == 5) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5m") == 5) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5hr") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5h") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "5hours") == 300) + #expect(SyntheticUsageParser.windowMinutes(fromText: "2days") == 2880) + #expect(SyntheticUsageParser.windowMinutes(fromText: "2d") == 2880) + #expect(SyntheticUsageParser.windowMinutes(fromText: "1 hour") == 60) + #expect(SyntheticUsageParser.windowMinutes(fromText: "junk") == nil) + #expect(SyntheticUsageParser.windowMinutes(fromText: "") == nil) + } + + @Test + func `preserves slot identity when rolling lane is missing`() throws { + let json = """ + { + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.0, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } + } + """ + let data = try #require(json.data(using: .utf8)) + let snapshot = try SyntheticUsageParser.parse(data: data, now: Date(timeIntervalSince1970: 123)) + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(abs((usage.secondary?.usedPercent ?? 0) - 2.0) < 0.001) + #expect(usage.tertiary?.usedPercent == 0.8) + #expect(usage.providerCost?.limit == 36) + } } diff --git a/Tests/CodexBarTests/T3ChatUsageFetcherTests.swift b/Tests/CodexBarTests/T3ChatUsageFetcherTests.swift new file mode 100644 index 000000000..83e69ef33 --- /dev/null +++ b/Tests/CodexBarTests/T3ChatUsageFetcherTests.swift @@ -0,0 +1,295 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct T3ChatUsageFetcherTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private static let now = Date(timeIntervalSince1970: 1_778_000_000) + // 2026-05-21T12:23:36Z, the usage-window reset that must not drive overage reset display. + private static let billingNextResetMilliseconds = 1_779_366_216_920 + // 2026-06-06T16:23:29Z, the subscription period end used for overage reset display. + private static let subscriptionPeriodEndSeconds = 1_780_763_009 + private static let subscriptionPeriodEndMilliseconds = Self.subscriptionPeriodEndSeconds * 1000 + + private static let sampleResponse = [ + #"{"json":{"0":[[0],[null,0,0]]}}"#, + #"{"json":[0,0,[[{"result":0}],["result",0,1]]]}"#, + #"{"json":[1,0,[[{"data":0}],["data",0,2]]]}"#, + #"{"json":[2,0,[[{"subTier":"pro","subscription":{"# + + #""productId":"pro","productName":"pro","status":"active","# + + #""currentPeriodStart":1778084609000,"currentPeriodEnd":1780763009000,"# + + #""canceledAt":null,"trialEndsAt":null},"lifetimeBalance":0,"usageBand":"max","# + + #""billingNextResetAt":1779366216920,"usageFourHourPercentage":12.5,"# + + #""usageMonthPercentage":34.25,"usageFourHourNextResetAt":1779366216920,"# + + #""usagePeriodPercentage":44,"usageWindowNextResetAt":1779366216920}]]]}"#, + ].joined(separator: "\n") + + @Test + func `parses customer data from json lines response`() throws { + let snapshot = try T3ChatUsageParser.parseJSONLines(Self.sampleResponse, now: Self.now) + + #expect(snapshot.customerData.subTier == "pro") + #expect(snapshot.customerData.usageBand == "max") + #expect(snapshot.customerData.usageFourHourPercentage == 12.5) + #expect(snapshot.customerData.usageMonthPercentage == 34.25) + #expect(snapshot.customerData.subscription?.status == "active") + } + + @Test + func `maps customer data to base and overage windows`() throws { + let usage = try T3ChatUsageParser.parseJSONLines(Self.sampleResponse, now: Self.now) + .toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 12.5) + #expect(usage.primary?.windowMinutes == 240) + #expect(usage.primary?.resetDescription == "Base - max") + #expect(usage.secondary?.usedPercent == 34.25) + #expect(usage.secondary?.resetDescription == "Overage") + #expect(usage.secondary?.resetsAt.map { Int($0.timeIntervalSince1970) } == Self.subscriptionPeriodEndSeconds) + #expect(usage.identity?.providerID == .t3chat) + #expect(usage.identity?.loginMethod == "Pro") + } + + @Test + func `falls back to usage period percentage when month percentage is absent`() throws { + let response = """ + {"json":[2,0,[[{"subTier":"free","usageFourHourPercentage":5,"usagePeriodPercentage":65}]]]} + """ + let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now) + .toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 5) + #expect(usage.secondary?.usedPercent == 65) + } + + @Test + func `overage reset ignores billing next reset`() throws { + let response = Self.customerDataResponse( + #"{"usageMonthPercentage":20,"billingNextResetAt":\#(Self.billingNextResetMilliseconds)}"#) + let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now) + .toUsageSnapshot() + + #expect(usage.secondary?.usedPercent == 20) + #expect(usage.secondary?.resetsAt == nil) + } + + @Test + func `overage reset uses subscription current period end`() throws { + let currentPeriodEnd = Self.subscriptionPeriodEndMilliseconds + let response = Self.customerDataResponse( + #"{"usageMonthPercentage":20,"subscription":{"currentPeriodEnd":\#(currentPeriodEnd)}}"#) + let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now) + .toUsageSnapshot() + + #expect(usage.secondary?.usedPercent == 20) + #expect(usage.secondary?.resetsAt.map { Int($0.timeIntervalSince1970) } == Self.subscriptionPeriodEndSeconds) + } + + @Test + func `fetch sends trpc headers and cookie`() async throws { + let stub = ProviderHTTPTransportStub { request in + #expect(request.url?.host == "t3.chat") + #expect(request.url?.path == "/api/trpc/getCustomerData") + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc") + #expect(request.value(forHTTPHeaderField: "trpc-accept") == "application/jsonl") + #expect(request.value(forHTTPHeaderField: "x-trpc-source") == "web-client") + #expect(request.value(forHTTPHeaderField: "Sec-Fetch-Site") == "same-origin") + #expect(request.url?.query?.contains("batch=1") == true) + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let snapshot = try await T3ChatUsageFetcher.fetchCustomerData( + cookieHeader: "session=abc", + now: Self.now, + transport: stub) + + #expect(snapshot.customerData.planName == "Pro") + } + + @Test + func `full curl capture forwards browser fingerprint headers`() async throws { + let curl = """ + curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\ + -H 'User-Agent: Mozilla/5.0 Firefox/151.0' \\ + --header "Referer: https://t3.chat/settings/customization" \\ + -H 'trpc-accept: application/jsonl' \\ + -H 'x-trpc-source: web-client' \\ + -H 'x-trpc-batch: true' \\ + -H 'X-Deployment-Id: dpl_test' \\ + -H 'x-client-context: eyJjbGllbnQiOnsidmVyc2lvbiI6IjEuMTIuNCJ9fQ==' \\ + -H 'Cookie: session=abc' + """ + let stub = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc") + #expect(request.value(forHTTPHeaderField: "User-Agent") == "Mozilla/5.0 Firefox/151.0") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://t3.chat/settings/customization") + #expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test") + #expect(request.value(forHTTPHeaderField: "x-client-context") == + "eyJjbGllbnQiOnsidmVyc2lvbiI6IjEuMTIuNCJ9fQ==") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + _ = try await fetcher.fetch( + cookieHeaderOverride: curl, + now: Self.now, + transport: stub) + } + + @Test + func `curl capture forwards ansi quoted and equals header forms`() async throws { + let curl = """ + curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\ + --header=$'User-Agent: Browser\\'s Agent' \\ + --header 'X-Deployment-Id: dpl_test' \\ + -H 'Cookie: session=abc' + """ + let stub = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc") + #expect(request.value(forHTTPHeaderField: "User-Agent") == "Browser's Agent") + #expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + _ = try await fetcher.fetch( + cookieHeaderOverride: curl, + now: Self.now, + transport: stub) + } + + @Test + func `full curl capture extracts cookie from long header form`() async throws { + let curl = """ + curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\ + --compressed \\ + --header "Referer: https://t3.chat/settings/customization" \\ + --header "Cookie: session=abc; cf_clearance=token" \\ + --header "X-Deployment-Id: dpl_test" + """ + let stub = ProviderHTTPTransportStub { request in + #expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc; cf_clearance=token") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://t3.chat/settings/customization") + #expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (Data(Self.sampleResponse.utf8), response) + } + + let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0)) + _ = try await fetcher.fetch( + cookieHeaderOverride: curl, + now: Self.now, + transport: stub) + } + + @Test + func `manual strategy accepts full curl capture`() async { + let curl = """ + curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\ + --header "Referer: https://t3.chat/settings/customization" \\ + --header "Cookie: session=abc; cf_clearance=token" \\ + --header "X-Deployment-Id: dpl_test" + """ + let settings = ProviderSettingsSnapshot.make( + t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings( + cookieSource: .manual, + manualCookieHeader: curl)) + + #expect(await T3ChatWebFetchStrategy().isAvailable(Self.makeContext(settings: settings))) + } + + @Test + func `unauthorized response is invalid credentials`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 401, + httpVersion: nil, + headerFields: nil)! + return (Data("unauthorized".utf8), response) + } + + await #expect { + _ = try await T3ChatUsageFetcher.fetchCustomerData( + cookieHeader: "session=abc", + now: Self.now, + transport: stub) + } throws: { error in + guard case T3ChatUsageError.invalidCredentials = error else { return false } + return true + } + } + + @Test + func `vercel challenge response asks for full curl capture`() async throws { + let stub = ProviderHTTPTransportStub { request in + let response = HTTPURLResponse( + url: request.url!, + statusCode: 429, + httpVersion: nil, + headerFields: ["x-vercel-mitigated": "challenge"])! + return (Data("checkpoint".utf8), response) + } + + await #expect { + _ = try await T3ChatUsageFetcher.fetchCustomerData( + cookieHeader: "session=abc", + now: Self.now, + transport: stub) + } throws: { error in + guard case T3ChatUsageError.vercelChallenge = error else { return false } + return true + } + } + + private static func customerDataResponse(_ customerDataJSON: String) -> String { + #"{"json":[2,0,[[\#(customerDataJSON)]]]}"# + "\n" + } + + private static func makeContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } +} diff --git a/Tests/CodexBarTests/TTYCommandRunnerTests.swift b/Tests/CodexBarTests/TTYCommandRunnerTests.swift index be4bc1958..d39040690 100644 --- a/Tests/CodexBarTests/TTYCommandRunnerTests.swift +++ b/Tests/CodexBarTests/TTYCommandRunnerTests.swift @@ -4,8 +4,25 @@ import Testing @Suite(.serialized) struct TTYCommandRunnerEnvTests { + private final class CallbackCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + self.lock.lock() + self.count += 1 + self.lock.unlock() + } + + func value() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.count + } + } + @Test - func shutdownFenceDrainsTrackedTTYProcesses() { + func `shutdown fence drains tracked TTY processes`() { TTYCommandRunner._test_resetTrackedProcesses() defer { TTYCommandRunner._test_resetTrackedProcesses() } @@ -19,7 +36,20 @@ struct TTYCommandRunnerEnvTests { } @Test - func trackedProcessHelpersIgnoreInvalidPID() { + func `cached CLI sessions share shutdown tracking`() { + TTYCommandRunner._test_resetTrackedProcesses() + defer { TTYCommandRunner._test_resetTrackedProcesses() } + + #expect(TTYCommandRunner.registerActiveProcessForAppShutdown(pid: 3001, binary: "codex")) + TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: 3001, processGroup: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 1) + + TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: 3001) + #expect(TTYCommandRunner._test_trackedProcessCount() == 0) + } + + @Test + func `tracked process helpers ignore invalid PID`() { TTYCommandRunner._test_resetTrackedProcesses() defer { TTYCommandRunner._test_resetTrackedProcesses() } @@ -28,7 +58,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func shutdownFenceRejectsNewRegistrations() { + func `shutdown fence rejects new registrations`() { TTYCommandRunner._test_resetTrackedProcesses() defer { TTYCommandRunner._test_resetTrackedProcesses() } @@ -41,7 +71,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func shutdownResolverSkipsHostProcessGroupFallback() { + func `shutdown resolver skips host process group fallback`() { let hostGroup: pid_t = 4242 let targets: [(pid: pid_t, binary: String, processGroup: pid_t?)] = [ (pid: 100, binary: "codex", processGroup: nil), @@ -63,7 +93,44 @@ struct TTYCommandRunnerEnvTests { } @Test - func preservesEnvironmentAndSetsTerm() { + func `descendant resolver walks process tree once`() { + let children: [pid_t: [pid_t]] = [ + 100: [101, 102], + 101: [103], + 102: [103], + 103: [100], + ] + + let descendants = TTYProcessTreeTerminator.descendantPIDs(of: 100) { children[$0] ?? [] } + + #expect(Set(descendants) == Set([101, 102, 103])) + #expect(descendants.count == 3) + } + + @Test + func `process tree termination signals escaped descendants`() { + let children: [pid_t: [pid_t]] = [ + 100: [101, 102], + 102: [103], + ] + var signaled: [(pid: pid_t, signal: Int32)] = [] + + TTYProcessTreeTerminator.terminateProcessTree( + rootPID: 100, + processGroup: 200, + signal: 15, + childResolver: { children[$0] ?? [] }, + signalSender: { pid, signal in + signaled.append((pid: pid, signal: signal)) + }) + + #expect(Set(signaled.map(\.pid)) == Set([100, 101, 102, 103, -200])) + #expect(signaled.allSatisfy { $0.signal == 15 }) + #expect(signaled.last?.pid == 100) + } + + @Test + func `preserves environment and sets term`() { let baseEnv: [String: String] = [ "PATH": "/custom/bin", "HOME": "/Users/tester", @@ -83,7 +150,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func backfillsHomeWhenMissing() { + func `backfills home when missing`() { let merged = TTYCommandRunner.enrichedEnvironment( baseEnv: ["PATH": "/custom/bin"], loginPATH: nil, @@ -93,7 +160,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func preservesExistingTermAndCustomVars() { + func `preserves existing term and custom vars`() { let merged = TTYCommandRunner.enrichedEnvironment( baseEnv: [ "PATH": "/custom/bin", @@ -111,7 +178,25 @@ struct TTYCommandRunnerEnvTests { } @Test - func setsWorkingDirectoryWhenProvided() throws { + func `codex status probe uses non persistent thread storage`() { + let stateHome = URL(fileURLWithPath: "/tmp/codexbar status \"state\"", isDirectory: true) + let args = CodexStatusProbeIsolation.codexArguments(stateHome: stateHome) + + #expect(args.starts(with: ["-s", "read-only", "-a", "untrusted"])) + #expect(args.contains("history.persistence=\"none\"")) + #expect(args.contains("experimental_thread_store={type=\"in_memory\",id=\"codexbar-status\"}")) + #expect(args.contains("sqlite_home=\"/tmp/codexbar status \\\"state\\\"\"")) + } + + @Test + func `codex status probe avoids root working directory when home exists`() { + let home = "/Users/tester" + let workingDirectory = CodexStatusProbeIsolation.workingDirectory(environment: ["HOME": home]) + #expect(workingDirectory?.path == home) + } + + @Test + func `sets working directory when provided`() throws { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) @@ -123,7 +208,56 @@ struct TTYCommandRunnerEnvTests { } @Test - func autoRespondsToTrustPrompt() throws { + func `claude runner keeps normal working directory by default`() throws { + let runner = TTYCommandRunner() + let fakeClaude = try Self.makeFakeClaudeCLI() + let result = try runner.run( + binary: fakeClaude.path, + send: "", + options: .init(timeout: 3, stopOnSubstrings: ["deep-link-enabled"])) + let clean = result.text.replacingOccurrences(of: "\r", with: "") + + #expect(clean.contains("deep-link-enabled")) + } + + @Test + func `claude runner uses probe directory with deep link registration disabled when requested`() throws { + let runner = TTYCommandRunner() + let fakeClaude = try Self.makeFakeClaudeCLI() + let result = try runner.run( + binary: fakeClaude.path, + send: "", + options: .init( + timeout: 3, + stopOnSubstrings: ["deep-link-disabled"], + useClaudeProbeWorkingDirectory: true)) + let clean = result.text.replacingOccurrences(of: "\r", with: "") + + #expect(clean.contains("deep-link-disabled")) + } + + @Test + func `claude runner uses probe directory for versioned CLI override`() throws { + let runner = TTYCommandRunner() + let fakeClaude = try Self.makeFakeClaudeCLI(fileName: "2.1.114") + var env = ProcessInfo.processInfo.environment + env["CLAUDE_CLI_PATH"] = fakeClaude.path + + let result = try runner.run( + binary: fakeClaude.path, + send: "", + options: .init( + timeout: 3, + baseEnvironment: env, + stopOnSubstrings: ["deep-link-disabled"], + useClaudeProbeWorkingDirectory: true)) + let clean = result.text.replacingOccurrences(of: "\r", with: "") + + #expect(clean.contains("deep-link-disabled")) + } + + @Test + func `auto responds to trust prompt`() throws { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) @@ -150,7 +284,7 @@ struct TTYCommandRunnerEnvTests { binary: scriptURL.path, send: "", options: .init( - timeout: 6, + timeout: 15, // Use LF for portability: some PTY/termios setups do not translate CR → NL for shell reads. sendOnSubstrings: ["trust the files in this folder?": "y\n"], stopOnSubstrings: ["accepted", "rejected"], @@ -159,8 +293,118 @@ struct TTYCommandRunnerEnvTests { #expect(result.text.contains("accepted")) } + private static func makeFakeClaudeCLI(fileName: String = "claude") throws -> URL { + let fm = FileManager.default + let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + let scriptURL = dir.appendingPathComponent(fileName) + let script = """ + #!/bin/sh + settings="$PWD/.claude/settings.local.json" + if [ -f "$settings" ] \ + && grep -q '"disableDeepLinkRegistration"' "$settings" \ + && grep -q '"disable"' "$settings"; then + echo "deep-link-disabled" + else + echo "deep-link-enabled" + fi + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: scriptURL.path) + return scriptURL + } + + @Test + func `post-exit drain processes trailing chunk through callback path`() { + let callbackCounter = CallbackCounter() + var reads: [TTYCommandRunner.DrainReadResult] = [ + .wouldBlock, + .wouldBlock, + .data(Data("https://example.com/auth".utf8)), + .closed, + ] + + TTYCommandRunner.drainRemainingOutput( + until: Date().addingTimeInterval(1), + readChunk: { + if reads.isEmpty { return .closed } + return reads.removeFirst() + }, + processChunk: { data in + if data.range(of: Data("https://".utf8)) != nil { + callbackCounter.increment() + } + }, + sleep: { _ in }) + + #expect(callbackCounter.value() == 1) + } + + @Test + func `post-exit drain keeps harvesting after late success marker`() { + var readCount = 0 + var processedChunks: [String] = [] + var reads: [TTYCommandRunner.DrainReadResult] = [ + .data(Data("accepted".utf8)), + .wouldBlock, + .data(Data(" trailing".utf8)), + .closed, + ] + + TTYCommandRunner.drainRemainingOutput( + until: Date().addingTimeInterval(1), + readChunk: { + readCount += 1 + if reads.isEmpty { return .closed } + return reads.removeFirst() + }, + processChunk: { data in + processedChunks.append(String(bytes: data, encoding: .utf8) ?? "") + }, + sleep: { _ in }) + + #expect(readCount == 4) + #expect(processedChunks == ["accepted", " trailing"]) + } + + @Test + func `post-exit drain stops once the PTY reports closure`() { + var readCount = 0 + + TTYCommandRunner.drainRemainingOutput( + until: Date().addingTimeInterval(1), + readChunk: { + readCount += 1 + return .closed + }, + processChunk: { _ in }, + sleep: { _ in }) + + #expect(readCount == 1) + } + + @Test + func `interrupted drain reads are treated as retryable`() { + let result = TTYCommandRunner.drainReadResult(for: Data(), terminalRead: -1, errno: EINTR) + if case .wouldBlock = result { + #expect(Bool(true)) + } else { + Issue.record("Expected interrupted read to remain retryable during drain") + } + } + + @Test + func `EOF beats stale would-block errno during drain classification`() { + let result = TTYCommandRunner.drainReadResult(for: Data(), terminalRead: 0, errno: EAGAIN) + if case .closed = result { + #expect(Bool(true)) + } else { + Issue.record("Expected EOF reads to stop draining even if errno still holds EAGAIN") + } + } + @Test - func stopsWhenOutputIsIdle() throws { + func `stops when output is idle`() throws { let fm = FileManager.default let dir = fm.temporaryDirectory.appendingPathComponent("codexbar-tty-\(UUID().uuidString)", isDirectory: true) try fm.createDirectory(at: dir, withIntermediateDirectories: true) @@ -195,7 +439,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func rollingBufferDetectsNeedleAcrossBoundary() { + func `rolling buffer detects needle across boundary`() { var scanner = TTYCommandRunner.RollingBuffer(maxNeedle: 6) let needle = Data("hello".utf8) let first = scanner.append(Data("he".utf8)) @@ -205,7 +449,7 @@ struct TTYCommandRunnerEnvTests { } @Test - func lowercasedASCIIOnlyTouchesAscii() { + func `lowercased ASCII only touches ascii`() { let data = Data("UpDaTe".utf8) let lowered = TTYCommandRunner.lowercasedASCII(data) #expect(String(data: lowered, encoding: .utf8) == "update") diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index adf59bccb..dca1536fa 100644 --- a/Tests/CodexBarTests/TTYIntegrationTests.swift +++ b/Tests/CodexBarTests/TTYIntegrationTests.swift @@ -6,7 +6,10 @@ import Testing @Suite(.serialized) struct TTYIntegrationTests { @Test - func codexRPCUsageLive() async throws { + func `codex RPC usage live`() async throws { + guard ProcessInfo.processInfo.environment["LIVE_CODEX_TTY"] == "1" else { + return + } let fetcher = UsageFetcher() do { let snapshot = try await fetcher.loadLatestUsage() @@ -23,7 +26,7 @@ struct TTYIntegrationTests { } @Test - func claudeTTYUsageProbeLive() async throws { + func `claude TTY usage probe live`() async throws { guard ProcessInfo.processInfo.environment["LIVE_CLAUDE_TTY"] == "1" else { return } @@ -55,4 +58,85 @@ struct TTYIntegrationTests { if !shouldAssert { return } } + + @Test + func `claude pty usage waits for values after session label`() async throws { + let cli = try Self.makeSlowUsageClaudeCLI() + defer { Task { await ClaudeCLISession.shared.reset() } } + + let snapshot = try await ClaudeCLISession.withIsolatedSessionForTesting { + try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 8).fetch() + } + + #expect(snapshot.sessionPercentLeft == 93) + #expect(snapshot.weeklyPercentLeft == 79) + } + + @Test + func `claude pty usage stops on subscription notice`() async throws { + let cli = try Self.makeSubscriptionNoticeClaudeCLI() + defer { Task { await ClaudeCLISession.shared.reset() } } + + do { + try await ClaudeCLISession.withIsolatedSessionForTesting { + _ = try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 3).fetch() + } + #expect(Bool(false), "Subscription notice should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("subscription")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + private static func makeSlowUsageClaudeCLI() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("claude") + let script = """ + #!/bin/sh + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf '%s\\n' 'Settings Status Config Usage' + printf '%s\\n' 'Current session' + sleep 4 + printf '%s\\n' '93% left' + printf '%s\\n' 'Current week (all models)' + printf '%s\\n' '79% left' + ;; + *"/status"*) + printf '%s\\n' 'Account: slow-usage@example.com' + ;; + esac + done + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } + + private static func makeSubscriptionNoticeClaudeCLI() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("claude") + let script = """ + #!/bin/sh + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf '%s\\n' 'You are currently using your subscription to power your Claude Code usage' + ;; + *"/status"*) + printf '%s\\n' 'Account: subscription@example.com' + ;; + esac + done + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } } diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index 7d8e27491..185248593 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -1,6 +1,9 @@ import CodexBarCore import Foundation @testable import CodexBar +#if os(macOS) +import AppKit +#endif final class InMemoryCookieHeaderStore: CookieHeaderStoring, @unchecked Sendable { var value: String? @@ -132,3 +135,57 @@ func testConfigStore(suiteName: String, reset: Bool = true) -> CodexBarConfigSto } return CodexBarConfigStore(fileURL: url) } + +#if os(macOS) +@MainActor +@discardableResult +func withStatusItemControllerForTesting( + store: UsageStore, + settings: SettingsStore, + fetcher: UsageFetcher, + statusBar: NSStatusBar = .system, + operation: (StatusItemController) throws -> T) rethrows -> T +{ + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: statusBar) + defer { controller.releaseStatusItemsForTesting() } + return try operation(controller) +} + +@MainActor +@discardableResult +func withStatusItemControllerForTesting( + store: UsageStore, + settings: SettingsStore, + fetcher: UsageFetcher, + statusBar: NSStatusBar = .system, + operation: (StatusItemController) async throws -> T) async rethrows -> T +{ + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: statusBar) + defer { controller.releaseStatusItemsForTesting() } + return try await operation(controller) +} +#endif + +func testPlanUtilizationHistoryStore(suiteName: String, reset: Bool = true) -> PlanUtilizationHistoryStore { + let sanitized = suiteName.replacingOccurrences(of: "/", with: "-") + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(sanitized, isDirectory: true) + let url = base.appendingPathComponent("history", isDirectory: true) + if reset { + try? FileManager.default.removeItem(at: url) + } + return PlanUtilizationHistoryStore(directoryURL: url) +} diff --git a/Tests/CodexBarTests/TextParsingTests.swift b/Tests/CodexBarTests/TextParsingTests.swift index 812adbe02..ad4d07d25 100644 --- a/Tests/CodexBarTests/TextParsingTests.swift +++ b/Tests/CodexBarTests/TextParsingTests.swift @@ -1,17 +1,16 @@ import CodexBarCore import Testing -@Suite struct TextParsingTests { @Test - func stripANSICodesRemovesCursorVisibilityCSI() { + func `strip ANSI codes removes cursor visibility CSI`() { let input = "\u{001B}[?25hhello\u{001B}[0m" let stripped = TextParsing.stripANSICodes(input) #expect(stripped == "hello") } @Test - func firstNumberParsesDecimalSeparators() { + func `first number parses decimal separators`() { let dotDecimal = TextParsing.firstNumber(pattern: #"Credits:\s*([0-9][0-9., ]*)"#, text: "Credits: 54.72") #expect(dotDecimal == 54.72) diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index cb36b24ae..14dfe6392 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -4,11 +4,11 @@ import Testing @testable import CodexBar @testable import CodexBarCLI +@Suite(.serialized) @MainActor -@Suite struct TokenAccountEnvironmentPrecedenceTests { @Test - func tokenAccountEnvironmentOverridesConfigAPIKey_inAppEnvironmentBuilder() { + func `token account environment overrides config API key in app environment builder`() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-app") settings.zaiAPIToken = "config-token" settings.addTokenAccount(provider: .zai, label: "Account 1", token: "account-token") @@ -25,7 +25,22 @@ struct TokenAccountEnvironmentPrecedenceTests { } @Test - func tokenAccountEnvironmentOverridesConfigAPIKey_inCLIEnvironmentBuilder() throws { + func `deepseek token account injects environment in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-deepseek-app") + settings.addTokenAccount(provider: .deepseek, label: "Account 1", token: "account-token") + + let env = ProviderRegistry.makeEnvironment( + base: ["FOO": "bar"], + provider: .deepseek, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == "account-token") + } + + @Test + func `token account environment overrides config API key in CLI environment builder`() throws { let config = CodexBarConfig( providers: [ ProviderConfig(id: .zai, apiKey: "config-token"), @@ -46,7 +61,24 @@ struct TokenAccountEnvironmentPrecedenceTests { } @Test - func ollamaTokenAccountSelectionForcesManualCookieSourceInCLISettingsSnapshot() throws { + func `deepseek token account injects environment in CLI environment builder`() throws { + let config = CodexBarConfig(providers: []) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = ProviderTokenAccount( + id: UUID(), + label: "Account 1", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + + let env = tokenContext.environment(base: [:], provider: .deepseek, account: account) + + #expect(env[DeepSeekSettingsReader.apiKeyEnvironmentKey] == "account-token") + } + + @Test + func `ollama token account selection forces manual cookie source in CLI settings snapshot`() throws { let accounts = ProviderTokenAccountData( version: 1, accounts: [ @@ -76,7 +108,534 @@ struct TokenAccountEnvironmentPrecedenceTests { } @Test - func applyAccountLabelInAppPreservesSnapshotFields() { + func `stepfun CLI snapshot reads manual token from region field`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .stepfun, + region: "Oasis-Token=manual-token; Oasis-Webid=web"), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .stepfun, account: nil)) + let stepfunSettings = try #require(snapshot.stepfun) + + #expect(stepfunSettings.cookieSource == .manual) + #expect(stepfunSettings.manualToken == "Oasis-Token=manual-token; Oasis-Webid=web") + } + + @Test + func `stepfun CLI token account overrides region manual token`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "StepFun", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .stepfun, + region: "manual-token", + tokenAccounts: ProviderTokenAccountData( + version: 1, + accounts: [account], + activeIndex: 0)), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let resolvedAccount = try #require(tokenContext.resolvedAccounts(for: .stepfun).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .stepfun, account: resolvedAccount)) + let stepfunSettings = try #require(snapshot.stepfun) + + #expect(stepfunSettings.cookieSource == .manual) + #expect(stepfunSettings.manualToken == "account-token") + } + + @Test + func `claude OAuth token account overrides environment in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-app") + settings.addTokenAccount(provider: .claude, label: "OAuth", token: "Bearer sk-ant-oat-account-token") + + let env = ProviderRegistry.makeEnvironment( + base: ["FOO": "bar"], + provider: .claude, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account-token") + } + + @Test + func `claude session account strips ambient admin api credentials in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-admin-strip-app") + settings.claudeAdminAPIKey = "sk-ant-admin-config" + settings.addTokenAccount(provider: .claude, label: "Session", token: "sk-ant-session-token") + + let env = ProviderRegistry.makeEnvironment( + base: [ + "FOO": "bar", + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "sk-ant-admin-base", + ClaudeOAuthCredentialsStore.environmentTokenKey: "sk-ant-oat-base", + ], + provider: .claude, + settings: settings, + tokenOverride: nil) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == nil) + } + + @Test + func `claude session key selection carries organization id in app settings snapshot`() throws { + let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-org-app") + settings.addTokenAccount( + provider: .claude, + label: "Team", + token: "sk-ant-session-token", + organizationID: " org-team ") + + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: settings, tokenOverride: nil) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token") + #expect(claudeSettings.organizationID == "org-team") + } + + @Test + func `claude OAuth token selection forces OAuth in CLI settings snapshot`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieSource: .auto, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.usageDataSource == .oauth) + #expect(claudeSettings.cookieSource == .off) + #expect(claudeSettings.manualCookieHeader == nil) + } + + @Test + func `claude OAuth token selection injects environment override in CLI`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig(id: .claude, tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + + let env = tokenContext.environment(base: ["FOO": "bar"], provider: .claude, account: account) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == "sk-ant-oat-account-token") + } + + @Test + func `claude session account strips ambient admin api credentials in CLI environment builder`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + apiKey: "sk-ant-admin-config", + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + + let env = tokenContext.environment( + base: [ + "FOO": "bar", + ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey: "sk-ant-admin-base", + ClaudeOAuthCredentialsStore.environmentTokenKey: "sk-ant-oat-base", + ], + provider: .claude, + account: account) + + #expect(env["FOO"] == "bar") + #expect(env[ClaudeAdminAPISettingsReader.adminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeAdminAPISettingsReader.alternateAdminAPIKeyEnvironmentKey] == nil) + #expect(env[ClaudeOAuthCredentialsStore.environmentTokenKey] == nil) + } + + @Test + func `claude OAuth token selection promotes auto source mode in CLI`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .auto, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .oauth) + } + + @Test + func `claude OAuth token selection reroutes explicit CLI source to OAuth in CLI`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .oauth) + } + + @Test + func `claude session key selection reroutes explicit CLI source to Web in CLI`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil) + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: account) + + #expect(effectiveSourceMode == .web) + } + + @Test + func `claude all accounts reroutes explicit CLI source per selected credential in CLI`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "OAuth", + token: "Bearer sk-ant-oat-account-token", + addedAt: 0, + lastUsed: nil), + ProviderTokenAccount( + id: UUID(), + label: "Session", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig(id: .claude, tokenAccounts: accounts), + ]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: true), + config: config, + verbose: false) + + let resolved = try tokenContext.resolvedAccounts(for: .claude) + #expect(resolved.map(\.label) == ["OAuth", "Session"]) + + let oauth = try #require(resolved.first) + let oauthSnapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: oauth)?.claude) + #expect(tokenContext.effectiveSourceMode(base: .cli, provider: .claude, account: oauth) == .oauth) + #expect(oauthSnapshot.usageDataSource == .oauth) + #expect(tokenContext.environment(base: [:], provider: .claude, account: oauth)[ + ClaudeOAuthCredentialsStore.environmentTokenKey, + ] == "sk-ant-oat-account-token") + + let session = try #require(resolved.dropFirst().first) + let sessionSnapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: session)?.claude) + #expect(tokenContext.effectiveSourceMode(base: .cli, provider: .claude, account: session) == .web) + #expect(sessionSnapshot.cookieSource == .manual) + #expect(sessionSnapshot.manualCookieHeader == "sessionKey=sk-ant-session-token") + } + + @Test + func `codex all accounts selection exposes visible managed accounts and scopes CLI homes`() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-cli-all-accounts-\(UUID().uuidString)", isDirectory: true) + let ambientHome = root.appendingPathComponent("ambient", isDirectory: true) + let firstHome = root.appendingPathComponent("first", isDirectory: true) + let secondHome = root.appendingPathComponent("second", isDirectory: true) + try FileManager.default.createDirectory(at: ambientHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: firstHome, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondHome, withIntermediateDirectories: true) + let storeURL = root.appendingPathComponent("managed-codex-accounts.json") + let firstID = UUID() + let secondID = UUID() + let accounts = ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: [ + ManagedCodexAccount( + id: firstID, + email: "FIRST@EXAMPLE.COM", + workspaceLabel: "Team", + managedHomePath: firstHome.path, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: nil), + ManagedCodexAccount( + id: secondID, + email: "second@example.com", + workspaceLabel: "Personal", + managedHomePath: secondHome.path, + createdAt: 0, + updatedAt: 0, + lastAuthenticatedAt: nil), + ]) + try FileManagedCodexAccountStore(fileURL: storeURL).storeAccounts(accounts) + let config = CodexBarConfig(providers: [ + ProviderConfig(id: .codex, codexActiveSource: .managedAccount(id: secondID)), + ]) + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: true), + config: config, + verbose: false, + baseEnvironment: ["CODEX_HOME": ambientHome.path], + managedCodexAccountStoreURL: storeURL) + + let projection = context.visibleCodexAccounts() + #expect(projection.visibleAccounts.map(\.menuDisplayName) == [ + "first@example.com — Team", + "second@example.com", + ]) + #expect(projection.visibleAccounts.map(\.selectionSource) == [ + .managedAccount(id: firstID), + .managedAccount(id: secondID), + ]) + #expect(projection.visibleAccounts.first { $0.email == "second@example.com" }?.isActive == true) + + let firstEnv = context.environment( + base: ["CODEX_HOME": ambientHome.path], + provider: .codex, + account: nil, + codexActiveSourceOverride: .managedAccount(id: firstID)) + #expect(firstEnv["CODEX_HOME"] == firstHome.path) + + let liveEnv = context.environment( + base: ["CODEX_HOME": ambientHome.path], + provider: .codex, + account: nil, + codexActiveSourceOverride: .liveSystem) + #expect(liveEnv["CODEX_HOME"] == ambientHome.path) + + let firstFetcher = context.fetcher( + base: UsageFetcher(environment: ["CODEX_HOME": ambientHome.path]), + provider: .codex, + env: firstEnv) + #expect(Self.codexHomePath(from: firstFetcher) == firstHome.path) + + let nonCodexBaseFetcher = UsageFetcher(environment: ["CODEX_HOME": ambientHome.path]) + let nonCodexFetcher = context.fetcher(base: nonCodexBaseFetcher, provider: .claude, env: firstEnv) + #expect(Self.codexHomePath(from: nonCodexFetcher) == ambientHome.path) + + let labeled = try context.applyCodexVisibleAccountLabel( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), + account: #require(projection.visibleAccounts.first)) + let identity = try #require(labeled.identity(for: .codex)) + #expect(identity.accountEmail == "first@example.com") + #expect(identity.accountOrganization == "Team") + } + + @Test + func `claude ambient explicit CLI source remains CLI in CLI`() throws { + let config = CodexBarConfig(providers: [ProviderConfig(id: .claude)]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .cli, + provider: .claude, + account: nil) + + #expect(effectiveSourceMode == .cli) + } + + @Test + func `claude session key selection stays in manual cookie mode in CLI settings snapshot`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Primary", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieSource: .auto, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.usageDataSource == .auto) + #expect(claudeSettings.cookieSource == .manual) + #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token") + } + + @Test + func `claude session key selection carries organization id in CLI settings snapshot`() throws { + let accounts = ProviderTokenAccountData( + version: 1, + accounts: [ + ProviderTokenAccount( + id: UUID(), + label: "Team", + token: "sk-ant-session-token", + addedAt: 0, + lastUsed: nil, + organizationID: " org-team "), + ], + activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let account = try #require(tokenContext.resolvedAccounts(for: .claude).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: account)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.organizationID == "org-team") + } + + @Test + func `claude token account organization id uses organizationId JSON key`() throws { + let json = """ + { + "id": "00000000-0000-0000-0000-000000000001", + "label": "Team", + "token": "sk-ant-session-token", + "addedAt": 0, + "lastUsed": null, + "organizationId": "org-team" + } + """ + let account = try JSONDecoder().decode(ProviderTokenAccount.self, from: Data(json.utf8)) + let encoded = try JSONSerialization.jsonObject(with: JSONEncoder().encode(account)) as? [String: Any] + + #expect(account.organizationID == "org-team") + #expect(encoded?["organizationId"] as? String == "org-team") + #expect(encoded?["organizationID"] == nil) + } + + @Test + func `claude config manual cookie uses shared route in CLI settings snapshot`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieHeader: "Cookie: sessionKey=sk-ant-session-token; foo=bar"), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .claude, account: nil)) + let claudeSettings = try #require(snapshot.claude) + + #expect(claudeSettings.usageDataSource == .auto) + #expect(claudeSettings.cookieSource == .manual) + #expect(claudeSettings.manualCookieHeader == "sessionKey=sk-ant-session-token; foo=bar") + } + + @Test + func `claude config manual cookie does not promote auto source mode in CLI`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .claude, + cookieHeader: "Cookie: sessionKey=sk-ant-session-token"), + ]) + let tokenContext = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: config, + verbose: false) + + let effectiveSourceMode = tokenContext.effectiveSourceMode( + base: .auto, + provider: .claude, + account: nil) + + #expect(effectiveSourceMode == .auto) + } + + @Test + func `apply account label in app preserves snapshot fields`() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-apply-app") let store = Self.makeUsageStore(settings: settings) let snapshot = Self.makeSnapshotWithAllFields(provider: .zai) @@ -95,7 +654,7 @@ struct TokenAccountEnvironmentPrecedenceTests { } @Test - func applyAccountLabelInCLIPreservesSnapshotFields() throws { + func `apply account label in CLI preserves snapshot fields`() throws { let context = try TokenAccountCLIContext( selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), config: CodexBarConfig(providers: []), @@ -115,7 +674,139 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(labeled.identity?.accountEmail == "CLI Account") } - private static func makeSettingsStore(suite: String) -> SettingsStore { + @Test + func `codex known owners match between app and CLI for live system only`() throws { + let ambientHome = Self.makeTempCodexHome( + email: "live@example.com", + plan: "pro", + accountId: "acct-live") + defer { try? FileManager.default.removeItem(at: ambientHome) } + + let appSettings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-codex-live-only") + appSettings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: ambientHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-live")) + defer { appSettings._test_liveSystemCodexAccount = nil } + let appStore = Self.makeUsageStore(settings: appSettings) + + try Self.withCLIKnownOwnerFixtures( + ambientHome: ambientHome, + managedAccounts: []) + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) + let cliOwners = try #require(rawCLIOwners) + let appOwners = appStore.codexDashboardKnownOwnerCandidates() + + #expect(Self.knownOwnerMultiset(appOwners) == Self.knownOwnerMultiset(cliOwners)) + } + } + + @Test + func `codex known owners match between app and CLI when managed and live identities are the same`() throws { + let ambientHome = Self.makeTempCodexHome( + email: "shared@example.com", + plan: "pro", + accountId: "acct-shared") + let managedHome = Self.makeTempCodexHome( + email: "shared@example.com", + plan: "pro", + accountId: "acct-shared") + defer { + try? FileManager.default.removeItem(at: ambientHome) + try? FileManager.default.removeItem(at: managedHome) + } + + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "shared@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let appSettings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-codex-same-identity") + appSettings._test_activeManagedCodexAccount = managedAccount + appSettings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "shared@example.com", + codexHomePath: ambientHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-shared")) + defer { + appSettings._test_activeManagedCodexAccount = nil + appSettings._test_liveSystemCodexAccount = nil + } + let appStore = Self.makeUsageStore(settings: appSettings) + + try Self.withCLIKnownOwnerFixtures( + ambientHome: ambientHome, + managedAccounts: [managedAccount]) + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) + let cliOwners = try #require(rawCLIOwners) + let appOwners = appStore.codexDashboardKnownOwnerCandidates() + + #expect(Self.knownOwnerMultiset(appOwners) == Self.knownOwnerMultiset(cliOwners)) + } + } + + @Test + func `codex known owners match between app and CLI when managed and live identities differ`() throws { + let ambientHome = Self.makeTempCodexHome( + email: "live@example.com", + plan: "pro", + accountId: "acct-live") + let managedHome = Self.makeTempCodexHome( + email: "managed@example.com", + plan: "pro", + accountId: "acct-managed") + defer { + try? FileManager.default.removeItem(at: ambientHome) + try? FileManager.default.removeItem(at: managedHome) + } + + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHome.path, + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + let appSettings = Self + .makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-codex-different-identities") + appSettings._test_activeManagedCodexAccount = managedAccount + appSettings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: ambientHome.path, + observedAt: Date(), + identity: .providerAccount(id: "acct-live")) + defer { + appSettings._test_activeManagedCodexAccount = nil + appSettings._test_liveSystemCodexAccount = nil + } + let appStore = Self.makeUsageStore(settings: appSettings) + + try Self.withCLIKnownOwnerFixtures( + ambientHome: ambientHome, + managedAccounts: [managedAccount]) + { managedStoreURL in + let rawCLIOwners = try Self.codexCLIKnownOwners( + ambientHome: ambientHome, + managedStoreURL: managedStoreURL) + let cliOwners = try #require(rawCLIOwners) + let appOwners = appStore.codexDashboardKnownOwnerCandidates() + + #expect(Self.knownOwnerMultiset(appOwners) == Self.knownOwnerMultiset(cliOwners)) + } + } +} + +extension TokenAccountEnvironmentPrecedenceTests { + fileprivate static func makeSettingsStore(suite: String) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) @@ -140,14 +831,98 @@ struct TokenAccountEnvironmentPrecedenceTests { tokenAccountStore: InMemoryTokenAccountStore()) } - private static func makeUsageStore(settings: SettingsStore) -> UsageStore { + fileprivate static func makeUsageStore(settings: SettingsStore) -> UsageStore { UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) } - private static func makeSnapshotWithAllFields(provider: UsageProvider) -> UsageSnapshot { + fileprivate static func codexCLIKnownOwners( + ambientHome: URL, + managedStoreURL: URL) throws -> [CodexDashboardKnownOwnerCandidate]? + { + let context = try TokenAccountCLIContext( + selection: TokenAccountCLISelection(label: nil, index: nil, allAccounts: false), + config: CodexBarConfig(providers: [ProviderConfig(id: .codex)]), + verbose: false, + baseEnvironment: ["CODEX_HOME": ambientHome.path], + managedCodexAccountStoreURL: managedStoreURL) + return context.settingsSnapshot(for: .codex, account: nil)?.codex?.dashboardAuthorityKnownOwners + } + + fileprivate static func codexHomePath(from fetcher: UsageFetcher) -> String? { + guard let environment = Mirror(reflecting: fetcher).children.first(where: { $0.label == "environment" })? + .value as? [String: String] + else { + return nil + } + return environment["CODEX_HOME"] + } + + fileprivate static func knownOwnerMultiset( + _ owners: [CodexDashboardKnownOwnerCandidate]) -> [CodexDashboardKnownOwnerCandidate: Int] + { + owners.reduce(into: [:]) { counts, owner in + counts[owner, default: 0] += 1 + } + } + + fileprivate static func makeTempCodexHome(email: String, plan: String, accountId: String) -> URL { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-known-owner-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + let credentials = CodexOAuthCredentials( + accessToken: "access-token", + refreshToken: "refresh-token", + idToken: self.fakeJWT(email: email, plan: plan, accountId: accountId), + accountId: accountId, + lastRefresh: Date()) + try? CodexOAuthCredentialsStore.save(credentials, env: ["CODEX_HOME": home.path]) + return home + } + + fileprivate static func fakeJWT(email: String, plan: String, accountId: String) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": [ + "chatgpt_plan_type": plan, + "chatgpt_account_id": accountId, + ], + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } + + fileprivate static func withCLIKnownOwnerFixtures( + ambientHome: URL, + managedAccounts: [ManagedCodexAccount], + operation: (URL) throws -> T) throws -> T + { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-known-owner-store-\(UUID().uuidString)", isDirectory: true) + let managedStoreURL = root.appendingPathComponent("managed-codex-accounts.json", isDirectory: false) + let fileManager = FileManager.default + defer { try? fileManager.removeItem(at: root) } + + let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL) + try managedStore.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: managedAccounts)) + + return try operation(managedStoreURL) + } + + fileprivate static func makeSnapshotWithAllFields(provider: UsageProvider) -> UsageSnapshot { let now = Date(timeIntervalSince1970: 1_700_000_000) let reset = Date(timeIntervalSince1970: 1_700_003_600) let tokenLimit = ZaiLimitEntry( @@ -203,7 +978,7 @@ struct TokenAccountEnvironmentPrecedenceTests { identity: identity) } - private static func expectSnapshotFieldsPreserved(before: UsageSnapshot, after: UsageSnapshot) { + fileprivate static func expectSnapshotFieldsPreserved(before: UsageSnapshot, after: UsageSnapshot) { #expect(after.primary?.usedPercent == before.primary?.usedPercent) #expect(after.secondary?.usedPercent == before.secondary?.usedPercent) #expect(after.tertiary?.usedPercent == before.tertiary?.usedPercent) diff --git a/Tests/CodexBarTests/TokenAccountStoreTests.swift b/Tests/CodexBarTests/TokenAccountStoreTests.swift index e1cbbce24..9a252219f 100644 --- a/Tests/CodexBarTests/TokenAccountStoreTests.swift +++ b/Tests/CodexBarTests/TokenAccountStoreTests.swift @@ -3,8 +3,8 @@ import Foundation import Testing @testable import CodexBar -@Test("ProviderTokenAccountData encoding") -func providerTokenAccountDataEncoding() throws { +@Test +func `ProviderTokenAccountData encoding`() throws { let now = Date().timeIntervalSince1970 let account = ProviderTokenAccount( id: UUID(), @@ -26,8 +26,8 @@ func providerTokenAccountDataEncoding() throws { #expect(decoded.activeIndex == 0) } -@Test("FileTokenAccountStore round trip") -func fileTokenAccountStoreRoundTrip() throws { +@Test +func `FileTokenAccountStore round trip`() throws { let tempDir = FileManager.default.temporaryDirectory let fileURL = tempDir.appendingPathComponent("codexbar-token-accounts-test.json") defer { try? FileManager.default.removeItem(at: fileURL) } diff --git a/Tests/CodexBarTests/UpdateChannelTests.swift b/Tests/CodexBarTests/UpdateChannelTests.swift index c5ee1f9c9..c93ed2398 100644 --- a/Tests/CodexBarTests/UpdateChannelTests.swift +++ b/Tests/CodexBarTests/UpdateChannelTests.swift @@ -1,22 +1,21 @@ import Testing @testable import CodexBar -@Suite struct UpdateChannelTests { @Test - func defaultChannelFromStableVersion() { + func `default channel from stable version`() { #expect(UpdateChannel.defaultChannel(for: "1.2.3") == .stable) } @Test - func defaultChannelFromPrereleaseVersion() { + func `default channel from prerelease version`() { #expect(UpdateChannel.defaultChannel(for: "1.2.3-beta.1") == .beta) #expect(UpdateChannel.defaultChannel(for: "1.2.3-rc.1") == .beta) #expect(UpdateChannel.defaultChannel(for: "1.2.3-alpha") == .beta) } @Test - func allowedSparkleChannels() { + func `allowed sparkle channels`() { #expect(UpdateChannel.stable.allowedSparkleChannels == [""]) #expect(UpdateChannel.beta.allowedSparkleChannels == ["", UpdateChannel.sparkleBetaChannel]) } diff --git a/Tests/CodexBarTests/UsageColorLevelTests.swift b/Tests/CodexBarTests/UsageColorLevelTests.swift new file mode 100644 index 000000000..125e28249 --- /dev/null +++ b/Tests/CodexBarTests/UsageColorLevelTests.swift @@ -0,0 +1,45 @@ +import AppKit +import Testing +@testable import CodexBar + +struct UsageColorLevelTests { + private func redComponent(_ color: NSColor?) -> CGFloat? { + guard let resolved = color?.usingColorSpace(.sRGB) else { return nil } + var r: CGFloat = 0 + resolved.getRed(&r, green: nil, blue: nil, alpha: nil) + return r + } + + @Test + func nilUsageReturnsNoTint() { + #expect(UsageColorLevel.tintColor(for: nil) == nil) + } + + @Test + func highUsageIsSystemRed() { + #expect(UsageColorLevel.tintColor(for: 90) == .systemRed) + #expect(UsageColorLevel.tintColor(for: 100) == .systemRed) + // Values above 100 are clamped and still red. + #expect(UsageColorLevel.tintColor(for: 250) == .systemRed) + } + + @Test + func rednessIncreasesWithUsage() throws { + let low = try #require(self.redComponent(UsageColorLevel.tintColor(for: 10))) + let mid = try #require(self.redComponent(UsageColorLevel.tintColor(for: 80))) + let high = try #require(self.redComponent(UsageColorLevel.tintColor(for: 95))) + #expect(low < mid) + #expect(mid <= high) + } + + @Test + func lowUsageIsGreenDominant() throws { + let color = try #require(UsageColorLevel.tintColor(for: 0)?.usingColorSpace(.sRGB)) + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + color.getRed(&r, green: &g, blue: &b, alpha: nil) + #expect(g > r) + #expect(g > b) + } +} diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 072b299df..873591d41 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -3,32 +3,120 @@ import Foundation import Testing @testable import CodexBar -@Suite +@Suite(.serialized) struct UsageFormatterTests { + private static let usageFormatterLocalizationKeys: [String] = [ + "%@ left", + "Resets %@", + "Resets in %@", + "Resets now", + "Updated %@", + "Updated %@h ago", + "Updated %@m ago", + "Updated just now", + "usage_percent_suffix_left", + "usage_percent_suffix_used", + ] + @Test - func formatsUsageLine() { + func `formats usage line`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() let line = UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: false) #expect(line == "25% left") } @Test - func formatsUsageLineShowUsed() { + func `formats usage line show used`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() let line = UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: true) #expect(line == "75% used") } @Test - func relativeUpdatedRecent() { + func `usage line respects injected localization provider`() { + UsageFormatter.setLocalizationProvider { key in + switch key { + case "usage_percent_suffix_left": "剩余" + case "usage_percent_suffix_used": "已使用" + default: key + } + } + defer { UsageFormatter.clearLocalizationProvider() } + + #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "22% 剩余") + #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "78% 已使用") + } + + @Test + func `default locale fallback matches stable en US POSIX behavior`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + + let defaultOutput = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.setLocaleProvider { Locale(identifier: "en_US_POSIX") } + let injectedStableOutput = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.clearLocaleProvider() + + #expect(defaultOutput == injectedStableOutput) + } + + @Test + func `injected zh Hans locale applies app language formatting`() { + UsageFormatter.setLocalizationProvider { key in + switch key { + case "Updated %@": + "更新于 %@" + default: + key + } + } + UsageFormatter.setLocaleProvider { Locale(identifier: "zh-Hans") } + defer { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + } + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + let output = UsageFormatter.updatedString(from: old, now: now) + + #expect(output.hasPrefix("更新于 ")) + } + + @Test + func `clearing locale provider returns to stable default behavior`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + let baseline = UsageFormatter.updatedString(from: old, now: now) + + UsageFormatter.setLocaleProvider { Locale(identifier: "fr_FR") } + _ = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.clearLocaleProvider() + + let restored = UsageFormatter.updatedString(from: old, now: now) + #expect(restored == baseline) + } + + @Test + func `relative updated recent`() { let now = Date() let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) let text = UsageFormatter.updatedString(from: fiveHoursAgo, now: now) - #expect(text.contains("Updated")) - // Check for relative time format (varies by locale: "ago" in English, "전" in Korean, etc.) - #expect(text.contains("5") || text.lowercased().contains("hour") || text.contains("시간")) + #expect(text.hasPrefix("Updated ") || text.hasPrefix("更新")) + #expect(text.contains("5")) + #expect(text.lowercased().contains("ago") || text.contains("前")) } @Test - func absoluteUpdatedOld() { + func `absolute updated old`() { let now = Date() let dayAgo = now.addingTimeInterval(-26 * 3600) let text = UsageFormatter.updatedString(from: dayAgo, now: now) @@ -37,42 +125,42 @@ struct UsageFormatterTests { } @Test - func resetCountdown_minutes() { + func `reset countdown minutes`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(10 * 60 + 1) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 11m") } @Test - func resetCountdown_hoursAndMinutes() { + func `reset countdown hours and minutes`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(3 * 3600 + 31 * 60) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 3h 31m") } @Test - func resetCountdown_daysAndHours() { + func `reset countdown days and hours`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval((26 * 3600) + 10) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 2h") } @Test - func resetCountdown_exactHour() { + func `reset countdown exact hour`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(60 * 60) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1h") } @Test - func resetCountdown_pastDate() { + func `reset countdown past date`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(-10) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "now") } @Test - func resetLineUsesCountdownWhenResetsAtIsAvailable() { + func `reset line uses countdown when resets at is available`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(10 * 60 + 1) let window = RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: reset, resetDescription: "Resets soon") @@ -81,7 +169,7 @@ struct UsageFormatterTests { } @Test - func resetLineFallsBackToProvidedDescription() { + func `reset line falls back to provided description`() { let window = RateWindow( usedPercent: 0, windowMinutes: nil, @@ -94,22 +182,39 @@ struct UsageFormatterTests { } @Test - func modelDisplayNameStripsTrailingDates() { + func `model display name strips trailing dates`() { #expect(UsageFormatter.modelDisplayName("claude-opus-4-5-20251101") == "claude-opus-4-5") #expect(UsageFormatter.modelDisplayName("gpt-4o-2024-08-06") == "gpt-4o") #expect(UsageFormatter.modelDisplayName("Claude Opus 4.5 2025 1101") == "Claude Opus 4.5") #expect(UsageFormatter.modelDisplayName("claude-sonnet-4-5") == "claude-sonnet-4-5") + #expect(UsageFormatter.modelDisplayName("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") + } + + @Test + func `model cost detail uses research preview label`() { + #expect( + UsageFormatter.modelCostDetail("gpt-5.3-codex-spark", costUSD: 0, totalTokens: nil) == "Research Preview") + #expect(UsageFormatter.modelCostDetail("gpt-5.2-codex", costUSD: 0.42, totalTokens: nil) == "$0.42") } @Test - func cleanPlanMapsOAuthToOllama() { + func `model cost detail includes token counts when present`() { + #expect(UsageFormatter.modelCostDetail("gpt-5.2-codex", costUSD: 0.42, totalTokens: 1200) == "$0.42 · 1.2K") + #expect( + UsageFormatter.modelCostDetail("gpt-5.3-codex-spark", costUSD: 0, totalTokens: 1500) + == "Research Preview · 1.5K") + #expect(UsageFormatter.modelCostDetail("custom-model", costUSD: nil, totalTokens: 987) == "987") + } + + @Test + func `clean plan maps O auth to ollama`() { #expect(UsageFormatter.cleanPlanName("oauth") == "Ollama") } // MARK: - Currency Formatting @Test - func currencyStringFormatsUSDCorrectly() { + func `currency string formats USD correctly`() { // Should produce "$54.72" without space after symbol let result = UsageFormatter.currencyString(54.72, currencyCode: "USD") #expect(result == "$54.72") @@ -117,7 +222,7 @@ struct UsageFormatterTests { } @Test - func currencyStringHandlesLargeValues() { + func `currency string handles large values`() { let result = UsageFormatter.currencyString(1234.56, currencyCode: "USD") // For USD, we use direct string formatting with thousand separators #expect(result == "$1,234.56") @@ -125,26 +230,26 @@ struct UsageFormatterTests { } @Test - func currencyStringHandlesVeryLargeValues() { + func `currency string handles very large values`() { let result = UsageFormatter.currencyString(1_234_567.89, currencyCode: "USD") #expect(result == "$1,234,567.89") } @Test - func currencyStringHandlesNegativeValues() { + func `currency string handles negative values`() { // Negative sign should come before the dollar sign: -$54.72 (not $-54.72) let result = UsageFormatter.currencyString(-54.72, currencyCode: "USD") #expect(result == "-$54.72") } @Test - func currencyStringHandlesNegativeLargeValues() { + func `currency string handles negative large values`() { let result = UsageFormatter.currencyString(-1234.56, currencyCode: "USD") #expect(result == "-$1,234.56") } @Test - func usdStringMatchesCurrencyString() { + func `usd string matches currency string`() { // usdString should produce identical output to currencyString for USD #expect(UsageFormatter.usdString(54.72) == UsageFormatter.currencyString(54.72, currencyCode: "USD")) #expect(UsageFormatter.usdString(-1234.56) == UsageFormatter.currencyString(-1234.56, currencyCode: "USD")) @@ -152,13 +257,13 @@ struct UsageFormatterTests { } @Test - func currencyStringHandlesZero() { + func `currency string handles zero`() { let result = UsageFormatter.currencyString(0, currencyCode: "USD") #expect(result == "$0.00") } @Test - func currencyStringHandlesNonUSDCurrencies() { + func `currency string handles non USD currencies`() { // FormatStyle handles all currencies with proper symbols let eur = UsageFormatter.currencyString(54.72, currencyCode: "EUR") #expect(eur == "€54.72") @@ -172,7 +277,7 @@ struct UsageFormatterTests { } @Test - func currencyStringHandlesSmallValues() { + func `currency string handles small values`() { // Values smaller than 0.01 should round to $0.00 let tiny = UsageFormatter.currencyString(0.001, currencyCode: "USD") #expect(tiny == "$0.00") @@ -187,7 +292,7 @@ struct UsageFormatterTests { } @Test - func currencyStringHandlesBoundaryValues() { + func `currency string handles boundary values`() { // Just under 1000 (no comma) let under1k = UsageFormatter.currencyString(999.99, currencyCode: "USD") #expect(under1k == "$999.99") @@ -202,8 +307,60 @@ struct UsageFormatterTests { } @Test - func creditsStringFormatsCorrectly() { + func `credits string formats correctly`() { let result = UsageFormatter.creditsString(from: 42.5) #expect(result == "42.5 left") } + + @Test + func `byte count string formats binary units`() { + #expect(UsageFormatter.byteCountString(0) == "0 B") + #expect(UsageFormatter.byteCountString(512) == "512 B") + #expect(UsageFormatter.byteCountString(1536) == "1.5 KB") + #expect(UsageFormatter.byteCountString(10 * 1024) == "10 KB") + #expect(UsageFormatter.byteCountString(5 * 1024 * 1024) == "5 MB") + #expect(UsageFormatter.byteCountString(Int64(1536 * 1024 * 1024)) == "1.5 GB") + } + + @Test + func `usage formatter localization keys exist in en and zh Hans with matching placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let enURL = root.appendingPathComponent("Sources/CodexBar/Resources/en.lproj/Localizable.strings") + let zhURL = root.appendingPathComponent("Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings") + + let en = try Self.readStringsTable(at: enURL) + let zh = try Self.readStringsTable(at: zhURL) + + for key in Self.usageFormatterLocalizationKeys { + let enValue = try #require(en[key], "Missing en key: \(key)") + let zhValue = try #require(zh[key], "Missing zh-Hans key: \(key)") + #expect( + Self.placeholderTokens(in: enValue) == Self.placeholderTokens(in: zhValue), + "Placeholder mismatch for key '\(key)': en='\(enValue)' zh='\(zhValue)'") + } + } + + private static func readStringsTable(at url: URL) throws -> [String: String] { + guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { + throw NSError( + domain: "UsageFormatterTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse strings file at \(url.path)"]) + } + return dict + } + + private static func placeholderTokens(in value: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: "%(?:\\d+\\$)?[@dDuUxXfFeEgGcCsSpaA]") else { + return [] + } + let nsRange = NSRange(value.startIndex.. 3 hours elapsed let window = RateWindow( usedPercent: 50, windowMinutes: 300, @@ -77,25 +89,9 @@ struct UsagePaceTests { #expect(pace != nil) guard let pace else { return } - // elapsed = 3h of 5h => expected = 60% #expect(abs(pace.expectedUsedPercent - 60.0) < 0.01) - // delta = 50 - 60 = -10 => behind (in reserve) #expect(abs(pace.deltaPercent - -10.0) < 0.01) #expect(pace.stage == .behind) #expect(pace.willLastToReset == true) } - - @Test - func weeklyPace_hidesWhenUsageExistsButNoElapsed() { - let now = Date(timeIntervalSince1970: 0) - let window = RateWindow( - usedPercent: 12, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(7 * 24 * 3600), - resetDescription: nil) - - let pace = UsagePace.weekly(window: window, now: now) - - #expect(pace == nil) - } } diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 7d6b2f010..2fc4cc1ef 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -3,10 +3,24 @@ import Foundation import Testing @testable import CodexBar -@Suite struct UsagePaceTextTests { + private static let localizedKeys: [String] = [ + "Pace: %@", + "Pace: %@ · %@", + "On pace", + "%d%% in deficit", + "%d%% in reserve", + "Lasts until reset", + "Projected empty now", + "Projected empty in %@", + "Runs out now", + "Runs out in %@", + "≈ %d%% run-out risk", + "%@ · %@", + ] + @Test - func weeklyPaceDetail_providesLeftRightLabels() throws { + func `weekly pace detail provides left right labels`() throws { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 50, @@ -22,7 +36,7 @@ struct UsagePaceTextTests { } @Test - func weeklyPaceDetail_reportsLastsUntilReset() throws { + func `weekly pace detail reports lasts until reset`() throws { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 10, @@ -38,7 +52,7 @@ struct UsagePaceTextTests { } @Test - func weeklyPaceSummary_formatsSingleLineText() throws { + func `weekly pace summary formats single line text`() throws { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 50, @@ -53,7 +67,7 @@ struct UsagePaceTextTests { } @Test - func weeklyPaceDetail_formatsRoundedRiskWhenAvailable() { + func `weekly pace detail formats rounded risk when available`() { let now = Date(timeIntervalSince1970: 0) let pace = UsagePace( stage: .ahead, @@ -72,7 +86,7 @@ struct UsagePaceTextTests { // MARK: - Session pace (5-hour window) @Test - func sessionPaceDetail_providesLeftRightLabels() { + func `session pace detail provides left right labels`() { let now = Date(timeIntervalSince1970: 0) // 300-minute window, 2h remaining => 3h elapsed out of 5h // expected = 60%, actual = 80% => 20% ahead (in deficit) @@ -86,12 +100,12 @@ struct UsagePaceTextTests { #expect(detail != nil) #expect(detail?.leftLabel == "20% in deficit") - #expect(detail?.rightLabel != nil) + #expect(detail?.rightLabel == "Projected empty in 45m") #expect(detail?.stage == .farAhead) } @Test - func sessionPaceDetail_reportsLastsUntilReset() { + func `session pace detail reports lasts until reset`() { let now = Date(timeIntervalSince1970: 0) // 300-minute window, 2h remaining => 3h elapsed // expected = 60%, actual = 10% => far behind (in reserve) @@ -109,7 +123,7 @@ struct UsagePaceTextTests { } @Test - func sessionPaceSummary_formatsSingleLineText() { + func `session pace summary formats single line text`() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 80, @@ -119,12 +133,40 @@ struct UsagePaceTextTests { let summary = UsagePaceText.sessionSummary(provider: .claude, window: window, now: now) - #expect(summary != nil) - #expect(summary?.hasPrefix("Pace:") == true) + #expect(summary == "Pace: 20% in deficit · Projected empty in 45m") + } + + @Test + func `session pace detail supports Ollama five hour window`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .ollama, window: window, now: now) + + #expect(detail?.leftLabel == "20% in deficit") + #expect(detail?.rightLabel == "Projected empty in 45m") } @Test - func sessionPaceDetail_hidesForUnsupportedProvider() { + func `session pace detail hides Ollama window without explicit duration`() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .ollama, window: window, now: now) + + #expect(detail == nil) + } + + @Test + func `session pace detail hides for unsupported provider`() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 50, @@ -138,7 +180,7 @@ struct UsagePaceTextTests { } @Test - func sessionPaceDetail_hidesWhenResetIsMissing() { + func `session pace detail hides when reset is missing`() { let now = Date(timeIntervalSince1970: 0) let window = RateWindow( usedPercent: 50, @@ -150,4 +192,46 @@ struct UsagePaceTextTests { #expect(detail == nil) } + + @Test + func `usage pace text localization keys exist in en and zh Hans with matching placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let enURL = root.appendingPathComponent("Sources/CodexBar/Resources/en.lproj/Localizable.strings") + let zhURL = root.appendingPathComponent("Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings") + + let en = try Self.readStringsTable(at: enURL) + let zh = try Self.readStringsTable(at: zhURL) + + for key in Self.localizedKeys { + let enValue = try #require(en[key], "Missing en key: \(key)") + let zhValue = try #require(zh[key], "Missing zh-Hans key: \(key)") + #expect( + Self.placeholderTokens(in: enValue) == Self.placeholderTokens(in: zhValue), + "Placeholder mismatch for key '\(key)': en='\(enValue)' zh='\(zhValue)'") + } + } + + private static func readStringsTable(at url: URL) throws -> [String: String] { + guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { + throw NSError( + domain: "UsagePaceTextTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse strings file at \(url.path)"]) + } + return dict + } + + private static func placeholderTokens(in value: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: "%(?:\\d+\\$)?[@dDuUxXfFeEgGcCsSpaA]") else { + return [] + } + let nsRange = NSRange(value.startIndex.. UsageStore { UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + settings: settings, + environmentBase: [:]) } } @@ -221,3 +543,15 @@ private final class InMemorySyntheticTokenStore: SyntheticTokenStoring, @uncheck self.value = token } } + +private struct LocalizedTestError: LocalizedError { + let message: String + + init(_ message: String) { + self.message = message + } + + var errorDescription: String? { + self.message + } +} diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index c75944d63..49a353234 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct UsageStoreHighestUsageTests { @Test - func selectsHighestUsageAmongEnabledProviders() { + func `selects highest usage among enabled providers`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-selects"), zaiTokenStore: NoopZaiTokenStore(), @@ -44,7 +43,7 @@ struct UsageStoreHighestUsageTests { } @Test - func skipsFullyUsedProviders() { + func `skips fully used providers`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-skips"), zaiTokenStore: NoopZaiTokenStore(), @@ -81,7 +80,7 @@ struct UsageStoreHighestUsageTests { } @Test - func automaticMetricUsesSecondaryForKimiWhenRankingHighestUsage() { + func `automatic metric uses secondary for kimi when ranking highest usage`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-kimi-automatic"), zaiTokenStore: NoopZaiTokenStore(), @@ -119,7 +118,86 @@ struct UsageStoreHighestUsageTests { } @Test - func automaticMetricKeepsCopilotMostConstrainedRanking() { + func `automatic metric uses antigravity tertiary when leading lanes are missing`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-antigravity-tertiary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .antigravity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let antigravityMeta = registry.metadata[.antigravity] { + settings.setProviderEnabled(provider: .antigravity, metadata: antigravityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let antigravitySnapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: RateWindow(usedPercent: 85, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(antigravitySnapshot, provider: .antigravity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .antigravity) + #expect(highest?.usedPercent == 85) + } + + @Test + func `automatic metric uses zai 5-hour token lane when ranking highest usage`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-zai-automatic-tertiary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .zai) + settings.addTokenAccount(provider: .zai, label: "Primary", token: "zai-token") + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let zaiMeta = registry.metadata[.zai] { + settings.setProviderEnabled(provider: .zai, metadata: zaiMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 70, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let zaiSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 15, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 90, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(zaiSnapshot, provider: .zai) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .zai) + #expect(highest?.usedPercent == 90) + } + + @Test + func `automatic metric keeps copilot most constrained ranking`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-copilot-automatic"), zaiTokenStore: NoopZaiTokenStore(), @@ -157,7 +235,7 @@ struct UsageStoreHighestUsageTests { } @Test - func automaticMetricDoesNotExcludePartiallyAvailableCopilotAtHundredPercent() { + func `automatic metric does not exclude partially available copilot at hundred percent`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-copilot-partial-100"), zaiTokenStore: NoopZaiTokenStore(), @@ -195,7 +273,7 @@ struct UsageStoreHighestUsageTests { } @Test - func automaticMetricExcludesCopilotWhenBothLanesAreExhausted() { + func `automatic metric excludes copilot when both lanes are exhausted`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-copilot-both-100"), zaiTokenStore: NoopZaiTokenStore(), @@ -231,4 +309,277 @@ struct UsageStoreHighestUsageTests { #expect(highest?.provider == .codex) #expect(highest?.usedPercent == 80) } + + @Test + func `automatic metric uses tertiary when it is most constrained for cursor`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-cursor-tertiary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .cursor) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let cursorSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 95, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(cursorSnapshot, provider: .cursor) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .cursor) + #expect(highest?.usedPercent == 95) + } + + @Test + func `automatic metric keeps perplexity in highest usage when purchased credits remain`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-purchased"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 15, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let perplexitySnapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .perplexity) + #expect(highest?.usedPercent == 45) + } + + @Test + func `automatic metric ignores exhausted recurring perplexity lane when fallback remains`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-recurring-exhausted"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let perplexitySnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .perplexity) + #expect(highest?.usedPercent == 40) + } + + @Test + func `automatic metric prefers purchased perplexity credits before bonus in highest usage`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-purchased-before-bonus"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .perplexity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let perplexitySnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .perplexity) + #expect(highest?.usedPercent == 45) + } + + @Test + func `primary metric keeps exhausted recurring perplexity lane in highest usage selection`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-primary-exhausted"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.primary, for: .perplexity) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let perplexityMeta = registry.metadata[.perplexity] { + settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let perplexitySnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 25) + } + + @Test + func `automatic metric excludes cursor when all opus lanes are exhausted`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-cursor-all-100"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.automatic, for: .cursor) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 80, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let cursorSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(cursorSnapshot, provider: .cursor) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .codex) + #expect(highest?.usedPercent == 80) + } + + @Test + func `cursor highest usage keeps provider when saved tertiary falls back to automatic`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-cursor-missing-tertiary"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.setMenuBarMetricPreference(.tertiary, for: .cursor) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let cursorMeta = registry.metadata[.cursor] { + settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + + let codexSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + let cursorSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + store._setSnapshotForTesting(cursorSnapshot, provider: .cursor) + + let highest = store.providerWithHighestUsage() + #expect(highest?.provider == .cursor) + #expect(highest?.usedPercent == 100) + } } diff --git a/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift new file mode 100644 index 000000000..014ce6716 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreManualTokenRefreshTests.swift @@ -0,0 +1,204 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +private actor TokenRefreshGate { + private var didStart = false + private var didFinish = false + private var released = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + private(set) var calls: [(provider: UsageProvider, force: Bool)] = [] + + func start(provider: UsageProvider, force: Bool) { + self.didStart = true + self.calls.append((provider, force)) + let waiters = self.startWaiters + self.startWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func waitForStart() async { + if self.didStart { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func waitForRelease() async { + if self.released { return } + await withCheckedContinuation { continuation in + self.releaseWaiters.append(continuation) + } + } + + func release() { + self.released = true + let waiters = self.releaseWaiters + self.releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } + + func finish() { + self.didFinish = true + } + + func hasFinished() -> Bool { + self.didFinish + } +} + +private actor CompletionFlag { + private var completed = false + + func markCompleted() { + self.completed = true + } + + func isCompleted() -> Bool { + self.completed + } +} + +private actor TokenRefreshRecorder { + private(set) var calls: [(provider: UsageProvider, force: Bool)] = [] + + func record(provider: UsageProvider, force: Bool) { + self.calls.append((provider, force)) + } +} + +@MainActor +@Suite(.serialized) +struct UsageStoreManualTokenRefreshTests { + @Test + func `manual refresh waits for token-cost refresh before completing`() async { + let store = Self.makeStore() + let gate = TokenRefreshGate() + let completion = CompletionFlag() + store._test_providerRefreshOverride = { _ in } + store._test_tokenUsageRefreshOverride = { provider, force in + await gate.start(provider: provider, force: force) + await gate.waitForRelease() + await gate.finish() + } + + let task = Task { @MainActor in + await store.refresh(forceTokenUsage: true) + await completion.markCompleted() + } + + await gate.waitForStart() + #expect(await completion.isCompleted() == false) + #expect(await gate.hasFinished() == false) + + await gate.release() + await task.value + + #expect(await completion.isCompleted()) + #expect(await gate.hasFinished()) + #expect(await gate.calls.map(\.provider) == [.codex]) + #expect(await gate.calls.map(\.force) == [true]) + } + + @Test + func `manual refresh drains scheduled token-cost refresh before forced pass`() async { + let store = Self.makeStore() + let scheduledGate = TokenRefreshGate() + let forcedGate = TokenRefreshGate() + let recorder = TokenRefreshRecorder() + let completion = CompletionFlag() + store._test_providerRefreshOverride = { _ in } + store._test_tokenUsageRefreshOverride = { provider, force in + await recorder.record(provider: provider, force: force) + if force { + await forcedGate.start(provider: provider, force: force) + await forcedGate.waitForRelease() + await forcedGate.finish() + } else { + await scheduledGate.start(provider: provider, force: force) + await scheduledGate.waitForRelease() + await scheduledGate.finish() + } + } + + await store.refresh(forceTokenUsage: false) + await scheduledGate.waitForStart() + + let task = Task { @MainActor in + await store.refresh(forceTokenUsage: true) + await completion.markCompleted() + } + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await completion.isCompleted() == false) + + await scheduledGate.release() + await forcedGate.waitForStart() + #expect(await completion.isCompleted() == false) + + await forcedGate.release() + await task.value + + #expect(await completion.isCompleted()) + #expect(await scheduledGate.hasFinished()) + #expect(await forcedGate.hasFinished()) + #expect(await recorder.calls.map(\.provider) == [.codex, .codex]) + #expect(await recorder.calls.map(\.force) == [false, true]) + } + + @Test + func `regular refresh schedules token-cost refresh without waiting`() async { + let store = Self.makeStore() + let gate = TokenRefreshGate() + store._test_providerRefreshOverride = { _ in } + store._test_tokenUsageRefreshOverride = { provider, force in + await gate.start(provider: provider, force: force) + await gate.waitForRelease() + await gate.finish() + } + + await store.refresh(forceTokenUsage: false) + #expect(await gate.hasFinished() == false) + + await gate.release() + try? await Task.sleep(for: .milliseconds(50)) + let calls = await gate.calls + if !calls.isEmpty { + #expect(calls.map(\.provider) == [.codex]) + #expect(calls.map(\.force) == [false]) + #expect(await gate.hasFinished()) + } + } + + private static func makeStore() -> UsageStore { + let suite = "UsageStoreManualTokenRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + settings.providerDetectionCompleted = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + } +} diff --git a/Tests/CodexBarTests/UsageStorePathDebugTests.swift b/Tests/CodexBarTests/UsageStorePathDebugTests.swift index a73a8b3eb..856fc178c 100644 --- a/Tests/CodexBarTests/UsageStorePathDebugTests.swift +++ b/Tests/CodexBarTests/UsageStorePathDebugTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct UsageStorePathDebugTests { @Test - func refreshPathDebugInfoPopulatesSnapshot() async throws { + func `refresh path debug info populates snapshot`() async throws { let suite = "UsageStorePathDebugTests-path" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -30,4 +29,27 @@ struct UsageStorePathDebugTests { #expect(store.pathDebugInfo != .empty) #expect(store.pathDebugInfo.effectivePATH.isEmpty == false) } + + @Test + func `deepseek debug log includes selected token account`() async throws { + let suite = "UsageStorePathDebugTests-deepseek-debug-token-account" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore()) + settings.addTokenAccount(provider: .deepseek, label: "Primary", token: "sk-deepseek-test") + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + let debugLog = await store.debugLog(for: UsageProvider.deepseek) + + #expect(debugLog == "DEEPSEEK_API_KEY=present source=settings-token-account") + } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift new file mode 100644 index 000000000..2ccc89a23 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift @@ -0,0 +1,244 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationClaudeIdentityTests { + @MainActor + @Test + func `selected token account chooses matching bucket`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + + store.settings.setActiveTokenAccountIndex(0, for: .claude) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [ + aliceKey: [planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ])], + bobKey: [planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 50), + ])], + ]) + + #expect(store.planUtilizationHistory(for: .claude) == [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]), + ]) + + store.settings.setActiveTokenAccountIndex(1, for: .claude) + #expect(store.planUtilizationHistory(for: .claude) == [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 50), + ]), + ]) + } + + @MainActor + @Test + func `fetched non selected accounts persist into separate claude buckets`() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "bob@example.com", + accountOrganization: nil, + loginMethod: "max")) + + await store.recordFetchedTokenAccountPlanUtilizationHistory( + provider: .claude, + samples: [(account: bob, snapshot: snapshot)], + selectedAccount: alice) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + let histories = try #require(buckets.accounts[bobKey]) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) + #expect(findSeries(histories, name: .opus, windowMinutes: 10080)?.entries.last?.usedPercent == 30) + } + + @MainActor + @Test + func `first resolved claude token account adopts unscoped history`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + let alice = try #require(store.settings.tokenAccounts(for: .claude).first) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 15), + ]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [bootstrap]) + store.settings.setActiveTokenAccountIndex(0, for: .claude) + + let history = store.planUtilizationHistory(for: .claude) + let buckets = try #require(store.planUtilizationHistory[.claude]) + + #expect(history == [bootstrap]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[aliceKey] == [bootstrap]) + } + + @MainActor + @Test + func `claude history without identity falls back to last resolved account`() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) + store._setSnapshotForTesting(snapshot, provider: .claude) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let identitylessSnapshot = UsageSnapshot( + primary: snapshot.primary, + secondary: snapshot.secondary, + updatedAt: snapshot.updatedAt) + store._setSnapshotForTesting(identitylessSnapshot, provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) + #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) + } + + @Test + func `same claude email separates team and personal plan history keys`() throws { + let team = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: "Team Org", + loginMethod: "Claude Team")) + let max = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: "Claude Max")) + + let teamKey = try #require(UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: team)) + let maxKey = try #require(UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: max)) + + #expect(teamKey != maxKey) + } + + @Test + func `claude email only identity keeps legacy history key`() throws { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let identityKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: snapshot)) + let legacyKey = try #require( + UsageStore._legacyClaudePlanUtilizationEmailAccountKeyForTesting(snapshot: snapshot)) + + #expect(identityKey == legacyKey) + } + + @Test + func `claude compact and branded plan labels share history key`() throws { + let compact = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: "Max")) + let branded = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: "Claude Max")) + + let compactKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: compact)) + let brandedKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: branded)) + + #expect(compactKey == brandedKey) + } + + @MainActor + @Test + func `new claude email discriminator adopts legacy email history`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "person@example.com", + accountOrganization: "Team Org", + loginMethod: "Claude Team")) + let legacyKey = try #require( + UsageStore._legacyClaudePlanUtilizationEmailAccountKeyForTesting(snapshot: snapshot)) + let accountKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting(provider: .claude, snapshot: snapshot)) + let legacyWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 42), + ]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + preferredAccountKey: legacyKey, + accounts: [ + legacyKey: [legacyWeekly], + ]) + store._setSnapshotForTesting(snapshot, provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + let buckets = try #require(store.planUtilizationHistory[.claude]) + + #expect(history == [legacyWeekly]) + #expect(buckets.accounts[legacyKey] == nil) + #expect(buckets.accounts[accountKey] == [legacyWeekly]) + #expect(buckets.preferredAccountKey == accountKey) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationCodexOwnershipTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationCodexOwnershipTests.swift new file mode 100644 index 000000000..cdd97b835 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationCodexOwnershipTests.swift @@ -0,0 +1,582 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationCodexOwnershipTests { + @MainActor + @Test + func `codex plan history aliases pre-upgrade codex email hash bucket into canonical email hash`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: "alice@example.com") + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "alice@example.com") + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + legacyEmailHash: [weekly], + ]) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(buckets.preferredAccountKey == canonicalKey) + #expect(history == [weekly]) + #expect(buckets.accounts[canonicalKey] == [weekly]) + #expect(buckets.accounts[legacyEmailHash] == nil) + } + + @MainActor + @Test + func `codex strict continuity adopts unscoped only when there is one owner`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: "alice@example.com") + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "alice@example.com") + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 15), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [bootstrap], + accounts: [ + legacyEmailHash: [weekly], + ]) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(buckets.preferredAccountKey == canonicalKey) + #expect(history == [bootstrap, weekly]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[canonicalKey] == [bootstrap, weekly]) + #expect(buckets.accounts[legacyEmailHash] == nil) + } + + @MainActor + @Test + func `codex strict continuity ignores later unrelated owners outside the unscoped period`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: "alice@example.com") + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "alice@example.com") + let laterOtherKey = "codex:v1:provider-account:acct-later" + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 15), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + let laterWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_701_900_000), usedPercent: 35), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [bootstrap], + accounts: [ + legacyEmailHash: [weekly], + laterOtherKey: [laterWeekly], + ]) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(buckets.preferredAccountKey == canonicalKey) + #expect(history == [bootstrap, weekly]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[canonicalKey] == [bootstrap, weekly]) + #expect(buckets.accounts[laterOtherKey] == [laterWeekly]) + } + + @MainActor + @Test + func `codex real fixture carries local opaque weekly continuity into provider account`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: "ratulsarna@gmail.com") + let canonicalEmailHashKey = CodexHistoryOwnership.canonicalEmailHashKey(for: "ratulsarna@gmail.com") + let opaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let weeklyResetAt = try #require(ISO8601DateFormatter().date(from: "2026-04-08T07:57:12Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + store.planUtilizationHistory[.codex] = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + let session = try #require(findSeries(history, name: .session, windowMinutes: 300)) + let expectedOldestWeekly = try #require(formatter.date(from: "2026-03-23T09:55:44Z")) + let expectedNewestWeekly = try #require(formatter.date(from: "2026-04-01T20:33:41Z")) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedOldestWeekly) + #expect(weekly.entries.last?.capturedAt == expectedNewestWeekly) + #expect(session.entries.first?.capturedAt == expectedOldestWeekly) + #expect(buckets.accounts[providerAccountKey] == history) + #expect(buckets.accounts[opaqueKey] == nil) + #expect(buckets.accounts[legacyEmailHash] == nil) + #expect(buckets.accounts[canonicalEmailHashKey] == nil) + } + + @MainActor + @Test + func `codex real fixture keeps opaque history separate when multiple opaque candidates could match`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let originalOpaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let duplicateOpaqueKey = "legacy-codex-opaque-duplicate" + let weeklyResetAt = try #require(ISO8601DateFormatter().date(from: "2026-04-08T07:57:12Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + var fixture = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + fixture.accounts[duplicateOpaqueKey] = fixture.accounts[originalOpaqueKey] + store.planUtilizationHistory[.codex] = fixture + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + let expectedNonOpaqueStart = try #require(formatter.date(from: "2026-03-28T06:50:16Z")) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedNonOpaqueStart) + #expect(buckets.accounts[originalOpaqueKey] != nil) + #expect(buckets.accounts[duplicateOpaqueKey] != nil) + } + + @MainActor + @Test + func `codex real fixture keeps opaque history separate when overlapping non target owner exists`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let opaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let overlappingOtherKey = "codex:v1:provider-account:acct-other" + let weeklyResetAt = try #require(formatter.date(from: "2026-04-08T07:57:12Z")) + let expectedNonOpaqueStart = try #require(formatter.date(from: "2026-03-28T06:50:16Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + let overlappingCapturedAt = try #require(formatter.date(from: "2026-03-30T12:00:00Z")) + var fixture = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + fixture.accounts[overlappingOtherKey] = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry( + at: overlappingCapturedAt, + usedPercent: 35, + resetsAt: weeklyResetAt), + ]), + ] + store.planUtilizationHistory[.codex] = fixture + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedNonOpaqueStart) + #expect(buckets.accounts[opaqueKey] != nil) + #expect(buckets.accounts[overlappingOtherKey] != nil) + } + + @MainActor + @Test + func `codex opaque recovery uses normalized dashboard weekly reset when snapshot has only session window`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let opaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let weeklyResetAt = try #require(formatter.date(from: "2026-04-08T07:57:12Z")) + let expectedOpaqueStart = try #require(formatter.date(from: "2026-03-23T09:55:44Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + store.planUtilizationHistory[.codex] = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "ratulsarna@gmail.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: "plus", + updatedAt: weeklyResetAt) + store.openAIDashboardAttachmentAuthorized = true + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedOpaqueStart) + #expect(buckets.accounts[opaqueKey] == nil) + } + + @MainActor + @Test + func `codex display only dashboard does not drive opaque recovery when snapshot has only session window`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let opaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let weeklyResetAt = try #require(formatter.date(from: "2026-04-08T07:57:12Z")) + let expectedNonOpaqueStart = try #require(formatter.date(from: "2026-03-28T06:50:16Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + store.planUtilizationHistory[.codex] = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + store.openAIDashboard = OpenAIDashboardSnapshot( + signedInEmail: "ratulsarna@gmail.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + secondaryLimit: nil, + creditsRemaining: nil, + accountPlan: "plus", + updatedAt: weeklyResetAt) + store.openAIDashboardAttachmentAuthorized = false + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedNonOpaqueStart) + #expect(buckets.accounts[opaqueKey] != nil) + } + + @MainActor + @Test + func `codex adjacent managed and live accounts veto unscoped adoption`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: managedAccount.email) + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: managedAccount.email) + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 15), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [bootstrap], + accounts: [ + legacyEmailHash: [weekly], + ]) + store.settings._test_activeManagedCodexAccount = managedAccount + store.settings.codexActiveSource = .managedAccount(id: managedAccount.id) + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "live@example.com", + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "live-acct")) + defer { + store.settings._test_activeManagedCodexAccount = nil + store.settings._test_liveSystemCodexAccount = nil + store.settings.codexActiveSource = .liveSystem + } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(history == [weekly]) + #expect(buckets.unscoped == [bootstrap]) + #expect(buckets.accounts[canonicalKey] == [weekly]) + } + + @MainActor + @Test + func `codex extra saved managed accounts do not veto active account adoption`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let activeManagedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let inactiveManagedAccount = ManagedCodexAccount( + id: UUID(), + email: "other@example.com", + managedHomePath: "/tmp/other-codex-home", + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2) + let managedStoreURL = try #require(store.settings._test_managedCodexAccountStoreURL) + let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL) + try managedStore.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [activeManagedAccount, inactiveManagedAccount])) + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: activeManagedAccount.email) + let canonicalKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: snapshot)) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: activeManagedAccount.email) + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 15), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [bootstrap], + accounts: [ + legacyEmailHash: [weekly], + ]) + store.settings._test_activeManagedCodexAccount = activeManagedAccount + store.settings.codexActiveSource = .managedAccount(id: activeManagedAccount.id) + defer { + store.settings._test_activeManagedCodexAccount = nil + store.settings.codexActiveSource = .liveSystem + } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(history == [bootstrap, weekly]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[canonicalKey] == [bootstrap, weekly]) + } + + @MainActor + @Test + func `codex inactive managed accounts do not veto live opaque recovery`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let formatter = ISO8601DateFormatter() + let providerAccountKey = try #require(CodexHistoryOwnership.canonicalKey(for: .providerAccount( + id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7"))) + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + let opaqueKey = "3e31a7fdc57ea26c62fd7061d25dcab74a91b0da2d8f514b07e99aad800ee897" + let weeklyResetAt = try #require(formatter.date(from: "2026-04-08T07:57:12Z")) + let expectedOpaqueStart = try #require(formatter.date(from: "2026-03-23T09:55:44Z")) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyResetAt, + resetDescription: nil), + updatedAt: weeklyResetAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "ratulsarna@gmail.com", + accountOrganization: nil, + loginMethod: "plus")) + + store.planUtilizationHistory[.codex] = try UsageStorePlanUtilizationTests.loadPlanUtilizationFixture( + named: "codex-plan-utilization-real-migration.json") + store.settings._test_activeManagedCodexAccount = managedAccount + store.settings.codexActiveSource = .liveSystem + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: "ratulsarna@gmail.com", + codexHomePath: "/Users/test/.codex", + observedAt: weeklyResetAt, + identity: .providerAccount(id: "0c2a5eef-a612-45bb-9796-9aa83ce1bed7")) + defer { + store.settings._test_activeManagedCodexAccount = nil + store.settings._test_liveSystemCodexAccount = nil + } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + let weekly = try #require(findSeries(history, name: .weekly, windowMinutes: 10080)) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(weekly.entries.first?.capturedAt == expectedOpaqueStart) + #expect(buckets.accounts[opaqueKey] == nil) + } + + @MainActor + @Test + func `codex provider account continuity absorbs matching email scoped history`() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let normalizedEmail = "alice@example.com" + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .codex, email: normalizedEmail) + let providerAccountKey = try #require( + CodexHistoryOwnership.canonicalKey(for: .providerAccount(id: "live-acct"))) + let canonicalEmailHashKey = CodexHistoryOwnership.canonicalEmailHashKey(for: normalizedEmail) + let legacyEmailHash = UsageStore._codexLegacyPlanUtilizationEmailHashKeyForTesting( + normalizedEmail: normalizedEmail) + let session = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 15), + ]) + let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(accounts: [ + canonicalEmailHashKey: [session], + legacyEmailHash: [weekly], + ]) + store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount( + email: normalizedEmail, + codexHomePath: "/Users/test/.codex", + observedAt: Date(), + identity: .providerAccount(id: "live-acct")) + defer { store.settings._test_liveSystemCodexAccount = nil } + store._setSnapshotForTesting(snapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + let buckets = try #require(store.planUtilizationHistory[.codex]) + + #expect(buckets.preferredAccountKey == providerAccountKey) + #expect(history == [session, weekly]) + #expect(buckets.accounts[providerAccountKey] == [session, weekly]) + #expect(buckets.accounts[canonicalEmailHashKey] == nil) + #expect(buckets.accounts[legacyEmailHash] == nil) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift new file mode 100644 index 000000000..96327c78a --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift @@ -0,0 +1,55 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationDerivedChartTests { + @MainActor + @Test + func `chart uses requested native series without cross series selection`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: firstBoundary), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary) + + #expect(model.selectedSeries == "weekly:10080") + #expect(model.usedPercents == [62, 48]) + } + + @MainActor + @Test + func `chart exposes claude opus as separate native tab`() { + let boundary = Date(timeIntervalSince1970: 1_710_000_000) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 10, resetsAt: boundary), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: boundary), + ]), + planSeries(name: .opus, windowMinutes: 10080, entries: [ + planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 30, resetsAt: boundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .claude, + referenceDate: boundary) + + #expect(model.visibleSeries == ["session:300", "weekly:10080", "opus:10080"]) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift new file mode 100644 index 000000000..ac6d69f3f --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift @@ -0,0 +1,227 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationExactFitResetTests { + @MainActor + @Test + func `weekly chart uses reset date as bar date`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let thirdBoundary = secondBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + planEntry(at: thirdBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: thirdBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: thirdBoundary) + + #expect(model.usedPercents == [62, 48, 20]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary), + formattedBoundary(secondBoundary), + formattedBoundary(thirdBoundary), + ]) + } + + @MainActor + @Test + func `chart keeps maximum usage for each effective period`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(5 * 60 * 60) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-50 * 60), usedPercent: 22, resetsAt: firstBoundary), + planEntry( + at: firstBoundary.addingTimeInterval(-20 * 60), + usedPercent: 61, + resetsAt: firstBoundary.addingTimeInterval(75)), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 18, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: secondBoundary) + + #expect(model.usedPercents == [61, 18]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary.addingTimeInterval(75)), + formattedBoundary(secondBoundary), + ]) + } + + @MainActor + @Test + func `chart prefers reset backed entry when usage ties within period`() { + let boundary = Date(timeIntervalSince1970: 1_710_000_000) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: boundary.addingTimeInterval(-55 * 60), usedPercent: 48), + planEntry(at: boundary.addingTimeInterval(-20 * 60), usedPercent: 48, resetsAt: boundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: boundary) + + #expect(model.usedPercents == [48]) + #expect(model.pointDates == [formattedBoundary(boundary)]) + } + + @MainActor + @Test + func `chart adds synthetic current bar when current period has no observation`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let currentBoundary = firstBoundary.addingTimeInterval(10 * 60 * 60) + let referenceDate = currentBoundary.addingTimeInterval(-30 * 60) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: referenceDate) + + #expect(model.usedPercents == [62, 0, 0]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary), + formattedBoundary(firstBoundary.addingTimeInterval(5 * 60 * 60)), + formattedBoundary(currentBoundary), + ]) + } + + @MainActor + @Test + func `weekly chart shows zero bars for missing reset periods`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let fourthBoundary = secondBoundary.addingTimeInterval(14 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + planEntry(at: fourthBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: fourthBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: fourthBoundary) + + #expect(model.usedPercents == [62, 48, 0, 20]) + } + + @MainActor + @Test + func `weekly chart starts axis labels from first bar`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let thirdBoundary = secondBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let fourthBoundary = thirdBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + planEntry(at: thirdBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: thirdBoundary), + planEntry(at: fourthBoundary.addingTimeInterval(-30 * 60), usedPercent: 15, resetsAt: fourthBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: fourthBoundary) + + #expect(model.axisIndexes == [0]) + } + + @MainActor + @Test + func `weekly chart keeps observed current boundary when reset times drift slightly`() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_055) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60 + 88) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 33, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary.addingTimeInterval(-60)) + + #expect(model.usedPercents == [62, 33]) + } + + @MainActor + @Test + func `weekly chart prefers reset backed history over legacy synthetic points`() { + let legacyCapturedAt = Date(timeIntervalSince1970: 1_742_100_000) + let firstBoundary = Date(timeIntervalSince1970: 1_742_356_855) // 2026-03-18T17:00:55Z + let secondBoundary = Date(timeIntervalSince1970: 1_742_961_343) // 2026-03-25T17:02:23Z + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: legacyCapturedAt, usedPercent: 57), + planEntry(at: firstBoundary.addingTimeInterval(-60 * 60), usedPercent: 73, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-60 * 60), usedPercent: 35, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary.addingTimeInterval(-60)) + + #expect(model.usedPercents == [73, 35]) + } + + @MainActor + @Test + func `chart keeps legacy history before first reset backed boundary`() { + let firstLegacyCapturedAt = Date(timeIntervalSince1970: 1_739_692_800) // 2026-02-23T07:00:00Z + let secondLegacyCapturedAt = firstLegacyCapturedAt.addingTimeInterval(7 * 24 * 60 * 60) + let firstBoundary = secondLegacyCapturedAt.addingTimeInterval(7 * 24 * 60 * 60 + 55) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstLegacyCapturedAt, usedPercent: 20), + planEntry(at: secondLegacyCapturedAt, usedPercent: 40), + planEntry(at: firstBoundary.addingTimeInterval(-60 * 60), usedPercent: 73, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-60 * 60), usedPercent: 35, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .claude, + referenceDate: secondBoundary.addingTimeInterval(-60)) + + #expect(model.usedPercents == [20, 40, 73, 35]) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift new file mode 100644 index 000000000..076f20b5f --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift @@ -0,0 +1,279 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct UsageStorePlanUtilizationResetCoalescingTests { + @Test + func `near canonical codex windows merge into canonical history series`() throws { + let base = Date(timeIntervalSince1970: 1_700_000_000) + let existing = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: base, usedPercent: 20), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: base, usedPercent: 40), + ]), + ] + let incoming = [ + planSeries(name: .session, windowMinutes: 299, entries: [ + planEntry(at: base.addingTimeInterval(3600), usedPercent: 30), + ]), + planSeries(name: .weekly, windowMinutes: 10079, entries: [ + planEntry(at: base.addingTimeInterval(3600), usedPercent: 50), + ]), + ] + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoriesForTesting( + existingHistories: existing, + samples: incoming)) + + #expect(updated.map { "\($0.name.rawValue):\($0.windowMinutes)" } == ["session:300", "weekly:10080"]) + #expect(findSeries(updated, name: .session, windowMinutes: 300)?.entries.map(\.usedPercent) == [20, 30]) + #expect(findSeries(updated, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [40, 50]) + } + + @Test + func `same hour entry backfills missing reset metadata`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let existing = planEntry( + at: hourStart.addingTimeInterval(10 * 60), + usedPercent: 20) + let incoming = planEntry( + at: hourStart.addingTimeInterval(45 * 60), + usedPercent: 30, + resetsAt: hourStart.addingTimeInterval(30 * 60)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == incoming.capturedAt) + #expect(updated[0].usedPercent == 30) + #expect(updated[0].resetsAt == incoming.resetsAt) + } + + @Test + func `same hour later higher usage without reset metadata keeps promoted reset boundary`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let first = planEntry( + at: hourStart.addingTimeInterval(10 * 60), + usedPercent: 40) + let second = planEntry( + at: hourStart.addingTimeInterval(25 * 60), + usedPercent: 8, + resetsAt: hourStart.addingTimeInterval(30 * 60)) + let third = planEntry( + at: hourStart.addingTimeInterval(50 * 60), + usedPercent: 22) + + let initial = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let promoted = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: promoted, + entry: third)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == third.capturedAt) + #expect(updated[0].usedPercent == third.usedPercent) + #expect(updated[0].resetsAt == second.resetsAt) + } + + @Test + func `same hour zero usage with drifting reset coalesces to latest entry`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 20, + hour: 0))) + let existing = planEntry( + at: hourStart.addingTimeInterval(14 * 60), + usedPercent: 0, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60 + 14 * 60 + 2)) + let incoming = planEntry( + at: hourStart.addingTimeInterval(23 * 60), + usedPercent: 0, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60 + 14 * 60 + 3)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0] == incoming) + } + + @Test + func `same hour reset times within two minutes still keep single hourly peak`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 20, + hour: 0))) + let existing = planEntry( + at: hourStart.addingTimeInterval(21 * 60), + usedPercent: 10, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)) + let incoming = planEntry( + at: hourStart.addingTimeInterval(55 * 60), + usedPercent: 10, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60 + 1)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == incoming.capturedAt) + #expect(updated[0].usedPercent == 10) + #expect(updated[0].resetsAt == incoming.resetsAt) + } + + @Test + func `same hour usage drop without meaningful reset still keeps single hourly peak`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 20, + hour: 0))) + let existing = planEntry( + at: hourStart.addingTimeInterval(15 * 60), + usedPercent: 40, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)) + let incoming = planEntry( + at: hourStart.addingTimeInterval(45 * 60), + usedPercent: 5, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60 + 30)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == existing.capturedAt) + #expect(updated[0].usedPercent == existing.usedPercent) + #expect(updated[0].resetsAt == incoming.resetsAt) + } + + @Test + func `same hour reset keeps peak before reset and latest peak after reset`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 20, + hour: 0))) + let initial = [ + planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 40, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)), + planEntry( + at: hourStart.addingTimeInterval(20 * 60), + usedPercent: 12, + resetsAt: hourStart.addingTimeInterval(8 * 60 * 60)), + ] + let incoming = planEntry( + at: hourStart.addingTimeInterval(45 * 60), + usedPercent: 18, + resetsAt: hourStart.addingTimeInterval(8 * 60 * 60)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: incoming)) + + #expect(updated.count == 2) + #expect(updated[0].usedPercent == 40) + #expect(updated[1].usedPercent == 18) + #expect(updated[1].resetsAt == incoming.resetsAt) + } + + @Test + func `newer reset within hour replaces earlier post reset record`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 20, + hour: 0))) + let initial = [ + planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 40, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)), + planEntry( + at: hourStart.addingTimeInterval(20 * 60), + usedPercent: 12, + resetsAt: hourStart.addingTimeInterval(8 * 60 * 60)), + ] + let incoming = planEntry( + at: hourStart.addingTimeInterval(50 * 60), + usedPercent: 3, + resetsAt: hourStart.addingTimeInterval(10 * 60 * 60)) + + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: incoming)) + + #expect(updated.count == 2) + #expect(updated[0].usedPercent == 40) + #expect(updated[1] == incoming) + } + + @Test + func `merged histories keep series separated by stable name`() throws { + let existing = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]), + ] + let incoming = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 40), + ]), + ] + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoriesForTesting( + existingHistories: existing, + samples: incoming)) + + #expect(findSeries(updated, name: .session, windowMinutes: 300) != nil) + #expect(findSeries(updated, name: .weekly, windowMinutes: 10080) != nil) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift new file mode 100644 index 000000000..aa3207f6d --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -0,0 +1,1256 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +// swiftlint:disable:next type_body_length +struct UsageStorePlanUtilizationTests { + @Test + func `coalesces changed usage within hour into single entry`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let first = planEntry(at: hourStart, usedPercent: 10) + let second = planEntry(at: hourStart.addingTimeInterval(25 * 60), usedPercent: 35) + + let initial = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + + #expect(updated.count == 1) + #expect(updated.last == second) + } + + @Test + func `changed reset boundary within hour appends new entry`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let first = planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 82, + resetsAt: hourStart.addingTimeInterval(30 * 60)) + let second = planEntry( + at: hourStart.addingTimeInterval(35 * 60), + usedPercent: 4, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60)) + + let initial = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + + #expect(updated.count == 2) + #expect(updated[0] == first) + #expect(updated[1] == second) + } + + @Test + func `first known reset boundary within hour replaces earlier provisional peak even when usage drops`() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let first = planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 82, + resetsAt: nil) + let second = planEntry( + at: hourStart.addingTimeInterval(35 * 60), + usedPercent: 4, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60)) + + let initial = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + + #expect(updated.count == 1) + #expect(updated[0] == second) + } + + @Test + func `trims entry history to retention limit`() throws { + let maxSamples = UsageStore._planUtilizationMaxSamplesForTesting + let base = Date(timeIntervalSince1970: 1_700_000_000) + var entries: [PlanUtilizationHistoryEntry] = [] + + for offset in 0.. UsageStore { + let suiteName = "UsageStorePlanUtilizationTests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create isolated UserDefaults suite for tests") + } + defaults.removePersistentDomain(forName: suiteName) + let configStore = testConfigStore(suiteName: suiteName) + let planHistoryStore = testPlanUtilizationHistoryStore(suiteName: suiteName) + let temporaryRoot = FileManager.default.temporaryDirectory.standardizedFileURL.path + let managedStoreURL = FileManager.default.temporaryDirectory + .appendingPathComponent("\(suiteName)-managed-codex-accounts.json") + precondition(configStore.fileURL.standardizedFileURL.path.hasPrefix(temporaryRoot)) + precondition(configStore.fileURL.standardizedFileURL != CodexBarConfigStore.defaultURL().standardizedFileURL) + if let historyURL = planHistoryStore.directoryURL?.standardizedFileURL { + precondition(historyURL.path.hasPrefix(temporaryRoot)) + } + let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL) + try? FileManager.default.removeItem(at: managedStoreURL) + do { + try managedStore.storeAccounts(ManagedCodexAccountSet( + version: FileManagedCodexAccountStore.currentVersion, + accounts: [])) + } catch { + fatalError("Failed to seed isolated managed Codex account store: \(error)") + } + let isolatedSettings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + tokenAccountStore: InMemoryTokenAccountStore()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: isolatedSettings, + planUtilizationHistoryStore: planHistoryStore, + startupBehavior: .testing) + isolatedSettings._test_managedCodexAccountStoreURL = managedStoreURL + isolatedSettings.codexActiveSource = .liveSystem + store.planUtilizationHistory = [:] + return store + } + + static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "plus")) + } + + static func loadPlanUtilizationFixture(named name: String) throws -> PlanUtilizationHistoryBuckets { + let fixtureURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures", isDirectory: true) + .appendingPathComponent(name, isDirectory: false) + let data = try Data(contentsOf: fixtureURL) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let document = try decoder.decode(FixtureDocument.self, from: data) + return PlanUtilizationHistoryBuckets( + preferredAccountKey: document.preferredAccountKey, + unscoped: document.unscoped, + accounts: document.accounts) + } +} + +func planEntry(at capturedAt: Date, usedPercent: Double, resetsAt: Date? = nil) -> PlanUtilizationHistoryEntry { + PlanUtilizationHistoryEntry(capturedAt: capturedAt, usedPercent: usedPercent, resetsAt: resetsAt) +} + +func planSeries( + name: PlanUtilizationSeriesName, + windowMinutes: Int, + entries: [PlanUtilizationHistoryEntry]) -> PlanUtilizationSeriesHistory +{ + PlanUtilizationSeriesHistory(name: name, windowMinutes: windowMinutes, entries: entries) +} + +func findSeries( + _ histories: [PlanUtilizationSeriesHistory], + name: PlanUtilizationSeriesName, + windowMinutes: Int) -> PlanUtilizationSeriesHistory? +{ + histories.first { $0.name == name && $0.windowMinutes == windowMinutes } +} + +private final class WeeklyLimitResetEventRecorder: @unchecked Sendable { + struct Event { + let provider: UsageProvider + let accountLabel: String? + let usedPercent: Double + } + + private let provider: UsageProvider + private let accountLabel: String? + private let lock = NSLock() + private var observedEvents: [Event] = [] + private var token: NSObjectProtocol? + + init(provider: UsageProvider, accountLabel: String?) { + self.provider = provider + self.accountLabel = accountLabel + self.token = NotificationCenter.default.addObserver( + forName: .codexbarWeeklyLimitReset, + object: nil, + queue: nil) + { [weak self] notification in + guard let self, + let event = notification.object as? WeeklyLimitResetEvent + else { + return + } + + let recorded = MainActor.assumeIsolated { () -> Event? in + guard event.provider == self.provider, + event.accountLabel == self.accountLabel + else { + return nil + } + return Event( + provider: event.provider, + accountLabel: event.accountLabel, + usedPercent: event.usedPercent) + } + guard let recorded else { return } + + self.lock.lock() + self.observedEvents.append(recorded) + self.lock.unlock() + } + } + + var events: [Event] { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents + } + + var count: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.observedEvents.count + } + + func invalidate() { + guard let token else { return } + NotificationCenter.default.removeObserver(token) + self.token = nil + } + + deinit { + self.invalidate() + } +} + +func formattedBoundary(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: date) +} diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 73af6fca3..1798b8eb4 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -1,26 +1,40 @@ -import CodexBarCore import Foundation import Testing @testable import CodexBar +@testable import CodexBarCore @MainActor -@Suite struct UsageStoreSessionQuotaTransitionTests { + private func makeSettings(suiteName: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suiteName), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + @MainActor final class SessionQuotaNotifierSpy: SessionQuotaNotifying { private(set) var posts: [(transition: SessionQuotaTransition, provider: UsageProvider)] = [] + private(set) var quotaWarningPosts: [( + event: QuotaWarningEvent, + provider: UsageProvider, + soundEnabled: Bool)] = [] func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) { self.posts.append((transition: transition, provider: provider)) } + + func postQuotaWarning(event: QuotaWarningEvent, provider: UsageProvider, soundEnabled: Bool) { + self.quotaWarningPosts.append((event: event, provider: provider, soundEnabled: soundEnabled)) + } } @Test - func copilotSwitchFromPrimaryToSecondaryResetsBaseline() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-primary-secondary"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + func `copilot switch from primary to secondary resets baseline`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-primary-secondary") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.sessionQuotaNotificationsEnabled = true @@ -48,11 +62,8 @@ struct UsageStoreSessionQuotaTransitionTests { } @Test - func copilotSwitchFromSecondaryToPrimaryResetsBaseline() { - let settings = SettingsStore( - configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-secondary-primary"), - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + func `copilot switch from secondary to primary resets baseline`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-secondary-primary") settings.refreshFrequency = .manual settings.statusChecksEnabled = false settings.sessionQuotaNotificationsEnabled = true @@ -78,4 +89,465 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.isEmpty) } + + @Test + func `claude weekly primary fallback does not emit session quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-weekly") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 7 * 24 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.isEmpty) + } + + @Test + func `claude spend limit fallback does not emit session or quota warning notifications`() throws { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-spend-limit") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + let json = """ + { + "extra_usage": { + "is_enabled": true, + "monthly_limit": 600, + "used_credits": 434.43, + "utilization": 72, + "currency": "USD" + } + } + """ + let claude = try ClaudeUsageFetcher._mapOAuthUsageForTesting( + Data(json.utf8), + subscriptionType: "enterprise") + let snapshot = ClaudeOAuthFetchStrategy._snapshotForTesting(from: claude) + + store.handleSessionQuotaTransition(provider: .claude, snapshot: snapshot) + store.handleQuotaWarningTransitions(provider: .claude, snapshot: snapshot) + + #expect(snapshot.primary == nil) + #expect(snapshot.providerCost?.period == "Spend limit") + #expect(notifier.posts.isEmpty) + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `claude five hour primary still emits session quota notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-session") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let baseline = UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: 5 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow(usedPercent: 100, windowMinutes: 5 * 60, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.map(\.provider) == [.claude]) + } + + @Test + func `quota warning disabled does not post`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-disabled") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = false + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 90, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleQuotaWarningTransitions(provider: .codex, snapshot: snapshot) + + #expect(notifier.quotaWarningPosts.isEmpty) + } + + @Test + func `quota warning posts once per downward threshold crossing`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-once") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil))) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil))) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil))) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.window == .session) + #expect(notifier.quotaWarningPosts.first?.event.threshold == 50) + #expect(notifier.quotaWarningPosts.first?.event.accountDisplayName == "person@example.com") + } + + @Test + func `quota warning omits account when personal info is hidden`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-account-hidden") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.hidePersonalInfo = true + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "person@example.com", + accountOrganization: nil, + loginMethod: nil) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: identity)) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: identity)) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.accountDisplayName == nil) + } + + @Test + func `hidden quota warning markers do not disable warning notifications`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-markers-hidden") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningMarkersVisible = false + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(notifier.quotaWarningPosts.first?.event.threshold == 50) + } + + @Test + func `quota warning crossing multiple thresholds posts most severe only`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-severe") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50, 20] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 85, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [20]) + } + + @Test + func `quota warning recovers and can fire again`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-recover") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + for used in [40, 55, 10, 55] { + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow( + usedPercent: Double(used), + windowMinutes: nil, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date())) + } + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [50, 50]) + } + + @Test + func `quota warning provider override beats global thresholds`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-override") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + settings.setQuotaWarningThresholds(provider: .codex, window: .session, thresholds: [10]) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 95, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.threshold) == [10]) + } + + @Test + func `quota warning session only config ignores weekly crossings`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-session-only") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: true) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: false) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.session]) + } + + @Test + func `quota warning weekly only config ignores session crossings`() { + let settings = self.makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-weekly-only") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + settings.setQuotaWarningWindowEnabled(.session, enabled: false) + settings.setQuotaWarningWindowEnabled(.weekly, enabled: true) + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.map(\.event.window) == [.weekly]) + } + + @Test + func `disabling quota warning window clears fired state`() { + let settings = self + .makeSettings(suiteName: "UsageStoreSessionQuotaTransitionTests-warning-disabled-clears-state") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.quotaWarningNotificationsEnabled = true + settings.quotaWarningThresholds = [50] + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + settings.setQuotaWarningWindowEnabled(.session, enabled: false) + store.handleQuotaWarningTransitions( + provider: .codex, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 60, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date())) + + #expect(notifier.quotaWarningPosts.count == 1) + #expect(store.quotaWarningState[UsageStore.QuotaWarningStateKey(provider: .codex, window: .session)] == nil) + } } diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift new file mode 100644 index 000000000..dcc797800 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -0,0 +1,50 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct UsageStoreWidgetSnapshotTests { + @Test + func `widget snapshot includes antigravity tertiary usage row`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-tertiary" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "antigravity-tertiary-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) + #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "tertiary"]) + #expect(entry.usageRows?.map(\.title) == ["Claude", "Gemini Pro", "Gemini Flash"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [90, 80, 70]) + } +} diff --git a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift new file mode 100644 index 000000000..b6545f074 --- /dev/null +++ b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +struct UserFacingLocalizationCoverageTests { + @Test + func `selected user-facing UI surfaces avoid raw English literals`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let forbiddenMarkersByFile: [String: [String]] = [ + "Sources/CodexBar/CostHistoryChartMenuView.swift": [ + ".value(\"Day\"", + ".value(\"Cost\"", + ".value(\"Cap start\"", + ".value(\"Cap end\"", + ], + "Sources/CodexBar/CreditsHistoryChartMenuView.swift": [ + ".value(\"Day\"", + ".value(\"Credits used\"", + ".value(\"Cap start\"", + ".value(\"Cap end\"", + "Text(\"Total (30d):", + "\\(total) credits", + "\\(used) credits", + ], + "Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift": [ + ".value(\"Series\"", + ".value(\"Capacity Start\"", + ".value(\"Capacity End\"", + ".value(\"Utilization Start\"", + ".value(\"Utilization End\"", + ], + "Sources/CodexBar/PreferencesCodexAccountsSection.swift": [ + "?? \"No system account\"", + "return \"Adding Account…\"", + "return \"Add Account\"", + "return \"Re-authenticating…\"", + "return \"Re-auth\"", + "ProviderSettingsSection(title: \"Accounts\")", + "Text(\"Active\")", + "Text(\"Choose which Codex account CodexBar should follow.\")", + "Text(\"Account\")", + "Text(\"No Codex accounts detected yet.\")", + "Text(\"System\")", + "Text(\"The default Codex account on this Mac.\")", + "Text(\"(System)\")", + "Button(\"Remove\")", + ], + "Sources/CodexBar/PreferencesProviderDetailView.swift": [ + ".help(\"Refresh\")", + "accessibilityLabel: \"Usage used\"", + ], + "Sources/CodexBar/PreferencesProviderErrorView.swift": [ + ".help(\"Copy error\")", + ], + "Sources/CodexBar/PreferencesProviderSettingsRows.swift": [ + "Text(self.title)", + "Text(self.toggle.title)", + "Text(self.toggle.subtitle)", + "Button(action.title)", + "Text(self.picker.title)", + "Text(option.title)", + "Text(trimmedTitle)", + "Text(trimmedSubtitle)", + "Text(self.descriptor.title)", + "Text(self.descriptor.subtitle)", + "Text(\"No token accounts yet.\")", + "Button(\"Remove\")", + "TextField(\"Label\"", + "Button(\"Add\")", + "TextField(\"Org ID (optional)\"", + ".help(\"Optional organization ID for accounts linked to multiple Anthropic organizations.\")", + "Button(\"Open token file\")", + "Button(\"Reload\")", + "Text(\"No organizations loaded. Click Refresh after setting your API key.\")", + "Button(\"Refresh organizations\")", + ], + "Sources/CodexBar/PreferencesProviderSidebarView.swift": [ + ".help(\"Drag to reorder\")", + "\"Disabled —", + ".accessibilityLabel(\"Reorder\")", + ], + "Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift": [ + "Text(\"Subscription Utilization\")", + ], + "Sources/CodexBar/StatusItemController+CostMenuCard.swift": [ + "static let costMenuTitle", + ], + "Sources/CodexBar/UsageBreakdownChartMenuView.swift": [ + ".value(\"Day\"", + ".value(\"Credits used\"", + ".value(\"Service\"", + ".value(\"Cap start\"", + ".value(\"Cap end\"", + ], + ] + + var violations: [String] = [] + for (relativePath, markers) in forbiddenMarkersByFile.sorted(by: { $0.key < $1.key }) { + let source = try String(contentsOf: root.appendingPathComponent(relativePath), encoding: .utf8) + for marker in markers where source.contains(marker) { + violations.append("\(relativePath): \(marker)") + } + } + + #expect( + violations.isEmpty, + "Raw user-facing localization markers remain:\n\(violations.joined(separator: "\n"))") + } +} diff --git a/Tests/CodexBarTests/VeniceSettingsReaderTests.swift b/Tests/CodexBarTests/VeniceSettingsReaderTests.swift new file mode 100644 index 000000000..bc44a0645 --- /dev/null +++ b/Tests/CodexBarTests/VeniceSettingsReaderTests.swift @@ -0,0 +1,73 @@ +import CodexBarCore +import Testing + +struct VeniceSettingsReaderTests { + @Test + func `reads VENICE_API_KEY`() { + let env = ["VENICE_API_KEY": "ven-abc123"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-abc123") + } + + @Test + func `falls back to VENICE_KEY`() { + let env = ["VENICE_KEY": "ven-fallback"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-fallback") + } + + @Test + func `VENICE_API_KEY takes priority over VENICE_KEY`() { + let env = ["VENICE_API_KEY": "ven-primary", "VENICE_KEY": "ven-secondary"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-primary") + } + + @Test + func `trims whitespace`() { + let env = ["VENICE_API_KEY": " ven-trimmed "] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-trimmed") + } + + @Test + func `strips double quotes`() { + let env = ["VENICE_API_KEY": "\"ven-quoted\""] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-quoted") + } + + @Test + func `strips single quotes`() { + let env = ["VENICE_KEY": "'ven-single'"] + #expect(VeniceSettingsReader.apiKey(environment: env) == "ven-single") + } + + @Test + func `returns nil when no key present`() { + #expect(VeniceSettingsReader.apiKey(environment: [:]) == nil) + } + + @Test + func `returns nil for empty key`() { + let env = ["VENICE_API_KEY": ""] + #expect(VeniceSettingsReader.apiKey(environment: env) == nil) + } + + @Test + func `returns nil for whitespace-only key`() { + let env = ["VENICE_API_KEY": " "] + #expect(VeniceSettingsReader.apiKey(environment: env) == nil) + } +} + +struct VeniceProviderTokenResolverTests { + @Test + func `resolves from environment`() { + let env = ["VENICE_API_KEY": "ven-resolve-test"] + let resolution = ProviderTokenResolver.veniceResolution(environment: env) + #expect(resolution?.token == "ven-resolve-test") + #expect(resolution?.source == .environment) + } + + @Test + func `returns nil when key absent`() { + let resolution = ProviderTokenResolver.veniceResolution(environment: [:]) + #expect(resolution == nil) + } +} diff --git a/Tests/CodexBarTests/VeniceUsageFetcherTests.swift b/Tests/CodexBarTests/VeniceUsageFetcherTests.swift new file mode 100644 index 000000000..fc226997c --- /dev/null +++ b/Tests/CodexBarTests/VeniceUsageFetcherTests.swift @@ -0,0 +1,297 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct VeniceUsageFetcherTests { + @Test + func `parses DIEM balance response`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 90.50, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.canConsume == true) + #expect(snapshot.consumptionCurrency == "DIEM") + #expect(snapshot.diemBalance == 90.50) + #expect(snapshot.usdBalance == nil) + #expect(snapshot.diemEpochAllocation == 100.0) + } + + @Test + func `parses USD balance response`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": null, + "usd": 25.75 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.canConsume == true) + #expect(snapshot.consumptionCurrency == "USD") + #expect(snapshot.diemBalance == nil) + #expect(snapshot.usdBalance == 25.75) + #expect(snapshot.diemEpochAllocation == nil) + } + + @Test + func `parses string-encoded balances and allocation`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": "90.50", + "usd": "25.75" + }, + "diemEpochAllocation": "100.0" + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.diemBalance == 90.50) + #expect(snapshot.usdBalance == 25.75) + #expect(snapshot.diemEpochAllocation == 100.0) + } + + @Test + func `parses both DIEM and USD present`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "BUNDLED_CREDITS", + "balances": { + "diem": 50.0, + "usd": 10.0 + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + #expect(snapshot.diemBalance == 50.0) + #expect(snapshot.usdBalance == 10.0) + } + + @Test + func `uses DIEM allocation progress for bundled credits currency`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "BUNDLED_CREDITS", + "balances": { + "diem": 50.0, + "usd": 10.0 + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("DIEM 50.00 / 100.00") == true) + #expect(usage.primary?.usedPercent == 50.0) + } + + @Test + func `uses USD display when consumptionCurrency is USD and both balances exist`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": 50.0, + "usd": 12.34 + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "$12.34 USD remaining") + #expect(usage.primary?.usedPercent == 0) + } + + @Test + func `handles canConsume=false`() throws { + let json = """ + { + "canConsume": false, + "consumptionCurrency": "USD", + "balances": { + "diem": null, + "usd": 100.0 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 100) + #expect(usage.primary?.resetDescription == "Balance unavailable for API calls") + } + + @Test + func `displays DIEM with epoch allocation`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 75.0, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("DIEM 75.00 / 100.00") == true) + #expect(usage.primary?.usedPercent == 25.0) + } + + @Test + func `displays DIEM without allocation`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 50.0, + "usd": null + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("DIEM 50.00 remaining") == true) + #expect(usage.primary?.usedPercent == 0) + } + + @Test + func `displays USD balance`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": null, + "usd": 15.50 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription?.contains("$15.50") == true) + #expect(usage.primary?.usedPercent == 0) + } + + @Test + func `handles zero balances`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "USD", + "balances": { + "diem": 0.0, + "usd": 0.0 + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "No Venice API balance available") + #expect(usage.primary?.usedPercent == 100) + } + + @Test + func `handles null balances with canConsume=true`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": null, + "balances": { + "diem": null, + "usd": null + }, + "diemEpochAllocation": null + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetDescription == "No Venice API balance available") + #expect(usage.primary?.usedPercent == 100) + } + + @Test + func `identity uses venice provider ID`() throws { + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 90.0, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.identity?.providerID == .venice) + #expect(usage.identity?.accountEmail == nil) + #expect(usage.identity?.accountOrganization == nil) + } + + @Test + func `throws on malformed JSON`() { + let json = "[{ \"canConsume\": true }]" + #expect { + _ = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case VeniceUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `throws on invalid JSON`() { + let json = "{ invalid json }" + #expect { + _ = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + } throws: { error in + guard case VeniceUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `clamps used percent to 0-100 range`() throws { + // Negative used percent should be clamped to 0 + let json = """ + { + "canConsume": true, + "consumptionCurrency": "DIEM", + "balances": { + "diem": 150.0, + "usd": null + }, + "diemEpochAllocation": 100.0 + } + """ + let snapshot = try VeniceUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 0) + } +} diff --git a/Tests/CodexBarTests/VertexAIOAuthCredentialsTests.swift b/Tests/CodexBarTests/VertexAIOAuthCredentialsTests.swift new file mode 100644 index 000000000..9527fdb3c --- /dev/null +++ b/Tests/CodexBarTests/VertexAIOAuthCredentialsTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct VertexAIOAuthCredentialsTests { + @Test + func `service account credentials from GOOGLE_APPLICATION_CREDENTIALS use gcloud token`() async throws { + let fileURL = try Self.writeServiceAccountCredentials() + defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) } + let env = ["GOOGLE_APPLICATION_CREDENTIALS": fileURL.path] + + #expect(VertexAIOAuthCredentialsStore.hasCredentials(environment: env)) + + let override: @Sendable ([String: String]) async throws -> String = { environment in + #expect(environment["GOOGLE_APPLICATION_CREDENTIALS"] == fileURL.path) + return "ya29.service-account\n" + } + let credentials = try await VertexAIOAuthCredentialsStore.$gcloudAccessTokenOverrideForTesting.withValue( + override) + { + try await VertexAIOAuthCredentialsStore.loadForFetch(environment: env) + } + + #expect(credentials.accessToken == "ya29.service-account") + #expect(credentials.projectId == "service-project") + #expect(credentials.email == "codexbar@test.iam.gserviceaccount.com") + #expect(!credentials.needsRefresh) + } + + @Test + func `user ADC credentials still parse from CLOUDSDK_CONFIG`() throws { + let configDir = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-vertex-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: configDir) } + + let credentialsURL = configDir.appendingPathComponent("application_default_credentials.json") + let credentialsJSON = """ + { + "client_id": "client-id", + "client_secret": "client-secret", + "refresh_token": "refresh-token" + } + """ + try credentialsJSON.write(to: credentialsURL, atomically: true, encoding: .utf8) + + let configurationsDir = configDir + .appendingPathComponent("configurations", isDirectory: true) + try FileManager.default.createDirectory(at: configurationsDir, withIntermediateDirectories: true) + try "project = configured-project\n".write( + to: configurationsDir.appendingPathComponent("config_default"), + atomically: true, + encoding: .utf8) + + let env = ["CLOUDSDK_CONFIG": configDir.path] + let credentials = try VertexAIOAuthCredentialsStore.load(environment: env) + + #expect(credentials.refreshToken == "refresh-token") + #expect(credentials.projectId == "configured-project") + } + + private static func writeServiceAccountCredentials() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-vertex-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let fileURL = directory.appendingPathComponent("service-account.json") + let json = """ + { + "type": "service_account", + "project_id": "service-project", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----\\n", + "client_email": "codexbar@test.iam.gserviceaccount.com", + "client_id": "1234567890", + "token_uri": "https://oauth2.googleapis.com/token" + } + """ + try json.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } +} diff --git a/Tests/CodexBarTests/WarpUsageFetcherTests.swift b/Tests/CodexBarTests/WarpUsageFetcherTests.swift index 9fb74999c..7d9bf1fc5 100644 --- a/Tests/CodexBarTests/WarpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/WarpUsageFetcherTests.swift @@ -2,10 +2,9 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct WarpUsageFetcherTests { @Test - func parsesSnapshotAndAggregatesBonusCredits() throws { + func `parses snapshot and aggregates bonus credits`() throws { let json = """ { "data": { @@ -66,7 +65,7 @@ struct WarpUsageFetcherTests { } @Test - func graphQLErrorsThrowAPIError() { + func `graph QL errors throw API error`() { let json = """ { "errors": [ @@ -84,7 +83,7 @@ struct WarpUsageFetcherTests { } @Test - func nullUnlimitedAndStringNumericsParseSafely() throws { + func `null unlimited and string numerics parse safely`() throws { let json = """ { "data": { @@ -112,7 +111,7 @@ struct WarpUsageFetcherTests { } @Test - func unexpectedTypenameReturnsParseError() { + func `unexpected typename returns parse error`() { let json = """ { "data": { @@ -132,7 +131,7 @@ struct WarpUsageFetcherTests { } @Test - func missingRequestLimitInfoReturnsParseError() { + func `missing request limit info returns parse error`() { let json = """ { "data": { @@ -153,7 +152,7 @@ struct WarpUsageFetcherTests { } @Test - func invalidRootReturnsParseError() { + func `invalid root returns parse error`() { let json = """ [{ "data": {} }] """ @@ -167,7 +166,7 @@ struct WarpUsageFetcherTests { } @Test - func toUsageSnapshotOmitsSecondaryWhenNoBonusCredits() { + func `to usage snapshot omits secondary when no bonus credits`() { let source = WarpUsageSnapshot( requestLimit: 100, requestsUsed: 10, @@ -184,7 +183,7 @@ struct WarpUsageFetcherTests { } @Test - func toUsageSnapshotKeepsBonusWindowWhenBonusExists() throws { + func `to usage snapshot keeps bonus window when bonus exists`() throws { let source = WarpUsageSnapshot( requestLimit: 100, requestsUsed: 10, @@ -202,7 +201,7 @@ struct WarpUsageFetcherTests { } @Test - func toUsageSnapshotUnlimitedPrimaryDoesNotShowResetDate() throws { + func `to usage snapshot unlimited primary does not show reset date`() throws { let source = WarpUsageSnapshot( requestLimit: 0, requestsUsed: 0, @@ -221,7 +220,7 @@ struct WarpUsageFetcherTests { } @Test - func apiErrorSummaryIncludesPlainTextBodies() { + func `api error summary includes plain text bodies`() { // Regression: Warp edge returns 429 with a non-JSON body ("Rate exceeded.") when User-Agent is missing/wrong. let summary = WarpUsageFetcher._apiErrorSummaryForTesting( statusCode: 429, diff --git a/Tests/CodexBarTests/WebKitTeardownTests.swift b/Tests/CodexBarTests/WebKitTeardownTests.swift index f824620bc..b3d4dac15 100644 --- a/Tests/CodexBarTests/WebKitTeardownTests.swift +++ b/Tests/CodexBarTests/WebKitTeardownTests.swift @@ -3,13 +3,12 @@ import AppKit import Testing @testable import CodexBarCore -@Suite @MainActor struct WebKitTeardownTests { final class Owner {} @Test - func scheduleCleanupRegistersOwner() { + func `schedule cleanup registers owner`() { let owner = Owner() WebKitTeardown.resetForTesting() WebKitTeardown.scheduleCleanup(owner: owner, window: nil, webView: nil) diff --git a/Tests/CodexBarTests/WidgetSnapshotTests.swift b/Tests/CodexBarTests/WidgetSnapshotTests.swift index 6be63f89e..d431e7eb6 100644 --- a/Tests/CodexBarTests/WidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/WidgetSnapshotTests.swift @@ -2,23 +2,29 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct WidgetSnapshotTests { @Test - func widgetSnapshotRoundTrip() throws { + func `widget snapshot round trip`() throws { let entry = WidgetSnapshot.ProviderEntry( provider: .codex, updatedAt: Date(), primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), tertiary: nil, + usageRows: [ + WidgetSnapshot.WidgetUsageRowSnapshot(id: "session", title: "Session", percentLeft: 90), + WidgetSnapshot.WidgetUsageRowSnapshot(id: "weekly", title: "Weekly", percentLeft: 80), + ], creditsRemaining: 123.4, codeReviewRemainingPercent: 80, tokenUsage: WidgetSnapshot.TokenUsageSummary( sessionCostUSD: 12.3, sessionTokens: 1200, last30DaysCostUSD: 456.7, - last30DaysTokens: 9800), + last30DaysTokens: 9800, + currencyCode: "eur", + sessionLabel: "Latest billing day", + last30DaysLabel: "This month"), dailyUsage: [ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-20", totalTokens: 1200, costUSD: 12.3), ]) @@ -39,11 +45,15 @@ struct WidgetSnapshotTests { #expect(decoded.entries.count == 1) #expect(decoded.entries.first?.provider == .codex) #expect(decoded.entries.first?.tokenUsage?.sessionTokens == 1200) + #expect(decoded.entries.first?.tokenUsage?.currencyCode == "EUR") + #expect(decoded.entries.first?.tokenUsage?.sessionLabel == "Latest billing day") + #expect(decoded.entries.first?.tokenUsage?.last30DaysLabel == "This month") + #expect(decoded.entries.first?.usageRows?.map(\.id) == ["session", "weekly"]) #expect(decoded.enabledProviders == [.codex, .claude]) } @Test - func widgetSnapshotRoundTripPreservesKiloProvider() throws { + func `widget snapshot round trip preserves kilo provider`() throws { let entry = WidgetSnapshot.ProviderEntry( provider: .kilo, updatedAt: Date(), @@ -80,7 +90,7 @@ struct WidgetSnapshotTests { } @Test - func widgetSnapshotRoundTripPreservesKiloZeroTotalEdgeState() throws { + func `widget snapshot round trip preserves kilo zero total edge state`() throws { let now = Date() let kiloSnapshot = KiloUsageSnapshot( creditsUsed: 0, @@ -121,4 +131,76 @@ struct WidgetSnapshotTests { #expect(decoded.entries.first?.primary?.resetDescription == "0/0 credits") #expect(decoded.enabledProviders == [.kilo]) } + + @Test + func `widget snapshot decodes legacy payload without usage rows`() throws { + let json = """ + { + "entries": [ + { + "provider": "codex", + "updatedAt": "2026-04-04T06:30:00Z", + "primary": null, + "secondary": { + "usedPercent": 25, + "windowMinutes": 10080, + "resetsAt": null, + "resetDescription": null + }, + "tertiary": null, + "creditsRemaining": null, + "codeReviewRemainingPercent": null, + "tokenUsage": null, + "dailyUsage": [] + } + ], + "enabledProviders": ["codex"], + "generatedAt": "2026-04-04T06:30:00Z" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(WidgetSnapshot.self, from: Data(json.utf8)) + + #expect(decoded.entries.count == 1) + #expect(decoded.entries.first?.usageRows == nil) + #expect(decoded.entries.first?.secondary?.usedPercent == 25) + } + + @Test + func `widget snapshot decodes legacy token usage as usd`() throws { + let json = """ + { + "entries": [ + { + "provider": "codex", + "updatedAt": "2026-04-04T06:30:00Z", + "primary": null, + "secondary": null, + "tertiary": null, + "creditsRemaining": null, + "codeReviewRemainingPercent": null, + "tokenUsage": { + "sessionCostUSD": 1.25, + "sessionTokens": 1200, + "last30DaysCostUSD": 9.50, + "last30DaysTokens": 4200 + }, + "dailyUsage": [] + } + ], + "generatedAt": "2026-04-04T06:30:00Z" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(WidgetSnapshot.self, from: Data(json.utf8)) + + #expect(decoded.entries.first?.tokenUsage?.currencyCode == "USD") + #expect(decoded.entries.first?.tokenUsage?.sessionLabel == "Today") + #expect(decoded.entries.first?.tokenUsage?.last30DaysLabel == "30d") + #expect(decoded.enabledProviders == [.codex]) + } } diff --git a/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift b/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift new file mode 100644 index 000000000..6d9a6ed72 --- /dev/null +++ b/Tests/CodexBarTests/WindsurfDevinSessionImporterTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct WindsurfDevinSessionImporterTests { + @Test + func `defaults to Chrome before fallback Chromium browsers`() { + #expect(WindsurfDevinSessionImporter.defaultPreferredBrowsers == [.chrome]) + #expect(!WindsurfDevinSessionImporter.fallbackBrowsers.contains(.chrome)) + #expect(WindsurfDevinSessionImporter.fallbackBrowsersExcluding([.chrome, .edge]).first == .chromeBeta) + #expect(!WindsurfDevinSessionImporter.fallbackBrowsersExcluding([.chrome, .edge]).contains(.edge)) + } + + @Test + func `decodes quoted local storage strings`() { + #expect(WindsurfDevinSessionImporter + .decodedStorageValue(#""devin-session-token$abc""#) == "devin-session-token$abc") + #expect(WindsurfDevinSessionImporter.decodedStorageValue("auth1_xyz") == "auth1_xyz") + } + + @Test + func `builds session only when all local storage keys exist`() { + let storage = [ + "devin_session_token": "devin-session-token$abc", + "devin_auth1_token": "auth1_xyz", + "devin_account_id": "account-123", + "devin_primary_org_id": "org-456", + ] + + let session = WindsurfDevinSessionImporter.session(from: storage, sourceLabel: "Chrome Default") + + #expect(session?.session.sessionToken == "devin-session-token$abc") + #expect(session?.session.auth1Token == "auth1_xyz") + #expect(session?.session.accountID == "account-123") + #expect(session?.session.primaryOrgID == "org-456") + #expect(session?.sourceLabel == "Chrome Default") + } + + @Test + func `deduplicates repeated session tokens while preserving first source`() { + let sessions = [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "devin-session-token$abc", + auth1Token: "auth1_xyz", + accountID: "account-123", + primaryOrgID: "org-456"), + sourceLabel: "Chrome Default"), + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "devin-session-token$abc", + auth1Token: "auth1_other", + accountID: "account-999", + primaryOrgID: "org-999"), + sourceLabel: "Chrome Profile 1"), + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "devin-session-token$def", + auth1Token: "auth1_def", + accountID: "account-456", + primaryOrgID: "org-789"), + sourceLabel: "Chrome Profile 2"), + ] + + let deduplicated = WindsurfDevinSessionImporter.deduplicateSessions(sessions) + + #expect(deduplicated.count == 2) + #expect(deduplicated[0].sourceLabel == "Chrome Default") + #expect(deduplicated[0].session.sessionToken == "devin-session-token$abc") + #expect(deduplicated[1].session.sessionToken == "devin-session-token$def") + } +} diff --git a/Tests/CodexBarTests/WindsurfProviderTests.swift b/Tests/CodexBarTests/WindsurfProviderTests.swift new file mode 100644 index 000000000..c0b9dd54c --- /dev/null +++ b/Tests/CodexBarTests/WindsurfProviderTests.swift @@ -0,0 +1,55 @@ +import Testing +@testable import CodexBarCore + +struct WindsurfProviderTests { + private func makeContext( + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } + + @Test + func `local probe is unavailable in explicit web mode`() async { + let strategy = WindsurfLocalFetchStrategy() + + #expect(await strategy.isAvailable(self.makeContext(sourceMode: .web)) == false) + #expect(await strategy.isAvailable(self.makeContext(sourceMode: .auto))) + #expect(await strategy.isAvailable(self.makeContext(sourceMode: .cli))) + } + + @Test + func `web mode with cookies off does not fall back to local probe`() async { + let settings = ProviderSettingsSnapshot.make( + windsurf: .init( + usageDataSource: .web, + cookieSource: .off, + manualCookieHeader: nil)) + let context = self.makeContext(sourceMode: .web, settings: settings) + + let outcome = await WindsurfProviderDescriptor.descriptor.fetchPlan.fetchOutcome( + context: context, + provider: .windsurf) + + guard case let .failure(error) = outcome.result else { + Issue.record("Expected web-only Windsurf fetch to fail when cookies are off") + return + } + + #expect(error is ProviderFetchError) + #expect(outcome.attempts.map(\.strategyID) == ["windsurf.web", "windsurf.local"]) + #expect(outcome.attempts.map(\.wasAvailable) == [false, false]) + } +} diff --git a/Tests/CodexBarTests/WindsurfStatusProbeTests.swift b/Tests/CodexBarTests/WindsurfStatusProbeTests.swift new file mode 100644 index 000000000..42524a9e7 --- /dev/null +++ b/Tests/CodexBarTests/WindsurfStatusProbeTests.swift @@ -0,0 +1,330 @@ +import CodexBarCore +import Foundation +import SQLite3 +import Testing + +struct WindsurfStatusProbeTests { + // MARK: - Helper + + private static func decode(_ json: String) throws -> WindsurfCachedPlanInfo { + try JSONDecoder().decode(WindsurfCachedPlanInfo.self, from: Data(json.utf8)) + } + + // MARK: - JSON Decoding + + @Test + func `decodes full plan info`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "startTimestamp": 1771610750000, + "endTimestamp": 1774029950000, + "usage": { + "messages": 50000, + "usedMessages": 35650, + "remainingMessages": 14350, + "flowActions": 150000, + "usedFlowActions": 0, + "remainingFlowActions": 150000 + }, + "quotaUsage": { + "dailyRemainingPercent": 9, + "weeklyRemainingPercent": 54, + "dailyResetAtUnix": 1774080000, + "weeklyResetAtUnix": 1774166400 + } + } + """) + + #expect(info.planName == "Pro") + #expect(info.startTimestamp == 1_771_610_750_000) + #expect(info.endTimestamp == 1_774_029_950_000) + #expect(info.usage?.messages == 50000) + #expect(info.usage?.usedMessages == 35650) + #expect(info.usage?.remainingMessages == 14350) + #expect(info.usage?.flowActions == 150_000) + #expect(info.usage?.usedFlowActions == 0) + #expect(info.usage?.remainingFlowActions == 150_000) + #expect(info.quotaUsage?.dailyRemainingPercent == 9) + #expect(info.quotaUsage?.weeklyRemainingPercent == 54) + #expect(info.quotaUsage?.dailyResetAtUnix == 1_774_080_000) + #expect(info.quotaUsage?.weeklyResetAtUnix == 1_774_166_400) + } + + @Test + func `decodes minimal plan info`() throws { + let info = try Self.decode(""" + {"planName": "Free"} + """) + + #expect(info.planName == "Free") + #expect(info.usage == nil) + #expect(info.quotaUsage == nil) + #expect(info.endTimestamp == nil) + } + + @Test + func `decodes empty object`() throws { + let info = try Self.decode("{}") + + #expect(info.planName == nil) + #expect(info.usage == nil) + #expect(info.quotaUsage == nil) + } + + // MARK: - toUsageSnapshot Conversion + + @Test + func `converts full plan to usage snapshot`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "startTimestamp": 1771610750000, + "endTimestamp": 1774029950000, + "usage": { + "messages": 50000, "usedMessages": 35650, "remainingMessages": 14350, + "flowActions": 150000, "usedFlowActions": 0, "remainingFlowActions": 150000 + }, + "quotaUsage": { + "dailyRemainingPercent": 9, "weeklyRemainingPercent": 54, + "dailyResetAtUnix": 1774080000, "weeklyResetAtUnix": 1774166400 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + // Primary = daily: usedPercent = 100 - 9 = 91 + #expect(snapshot.primary?.usedPercent == 91) + #expect(snapshot.primary?.resetsAt != nil) + + // Secondary = weekly: usedPercent = 100 - 54 = 46 + #expect(snapshot.secondary?.usedPercent == 46) + #expect(snapshot.secondary?.resetsAt != nil) + + // Identity + #expect(snapshot.identity?.providerID == .windsurf) + #expect(snapshot.identity?.loginMethod == "Pro") + #expect(snapshot.identity?.accountOrganization != nil) + } + + @Test + func `converts minimal plan to usage snapshot`() throws { + let info = try Self.decode(""" + {"planName": "Free"} + """) + + let snapshot = info.toUsageSnapshot() + + // Without quotaUsage, primary and secondary should be nil + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.identity?.loginMethod == "Free") + #expect(snapshot.identity?.accountOrganization == nil) + } + + @Test + func `converts usage counts when quota usage is absent`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "usage": { + "messages": 50000, + "usedMessages": 1200, + "remainingMessages": 48800, + "flowActions": 150000, + "usedFlowActions": 0, + "remainingFlowActions": 150000, + "flexCredits": 123700, + "usedFlexCredits": 0, + "remainingFlexCredits": 123700 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 2.4) + #expect(snapshot.primary?.resetDescription == "1200 / 50000 messages") + #expect(snapshot.secondary?.usedPercent == 0) + #expect(snapshot.secondary?.resetDescription == "0 / 150000 flow actions") + } + + @Test + func `usage counts infer used amount from remaining`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "usage": { + "messages": 100, + "remainingMessages": 25 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 75) + #expect(snapshot.primary?.resetDescription == "75 / 100 messages") + #expect(snapshot.secondary == nil) + } + + @Test + func `daily at zero remaining shows 100 percent used`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": {"dailyRemainingPercent": 0, "weeklyRemainingPercent": 100} + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 100) + #expect(snapshot.secondary?.usedPercent == 0) + } + + @Test + func `weekly at full remaining shows 0 percent used`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": {"dailyRemainingPercent": 100, "weeklyRemainingPercent": 100} + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.usedPercent == 0) + #expect(snapshot.secondary?.usedPercent == 0) + } + + @Test + func `reset dates are correctly converted from unix timestamps`() throws { + let info = try Self.decode(""" + { + "planName": "Pro", + "quotaUsage": { + "dailyRemainingPercent": 50, "weeklyRemainingPercent": 50, + "dailyResetAtUnix": 1774080000, "weeklyResetAtUnix": 1774166400 + } + } + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.primary?.resetsAt == Date(timeIntervalSince1970: 1_774_080_000)) + #expect(snapshot.secondary?.resetsAt == Date(timeIntervalSince1970: 1_774_166_400)) + } + + @Test + func `end timestamp converts to expiry description`() throws { + let futureMs = Int64(Date().addingTimeInterval(86400 * 30).timeIntervalSince1970 * 1000) + let info = try Self.decode(""" + {"planName": "Pro", "endTimestamp": \(futureMs)} + """) + + let snapshot = info.toUsageSnapshot() + + #expect(snapshot.identity?.accountOrganization?.hasPrefix("Expires ") == true) + } + + // MARK: - Probe Database Decoding + + @Test + func `probe decodes UTF-8 JSON blob`() throws { + let dbURL = try Self.makeTemporaryDatabase( + jsonData: Data(#"{"planName":"UTF-8 Pro"}"#.utf8)) + defer { try? FileManager.default.removeItem(at: dbURL.deletingLastPathComponent()) } + + let info = try WindsurfStatusProbe(dbPath: dbURL.path).fetch() + + #expect(info.planName == "UTF-8 Pro") + } + + @Test + func `probe decodes UTF-16LE JSON blob`() throws { + let jsonData = try #require(#"{"planName":"UTF-16 Pro"}"#.data(using: .utf16LittleEndian)) + let dbURL = try Self.makeTemporaryDatabase(jsonData: jsonData) + defer { try? FileManager.default.removeItem(at: dbURL.deletingLastPathComponent()) } + + let info = try WindsurfStatusProbe(dbPath: dbURL.path).fetch() + + #expect(info.planName == "UTF-16 Pro") + } + + // MARK: - Probe Error Cases + + @Test + func `probe throws dbNotFound for missing file`() { + let probe = WindsurfStatusProbe(dbPath: "/nonexistent/path/state.vscdb") + + #expect(throws: WindsurfStatusProbeError.self) { + _ = try probe.fetch() + } + } + + private static func makeTemporaryDatabase(jsonData: Data) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("windsurf-status-probe-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let dbURL = directory.appendingPathComponent("state.vscdb") + + var db: OpaquePointer? + guard sqlite3_open(dbURL.path, &db) == SQLITE_OK else { + throw TestSQLiteError.openFailed(String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_close(db) } + + try self.execute( + """ + CREATE TABLE ItemTable( + key TEXT PRIMARY KEY, + value BLOB + ); + """, + db: db) + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO ItemTable(key, value) VALUES('windsurf.settings.cachedPlanInfo', ?);", + -1, + &stmt, + nil) == SQLITE_OK + else { + throw TestSQLiteError.prepareFailed(String(cString: sqlite3_errmsg(db))) + } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + let bindResult = jsonData.withUnsafeBytes { buffer in + sqlite3_bind_blob(stmt, 1, buffer.baseAddress, Int32(jsonData.count), transient) + } + guard bindResult == SQLITE_OK else { + throw TestSQLiteError.bindFailed(String(cString: sqlite3_errmsg(db))) + } + guard sqlite3_step(stmt) == SQLITE_DONE else { + throw TestSQLiteError.stepFailed(String(cString: sqlite3_errmsg(db))) + } + + return dbURL + } + + private static func execute(_ sql: String, db: OpaquePointer?) throws { + var errorMessage: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &errorMessage) == SQLITE_OK else { + defer { sqlite3_free(errorMessage) } + let message = errorMessage.map { String(cString: $0) } ?? "unknown error" + throw TestSQLiteError.execFailed(message) + } + } + + private enum TestSQLiteError: Error { + case openFailed(String) + case execFailed(String) + case prepareFailed(String) + case bindFailed(String) + case stepFailed(String) + } +} diff --git a/Tests/CodexBarTests/WindsurfWebFetcherTests.swift b/Tests/CodexBarTests/WindsurfWebFetcherTests.swift new file mode 100644 index 000000000..c6f3e571a --- /dev/null +++ b/Tests/CodexBarTests/WindsurfWebFetcherTests.swift @@ -0,0 +1,474 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct WindsurfWebFetcherTests { + private struct ResponseFixture { + let planName: String + let dailyRemaining: Int + let weeklyRemaining: Int + let planEndUnix: Int64 + let dailyResetUnix: Int64 + let weeklyResetUnix: Int64 + } + + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [WindsurfWebFetcherStubURLProtocol.self] + return URLSession(configuration: config) + } + + @Test + func `manual devin session sends protobuf request and auth headers`() async throws { + defer { + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + #expect(url.host == "windsurf.com") + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/proto") + #expect(request.value(forHTTPHeaderField: "Connect-Protocol-Version") == "1") + #expect(request.value(forHTTPHeaderField: "Origin") == "https://windsurf.com") + #expect(request.value(forHTTPHeaderField: "Referer") == "https://windsurf.com/profile") + #expect(request.value(forHTTPHeaderField: "x-auth-token") == "devin-session-token$abc") + #expect(request.value(forHTTPHeaderField: "x-devin-session-token") == "devin-session-token$abc") + #expect(request.value(forHTTPHeaderField: "x-devin-auth1-token") == "auth1_xyz") + #expect(request.value(forHTTPHeaderField: "x-devin-account-id") == "account-123") + #expect(request.value(forHTTPHeaderField: "x-devin-primary-org-id") == "org-456") + + let body = try WindsurfPlanStatusProtoCodec.decodeRequest(Self.requestBodyData(from: request)) + #expect(body.authToken == "devin-session-token$abc") + #expect(body.includeTopUpStatus == true) + + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Pro", + dailyRemaining: 68, + weeklyRemaining: 84, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + let manualSession = """ + { + "devin_session_token": "devin-session-token$abc", + "devin_auth1_token": "auth1_xyz", + "devin_account_id": "account-123", + "devin_primary_org_id": "org-456" + } + """ + + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .manual, + manualSessionInput: manualSession, + timeout: 2, + session: self.makeSession()) + + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 1) + #expect(snapshot.identity?.providerID == .windsurf) + #expect(snapshot.identity?.loginMethod == "Pro") + #expect(snapshot.primary?.usedPercent == 32) + #expect(snapshot.secondary?.usedPercent == 16) + } + + @Test + func `auto session import retries next profile after auth failure`() async throws { + defer { + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "stale-token", + auth1Token: "stale-auth1", + accountID: "stale-account", + primaryOrgID: "stale-org"), + sourceLabel: "Chrome Default"), + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "fresh-token", + auth1Token: "fresh-auth1", + accountID: "fresh-account", + primaryOrgID: "fresh-org"), + sourceLabel: "Chrome Profile 1"), + ] + } + + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + let token = request.value(forHTTPHeaderField: "x-devin-session-token") + + if token == "stale-token" { + return Self.makeResponse( + url: url, + body: Data("unauthorized".utf8), + contentType: "text/plain", + statusCode: 401) + } + + #expect(token == "fresh-token") + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Teams", + dailyRemaining: 75, + weeklyRemaining: 90, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .auto, + timeout: 2, + session: self.makeSession()) + + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 2) + #expect(snapshot.identity?.loginMethod == "Teams") + #expect(snapshot.primary?.usedPercent == 25) + #expect(snapshot.secondary?.usedPercent == 10) + } + + @Test + func `auto session import tries fallback browsers after preferred sessions fail`() async throws { + defer { + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "stale-chrome-token", + auth1Token: "stale-auth1", + accountID: "stale-account", + primaryOrgID: "stale-org"), + sourceLabel: "Chrome Default"), + ] + } + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "fresh-edge-token", + auth1Token: "fresh-auth1", + accountID: "fresh-account", + primaryOrgID: "fresh-org"), + sourceLabel: "Microsoft Edge Default"), + ] + } + + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = { request in + let url = try #require(request.url) + let token = request.value(forHTTPHeaderField: "x-devin-session-token") + + if token == "stale-chrome-token" { + return Self.makeResponse( + url: url, + body: Data("unauthorized".utf8), + contentType: "text/plain", + statusCode: 401) + } + + #expect(token == "fresh-edge-token") + return Self.makeResponse( + url: url, + body: Self.makePlanStatusResponse(ResponseFixture( + planName: "Teams", + dailyRemaining: 64, + weeklyRemaining: 80, + planEndUnix: 1_777_888_000, + dailyResetUnix: 1_777_900_000, + weeklyResetUnix: 1_778_000_000)), + contentType: "application/proto", + statusCode: 200) + } + + let snapshot = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .auto, + timeout: 2, + session: self.makeSession()) + + #expect(WindsurfWebFetcherStubURLProtocol.requests.count == 2) + #expect(snapshot.identity?.loginMethod == "Teams") + #expect(snapshot.primary?.usedPercent == 36) + #expect(snapshot.secondary?.usedPercent == 20) + } + + @Test + func `manual mode with empty session does not fall back to imported session`() async { + defer { + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importPreferredSessionsOverrideForTesting = nil + WindsurfDevinSessionImporter.importFallbackSessionsOverrideForTesting = nil + WindsurfWebFetcherStubURLProtocol.requests = [] + WindsurfWebFetcherStubURLProtocol.handler = nil + } + + WindsurfDevinSessionImporter.importSessionsOverrideForTesting = { _, _ in + [ + WindsurfDevinSessionImporter.SessionInfo( + session: WindsurfDevinSessionAuth( + sessionToken: "auto-token", + auth1Token: "auto-auth1", + accountID: "auto-account", + primaryOrgID: "auto-org"), + sourceLabel: "Chrome Default"), + ] + } + WindsurfWebFetcherStubURLProtocol.requests = [] + + await #expect { + _ = try await WindsurfWebFetcher.fetchUsage( + browserDetection: BrowserDetection(cacheTTL: 0), + cookieSource: .manual, + manualSessionInput: " \n", + timeout: 2, + session: self.makeSession()) + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message == "empty input" + } + #expect(WindsurfWebFetcherStubURLProtocol.requests.isEmpty) + } + + @Test + func `manual key value session input is accepted`() throws { + let parsed = try WindsurfWebFetcher.parseManualSessionInput( + """ + devin_session_token=devin-session-token$abc + devin_auth1_token=auth1_xyz + devin_account_id=account-123 + devin_primary_org_id=org-456 + """) + + #expect(parsed.sessionToken == "devin-session-token$abc") + #expect(parsed.auth1Token == "auth1_xyz") + #expect(parsed.accountID == "account-123") + #expect(parsed.primaryOrgID == "org-456") + } + + @Test + func `manual JSON camelCase aliases are accepted`() throws { + let parsed = try WindsurfWebFetcher.parseManualSessionInput( + """ + { + "devinSessionToken": "devin-session-token$abc", + "devinAuth1Token": "auth1_xyz", + "devinAccountId": "account-123", + "devinPrimaryOrgId": "org-456" + } + """) + + #expect(parsed.sessionToken == "devin-session-token$abc") + #expect(parsed.auth1Token == "auth1_xyz") + #expect(parsed.accountID == "account-123") + #expect(parsed.primaryOrgID == "org-456") + } + + @Test + func `manual session input rejects empty string`() { + #expect { + try WindsurfWebFetcher.parseManualSessionInput(" \n") + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message == "empty input" + } + } + + @Test + func `manual session input rejects invalid text`() { + #expect { + try WindsurfWebFetcher.parseManualSessionInput("not a valid session bundle") + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message.contains("expected JSON") + } + } + + @Test + func `manual session input rejects missing required fields`() { + #expect { + try WindsurfWebFetcher.parseManualSessionInput( + """ + { + "devin_session_token": "devin-session-token$abc", + "devin_auth1_token": "auth1_xyz", + "devin_account_id": "account-123" + } + """) + } throws: { error in + guard case let WindsurfWebFetcherError.invalidManualSession(message) = error else { return false } + return message.contains("expected JSON") + } + } + + private static func makeResponse( + url: URL, + body: Data, + contentType: String, + statusCode: Int) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": contentType])! + return (response, body) + } + + private static func requestBodyData(from request: URLRequest) -> Data { + if let data = request.httpBody { + return data + } + + guard let stream = request.httpBodyStream else { + return Data() + } + + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let count = stream.read(buffer, maxLength: bufferSize) + if count <= 0 { + break + } + data.append(buffer, count: count) + } + + return data + } + + private static func makePlanStatusResponse(_ fixture: ResponseFixture) -> Data { + let planInfo = self.message([ + self.stringField(2, fixture.planName), + ]) + + let planStatus = self.message([ + self.messageField(1, planInfo), + self.messageField(3, self.timestamp(seconds: fixture.planEndUnix)), + self.varintField(14, UInt64(fixture.dailyRemaining)), + self.varintField(15, UInt64(fixture.weeklyRemaining)), + self.varintField(17, UInt64(fixture.dailyResetUnix)), + self.varintField(18, UInt64(fixture.weeklyResetUnix)), + ]) + + return self.message([ + self.messageField(1, planStatus), + ]) + } + + private static func timestamp(seconds: Int64) -> Data { + self.message([ + self.varintField(1, UInt64(seconds)), + ]) + } + + private static func message(_ fields: [Data]) -> Data { + fields.reduce(into: Data()) { partialResult, field in + partialResult.append(field) + } + } + + private static func stringField(_ number: Int, _ value: String) -> Data { + self.lengthDelimitedField(number, Data(value.utf8)) + } + + private static func messageField(_ number: Int, _ value: Data) -> Data { + self.lengthDelimitedField(number, value) + } + + private static func lengthDelimitedField(_ number: Int, _ value: Data) -> Data { + var data = Data() + data.append(self.fieldKey(number, wireType: 2)) + data.append(self.varint(UInt64(value.count))) + data.append(value) + return data + } + + private static func varintField(_ number: Int, _ value: UInt64) -> Data { + var data = Data() + data.append(self.fieldKey(number, wireType: 0)) + data.append(self.varint(value)) + return data + } + + private static func fieldKey(_ number: Int, wireType: UInt64) -> Data { + self.varint(UInt64((number << 3) | Int(wireType))) + } + + private static func varint(_ value: UInt64) -> Data { + var remaining = value + var data = Data() + while remaining >= 0x80 { + data.append(UInt8((remaining & 0x7F) | 0x80)) + remaining >>= 7 + } + data.append(UInt8(remaining)) + return data + } +} + +final class WindsurfWebFetcherStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var requests: [URLRequest] = [] + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with _: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.requests.append(self.request) + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/ZaiAvailabilityTests.swift b/Tests/CodexBarTests/ZaiAvailabilityTests.swift index 6ce229589..408317121 100644 --- a/Tests/CodexBarTests/ZaiAvailabilityTests.swift +++ b/Tests/CodexBarTests/ZaiAvailabilityTests.swift @@ -4,10 +4,9 @@ import Testing @testable import CodexBar @MainActor -@Suite struct ZaiAvailabilityTests { @Test - func enablesZaiWhenTokenExistsInStore() throws { + func `enables zai when token exists in store`() throws { let suite = "ZaiAvailabilityTests-token" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) @@ -31,7 +30,7 @@ struct ZaiAvailabilityTests { } @Test - func enablesZaiWhenTokenExistsInTokenAccounts() throws { + func `enables zai when token exists in token accounts`() throws { let suite = "ZaiAvailabilityTests-token-accounts" let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/ZaiMenuCardTests.swift b/Tests/CodexBarTests/ZaiMenuCardTests.swift new file mode 100644 index 000000000..4433fdf9f --- /dev/null +++ b/Tests/CodexBarTests/ZaiMenuCardTests.swift @@ -0,0 +1,70 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ZaiMenuCardTests { + @Test + func `zai metrics titles are Tokens MCP and 5-hour when session token limit present`() throws { + let now = Date() + let zai = ZaiUsageSnapshot( + tokenLimit: ZaiLimitEntry( + type: .tokensLimit, + unit: .weeks, + number: 1, + usage: nil, + currentValue: nil, + remaining: nil, + percentage: 9, + usageDetails: [], + nextResetTime: nil), + sessionTokenLimit: ZaiLimitEntry( + type: .tokensLimit, + unit: .hours, + number: 5, + usage: 1000, + currentValue: 750, + remaining: 250, + percentage: 25, + usageDetails: [], + nextResetTime: nil), + timeLimit: ZaiLimitEntry( + type: .timeLimit, + unit: .minutes, + number: 1, + usage: 100, + currentValue: 50, + remaining: 50, + percentage: 50, + usageDetails: [], + nextResetTime: nil), + planName: "pro", + updatedAt: now) + let snapshot = zai.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.zai]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .zai, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Tokens", "MCP", "5-hour"]) + let tertiary = try #require(model.metrics.first(where: { $0.title == "5-hour" })) + #expect(tertiary.detailText == "750 / 1K (250 remaining)") + } +} diff --git a/Tests/CodexBarTests/ZaiProviderTests.swift b/Tests/CodexBarTests/ZaiProviderTests.swift index c4184396a..64c1bdbfa 100644 --- a/Tests/CodexBarTests/ZaiProviderTests.swift +++ b/Tests/CodexBarTests/ZaiProviderTests.swift @@ -2,38 +2,36 @@ import Foundation import Testing @testable import CodexBarCore -@Suite struct ZaiSettingsReaderTests { @Test - func apiTokenReadsFromEnvironment() { + func `api token reads from environment`() { let token = ZaiSettingsReader.apiToken(environment: ["Z_AI_API_KEY": "abc123"]) #expect(token == "abc123") } @Test - func apiTokenStripsQuotes() { + func `api token strips quotes`() { let token = ZaiSettingsReader.apiToken(environment: ["Z_AI_API_KEY": "\"token-xyz\""]) #expect(token == "token-xyz") } @Test - func apiHostReadsFromEnvironment() { + func `api host reads from environment`() { let host = ZaiSettingsReader.apiHost(environment: [ZaiSettingsReader.apiHostKey: " open.bigmodel.cn "]) #expect(host == "open.bigmodel.cn") } @Test - func quotaURLInfersScheme() { + func `quota URL infers scheme`() { let url = ZaiSettingsReader .quotaURL(environment: [ZaiSettingsReader.quotaURLKey: "open.bigmodel.cn/api/coding"]) #expect(url?.absoluteString == "https://open.bigmodel.cn/api/coding") } } -@Suite struct ZaiUsageSnapshotTests { @Test - func mapsUsageSnapshotWindows() { + func `maps usage snapshot windows`() { let reset = Date(timeIntervalSince1970: 123) let tokenLimit = ZaiLimitEntry( type: .tokensLimit, @@ -69,11 +67,13 @@ struct ZaiUsageSnapshotTests { #expect(usage.primary?.resetDescription == "5 hours window") #expect(usage.secondary?.usedPercent == 20) #expect(usage.secondary?.resetDescription == "30 days window") + #expect(usage.tertiary == nil) #expect(usage.zaiUsage?.tokenLimit?.usage == 100) + #expect(usage.zaiUsage?.sessionTokenLimit == nil) } @Test - func mapsUsageSnapshotWindowsWithMissingFields() { + func `maps usage snapshot windows with missing fields`() { let reset = Date(timeIntervalSince1970: 123) let tokenLimit = ZaiLimitEntry( type: .tokensLimit, @@ -101,7 +101,7 @@ struct ZaiUsageSnapshotTests { } @Test - func mapsUsageSnapshotWindowsWithMissingRemainingUsesCurrentValue() { + func `maps usage snapshot windows with missing remaining uses current value`() { let reset = Date(timeIntervalSince1970: 123) let tokenLimit = ZaiLimitEntry( type: .tokensLimit, @@ -125,7 +125,7 @@ struct ZaiUsageSnapshotTests { } @Test - func mapsUsageSnapshotWindowsWithMissingCurrentValueUsesRemaining() { + func `maps usage snapshot windows with missing current value uses remaining`() { let reset = Date(timeIntervalSince1970: 123) let tokenLimit = ZaiLimitEntry( type: .tokensLimit, @@ -149,7 +149,7 @@ struct ZaiUsageSnapshotTests { } @Test - func mapsUsageSnapshotWindowsWithMissingRemainingAndCurrentValueFallsBackToPercentage() { + func `maps usage snapshot windows with missing remaining and current value falls back to percentage`() { let reset = Date(timeIntervalSince1970: 123) let tokenLimit = ZaiLimitEntry( type: .tokensLimit, @@ -173,10 +173,9 @@ struct ZaiUsageSnapshotTests { } } -@Suite struct ZaiUsageParsingTests { @Test - func emptyBodyReturnsParseFailed() { + func `empty body returns parse failed`() { #expect { _ = try ZaiUsageFetcher.parseUsageSnapshot(from: Data()) } throws: { error in @@ -186,7 +185,7 @@ struct ZaiUsageParsingTests { } @Test - func parsesUsageResponse() throws { + func `parses usage response`() throws { let json = """ { "code": 200, @@ -228,10 +227,53 @@ struct ZaiUsageParsingTests { #expect(snapshot.tokenLimit?.usage == 40_000_000) #expect(snapshot.timeLimit?.usageDetails.first?.modelCode == "search-prime") #expect(snapshot.tokenLimit?.percentage == 34.0) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.resetDescription == "Monthly") + } + + @Test + func `zai mcp time limit displays monthly instead of one minute window`() throws { + let json = """ + { + "code": 200, + "msg": "Operation successful", + "data": { + "limits": [ + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 100, + "currentValue": 50, + "remaining": 50, + "percentage": 50, + "usageDetails": [] + }, + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 34, + "nextResetTime": 1768507567547 + } + ] + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + + #expect(snapshot.timeLimit?.windowDescription == "1 minute") + #expect(usage.secondary?.windowMinutes == nil) + #expect(usage.secondary?.resetDescription == "Monthly") } @Test - func missingDataReturnsApiError() { + func `missing data returns api error`() { let json = """ { "code": 1001, "msg": "Authorization Token Missing", "success": false } """ @@ -245,7 +287,7 @@ struct ZaiUsageParsingTests { } @Test - func successWithoutDataReturnsParseFailed() { + func `success without data returns parse failed`() { let json = """ { "code": 200, "msg": "Operation successful", "success": true } """ @@ -259,7 +301,7 @@ struct ZaiUsageParsingTests { } @Test - func successWithoutLimitsParsesEmptyUsage() throws { + func `success without limits parses empty usage`() throws { let json = """ { "code": 200, @@ -277,7 +319,7 @@ struct ZaiUsageParsingTests { } @Test - func parsesNewSchemaWithMissingTokenLimitFields() throws { + func `parses new schema with missing token limit fields`() throws { let json = """ { "code": 200, @@ -323,29 +365,248 @@ struct ZaiUsageParsingTests { } } -@Suite +struct ZaiHourlyUsageTests { + @Test + func `model usage parser decodes hourly model payload`() throws { + let json = """ + { + "code": 200, + "msg": "success", + "success": true, + "data": { + "x_time": ["2026-05-14 08:00", "2026-05-14 09:00"], + "modelDataList": [ + { "modelName": "glm-4.6", "tokensUsage": [100, null] }, + { "modelName": "glm-4.5", "tokensUsage": [50, 25] } + ] + } + } + """ + + let usage = try ZaiUsageFetcher.parseModelUsage(from: Data(json.utf8)) + + #expect(usage.xTime == ["2026-05-14 08:00", "2026-05-14 09:00"]) + #expect(usage.modelNames == ["glm-4.6", "glm-4.5"]) + #expect(usage.modelDataList[0].tokensUsage == [100, nil]) + #expect(usage.modelDataList[1].tokensUsage == [50, 25]) + } + + @Test + func `today hourly bars filter earlier days and skip empty hours`() { + let reference = Self.localDate(year: 2026, month: 5, day: 14, hour: 12) + let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: reference) ?? reference + let modelData = ZaiModelUsageData( + xTime: [ + Self.hourString(yesterday), + "2026-05-14 08:00", + "2026-05-14 09:00", + ], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.6", tokensUsage: [999, 100, 0]), + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [0, 50, nil]), + ]) + + let bars = ZaiHourlyBars.from(modelData: modelData, range: .today(referenceDate: reference), now: reference) + + #expect(bars.map(\.label) == ["08"]) + #expect(bars.first?.totalTokens == 150) + #expect(bars.first?.segments.count == 2) + } + + @Test + func `last 24 hour bars filter data outside trailing window`() { + let reference = Self.localDate(year: 2026, month: 5, day: 14, hour: 12) + let old = Calendar.current.date(byAdding: .hour, value: -25, to: reference) ?? reference + let inWindow = Calendar.current.date(byAdding: .hour, value: -23, to: reference) ?? reference + let modelData = ZaiModelUsageData( + xTime: [ + Self.hourString(old), + Self.hourString(inWindow), + Self.hourString(reference), + ], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.6", tokensUsage: [10, 20, 30]), + ]) + + let bars = ZaiHourlyBars.from(modelData: modelData, range: .last24h, now: reference) + + #expect(bars.map(\.label) == [Self.hourLabel(inWindow), Self.hourLabel(reference)]) + #expect(bars.map(\.totalTokens) == [20, 30]) + } + + private static func localDate(year: Int, month: Int, day: Int, hour: Int) -> Date { + Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: hour)) ?? Date() + } + + private static func hourString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: date) + } + + private static func hourLabel(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter.string(from: date) + } +} + +struct ZaiThreeLimitTests { + @Test + func `parses three limit entries into session weekly and mcp slots`() throws { + let json = """ + { + "code": 200, + "msg": "操作成功", + "data": { + "limits": [ + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 25, + "nextResetTime": 1775020168897 + }, + { + "type": "TOKENS_LIMIT", + "unit": 6, + "number": 1, + "percentage": 9, + "nextResetTime": 1775588029998 + }, + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 1000, + "currentValue": 224, + "remaining": 776, + "percentage": 22, + "nextResetTime": 1777575229998, + "usageDetails": [ + { "modelCode": "search-prime", "usage": 210 }, + { "modelCode": "web-reader", "usage": 14 } + ] + } + ], + "level": "pro" + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + // Weekly token limit (unit:6=weeks, longer window) → tokenLimit (primary) + #expect(snapshot.tokenLimit?.unit == .weeks) + #expect(snapshot.tokenLimit?.number == 1) + #expect(snapshot.tokenLimit?.percentage == 9.0) + #expect(snapshot.tokenLimit?.windowMinutes == 10080) + + // 5-hour token limit (unit:3=hours, number:5 → 300 min) → sessionTokenLimit (tertiary) + #expect(snapshot.sessionTokenLimit?.unit == .hours) + #expect(snapshot.sessionTokenLimit?.number == 5) + #expect(snapshot.sessionTokenLimit?.percentage == 25.0) + #expect(snapshot.sessionTokenLimit?.windowMinutes == 300) + + // MCP time limit → timeLimit (secondary) + #expect(snapshot.timeLimit?.usage == 1000) + #expect(snapshot.timeLimit?.usageDetails.first?.modelCode == "search-prime") + + // UsageSnapshot slot mapping + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.usedPercent == 9.0) + #expect(usage.primary?.windowMinutes == 10080) + #expect(usage.secondary != nil) // MCP + #expect(usage.tertiary?.usedPercent == 25.0) + #expect(usage.tertiary?.windowMinutes == 300) + } + + @Test + func `unit 6 maps to weeks with correct window minutes`() { + let entry = ZaiLimitEntry( + type: .tokensLimit, + unit: .weeks, + number: 1, + usage: nil, + currentValue: nil, + remaining: nil, + percentage: 9, + usageDetails: [], + nextResetTime: nil) + #expect(entry.windowMinutes == 10080) + #expect(entry.windowDescription == "1 week") + #expect(entry.windowLabel == "1 week window") + } + + @Test + func `two limit entries remain backward compatible`() throws { + let json = """ + { + "code": 200, + "msg": "Operation successful", + "data": { + "limits": [ + { + "type": "TIME_LIMIT", + "unit": 5, + "number": 1, + "usage": 100, + "currentValue": 50, + "remaining": 50, + "percentage": 50, + "usageDetails": [] + }, + { + "type": "TOKENS_LIMIT", + "unit": 3, + "number": 5, + "percentage": 34, + "nextResetTime": 1768507567547 + } + ] + }, + "success": true + } + """ + + let snapshot = try ZaiUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) + + #expect(snapshot.tokenLimit != nil) + #expect(snapshot.sessionTokenLimit == nil) + #expect(snapshot.timeLimit != nil) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary != nil) + #expect(usage.secondary != nil) + #expect(usage.tertiary == nil) + } +} + struct ZaiAPIRegionTests { @Test - func defaultsToGlobalEndpoint() { + func `defaults to global endpoint`() { let url = ZaiUsageFetcher.resolveQuotaURL(region: .global, environment: [:]) #expect(url.absoluteString == "https://api.z.ai/api/monitor/usage/quota/limit") } @Test - func usesBigModelRegionWhenSelected() { + func `uses big model region when selected`() { let url = ZaiUsageFetcher.resolveQuotaURL(region: .bigmodelCN, environment: [:]) #expect(url.absoluteString == "https://open.bigmodel.cn/api/monitor/usage/quota/limit") } @Test - func quotaUrlEnvironmentOverrideWins() { + func `quota url environment override wins`() { let env = [ZaiSettingsReader.quotaURLKey: "https://open.bigmodel.cn/api/coding/paas/v4"] let url = ZaiUsageFetcher.resolveQuotaURL(region: .global, environment: env) #expect(url.absoluteString == "https://open.bigmodel.cn/api/coding/paas/v4") } @Test - func apiHostEnvironmentAppendsQuotaPath() { + func `api host environment appends quota path`() { let env = [ZaiSettingsReader.apiHostKey: "open.bigmodel.cn"] let url = ZaiUsageFetcher.resolveQuotaURL(region: .global, environment: env) #expect(url.absoluteString == "https://open.bigmodel.cn/api/monitor/usage/quota/limit") diff --git a/TestsLinux/OpenAIDashboardParserLinuxTests.swift b/TestsLinux/OpenAIDashboardParserLinuxTests.swift new file mode 100644 index 000000000..06f7417bd --- /dev/null +++ b/TestsLinux/OpenAIDashboardParserLinuxTests.swift @@ -0,0 +1,60 @@ +import CodexBarCore +import Foundation +import Testing + +/// Cross-platform tests for the OpenAI dashboard text parser. +/// +/// The dashboard renders reset countdowns like "Resets Wednesday at 3pm". The parser +/// converts a textual weekday into the next occurrence of that weekday so it can be +/// formatted into a concrete `Date`. These tests pin down full-weekday-name coverage +/// because the underlying regex previously missed "Wednesday" and "Saturday" (their +/// abbreviations were not long enough to combine with the optional "day" suffix). +@Suite +struct OpenAIDashboardParserLinuxTests { + private static func fixedNow() -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + // 2026-05-21 is a Thursday in the Gregorian calendar; using a fixed anchor keeps + // the test deterministic regardless of when it executes. + return calendar.date(from: DateComponents(year: 2026, month: 5, day: 21))! + } + + private static func body(forWeekday weekday: String) -> String { + """ + 5h limit + 50% remaining + Resets \(weekday) + """ + } + + @Test + func parsesResetLineForEveryFullWeekdayName() { + let now = Self.fixedNow() + for weekday in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] { + let result = OpenAIDashboardParser.parseRateLimits( + bodyText: Self.body(forWeekday: weekday), + now: now) + #expect( + result.primary?.resetsAt != nil, + "Expected a parsed resetsAt for weekday \(weekday)") + } + } + + @Test + func parsesResetLineForLowercaseWednesday() { + let now = Self.fixedNow() + let result = OpenAIDashboardParser.parseRateLimits( + bodyText: Self.body(forWeekday: "wednesday"), + now: now) + #expect(result.primary?.resetsAt != nil) + } + + @Test + func parsesResetLineForLowercaseSaturday() { + let now = Self.fixedNow() + let result = OpenAIDashboardParser.parseRateLimits( + bodyText: Self.body(forWeekday: "saturday"), + now: now) + #expect(result.primary?.resetsAt != nil) + } +} diff --git a/TestsLinux/SettingsReaderQuoteUnwrapTrapTests.swift b/TestsLinux/SettingsReaderQuoteUnwrapTrapTests.swift new file mode 100644 index 000000000..91c0e1e7a --- /dev/null +++ b/TestsLinux/SettingsReaderQuoteUnwrapTrapTests.swift @@ -0,0 +1,75 @@ +import CodexBarCore +import Foundation +import Testing + +/// Regression tests for a copy-pasted quote-unwrap helper that traps on length-1 input. +/// +/// 32 provider settings readers (plus `CodexBarConfig` and `CLIConfigCommand`) share a +/// `cleaned(_:)` helper of the form: +/// +/// if (value.hasPrefix("\"") && value.hasSuffix("\"")) || +/// (value.hasPrefix("'") && value.hasSuffix("'")) +/// { +/// value.removeFirst() +/// value.removeLast() +/// } +/// +/// For a value of length 1 (the single character `"` or `'`), both `hasPrefix` and +/// `hasSuffix` return true, `removeFirst()` empties the string, and `removeLast()` then +/// traps with "Can't remove last element from empty collection." This is reachable from +/// a misconfigured env var (e.g. `ALIBABA_TOKEN_PLAN_COOKIE='"'`) and from quoted JSON +/// values in `~/.codexbar/config.json`, both of which are user-controllable. +/// +/// These tests exercise two representative public readers — Alibaba Token Plan (the +/// newest addition in #1098) and the Ollama API key reader (added in #1087) — by +/// passing the trap-inducing single-quote inputs and asserting the readers return nil +/// instead of crashing. The patch swaps `removeFirst()/removeLast()` for +/// `String(value.dropFirst().dropLast())`, which is empty-safe. +@Suite +struct SettingsReaderQuoteUnwrapTrapTests { + @Test + func alibabaTokenPlanCookieHeader_returnsNilForLoneDoubleQuoteValue() { + let env = [AlibabaTokenPlanSettingsReader.cookieHeaderKey: "\""] + #expect(AlibabaTokenPlanSettingsReader.cookieHeader(environment: env) == nil) + } + + @Test + func alibabaTokenPlanCookieHeader_returnsNilForLoneApostropheValue() { + let env = [AlibabaTokenPlanSettingsReader.cookieHeaderKey: "'"] + #expect(AlibabaTokenPlanSettingsReader.cookieHeader(environment: env) == nil) + } + + @Test + func alibabaTokenPlanCookieHeader_unwrapsProperlyDoubleQuotedValue() { + let env = [AlibabaTokenPlanSettingsReader.cookieHeaderKey: "\"abc=def\""] + #expect(AlibabaTokenPlanSettingsReader.cookieHeader(environment: env) == "abc=def") + } + + @Test + func alibabaTokenPlanCookieHeader_unwrapsProperlySingleQuotedValue() { + let env = [AlibabaTokenPlanSettingsReader.cookieHeaderKey: "'abc=def'"] + #expect(AlibabaTokenPlanSettingsReader.cookieHeader(environment: env) == "abc=def") + } + + @Test + func ollamaAPIKey_returnsNilForLoneDoubleQuoteValue() { + for key in OllamaAPISettingsReader.apiKeyEnvironmentKeys { + let env = [key: "\""] + #expect(OllamaAPISettingsReader.apiKey(environment: env) == nil) + } + } + + @Test + func ollamaAPIKey_returnsNilForLoneApostropheValue() { + for key in OllamaAPISettingsReader.apiKeyEnvironmentKeys { + let env = [key: "'"] + #expect(OllamaAPISettingsReader.apiKey(environment: env) == nil) + } + } + + @Test + func ollamaAPIKey_unwrapsProperlyQuotedValue() { + let env = [OllamaAPISettingsReader.apiKeyEnvironmentKeys[0]: "\"sk-token\""] + #expect(OllamaAPISettingsReader.apiKey(environment: env) == "sk-token") + } +} diff --git a/VISION.md b/VISION.md new file mode 100644 index 000000000..ec6c5ec89 --- /dev/null +++ b/VISION.md @@ -0,0 +1,20 @@ +# Vision + +CodexBar is the menu bar control surface for AI provider limits, credits, spend, status, and reset windows. It should keep adding useful provider coverage while preserving fast refreshes, privacy-first local data handling, and shared provider-driven UI instead of one-off surfaces. + +## Merge by Default + +- Performance improvements, unless they add too much complexity. +- Bug fixes with clear cause and bounded risk. +- New model/provider support that follows existing descriptor, strategy, settings, and test patterns. +- Small UI or UX tweaks. +- Documentation fixes. + +## Needs Sign-Off + +- New features. +- Package, dependency, or toolchain changes. +- Broad refactors or architecture changes. +- Changes that add meaningful maintenance complexity. +- Behavior changes that affect provider auth, data storage, releases, or user privacy. +- Provider additions that need new host APIs, bespoke UI, broad filesystem access, or unclear auth/privacy behavior. diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj new file mode 100644 index 000000000..83a8c7f52 --- /dev/null +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj @@ -0,0 +1,352 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */; }; + 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */; }; + 6F12082A467310EEDD1F3439 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E430B27E4F28973A5E77EA3F /* WidgetKit.framework */; }; + 7A3A654DC5A5B85C9C41EA02 /* CodexBarCore in Frameworks */ = {isa = PBXBuildFile; productRef = 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */; }; + 7F0E34471853E41206F690FB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F15F392AF9D502F0503F8F /* SwiftUI.framework */; }; + 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 02F15F392AF9D502F0503F8F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetBundle.swift; sourceTree = ""; }; + 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetProvider.swift; sourceTree = ""; }; + 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetViews.swift; sourceTree = ""; }; + 9FA0A78FB7CA1D877E7BA54B /* codexbar */ = {isa = PBXFileReference; lastKnownFileType = folder; name = codexbar; path = ..; sourceTree = SOURCE_ROOT; }; + E430B27E4F28973A5E77EA3F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodexBarWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + F5F0F061CD72D02EB841D35C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7A3A654DC5A5B85C9C41EA02 /* CodexBarCore in Frameworks */, + 7F0E34471853E41206F690FB /* SwiftUI.framework in Frameworks */, + 6F12082A467310EEDD1F3439 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4FAD1E2FCD6C4AC65D308ABC /* Packages */ = { + isa = PBXGroup; + children = ( + 9FA0A78FB7CA1D877E7BA54B /* codexbar */, + ); + name = Packages; + sourceTree = ""; + }; + 74E0E4CB8C1E1700BE59E54D = { + isa = PBXGroup; + children = ( + B37422CB8DFAAFC8B3B8C1B6 /* CodexBarWidget */, + 4FAD1E2FCD6C4AC65D308ABC /* Packages */, + CEE79B6AB070A55FA0FB7E12 /* Frameworks */, + B7E03090CEF29F6B74205FAE /* Products */, + ); + sourceTree = ""; + }; + B37422CB8DFAAFC8B3B8C1B6 /* CodexBarWidget */ = { + isa = PBXGroup; + children = ( + 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */, + 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */, + 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */, + ); + name = CodexBarWidget; + path = ../Sources/CodexBarWidget; + sourceTree = ""; + }; + B7E03090CEF29F6B74205FAE /* Products */ = { + isa = PBXGroup; + children = ( + E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + CEE79B6AB070A55FA0FB7E12 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 02F15F392AF9D502F0503F8F /* SwiftUI.framework */, + E430B27E4F28973A5E77EA3F /* WidgetKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E9AFBF11687E131ED1AD113A /* CodexBarWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0BDFA2A2ECD62489A63741CF /* Build configuration list for PBXNativeTarget "CodexBarWidgetExtension" */; + buildPhases = ( + 7FB8FE18C057D477EA90DD38 /* Sources */, + F5F0F061CD72D02EB841D35C /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CodexBarWidgetExtension; + packageProductDependencies = ( + 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */, + ); + productName = CodexBarWidgetExtension; + productReference = E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 31A080B4D7A0849832821889 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + }; + }; + buildConfigurationList = A17A8A4315AD7DBD4D417FCA /* Build configuration list for PBXProject "CodexBarWidgetExtension" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 74E0E4CB8C1E1700BE59E54D; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + B0BE8F0E2917CB6B2EDF1826 /* XCLocalSwiftPackageReference ".." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = B7E03090CEF29F6B74205FAE /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E9AFBF11687E131ED1AD113A /* CodexBarWidgetExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 7FB8FE18C057D477EA90DD38 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */, + 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */, + 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 4A864C1BFFF710E1A519CCF5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 76EC15BB9FE2307D815E380E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 7B3A3941F0FAA0B4ADAB8760 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = NO; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CodexBarTeamID = "$(CODEXBAR_TEAM_ID)"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(CODEXBAR_WIDGET_BUNDLE_ID)"; + PRODUCT_NAME = CodexBarWidget; + SDKROOT = macosx; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 88E6F58603FDA13ED00BF91D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = NO; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CodexBarTeamID = "$(CODEXBAR_TEAM_ID)"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(CODEXBAR_WIDGET_BUNDLE_ID)"; + PRODUCT_NAME = CodexBarWidget; + SDKROOT = macosx; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0BDFA2A2ECD62489A63741CF /* Build configuration list for PBXNativeTarget "CodexBarWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7B3A3941F0FAA0B4ADAB8760 /* Debug */, + 88E6F58603FDA13ED00BF91D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + A17A8A4315AD7DBD4D417FCA /* Build configuration list for PBXProject "CodexBarWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4A864C1BFFF710E1A519CCF5 /* Debug */, + 76EC15BB9FE2307D815E380E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + B0BE8F0E2917CB6B2EDF1826 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CodexBarCore; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 31A080B4D7A0849832821889 /* Project object */; +} diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/WidgetExtension/Info.plist b/WidgetExtension/Info.plist new file mode 100644 index 000000000..bf7636ef9 --- /dev/null +++ b/WidgetExtension/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + CodexBar + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + CodexBarWidget + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CodexBarTeamID + $(CODEXBAR_TEAM_ID) + LSMinimumSystemVersion + 14.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/WidgetExtension/project.yml b/WidgetExtension/project.yml new file mode 100644 index 000000000..269c830c3 --- /dev/null +++ b/WidgetExtension/project.yml @@ -0,0 +1,42 @@ +name: CodexBarWidgetExtension +options: + deploymentTarget: + macOS: "14.0" +packages: + CodexBar: + path: .. +targets: + CodexBarWidgetExtension: + type: app-extension + platform: macOS + deploymentTarget: "14.0" + sources: + - path: ../Sources/CodexBarWidget + dependencies: + - package: CodexBar + product: CodexBarCore + - sdk: SwiftUI.framework + - sdk: WidgetKit.framework + info: + path: Info.plist + properties: + CFBundleDisplayName: CodexBar + CFBundleName: CodexBarWidget + CFBundlePackageType: XPC! + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + CodexBarTeamID: "$(CODEXBAR_TEAM_ID)" + LSMinimumSystemVersion: "14.0" + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + settings: + base: + APPLICATION_EXTENSION_API_ONLY: true + CODE_SIGNING_ALLOWED: false + ENABLE_DEBUG_DYLIB: false + ENABLE_APP_SANDBOX: true + INFOPLIST_KEY_CodexBarTeamID: "$(CODEXBAR_TEAM_ID)" + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks" + PRODUCT_BUNDLE_IDENTIFIER: "$(CODEXBAR_WIDGET_BUNDLE_ID)" + PRODUCT_NAME: CodexBarWidget + SWIFT_VERSION: "6.0" diff --git a/appcast.xml b/appcast.xml index c7bc8f030..c0df2ebba 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,234 +2,98 @@ CodexBar - - 0.18.0-beta.3 - Fri, 13 Feb 2026 18:57:54 +0100 - https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 51 - 0.18.0-beta.3 - 14.0 - CodexBar 0.18.0-beta.3 -

Highlights

-
    -
  • Claude OAuth/keychain flows were reworked across a series of follow-up PRs to reduce prompt storms, stabilize background behavior, surface a setting to control prompt policy and make failure modes deterministic (#245, #305, #308, #309, #364). Thanks @manikv12!
  • -
  • Claude: harden Claude Code PTY capture for /usage and /status (prompt automation, safer command palette confirmation, partial UTF-8 handling, and parsing guards against status-bar context meters) (#320).
  • -
  • New provider: Warp (credits + add-on credits) (#352). Thanks @Kathie-yu!
  • -
  • Provider correctness fixes landed for Cursor plan parsing and MiniMax region routing (#240, #234, #344). Thanks @robinebers and @theglove44!
  • -
  • Menu bar animation behavior was hardened in merged mode and fallback mode (#283, #291). Thanks @vignesh07 and @Ilakiancs!
  • -
  • CI/tooling reliability improved via pinned lint tools, deterministic macOS test execution, and PTY timing test stabilization plus Node 24-ready GitHub Actions upgrades (#292, #312, #290).
  • -
-

Claude OAuth & Keychain

-
    -
  • Claude OAuth creds are cached in CodexBar Keychain to reduce repeated prompts.
  • -
  • Prompts can still appear when Claude OAuth credentials are expired, invalid, or missing and re-auth is required.
  • -
  • In Auto mode, background refresh keeps prompts suppressed; interactive prompts are limited to user actions (menu open or manual refresh).
  • -
  • OAuth-only mode remains strict (no silent Web/CLI fallback); Auto mode may do one delegated CLI refresh + one OAuth retry before falling back.
  • -
  • Preferences now expose a Claude Keychain prompt policy (Never / Only on user action / Always allow prompts) under Providers → Claude; if global Keychain access is disabled in Advanced, this control remains visible but inactive.
  • -
-

Provider & Usage Fixes

-
    -
  • Warp: add Warp provider support (credits + add-on credits), configurable via Settings or WARP_API_KEY/WARP_TOKEN (#352). Thanks @Kathie-yu!
  • -
  • Cursor: compute usage against plan.limit rather than breakdown.total to avoid incorrect limit interpretation (#240). Thanks @robinebers!
  • -
  • MiniMax: correct API region URL selection to route requests to the expected regional endpoint (#234). Thanks @theglove44!
  • -
  • MiniMax: always show the API region picker and retry the China endpoint when the global host rejects the token to avoid upgrade regressions for users without a persisted region (#344). Thanks @apoorvdarshan!
  • -
  • Claude: add Opus 4.6 pricing so token cost scanning tracks USD consumed correctly (#348). Thanks @arandaschimpf!
  • -
  • z.ai: handle quota responses with missing token-limit fields, avoid incorrect used-percent calculations, and harden empty-response behavior with safer logging (#346). Thanks @MohamedMohana and @halilertekin!
  • -
  • z.ai: fix provider visibility in the menu when enabled with token-account credentials (availability now considers the effective fetch environment).
  • -
  • Amp: detect login redirects during usage fetch and fail fast when the session is invalid (#339). Thanks @JosephDoUrden!
  • -
  • Resource loading: fix app bundle lookup path to avoid "could not load resource bundle" startup failures (#223). Thanks @validatedev!
  • -
  • OpenAI Web dashboard: keep WebView instances cached for reuse to reduce repeated network fetch overhead; tests were updated to avoid network-dependent flakes (#284). Thanks @vignesh07!
  • -
  • Token-account precedence: selected token account env injection now correctly overrides provider config apiKey values in app and CLI environments. Thanks @arvindcr4!
  • -
  • Claude: make Claude CLI probing more resilient by scoping auto-input to the active subcommand and trimming to the latest Usage panel before parsing to avoid false matches from earlier screen fragments (#320).
  • -
-

Menu Bar & UI Behavior

-
    -
  • Prevent fallback-provider loading animation loops (battery/CPU drain when no providers are enabled) (#283). Thanks @vignesh07!
  • -
  • Prevent status overlay rendering for disabled providers while in merged mode (#291). Thanks @Ilakiancs!
  • -
-

CI, Tooling & Test Stability

-
    -
  • Pin SwiftFormat/SwiftLint versions and harden lint installer behavior (version drift + temp-file leak fixes) (#292).
  • -
  • Use more deterministic macOS CI test settings (including non-parallel paths where needed) and align runner/toolchain behavior for stability (#292).
  • -
  • Stabilize PTY command timing tests to reduce CI flakiness (#312).
  • -
  • Upgrade actions/checkout to v6 and actions/github-script to v8 for Node 24 compatibility in upstream-monitor.yml (#290). Thanks @salmanmkc!
  • -
  • Tests: add TaskLocal-based keychain/cache overrides so keychain gating and KeychainCacheStore test stores do not leak across concurrent test execution (#320).
  • -
-

Docs & Maintenance

-
    -
  • Update docs for Claude data fetch behavior and keychain troubleshooting notes.
  • -
  • Update MIT license year.
  • -
-

View full changelog

-]]>
- -
- - 0.18.0-beta.2 - Wed, 21 Jan 2026 08:42:37 +0000 + + 0.31.0 + Thu, 28 May 2026 23:11:46 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 50 - 0.18.0-beta.2 + 73 + 0.31.0 14.0 - CodexBar 0.18.0-beta.2 -

Highlights

+ CodexBar 0.31.0 +

Changed

    -
  • OpenAI web dashboard refresh cadence now follows 5× the base refresh interval.
  • -
  • OpenAI web dashboard WebView is torn down after each scrape to reduce idle CPU.
  • -
  • Codex settings now include a toggle to disable OpenAI web extras.
  • +
  • Docs: update the Homebrew install command to use the official codexbar cask now that it supports Intel Macs (#1189). Thanks @SSakutaro!
  • +
  • Tests: document and audit that routine validation must not trigger macOS Keychain prompts.
  • +
  • Localization: localize popup panels and provider settings UI across supported languages (#1181). Thanks @jack24254029!
  • +
  • Localization: complete Brazilian Portuguese coverage so pt-BR no longer falls back to English for new UI strings (#1188). Thanks @ManuzimFerreira!
-

Providers

+

Added

    -
  • Providers: add Dia browser support across cookie import and profile detection (#209). Thanks @validatedev!
  • -
  • Codex: include archived session logs in local token cost scanning and dedupe by session id.
  • -
  • Claude: harden CLI /usage parsing and avoid ANTHROPIC_* env interference during probes.
  • +
  • AWS Bedrock: support resolving usage and cost-history credentials from a named AWS profile via the AWS CLI (#1190). Thanks @oleksandr-soldatov!
  • +
  • Codex: show Codex Spark model-specific usage as an optional extra quota lane (#1195, fixes #1177). Thanks @LeoLin990405!
  • +
  • Localization: add Swedish as a selectable app language (#1186). Thanks @yeager!
-

Menu & Menu Bar

+

Fixed

    -
  • Menu: opening OpenAI web submenus triggers a refresh when the data is stale.
  • -
  • Menu: fix usage line labels to honor “Show usage as used”.
  • -
  • Debug: add a toggle to keep Codex/Claude CLI sessions alive between probes.
  • -
  • Debug: add a button to reset CLI probe sessions.
  • -
  • App icon: use the classic icon on macOS 15 and earlier while keeping Liquid Glass for macOS 26+ (#178). Thanks @zerone0x!
  • +
  • Cost history: make token-cost JSONL scans cancellation-aware so quitting, forced refreshes, and account switches can stop stale scans sooner.
  • +
  • Codex: show Spark 5-hour and weekly usage as separate quota lanes in Codex breakdowns (#1201).
  • +
  • Codex: show captured codex login output when managed Add Account fails so users can recover from account-selection or OAuth failures (#1199). Thanks @chapati23!
  • +
  • Claude: hide the obsolete Design quota lane now that Claude Design shares the main Claude usage limit (#1197).
  • +
  • Menu bar: coalesce visible-menu rebuilds and reduce hover highlight work so the dropdown stays responsive on macOS 26.5 (#1196).

View full changelog

]]>
- +
- - 0.18.0-beta.1 - Sun, 18 Jan 2026 23:09:38 +0000 + + 0.30.1 + Thu, 28 May 2026 07:56:49 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 49 - 0.18.0-beta.1 + 72 + 0.30.1 14.0 - CodexBar 0.18.0-beta.1 -

Highlights

-
    -
  • New providers: OpenCode (web usage), Vertex AI, Kiro, Kimi, Kimi K2, Augment, Amp, Synthetic.
  • -
  • Provider source controls: usage source pickers for Codex/Claude, manual cookie headers, cookie caching with source/timestamp.
  • -
  • Menu bar upgrades: display mode picker (percent/pace/both), auto-select near limit, absolute reset times, pace summary line.
  • -
  • CLI/config revamp: config-backed provider settings, JSON-only errors, config validate/dump.
  • -
-

Providers

-
    -
  • OpenCode: add web usage provider with workspace override + Chrome-first cookie import (#188). Thanks @anthnykr!
  • -
  • OpenCode: refresh provider logo (#190). Thanks @anthnykr!
  • -
  • Vertex AI: add provider with quota-based usage from gcloud ADC. Thanks @bahag-chaurasiak!
  • -
  • Vertex AI: token costs are shown via the Claude provider (same local logs).
  • -
  • Vertex AI: harden quota usage parsing for edge-case responses.
  • -
  • Kiro: add CLI-based usage provider via kiro-cli. Thanks @neror!
  • -
  • Kiro: clean up provider wiring and show plan name in the menu.
  • -
  • Kiro: harden CLI idle handling to avoid partial usage snapshots (#145). Thanks @chadneal!
  • -
  • Kimi: add usage provider with cookie-based API token stored in Keychain (#146). Thanks @rehanchrl!
  • -
  • Kimi K2: add API-key usage provider for credit totals (#147). Thanks @0-CYBERDYNE-SYSTEMS-0!
  • -
  • Augment: add provider with browser-cookie usage tracking.
  • -
  • Augment: prefer Auggie CLI usage with web fallback, plus session refresh + recovery tools (#142). Thanks @bcharleson!
  • -
  • Amp: add provider with Amp Free usage tracking (#167). Thanks @duailibe!
  • -
  • Synthetic: add API-key usage provider with quota snapshots (#171). Thanks @monotykamary!
  • -
  • JetBrains AI: include IDEs missing quota files, expand custom paths, and add Android Studio base paths (#194). Thanks @steipete!
  • -
  • Cursor: support legacy request-based plans and show individual on-demand usage (#125) — thanks @vltansky
  • -
  • Cursor: avoid Intel crash when opening login and harden WebKit teardown. Thanks @meghanto!
  • -
  • Cursor: load stored session cookies before reads to make relaunches deterministic.
  • -
  • z.ai: add BigModel CN region option for API endpoint selection (#140). Thanks @nailuoGG!
  • -
  • MiniMax: add China mainland region option + host overrides (#143). Thanks @nailuoGG!
  • -
  • MiniMax: support API token or cookie auth; API token takes precedence and hides cookie UI (#149). Thanks @aonsyed!
  • -
  • Gemini: prefer loadCodeAssist project IDs for quota fetches (#172). Thanks @lolwierd!
  • -
  • Gemini: honor loadCodeAssist project IDs for quota + support Nix CLI layout (#184). Thanks @HaukeSchnau!
  • -
  • Claude: fix OAuth “Extra usage” spend/limit units when the API returns minor currency units (#97).
  • -
  • Claude: rescale extra usage costs when plan hints are missing and prefer web plan hints for extras (#181). Thanks @jorda0mega!
  • -
  • Usage formatting: fix currency parsing/formatting on non-US locales (e.g., pt-BR). Thanks @mneves75!
  • -
-

Provider Sources & Security

+ CodexBar 0.30.1 +

Changed

    -
  • Providers: cache browser cookies in Keychain (per provider) and show cached source/time in settings.
  • -
  • Codex/Claude/Cursor/Factory/MiniMax: cookie sources now include Manual (paste a Cookie header) in addition to Automatic.
  • -
  • Codex/Claude/Cursor/Factory/MiniMax: skip cookie imports from browsers without usable cookie stores (profile/cookie DB) to avoid unnecessary Keychain prompts.
  • -
  • Providers: suppress repeated Chromium Keychain prompts after access denied and honor disabled Keychain access.
  • +
  • CLI: make codexbar diagnose use a generic safe provider diagnostic export for all providers, with MiniMax details attached only as provider-specific metadata.
-

Preferences & Settings

+

Fixed

    -
  • Preferences: swap provider refresh button and enable toggle order.
  • -
  • Preferences: animate settings width and widen Providers on selection.
  • -
  • Preferences: shrink default settings size and reduce overall height.
  • -
  • Preferences: move “Hide personal information” to Advanced.
  • -
  • Providers: shorten fetch subtitle to relative time only.
  • -
  • Preferences: soften provider sidebar background and stabilize drag reordering.
  • -
  • Preferences: restrict provider drag handle to handle-only.
  • -
  • Preferences: move provider refresh timing to a dedicated second line.
  • -
  • Preferences: tighten provider usage metrics spacing.
  • -
  • Preferences: show refresh timing inline in provider detail subtitle.
  • -
  • Preferences: move “Access OpenAI via web” into Providers → Codex.
  • -
  • Preferences: add usage source pickers for Codex + Claude with auto fallback.
  • -
  • Preferences: add cookie source pickers with contextual helper text for the selected mode.
  • -
  • Preferences: move “Disable Keychain access” to Advanced and require manual cookies when enabled.
  • -
  • Preferences: add per-provider menu bar metric picker (#185) — thanks @HaukeSchnau
  • -
  • Preferences: tighten provider rows (inline pickers, compact layout, inline refresh + auto-source status).
  • -
  • Preferences: remove the “experimental” label from Antigravity.
  • -
-

Menu & Menu Bar

-
    -
  • Menu: add a toggle to show reset times as absolute clock values (instead of countdowns).
  • -
  • Menu: show an “Open Terminal” action when Claude OAuth fails.
  • -
  • Menu: add “Hide personal information” toggle and redact emails in menu UI (#137). Thanks @t3dotgg!
  • -
  • Menu: keep a pace summary line alongside the visual marker (#155). Thanks @antons!
  • -
  • Menu: reduce provider-switch flicker and avoid redundant menu card sizing for faster opens (#132). Thanks @ibehnam!
  • -
  • Menu: keep background refresh on open without forcing token usage (#158). Thanks @weequan93!
  • -
  • Menu: Cursor switcher shows On-Demand remaining when Plan is exhausted in show-remaining mode (#193). Thanks @vltansky!
  • -
  • Menu: avoid single-letter wraps in provider switcher titles.
  • -
  • Menu: widen provider switcher buttons to avoid clipped titles.
  • -
  • Menu bar: rebuild provider status items on reorder so icons update correctly.
  • -
  • Menu bar: optional auto-select provider closest to its rate limit and keep switcher progress visible (#159). Thanks @phillco!
  • -
  • Menu bar: add display mode picker for percent/pace/both in the menu bar icon (#169). Thanks @PhilETaylor!
  • -
  • Menu bar: fix combined loading indicator flicker during loading animation (incl. debug replay).
  • -
  • Menu bar: prevent blink updates from clobbering the loading animation.
  • -
-

CLI & Config

-
    -
  • CLI: respect the reset time display setting.
  • -
  • CLI: add pink accents, usage bars, and weekly pace lines to text output.
  • -
  • CLI: add config-backed provider settings, --json-only, and --source api for key-based providers.
  • -
  • CLI: add config validate/config dump commands and per-provider JSON error payloads.
  • -
  • CLI/App: move provider secrets + ordering to ~/.codexbar/config.json (no Keychain persistence).
  • -
  • Providers: resolve API tokens from config/env only (no Keychain fallback).
  • -
-

Dev & Tests

-
    -
  • Dev: move Chromium profile discovery into SweetCookieKit (adds Helium net.imput.helium). Thanks @hhushhas!
  • -
  • Dev: bump SweetCookieKit to 0.2.0.
  • -
  • Dev: migrate stored Keychain items to reduce rebuild prompts.
  • -
  • Dev: move path debug snapshot off the main thread and debounce refreshes to avoid startup hitches (#131). Thanks @ibehnam!
  • -
  • Tests: expand Kiro CLI coverage.
  • -
  • Tests: stabilize Claude PTY integration cleanup and reset CLI sessions after probes.
  • -
  • Tests: kill leaked codex app-server after tests.
  • -
  • Tests: add regression coverage for merged loading icon layout stability.
  • -
  • Tests: cover config validation and JSON-only CLI errors.
  • -
  • Build: stabilize Swift test runtime.
  • +
  • Settings: add trailing breathing room to provider-sidebar controls (#1183). Thanks @Yuxin-Qiao!
  • +
  • Claude: treat OAuth usage HTTP 429s as rate limits, preserve cached credentials, and back off background retries while still allowing manual refresh (#1179). Thanks @LeoLin990405!
  • +
  • Menu bar: stop repeated display-change status-item recreation from corrupting Control Center or confusing menu bar managers (#1176, fixes #1175). Thanks @diazdesandi!

View full changelog

]]>
- +
- 0.17.0 - Wed, 31 Dec 2025 23:12:24 +0100 + 0.30.0 + Wed, 27 May 2026 07:04:18 +0100 https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml - 48 - 0.17.0 + 71 + 0.30.0 14.0 - CodexBar 0.17.0 -
    -
  • New providers: MiniMax.
  • -
  • Keychain: show a preflight explanation before macOS prompts for OAuth tokens or cookie decryption.
  • -
  • Providers: defer z.ai + Copilot Keychain reads until the user interacts with the token field.
  • -
  • Menu bar: avoid status item menu reattachment and layout flips during refresh to reduce icon flicker.
  • -
  • Dev: align SweetCookieKit local-storage tests with Swift Testing.
  • -
  • Charts: align hover selection bands with visible bars in credits + usage breakdown history.
  • -
  • About: fix website link in the About panel. Thanks @felipeorlando!
  • + CodexBar 0.30.0 +

    Added

    +
      +
    • MiniMax: add a redacted diagnostic CLI export for safe issue reports (#1128). Thanks @Yuxin-Qiao!
    • +
    • Antigravity: show the complete per-model quota breakdown alongside the existing summary lanes (#1139). Thanks @guhyun9454!
    • +
    • Widget: show tertiary usage rows for providers that expose a third quota lane (#1160). Thanks @LeoLin990405!
    • +
    • DeepSeek: show optional web-session usage and cost summaries alongside the balance card (#1166). Thanks @Yuxin-Qiao!
    • +
    • OpenAI: scope Admin API usage to the configured project and keep token accounts from inheriting stale project filters (#1168). Thanks @mstallone!
    • +
    +

    Fixed

    +
      +
    • App shutdown: detach status items, close tracked menus, and cancel menu tasks before quit so Dock autohide stays responsive on macOS 26.5 (#1174). Thanks @jskoiz!
    • +
    • Widgets: package the macOS widget as a real Xcode app-extension target so WidgetKit descriptors load on macOS 26.5 (#1095). Thanks @jamesjlopez!
    • +
    • Menu: render quota-warning markers as subtle inset ticks instead of full-height bars (#1149).
    • +
    • Codex: show sign-in guidance when the Codex CLI is logged out instead of reporting a temporary usage outage (#1171, fixes #1170). Thanks @jskoiz!
    • +
    • Menu bar: clear stale hidden macOS status-item visibility defaults once before creating CodexBar items (#1169).
    • +
    • StepFun: refresh expired Oasis tokens and persist recovered manual sessions. Thanks @LeoLin990405!
    • +
    • Release: prevent manual CLI artifact builds from publishing or clobbering release assets (#1154). Thanks @jskoiz!
    • +
    • Cost history: route OpenAI and Mistral API spend through the shared cost-history cards, including OpenAI request counts (#1163). Thanks @LeoLin990405!
    • +
    • Menu: keep provider switcher Cmd-number and arrow shortcuts working while the open menu is tracking events (#1157, fixes #1156 and #1144). Thanks @anirudhvee!
    • +
    • Codex: prevent fork token replay from overcounting corrected cumulative session totals (#1164). Thanks @xx205!
    • +
    • Alibaba Token Plan: update usage refreshes to the Bailian subscription-summary endpoint (#1142). Thanks @YanxinXue!
    • +
    • Ollama: show pace projections for documented 5-hour session and 7-day weekly usage windows (#1136). Thanks @bdamokos!
    • +
    • Localization: polish Simplified Chinese wording and add notification strings (#1165). Thanks @fanfanci!
    • +
    • Localization: improve Traditional Chinese wording and localize notification copy (#1158). Thanks @jack24254029!
    • +
    • Localization: improve Simplified Chinese visible menu, dashboard, and usage labels (#1145). Thanks @Yuxin-Qiao!

    View full changelog

    ]]>
    - + 0.14.0 @@ -272,4 +136,4 @@ - + \ No newline at end of file diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 072cd9e7f..4a687f423 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -13,9 +13,12 @@ read_when: ### Building and Running ```bash -# Full build, test, package, and launch (recommended) +# Full build, package, and launch (recommended) ./Scripts/compile_and_run.sh +# Also run swift test before packaging/relaunching +./Scripts/compile_and_run.sh --test + # Just build and package (no tests) ./Scripts/package_app.sh @@ -26,7 +29,7 @@ read_when: ### Development Workflow 1. **Make code changes** in `Sources/CodexBar/` -2. **Run** `./Scripts/compile_and_run.sh` to rebuild and launch +2. **Run** `./Scripts/compile_and_run.sh --test` to test, rebuild, and launch 3. **Check logs** in Console.app (filter by "codexbar") 4. **Optional file log**: enable Debug → Logging → "Enable file logging" to write `~/Library/Logs/CodexBar/CodexBar.log` (verbosity defaults to "Verbose") @@ -37,7 +40,9 @@ read_when: You'll see **one keychain prompt per stored credential** on the first launch. This is a **one-time migration** that converts existing keychain items to use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. ### Subsequent Rebuilds -**Zero prompts!** The migration flag is stored in UserDefaults, so future rebuilds won't prompt. +The migration flag is stored in UserDefaults, so migrated CodexBar-owned items should not prompt again. Ad-hoc +signing can still prompt for other keychain surfaces; use `./Scripts/compile_and_run.sh --clear-adhoc-keychain` +when you intentionally want to reset ad-hoc keychain state. ### Why This Happens - Ad-hoc signed development builds change code signature on every rebuild @@ -50,28 +55,20 @@ You'll see **one keychain prompt per stored credential** on the first launch. Th defaults delete com.steipete.codexbar KeychainMigrationV1Completed ``` -## Auto-Refresh for Augment Cookies +## Augment Cookie Refresh ### How It Works -CodexBar automatically refreshes Augment cookies from your browser: - -1. **Automatic Import**: On every usage refresh, CodexBar imports fresh cookies from your browser -2. **Browser Priority**: Chrome → Arc → Safari → Firefox → Brave (configurable) -3. **Session Detection**: Looks for Auth0/NextAuth session cookies -4. **Fallback**: If import fails, uses last known good cookies from keychain +CodexBar checks Augment through the provider fetch pipeline. Auto mode tries the Augment CLI first, then the +browser-cookie web path. The web path reuses cached cookies when possible and imports from supported browsers when +the cache is missing or rejected. ### Refresh Frequency - Default: Every 5 minutes (configurable in Preferences → General) -- Minimum: 30 seconds -- Cookie import happens automatically on each refresh +- Minimum: 1 minute +- Cookie import happens automatically when cached cookies need refresh ### Supported Browsers -- Chrome -- Arc -- Safari -- Firefox -- Brave -- Edge +- Safari, Chrome variants, Edge variants, Brave, Arc variants, Dia, and Firefox. ### Manual Cookie Override If automatic import fails: @@ -102,20 +99,18 @@ CodexBar/ ## Common Tasks ### Add a New Provider -1. Create `Sources/CodexBar/Providers/YourProvider/` -2. Implement `ProviderImplementation` protocol -3. Add to `ProviderRegistry.swift` -4. Add icon to `Resources/ProviderIcon-yourprovider.svg` +1. Add a `UsageProvider` case in `Sources/CodexBarCore/Providers/Providers.swift` +2. Add core descriptor/fetcher wiring under `Sources/CodexBarCore/Providers/YourProvider/` +3. Add app-side implementation under `Sources/CodexBar/Providers/YourProvider/` +4. Register the implementation in `ProviderImplementationRegistry` +5. Add icon assets such as `Resources/ProviderIcon-yourprovider.svg` ### Debug Cookie Issues -```bash -# Enable verbose logging -export CODEXBAR_LOG_LEVEL=debug -./Scripts/compile_and_run.sh - -# Check logs in Console.app -# Filter: subsystem:com.steipete.codexbar category:augment-cookie -``` +1. Enable Debug → Logging → "Enable file logging" or raise verbosity in the app settings. +2. Reproduce with `./Scripts/compile_and_run.sh`. +3. Check logs in Console.app: + - Filter: `subsystem:com.steipete.codexbar category:augment` + - Importer messages include the `[augment-cookie]` prefix ### Run Tests Only ```bash @@ -133,13 +128,13 @@ swiftlint --strict ### Local Development Build ```bash ./Scripts/package_app.sh -# Creates: CodexBar.app (ad-hoc signed) +# Creates: CodexBar.app (Developer ID by default; set CODEXBAR_SIGNING=adhoc for ad-hoc signing) ``` ### Release Build (Notarized) ```bash ./Scripts/sign-and-notarize.sh -# Creates: CodexBar-arm64.zip (notarized for distribution) +# Creates: CodexBar-.zip and CodexBar-.dSYM.zip ``` See `docs/RELEASING.md` for full release process. @@ -162,11 +157,11 @@ defaults read com.steipete.codexbar KeychainMigrationV1Completed # Should output: 1 # Check migration logs -log show --predicate 'category == "KeychainMigration"' --last 5m +log show --predicate 'category == "keychain-migration"' --last 5m ``` ### Cookies Not Refreshing -1. Check browser is supported (Chrome, Arc, Safari, Firefox, Brave) +1. Check the browser is supported by the Augment provider metadata 2. Verify you're logged into Augment in that browser 3. Check Preferences → Providers → Augment → Cookie source is "Automatic" 4. Enable debug logging and check Console.app @@ -181,12 +176,13 @@ log show --predicate 'category == "KeychainMigration"' --last 5m ### Cookie Management - Automatic browser import via SweetCookieKit -- Keychain storage for persistence +- Keychain cache for some imported browser cookies and OAuth/device-flow credentials +- `~/.codexbar/config.json` for provider settings, manual cookies, and stored API keys - Manual override for debugging -- Auto-refresh on every usage poll +- Browser-cookie import when cached sessions need refresh ### Usage Polling - Background timer (configurable frequency) - Parallel provider fetches -- Exponential backoff on errors -- Widget snapshot for iOS widget +- First failure can be suppressed when prior data exists +- WidgetKit snapshot for macOS widgets diff --git a/docs/DEVELOPMENT_SETUP.md b/docs/DEVELOPMENT_SETUP.md index 1e9fe34ce..48343efe9 100644 --- a/docs/DEVELOPMENT_SETUP.md +++ b/docs/DEVELOPMENT_SETUP.md @@ -8,15 +8,24 @@ read_when: # Development Setup Guide -## Keychain Permission Prompts +## Reducing Keychain Permission Prompts -As of v0.18.0-beta.3-jl.2, CodexBar defaults to reading Claude credentials via `/usr/bin/security` CLI, which **does not trigger keychain prompts**. No special setup is needed. - -If you've switched to the Security.framework reader (via Preferences), you may see prompts like: +When developing CodexBar, you may see frequent keychain permission prompts like: > **CodexBar wants to access key "Claude Code-credentials" in your keychain.** -This happens because each rebuild creates a new code signature, and macOS treats it as a "different" app. To reduce these prompts with the Security.framework reader: +This happens because each rebuild creates a new code signature, and macOS treats it as a "different" app. +That can affect both CodexBar-owned entries (`com.steipete.CodexBar`, `com.steipete.codexbar.cache`) and +third-party items such as `Claude Code-credentials`, so an ad-hoc-signed rebuild can keep re-triggering +password/keychain approval dialogs even after you previously chose **Always Allow**. + +### Quick Fix (Temporary) + +When the prompt appears, click **"Always Allow"** instead of just "Allow". This grants access to the current build. + +### Permanent Fix (Recommended) + +Use a stable development certificate that doesn't change between rebuilds: #### 1. Create Development Certificate @@ -95,6 +104,13 @@ This script: 5. Launches `CodexBar.app` 6. Verifies it stays running +When the script falls back to ad-hoc signing, it preserves CodexBar-owned keychain state by default. +That means you may still see keychain prompts for existing CodexBar cache entries, but allowing those prompts keeps the +cached browser/OAuth state available across normal rebuilds. +If you want a clean reset of CodexBar-owned keychain state for an ad-hoc build, run +`./Scripts/compile_and_run.sh --clear-adhoc-keychain` before relaunching. +Third-party keychain items still need stable signing if you want macOS to remember **Always Allow** across rebuilds. + ### Quick Build (No Tests) ```bash @@ -129,7 +145,7 @@ pkill -x CodexBar || pkill -f CodexBar.app || true ### "Permission denied" when accessing keychain -With the default `/usr/bin/security` CLI reader, this should not happen. If using the Security.framework reader, make sure you clicked **"Always Allow"** or set up the development certificate (see above). +Make sure you clicked **"Always Allow"** or set up the development certificate (see above). ### Multiple app bundles keep appearing diff --git a/docs/FORK_QUICK_START.md b/docs/FORK_QUICK_START.md index 3f60c9f87..23cdb9c4e 100644 --- a/docs/FORK_QUICK_START.md +++ b/docs/FORK_QUICK_START.md @@ -57,11 +57,9 @@ cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexba ### Release ```bash -# Sign and notarize (keep in foreground!) -./Scripts/sign-and-notarize.sh - -# Create appcast -./Scripts/make_appcast.sh +# Edit .mac-release.env first: MAC_RELEASE_REPO, feed URL, download URL, +# bundle id, and Sparkle public/signing key must point at your fork. +./Scripts/release.sh # See full release process cat docs/RELEASING.md diff --git a/docs/FORK_ROADMAP.md b/docs/FORK_ROADMAP.md index 0c73db976..bf52353de 100644 --- a/docs/FORK_ROADMAP.md +++ b/docs/FORK_ROADMAP.md @@ -118,7 +118,7 @@ This document outlines the development roadmap for the CodexBar fork maintained **Files to Create:** - `Scripts/sync_upstream.sh` -- `docs/UPSTREAM_SYNC.md` +- `docs/UPSTREAM_STRATEGY.md` - `.github/workflows/upstream-sync-check.yml` --- @@ -228,5 +228,5 @@ This document outlines the development roadmap for the CodexBar fork maintained - [Augment Provider](augment.md) - Augment-specific documentation - [Development Guide](DEVELOPMENT.md) - Build and test instructions - [Provider Authoring](provider.md) - How to create new providers -- [Upstream Sync](UPSTREAM_SYNC.md) - Syncing with original repository (TBD) +- [Upstream Strategy](UPSTREAM_STRATEGY.md) - Syncing with original repository - [Quotio Analysis](QUOTIO_ANALYSIS.md) - Feature comparison (TBD) diff --git a/docs/ISSUE_LABELING.md b/docs/ISSUE_LABELING.md new file mode 100644 index 000000000..8e0ba964a --- /dev/null +++ b/docs/ISSUE_LABELING.md @@ -0,0 +1,199 @@ +--- +summary: "Issue labeling policy for triage, prioritization, and backlog hygiene." +read_when: + - Triageing GitHub issues + - Adding or updating issue labels + - Organizing the backlog +--- + +# Issue labeling guide + +This repo uses labels to make the issue tracker easier to scan by: + +- **type** — what kind of issue is this? +- **priority** — how urgent is it? +- **area** — what subsystem is affected? +- **provider** — which provider/service is involved? +- **workflow state** — what kind of follow-up is needed? + +The goal is not to perfectly label everything. The goal is to make open issues easy to sort into: + +- what is broken now, +- what needs maintainer attention, +- what is accepted backlog, +- and what belongs to a specific provider or subsystem. + +## Labeling rules + +For most open issues, aim to apply: + +- **1 type label** +- **1 priority label** +- **1 workflow label** +- **1 area label** +- **0–1 provider labels** + +That means most issues should end up with **3–5 labels max**. + +## Type labels + +Use the existing GitHub-style labels: + +- `bug` — broken behavior, crash, mismatch, false negative, bad parsing, auth failure +- `enhancement` — feature request, UX improvement, support for a new workflow +- `documentation` — docs, onboarding, missing setup guidance +- `question` — only for issues that are primarily asking for clarification or support + +Avoid using `question` as a generic fallback when the issue is actually a bug or feature request. + +## Priority labels + +- `priority:high` — crashes, install failures, auth/account breakage, provider unusable, severe resource issues +- `priority:medium` — real issue or good feature request, but not urgent +- `priority:low` — minor polish, optional UX improvements, long-tail backlog + +## Workflow labels + +- `needs-triage` — new issue that has not been categorized yet +- `needs-repro` — needs logs, screenshots, exact steps, or a current repro +- `needs-design` — valid request, but needs a product/UX decision before implementation +- `blocked-upstream` — likely caused or limited by upstream provider behavior +- `accepted` — intentionally kept open as part of the backlog/roadmap + +## Area labels + +- `area:auth-keychain` — keychain prompts, login state, token refresh, account switching +- `area:install-distribution` — Homebrew, packaging, launch/install failures, binary detection +- `area:usage-accuracy` — usage %, reset windows, plan parsing, cost/token math +- `area:performance` — CPU, battery, memory, background sessions/process churn +- `area:ui-ux` — menu bar behavior, settings, copy, visual layout, interaction polish +- `area:widget` — widget registration, app groups, widget gallery visibility +- `area:docs-onboarding` — setup docs, onboarding docs, missing instructions +- `area:notifications` — threshold alerts, prompt waiting, quota notifications +- `area:export-integration` — Prometheus, HTTP server mode, external integrations +- `area:accounts` — multiple accounts, account discovery, account switching UX + +## Provider labels + +Only apply one when a provider is clearly the main subject: + +- `provider:claude` +- `provider:codex` +- `provider:cursor` +- `provider:copilot` +- `provider:gemini` +- `provider:alibaba` +- `provider:factory` +- `provider:antigravity` +- `provider:opencode` +- `provider:zai` +- `provider:openrouter` + +Not every issue needs a provider label. + +## Close-time labels + +These are mostly useful when resolving issues, not as backlog-organizing labels: + +- `duplicate` +- `invalid` +- `wontfix` +- `stale` + +## Recommended minimum viable label set + +If starting from a sparse tracker, add these first: + +### Priority +- `priority:high` +- `priority:medium` +- `priority:low` + +### Workflow +- `needs-triage` +- `needs-repro` +- `needs-design` +- `accepted` + +### Area +- `area:auth-keychain` +- `area:install-distribution` +- `area:usage-accuracy` +- `area:performance` +- `area:ui-ux` +- `area:widget` +- `area:docs-onboarding` + +### Provider +- `provider:claude` +- `provider:codex` +- `provider:cursor` +- `provider:copilot` + +This smaller set already gives most of the value. + +## Examples + +### Example 1 — severe Claude keychain issue +Issue: repeated Claude keychain prompts, user can’t keep the app running normally. + +Suggested labels: +- `bug` +- `priority:high` +- `area:auth-keychain` +- `provider:claude` + +### Example 2 — roadmap feature +Issue: multiple account support. + +Suggested labels: +- `enhancement` +- `priority:high` +- `area:accounts` +- `needs-design` + +### Example 3 — needs better repro +Issue: generic usage mismatch with unclear screenshots and no exact values. + +Suggested labels: +- `bug` +- `priority:medium` +- `area:usage-accuracy` +- `needs-repro` + +### Example 4 — accepted backlog UI request +Issue: show burn rate / pacing indicators. + +Suggested labels: +- `enhancement` +- `priority:medium` +- `area:usage-accuracy` +- `accepted` + +## Suggested rollout + +1. **Create the new labels** +2. **Backfill the top-priority open issues first** + - all `priority:high` bugs + - major roadmap items + - maintainer-triage issues +3. **Apply labels to new issues at intake** +4. **Backfill older backlog issues gradually** + +## Practical guidance + +- Prefer **fewer, clearer labels** over many vague labels. +- Do not label everything `question`. +- Do not use both `needs-repro` and `accepted` on the same issue unless there is a strong reason. +- If an issue is provider-specific, add the provider label early. +- If an issue is obviously real and intended to stay open, add `accepted` so it doesn’t look abandoned. + +## Current workflow-specific labels + +These already exist and should stay scoped to their current purpose: + +- `upstream-sync` +- `needs-review` +- `changes requested` + +They should not replace the general issue triage labels above. diff --git a/docs/KEYCHAIN_FIX.md b/docs/KEYCHAIN_FIX.md index 94948b42d..3c96f05ef 100644 --- a/docs/KEYCHAIN_FIX.md +++ b/docs/KEYCHAIN_FIX.md @@ -45,15 +45,10 @@ Load order for credentials: 2. In-memory cache. 3. CodexBar keychain cache (`com.steipete.codexbar.cache`, account `oauth.claude`). 4. `~/.claude/.credentials.json`. -5. Claude CLI keychain service: `Claude Code-credentials` — read via `/usr/bin/security` CLI by default (prompt-free), with Security.framework as fallback. +5. Claude CLI keychain service: `Claude Code-credentials` (promptable fallback). -Keychain read strategy: -- **Default: `/usr/bin/security` CLI** (`ClaudeOAuthKeychainReadStrategy.securityCLI`). The CLI binary is permanently in the keychain item's ACL (added by Claude Code during login), so reads never trigger macOS keychain prompts — even after app rebuilds or updates. -- **Override: Security.framework** (`ClaudeOAuthKeychainReadStrategy.securityFramework`). Available via user preference. Uses `SecItemCopyMatching`, which requires the calling binary to be in the keychain ACL — invalidated on every rebuild, causing recurring prompts. -- Strategy is stored in UserDefaults key `claudeOAuthKeychainReadStrategy`. - -Prompt mitigation (Security.framework fallback path only): -- Non-interactive keychain probes use `KeychainNoUIQuery` (`LAContext.interactionNotAllowed` + `kSecUseAuthenticationUIFail`). +Prompt mitigation: +- Non-interactive keychain probes use `KeychainNoUIQuery` with `LAContext.interactionNotAllowed`. - Pre-alert is shown only when preflight suggests interaction may be required. - Denials are cooled down in the background via `claudeOAuthKeychainDeniedUntil` (`ClaudeOAuthKeychainAccessGate`). User actions (menu open / manual refresh) clear this cooldown. @@ -61,14 +56,11 @@ Prompt mitigation (Security.framework fallback path only): - Background cache-sync-on-change also performs non-interactive Claude keychain probes (`syncWithClaudeKeychainIfChanged`) and can update cached OAuth data when the token changes. -### Why two Claude keychain prompts could happen on startup (Security.framework path only) -> **Note:** With the default `/usr/bin/security` CLI reader, keychain prompts do not occur. This section only applies -> when the user has explicitly switched to the Security.framework reader. - +### Why two Claude keychain prompts can still happen on startup When CodexBar does not have usable OAuth credentials in its own cache (`com.steipete.codexbar.cache` / `oauth.claude`), bootstrap falls through to Claude CLI keychain reads. -Under the Security.framework path, the flow can perform up to two interactive reads in one bootstrap call: +Current flow can perform up to two interactive reads in one bootstrap call: 1. Interactive read of the newest discovered keychain candidate. 2. If that does not return usable data, interactive legacy service-level fallback read. @@ -88,6 +80,9 @@ This is OS/keychain ACL behavior, not a `ThisDeviceOnly` migration issue. - Browser-imported Claude session cookies are cached in keychain service `com.steipete.codexbar.cache`. - Account key is `cookie.claude`. - Cache writes use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. +- Users can clear browser-cookie cache entries from **Preferences → Debug → Caches** or with + `codexbar cache clear --cookies`. `--provider ` scopes cookie clearing to one provider and includes scoped + Codex managed-account cookie keys. ## What still uses `ThisDeviceOnly` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 0fdf6585f..5c245d3a6 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -21,14 +21,15 @@ SwiftPM-only; package/sign/notarize manually (no Xcode project). Sparkle feed is - Sparkle key probe runs up front; appcast entry + signature verified automatically after generation. - Release notes are extracted directly from the current changelog section and passed to the GitHub release (no manual notes flag needed). - Sparkle appcast notes are generated as HTML from the same changelog section and embedded into the appcast entry. -- Requires tools/env on PATH: `swiftformat`, `swiftlint`, `swift`, `sign_update`, `generate_appcast`, `gh`, `python3`, `zip`, `curl`, plus `APP_STORE_CONNECT_*` and `SPARKLE_PRIVATE_KEY_FILE`. +- Requires tools/env on PATH: `swiftformat`, `swiftlint`, `swift`, `sign_update`, `generate_keys`, `generate_appcast`, `gh`, `python3`, `zip`, `curl`, plus `APP_STORE_CONNECT_*`. `SPARKLE_PRIVATE_KEY_FILE` is only needed when overriding the default Keychain Sparkle key. ## Prereqs - Xcode 26+ installed at `/Applications/Xcode.app` (for ictool/iconutil and SDKs). - Developer ID Application cert installed: `Developer ID Application: Peter Steinberger (Y5PE65HELJ)`. - ASC API creds in env: `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`. -- Sparkle keys: public key already in Info.plist; private key path set via `SPARKLE_PRIVATE_KEY_FILE` when generating appcast. +- Sparkle keys: public key expectation is in `.mac-release.env`; CodexBar still uses the older shared AGCY key, so the manifest includes the local Dropbox fallback path. `SPARKLE_PRIVATE_KEY_FILE` overrides it. - Ensure shell has release env vars loaded (usually `source ~/.profile`) before running `Scripts/release.sh`. +- Shared release helper: `Scripts/mac-release` resolves `MAC_RELEASE_TOOL`, sibling `../agent-scripts`, or `~/Projects/agent-scripts`. ## Icon (glass .icon → .icns) ``` @@ -45,7 +46,7 @@ What it does: - Packages `CodexBar.app` with Info.plist and Icon.icns - Embeds Sparkle.framework, Updater, Autoupdate, XPCs - Codesigns **everything** with runtime + timestamp (deep) and adds rpath -- Zips to `CodexBar-.zip` +- Zips to `CodexBar-macos-universal-.zip` - Submits to notarytool, waits, staples, validates Gotchas fixed: @@ -55,28 +56,24 @@ Gotchas fixed: - Manual sanity check before uploading: `find CodexBar.app -name '._*'` should return nothing; then `spctl --assess --type execute --verbose CodexBar.app` and `codesign --verify --deep --strict --verbose CodexBar.app` should both pass on the packaged bundle. ## Appcast (Sparkle) -After notarization: +After notarization, or let `Scripts/release.sh` do this: ``` -SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-priv.key \ -./Scripts/make_appcast.sh CodexBar-0.1.0.zip \ +./Scripts/make_appcast.sh CodexBar-macos-universal-0.1.0.zip \ https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml -Generates HTML release notes from `CHANGELOG.md` (via `Scripts/changelog-to-html.sh`) and embeds them into the appcast entry. ``` +Generates HTML release notes from `CHANGELOG.md` (via `Scripts/changelog-to-html.sh`) and embeds them into the appcast entry. Uploads not handled automatically—commit/publish appcast + zip to the feed location (GitHub Releases/raw URL). ## Tag & release ``` -git tag v -./Scripts/make_appcast.sh ... -# upload zip + appcast to Releases -# then create GitHub release (gh release create v ...) +./Scripts/release.sh ``` ## Homebrew (Cask) CodexBar ships a Homebrew **Cask** in `../homebrew-tap`. When installed via Homebrew, CodexBar disables Sparkle and the app must be updated via `brew`. -After publishing the GitHub release, update the tap cask + Linux CLI formula (see `docs/releasing-homebrew.md`). +After publishing the GitHub release, `.github/workflows/release-cli.yml` builds the CLI tarballs, uploads `CodexBarCLI-v-{macos-arm64,macos-x86_64,linux-aarch64,linux-x86_64}.tar.gz` plus checksums, then dispatches the Homebrew tap update for both the CLI formula and app cask. If the final dispatch is rate-limited, the tarballs and app zip may still be present; rerun or manually update the tap formula/cask from the published assets. ## Checklist (quick) - [ ] Read both this file and `~/Projects/agent-scripts/docs/RELEASING-MAC.md`; resolve any conflicts toward CodexBar’s specifics. @@ -84,15 +81,16 @@ After publishing the GitHub release, update the tap cask + Linux CLI formula (se - [ ] `swiftformat`, `swiftlint`, `swift test` (zero warnings/errors) - [ ] `./Scripts/build_icon.sh` if icon changed - [ ] `./Scripts/sign-and-notarize.sh` -- [ ] Generate Sparkle appcast with private key - - Sparkle ed25519 private key path: `/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/sparkle-private-key-KEEP-SECURE.txt` (primary) and `/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle-VibeTunnel/sparkle-private-key-KEEP-SECURE.txt` (older backup) +- [ ] Generate Sparkle appcast via `Scripts/release.sh` or `Scripts/make_appcast.sh`; use `SPARKLE_PRIVATE_KEY_FILE` only if overriding Keychain signing. - Upload the dSYM archive alongside the app zip on the GitHub release; the release script now automates this and will fail if it’s missing. - - After publishing the release, run `Scripts/check-release-assets.sh ` to confirm both the app zip and dSYM zip are present on GitHub. - - Generate the appcast + HTML release notes: `./Scripts/make_appcast.sh CodexBar-.zip https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml` + - After publishing the release and the Release CLI workflow finishes, run `Scripts/check-release-assets.sh ` to confirm the app zip, dSYM zip, CLI tarballs, and CLI checksums are present on GitHub. + - Generate the appcast + HTML release notes: `./Scripts/make_appcast.sh CodexBar-macos-universal-.zip https://raw.githubusercontent.com/steipete/CodexBar/main/appcast.xml` - Beta channel: prefix the command with `SPARKLE_CHANNEL=beta` to tag the entry. - - Verify the enclosure signature + size: `SPARKLE_PRIVATE_KEY_FILE=... ./Scripts/verify_appcast.sh ` + - Verify the enclosure signature + size: `./Scripts/verify_appcast.sh ` - [ ] Upload zip + appcast to feed; publish tag + GitHub release so Sparkle URL is live (avoid 404) -- [ ] Homebrew tap: update `../homebrew-tap/Casks/codexbar.rb` (url + sha256) and `../homebrew-tap/Formula/codexbar.rb` (Linux CLI tarball urls + sha256), then verify: +- [ ] Homebrew tap: wait for the Release CLI workflow to update `../homebrew-tap/Casks/codexbar.rb` (app zip url + sha256) and `../homebrew-tap/Formula/codexbar.rb` (CLI tarball urls + sha256), then verify: + - `gh run watch --exit-status` + - `Scripts/check-release-assets.sh v` - `brew uninstall --cask codexbar || true` - `brew untap steipete/tap || true; brew tap steipete/tap` - `brew install --cask steipete/tap/codexbar && open -a CodexBar` @@ -100,7 +98,7 @@ After publishing the GitHub release, update the tap cask + Linux CLI formula (se - [ ] Changelog sanity: single top-level title, no duplicate version sections, versions strictly descending with no repeats - [ ] Release pages: title format `CodexBar `, notes as Markdown list (no stray blank lines) - [ ] Changelog/release notes are user-facing: avoid internal-only bullets (build numbers, script bumps) and keep entries concise -- [ ] Download uploaded `CodexBar-.zip`, unzip via `ditto`, run, and verify signature (`spctl -a -t exec -vv CodexBar.app` + `stapler validate`) +- [ ] Download uploaded `CodexBar-macos-universal-.zip`, unzip via `ditto`, run, and verify signature (`spctl -a -t exec -vv CodexBar.app` + `stapler validate`) - [ ] Confirm `appcast.xml` points to the new zip/version and renders the HTML release notes (not escaped tags) - [ ] Verify on GitHub Releases: assets present (zip, appcast), release notes match changelog, version/tag correct - [ ] Open the appcast URL in browser to confirm the new entry is visible and enclosure URL is reachable diff --git a/docs/abacus.md b/docs/abacus.md new file mode 100644 index 000000000..c4f9893ae --- /dev/null +++ b/docs/abacus.md @@ -0,0 +1,67 @@ +--- +summary: "Abacus AI provider: browser cookie auth for ChatLLM/RouteLLM compute credit tracking." +read_when: + - Adding or modifying the Abacus AI provider + - Debugging Abacus cookie imports or API responses + - Adjusting Abacus usage display or credit formatting +--- + +# Abacus AI Provider + +The Abacus AI provider tracks ChatLLM/RouteLLM compute credit usage via browser cookie authentication. + +## Features + +- **Monthly credit gauge**: Shows credits used vs. plan total with pace tick indicator. +- **Reserve/deficit estimate**: Projected credit usage through the billing cycle. +- **Reset timing**: Displays the next billing date from the Abacus billing API. +- **Subscription tiers**: Detects Basic and Pro plans. +- **Cookie auth**: Automatic browser cookie import (Safari, Chrome, Firefox) or manual cookie header. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Abacus AI** +3. Log in to [apps.abacus.ai](https://apps.abacus.ai) in your browser +4. Cookie import happens automatically on the next refresh + +### Manual cookie mode + +1. In **Settings → Providers → Abacus AI**, set Cookie source to **Manual** +2. Open your browser DevTools on `apps.abacus.ai`, copy the `Cookie:` header from any API request +3. Paste the header into the cookie field in CodexBar + +## How it works + +Two API endpoints are fetched concurrently using browser session cookies: + +- `GET https://apps.abacus.ai/api/_getOrganizationComputePoints` — returns `totalComputePoints` and `computePointsLeft` (values are in credit units, no conversion needed). +- `POST https://apps.abacus.ai/api/_getBillingInfo` — returns `nextBillingDate` (ISO 8601) and `currentTier` (plan name). + +Cookie domains: `abacus.ai`, `apps.abacus.ai`. Session cookies are validated before use (anonymous/marketing-only cookie sets are skipped). Valid cookies are cached in Keychain and reused until the session expires. + +The billing cycle window is set to 30 days for pace calculation. + +## CLI + +```bash +codexbar usage --provider abacusai --verbose +``` + +## Troubleshooting + +### "No Abacus AI session found" + +Log in to [apps.abacus.ai](https://apps.abacus.ai) in a supported browser (Safari, Chrome, Firefox), then refresh CodexBar. + +### "Abacus AI session expired" + +Re-login to Abacus AI. The cached cookie will be cleared automatically and a fresh one imported on the next refresh. + +### "Unauthorized" + +Your session cookies may be invalid. Log out and back in to Abacus AI, or paste a fresh `Cookie:` header in manual mode. + +### Credits show 0 + +Verify that your Abacus AI account has an active subscription with compute credits allocated. diff --git a/docs/alibaba-coding-plan.md b/docs/alibaba-coding-plan.md new file mode 100644 index 000000000..480204803 --- /dev/null +++ b/docs/alibaba-coding-plan.md @@ -0,0 +1,73 @@ +--- +summary: "Alibaba Coding Plan provider data sources: browser-session baseline, secondary API mode, and honest quota fallback behavior." +read_when: + - Debugging Alibaba Coding Plan API key handling or quota parsing + - Updating Alibaba Coding Plan endpoints or region behavior + - Adjusting Alibaba Coding Plan provider UI/menu behavior +--- + +# Alibaba Coding Plan provider + +Alibaba Coding Plan supports both browser-session and API-key paths, but the supported baseline is browser-session fetching from the Model Studio/Bailian console. API mode remains secondary and may still be limited by account/region behavior. + +## Cookie sources (web mode) +1) Automatic browser import (Model Studio/Bailian cookies). +2) Manual cookie header from Settings. +3) Environment variable `ALIBABA_CODING_PLAN_COOKIE`. + +When the RPC endpoint returns `ConsoleNeedLogin`, CodexBar treats that as a console-session requirement. In API mode it is surfaced as an explicit API-path limitation; in `auto` mode fallback remains observable through the fetch-attempt chain. + +## Token sources (fallback order) +1) Config token (`~/.codexbar/config.json` -> `providers[].apiKey` for provider `alibaba`). +2) Environment variables, checked in order: + - `ALIBABA_CODING_PLAN_API_KEY` + - `ALIBABA_QWEN_API_KEY` + - `DASHSCOPE_API_KEY` + +## Region + endpoint behavior +- International host: `https://modelstudio.console.alibabacloud.com` +- China mainland host: `https://bailian.console.aliyun.com` +- Quota request path: + - `POST /data/api.json?action=zeldaEasy.broadscope-bailian.codingPlan.queryCodingPlanInstanceInfoV2&product=broadscope-bailian&api=queryCodingPlanInstanceInfoV2` +- Region is selected in Preferences -> Providers -> Alibaba Coding Plan -> Gateway region. +- Auto fallback behavior: + - If International fails with credential/host-style API errors, CodexBar retries China mainland once. + +### CN API-key limitation (known) +- In some China mainland accounts/environments, the current Alibaba `/data/api.json` coding-plan endpoint can still return console-login-required responses (`ConsoleNeedLogin`) even when an API key is configured. +- In that case, API-key mode may not be functionally available for that account/endpoint, and web session mode is required. +- CodexBar now surfaces this as an API error in API mode (instead of a cookie-login-required message) so the limitation is explicit. + +## Overrides +- Override host base: `ALIBABA_CODING_PLAN_HOST` + - Example: `ALIBABA_CODING_PLAN_HOST=modelstudio.console.alibabacloud.com` +- Override full quota URL: `ALIBABA_CODING_PLAN_QUOTA_URL` + - Example: `ALIBABA_CODING_PLAN_QUOTA_URL=https://example.com/data/api.json?action=...` + +## Request headers +- `Authorization: Bearer ` +- `x-api-key: ` +- `X-DashScope-API-Key: ` +- `Content-Type: application/json` +- `Accept: application/json` + +## Parsing + mapping +- Plan name (best effort): + - `codingPlanInstanceInfos[].planName` / `instanceName` / `packageName` +- Quota windows (from `codingPlanQuotaInfo`): + - `per5HourUsedQuota` + `per5HourTotalQuota` + `per5HourQuotaNextRefreshTime` -> primary (5-hour) + - `perWeekUsedQuota` + `perWeekTotalQuota` + `perWeekQuotaNextRefreshTime` -> secondary (weekly) + - `perBillMonthUsedQuota` + `perBillMonthTotalQuota` + `perBillMonthQuotaNextRefreshTime` -> tertiary (monthly) +- Each window maps to `usedPercent = used / total * 100` (bounded to valid range). +- If the payload proves the plan is active but does not expose defensible quota counters, CodexBar preserves the visible plan state without manufacturing a normal quantitative quota window. +- If neither real counters nor a defensible active-plan fallback signal exist, parsing fails explicitly instead of degrading to fake `0%` usage. + +## Dashboard links +- International console: `https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan` +- China mainland console: `https://bailian.console.aliyun.com/cn-beijing/?tab=model#/efm/coding_plan` + +## Key files +- `Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanProviderDescriptor.swift` +- `Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift` +- `Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageSnapshot.swift` +- `Sources/CodexBar/Providers/Alibaba/AlibabaCodingPlanProviderImplementation.swift` diff --git a/docs/alibaba-token-plan.md b/docs/alibaba-token-plan.md new file mode 100644 index 000000000..1d8884605 --- /dev/null +++ b/docs/alibaba-token-plan.md @@ -0,0 +1,61 @@ +--- +summary: "Alibaba Token Plan provider notes: Bailian cookie auth, subscription summary endpoint, and setup." +read_when: + - Adding or modifying the Alibaba Token Plan provider + - Debugging Alibaba Token Plan cookie import or subscription summary fetching + - Explaining Alibaba Token Plan setup and limitations to users +--- + +# Alibaba Token Plan Provider + +The Alibaba Token Plan provider tracks Bailian token-plan credits from the Alibaba Cloud console. + +## Features + +- **Token-plan usage display**: Shows used, total, and remaining token-plan credits when Bailian returns quota totals. +- **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. +- **Expiry awareness**: Shows the nearest token-plan expiration date as the reset time when the subscription summary includes it. + +## Setup + +1. Open **Settings -> Providers** +2. Enable **Alibaba Token Plan** +3. Leave **Cookie source** on **Auto** (recommended) + +### Manual cookie import (optional) + +1. Open `https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan` +2. Copy a `Cookie:` header from your browser's Network tab +3. Paste it into **Alibaba Token Plan -> Cookie source -> Manual** + +## How it works + +- Fetches `POST https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3&_tag=` +- Sends form-encoded fields for `product=BssOpenAPI-V3`, `action=GetSubscriptionSummary`, `region=cn-beijing`, and `params={"ProductCode":"sfm_tokenplanteams_dp_cn"}` +- Uses Alibaba/Bailian login cookies, with `sec_token` added when it can be resolved from the dashboard page +- Parses `TotalValue`, `TotalSurplusValue`, `TotalCount`, and `NearestExpireDate` from the subscription summary response +- Supports `ALIBABA_TOKEN_PLAN_HOST` and `ALIBABA_TOKEN_PLAN_QUOTA_URL` for testing endpoint overrides + +## Limitations + +- Alibaba Token Plan currently supports the Bailian web-cookie path only +- API-key auth, token cost summaries, and automatic status polling are not supported +- The default endpoint is the China mainland Bailian token-plan subscription summary + +## Troubleshooting + +### "No Alibaba Token Plan session cookies found in browsers" + +Log in at `https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan` in Chrome, then refresh CodexBar. + +### "Alibaba Token Plan cookie header is invalid" + +The pasted header is empty or not a valid Cookie header. Re-copy the request from the Token Plan page after logging in again. + +### "Alibaba Token Plan login required" + +Your Bailian session is stale. Sign out and back in on the Bailian console, then refresh CodexBar. + +### Empty subscription summary + +If Bailian returns `TotalCount: 0`, CodexBar keeps the provider visible but does not show a quota window because the account has no active token-plan subscription summary to graph. diff --git a/docs/antigravity.md b/docs/antigravity.md index ab99af30f..5ba4978da 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -1,14 +1,32 @@ --- -summary: "Antigravity provider notes: local LSP probing, port discovery, quota parsing, and UI mapping." +summary: "Antigravity provider notes: OAuth usage, multi-account switching, local LSP probing, and quota parsing." read_when: - Adding or modifying the Antigravity provider - Debugging Antigravity port detection or quota parsing - Adjusting Antigravity menu labels or model mapping + - Working with Antigravity OAuth or account switching --- # Antigravity provider -Antigravity is a local-only provider. We talk directly to the Antigravity language server running on the same machine. +Antigravity supports local IDE probing and Google OAuth-backed remote usage. The OAuth path can store multiple Google accounts through the shared token-account switcher. + +## OAuth account switching + +- Login still uses Antigravity's Google OAuth client, discovered from `Antigravity.app` or overridden with `ANTIGRAVITY_OAUTH_CLIENT_ID` and `ANTIGRAVITY_OAUTH_CLIENT_SECRET`. +- A successful login writes the latest shared credentials to `~/.codexbar/antigravity/oauth_creds.json` and upserts a token-account entry for the Google account. +- Each token-account entry stores serialized `AntigravityOAuthCredentials` and is injected into remote fetches through `ANTIGRAVITY_OAUTH_CREDENTIALS_JSON`. +- When a token account is selected, the OAuth fetcher uses that account before falling back to the shared credentials file. +- The menu action is labeled `Add Account...`; switching between saved accounts uses the existing segmented/stacked token-account menu UI. + +## Remote OAuth data sources + +- `POST https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` +- `POST https://cloudcode-pa.googleapis.com/v1internal:onboardUser` +- `POST https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` +- `POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota` + +## Local data sources + fallback order ## Data sources + fallback order diff --git a/docs/augment.md b/docs/augment.md index b7f5fcdc9..0790ebdd6 100644 --- a/docs/augment.md +++ b/docs/augment.md @@ -79,9 +79,9 @@ When the CLI is unavailable or not authenticated, CodexBar falls back to browser The provider includes an automatic session keepalive system: -- **Check Interval**: Every 5 minutes +- **Check Interval**: Every 1 minute - **Refresh Buffer**: Refreshes 5 minutes before cookie expiration -- **Rate Limiting**: Minimum 2 minutes between refresh attempts +- **Rate Limiting**: Minimum 1 minute between refresh attempts - **Session Cookies**: Refreshed every 30 minutes (no expiration date) This ensures your session stays active without manual intervention. @@ -170,7 +170,7 @@ This prevents cookies from other subdomains being sent to the API. ### Session Refresh Mechanism -1. Keepalive checks cookie expiration every 5 minutes +1. Keepalive checks cookie expiration every 1 minute 2. If expiration is within 5 minutes, triggers refresh 3. Pings `/api/auth/session` to trigger cookie update 4. Waits 1 second for browser to update cookies diff --git a/docs/bedrock.md b/docs/bedrock.md new file mode 100644 index 000000000..6cb6d7fa4 --- /dev/null +++ b/docs/bedrock.md @@ -0,0 +1,112 @@ +--- +summary: "AWS Bedrock provider: Cost Explorer credentials, budget tracking, and usage display." +read_when: + - Setting up AWS Bedrock usage tracking + - Debugging Bedrock Cost Explorer fetches + - Updating Bedrock credentials, region, or budget handling +--- + +# AWS Bedrock provider + +CodexBar reads AWS Cost Explorer for Bedrock spend and can compare the current month against an optional budget. + +## Authentication + +CodexBar supports two authentication modes, selected in Preferences → Providers → AWS Bedrock → Authentication. + +### Access keys (default) + +Provide static AWS credentials through Settings or the environment inherited by CodexBar/the CLI: + +```bash +export AWS_ACCESS_KEY_ID="..." +export AWS_SECRET_ACCESS_KEY="..." +export AWS_REGION="us-east-1" +``` + +Optional: + +```bash +export AWS_SESSION_TOKEN="..." +export CODEXBAR_BEDROCK_BUDGET="250" +``` + +### AWS profile + +Resolve credentials from a named profile in `~/.aws/config` / `~/.aws/credentials` instead of pasting keys. Set the +profile name in Settings (or via `AWS_PROFILE`). CodexBar shells out to the AWS CLI +(`aws configure export-credentials --profile `), so this works with **SSO**, **assume-role**, +`credential_process`, and MFA-cached profiles — not just static credentials. + +Requirements: + +- AWS CLI v2 on your `PATH` (CodexBar also checks `/opt/homebrew/bin/aws`, `/usr/local/bin/aws`, and `~/.local/bin/aws`). + Override the location with `AWS_CLI_PATH` if it lives elsewhere. +- For SSO profiles, an active session (`aws sso login --profile `). Credentials are resolved fresh on each + refresh; the AWS CLI caches the SSO token, so this does not re-prompt unless the session has expired. + +The profile's region is read automatically (`aws configure get region`); leave the Region field blank to use it, or set +`AWS_REGION` / the Region field to override. + +Relevant environment variables: + +```bash +export CODEXBAR_BEDROCK_AUTH_MODE="profile" # set automatically by Settings; "keys" or "profile" +export AWS_PROFILE="work" +export AWS_CLI_PATH="/opt/homebrew/bin/aws" # optional override +``` + +The AWS identity (from either mode) must have permission to call Cost Explorer APIs, including `ce:GetCostAndUsage`. + +## Data source + +- Service: AWS Cost Explorer. +- Region: `AWS_REGION` or `AWS_DEFAULT_REGION`, defaulting to `us-east-1`. +- Usage: current-month Bedrock spend and historical daily cost buckets. +- Budget: `CODEXBAR_BEDROCK_BUDGET`, when set to a positive dollar amount. +- Test override: `CODEXBAR_BEDROCK_API_URL` replaces the Cost Explorer endpoint. + +## Display + +- Shows month-to-date Bedrock spend. +- Shows budget progress when a budget is configured. +- Reuses the shared inline dashboard for daily cost history when enough buckets are available. + +## CLI + +```bash +codexbar --provider bedrock --source api +codexbar --provider bedrock --format json --pretty +``` + +## Troubleshooting + +### "No AWS Bedrock cost data available" + +- Confirm the credentials are visible to CodexBar. +- Confirm the AWS account has Cost Explorer enabled. +- Confirm the IAM principal can call `ce:GetCostAndUsage`. +- If using temporary credentials, include `AWS_SESSION_TOKEN`. +- In profile mode, confirm the AWS CLI is installed (or set `AWS_CLI_PATH`) and that the profile name is correct. + +### "AWS profile session expired" + +The profile's SSO/temporary session has expired. Run `aws sso login --profile ` (or refresh the underlying +credentials) and retry. + +### "AWS CLI not found" + +Profile mode requires AWS CLI v2. Install it (e.g. `brew install awscli`) or point CodexBar at the binary with +`AWS_CLI_PATH`. + +### Wrong region + +Set `AWS_REGION` or `AWS_DEFAULT_REGION`. Bedrock usage is regional, but Cost Explorer itself is account-level; CodexBar still needs a signing region for the request. + +## Key files + +- `Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockProfileCredentialProvider.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift` diff --git a/docs/claude-comparison-since-0.18.0beta2.md b/docs/claude-comparison-since-0.18.0beta2.md index b2797a7fa..6d7f665bc 100644 --- a/docs/claude-comparison-since-0.18.0beta2.md +++ b/docs/claude-comparison-since-0.18.0beta2.md @@ -14,7 +14,7 @@ Focus areas: ## High-level Changes - OAuth moved from "load token and fail if expired" to "auto-refresh when expired". -- Claude keychain reads now use stricter non-interactive probes (`kSecUseAuthenticationUIFail`) before any promptable path. +- Claude keychain reads now use stricter non-interactive probes (`LAContext.interactionNotAllowed`) before any promptable path. - New Claude keychain prompt cooldown gate: 6-hour suppression after denial. - New OAuth refresh failure gate: - `invalid_grant` => terminal block until auth fingerprint changes. diff --git a/docs/claude.md b/docs/claude.md index 22737efd9..ecafe1786 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -9,18 +9,43 @@ read_when: # Claude provider -Claude supports three usage data paths plus local cost usage. Source selection is automatic unless debug override is set. +Claude supports three usage data paths plus local cost usage. The main provider pipeline uses runtime-specific +automatic selection, but the codebase still has multiple active Claude `.auto` decision sites while the refactor is +pending. For the exact current-state parity contract, see +[docs/refactor/claude-current-baseline.md](refactor/claude-current-baseline.md). + +When an Anthropic Admin API key is configured, Claude can also show organization-level spend/messages/tokens in the +same inline dashboard pattern used by the OpenAI API provider. ## Data sources + selection order ### Default selection (debug menu disabled) -1) OAuth API (if Claude CLI credentials include `user:profile` scope). -2) CLI PTY (`claude`), if OAuth is unavailable or fails. -3) Web API (browser cookies, `sessionKey`), if OAuth + CLI are unavailable or fail. +- If an Admin API key is configured, the Admin API strategy is used for Claude API spend/usage. +- App runtime main pipeline: OAuth API → CLI PTY → Web API. +- CLI runtime main pipeline: Web API → CLI PTY. +- Explicit picker modes (OAuth/Web/CLI) bypass automatic fallback. +- A lower-level direct Claude fetcher still contains a separate `.auto` order. That inconsistency is tracked in + [docs/refactor/claude-current-baseline.md](refactor/claude-current-baseline.md). Usage source picker: - Preferences → Providers → Claude → Usage source (Auto/OAuth/Web/CLI). +Admin API key setup: +- Preferences → Providers → Claude → Admin API key, stored in `~/.codexbar/config.json`. +- CLI/env: `printf '%s' "$ANTHROPIC_ADMIN_KEY" | codexbar config set-api-key --provider claude --stdin`. +- Token accounts can also hold `sk-ant-admin...` keys; they route to the Admin API instead of cookie/OAuth usage. +- Environment fallback: `ANTHROPIC_ADMIN_KEY`. + +## Admin API +- Key prefix: `sk-ant-admin...`. +- Endpoints: + - `/v1/organizations/cost_report` + - `/v1/organizations/usage_report/messages` +- Output: + - Today/7d/30d spend and message/token summaries. + - Inline 30-day dashboard chart when daily buckets are present. + - Identity login method: `Admin API`. + ## Keychain prompt policy (Claude OAuth) - Preferences → Providers → Claude → Keychain prompt policy. - Options: @@ -37,8 +62,9 @@ Usage source picker: ## OAuth API (preferred) - Credentials: - - Keychain service: `Claude Code-credentials` (primary on macOS). + - CodexBar OAuth cache when available. - File fallback: `~/.claude/.credentials.json`. + - Claude CLI Keychain bootstrap/repair fallback: `Claude Code-credentials`. - Requires `user:profile` scope (CLI tokens with only `user:inference` cannot call usage). - Endpoint: - `GET https://api.anthropic.com/api/oauth/usage` @@ -47,18 +73,24 @@ Usage source picker: - `anthropic-beta: oauth-2025-04-20` - Mapping: - `five_hour` → session window. - - `seven_day` → weekly window. + - `seven_day` → weekly window; also becomes the primary fallback when `five_hour` is absent or has no utilization. - `seven_day_sonnet` / `seven_day_opus` → model-specific weekly window. + - `seven_day_routines` / `seven_day_cowork` → Daily Routines extra window. + - Claude Design/Omelette keys are ignored because Claude Design shares the main Claude usage limit. - `extra_usage` → Extra usage cost (monthly spend/limit). -- Plan inference: `rate_limit_tier` from credentials maps to Max/Pro/Team/Enterprise. +- Successful OAuth login enables Claude and selects OAuth as the usage source. +- Plan inference: `subscriptionType` is preferred when present; `rate_limit_tier` falls back to + Max/Pro/Team/Enterprise. ## Web API (cookies) - Preferences → Providers → Claude → Cookie source (Automatic or Manual). - Manual mode accepts a `Cookie:` header from a claude.ai request. - Multi-account manual tokens: add entries to `~/.codexbar/config.json` (`tokenAccounts`) and set Claude cookies to Manual. The menu can show all accounts stacked or a switcher bar (Preferences → Advanced → Display). -- Claude token accounts accept either `sessionKey` cookies or OAuth access tokens (`sk-ant-oat...`). OAuth tokens use - the Anthropic OAuth usage endpoint; to force cookie mode, paste `sessionKey=` or a full `Cookie:` header. +- Claude token accounts accept either `sessionKey` cookies or OAuth access tokens (`sk-ant-oat...`). OAuth-token + accounts route to the OAuth path and disable cookie mode; session-key or cookie-header accounts stay in manual + cookie mode. The exact edge-routing rules are documented in + [docs/refactor/claude-current-baseline.md](refactor/claude-current-baseline.md). - Cookie source order: 1) Safari: `~/Library/Cookies/Cookies.binarycookies` 2) Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies` @@ -75,12 +107,15 @@ Usage source picker: - `GET https://claude.ai/api/account` → email + plan hints. - Outputs: - Session + weekly + model-specific percent used. + - Daily Routines extra window when returned by the usage API. - Extra usage spend/limit (if enabled). - Account email + inferred plan. ## CLI PTY (fallback) - Runs `claude` in a PTY session (`ClaudeCLISession`). - Default behavior: exit after each probe; Debug → "Keep CLI sessions alive" keeps it running between probes. +- Probe working directory: `~/Library/Application Support/CodexBar/ClaudeProbe` with local Claude settings that disable + deep-link URL handler registration during headless probes. - Command flow: 1) Start CLI with `--allowed-tools ""` (no tools). 2) Auto-respond to first-run prompts (trust files, workspace, telemetry). @@ -94,17 +129,23 @@ Usage source picker: ## Cost usage (local log scan) - Source roots: - - `$CLAUDE_CONFIG_DIR` (comma-separated), each root uses `/projects`. - - Fallback roots: - - `~/.config/claude/projects` - - `~/.claude/projects` -- Files: `**/*.jsonl` under the project roots. + - Native Claude logs: + - `$CLAUDE_CONFIG_DIR` (comma-separated), each root uses `/projects`. + - Fallback roots: + - `~/.config/claude/projects` + - `~/.claude/projects` + - Supported pi sessions: + - `~/.pi/agent/sessions/**/*.jsonl` +- Files: `**/*.jsonl` under the native project roots plus supported pi session files. - Parsing: - - Lines with `type: "assistant"` and `message.usage`. + - Native Claude logs parse lines with `type: "assistant"` and `message.usage`. - Uses per-model token counts (input, cache read/create, output). - Deduplicates streaming chunks by `message.id + requestId` (usage is cumulative per chunk). + - pi sessions attribute `anthropic` assistant usage to Claude and bucket it by assistant-turn timestamp, so a single pi + session can contribute to multiple models/days. - Cache: - - `~/Library/Caches/CodexBar/cost-usage/claude-v1.json` + - Native + merged provider cache: `~/Library/Caches/CodexBar/cost-usage/claude-v2.json` + - pi session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v1.json` ## Key files - OAuth: `Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/*` @@ -112,4 +153,6 @@ Usage source picker: - CLI PTY: `Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift`, `Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift` - Cost usage: `Sources/CodexBarCore/CostUsageFetcher.swift`, + `Sources/CodexBarCore/PiSessionCostScanner.swift`, + `Sources/CodexBarCore/PiSessionCostCache.swift`, `Sources/CodexBarCore/Vendored/CostUsage/*` diff --git a/docs/cli-configuration.md b/docs/cli-configuration.md new file mode 100644 index 000000000..cdd060826 --- /dev/null +++ b/docs/cli-configuration.md @@ -0,0 +1,101 @@ +--- +summary: "CodexBar CLI configuration commands for provider toggles, API keys, and isolated config files." +read_when: + - Using codexbar config from scripts or CI + - Enabling or disabling providers without opening Settings + - Storing provider API keys from the command line +--- + +# CLI configuration + +`codexbar config` edits the same `~/.codexbar/config.json` file used by the app's Settings → Providers pane. +The CLI writes the file with `0600` permissions. + +## Providers + +List persistent provider toggles: + +```bash +codexbar config providers +codexbar config providers --json --pretty +``` + +Enable or disable a provider: + +```bash +codexbar config enable --provider grok +codexbar config disable --provider cursor +``` + +These are persistent app/CLI settings. They are different from `codexbar usage --provider grok`, which is a one-shot +command override and does not edit config. + +If every provider is disabled, `codexbar usage` with no `--provider` prints no text output, and +`codexbar usage --json` prints `[]`. Passing `--provider ` still fetches that provider for the one command. + +## API keys + +API keys are stored under the provider entry in config: + +```bash +printf '%s' "$ELEVENLABS_API_KEY" | codexbar config set-api-key --provider elevenlabs --stdin +``` + +`set-api-key` enables the provider by default. Add `--no-enable` when you only want to save the key: + +```bash +printf '%s' "$OPENROUTER_API_KEY" | codexbar config set-api-key --provider openrouter --stdin --no-enable +``` + +Useful examples: + +```bash +printf '%s' "$OPENAI_ADMIN_KEY" | codexbar config set-api-key --provider openai --stdin +printf '%s' "$ANTHROPIC_ADMIN_KEY" | codexbar config set-api-key --provider claude --stdin +printf '%s' "$DEEPGRAM_API_KEY" | codexbar config set-api-key --provider deepgram --stdin +printf '%s' "$GROQ_API_KEY" | codexbar config set-api-key --provider groq --stdin +printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmproxy --stdin +printf '%s' "$Z_AI_API_KEY" | codexbar config set-api-key --provider zai --stdin +``` + +Only providers that consume config-backed API keys accept this command. Admin API providers may require a key with +organization/usage permissions, not a normal inference key. Browser/OAuth providers such as Grok use their own provider +sessions instead of an xAI API key for CodexBar's billing view, so enable them with +`codexbar config enable --provider grok`. + +LLM Proxy also needs a base URL. Use `LLM_PROXY_BASE_URL` for CLI runs, or add `"enterpriseHost"` to the provider entry +in `~/.codexbar/config.json`. + +## Isolated config files + +For tests, demos, and CI, point CodexBar at a temporary config file: + +```bash +export CODEXBAR_CONFIG=/tmp/codexbar-config.json +codexbar config enable --provider grok +codexbar config providers --json --pretty +``` + +The override applies to both reads and writes for the current process environment. + +## Cost history window + +The app setting controls the menu's local cost-history window. For one-off CLI reports, pass `--days`: + +```bash +codexbar cost --provider codex --days 90 +codexbar cost --provider claude --days 180 --format json --pretty +``` + +The accepted range is 1...365 days. + +## Validation + +After hand-editing config: + +```bash +codexbar config validate +codexbar config dump --pretty +``` + +`dump` prints normalized config, including providers omitted from a hand-written file. diff --git a/docs/cli.md b/docs/cli.md index a13c8f830..c9a010152 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -8,21 +8,23 @@ read_when: # CodexBar CLI -A lightweight Commander-based CLI that mirrors the menubar app’s data paths (Codex web/RPC → PTY fallback; Claude web by default with CLI fallback and OAuth debug). +A lightweight Commander-based CLI that mirrors the menu bar app’s provider fetchers and config file. Use it when you need usage numbers in scripts, CI, or dashboards without UI. ## Install - In the app: **Preferences → Advanced → Install CLI**. This symlinks `CodexBarCLI` to `/usr/local/bin/codexbar` and `/opt/homebrew/bin/codexbar`. -- From the repo: `./bin/install-codexbar-cli.sh` (same symlink targets). +- From the repo, after installing `CodexBar.app` in `/Applications`: `./bin/install-codexbar-cli.sh` (same symlink targets). - Manual: `ln -sf "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI" /usr/local/bin/codexbar`. -### Linux install -- Homebrew (Linuxbrew, Linux only): `brew install steipete/tap/codexbar`. -- Download `CodexBarCLI-v-linux-.tar.gz` from GitHub Releases (x86_64 + aarch64). -- Extract; run `./codexbar` (symlink) or `./CodexBarCLI`. +### Release tarball install (macOS/Linux) +- Homebrew formula (Linux today): `brew install steipete/tap/codexbar`. +- Download release tarballs from GitHub Releases: + - macOS: `CodexBarCLI-v-macos-arm64.tar.gz`, `CodexBarCLI-v-macos-x86_64.tar.gz` + - Linux: `CodexBarCLI-v-linux-aarch64.tar.gz`, `CodexBarCLI-v-linux-x86_64.tar.gz` +- Extract and run `./codexbar` (symlink) or `./CodexBarCLI`. ``` -tar -xzf CodexBarCLI-v0.17.0-linux-x86_64.tar.gz +tar -xzf CodexBarCLI-v0.17.0-macos-x86_64.tar.gz ./codexbar --version ./codexbar usage --format json --pretty ``` @@ -39,29 +41,43 @@ See `docs/configuration.md` for the schema. ## Command - `codexbar` defaults to the `usage` command. - `--format text|json` (default: text). -- `codexbar cost` prints local token cost usage (Claude + Codex) without web/CLI access. +- `codexbar cost` prints local token cost usage for Claude + Codex without web/CLI access. - `--format text|json` (default: text). - `--refresh` ignores cached scans. +- `codexbar serve` starts a foreground localhost-only HTTP server for usage and cost JSON. + - `--port ` defaults to `8080`. + - `--refresh-interval ` defaults to `60` and controls the in-memory response cache TTL. + - v1 binds to `127.0.0.1` only and rejects non-loopback `Host` headers. It does not expose remote bind, auth, CORS, TLS, or daemon mode. + - Endpoints: `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`. + - Codex usage responses include every visible Codex account, matching the menu bar switcher. +- `codexbar cache clear` clears local CodexBar caches. + - `--cookies` removes cached browser-cookie headers from the CodexBar Keychain cache. + - `--cookies --provider ` removes browser-cookie cache entries for that provider, including managed Codex account scopes. + - `--cost` removes local cost-usage scan caches. + - `--all` clears both cookies and cost caches. `--provider` is cookie-only and cannot be combined with `--cost` or `--all`. - `--provider ` (default: enabled providers in config; falls back to defaults when missing). - Provider IDs live in the config file (see `docs/configuration.md`). - - `--account
+

Free & open source · macOS 14+ · Universal on GitHub Releases · Apple Silicon via Homebrew

-
-
-

macOS permissions

-

- Full Disk Access only for Safari cookies, Keychain access for tokens, and Files & Folders prompts when - provider CLIs access project directories. -

-
+
+
+

40+ providers, one menu bar

+

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

+
+
- + + + + diff --git a/docs/kilo.md b/docs/kilo.md index b8a6fa2b0..6425bedec 100644 --- a/docs/kilo.md +++ b/docs/kilo.md @@ -35,3 +35,20 @@ Kilo supports API and CLI-backed auth. Source mode can be `auto`, `api`, or `cli - Missing API token: set `KILO_API_KEY` or provider `apiKey`. - Missing CLI session file: run `kilo login` to create `~/.local/share/kilo/auth.json`. - Unauthorized API token (401/403): refresh `KILO_API_KEY` or rerun `kilo login`. + +## Organizations + +CodexBar can show usage for any Kilo organization the API key belongs to. + +- Open Preferences → Providers → Kilo, set the API key, then click **Refresh + organizations**. +- Toggle the organizations you want to display alongside Personal. Personal is + always shown. +- When at least one organization is enabled, the menu renders one Kilo card per + enabled scope. +- The CodexBar fetcher sends the standard `X-KILOCODE-ORGANIZATIONID` header on + every usage call to scope the response to that organization. +- CLI source mode (`auth.json`): the header is applied to CLI-resolved tokens + as well. If a CLI token isn't authorized for the chosen organization, that + card surfaces an unauthorized error while Personal and other enabled scopes + continue to render normally. diff --git a/docs/kimi-k2.md b/docs/kimi-k2.md index a40aa3566..a94beb777 100644 --- a/docs/kimi-k2.md +++ b/docs/kimi-k2.md @@ -8,13 +8,18 @@ read_when: # Kimi K2 provider -Kimi K2 is API-only. Usage is reported by the credit counter behind `GET https://kimi-k2.ai/api/user/credits`, -so CodexBar only needs a valid API key to pull your remaining balance and usage. +> This is a legacy, unofficial provider for the `kimi-k2.ai` credit endpoint. +> For the official Kimi API account and billing surface, use the Moonshot / Kimi +> API provider instead. + +Kimi K2 is API-only. Usage is reported by the credit counter behind +`GET https://kimi-k2.ai/api/user/credits`, so CodexBar only needs a valid API +key for that legacy endpoint to pull your remaining balance and usage. ## Data sources + fallback order 1) **API key** stored in `~/.codexbar/config.json` or supplied via `KIMI_K2_API_KEY` / `KIMI_API_KEY` / `KIMI_KEY`. - CodexBar stores the key in config after you paste it in Preferences → Providers → Kimi K2. + CodexBar stores the key in config after you paste it in Preferences → Providers → Kimi K2 (unofficial). 2) **Credit endpoint** - `GET https://kimi-k2.ai/api/user/credits` - Request headers: `Authorization: Bearer `, `Accept: application/json` diff --git a/docs/llm-proxy.md b/docs/llm-proxy.md new file mode 100644 index 000000000..49ce39eaf --- /dev/null +++ b/docs/llm-proxy.md @@ -0,0 +1,41 @@ +--- +summary: "LLM Proxy provider setup and quota-stats usage source." +read_when: + - Configuring LLM Proxy usage tracking + - Debugging aggregate proxy quota or provider breakdown display +--- + +# LLM Proxy + +CodexBar reads aggregate usage from an LLM-API-Key-Proxy compatible `/v1/quota-stats` endpoint. + +## Setup + +Store the API key: + +```bash +printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmproxy --stdin +``` + +Set the base URL with `LLM_PROXY_BASE_URL`, or add `enterpriseHost` to the provider config: + +```json +{ + "id": "llmproxy", + "enabled": true, + "apiKey": "", + "enterpriseHost": "https://proxy.example.com" +} +``` + +The base URL may point at either the service root or `/v1`; CodexBar normalizes both to `/v1/quota-stats`. + +## Menu display + +- Primary: lowest remaining quota group, rendered as percent used. +- Secondary: total requests. +- Tertiary: total tokens. +- Extra rows: top provider summaries by request count. +- Cost: approximate spend when the proxy reports `approx_cost`. + +`quota_groups` may be either an array or a keyed object; CodexBar accepts both shapes. diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 000000000..c30878b31 --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,12 @@ +# CodexBar + +CodexBar shows AI coding-provider usage limits, credits, costs, and reset windows in the macOS menu bar. + +Canonical documentation: +- CodexBar — every AI coding limit in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 40+ providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, and more. + +Source: https://github.com/steipete/CodexBar + +Guidance for agents: +- Prefer the canonical documentation URLs above over README excerpts or package metadata. +- Fetch only the pages needed for the current task; this is an index, not a full-site corpus. diff --git a/docs/logos/abacus-ai-dark.svg b/docs/logos/abacus-ai-dark.svg new file mode 100644 index 000000000..468bb3dfe --- /dev/null +++ b/docs/logos/abacus-ai-dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/docs/logos/abacus-ai.png b/docs/logos/abacus-ai.png new file mode 100644 index 000000000..46572923b Binary files /dev/null and b/docs/logos/abacus-ai.png differ diff --git a/docs/logos/alibaba.svg b/docs/logos/alibaba.svg new file mode 100644 index 000000000..bb458d7ed --- /dev/null +++ b/docs/logos/alibaba.svg @@ -0,0 +1 @@ +Alibaba \ No newline at end of file diff --git a/docs/logos/amp.svg b/docs/logos/amp.svg new file mode 100644 index 000000000..f8f9d31c0 --- /dev/null +++ b/docs/logos/amp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/logos/antigravity.svg b/docs/logos/antigravity.svg new file mode 100644 index 000000000..3ed10ab65 --- /dev/null +++ b/docs/logos/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/docs/logos/augment.svg b/docs/logos/augment.svg new file mode 100644 index 000000000..5e81dd27b --- /dev/null +++ b/docs/logos/augment.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/docs/logos/claude.svg b/docs/logos/claude.svg new file mode 100644 index 000000000..62dc0db12 --- /dev/null +++ b/docs/logos/claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/docs/logos/codebuff-dark.svg b/docs/logos/codebuff-dark.svg new file mode 100644 index 000000000..6d5f9e455 --- /dev/null +++ b/docs/logos/codebuff-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/logos/codebuff.png b/docs/logos/codebuff.png new file mode 100644 index 000000000..47b4b7bee Binary files /dev/null and b/docs/logos/codebuff.png differ diff --git a/docs/logos/codebuff.svg b/docs/logos/codebuff.svg new file mode 100644 index 000000000..5791738f4 --- /dev/null +++ b/docs/logos/codebuff.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/logos/codex-dark.svg b/docs/logos/codex-dark.svg new file mode 100644 index 000000000..0d789fb75 --- /dev/null +++ b/docs/logos/codex-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/logos/codex.svg b/docs/logos/codex.svg new file mode 100644 index 000000000..c77ccfdd9 --- /dev/null +++ b/docs/logos/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/docs/logos/copilot.svg b/docs/logos/copilot.svg new file mode 100644 index 000000000..dd11956e0 --- /dev/null +++ b/docs/logos/copilot.svg @@ -0,0 +1 @@ +GithubCopilot \ No newline at end of file diff --git a/docs/logos/cursor.svg b/docs/logos/cursor.svg new file mode 100644 index 000000000..e4135e124 --- /dev/null +++ b/docs/logos/cursor.svg @@ -0,0 +1 @@ +Cursor \ No newline at end of file diff --git a/docs/logos/deepseek.svg b/docs/logos/deepseek.svg new file mode 100644 index 000000000..185c108b4 --- /dev/null +++ b/docs/logos/deepseek.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/docs/logos/droid-dark.svg b/docs/logos/droid-dark.svg new file mode 100644 index 000000000..f7807d8ab --- /dev/null +++ b/docs/logos/droid-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/logos/droid.svg b/docs/logos/droid.svg new file mode 100644 index 000000000..38aad643a --- /dev/null +++ b/docs/logos/droid.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/docs/logos/elevenlabs.svg b/docs/logos/elevenlabs.svg new file mode 100644 index 000000000..ce9145e07 --- /dev/null +++ b/docs/logos/elevenlabs.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/logos/gemini.svg b/docs/logos/gemini.svg new file mode 100644 index 000000000..62681dfa7 --- /dev/null +++ b/docs/logos/gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/docs/logos/jetbrains-ai.svg b/docs/logos/jetbrains-ai.svg new file mode 100644 index 000000000..8734b4987 --- /dev/null +++ b/docs/logos/jetbrains-ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/logos/kilo.svg b/docs/logos/kilo.svg new file mode 100644 index 000000000..74c381b1d --- /dev/null +++ b/docs/logos/kilo.svg @@ -0,0 +1 @@ +Kilo Code \ No newline at end of file diff --git a/docs/logos/kimi-k2.svg b/docs/logos/kimi-k2.svg new file mode 100644 index 000000000..45b894ba5 --- /dev/null +++ b/docs/logos/kimi-k2.svg @@ -0,0 +1 @@ +Kimi diff --git a/docs/logos/kimi.svg b/docs/logos/kimi.svg new file mode 100644 index 000000000..45b894ba5 --- /dev/null +++ b/docs/logos/kimi.svg @@ -0,0 +1 @@ +Kimi diff --git a/docs/logos/kiro-dark.svg b/docs/logos/kiro-dark.svg new file mode 100644 index 000000000..6dfa798de --- /dev/null +++ b/docs/logos/kiro-dark.svg @@ -0,0 +1,19 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + diff --git a/docs/logos/kiro.svg b/docs/logos/kiro.svg new file mode 100644 index 000000000..5fe3cf684 --- /dev/null +++ b/docs/logos/kiro.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/logos/minimax.svg b/docs/logos/minimax.svg new file mode 100644 index 000000000..b1b0a6a2d --- /dev/null +++ b/docs/logos/minimax.svg @@ -0,0 +1 @@ +MiniMax \ No newline at end of file diff --git a/docs/logos/mistral.svg b/docs/logos/mistral.svg new file mode 100644 index 000000000..00ee1b9e5 --- /dev/null +++ b/docs/logos/mistral.svg @@ -0,0 +1 @@ +Mistral AI \ No newline at end of file diff --git a/docs/logos/ollama.svg b/docs/logos/ollama.svg new file mode 100644 index 000000000..432f73e73 --- /dev/null +++ b/docs/logos/ollama.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/docs/logos/opencode-dark.svg b/docs/logos/opencode-dark.svg new file mode 100644 index 000000000..ff40ad792 --- /dev/null +++ b/docs/logos/opencode-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/logos/opencode.svg b/docs/logos/opencode.svg new file mode 100644 index 000000000..e215d428c --- /dev/null +++ b/docs/logos/opencode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/logos/openrouter.svg b/docs/logos/openrouter.svg new file mode 100644 index 000000000..c3ce7aab3 --- /dev/null +++ b/docs/logos/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/docs/logos/perplexity.svg b/docs/logos/perplexity.svg new file mode 100644 index 000000000..42968a82e --- /dev/null +++ b/docs/logos/perplexity.svg @@ -0,0 +1 @@ +Perplexity \ No newline at end of file diff --git a/docs/logos/synthetic-dark.svg b/docs/logos/synthetic-dark.svg new file mode 100644 index 000000000..b50f4da2c --- /dev/null +++ b/docs/logos/synthetic-dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs/logos/synthetic.svg b/docs/logos/synthetic.svg new file mode 100644 index 000000000..87dfcb738 --- /dev/null +++ b/docs/logos/synthetic.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/logos/vertex-ai.svg b/docs/logos/vertex-ai.svg new file mode 100644 index 000000000..1a6a483ab --- /dev/null +++ b/docs/logos/vertex-ai.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/logos/warp.svg b/docs/logos/warp.svg new file mode 100644 index 000000000..8b12c8341 --- /dev/null +++ b/docs/logos/warp.svg @@ -0,0 +1 @@ +Warp \ No newline at end of file diff --git a/docs/logos/zai-dark.svg b/docs/logos/zai-dark.svg new file mode 100644 index 000000000..902655208 --- /dev/null +++ b/docs/logos/zai-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/logos/zai.svg b/docs/logos/zai.svg new file mode 100644 index 000000000..4f511bd72 --- /dev/null +++ b/docs/logos/zai.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/manus.md b/docs/manus.md new file mode 100644 index 000000000..7ee919846 --- /dev/null +++ b/docs/manus.md @@ -0,0 +1,73 @@ +--- +summary: "Manus provider: browser session_id cookie auth for credit balance, monthly credits, and daily refresh tracking." +read_when: + - Adding or modifying the Manus provider + - Debugging Manus cookie imports or API responses + - Adjusting Manus usage display or credit formatting +--- + +# Manus Provider + +The Manus provider tracks credit usage on [manus.im](https://manus.im) via browser `session_id` cookie authentication. + +## Features + +- **Monthly credit gauge**: Shows Pro monthly credits used vs. plan total (`proMonthlyCredits` − `periodicCredits`). +- **Daily refresh gauge**: Shows daily refresh credits used vs. max refresh allotment, with reset timing. +- **Balance display**: Total credits available, shown in the menu identity line. +- **Cookie auth**: Automatic browser cookie import (Safari, Chrome, Firefox) or manual cookie header. +- **Env var support**: `MANUS_SESSION_TOKEN` (raw token) or `MANUS_COOKIE` (full cookie header) for CLI/headless usage. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Manus** +3. Log in to [manus.im](https://manus.im) in your browser +4. Cookie import happens automatically on the next refresh + +### Manual cookie mode + +1. In **Settings → Providers → Manus**, set Cookie source to **Manual** +2. Open your browser DevTools on `manus.im`, copy the `Cookie:` header from any API request (must contain `session_id=...`) +3. Paste the header into the cookie field in CodexBar + +### Environment variables (CLI / headless) + +- `MANUS_SESSION_TOKEN`: the raw `session_id` value. +- `MANUS_COOKIE`: a full cookie header; the provider extracts `session_id` from it. + +Either works; raw-token form is preferred when only one value is needed. + +## How it works + +A single API endpoint is fetched with a bearer token derived from the `session_id` cookie value: + +- `POST https://api.manus.im/user.v1.UserService/GetAvailableCredits` — returns credit fields including `totalCredits`, `freeCredits`, `periodicCredits`, `proMonthlyCredits`, `refreshCredits`, `maxRefreshCredits`, `nextRefreshTime`, and `refreshInterval`. + +Cookie domain: `manus.im`. Valid `session_id` cookies are cached in Keychain and reused until the session expires. + +The response parser tolerates both a direct object and common envelope shapes (`data` / `result` / `response` / `availableCredits`). Payloads missing all expected credit fields are rejected as a parse error rather than surfacing a misleading zero-credit snapshot. + +## Token accounts + +Manus supports multiple accounts via the standard token-account mechanism. Add entries to `~/.codexbar/config.json` (`tokenAccounts`) with the full `Cookie:` header (containing `session_id=...`), then switch between accounts from the menu. + +## CLI + +```bash +codexbar usage --provider manus --verbose +``` + +## Troubleshooting + +### "No Manus session token provided" + +Log in to [manus.im](https://manus.im) in a supported browser (Safari, Chrome, Firefox), then refresh CodexBar. Alternatively, set `MANUS_SESSION_TOKEN` or `MANUS_COOKIE`, or paste a cookie header in manual mode. + +### "Invalid Manus session token" + +Your session has expired or been revoked. Log out and back in to Manus, or paste a fresh `Cookie:` header in manual mode. + +### "Response missing expected credits fields" + +The API returned a 200 response that doesn't look like a credits payload (often an error object). Re-login to Manus; if it persists, the upstream response schema may have changed. diff --git a/docs/mimo.md b/docs/mimo.md new file mode 100644 index 000000000..be6fb0636 --- /dev/null +++ b/docs/mimo.md @@ -0,0 +1,55 @@ +--- +summary: "Xiaomi MiMo provider notes: cookie auth, balance endpoint, and setup." +read_when: + - Adding or modifying the Xiaomi MiMo provider + - Debugging MiMo cookie import or balance fetching + - Explaining MiMo setup and limitations to users +--- + +# Xiaomi MiMo Provider + +The Xiaomi MiMo provider tracks your current balance from the Xiaomi MiMo console. + +## Features + +- **Balance display**: Shows the current MiMo balance as provider identity text. +- **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. +- **Near-real-time updates**: Balance usually reflects within a few minutes. + +## Setup + +1. Open **Settings → Providers** +2. Enable **Xiaomi MiMo** +3. Leave **Cookie source** on **Auto** (recommended) + +### Manual cookie import (optional) + +1. Open `https://platform.xiaomimimo.com/#/console/balance` +2. Copy a `Cookie:` header from your browser’s Network tab +3. Paste it into **Xiaomi MiMo → Cookie source → Manual** + +## How it works + +- Fetches `GET https://platform.xiaomimimo.com/api/v1/balance` +- Requires the `api-platform_serviceToken` and `userId` cookies +- Accepts optional MiMo cookies like `api-platform_ph` and `api-platform_slh` when present +- Supports `MIMO_API_URL` to override the base API URL for testing + +## Limitations + +- MiMo currently exposes **balance only** +- Token cost, status polling, debug log output, and widgets are not supported yet + +## Troubleshooting + +### “No Xiaomi MiMo browser session found” + +Log in at `https://platform.xiaomimimo.com/#/console/balance` in Chrome, then refresh CodexBar. + +### “Xiaomi MiMo requires the api-platform_serviceToken and userId cookies” + +The pasted header or imported browser session is missing required cookies. Re-copy the request from the balance page after logging in again. + +### “Xiaomi MiMo browser session expired” + +Your MiMo login is stale. Sign out and back in on the MiMo site, then refresh CodexBar. diff --git a/docs/minimax.md b/docs/minimax.md index 6b7b7d647..15bb3ecbc 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -1,5 +1,5 @@ --- -summary: "MiniMax provider data sources: API token or browser cookies + coding plan remains API." +summary: "MiniMax provider data sources: Coding Plan tokens, browser cookies, and web-session parsing." read_when: - Debugging MiniMax usage parsing - Updating MiniMax cookie handling or coding plan scraping @@ -8,49 +8,33 @@ read_when: # MiniMax provider -MiniMax supports API tokens or web sessions. Usage is fetched from the Coding Plan remains API using -either a Bearer API token or a session cookie header. +MiniMax supports Coding Plan API tokens or web sessions. Web-session mode uses MiniMax browser/session state and +falls back across the provider's supported web requests when needed. -## Data sources + fallback order +## Data sources -1) **API token** (preferred) - - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`) or `MINIMAX_API_KEY`. - - When present, MiniMax uses the API token and ignores cookies entirely. +1) **Coding Plan API token** + - Set in Preferences → Providers → MiniMax (stored in `~/.codexbar/config.json`), `MINIMAX_CODING_API_KEY`, + or `MINIMAX_API_KEY`. + - When both environment variables are present, `MINIMAX_CODING_API_KEY` wins so a standard `sk-api-*` key does + not mask a coding-plan `sk-cp-*` key. + - Auto mode can fall back to the web/cookie path when API-token credentials are rejected or the global endpoint + returns 404. -2) **Cached cookie header** (automatic, only when no API token) - - Keychain cache: `com.steipete.codexbar.cache` (account `cookie.minimax`). +2) **Cached/imported browser session** (automatic web path) + - Uses CodexBar's standard cookie cache and browser import flow. 3) **Browser cookie import** (automatic) - - Cookie order from provider metadata (default: Safari → Chrome → Firefox). - - Merges Chromium profile cookies across the primary + Network stores before attempting a request. - - Tries each browser source until the Coding Plan API accepts the cookies. - - Domain filters: `platform.minimax.io`, `minimax.io`. - -4) **Browser local storage access token** (Chromium-based) - - Reads `access_token` (and related tokens) from Chromium local storage (LevelDB) to authorize the remains API. - - If decoding fails, falls back to a text-entry scan for `minimax.io` keys/values and filters for MiniMax JWT claims. - - Used automatically; no UI field. - - Also extracts `GroupId` when present (appends query param). - -5) **Manual session cookie header** (optional override) + - Uses provider metadata for browser order and MiniMax domain filters. + - Chromium browser storage can supplement imported cookies with access-token context when available. + +4) **Manual session cookie header** (optional web-path override) - Stored in `~/.codexbar/config.json` via Preferences → Providers → MiniMax (Cookie source → Manual). - Accepts a raw `Cookie:` header or a full "Copy as cURL" string. - - When a cURL string is pasted, MiniMax extracts the cookie header plus `Authorization: Bearer …` and - `GroupId=…` for the remains API. - - CLI/runtime env: `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`. - -## Endpoints -- API token endpoint: `https://api.minimax.io/v1/coding_plan/remains` - - Requires `Authorization: Bearer `. -- Global host (cookies): `https://platform.minimax.io` -- China mainland host: `https://platform.minimaxi.com` -- `GET {host}/user-center/payment/coding-plan` - - HTML parse for "Available usage" and plan name. -- `GET {host}/v1/api/openplatform/coding_plan/remains` - - Fallback when HTML parsing fails. - - Sent with a `Referer` to the Coding Plan page. - - Adds `Authorization: Bearer ` when available. - - Adds `GroupId` query param when known. + - Low-level no-settings runtime can read `MINIMAX_COOKIE` or `MINIMAX_COOKIE_HEADER`. + +## Requests +- Web sessions use the global host or China mainland host. - Region picker in Providers settings toggles the host; environment overrides: - `MINIMAX_HOST=platform.minimaxi.com` - `MINIMAX_CODING_PLAN_URL=...` (full URL override) @@ -62,25 +46,46 @@ either a Bearer API token or a session cookie header. - Copy the `Cookie` request header (or use “Copy as cURL” and paste the whole line). - Paste into Preferences → Providers → MiniMax only if automatic import fails. -## Notes -- Cookies alone often return status 1004 (“cookie is missing, log in again”); the remains API expects a Bearer token. -- MiniMax stores `access_token` in Chromium local storage (LevelDB). Some entries serialize the storage key without a scheme - (ex: `minimax.io`), so origin matching must account for host-only keys. -- Raw JWT scan fallback remains as a safety net if Chromium key formats change. -- If local storage keys don’t decode (some Chrome builds), the MiniMax-specific text scan avoids a full raw-byte scan. - -## Cookie file paths -- Safari: `~/Library/Cookies/Cookies.binarycookies` -- Chrome/Chromium forks: `~/Library/Application Support/Google/Chrome/*/Cookies` -- Firefox: `~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite` - ## Snapshot mapping -- Primary: percent used from `model_remains` (used/total) or HTML "Available usage". -- Window: derived from `start_time`/`end_time` or HTML duration text. -- Reset: derived from `remains_time` (fallback to `end_time`) or HTML "Resets in …". -- Plan/tier: best-effort from response fields or HTML title. +- Primary usage, reset timing, and plan/tier are derived from Coding Plan response fields or page text. +- Web-session billing history, when available, is mapped into the shared inline usage dashboard: + - 30-day token trend. + - Top model and top method breakdowns. + - Summary rows for recent billing-history totals. + +If the billing-history endpoint is unavailable but normal Coding Plan quota data is present, CodexBar still shows the +quota card and omits the chart instead of treating the whole provider as failed. ## Key files - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift` - `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift` + +## CLI diagnose command + +The generic `diagnose` command performs a real provider diagnostic invocation and emits a safe, redacted JSON export +for issue reporting and verification. MiniMax adds a provider-specific `details` block with safe usage metadata. + +### Usage +``` +codexbar diagnose --provider minimax --format json --pretty +``` + +### Output +- Structural diagnostic JSON with provider, source/source mode, auth summary, usage summary, fetch attempts, and error categories. +- All sensitive fields (API tokens, cookies, emails, auth headers) are redacted via `LogRedactor`. +- Errors are mapped to safe categories (`network`, `auth`, `api`, `parse`) with user-friendly descriptions. +- No raw API responses, raw error messages, tokens, cookies, emails, account IDs, org IDs, or billing history. + +### What is excluded from output +- Raw API tokens (`sk-cp-*`, `sk-api-*`) and authorization headers +- Cookie header values +- Email addresses +- Account IDs, org IDs +- Raw error messages (replaced with safe category-based descriptions) +- Raw HTTP responses or request bodies +- Billing history details + +### Exit codes +- `0`: Diagnostic completed successfully (even if provider auth is not configured) +- `1`: Unknown error or invalid arguments diff --git a/docs/model-pricing.md b/docs/model-pricing.md new file mode 100644 index 000000000..0cec29517 --- /dev/null +++ b/docs/model-pricing.md @@ -0,0 +1,34 @@ +# Model pricing metadata + +CodexBar has an additive models.dev pricing pipeline for future cost lookup work. Existing hardcoded pricing remains unchanged for now. + +## Source and cache + +- Source API: `https://models.dev/api.json` +- No API key is required. +- Local cache: `~/Library/Caches/CodexBar/model-pricing/models-dev-v1.json` +- TTL: 24 hours + +The pipeline lets future scanner code read the last valid cache synchronously with `ModelsDevPricingPipeline.lookup` and refresh stale metadata separately with `ModelsDevPricingPipeline.refreshIfNeeded`. If a refresh fails, the last valid cache remains usable. + +## Lookup rules + +Pricing is scoped by provider id and model id. This prevents two providers with the same model id or display name from sharing pricing accidentally. + +Planned local source mapping: + +- Codex/OpenAI logs: models.dev provider id `openai` +- Claude logs: models.dev provider id `anthropic` +- Vertex AI Claude logs: models.dev provider id `google-vertex-anthropic` + +The first integration PR only adds the parser, client, cache, provider-scoped lookup, and tests. It does not route live cost calculations through models.dev yet. + +## Units + +models.dev publishes costs as USD per 1M tokens. CodexBar converts those to USD per token in the metadata layer: + +```text +perToken = modelsDevCost / 1_000_000 +``` + +When models.dev includes `cost.context_over_200k`, CodexBar parses those values as the above-200k-token pricing lane and converts them with the same per-1M-token rule. diff --git a/docs/moonshot.md b/docs/moonshot.md new file mode 100644 index 000000000..b57ce8a93 --- /dev/null +++ b/docs/moonshot.md @@ -0,0 +1,50 @@ +--- +summary: "Moonshot / Kimi API provider data sources: API key + balance endpoint." +read_when: + - Adding or tweaking Moonshot balance parsing + - Updating Moonshot / Kimi API key handling + - Documenting Moonshot / Kimi API provider behavior +--- + +# Moonshot / Kimi API provider + +Moonshot / Kimi API is API-only. Balance is reported by `GET /v1/users/me/balance`, +so CodexBar only needs a valid API key to show the current account balance. + +## Rationale + +Kimi API docs use the Moonshot API surface for current Kimi models: examples read +`MOONSHOT_API_KEY` and call `https://api.moonshot.ai/v1`, including the Kimi K2.6 +quickstart. This provider is therefore named after the account and billing surface, +not a specific Kimi model version. + +The existing `Kimi K2` provider remains separate because it targets the legacy +`kimi-k2.ai` credit endpoint. Migrating or deprecating that provider should be a +separate cleanup so existing user settings are not silently repointed. + +## Data sources + +1. **API key** stored in `~/.codexbar/config.json` or supplied via `MOONSHOT_API_KEY` / `MOONSHOT_KEY`. + CodexBar stores the key in config after you paste it in Settings → Providers → Moonshot / Kimi API. +2. **Region** + - International: `https://api.moonshot.ai/v1/users/me/balance` + - China mainland: `https://api.moonshot.cn/v1/users/me/balance` + - Configure with Settings → Providers → Moonshot → API region or `MOONSHOT_REGION`. +3. **Balance endpoint** + - Request headers: `Authorization: Bearer `, `Accept: application/json` + - Response contains `available_balance`, `voucher_balance`, and `cash_balance`. + +## Usage details + +- The menu card shows the available balance. +- If `cash_balance` is negative, the card also surfaces the deficit. +- There is no session or weekly window — Moonshot / Kimi API does not expose per-window quota via API. +- Settings config takes precedence over environment variables when both are present. + +## Key files + +- `Sources/CodexBarCore/Providers/Moonshot/MoonshotProviderDescriptor.swift` (descriptor + fetch strategy) +- `Sources/CodexBarCore/Providers/Moonshot/MoonshotUsageFetcher.swift` (HTTP client + JSON parser) +- `Sources/CodexBarCore/Providers/Moonshot/MoonshotSettingsReader.swift` (env var resolution) +- `Sources/CodexBar/Providers/Moonshot/MoonshotProviderImplementation.swift` (settings field + activation logic) +- `Sources/CodexBar/Providers/Moonshot/MoonshotSettingsStore.swift` (SettingsStore extension) diff --git a/docs/ollama.md b/docs/ollama.md index 5be112746..aec630650 100644 --- a/docs/ollama.md +++ b/docs/ollama.md @@ -1,5 +1,5 @@ --- -summary: "Ollama provider notes: settings scrape, cookie auth, and Cloud Usage parsing." +summary: "Ollama provider notes: API key auth, settings scrape, cookie auth, and Cloud Usage parsing." read_when: - Adding or modifying the Ollama provider - Debugging Ollama cookie import or settings parsing @@ -8,20 +8,24 @@ read_when: # Ollama Provider -The Ollama provider scrapes the **Plan & Billing** page to extract Cloud Usage limits for session and weekly windows. +The Ollama provider can verify Ollama Cloud API-key access and scrape the **Plan & Billing** page to extract Cloud +Usage limits for session and weekly windows. ## Features - **Plan badge**: Reads the plan tier (Free/Pro/Max) from the Cloud Usage header. - **Session + weekly usage**: Parses the percent-used values shown in the usage bars. - **Reset timestamps**: Uses the `data-time` attribute on the “Resets in …” elements. -- **Browser cookie auth**: No API keys required. +- **API key auth**: Verifies direct `https://ollama.com/api` access with `OLLAMA_API_KEY` or a configured key. +- **Browser cookie auth**: Required for Cloud Usage quota windows because Ollama does not expose those limits through + the documented API. ## Setup 1. Open **Settings → Providers**. 2. Enable **Ollama**. -3. Leave **Cookie source** on **Auto** (recommended, imports Chrome cookies by default). +3. For API-key mode, paste an API key from `https://ollama.com/settings/keys` or set `OLLAMA_API_KEY`. +4. For quota bars, leave **Cookie source** on **Auto** (recommended, imports Chrome cookies by default). ### Manual cookie import (optional) @@ -31,7 +35,8 @@ The Ollama provider scrapes the **Plan & Billing** page to extract Cloud Usage l ## How it works -- Fetches `https://ollama.com/settings` using browser cookies. +- API-key mode fetches `https://ollama.com/api/tags` with `Authorization: Bearer ` to verify Cloud API access. +- Cookie mode fetches `https://ollama.com/settings` using browser cookies. - Parses: - Plan badge under **Cloud Usage**. - **Session usage** and **Weekly usage** percentages. diff --git a/docs/openai.md b/docs/openai.md new file mode 100644 index 000000000..6db22965d --- /dev/null +++ b/docs/openai.md @@ -0,0 +1,61 @@ +--- +summary: "OpenAI API provider: Admin API key usage/cost graphs and legacy balance fallback." +read_when: + - Updating OpenAI API Platform usage or cost display + - Debugging OPENAI_ADMIN_KEY or OPENAI_API_KEY behavior +--- + +# OpenAI API provider + +CodexBar's OpenAI API provider targets the API Platform organization dashboard, not ChatGPT/Codex subscription limits. + +## Data sources + +1. Preferred: `OPENAI_ADMIN_KEY` or configured key with Admin API access. + - `GET https://api.openai.com/v1/organization/costs` + - `GET https://api.openai.com/v1/organization/usage/completions` + - Daily buckets use `bucket_width=1d`, costs are grouped by `line_item`, and completion usage is grouped by `model`. + - Optional project scoping comes from `OPENAI_PROJECT_ID` or `providers[].workspaceID` for `openai`. + Project-scoped requests add `project_ids=` to both Admin API endpoints. +2. Fallback: legacy `GET https://api.openai.com/v1/dashboard/billing/credit_grants` for normal API keys that cannot access organization usage. + +## Setup + +Store a key in the shared app/CLI config: + +```bash +printf '%s' "$OPENAI_ADMIN_KEY" | codexbar config set-api-key --provider openai --stdin +``` + +Settings → Providers → OpenAI writes the same `~/.codexbar/config.json` field. `OPENAI_ADMIN_KEY` is preferred over +`OPENAI_API_KEY` because it unlocks organization costs and usage; a normal API key only supports the legacy balance +fallback. + +To scope Admin API usage to a project, set the OpenAI Project ID field in Settings or add `workspaceID` to the `openai` +provider config: + +```json +{ + "id": "openai", + "apiKey": "", + "workspaceID": "proj_..." +} +``` + +Project scoping is tied to the configured Admin API key. Selected OpenAI token accounts intentionally scrub +`OPENAI_PROJECT_ID`/`workspaceID` so one account cannot inherit another account's project filter. Project-scoped Admin +API failures do not fall back to the legacy billing endpoint, because that endpoint is not project-filtered. + +## Menu display + +- Admin API data renders inline Today/7d/configured-window KPIs plus a compact spend chart. +- The inline usage card opens a hosted chart submenu with daily spend, token, and request trends plus selected-day detail. +- Top model and top spend labels come from the configured completion/cost buckets when the Admin API returns them. +- Legacy balance data keeps the older available/used credit summary and does not show organization graphs. +- Project-scoped Admin API data labels the account as `Admin API: ` and the organization line as + `Project: `. + +## Notes + +- Costs are the source of truth for financial totals. Token usage and cost buckets can differ slightly from dashboard billing reconciliation. +- Admin API keys are organization-scoped and cannot be used for normal model inference. diff --git a/docs/openrouter.md b/docs/openrouter.md index a0d7985e3..38a023c18 100644 --- a/docs/openrouter.md +++ b/docs/openrouter.md @@ -1,3 +1,11 @@ +--- +summary: "OpenRouter provider: API key credits, rate limits, and daily/weekly/monthly spend." +read_when: + - Debugging OpenRouter API key usage or spend parsing + - Updating OpenRouter credits or key-limit display + - Explaining OpenRouter setup and environment variables +--- + # OpenRouter Provider [OpenRouter](https://openrouter.ai) is a unified API that provides access to multiple AI models from different providers (OpenAI, Anthropic, Google, Meta, and more) through a single endpoint. @@ -18,19 +26,27 @@ export OPENROUTER_API_KEY="sk-or-v1-..." You can also configure the API key in CodexBar Settings → Providers → OpenRouter. +### CLI config + +```bash +printf '%s' "$OPENROUTER_API_KEY" | codexbar config set-api-key --provider openrouter --stdin +``` + ## Data Source The OpenRouter provider fetches usage data from two API endpoints: 1. **Credits API** (`/api/v1/credits`): Returns total credits purchased and total usage. The balance is calculated as `total_credits - total_usage`. -2. **Key API** (`/api/v1/key`): Returns rate limit information for your API key. +2. **Key API** (`/api/v1/key`): Returns rate limit information plus current daily, weekly, and monthly spend for your API key. ## Display The OpenRouter menu card shows: -- **Primary meter**: Credit usage percentage (how much of your purchased credits have been used) +- **Primary meter**: API key limit usage when the key has a configured limit +- **Spend notes**: Daily, weekly, and monthly API key spend when OpenRouter returns those fields +- **Spend chart**: Day/week/month spend can reuse the shared inline dashboard when enough history is available - **Balance**: Displayed in the identity section as "Balance: $X.XX" ## CLI Usage diff --git a/docs/packaging.md b/docs/packaging.md index ec058fcda..16e998152 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -11,11 +11,11 @@ read_when: - `Scripts/package_app.sh`: builds host arch by default; set `ARCHES="arm64 x86_64"` for universal. Verifies slices. - `Scripts/compile_and_run.sh`: uses host arch; pass `--release-universal` or `--release-arches="arm64 x86_64"` for release packaging. - `Scripts/sign-and-notarize.sh`: signs, notarizes, staples, zips (accepts `ARCHES` for universal). -- `Scripts/make_appcast.sh`: generates Sparkle appcast and embeds HTML release notes. +- `Scripts/make_appcast.sh`: wrapper around the shared `mac-release make-appcast` helper; app metadata comes from `.mac-release.env`. - `Scripts/changelog-to-html.sh`: converts the per-version changelog section to HTML for Sparkle. ## Bundle contents -- `CodexBarWidget.appex` bundled with app-group entitlements. +- `CodexBarWidget.appex` is built by `WidgetExtension/CodexBarWidgetExtension.xcodeproj` as a real macOS app extension, then bundled with app-group entitlements. - `CodexBarCLI` copied to `CodexBar.app/Contents/Helpers/` for symlinking. - SwiftPM resource bundles (e.g. `KeyboardShortcuts_KeyboardShortcuts.bundle`) copied into `Contents/Resources` (required for `KeyboardShortcuts.Recorder`). diff --git a/docs/providers.md b/docs/providers.md index 99da2542d..18f523522 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,5 +1,5 @@ --- -summary: "Provider data sources and parsing overview (Codex, Claude, Gemini, Antigravity, Cursor, Droid/Factory, z.ai, Copilot, Kimi, Kilo, Kimi K2, Kiro, Warp, Vertex AI, Augment, Amp, Ollama, JetBrains AI, OpenRouter)." +summary: "Provider data sources and parsing overview for every registered CodexBar provider." read_when: - Adding or modifying provider fetch/parsing - Adjusting provider labels, toggles, or metadata @@ -8,68 +8,120 @@ read_when: # Providers +CodexBar currently registers 48 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +OpenCode vs OpenCode Go, because the auth source and quota shape differ. + ## Fetch strategies (current) -Legend: web (browser cookies/WebView), cli (RPC/PTy), oauth (API), api token, local probe, web dashboard. -Source labels (CLI/header): `openai-web`, `web`, `oauth`, `api`, `local`, plus provider-specific CLI labels (e.g. `codex-cli`, `claude`). +Legend: web (browser cookies/WebView), cli (RPC/PTy or provider CLI), oauth (provider OAuth), api token, local probe, web dashboard. +Source labels (CLI/header): `openai-web`, `web`, `oauth`, `api`, `local`, `cli`, plus provider-specific CLI labels (e.g. `codex-cli`, `claude`). Cookie-based providers expose a Cookie source picker (Automatic or Manual) in Settings → Providers. -Browser cookie imports are cached in Keychain (`com.steipete.codexbar.cache`, account `cookie.`) and reused -until the session is invalid, to avoid repeated Keychain prompts. +Some browser cookie imports are cached in Keychain and reused until the session is invalid. API keys, manual cookie +headers, source selection, provider ordering, and token accounts are stored in `~/.codexbar/config.json`. | Provider | Strategies (ordered for auto) | | --- | --- | -| Codex | Web dashboard (`openai-web`) → CLI RPC/PTy (`codex-cli`); app uses CLI usage + optional dashboard scrape. | -| Claude | App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). CLI Auto: Web API (`web`) → CLI PTY (`claude`). | -| Gemini | OAuth API via Gemini CLI credentials (`api`). | +| Codex | App Auto: OAuth API (`oauth`) → CLI RPC/PTy (`codex-cli`). CLI Auto: Web dashboard (`openai-web`) → CLI RPC/PTy (`codex-cli`). | +| OpenAI | Admin API key (`api`) for organization spend/usage; legacy API-key balance fallback. | +| Azure OpenAI | API key + endpoint + deployment probe (`api`) for deployment status validation. | +| Claude | Admin API key (`api`) when configured; otherwise App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). CLI Auto: Web API (`web`) → CLI PTY (`claude`). | +| Gemini | OAuth-backed API via Gemini CLI credentials (`api`). | | Antigravity | Local LSP/HTTP probe (`local`). | | Cursor | Web API via cookies → stored WebKit session (`web`). | | OpenCode | Web dashboard via cookies (`web`). | +| OpenCode Go | Web dashboard via cookies (`web`); optional workspace ID. | +| Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). | +| Alibaba Token Plan | Bailian subscription summary API via browser or manual cookies (`web`). | | Droid/Factory | Web cookies → stored tokens → local storage → WorkOS cookies (`web`). | -| z.ai | API token (Keychain/env) → quota API (`api`). | -| MiniMax | Manual cookie header (Keychain/env) → browser cookies (+ local storage access token) → coding plan page (HTML) with remains API fallback (`web`). | -| Kimi | API token (JWT from `kimi-auth` cookie) → usage API (`api`). | -| Kilo | API token (`KILO_API_KEY`) → usage API (`api`); auto falls back to CLI session auth (`cli`). | -| Copilot | API token (device flow/env) → copilot_internal API (`api`). | -| Kimi K2 | API key (Keychain/env) → credit endpoint (`api`). | +| z.ai | API token from config/env → quota API (`api`). | +| Manus | Browser `session_id` cookie (auto/manual/env) → credits API (`web`). | +| MiniMax | Manual/browser session via Coding Plan web path (`web`), or Coding Plan API token (`api`). | +| Kimi | Auth token from `kimi-auth` cookie/manual token/env → usage API (`web`). | +| Kilo | API token from config/env → usage API (`api`); auto falls back to CLI session auth (`cli`). | +| Copilot | Device-flow/env/config token → `copilot_internal` API (`api`). | +| Kimi K2 (unofficial) | API key from config/env → legacy credit endpoint (`api`). | | Kiro | CLI command via `kiro-cli chat --no-interactive "/usage"` (`cli`). | | Vertex AI | Google ADC OAuth (gcloud) → Cloud Monitoring quota usage (`oauth`). | +| Augment | `auggie` CLI first, then browser-cookie web fallback (`cli`, `web`). | | JetBrains AI | Local XML quota file (`local`). | | Amp | Web settings page via browser cookies (`web`). | +| T3 Chat | Web tRPC customer-data endpoint via browser cookies (`web`). | | Warp | API token (config/env) → GraphQL request limits (`api`). | -| Ollama | Web settings page via browser cookies (`web`). | +| ElevenLabs | API key from config/env → subscription usage API (`api`). | +| Windsurf | Web session bundle from browser localStorage (`web`) → local SQLite cache (`local`). | +| Ollama | API key verifies Cloud API access (`api`); browser cookies expose Cloud quota windows (`web`). | +| Synthetic | API key from config/env → quota API (`api`). | | OpenRouter | API token (config, overrides env) → credits API (`api`). | +| Perplexity | Browser cookies/manual cookie/env session token → credits API (`web`). | +| Xiaomi MiMo | Browser cookies → balance/token plan endpoints (`web`). | +| Doubao | API key from config/env → Volcengine Ark chat-completions probe (`api`). | +| Abacus AI | Browser cookies → compute points + billing API (`web`). | +| Mistral | Console billing API via Ory Kratos session cookies (`web`). | +| DeepSeek | API key from env or token accounts → balance endpoint (`api`). | +| Moonshot | API key from config/env → balance endpoint (`api`). | +| Codebuff | API token from config/env or `codebuff login` credentials → usage API (`api`). | +| Crof | API key from config/env → credit balance + requests quota API (`api`). | +| Venice | API key from config/env → DIEM/USD balance API (`api`). | +| Command Code | Web billing API via Command Code session cookies (`web`). | +| StepFun | Username/password login or manual Oasis token (`web`). | +| AWS Bedrock | AWS credentials → Cost Explorer usage and budget tracking (`api`). | +| Grok | `grok agent stdio` JSON-RPC `x.ai/billing` (`cli`) → grok.com billing gRPC-web via Chrome session cookies (`web`); local `~/.grok/sessions` signals as fallback. | +| GroqCloud | API key → Prometheus metrics API for request/token/cache-hit rates (`api`). | +| LLM Proxy | API key + base URL → `/v1/quota-stats` aggregate proxy usage (`api`). | +| Deepgram | API key → project discovery and usage breakdown API (`api`). | ## Codex -- Web dashboard (when enabled): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. +- App Auto: OAuth API first; falls back to CLI only when OAuth credentials are missing or auth/refresh is invalid. +- Web dashboard (optional, off by default): `https://chatgpt.com/codex/settings/usage` via WebView + browser cookies. +- Battery saver toggle (currently off by default): reduces routine OpenAI web refreshes but still allows explicit manual refreshes. - CLI RPC default: `codex ... app-server` JSON-RPC (`account/read`, `account/rateLimits/read`). -- CLI PTY fallback: `/status` scrape. -- Local cost usage: scans `~/.codex/sessions/**/*.jsonl` (last 30 days). +- CLI PTY: manual diagnostics/parser coverage only; automatic refresh does not launch bare Codex TUI. +- Local cost usage: scans `CODEX_HOME` (or `~/.codex`) `sessions` and sibling `archived_sessions` JSONL files for the configured history window. - Status: Statuspage.io (OpenAI). - Details: `docs/codex.md`. +## OpenAI +- API key from `~/.codexbar/config.json`, `OPENAI_ADMIN_KEY`, or `OPENAI_API_KEY`. +- Admin API keys are preferred and fetch organization costs plus completion usage for inline Today/7d/configured-window dashboards. +- Normal API keys fall back to the legacy credit-grants balance endpoint when organization usage is unavailable. +- Details: `docs/openai.md`. + +## Azure OpenAI +- API key, endpoint, and deployment from `~/.codexbar/config.json` or `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, and `AZURE_OPENAI_DEPLOYMENT_NAME`. +- Validates the configured deployment with a minimal chat-completions request; it does not expose Azure spend or quota history. +- Use `AZURE_OPENAI_API_VERSION` to override the API version. Set it to `v1` for Azure's OpenAI-compatible v1 API path. +- Status: Azure status page link. + ## Claude +- Admin API: `sk-ant-admin...` key in Settings/config, token accounts, or `ANTHROPIC_ADMIN_KEY`. +- Admin API shows organization spend/messages summaries with the same inline dashboard pattern as OpenAI API. - App Auto: OAuth API (`oauth`) → CLI PTY (`claude`) → Web API (`web`). - CLI Auto: Web API (`web`) → CLI PTY (`claude`). -- Local cost usage: scans `~/.config/claude/projects/**/*.jsonl` (last 30 days). +- Local cost usage: scans `CLAUDE_CONFIG_DIR` when set, otherwise `~/.config/claude/projects` and `~/.claude/projects` JSONL files for the configured history window. - Status: Statuspage.io (Anthropic). - Details: `docs/claude.md`. ## z.ai -- API token from Keychain or `Z_AI_API_KEY` env var. -- Quota endpoint: `https://api.z.ai/api/monitor/usage/quota/limit` (global) or `https://open.bigmodel.cn/api/monitor/usage/quota/limit` (BigModel CN); override with `Z_AI_API_HOST` or `Z_AI_QUOTA_URL`. +- API token from `~/.codexbar/config.json` (`providers[].apiKey`) or `Z_AI_API_KEY` env var. +- Supports global and BigModel CN quota hosts; override with `Z_AI_API_HOST` or `Z_AI_QUOTA_URL`. - Status: none yet. - Details: `docs/zai.md`. +## Manus +- Session token via browser `session_id` cookie, manual Settings entry, `MANUS_SESSION_TOKEN`, or `MANUS_COOKIE`. +- Credits endpoint: `POST https://api.manus.im/user.v1.UserService/GetAvailableCredits`. +- Auto mode prefers cached/browser cookies before env fallback; manual mode accepts either a bare `session_id` value or a full Cookie header. +- Status: none yet. + ## MiniMax -- Session cookie header from Keychain or `MINIMAX_COOKIE`/`MINIMAX_COOKIE_HEADER` env var. -- Hosts: `platform.minimax.io` (global) or `platform.minimaxi.com` (China mainland) via region picker or `MINIMAX_HOST`; full overrides via `MINIMAX_CODING_PLAN_URL` / `MINIMAX_REMAINS_URL`. -- `GET {host}/v1/api/openplatform/coding_plan/remains`. +- Coding Plan API token or web session from configured/manual/browser sources. +- Supports global and China mainland hosts via provider region settings and environment overrides. +- Web-session billing history can render 30-day token charts plus top model/method breakdowns when MiniMax exposes it. - Status: none yet. - Details: `docs/minimax.md`. ## Kimi - Auth token (JWT from `kimi-auth` cookie) via manual entry or `KIMI_AUTH_TOKEN` env var. -- `POST https://www.kimi.com/apiv2/kimi.gateway.billing.v1.BillingService/GetUsages`. - Shows weekly quota and 5-hour rate limit (300 minutes). - Status: none yet. - Details: `docs/kimi.md`. @@ -78,14 +130,13 @@ until the session is invalid, to avoid repeated Keychain prompts. - API token from `~/.codexbar/config.json` (`providers[].apiKey`) or `KILO_API_KEY`. - Auto mode tries API first and falls back to CLI auth when API credentials are missing or unauthorized. - CLI auth source: `~/.local/share/kilo/auth.json` (`kilo.access`), typically created by `kilo login`. -- Usage endpoint: `https://app.kilo.ai/api/trpc`. - Status: none yet. - Details: `docs/kilo.md`. -## Kimi K2 -- API key via Settings (Keychain) or `KIMI_K2_API_KEY`/`KIMI_API_KEY` env var. -- `GET https://kimi-k2.ai/api/user/credits`. -- Shows credit usage based on consumed/remaining totals. +## Kimi K2 (unofficial) +- API key via `~/.codexbar/config.json` or `KIMI_K2_API_KEY`/`KIMI_API_KEY` env var. +- Shows credit usage from the legacy `kimi-k2.ai` consumed/remaining totals. +- Use Moonshot / Kimi API for the official Kimi API account and billing surface. - Status: none yet. - Details: `docs/kimi-k2.md`. @@ -110,10 +161,33 @@ until the session is invalid, to avoid repeated Keychain prompts. ## OpenCode - Web dashboard via browser cookies (`opencode.ai`). -- `POST https://opencode.ai/_server` (workspaces + subscription usage). - Status: none yet. - Details: `docs/opencode.md`. +## OpenCode Go +- Web dashboard via browser cookies (`opencode.ai`). +- Uses the workspace Go page/server data for rolling 5-hour, weekly, and optional monthly usage windows. +- Optional workspace ID comes from `~/.codexbar/config.json` (`providers[].workspaceID`) or `CODEXBAR_OPENCODEGO_WORKSPACE_ID`. +- Status: none yet. +- Details: `docs/opencode.md`. + +## Alibaba Coding Plan +- Web mode uses Alibaba console RPC with form payload + `sec_token`. +- Cookie sources: browser import (`auto`) or manual header (`cookieSource: manual`). +- API key fallback from Settings (`providers[].apiKey`) or `ALIBABA_CODING_PLAN_API_KEY` env var. +- Region hosts: international (`ap-southeast-1`) and China mainland (`cn-beijing`). +- Host overrides: `ALIBABA_CODING_PLAN_HOST` or `ALIBABA_CODING_PLAN_QUOTA_URL`. +- Status: `https://status.aliyun.com` (link only, no auto-polling). +- Details: `docs/alibaba-coding-plan.md`. + +## Alibaba Token Plan +- Web mode posts to the Bailian `GetSubscriptionSummary` endpoint with form-encoded params and optional `sec_token`. +- Cookie sources: browser import (`auto`), manual Cookie header, or `ALIBABA_TOKEN_PLAN_COOKIE`. +- Default quota URL: `https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3`. +- Host overrides: `ALIBABA_TOKEN_PLAN_HOST` or `ALIBABA_TOKEN_PLAN_QUOTA_URL`. +- Status: `https://status.aliyun.com` (link only, no auto-polling). +- Details: `docs/alibaba-token-plan.md`. + ## Droid (Factory) - Web API via Factory cookies, bearer tokens, and WorkOS refresh tokens. - Multiple fallback strategies (cookies → stored tokens → local storage → WorkOS cookies). @@ -122,6 +196,7 @@ until the session is invalid, to avoid repeated Keychain prompts. ## Copilot - GitHub device flow OAuth token + `api.github.com/copilot_internal/user`. +- Supports multiple token accounts and account switching from provider settings/menu surfaces. - Status: Statuspage.io (GitHub). - Details: `docs/copilot.md`. @@ -134,17 +209,25 @@ until the session is invalid, to avoid repeated Keychain prompts. ## Warp - API token from Settings or `WARP_API_KEY` / `WARP_TOKEN` env var. -- GraphQL credit limits: `https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo`. - Shows monthly credits usage and next refresh time. - Status: none yet. - Details: `docs/warp.md`. +## ElevenLabs +- API key from Settings, token accounts, `ELEVENLABS_API_KEY`, or `XI_API_KEY`. +- Reads `GET /v1/user/subscription` from `api.elevenlabs.io`. +- Shows character credit usage, reset timing, and voice slot usage when available. +- Override the API base URL with `ELEVENLABS_API_URL`. +- Status: `https://status.elevenlabs.io` (link only, no auto-polling). +- Details: `docs/elevenlabs.md`. + ## Vertex AI - OAuth credentials from `gcloud auth application-default login` (ADC). - Quota usage via Cloud Monitoring `consumer_quota` metrics for `aiplatform.googleapis.com`. -- Token cost: scans `~/.claude/projects/` logs filtered to Vertex AI-tagged entries. +- Token cost: uses the Claude local-log scanner filtered to Vertex AI-tagged entries. - Requires Cloud Monitoring API access in the current project. - Details: `docs/vertexai.md`. + ## JetBrains AI - Local XML quota file from IDE configuration directory. - Auto-detects installed JetBrains IDEs; uses most recently used. @@ -152,24 +235,147 @@ until the session is invalid, to avoid repeated Keychain prompts. - Status: none (no status page). - Details: `docs/jetbrains.md`. +## Augment +- Auto mode tries the `auggie` CLI first. +- Web fallback uses browser cookies, with manual cookie header support. +- Tracks credit usage and account/subscription data where available. +- Status: none yet. +- Details: `docs/augment.md`. + ## Amp - Web settings page (`https://ampcode.com/settings`) via browser cookies. - Parses Amp Free usage from the settings HTML. - Status: none yet. - Details: `docs/amp.md`. +## T3 Chat +- Web tRPC endpoint (`https://t3.chat/api/trpc/getCustomerData`) via browser cookies. +- Parses JSONL response lines and extracts customer data from the embedded tRPC payload. +- Shows the 4-hour Base bucket and monthly Overage bucket documented in the T3 Chat FAQ. +- Status: none yet. + ## Ollama - Web settings page (`https://ollama.com/settings`) via browser cookies. - Parses Cloud Usage plan badge, session/weekly usage, and reset timestamps. - Status: none yet. - Details: `docs/ollama.md`. +## Synthetic +- API key from `~/.codexbar/config.json` (`providers[].apiKey`) or `SYNTHETIC_API_KEY`. +- Shows rolling five-hour, weekly token, search-hourly, and cost/credit quota lanes when present. +- Status: none yet. + ## OpenRouter -- API token from `~/.codexbar/config.json` (`providerConfig.openrouter.apiKey`) or `OPENROUTER_API_KEY` env var. -- Credits endpoint: `https://openrouter.ai/api/v1/credits` (returns total credits purchased and usage). -- Key info endpoint: `https://openrouter.ai/api/v1/key` (returns rate limit info). +- API token from `~/.codexbar/config.json` (`providers[].apiKey`) or `OPENROUTER_API_KEY` env var. +- Reads credits and key rate-limit info from OpenRouter APIs. +- Shows daily, weekly, and monthly API-key spend when `/api/v1/key` returns those fields. - Override base URL with `OPENROUTER_API_URL` env var. - Status: `https://status.openrouter.ai` (link only, no auto-polling yet). - Details: `docs/openrouter.md`. +## Perplexity +- Browser session cookie from automatic import, manual header/token, or `PERPLEXITY_SESSION_TOKEN` / `PERPLEXITY_COOKIE`. +- Tracks recurring credits, bonus/promotional credits, purchased credits, and renewal date when present. +- Status: `https://status.perplexity.com/` (link only, no auto-polling). + +## Xiaomi MiMo +- Browser cookies from automatic import or manual `Cookie:` header. +- Reads balance and token-plan usage from `platform.xiaomimimo.com`. +- Status: none yet. +- Details: `docs/mimo.md`. + +## Doubao +- API key via `ARK_API_KEY`, `VOLCENGINE_API_KEY`, `DOUBAO_API_KEY`, or provider config. +- Probes Volcengine Ark chat completions and reads request rate-limit headers when present. +- Status: none yet. +- Details: `docs/doubao.md`. + +## Abacus AI +- Browser cookies (`abacus.ai`, `apps.abacus.ai`) via automatic import or manual header. +- Reads organization compute points and billing data. +- Shows monthly credit gauge with pace tick and reserve/deficit estimate. +- Status: none yet. +- Details: `docs/abacus.md`. + +## Mistral +- Session cookie (`ory_session_*`) from browser auto-import or manual `Cookie:` header. +- CSRF token (`csrftoken` cookie) sent as `X-CSRFTOKEN` header. +- Domain: `admin.mistral.ai`. +- Reads monthly usage and pricing from the Mistral billing API. +- Cost is computed client-side from token counts and response pricing. +- Resets at end of calendar month. +- Status: `https://status.mistral.ai` (link only, no auto-polling). + +## DeepSeek +- API key via `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY` env var or DeepSeek token accounts. +- Shows total balance with paid vs. granted breakdown; USD preferred when multiple currencies present. +- Status: `https://status.deepseek.com` (link only, no auto-polling). +- Details: `docs/deepseek.md`. + +## Moonshot / Kimi API +- API key via `MOONSHOT_API_KEY` / `MOONSHOT_KEY` env var or provider config. +- Reads `GET /v1/users/me/balance` from the selected Moonshot region. +- Region: international (`api.moonshot.ai`) or China mainland (`api.moonshot.cn`), configurable in Settings or `MOONSHOT_REGION`. +- Shows available balance; negative cash balance is surfaced as a deficit. +- Status: none yet. +- Details: `docs/moonshot.md`. + +## Venice +- API key via `VENICE_API_KEY` / `VENICE_KEY` env var or Venice token accounts. +- Shows current DIEM or USD balance; DIEM epoch allocation progress when available. +- Status: none yet. +- Details: `docs/venice.md`. + +## Codebuff +- API token from `~/.codexbar/config.json`, `CODEBUFF_API_KEY`, or `~/.config/manicode/credentials.json` created by `codebuff login`. +- Reads usage and subscription data from Codebuff APIs. +- Shows credit balance, weekly rate limit, reset timing, subscription status, and auto-top-up flag when present. +- Override base URL with `CODEBUFF_API_URL`. +- Status: none yet. +- Details: `docs/codebuff.md`. + +## Crof +- API key from `~/.codexbar/config.json`, `CROF_API_KEY`, or `CROFAI_API_KEY`. +- Reads `credits`, `requests_plan`, and `usable_requests` from `GET https://crof.ai/usage_api/`. +- Shows request quota as the primary usage window and dollar credits as the secondary row. +- Infers the daily request reset from midnight America/Chicago until the usage API exposes reset metadata. +- Status: none yet. +- Details: `docs/crof.md`. + +## Command Code +- Browser session cookies from automatic import or manual `Cookie:` header. +- Reads monthly USD credits and billing-cycle usage from `api.commandcode.ai`. +- Automatic import looks for better-auth session cookies from `commandcode.ai` / `www.commandcode.ai`. +- Status: none yet. +- Details: `docs/command-code.md`. + +## Grok +- `grok agent stdio` (ACP) JSON-RPC `x.ai/billing` method; requires `grok login` (SuperGrok OAuth/OIDC). +- Reads cached credentials from `~/.grok/auth.json` for identity (email, team). +- Falls back to grok.com's billing gRPC-web endpoint via Chrome session cookies when the CLI does not expose billing. +- CLI/test runs do not import browser cookies unless `CODEXBAR_ALLOW_BROWSER_COOKIE_IMPORT=1` is set. +- Local fallback aggregates `~/.grok/sessions/**/signals.json` token counts when the RPC is unavailable. +- Status: link only to `https://status.x.ai` (no auto-polling yet). +- Details: `docs/grok.md`. + +## AWS Bedrock +- AWS credentials from `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and optional `AWS_SESSION_TOKEN`. +- Region from `AWS_REGION` / `AWS_DEFAULT_REGION`, defaulting to `us-east-1`. +- Reads AWS Cost Explorer for Bedrock spend and can compare usage against `CODEXBAR_BEDROCK_BUDGET`. +- Override Cost Explorer base URL with `CODEXBAR_BEDROCK_API_URL` for tests. +- Details: `docs/bedrock.md`. + +## Deepgram +- API key from config or `DEEPGRAM_API_KEY`. +- Optional project ID from provider settings or `DEEPGRAM_PROJECT_ID`; otherwise aggregates all visible projects. +- Reads Deepgram usage breakdowns for audio hours, agent hours, token totals, TTS characters, and requests. +- Details: `docs/deepgram.md`. + +## StepFun +- Username/password login or manual Oasis-Token. +- Reads Step Plan 5-hour and weekly rate-limit windows from `platform.stepfun.com`. +- Shows subscription plan name when the Step Plan status API returns one. +- Status: none yet. +- Details: `docs/stepfun.md`. + See also: `docs/provider.md` for architecture notes. diff --git a/docs/refactor/claude-current-baseline.md b/docs/refactor/claude-current-baseline.md new file mode 100644 index 000000000..bedeca386 --- /dev/null +++ b/docs/refactor/claude-current-baseline.md @@ -0,0 +1,155 @@ +--- +summary: "Current Claude behavior baseline before vNext refactor work." +read_when: + - Planning Claude refactor tickets + - Changing Claude runtime/source selection + - Changing Claude OAuth prompt or cooldown behavior + - Changing Claude token-account routing +--- + +# Claude current baseline + +This document is the current-state parity reference for Claude behavior in CodexBar. + +Use it when later tickets need to preserve or intentionally change Claude behavior. When the refactor plan, +summary docs, and running code disagree, treat current code plus characterization coverage as authoritative, and use +this document as the human-readable summary of that current state. + +## Scope of this baseline + +This baseline captures the current behavior surface that later refactor work must preserve unless a future ticket +changes it intentionally: + +- runtime/source-mode selection, +- prompt and cooldown behavior that affects Claude OAuth repair flows, +- token-account routing at the app and CLI edges, +- provider siloing and web-enrichment rules, +- the current relationship between the public Claude doc and the vNext refactor plan. + +## Active behavior owners + +Current Claude behavior is defined by several active owners, not one central planner: + +- `Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift` + owns the main provider-pipeline strategy order and fallback rules. +- `Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift` + still owns a separate direct `.auto` path, delegated refresh, prompt/cooldown handling, and web-extra enrichment. +- `Sources/CodexBar/Providers/Claude/ClaudeSettingsStore.swift` + owns app-side token-account routing into cookie or OAuth behavior. +- `Sources/CodexBarCLI/TokenAccountCLI.swift` + owns CLI-side token-account routing and effective source-mode overrides. +- `Sources/CodexBarCore/TokenAccountSupport.swift` + owns the current string heuristics that distinguish Claude OAuth access tokens from cookie/session-key inputs. + +## Current runtime and source-mode behavior + +### Main provider pipeline + +The generic provider pipeline currently resolves Claude strategies in this order: + +| Runtime | Selected mode | Ordered strategies | Fallback behavior | +| --- | --- | --- | --- | +| app | auto | `oauth -> cli -> web` | OAuth can fall through to CLI/Web. CLI can fall through to Web only when Web is available. Web is terminal. | +| app | oauth | `oauth` | No fallback. | +| app | cli | `cli` | No fallback. | +| app | web | `web` | No fallback. | +| cli | auto | `web -> cli` | Web can fall through to CLI. CLI is terminal. | +| cli | oauth | `oauth` | No fallback. | +| cli | cli | `cli` | No fallback. | +| cli | web | `web` | No fallback. | + +This behavior is owned by `Sources/CodexBarCore/Providers/Claude/ClaudeProviderDescriptor.swift` +through `ProviderFetchPlan` and `ProviderFetchPipeline`. + +### Other active `.auto` decision sites + +The codebase still contains multiple active `.auto` decision sites: + +| Owner | Current behavior | +| --- | --- | +| `ClaudeProviderDescriptor.resolveUsageStrategy(...)` | Chooses `oauth`, then `cli`, then `web`, with final `cli` fallback when none are available. | +| `ClaudeUsageFetcher.loadLatestUsage(.auto)` | Chooses `oauth`, then `web`, then `cli`, with final `oauth` fallback. | + +This inconsistency is intentional to record here. RAT-107 directly characterizes the active direct-fetcher branches it +can reach cleanly in tests and records the remaining current-state behavior without reconciling it. + +## Prompt and cooldown baseline + +Current behavior that later refactor work must preserve: + +- The default Claude keychain prompt mode is `onlyOnUserAction`. +- Prompt policy is only applicable when the Claude OAuth read strategy is `securityFramework`. +- User-initiated interaction clears a prior Claude keychain cooldown denial before retrying availability or repair. +- Startup bootstrap prompting is allowed only when all of these are true: + - runtime is app, + - interaction is background, + - refresh phase is startup, + - prompt mode is `onlyOnUserAction`, + - no cached Claude credentials exist. +- Background delegated refresh is blocked when prompt policy is `onlyOnUserAction` and the caller did not explicitly + allow background delegated refresh. +- Prompt mode `never` blocks delegated refresh attempts. +- Expired credential owner behavior remains owner-specific: + - `.claudeCLI`: delegated refresh path, + - `.codexbar`: direct refresh path, + - `.environment`: no auto-refresh. + +## Token-account routing baseline + +Accepted Claude token-account input shapes today: + +- raw OAuth access token with `sk-ant-oat...` prefix, +- `Bearer sk-ant-oat...` input, +- raw session key, +- full cookie header. + +Current routing rules: + +- OAuth-token-shaped inputs are not treated as cookies. +- Cookie/header-shaped inputs are any value that already contains `Cookie:` or `=`. +- App-side Claude snapshot behavior: + - OAuth token account keeps the usage source setting as-is, disables cookie mode (`.off`), clears the manual cookie + header, and relies on environment-token injection. + - Session-key or cookie-header account keeps the usage source setting as-is, forces manual cookie mode, and + normalizes raw session keys into `sessionKey=`. +- CLI-side Claude token-account behavior: + - OAuth token account changes the effective source mode from `auto` to `oauth`, disables cookie mode, omits a + manual cookie header, and injects `CODEXBAR_CLAUDE_OAUTH_TOKEN`. + - Session-key or cookie-header account stays in cookie/manual mode. + +## Siloing and web-enrichment baseline + +Claude Web enrichment is cost-only when the primary source is OAuth or CLI: + +- Web extras may populate `providerCost` when it is missing. +- Web extras must not replace `accountEmail`, `accountOrganization`, or `loginMethod` from the primary source. +- Snapshot identity remains provider-scoped to Claude. + +This behavior is implemented in `Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift` +inside `applyWebExtrasIfNeeded`. + +## Documentation contract + +- [docs/claude.md](../claude.md) is the summary doc for contributors who want an overview. +- This file is the exact current-state baseline for contributor and refactor parity work. +- [claude-provider-vnext-locked.md](claude-provider-vnext-locked.md) + is the future refactor plan and should cite this file for present behavior. + +## Characterization coverage + +Stable automated coverage for this baseline lives in: + +- `Tests/CodexBarTests/ClaudeBaselineCharacterizationTests.swift` +- `Tests/CodexBarTests/ClaudeOAuthFetchStrategyAvailabilityTests.swift` +- `Tests/CodexBarTests/ClaudeUsageTests.swift` +- `Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift` +- `Tests/CodexBarTests/SettingsStoreCoverageTests.swift` + +`ClaudeUsageTests.swift` now directly characterizes the reachable `ClaudeUsageFetcher(.auto)` branches for: + +- OAuth when OAuth, Web, and CLI all appear available, +- Web before CLI when OAuth is unavailable, + +The successful CLI-selected branch and the CLI-failure-to-OAuth fallback remain documented from code inspection plus +surrounding Claude probe/regression coverage, because the current CLI-availability decision is sourced from process-wide +binary discovery with no stable test seam that would keep RAT-107 in scope. diff --git a/docs/refactor/claude-provider-vnext-locked.md b/docs/refactor/claude-provider-vnext-locked.md new file mode 100644 index 000000000..8576cf8ba --- /dev/null +++ b/docs/refactor/claude-provider-vnext-locked.md @@ -0,0 +1,356 @@ +--- +summary: "Locked implementation plan for Claude provider vNext: resolved source-selection contracts, typed credential rules, siloing guarantees, and phase gates." +supersedes: "Initial vNext draft (removed)" +created: "2026-02-18" +status: "Locked for implementation" +--- + +# Claude provider vNext (locked plan) + +This is the implementation-locked vNext plan. + +It preserves the original architecture direction, but removes ambiguity in behavior-critical areas before refactor work starts. + +Current-state parity reference for present behavior: + +- [docs/refactor/claude-current-baseline.md](claude-current-baseline.md) + +Use the baseline doc for present behavior. This vNext plan defines what the refactor should preserve and how it should +be staged; it is not the sole source of truth for current implementation details, and RAT-107 does not re-approve the +rest of the future architecture below. + +## Assessment snapshot + +- **Approach score:** `8.4/10`. +- **Why not 9+ yet:** the original plan left runtime ordering, token-account credential typing behavior, and compatibility mapping under-specified. +- **How this doc closes the gap:** explicit contracts + resolved decisions + phase exit gates + risk checklist. +- **Validated gap coverage in this version:** explicit `.auto` inconsistency handling, `ClaudeUsageFetcher` decomposition, stronger parity gates, TaskLocal-to-DI migration, and OAuth decomposition sub-phases. + +## Locked behavioral contracts + +These behaviors are **non-negotiable** during refactor unless this doc is explicitly updated. + +### 1) Runtime + source-mode contract + +`ClaudeSourcePlanner` must reproduce this matrix exactly: + +| Runtime | Selected mode | Ordered attempts | Fallback rules | +| --- | --- | --- | --- | +| app | auto | oauth -> cli -> web | oauth fallback allowed; cli fallback to web only when web available; web terminal | +| app | oauth | oauth | no fallback | +| app | cli | cli | no fallback | +| app | web | web | no fallback | +| cli | auto | web -> cli | web fallback allowed to cli; cli terminal | +| cli | oauth | oauth | no fallback | +| cli | cli | cli | no fallback | +| cli | web | web | no fallback | + +Notes: +- `sourceLabel` remains the final step label for successful fetch output. +- Planner diagnostics must include ordered steps and inclusion reasons. +- Planner output must feed the existing generic provider fetch pipeline; do not introduce a second Claude-only + execution stack alongside `ProviderFetchPlan` / `ProviderFetchPipeline`. + +### 1a) `.auto` inconsistency characterization contract (must-do before reconciliation) + +Current code has three `.auto` decision sites with inconsistent app ordering: + +- Strategy pipeline resolve order (app): `oauth -> cli -> web`. +- `resolveUsageStrategy` helper order: `oauth -> cli -> web -> cli fallback`. +- `ClaudeUsageFetcher.loadLatestUsage(.auto)` order: `oauth -> web -> cli -> oauth fallback`. + +Phase 0 must characterize these paths with tests where they are reachable through stable seams, and otherwise defer to +the baseline doc before deleting any path. +Phase 2 must reconcile this into planner-only source selection. + +### 2) Prompt/cooldown contract + +The planner must use one explicit `ClaudePromptDecision` equivalent, but outcome parity with current behavior is required: + +- User-initiated actions can clear prior keychain cooldown denial. +- Startup bootstrap prompt is only allowed when all are true: + - runtime is app + - interaction is background + - refresh phase is startup + - prompt mode is `onlyOnUserAction` + - no cached credentials +- Background delegated refresh is blocked when: + - prompt policy is `onlyOnUserAction` + - caller does not explicitly allow background delegated refresh +- Prompt mode `never` blocks delegated refresh attempts. + +### 3) Credential typing + routing contract + +Typed credentials must be introduced at the settings snapshot edge, with behavior parity: + +- `ClaudeManualCredential.sessionKey` +- `ClaudeManualCredential.cookieHeader` +- `ClaudeManualCredential.oauthAccessToken` + +Accepted Claude token-account inputs must continue to work: + +- Raw OAuth token (including `Bearer ...` input) +- Raw session key +- Full cookie header + +Routing parity requirements: + +- OAuth token account values must route to OAuth path (not cookie mode). +- Cookie/session-key account values must route to web cookie path. +- CLI token-account behavior must remain consistent in both app and `CodexBarCLI`. +- Scope note: current string heuristics are mostly edge-routing logic, not deep OAuth credential decoding internals. + +### 4) Ownership and refresh contract + +Credential owner behavior must remain identical: + +- `.claudeCLI` expired credentials: delegated refresh path. +- `.codexbar` expired credentials: direct refresh endpoint path. +- `.environment` expired credentials: no auto-refresh. + +Refresh failure-gate semantics must remain unchanged. + +### 5) Provider siloing + enrichment contract + +Hard invariant: + +- Never merge Claude Web identity into OAuth/CLI snapshots. +- Web extras may enrich **cost only**. +- Snapshot identity must always remain provider-scoped to `.claude` when persisted/displayed. + +### 6) Plan inference compatibility contract + +Canonical plan inference can live behind the existing `loginMethod` compatibility surface, but outward +compatibility must be preserved: + +- Existing detectable plans continue mapping to display strings: + - `Claude Max` + - `Claude Pro` + - `Claude Team` + - `Claude Enterprise` +- Subscription detection behavior must remain compatible with current UI logic, including existing `Ultra` detection + semantics until that behavior is explicitly changed. + +### 7) Documentation + diagnostics contract + +- During refactor, characterization coverage plus + [docs/refactor/claude-current-baseline.md](claude-current-baseline.md) + are the source of truth when docs and code disagree. +- `docs/claude.md` must be updated as part of Phase 0 after characterization lands so it no longer presents divergent + runtime ordering as settled behavior. +- Debug surfaces must consume planner-derived diagnostics instead of recomputing Claude source decisions separately. + +## Resolved decisions (from open questions) + +### Web identity fill-ins + +- **Decision:** do not use Web identity to fill missing OAuth/CLI identity fields. +- **Allowed:** Web cost enrichment only. + +### CLI runtime fallback ordering + +- **Decision:** keep current CLI ordering in `auto`: `web -> cli`. +- Planner must encode this explicitly and not rely on incidental strategy ordering. + +### Startup bootstrap prompt in `onlyOnUserAction` + +- **Decision:** keep support exactly under the startup bootstrap constraints listed above. +- Any expansion/restriction requires explicit doc update and tests. + +### Runtime policy unification timing + +- **Decision:** do not unify app and CLI `auto` ordering before this refactor. +- First consolidate to one planner implementation with current runtime-specific behavior preserved. +- Any runtime-policy unification is a separate, explicit behavior-change follow-up. + +### Planner integration timing + +- **Decision:** `ClaudeSourcePlanner` must be integrated into the existing provider descriptor / fetch pipeline rather + than added as a parallel orchestration layer. +- Reuse current `ProviderFetchContext` / `ProviderFetchPlan` plumbing where possible. + +### Dependency seam timing + +- **Decision:** introduce dependency-container seams for newly extracted planner/executor components as they are + created. +- Full TaskLocal cleanup can remain later, but new components should not deepen TaskLocal coupling. + +## Locked migration plan with exit gates + +### Phase 0: Baseline lock + +Deliverables: +- Add `docs/refactor/claude-current-baseline.md` as the current-state behavior reference. +- Add/refresh characterization tests for runtime/source matrix and prompt-decision parity. +- Add explicit characterization tests for existing `.auto` decision paths where they are reachable through stable seams, + and defer remaining current-state details to the baseline doc until later reconciliation. +- Update `docs/claude.md` after tests land so documented ordering matches characterized behavior. + +Exit gate: +- Behavior matrix tests pass for app and cli runtimes. +- `.auto` characterization coverage plus the baseline doc record current divergence explicitly without forcing new + production seams in Phase 0. +- `docs/claude.md` no longer contradicts characterized runtime/source behavior. + +### Phase 1: Canonical plan resolver + +Deliverables: +- Introduce `ClaudePlan` + one resolver used by OAuth/Web/CLI mapping and downstream UI consumers. + +Exit gate: +- Plan mapping tests cover tier/billing/status-derived hints, compatibility display strings, and current UI subscription + detection compatibility. + +### Phase 1b: Typed credentials at the snapshot edge + +Deliverables: +- Parse manual Claude credentials once at the app + CLI snapshot edges into a typed model. +- Remove duplicated edge-routing heuristics for OAuth-vs-cookie decisions across settings snapshots and token-account + CLI code. + +Exit gate: +- Token account parity tests pass for app + CLI. +- Snapshot-edge routing no longer duplicates Claude OAuth-token detection logic in multiple call sites. + +### Phase 2: Single source planner + +Deliverables: +- Introduce `ClaudeSourcePlanner` + explicit `ClaudeFetchPlan`. +- Integrate planner outputs into the existing provider pipeline / descriptor flow. +- Remove duplicate `.auto` policy branches from lower layers. +- Reconcile and remove `ClaudeUsageFetcher` internal `.auto` source selection. +- Move debug/diagnostic surfaces to planner-derived attempt ordering instead of helper-specific recomputation. + +Exit gate: +- One authoritative planner path for mode/runtime ordering. +- Fallback attempt logs still show expected sequence and source labels. +- No surviving `.auto` source-order logic outside planner. +- No surviving debug-only source-order recomputation outside planner diagnostics. +- Old-vs-new planner parity tests pass before old branches are removed. + +### Phase 2b: `ClaudeUsageFetcher` decomposition + +Deliverables: +- Split `ClaudeUsageFetcher` into smaller executor-focused components. +- Extract delegated OAuth retry/recovery flow into dedicated units. +- Remove embedded prompt-policy/source-selection ownership from fetcher; keep it execution-only. + +Exit gate: +- Fetcher no longer owns source-selection policy. +- Delegated OAuth retry behavior is covered by dedicated tests and remains parity-compatible. + +### Phase 3: Test injection cleanup + +Deliverables: +- Prefer dependency seams on extracted units instead of adding new TaskLocal-only override points. + - Avoid expanding TaskLocal-only test hooks while decomposition work lands. + +Exit gate: +- New fetcher tests rely on explicit dependency seams where practical. + +### Phase 4: OAuth decomposition + +Deliverables (sub-phases): + +- **Phase 4a (repository extraction):** + - Extract IO + caching + owner/source loading into repository surface. + - Keep prompt-gate semantics unchanged. +- **Phase 4b (refresher extraction):** + - Extract network refresh + failure gating to refresher component. + - Keep owner-based refresh behavior unchanged. +- **Phase 4c (delegated controller extraction):** + - Extract delegated CLI touch + keychain-change observation + cooldown behavior. + - Keep delegated retry outcomes unchanged. + +Exit gate: +- Existing OAuth delegated refresh / prompt policy / cooldown suites pass without behavior deltas at each sub-phase. +- Owner semantics parity remains intact across all sub-phases (`claudeCLI`, `codexbar`, `environment`). + +### Phase 5: Test injection migration (TaskLocal -> DI) + +Deliverables: +- Move test injection from TaskLocal-heavy overrides to `ClaudeFetchDependencies` and explicit protocol stubs. +- Keep compatibility shims temporarily where needed, then remove them. + +Exit gate: +- Core planner/executor tests run without TaskLocal injection dependencies. +- Legacy TaskLocal-only override surfaces are either removed or isolated to compatibility adapters with deletion TODOs. + +### Phase 6: Web decomposition (optional) + +Deliverables: +- Separate cookie acquisition from web usage client. +- Keep probe tooling isolated behind debug/tool surface. + +Exit gate: +- Web parsing and account mapping tests remain green. + +## Implementation PR plan (stacked) + +Use this sequence to keep each PR reviewable without turning the rollout into unnecessary PR overhead. + +| PR | Title | Scope | Primary risks | Must-pass gate before merge | +| --- | --- | --- | --- | --- | +| PR-01 | Baseline characterization + doc correction | Lock current matrix behavior, characterize `.auto` paths through stable seams, defer remaining lower-level current-state details to the baseline doc, characterize prompt bootstrap/cooldown and token-account routing, then update docs to match reality. | R1, R2, R5, R6, R10 | No production behavior changes; characterization suites green; docs no longer contradict tests or the baseline. | +| PR-02 | Canonical plan resolver | Introduce `ClaudePlan` and central resolver; map OAuth/Web/CLI/UI compatibility through one model while preserving current `loginMethod` projections. | R8 | Plan compatibility tests green (`Max/Pro/Team/Enterprise` + current subscription compatibility). | +| PR-03 | Typed credentials at the edge | Parse manual credentials once (`sessionKey`, `cookieHeader`, `oauthAccessToken`) in app + CLI snapshot shaping. | R6 | Token-account routing parity tests green in app + CLI contexts. | +| PR-04 | Source planner introduction + cutover | Add `ClaudeSourcePlanner`, prove parity against old path, then remove duplicate `.auto` selection branches once parity is proven. | R1, R5, R10 | One `.auto` authority remains; attempt/source-label diagnostics remain parity-compatible. | +| PR-05 | `ClaudeUsageFetcher` decomposition | Split fetcher into execution/retry-focused units; remove embedded source-selection ownership. | R2, R10 | Delegated OAuth retry/recovery tests green with no behavior deltas. | +| PR-06 | OAuth decomposition | Extract repository, refresher, and delegated-controller seams from `ClaudeOAuthCredentialsStore` while preserving owner semantics. | R3, R4, R7, R9 | Cache/fingerprint/prompt/owner suites green (`claudeCLI`, `codexbar`, `environment`). | +| PR-07 (optional) | TaskLocal -> DI migration | Move remaining tests and seams to `ClaudeFetchDependencies`, keep temporary compat adapters, then remove. | R9 | Core planner/executor tests run without TaskLocal globals. | +| PR-08 (optional) | Web decomposition | Split cookie acquisition from web usage client and keep tooling isolated. | R8, R10 | Web parsing/account mapping suites remain green. | + +Stacking rules: + +1. Keep each PR scoped to one risk cluster and one merge gate. +2. Do not remove old branches until a prior PR has old-vs-new parity tests in CI. +3. If a PR intentionally changes behavior, update this locked doc in the same PR and call it out in summary. +4. Prefer 6 core PRs unless parity risk forces a temporary split; do not fragment the rollout further without a + concrete rollback or reviewability reason. + +## Mandatory test additions + +Add these test groups before or during Phases 1-3, then extend for later phases: + +1. Planner matrix tests: + - `(runtime x selected mode x interaction x refresh phase x availability)` -> exact step order + fallback. +2. `.auto` divergence characterization tests: + - Lock current behavior of strategy pipeline vs `resolveUsageStrategy` helper vs fetcher-direct `.auto`. + - Use as guardrails while consolidating to planner-only logic. +3. Typed credential parsing tests: + - OAuth token, bearer token, session key, cookie header, malformed strings. +4. Cross-provider identity isolation tests: + - Ensure `.claude` identity does not leak via snapshot scoping/merging. +5. Source-label and attempt diagnostics tests: + - Validate final source label and attempt list parity. +6. CLI token-account parity tests: + - `TokenAccountCLIContext` and app settings snapshot behavior match for OAuth-vs-cookie routing. +7. Old-vs-new parity tests: + - Compare old path and planner path outputs before branch removals in Phase 2 and Phase 2b. +8. DI migration tests: + - Ensure new dependency container can drive planner/executor tests without TaskLocal globals. + +## Risk checklist (implementation review) + +Use these risk IDs in refactor PR checklists/reviews. + +| Risk ID | Severity | Risk | Detail | +| --- | --- | --- | --- | +| R1 | Critical | Auto-ordering reconciliation | Three `.auto` paths are inconsistent today. Characterize strategy pipeline vs `resolveUsageStrategy` helper vs fetcher-direct `.auto` before deleting any path. | +| R2 | High | Prompt policy consolidation | Prompt policy exists across strategy availability, fetcher flow, and credentials store gates. Preserve startup bootstrap constraints exactly to avoid prompt storms or silent OAuth suppression. | +| R3 | High | `ClaudeOAuthCredentialsStore` decomposition | Large lock-protected state + layered caches + fingerprint invalidation + security calls. Splits can break cache coherence, invalidation timing, or prompt gating order. | +| R4 | High | Owner semantics drift | Preserve exact owner-to-refresh mapping: `.claudeCLI` delegated, `.codexbar` direct refresh, `.environment` no refresh. | +| R5 | Medium | CLI runtime parity | Preserve runtime-specific policy: CLI `auto` remains `web -> cli`; OAuth is available only when explicitly selected as `sourceMode=.oauth`. Do not accidentally default CLI runtime to app ordering. | +| R6 | Medium | Token-account OAuth-vs-cookie misrouting | Keep routing parity for OAuth token vs session key vs full cookie header, including `Bearer sk-ant-oat...` normalization. | +| R7 | Medium | Cache invalidation regressions | Preserve credentials file/keychain fingerprint semantics and stale-cache guards during repository extraction. | +| R8 | Low-Medium | Plan inference heuristic drift | Preserve web-specific plan inference fallback (`billing_type` + `rate_limit_tier`) when unifying plan resolution. | +| R9 | Medium | Strict concurrency / `@Sendable` regressions | Maintain thread-safe behavior from current NSLock-based state while moving to DI/decomposed components under Swift 6 strict concurrency. | +| R10 | Low | Debug/diagnostic drift | Keep source labels, attempt sequences, and debug output aligned with real planner decisions after consolidation. | + +## Change-control rule + +Any refactor PR that intentionally changes one of the locked contracts above must: + +1. Update this document. +2. Add/adjust tests proving the new behavior. +3. Call out the behavior change explicitly in the PR summary. diff --git a/docs/refactor/cli.md b/docs/refactor/cli.md index 0eadaa702..f849cc887 100644 --- a/docs/refactor/cli.md +++ b/docs/refactor/cli.md @@ -73,7 +73,6 @@ read_when: - Config validation (bad region/source/apiKey field). - SettingsStore order/toggle invariants still pass. 8. **Verification** - - `swift test`, `swiftformat Sources Tests`, `swiftlint --strict`, `pnpm check`. + - `swift test`, `swiftformat Sources Tests`, `swiftlint --strict`, `make check`. - `./Scripts/compile_and_run.sh`. - CLI e2e: `codexbar --json-only ...`, `codexbar config validate`. - diff --git a/docs/refresh-loop.md b/docs/refresh-loop.md index 58cb7c545..1426ce242 100644 --- a/docs/refresh-loop.md +++ b/docs/refresh-loop.md @@ -15,6 +15,9 @@ read_when: - Background refresh runs off-main and updates `UsageStore` (usage + credits + optional web scrape). - Manual “Refresh now” always available in the menu. - Stale/error states dim the icon and surface status in-menu. +- Optional provider-storage scans run only when “Show provider storage usage” is enabled. They are scheduled in the + background, coalesced/throttled during automatic refreshes, and forced by manual refresh without blocking the usage + refresh path. ## Optional future - Auto-seed a log if none exists via `codex exec --skip-git-repo-check --json "ping"` (currently not executed). diff --git a/docs/releasing-homebrew.md b/docs/releasing-homebrew.md index 4bbfaf69c..8f2078b96 100644 --- a/docs/releasing-homebrew.md +++ b/docs/releasing-homebrew.md @@ -14,20 +14,29 @@ Homebrew is for the UI app via Cask. When installed via Homebrew, CodexBar disab - Access to the tap repo: `../homebrew-tap`. ## 1) Release CodexBar normally -Follow `docs/RELEASING.md` to publish `CodexBar-.zip` to GitHub Releases. +Follow `docs/RELEASING.md` to publish `CodexBar-macos-universal-.zip` to GitHub Releases. -## 2) Update the Homebrew tap cask -In `../homebrew-tap`, add/update the cask at `Casks/codexbar.rb`: -- `url` points at the GitHub release asset: `.../releases/download/v/CodexBar-.zip` +## 2) Let the Release CLI workflow update the tap +After the GitHub release is published, `.github/workflows/release-cli.yml` builds the standalone CLI assets and dispatches `steipete/homebrew-tap`'s `update-formula.yml`. That tap workflow updates both: +- `Casks/codexbar.rb` for the app zip. +- `Formula/codexbar.rb` for the standalone CLI tarballs. + +If dispatch fails or is rate-limited, update the files manually. + +## 2a) Manual cask update +In `../homebrew-tap`, update the cask at `Casks/codexbar.rb`: +- `url` points at the GitHub release asset: `.../releases/download/v/CodexBar-macos-universal-.zip` - Update `sha256` to match that zip. - Keep `depends_on arch: :arm64` and `depends_on macos: ">= :sonoma"` (CodexBar is macOS 14+). -## 2b) Update the Homebrew tap formula (Linux CLI) -In `../homebrew-tap`, add/update the formula at `Formula/codexbar.rb`: +## 2b) Manual formula update +In `../homebrew-tap`, update the formula at `Formula/codexbar.rb`: - `url` points at the GitHub release assets: - - `.../releases/download/v/CodexBarCLI-v-linux-aarch64.tar.gz` - - `.../releases/download/v/CodexBarCLI-v-linux-x86_64.tar.gz` -- Update both `sha256` values to match those tarballs. + - macOS: `.../releases/download/v/CodexBarCLI-v-macos-arm64.tar.gz` + - macOS: `.../releases/download/v/CodexBarCLI-v-macos-x86_64.tar.gz` + - Linux: `.../releases/download/v/CodexBarCLI-v-linux-aarch64.tar.gz` + - Linux: `.../releases/download/v/CodexBarCLI-v-linux-x86_64.tar.gz` +- Update all `sha256` values to match those tarballs. ## 3) Verify install ```sh diff --git a/docs/site.css b/docs/site.css index bf335d072..d1a522c39 100644 --- a/docs/site.css +++ b/docs/site.css @@ -1,30 +1,79 @@ :root { - color-scheme: light dark; - --bg: #070b12; - --panel: rgba(14, 18, 28, 0.78); - --panel-solid: #0e121c; - --text: rgba(241, 246, 255, 0.92); - --muted: rgba(187, 198, 218, 0.78); - --line: rgba(255, 255, 255, 0.1); - --accent: #3ff0d6; - --accent2: #2d5bff; - --shadow: rgba(0, 0, 0, 0.42); - --radius: 18px; - --radius-sm: 14px; -} - -@media (prefers-color-scheme: light) { - :root { - --bg: #f8fbff; - --panel: rgba(255, 255, 255, 0.74); - --panel-solid: #ffffff; - --text: rgba(11, 16, 22, 0.92); - --muted: rgba(56, 70, 88, 0.75); - --line: rgba(11, 16, 22, 0.1); - --shadow: rgba(11, 16, 22, 0.12); + color-scheme: light; + --bg: #fbfbfc; + --bg-elev: #ffffff; + --bg-elev-2: #f3f4f6; + --ink-1: #0a0a0c; + --ink-2: #5b6068; + --ink-3: #8a8f97; + --line: rgba(0, 0, 0, 0.08); + --line-strong: rgba(0, 0, 0, 0.18); + --link: #0066cc; + --structure-line: rgba(10, 10, 12, 0.045); + --structure-line-strong: rgba(10, 10, 12, 0.1); + --structure-accent-hot: rgba(255, 106, 0, 0.12); + --structure-accent-cool: rgba(0, 191, 165, 0.1); + --logo-chip-bg: #ffffff; + --logo-chip-line: rgba(0, 0, 0, 0.08); + --logo-image-filter: none; + --logo-chip-shadow: 0 1px 0 rgba(255, 255, 255, 0.34), inset 0 0 0 1px rgba(0, 0, 0, 0.04); + --theme-toggle-bg: #101116; + --theme-toggle-ink: #ffffff; + --radius: 8px; + --radius-sm: 7px; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg: #0a0a0c; + --bg-elev: #131418; + --bg-elev-2: #1c1d22; + --ink-1: #f0f0f3; + --ink-2: #97a0aa; + --ink-3: #6b7280; + --line: rgba(255, 255, 255, 0.09); + --line-strong: rgba(255, 255, 255, 0.2); + --link: #6ea8ff; + --structure-line: rgba(255, 255, 255, 0.035); + --structure-line-strong: rgba(255, 255, 255, 0.095); + --structure-accent-hot: rgba(255, 106, 0, 0.14); + --structure-accent-cool: rgba(0, 191, 165, 0.12); + --logo-chip-bg: #101116; + --logo-chip-line: rgba(255, 255, 255, 0.14); + --logo-image-filter: brightness(0) invert(1) saturate(0.86) contrast(1.12); + --logo-chip-shadow: 0 1px 0 rgba(255, 255, 255, 0.08), inset 0 0 0 1px rgba(255, 255, 255, 0.08); + --theme-toggle-bg: #ffffff; + --theme-toggle-ink: #0a0a0c; } } +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #0a0a0c; + --bg-elev: #131418; + --bg-elev-2: #1c1d22; + --ink-1: #f0f0f3; + --ink-2: #97a0aa; + --ink-3: #6b7280; + --line: rgba(255, 255, 255, 0.09); + --line-strong: rgba(255, 255, 255, 0.2); + --link: #6ea8ff; + --structure-line: rgba(255, 255, 255, 0.035); + --structure-line-strong: rgba(255, 255, 255, 0.095); + --structure-accent-hot: rgba(255, 106, 0, 0.14); + --structure-accent-cool: rgba(0, 191, 165, 0.12); + --logo-chip-bg: #101116; + --logo-chip-line: rgba(255, 255, 255, 0.14); + --logo-image-filter: brightness(0) invert(1) saturate(0.86) contrast(1.12); + --logo-chip-shadow: 0 1px 0 rgba(255, 255, 255, 0.08), inset 0 0 0 1px rgba(255, 255, 255, 0.08); + --theme-toggle-bg: #ffffff; + --theme-toggle-ink: #0a0a0c; +} + +:root[data-theme="light"] { + color-scheme: light; +} + * { box-sizing: border-box; } @@ -34,472 +83,808 @@ body { min-height: 100%; } +html { + background: var(--bg); +} + body { margin: 0; - font: 16px/1.55 ui-sans-serif, "Avenir Next", Avenir, "Segoe UI", "Helvetica Neue", sans-serif; - color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "Helvetica Neue", sans-serif; + font-size: 16px; + line-height: 1.5; + color: var(--ink-1); + background: var(--bg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + position: relative; + overflow-x: hidden; +} + +h1, +h2, +h3, +p { + overflow-wrap: break-word; +} + +.backdrop { + position: fixed; + inset: 0; + z-index: 0; + overflow: hidden; + pointer-events: none; + contain: strict; +} + +.backdrop::before { + content: ""; + position: absolute; + inset: -20% -10%; background: - radial-gradient(1100px 800px at 15% 10%, rgba(63, 240, 214, 0.18), transparent 58%), - radial-gradient(950px 780px at 85% 20%, rgba(45, 91, 255, 0.18), transparent 60%), - radial-gradient(900px 700px at 40% 95%, rgba(63, 240, 214, 0.06), transparent 60%), - repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.04) 1px, transparent 1px, transparent 72px), - var(--bg); + linear-gradient(115deg, transparent 0 42%, var(--structure-accent-hot) 42% 42.35%, transparent 42.35% 100%), + linear-gradient(115deg, transparent 0 58%, var(--structure-accent-cool) 58% 58.3%, transparent 58.3% 100%); + opacity: 0.42; + transform: skewY(-4deg); } -html { +.backdrop .plane, +.backdrop .trace { + position: absolute; + display: block; +} + +.backdrop .plane { + border: 1px solid var(--structure-line-strong); + border-radius: 18px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); + opacity: 0.32; +} + +.backdrop .p1 { + width: 780px; + height: 300px; + left: max(24px, calc(50% - 720px)); + top: 128px; + transform: rotate(-2deg); +} + +.backdrop .p2 { + width: 580px; + height: 360px; + right: max(24px, calc(50% - 760px)); + top: 460px; + transform: rotate(3deg); +} + +.backdrop .trace { + height: 1px; + background: linear-gradient(90deg, transparent, var(--structure-line-strong), transparent); + opacity: 0.36; +} + +.backdrop .trace::before, +.backdrop .trace::after { + content: ""; + position: absolute; + top: -3px; + width: 7px; + height: 7px; + border: 1px solid var(--structure-line-strong); + border-radius: 2px; background: var(--bg); } -a, -a:visited { - color: inherit; +.backdrop .trace::before { + left: 18%; +} + +.backdrop .trace::after { + right: 12%; +} + +.backdrop .t1 { + width: 58vw; + left: -6vw; + top: 260px; + transform: rotate(-12deg); +} + +.backdrop .t2 { + width: 44vw; + right: -4vw; + top: 210px; + transform: rotate(14deg); +} + +.backdrop .t3 { + width: 52vw; + left: 10vw; + top: 660px; + transform: rotate(7deg); +} + +.backdrop .t4 { + width: 38vw; + right: 10vw; + top: 910px; + transform: rotate(-9deg); +} + +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(var(--structure-line) 1px, transparent 1px), + linear-gradient(90deg, var(--structure-line) 1px, transparent 1px), + linear-gradient(var(--structure-line-strong) 1px, transparent 1px), + linear-gradient(90deg, var(--structure-line-strong) 1px, transparent 1px); + background-size: 32px 32px, 32px 32px, 128px 128px, 128px 128px; + background-position: 50% 0, 50% 0, 50% 0, 50% 0; + -webkit-mask-image: linear-gradient(180deg, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0.7) 70%, rgba(0, 0, 0, 0.45)); + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0.7) 70%, rgba(0, 0, 0, 0.45)); +} + +body::after { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + repeating-linear-gradient(135deg, transparent 0 56px, var(--structure-line) 56px 57px, transparent 57px 112px), + radial-gradient(circle at center, var(--structure-line-strong) 1px, transparent 1.6px); + background-size: auto, 96px 96px; + background-position: 0 0, 50% 0; + -webkit-mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.48), rgba(0, 0, 0, 0.24) 78%, transparent); + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.48), rgba(0, 0, 0, 0.24) 78%, transparent); } a { + color: inherit; text-decoration: none; } a:hover { text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; } code { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; - font-size: 0.95em; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.92em; } -.wrap { - max-width: 1020px; - margin: 0 auto; - padding: 46px 22px 34px; +.masthead, +.lede, +.providers, +.showcase, +.detail, +.foot { + position: relative; + z-index: 1; + width: 100%; + max-width: 1080px; + margin-left: auto; + margin-right: auto; + padding-left: 24px; + padding-right: 24px; } -.top { +.masthead { display: flex; align-items: center; justify-content: space-between; - gap: 18px; - margin-bottom: 28px; + padding-top: 22px; + padding-bottom: 22px; } .brand { - display: flex; + display: inline-flex; align-items: center; - gap: 14px; - min-width: 240px; + gap: 10px; + font-weight: 600; + font-size: 16px; + letter-spacing: 0; } -.name { - display: flex; - flex-direction: column; - gap: 2px; +.brand:hover { + text-decoration: none; } -.title { - margin: 0; - font-family: ui-serif, "New York", "Iowan Old Style", Palatino, serif; - font-size: 22px; - letter-spacing: 0.1px; +.mark { + width: 28px; + height: 28px; + border-radius: 7px; } -.tagline { - margin: 0; - color: var(--muted); - font-size: 13.5px; +.nav { + display: flex; + align-items: center; + gap: 22px; + font-size: 14px; } -.mark { - width: 44px; - height: 44px; - border-radius: 13px; - border: 0; - box-shadow: none; - background: transparent; - object-fit: cover; - filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.35)); +.nav a { + color: var(--ink-2); } -.brand:hover .mark { - filter: drop-shadow(0 16px 34px rgba(0, 0, 0, 0.42)); +.nav a:hover { + color: var(--ink-1); + text-decoration: none; } -.links { - display: flex; - gap: 10px; +.theme-toggle { + width: 36px; + height: 36px; + padding: 0; + border: 1px solid var(--line-strong); + border-radius: 999px; + display: inline-grid; + place-items: center; + color: var(--theme-toggle-ink); + background: var(--theme-toggle-bg); + cursor: pointer; + transition: transform 140ms ease, border-color 140ms ease, background 140ms ease, color 140ms ease; } -.btn { - padding: 10px 14px; - border-radius: 13px; - border: 0; - background: color-mix(in srgb, var(--panel-solid) 32%, transparent); - box-shadow: 0 10px 28px var(--shadow); - transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease; - white-space: nowrap; +.theme-toggle:hover { + transform: translateY(-1px); + border-color: var(--ink-2); } -.btn:hover { - transform: translateY(-1px); - text-decoration: none; - background: color-mix(in srgb, var(--panel-solid) 52%, transparent); - box-shadow: 0 14px 34px var(--shadow); +.theme-toggle .sun, +.theme-toggle .moon { + grid-area: 1 / 1; + transition: opacity 140ms ease, transform 140ms ease; } -.btn.primary { - background: linear-gradient(135deg, rgba(63, 240, 214, 0.65), rgba(45, 91, 255, 0.62)); - background-clip: padding-box; - box-shadow: 0 12px 30px color-mix(in srgb, var(--accent) 25%, var(--shadow)); +.theme-toggle .sun { + opacity: 1; + transform: scale(1); } -.btn.ghost { - background: transparent; - box-shadow: none; - color: color-mix(in srgb, var(--text) 86%, var(--muted)); +.theme-toggle .moon { + opacity: 0; + transform: scale(0.72) rotate(-12deg); } -.btn.ghost:hover { - background: color-mix(in srgb, var(--panel-solid) 28%, transparent); - box-shadow: 0 10px 24px var(--shadow); +:root[data-theme="dark"] .theme-toggle .sun, +:root:not([data-theme]) .theme-toggle[aria-pressed="true"] .sun { + opacity: 0; + transform: scale(0.72) rotate(12deg); } -.hero { - display: grid; - grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.9fr); - gap: 26px; +:root[data-theme="dark"] .theme-toggle .moon, +:root:not([data-theme]) .theme-toggle[aria-pressed="true"] .moon { + opacity: 1; + transform: scale(1); +} + +.btn { + display: inline-flex; align-items: center; - padding: 24px 26px; - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--panel); - backdrop-filter: blur(12px); - box-shadow: 0 18px 60px var(--shadow); - position: relative; - overflow: hidden; + justify-content: center; + gap: 8px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid var(--line-strong); + background: var(--bg-elev); + font-size: 14px; + font-weight: 500; + color: var(--ink-1); + white-space: nowrap; + transition: background 140ms ease, border-color 140ms ease, transform 140ms ease, color 140ms ease; } -.copy { - position: relative; - z-index: 1; +.btn:hover { + background: var(--bg-elev-2); + border-color: var(--ink-2); + text-decoration: none; } -.hero-heading { - display: flex; - gap: 14px; - align-items: flex-start; +.btn.primary { + background: var(--ink-1); + color: var(--bg); + border-color: var(--ink-1); } -.hero-icon { - width: 40px; - height: 40px; - border-radius: 12px; - filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.35)); +.btn.primary:hover { + background: color-mix(in srgb, var(--ink-1) 86%, transparent); + border-color: var(--ink-1); + color: var(--bg); } -.hero::before { - content: ""; - position: absolute; - inset: -1px; - background: - radial-gradient(600px 320px at 10% 15%, rgba(63, 240, 214, 0.18), transparent 55%), - radial-gradient(520px 300px at 92% 18%, rgba(45, 91, 255, 0.18), transparent 55%); - pointer-events: none; - opacity: 0.9; +.btn.lg { + padding: 11px 20px; + font-size: 15px; } -.copy h2 { - margin: 0 0 8px; - font-family: ui-serif, "New York", "Iowan Old Style", Palatino, serif; - font-size: 34px; - letter-spacing: -0.3px; - line-height: 1.12; - position: relative; +.lede { + padding-top: 56px; + padding-bottom: 64px; } -.copy p { - margin: 0 0 10px; - color: var(--muted); +.lede h1 { + margin: 0 0 18px; + font-size: 60px; + line-height: 1.04; + letter-spacing: -0.01em; + font-weight: 600; + max-width: 18ch; } -.requirement { - margin: 6px 0 0; - color: color-mix(in srgb, var(--text) 92%, var(--muted)); - font-size: 13.5px; +.lede h1 .grad { + background-image: linear-gradient(100deg, #ff6a00 0%, #ff3399 32%, #6e5aff 62%, #00bfa5 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + background-size: 200% 100%; + animation: hue-slide 14s ease-in-out infinite; } -.badges { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 10px; - position: relative; +@keyframes hue-slide { + 0%, 100% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } } -.badge { - font-size: 12px; - padding: 6px 10px; - border-radius: 999px; - border: 1px solid var(--line); - background: color-mix(in srgb, var(--panel-solid) 14%, transparent); - color: color-mix(in srgb, var(--text) 86%, var(--muted)); - letter-spacing: 0.2px; +.lede .dek { + margin: 0; + font-size: 19px; + line-height: 1.5; + color: var(--ink-2); + max-width: 60ch; } -.cta { +.lede .actions { display: flex; - gap: 10px; flex-wrap: wrap; - margin-top: 14px; - position: relative; + gap: 12px; + margin: 28px 0 14px; + align-items: center; } -.brew-line { - display: flex; +.brew { + display: inline-flex; align-items: center; - gap: 8px; - flex-wrap: wrap; + gap: 4px; + padding: 6px 6px 6px 14px; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: var(--bg-elev); + font-size: 13.5px; + max-width: 100%; + overflow: hidden; } -.brew-line code { - background: color-mix(in srgb, var(--panel-solid) 40%, transparent); - padding: 4px 8px; - border-radius: 6px; - border: 1px solid var(--line); +.brew code { + background: none; + padding: 0; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.copy-btn { +.brew-copy { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; - border: 1px solid var(--line); - border-radius: 6px; - background: color-mix(in srgb, var(--panel-solid) 30%, transparent); - color: var(--muted); + margin: 0; + border: 0; + background: transparent; + border-radius: 999px; + color: var(--ink-2); cursor: pointer; - transition: background 160ms ease, color 160ms ease, border-color 160ms ease; + flex-shrink: 0; + transition: background 140ms ease, color 140ms ease; } -.copy-btn:hover { - background: color-mix(in srgb, var(--panel-solid) 60%, transparent); - color: var(--text); - border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); +.brew-copy:hover { + background: var(--bg-elev-2); + color: var(--ink-1); } -.copy-btn .icon-check { +.brew-copy .ok { display: none; - color: var(--accent); } -.copy-btn.copied .icon-copy { +.brew-copy.copied .ic { display: none; } -.copy-btn.copied .icon-check { - display: block; +.brew-copy.copied .ok { + display: inline; + color: #16a34a; } -.copy-btn.copied { - border-color: var(--accent); - color: var(--accent); +.fineprint { + margin: 6px 0 0; + font-size: 13px; + color: var(--ink-3); } -.bullets { - margin: 14px 0 0; - padding: 0 0 0 18px; - color: var(--muted); - font-size: 13.5px; - position: relative; +.section-head { + margin: 0 0 22px; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 18px; + flex-wrap: wrap; } -.bullets li { - margin: 6px 0; +.section-head h2 { + margin: 0; + font-size: 28px; + font-weight: 600; + letter-spacing: 0; } -.note { - margin: 10px 0 0; - color: var(--muted); - font-size: 13px; +.section-head p { + margin: 0; + color: var(--ink-2); + font-size: 14.5px; + max-width: 48ch; } -.shot { +.providers { + padding-bottom: 64px; +} + +.grid { + list-style: none; margin: 0; - position: relative; - z-index: 1; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 8px; } -.shot img { - width: 100%; - height: auto; - border-radius: var(--radius-sm); +.grid > li { + margin: 0; +} + +.provider { + --ink: #ffffff; + position: relative; + display: flex; + align-items: center; + gap: 12px; + padding: 12px; border: 1px solid var(--line); - box-shadow: 0 18px 60px var(--shadow); - background: color-mix(in srgb, var(--panel-solid) 92%, transparent); - transform: perspective(900px) rotateY(-6deg) rotateX(2deg); - transition: transform 220ms ease, box-shadow 220ms ease; + border-radius: var(--radius); + background: color-mix(in srgb, var(--bg-elev) 82%, transparent); + backdrop-filter: blur(8px) saturate(140%); + -webkit-backdrop-filter: blur(8px) saturate(140%); + text-decoration: none; + color: var(--ink-1); + min-height: 64px; + transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease; } -.hero-shot { - margin: 0; - justify-self: end; +.provider:hover { + text-decoration: none; + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--brand, var(--line-strong)) 55%, var(--line-strong)); + box-shadow: 0 6px 18px color-mix(in srgb, var(--brand, transparent) 18%, transparent); } -.hero-shot img { - max-width: 300px; - margin-left: auto; +.provider:has(.chip-logo)::before { + content: ""; + width: 38px; + height: 38px; + border-radius: var(--radius-sm); + flex: 0 0 38px; + background: var(--logo-chip-bg); + border: 1px solid var(--logo-chip-line); + box-shadow: var(--logo-chip-shadow); + transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease; } -.hero-shot figcaption { - max-width: 300px; - text-align: center; - margin-left: auto; +.provider.logo-dark::before { + background: #101116; + border-color: rgba(0, 0, 0, 0.14); } -.shot figcaption { - margin-top: 8px; - color: var(--muted); - font-size: 12.5px; - text-align: center; +:root[data-theme="dark"] .provider.logo-dark::before { + background: var(--logo-chip-bg); + border-color: var(--logo-chip-line); } -.shot img:hover { - transform: perspective(900px) rotateY(-2deg) rotateX(0deg) translateY(-2px); - box-shadow: 0 24px 80px var(--shadow); +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) .provider.logo-dark::before { + background: var(--logo-chip-bg); + border-color: var(--logo-chip-line); + } } -.details { +.provider .chip { + width: 38px; + height: 38px; + border-radius: var(--radius-sm); display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 14px; - margin-top: 14px; + place-items: center; + background: var(--brand, var(--bg-elev-2)); + color: var(--ink, #fff); + font-weight: 700; + font-size: 17px; + letter-spacing: 0; + flex-shrink: 0; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.18), + inset 0 -1px 0 rgba(0, 0, 0, 0.12); +} + +.provider .chip-logo { + position: absolute; + left: 18px; + top: 50%; + width: 26px; + height: 26px; + transform: translateY(-50%); + border-radius: var(--radius-sm); + flex-shrink: 0; + object-fit: contain; + background: transparent; + border: 0; + filter: var(--logo-image-filter); + padding: 0; + box-shadow: none; + transition: filter 140ms ease; } -.details.permissions { - grid-template-columns: 1fr; +.provider .chip-logo.no-bg { + background: transparent; + padding: 0; } -.card { - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 14px 14px 12px; - background: var(--panel); - backdrop-filter: blur(10px); - box-shadow: 0 14px 40px var(--shadow); +.provider .chip-logo.preserve-color { + filter: none; } -.card h3 { - margin: 0 0 6px; - font-size: 13px; - letter-spacing: 0.2px; - text-transform: uppercase; - color: color-mix(in srgb, var(--text) 88%, var(--muted)); +.provider .meta { + display: flex; + flex-direction: column; + min-width: 0; } -.card p { - margin: 0; - color: var(--muted); - font-size: 13.5px; +.provider .name { + font-size: 14px; + font-weight: 500; + line-height: 1.2; + color: var(--ink-1); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.foot { - margin-top: 22px; - text-align: center; - color: var(--muted); - font-size: 13px; +.provider .auth { + font-size: 12px; + color: var(--ink-2); + margin-top: 3px; + letter-spacing: 0; } -.links-row { - margin-top: 14px; - display: flex; - gap: 10px; - flex-wrap: wrap; - justify-content: center; +.provider.add { + border-style: dashed; + background: transparent; } -.link-pill { - padding: 8px 12px; - border-radius: 999px; +.provider.add:hover { + border-color: var(--ink-2); + box-shadow: none; +} + +.provider.add .chip { + background: transparent; + color: var(--ink-2); + box-shadow: inset 0 0 0 1px var(--line-strong); + font-weight: 400; + font-size: 22px; +} + +.showcase { + display: grid; + grid-template-columns: 1.05fr 1fr; + gap: 48px; + align-items: center; + padding-top: 8px; + padding-bottom: 64px; +} + +.showcase .shot { + margin: 0; +} + +.showcase .shot img { + width: 100%; + height: auto; + border-radius: var(--radius); border: 1px solid var(--line); - background: color-mix(in srgb, var(--panel-solid) 24%, transparent); - font-size: 13px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.18); + background: var(--bg-elev); } -.foot a { - text-decoration: underline; - text-decoration-color: color-mix(in srgb, var(--muted) 45%, transparent); +.showcase .features { + display: grid; + grid-template-columns: 1fr; + gap: 20px; } -[data-animate] { - opacity: 0; - transform: translateY(10px); - animation: rise 680ms cubic-bezier(0.2, 0.9, 0.2, 1) forwards; - animation-delay: calc(var(--d, 0) * 90ms); +.showcase .features article { + padding-left: 14px; + border-left: 2px solid var(--line); } -[data-animate="1"] { - --d: 1; +.showcase .features article h3 { + margin: 0 0 4px; + font-size: 15px; + font-weight: 600; } -[data-animate="2"] { - --d: 2; +.showcase .features article p { + margin: 0; + color: var(--ink-2); + font-size: 14.5px; + line-height: 1.5; } -[data-animate="3"] { - --d: 3; +.detail { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + padding-bottom: 56px; } -[data-animate="4"] { - --d: 4; +.detail .block { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 18px; + background: color-mix(in srgb, var(--bg-elev) 82%, transparent); + backdrop-filter: blur(8px) saturate(140%); + -webkit-backdrop-filter: blur(8px) saturate(140%); } -[data-animate="5"] { - --d: 5; +.detail .block h3 { + margin: 0 0 6px; + font-size: 14px; + font-weight: 600; } -[data-animate="6"] { - --d: 6; +.detail .block p { + margin: 0; + color: var(--ink-2); + font-size: 14px; + line-height: 1.5; } -[data-animate="7"] { - --d: 7; +.detail .block code, +.lede .brew code { + background: var(--bg-elev-2); + padding: 2px 6px; + border-radius: 5px; } -[data-animate="8"] { - --d: 8; +.lede .brew code { + background: none; + padding: 0; } -@keyframes rise { - to { - opacity: 1; - transform: translateY(0); - } +.lede a, +.showcase a, +.detail a, +.foot a { + color: var(--link); + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--link) 35%, transparent); + text-underline-offset: 2px; } -@media (prefers-reduced-motion: reduce) { - [data-animate] { - animation: none; - opacity: 1; - transform: none; - } - .btn, - .shot img { - transition: none; - } +.lede a:hover, +.showcase a:hover, +.detail a:hover, +.foot a:hover { + text-decoration-color: var(--link); +} + +.foot { + padding-top: 22px; + padding-bottom: 36px; + margin-top: 8px; + border-top: 1px solid var(--line); + text-align: center; + color: var(--ink-3); + font-size: 13px; } -@media (max-width: 860px) { - .top { +.foot p { + margin: 0; +} + +@media (max-width: 720px) { + .masthead, + .lede, + .providers, + .showcase, + .detail, + .foot { + padding-left: 18px; + padding-right: 18px; + } + .masthead { + padding-top: 16px; + padding-bottom: 16px; + } + .nav { + gap: 8px; + } + .nav-link { + display: none; + } + .theme-toggle { + width: 34px; + height: 34px; + } + .nav .btn { + padding-left: 12px; + padding-right: 12px; + } + .lede { + padding-top: 28px; + padding-bottom: 40px; + } + .lede h1 { + font-size: 32px; + max-width: 11ch; + } + .lede .dek { + font-size: 16px; + max-width: 30ch; + } + .section-head h2 { + font-size: 23px; + } + .section-head p { + max-width: 30ch; + } + .lede .actions { flex-direction: column; - align-items: flex-start; + align-items: stretch; + } + .lede .actions .btn { + width: 100%; + } + .brew { + width: 100%; + } + .grid { + grid-template-columns: 1fr; } - .hero { + .showcase { grid-template-columns: 1fr; + gap: 28px; } - .details { + .detail { grid-template-columns: 1fr; } - .shot img { +} + +@media (prefers-reduced-motion: reduce) { + .btn, + .provider { + transition: none; + } + .provider:hover, + .btn:hover { transform: none; } + .lede h1 .grad { + animation: none; + } } diff --git a/docs/social.html b/docs/social.html new file mode 100644 index 000000000..9d2a19bae --- /dev/null +++ b/docs/social.html @@ -0,0 +1,241 @@ + + + + + CodexBar social card + + + +
+ + + + +
+
+
+ +
+
+
+ + CodexBar +
+
codexbar.app
+
+ +
+

Every AI coding limit, in your menu bar.

+

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

+
+ +
    +
  • C
    Codex
  • +
  • C
    Claude
  • +
  • C
    Cursor
  • +
  • O
    OpenCode
  • +
  • A
    Alibaba
  • +
  • G
    Gemini
  • +
  • A
    Antigravity
  • + +
  • D
    Droid
  • +
  • C
    Copilot
  • +
  • z
    z.ai
  • +
  • M
    MiniMax
  • +
  • K
    Kimi
  • +
  • K
    Kimi K2 Legacy
  • +
  • K
    Kilo
  • +
  • K
    Kiro
  • + +
  • V
    Vertex AI
  • +
  • A
    Augment
  • +
  • A
    Amp
  • +
  • O
    Ollama
  • +
  • S
    Synthetic
  • +
  • J
    JetBrains AI
  • +
  • W
    Warp
  • +
  • E
    ElevenLabs
  • +
  • O
    OpenRouter
  • + +
  • P
    Perplexity
  • +
  • A
    Abacus AI
  • +
  • M
    Mistral
  • +
  • D
    DeepSeek
  • +
  • C
    Codebuff
  • +
+
+ + diff --git a/docs/social.png b/docs/social.png new file mode 100644 index 000000000..9a0012bac Binary files /dev/null and b/docs/social.png differ diff --git a/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md b/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md new file mode 100644 index 000000000..8636edb3f --- /dev/null +++ b/docs/solutions/performance-issues/openai-web-extras-default-off-codexbar-20260307.md @@ -0,0 +1,67 @@ +--- +module: CodexBar +date: 2026-03-07 +problem_type: performance_issue +component: tooling +symptoms: + - "Hidden chatgpt.com web content could spike to extremely high Energy Impact values in Activity Monitor" + - "CodexBar battery usage stayed abnormally high even when the app appeared idle" + - "Users did not realize optional OpenAI web extras were enabled by default" +root_cause: wrong_api +resolution_type: config_change +severity: high +tags: [codexbar, battery-drain, openai-web, webview, chatgpt, defaults] +--- + +# Troubleshooting: Default OpenAI Web Extras Off + +## Problem +CodexBar exposed optional OpenAI dashboard extras through a hidden `chatgpt.com` WebView, but the feature was enabled by default. That created a mismatch between user expectations for a lightweight menu bar app and the real cost of running a hidden single-page web app in the background. + +## Environment +- Module: CodexBar +- Affected component: Codex OpenAI web extras +- Date: 2026-03-07 + +## Symptoms +- Activity Monitor showed extreme energy usage attributed to `https://chatgpt.com` under the CodexBar process tree. +- Users observed battery drain that was out of proportion to the visible work the app was doing. +- The optional setting existed, but it was easy to miss, so affected users often did not know they could disable it. + +## What Didn't Work + +**Attempted solution 1:** Throttle failed OpenAI dashboard refresh attempts and evict cached WebViews more aggressively. +- **Why it failed:** This reduced the runaway failure loop, but it did not change the product default. Users could still pay the cost of a hidden ChatGPT dashboard without explicitly opting into it. + +**Attempted solution 2:** Keep the feature enabled by default and rely on a visible opt-out toggle. +- **Why it failed:** The battery and network cost was too high for a background utility. An opt-out-only design still left many users exposed to behavior they did not expect or understand. + +## Solution +Change OpenAI web extras to be off by default for new installs while preserving existing explicit configurations. + +**Code changes** +- `SettingsStore` now defaults `openAIWebAccessEnabled` to `false` when no prior preference exists. +- `SettingsStore` now defaults `openAIWebBatterySaverEnabled` to `false`; users can still opt into reduced routine OpenAI web refreshes separately. +- Existing users with an explicit Codex cookie configuration are inferred as enabled so upgrades do not silently break working setups. +- The Codex settings copy now describes the feature as optional and warns about battery and network cost. +- Documentation now labels the OpenAI web dashboard path as optional and off by default. + +## Why This Works +The root problem was not that the app had a toggle. The root problem was that an optional feature with heavyweight implementation details was enabled by default. + +The OpenAI web extras path uses a hidden `WKWebView` against `chatgpt.com` to gather dashboard-only data. That mechanism is fundamentally more expensive than the main Codex data paths, which already provide the normal information users expect from the app: session usage, weekly usage, reset timers, account identity, plan label, and normal credits remaining. + +Making the feature opt-in aligns the default behavior with the actual technical cost: +1. The normal Codex card continues to work without the hidden ChatGPT dashboard. +2. Users only incur the WebView cost if they deliberately choose the extra dashboard data. +3. Existing users with a configured Codex web setup keep their behavior on upgrade instead of being silently broken. + +## Prevention +- Do not default-enable optional features that load heavyweight hidden web content in a background utility. +- If a feature depends on a hidden SPA or WebView, require explicit user opt-in unless it is essential to core functionality. +- Prefer direct API or cookie-backed HTTP requests over hidden browser automation for background data collection. +- Surface the operational cost of optional features in the settings copy, not only in debug notes or issue threads. + +## Related Issues +- See also: [perf-energy-issue-139-simulation-report-2026-02-19.md](../../perf-energy-issue-139-simulation-report-2026-02-19.md) +- See also: [perf-energy-issue-139-main-fix-validation-2026-02-19.md](../../perf-energy-issue-139-main-fix-validation-2026-02-19.md) diff --git a/docs/sparkle.md b/docs/sparkle.md index 5aeed10d1..3f43c0bc3 100644 --- a/docs/sparkle.md +++ b/docs/sparkle.md @@ -17,7 +17,7 @@ read_when: - Channels: stable vs beta are served from the same appcast. Beta items are tagged with `sparkle:channel="beta"`; About → Update Channel controls `allowedChannels`. ## Release flow -1) Build & notarize as usual (`./Scripts/sign-and-notarize.sh`), producing notarized `CodexBar-.zip`. +1) Build & notarize as usual (`./Scripts/sign-and-notarize.sh`), producing notarized `CodexBar-macos-universal-.zip`. 2) Generate appcast entry with Sparkle `generate_appcast` using the Ed25519 private key; HTML release notes come from `CHANGELOG.md` via `Scripts/changelog-to-html.sh`. For beta releases: set `SPARKLE_CHANNEL=beta` to tag the entry. 3) Upload `appcast.xml` + zip to GitHub Releases (feed URL stays stable). 4) Tag/release. diff --git a/docs/stepfun.md b/docs/stepfun.md new file mode 100644 index 000000000..cba78d33d --- /dev/null +++ b/docs/stepfun.md @@ -0,0 +1,57 @@ +--- +summary: "StepFun provider data sources: username + password login for Step Plan rate limits and subscription plan name." +read_when: + - Adding or tweaking StepFun rate limit parsing + - Updating StepFun login flow + - Documenting new provider behavior +--- + +# StepFun provider + +StepFun (阶跃星辰) is a web-based provider. Usage data comes from the Step Plan rate limit API, +authenticated via an Oasis-Token obtained through a username + password login flow. + +## Data sources + +1. **Authentication** — Three methods (in priority order): + - **Auto mode**: Username + password entered in Settings → Providers → StepFun. + CodexBar performs a 3-step login flow to obtain an Oasis-Token: + 1. `GET https://platform.stepfun.com` → `INGRESSCOOKIE` + 2. `POST …/RegisterDevice` → anonymous token + 3. `POST …/SignInByPassword` → authenticated Oasis-Token + The token is cached in Keychain-backed `CookieHeaderCache` and reused until it expires. + - **Manual mode**: Paste an Oasis-Token directly in Settings → Providers → StepFun. + - **Environment variables**: `STEPFUN_USERNAME` + `STEPFUN_PASSWORD`, or `STEPFUN_TOKEN`. + +2. **Rate limit endpoint** + - `POST https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/QueryStepPlanRateLimit` + - Request headers: `Cookie: Oasis-Token=`, `Content-Type: application/json` + - Response fields: + - `five_hour_usage_left_rate` — remaining fraction for the 5-hour window (e.g. `0.99781543`) + - `weekly_usage_left_rate` — remaining fraction for the weekly window + - `five_hour_usage_reset_time` — reset timestamp (string or integer) + - `weekly_usage_reset_time` — reset timestamp (string or integer) + +3. **Plan status endpoint** + - `POST https://platform.stepfun.com/api/step.openapi.devcenter.Dashboard/GetStepPlanStatus` + - Same auth headers as above + - Response → `subscription.name` → plan name (e.g. "Plus", "Mini") + - If this request fails, usage data is still displayed without a plan name. + +## Usage details + +- **Primary window** (top bar): 5-hour rate limit (300 minutes). +- **Secondary window** (bottom bar): weekly rate limit (10 080 minutes). +- `usedPercent` is computed as `(1.0 - left_rate) × 100`. +- Plan name is shown as the `loginMethod` label in the menu card (e.g. "Plus"). +- When auth source is set to **Off**, no background refreshes occur. +- Token expiry triggers automatic re-login (cache is cleared and the 3-step flow runs again). + +## Key files + +- `Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift` (descriptor + web fetch strategy) +- `Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift` (login flow + HTTP client + JSON parser) +- `Sources/CodexBarCore/Providers/StepFun/StepFunSettingsReader.swift` (env var resolution) +- `Sources/CodexBar/Providers/StepFun/StepFunProviderImplementation.swift` (settings fields + activation logic) +- `Sources/CodexBar/Providers/StepFun/StepFunSettingsStore.swift` (SettingsStore extension) +- `Tests/CodexBarTests/StepFunUsageFetcherTests.swift` (22 test cases) diff --git a/docs/superpowers/plans/2026-05-11-kilo-organization-selection.md b/docs/superpowers/plans/2026-05-11-kilo-organization-selection.md new file mode 100644 index 000000000..2df89078b --- /dev/null +++ b/docs/superpowers/plans/2026-05-11-kilo-organization-selection.md @@ -0,0 +1,1482 @@ +# Kilo Organization Selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let CodexBar users opt in to one or more Kilo organizations from Preferences → Providers → Kilo. Enabled orgs render as stacked cards alongside their personal account in the Kilo menu. + +**Architecture:** Add `KiloUsageScope` and `KiloOrganization` types in `CodexBarCore`. Inject `X-KILOCODE-ORGANIZATIONID` header in `KiloUsageFetcher` when a scope is `.organization`. Persist known orgs and enabled-ids in `ProviderConfig` JSON. Mirror the existing tokenAccounts pattern in `UsageStore` to fan out a fetch per enabled scope and store stacked snapshots. Render via the existing stacked-snapshot menu pipeline by surfacing a Kilo-scoped accounts adapter. + +**Tech Stack:** Swift 6, SwiftUI, Swift Testing (`@Test`), `swift build` / `swift test` / `make check`, GitHub CLI for PR. + +**Spec:** `docs/superpowers/specs/2026-05-11-kilo-organization-selection-design.md` + +--- + +## Pre-flight (lead-only — do BEFORE dispatching tasks) + +- [ ] **Step 0.1: Confirm clean working tree on `main`** + +```bash +git status +``` + +Expected output: `nothing to commit, working tree clean` on branch `main` (the spec commit `c24e58a4` is already in). + +- [ ] **Step 0.2: Create feature branch** + +```bash +git switch -c feat/kilo-organization-selection +``` + +- [ ] **Step 0.3: Verify Swift toolchain and tests baseline** + +```bash +swift build 2>&1 | tail -5 +swift test --filter KiloUsageFetcherTests 2>&1 | tail -10 +``` + +Expected: build succeeds, KiloUsageFetcher tests pass. + +--- + +## Task 1: Add `KiloOrganization` data type + +**Files:** +- Create: `Sources/CodexBarCore/Providers/Kilo/KiloOrganization.swift` +- Create: `Tests/CodexBarTests/KiloOrganizationTests.swift` + +- [ ] **Step 1.1: Write the failing test** + +Create `Tests/CodexBarTests/KiloOrganizationTests.swift`: + +```swift +import Foundation +import Testing +@testable import CodexBarCore + +struct KiloOrganizationTests { + @Test + func `decodes from canonical Kilo profile payload`() throws { + let json = #""" + { "id": "org_123", "name": "Acme Corp", "role": "owner" } + """# + let data = Data(json.utf8) + let org = try JSONDecoder().decode(KiloOrganization.self, from: data) + #expect(org.id == "org_123") + #expect(org.name == "Acme Corp") + #expect(org.role == "owner") + } + + @Test + func `decodes when role missing`() throws { + let json = #""" + { "id": "org_xyz", "name": "No Role Org" } + """# + let data = Data(json.utf8) + let org = try JSONDecoder().decode(KiloOrganization.self, from: data) + #expect(org.role == nil) + } + + @Test + func `equality covers all stored fields`() { + let a = KiloOrganization(id: "org_1", name: "A", role: "member") + let b = KiloOrganization(id: "org_1", name: "A", role: "member") + let differentRole = KiloOrganization(id: "org_1", name: "A", role: "owner") + #expect(a == b) + #expect(a != differentRole) + } +} +``` + +- [ ] **Step 1.2: Run test to verify it fails** + +```bash +swift test --filter KiloOrganizationTests 2>&1 | tail -10 +``` + +Expected: compile error "cannot find 'KiloOrganization' in scope". + +- [ ] **Step 1.3: Create the type** + +Create `Sources/CodexBarCore/Providers/Kilo/KiloOrganization.swift`: + +```swift +import Foundation + +public struct KiloOrganization: Codable, Sendable, Equatable, Hashable, Identifiable { + public let id: String + public let name: String + public let role: String? + + public init(id: String, name: String, role: String? = nil) { + self.id = id + self.name = name + self.role = role + } +} +``` + +- [ ] **Step 1.4: Run tests, verify pass** + +```bash +swift test --filter KiloOrganizationTests 2>&1 | tail -10 +``` + +Expected: all 3 tests pass. + +- [ ] **Step 1.5: Commit** + +```bash +git add Sources/CodexBarCore/Providers/Kilo/KiloOrganization.swift \ + Tests/CodexBarTests/KiloOrganizationTests.swift +git commit -m "feat(kilo): add KiloOrganization model" +``` + +--- + +## Task 2: Add `KiloUsageScope` enum + +**Files:** +- Create: `Sources/CodexBarCore/Providers/Kilo/KiloUsageScope.swift` +- Modify: `Tests/CodexBarTests/KiloOrganizationTests.swift` + +- [ ] **Step 2.1: Append failing tests** + +Append to `Tests/CodexBarTests/KiloOrganizationTests.swift`: + +```swift +struct KiloUsageScopeTests { + @Test + func `personal scope identifier is stable`() { + let scope: KiloUsageScope = .personal + #expect(scope.scopeIdentifier == "personal") + } + + @Test + func `organization scope identifier prefixes id`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.scopeIdentifier == "org:org_42") + } + + @Test + func `organizationID is nil for personal`() { + #expect(KiloUsageScope.personal.organizationID == nil) + } + + @Test + func `organizationID returns id for organization`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.organizationID == "org_42") + } + + @Test + func `displayName falls back to Personal for personal`() { + #expect(KiloUsageScope.personal.displayName == "Personal") + } + + @Test + func `displayName uses org name for organization`() { + let scope: KiloUsageScope = .organization(id: "org_42", name: "Acme") + #expect(scope.displayName == "Acme") + } +} +``` + +- [ ] **Step 2.2: Run test, verify it fails** + +```bash +swift test --filter KiloUsageScopeTests 2>&1 | tail -10 +``` + +Expected: compile error "cannot find 'KiloUsageScope' in scope". + +- [ ] **Step 2.3: Create the type** + +Create `Sources/CodexBarCore/Providers/Kilo/KiloUsageScope.swift`: + +```swift +import Foundation + +public enum KiloUsageScope: Sendable, Hashable, Equatable { + case personal + case organization(id: String, name: String) + + public var scopeIdentifier: String { + switch self { + case .personal: + "personal" + case let .organization(id, _): + "org:\(id)" + } + } + + public var organizationID: String? { + switch self { + case .personal: + nil + case let .organization(id, _): + id + } + } + + public var displayName: String { + switch self { + case .personal: + "Personal" + case let .organization(_, name): + name + } + } +} +``` + +- [ ] **Step 2.4: Run tests, verify pass** + +```bash +swift test --filter KiloUsageScopeTests 2>&1 | tail -10 +``` + +Expected: all 6 tests pass. + +- [ ] **Step 2.5: Commit** + +```bash +git add Sources/CodexBarCore/Providers/Kilo/KiloUsageScope.swift \ + Tests/CodexBarTests/KiloOrganizationTests.swift +git commit -m "feat(kilo): add KiloUsageScope enum" +``` + +--- + +## Task 3: Inject org header in `KiloUsageFetcher.fetchUsage` + +**Files:** +- Modify: `Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift` +- Modify: `Tests/CodexBarTests/KiloUsageFetcherTests.swift` + +- [ ] **Step 3.1: Append failing test for header injection** + +Append the following inside `struct KiloUsageFetcherTests` in `Tests/CodexBarTests/KiloUsageFetcherTests.swift`: + +```swift + @Test + func `request builder adds org header for organization scope`() throws { + let baseURL = try #require(URL(string: "https://kilo.example/trpc")) + let request = try KiloUsageFetcher._buildRequestForTesting( + baseURL: baseURL, + apiKey: "test-token", + scope: .organization(id: "org_42", name: "Acme")) + #expect(request.value(forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") == "org_42") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + } + + @Test + func `request builder omits org header for personal scope`() throws { + let baseURL = try #require(URL(string: "https://kilo.example/trpc")) + let request = try KiloUsageFetcher._buildRequestForTesting( + baseURL: baseURL, + apiKey: "test-token", + scope: .personal) + #expect(request.value(forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") == nil) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token") + } +``` + +- [ ] **Step 3.2: Run test, verify it fails** + +```bash +swift test --filter KiloUsageFetcherTests 2>&1 | tail -15 +``` + +Expected: compile error — `_buildRequestForTesting` not found. + +- [ ] **Step 3.3: Refactor `KiloUsageFetcher` to accept scope and extract request builder** + +In `Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift`: + +Replace the existing `public static func fetchUsage(apiKey:environment:)` signature (around line 258) with the scoped version, and extract the request building into a testable helper. The new code: + +```swift + public static func fetchUsage( + apiKey: String, + scope: KiloUsageScope = .personal, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> KiloUsageSnapshot + { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw KiloUsageError.missingCredentials + } + + let baseURL = KiloSettingsReader.apiURL(environment: environment) + let request = try self.makeRequest(baseURL: baseURL, apiKey: apiKey, scope: scope) + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw KiloUsageError.networkError(error.localizedDescription) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw KiloUsageError.networkError("Invalid response") + } + + if let mapped = self.statusError(for: httpResponse.statusCode) { + throw mapped + } + + guard httpResponse.statusCode == 200 else { + throw KiloUsageError.apiError(httpResponse.statusCode) + } + + return try self.parseSnapshot(data: data) + } + + static func _buildRequestForTesting( + baseURL: URL, + apiKey: String, + scope: KiloUsageScope) throws -> URLRequest + { + try self.makeRequest(baseURL: baseURL, apiKey: apiKey, scope: scope) + } + + private static func makeRequest( + baseURL: URL, + apiKey: String, + scope: KiloUsageScope) throws -> URLRequest + { + let batchURL = try self.makeBatchURL(baseURL: baseURL) + var request = URLRequest(url: batchURL) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let orgId = scope.organizationID { + request.setValue(orgId, forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") + } + return request + } +``` + +Then delete the old inline `var request = URLRequest(url: batchURL)` setup that lived in `fetchUsage` (it's now in `makeRequest`). + +- [ ] **Step 3.4: Run tests, verify pass** + +```bash +swift test --filter KiloUsageFetcherTests 2>&1 | tail -15 +``` + +Expected: all KiloUsageFetcher tests pass (existing + 2 new). + +- [ ] **Step 3.5: Commit** + +```bash +git add Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift \ + Tests/CodexBarTests/KiloUsageFetcherTests.swift +git commit -m "feat(kilo): scope KiloUsageFetcher.fetchUsage with org header" +``` + +--- + +## Task 4: Add `fetchOrganizations` to `KiloUsageFetcher` + +**Files:** +- Modify: `Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift` +- Modify: `Tests/CodexBarTests/KiloUsageFetcherTests.swift` + +- [ ] **Step 4.1: Append failing parse tests** + +Append inside `struct KiloUsageFetcherTests`: + +```swift + @Test + func `parseOrganizations decodes tRPC array shape`() throws { + let json = #""" + [ + { + "result": { + "data": { + "json": [ + { "id": "org_1", "name": "Alpha", "role": "owner" }, + { "id": "org_2", "name": "Beta", "role": "member" } + ] + } + } + } + ] + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.count == 2) + #expect(orgs[0].id == "org_1") + #expect(orgs[0].name == "Alpha") + #expect(orgs[0].role == "owner") + #expect(orgs[1].id == "org_2") + #expect(orgs[1].role == "member") + } + + @Test + func `parseOrganizations decodes profile REST shape`() throws { + let json = #""" + { + "user": { "email": "test@example.com" }, + "organizations": [ + { "id": "org_42", "name": "Gamma" } + ] + } + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.count == 1) + #expect(orgs[0].id == "org_42") + #expect(orgs[0].role == nil) + } + + @Test + func `parseOrganizations returns empty for no orgs`() throws { + let json = #""" + { "user": { "email": "x@y" }, "organizations": [] } + """# + let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8)) + #expect(orgs.isEmpty) + } +``` + +- [ ] **Step 4.2: Run, verify fail** + +```bash +swift test --filter KiloUsageFetcherTests 2>&1 | tail -10 +``` + +Expected: compile error — `_parseOrganizationsForTesting` undefined. + +- [ ] **Step 4.3: Add organization fetching logic** + +Append to `Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift` (inside the `KiloUsageFetcher` struct): + +```swift + public static func fetchOrganizations( + apiKey: String, + environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> [KiloOrganization] + { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw KiloUsageError.missingCredentials + } + + let baseURL = KiloSettingsReader.apiURL(environment: environment) + let trpcRequest = try self.makeOrgListTRPCRequest(baseURL: baseURL, apiKey: apiKey) + + do { + let (data, response) = try await URLSession.shared.data(for: trpcRequest) + guard let httpResponse = response as? HTTPURLResponse else { + throw KiloUsageError.networkError("Invalid response") + } + if httpResponse.statusCode == 404 { + return try await self.fetchOrganizationsRESTFallback(apiKey: apiKey) + } + if let mapped = self.statusError(for: httpResponse.statusCode) { + throw mapped + } + return try self.parseOrganizations(data: data) + } catch let error as KiloUsageError { + throw error + } catch { + throw KiloUsageError.networkError(error.localizedDescription) + } + } + + static func _parseOrganizationsForTesting(_ data: Data) throws -> [KiloOrganization] { + try self.parseOrganizations(data: data) + } + + private static func makeOrgListTRPCRequest( + baseURL: URL, + apiKey: String) throws -> URLRequest + { + let endpoint = baseURL.appendingPathComponent("user.getOrganizations") + let inputData = try JSONSerialization.data( + withJSONObject: ["0": ["json": NSNull()]] as [String: Any]) + guard let inputString = String(data: inputData, encoding: .utf8), + var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else { + throw KiloUsageError.parseFailed("Invalid org list endpoint") + } + components.queryItems = [ + URLQueryItem(name: "batch", value: "1"), + URLQueryItem(name: "input", value: inputString), + ] + guard let url = components.url else { + throw KiloUsageError.parseFailed("Invalid org list endpoint") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + return request + } + + private static func fetchOrganizationsRESTFallback(apiKey: String) async throws -> [KiloOrganization] { + guard let url = URL(string: "https://api.kilo.ai/api/profile") else { + throw KiloUsageError.parseFailed("Invalid REST fallback URL") + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw KiloUsageError.networkError("Invalid response") + } + if let mapped = self.statusError(for: httpResponse.statusCode) { + throw mapped + } + guard httpResponse.statusCode == 200 else { + throw KiloUsageError.apiError(httpResponse.statusCode) + } + return try self.parseOrganizations(data: data) + } + + private static func parseOrganizations(data: Data) throws -> [KiloOrganization] { + guard let root = try? JSONSerialization.jsonObject(with: data) else { + throw KiloUsageError.parseFailed("Invalid JSON") + } + + // tRPC batch shape: [ { result: { data: { json: [orgs] } } } ] + if let entries = root as? [[String: Any]], + let first = entries.first, + let resultObject = first["result"] as? [String: Any] + { + if let dataObject = resultObject["data"] as? [String: Any], + let payload = dataObject["json"] as? [[String: Any]] + { + return self.decodeOrganizations(payload) + } + if let payload = resultObject["data"] as? [[String: Any]] { + return self.decodeOrganizations(payload) + } + } + + // REST profile shape: { user: ..., organizations: [orgs] } + if let dictionary = root as? [String: Any] { + if let orgs = dictionary["organizations"] as? [[String: Any]] { + return self.decodeOrganizations(orgs) + } + // Some single-procedure tRPC shapes flatten to { result: { data: { json: { organizations: [...] }}}} + if let resultObject = dictionary["result"] as? [String: Any], + let dataObject = resultObject["data"] as? [String: Any] + { + if let payload = dataObject["json"] as? [[String: Any]] { + return self.decodeOrganizations(payload) + } + if let payload = dataObject["json"] as? [String: Any], + let orgs = payload["organizations"] as? [[String: Any]] + { + return self.decodeOrganizations(orgs) + } + } + } + + return [] + } + + private static func decodeOrganizations(_ raw: [[String: Any]]) -> [KiloOrganization] { + raw.compactMap { item -> KiloOrganization? in + guard let id = item["id"] as? String, !id.isEmpty else { return nil } + let name = (item["name"] as? String).map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } ?? id + let role = (item["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedRole = (role?.isEmpty ?? true) ? nil : role + return KiloOrganization(id: id, name: name.isEmpty ? id : name, role: normalizedRole) + } + } +``` + +- [ ] **Step 4.4: Run tests, verify pass** + +```bash +swift test --filter KiloUsageFetcherTests 2>&1 | tail -15 +``` + +Expected: all 3 new parse tests pass plus prior tests. + +- [ ] **Step 4.5: Commit** + +```bash +git add Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift \ + Tests/CodexBarTests/KiloUsageFetcherTests.swift +git commit -m "feat(kilo): add fetchOrganizations with REST fallback" +``` + +--- + +## Task 5: Extend `ProviderConfig` with `kiloOrganizations` + +**Files:** +- Modify: `Sources/CodexBarCore/Config/CodexBarConfig.swift` +- Tests: covered indirectly via Task 6 SettingsStore tests. + +- [ ] **Step 5.1: Add fields to `ProviderConfig`** + +In `Sources/CodexBarCore/Config/CodexBarConfig.swift`, inside `public struct ProviderConfig`: + +After the existing `public var quotaWarnings: QuotaWarningConfig?` line, add: + +```swift + public var kiloKnownOrganizations: [KiloOrganization]? + public var kiloEnabledOrganizationIDs: [String]? +``` + +Then extend the `init` signature with these matching parameters (defaulted to `nil`) and assign them in the body. Match the existing init ordering pattern — add the two new parameters at the bottom of the init parameter list: + +```swift + public init( + id: UsageProvider, + enabled: Bool? = nil, + source: ProviderSourceMode? = nil, + extrasEnabled: Bool? = nil, + apiKey: String? = nil, + cookieHeader: String? = nil, + cookieSource: ProviderCookieSource? = nil, + region: String? = nil, + workspaceID: String? = nil, + enterpriseHost: String? = nil, + tokenAccounts: ProviderTokenAccountData? = nil, + codexActiveSource: CodexActiveSource? = nil, + quotaWarnings: QuotaWarningConfig? = nil, + kiloKnownOrganizations: [KiloOrganization]? = nil, + kiloEnabledOrganizationIDs: [String]? = nil) + { + self.id = id + self.enabled = enabled + self.source = source + self.extrasEnabled = extrasEnabled + self.apiKey = apiKey + self.cookieHeader = cookieHeader + self.cookieSource = cookieSource + self.region = region + self.workspaceID = workspaceID + self.enterpriseHost = enterpriseHost + self.tokenAccounts = tokenAccounts + self.codexActiveSource = codexActiveSource + self.quotaWarnings = quotaWarnings + self.kiloKnownOrganizations = kiloKnownOrganizations + self.kiloEnabledOrganizationIDs = kiloEnabledOrganizationIDs + } +``` + +The implicit `Codable` conformance will pick up the new optional fields automatically. + +- [ ] **Step 5.2: Build to verify schema compiles** + +```bash +swift build 2>&1 | tail -5 +``` + +Expected: build succeeds. + +- [ ] **Step 5.3: Commit** + +```bash +git add Sources/CodexBarCore/Config/CodexBarConfig.swift +git commit -m "feat(kilo): persist kilo organizations in ProviderConfig" +``` + +--- + +## Task 6: Extend `SettingsStore` with Kilo orgs accessors + +**Files:** +- Modify: `Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift` +- Create: `Tests/CodexBarTests/KiloSettingsStoreTests.swift` + +- [ ] **Step 6.1: Write failing tests** + +Create `Tests/CodexBarTests/KiloSettingsStoreTests.swift`: + +```swift +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct KiloSettingsStoreTests { + private func makeSettings() -> SettingsStore { + let env = ProviderConfigEnvironment( + configFileURL: FileManager.default.temporaryDirectory.appendingPathComponent( + "kilo-org-settings-test-\(UUID().uuidString).json"), + environment: [:]) + return SettingsStore(providerConfigEnvironment: env) + } + + @Test + func `defaults to empty known organizations and empty enabled ids`() { + let settings = self.makeSettings() + #expect(settings.kiloKnownOrganizations.isEmpty) + #expect(settings.kiloEnabledOrganizationIDs.isEmpty) + } + + @Test + func `setting known organizations persists them`() { + let settings = self.makeSettings() + let orgs = [ + KiloOrganization(id: "org_1", name: "Alpha", role: "owner"), + KiloOrganization(id: "org_2", name: "Beta", role: "member"), + ] + settings.kiloKnownOrganizations = orgs + #expect(settings.kiloKnownOrganizations == orgs) + } + + @Test + func `setting enabled org ids persists them`() { + let settings = self.makeSettings() + settings.kiloEnabledOrganizationIDs = ["org_1", "org_2"] + #expect(settings.kiloEnabledOrganizationIDs == ["org_1", "org_2"]) + } + + @Test + func `setKiloKnownOrganizations prunes stale enabled ids`() { + let settings = self.makeSettings() + settings.kiloKnownOrganizations = [ + KiloOrganization(id: "org_1", name: "Alpha", role: nil), + KiloOrganization(id: "org_2", name: "Beta", role: nil), + ] + settings.kiloEnabledOrganizationIDs = ["org_1", "org_2"] + settings.setKiloKnownOrganizationsPruningEnabled( + [KiloOrganization(id: "org_2", name: "Beta", role: nil)]) + #expect(settings.kiloKnownOrganizations.map(\.id) == ["org_2"]) + #expect(settings.kiloEnabledOrganizationIDs == ["org_2"]) + } +} +``` + +- [ ] **Step 6.2: Run, verify fail** + +```bash +swift test --filter KiloSettingsStoreTests 2>&1 | tail -10 +``` + +Expected: compile error — missing properties. + +- [ ] **Step 6.3: Add accessors to `KiloSettingsStore`** + +Append to `Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift`: + +```swift +extension SettingsStore { + var kiloKnownOrganizations: [KiloOrganization] { + get { self.configSnapshot.providerConfig(for: .kilo)?.kiloKnownOrganizations ?? [] } + set { + self.updateProviderConfig(provider: .kilo) { entry in + entry.kiloKnownOrganizations = newValue.isEmpty ? nil : newValue + } + } + } + + var kiloEnabledOrganizationIDs: [String] { + get { self.configSnapshot.providerConfig(for: .kilo)?.kiloEnabledOrganizationIDs ?? [] } + set { + let cleaned = Array(LinkedHashSet(newValue + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty })) + self.updateProviderConfig(provider: .kilo) { entry in + entry.kiloEnabledOrganizationIDs = cleaned.isEmpty ? nil : cleaned + } + self.logProviderModeChange( + provider: .kilo, + field: "enabledOrganizations", + value: cleaned.joined(separator: ",")) + } + } + + func setKiloKnownOrganizationsPruningEnabled(_ orgs: [KiloOrganization]) { + self.kiloKnownOrganizations = orgs + let validIDs = Set(orgs.map(\.id)) + let pruned = self.kiloEnabledOrganizationIDs.filter { validIDs.contains($0) } + if pruned != self.kiloEnabledOrganizationIDs { + self.kiloEnabledOrganizationIDs = pruned + } + } + + func kiloIsOrganizationEnabled(_ orgID: String) -> Bool { + self.kiloEnabledOrganizationIDs.contains(orgID) + } + + func setKiloOrganization(_ orgID: String, enabled: Bool) { + var current = self.kiloEnabledOrganizationIDs + if enabled { + guard !current.contains(orgID) else { return } + current.append(orgID) + } else { + current.removeAll { $0 == orgID } + } + self.kiloEnabledOrganizationIDs = current + } +} + +// Small order-preserving set used to dedupe enabled IDs without sorting. +private struct LinkedHashSet: Sequence { + private var seen: Set = [] + private var ordered: [Element] = [] + + init(_ sequence: S) where S.Element == Element { + for element in sequence where self.seen.insert(element).inserted { + self.ordered.append(element) + } + } + + func makeIterator() -> IndexingIterator<[Element]> { + self.ordered.makeIterator() + } +} +``` + +- [ ] **Step 6.4: Run tests, verify pass** + +```bash +swift test --filter KiloSettingsStoreTests 2>&1 | tail -10 +``` + +Expected: all 4 tests pass. + +- [ ] **Step 6.5: Commit** + +```bash +git add Sources/CodexBar/Providers/Kilo/KiloSettingsStore.swift \ + Tests/CodexBarTests/KiloSettingsStoreTests.swift +git commit -m "feat(kilo): settings accessors for known + enabled organizations" +``` + +--- + +## Task 7: Wire scoped fetching into Kilo strategies + +**Files:** +- Modify: `Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift` +- Modify: `Sources/CodexBar/UsageStore+Refresh.swift` +- Create: `Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift` + +The `ProviderFetchStrategy` returns one `UsageSnapshot`, so we keep the existing strategy returning the personal scope. Org snapshots are fanned out at the UsageStore layer, mirroring `refreshTokenAccounts`. + +- [ ] **Step 7.1: Add scope-aware overload to the AP strategies (no-op call path so they still build)** + +This task does NOT change `KiloAPIFetchStrategy.fetch`. The strategy continues to fetch the personal scope. The fan-out is added at the UsageStore layer below. Skip directly to step 7.2. + +- [ ] **Step 7.2: Add a Kilo scope account adapter** + +The codebase already has `TokenAccountUsageSnapshot` for stacked rendering. We mirror that for Kilo scopes. + +Create `Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift`: + +```swift +import CodexBarCore +import Foundation + +struct KiloScopeSnapshot: Identifiable, Equatable { + let id: String // KiloUsageScope.scopeIdentifier + let scope: KiloUsageScope + let snapshot: UsageSnapshot? + let errorMessage: String? + let sourceLabel: String? + + static func == (lhs: KiloScopeSnapshot, rhs: KiloScopeSnapshot) -> Bool { + lhs.id == rhs.id + && lhs.snapshot?.updatedAt == rhs.snapshot?.updatedAt + && lhs.errorMessage == rhs.errorMessage + && lhs.sourceLabel == rhs.sourceLabel + } +} + +extension UsageStore { + var kiloEnabledScopes: [KiloUsageScope] { + var scopes: [KiloUsageScope] = [.personal] + let enabled = self.settings.kiloEnabledOrganizationIDs + guard !enabled.isEmpty else { return scopes } + let knownByID = Dictionary( + uniqueKeysWithValues: self.settings.kiloKnownOrganizations.map { ($0.id, $0) }) + for id in enabled { + if let org = knownByID[id] { + scopes.append(.organization(id: org.id, name: org.name)) + } + } + return scopes + } + + func shouldFanOutKiloScopes() -> Bool { + self.kiloEnabledScopes.count > 1 + } + + func refreshKiloScopes() async { + let scopes = self.kiloEnabledScopes + guard scopes.count > 1 else { + await MainActor.run { self.kiloScopeSnapshots = [] } + return + } + let apiKey = self.settings.configSnapshot.providerConfig(for: .kilo)?.sanitizedAPIKey + ?? ProcessInfo.processInfo.environment[KiloSettingsReader.apiTokenKey] + guard let resolvedKey = apiKey, !resolvedKey.isEmpty else { + await MainActor.run { + self.kiloScopeSnapshots = scopes.map { + KiloScopeSnapshot( + id: $0.scopeIdentifier, + scope: $0, + snapshot: nil, + errorMessage: "Kilo API credentials missing.", + sourceLabel: nil) + } + } + return + } + + let env = ProcessInfo.processInfo.environment + let results: [KiloScopeSnapshot] = await withTaskGroup(of: KiloScopeSnapshot.self) { group in + for scope in scopes { + group.addTask { + do { + let raw = try await KiloUsageFetcher.fetchUsage( + apiKey: resolvedKey, + scope: scope, + environment: env) + var snapshot = raw.toUsageSnapshot() + snapshot = snapshot.replacingIdentityOrganization(scope.displayName) + return KiloScopeSnapshot( + id: scope.scopeIdentifier, + scope: scope, + snapshot: snapshot, + errorMessage: nil, + sourceLabel: "api") + } catch { + return KiloScopeSnapshot( + id: scope.scopeIdentifier, + scope: scope, + snapshot: nil, + errorMessage: (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription, + sourceLabel: nil) + } + } + } + var collected: [KiloScopeSnapshot] = [] + for await result in group { + collected.append(result) + } + return collected + } + + // Preserve the order from `scopes` (personal first, then enabled orgs in order). + let resultByID = Dictionary(uniqueKeysWithValues: results.map { ($0.id, $0) }) + let ordered = scopes.compactMap { resultByID[$0.scopeIdentifier] } + + await MainActor.run { + self.kiloScopeSnapshots = ordered + } + } +} + +extension UsageSnapshot { + fileprivate func replacingIdentityOrganization(_ org: String) -> UsageSnapshot { + let baseIdentity = self.identity + let newIdentity = ProviderIdentitySnapshot( + providerID: baseIdentity?.providerID ?? .kilo, + accountEmail: baseIdentity?.accountEmail, + accountOrganization: org, + loginMethod: baseIdentity?.loginMethod) + return UsageSnapshot( + primary: self.primary, + secondary: self.secondary, + tertiary: self.tertiary, + providerCost: self.providerCost, + updatedAt: self.updatedAt, + identity: newIdentity) + } +} +``` + +- [ ] **Step 7.3: Add the `kiloScopeSnapshots` stored property to `UsageStore`** + +In `Sources/CodexBar/UsageStore.swift`, find the closest place where Codex-related published properties live (e.g. near `codexAccountSnapshots`) and add: + +```swift + @Published var kiloScopeSnapshots: [KiloScopeSnapshot] = [] +``` + +Choose a location adjacent to existing per-provider stacked snapshot arrays. The exact line is around `codexAccountSnapshots: [CodexAccountUsageSnapshot] = []` — add directly below it. + +- [ ] **Step 7.4: Invoke fan-out from `refreshProvider`** + +In `Sources/CodexBar/UsageStore+Refresh.swift`, find the existing `tokenAccounts` fan-out block in `refreshProvider`: + +```swift + let tokenAccounts = self.tokenAccounts(for: provider) + if self.shouldFetchAllTokenAccounts(provider: provider, accounts: tokenAccounts) { + await self.refreshTokenAccounts(provider: provider, accounts: tokenAccounts) + return + } +``` + +Insert directly above it (before line 55): + +```swift + if provider == .kilo, self.shouldFanOutKiloScopes() { + await self.refreshKiloScopes() + // Continue to also fetch the personal snapshot through the regular path + // so the existing single-card render keeps working when only personal is shown. + // The presence of multi-element kiloScopeSnapshots triggers stacked rendering. + } +``` + +- [ ] **Step 7.5: Reset `kiloScopeSnapshots` when Kilo is disabled or single-scope** + +Inside the existing disabled-provider branch of `refreshProvider` (the block that runs when `!spec.isEnabled()`), add inside the `MainActor.run`: + +```swift + if provider == .kilo { + self.kiloScopeSnapshots = [] + } +``` + +Also at the start of the regular fetch path (just before `let fetchContext = spec.makeFetchContext()`), add: + +```swift + if provider == .kilo, !self.shouldFanOutKiloScopes() { + await MainActor.run { self.kiloScopeSnapshots = [] } + } +``` + +- [ ] **Step 7.6: Build to confirm wiring compiles** + +```bash +swift build 2>&1 | tail -15 +``` + +Expected: succeeds. If `UsageSnapshot.replacingIdentityOrganization` clashes with an existing extension, rename to `withAccountOrganization` and update the call site. + +- [ ] **Step 7.7: Commit** + +```bash +git add Sources/CodexBar/UsageStore.swift \ + Sources/CodexBar/UsageStore+Refresh.swift \ + Sources/CodexBar/Providers/Kilo/UsageStore+KiloOrgRefresh.swift +git commit -m "feat(kilo): fan out usage fetch per enabled scope" +``` + +--- + +## Task 8: Surface scope snapshots in menu rendering + +**Files:** +- Modify: `Sources/CodexBar/StatusItemController+MenuCardModel.swift` (or the file that produces Kilo menu rows) +- Modify: `Sources/CodexBar/MenuDescriptor.swift` if needed + +The menu currently renders one Kilo card. Add a branch: when `kiloScopeSnapshots` has 2+ entries, render one card per scope. + +- [ ] **Step 8.1: Locate the Kilo menu-row producer** + +```bash +grep -n "case \\.kilo" Sources/CodexBar/StatusItemController*.swift Sources/CodexBar/Menu*.swift 2>&1 | head -15 +``` + +This points at the rendering site. Open whichever file produces the per-provider row group for Kilo. + +- [ ] **Step 8.2: Insert scope-fan-out in the Kilo row builder** + +At the location that produces the Kilo `MenuCardModel` (or the equivalent NSMenu items), guard: + +```swift +if !self.kiloScopeSnapshots.isEmpty, self.kiloScopeSnapshots.count > 1 { + return self.kiloScopeSnapshots.map { scope -> MenuCardModel in + self.makeKiloMenuCard( + snapshot: scope.snapshot, + errorMessage: scope.errorMessage, + sourceLabel: scope.sourceLabel, + scopeName: scope.scope.displayName) + } +} +``` + +If a helper named `makeKiloMenuCard(...)` does not exist, factor the existing inline Kilo card construction into one, taking the four parameters above. Reuse the same code path used by Claude's stacked tokenAccount rendering as a structural reference. + +- [ ] **Step 8.3: Verify visually using CLI snapshot test (no UI required)** + +```bash +swift test --filter CLIRendererTests 2>&1 | tail -15 +swift test --filter MenuCardModelTests 2>&1 | tail -15 +``` + +Existing tests must continue to pass. + +- [ ] **Step 8.4: Build** + +```bash +swift build 2>&1 | tail -5 +``` + +Expected: success. + +- [ ] **Step 8.5: Commit** + +```bash +git add Sources/CodexBar/StatusItemController+MenuCardModel.swift \ + Sources/CodexBar/MenuDescriptor.swift +git commit -m "feat(kilo): render one menu card per enabled scope" +``` + +--- + +## Task 9: Preferences pane — Organizations section + +**Files:** +- Modify: `Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift` +- Possibly modify: `Sources/CodexBar/PreferencesProviderDetailView.swift` and `Sources/CodexBarCore/Providers/ProviderDescriptor.swift` if a new descriptor variant is needed. + +If a multi-toggle descriptor is not yet supported, surface the org list using a `ProviderSettingsFieldDescriptor.kind = .info`-style wrapper combined with action buttons, OR add a new descriptor variant. Pick the minimum needed. + +- [ ] **Step 9.1: Add an org-section descriptor type** + +Search first to see whether the existing `ProviderSettingsFieldDescriptor` already has a list/toggle kind: + +```bash +grep -n "enum Kind\|case info\|case toggleList\|case checkboxList" \ + Sources/CodexBarCore/Providers/ProviderDescriptor.swift \ + Sources/CodexBar/PreferencesProviderDetailView.swift 2>&1 | head -20 +``` + +If a toggle-list kind exists, reuse it. Otherwise, add a new `ProviderSettingsOrganizationsDescriptor` in `Sources/CodexBarCore/Providers/ProviderDescriptor.swift`: + +```swift +public struct ProviderSettingsOrganizationsDescriptor: Sendable { + public let id: String + public let title: String + public let subtitle: String? + public let entries: () -> [Entry] + public let onToggle: @MainActor (String, Bool) -> Void + public let onRefresh: @MainActor () async -> RefreshOutcome + public let canRefresh: () -> Bool + + public struct Entry: Sendable, Identifiable { + public let id: String + public let title: String + public let subtitle: String? + public let isEnabled: Bool + public let isLocked: Bool + + public init(id: String, title: String, subtitle: String?, isEnabled: Bool, isLocked: Bool) { + self.id = id + self.title = title + self.subtitle = subtitle + self.isEnabled = isEnabled + self.isLocked = isLocked + } + } + + public struct RefreshOutcome: Sendable { + public let success: Bool + public let errorMessage: String? + + public init(success: Bool, errorMessage: String? = nil) { + self.success = success + self.errorMessage = errorMessage + } + } + + public init( + id: String, + title: String, + subtitle: String?, + entries: @escaping () -> [Entry], + onToggle: @escaping @MainActor (String, Bool) -> Void, + onRefresh: @escaping @MainActor () async -> RefreshOutcome, + canRefresh: @escaping () -> Bool) + { + self.id = id + self.title = title + self.subtitle = subtitle + self.entries = entries + self.onToggle = onToggle + self.onRefresh = onRefresh + self.canRefresh = canRefresh + } +} +``` + +Then add an optional `settingsOrganizations:` slot to whatever protocol `ProviderImplementation` exposes (e.g. add `func settingsOrganizations(context: ProviderSettingsContext) -> ProviderSettingsOrganizationsDescriptor?`). Default the protocol method to `nil`. + +- [ ] **Step 9.2: Implement `settingsOrganizations` in `KiloProviderImplementation`** + +In `Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift`: + +```swift + @MainActor + func settingsOrganizations( + context: ProviderSettingsContext) -> ProviderSettingsOrganizationsDescriptor? + { + ProviderSettingsOrganizationsDescriptor( + id: "kilo-organizations", + title: "Organizations", + subtitle: "Show usage for organizations you belong to. Personal account is always shown.", + entries: { + var entries: [ProviderSettingsOrganizationsDescriptor.Entry] = [ + .init( + id: "personal", + title: "Personal account", + subtitle: nil, + isEnabled: true, + isLocked: true), + ] + for org in context.settings.kiloKnownOrganizations { + entries.append( + .init( + id: org.id, + title: org.name, + subtitle: org.role, + isEnabled: context.settings.kiloIsOrganizationEnabled(org.id), + isLocked: false)) + } + return entries + }, + onToggle: { orgID, enabled in + guard orgID != "personal" else { return } + context.settings.setKiloOrganization(orgID, enabled: enabled) + }, + onRefresh: { + let apiKey = context.settings.kiloAPIToken.isEmpty + ? ProcessInfo.processInfo.environment[KiloSettingsReader.apiTokenKey] ?? "" + : context.settings.kiloAPIToken + guard !apiKey.isEmpty else { + return .init(success: false, + errorMessage: "Set the Kilo API key first.") + } + do { + let orgs = try await KiloUsageFetcher.fetchOrganizations(apiKey: apiKey) + context.settings.setKiloKnownOrganizationsPruningEnabled(orgs) + return .init(success: true) + } catch let error as LocalizedError { + return .init(success: false, + errorMessage: error.errorDescription ?? "Failed to load organizations.") + } catch { + return .init(success: false, + errorMessage: error.localizedDescription) + } + }, + canRefresh: { + !context.settings.kiloAPIToken.isEmpty + || !(ProcessInfo.processInfo.environment[KiloSettingsReader.apiTokenKey] ?? "").isEmpty + }) + } +``` + +- [ ] **Step 9.3: Render the new descriptor in `PreferencesProviderDetailView`** + +In `Sources/CodexBar/PreferencesProviderDetailView.swift`, follow the existing pattern used by `settingsTokenAccounts`. Add a stored property `settingsOrganizations: ProviderSettingsOrganizationsDescriptor?`, source it from the provider implementation, and render it as a SwiftUI section under the API key: + +```swift +if let descriptor = self.settingsOrganizations { + Section(descriptor.title) { + if let subtitle = descriptor.subtitle { + Text(subtitle).font(.caption).foregroundStyle(.secondary) + } + ForEach(descriptor.entries(), id: \.id) { entry in + Toggle(isOn: Binding( + get: { entry.isEnabled }, + set: { newValue in descriptor.onToggle(entry.id, newValue) })) + { + VStack(alignment: .leading, spacing: 2) { + Text(entry.title) + if let subtitle = entry.subtitle { + Text(subtitle).font(.caption).foregroundStyle(.secondary) + } + } + } + .disabled(entry.isLocked) + } + HStack { + Button("Refresh organizations") { + Task { + let result = await descriptor.onRefresh() + if !result.success, let message = result.errorMessage { + self.kiloOrganizationsErrorMessage = message + } else { + self.kiloOrganizationsErrorMessage = nil + } + } + } + .disabled(!descriptor.canRefresh()) + Spacer() + if let message = self.kiloOrganizationsErrorMessage { + Text(message).font(.caption).foregroundStyle(.red) + } + } + } +} +``` + +Add `@State private var kiloOrganizationsErrorMessage: String?` near other `@State` properties in the view. + +- [ ] **Step 9.4: Build and verify** + +```bash +swift build 2>&1 | tail -10 +``` + +Expected: success. If type mismatches arise, follow them and align signatures. + +- [ ] **Step 9.5: Commit** + +```bash +git add Sources/CodexBarCore/Providers/ProviderDescriptor.swift \ + Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift \ + Sources/CodexBar/PreferencesProviderDetailView.swift +git commit -m "feat(kilo): Preferences organizations section with refresh + toggles" +``` + +--- + +## Task 10: Update Kilo docs + +**Files:** +- Modify: `docs/kilo.md` + +- [ ] **Step 10.1: Add a "Organizations" section** + +Append to `docs/kilo.md`: + +```markdown +## Organizations + +CodexBar can show usage for any Kilo organization the API key belongs to. + +- Open Preferences → Providers → Kilo, set the API key, then click **Refresh + organizations**. +- Toggle the organizations you want to display alongside Personal. Personal is + always shown. +- When at least one organization is enabled, the menu renders one Kilo card per + enabled scope. +- The CodexBar fetcher sends the standard `X-KILOCODE-ORGANIZATIONID` header on + every usage call to scope the response to that organization. +- CLI source mode (`auth.json`): the header is applied to CLI-resolved tokens + as well. If a CLI token isn't authorized for the chosen organization, that + card surfaces an unauthorized error while Personal and other enabled scopes + continue to render normally. +``` + +- [ ] **Step 10.2: Commit** + +```bash +git add docs/kilo.md +git commit -m "docs(kilo): document organization selection" +``` + +--- + +## Task 11: Repo-wide validation + +- [ ] **Step 11.1: Run the full unit test suite** + +```bash +swift test 2>&1 | tail -30 +``` + +Expected: all tests pass. If new failures appear in unrelated tests (network-flaky, etc.), record and rerun. + +- [ ] **Step 11.2: Run `make check`** + +```bash +make check 2>&1 | tail -30 +``` + +Expected: swiftformat + swiftlint clean. Fix any reported issues by applying suggestions inline and re-running. + +- [ ] **Step 11.3: Build release config to ensure no debug-only types leaked** + +```bash +swift build -c release 2>&1 | tail -10 +``` + +Expected: success. + +- [ ] **Step 11.4: Commit any lint/format fixups** + +```bash +git status +git diff --stat +git add -A +git commit -m "chore: swiftformat/swiftlint fixups for kilo orgs work" +``` + +(Skip if no diff.) + +--- + +## Task 12: Open the PR (lead-only — runs after all build tasks land) + +- [ ] **Step 12.1: Ensure a fork exists for `noefabris`** + +```bash +gh repo view noefabris/CodexBar --json url 2>&1 | head -5 +``` + +If 404, fork: + +```bash +gh repo fork steipete/CodexBar --remote=false --clone=false +``` + +- [ ] **Step 12.2: Add fork as a remote (if missing) and push** + +```bash +git remote get-url fork 2>/dev/null || git remote add fork https://github.com/noefabris/CodexBar.git +git push -u fork feat/kilo-organization-selection +``` + +- [ ] **Step 12.3: Create the PR** + +```bash +gh pr create \ + --repo steipete/CodexBar \ + --base main \ + --head noefabris:feat/kilo-organization-selection \ + --title "Add Kilo organization selection (usage stacking)" \ + --body "$(cat <<'EOF' +## Summary +- Adds Kilo organization selection to Preferences → Providers → Kilo. +- Refresh button fetches `user.getOrganizations` (with `/api/profile` REST fallback). +- Each enabled organization is fetched in parallel with the personal account using the standard `X-KILOCODE-ORGANIZATIONID` header. +- The Kilo menu now stacks one card per enabled scope (Personal + each chosen org), reusing the existing multi-snapshot rendering pattern. + +## Design doc +- `docs/superpowers/specs/2026-05-11-kilo-organization-selection-design.md` +- `docs/superpowers/plans/2026-05-11-kilo-organization-selection.md` + +## Test plan +- [x] `swift test` passes +- [x] `make check` clean +- [x] `swift build -c release` succeeds +- [ ] Manual: launch app, set Kilo API key, hit Refresh organizations, toggle orgs, observe stacked menu cards +- [ ] Manual: revoke org permission and confirm only that scope errors out + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Capture the printed PR URL. + +- [ ] **Step 12.4: Report the PR URL back to the user** + +--- + +## Self-review checklist + +- [x] Each spec section has at least one task implementing it. +- [x] Task 4 covers both tRPC and REST org-discovery shapes (spec §2). +- [x] Task 7 fan-out covers both API and CLI source modes — the strategy resolves the token, then `refreshKiloScopes` reuses the same API key transport. +- [x] Persistence covered by Task 5 + 6. +- [x] UI section covered by Task 9. +- [x] Menu rendering covered by Task 8. +- [x] Tests written before implementation per TDD in Tasks 1–6. +- [x] No placeholders ("TBD", "fill in later", etc.). +- [x] Types stay consistent: `KiloOrganization`, `KiloUsageScope`, `KiloScopeSnapshot` referenced consistently across tasks. +- [x] Out-of-scope items from spec (menu switcher, multi-key auth, widget) intentionally absent. + +If during execution any task uncovers an actual gap not covered above (e.g. existing tests that mock `KiloUsageFetcher.fetchUsage(apiKey:environment:)` without `scope`), update them to use `scope: .personal` explicitly — keep the default-parameter migration path even though tests typically pass it explicitly. diff --git a/docs/superpowers/specs/2026-05-11-kilo-organization-selection-design.md b/docs/superpowers/specs/2026-05-11-kilo-organization-selection-design.md new file mode 100644 index 000000000..20e09e9db --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-kilo-organization-selection-design.md @@ -0,0 +1,148 @@ +# Kilo Organization Selection & Usage — Design + +**Status:** approved +**Date:** 2026-05-11 +**Owner:** noefabris + +## Problem + +The Kilo provider in CodexBar always queries the personal account. Users who belong to one or more Kilo organizations cannot see organization-level credits, KiloPass usage, or plan info. Kilo's own clients (VS Code extension, CLI) let users pick "Personal" or any organization they belong to and route requests with an `X-KILOCODE-ORGANIZATIONID` header. + +Goal: let CodexBar users opt in to seeing one or more Kilo organizations alongside their personal account. + +## Non-goals + +- Menu-bar org switcher. Selection lives in Preferences only. +- Editing org membership from CodexBar (read-only consumer of Kilo's org list). +- Per-org auth (CodexBar reuses the same API key/CLI session; Kilo's gateway scopes via header). +- Replacing the existing single-account `Personal` flow when no orgs are configured. + +## Constraints / context + +- Kilo gateway accepts `X-KILOCODE-ORGANIZATIONID: ` to scope any authenticated request. Documented at `kilo.ai/docs/gateway/authentication`. +- Profile endpoint shape (from `Kilo-Org/kilocode` `packages/kilo-gateway/src/api/profile.ts`): + `GET /api/profile` → `{ user: { email, name }, organizations: [{ id, name, role }] }`. +- CodexBar's Kilo provider currently calls `https://app.kilo.ai/api/trpc` with procedures `user.getCreditBlocks`, `kiloPass.getState`, `user.getAutoTopUpPaymentMethod`. The `X-KILOCODE-ORGANIZATIONID` header is transport-level and must work for the same procedures. +- Existing CodexBar patterns to reuse: + - `ProviderIdentitySnapshot.accountOrganization` for rendering the org name in a card. + - Stacked multi-snapshot rendering used by Claude tokenAccounts (Preferences → Advanced → Display). + - `~/.codexbar/config.json` per-provider entry for persisted state. + +## User stories + +1. **As a user with no orgs**, the Kilo card looks exactly as it does today. No new UI noise. +2. **As a user with one or more orgs**, I can open Preferences → Providers → Kilo, hit "Refresh organizations", see my org list, and tick the orgs I want to monitor. Each enabled org appears as its own card stacked with Personal in the menu. +3. **As a user whose API key isn't set**, the Organizations section explains I need the key first and the Refresh button is disabled. +4. **As a user using CLI source mode**, org selection still works — the header is sent on every fetch regardless of where the bearer token came from. + +## Architecture + +### Data model + +- `KiloOrganization` (Sendable, Codable, Equatable) in `Sources/CodexBarCore/Providers/Kilo/`: + - `id: String` + - `name: String` + - `role: String?` (optional; treated as display-only) +- `KiloUsageScope` (Sendable, Hashable) in same module: + - `.personal` + - `.organization(id: String, name: String)` + - A `scopeIdentifier` computed property: `"personal"` or `"org:"` used as the snapshot map key. +- `KiloUsageSnapshot` gains `scope: KiloUsageScope` (default `.personal` keeps existing call sites compiling). + +### API layer (`KiloUsageFetcher`) + +- Existing `fetchUsage(apiKey:environment:)` becomes: + `fetchUsage(apiKey:scope:environment:)` with `scope: KiloUsageScope = .personal`. + - When `.organization(id, _)`: set request header `X-KILOCODE-ORGANIZATIONID: id` on the existing tRPC batch URLRequest. Everything else unchanged. +- New `fetchOrganizations(apiKey:environment:)` → `[KiloOrganization]`: + - Primary: tRPC batch call to `user.getOrganizations` against `https://app.kilo.ai/api/trpc`. Parse the same payload-context shape as other procedures (defensive against schema drift). + - Fallback: if tRPC returns 404 / endpoint not found, fall back to `GET https://api.kilo.ai/api/profile` and read `data.organizations`. Both endpoints are part of the documented Kilo Gateway. + - Returns `[]` (not error) when the user has no orgs. + - Maps `401/403 → KiloUsageError.unauthorized`, `404 → endpointNotFound`, etc., using the existing `statusError(for:)`. + +### Settings + +`SettingsStore` extension (new file `SettingsStore+Kilo.swift` or extend `KiloSettingsStore.swift`): + +- `kiloKnownOrganizations: [KiloOrganization]` — cache of organizations last fetched. Survives restart. +- `kiloEnabledOrganizationIDs: Set` — which orgs the user wants to fetch + render. +- Personal scope is implicit: always enabled, can't be toggled off. + +Persistence: +- Add `organizations: [KiloOrganization]?` and `enabledOrganizationIds: [String]?` to the Kilo provider's entry in `~/.codexbar/config.json`. +- Mutators write through `updateProviderConfig(provider: .kilo)`. + +### Refresh / UsageStore + +- `UsageStore`'s Kilo refresh path computes the active scope list: `[.personal] + enabled orgs from kiloKnownOrganizations`. +- Fan-out using a `TaskGroup`, one child per scope, each calling `KiloUsageFetcher.fetchUsage(apiKey:scope:)`. +- Per-scope failures isolated: a 403 on one org sets that scope's error state but does not affect personal or other orgs. +- Snapshots stored in a new dictionary `kiloScopedSnapshots: [String: KiloUsageSnapshot]` keyed by `scope.scopeIdentifier`, alongside the existing single-snapshot field. When `kiloScopedSnapshots` has more than one entry the menu uses stacked rendering; otherwise existing single-card rendering. + +### UI — Preferences → Providers → Kilo + +- New section header "Organizations" placed below the API key field. +- Row 1 (always): `Personal account` with a disabled-on checkbox. +- Rows 2..N: each known organization with a togglable checkbox `[✓] `. +- Empty state when `kiloKnownOrganizations` is empty: "No organizations loaded. Click Refresh after setting your API key." +- "Refresh organizations" button: + - Disabled when API key is empty. + - Calls `KiloUsageFetcher.fetchOrganizations(apiKey:)` on a background task. + - On success: writes to `kiloKnownOrganizations`, prunes `kiloEnabledOrganizationIDs` entries that no longer exist. + - On 401/403: surface inline error "API key unauthorized. Refresh or update it." + - On network error: surface inline error. + +### Menu rendering + +- The Kilo card renderer reads `kiloScopedSnapshots`. When count > 1, render each as a stacked card using the same vertical layout already used by Claude's stacked tokenAccount snapshots. +- Each scope's card sets `ProviderIdentitySnapshot.accountOrganization`: + - `.personal` → `"Personal"` + - `.organization(_, name)` → `name` +- Existing credit/pass/plan rows render unchanged inside each card. + +### Errors / edge cases + +| Case | Behavior | +| --- | --- | +| User has no orgs | Personal scope only. Org section shows empty state. No fan-out. | +| API key missing | Refresh button disabled. Existing missing-credentials error still surfaces on usage fetch. | +| Org refresh fails (401/403) | Inline error in Preferences; keep cached list. | +| Org usage fetch returns 403 | That org's card shows a small error label; personal + other orgs render normally. | +| Org removed on Kilo side | Next Refresh drops it from `kiloKnownOrganizations`; if it was in `kiloEnabledOrganizationIDs` it is silently pruned. | +| CLI source mode | Header sent same way. If the resolved CLI bearer can't scope to that org, it 403s — handled by the per-scope error case above. | +| Source = `auto` | Org scope follows the same auto fallback; failures inside the scope respect the fallback chain. | +| Multiple orgs enabled | Concurrent fetch; per-scope timeouts isolated. | + +## Testing + +- Unit tests (in `Tests/CodexBarTests`): + - `KiloUsageFetcherTests`: + - Header injection: `.organization(id, _)` → request contains `X-KILOCODE-ORGANIZATIONID: id`. `.personal` → no header. + - `fetchOrganizations` parses both tRPC shape and `/api/profile` REST shape. + - 401/403 → `.unauthorized`. + - `KiloSettingsStoreTests`: + - Round-trip of `kiloKnownOrganizations` and `kiloEnabledOrganizationIDs` through config.json. + - Pruning of stale IDs when known list shrinks. + - `UsageStore+KiloRefreshTests`: + - Fan-out runs N scopes, per-scope error isolation. + - Single-scope path unchanged when no orgs enabled. +- CLI snapshot test (`Tests/CodexBarTests/CLIRendererTests` or similar): `codexbar-cli kilo` shows one section per active scope when orgs are enabled. +- Run `make check` and `swift test` before handoff. + +## Migration / compatibility + +- `KiloUsageSnapshot.scope` defaults to `.personal` — all existing call sites compile unchanged. +- Config additions are optional — old configs continue to load as personal-only. +- No new dependencies. + +## Open items (verify during build) + +- Confirm `https://app.kilo.ai/api/trpc/user.getOrganizations` exists. If not, the REST fallback to `https://api.kilo.ai/api/profile` is the path of record. +- Confirm the tRPC procedures honor `X-KILOCODE-ORGANIZATIONID`. Documented for gateway; spot-check during integration. + +## Out of scope + +- Menu-bar org switcher UI. +- Per-org token storage / multiple API keys for the same provider. +- CLI command for org switching (`codexbar-cli` consumes the same settings). +- Widget rendering changes for multi-scope (widget continues to show personal scope). diff --git a/docs/ui.md b/docs/ui.md index 3c9c7c3bf..b0c0c1746 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -10,25 +10,33 @@ read_when: ## Menu bar - LSUIElement app: no Dock icon; status item uses custom NSImage. - Merge Icons toggle combines providers into one status item with a switcher. +- Provider status items use stable autosave names and are reused across provider toggles so macOS can preserve icon + positions. - When Overview has selected providers, the switcher includes an Overview tab that renders up to 3 provider rows. - Overview row order follows provider order; selecting a row jumps to that provider detail card. +- The global open-menu keyboard shortcut toggles the currently tracked menu closed before opening a new one. ## Icon rendering - 18×18 template image. -- Top bar = 5-hour window; bottom hairline = weekly window. +- Bar windows are provider/style-specific primary and secondary windows. - Fill represents percent remaining by default; “Show usage as used” flips to percent used. -- Dimmed when last refresh failed; status overlays render incident indicators. -- Advanced: menu bar can show provider branding icons with a percent label instead of critter bars. +- Renderer/critter icons dim when last refresh failed and can render incident indicators; brand display mode uses provider branding plus title text. +- Loading animation runs at a bounded frame rate and has a hard continuous-duration ceiling so provider hangs cannot keep + the menu bar redrawing forever. +- Display → Menu bar: menu bar can show provider branding icons with a percent label instead of critter bars. ## Menu card -- Session + weekly rows with resets (countdown by default; optional absolute clock display). -- Codex-only: Credits + “Buy Credits…” in-card action. -- Web-only rows (when OpenAI cookies are enabled): code review remaining, usage breakdown submenu. +- Provider-specific rows with resets (countdown by default; optional absolute clock display). Primary, secondary, + tertiary, and extra windows render when the provider snapshot has data for them. +- Codex credits can add a separate “Buy Credits…” menu action. +- Codex OpenAI web extras: code review remaining and usage breakdown render when dashboard data is attached. - Token accounts: optional account switcher bar or stacked account cards (up to 6) when multiple manual tokens exist. +- Provider storage usage is opt-in from Advanced settings. When enabled, overview rows and provider detail cards can show + local provider-owned storage totals, with a submenu for path breakdowns and copyable paths. ## Pace tracking -Pace compares your actual usage against an even-consumption budget that would spread your allowance evenly across the reset window. +Pace compares your actual usage against the expected consumption rate for the current window. Most providers use an even-consumption budget; Codex can use historical pace data when historical tracking is available. - **On pace** – usage matches the expected rate. - **X% in deficit** – you're consuming faster than the even rate; at this pace you'll run out before the window resets. @@ -36,18 +44,20 @@ Pace compares your actual usage against an even-consumption budget that would sp When usage is in deficit, the right-hand label shows an estimated "Runs out in …" countdown. When usage will last until the reset, it shows "Lasts until reset". -Pace is calculated for Codex and Claude weekly windows only and is hidden when less than 3% of the window has elapsed. +Pace is calculated for any provider window with enough reset timing data and is hidden when less than 3% of the +window has elapsed. ## Preferences notes - Advanced: “Disable Keychain access” turns off browser cookie import; paste Cookie headers manually in Providers. +- Advanced: “Show provider storage usage” enables background scans of known provider-owned local paths; CodexBar only + reports sizes and cleanup ideas, it does not delete files. - Display: “Overview tab providers” controls which providers appear in Merge Icons → Overview (up to 3). - If no providers are selected for Overview, the Overview tab is hidden. -- Providers → Claude: “Keychain prompt policy” controls Claude OAuth prompt behavior (Never / Only on user action / - Always allow prompts). -- When “Disable Keychain access” is enabled in Advanced, the Claude keychain prompt policy remains visible but is - inactive. +- Providers → Claude: “Avoid Keychain prompts” uses the prompt-free Security CLI reader when available. +- The lower-level “Keychain prompt policy” picker only appears when the Security.framework reader is active. ## Widgets (high level) -- Widget entries mirror the menu card; detailed pipeline in `docs/widgets.md`. +- Widgets render shared usage snapshots for the supported widget families and + provider picker; detailed pipeline in `docs/widgets.md`. See also: `docs/widgets.md`. diff --git a/docs/venice.md b/docs/venice.md new file mode 100644 index 000000000..b1db45bcb --- /dev/null +++ b/docs/venice.md @@ -0,0 +1,40 @@ +# Venice + +[Venice](https://venice.ai) is an AI inference platform that provides API access to various language models. + +## Setup + +1. Sign up or log in at https://venice.ai +2. Navigate to your API settings at https://venice.ai/settings/api +3. Create or retrieve your API key +4. In CodexBar, add your Venice API key via: + - Preferences > Providers > Venice, OR + - Set the environment variable `VENICE_API_KEY` or `VENICE_KEY` + +## Balance Query + +CodexBar fetches your current Venice API balance using the `/api/v1/billing/balance` endpoint. + +### Balance Types + +- **DIEM**: Venice's native credits (if epoch allocation is configured) +- **USD**: Dollar balance if available +- **Consumption Currency**: Indicates which currency is active for current billing + +### Display + +CodexBar shows: +- Current remaining balance (DIEM or USD) +- Epoch allocation progress (if applicable) +- "Balance unavailable" if consumption is temporarily disabled + +## Troubleshooting + +**No balance showing?** +- Verify your API key is correct +- Check network connectivity +- Ensure your Venice account has an active balance + +**API rate limiting?** +- CodexBar caches balance data and updates every 30 seconds +- If you hit rate limits, wait a moment before refreshing diff --git a/docs/widgets.md b/docs/widgets.md index 8fbff0f27..bb916e3ba 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -11,11 +11,26 @@ read_when: ## Snapshot pipeline - `WidgetSnapshotStore` writes compact JSON snapshots to the app-group container. - Widgets read the snapshot and render usage/credits/history states. +- The app writes snapshots after the main refresh pipeline and token-usage refreshes; narrow single-provider refresh paths may wait for the next snapshot write. +- If no snapshot is available, widgets fall back to preview/empty data. ## Extension - `Sources/CodexBarWidget` contains timeline + views. +- `WidgetExtension/CodexBarWidgetExtension.xcodeproj` builds those sources as the packaged macOS WidgetKit app extension. - Keep data shape in sync with `WidgetSnapshot` in the main app. +## Widget types +- **CodexBar Switcher** (`CodexBarSwitcherWidget`): static provider switcher widget, small/medium/large. +- **CodexBar Usage** (`CodexBarUsageWidget`): configurable provider usage widget, small/medium/large. +- **CodexBar History** (`CodexBarHistoryWidget`): configurable usage-history chart, medium/large. +- **CodexBar Metric** (`CodexBarCompactWidget`): compact credits/today-cost/30-day-cost widget, small only. + +## Provider picker support +The configurable provider widgets currently expose: +Codex, Claude, Gemini, Alibaba, Antigravity, z.ai, Copilot, MiniMax, Kilo, OpenCode, and OpenCode Go. + +Providers without a `ProviderChoice` case can still be present in the app snapshot, but they are not selectable from the widget configuration UI yet. + ## Visibility troubleshooting (macOS 14+) When widgets do not appear in the gallery at all, the issue is almost always registration, signing, or daemon caching (not SwiftUI code). @@ -24,6 +39,7 @@ registration, signing, or daemon caching (not SwiftUI code). ``` APP="/Applications/CodexBar.app" WAPPEX="$APP/Contents/PlugIns/CodexBarWidget.appex" +WIDGET_ID="com.steipete.codexbar.widget" # debug builds use com.steipete.codexbar.debug.widget ls -la "$WAPPEX" "$WAPPEX/Contents" "$WAPPEX/Contents/MacOS" ``` @@ -31,18 +47,18 @@ ls -la "$WAPPEX" "$WAPPEX/Contents" "$WAPPEX/Contents/MacOS" ### 2) PlugInKit registration (pkd) ``` pluginkit -m -p com.apple.widgetkit-extension -v | grep -i codexbar || true -pluginkit -m -p com.apple.widgetkit-extension -i com.steipete.codexbar.widget -vv +pluginkit -m -p com.apple.widgetkit-extension -i "$WIDGET_ID" -vv ``` Notes: - `+` = elected to use, `-` = ignored (PlugInKit elections). - If missing or ignored, force-add and re-elect: ``` pluginkit -a "$WAPPEX" -pluginkit -e use -p com.apple.widgetkit-extension -i com.steipete.codexbar.widget +pluginkit -e use -p com.apple.widgetkit-extension -i "$WIDGET_ID" ``` - Check for duplicates (old installs or version precedence): ``` -pluginkit -m -D -p com.apple.widgetkit-extension -i com.steipete.codexbar.widget -vv +pluginkit -m -D -p com.apple.widgetkit-extension -i "$WIDGET_ID" -vv ``` If multiple paths appear, delete older installs and bump `CFBundleVersion`. @@ -68,7 +84,7 @@ log stream --style compact --predicate '(process == "pkd" OR process == "chronod ``` ### 6) Packaging sanity checks -- Widget bundle id should be `com.steipete.codexbar.widget`. +- Widget bundle id should be `com.steipete.codexbar.widget` for release and `com.steipete.codexbar.debug.widget` for debug. - `NSExtensionPointIdentifier` must be `com.apple.widgetkit-extension`. - Bundle folder name should match: `CodexBarWidget.appex`. diff --git a/docs/windsurf.md b/docs/windsurf.md new file mode 100644 index 000000000..da2cec8a5 --- /dev/null +++ b/docs/windsurf.md @@ -0,0 +1,168 @@ +--- +summary: "Windsurf provider data sources: browser localStorage session import, local SQLite cache, and GetPlanStatus protobuf API." +read_when: + - Debugging Windsurf usage fetch + - Updating Windsurf web session import or API handling + - Adjusting Windsurf provider UI/menu behavior +--- + +# Windsurf provider + +Windsurf supports two data sources: a web API backed by the current website session, and a local SQLite cache. + +## Data sources + fallback order + +Usage source picker: +- Preferences → Providers → Windsurf → Usage source (Auto / Web API / Local). + +### Auto mode (default) +1) **Web API** (preferred) — real-time data from windsurf.com. +2) **Local SQLite cache** (fallback) — reads from Windsurf's `state.vscdb`. + +### Web API fetch order +1) **Manual session bundle** (when Cookie source = Manual). +2) **Browser localStorage import** — extracts the active `devin_*` session values from Chromium browsers. + +### Local SQLite cache +- File: `~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb`. +- Key: `windsurf.settings.cachedPlanInfo` in `ItemTable`. +- Newer cache shapes may omit `quotaUsage` but include `usage` counters. In that case CodexBar derives + usage windows from `usedMessages/messages` and `usedFlowActions/flowActions`. +- Limitation: only updates when Windsurf is launched; can be significantly stale. + +## Cookie source settings + +Preferences → Providers → Windsurf → Cookie source: + +- **Automatic** (default): imports the active Windsurf session bundle from Chromium browser localStorage. +- **Manual**: paste a JSON bundle with `devin_session_token`, `devin_auth1_token`, `devin_account_id`, and `devin_primary_org_id`. +- **Off**: disables web API access entirely; only local SQLite cache is used. + +### How to get a manual session bundle + +1. Open [windsurf.com/profile](https://windsurf.com/profile) in Chrome or Edge and sign in. +2. Open Developer Tools (`F12` or `Cmd+Option+I`). +3. Go to the **Console** tab. +4. Paste the following JavaScript and press Enter: + +```javascript +(() => { + const keys = [ + "devin_session_token", + "devin_auth1_token", + "devin_account_id", + "devin_primary_org_id", + ]; + + const read = (key) => { + const value = localStorage.getItem(key); + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return value; + } + }; + + const payload = Object.fromEntries(keys.map((key) => [key, read(key)])); + const missing = keys.filter((key) => !payload[key]); + + if (missing.length > 0) { + console.log("Missing Windsurf session keys:", missing.join(", ")); + return; + } + + const json = JSON.stringify(payload, null, 2); + console.log(json); + if (typeof copy === "function") { + copy(json); + console.log("Copied Windsurf session bundle to clipboard."); + } +})(); +``` + +5. Copy the JSON output. +6. In CodexBar: Providers → Windsurf → Cookie source → Manual → paste the JSON bundle. + +## Authentication flow (Automatic mode) + +```text +Browser localStorage (leveldb on disk) + ↓ extract devin_session_token / devin_auth1_token / devin_account_id / devin_primary_org_id +POST https://windsurf.com/_backend/.../GetPlanStatus + ↓ headers: x-auth-token + x-devin-* + ↓ protobuf body: { auth_token, include_top_up_status: true } +UsageSnapshot (daily/weekly quota %) +``` + +## Browser session extraction + +- **Browsers scanned**: Chrome, Edge, Brave, Arc, Vivaldi, Chromium, and compatible Chromium forks. +- **Local storage path**: `~/Library/Application Support///Local Storage/leveldb/` +- **Origin**: `https://windsurf.com` +- **Required keys**: + - `devin_session_token` + - `devin_auth1_token` + - `devin_account_id` + - `devin_primary_org_id` + +## API endpoint + +### GetPlanStatus (ConnectRPC over protobuf) +- `POST https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus` +- Headers: + - `Content-Type: application/proto` + - `Connect-Protocol-Version: 1` + - `Origin: https://windsurf.com` + - `Referer: https://windsurf.com/profile` + - `x-auth-token: ` + - `x-devin-session-token: ` + - `x-devin-auth1-token: ` + - `x-devin-account-id: ` + - `x-devin-primary-org-id: ` +- Protobuf request fields: + - `1 auth_token: string` + - `2 include_top_up_status: bool` +- Parsed response fields used by CodexBar: + - `plan_status.plan_info.plan_name` + - `plan_status.plan_end` + - `plan_status.daily_quota_remaining_percent` + - `plan_status.weekly_quota_remaining_percent` + - `plan_status.daily_quota_reset_at_unix` + - `plan_status.weekly_quota_reset_at_unix` + +## Snapshot mapping +- Web primary: daily usage percent (`100 - daily_quota_remaining_percent`). +- Web secondary: weekly usage percent (`100 - weekly_quota_remaining_percent`). +- Local primary: daily quota percent when present; otherwise message usage (`usedMessages/messages`). +- Local secondary: weekly quota percent when present; otherwise flow-action usage (`usedFlowActions/flowActions`). +- Reset: daily/weekly reset timestamps (Unix seconds), when available. +- Plan: `plan_status.plan_info.plan_name`. +- Expiry: `plan_status.plan_end`. + +## Troubleshooting + +### "No Windsurf web session found in Chromium localStorage" +- Sign in to [windsurf.com](https://windsurf.com) in Chrome, Edge, or another Chromium browser. +- Grant Full Disk Access to CodexBar (System Settings → Privacy & Security → Full Disk Access). +- Try Manual mode and paste the JSON session bundle directly. + +### "Invalid Windsurf session payload" +- The manual value must include all four keys: `devin_session_token`, `devin_auth1_token`, `devin_account_id`, and `devin_primary_org_id`. +- Re-run the console snippet on a logged-in `windsurf.com` page. + +### "Windsurf API call failed: HTTP 401" +- The imported browser session is stale or invalid. +- Refresh the Windsurf page in your browser and try again. +- If using Manual mode, paste a fresh JSON bundle. + +### Stale data with Local mode +- The local SQLite cache only updates when Windsurf is launched. Switch to Auto or Web API mode for real-time data. + +## Key files +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfStatusProbe.swift` (local SQLite) +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfDevinSessionImporter.swift` (Chromium localStorage extraction) +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfWebFetcher.swift` (protobuf request + response parsing) +- `Sources/CodexBarCore/Providers/Windsurf/WindsurfProviderDescriptor.swift` (fetch strategies) +- `Sources/CodexBar/Providers/Windsurf/WindsurfProviderImplementation.swift` (settings UI) +- `Sources/CodexBar/Providers/Windsurf/WindsurfSettingsStore.swift` (settings persistence) diff --git a/package.json b/package.json deleted file mode 100644 index f7a987506..000000000 --- a/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "codexbar", - "private": true, - "scripts": { - "start": "./Scripts/compile_and_run.sh", - "start:debug": "./Scripts/compile_and_run.sh", - "start:release": "sh -c './Scripts/package_app.sh release && (pkill -x CodexBar || pkill -f CodexBar.app || true) && cd /Users/steipete/Projects/codexbar && open -n /Users/steipete/Projects/codexbar/CodexBar.app'", - "lint": "./Scripts/lint.sh lint", - "format": "./Scripts/lint.sh format", - "check": "./Scripts/lint.sh lint", - "docs:list": "node Scripts/docs-list.mjs", - "build": "swift build", - "test": "swift test", - "test:tty": "swift test --filter TTYIntegrationTests", - "test:live": "LIVE_TEST=1 swift test --filter LiveAccountTests", - "release": "./Scripts/package_app.sh release", - "restart": "pnpm start", - "stop": "pkill -x CodexBar || pkill -f CodexBar.app || true" - } -} diff --git a/version.env b/version.env index 529133131..c2d46a56d 100644 --- a/version.env +++ b/version.env @@ -1,2 +1,2 @@ -MARKETING_VERSION=0.18.0-beta.3-jl.4 -BUILD_NUMBER=54 +MARKETING_VERSION=0.31.1-jl.1 +BUILD_NUMBER=75