diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af91f914..8709971cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - Reduce CPU/energy regressions and JSONL scanner overhead in Codex/web usage paths (#402, #392). Thanks @bald-ai and @asonawalla! ### Providers & Usage +- Perplexity: add provider support with credit tracking for recurring (monthly), bonus (promotional), and purchased on-demand credits; plan detection (Pro/Max); and browser-cookie auto-import with manual-cookie fallback (#449). Thanks @BeelixGit! - Codex: add historical pace risk forecasting and backfill, gate pace computation by display mode, and handle zero-usage days in historical data (#482, supersedes #438). Thanks @tristanmanchester! - Kilo: add provider support with source-mode fallback, clearer credential/login guidance, auto top-up activity labeling, zero-balance credit handling, and pass parsing/menu rendering (#454). Thanks @coreh! - Ollama: add provider support with token-account support in app/CLI, Chrome-default auto cookie import, and manual-cookie mode (#380). Thanks @CryptoSageSnr! diff --git a/README.md b/README.md index 40dd56d2a..3f685d6fc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CodexBar 🎚️ - May your tokens never run out. -Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, and OpenRouter limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. +Tiny macOS 14+ menu bar app that keeps your Codex, Claude, Cursor, Gemini, Antigravity, Droid (Factory), Copilot, z.ai, Kiro, Vertex AI, Augment, Amp, JetBrains AI, OpenRouter, and Perplexity limits visible (session + weekly where available) and shows when each window resets. One status item per provider (or Merge Icons mode with a provider switcher and optional Overview tab); enable what you use from Settings. No Dock icon, minimal UI, dynamic bar icons in the menu bar. CodexBar menu screenshot diff --git a/Sources/CodexBar/IconView.swift b/Sources/CodexBar/IconView.swift index 684f0a3fe..213d4a22c 100644 --- a/Sources/CodexBar/IconView.swift +++ b/Sources/CodexBar/IconView.swift @@ -3,6 +3,12 @@ import SwiftUI enum IconRemainingResolver { static func resolvedRemaining(snapshot: UsageSnapshot, style: IconStyle) -> (primary: Double?, secondary: Double?) { + if style == .perplexity { + let windows = snapshot.orderedPerplexityDisplayWindows() + return ( + primary: windows.first?.remainingPercent, + secondary: windows.dropFirst().first?.remainingPercent) + } guard style == .antigravity else { return ( primary: snapshot.primary?.remainingPercent, diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index a154be9cf..c2e2d10b3 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -12,6 +12,9 @@ enum MenuBarMetricWindowResolver { guard let snapshot else { return nil } switch preference { case .tertiary: + if provider == .perplexity { + return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary + } guard provider == .cursor else { if provider == .antigravity { return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary @@ -20,11 +23,17 @@ enum MenuBarMetricWindowResolver { } return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary case .primary: + if provider == .perplexity { + return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary + } if provider == .antigravity { return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary } return snapshot.primary ?? snapshot.secondary case .secondary: + if provider == .perplexity { + return snapshot.secondary ?? snapshot.tertiary ?? snapshot.primary + } if provider == .antigravity { return snapshot.secondary ?? snapshot.primary ?? snapshot.tertiary } @@ -45,6 +54,9 @@ enum MenuBarMetricWindowResolver { if provider == .antigravity { return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary } + if provider == .perplexity { + return snapshot.automaticPerplexityWindow() + } if provider == .factory || provider == .kimi { return snapshot.secondary ?? snapshot.primary } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5f685af23..9da4f9e04 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -1007,6 +1007,13 @@ extension UsageMenuCardView.Model { { weeklyDetailText = detail } + // Perplexity bonus credits don't reset; show balance without "Resets" prefix. + if input.provider == .perplexity, + let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), + !detail.isEmpty + { + weeklyResetText = detail + } metrics.append(Metric( id: "secondary", title: input.metadata.weeklyLabel, @@ -1039,12 +1046,16 @@ extension UsageMenuCardView.Model { { tertiaryDetailText = detail } + // Perplexity purchased credits don't reset; show balance without "Resets" prefix. + let opusResetText: String? = input.provider == .perplexity + ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) + : Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now) metrics.append(Metric( id: "tertiary", title: input.metadata.opusLabel ?? "Sonnet", percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), percentStyle: percentStyle, - resetText: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now), + resetText: opusResetText, detailText: tertiaryDetailText, detailLeftText: nil, detailRightText: nil, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 05aa55fff..a9ae51bf8 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -144,7 +144,7 @@ struct MenuDescriptor { } if let weekly = snap.secondary { let weeklyResetOverride: String? = { - guard provider == .warp || provider == .kilo else { return nil } + guard provider == .warp || provider == .kilo || provider == .perplexity else { return nil } let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) guard let detail, !detail.isEmpty else { return nil } if provider == .kilo, weekly.resetsAt != nil { @@ -172,12 +172,17 @@ struct MenuDescriptor { } } if meta.supportsOpus, let opus = snap.tertiary { + // Perplexity purchased credits don't reset; show the balance as plain text. + let opusResetOverride: String? = provider == .perplexity + ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) + : nil Self.appendRateWindow( entries: &entries, title: meta.opusLabel ?? "Sonnet", window: opus, resetStyle: resetStyle, - showUsed: settings.usageBarsShowUsed) + showUsed: settings.usageBarsShowUsed, + resetOverride: opusResetOverride) } if let cost = snap.providerCost { diff --git a/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift b/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift new file mode 100644 index 000000000..770e18c2e --- /dev/null +++ b/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift @@ -0,0 +1,95 @@ +import AppKit +import CodexBarCore +import CodexBarMacroSupport +import Foundation +import SwiftUI + +@ProviderImplementationRegistration +struct PerplexityProviderImplementation: ProviderImplementation { + let id: UsageProvider = .perplexity + let supportsLoginFlow: Bool = true + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web" } + } + + @MainActor + func runLoginFlow(context _: ProviderLoginContext) async -> Bool { + if let url = URL(string: "https://www.perplexity.ai/") { + NSWorkspace.shared.open(url) + } + return false + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.perplexityCookieSource + _ = settings.perplexityManualCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? { + .perplexity(context.settings.perplexitySettingsSnapshot(tokenOverride: context.tokenOverride)) + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.perplexityCookieSource.rawValue }, + set: { raw in + context.settings.perplexityCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto + }) + let options = ProviderCookieSourceUI.options( + allowsOff: true, + keychainDisabled: context.settings.debugDisableKeychainAccess) + + let subtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.perplexityCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatically imports browser session cookie.", + manual: "Paste a full cookie header or the __Secure-next-auth.session-token value.", + off: "Perplexity cookies are disabled.") + } + + return [ + ProviderSettingsPickerDescriptor( + id: "perplexity-cookie-source", + title: "Cookie source", + subtitle: "Automatically imports browser session cookie.", + dynamicSubtitle: subtitle, + binding: cookieBinding, + options: options, + isVisible: nil, + onChange: nil), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "perplexity-cookie", + title: "", + subtitle: "", + kind: .secure, + placeholder: "Cookie: \u{2026}\n\nor paste the __Secure-next-auth.session-token value", + binding: context.stringBinding(\.perplexityManualCookieHeader), + actions: [ + ProviderSettingsActionDescriptor( + id: "perplexity-open-usage", + title: "Open Usage Page", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://www.perplexity.ai/account/usage") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: { context.settings.perplexityCookieSource == .manual }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift b/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift new file mode 100644 index 000000000..6d2d44dfc --- /dev/null +++ b/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var perplexityManualCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .perplexity)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .perplexity) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .perplexity, field: "cookieHeader", value: newValue) + } + } + + var perplexityCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .perplexity, fallback: .auto) } + set { + self.updateProviderConfig(provider: .perplexity) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .perplexity, field: "cookieSource", value: newValue.rawValue) + } + } +} + +extension SettingsStore { + func perplexitySettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot + .PerplexityProviderSettings { + // tokenOverride is not used: Perplexity auth is cookie-based, not token-account-based. + // Manual cookies are handled via perplexityManualCookieHeader in the settings snapshot below. + _ = tokenOverride + return ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: self.perplexityCookieSource, + manualCookieHeader: self.perplexityManualCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index 9cb99850b..6fb94b479 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -36,6 +36,7 @@ enum ProviderImplementationRegistry { case .synthetic: SyntheticProviderImplementation() case .openrouter: OpenRouterProviderImplementation() case .warp: WarpProviderImplementation() + case .perplexity: PerplexityProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-perplexity.svg b/Sources/CodexBar/Resources/ProviderIcon-perplexity.svg new file mode 100644 index 000000000..d869791b2 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-perplexity.svg @@ -0,0 +1 @@ +Perplexity diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift index cc8a89396..34601636a 100644 --- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift +++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift @@ -51,12 +51,14 @@ extension SettingsStore { } func menuBarMetricSupportsTertiary(for provider: UsageProvider) -> Bool { - provider == .cursor + provider == .cursor || provider == .perplexity } func menuBarMetricSupportsTertiary(for provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool { - guard provider == .cursor else { return self.menuBarMetricSupportsTertiary(for: provider) } - return snapshot?.tertiary != nil + if provider == .cursor { + return snapshot?.tertiary != nil + } + return self.menuBarMetricSupportsTertiary(for: provider) } func menuBarMetricPreference(for provider: UsageProvider, snapshot: UsageSnapshot?) -> MenuBarMetricPreference { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 47efc5b63..9bb258a14 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1231,7 +1231,7 @@ extension UsageStore { let source = resolution?.source.rawValue ?? "none" return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi, - .kimik2, .jetbrains: + .kimik2, .jetbrains, .perplexity: return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" } } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 937b37aa0..d52302847 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -177,6 +177,13 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) + case .perplexity: + let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config) + let cookieSource = self.cookieSource(provider: provider, account: account, config: config) + return self.makeSnapshot( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: return nil } @@ -196,7 +203,8 @@ struct TokenAccountCLIContext { augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil, amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil, ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil, - jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot + jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil, + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot.make( codex: codex, @@ -212,7 +220,8 @@ struct TokenAccountCLIContext { augment: augment, amp: amp, ollama: ollama, - jetbrains: jetbrains) + jetbrains: jetbrains, + perplexity: perplexity) } func environment( diff --git a/Sources/CodexBarClaudeWebProbe/main.swift b/Sources/CodexBarClaudeWebProbe/ClaudeWebProbeEntry.swift similarity index 100% rename from Sources/CodexBarClaudeWebProbe/main.swift rename to Sources/CodexBarClaudeWebProbe/ClaudeWebProbeEntry.swift diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 37a7726ef..0f2a6b0f9 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -43,6 +43,9 @@ public enum LogCategories { public static let ollama = "ollama" public static let opencodeUsage = "opencode-usage" public static let openRouterUsage = "openrouter-usage" + public static let perplexityAPI = "perplexity-api" + public static let perplexityCookie = "perplexity-cookie" + public static let perplexityWeb = "perplexity-web" public static let providerDetection = "provider-detection" public static let providers = "providers" public static let sessionQuota = "sessionQuota" 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.records( + 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, Sendable { + 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..e36eb0e60 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift @@ -0,0 +1,39 @@ +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.removeFirst() + value.removeLast() + } + + 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..2a9fe5211 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift @@ -0,0 +1,73 @@ +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 (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw PerplexityAPIError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + let truncated = body.count > 200 ? String(body.prefix(200)) + "…" : body + Self.log.error("Perplexity API returned \(httpResponse.statusCode): \(truncated)") + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw PerplexityAPIError.invalidToken + } + throw PerplexityAPIError.apiError("HTTP \(httpResponse.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/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 0596617b7..236af4bd3 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -76,6 +76,7 @@ public enum ProviderDescriptorRegistry { .synthetic: SyntheticProviderDescriptor.descriptor, .openrouter: OpenRouterProviderDescriptor.descriptor, .warp: WarpProviderDescriptor.descriptor, + .perplexity: PerplexityProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift index c5d9af4f7..7c1d1f786 100644 --- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift +++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift @@ -18,7 +18,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: AugmentProviderSettings? = nil, amp: AmpProviderSettings? = nil, ollama: OllamaProviderSettings? = nil, - jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot + jetbrains: JetBrainsProviderSettings? = nil, + perplexity: PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot { ProviderSettingsSnapshot( debugMenuEnabled: debugMenuEnabled, @@ -37,7 +38,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: augment, amp: amp, ollama: ollama, - jetbrains: jetbrains) + jetbrains: jetbrains, + perplexity: perplexity) } public struct CodexProviderSettings: Sendable { @@ -209,6 +211,16 @@ public struct ProviderSettingsSnapshot: Sendable { } } + 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 let debugMenuEnabled: Bool public let debugKeepCLISessionsAlive: Bool public let codex: CodexProviderSettings? @@ -226,6 +238,7 @@ public struct ProviderSettingsSnapshot: Sendable { public let amp: AmpProviderSettings? public let ollama: OllamaProviderSettings? public let jetbrains: JetBrainsProviderSettings? + public let perplexity: PerplexityProviderSettings? public var jetbrainsIDEBasePath: String? { self.jetbrains?.ideBasePath @@ -248,7 +261,8 @@ public struct ProviderSettingsSnapshot: Sendable { augment: AugmentProviderSettings?, amp: AmpProviderSettings?, ollama: OllamaProviderSettings?, - jetbrains: JetBrainsProviderSettings? = nil) + jetbrains: JetBrainsProviderSettings? = nil, + perplexity: PerplexityProviderSettings? = nil) { self.debugMenuEnabled = debugMenuEnabled self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive @@ -267,6 +281,7 @@ public struct ProviderSettingsSnapshot: Sendable { self.amp = amp self.ollama = ollama self.jetbrains = jetbrains + self.perplexity = perplexity } } @@ -286,6 +301,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable { case amp(ProviderSettingsSnapshot.AmpProviderSettings) case ollama(ProviderSettingsSnapshot.OllamaProviderSettings) case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings) + case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings) } public struct ProviderSettingsSnapshotBuilder: Sendable { @@ -306,6 +322,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { public var amp: ProviderSettingsSnapshot.AmpProviderSettings? public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings? public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? + public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) { self.debugMenuEnabled = debugMenuEnabled @@ -329,6 +346,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { case let .amp(value): self.amp = value case let .ollama(value): self.ollama = value case let .jetbrains(value): self.jetbrains = value + case let .perplexity(value): self.perplexity = value } } @@ -350,6 +368,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable { augment: self.augment, amp: self.amp, ollama: self.ollama, - jetbrains: self.jetbrains) + jetbrains: self.jetbrains, + perplexity: self.perplexity) } } diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index a8d529f12..85113cc26 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -65,6 +65,12 @@ public enum ProviderTokenResolver { self.openRouterResolution(environment: environment)?.token } + public static func perplexitySessionToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + self.perplexityResolution(environment: environment)?.token + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -151,6 +157,25 @@ public enum ProviderTokenResolver { self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment)) } + 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 diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 039f7a3f3..ff0f8eeb4 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -26,6 +26,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case synthetic case warp case openrouter + case perplexity } // swiftformat:enable sortDeclarations @@ -54,6 +55,7 @@ public enum IconStyle: Sendable, CaseIterable { case synthetic case warp case openrouter + case perplexity case combined } diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 98859b18e..61b055897 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -145,11 +145,35 @@ 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 budget when the included plan is exhausted (only in // "show remaining" mode). The secondary/tertiary lanes are Total/Auto/API breakdowns, @@ -206,6 +230,13 @@ public struct UsageSnapshot: Codable, Sendable { if scopedIdentity.providerID == identity.providerID { return self } return self.withIdentity(scopedIdentity) } + + 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 + } } public struct AccountInfo: Equatable, Sendable { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 0e6767e15..f4a3ba8bb 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -72,7 +72,7 @@ enum CostUsageScanner { return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) case .zai, .gemini, .antigravity, .cursor, .opencode, .alibaba, .factory, .copilot, .minimax, .kilo, .kiro, .kimi, - .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp: + .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity: return emptyReport } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index c828a2695..c0e600fa9 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -71,6 +71,7 @@ enum ProviderChoice: String, AppEnum { case .synthetic: return nil // Synthetic not yet supported in widgets case .openrouter: return nil // OpenRouter not yet supported in widgets case .warp: return nil // Warp not yet supported in widgets + case .perplexity: return nil // Perplexity not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 3b0dd2d27..e08b23367 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -280,6 +280,7 @@ private struct ProviderSwitchChip: View { case .synthetic: "Synthetic" case .openrouter: "OpenRouter" case .warp: "Warp" + case .perplexity: "Pplx" } } } @@ -621,6 +622,8 @@ enum WidgetColors { Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple case .warp: Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255) + case .perplexity: + Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal } } } diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 026ba43bc..4e2b3f137 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -74,6 +74,45 @@ struct CodexBarTests { #expect(remaining.secondary == 40) } + @Test + func `perplexity icon falls back to purchased lane when bonus is exhausted`() { + 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()) + + let remaining = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .perplexity) + #expect(remaining.primary == 80) + #expect(remaining.secondary == 0) + } + + @Test + func `perplexity icon skips exhausted recurring lane when purchased credits remain`() { + 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: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let remaining = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .perplexity) + #expect(remaining.primary == 80) + #expect(remaining.secondary == 0) + } + + @Test + func `perplexity icon prefers purchased lane before bonus`() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()) + + let remaining = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .perplexity) + #expect(remaining.primary == 55) + #expect(remaining.secondary == 80) + } + @Test func `icon renderer codex eyes punch through when unknown`() { // Regression: when remaining is nil, CoreGraphics inherits the previous fill alpha which caused diff --git a/Tests/CodexBarTests/PerplexityCookieCacheTests.swift b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift new file mode 100644 index 000000000..246a053ec --- /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 cacheRoundTripProducesValidCookieOverride() { + 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 isAvailableReturnsTrueWhenCachePopulated() { + 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 cacheClearedOnInvalidToken() { + 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 cacheNotClearedOnNetworkError() { + 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 cacheNotClearedOnAPIError() { + 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 bareTokenRoundTripsWithDefaultCookieName() { + 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 offModeIgnoresCachedSessionCookie() 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..77c74a2c2 --- /dev/null +++ b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +struct PerplexityCookieHeaderTests { + @Test + func bareTokenUsesDefaultSessionCookieName() { + let override = PerplexityCookieHeader.override(from: "abc123") + #expect(override?.name == PerplexityCookieHeader.defaultSessionCookieName) + #expect(override?.token == "abc123") + #expect(override?.requestCookieNames == PerplexityCookieHeader.supportedSessionCookieNames) + } + + @Test + func extractsSecureNextAuthSessionCookieFromHeader() { + 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 extractsAuthJSSessionCookieFromHeader() { + 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 prefersAuthJSSessionCookieWhenBothNamesExist() { + 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 reassemblesChunkedNextAuthSessionCookieFromHeader() { + 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 reassemblesChunkedAuthJSSessionCookieFromHeader() { + 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 unsupportedCookieHeaderReturnsNil() { + let override = PerplexityCookieHeader.override(from: "foo=bar; hello=world") + #expect(override == nil) + } + + #if os(macOS) + @Test + func importerSessionInfoReassemblesChunkedSessionCookies() 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..fa475f8b9 --- /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 offModeIgnoresEnvironmentSessionCookie() 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 manualModeInvalidCookieDoesNotFallBackToCacheOrEnvironment() 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 environmentTokenDoesNotPopulateBrowserCookieCache() 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 manualTokenDoesNotPopulateBrowserCookieCache() 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 bareEnvironmentTokenFallsBackToAuthJSCookieName() 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 validEnvironmentCookieWinsAfterInvalidBrowserSession() 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 laterBrowserSessionWinsAfterEarlierImportedSessionFailsAuth() 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 autoModeReusesBrowserImportBetweenAvailabilityAndFetch() 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..cd115081a --- /dev/null +++ b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift @@ -0,0 +1,363 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite +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 parsesFullResponseWithRecurringAndPromotionalCredits() 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 waterfallAttributionRecurringThenPurchasedThenPromo() 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 expiredPromotionalGrantsAreExcluded() 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 emptyCreditGrantsProducesZeroRecurring() 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 malformedJSONThrowsParseFailed() { + 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 planNameInference() 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 toUsageSnapshotAlwaysHasSecondaryAndTertiary() 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 toUsageSnapshotZeroRecurringBarIsFullyDepleted() 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 toUsageSnapshotOmitsPrimaryWhenOnlyFallbackCreditsRemain() 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 toUsageSnapshotEmptyPoolsBarsAreFullyDepleted() 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 purchasedCreditsFromCreditGrantsArray() 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 purchasedCreditsPreferGrantsOverFieldWhenBothPresent() 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 purchasedCreditsFromFieldWhenNoGrantType() 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 realWorldMaxPlanWithAllThreePools() 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 toUsageSnapshotPrimaryPercentMatchesUsage() 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/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift index e76290615..3de5099f4 100644 --- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift +++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift @@ -26,6 +26,11 @@ struct SettingsStoreAdditionalTests { #expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic) #expect(settings.menuBarMetricSupportsTertiary(for: .cursor, 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) } diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index a7cfb81b1..24e854be8 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -767,6 +767,7 @@ struct SettingsStoreTests { .synthetic, .warp, .openrouter, + .perplexity, ]) // Move one provider; ensure it's persisted across instances. diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index f29ada71b..915c82532 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -439,6 +439,209 @@ struct StatusItemAnimationTests { #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( diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift index 0507a60fe..2df1afef9 100644 --- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift @@ -7,12 +7,14 @@ struct StatusItemControllerMenuTests { 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()) } @@ -78,6 +80,21 @@ struct StatusItemControllerMenuTests { #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 `open router brand fallback enabled when no key limit configured`() { let snapshot = OpenRouterUsageSnapshot( diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 190c00892..217e28da3 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -309,6 +309,162 @@ struct UsageStoreHighestUsageTests { #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(