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.
diff --git a/Sources/CodexBar/IconView.swift b/Sources/CodexBar/IconView.swift
index 684f0a3fe..213d4a22c 100644
--- a/Sources/CodexBar/IconView.swift
+++ b/Sources/CodexBar/IconView.swift
@@ -3,6 +3,12 @@ import SwiftUI
enum IconRemainingResolver {
static func resolvedRemaining(snapshot: UsageSnapshot, style: IconStyle) -> (primary: Double?, secondary: Double?) {
+ if style == .perplexity {
+ let windows = snapshot.orderedPerplexityDisplayWindows()
+ return (
+ primary: windows.first?.remainingPercent,
+ secondary: windows.dropFirst().first?.remainingPercent)
+ }
guard style == .antigravity else {
return (
primary: snapshot.primary?.remainingPercent,
diff --git a/Sources/CodexBar/MenuBarMetricWindowResolver.swift b/Sources/CodexBar/MenuBarMetricWindowResolver.swift
index a154be9cf..c2e2d10b3 100644
--- a/Sources/CodexBar/MenuBarMetricWindowResolver.swift
+++ b/Sources/CodexBar/MenuBarMetricWindowResolver.swift
@@ -12,6 +12,9 @@ enum MenuBarMetricWindowResolver {
guard let snapshot else { return nil }
switch preference {
case .tertiary:
+ if provider == .perplexity {
+ return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary
+ }
guard provider == .cursor else {
if provider == .antigravity {
return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary
@@ -20,11 +23,17 @@ enum MenuBarMetricWindowResolver {
}
return snapshot.tertiary ?? snapshot.secondary ?? snapshot.primary
case .primary:
+ if provider == .perplexity {
+ return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary
+ }
if provider == .antigravity {
return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary
}
return snapshot.primary ?? snapshot.secondary
case .secondary:
+ if provider == .perplexity {
+ return snapshot.secondary ?? snapshot.tertiary ?? snapshot.primary
+ }
if provider == .antigravity {
return snapshot.secondary ?? snapshot.primary ?? snapshot.tertiary
}
@@ -45,6 +54,9 @@ enum MenuBarMetricWindowResolver {
if provider == .antigravity {
return snapshot.primary ?? snapshot.secondary ?? snapshot.tertiary
}
+ if provider == .perplexity {
+ return snapshot.automaticPerplexityWindow()
+ }
if provider == .factory || provider == .kimi {
return snapshot.secondary ?? snapshot.primary
}
diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift
index 5f685af23..9da4f9e04 100644
--- a/Sources/CodexBar/MenuCardView.swift
+++ b/Sources/CodexBar/MenuCardView.swift
@@ -1007,6 +1007,13 @@ extension UsageMenuCardView.Model {
{
weeklyDetailText = detail
}
+ // Perplexity bonus credits don't reset; show balance without "Resets" prefix.
+ if input.provider == .perplexity,
+ let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !detail.isEmpty
+ {
+ weeklyResetText = detail
+ }
metrics.append(Metric(
id: "secondary",
title: input.metadata.weeklyLabel,
@@ -1039,12 +1046,16 @@ extension UsageMenuCardView.Model {
{
tertiaryDetailText = detail
}
+ // Perplexity purchased credits don't reset; show balance without "Resets" prefix.
+ let opusResetText: String? = input.provider == .perplexity
+ ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
+ : Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now)
metrics.append(Metric(
id: "tertiary",
title: input.metadata.opusLabel ?? "Sonnet",
percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent),
percentStyle: percentStyle,
- resetText: Self.resetText(for: opus, style: input.resetTimeDisplayStyle, now: input.now),
+ resetText: opusResetText,
detailText: tertiaryDetailText,
detailLeftText: nil,
detailRightText: nil,
diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift
index 05aa55fff..a9ae51bf8 100644
--- a/Sources/CodexBar/MenuDescriptor.swift
+++ b/Sources/CodexBar/MenuDescriptor.swift
@@ -144,7 +144,7 @@ struct MenuDescriptor {
}
if let weekly = snap.secondary {
let weeklyResetOverride: String? = {
- guard provider == .warp || provider == .kilo else { return nil }
+ guard provider == .warp || provider == .kilo || provider == .perplexity else { return nil }
let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let detail, !detail.isEmpty else { return nil }
if provider == .kilo, weekly.resetsAt != nil {
@@ -172,12 +172,17 @@ struct MenuDescriptor {
}
}
if meta.supportsOpus, let opus = snap.tertiary {
+ // Perplexity purchased credits don't reset; show the balance as plain text.
+ let opusResetOverride: String? = provider == .perplexity
+ ? opus.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines)
+ : nil
Self.appendRateWindow(
entries: &entries,
title: meta.opusLabel ?? "Sonnet",
window: opus,
resetStyle: resetStyle,
- showUsed: settings.usageBarsShowUsed)
+ showUsed: settings.usageBarsShowUsed,
+ resetOverride: opusResetOverride)
}
if let cost = snap.providerCost {
diff --git a/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift b/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift
new file mode 100644
index 000000000..770e18c2e
--- /dev/null
+++ b/Sources/CodexBar/Providers/Perplexity/PerplexityProviderImplementation.swift
@@ -0,0 +1,95 @@
+import AppKit
+import CodexBarCore
+import CodexBarMacroSupport
+import Foundation
+import SwiftUI
+
+@ProviderImplementationRegistration
+struct PerplexityProviderImplementation: ProviderImplementation {
+ let id: UsageProvider = .perplexity
+ let supportsLoginFlow: Bool = true
+
+ @MainActor
+ func presentation(context _: ProviderPresentationContext) -> ProviderPresentation {
+ ProviderPresentation { _ in "web" }
+ }
+
+ @MainActor
+ func runLoginFlow(context _: ProviderLoginContext) async -> Bool {
+ if let url = URL(string: "https://www.perplexity.ai/") {
+ NSWorkspace.shared.open(url)
+ }
+ return false
+ }
+
+ @MainActor
+ func observeSettings(_ settings: SettingsStore) {
+ _ = settings.perplexityCookieSource
+ _ = settings.perplexityManualCookieHeader
+ }
+
+ @MainActor
+ func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
+ .perplexity(context.settings.perplexitySettingsSnapshot(tokenOverride: context.tokenOverride))
+ }
+
+ @MainActor
+ func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] {
+ let cookieBinding = Binding(
+ get: { context.settings.perplexityCookieSource.rawValue },
+ set: { raw in
+ context.settings.perplexityCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto
+ })
+ let options = ProviderCookieSourceUI.options(
+ allowsOff: true,
+ keychainDisabled: context.settings.debugDisableKeychainAccess)
+
+ let subtitle: () -> String? = {
+ ProviderCookieSourceUI.subtitle(
+ source: context.settings.perplexityCookieSource,
+ keychainDisabled: context.settings.debugDisableKeychainAccess,
+ auto: "Automatically imports browser session cookie.",
+ manual: "Paste a full cookie header or the __Secure-next-auth.session-token value.",
+ off: "Perplexity cookies are disabled.")
+ }
+
+ return [
+ ProviderSettingsPickerDescriptor(
+ id: "perplexity-cookie-source",
+ title: "Cookie source",
+ subtitle: "Automatically imports browser session cookie.",
+ dynamicSubtitle: subtitle,
+ binding: cookieBinding,
+ options: options,
+ isVisible: nil,
+ onChange: nil),
+ ]
+ }
+
+ @MainActor
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "perplexity-cookie",
+ title: "",
+ subtitle: "",
+ kind: .secure,
+ placeholder: "Cookie: \u{2026}\n\nor paste the __Secure-next-auth.session-token value",
+ binding: context.stringBinding(\.perplexityManualCookieHeader),
+ actions: [
+ ProviderSettingsActionDescriptor(
+ id: "perplexity-open-usage",
+ title: "Open Usage Page",
+ style: .link,
+ isVisible: nil,
+ perform: {
+ if let url = URL(string: "https://www.perplexity.ai/account/usage") {
+ NSWorkspace.shared.open(url)
+ }
+ }),
+ ],
+ isVisible: { context.settings.perplexityCookieSource == .manual },
+ onActivate: nil),
+ ]
+ }
+}
diff --git a/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift b/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift
new file mode 100644
index 000000000..6d2d44dfc
--- /dev/null
+++ b/Sources/CodexBar/Providers/Perplexity/PerplexitySettingsStore.swift
@@ -0,0 +1,36 @@
+import CodexBarCore
+import Foundation
+
+extension SettingsStore {
+ var perplexityManualCookieHeader: String {
+ get { self.configSnapshot.providerConfig(for: .perplexity)?.sanitizedCookieHeader ?? "" }
+ set {
+ self.updateProviderConfig(provider: .perplexity) { entry in
+ entry.cookieHeader = self.normalizedConfigValue(newValue)
+ }
+ self.logSecretUpdate(provider: .perplexity, field: "cookieHeader", value: newValue)
+ }
+ }
+
+ var perplexityCookieSource: ProviderCookieSource {
+ get { self.resolvedCookieSource(provider: .perplexity, fallback: .auto) }
+ set {
+ self.updateProviderConfig(provider: .perplexity) { entry in
+ entry.cookieSource = newValue
+ }
+ self.logProviderModeChange(provider: .perplexity, field: "cookieSource", value: newValue.rawValue)
+ }
+ }
+}
+
+extension SettingsStore {
+ func perplexitySettingsSnapshot(tokenOverride: TokenAccountOverride?) -> ProviderSettingsSnapshot
+ .PerplexityProviderSettings {
+ // tokenOverride is not used: Perplexity auth is cookie-based, not token-account-based.
+ // Manual cookies are handled via perplexityManualCookieHeader in the settings snapshot below.
+ _ = tokenOverride
+ return ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: self.perplexityCookieSource,
+ manualCookieHeader: self.perplexityManualCookieHeader)
+ }
+}
diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
index 9cb99850b..6fb94b479 100644
--- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
+++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift
@@ -36,6 +36,7 @@ enum ProviderImplementationRegistry {
case .synthetic: SyntheticProviderImplementation()
case .openrouter: OpenRouterProviderImplementation()
case .warp: WarpProviderImplementation()
+ case .perplexity: PerplexityProviderImplementation()
}
}
diff --git a/Sources/CodexBar/Resources/ProviderIcon-perplexity.svg b/Sources/CodexBar/Resources/ProviderIcon-perplexity.svg
new file mode 100644
index 000000000..d869791b2
--- /dev/null
+++ b/Sources/CodexBar/Resources/ProviderIcon-perplexity.svg
@@ -0,0 +1 @@
+
diff --git a/Sources/CodexBar/SettingsStore+MenuPreferences.swift b/Sources/CodexBar/SettingsStore+MenuPreferences.swift
index cc8a89396..34601636a 100644
--- a/Sources/CodexBar/SettingsStore+MenuPreferences.swift
+++ b/Sources/CodexBar/SettingsStore+MenuPreferences.swift
@@ -51,12 +51,14 @@ extension SettingsStore {
}
func menuBarMetricSupportsTertiary(for provider: UsageProvider) -> Bool {
- provider == .cursor
+ provider == .cursor || provider == .perplexity
}
func menuBarMetricSupportsTertiary(for provider: UsageProvider, snapshot: UsageSnapshot?) -> Bool {
- guard provider == .cursor else { return self.menuBarMetricSupportsTertiary(for: provider) }
- return snapshot?.tertiary != nil
+ if provider == .cursor {
+ return snapshot?.tertiary != nil
+ }
+ return self.menuBarMetricSupportsTertiary(for: provider)
}
func menuBarMetricPreference(for provider: UsageProvider, snapshot: UsageSnapshot?) -> MenuBarMetricPreference {
diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift
index 47efc5b63..9bb258a14 100644
--- a/Sources/CodexBar/UsageStore.swift
+++ b/Sources/CodexBar/UsageStore.swift
@@ -1231,7 +1231,7 @@ extension UsageStore {
let source = resolution?.source.rawValue ?? "none"
return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)"
case .gemini, .antigravity, .opencode, .factory, .copilot, .vertexai, .kilo, .kiro, .kimi,
- .kimik2, .jetbrains:
+ .kimik2, .jetbrains, .perplexity:
return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented"
}
}
diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift
index 937b37aa0..d52302847 100644
--- a/Sources/CodexBarCLI/TokenAccountCLI.swift
+++ b/Sources/CodexBarCLI/TokenAccountCLI.swift
@@ -177,6 +177,13 @@ struct TokenAccountCLIContext {
return self.makeSnapshot(
jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings(
ideBasePath: nil))
+ case .perplexity:
+ let cookieHeader = self.manualCookieHeader(provider: provider, account: account, config: config)
+ let cookieSource = self.cookieSource(provider: provider, account: account, config: config)
+ return self.makeSnapshot(
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: cookieSource,
+ manualCookieHeader: cookieHeader))
case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .openrouter, .warp:
return nil
}
@@ -196,7 +203,8 @@ struct TokenAccountCLIContext {
augment: ProviderSettingsSnapshot.AugmentProviderSettings? = nil,
amp: ProviderSettingsSnapshot.AmpProviderSettings? = nil,
ollama: ProviderSettingsSnapshot.OllamaProviderSettings? = nil,
- jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot
+ jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings? = nil,
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot.make(
codex: codex,
@@ -212,7 +220,8 @@ struct TokenAccountCLIContext {
augment: augment,
amp: amp,
ollama: ollama,
- jetbrains: jetbrains)
+ jetbrains: jetbrains,
+ perplexity: perplexity)
}
func environment(
diff --git a/Sources/CodexBarClaudeWebProbe/main.swift b/Sources/CodexBarClaudeWebProbe/ClaudeWebProbeEntry.swift
similarity index 100%
rename from Sources/CodexBarClaudeWebProbe/main.swift
rename to Sources/CodexBarClaudeWebProbe/ClaudeWebProbeEntry.swift
diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift
index 37a7726ef..0f2a6b0f9 100644
--- a/Sources/CodexBarCore/Logging/LogCategories.swift
+++ b/Sources/CodexBarCore/Logging/LogCategories.swift
@@ -43,6 +43,9 @@ public enum LogCategories {
public static let ollama = "ollama"
public static let opencodeUsage = "opencode-usage"
public static let openRouterUsage = "openrouter-usage"
+ public static let perplexityAPI = "perplexity-api"
+ public static let perplexityCookie = "perplexity-cookie"
+ public static let perplexityWeb = "perplexity-web"
public static let providerDetection = "provider-detection"
public static let providers = "providers"
public static let sessionQuota = "sessionQuota"
diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift
new file mode 100644
index 000000000..35baf9350
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityAPIError.swift
@@ -0,0 +1,27 @@
+import Foundation
+
+public enum PerplexityAPIError: LocalizedError, Sendable, Equatable {
+ case missingToken
+ case invalidCookie
+ case invalidToken
+ case networkError(String)
+ case apiError(String)
+ case parseFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingToken:
+ "Perplexity session token is missing. Please log into Perplexity in your browser."
+ case .invalidCookie:
+ "Perplexity manual cookie header is empty or invalid."
+ case .invalidToken:
+ "Perplexity session token is invalid or expired. Please log in again."
+ case let .networkError(message):
+ "Perplexity network error: \(message)"
+ case let .apiError(message):
+ "Perplexity API error: \(message)"
+ case let .parseFailed(message):
+ "Failed to parse Perplexity usage data: \(message)"
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift
new file mode 100644
index 000000000..1c66a95f2
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityCookieHeader.swift
@@ -0,0 +1,129 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct PerplexityCookieOverride: Sendable {
+ public let name: String
+ public let token: String
+ public let requestCookieNames: [String]
+
+ public init(name: String, token: String, requestCookieNames: [String]? = nil) {
+ self.name = name
+ self.token = token
+ self.requestCookieNames = requestCookieNames ?? [name]
+ }
+}
+
+public enum PerplexityCookieHeader {
+ public static let defaultSessionCookieName = "__Secure-next-auth.session-token"
+ public static let supportedSessionCookieNames = [
+ "__Secure-authjs.session-token",
+ "authjs.session-token",
+ "__Secure-next-auth.session-token",
+ "next-auth.session-token",
+ ]
+
+ public static func resolveCookieOverride(context: ProviderFetchContext) -> PerplexityCookieOverride? {
+ if let settings = context.settings?.perplexity, settings.cookieSource == .manual {
+ if let manual = settings.manualCookieHeader, !manual.isEmpty {
+ return self.override(from: manual)
+ }
+ }
+ return nil
+ }
+
+ public static func override(from raw: String?) -> PerplexityCookieOverride? {
+ guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
+ return nil
+ }
+
+ // Accept bare token value
+ if !raw.contains("="), !raw.contains(";") {
+ return PerplexityCookieOverride(
+ name: self.defaultSessionCookieName,
+ token: raw,
+ requestCookieNames: self.supportedSessionCookieNames)
+ }
+
+ // Extract a supported session cookie from a full cookie string.
+ if let cookie = self.extractSessionCookie(from: raw) {
+ return cookie
+ }
+
+ return nil
+ }
+
+ static func sessionCookie(from cookies: [HTTPCookie]) -> PerplexityCookieOverride? {
+ self.extractSessionCookie(from: cookies.map { (name: $0.name, value: $0.value) })
+ }
+
+ private static func extractSessionCookie(from raw: String) -> PerplexityCookieOverride? {
+ let pairs = raw.split(separator: ";")
+ var cookies: [(name: String, value: String)] = []
+ for pair in pairs {
+ let trimmed = pair.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { continue }
+ guard let separator = trimmed.firstIndex(of: "=") else { continue }
+ let key = String(trimmed[.. PerplexityCookieOverride? {
+ var cookieMap: [String: (name: String, value: String)] = [:]
+ var chunkedCookies: [String: [Int: (name: String, value: String)]] = [:]
+
+ for cookie in cookies {
+ let loweredName = cookie.name.lowercased()
+ cookieMap[loweredName] = cookie
+
+ for expected in self.supportedSessionCookieNames {
+ let loweredExpected = expected.lowercased()
+ let prefix = "\(loweredExpected)."
+ guard loweredName.hasPrefix(prefix) else { continue }
+ let suffix = String(loweredName.dropFirst(prefix.count))
+ guard let index = Int(suffix) else { continue }
+ chunkedCookies[loweredExpected, default: [:]][index] = cookie
+ }
+ }
+
+ for expected in self.supportedSessionCookieNames {
+ let loweredExpected = expected.lowercased()
+ if let match = cookieMap[loweredExpected] {
+ return PerplexityCookieOverride(name: match.name, token: match.value)
+ }
+ if let chunked = self.reassembleChunkedSessionCookie(from: chunkedCookies[loweredExpected]) {
+ return chunked
+ }
+ }
+ return nil
+ }
+
+ private static func reassembleChunkedSessionCookie(
+ from chunks: [Int: (name: String, value: String)]?) -> PerplexityCookieOverride?
+ {
+ guard let chunks,
+ let firstChunk = chunks[0],
+ let maxIndex = chunks.keys.max()
+ else {
+ return nil
+ }
+
+ var tokenParts: [String] = []
+ tokenParts.reserveCapacity(maxIndex + 1)
+ for index in 0...maxIndex {
+ guard let chunk = chunks[index] else { return nil }
+ tokenParts.append(chunk.value)
+ }
+
+ guard let suffixStart = firstChunk.name.lastIndex(of: ".") else { return nil }
+ let baseName = String(firstChunk.name[.. Void)?) throws -> SessionInfo)?
+ nonisolated(unsafe) static var importSessionsOverrideForTesting:
+ ((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])?
+
+ public struct SessionInfo: Sendable {
+ public let cookies: [HTTPCookie]
+ public let sourceLabel: String
+
+ public init(cookies: [HTTPCookie], sourceLabel: String) {
+ self.cookies = cookies
+ self.sourceLabel = sourceLabel
+ }
+
+ public var sessionCookie: PerplexityCookieOverride? {
+ PerplexityCookieHeader.sessionCookie(from: self.cookies)
+ }
+
+ public var sessionToken: String? {
+ self.sessionCookie?.token
+ }
+ }
+
+ public static func importSessions(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ if let cached = self.cachedImportSessions() {
+ return cached
+ }
+ if let override = self.importSessionsOverrideForTesting {
+ let sessions = try override(browserDetection, logger)
+ self.storeImportSessions(sessions)
+ return sessions
+ }
+ if let override = self.importSessionOverrideForTesting {
+ let session = try override(browserDetection, logger)
+ let sessions = [session]
+ self.storeImportSessions(sessions)
+ return sessions
+ }
+
+ var sessions: [SessionInfo] = []
+ let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
+ for browserSource in candidates {
+ do {
+ let perSource = try self.importSessions(from: browserSource, logger: logger)
+ sessions.append(contentsOf: perSource)
+ } catch {
+ BrowserCookieAccessGate.recordIfNeeded(error)
+ self.emit(
+ "\(browserSource.displayName) cookie import failed: \(error.localizedDescription)",
+ logger: logger)
+ }
+ }
+
+ guard !sessions.isEmpty else {
+ throw PerplexityCookieImportError.noCookies
+ }
+ self.storeImportSessions(sessions)
+ return sessions
+ }
+
+ public static func importSessions(
+ from browserSource: Browser,
+ logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
+ {
+ let query = BrowserCookieQuery(domains: self.cookieDomains)
+ let log: (String) -> Void = { msg in self.emit(msg, logger: logger) }
+ let sources = try Self.cookieClient.records(
+ matching: query,
+ in: browserSource,
+ logger: log)
+
+ var sessions: [SessionInfo] = []
+ let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id })
+ let sortedGroups = grouped.values.sorted { lhs, rhs in
+ self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs)
+ }
+
+ for group in sortedGroups where !group.isEmpty {
+ let label = self.mergedLabel(for: group)
+ let mergedRecords = self.mergeRecords(group)
+ guard !mergedRecords.isEmpty else { continue }
+ let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin)
+ guard !httpCookies.isEmpty else { continue }
+
+ let session = SessionInfo(cookies: httpCookies, sourceLabel: label)
+ guard let sessionCookie = session.sessionCookie else {
+ continue
+ }
+
+ log("Found \(sessionCookie.name) cookie in \(label)")
+ sessions.append(session)
+ }
+ return sessions
+ }
+
+ public static func importSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) throws -> SessionInfo
+ {
+ let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger)
+ guard let first = sessions.first else {
+ throw PerplexityCookieImportError.noCookies
+ }
+ return first
+ }
+
+ public static func hasSession(
+ browserDetection: BrowserDetection = BrowserDetection(),
+ logger: ((String) -> Void)? = nil) -> Bool
+ {
+ do {
+ _ = try self.importSession(browserDetection: browserDetection, logger: logger)
+ return true
+ } catch {
+ return false
+ }
+ }
+
+ static func invalidateImportSessionCache() {
+ self.importSessionCache.invalidate()
+ }
+
+ private static func emit(_ message: String, logger: ((String) -> Void)?) {
+ logger?("[perplexity-cookie] \(message)")
+ self.log.debug(message)
+ }
+
+ private static func cachedImportSessions(now: Date = Date()) -> [SessionInfo]? {
+ self.importSessionCache.load(now: now)
+ }
+
+ private static func storeImportSessions(_ sessions: [SessionInfo], now: Date = Date()) {
+ self.importSessionCache.store(sessions, now: now)
+ }
+
+ private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String {
+ guard let base = sources.map(\.label).min() else { return "Unknown" }
+ if base.hasSuffix(" (Network)") {
+ return String(base.dropLast(" (Network)".count))
+ }
+ return base
+ }
+
+ private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] {
+ let sortedSources = sources.sorted { lhs, rhs in
+ self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind)
+ }
+ var mergedByKey: [String: BrowserCookieRecord] = [:]
+ for source in sortedSources {
+ for record in source.records {
+ let key = self.recordKey(record)
+ if let existing = mergedByKey[key] {
+ if self.shouldReplace(existing: existing, candidate: record) {
+ mergedByKey[key] = record
+ }
+ } else {
+ mergedByKey[key] = record
+ }
+ }
+ }
+ return Array(mergedByKey.values)
+ }
+
+ private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int {
+ switch kind {
+ case .network: 0
+ case .primary: 1
+ case .safari: 2
+ }
+ }
+
+ private static func recordKey(_ record: BrowserCookieRecord) -> String {
+ "\(record.name)|\(record.domain)|\(record.path)"
+ }
+
+ private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool {
+ switch (existing.expires, candidate.expires) {
+ case let (lhs?, rhs?): rhs > lhs
+ case (nil, .some): true
+ case (.some, nil): false
+ case (nil, nil): false
+ }
+ }
+
+ private final class ImportSessionCache: @unchecked Sendable {
+ private let ttl: TimeInterval
+ private let lock = NSLock()
+ private var entry: (sessions: [SessionInfo], expiresAt: Date)?
+
+ init(ttl: TimeInterval) {
+ self.ttl = ttl
+ }
+
+ func load(now: Date) -> [SessionInfo]? {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ guard let entry = self.entry else { return nil }
+ guard entry.expiresAt > now else {
+ self.entry = nil
+ return nil
+ }
+ return entry.sessions
+ }
+
+ func store(_ sessions: [SessionInfo], now: Date) {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ self.entry = (sessions: sessions, expiresAt: now.addingTimeInterval(self.ttl))
+ }
+
+ func invalidate() {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ self.entry = nil
+ }
+ }
+}
+
+enum PerplexityCookieImportError: LocalizedError, Sendable {
+ case noCookies
+
+ var errorDescription: String? {
+ switch self {
+ case .noCookies:
+ "No Perplexity session cookies found in browsers. Please log into perplexity.ai."
+ }
+ }
+}
+#endif
diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityModels.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityModels.swift
new file mode 100644
index 000000000..ae4ae592b
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityModels.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+public struct PerplexityCreditsResponse: Codable {
+ public let balanceCents: Double
+ public let renewalDateTs: TimeInterval
+ public let currentPeriodPurchasedCents: Double
+ public let creditGrants: [PerplexityCreditGrant]
+ public let totalUsageCents: Double
+
+ enum CodingKeys: String, CodingKey {
+ case balanceCents = "balance_cents"
+ case renewalDateTs = "renewal_date_ts"
+ case currentPeriodPurchasedCents = "current_period_purchased_cents"
+ case creditGrants = "credit_grants"
+ case totalUsageCents = "total_usage_cents"
+ }
+}
+
+public struct PerplexityCreditGrant: Codable {
+ public let type: String
+ public let amountCents: Double
+ public let expiresAtTs: TimeInterval?
+
+ enum CodingKeys: String, CodingKey {
+ case type
+ case amountCents = "amount_cents"
+ case expiresAtTs = "expires_at_ts"
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift
new file mode 100644
index 000000000..1a6da7c1b
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityProviderDescriptor.swift
@@ -0,0 +1,238 @@
+import CodexBarMacroSupport
+import Foundation
+
+@ProviderDescriptorRegistration
+@ProviderDescriptorDefinition
+public enum PerplexityProviderDescriptor {
+ static func makeDescriptor() -> ProviderDescriptor {
+ ProviderDescriptor(
+ id: .perplexity,
+ metadata: ProviderMetadata(
+ id: .perplexity,
+ displayName: "Perplexity",
+ sessionLabel: "Credits",
+ weeklyLabel: "Bonus credits",
+ opusLabel: "Purchased",
+ supportsOpus: true,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Perplexity usage",
+ cliName: "perplexity",
+ defaultEnabled: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ browserCookieOrder: nil,
+ dashboardURL: "https://www.perplexity.ai/account/usage",
+ statusPageURL: nil,
+ statusLinkURL: "https://status.perplexity.com/"),
+ branding: ProviderBranding(
+ iconStyle: .perplexity,
+ iconResourceName: "ProviderIcon-perplexity",
+ color: ProviderColor(red: 32 / 255, green: 178 / 255, blue: 170 / 255)),
+ tokenCost: ProviderTokenCostConfig(
+ supportsTokenCost: false,
+ noDataMessage: { "Perplexity cost tracking is not supported." }),
+ fetchPlan: ProviderFetchPlan(
+ sourceModes: [.auto, .web],
+ pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [PerplexityWebFetchStrategy()] })),
+ cli: ProviderCLIConfig(
+ name: "perplexity",
+ aliases: [],
+ versionDetector: nil))
+ }
+}
+
+struct PerplexityWebFetchStrategy: ProviderFetchStrategy {
+ private enum SessionCookieSource {
+ case manual
+ case cache
+ case browser
+ case environment
+
+ var shouldCacheAfterFetch: Bool {
+ self == .browser
+ }
+ }
+
+ private struct ResolvedSessionCookie {
+ let value: PerplexityCookieOverride
+ let source: SessionCookieSource
+ }
+
+ private struct SessionFetchResult {
+ let snapshot: PerplexityUsageSnapshot
+ let cookie: PerplexityCookieOverride
+ }
+
+ let id: String = "perplexity.web"
+ let kind: ProviderFetchKind = .web
+
+ func isAvailable(_ context: ProviderFetchContext) async -> Bool {
+ guard context.settings?.perplexity?.cookieSource != .off else { return false }
+ if context.settings?.perplexity?.cookieSource == .manual { return true }
+
+ // Priority order mirrors resolveSessionCookie: manual override → cache → browser import → env var
+ if PerplexityCookieHeader.resolveCookieOverride(context: context) != nil {
+ return true
+ }
+
+ if CookieHeaderCache.load(provider: .perplexity) != nil {
+ return true
+ }
+
+ #if os(macOS)
+ if context.settings?.perplexity?.cookieSource != .off {
+ if PerplexityCookieImporter.hasSession() { return true }
+ }
+ #endif
+
+ if PerplexitySettingsReader.sessionToken(environment: context.env) != nil {
+ return true
+ }
+
+ return false
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ let resolvedCookies = try self.resolveSessionCookies(context: context)
+ guard !resolvedCookies.isEmpty else {
+ throw PerplexityAPIError.missingToken
+ }
+ var sawInvalidToken = false
+
+ for resolvedCookie in resolvedCookies {
+ do {
+ let result = try await self.fetchSnapshot(using: resolvedCookie)
+ self.cacheSessionCookieIfNeeded(resolvedCookie, usedCookie: result.cookie, sourceLabel: "web")
+ return self.makeResult(
+ usage: result.snapshot.toUsageSnapshot(),
+ sourceLabel: "web")
+ } catch PerplexityAPIError.invalidToken {
+ sawInvalidToken = true
+ if resolvedCookie.source == .cache {
+ CookieHeaderCache.clear(provider: .perplexity)
+ }
+ continue
+ }
+ }
+
+ if sawInvalidToken {
+ throw PerplexityAPIError.invalidToken
+ }
+ throw PerplexityAPIError.missingToken
+ }
+
+ func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool {
+ if case PerplexityAPIError.missingToken = error { return false }
+ if case PerplexityAPIError.invalidCookie = error { return false }
+ if case PerplexityAPIError.invalidToken = error { return false }
+ return true
+ }
+
+ private func resolveSessionCookies(context: ProviderFetchContext) throws -> [ResolvedSessionCookie] {
+ guard context.settings?.perplexity?.cookieSource != .off else { return [] }
+
+ if context.settings?.perplexity?.cookieSource == .manual {
+ guard let override = PerplexityCookieHeader.resolveCookieOverride(context: context) else {
+ throw PerplexityAPIError.invalidCookie
+ }
+ return [ResolvedSessionCookie(value: override, source: .manual)]
+ }
+
+ var cookies: [ResolvedSessionCookie] = []
+
+ // Try cached cookie before expensive browser import
+ if let cached = CookieHeaderCache.load(provider: .perplexity) {
+ if let override = PerplexityCookieHeader.override(from: cached.cookieHeader) {
+ cookies.append(ResolvedSessionCookie(value: override, source: .cache))
+ }
+ }
+
+ cookies.append(contentsOf: self.resolveSessionCookiesFromBrowserOrEnv(context: context))
+ return self.deduplicatedSessionCookies(cookies)
+ }
+
+ private func resolveSessionCookiesFromBrowserOrEnv(
+ context: ProviderFetchContext,
+ preferEnvironment: Bool = false) -> [ResolvedSessionCookie]
+ {
+ guard context.settings?.perplexity?.cookieSource != .off else { return [] }
+ var cookies: [ResolvedSessionCookie] = []
+
+ if preferEnvironment,
+ let cookie = PerplexitySettingsReader.sessionCookieOverride(environment: context.env)
+ {
+ cookies.append(ResolvedSessionCookie(value: cookie, source: .environment))
+ }
+
+ // Try browser cookie import when auto mode is enabled
+ #if os(macOS)
+ do {
+ let sessions = try PerplexityCookieImporter.importSessions()
+ cookies.append(contentsOf: sessions.compactMap { session in
+ guard let cookie = session.sessionCookie else { return nil }
+ return ResolvedSessionCookie(value: cookie, source: .browser)
+ })
+ } catch {
+ // No browser cookies found
+ }
+ #endif
+
+ // Fall back to environment
+ if !preferEnvironment,
+ let cookie = PerplexitySettingsReader.sessionCookieOverride(environment: context.env)
+ {
+ cookies.append(ResolvedSessionCookie(value: cookie, source: .environment))
+ }
+ return self.deduplicatedSessionCookies(cookies)
+ }
+
+ private func deduplicatedSessionCookies(_ cookies: [ResolvedSessionCookie]) -> [ResolvedSessionCookie] {
+ var deduplicated: [ResolvedSessionCookie] = []
+ for cookie in cookies {
+ if deduplicated.contains(where: { self.isEquivalentCookie($0.value, cookie.value) }) {
+ continue
+ }
+ deduplicated.append(cookie)
+ }
+ return deduplicated
+ }
+
+ private func cacheSessionCookieIfNeeded(
+ _ cookie: ResolvedSessionCookie,
+ usedCookie: PerplexityCookieOverride,
+ sourceLabel: String)
+ {
+ guard cookie.source.shouldCacheAfterFetch else { return }
+ CookieHeaderCache.store(
+ provider: .perplexity,
+ cookieHeader: "\(usedCookie.name)=\(usedCookie.token)",
+ sourceLabel: sourceLabel)
+ }
+
+ private func fetchSnapshot(using cookie: ResolvedSessionCookie) async throws -> SessionFetchResult {
+ var lastInvalidToken = false
+ for cookieName in cookie.value.requestCookieNames {
+ do {
+ let snapshot = try await PerplexityUsageFetcher.fetchCredits(
+ sessionToken: cookie.value.token,
+ cookieName: cookieName)
+ return SessionFetchResult(
+ snapshot: snapshot,
+ cookie: PerplexityCookieOverride(name: cookieName, token: cookie.value.token))
+ } catch PerplexityAPIError.invalidToken {
+ lastInvalidToken = true
+ continue
+ }
+ }
+
+ if lastInvalidToken {
+ throw PerplexityAPIError.invalidToken
+ }
+ throw PerplexityAPIError.missingToken
+ }
+
+ private func isEquivalentCookie(_ lhs: PerplexityCookieOverride, _ rhs: PerplexityCookieOverride) -> Bool {
+ lhs.token == rhs.token && lhs.requestCookieNames == rhs.requestCookieNames
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift
new file mode 100644
index 000000000..e36eb0e60
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexitySettingsReader.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+public enum PerplexitySettingsReader {
+ public static func sessionCookieOverride(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> PerplexityCookieOverride?
+ {
+ let raw = environment["PERPLEXITY_SESSION_TOKEN"]
+ ?? environment["perplexity_session_token"]
+ if let token = self.cleaned(raw) { return PerplexityCookieHeader.override(from: token) }
+
+ // PERPLEXITY_COOKIE may be a full Cookie header string; preserve the matching session cookie name.
+ if let cookieRaw = environment["PERPLEXITY_COOKIE"] {
+ return PerplexityCookieHeader.override(from: self.cleaned(cookieRaw))
+ }
+ return nil
+ }
+
+ public static func sessionToken(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ self.sessionCookieOverride(environment: environment)?.token
+ }
+
+ private static func cleaned(_ raw: String?) -> String? {
+ guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
+ return nil
+ }
+
+ if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
+ (value.hasPrefix("'") && value.hasSuffix("'"))
+ {
+ value.removeFirst()
+ value.removeLast()
+ }
+
+ value = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift
new file mode 100644
index 000000000..2a9fe5211
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift
@@ -0,0 +1,73 @@
+import Foundation
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+public struct PerplexityUsageFetcher: Sendable {
+ private static let log = CodexBarLog.logger(LogCategories.perplexityAPI)
+ private static let creditsURL =
+ URL(string: "https://www.perplexity.ai/rest/billing/credits?version=2.18&source=default")!
+ @TaskLocal static var fetchCreditsOverride:
+ (@Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot)?
+
+ /// Testing hook: parse a raw JSON response without making network calls.
+ public static func _parseResponseForTesting(_ data: Data, now: Date = Date()) throws -> PerplexityUsageSnapshot {
+ do {
+ let decoded = try JSONDecoder().decode(PerplexityCreditsResponse.self, from: data)
+ return PerplexityUsageSnapshot(response: decoded, now: now)
+ } catch {
+ throw PerplexityAPIError.parseFailed(error.localizedDescription)
+ }
+ }
+
+ public static func fetchCredits(
+ sessionToken: String,
+ cookieName: String = PerplexityCookieHeader.defaultSessionCookieName,
+ now: Date = Date()) async throws -> PerplexityUsageSnapshot
+ {
+ if let override = self.fetchCreditsOverride {
+ return try await override(sessionToken, cookieName, now)
+ }
+
+ var request = URLRequest(url: self.creditsURL)
+ request.httpMethod = "GET"
+ request.timeoutInterval = 15
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue(
+ "\(cookieName)=\(sessionToken)",
+ forHTTPHeaderField: "Cookie")
+ request.setValue("https://www.perplexity.ai", forHTTPHeaderField: "Origin")
+ request.setValue("https://www.perplexity.ai/account/usage", forHTTPHeaderField: "Referer")
+ let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
+ request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw PerplexityAPIError.networkError("Invalid response")
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let body = String(data: data, encoding: .utf8) ?? ""
+ let truncated = body.count > 200 ? String(body.prefix(200)) + "…" : body
+ Self.log.error("Perplexity API returned \(httpResponse.statusCode): \(truncated)")
+ if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
+ throw PerplexityAPIError.invalidToken
+ }
+ throw PerplexityAPIError.apiError("HTTP \(httpResponse.statusCode)")
+ }
+
+ do {
+ let decoded = try JSONDecoder().decode(PerplexityCreditsResponse.self, from: data)
+ let snapshot = PerplexityUsageSnapshot(response: decoded, now: now)
+ Self.log.debug(
+ "Perplexity credits parsed balance=\(snapshot.balanceCents) totalUsage=\(snapshot.totalUsageCents)")
+ return snapshot
+ } catch {
+ let preview = String(data: data.prefix(500), encoding: .utf8) ?? ""
+ Self.log.error("Perplexity parse failed: \(error) — response: \(preview)")
+ throw PerplexityAPIError.parseFailed(error.localizedDescription)
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift
new file mode 100644
index 000000000..48e4cf9cc
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageSnapshot.swift
@@ -0,0 +1,134 @@
+import Foundation
+
+public struct PerplexityUsageSnapshot: Sendable {
+ public let recurringTotal: Double
+ public let recurringUsed: Double
+ public let promoTotal: Double
+ public let promoUsed: Double
+ public let purchasedTotal: Double
+ public let purchasedUsed: Double
+ public let balanceCents: Double
+ public let totalUsageCents: Double
+ public let renewalDate: Date
+ public let promoExpiration: Date?
+ public let updatedAt: Date
+
+ public init(response: PerplexityCreditsResponse, now: Date) {
+ let recurring = response.creditGrants.filter { $0.type == "recurring" }
+ let promotional = response.creditGrants.filter {
+ $0.type == "promotional" && ($0.expiresAtTs ?? .infinity) > now.timeIntervalSince1970
+ }
+ let purchased = response.creditGrants.filter { $0.type == "purchased" }
+
+ // All timestamps from the Perplexity API are Unix seconds (verified Feb 2026).
+ let recurringSum = max(0, recurring.reduce(0.0) { $0 + $1.amountCents })
+ let promoSum = max(0, promotional.reduce(0.0) { $0 + $1.amountCents })
+ // Purchased credits may appear in the top-level field, in the credit_grants
+ // array (type == "purchased"), or both. Take whichever is larger to avoid
+ // double-counting while still catching either source.
+ let purchasedFromGrants = max(0, purchased.reduce(0.0) { $0 + $1.amountCents })
+ let purchasedFromField = max(0, response.currentPeriodPurchasedCents)
+ let purchasedSum = max(purchasedFromGrants, purchasedFromField)
+
+ // Waterfall attribution: recurring → purchased → promotional
+ var remaining = response.totalUsageCents
+ let usedFromRecurring = min(remaining, recurringSum); remaining -= usedFromRecurring
+ let usedFromPurchased = min(remaining, purchasedSum); remaining -= usedFromPurchased
+ let usedFromPromo = min(remaining, promoSum)
+
+ self.recurringTotal = recurringSum
+ self.recurringUsed = usedFromRecurring
+ self.promoTotal = promoSum
+ self.promoUsed = usedFromPromo
+ self.purchasedTotal = purchasedSum
+ self.purchasedUsed = usedFromPurchased
+ self.balanceCents = response.balanceCents
+ self.totalUsageCents = response.totalUsageCents
+ self.renewalDate = Date(timeIntervalSince1970: response.renewalDateTs)
+ self.promoExpiration = promotional
+ .compactMap { $0.expiresAtTs.map { Date(timeIntervalSince1970: $0) } }
+ .min()
+ self.updatedAt = now
+ }
+
+ /// Infer plan name from recurring credit allotment.
+ /// Free = 0, Pro = small pool (~500–1000), Max = 10,000+.
+ public var planName: String? {
+ if self.recurringTotal <= 0 { return nil }
+ if self.recurringTotal < 5000 { return "Pro" }
+ return "Max"
+ }
+
+ private static let promoExpiryFormatter: DateFormatter = {
+ let fmt = DateFormatter()
+ fmt.dateFormat = "MMM d"
+ return fmt
+ }()
+}
+
+extension PerplexityUsageSnapshot {
+ public func toUsageSnapshot() -> UsageSnapshot {
+ // Primary: recurring (monthly) credits
+ let hasFallbackCredits = self.promoTotal > 0 || self.purchasedTotal > 0
+ let primaryWindow: RateWindow? = {
+ if self.recurringTotal > 0 {
+ let primaryPercent = min(100, max(0, self.recurringUsed / self.recurringTotal * 100))
+ return RateWindow(
+ usedPercent: primaryPercent,
+ windowMinutes: nil,
+ resetsAt: self.renewalDate,
+ resetDescription: "\(Int(self.recurringUsed.rounded()))/\(Int(self.recurringTotal)) credits")
+ }
+ if hasFallbackCredits {
+ // When recurring is absent but bonus/purchased credits remain, omit the fake 0/0 primary lane
+ // so automatic menu-bar rendering can fall through to the usable pool.
+ return nil
+ }
+ return RateWindow(
+ usedPercent: 100,
+ windowMinutes: nil,
+ resetsAt: self.renewalDate,
+ resetDescription: "0/0 credits")
+ }()
+
+ // Secondary: promotional bonus credits — always shown.
+ // usedPercent=100 when promoTotal==0 so the bar renders empty rather than full.
+ let promoPercent = self.promoTotal > 0
+ ? min(100, max(0, self.promoUsed / self.promoTotal * 100))
+ : 100.0
+ var promoDesc = "\(Int(promoUsed.rounded()))/\(Int(self.promoTotal)) bonus"
+ if let expiry = promoExpiration {
+ promoDesc += " \u{00b7} exp. \(Self.promoExpiryFormatter.string(from: expiry))"
+ }
+ let secondary = RateWindow(
+ usedPercent: promoPercent,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: promoDesc)
+
+ // Tertiary: on-demand purchased credits — always shown.
+ // usedPercent=100 when purchasedTotal==0 so the bar renders empty rather than full.
+ let purchasedPercent = self.purchasedTotal > 0
+ ? min(100, max(0, self.purchasedUsed / self.purchasedTotal * 100))
+ : 100.0
+ let tertiary = RateWindow(
+ usedPercent: purchasedPercent,
+ windowMinutes: nil,
+ resetsAt: nil,
+ resetDescription: "\(Int(purchasedUsed.rounded()))/\(Int(self.purchasedTotal)) credits")
+
+ let identity = ProviderIdentitySnapshot(
+ providerID: .perplexity,
+ accountEmail: nil,
+ accountOrganization: nil,
+ loginMethod: planName)
+
+ return UsageSnapshot(
+ primary: primaryWindow,
+ secondary: secondary,
+ tertiary: tertiary,
+ providerCost: nil,
+ updatedAt: self.updatedAt,
+ identity: identity)
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
index 0596617b7..236af4bd3 100644
--- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
+++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift
@@ -76,6 +76,7 @@ public enum ProviderDescriptorRegistry {
.synthetic: SyntheticProviderDescriptor.descriptor,
.openrouter: OpenRouterProviderDescriptor.descriptor,
.warp: WarpProviderDescriptor.descriptor,
+ .perplexity: PerplexityProviderDescriptor.descriptor,
]
private static let bootstrap: Void = {
for provider in UsageProvider.allCases {
diff --git a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
index c5d9af4f7..7c1d1f786 100644
--- a/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
+++ b/Sources/CodexBarCore/Providers/ProviderSettingsSnapshot.swift
@@ -18,7 +18,8 @@ public struct ProviderSettingsSnapshot: Sendable {
augment: AugmentProviderSettings? = nil,
amp: AmpProviderSettings? = nil,
ollama: OllamaProviderSettings? = nil,
- jetbrains: JetBrainsProviderSettings? = nil) -> ProviderSettingsSnapshot
+ jetbrains: JetBrainsProviderSettings? = nil,
+ perplexity: PerplexityProviderSettings? = nil) -> ProviderSettingsSnapshot
{
ProviderSettingsSnapshot(
debugMenuEnabled: debugMenuEnabled,
@@ -37,7 +38,8 @@ public struct ProviderSettingsSnapshot: Sendable {
augment: augment,
amp: amp,
ollama: ollama,
- jetbrains: jetbrains)
+ jetbrains: jetbrains,
+ perplexity: perplexity)
}
public struct CodexProviderSettings: Sendable {
@@ -209,6 +211,16 @@ public struct ProviderSettingsSnapshot: Sendable {
}
}
+ public struct PerplexityProviderSettings: Sendable {
+ public let cookieSource: ProviderCookieSource
+ public let manualCookieHeader: String?
+
+ public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) {
+ self.cookieSource = cookieSource
+ self.manualCookieHeader = manualCookieHeader
+ }
+ }
+
public let debugMenuEnabled: Bool
public let debugKeepCLISessionsAlive: Bool
public let codex: CodexProviderSettings?
@@ -226,6 +238,7 @@ public struct ProviderSettingsSnapshot: Sendable {
public let amp: AmpProviderSettings?
public let ollama: OllamaProviderSettings?
public let jetbrains: JetBrainsProviderSettings?
+ public let perplexity: PerplexityProviderSettings?
public var jetbrainsIDEBasePath: String? {
self.jetbrains?.ideBasePath
@@ -248,7 +261,8 @@ public struct ProviderSettingsSnapshot: Sendable {
augment: AugmentProviderSettings?,
amp: AmpProviderSettings?,
ollama: OllamaProviderSettings?,
- jetbrains: JetBrainsProviderSettings? = nil)
+ jetbrains: JetBrainsProviderSettings? = nil,
+ perplexity: PerplexityProviderSettings? = nil)
{
self.debugMenuEnabled = debugMenuEnabled
self.debugKeepCLISessionsAlive = debugKeepCLISessionsAlive
@@ -267,6 +281,7 @@ public struct ProviderSettingsSnapshot: Sendable {
self.amp = amp
self.ollama = ollama
self.jetbrains = jetbrains
+ self.perplexity = perplexity
}
}
@@ -286,6 +301,7 @@ public enum ProviderSettingsSnapshotContribution: Sendable {
case amp(ProviderSettingsSnapshot.AmpProviderSettings)
case ollama(ProviderSettingsSnapshot.OllamaProviderSettings)
case jetbrains(ProviderSettingsSnapshot.JetBrainsProviderSettings)
+ case perplexity(ProviderSettingsSnapshot.PerplexityProviderSettings)
}
public struct ProviderSettingsSnapshotBuilder: Sendable {
@@ -306,6 +322,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
public var amp: ProviderSettingsSnapshot.AmpProviderSettings?
public var ollama: ProviderSettingsSnapshot.OllamaProviderSettings?
public var jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings?
+ public var perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings?
public init(debugMenuEnabled: Bool = false, debugKeepCLISessionsAlive: Bool = false) {
self.debugMenuEnabled = debugMenuEnabled
@@ -329,6 +346,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
case let .amp(value): self.amp = value
case let .ollama(value): self.ollama = value
case let .jetbrains(value): self.jetbrains = value
+ case let .perplexity(value): self.perplexity = value
}
}
@@ -350,6 +368,7 @@ public struct ProviderSettingsSnapshotBuilder: Sendable {
augment: self.augment,
amp: self.amp,
ollama: self.ollama,
- jetbrains: self.jetbrains)
+ jetbrains: self.jetbrains,
+ perplexity: self.perplexity)
}
}
diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
index a8d529f12..85113cc26 100644
--- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
+++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift
@@ -65,6 +65,12 @@ public enum ProviderTokenResolver {
self.openRouterResolution(environment: environment)?.token
}
+ public static func perplexitySessionToken(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
+ {
+ self.perplexityResolution(environment: environment)?.token
+ }
+
public static func zaiResolution(
environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
{
@@ -151,6 +157,25 @@ public enum ProviderTokenResolver {
self.resolveEnv(OpenRouterSettingsReader.apiToken(environment: environment))
}
+ public static func perplexityResolution(
+ environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution?
+ {
+ if let resolution = self.resolveEnv(PerplexitySettingsReader.sessionToken(environment: environment)) {
+ return resolution
+ }
+ #if os(macOS)
+ do {
+ let session = try PerplexityCookieImporter.importSession()
+ if let token = session.sessionToken {
+ return ProviderTokenResolution(token: token, source: .environment)
+ }
+ } catch {
+ // No browser cookies found, continue to fallback
+ }
+ #endif
+ return nil
+ }
+
private static func cleaned(_ raw: String?) -> String? {
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift
index 039f7a3f3..ff0f8eeb4 100644
--- a/Sources/CodexBarCore/Providers/Providers.swift
+++ b/Sources/CodexBarCore/Providers/Providers.swift
@@ -26,6 +26,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case synthetic
case warp
case openrouter
+ case perplexity
}
// swiftformat:enable sortDeclarations
@@ -54,6 +55,7 @@ public enum IconStyle: Sendable, CaseIterable {
case synthetic
case warp
case openrouter
+ case perplexity
case combined
}
diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift
index 98859b18e..61b055897 100644
--- a/Sources/CodexBarCore/UsageFetcher.swift
+++ b/Sources/CodexBarCore/UsageFetcher.swift
@@ -145,11 +145,35 @@ public struct UsageSnapshot: Codable, Sendable {
return identity
}
+ public func automaticPerplexityWindow() -> RateWindow? {
+ let fallbackWindows = self.orderedPerplexityFallbackWindows()
+ guard let primary = self.primary else {
+ return fallbackWindows.first
+ }
+ if primary.remainingPercent > 0 || fallbackWindows.isEmpty {
+ return primary
+ }
+ return fallbackWindows.first
+ }
+
+ public func orderedPerplexityDisplayWindows() -> [RateWindow] {
+ let fallbackWindows = self.orderedPerplexityFallbackWindows()
+ guard let primary = self.primary else {
+ return fallbackWindows
+ }
+ if primary.remainingPercent > 0 || fallbackWindows.isEmpty {
+ return [primary] + fallbackWindows
+ }
+ return fallbackWindows + [primary]
+ }
+
public func switcherWeeklyWindow(for provider: UsageProvider, showUsed: Bool) -> RateWindow? {
switch provider {
case .factory:
// Factory prefers secondary window
return self.secondary ?? self.primary
+ case .perplexity:
+ return self.automaticPerplexityWindow()
case .cursor:
// Cursor: fall back to on-demand budget when the included plan is exhausted (only in
// "show remaining" mode). The secondary/tertiary lanes are Total/Auto/API breakdowns,
@@ -206,6 +230,13 @@ public struct UsageSnapshot: Codable, Sendable {
if scopedIdentity.providerID == identity.providerID { return self }
return self.withIdentity(scopedIdentity)
}
+
+ private func orderedPerplexityFallbackWindows() -> [RateWindow] {
+ let fallbackWindows = [self.tertiary, self.secondary].compactMap(\.self)
+ let usableFallback = fallbackWindows.filter { $0.remainingPercent > 0 }
+ let exhaustedFallback = fallbackWindows.filter { $0.remainingPercent <= 0 }
+ return usableFallback + exhaustedFallback
+ }
}
public struct AccountInfo: Equatable, Sendable {
diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
index 0e6767e15..f4a3ba8bb 100644
--- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
+++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift
@@ -72,7 +72,7 @@ enum CostUsageScanner {
return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered)
case .zai, .gemini, .antigravity, .cursor, .opencode, .alibaba, .factory, .copilot, .minimax, .kilo,
.kiro, .kimi,
- .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp:
+ .kimik2, .augment, .jetbrains, .amp, .ollama, .synthetic, .openrouter, .warp, .perplexity:
return emptyReport
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
index c828a2695..c0e600fa9 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift
@@ -71,6 +71,7 @@ enum ProviderChoice: String, AppEnum {
case .synthetic: return nil // Synthetic not yet supported in widgets
case .openrouter: return nil // OpenRouter not yet supported in widgets
case .warp: return nil // Warp not yet supported in widgets
+ case .perplexity: return nil // Perplexity not yet supported in widgets
}
}
}
diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
index 3b0dd2d27..e08b23367 100644
--- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift
+++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift
@@ -280,6 +280,7 @@ private struct ProviderSwitchChip: View {
case .synthetic: "Synthetic"
case .openrouter: "OpenRouter"
case .warp: "Warp"
+ case .perplexity: "Pplx"
}
}
}
@@ -621,6 +622,8 @@ enum WidgetColors {
Color(red: 111 / 255, green: 66 / 255, blue: 193 / 255) // OpenRouter purple
case .warp:
Color(red: 147 / 255, green: 139 / 255, blue: 180 / 255)
+ case .perplexity:
+ Color(red: 32 / 255, green: 178 / 255, blue: 170 / 255) // Perplexity teal
}
}
}
diff --git a/Tests/CodexBarTests/CodexbarTests.swift b/Tests/CodexBarTests/CodexbarTests.swift
index 026ba43bc..4e2b3f137 100644
--- a/Tests/CodexBarTests/CodexbarTests.swift
+++ b/Tests/CodexBarTests/CodexbarTests.swift
@@ -74,6 +74,45 @@ struct CodexBarTests {
#expect(remaining.secondary == 40)
}
+ @Test
+ func `perplexity icon falls back to purchased lane when bonus is exhausted`() {
+ let snapshot = UsageSnapshot(
+ primary: nil,
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ let remaining = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .perplexity)
+ #expect(remaining.primary == 80)
+ #expect(remaining.secondary == 0)
+ }
+
+ @Test
+ func `perplexity icon skips exhausted recurring lane when purchased credits remain`() {
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ let remaining = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .perplexity)
+ #expect(remaining.primary == 80)
+ #expect(remaining.secondary == 0)
+ }
+
+ @Test
+ func `perplexity icon prefers purchased lane before bonus`() {
+ let snapshot = UsageSnapshot(
+ primary: nil,
+ secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ let remaining = IconRemainingResolver.resolvedRemaining(snapshot: snapshot, style: .perplexity)
+ #expect(remaining.primary == 55)
+ #expect(remaining.secondary == 80)
+ }
+
@Test
func `icon renderer codex eyes punch through when unknown`() {
// Regression: when remaining is nil, CoreGraphics inherits the previous fill alpha which caused
diff --git a/Tests/CodexBarTests/PerplexityCookieCacheTests.swift b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift
new file mode 100644
index 000000000..246a053ec
--- /dev/null
+++ b/Tests/CodexBarTests/PerplexityCookieCacheTests.swift
@@ -0,0 +1,203 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct PerplexityCookieCacheTests {
+ private static let testToken = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0.fake-test-token"
+ private static let testCookieName = PerplexityCookieHeader.defaultSessionCookieName
+
+ private struct StubClaudeFetcher: ClaudeUsageFetching {
+ func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
+ throw ClaudeUsageError.parseFailed("stub")
+ }
+
+ func debugRawProbe(model _: String) async -> String {
+ "stub"
+ }
+
+ func detectVersion() -> String? {
+ nil
+ }
+ }
+
+ // MARK: - Cache round-trip
+
+ @Test
+ func cacheRoundTripProducesValidCookieOverride() {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer {
+ CookieHeaderCache.clear(provider: .perplexity)
+ KeychainCacheStore.setTestStoreForTesting(false)
+ }
+
+ CookieHeaderCache.store(
+ provider: .perplexity,
+ cookieHeader: "\(Self.testCookieName)=\(Self.testToken)",
+ sourceLabel: "web")
+
+ let cached = CookieHeaderCache.load(provider: .perplexity)
+ #expect(cached != nil)
+ #expect(cached?.sourceLabel == "web")
+
+ let override = PerplexityCookieHeader.override(from: cached?.cookieHeader)
+ #expect(override?.name == Self.testCookieName)
+ #expect(override?.token == Self.testToken)
+ }
+
+ // MARK: - isAvailable returns true when cache has entry
+
+ @Test
+ func isAvailableReturnsTrueWhenCachePopulated() {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer {
+ CookieHeaderCache.clear(provider: .perplexity)
+ KeychainCacheStore.setTestStoreForTesting(false)
+ }
+
+ // With no cache and no other sources, load should return nil
+ let beforeStore = CookieHeaderCache.load(provider: .perplexity)
+ #expect(beforeStore == nil)
+
+ // After storing, cache should be available
+ CookieHeaderCache.store(
+ provider: .perplexity,
+ cookieHeader: "\(Self.testCookieName)=\(Self.testToken)",
+ sourceLabel: "web")
+
+ let afterStore = CookieHeaderCache.load(provider: .perplexity)
+ #expect(afterStore != nil)
+ }
+
+ // MARK: - Cache cleared on invalidToken
+
+ @Test
+ func cacheClearedOnInvalidToken() {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer {
+ CookieHeaderCache.clear(provider: .perplexity)
+ KeychainCacheStore.setTestStoreForTesting(false)
+ }
+
+ CookieHeaderCache.store(
+ provider: .perplexity,
+ cookieHeader: "\(Self.testCookieName)=\(Self.testToken)",
+ sourceLabel: "web")
+
+ // Verify it's cached
+ #expect(CookieHeaderCache.load(provider: .perplexity) != nil)
+
+ // Simulate what fetch() does on invalidToken: clear the cache
+ CookieHeaderCache.clear(provider: .perplexity)
+
+ #expect(CookieHeaderCache.load(provider: .perplexity) == nil)
+ }
+
+ // MARK: - Cache NOT cleared on non-auth errors
+
+ @Test
+ func cacheNotClearedOnNetworkError() {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer {
+ CookieHeaderCache.clear(provider: .perplexity)
+ KeychainCacheStore.setTestStoreForTesting(false)
+ }
+
+ CookieHeaderCache.store(
+ provider: .perplexity,
+ cookieHeader: "\(Self.testCookieName)=\(Self.testToken)",
+ sourceLabel: "web")
+
+ // Simulate a networkError — cache should NOT be cleared
+ let error = PerplexityAPIError.networkError("timeout")
+ switch error {
+ case .invalidToken:
+ CookieHeaderCache.clear(provider: .perplexity)
+ default:
+ break // non-auth errors do not clear cache
+ }
+
+ #expect(CookieHeaderCache.load(provider: .perplexity) != nil)
+ }
+
+ @Test
+ func cacheNotClearedOnAPIError() {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer {
+ CookieHeaderCache.clear(provider: .perplexity)
+ KeychainCacheStore.setTestStoreForTesting(false)
+ }
+
+ CookieHeaderCache.store(
+ provider: .perplexity,
+ cookieHeader: "\(Self.testCookieName)=\(Self.testToken)",
+ sourceLabel: "web")
+
+ // Simulate an apiError (e.g. HTTP 500) — cache should NOT be cleared
+ let error = PerplexityAPIError.apiError("HTTP 500")
+ switch error {
+ case .invalidToken:
+ CookieHeaderCache.clear(provider: .perplexity)
+ default:
+ break // non-auth errors do not clear cache
+ }
+
+ #expect(CookieHeaderCache.load(provider: .perplexity) != nil)
+ }
+
+ // MARK: - Bare token stored as default cookie name
+
+ @Test
+ func bareTokenRoundTripsWithDefaultCookieName() {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer {
+ CookieHeaderCache.clear(provider: .perplexity)
+ KeychainCacheStore.setTestStoreForTesting(false)
+ }
+
+ // Store with default cookie name format
+ CookieHeaderCache.store(
+ provider: .perplexity,
+ cookieHeader: "\(Self.testCookieName)=\(Self.testToken)",
+ sourceLabel: "web")
+
+ let cached = CookieHeaderCache.load(provider: .perplexity)
+ let override = PerplexityCookieHeader.override(from: cached?.cookieHeader)
+ #expect(override?.name == Self.testCookieName)
+ #expect(override?.token == Self.testToken)
+ }
+
+ @Test
+ func offModeIgnoresCachedSessionCookie() async {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer {
+ CookieHeaderCache.clear(provider: .perplexity)
+ KeychainCacheStore.setTestStoreForTesting(false)
+ }
+
+ CookieHeaderCache.store(
+ provider: .perplexity,
+ cookieHeader: "\(Self.testCookieName)=cached-token",
+ sourceLabel: "web")
+
+ let strategy = PerplexityWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: .off,
+ manualCookieHeader: nil))
+ let context = ProviderFetchContext(
+ runtime: .app,
+ sourceMode: .auto,
+ includeCredits: false,
+ webTimeout: 1,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: [:],
+ settings: settings,
+ fetcher: UsageFetcher(environment: [:]),
+ claudeFetcher: StubClaudeFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0))
+
+ #expect(await strategy.isAvailable(context) == false)
+ }
+}
diff --git a/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift
new file mode 100644
index 000000000..77c74a2c2
--- /dev/null
+++ b/Tests/CodexBarTests/PerplexityCookieHeaderTests.swift
@@ -0,0 +1,90 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite
+struct PerplexityCookieHeaderTests {
+ @Test
+ func bareTokenUsesDefaultSessionCookieName() {
+ let override = PerplexityCookieHeader.override(from: "abc123")
+ #expect(override?.name == PerplexityCookieHeader.defaultSessionCookieName)
+ #expect(override?.token == "abc123")
+ #expect(override?.requestCookieNames == PerplexityCookieHeader.supportedSessionCookieNames)
+ }
+
+ @Test
+ func extractsSecureNextAuthSessionCookieFromHeader() {
+ let header = "foo=bar; __Secure-next-auth.session-token=token-a; baz=qux"
+ let override = PerplexityCookieHeader.override(from: header)
+ #expect(override?.name == "__Secure-next-auth.session-token")
+ #expect(override?.token == "token-a")
+ }
+
+ @Test
+ func extractsAuthJSSessionCookieFromHeader() {
+ let header = "foo=bar; __Secure-authjs.session-token=token-b; baz=qux"
+ let override = PerplexityCookieHeader.override(from: header)
+ #expect(override?.name == "__Secure-authjs.session-token")
+ #expect(override?.token == "token-b")
+ }
+
+ @Test
+ func prefersAuthJSSessionCookieWhenBothNamesExist() {
+ let header = """
+ __Secure-next-auth.session-token=legacy-token; __Secure-authjs.session-token=live-token
+ """
+ let override = PerplexityCookieHeader.override(from: header)
+ #expect(override?.name == "__Secure-authjs.session-token")
+ #expect(override?.token == "live-token")
+ }
+
+ @Test
+ func reassemblesChunkedNextAuthSessionCookieFromHeader() {
+ let header = """
+ foo=bar; __Secure-next-auth.session-token.1=chunk-b; __Secure-next-auth.session-token.0=chunk-a
+ """
+ let override = PerplexityCookieHeader.override(from: header)
+ #expect(override?.name == "__Secure-next-auth.session-token")
+ #expect(override?.token == "chunk-achunk-b")
+ }
+
+ @Test
+ func reassemblesChunkedAuthJSSessionCookieFromHeader() {
+ let header = "foo=bar; authjs.session-token.0=chunk-a; authjs.session-token.1=chunk-b"
+ let override = PerplexityCookieHeader.override(from: header)
+ #expect(override?.name == "authjs.session-token")
+ #expect(override?.token == "chunk-achunk-b")
+ }
+
+ @Test
+ func unsupportedCookieHeaderReturnsNil() {
+ let override = PerplexityCookieHeader.override(from: "foo=bar; hello=world")
+ #expect(override == nil)
+ }
+
+ #if os(macOS)
+ @Test
+ func importerSessionInfoReassemblesChunkedSessionCookies() throws {
+ let cookies = try [
+ #require(self.makeCookie(name: "__Secure-authjs.session-token.0", value: "chunk-a")),
+ #require(self.makeCookie(name: "__Secure-authjs.session-token.1", value: "chunk-b")),
+ ]
+ let session = PerplexityCookieImporter.SessionInfo(cookies: cookies, sourceLabel: "Chrome")
+
+ #expect(session.sessionCookie?.name == "__Secure-authjs.session-token")
+ #expect(session.sessionCookie?.token == "chunk-achunk-b")
+ }
+ #endif
+
+ #if os(macOS)
+ private func makeCookie(name: String, value: String) -> HTTPCookie? {
+ HTTPCookie(properties: [
+ .domain: "www.perplexity.ai",
+ .path: "/",
+ .name: name,
+ .value: value,
+ .secure: "TRUE",
+ ])
+ }
+ #endif
+}
diff --git a/Tests/CodexBarTests/PerplexityProviderTests.swift b/Tests/CodexBarTests/PerplexityProviderTests.swift
new file mode 100644
index 000000000..fa475f8b9
--- /dev/null
+++ b/Tests/CodexBarTests/PerplexityProviderTests.swift
@@ -0,0 +1,397 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite(.serialized)
+struct PerplexityProviderTests {
+ private static let now = Date(timeIntervalSince1970: 1_740_000_000)
+
+ private final class LockedArray: @unchecked Sendable {
+ private let lock = NSLock()
+ private var values: [Element] = []
+
+ func append(_ value: Element) {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ self.values.append(value)
+ }
+
+ func snapshot() -> [Element] {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ return self.values
+ }
+ }
+
+ private final class LockedCounter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var value: Int = 0
+
+ func increment() {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ self.value += 1
+ }
+
+ func snapshot() -> Int {
+ self.lock.lock()
+ defer { self.lock.unlock() }
+ return self.value
+ }
+ }
+
+ private struct StubClaudeFetcher: ClaudeUsageFetching {
+ func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
+ throw ClaudeUsageError.parseFailed("stub")
+ }
+
+ func debugRawProbe(model _: String) async -> String {
+ "stub"
+ }
+
+ func detectVersion() -> String? {
+ nil
+ }
+ }
+
+ private func makeContext(
+ settings: ProviderSettingsSnapshot?,
+ env: [String: String] = [:]) -> ProviderFetchContext
+ {
+ ProviderFetchContext(
+ runtime: .app,
+ sourceMode: .auto,
+ includeCredits: false,
+ webTimeout: 1,
+ webDebugDumpHTML: false,
+ verbose: false,
+ env: env,
+ settings: settings,
+ fetcher: UsageFetcher(environment: env),
+ claudeFetcher: StubClaudeFetcher(),
+ browserDetection: BrowserDetection(cacheTTL: 0))
+ }
+
+ private func stubSnapshot(now: Date = Self.now) -> PerplexityUsageSnapshot {
+ PerplexityUsageSnapshot(
+ response: PerplexityCreditsResponse(
+ balanceCents: 500,
+ renewalDateTs: now.addingTimeInterval(3600).timeIntervalSince1970,
+ currentPeriodPurchasedCents: 0,
+ creditGrants: [
+ PerplexityCreditGrant(type: "recurring", amountCents: 1000, expiresAtTs: nil),
+ ],
+ totalUsageCents: 500),
+ now: now)
+ }
+
+ private func withIsolatedCacheStore(operation: () async throws -> T) async rethrows -> T {
+ let service = "perplexity-provider-tests-\(UUID().uuidString)"
+ return try await KeychainCacheStore.withServiceOverrideForTesting(service) {
+ KeychainCacheStore.setTestStoreForTesting(true)
+ defer { KeychainCacheStore.setTestStoreForTesting(false) }
+ return try await operation()
+ }
+ }
+
+ @Test
+ func offModeIgnoresEnvironmentSessionCookie() async {
+ let strategy = PerplexityWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: .off,
+ manualCookieHeader: nil))
+ let context = self.makeContext(
+ settings: settings,
+ env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"])
+
+ #expect(await strategy.isAvailable(context) == false)
+ }
+
+ @Test
+ func manualModeInvalidCookieDoesNotFallBackToCacheOrEnvironment() async {
+ await self.withIsolatedCacheStore {
+ CookieHeaderCache.store(
+ provider: .perplexity,
+ cookieHeader: "\(PerplexityCookieHeader.defaultSessionCookieName)=cached-token",
+ sourceLabel: "web")
+
+ let strategy = PerplexityWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: .manual,
+ manualCookieHeader: "foo=bar"))
+ let context = self.makeContext(
+ settings: settings,
+ env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"])
+ let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in
+ self.stubSnapshot()
+ }
+
+ do {
+ _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
+ try await strategy.fetch(context)
+ })
+ Issue.record("Expected invalid manual-cookie error instead of falling back to cache/environment")
+ } catch let error as PerplexityAPIError {
+ #expect(error == .invalidCookie)
+ } catch {
+ Issue.record("Expected PerplexityAPIError.invalidCookie, got \(error)")
+ }
+ }
+ }
+
+ @Test
+ func environmentTokenDoesNotPopulateBrowserCookieCache() async throws {
+ try await self.withIsolatedCacheStore {
+ PerplexityCookieImporter.invalidateImportSessionCache()
+ PerplexityCookieImporter.importSessionsOverrideForTesting = nil
+ PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in
+ throw PerplexityCookieImportError.noCookies
+ }
+ defer {
+ PerplexityCookieImporter.importSessionsOverrideForTesting = nil
+ PerplexityCookieImporter.importSessionOverrideForTesting = nil
+ PerplexityCookieImporter.invalidateImportSessionCache()
+ }
+
+ let strategy = PerplexityWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil))
+ let context = self.makeContext(
+ settings: settings,
+ env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"])
+ let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in
+ self.stubSnapshot()
+ }
+
+ _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
+ try await strategy.fetch(context)
+ })
+
+ #expect(CookieHeaderCache.load(provider: .perplexity) == nil)
+ }
+ }
+
+ @Test
+ func manualTokenDoesNotPopulateBrowserCookieCache() async throws {
+ try await self.withIsolatedCacheStore {
+ let strategy = PerplexityWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: .manual,
+ manualCookieHeader: "authjs.session-token=manual-token"))
+ let context = self.makeContext(settings: settings)
+ let fetchOverride: @Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot = { _, _, _ in
+ self.stubSnapshot()
+ }
+
+ _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
+ try await strategy.fetch(context)
+ })
+
+ #expect(CookieHeaderCache.load(provider: .perplexity) == nil)
+ }
+ }
+
+ @Test
+ func bareEnvironmentTokenFallsBackToAuthJSCookieName() async throws {
+ try await self.withIsolatedCacheStore {
+ PerplexityCookieImporter.invalidateImportSessionCache()
+ PerplexityCookieImporter.importSessionsOverrideForTesting = nil
+ PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in
+ throw PerplexityCookieImportError.noCookies
+ }
+ defer {
+ PerplexityCookieImporter.importSessionsOverrideForTesting = nil
+ PerplexityCookieImporter.importSessionOverrideForTesting = nil
+ PerplexityCookieImporter.invalidateImportSessionCache()
+ }
+
+ let attemptedCookieNames = LockedArray()
+ let strategy = PerplexityWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil))
+ let context = self.makeContext(
+ settings: settings,
+ env: ["PERPLEXITY_SESSION_TOKEN": "env-token"])
+ let fetchOverride: @Sendable (String, String, Date) async throws
+ -> PerplexityUsageSnapshot = { token, cookieName, _ in
+ #expect(token == "env-token")
+ attemptedCookieNames.append(cookieName)
+ if cookieName == "authjs.session-token" {
+ return self.stubSnapshot()
+ }
+ throw PerplexityAPIError.invalidToken
+ }
+
+ _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
+ try await strategy.fetch(context)
+ })
+
+ #expect(attemptedCookieNames.snapshot() == [
+ "__Secure-authjs.session-token",
+ "authjs.session-token",
+ ])
+ }
+ }
+
+ @Test
+ func validEnvironmentCookieWinsAfterInvalidBrowserSession() async throws {
+ try await self.withIsolatedCacheStore {
+ PerplexityCookieImporter.invalidateImportSessionCache()
+ PerplexityCookieImporter.importSessionsOverrideForTesting = nil
+ PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in
+ let cookie = try #require(HTTPCookie(properties: [
+ .domain: "www.perplexity.ai",
+ .path: "/",
+ .name: PerplexityCookieHeader.defaultSessionCookieName,
+ .value: "browser-token",
+ .secure: "TRUE",
+ ]))
+ return PerplexityCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome")
+ }
+ defer {
+ PerplexityCookieImporter.importSessionsOverrideForTesting = nil
+ PerplexityCookieImporter.importSessionOverrideForTesting = nil
+ PerplexityCookieImporter.invalidateImportSessionCache()
+ }
+
+ let attemptedTokens = LockedArray()
+ let strategy = PerplexityWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil))
+ let context = self.makeContext(
+ settings: settings,
+ env: ["PERPLEXITY_COOKIE": "authjs.session-token=env-token"])
+ let fetchOverride: @Sendable (String, String, Date) async throws
+ -> PerplexityUsageSnapshot = { token, _, _ in
+ attemptedTokens.append(token)
+ if token == "browser-token" {
+ throw PerplexityAPIError.invalidToken
+ }
+ if token == "env-token" {
+ return self.stubSnapshot()
+ }
+ Issue.record("Unexpected token \(token)")
+ throw PerplexityAPIError.invalidToken
+ }
+
+ _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
+ try await strategy.fetch(context)
+ })
+
+ #expect(attemptedTokens.snapshot() == ["browser-token", "env-token"])
+ }
+ }
+
+ @Test
+ func laterBrowserSessionWinsAfterEarlierImportedSessionFailsAuth() async throws {
+ try await self.withIsolatedCacheStore {
+ PerplexityCookieImporter.invalidateImportSessionCache()
+ PerplexityCookieImporter.importSessionOverrideForTesting = nil
+ PerplexityCookieImporter.importSessionsOverrideForTesting = { _, _ in
+ let staleCookie = try #require(HTTPCookie(properties: [
+ .domain: "www.perplexity.ai",
+ .path: "/",
+ .name: "__Secure-authjs.session-token",
+ .value: "stale-browser-token",
+ .secure: "TRUE",
+ ]))
+ let liveCookie = try #require(HTTPCookie(properties: [
+ .domain: "www.perplexity.ai",
+ .path: "/",
+ .name: "__Secure-authjs.session-token",
+ .value: "live-browser-token",
+ .secure: "TRUE",
+ ]))
+ return [
+ PerplexityCookieImporter.SessionInfo(cookies: [staleCookie], sourceLabel: "Chrome"),
+ PerplexityCookieImporter.SessionInfo(cookies: [liveCookie], sourceLabel: "Safari"),
+ ]
+ }
+ defer {
+ PerplexityCookieImporter.importSessionsOverrideForTesting = nil
+ PerplexityCookieImporter.importSessionOverrideForTesting = nil
+ PerplexityCookieImporter.invalidateImportSessionCache()
+ }
+
+ let attemptedTokens = LockedArray()
+ let strategy = PerplexityWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil))
+ let context = self.makeContext(settings: settings)
+ let fetchOverride: @Sendable (String, String, Date) async throws
+ -> PerplexityUsageSnapshot = { token, _, _ in
+ attemptedTokens.append(token)
+ if token == "stale-browser-token" {
+ throw PerplexityAPIError.invalidToken
+ }
+ if token == "live-browser-token" {
+ return self.stubSnapshot()
+ }
+ Issue.record("Unexpected token \(token)")
+ throw PerplexityAPIError.invalidToken
+ }
+
+ _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
+ try await strategy.fetch(context)
+ })
+
+ #expect(attemptedTokens.snapshot() == ["stale-browser-token", "live-browser-token"])
+ }
+ }
+
+ @Test
+ func autoModeReusesBrowserImportBetweenAvailabilityAndFetch() async throws {
+ try await self.withIsolatedCacheStore {
+ let importCount = LockedCounter()
+ PerplexityCookieImporter.invalidateImportSessionCache()
+ PerplexityCookieImporter.importSessionsOverrideForTesting = nil
+ PerplexityCookieImporter.importSessionOverrideForTesting = { _, _ in
+ importCount.increment()
+ let cookie = try #require(HTTPCookie(properties: [
+ .domain: "www.perplexity.ai",
+ .path: "/",
+ .name: PerplexityCookieHeader.defaultSessionCookieName,
+ .value: "browser-token",
+ .secure: "TRUE",
+ ]))
+ return PerplexityCookieImporter.SessionInfo(cookies: [cookie], sourceLabel: "Chrome")
+ }
+ defer {
+ PerplexityCookieImporter.importSessionsOverrideForTesting = nil
+ PerplexityCookieImporter.importSessionOverrideForTesting = nil
+ PerplexityCookieImporter.invalidateImportSessionCache()
+ }
+
+ let strategy = PerplexityWebFetchStrategy()
+ let settings = ProviderSettingsSnapshot.make(
+ perplexity: ProviderSettingsSnapshot.PerplexityProviderSettings(
+ cookieSource: .auto,
+ manualCookieHeader: nil))
+ let context = self.makeContext(settings: settings)
+ let fetchOverride: @Sendable (String, String, Date) async throws
+ -> PerplexityUsageSnapshot = { token, _, _ in
+ #expect(token == "browser-token")
+ return self.stubSnapshot()
+ }
+
+ #expect(await strategy.isAvailable(context))
+
+ _ = try await PerplexityUsageFetcher.$fetchCreditsOverride.withValue(fetchOverride, operation: {
+ try await strategy.fetch(context)
+ })
+
+ #expect(importCount.snapshot() == 1)
+ }
+ }
+}
diff --git a/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift b/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift
new file mode 100644
index 000000000..2f7b1ff7a
--- /dev/null
+++ b/Tests/CodexBarTests/PerplexitySettingsReaderTests.swift
@@ -0,0 +1,38 @@
+import Testing
+@testable import CodexBarCore
+
+struct PerplexitySettingsReaderTests {
+ @Test
+ func `PERPLEXITY_COOKIE preserves the original supported cookie name`() {
+ let override = PerplexitySettingsReader.sessionCookieOverride(environment: [
+ "PERPLEXITY_COOKIE": "authjs.session-token=env-token",
+ ])
+
+ #expect(override?.name == "authjs.session-token")
+ #expect(override?.token == "env-token")
+ #expect(PerplexitySettingsReader.sessionToken(environment: [
+ "PERPLEXITY_COOKIE": "authjs.session-token=env-token",
+ ]) == "env-token")
+ }
+
+ @Test
+ func `PERPLEXITY_COOKIE reassembles chunked session cookies`() {
+ let override = PerplexitySettingsReader.sessionCookieOverride(environment: [
+ "PERPLEXITY_COOKIE": "authjs.session-token.0=chunk-a; authjs.session-token.1=chunk-b",
+ ])
+
+ #expect(override?.name == "authjs.session-token")
+ #expect(override?.token == "chunk-achunk-b")
+ }
+
+ @Test
+ func `PERPLEXITY_SESSION_TOKEN tries all supported cookie names`() {
+ let override = PerplexitySettingsReader.sessionCookieOverride(environment: [
+ "PERPLEXITY_SESSION_TOKEN": "env-token",
+ ])
+
+ #expect(override?.name == PerplexityCookieHeader.defaultSessionCookieName)
+ #expect(override?.token == "env-token")
+ #expect(override?.requestCookieNames == PerplexityCookieHeader.supportedSessionCookieNames)
+ }
+}
diff --git a/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift
new file mode 100644
index 000000000..cd115081a
--- /dev/null
+++ b/Tests/CodexBarTests/PerplexityUsageFetcherTests.swift
@@ -0,0 +1,363 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+
+@Suite
+struct PerplexityUsageFetcherTests {
+ // Fixed "now" so expiry comparisons are deterministic
+ private static let now = Date(timeIntervalSince1970: 1_740_000_000) // Feb 20, 2026
+ private static let futureTs: TimeInterval = 1_750_000_000 // ~Jun 2025, after now
+ private static let pastTs: TimeInterval = 1_700_000_000 // ~Nov 2023, before now
+ private static let renewalTs: TimeInterval = 1_743_000_000 // ~Mar 26, 2026
+
+ // MARK: - JSON Parsing
+
+ @Test
+ func parsesFullResponseWithRecurringAndPromotionalCredits() throws {
+ let json = """
+ {
+ "balance_cents": 7250,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 0,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) },
+ { "type": "promotional", "amount_cents": 20000, "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 2750
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+
+ #expect(snapshot.recurringTotal == 10000)
+ #expect(snapshot.recurringUsed == 2750)
+ #expect(snapshot.promoTotal == 20000)
+ #expect(snapshot.promoUsed == 0)
+ #expect(snapshot.purchasedTotal == 0)
+ #expect(snapshot.purchasedUsed == 0)
+ #expect(snapshot.balanceCents == 7250)
+ #expect(snapshot.totalUsageCents == 2750)
+ #expect(abs(snapshot.renewalDate.timeIntervalSince1970 - Self.renewalTs) < 1)
+ }
+
+ @Test
+ func waterfallAttributionRecurringThenPurchasedThenPromo() throws {
+ // Usage exceeds recurring, spills into purchased, then promo
+ let json = """
+ {
+ "balance_cents": 0,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 3000,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) },
+ { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 9000
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+
+ #expect(snapshot.recurringUsed == 5000) // recurring fully consumed
+ #expect(snapshot.purchasedUsed == 3000) // purchased fully consumed
+ #expect(snapshot.promoUsed == 1000) // 9000 - 5000 - 3000 = 1000 from promo
+ }
+
+ @Test
+ func expiredPromotionalGrantsAreExcluded() throws {
+ let json = """
+ {
+ "balance_cents": 0,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 0,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) },
+ { "type": "promotional", "amount_cents": 5000, "expires_at_ts": \(Self.pastTs) }
+ ],
+ "total_usage_cents": 1000
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+
+ #expect(snapshot.promoTotal == 0) // expired grant excluded
+ #expect(snapshot.promoUsed == 0)
+ #expect(snapshot.promoExpiration == nil)
+ }
+
+ @Test
+ func emptyCreditGrantsProducesZeroRecurring() throws {
+ let json = """
+ {
+ "balance_cents": 0,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 0,
+ "credit_grants": [],
+ "total_usage_cents": 0
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+
+ #expect(snapshot.recurringTotal == 0)
+ #expect(snapshot.promoTotal == 0)
+ #expect(snapshot.purchasedTotal == 0)
+ #expect(snapshot.planName == nil)
+ }
+
+ @Test
+ func malformedJSONThrowsParseFailed() {
+ let json = """
+ { "balance_cents": "not a number", "credit_grants": null }
+ """
+ #expect {
+ _ = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+ } throws: { error in
+ guard case PerplexityAPIError.parseFailed = error else { return false }
+ return true
+ }
+ }
+
+ // MARK: - Plan Name Inference
+
+ @Test
+ func planNameInference() throws {
+ func makeSnapshot(recurringCents: Double) throws -> PerplexityUsageSnapshot {
+ let json = """
+ {
+ "balance_cents": 0,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 0,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": \(recurringCents), "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 0
+ }
+ """
+ return try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+ }
+
+ #expect(try makeSnapshot(recurringCents: 0).planName == nil)
+ #expect(try makeSnapshot(recurringCents: 500).planName == "Pro")
+ #expect(try makeSnapshot(recurringCents: 1000).planName == "Pro")
+ #expect(try makeSnapshot(recurringCents: 10000).planName == "Max")
+ }
+
+ // MARK: - toUsageSnapshot
+
+ @Test
+ func toUsageSnapshotAlwaysHasSecondaryAndTertiary() throws {
+ let json = """
+ {
+ "balance_cents": 0,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 0,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 0
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+ .toUsageSnapshot()
+
+ // secondary and tertiary always present even when no promo/purchased credits
+ #expect(snapshot.secondary != nil)
+ #expect(snapshot.tertiary != nil)
+ }
+
+ @Test
+ func toUsageSnapshotZeroRecurringBarIsFullyDepleted() throws {
+ let json = """
+ {
+ "balance_cents": 0,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 0,
+ "credit_grants": [],
+ "total_usage_cents": 0
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+ .toUsageSnapshot()
+ let primary = try #require(snapshot.primary)
+
+ // No recurring credits → bar renders as empty (100% used), not full (0% used)
+ #expect(primary.usedPercent == 100.0)
+ }
+
+ @Test
+ func toUsageSnapshotOmitsPrimaryWhenOnlyFallbackCreditsRemain() throws {
+ let json = """
+ {
+ "balance_cents": 6000,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 2000,
+ "credit_grants": [
+ { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 0
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+ .toUsageSnapshot()
+
+ #expect(snapshot.primary == nil)
+ #expect(snapshot.secondary?.usedPercent == 0.0)
+ #expect(snapshot.tertiary?.usedPercent == 0.0)
+ }
+
+ @Test
+ func toUsageSnapshotEmptyPoolsBarsAreFullyDepleted() throws {
+ let json = """
+ {
+ "balance_cents": 0,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 0,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 0
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+ .toUsageSnapshot()
+ let secondary = try #require(snapshot.secondary)
+ let tertiary = try #require(snapshot.tertiary)
+
+ // Empty pools render as 100% used (empty bar) not 0% used (full bar)
+ #expect(secondary.usedPercent == 100.0)
+ #expect(tertiary.usedPercent == 100.0)
+ }
+
+ // MARK: - Purchased credits from credit_grants
+
+ @Test
+ func purchasedCreditsFromCreditGrantsArray() throws {
+ // Purchased credits appear as credit_grant type="purchased" instead of
+ // current_period_purchased_cents. The snapshot should pick them up.
+ let json = """
+ {
+ "balance_cents": 23065,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 0,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) },
+ { "type": "purchased", "amount_cents": 40000 },
+ { "type": "promotional", "amount_cents": 55000, "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 81935
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+
+ #expect(snapshot.recurringTotal == 10000)
+ #expect(snapshot.purchasedTotal == 40000)
+ #expect(snapshot.promoTotal == 55000)
+
+ // Waterfall: recurring eats 10000, purchased eats 40000, promo eats 31935
+ #expect(snapshot.recurringUsed == 10000)
+ #expect(snapshot.purchasedUsed == 40000)
+ #expect(snapshot.promoUsed == 31935)
+ }
+
+ @Test
+ func purchasedCreditsPreferGrantsOverFieldWhenBothPresent() throws {
+ // When both current_period_purchased_cents AND credit_grants type="purchased"
+ // are provided, the larger value wins.
+ let json = """
+ {
+ "balance_cents": 0,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 3000,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) },
+ { "type": "purchased", "amount_cents": 8000 },
+ { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 14000
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+
+ // Purchased should use max(8000, 3000) = 8000
+ #expect(snapshot.purchasedTotal == 8000)
+ // Waterfall: 5000 recurring + 8000 purchased + 1000 promo = 14000
+ #expect(snapshot.recurringUsed == 5000)
+ #expect(snapshot.purchasedUsed == 8000)
+ #expect(snapshot.promoUsed == 1000)
+ }
+
+ @Test
+ func purchasedCreditsFromFieldWhenNoGrantType() throws {
+ // Legacy path: current_period_purchased_cents is set but no "purchased" grant
+ let json = """
+ {
+ "balance_cents": 0,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 3000,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) },
+ { "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 9000
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+
+ // Still picks up purchased from the top-level field
+ #expect(snapshot.purchasedTotal == 3000)
+ #expect(snapshot.recurringUsed == 5000)
+ #expect(snapshot.purchasedUsed == 3000)
+ #expect(snapshot.promoUsed == 1000)
+ }
+
+ @Test
+ func realWorldMaxPlanWithAllThreePools() throws {
+ // Real-world scenario: Max plan, 10k recurring + 40k purchased + 55k bonus
+ // Total 105,000 available, 23,065 remaining → 81,935 used
+ let json = """
+ {
+ "balance_cents": 23065,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 0,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) },
+ { "type": "purchased", "amount_cents": 40000 },
+ { "type": "promotional", "amount_cents": 55000, "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 81935
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+ let usage = snapshot.toUsageSnapshot()
+
+ // Primary (recurring): fully consumed → 100%
+ let primary = try #require(usage.primary)
+ #expect(primary.usedPercent == 100.0)
+
+ // Tertiary (purchased): fully consumed → 100%
+ let tertiary = try #require(usage.tertiary)
+ #expect(tertiary.usedPercent == 100.0)
+
+ // Secondary (bonus): 31935/55000 ≈ 58.06% used → ~42% remaining
+ let secondary = try #require(usage.secondary)
+ let expectedPromoPercent = 31935.0 / 55000.0 * 100.0
+ #expect(abs(secondary.usedPercent - expectedPromoPercent) < 0.1)
+ }
+
+ @Test
+ func toUsageSnapshotPrimaryPercentMatchesUsage() throws {
+ let json = """
+ {
+ "balance_cents": 0,
+ "renewal_date_ts": \(Self.renewalTs),
+ "current_period_purchased_cents": 0,
+ "credit_grants": [
+ { "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }
+ ],
+ "total_usage_cents": 2500
+ }
+ """
+ let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
+ .toUsageSnapshot()
+ let primary = try #require(snapshot.primary)
+
+ #expect(primary.usedPercent == 25.0)
+ }
+}
diff --git a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
index e76290615..3de5099f4 100644
--- a/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreAdditionalTests.swift
@@ -26,6 +26,11 @@ struct SettingsStoreAdditionalTests {
#expect(settings.menuBarMetricPreference(for: .cursor, snapshot: nil) == .automatic)
#expect(settings.menuBarMetricSupportsTertiary(for: .cursor, snapshot: nil) == false)
+ settings.setMenuBarMetricPreference(.tertiary, for: .perplexity)
+ #expect(settings.menuBarMetricPreference(for: .perplexity) == .tertiary)
+ #expect(settings.menuBarMetricPreference(for: .perplexity, snapshot: nil) == .tertiary)
+ #expect(settings.menuBarMetricSupportsTertiary(for: .perplexity, snapshot: nil))
+
settings.setMenuBarMetricPreference(.tertiary, for: .gemini)
#expect(settings.menuBarMetricPreference(for: .gemini) == .automatic)
}
diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift
index a7cfb81b1..24e854be8 100644
--- a/Tests/CodexBarTests/SettingsStoreTests.swift
+++ b/Tests/CodexBarTests/SettingsStoreTests.swift
@@ -767,6 +767,7 @@ struct SettingsStoreTests {
.synthetic,
.warp,
.openrouter,
+ .perplexity,
])
// Move one provider; ensure it's persisted across instances.
diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift
index f29ada71b..915c82532 100644
--- a/Tests/CodexBarTests/StatusItemAnimationTests.swift
+++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift
@@ -439,6 +439,209 @@ struct StatusItemAnimationTests {
#expect(window?.usedPercent == 90)
}
+ @Test
+ func `menu bar percent automatic falls back to purchased perplexity lane when bonus is exhausted`() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "StatusItemAnimationTests-perplexity-automatic-purchased"),
+ zaiTokenStore: NoopZaiTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .perplexity
+ settings.setMenuBarMetricPreference(.automatic, for: .perplexity)
+
+ let registry = ProviderRegistry.shared
+ if let perplexityMeta = registry.metadata[.perplexity] {
+ settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let snapshot = UsageSnapshot(
+ primary: nil,
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(snapshot, provider: .perplexity)
+ store._setErrorForTesting(nil, provider: .perplexity)
+
+ let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot)
+
+ #expect(window?.usedPercent == 20)
+ }
+
+ @Test
+ func `menu bar percent automatic falls through after recurring perplexity credits are exhausted`() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(
+ suiteName: "StatusItemAnimationTests-perplexity-automatic-recurring-exhausted"),
+ zaiTokenStore: NoopZaiTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .perplexity
+ settings.setMenuBarMetricPreference(.automatic, for: .perplexity)
+
+ let registry = ProviderRegistry.shared
+ if let perplexityMeta = registry.metadata[.perplexity] {
+ settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 32, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(snapshot, provider: .perplexity)
+ store._setErrorForTesting(nil, provider: .perplexity)
+
+ let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot)
+
+ #expect(window?.usedPercent == 32)
+ }
+
+ @Test
+ func `menu bar percent automatic prefers purchased perplexity credits before bonus`() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(
+ suiteName: "StatusItemAnimationTests-perplexity-automatic-purchased-before-bonus"),
+ zaiTokenStore: NoopZaiTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .perplexity
+ settings.setMenuBarMetricPreference(.automatic, for: .perplexity)
+
+ let registry = ProviderRegistry.shared
+ if let perplexityMeta = registry.metadata[.perplexity] {
+ settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(snapshot, provider: .perplexity)
+ store._setErrorForTesting(nil, provider: .perplexity)
+
+ let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot)
+
+ #expect(window?.usedPercent == 45)
+ }
+
+ @Test
+ func `menu bar percent primary preference stays on recurring perplexity credits`() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(
+ suiteName: "StatusItemAnimationTests-perplexity-primary-recurring-exhausted"),
+ zaiTokenStore: NoopZaiTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .perplexity
+ settings.setMenuBarMetricPreference(.primary, for: .perplexity)
+
+ let registry = ProviderRegistry.shared
+ if let perplexityMeta = registry.metadata[.perplexity] {
+ settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let snapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 32, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(snapshot, provider: .perplexity)
+ store._setErrorForTesting(nil, provider: .perplexity)
+
+ let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot)
+
+ #expect(window?.usedPercent == 100)
+ }
+
+ @Test
+ func `menu bar percent tertiary preference uses purchased perplexity lane`() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "StatusItemAnimationTests-perplexity-tertiary-pref"),
+ zaiTokenStore: NoopZaiTokenStore())
+ settings.statusChecksEnabled = false
+ settings.refreshFrequency = .manual
+ settings.mergeIcons = true
+ settings.selectedMenuProvider = .perplexity
+ settings.setMenuBarMetricPreference(.tertiary, for: .perplexity)
+
+ let registry = ProviderRegistry.shared
+ if let perplexityMeta = registry.metadata[.perplexity] {
+ settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+ let controller = StatusItemController(
+ store: store,
+ settings: settings,
+ account: fetcher.loadAccountInfo(),
+ updater: DisabledUpdaterController(),
+ preferencesSelection: PreferencesSelection(),
+ statusBar: self.makeStatusBarForTesting())
+
+ let snapshot = UsageSnapshot(
+ primary: nil,
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 28, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(snapshot, provider: .perplexity)
+ store._setErrorForTesting(nil, provider: .perplexity)
+
+ let window = controller.menuBarMetricWindow(for: .perplexity, snapshot: snapshot)
+
+ #expect(window?.usedPercent == 28)
+ }
+
@Test
func `menu bar percent tertiary preference uses api lane for cursor`() {
let settings = SettingsStore(
diff --git a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
index 0507a60fe..2df1afef9 100644
--- a/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
+++ b/Tests/CodexBarTests/StatusItemControllerMenuTests.swift
@@ -7,12 +7,14 @@ struct StatusItemControllerMenuTests {
private func makeSnapshot(
primary: RateWindow?,
secondary: RateWindow?,
+ tertiary: RateWindow? = nil,
providerCost: ProviderCostSnapshot? = nil)
-> UsageSnapshot
{
UsageSnapshot(
primary: primary,
secondary: secondary,
+ tertiary: tertiary,
providerCost: providerCost,
updatedAt: Date())
}
@@ -78,6 +80,21 @@ struct StatusItemControllerMenuTests {
#expect(percent == 0)
}
+ @Test
+ func `perplexity switcher falls back after recurring credits are exhausted`() {
+ let primary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil)
+ let secondary = RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil)
+ let tertiary = RateWindow(usedPercent: 24, windowMinutes: nil, resetsAt: nil, resetDescription: nil)
+ let snapshot = self.makeSnapshot(primary: primary, secondary: secondary, tertiary: tertiary)
+
+ let percent = StatusItemController.switcherWeeklyMetricPercent(
+ for: .perplexity,
+ snapshot: snapshot,
+ showUsed: false)
+
+ #expect(percent == 76)
+ }
+
@Test
func `open router brand fallback enabled when no key limit configured`() {
let snapshot = OpenRouterUsageSnapshot(
diff --git a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift
index 190c00892..217e28da3 100644
--- a/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift
+++ b/Tests/CodexBarTests/UsageStoreHighestUsageTests.swift
@@ -309,6 +309,162 @@ struct UsageStoreHighestUsageTests {
#expect(highest?.usedPercent == 95)
}
+ @Test
+ func `automatic metric keeps perplexity in highest usage when purchased credits remain`() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-purchased"),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.refreshFrequency = .manual
+ settings.statusChecksEnabled = false
+ settings.setMenuBarMetricPreference(.automatic, for: .perplexity)
+
+ let registry = ProviderRegistry.shared
+ if let codexMeta = registry.metadata[.codex] {
+ settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
+ }
+ if let perplexityMeta = registry.metadata[.perplexity] {
+ settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+
+ let codexSnapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 15, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ updatedAt: Date())
+ let perplexitySnapshot = UsageSnapshot(
+ primary: nil,
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(codexSnapshot, provider: .codex)
+ store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity)
+
+ let highest = store.providerWithHighestUsage()
+ #expect(highest?.provider == .perplexity)
+ #expect(highest?.usedPercent == 45)
+ }
+
+ @Test
+ func `automatic metric ignores exhausted recurring perplexity lane when fallback remains`() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-recurring-exhausted"),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.refreshFrequency = .manual
+ settings.statusChecksEnabled = false
+ settings.setMenuBarMetricPreference(.automatic, for: .perplexity)
+
+ let registry = ProviderRegistry.shared
+ if let codexMeta = registry.metadata[.codex] {
+ settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
+ }
+ if let perplexityMeta = registry.metadata[.perplexity] {
+ settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+
+ let codexSnapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ updatedAt: Date())
+ let perplexitySnapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(codexSnapshot, provider: .codex)
+ store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity)
+
+ let highest = store.providerWithHighestUsage()
+ #expect(highest?.provider == .perplexity)
+ #expect(highest?.usedPercent == 40)
+ }
+
+ @Test
+ func `automatic metric prefers purchased perplexity credits before bonus in highest usage`() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-purchased-before-bonus"),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.refreshFrequency = .manual
+ settings.statusChecksEnabled = false
+ settings.setMenuBarMetricPreference(.automatic, for: .perplexity)
+
+ let registry = ProviderRegistry.shared
+ if let codexMeta = registry.metadata[.codex] {
+ settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
+ }
+ if let perplexityMeta = registry.metadata[.perplexity] {
+ settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+
+ let codexSnapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ updatedAt: Date())
+ let perplexitySnapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 45, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(codexSnapshot, provider: .codex)
+ store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity)
+
+ let highest = store.providerWithHighestUsage()
+ #expect(highest?.provider == .perplexity)
+ #expect(highest?.usedPercent == 45)
+ }
+
+ @Test
+ func `primary metric keeps exhausted recurring perplexity lane in highest usage selection`() {
+ let settings = SettingsStore(
+ configStore: testConfigStore(suiteName: "UsageStoreHighestUsageTests-perplexity-primary-exhausted"),
+ zaiTokenStore: NoopZaiTokenStore(),
+ syntheticTokenStore: NoopSyntheticTokenStore())
+ settings.refreshFrequency = .manual
+ settings.statusChecksEnabled = false
+ settings.setMenuBarMetricPreference(.primary, for: .perplexity)
+
+ let registry = ProviderRegistry.shared
+ if let codexMeta = registry.metadata[.codex] {
+ settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true)
+ }
+ if let perplexityMeta = registry.metadata[.perplexity] {
+ settings.setProviderEnabled(provider: .perplexity, metadata: perplexityMeta, enabled: true)
+ }
+
+ let fetcher = UsageFetcher()
+ let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
+
+ let codexSnapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: nil,
+ updatedAt: Date())
+ let perplexitySnapshot = UsageSnapshot(
+ primary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ secondary: RateWindow(usedPercent: 100, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ tertiary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil),
+ updatedAt: Date())
+
+ store._setSnapshotForTesting(codexSnapshot, provider: .codex)
+ store._setSnapshotForTesting(perplexitySnapshot, provider: .perplexity)
+
+ let highest = store.providerWithHighestUsage()
+ #expect(highest?.provider == .codex)
+ #expect(highest?.usedPercent == 25)
+ }
+
@Test
func `automatic metric excludes cursor when all opus lanes are exhausted`() {
let settings = SettingsStore(