Files
2026-07-13 12:22:33 +08:00

466 lines
17 KiB
Swift

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[..<limitIndex])..."
}
private static func parseDate(_ dateString: String) -> 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)
}
}