import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public enum ClaudeOAuthFetchError: LocalizedError, Sendable { case unauthorized case rateLimited(retryAfter: Date?) case invalidResponse case serverError(Int, String?) case networkError(Error) public var errorDescription: String? { switch self { case .unauthorized: return "Claude OAuth request unauthorized. Run `claude` to re-authenticate." case .rateLimited: return "Claude OAuth usage endpoint is rate limited by Anthropic right now. Wait a few minutes, " + "then click Refresh. If it keeps happening, run `claude logout && claude login`, then try again." case .invalidResponse: return "Claude OAuth response was invalid." case let .serverError(code, body): if let body, !body.isEmpty { let cleaned = body .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) let shortened = cleaned.count > 400 ? String(cleaned.prefix(400)) + "…" : cleaned return "Claude OAuth error: HTTP \(code) – \(shortened)" } return "Claude OAuth error: HTTP \(code)" case let .networkError(error): return "Claude OAuth network error: \(error.localizedDescription)" } } } enum ClaudeOAuthUsageFetcher { private static let baseURL = "https://api.anthropic.com" private static let usagePath = "/api/oauth/usage" private static let betaHeader = "oauth-2025-04-20" private static let fallbackClaudeCodeVersion = "2.1.0" static func fetchUsage(accessToken: String) async throws -> OAuthUsageResponse { if let blockedUntil = ClaudeOAuthUsageRateLimitGate.blockedUntil() { throw ClaudeOAuthFetchError.rateLimited(retryAfter: blockedUntil) } guard let url = URL(string: baseURL + usagePath) else { throw ClaudeOAuthFetchError.invalidResponse } var request = URLRequest(url: url) request.httpMethod = "GET" request.timeoutInterval = 30 request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Content-Type") // OAuth usage endpoint currently requires the beta header. request.setValue(Self.betaHeader, forHTTPHeaderField: "anthropic-beta") request.setValue(Self.claudeCodeUserAgent(), forHTTPHeaderField: "User-Agent") do { let response = try await ProviderHTTPClient.shared.response(for: request) let data = response.data switch response.statusCode { case 200: let usage = try Self.decodeUsageResponse(data) ClaudeOAuthUsageRateLimitGate.recordSuccess() return usage case 401: throw ClaudeOAuthFetchError.unauthorized case 429: let retryAfter = Self.retryAfterDate(from: response.response) ClaudeOAuthUsageRateLimitGate.recordRateLimit(retryAfter: retryAfter) throw ClaudeOAuthFetchError.rateLimited( retryAfter: ClaudeOAuthUsageRateLimitGate.currentBlockedUntil() ?? retryAfter) case 403: let body = String(data: data, encoding: .utf8) throw ClaudeOAuthFetchError.serverError(response.statusCode, body) default: let body = String(data: data, encoding: .utf8) throw ClaudeOAuthFetchError.serverError(response.statusCode, body) } } catch let error as ClaudeOAuthFetchError { throw error } catch { throw ClaudeOAuthFetchError.networkError(error) } } static func decodeUsageResponse(_ data: Data) throws -> OAuthUsageResponse { let decoder = JSONDecoder() return try decoder.decode(OAuthUsageResponse.self, from: data) } static func parseISO8601Date(_ string: String?) -> Date? { guard let string, !string.isEmpty else { return nil } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: string) { return date } formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: string) } private static func retryAfterDate(from response: HTTPURLResponse, now: Date = Date()) -> Date? { guard let raw = response.value(forHTTPHeaderField: "Retry-After")? .trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } if let seconds = TimeInterval(raw), seconds >= 0 { return now.addingTimeInterval(seconds) } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss zzz" return formatter.date(from: raw) } private static func claudeCodeUserAgent() -> String { self.claudeCodeUserAgent(versionString: ProviderVersionDetector.claudeVersion()) } private static func claudeCodeUserAgent(versionString: String?) -> String { let version = self.normalizedClaudeCodeVersion(versionString) ?? self.fallbackClaudeCodeVersion return "claude-code/\(version)" } private static func normalizedClaudeCodeVersion(_ versionString: String?) -> String? { guard let raw = versionString?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } let token = raw.split(whereSeparator: \.isWhitespace).first.map(String.init) ?? raw let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } } struct OAuthUsageResponse: Decodable { let fiveHour: OAuthUsageWindow? let sevenDay: OAuthUsageWindow? let sevenDayOAuthApps: OAuthUsageWindow? let sevenDayOpus: OAuthUsageWindow? let sevenDaySonnet: OAuthUsageWindow? let sevenDayRoutines: OAuthUsageWindow? let sevenDayRoutinesSourceKey: String? let iguanaNecktie: OAuthUsageWindow? let extraUsage: OAuthExtraUsage? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: DynamicCodingKey.self) self.fiveHour = Self.decodeWindow(in: container, keys: ["five_hour"]) self.sevenDay = Self.decodeWindow(in: container, keys: ["seven_day"]) self.sevenDayOAuthApps = Self.decodeWindow(in: container, keys: ["seven_day_oauth_apps"]) self.sevenDayOpus = Self.decodeWindow(in: container, keys: ["seven_day_opus"]) self.sevenDaySonnet = Self.decodeWindow(in: container, keys: ["seven_day_sonnet"]) let routines = Self.decodeWindowWithSource(in: container, keys: [ "seven_day_routines", "seven_day_claude_routines", "claude_routines", "routines", "routine", "seven_day_cowork", "cowork", ]) self.sevenDayRoutines = routines.window self.sevenDayRoutinesSourceKey = routines.sourceKey self.iguanaNecktie = Self.decodeWindow(in: container, keys: ["iguana_necktie"]) self.extraUsage = Self.decodeValue(in: container, keys: ["extra_usage"]) } private static func decodeWindow( in container: KeyedDecodingContainer, keys: [String]) -> OAuthUsageWindow? { self.decodeValue(in: container, keys: keys) } private static func decodeWindowWithSource( in container: KeyedDecodingContainer, keys: [String]) -> (window: OAuthUsageWindow?, sourceKey: String?) { var firstNullKey: String? for keyName in keys { guard let key = DynamicCodingKey(stringValue: keyName) else { continue } guard container.contains(key) else { continue } if let value = try? container.decodeIfPresent(OAuthUsageWindow.self, forKey: key) { return (value, keyName) } if firstNullKey == nil { firstNullKey = keyName } } return (nil, firstNullKey) } private static func decodeValue( in container: KeyedDecodingContainer, keys: [String]) -> T? { for keyName in keys { guard let key = DynamicCodingKey(stringValue: keyName) else { continue } if let value = try? container.decodeIfPresent(T.self, forKey: key) { return value } } return nil } } private struct DynamicCodingKey: CodingKey { let stringValue: String let intValue: Int? init?(stringValue: String) { self.stringValue = stringValue self.intValue = nil } init?(intValue: Int) { nil } } struct OAuthUsageWindow: Decodable { let utilization: Double? let resetsAt: String? enum CodingKeys: String, CodingKey { case utilization case resetsAt = "resets_at" } } struct OAuthExtraUsage: Decodable { let isEnabled: Bool? let monthlyLimit: Double? let usedCredits: Double? let utilization: Double? let currency: String? enum CodingKeys: String, CodingKey { case isEnabled = "is_enabled" case monthlyLimit = "monthly_limit" case usedCredits = "used_credits" case utilization case currency } } #if DEBUG extension ClaudeOAuthUsageFetcher { static func _decodeUsageResponseForTesting(_ data: Data) throws -> OAuthUsageResponse { try self.decodeUsageResponse(data) } static func _userAgentForTesting(versionString: String?) -> String { self.claudeCodeUserAgent(versionString: versionString) } static func _retryAfterDateForTesting(from response: HTTPURLResponse, now: Date) -> Date? { self.retryAfterDate(from: response, now: now) } } #endif