import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public struct WarpUsageSnapshot: Sendable { public let requestLimit: Int public let requestsUsed: Int public let nextRefreshTime: Date? public let isUnlimited: Bool public let updatedAt: Date // Combined bonus credits (user-level + workspace-level) public let bonusCreditsRemaining: Int public let bonusCreditsTotal: Int // Earliest expiring bonus batch with remaining credits public let bonusNextExpiration: Date? public let bonusNextExpirationRemaining: Int public init( requestLimit: Int, requestsUsed: Int, nextRefreshTime: Date?, isUnlimited: Bool, updatedAt: Date, bonusCreditsRemaining: Int = 0, bonusCreditsTotal: Int = 0, bonusNextExpiration: Date? = nil, bonusNextExpirationRemaining: Int = 0) { self.requestLimit = requestLimit self.requestsUsed = requestsUsed self.nextRefreshTime = nextRefreshTime self.isUnlimited = isUnlimited self.updatedAt = updatedAt self.bonusCreditsRemaining = bonusCreditsRemaining self.bonusCreditsTotal = bonusCreditsTotal self.bonusNextExpiration = bonusNextExpiration self.bonusNextExpirationRemaining = bonusNextExpirationRemaining } public func toUsageSnapshot() -> UsageSnapshot { let usedPercent: Double = if self.isUnlimited { 0 } else if self.requestLimit > 0 { min(100, max(0, Double(self.requestsUsed) / Double(self.requestLimit) * 100)) } else { 0 } let resetDescription: String? = if self.isUnlimited { "Unlimited" } else { "\(self.requestsUsed)/\(self.requestLimit) credits" } let primary = RateWindow( usedPercent: usedPercent, windowMinutes: nil, resetsAt: self.isUnlimited ? nil : self.nextRefreshTime, resetDescription: resetDescription) // Secondary: combined bonus/add-on credits (user + workspace) var bonusDetail: String? if self.bonusCreditsRemaining > 0, let expiry = self.bonusNextExpiration, self.bonusNextExpirationRemaining > 0 { let dateText = expiry.formatted(date: .abbreviated, time: .shortened) bonusDetail = "\(self.bonusNextExpirationRemaining) credits expires on \(dateText)" } let hasBonusWindow = self.bonusCreditsTotal > 0 || self.bonusCreditsRemaining > 0 || (bonusDetail?.isEmpty == false) let secondary: RateWindow? if hasBonusWindow { let bonusUsedPercent: Double = { guard self.bonusCreditsTotal > 0 else { return self.bonusCreditsRemaining > 0 ? 0 : 100 } let used = self.bonusCreditsTotal - self.bonusCreditsRemaining return min(100, max(0, Double(used) / Double(self.bonusCreditsTotal) * 100)) }() secondary = RateWindow( usedPercent: bonusUsedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: bonusDetail) } else { secondary = nil } let identity = ProviderIdentitySnapshot( providerID: .warp, accountEmail: nil, accountOrganization: nil, loginMethod: nil) return UsageSnapshot( primary: primary, secondary: secondary, tertiary: nil, providerCost: nil, updatedAt: self.updatedAt, identity: identity) } } public enum WarpUsageError: LocalizedError, Sendable { case missingCredentials case networkError(String) case apiError(Int, String) case parseFailed(String) public var errorDescription: String? { switch self { case .missingCredentials: "Missing Warp API key." case let .networkError(message): "Warp network error: \(message)" case let .apiError(code, message): "Warp API error (\(code)): \(message)" case let .parseFailed(message): "Failed to parse Warp response: \(message)" } } } public struct WarpUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.warpUsage) private static let apiURL = URL(string: "https://app.warp.dev/graphql/v2?op=GetRequestLimitInfo")! private static let clientID = "warp-app" /// Warp's GraphQL endpoint is fronted by an edge limiter that returns HTTP 429 ("Rate exceeded.") /// unless the User-Agent matches the official client pattern (e.g. "Warp/1.0"). private static let userAgent = "Warp/1.0" private static let graphQLQuery = """ query GetRequestLimitInfo($requestContext: RequestContext!) { user(requestContext: $requestContext) { __typename ... on UserOutput { user { requestLimitInfo { isUnlimited nextRefreshTime requestLimit requestsUsedSinceLastRefresh } bonusGrants { requestCreditsGranted requestCreditsRemaining expiration } workspaces { bonusGrantsInfo { grants { requestCreditsGranted requestCreditsRemaining expiration } } } } } } } """ public static func fetchUsage(apiKey: String) async throws -> WarpUsageSnapshot { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw WarpUsageError.missingCredentials } let osVersion = ProcessInfo.processInfo.operatingSystemVersion let osVersionString = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" var request = URLRequest(url: self.apiURL) request.httpMethod = "POST" request.timeoutInterval = 15 request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(self.clientID, forHTTPHeaderField: "x-warp-client-id") request.setValue("macOS", forHTTPHeaderField: "x-warp-os-category") request.setValue("macOS", forHTTPHeaderField: "x-warp-os-name") request.setValue(osVersionString, forHTTPHeaderField: "x-warp-os-version") request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent") let variables: [String: Any] = [ "requestContext": [ "clientContext": [:] as [String: Any], "osContext": [ "category": "macOS", "name": "macOS", "version": osVersionString, ] as [String: Any], ] as [String: Any], ] let body: [String: Any] = [ "query": self.graphQLQuery, "variables": variables, "operationName": "GetRequestLimitInfo", ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let response = try await ProviderHTTPClient.shared.response(for: request) let data = response.data guard response.statusCode == 200 else { let summary = Self.apiErrorSummary(statusCode: response.statusCode, data: data) Self.log.error("Warp API returned \(response.statusCode): \(summary)") throw WarpUsageError.apiError(response.statusCode, summary) } do { let snapshot = try Self.parseResponse(data: data) Self.log.debug( "Warp usage parsed requestLimit=\(snapshot.requestLimit) requestsUsed=\(snapshot.requestsUsed) " + "bonusRemaining=\(snapshot.bonusCreditsRemaining) bonusTotal=\(snapshot.bonusCreditsTotal) " + "isUnlimited=\(snapshot.isUnlimited)") return snapshot } catch { Self.log.error("Warp response parse failed bytes=\(data.count) error=\(error.localizedDescription)") throw error } } static func _parseResponseForTesting(_ data: Data) throws -> WarpUsageSnapshot { try self.parseResponse(data: data) } static func _apiErrorSummaryForTesting(statusCode: Int, data: Data) -> String { self.apiErrorSummary(statusCode: statusCode, data: data) } private static func parseResponse(data: Data) throws -> WarpUsageSnapshot { guard let root = try? JSONSerialization.jsonObject(with: data), let json = root as? [String: Any] else { throw WarpUsageError.parseFailed("Root JSON is not an object.") } if let rawErrors = json["errors"] as? [Any], !rawErrors.isEmpty { let messages = rawErrors.compactMap(Self.graphQLErrorMessage(from:)) let summary = messages.isEmpty ? "GraphQL request failed." : messages.prefix(3).joined(separator: " | ") throw WarpUsageError.apiError(200, summary) } guard let dataObj = json["data"] as? [String: Any], let userObj = dataObj["user"] as? [String: Any] else { throw WarpUsageError.parseFailed("Missing data.user in response.") } let typeName = (userObj["__typename"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) guard let innerUserObj = userObj["user"] as? [String: Any], let limitInfo = innerUserObj["requestLimitInfo"] as? [String: Any] else { if let typeName, !typeName.isEmpty, typeName != "UserOutput" { throw WarpUsageError.parseFailed("Unexpected user type '\(typeName)'.") } throw WarpUsageError.parseFailed("Unable to extract requestLimitInfo from response.") } let isUnlimited = Self.boolValue(limitInfo["isUnlimited"]) let requestLimit = self.intValue(limitInfo["requestLimit"]) let requestsUsed = self.intValue(limitInfo["requestsUsedSinceLastRefresh"]) var nextRefreshTime: Date? if let nextRefreshTimeString = limitInfo["nextRefreshTime"] as? String { nextRefreshTime = Self.parseDate(nextRefreshTimeString) } // Parse and combine bonus credits from user-level and workspace-level let bonus = Self.parseBonusCredits(from: innerUserObj) return WarpUsageSnapshot( requestLimit: requestLimit, requestsUsed: requestsUsed, nextRefreshTime: nextRefreshTime, isUnlimited: isUnlimited, updatedAt: Date(), bonusCreditsRemaining: bonus.remaining, bonusCreditsTotal: bonus.total, bonusNextExpiration: bonus.nextExpiration, bonusNextExpirationRemaining: bonus.nextExpirationRemaining) } private struct BonusGrant { let granted: Int let remaining: Int let expiration: Date? } private struct BonusSummary { let remaining: Int let total: Int let nextExpiration: Date? let nextExpirationRemaining: Int } private static func parseBonusCredits(from userObj: [String: Any]) -> BonusSummary { var grants: [BonusGrant] = [] // User-level bonus grants if let bonusGrants = userObj["bonusGrants"] as? [[String: Any]] { for grant in bonusGrants { grants.append(Self.parseBonusGrant(from: grant)) } } // Workspace-level bonus grants if let workspaces = userObj["workspaces"] as? [[String: Any]] { for workspace in workspaces { if let bonusGrantsInfo = workspace["bonusGrantsInfo"] as? [String: Any], let workspaceGrants = bonusGrantsInfo["grants"] as? [[String: Any]] { for grant in workspaceGrants { grants.append(Self.parseBonusGrant(from: grant)) } } } } let totalRemaining = grants.reduce(0) { $0 + $1.remaining } let totalGranted = grants.reduce(0) { $0 + $1.granted } let expiring = grants.compactMap { grant -> (date: Date, remaining: Int)? in guard grant.remaining > 0, let expiration = grant.expiration else { return nil } return (expiration, grant.remaining) } let nextExpiration: Date? let nextExpirationRemaining: Int if let earliest = expiring.min(by: { $0.date < $1.date }) { let earliestKey = Int(earliest.date.timeIntervalSince1970) let remaining = expiring.reduce(0) { result, item in let key = Int(item.date.timeIntervalSince1970) return result + (key == earliestKey ? item.remaining : 0) } nextExpiration = earliest.date nextExpirationRemaining = remaining } else { nextExpiration = nil nextExpirationRemaining = 0 } return BonusSummary( remaining: totalRemaining, total: totalGranted, nextExpiration: nextExpiration, nextExpirationRemaining: nextExpirationRemaining) } private static func parseBonusGrant(from grant: [String: Any]) -> BonusGrant { let granted = self.intValue(grant["requestCreditsGranted"]) let remaining = self.intValue(grant["requestCreditsRemaining"]) let expiration = (grant["expiration"] as? String).flatMap(Self.parseDate) return BonusGrant(granted: granted, remaining: remaining, expiration: expiration) } private static func intValue(_ value: Any?) -> Int { if let int = value as? Int { return int } if let num = value as? NSNumber { return num.intValue } if let text = value as? String, let int = Int(text) { return int } return 0 } private static func boolValue(_ value: Any?) -> Bool { if let bool = value as? Bool { return bool } if let number = value as? NSNumber { return number.boolValue } if let text = value as? String { let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if ["true", "1", "yes"].contains(normalized) { return true } if ["false", "0", "no"].contains(normalized) { return false } } return false } private static func graphQLErrorMessage(from value: Any) -> String? { if let message = value as? String { let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } if let dict = value as? [String: Any], let message = dict["message"] as? String { let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } return nil } private static func apiErrorSummary(statusCode: Int, data: Data) -> String { guard let root = try? JSONSerialization.jsonObject(with: data), let json = root as? [String: Any] else { if let text = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return self.compactSummaryText(text) } return "Unexpected response body (\(data.count) bytes)." } if let rawErrors = json["errors"] as? [Any], !rawErrors.isEmpty { let messages = rawErrors.compactMap(Self.graphQLErrorMessage(from:)) let joined = messages.prefix(3).joined(separator: " | ") if !joined.isEmpty { return self.compactSummaryText(joined) } } if let error = json["error"] as? String { let trimmed = error.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return self.compactSummaryText(trimmed) } } if let message = json["message"] as? String { let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return self.compactSummaryText(trimmed) } } return "HTTP \(statusCode) (\(data.count) bytes)." } private static func compactSummaryText(_ text: String, maxLength: Int = 200) -> String { let collapsed = text .components(separatedBy: .newlines) .joined(separator: " ") .trimmingCharacters(in: .whitespacesAndNewlines) if collapsed.count <= maxLength { return collapsed } let limitIndex = collapsed.index(collapsed.startIndex, offsetBy: maxLength) return "\(collapsed[.. Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: dateString) { return date } let fallback = ISO8601DateFormatter() fallback.formatOptions = [.withInternetDateTime] return fallback.date(from: dateString) } }