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) ?? "" 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) ?? "" Self.log.error("Perplexity parse failed: \(error) — response: \(preview)") throw PerplexityAPIError.parseFailed(error.localizedDescription) } } }