chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:33 +08:00
commit fc4fcbab58
1848 changed files with 472303 additions and 0 deletions
@@ -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 (~5001000), 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)
}
}