From 14946afacbbf25dd70165cc3168966633dd3aab3 Mon Sep 17 00:00:00 2001 From: John Budnick Date: Fri, 27 Feb 2026 13:28:50 -0500 Subject: [PATCH 01/11] Add Perplexity provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks usage-based API credits from Perplexity Pro/Max plans via the /rest/billing/credits endpoint using a browser session cookie. - Three-tier display: recurring credits (monthly), bonus credits (promotional grants), and purchased on-demand credits - Infers plan name (Free/Pro/Max) from recurring credit pool size - Cookie auth: auto browser import from Chrome/Chromium, manual paste, or PERPLEXITY_SESSION_TOKEN / PERPLEXITY_COOKIE env var fallback - Waterfall attribution: recurring → purchased → promotional - Empty credit pools render as depleted bars, not full ones - Login flow opens perplexity.ai/signin with the standard key icon - 10 unit tests covering parsing, waterfall logic, plan inference, empty-pool rendering, and UsageSnapshot conversion Co-Authored-By: Claude Sonnet 4.6 --- Sources/CodexBar/MenuCardView.swift | 13 +- Sources/CodexBar/MenuDescriptor.swift | 9 +- .../PerplexityProviderImplementation.swift | 95 ++++++++ .../Perplexity/PerplexitySettingsStore.swift | 35 +++ .../ProviderImplementationRegistry.swift | 1 + .../Resources/ProviderIcon-perplexity.svg | 1 + Sources/CodexBar/UsageStore.swift | 2 +- Sources/CodexBarCLI/TokenAccountCLI.swift | 11 +- .../{main.swift => ClaudeWebProbeEntry.swift} | 0 .../CodexBarCore/Logging/LogCategories.swift | 3 + .../Perplexity/PerplexityAPIError.swift | 24 ++ .../Perplexity/PerplexityCookieHeader.swift | 47 ++++ .../Perplexity/PerplexityCookieImporter.swift | 175 ++++++++++++++ .../Perplexity/PerplexityModels.swift | 29 +++ .../PerplexityProviderDescriptor.swift | 111 +++++++++ .../Perplexity/PerplexitySettingsReader.swift | 33 +++ .../Perplexity/PerplexityUsageFetcher.swift | 67 ++++++ .../Perplexity/PerplexityUsageSnapshot.swift | 116 +++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderSettingsSnapshot.swift | 27 ++- .../Providers/ProviderTokenResolver.swift | 25 ++ .../CodexBarCore/Providers/Providers.swift | 2 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 +- .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + .../PerplexityUsageFetcherTests.swift | 226 ++++++++++++++++++ 26 files changed, 1048 insertions(+), 11 deletions(-) create mode 100644 Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-perplexity.svg rename Sources/CodexBarClaudeWebProbe/{main.swift => ClaudeWebProbeEntry.swift} (100%) create mode 100644 Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift create mode 100644 Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift create mode 100644 Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift create mode 100644 Sources/CodexBarCore/Providers/Perplexity/PerplexityModels.swift create mode 100644 Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift create mode 100644 Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift create mode 100644 Tests/CodexBarTests/PerplexityUsageFetcherTests.swift 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..55fa16f95 --- /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/signin") { + 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..67100c6a1 --- /dev/null +++ b/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift @@ -0,0 +1,35 @@ +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/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..07268a73c 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -177,6 +177,11 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) + case .perplexity: + return self.makeSnapshot( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: cookieSource, + manualCookieHeader: cookieHeader)) case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp: return nil } @@ -196,7 +201,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 +218,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..91a3036b5 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum PerplexityAPIError: LocalizedError, Sendable, Equatable { + case missingToken + 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 .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..846dc7454 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift @@ -0,0 +1,47 @@ +import Foundation + +public struct PerplexityCookieOverride: Sendable { + public let token: String + + public init(token: String) { + self.token = token + } +} + +public enum PerplexityCookieHeader { + 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(token: raw) + } + + // Extract __Secure-next-auth.session-token from a full cookie string + if let token = self.extractSessionToken(from: raw) { + return PerplexityCookieOverride(token: token) + } + + return nil + } + + private static func extractSessionToken(from raw: String) -> String? { + let key = "__Secure-next-auth.session-token=" + guard let keyRange = raw.range(of: key, options: .caseInsensitive) else { return nil } + let rest = raw[keyRange.upperBound...] + let value = rest.prefix(while: { $0 != ";" && !$0.isWhitespace }) + let token = String(value).trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : token + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift new file mode 100644 index 000000000..99354737a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift @@ -0,0 +1,175 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +public enum PerplexityCookieImporter { + private static let log = CodexBarLog.logger(LogCategories.perplexityCookie) + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = ["www.perplexity.ai", "perplexity.ai"] + private static let cookieImportOrder: BrowserCookieImportOrder = + ProviderDefaults.metadata[.perplexity]?.browserCookieOrder ?? Browser.defaultImportOrder + + // The session cookie set on HTTPS is prefixed with __Secure- + private static let sessionCookieName = "__Secure-next-auth.session-token" + + 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 sessionToken: String? { + self.cookies.first(where: { $0.name == sessionCookieName })?.value + } + } + + public static func importSessions( + browserDetection: BrowserDetection = BrowserDetection(), + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + 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 + } + 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 } + + guard httpCookies.contains(where: { $0.name == sessionCookieName }) else { + continue + } + + log("Found \(sessionCookieName) cookie in \(label)") + sessions.append(SessionInfo(cookies: httpCookies, sourceLabel: label)) + } + 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 { + return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty + } catch { + return false + } + } + + private static func emit(_ message: String, logger: ((String) -> Void)?) { + logger?("[perplexity-cookie] \(message)") + self.log.debug(message) + } + + 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 + } + } +} + +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..b0e9a2993 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift @@ -0,0 +1,111 @@ +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 { + let id: String = "perplexity.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + // Priority order mirrors resolveToken: manual override → browser import → env var + if PerplexityCookieHeader.resolveCookieOverride(context: context) != 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 { + guard let token = self.resolveToken(context: context) else { + throw PerplexityAPIError.missingToken + } + + let snapshot = try await PerplexityUsageFetcher.fetchCredits(sessionToken: token) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } + + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + if case PerplexityAPIError.missingToken = error { return false } + if case PerplexityAPIError.invalidToken = error { return false } + return true + } + + private func resolveToken(context: ProviderFetchContext) -> String? { + // Check manual cookie first (highest priority when set) + if let override = PerplexityCookieHeader.resolveCookieOverride(context: context) { + return override.token + } + + // Try browser cookie import when auto mode is enabled + #if os(macOS) + if context.settings?.perplexity?.cookieSource != .off { + do { + let session = try PerplexityCookieImporter.importSession() + if let token = session.sessionToken { + return token + } + } catch { + // No browser cookies found + } + } + #endif + + // Fall back to environment + if let token = PerplexitySettingsReader.sessionToken(environment: context.env) { + return token + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift new file mode 100644 index 000000000..c9916529d --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift @@ -0,0 +1,33 @@ +import Foundation + +public enum PerplexitySettingsReader { + public static func sessionToken( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + { + let raw = environment["PERPLEXITY_SESSION_TOKEN"] + ?? environment["perplexity_session_token"] + if let token = self.cleaned(raw) { return token } + + // PERPLEXITY_COOKIE may be a full Cookie header string; extract the session token from it. + if let cookieRaw = environment["PERPLEXITY_COOKIE"] { + return PerplexityCookieHeader.override(from: self.cleaned(cookieRaw))?.token + } + return nil + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || + (value.hasPrefix("'") && value.hasSuffix("'")) + { + value.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..6257acaea --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift @@ -0,0 +1,67 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct PerplexityUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.perplexityAPI) + // version=2.18&source=default was current as of Feb 2026; update if the endpoint shape changes. + private static let creditsURL = + URL(string: "https://www.perplexity.ai/rest/billing/credits?version=2.18&source=default")! + + /// 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 = "__Secure-next-auth.session-token", + now: Date = Date()) async throws -> PerplexityUsageSnapshot + { + Self.log.debug("Perplexity fetchCredits starting cookieName=\(cookieName)") + 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 { + 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..0afa167aa --- /dev/null +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift @@ -0,0 +1,116 @@ +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.map { $0 > now.timeIntervalSince1970 } ?? true) + } + + // 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 }) + let purchasedSum = max(0, response.currentPeriodPurchasedCents) + + // 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 recurringTotal <= 0 { return nil } + if recurringTotal < 5_000 { 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 + // usedPercent=100 when recurringTotal==0 so the bar renders empty rather than full. + let primaryPercent = recurringTotal > 0 + ? min(100, max(0, recurringUsed / recurringTotal * 100)) + : 100.0 + let primaryWindow = RateWindow( + usedPercent: primaryPercent, + windowMinutes: nil, + resetsAt: renewalDate, + resetDescription: "\(Int(recurringUsed.rounded()))/\(Int(recurringTotal)) credits") + + // Secondary: promotional bonus credits — always shown. + // usedPercent=100 when promoTotal==0 so the bar renders empty rather than full. + let promoPercent = promoTotal > 0 + ? min(100, max(0, promoUsed / promoTotal * 100)) + : 100.0 + var promoDesc = "\(Int(promoUsed.rounded()))/\(Int(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 = purchasedTotal > 0 + ? min(100, max(0, purchasedUsed / purchasedTotal * 100)) + : 100.0 + let tertiary = RateWindow( + usedPercent: purchasedPercent, + windowMinutes: nil, + resetsAt: nil, + resetDescription: "\(Int(purchasedUsed.rounded()))/\(Int(purchasedTotal)) credits") + + let identity = ProviderIdentitySnapshot( + providerID: .perplexity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: planName) + + return UsageSnapshot( + primary: primaryWindow, + secondary: secondary, + tertiary: tertiary, + providerCost: nil, + updatedAt: 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/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/PerplexityUsageFetcherTests.swift b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift new file mode 100644 index 000000000..f8703e0ca --- /dev/null +++ b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift @@ -0,0 +1,226 @@ +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 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) + } + + @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) + } +} From faac24f5b932276f481987567f6bf61bafe5dc1e Mon Sep 17 00:00:00 2001 From: John Budnick Date: Thu, 5 Mar 2026 14:51:01 -0500 Subject: [PATCH 02/11] Add cookie caching, fix API parsing, and remove keychain deprecation warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache Perplexity session cookies via CookieHeaderCache (matching Claude/Cursor pattern): try cache before browser import, cache on success, clear + retry on 401/403, preserve cache on transient errors - Fix PerplexityCreditGrant.expiresAtTs to be optional — the API returns null for purchased grants, causing parse failures - Remove deprecated kSecUseAuthenticationUIFail (macOS 14+ only needs LAContext.interactionNotAllowed) - Add debug logging for parse failures (raw response preview) - Add 6 tests for cookie cache behavior Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + README.md | 2 +- .../PerplexityProviderImplementation.swift | 2 +- .../Perplexity/PerplexityCookieHeader.swift | 47 ++++-- .../Perplexity/PerplexityCookieImporter.swift | 21 ++- .../PerplexityProviderDescriptor.swift | 74 +++++++-- .../Perplexity/PerplexityUsageFetcher.swift | 5 +- .../Perplexity/PerplexityUsageSnapshot.swift | 2 +- .../PerplexityCookieCacheTests.swift | 155 ++++++++++++++++++ .../PerplexityCookieHeaderTests.swift | 34 ++++ 10 files changed, 308 insertions(+), 35 deletions(-) create mode 100644 Tests/CodexBarTests/PerplexityCookieCacheTests.swift create mode 100644 Tests/CodexBarTests/PerplexityCookieHeaderTests.swift 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/Providers/Perplexity/PerplexityProviderImplementation.swift b/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift index 55fa16f95..770e18c2e 100644 --- a/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift @@ -16,7 +16,7 @@ struct PerplexityProviderImplementation: ProviderImplementation { @MainActor func runLoginFlow(context _: ProviderLoginContext) async -> Bool { - if let url = URL(string: "https://www.perplexity.ai/signin") { + if let url = URL(string: "https://www.perplexity.ai/") { NSWorkspace.shared.open(url) } return false diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift index 846dc7454..bffc26c5c 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift @@ -1,14 +1,24 @@ import Foundation public struct PerplexityCookieOverride: Sendable { + public let name: String public let token: String - public init(token: String) { + public init(name: String, token: String) { + self.name = name self.token = token } } public enum PerplexityCookieHeader { + public static let defaultSessionCookieName = "__Secure-next-auth.session-token" + public static let supportedSessionCookieNames = [ + "__Secure-next-auth.session-token", + "next-auth.session-token", + "__Secure-authjs.session-token", + "authjs.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 { @@ -25,23 +35,36 @@ public enum PerplexityCookieHeader { // Accept bare token value if !raw.contains("=") && !raw.contains(";") { - return PerplexityCookieOverride(token: raw) + return PerplexityCookieOverride(name: self.defaultSessionCookieName, token: raw) } - // Extract __Secure-next-auth.session-token from a full cookie string - if let token = self.extractSessionToken(from: raw) { - return PerplexityCookieOverride(token: token) + // Extract a supported session cookie from a full cookie string. + if let cookie = self.extractSessionCookie(from: raw) { + return cookie } return nil } - private static func extractSessionToken(from raw: String) -> String? { - let key = "__Secure-next-auth.session-token=" - guard let keyRange = raw.range(of: key, options: .caseInsensitive) else { return nil } - let rest = raw[keyRange.upperBound...] - let value = rest.prefix(while: { $0 != ";" && !$0.isWhitespace }) - let token = String(value).trimmingCharacters(in: .whitespacesAndNewlines) - return token.isEmpty ? nil : token + private static func extractSessionCookie(from raw: String) -> PerplexityCookieOverride? { + let pairs = raw.split(separator: ";") + var cookieMap: [String: (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[.. Bool { - // Priority order mirrors resolveToken: manual override → browser import → env var + // 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 } @@ -66,14 +70,34 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let token = self.resolveToken(context: context) else { + guard let cookie = self.resolveSessionCookie(context: context) else { throw PerplexityAPIError.missingToken } - let snapshot = try await PerplexityUsageFetcher.fetchCredits(sessionToken: token) - return self.makeResult( - usage: snapshot.toUsageSnapshot(), - sourceLabel: "web") + do { + let snapshot = try await PerplexityUsageFetcher.fetchCredits( + sessionToken: cookie.token, + cookieName: cookie.name) + self.cacheSessionCookie(cookie, sourceLabel: "web") + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } catch PerplexityAPIError.invalidToken { + // Clear stale cache and retry once with a fresh browser import + CookieHeaderCache.clear(provider: .perplexity) + guard let freshCookie = self.resolveSessionCookieSkippingCache(context: context), + freshCookie.token != cookie.token + else { + throw PerplexityAPIError.invalidToken + } + let snapshot = try await PerplexityUsageFetcher.fetchCredits( + sessionToken: freshCookie.token, + cookieName: freshCookie.name) + self.cacheSessionCookie(freshCookie, sourceLabel: "web (retry)") + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "web") + } } func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { @@ -82,19 +106,38 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { return true } - private func resolveToken(context: ProviderFetchContext) -> String? { + private func resolveSessionCookie(context: ProviderFetchContext) -> PerplexityCookieOverride? { // Check manual cookie first (highest priority when set) if let override = PerplexityCookieHeader.resolveCookieOverride(context: context) { - return override.token + return override + } + + // Try cached cookie before expensive browser import + if let cached = CookieHeaderCache.load(provider: .perplexity) { + if let override = PerplexityCookieHeader.override(from: cached.cookieHeader) { + return override + } + } + + return self.resolveSessionCookieFromBrowserOrEnv(context: context) + } + + /// Resolves a session cookie without consulting the cache (used for retry after invalidToken). + private func resolveSessionCookieSkippingCache(context: ProviderFetchContext) -> PerplexityCookieOverride? { + if let override = PerplexityCookieHeader.resolveCookieOverride(context: context) { + return override } + return self.resolveSessionCookieFromBrowserOrEnv(context: context) + } + private func resolveSessionCookieFromBrowserOrEnv(context: ProviderFetchContext) -> PerplexityCookieOverride? { // Try browser cookie import when auto mode is enabled #if os(macOS) if context.settings?.perplexity?.cookieSource != .off { do { let session = try PerplexityCookieImporter.importSession() - if let token = session.sessionToken { - return token + if let cookie = session.sessionCookie { + return cookie } } catch { // No browser cookies found @@ -104,8 +147,17 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { // Fall back to environment if let token = PerplexitySettingsReader.sessionToken(environment: context.env) { - return token + return PerplexityCookieOverride( + name: PerplexityCookieHeader.defaultSessionCookieName, + token: token) } return nil } + + private func cacheSessionCookie(_ cookie: PerplexityCookieOverride, sourceLabel: String) { + CookieHeaderCache.store( + provider: .perplexity, + cookieHeader: "\(cookie.name)=\(cookie.token)", + sourceLabel: sourceLabel) + } } diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift index 6257acaea..965f9ba56 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift @@ -22,10 +22,9 @@ public struct PerplexityUsageFetcher: Sendable { public static func fetchCredits( sessionToken: String, - cookieName: String = "__Secure-next-auth.session-token", + cookieName: String = PerplexityCookieHeader.defaultSessionCookieName, now: Date = Date()) async throws -> PerplexityUsageSnapshot { - Self.log.debug("Perplexity fetchCredits starting cookieName=\(cookieName)") var request = URLRequest(url: self.creditsURL) request.httpMethod = "GET" request.timeoutInterval = 15 @@ -61,6 +60,8 @@ public struct PerplexityUsageFetcher: Sendable { "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 index 0afa167aa..a487b1f80 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift @@ -16,7 +16,7 @@ public struct PerplexityUsageSnapshot: Sendable { public init(response: PerplexityCreditsResponse, now: Date) { let recurring = response.creditGrants.filter { $0.type == "recurring" } let promotional = response.creditGrants.filter { - $0.type == "promotional" && ($0.expiresAtTs.map { $0 > now.timeIntervalSince1970 } ?? true) + $0.type == "promotional" && ($0.expiresAtTs ?? .infinity) > now.timeIntervalSince1970 } // All timestamps from the Perplexity API are Unix seconds (verified Feb 2026). diff --git a/Tests/CodexBarTests/PerplexityCookieCacheTests.swift b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift new file mode 100644 index 000000000..40f5ea293 --- /dev/null +++ b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift @@ -0,0 +1,155 @@ +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 + + // 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) + } +} diff --git a/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift new file mode 100644 index 000000000..4ed7e7209 --- /dev/null +++ b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift @@ -0,0 +1,34 @@ +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") + } + + @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 unsupportedCookieHeaderReturnsNil() { + let override = PerplexityCookieHeader.override(from: "foo=bar; hello=world") + #expect(override == nil) + } +} From b0cd9719aeda94efba44bcebea11a1607c119283 Mon Sep 17 00:00:00 2001 From: John Budnick Date: Fri, 20 Mar 2026 14:03:17 -0400 Subject: [PATCH 03/11] fix: detect purchased credits from credit_grants array Perplexity now sends purchased credits as a credit_grant with type="purchased" instead of (or in addition to) the top-level current_period_purchased_cents field. Take the larger of the two sources to avoid double-counting while catching either path. Without this fix, 40k purchased credits were invisible to the waterfall calculation, causing bonus credits to show 100% used when they still had ~42% remaining. --- Sources/CodexBarCLI/TokenAccountCLI.swift | 2 + .../Perplexity/PerplexityUsageSnapshot.swift | 8 +- .../PerplexityUsageFetcherTests.swift | 116 ++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index 07268a73c..d52302847 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -178,6 +178,8 @@ struct TokenAccountCLIContext { 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, diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift index a487b1f80..2da07b5e9 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift @@ -18,11 +18,17 @@ public struct PerplexityUsageSnapshot: Sendable { 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 }) - let purchasedSum = max(0, response.currentPeriodPurchasedCents) + // 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 diff --git a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift index f8703e0ca..1a4c05417 100644 --- a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift +++ b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift @@ -204,6 +204,122 @@ struct PerplexityUsageFetcherTests { #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 = """ From 5bd3e4069524425a9148a386686397963d6b50d1 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 26 Mar 2026 00:42:49 +0530 Subject: [PATCH 04/11] Fix Perplexity session cookie handling --- .../Perplexity/PerplexitySettingsStore.swift | 3 +- .../Perplexity/PerplexityCookieHeader.swift | 59 +++++++++++++++++-- .../Perplexity/PerplexityCookieImporter.swift | 7 +-- .../PerplexityProviderDescriptor.swift | 29 ++++----- .../Perplexity/PerplexitySettingsReader.swift | 20 +++++-- .../Perplexity/PerplexityUsageFetcher.swift | 2 +- .../Perplexity/PerplexityUsageSnapshot.swift | 24 ++++---- .../PerplexityCookieCacheTests.swift | 48 +++++++++++++++ .../PerplexityCookieHeaderTests.swift | 45 ++++++++++++++ .../PerplexityProviderTests.swift | 52 ++++++++++++++++ .../PerplexitySettingsReaderTests.swift | 27 +++++++++ .../PerplexityUsageFetcherTests.swift | 8 +-- 12 files changed, 277 insertions(+), 47 deletions(-) create mode 100644 Tests/CodexBarTests/PerplexityProviderTests.swift create mode 100644 Tests/CodexBarTests/PerplexitySettingsReaderTests.swift diff --git a/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift b/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift index 67100c6a1..6d2d44dfc 100644 --- a/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift +++ b/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift @@ -24,7 +24,8 @@ extension SettingsStore { } extension SettingsStore { - func perplexitySettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot.PerplexityProviderSettings { + 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 diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift index bffc26c5c..1cdd2ab92 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift @@ -34,7 +34,7 @@ public enum PerplexityCookieHeader { } // Accept bare token value - if !raw.contains("=") && !raw.contains(";") { + if !raw.contains("="), !raw.contains(";") { return PerplexityCookieOverride(name: self.defaultSessionCookieName, token: raw) } @@ -46,9 +46,13 @@ public enum PerplexityCookieHeader { 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 cookieMap: [String: (name: String, value: String)] = [:] + var cookies: [(name: String, value: String)] = [] for pair in pairs { let trimmed = pair.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } @@ -57,14 +61,61 @@ public enum PerplexityCookieHeader { let value = String(trimmed[trimmed.index(after: separator)...]).trimmingCharacters( in: .whitespacesAndNewlines) guard !key.isEmpty, !value.isEmpty else { continue } - cookieMap[key.lowercased()] = (name: key, value: value) + cookies.append((name: key, value: value)) + } + return self.extractSessionCookie(from: cookies) + } + + private static func extractSessionCookie(from cookies: [(name: String, value: String)]) + -> 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 { - if let match = cookieMap[expected.lowercased()] { + 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[.. Bool { + guard context.settings?.perplexity?.cookieSource != .off else { return false } + // Priority order mirrors resolveSessionCookie: manual override → cache → browser import → env var if PerplexityCookieHeader.resolveCookieOverride(context: context) != nil { return true @@ -107,6 +109,8 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { } private func resolveSessionCookie(context: ProviderFetchContext) -> PerplexityCookieOverride? { + guard context.settings?.perplexity?.cookieSource != .off else { return nil } + // Check manual cookie first (highest priority when set) if let override = PerplexityCookieHeader.resolveCookieOverride(context: context) { return override @@ -124,6 +128,8 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { /// Resolves a session cookie without consulting the cache (used for retry after invalidToken). private func resolveSessionCookieSkippingCache(context: ProviderFetchContext) -> PerplexityCookieOverride? { + guard context.settings?.perplexity?.cookieSource != .off else { return nil } + if let override = PerplexityCookieHeader.resolveCookieOverride(context: context) { return override } @@ -131,27 +137,22 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { } private func resolveSessionCookieFromBrowserOrEnv(context: ProviderFetchContext) -> PerplexityCookieOverride? { + guard context.settings?.perplexity?.cookieSource != .off else { return nil } + // Try browser cookie import when auto mode is enabled #if os(macOS) - if context.settings?.perplexity?.cookieSource != .off { - do { - let session = try PerplexityCookieImporter.importSession() - if let cookie = session.sessionCookie { - return cookie - } - } catch { - // No browser cookies found + do { + let session = try PerplexityCookieImporter.importSession() + if let cookie = session.sessionCookie { + return cookie } + } catch { + // No browser cookies found } #endif // Fall back to environment - if let token = PerplexitySettingsReader.sessionToken(environment: context.env) { - return PerplexityCookieOverride( - name: PerplexityCookieHeader.defaultSessionCookieName, - token: token) - } - return nil + return PerplexitySettingsReader.sessionCookieOverride(environment: context.env) } private func cacheSessionCookie(_ cookie: PerplexityCookieOverride, sourceLabel: String) { diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift index c9916529d..dc5558eaa 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift @@ -1,20 +1,30 @@ import Foundation public enum PerplexitySettingsReader { - public static func sessionToken( - environment: [String: String] = ProcessInfo.processInfo.environment) -> String? + 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 token } + if let token = self.cleaned(raw) { + return PerplexityCookieOverride( + name: PerplexityCookieHeader.defaultSessionCookieName, + token: token) + } - // PERPLEXITY_COOKIE may be a full Cookie header string; extract the session token from it. + // 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))?.token + 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 diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift index 965f9ba56..20787f455 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift @@ -6,9 +6,9 @@ import FoundationNetworking public struct PerplexityUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.perplexityAPI) - // version=2.18&source=default was current as of Feb 2026; update if the endpoint shape changes. private static let creditsURL = URL(string: "https://www.perplexity.ai/rest/billing/credits?version=2.18&source=default")! + // version=2.18&source=default was current as of Feb 2026; update if the endpoint shape changes. /// Testing hook: parse a raw JSON response without making network calls. public static func _parseResponseForTesting(_ data: Data, now: Date = Date()) throws -> PerplexityUsageSnapshot { diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift index 2da07b5e9..7f2507709 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift @@ -54,8 +54,8 @@ public struct PerplexityUsageSnapshot: Sendable { /// Infer plan name from recurring credit allotment. /// Free = 0, Pro = small pool (~500–1000), Max = 10,000+. public var planName: String? { - if recurringTotal <= 0 { return nil } - if recurringTotal < 5_000 { return "Pro" } + if self.recurringTotal <= 0 { return nil } + if self.recurringTotal < 5000 { return "Pro" } return "Max" } @@ -70,21 +70,21 @@ extension PerplexityUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { // Primary: recurring (monthly) credits // usedPercent=100 when recurringTotal==0 so the bar renders empty rather than full. - let primaryPercent = recurringTotal > 0 - ? min(100, max(0, recurringUsed / recurringTotal * 100)) + let primaryPercent = self.recurringTotal > 0 + ? min(100, max(0, self.recurringUsed / self.recurringTotal * 100)) : 100.0 let primaryWindow = RateWindow( usedPercent: primaryPercent, windowMinutes: nil, resetsAt: renewalDate, - resetDescription: "\(Int(recurringUsed.rounded()))/\(Int(recurringTotal)) credits") + resetDescription: "\(Int(recurringUsed.rounded()))/\(Int(self.recurringTotal)) credits") // Secondary: promotional bonus credits — always shown. // usedPercent=100 when promoTotal==0 so the bar renders empty rather than full. - let promoPercent = promoTotal > 0 - ? min(100, max(0, promoUsed / promoTotal * 100)) + let promoPercent = self.promoTotal > 0 + ? min(100, max(0, self.promoUsed / self.promoTotal * 100)) : 100.0 - var promoDesc = "\(Int(promoUsed.rounded()))/\(Int(promoTotal)) bonus" + var promoDesc = "\(Int(promoUsed.rounded()))/\(Int(self.promoTotal)) bonus" if let expiry = promoExpiration { promoDesc += " \u{00b7} exp. \(Self.promoExpiryFormatter.string(from: expiry))" } @@ -96,14 +96,14 @@ extension PerplexityUsageSnapshot { // Tertiary: on-demand purchased credits — always shown. // usedPercent=100 when purchasedTotal==0 so the bar renders empty rather than full. - let purchasedPercent = purchasedTotal > 0 - ? min(100, max(0, purchasedUsed / purchasedTotal * 100)) + 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(purchasedTotal)) credits") + resetDescription: "\(Int(purchasedUsed.rounded()))/\(Int(self.purchasedTotal)) credits") let identity = ProviderIdentitySnapshot( providerID: .perplexity, @@ -116,7 +116,7 @@ extension PerplexityUsageSnapshot { secondary: secondary, tertiary: tertiary, providerCost: nil, - updatedAt: updatedAt, + updatedAt: self.updatedAt, identity: identity) } } diff --git a/Tests/CodexBarTests/PerplexityCookieCacheTests.swift b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift index 40f5ea293..246a053ec 100644 --- a/Tests/CodexBarTests/PerplexityCookieCacheTests.swift +++ b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift @@ -7,6 +7,20 @@ 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 @@ -152,4 +166,38 @@ struct PerplexityCookieCacheTests { #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 index 4ed7e7209..aa0810ba9 100644 --- a/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift +++ b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import CodexBarCore @@ -26,9 +27,53 @@ struct PerplexityCookieHeaderTests { #expect(override?.token == "token-b") } + @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..18ccb1e2b --- /dev/null +++ b/Tests/CodexBarTests/PerplexityProviderTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PerplexityProviderTests { + 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)) + } + + @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) + } +} diff --git a/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift b/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift new file mode 100644 index 000000000..2eb4e00bd --- /dev/null +++ b/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift @@ -0,0 +1,27 @@ +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") + } +} diff --git a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift index 1a4c05417..075a258bf 100644 --- a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift +++ b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift @@ -56,9 +56,9 @@ struct PerplexityUsageFetcherTests { """ 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 + #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 @@ -77,7 +77,7 @@ struct PerplexityUsageFetcherTests { """ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now) - #expect(snapshot.promoTotal == 0) // expired grant excluded + #expect(snapshot.promoTotal == 0) // expired grant excluded #expect(snapshot.promoUsed == 0) #expect(snapshot.promoExpiration == nil) } From 3d49680966b2115ab88f4245624a8ed5ea45cfd3 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 26 Mar 2026 01:12:18 +0530 Subject: [PATCH 05/11] Refine Perplexity credit and cookie behavior --- .../Perplexity/PerplexityAPIError.swift | 3 + .../Perplexity/PerplexityCookieImporter.swift | 5 + .../PerplexityProviderDescriptor.swift | 68 ++++++++---- .../Perplexity/PerplexityUsageFetcher.swift | 7 +- .../Perplexity/PerplexityUsageSnapshot.swift | 30 +++-- .../PerplexityProviderTests.swift | 105 ++++++++++++++++++ .../PerplexityUsageFetcherTests.swift | 21 ++++ 7 files changed, 209 insertions(+), 30 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift index 91a3036b5..35baf9350 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift @@ -2,6 +2,7 @@ import Foundation public enum PerplexityAPIError: LocalizedError, Sendable, Equatable { case missingToken + case invalidCookie case invalidToken case networkError(String) case apiError(String) @@ -11,6 +12,8 @@ public enum PerplexityAPIError: LocalizedError, Sendable, Equatable { 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): diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift index a45342d02..1eab0f7cc 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift @@ -9,6 +9,8 @@ public enum PerplexityCookieImporter { private static let cookieDomains = ["www.perplexity.ai", "perplexity.ai"] private static let cookieImportOrder: BrowserCookieImportOrder = ProviderDefaults.metadata[.perplexity]?.browserCookieOrder ?? Browser.defaultImportOrder + nonisolated(unsafe) static var importSessionOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) throws -> SessionInfo)? public struct SessionInfo: Sendable { public let cookies: [HTTPCookie] @@ -91,6 +93,9 @@ public enum PerplexityCookieImporter { browserDetection: BrowserDetection = BrowserDetection(), logger: ((String) -> Void)? = nil) throws -> SessionInfo { + if let override = self.importSessionOverrideForTesting { + return try override(browserDetection, logger) + } let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) guard let first = sessions.first else { throw PerplexityCookieImportError.noCookies diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift index 94edb3f99..695e72197 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift @@ -43,11 +43,28 @@ public enum PerplexityProviderDescriptor { } 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 + } + 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 { @@ -72,30 +89,31 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let cookie = self.resolveSessionCookie(context: context) else { + guard let resolvedCookie = try self.resolveSessionCookie(context: context) else { throw PerplexityAPIError.missingToken } + let cookie = resolvedCookie.value do { let snapshot = try await PerplexityUsageFetcher.fetchCredits( sessionToken: cookie.token, cookieName: cookie.name) - self.cacheSessionCookie(cookie, sourceLabel: "web") + self.cacheSessionCookieIfNeeded(resolvedCookie, sourceLabel: "web") return self.makeResult( usage: snapshot.toUsageSnapshot(), sourceLabel: "web") } catch PerplexityAPIError.invalidToken { // Clear stale cache and retry once with a fresh browser import CookieHeaderCache.clear(provider: .perplexity) - guard let freshCookie = self.resolveSessionCookieSkippingCache(context: context), - freshCookie.token != cookie.token + guard let freshCookie = try self.resolveSessionCookieSkippingCache(context: context), + freshCookie.value.token != cookie.token else { throw PerplexityAPIError.invalidToken } let snapshot = try await PerplexityUsageFetcher.fetchCredits( - sessionToken: freshCookie.token, - cookieName: freshCookie.name) - self.cacheSessionCookie(freshCookie, sourceLabel: "web (retry)") + sessionToken: freshCookie.value.token, + cookieName: freshCookie.value.name) + self.cacheSessionCookieIfNeeded(freshCookie, sourceLabel: "web (retry)") return self.makeResult( usage: snapshot.toUsageSnapshot(), sourceLabel: "web") @@ -104,22 +122,25 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { 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 resolveSessionCookie(context: ProviderFetchContext) -> PerplexityCookieOverride? { + private func resolveSessionCookie(context: ProviderFetchContext) throws -> ResolvedSessionCookie? { guard context.settings?.perplexity?.cookieSource != .off else { return nil } - // Check manual cookie first (highest priority when set) - if let override = PerplexityCookieHeader.resolveCookieOverride(context: context) { - return override + if context.settings?.perplexity?.cookieSource == .manual { + guard let override = PerplexityCookieHeader.resolveCookieOverride(context: context) else { + throw PerplexityAPIError.invalidCookie + } + return ResolvedSessionCookie(value: override, source: .manual) } // Try cached cookie before expensive browser import if let cached = CookieHeaderCache.load(provider: .perplexity) { if let override = PerplexityCookieHeader.override(from: cached.cookieHeader) { - return override + return ResolvedSessionCookie(value: override, source: .cache) } } @@ -127,16 +148,19 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { } /// Resolves a session cookie without consulting the cache (used for retry after invalidToken). - private func resolveSessionCookieSkippingCache(context: ProviderFetchContext) -> PerplexityCookieOverride? { + private func resolveSessionCookieSkippingCache(context: ProviderFetchContext) throws -> ResolvedSessionCookie? { guard context.settings?.perplexity?.cookieSource != .off else { return nil } - if let override = PerplexityCookieHeader.resolveCookieOverride(context: context) { - return override + if context.settings?.perplexity?.cookieSource == .manual { + guard let override = PerplexityCookieHeader.resolveCookieOverride(context: context) else { + throw PerplexityAPIError.invalidCookie + } + return ResolvedSessionCookie(value: override, source: .manual) } return self.resolveSessionCookieFromBrowserOrEnv(context: context) } - private func resolveSessionCookieFromBrowserOrEnv(context: ProviderFetchContext) -> PerplexityCookieOverride? { + private func resolveSessionCookieFromBrowserOrEnv(context: ProviderFetchContext) -> ResolvedSessionCookie? { guard context.settings?.perplexity?.cookieSource != .off else { return nil } // Try browser cookie import when auto mode is enabled @@ -144,7 +168,7 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { do { let session = try PerplexityCookieImporter.importSession() if let cookie = session.sessionCookie { - return cookie + return ResolvedSessionCookie(value: cookie, source: .browser) } } catch { // No browser cookies found @@ -152,13 +176,17 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { #endif // Fall back to environment - return PerplexitySettingsReader.sessionCookieOverride(environment: context.env) + if let cookie = PerplexitySettingsReader.sessionCookieOverride(environment: context.env) { + return ResolvedSessionCookie(value: cookie, source: .environment) + } + return nil } - private func cacheSessionCookie(_ cookie: PerplexityCookieOverride, sourceLabel: String) { + private func cacheSessionCookieIfNeeded(_ cookie: ResolvedSessionCookie, sourceLabel: String) { + guard cookie.source.shouldCacheAfterFetch else { return } CookieHeaderCache.store( provider: .perplexity, - cookieHeader: "\(cookie.name)=\(cookie.token)", + cookieHeader: "\(cookie.value.name)=\(cookie.value.token)", sourceLabel: sourceLabel) } } diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift index 20787f455..2a9fe5211 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift @@ -8,7 +8,8 @@ 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")! - // version=2.18&source=default was current as of Feb 2026; update if the endpoint shape changes. + @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 { @@ -25,6 +26,10 @@ public struct PerplexityUsageFetcher: Sendable { 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 diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift index 7f2507709..48e4cf9cc 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift @@ -69,15 +69,27 @@ public struct PerplexityUsageSnapshot: Sendable { extension PerplexityUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { // Primary: recurring (monthly) credits - // usedPercent=100 when recurringTotal==0 so the bar renders empty rather than full. - let primaryPercent = self.recurringTotal > 0 - ? min(100, max(0, self.recurringUsed / self.recurringTotal * 100)) - : 100.0 - let primaryWindow = RateWindow( - usedPercent: primaryPercent, - windowMinutes: nil, - resetsAt: renewalDate, - resetDescription: "\(Int(recurringUsed.rounded()))/\(Int(self.recurringTotal)) 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. diff --git a/Tests/CodexBarTests/PerplexityProviderTests.swift b/Tests/CodexBarTests/PerplexityProviderTests.swift index 18ccb1e2b..b37ec3f64 100644 --- a/Tests/CodexBarTests/PerplexityProviderTests.swift +++ b/Tests/CodexBarTests/PerplexityProviderTests.swift @@ -4,6 +4,8 @@ import Testing @Suite(.serialized) struct PerplexityProviderTests { + private static let now = Date(timeIntervalSince1970: 1_740_000_000) + private struct StubClaudeFetcher: ClaudeUsageFetching { func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { throw ClaudeUsageError.parseFailed("stub") @@ -36,6 +38,28 @@ struct PerplexityProviderTests { 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() @@ -49,4 +73,85 @@ struct PerplexityProviderTests { #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"]) + + do { + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue { _, _, _ in + self.stubSnapshot() + } 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.importSessionOverrideForTesting = { _, _ in + throw PerplexityCookieImportError.noCookies + } + defer { + PerplexityCookieImporter.importSessionOverrideForTesting = nil + } + + 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"]) + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue { _, _, _ in + self.stubSnapshot() + } 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) + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue { _, _, _ in + self.stubSnapshot() + } operation: { + try await strategy.fetch(context) + } + + #expect(CookieHeaderCache.load(provider: .perplexity) == nil) + } + } } diff --git a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift index 075a258bf..cd115081a 100644 --- a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift +++ b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift @@ -181,6 +181,27 @@ struct PerplexityUsageFetcherTests { #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 = """ From ac0a36ea5008d0d2fa0a701ae472bdb38b48b373 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 26 Mar 2026 11:46:43 +0530 Subject: [PATCH 06/11] Improve Perplexity cookie fallback handling --- .../Perplexity/PerplexityCookieHeader.swift | 9 +- .../Perplexity/PerplexityCookieImporter.swift | 58 +++++- .../PerplexityProviderDescriptor.swift | 85 +++++++-- .../Perplexity/PerplexitySettingsReader.swift | 6 +- .../PerplexityCookieHeaderTests.swift | 1 + .../PerplexityProviderTests.swift | 166 ++++++++++++++++++ .../PerplexitySettingsReaderTests.swift | 11 ++ 7 files changed, 308 insertions(+), 28 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift index 1cdd2ab92..70f604ae4 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift @@ -3,10 +3,12 @@ import Foundation public struct PerplexityCookieOverride: Sendable { public let name: String public let token: String + public let requestCookieNames: [String] - public init(name: String, token: String) { + public init(name: String, token: String, requestCookieNames: [String]? = nil) { self.name = name self.token = token + self.requestCookieNames = requestCookieNames ?? [name] } } @@ -35,7 +37,10 @@ public enum PerplexityCookieHeader { // Accept bare token value if !raw.contains("="), !raw.contains(";") { - return PerplexityCookieOverride(name: self.defaultSessionCookieName, token: raw) + return PerplexityCookieOverride( + name: self.defaultSessionCookieName, + token: raw, + requestCookieNames: self.supportedSessionCookieNames) } // Extract a supported session cookie from a full cookie string. diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift index 1eab0f7cc..9ae2e228f 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift @@ -4,6 +4,8 @@ import Foundation import SweetCookieKit public enum PerplexityCookieImporter { + private static let importSessionCacheTTL: TimeInterval = 5 + private static let importSessionCache = ImportSessionCache(ttl: importSessionCacheTTL) private static let log = CodexBarLog.logger(LogCategories.perplexityCookie) private static let cookieClient = BrowserCookieClient() private static let cookieDomains = ["www.perplexity.ai", "perplexity.ai"] @@ -93,13 +95,19 @@ public enum PerplexityCookieImporter { browserDetection: BrowserDetection = BrowserDetection(), logger: ((String) -> Void)? = nil) throws -> SessionInfo { + if let cached = self.cachedImportSession() { + return cached + } if let override = self.importSessionOverrideForTesting { - return try override(browserDetection, logger) + let session = try override(browserDetection, logger) + self.storeImportSession(session) + return session } let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) guard let first = sessions.first else { throw PerplexityCookieImportError.noCookies } + self.storeImportSession(first) return first } @@ -108,17 +116,30 @@ public enum PerplexityCookieImporter { logger: ((String) -> Void)? = nil) -> Bool { do { - return try !self.importSessions(browserDetection: browserDetection, logger: logger).isEmpty + _ = 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 cachedImportSession(now: Date = Date()) -> SessionInfo? { + self.importSessionCache.load(now: now) + } + + private static func storeImportSession(_ session: SessionInfo, now: Date = Date()) { + self.importSessionCache.store(session, now: now) + } + private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String { guard let base = sources.map(\.label).min() else { return "Unknown" } if base.hasSuffix(" (Network)") { @@ -167,6 +188,39 @@ public enum PerplexityCookieImporter { case (nil, nil): false } } + + private final class ImportSessionCache: @unchecked Sendable { + private let ttl: TimeInterval + private let lock = NSLock() + private var entry: (session: 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.session + } + + func store(_ session: SessionInfo, now: Date) { + self.lock.lock() + defer { self.lock.unlock() } + self.entry = (session: session, expiresAt: now.addingTimeInterval(self.ttl)) + } + + func invalidate() { + self.lock.lock() + defer { self.lock.unlock() } + self.entry = nil + } + } } enum PerplexityCookieImportError: LocalizedError, Sendable { diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift index 695e72197..09930a53a 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift @@ -59,6 +59,11 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { let source: SessionCookieSource } + private struct SessionFetchResult { + let snapshot: PerplexityUsageSnapshot + let cookie: PerplexityCookieOverride + } + let id: String = "perplexity.web" let kind: ProviderFetchKind = .web @@ -92,30 +97,28 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { guard let resolvedCookie = try self.resolveSessionCookie(context: context) else { throw PerplexityAPIError.missingToken } - let cookie = resolvedCookie.value - do { - let snapshot = try await PerplexityUsageFetcher.fetchCredits( - sessionToken: cookie.token, - cookieName: cookie.name) - self.cacheSessionCookieIfNeeded(resolvedCookie, sourceLabel: "web") + let result = try await self.fetchSnapshot(using: resolvedCookie) + self.cacheSessionCookieIfNeeded(resolvedCookie, usedCookie: result.cookie, sourceLabel: "web") return self.makeResult( - usage: snapshot.toUsageSnapshot(), + usage: result.snapshot.toUsageSnapshot(), sourceLabel: "web") } catch PerplexityAPIError.invalidToken { // Clear stale cache and retry once with a fresh browser import CookieHeaderCache.clear(provider: .perplexity) - guard let freshCookie = try self.resolveSessionCookieSkippingCache(context: context), - freshCookie.value.token != cookie.token + PerplexityCookieImporter.invalidateImportSessionCache() + let preferEnvironment = resolvedCookie.source == .browser || resolvedCookie.source == .cache + guard let freshCookie = try self.resolveSessionCookieSkippingCache( + context: context, + preferEnvironment: preferEnvironment), + !self.isEquivalentCookie(freshCookie.value, resolvedCookie.value) else { throw PerplexityAPIError.invalidToken } - let snapshot = try await PerplexityUsageFetcher.fetchCredits( - sessionToken: freshCookie.value.token, - cookieName: freshCookie.value.name) - self.cacheSessionCookieIfNeeded(freshCookie, sourceLabel: "web (retry)") + let result = try await self.fetchSnapshot(using: freshCookie) + self.cacheSessionCookieIfNeeded(freshCookie, usedCookie: result.cookie, sourceLabel: "web (retry)") return self.makeResult( - usage: snapshot.toUsageSnapshot(), + usage: result.snapshot.toUsageSnapshot(), sourceLabel: "web") } } @@ -148,7 +151,10 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { } /// Resolves a session cookie without consulting the cache (used for retry after invalidToken). - private func resolveSessionCookieSkippingCache(context: ProviderFetchContext) throws -> ResolvedSessionCookie? { + private func resolveSessionCookieSkippingCache( + context: ProviderFetchContext, + preferEnvironment: Bool = false) throws -> ResolvedSessionCookie? + { guard context.settings?.perplexity?.cookieSource != .off else { return nil } if context.settings?.perplexity?.cookieSource == .manual { @@ -157,12 +163,23 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { } return ResolvedSessionCookie(value: override, source: .manual) } - return self.resolveSessionCookieFromBrowserOrEnv(context: context) + return self.resolveSessionCookieFromBrowserOrEnv( + context: context, + preferEnvironment: preferEnvironment) } - private func resolveSessionCookieFromBrowserOrEnv(context: ProviderFetchContext) -> ResolvedSessionCookie? { + private func resolveSessionCookieFromBrowserOrEnv( + context: ProviderFetchContext, + preferEnvironment: Bool = false) -> ResolvedSessionCookie? + { guard context.settings?.perplexity?.cookieSource != .off else { return nil } + if preferEnvironment, + let cookie = PerplexitySettingsReader.sessionCookieOverride(environment: context.env) + { + return ResolvedSessionCookie(value: cookie, source: .environment) + } + // Try browser cookie import when auto mode is enabled #if os(macOS) do { @@ -182,11 +199,41 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { return nil } - private func cacheSessionCookieIfNeeded(_ cookie: ResolvedSessionCookie, sourceLabel: String) { + private func cacheSessionCookieIfNeeded( + _ cookie: ResolvedSessionCookie, + usedCookie: PerplexityCookieOverride, + sourceLabel: String) + { guard cookie.source.shouldCacheAfterFetch else { return } CookieHeaderCache.store( provider: .perplexity, - cookieHeader: "\(cookie.value.name)=\(cookie.value.token)", + 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 index dc5558eaa..e36eb0e60 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift @@ -6,11 +6,7 @@ public enum PerplexitySettingsReader { { let raw = environment["PERPLEXITY_SESSION_TOKEN"] ?? environment["perplexity_session_token"] - if let token = self.cleaned(raw) { - return PerplexityCookieOverride( - name: PerplexityCookieHeader.defaultSessionCookieName, - token: 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"] { diff --git a/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift index aa0810ba9..ce98250d4 100644 --- a/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift +++ b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift @@ -9,6 +9,7 @@ struct PerplexityCookieHeaderTests { let override = PerplexityCookieHeader.override(from: "abc123") #expect(override?.name == PerplexityCookieHeader.defaultSessionCookieName) #expect(override?.token == "abc123") + #expect(override?.requestCookieNames == PerplexityCookieHeader.supportedSessionCookieNames) } @Test diff --git a/Tests/CodexBarTests/PerplexityProviderTests.swift b/Tests/CodexBarTests/PerplexityProviderTests.swift index b37ec3f64..bb8bcc795 100644 --- a/Tests/CodexBarTests/PerplexityProviderTests.swift +++ b/Tests/CodexBarTests/PerplexityProviderTests.swift @@ -6,6 +6,40 @@ import Testing 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") @@ -109,11 +143,13 @@ struct PerplexityProviderTests { @Test func environmentTokenDoesNotPopulateBrowserCookieCache() async throws { try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in throw PerplexityCookieImportError.noCookies } defer { PerplexityCookieImporter.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() } let strategy = PerplexityWebFetchStrategy() @@ -154,4 +190,134 @@ struct PerplexityProviderTests { #expect(CookieHeaderCache.load(provider: .perplexity) == nil) } } + + @Test + func bareEnvironmentTokenFallsBackToAuthJSCookieName() async throws { + try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() + PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in + throw PerplexityCookieImportError.noCookies + } + defer { + 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"]) + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue { token, cookieName, _ in + #expect(token == "env-token") + attemptedCookieNames.append(cookieName) + if cookieName == "authjs.session-token" { + return self.stubSnapshot() + } + throw PerplexityAPIError.invalidToken + } operation: { + try await strategy.fetch(context) + } + + #expect(attemptedCookieNames.snapshot() == [ + "__Secure-next-auth.session-token", + "next-auth.session-token", + "__Secure-authjs.session-token", + "authjs.session-token", + ]) + } + } + + @Test + func validEnvironmentCookieWinsAfterInvalidBrowserSession() async throws { + try await self.withIsolatedCacheStore { + PerplexityCookieImporter.invalidateImportSessionCache() + 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.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"]) + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue { 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 + } operation: { + try await strategy.fetch(context) + } + + #expect(attemptedTokens.snapshot() == ["browser-token", "env-token"]) + } + } + + @Test + func autoModeReusesBrowserImportBetweenAvailabilityAndFetch() async throws { + try await self.withIsolatedCacheStore { + let importCount = LockedCounter() + PerplexityCookieImporter.invalidateImportSessionCache() + 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.importSessionOverrideForTesting = nil + PerplexityCookieImporter.invalidateImportSessionCache() + } + + let strategy = PerplexityWebFetchStrategy() + let settings = ProviderSettingsSnapshot.make( + perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings( + cookieSource: .auto, + manualCookieHeader: nil)) + let context = self.makeContext(settings: settings) + + #expect(await strategy.isAvailable(context)) + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue { token, _, _ in + #expect(token == "browser-token") + return self.stubSnapshot() + } operation: { + try await strategy.fetch(context) + } + + #expect(importCount.snapshot() == 1) + } + } } diff --git a/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift b/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift index 2eb4e00bd..2f7b1ff7a 100644 --- a/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift +++ b/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift @@ -24,4 +24,15 @@ struct PerplexitySettingsReaderTests { #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) + } } From a25d2360db44b850a74979660a06c65121d03d98 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 26 Mar 2026 12:38:57 +0530 Subject: [PATCH 07/11] Fix Perplexity menu and browser session fallback --- Sources/CodexBar/IconView.swift | 17 ++ .../MenuBarMetricWindowResolver.swift | 17 ++ .../SettingsStore+MenuPreferences.swift | 8 +- .../Perplexity/PerplexityCookieHeader.swift | 4 +- .../Perplexity/PerplexityCookieImporter.swift | 43 +++-- .../PerplexityProviderDescriptor.swift | 111 +++++++------ Tests/CodexBarTests/CodexbarTests.swift | 13 ++ .../PerplexityCookieHeaderTests.swift | 10 ++ .../PerplexityProviderTests.swift | 152 +++++++++++++----- .../SettingsStoreAdditionalTests.swift | 5 + Tests/CodexBarTests/SettingsStoreTests.swift | 1 + .../StatusItemAnimationTests.swift | 80 +++++++++ .../UsageStoreHighestUsageTests.swift | 39 +++++ 13 files changed, 383 insertions(+), 117 deletions(-) diff --git a/Sources/CodexBar/IconView.swift b/Sources/CodexBar/IconView.swift index 684f0a3fe..d6e78a888 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 = self.perplexityWindows(snapshot: snapshot) + return ( + primary: windows.first?.remainingPercent, + secondary: windows.dropFirst().first?.remainingPercent) + } guard style == .antigravity else { return ( primary: snapshot.primary?.remainingPercent, @@ -14,6 +20,17 @@ enum IconRemainingResolver { primary: windows.first?.remainingPercent, secondary: windows.dropFirst().first?.remainingPercent) } + + private static func perplexityWindows(snapshot: UsageSnapshot) -> [RateWindow] { + if let primary = snapshot.primary { + return [primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) + } + + let fallbackWindows = [snapshot.secondary, snapshot.tertiary].compactMap(\.self) + let usableFallback = fallbackWindows.filter { $0.usedPercent < 100 } + let exhaustedFallback = fallbackWindows.filter { $0.usedPercent >= 100 } + return usableFallback + exhaustedFallback + } } @MainActor diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index a154be9cf..66d1fa08e 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 ?? Self.preferredPerplexityFallbackWindow(snapshot: snapshot) + } 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.primary ?? Self.preferredPerplexityFallbackWindow(snapshot: snapshot) + } if provider == .factory || provider == .kimi { return snapshot.secondary ?? snapshot.primary } @@ -64,6 +76,11 @@ enum MenuBarMetricWindowResolver { } } + private static func preferredPerplexityFallbackWindow(snapshot: UsageSnapshot) -> RateWindow? { + let fallbackWindows = [snapshot.secondary, snapshot.tertiary].compactMap(\.self) + return fallbackWindows.first(where: { $0.usedPercent < 100 }) ?? fallbackWindows.first + } + private static func mostConstrainedWindow( primary: RateWindow?, secondary: RateWindow?, 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/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift index 70f604ae4..75a51055f 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift @@ -15,10 +15,10 @@ public struct PerplexityCookieOverride: Sendable { public enum PerplexityCookieHeader { public static let defaultSessionCookieName = "__Secure-next-auth.session-token" public static let supportedSessionCookieNames = [ - "__Secure-next-auth.session-token", - "next-auth.session-token", "__Secure-authjs.session-token", "authjs.session-token", + "__Secure-next-auth.session-token", + "next-auth.session-token", ] public static func resolveCookieOverride(context: ProviderFetchContext) -> PerplexityCookieOverride? { diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift index 9ae2e228f..e23f6c167 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieImporter.swift @@ -13,6 +13,8 @@ public enum PerplexityCookieImporter { ProviderDefaults.metadata[.perplexity]?.browserCookieOrder ?? Browser.defaultImportOrder nonisolated(unsafe) static var importSessionOverrideForTesting: ((BrowserDetection, ((String) -> Void)?) throws -> SessionInfo)? + nonisolated(unsafe) static var importSessionsOverrideForTesting: + ((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])? public struct SessionInfo: Sendable { public let cookies: [HTTPCookie] @@ -36,6 +38,21 @@ public enum PerplexityCookieImporter { 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 { @@ -53,6 +70,7 @@ public enum PerplexityCookieImporter { guard !sessions.isEmpty else { throw PerplexityCookieImportError.noCookies } + self.storeImportSessions(sessions) return sessions } @@ -95,19 +113,10 @@ public enum PerplexityCookieImporter { browserDetection: BrowserDetection = BrowserDetection(), logger: ((String) -> Void)? = nil) throws -> SessionInfo { - if let cached = self.cachedImportSession() { - return cached - } - if let override = self.importSessionOverrideForTesting { - let session = try override(browserDetection, logger) - self.storeImportSession(session) - return session - } let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) guard let first = sessions.first else { throw PerplexityCookieImportError.noCookies } - self.storeImportSession(first) return first } @@ -132,12 +141,12 @@ public enum PerplexityCookieImporter { self.log.debug(message) } - private static func cachedImportSession(now: Date = Date()) -> SessionInfo? { + private static func cachedImportSessions(now: Date = Date()) -> [SessionInfo]? { self.importSessionCache.load(now: now) } - private static func storeImportSession(_ session: SessionInfo, now: Date = Date()) { - self.importSessionCache.store(session, 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 { @@ -192,13 +201,13 @@ public enum PerplexityCookieImporter { private final class ImportSessionCache: @unchecked Sendable { private let ttl: TimeInterval private let lock = NSLock() - private var entry: (session: SessionInfo, expiresAt: Date)? + private var entry: (sessions: [SessionInfo], expiresAt: Date)? init(ttl: TimeInterval) { self.ttl = ttl } - func load(now: Date) -> SessionInfo? { + func load(now: Date) -> [SessionInfo]? { self.lock.lock() defer { self.lock.unlock() } guard let entry = self.entry else { return nil } @@ -206,13 +215,13 @@ public enum PerplexityCookieImporter { self.entry = nil return nil } - return entry.session + return entry.sessions } - func store(_ session: SessionInfo, now: Date) { + func store(_ sessions: [SessionInfo], now: Date) { self.lock.lock() defer { self.lock.unlock() } - self.entry = (session: session, expiresAt: now.addingTimeInterval(self.ttl)) + self.entry = (sessions: sessions, expiresAt: now.addingTimeInterval(self.ttl)) } func invalidate() { diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift index 09930a53a..1a6da7c1b 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift @@ -94,33 +94,32 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let resolvedCookie = try self.resolveSessionCookie(context: context) else { + let resolvedCookies = try self.resolveSessionCookies(context: context) + guard !resolvedCookies.isEmpty else { throw PerplexityAPIError.missingToken } - 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 { - // Clear stale cache and retry once with a fresh browser import - CookieHeaderCache.clear(provider: .perplexity) - PerplexityCookieImporter.invalidateImportSessionCache() - let preferEnvironment = resolvedCookie.source == .browser || resolvedCookie.source == .cache - guard let freshCookie = try self.resolveSessionCookieSkippingCache( - context: context, - preferEnvironment: preferEnvironment), - !self.isEquivalentCookie(freshCookie.value, resolvedCookie.value) - else { - throw PerplexityAPIError.invalidToken + 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 } - let result = try await self.fetchSnapshot(using: freshCookie) - self.cacheSessionCookieIfNeeded(freshCookie, usedCookie: result.cookie, sourceLabel: "web (retry)") - return self.makeResult( - usage: result.snapshot.toUsageSnapshot(), - sourceLabel: "web") } + + if sawInvalidToken { + throw PerplexityAPIError.invalidToken + } + throw PerplexityAPIError.missingToken } func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { @@ -130,73 +129,73 @@ struct PerplexityWebFetchStrategy: ProviderFetchStrategy { return true } - private func resolveSessionCookie(context: ProviderFetchContext) throws -> ResolvedSessionCookie? { - guard context.settings?.perplexity?.cookieSource != .off else { return nil } + 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) + 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) { - return ResolvedSessionCookie(value: override, source: .cache) + cookies.append(ResolvedSessionCookie(value: override, source: .cache)) } } - return self.resolveSessionCookieFromBrowserOrEnv(context: context) - } - - /// Resolves a session cookie without consulting the cache (used for retry after invalidToken). - private func resolveSessionCookieSkippingCache( - context: ProviderFetchContext, - preferEnvironment: Bool = false) throws -> ResolvedSessionCookie? - { - guard context.settings?.perplexity?.cookieSource != .off else { return nil } - - if context.settings?.perplexity?.cookieSource == .manual { - guard let override = PerplexityCookieHeader.resolveCookieOverride(context: context) else { - throw PerplexityAPIError.invalidCookie - } - return ResolvedSessionCookie(value: override, source: .manual) - } - return self.resolveSessionCookieFromBrowserOrEnv( - context: context, - preferEnvironment: preferEnvironment) + cookies.append(contentsOf: self.resolveSessionCookiesFromBrowserOrEnv(context: context)) + return self.deduplicatedSessionCookies(cookies) } - private func resolveSessionCookieFromBrowserOrEnv( + private func resolveSessionCookiesFromBrowserOrEnv( context: ProviderFetchContext, - preferEnvironment: Bool = false) -> ResolvedSessionCookie? + preferEnvironment: Bool = false) -> [ResolvedSessionCookie] { - guard context.settings?.perplexity?.cookieSource != .off else { return nil } + guard context.settings?.perplexity?.cookieSource != .off else { return [] } + var cookies: [ResolvedSessionCookie] = [] if preferEnvironment, let cookie = PerplexitySettingsReader.sessionCookieOverride(environment: context.env) { - return ResolvedSessionCookie(value: cookie, source: .environment) + cookies.append(ResolvedSessionCookie(value: cookie, source: .environment)) } // Try browser cookie import when auto mode is enabled #if os(macOS) do { - let session = try PerplexityCookieImporter.importSession() - if let cookie = session.sessionCookie { + 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 let cookie = PerplexitySettingsReader.sessionCookieOverride(environment: context.env) { - return ResolvedSessionCookie(value: cookie, source: .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 nil + return deduplicated } private func cacheSessionCookieIfNeeded( diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 026ba43bc..0ffc17c3d 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -74,6 +74,19 @@ 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 `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/PerplexityCookieHeaderTests.swift b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift index ce98250d4..77c74a2c2 100644 --- a/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift +++ b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift @@ -28,6 +28,16 @@ struct PerplexityCookieHeaderTests { #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 = """ diff --git a/Tests/CodexBarTests/PerplexityProviderTests.swift b/Tests/CodexBarTests/PerplexityProviderTests.swift index bb8bcc795..fa475f8b9 100644 --- a/Tests/CodexBarTests/PerplexityProviderTests.swift +++ b/Tests/CodexBarTests/PerplexityProviderTests.swift @@ -124,13 +124,14 @@ struct PerplexityProviderTests { 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 { _, _, _ in - self.stubSnapshot() - } operation: { + _ = 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) @@ -144,10 +145,12 @@ struct PerplexityProviderTests { 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() } @@ -160,13 +163,14 @@ struct PerplexityProviderTests { let context = self.makeContext( settings: settings, env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) - - _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue { _, _, _ in + let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in self.stubSnapshot() - } operation: { - try await strategy.fetch(context) } + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + #expect(CookieHeaderCache.load(provider: .perplexity) == nil) } } @@ -180,13 +184,14 @@ struct PerplexityProviderTests { cookieSource: .manual, manualCookieHeader: "authjs.session-token=manual-token")) let context = self.makeContext(settings: settings) - - _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue { _, _, _ in + let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in self.stubSnapshot() - } operation: { - try await strategy.fetch(context) } + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { + try await strategy.fetch(context) + }) + #expect(CookieHeaderCache.load(provider: .perplexity) == nil) } } @@ -195,10 +200,12 @@ struct PerplexityProviderTests { 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() } @@ -212,21 +219,21 @@ struct PerplexityProviderTests { let context = self.makeContext( settings: settings, env: ["PERPLEXITY_SESSION_TOKEN": "env-token"]) - - _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue { token, cookieName, _ in - #expect(token == "env-token") - attemptedCookieNames.append(cookieName) - if cookieName == "authjs.session-token" { - return self.stubSnapshot() + 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 } - throw PerplexityAPIError.invalidToken - } operation: { + + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { try await strategy.fetch(context) - } + }) #expect(attemptedCookieNames.snapshot() == [ - "__Secure-next-auth.session-token", - "next-auth.session-token", "__Secure-authjs.session-token", "authjs.session-token", ]) @@ -237,6 +244,7 @@ struct PerplexityProviderTests { 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", @@ -248,6 +256,7 @@ struct PerplexityProviderTests { return PerplexityCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome") } defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil PerplexityCookieImporter.importSessionOverrideForTesting = nil PerplexityCookieImporter.invalidateImportSessionCache() } @@ -261,30 +270,92 @@ struct PerplexityProviderTests { let context = self.makeContext( settings: settings, env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"]) - - _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue { token, _, _ in - attemptedTokens.append(token) - if token == "browser-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 } - if token == "env-token" { - return self.stubSnapshot() - } - Issue.record("Unexpected token \(token)") - throw PerplexityAPIError.invalidToken - } operation: { + + _ = 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: [ @@ -297,6 +368,7 @@ struct PerplexityProviderTests { return PerplexityCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome") } defer { + PerplexityCookieImporter.importSessionsOverrideForTesting = nil PerplexityCookieImporter.importSessionOverrideForTesting = nil PerplexityCookieImporter.invalidateImportSessionCache() } @@ -307,15 +379,17 @@ struct PerplexityProviderTests { 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 { token, _, _ in - #expect(token == "browser-token") - return self.stubSnapshot() - } operation: { + _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: { try await strategy.fetch(context) - } + }) #expect(importCount.snapshot() == 1) } 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..b00c6db2a 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -439,6 +439,86 @@ 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 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/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 190c00892..43526d233 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -309,6 +309,45 @@ 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 excludes cursor when all opus lanes are exhausted`() { let settings = SettingsStore( From fe9d31eadba57fd20f7644bf9decf76be12f1abc Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 26 Mar 2026 15:28:15 +0530 Subject: [PATCH 08/11] Fix Perplexity fallback credit selection --- Sources/CodexBar/IconView.swift | 13 +----- .../MenuBarMetricWindowResolver.swift | 9 +--- Sources/CodexBarCore/UsageFetcher.swift | 31 ++++++++++++++ Tests/CodexBarTests/CodexbarTests.swift | 13 ++++++ .../StatusItemAnimationTests.swift | 41 +++++++++++++++++++ .../StatusItemControllerMenuTests.swift | 17 ++++++++ .../UsageStoreHighestUsageTests.swift | 39 ++++++++++++++++++ 7 files changed, 144 insertions(+), 19 deletions(-) diff --git a/Sources/CodexBar/IconView.swift b/Sources/CodexBar/IconView.swift index d6e78a888..213d4a22c 100644 --- a/Sources/CodexBar/IconView.swift +++ b/Sources/CodexBar/IconView.swift @@ -4,7 +4,7 @@ import SwiftUI enum IconRemainingResolver { static func resolvedRemaining(snapshot: UsageSnapshot, style: IconStyle) -> (primary: Double?, secondary: Double?) { if style == .perplexity { - let windows = self.perplexityWindows(snapshot: snapshot) + let windows = snapshot.orderedPerplexityDisplayWindows() return ( primary: windows.first?.remainingPercent, secondary: windows.dropFirst().first?.remainingPercent) @@ -20,17 +20,6 @@ enum IconRemainingResolver { primary: windows.first?.remainingPercent, secondary: windows.dropFirst().first?.remainingPercent) } - - private static func perplexityWindows(snapshot: UsageSnapshot) -> [RateWindow] { - if let primary = snapshot.primary { - return [primary, snapshot.secondary, snapshot.tertiary].compactMap(\.self) - } - - let fallbackWindows = [snapshot.secondary, snapshot.tertiary].compactMap(\.self) - let usableFallback = fallbackWindows.filter { $0.usedPercent < 100 } - let exhaustedFallback = fallbackWindows.filter { $0.usedPercent >= 100 } - return usableFallback + exhaustedFallback - } } @MainActor diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 66d1fa08e..28960796a 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -24,7 +24,7 @@ enum MenuBarMetricWindowResolver { return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary case .primary: if provider == .perplexity { - return snapshot.primary ?? Self.preferredPerplexityFallbackWindow(snapshot: snapshot) + return snapshot.preferredPerplexityWindow() } if provider == .antigravity { return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary @@ -55,7 +55,7 @@ enum MenuBarMetricWindowResolver { return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary } if provider == .perplexity { - return snapshot.primary ?? Self.preferredPerplexityFallbackWindow(snapshot: snapshot) + return snapshot.preferredPerplexityWindow() } if provider == .factory || provider == .kimi { return snapshot.secondary ?? snapshot.primary @@ -76,11 +76,6 @@ enum MenuBarMetricWindowResolver { } } - private static func preferredPerplexityFallbackWindow(snapshot: UsageSnapshot) -> RateWindow? { - let fallbackWindows = [snapshot.secondary, snapshot.tertiary].compactMap(\.self) - return fallbackWindows.first(where: { $0.usedPercent < 100 }) ?? fallbackWindows.first - } - private static func mostConstrainedWindow( primary: RateWindow?, secondary: RateWindow?, diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 98859b18e..e3098b380 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -145,11 +145,35 @@ public struct UsageSnapshot: Codable, Sendable { return identity } + public func preferredPerplexityWindow() -> 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.preferredPerplexityWindow() 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.secondary, self.tertiary].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/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index 0ffc17c3d..a0baaca5c 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -87,6 +87,19 @@ struct CodexBarTests { #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 `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/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index b00c6db2a..6bc41d775 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -479,6 +479,47 @@ struct StatusItemAnimationTests { #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 tertiary preference uses purchased perplexity lane`() { 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 43526d233..512a39eaf 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -348,6 +348,45 @@ struct UsageStoreHighestUsageTests { #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 excludes cursor when all opus lanes are exhausted`() { let settings = SettingsStore( From 68606e6fa79f6e270a409b46f694fe7489219ca6 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 26 Mar 2026 15:39:15 +0530 Subject: [PATCH 09/11] Respect Perplexity primary metric selection --- .../MenuBarMetricWindowResolver.swift | 4 +- Sources/CodexBarCore/UsageFetcher.swift | 4 +- .../StatusItemAnimationTests.swift | 41 +++++++++++++++++++ .../UsageStoreHighestUsageTests.swift | 39 ++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift index 28960796a..c2e2d10b3 100644 --- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift +++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift @@ -24,7 +24,7 @@ enum MenuBarMetricWindowResolver { return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary case .primary: if provider == .perplexity { - return snapshot.preferredPerplexityWindow() + return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary } if provider == .antigravity { return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary @@ -55,7 +55,7 @@ enum MenuBarMetricWindowResolver { return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary } if provider == .perplexity { - return snapshot.preferredPerplexityWindow() + return snapshot.automaticPerplexityWindow() } if provider == .factory || provider == .kimi { return snapshot.secondary ?? snapshot.primary diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index e3098b380..7ded71088 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -145,7 +145,7 @@ public struct UsageSnapshot: Codable, Sendable { return identity } - public func preferredPerplexityWindow() -> RateWindow? { + public func automaticPerplexityWindow() -> RateWindow? { let fallbackWindows = self.orderedPerplexityFallbackWindows() guard let primary = self.primary else { return fallbackWindows.first @@ -173,7 +173,7 @@ public struct UsageSnapshot: Codable, Sendable { // Factory prefers secondary window return self.secondary ?? self.primary case .perplexity: - return self.preferredPerplexityWindow() + 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, diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index 6bc41d775..729bcb2d8 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -520,6 +520,47 @@ struct StatusItemAnimationTests { #expect(window?.usedPercent == 32) } + @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( diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 512a39eaf..6ca878f71 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -387,6 +387,45 @@ struct UsageStoreHighestUsageTests { #expect(highest?.usedPercent == 40) } + @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( From 13eecaf076ace9666cbe3068afddbae1da76d521 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 26 Mar 2026 16:17:11 +0530 Subject: [PATCH 10/11] Fix Perplexity Linux cookie import --- .../Providers/Perplexity/PerplexityCookieHeader.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift index 75a51055f..1c66a95f2 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif public struct PerplexityCookieOverride: Sendable { public let name: String From 4511c789e1982485d17f2c5a8798fd451c1cd489 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Thu, 26 Mar 2026 16:45:59 +0530 Subject: [PATCH 11/11] Prefer purchased Perplexity fallback credits --- Sources/CodexBarCore/UsageFetcher.swift | 2 +- Tests/CodexBarTests/CodexbarTests.swift | 13 ++++++ .../StatusItemAnimationTests.swift | 41 +++++++++++++++++++ .../UsageStoreHighestUsageTests.swift | 39 ++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 7ded71088..61b055897 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -232,7 +232,7 @@ public struct UsageSnapshot: Codable, Sendable { } private func orderedPerplexityFallbackWindows() -> [RateWindow] { - let fallbackWindows = [self.secondary, self.tertiary].compactMap(\.self) + 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 diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift index a0baaca5c..4e2b3f137 100644 --- a/Tests/CodexBarTests/CodexbarTests.swift +++ b/Tests/CodexBarTests/CodexbarTests.swift @@ -100,6 +100,19 @@ struct CodexBarTests { #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/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index 729bcb2d8..915c82532 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -520,6 +520,47 @@ struct StatusItemAnimationTests { #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( diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift index 6ca878f71..217e28da3 100644 --- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift +++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift @@ -387,6 +387,45 @@ struct UsageStoreHighestUsageTests { #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(