From 625c870e734e18b5ddba9b9d33d52b59dd3ff894 Mon Sep 17 00:00:00 2001 From: John Budnick Date: Fri, 27 Feb 2026 13:28:50 -0500 Subject: [PATCH 1/3] 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 b4d89c4839305e4e0cc7cc216d1a130db61b0102 Mon Sep 17 00:00:00 2001 From: John Budnick Date: Thu, 5 Mar 2026 14:51:01 -0500 Subject: [PATCH 2/3] 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 b7bce291d..0c87d122b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,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 0e218b99256cc6e61e3927a9f4a20a310d3e0bb9 Mon Sep 17 00:00:00 2001 From: John Budnick Date: Fri, 20 Mar 2026 14:03:17 -0400 Subject: [PATCH 3/3] 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 = """