993 lines · 37.9 KB · Swift
RawDownload
import Foundationimport SweetCookieKit#if canImport(FoundationNetworking)import FoundationNetworking#endif /// Fetches Claude usage data directly from the claude.ai API using browser session cookies.////// This approach mirrors what Claude Usage Tracker does, but automatically extracts the session key/// from browser cookies instead of requiring manual setup.////// API endpoints used:/// - `GET https://claude.ai/api/organizations` → get org UUID/// - `GET https://claude.ai/api/organizations/{org_id}/usage` → usage percentages + reset timespublic enum ClaudeWebAPIFetcher {    private static let baseURL = "https://claude.ai/api"    private static let maxProbeBytes = 200_000    #if os(macOS)    private static let cookieClient = BrowserCookieClient()    private static let cookieImportOrder: BrowserCookieImportOrder =        ProviderDefaults.metadata[.claude]?.browserCookieOrder ?? Browser.defaultImportOrder    #else    private static let cookieImportOrder: BrowserCookieImportOrder = []    #endif     public struct OrganizationInfo: Sendable {        public let id: String        public let name: String?         public init(id: String, name: String?) {            self.id = id            self.name = name        }    }     public struct SessionKeyInfo: Sendable {        public let key: String        public let sourceLabel: String        public let cookieCount: Int         public init(key: String, sourceLabel: String, cookieCount: Int) {            self.key = key            self.sourceLabel = sourceLabel            self.cookieCount = cookieCount        }    }     public enum FetchError: LocalizedError, Sendable {        case noSessionKeyFound        case invalidSessionKey        case notSupportedOnThisPlatform        case networkError(Error)        case invalidResponse        case unauthorized        case serverError(statusCode: Int)        case noOrganization        case organizationNotFound(String)         public var errorDescription: String? {            switch self {            case .noSessionKeyFound:                "No Claude session key found in browser cookies."            case .invalidSessionKey:                "Invalid Claude session key format."            case .notSupportedOnThisPlatform:                "Claude web fetching is only supported on macOS."            case let .networkError(error):                "Network error: \(error.localizedDescription)"            case .invalidResponse:                "Invalid response from Claude API."            case .unauthorized:                "Sign in to claude.ai (or refresh Claude cookies) to load usage data."            case let .serverError(code):                "Claude API error: HTTP \(code)"            case .noOrganization:                "No Claude organization found for this account."            case let .organizationNotFound(id):                "Claude organization '\(id)' was not found for this session."            }        }    }     /// Claude usage data from the API    public struct WebUsageData: Sendable {        public let sessionPercentUsed: Double        public let sessionResetsAt: Date?        public let weeklyPercentUsed: Double?        public let weeklyResetsAt: Date?        public let opusPercentUsed: Double?        public let extraRateWindows: [NamedRateWindow]        public let extraUsageCost: ProviderCostSnapshot?        public let accountOrganization: String?        public let accountEmail: String?        public let loginMethod: String?         public init(            sessionPercentUsed: Double,            sessionResetsAt: Date?,            weeklyPercentUsed: Double?,            weeklyResetsAt: Date?,            opusPercentUsed: Double?,            extraRateWindows: [NamedRateWindow],            extraUsageCost: ProviderCostSnapshot?,            accountOrganization: String?,            accountEmail: String?,            loginMethod: String?)        {            self.sessionPercentUsed = sessionPercentUsed            self.sessionResetsAt = sessionResetsAt            self.weeklyPercentUsed = weeklyPercentUsed            self.weeklyResetsAt = weeklyResetsAt            self.opusPercentUsed = opusPercentUsed            self.extraRateWindows = extraRateWindows            self.extraUsageCost = extraUsageCost            self.accountOrganization = accountOrganization            self.accountEmail = accountEmail            self.loginMethod = loginMethod        }    }     public struct ProbeResult: Sendable {        public let url: String        public let statusCode: Int?        public let contentType: String?        public let topLevelKeys: [String]        public let emails: [String]        public let planHints: [String]        public let notableFields: [String]        public let bodyPreview: String?    }     // MARK: - Public API     #if os(macOS)     /// Attempts to fetch Claude usage data using cookies extracted from browsers.    /// Tries browser cookies using the standard import order.    public static func fetchUsage(        browserDetection: BrowserDetection,        targetOrganizationID: String? = nil,        logger: ((String) -> Void)? = nil) async throws -> WebUsageData    {        let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") }         if let cached = CookieHeaderCache.load(provider: .claude),           !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty        {            log("Using cached cookie header from \(cached.sourceLabel)")            do {                return try await self.fetchUsage(                    cookieHeader: cached.cookieHeader,                    targetOrganizationID: targetOrganizationID,                    logger: log)            } catch let error as FetchError {                switch error {                case .unauthorized, .noSessionKeyFound, .invalidSessionKey:                    CookieHeaderCache.clear(provider: .claude)                default:                    throw error                }            } catch {                throw error            }        }         let sessionInfo = try extractSessionKeyInfo(browserDetection: browserDetection, logger: log)        log("Found session key (\(sessionInfo.cookieCount) cookies)")         let usage = try await self.fetchUsage(            using: sessionInfo,            targetOrganizationID: targetOrganizationID,            logger: log)        CookieHeaderCache.store(            provider: .claude,            cookieHeader: "sessionKey=\(sessionInfo.key)",            sourceLabel: sessionInfo.sourceLabel)        return usage    }     public static func fetchUsage(        cookieHeader: String,        targetOrganizationID: String? = nil,        logger: ((String) -> Void)? = nil) async throws -> WebUsageData    {        let log: (String) -> Void = { msg in logger?("[claude-web] \(msg)") }        let sessionInfo = try self.sessionKeyInfo(cookieHeader: cookieHeader)        log("Using manual session key (\(sessionInfo.cookieCount) cookies)")        return try await self.fetchUsage(            using: sessionInfo,            targetOrganizationID: targetOrganizationID,            logger: log)    }     public static func fetchUsage(        using sessionKeyInfo: SessionKeyInfo,        targetOrganizationID: String? = nil,        logger: ((String) -> Void)? = nil) async throws -> WebUsageData    {        let log: (String) -> Void = { msg in logger?(msg) }        let sessionKey = sessionKeyInfo.key         // Fetch organization info        let organization = try await fetchOrganizationInfo(            sessionKey: sessionKey,            targetOrganizationID: targetOrganizationID,            logger: log)        log("Organization resolved")         var usage = try await fetchUsageData(orgId: organization.id, sessionKey: sessionKey, logger: log)        if usage.extraUsageCost == nil,           let extra = await ClaudeWebExtraUsageCost.fetch(               baseURL: Self.baseURL,               orgId: organization.id,               sessionKey: sessionKey,               logger: log)        {            usage = WebUsageData(                sessionPercentUsed: usage.sessionPercentUsed,                sessionResetsAt: usage.sessionResetsAt,                weeklyPercentUsed: usage.weeklyPercentUsed,                weeklyResetsAt: usage.weeklyResetsAt,                opusPercentUsed: usage.opusPercentUsed,                extraRateWindows: usage.extraRateWindows,                extraUsageCost: extra,                accountOrganization: usage.accountOrganization,                accountEmail: usage.accountEmail,                loginMethod: usage.loginMethod)        }        if let account = await fetchAccountInfo(sessionKey: sessionKey, orgId: organization.id, logger: log) {            usage = WebUsageData(                sessionPercentUsed: usage.sessionPercentUsed,                sessionResetsAt: usage.sessionResetsAt,                weeklyPercentUsed: usage.weeklyPercentUsed,                weeklyResetsAt: usage.weeklyResetsAt,                opusPercentUsed: usage.opusPercentUsed,                extraRateWindows: usage.extraRateWindows,                extraUsageCost: usage.extraUsageCost,                accountOrganization: usage.accountOrganization,                accountEmail: account.email,                loginMethod: account.loginMethod)        }        if usage.accountOrganization == nil, let name = organization.name {            usage = WebUsageData(                sessionPercentUsed: usage.sessionPercentUsed,                sessionResetsAt: usage.sessionResetsAt,                weeklyPercentUsed: usage.weeklyPercentUsed,                weeklyResetsAt: usage.weeklyResetsAt,                opusPercentUsed: usage.opusPercentUsed,                extraRateWindows: usage.extraRateWindows,                extraUsageCost: usage.extraUsageCost,                accountOrganization: name,                accountEmail: usage.accountEmail,                loginMethod: usage.loginMethod)        }        return usage    }     /// Probes a list of endpoints using the current claude.ai session cookies.    /// - Parameters:    ///   - endpoints: Absolute URLs or "/api/..." paths. Supports "{orgId}" placeholder.    ///   - includePreview: When true, includes a truncated response preview in results.    public static func probeEndpoints(        _ endpoints: [String],        browserDetection: BrowserDetection,        includePreview: Bool = false,        logger: ((String) -> Void)? = nil) async throws -> [ProbeResult]    {        let log: (String) -> Void = { msg in logger?("[claude-probe] \(msg)") }        let sessionInfo = try extractSessionKeyInfo(browserDetection: browserDetection, logger: log)        let sessionKey = sessionInfo.key        let organization = try? await fetchOrganizationInfo(sessionKey: sessionKey, logger: log)        let expanded = endpoints.map { endpoint -> String in            var url = endpoint            if let orgId = organization?.id {                url = url.replacingOccurrences(of: "{orgId}", with: orgId)            }            if url.hasPrefix("/") {                url = "https://claude.ai\(url)"            }            return url        }         var results: [ProbeResult] = []        results.reserveCapacity(expanded.count)         for endpoint in expanded {            guard let url = URL(string: endpoint) else { continue }            var request = URLRequest(url: url)            request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")            request.setValue("application/json, text/html;q=0.9, */*;q=0.8", forHTTPHeaderField: "Accept")            request.httpMethod = "GET"            request.timeoutInterval = 20             do {                let (data, response) = try await ProviderHTTPClient.shared.data(for: request)                let http = response as? HTTPURLResponse                let contentType = http?.allHeaderFields["Content-Type"] as? String                let truncated = data.prefix(Self.maxProbeBytes)                let body = String(data: truncated, encoding: .utf8) ?? ""                 let parsed = Self.parseProbeBody(data: data, fallbackText: body, contentType: contentType)                let preview = includePreview ? parsed.preview : nil                 results.append(ProbeResult(                    url: endpoint,                    statusCode: http?.statusCode,                    contentType: contentType,                    topLevelKeys: parsed.keys,                    emails: parsed.emails,                    planHints: parsed.planHints,                    notableFields: parsed.notableFields,                    bodyPreview: preview))            } catch {                results.append(ProbeResult(                    url: endpoint,                    statusCode: nil,                    contentType: nil,                    topLevelKeys: [],                    emails: [],                    planHints: [],                    notableFields: [],                    bodyPreview: "Error: \(error.localizedDescription)"))            }        }         return results    }     /// Checks if we can find a Claude session key in browser cookies without making API calls.    public static func hasSessionKey(browserDetection: BrowserDetection, logger: ((String) -> Void)? = nil) -> Bool {        if let cached = CookieHeaderCache.load(provider: .claude),           self.hasSessionKey(cookieHeader: cached.cookieHeader)        {            return true        }        do {            _ = try self.sessionKeyInfo(browserDetection: browserDetection, logger: logger)            return true        } catch {            return false        }    }     public static func hasSessionKey(cookieHeader: String?) -> Bool {        guard let cookieHeader else { return false }        return (try? self.sessionKeyInfo(cookieHeader: cookieHeader)) != nil    }     public static func sessionKeyInfo(        browserDetection: BrowserDetection,        logger: ((String) -> Void)? = nil) throws -> SessionKeyInfo    {        try self.extractSessionKeyInfo(browserDetection: browserDetection, logger: logger)    }     public static func sessionKeyInfo(cookieHeader: String) throws -> SessionKeyInfo {        let pairs = CookieHeaderNormalizer.pairs(from: cookieHeader)        if let sessionKey = self.findSessionKey(in: pairs) {            return SessionKeyInfo(                key: sessionKey,                sourceLabel: "Manual",                cookieCount: pairs.count)        }        throw FetchError.noSessionKeyFound    }     // MARK: - Session Key Extraction     private static func extractSessionKeyInfo(        browserDetection: BrowserDetection,        logger: ((String) -> Void)? = nil) throws -> SessionKeyInfo    {        let log: (String) -> Void = { msg in logger?(msg) }         let cookieDomains = ["claude.ai"]         // Filter to cookie-eligible browsers to avoid unnecessary keychain prompts        let installedBrowsers = Self.cookieImportOrder.cookieImportCandidates(using: browserDetection)        for browserSource in installedBrowsers {            do {                let query = BrowserCookieQuery(domains: cookieDomains)                let sources = try Self.cookieClient.codexBarRecords(                    matching: query,                    in: browserSource,                    logger: log)                for source in sources {                    if let sessionKey = findSessionKey(in: source.records.map { record in                        (name: record.name, value: record.value)                    }) {                        log("Found sessionKey in \(source.label)")                        return SessionKeyInfo(                            key: sessionKey,                            sourceLabel: source.label,                            cookieCount: source.records.count)                    }                }            } catch {                BrowserCookieAccessGate.recordIfNeeded(error)                log("\(browserSource.displayName) cookie load failed: \(error.localizedDescription)")            }        }         throw FetchError.noSessionKeyFound    }     private static func findSessionKey(in cookies: [(name: String, value: String)]) -> String? {        for cookie in cookies where cookie.name == "sessionKey" {            let value = cookie.value.trimmingCharacters(in: .whitespacesAndNewlines)            // Validate it looks like a Claude session key            if value.hasPrefix("sk-ant-") {                return value            }        }        return nil    }     // MARK: - API Calls     private static func fetchOrganizationInfo(        sessionKey: String,        targetOrganizationID: String? = nil,        logger: ((String) -> Void)? = nil) async throws -> OrganizationInfo    {        let url = URL(string: "\(baseURL)/organizations")!        var request = URLRequest(url: url)        request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")        request.setValue("application/json", forHTTPHeaderField: "Accept")        request.httpMethod = "GET"        request.timeoutInterval = 15         let (data, response) = try await ProviderHTTPClient.shared.data(for: request)         guard let httpResponse = response as? HTTPURLResponse else {            throw FetchError.invalidResponse        }         logger?("Organizations API status: \(httpResponse.statusCode)")         switch httpResponse.statusCode {        case 200:            return try self.parseOrganizationResponse(data, targetOrganizationID: targetOrganizationID)        case 401, 403:            throw FetchError.unauthorized        default:            throw FetchError.serverError(statusCode: httpResponse.statusCode)        }    }     private static func fetchUsageData(        orgId: String,        sessionKey: String,        logger: ((String) -> Void)? = nil) async throws -> WebUsageData    {        let url = URL(string: "\(baseURL)/organizations/\(orgId)/usage")!        var request = URLRequest(url: url)        request.setValue("sessionKey=\(sessionKey)", forHTTPHeaderField: "Cookie")        request.setValue("application/json", forHTTPHeaderField: "Accept")        request.httpMethod = "GET"        request.timeoutInterval = 15         let (data, response) = try await ProviderHTTPClient.shared.data(for: request)         guard let httpResponse = response as? HTTPURLResponse else {            throw FetchError.invalidResponse        }         logger?("Usage API status: \(httpResponse.statusCode)")         switch httpResponse.statusCode {        case 200:            return try self.parseUsageResponse(data, logger: logger)        case 401, 403:            throw FetchError.unauthorized        default:            throw FetchError.serverError(statusCode: httpResponse.statusCode)        }    }     private static func parseUsageResponse(_ data: Data, logger: ((String) -> Void)? = nil) throws -> WebUsageData {        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {            throw FetchError.invalidResponse        }         // Parse five_hour (session) usage        var sessionPercent: Double?        var sessionResets: Date?        if let fiveHour = json["five_hour"] as? [String: Any] {            if let utilization = fiveHour["utilization"] as? Int {                sessionPercent = Double(utilization)            }            if let resetsAt = fiveHour["resets_at"] as? String {                sessionResets = self.parseISO8601Date(resetsAt)            }        }        // Enterprise/credit-based accounts return null for five_hour; treat as 0% rather than an error.        let resolvedSessionPercent = sessionPercent ?? 0.0         // Parse seven_day (weekly) usage        var weeklyPercent: Double?        var weeklyResets: Date?

Lines 1–500 of 993.