466 lines
17 KiB
Swift
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)
|
|
}
|
|
}
|