chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[..<separator]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = String(trimmed[trimmed.index(after: separator)...]).trimmingCharacters(
|
||||
in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty, !value.isEmpty else { continue }
|
||||
cookies.append((name: key, value: value))
|
||||
}
|
||||
return self.extractSessionCookie(from: cookies)
|
||||
}
|
||||
|
||||
private static func extractSessionCookie(from cookies: [(name: String, value: String)])
|
||||
-> PerplexityCookieOverride? {
|
||||
var cookieMap: [String: (name: String, value: String)] = [:]
|
||||
var chunkedCookies: [String: [Int: (name: String, value: String)]] = [:]
|
||||
|
||||
for cookie in cookies {
|
||||
let loweredName = cookie.name.lowercased()
|
||||
cookieMap[loweredName] = cookie
|
||||
|
||||
for expected in self.supportedSessionCookieNames {
|
||||
let loweredExpected = expected.lowercased()
|
||||
let prefix = "\(loweredExpected)."
|
||||
guard loweredName.hasPrefix(prefix) else { continue }
|
||||
let suffix = String(loweredName.dropFirst(prefix.count))
|
||||
guard let index = Int(suffix) else { continue }
|
||||
chunkedCookies[loweredExpected, default: [:]][index] = cookie
|
||||
}
|
||||
}
|
||||
|
||||
for expected in self.supportedSessionCookieNames {
|
||||
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[..<suffixStart])
|
||||
return PerplexityCookieOverride(name: baseName, token: tokenParts.joined())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import SweetCookieKit
|
||||
|
||||
public enum PerplexityCookieImporter {
|
||||
private static let importSessionCacheTTL: TimeInterval = 5
|
||||
private static let importSessionCache = ImportSessionCache(ttl: importSessionCacheTTL)
|
||||
private static let log = CodexBarLog.logger(LogCategories.perplexityCookie)
|
||||
private static let cookieClient = BrowserCookieClient()
|
||||
private static let cookieDomains = ["www.perplexity.ai", "perplexity.ai"]
|
||||
private static let cookieImportOrder: BrowserCookieImportOrder =
|
||||
ProviderDefaults.metadata[.perplexity]?.browserCookieOrder ?? Browser.defaultImportOrder
|
||||
nonisolated(unsafe) static var importSessionOverrideForTesting:
|
||||
((BrowserDetection, ((String) -> Void)?) throws -> SessionInfo)?
|
||||
nonisolated(unsafe) static var importSessionsOverrideForTesting:
|
||||
((BrowserDetection, ((String) -> Void)?) throws -> [SessionInfo])?
|
||||
|
||||
public struct SessionInfo: Sendable {
|
||||
public let cookies: [HTTPCookie]
|
||||
public let sourceLabel: String
|
||||
|
||||
public init(cookies: [HTTPCookie], sourceLabel: String) {
|
||||
self.cookies = cookies
|
||||
self.sourceLabel = sourceLabel
|
||||
}
|
||||
|
||||
public var sessionCookie: PerplexityCookieOverride? {
|
||||
PerplexityCookieHeader.sessionCookie(from: self.cookies)
|
||||
}
|
||||
|
||||
public var sessionToken: String? {
|
||||
self.sessionCookie?.token
|
||||
}
|
||||
}
|
||||
|
||||
public static func importSessions(
|
||||
browserDetection: BrowserDetection = BrowserDetection(),
|
||||
logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
|
||||
{
|
||||
if let cached = self.cachedImportSessions() {
|
||||
return cached
|
||||
}
|
||||
if let override = self.importSessionsOverrideForTesting {
|
||||
let sessions = try override(browserDetection, logger)
|
||||
self.storeImportSessions(sessions)
|
||||
return sessions
|
||||
}
|
||||
if let override = self.importSessionOverrideForTesting {
|
||||
let session = try override(browserDetection, logger)
|
||||
let sessions = [session]
|
||||
self.storeImportSessions(sessions)
|
||||
return sessions
|
||||
}
|
||||
|
||||
var sessions: [SessionInfo] = []
|
||||
let candidates = self.cookieImportOrder.cookieImportCandidates(using: browserDetection)
|
||||
for browserSource in candidates {
|
||||
do {
|
||||
let perSource = try self.importSessions(from: browserSource, logger: logger)
|
||||
sessions.append(contentsOf: perSource)
|
||||
} catch {
|
||||
BrowserCookieAccessGate.recordIfNeeded(error)
|
||||
self.emit(
|
||||
"\(browserSource.displayName) cookie import failed: \(error.localizedDescription)",
|
||||
logger: logger)
|
||||
}
|
||||
}
|
||||
|
||||
guard !sessions.isEmpty else {
|
||||
throw PerplexityCookieImportError.noCookies
|
||||
}
|
||||
self.storeImportSessions(sessions)
|
||||
return sessions
|
||||
}
|
||||
|
||||
public static func importSessions(
|
||||
from browserSource: Browser,
|
||||
logger: ((String) -> Void)? = nil) throws -> [SessionInfo]
|
||||
{
|
||||
let query = BrowserCookieQuery(domains: self.cookieDomains)
|
||||
let log: (String) -> Void = { msg in self.emit(msg, logger: logger) }
|
||||
let sources = try Self.cookieClient.codexBarRecords(
|
||||
matching: query,
|
||||
in: browserSource,
|
||||
logger: log)
|
||||
|
||||
var sessions: [SessionInfo] = []
|
||||
let grouped = Dictionary(grouping: sources, by: { $0.store.profile.id })
|
||||
let sortedGroups = grouped.values.sorted { lhs, rhs in
|
||||
self.mergedLabel(for: lhs) < self.mergedLabel(for: rhs)
|
||||
}
|
||||
|
||||
for group in sortedGroups where !group.isEmpty {
|
||||
let label = self.mergedLabel(for: group)
|
||||
let mergedRecords = self.mergeRecords(group)
|
||||
guard !mergedRecords.isEmpty else { continue }
|
||||
let httpCookies = BrowserCookieClient.makeHTTPCookies(mergedRecords, origin: query.origin)
|
||||
guard !httpCookies.isEmpty else { continue }
|
||||
|
||||
let session = SessionInfo(cookies: httpCookies, sourceLabel: label)
|
||||
guard let sessionCookie = session.sessionCookie else {
|
||||
continue
|
||||
}
|
||||
|
||||
log("Found \(sessionCookie.name) cookie in \(label)")
|
||||
sessions.append(session)
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
public static func importSession(
|
||||
browserDetection: BrowserDetection = BrowserDetection(),
|
||||
logger: ((String) -> Void)? = nil) throws -> SessionInfo
|
||||
{
|
||||
let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger)
|
||||
guard let first = sessions.first else {
|
||||
throw PerplexityCookieImportError.noCookies
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
public static func hasSession(
|
||||
browserDetection: BrowserDetection = BrowserDetection(),
|
||||
logger: ((String) -> Void)? = nil) -> Bool
|
||||
{
|
||||
do {
|
||||
_ = try self.importSession(browserDetection: browserDetection, logger: logger)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func invalidateImportSessionCache() {
|
||||
self.importSessionCache.invalidate()
|
||||
}
|
||||
|
||||
private static func emit(_ message: String, logger: ((String) -> Void)?) {
|
||||
logger?("[perplexity-cookie] \(message)")
|
||||
self.log.debug(message)
|
||||
}
|
||||
|
||||
private static func cachedImportSessions(now: Date = Date()) -> [SessionInfo]? {
|
||||
self.importSessionCache.load(now: now)
|
||||
}
|
||||
|
||||
private static func storeImportSessions(_ sessions: [SessionInfo], now: Date = Date()) {
|
||||
self.importSessionCache.store(sessions, now: now)
|
||||
}
|
||||
|
||||
private static func mergedLabel(for sources: [BrowserCookieStoreRecords]) -> String {
|
||||
guard let base = sources.map(\.label).min() else { return "Unknown" }
|
||||
if base.hasSuffix(" (Network)") {
|
||||
return String(base.dropLast(" (Network)".count))
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
private static func mergeRecords(_ sources: [BrowserCookieStoreRecords]) -> [BrowserCookieRecord] {
|
||||
let sortedSources = sources.sorted { lhs, rhs in
|
||||
self.storePriority(lhs.store.kind) < self.storePriority(rhs.store.kind)
|
||||
}
|
||||
var mergedByKey: [String: BrowserCookieRecord] = [:]
|
||||
for source in sortedSources {
|
||||
for record in source.records {
|
||||
let key = self.recordKey(record)
|
||||
if let existing = mergedByKey[key] {
|
||||
if self.shouldReplace(existing: existing, candidate: record) {
|
||||
mergedByKey[key] = record
|
||||
}
|
||||
} else {
|
||||
mergedByKey[key] = record
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array(mergedByKey.values)
|
||||
}
|
||||
|
||||
private static func storePriority(_ kind: BrowserCookieStoreKind) -> Int {
|
||||
switch kind {
|
||||
case .network: 0
|
||||
case .primary: 1
|
||||
case .safari: 2
|
||||
}
|
||||
}
|
||||
|
||||
private static func recordKey(_ record: BrowserCookieRecord) -> String {
|
||||
"\(record.name)|\(record.domain)|\(record.path)"
|
||||
}
|
||||
|
||||
private static func shouldReplace(existing: BrowserCookieRecord, candidate: BrowserCookieRecord) -> Bool {
|
||||
switch (existing.expires, candidate.expires) {
|
||||
case let (lhs?, rhs?): rhs > lhs
|
||||
case (nil, .some): true
|
||||
case (.some, nil): false
|
||||
case (nil, nil): false
|
||||
}
|
||||
}
|
||||
|
||||
private final class ImportSessionCache: @unchecked Sendable {
|
||||
private let ttl: TimeInterval
|
||||
private let lock = NSLock()
|
||||
private var entry: (sessions: [SessionInfo], expiresAt: Date)?
|
||||
|
||||
init(ttl: TimeInterval) {
|
||||
self.ttl = ttl
|
||||
}
|
||||
|
||||
func load(now: Date) -> [SessionInfo]? {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
guard let entry = self.entry else { return nil }
|
||||
guard entry.expiresAt > now else {
|
||||
self.entry = nil
|
||||
return nil
|
||||
}
|
||||
return entry.sessions
|
||||
}
|
||||
|
||||
func store(_ sessions: [SessionInfo], now: Date) {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
self.entry = (sessions: sessions, expiresAt: now.addingTimeInterval(self.ttl))
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
self.entry = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PerplexityCookieImportError: LocalizedError {
|
||||
case noCookies
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCookies:
|
||||
"No Perplexity session cookies found in browsers. Please log into perplexity.ai."
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import Foundation
|
||||
|
||||
public enum PerplexityProviderDescriptor {
|
||||
public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
public enum PerplexitySettingsReader {
|
||||
public static func sessionCookieOverride(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment) -> PerplexityCookieOverride?
|
||||
{
|
||||
let raw = environment["PERPLEXITY_SESSION_TOKEN"]
|
||||
?? environment["perplexity_session_token"]
|
||||
if let token = self.cleaned(raw) { return PerplexityCookieHeader.override(from: token) }
|
||||
|
||||
// PERPLEXITY_COOKIE may be a full Cookie header string; preserve the matching session cookie name.
|
||||
if let cookieRaw = environment["PERPLEXITY_COOKIE"] {
|
||||
return PerplexityCookieHeader.override(from: self.cleaned(cookieRaw))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func sessionToken(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment) -> String?
|
||||
{
|
||||
self.sessionCookieOverride(environment: environment)?.token
|
||||
}
|
||||
|
||||
private static func cleaned(_ raw: String?) -> String? {
|
||||
guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if (value.hasPrefix("\"") && value.hasSuffix("\"")) ||
|
||||
(value.hasPrefix("'") && value.hasSuffix("'"))
|
||||
{
|
||||
value = String(value.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
public struct PerplexityUsageFetcher: Sendable {
|
||||
private static let log = CodexBarLog.logger(LogCategories.perplexityAPI)
|
||||
private static let creditsURL =
|
||||
URL(string: "https://www.perplexity.ai/rest/billing/credits?version=2.18&source=default")!
|
||||
@TaskLocal static var fetchCreditsOverride:
|
||||
(@Sendable (String, String, Date) async throws -> PerplexityUsageSnapshot)?
|
||||
|
||||
/// Testing hook: parse a raw JSON response without making network calls.
|
||||
public static func _parseResponseForTesting(_ data: Data, now: Date = Date()) throws -> PerplexityUsageSnapshot {
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(PerplexityCreditsResponse.self, from: data)
|
||||
return PerplexityUsageSnapshot(response: decoded, now: now)
|
||||
} catch {
|
||||
throw PerplexityAPIError.parseFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public static func fetchCredits(
|
||||
sessionToken: String,
|
||||
cookieName: String = PerplexityCookieHeader.defaultSessionCookieName,
|
||||
now: Date = Date()) async throws -> PerplexityUsageSnapshot
|
||||
{
|
||||
if let override = self.fetchCreditsOverride {
|
||||
return try await override(sessionToken, cookieName, now)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: self.creditsURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 15
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(
|
||||
"\(cookieName)=\(sessionToken)",
|
||||
forHTTPHeaderField: "Cookie")
|
||||
request.setValue("https://www.perplexity.ai", forHTTPHeaderField: "Origin")
|
||||
request.setValue("https://www.perplexity.ai/account/usage", forHTTPHeaderField: "Referer")
|
||||
let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let response = try await ProviderHTTPClient.shared.response(for: request)
|
||||
let data = response.data
|
||||
guard response.statusCode == 200 else {
|
||||
let statusCode = response.statusCode
|
||||
let body = String(data: data, encoding: .utf8) ?? "<binary>"
|
||||
let truncated = body.count > 200 ? String(body.prefix(200)) + "…" : body
|
||||
Self.log.error("Perplexity API returned \(statusCode): \(truncated)")
|
||||
if statusCode == 401 || statusCode == 403 {
|
||||
throw PerplexityAPIError.invalidToken
|
||||
}
|
||||
throw PerplexityAPIError.apiError("HTTP \(statusCode)")
|
||||
}
|
||||
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(PerplexityCreditsResponse.self, from: data)
|
||||
let snapshot = PerplexityUsageSnapshot(response: decoded, now: now)
|
||||
Self.log.debug(
|
||||
"Perplexity credits parsed balance=\(snapshot.balanceCents) totalUsage=\(snapshot.totalUsageCents)")
|
||||
return snapshot
|
||||
} catch {
|
||||
let preview = String(data: data.prefix(500), encoding: .utf8) ?? "<binary>"
|
||||
Self.log.error("Perplexity parse failed: \(error) — response: \(preview)")
|
||||
throw PerplexityAPIError.parseFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user