import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif // MARK: - Wire DTOs (CrossModel returns integer micro units; 1 major currency unit = 1_000_000 micro) /// `GET /v1/credits` response. struct CrossModelCreditsResponse: Decodable { let currency: String let balanceMicro: Int64 let uncollectedMicro: Int64 private enum CodingKeys: String, CodingKey { case currency case balanceMicro = "balance_micro" case uncollectedMicro = "uncollected_micro" } } /// One usage window from `GET /v1/usage`. struct CrossModelUsageWindowDTO: Decodable { let costMicro: Int64 let promptTokens: Int64 let completionTokens: Int64 let totalTokens: Int64 let requestCount: Int64 let successCount: Int64 private enum CodingKeys: String, CodingKey { case costMicro = "cost_micro" case promptTokens = "prompt_tokens" case completionTokens = "completion_tokens" case totalTokens = "total_tokens" case requestCount = "request_count" case successCount = "success_count" } } /// `GET /v1/usage` response. struct CrossModelUsageResponse: Decodable { let currency: String let daily: CrossModelUsageWindowDTO let weekly: CrossModelUsageWindowDTO let monthly: CrossModelUsageWindowDTO } // MARK: - Snapshot (currency-normalized, persisted in UsageSnapshot) /// One usage window with cost converted to the response currency's major unit. public struct CrossModelUsageWindow: Codable, Sendable, Equatable { public let cost: Double public let promptTokens: Int public let completionTokens: Int public let totalTokens: Int public let requestCount: Int public let successCount: Int public init( cost: Double, promptTokens: Int, completionTokens: Int, totalTokens: Int, requestCount: Int, successCount: Int) { self.cost = cost self.promptTokens = promptTokens self.completionTokens = completionTokens self.totalTokens = totalTokens self.requestCount = requestCount self.successCount = successCount } init(dto: CrossModelUsageWindowDTO) { self.cost = CrossModelUsageSnapshot.majorUnits(dto.costMicro) self.promptTokens = Int(dto.promptTokens) self.completionTokens = Int(dto.completionTokens) self.totalTokens = Int(dto.totalTokens) self.requestCount = Int(dto.requestCount) self.successCount = Int(dto.successCount) } } /// Complete CrossModel usage snapshot: wallet balance plus UTC day/week/month spend. public struct CrossModelUsageSnapshot: Codable, Sendable, Equatable { public let currency: String public let balance: Double public let uncollected: Double public let daily: CrossModelUsageWindow? public let weekly: CrossModelUsageWindow? public let monthly: CrossModelUsageWindow? public let updatedAt: Date public init( currency: String, balance: Double, uncollected: Double, daily: CrossModelUsageWindow?, weekly: CrossModelUsageWindow?, monthly: CrossModelUsageWindow?, updatedAt: Date) { self.currency = Self.normalizedCurrency(currency) self.balance = balance self.uncollected = uncollected self.daily = daily self.weekly = weekly self.monthly = monthly self.updatedAt = updatedAt } static func majorUnits(_ micro: Int64) -> Double { Double(micro) / 1_000_000.0 } static func normalizedCurrency(_ raw: String) -> String { raw.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() } /// Formatted balance for provider-owned menu/status display. public var balanceDisplay: String { self.currencyString(self.balance) } public func currencyString(_ value: Double) -> String { UsageFormatter.currencyString(value, currencyCode: self.currency) } } extension CrossModelUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { let identity = ProviderIdentitySnapshot( providerID: .crossmodel, accountEmail: nil, accountOrganization: nil, loginMethod: "API key") return UsageSnapshot( primary: nil, secondary: nil, tertiary: nil, crossModelUsage: self, updatedAt: self.updatedAt, identity: identity, dataConfidence: .exact) } } // MARK: - Fetcher /// Fetches balance + usage stats from the CrossModel API. public struct CrossModelUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.crossModelUsage) private static let creditsRequestTimeoutSeconds: TimeInterval = 15 private static let usageRequestTimeoutSeconds: TimeInterval = 3 private static let maxErrorBodyLength = 240 /// Fetches balance (required) and usage windows (best-effort) using the provided API key. public static func fetchUsage( apiKey: String, environment: [String: String] = ProcessInfo.processInfo.environment, includeOptionalUsage: Bool = true, transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, usageJoinGrace: Duration = .seconds(3)) async throws -> CrossModelUsageSnapshot { guard !apiKey.isEmpty else { throw CrossModelUsageError.invalidCredentials } try CrossModelSettingsReader.validateEndpointOverrides(environment: environment) let baseURL = CrossModelSettingsReader.apiURL(environment: environment) let credits = try await self.fetchCredits(apiKey: apiKey, baseURL: baseURL, transport: transport) let currency = try self.normalizedResponseCurrency(credits.currency, endpoint: "credits") guard includeOptionalUsage else { return CrossModelUsageSnapshot( currency: currency, balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro), uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro), daily: nil, weekly: nil, monthly: nil, updatedAt: Date()) } // Usage windows are best-effort: a slow or failing /usage call should not // block the balance the user came to see. let usage = try await self.fetchUsageWindows( apiKey: apiKey, baseURL: baseURL, transport: transport, joinGrace: usageJoinGrace) let validatedUsage: CrossModelUsageResponse? if let usage { let usageCurrency: String do { usageCurrency = try self.normalizedResponseCurrency(usage.currency, endpoint: "usage") } catch { Self.log.error("CrossModel /usage omitted: invalid currency") validatedUsage = nil return CrossModelUsageSnapshot( currency: currency, balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro), uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro), daily: nil, weekly: nil, monthly: nil, updatedAt: Date()) } guard usageCurrency == currency else { Self.log.error("CrossModel currency mismatch: credits=\(currency) usage=\(usageCurrency)") return CrossModelUsageSnapshot( currency: currency, balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro), uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro), daily: nil, weekly: nil, monthly: nil, updatedAt: Date()) } validatedUsage = usage } else { validatedUsage = nil } return CrossModelUsageSnapshot( currency: currency, balance: CrossModelUsageSnapshot.majorUnits(credits.balanceMicro), uncollected: CrossModelUsageSnapshot.majorUnits(credits.uncollectedMicro), daily: validatedUsage.map { CrossModelUsageWindow(dto: $0.daily) }, weekly: validatedUsage.map { CrossModelUsageWindow(dto: $0.weekly) }, monthly: validatedUsage.map { CrossModelUsageWindow(dto: $0.monthly) }, updatedAt: Date()) } private static func fetchCredits( apiKey: String, baseURL: URL, transport: any ProviderHTTPTransport) async throws -> CrossModelCreditsResponse { let url = baseURL.appendingPathComponent("credits") var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = Self.creditsRequestTimeoutSeconds let response = try await transport.response(for: request) guard response.statusCode == 200 else { let summary = LogRedactor.redact(Self.sanitizedResponseBodySummary(response.data)) Self.log.error("CrossModel /credits returned \(response.statusCode): \(summary)") if response.statusCode == 401 || response.statusCode == 403 { throw CrossModelUsageError.invalidCredentials } throw CrossModelUsageError.apiError("HTTP \(response.statusCode)") } try self.validateSameOrigin(response: response, request: request, endpoint: "credits") do { return try JSONDecoder().decode(CrossModelCreditsResponse.self, from: response.data) } catch let error as DecodingError { Self.log.error("CrossModel /credits decode error: \(error.localizedDescription)") throw CrossModelUsageError.parseFailed(error.localizedDescription) } } private static func fetchUsageWindows( apiKey: String, baseURL: URL, transport: any ProviderHTTPTransport, joinGrace: Duration) async throws -> CrossModelUsageResponse? { let url = baseURL.appendingPathComponent("usage") var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = Self.usageRequestTimeoutSeconds let sourceTask = Task { let response = try await transport.response(for: request) guard response.statusCode == 200 else { Self.log.debug("CrossModel /usage enrichment returned \(response.statusCode)") return nil } try self.validateSameOrigin(response: response, request: request, endpoint: "usage") return try JSONDecoder().decode(CrossModelUsageResponse.self, from: response.data) } let join = BoundedTaskJoin(sourceTask: sourceTask) let outcome = await join.value(joinGrace: joinGrace) do { switch outcome { case let .value(response): return response case .timedOut: Self.log.debug("Timed out fetching CrossModel /usage enrichment") return nil case let .failure(error): throw error } } catch is CancellationError { throw CancellationError() } catch let error as URLError where error.code == .cancelled { throw CancellationError() } catch { if Task.isCancelled { throw CancellationError() } Self.log.debug("Failed to fetch CrossModel /usage enrichment: \(error.localizedDescription)") return nil } } private static func normalizedResponseCurrency(_ raw: String, endpoint: String) throws -> String { let currency = CrossModelUsageSnapshot.normalizedCurrency(raw) guard !currency.isEmpty else { throw CrossModelUsageError.parseFailed("Missing CrossModel /\(endpoint) currency") } return currency } private static func validateSameOrigin( response: ProviderHTTPResponse, request: URLRequest, endpoint: String) throws { guard let requestURL = request.url, let responseURL = response.response.url, let requestHost = requestURL.host?.lowercased(), let responseHost = responseURL.host?.lowercased(), requestURL.scheme?.lowercased() == responseURL.scheme?.lowercased(), requestHost == responseHost, self.effectivePort(for: requestURL) == self.effectivePort(for: responseURL) else { throw CrossModelUsageError.apiError("CrossModel /\(endpoint) redirected to a different origin") } } private static func effectivePort(for url: URL) -> Int? { if let port = url.port { return port } switch url.scheme?.lowercased() { case "https": return 443 case "http": return 80 default: return nil } } private static func sanitizedResponseBodySummary(_ data: Data) -> String { guard !data.isEmpty else { return "empty body" } guard let rawBody = String(bytes: data, encoding: .utf8) else { return "non-text body (\(data.count) bytes)" } let body = Self.redactSensitiveBodyContent(rawBody) .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) guard !body.isEmpty else { return "non-text body (\(data.count) bytes)" } guard body.count > Self.maxErrorBodyLength else { return body } let index = body.index(body.startIndex, offsetBy: Self.maxErrorBodyLength) return "\(body[.. String { let replacements: [(String, String)] = [ (#"(?i)(bearer\s+)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"), (#"(?i)(cm-)[A-Za-z0-9._\-]+"#, "$1[REDACTED]"), ( #"(?i)(\"(?:api_?key|authorization|token|access_token|refresh_token)\"\s*:\s*\")([^\"]+)(\")"#, "$1[REDACTED]$3"), ( #"(?i)((?:api_?key|authorization|token|access_token|refresh_token)\s*[=:]\s*)([^,\s]+)"#, "$1[REDACTED]"), ] return replacements.reduce(text) { partial, replacement in partial.replacingOccurrences( of: replacement.0, with: replacement.1, options: .regularExpression) } } #if DEBUG static func _sanitizedResponseBodySummaryForTesting(_ body: String) -> String { self.sanitizedResponseBodySummary(Data(body.utf8)) } #endif } /// Errors that can occur during CrossModel usage fetching. public enum CrossModelUsageError: LocalizedError, Sendable, Equatable { case invalidCredentials case networkError(String) case apiError(String) case parseFailed(String) public var errorDescription: String? { switch self { case .invalidCredentials: "Invalid CrossModel API credentials" case let .networkError(message): "CrossModel network error: \(message)" case let .apiError(message): "CrossModel API error: \(message)" case let .parseFailed(message): "Failed to parse CrossModel response: \(message)" } } }