import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public enum MistralUsageFetcher { private static let baseURL = URL(string: "https://admin.mistral.ai")! public struct MistralVibeUsageResult: Equatable, Sendable { public let usagePercentage: Double public let resetAt: Date? } public static func fetchUsage( cookieHeader: String, csrfToken: String?, timeout: TimeInterval = 15, transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MistralUsageSnapshot { let now = Date() var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(identifier: "UTC")! let month = calendar.component(.month, from: now) let year = calendar.component(.year, from: now) let usagePath = self.baseURL.appendingPathComponent("/api/billing/v2/usage") var components = URLComponents(url: usagePath, resolvingAgainstBaseURL: false)! components.queryItems = [ URLQueryItem(name: "month", value: "\(month)"), URLQueryItem(name: "year", value: "\(year)"), ] guard let url = components.url else { throw MistralUsageError.apiError("Failed to construct URL") } var request = URLRequest(url: url, timeoutInterval: timeout) request.setValue("*/*", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") request.setValue("https://admin.mistral.ai/organization/usage", forHTTPHeaderField: "Referer") request.setValue("https://admin.mistral.ai", forHTTPHeaderField: "Origin") if let csrfToken { request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN") } let response = try await transport.response(for: request) let data = response.data switch response.statusCode { case 200: break case 401, 403: throw MistralUsageError.invalidCredentials default: let body = String(data: data.prefix(200), encoding: .utf8) ?? "" throw MistralUsageError.apiError("HTTP \(response.statusCode): \(body)") } return try Self.parseResponse(data: data, updatedAt: now) } public static func fetchVibeUsage( csrfToken: String, cookieHeader: String? = nil, timeout: TimeInterval = 4, transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MistralVibeUsageResult { let urlString = "https://console.mistral.ai/api-ui/trpc/billing.vibeUsage?batch=1&input=%7B%220%22%3A%7B%22json%22%3Anull%2C%22meta%22%3A%7B%22values%22%3A%5B%22undefined%22%5D%2C%22v%22%3A1%7D%7D%7D" guard let url = URL(string: urlString) else { throw MistralUsageError.apiError("Failed to construct URL") } var request = URLRequest(url: url, timeoutInterval: timeout) request.httpShouldHandleCookies = false let validatedCSRFToken = try Self.validatedVibeCSRFToken(csrfToken) request.setValue("*/*", forHTTPHeaderField: "Accept") // Forward ory_session_* and csrftoken cookies — scoped to what console.mistral.ai needs. let consoleCookie = Self.consoleCookieHeader( csrfToken: validatedCSRFToken, adminCookieHeader: cookieHeader) request.setValue(consoleCookie, forHTTPHeaderField: "Cookie") request.setValue(validatedCSRFToken, forHTTPHeaderField: "X-CSRFToken") let response = try await transport.response(for: request) let data = response.data switch response.statusCode { case 200: break case 401, 403: throw MistralUsageError.invalidCredentials default: let body = String(data: data.prefix(200), encoding: .utf8) ?? "" throw MistralUsageError.apiError("HTTP \(response.statusCode): \(body)") } return try Self.parseVibeUsage(data: data) } public static func fetchCredits( cookieHeader: String, csrfToken: String?, timeout: TimeInterval = 4, transport: ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MistralCreditsSnapshot { let url = self.baseURL.appendingPathComponent("/api/billing/credits") var request = URLRequest(url: url, timeoutInterval: timeout) request.setValue("*/*", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") request.setValue("https://admin.mistral.ai/organization/billing", forHTTPHeaderField: "Referer") request.setValue("https://admin.mistral.ai", forHTTPHeaderField: "Origin") if let csrfToken { request.setValue(csrfToken, forHTTPHeaderField: "X-CSRFTOKEN") } let response = try await transport.response(for: request) let data = response.data switch response.statusCode { case 200: break case 401, 403: throw MistralUsageError.invalidCredentials default: let body = String(data: data.prefix(200), encoding: .utf8) ?? "" throw MistralUsageError.apiError("HTTP \(response.statusCode): \(body)") } return try Self.parseCredits(data: data) } static func parseVibeUsage(data: Data) throws -> MistralVibeUsageResult { let responses: [VibeUsageResponse] do { responses = try JSONDecoder().decode([VibeUsageResponse].self, from: data) } catch { throw MistralUsageError.parseFailed(error.localizedDescription) } guard let json = responses.first?.result.data.json else { throw MistralUsageError.parseFailed("Empty response array") } guard json.usagePercentage.isFinite, (0...100).contains(json.usagePercentage) else { throw MistralUsageError.parseFailed("Invalid usage percentage") } return MistralVibeUsageResult( usagePercentage: json.usagePercentage, resetAt: json.resetAt.flatMap(Self.parseISO8601Date)) } static func parseCredits(data: Data) throws -> MistralCreditsSnapshot { let response: MistralCreditsResponse do { response = try JSONDecoder().decode(MistralCreditsResponse.self, from: data) } catch { throw MistralUsageError.parseFailed(error.localizedDescription) } let snapshot = MistralCreditsSnapshot( walletAmount: response.walletAmount, creditNotesAmount: response.creditNotesAmount ?? 0, ongoingUsageBalance: response.ongoingUsageBalance ?? 0, currency: response.currency) let amounts = [snapshot.walletAmount, snapshot.creditNotesAmount, snapshot.ongoingUsageBalance] let available = snapshot.walletAmount + snapshot.creditNotesAmount - snapshot.ongoingUsageBalance guard amounts.allSatisfy(\.isFinite), available.isFinite else { throw MistralUsageError.parseFailed("Invalid credit amount") } return snapshot } static func vibeCookieHeader(csrfToken: String) throws -> String { try "csrftoken=\(self.validatedVibeCSRFToken(csrfToken))" } /// Builds a minimal Cookie header for console.mistral.ai. /// Only csrftoken + ory_session_* pass through; all other admin.mistral.ai cookies stay origin-bound. static func consoleCookieHeader(csrfToken: String, adminCookieHeader: String?) -> String { var pairs = ["csrftoken=\(csrfToken)"] if let adminCookies = adminCookieHeader { let sessionPairs = CookieHeaderNormalizer.pairs(from: adminCookies) .filter { $0.name.hasPrefix("ory_session_") } .map { "\($0.name)=\($0.value)" } pairs.append(contentsOf: sessionPairs) } return pairs.joined(separator: "; ") } private static func validatedVibeCSRFToken(_ csrfToken: String) throws -> String { let token = csrfToken.trimmingCharacters(in: .whitespacesAndNewlines) let forbidden = CharacterSet(charactersIn: ";,\r\n") guard !token.isEmpty, token.rangeOfCharacter(from: forbidden) == nil else { throw MistralUsageError.invalidCredentials } return token } private static func parseISO8601Date(_ value: String) -> Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: value) { return date } formatter.formatOptions = [.withInternetDateTime] return formatter.date(from: value) } static func parseResponse(data: Data, updatedAt: Date) throws -> MistralUsageSnapshot { let decoder = JSONDecoder() let billing: MistralBillingResponse do { billing = try decoder.decode(MistralBillingResponse.self, from: data) } catch { throw MistralUsageError.parseFailed(error.localizedDescription) } let prices = Self.buildPriceIndex(billing.prices ?? []) var totalCost: Double = 0 var totalInput = 0 var totalOutput = 0 var totalCached = 0 var modelCount = 0 var daily: [String: DailyAccumulator] = [:] // Aggregate completion tokens if let models = billing.completion?.models { for (modelName, modelData) in models { modelCount += 1 let (input, output, cached, cost) = Self.aggregateModel(modelData, prices: prices) totalInput += input totalOutput += output totalCached += cached Self.accumulateFiniteCost(cost, into: &totalCost) Self.addDailyEntries( modelName: modelName, data: modelData, prices: prices, daily: &daily, countsTokens: true) } } // Aggregate OCR, connectors, audio if present for category in [billing.ocr, billing.connectors, billing.audio] { if let models = category?.models { for (modelName, modelData) in models { let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) Self.accumulateFiniteCost(cost, into: &totalCost) Self.addDailyEntries( modelName: modelName, data: modelData, prices: prices, daily: &daily, countsTokens: false) } } } // Aggregate libraries_api (pages + tokens) if let models = billing.librariesApi?.pages?.models { for (modelName, modelData) in models { let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) Self.accumulateFiniteCost(cost, into: &totalCost) Self.addDailyEntries( modelName: modelName, data: modelData, prices: prices, daily: &daily, countsTokens: false) } } if let models = billing.librariesApi?.tokens?.models { for (modelName, modelData) in models { let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) Self.accumulateFiniteCost(cost, into: &totalCost) Self.addDailyEntries( modelName: modelName, data: modelData, prices: prices, daily: &daily, countsTokens: true) } } // Aggregate fine_tuning (training + storage) for models in [billing.fineTuning?.training, billing.fineTuning?.storage] { if let models { for (modelName, modelData) in models { let (_, _, _, cost) = Self.aggregateModel(modelData, prices: prices) Self.accumulateFiniteCost(cost, into: &totalCost) Self.addDailyEntries( modelName: modelName, data: modelData, prices: prices, daily: &daily, countsTokens: false) } } } let currency = billing.currency ?? "EUR" let currencySymbol = billing.currencySymbol ?? "€" let startDate = billing.startDate.flatMap { Self.parseDate($0) } let endDate = billing.endDate.flatMap { Self.parseDate($0) } return MistralUsageSnapshot( totalCost: totalCost, currency: currency, currencySymbol: currencySymbol, totalInputTokens: totalInput, totalOutputTokens: totalOutput, totalCachedTokens: totalCached, modelCount: modelCount, daily: daily.values.map { $0.makeBucket() }, startDate: startDate, endDate: endDate, updatedAt: updatedAt) } // MARK: - Private Helpers private static func buildPriceIndex(_ prices: [MistralPrice]) -> [String: Double] { var index: [String: Double] = [:] for price in prices { guard let metric = price.billingMetric, let group = price.billingGroup, let priceStr = price.price, let value = Double(priceStr), value.isFinite else { continue } let key = "\(metric)::\(group)" index[key] = value } return index } private static func aggregateModel( _ data: MistralModelUsageData, prices: [String: Double]) -> (input: Int, output: Int, cached: Int, cost: Double) { var totalInput = 0 var totalOutput = 0 var totalCached = 0 var totalCost: Double = 0 for entry in data.input ?? [] { let tokens = entry.valuePaid ?? entry.value ?? 0 totalInput += tokens Self.accumulateFiniteCost(Self.cost(for: entry, units: tokens, prices: prices), into: &totalCost) } for entry in data.output ?? [] { let tokens = entry.valuePaid ?? entry.value ?? 0 totalOutput += tokens Self.accumulateFiniteCost(Self.cost(for: entry, units: tokens, prices: prices), into: &totalCost) } for entry in data.cached ?? [] { let tokens = entry.valuePaid ?? entry.value ?? 0 totalCached += tokens Self.accumulateFiniteCost(Self.cost(for: entry, units: tokens, prices: prices), into: &totalCost) } return (totalInput, totalOutput, totalCached, totalCost) } private static func addDailyEntries( modelName: String, data: MistralModelUsageData, prices: [String: Double], daily: inout [String: DailyAccumulator], countsTokens: Bool) { self.addDaily( entries: data.input ?? [], context: DailyEntryContext( kind: .input, modelName: modelName, prices: prices, countsTokens: countsTokens), daily: &daily) self.addDaily( entries: data.output ?? [], context: DailyEntryContext( kind: .output, modelName: modelName, prices: prices, countsTokens: countsTokens), daily: &daily) self.addDaily( entries: data.cached ?? [], context: DailyEntryContext( kind: .cached, modelName: modelName, prices: prices, countsTokens: countsTokens), daily: &daily) } fileprivate enum TokenKind { case input case cached case output } private static func addDaily( entries: [MistralUsageEntry], context: DailyEntryContext, daily: inout [String: DailyAccumulator]) { for entry in entries { guard let day = dayKey(from: entry.timestamp) else { continue } let units = entry.valuePaid ?? entry.value ?? 0 let cost = Self.cost(for: entry, units: units, prices: context.prices) var accumulator = daily[day] ?? DailyAccumulator(day: day) accumulator.add( modelName: Self.displayModelName(context.modelName, entry: entry), kind: context.kind, units: units, cost: cost, countsTokens: context.countsTokens) daily[day] = accumulator } } private static func cost(for entry: MistralUsageEntry, units: Int, prices: [String: Double]) -> Double { guard let metric = entry.billingMetric, let group = entry.billingGroup else { return 0 } let cost = Double(units) * (prices["\(metric)::\(group)"] ?? 0) return cost.isFinite ? cost : 0 } fileprivate static func accumulateFiniteCost(_ cost: Double, into total: inout Double) { guard cost.isFinite else { return } let updatedTotal = total + cost guard updatedTotal.isFinite else { return } total = updatedTotal } private static func displayModelName(_ raw: String, entry: MistralUsageEntry) -> String { if let display = entry.billingDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines), !display.isEmpty { return display } return raw.split(separator: "::").first.map(String.init) ?? raw } private static func dayKey(from timestamp: String?) -> String? { guard let trimmed = timestamp?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } if trimmed.count >= 10 { return String(trimmed.prefix(10)) } return nil } private static func parseDate(_ string: String) -> Date? { 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 struct DailyEntryContext { let kind: MistralUsageFetcher.TokenKind let modelName: String let prices: [String: Double] let countsTokens: Bool } private struct DailyAccumulator { let day: String var cost: Double = 0 var inputTokens = 0 var cachedTokens = 0 var outputTokens = 0 var models: [String: ModelAccumulator] = [:] mutating func add( modelName: String, kind: MistralUsageFetcher.TokenKind, units: Int, cost: Double, countsTokens: Bool) { MistralUsageFetcher.accumulateFiniteCost(cost, into: &self.cost) var model = self.models[modelName] ?? ModelAccumulator(name: modelName) MistralUsageFetcher.accumulateFiniteCost(cost, into: &model.cost) guard countsTokens else { self.models[modelName] = model return } switch kind { case .input: self.inputTokens += units model.inputTokens += units case .cached: self.cachedTokens += units model.cachedTokens += units case .output: self.outputTokens += units model.outputTokens += units } self.models[modelName] = model } func makeBucket() -> MistralDailyUsageBucket { MistralDailyUsageBucket( day: self.day, cost: self.cost, inputTokens: self.inputTokens, cachedTokens: self.cachedTokens, outputTokens: self.outputTokens, models: self.models.values .map { $0.makeBreakdown() } .sorted { if $0.totalTokens == $1.totalTokens { return $0.name < $1.name } return $0.totalTokens > $1.totalTokens }) } } private struct ModelAccumulator { let name: String var cost: Double = 0 var inputTokens = 0 var cachedTokens = 0 var outputTokens = 0 func makeBreakdown() -> MistralDailyUsageBucket.ModelBreakdown { MistralDailyUsageBucket.ModelBreakdown( name: self.name, cost: self.cost, inputTokens: self.inputTokens, cachedTokens: self.cachedTokens, outputTokens: self.outputTokens) } } private struct VibeUsageResponse: Decodable { let result: VibeResult struct VibeResult: Decodable { let data: VibeData struct VibeData: Decodable { let json: VibeJson struct VibeJson: Decodable { let usagePercentage: Double let resetAt: String? enum CodingKeys: String, CodingKey { case usagePercentage = "usage_percentage" case resetAt = "reset_at" } } } } } private struct MistralCreditsResponse: Decodable { let walletAmount: Double let creditNotesAmount: Double? let ongoingUsageBalance: Double? let currency: String enum CodingKeys: String, CodingKey { case currency case walletAmount = "wallet_amount" case creditNotesAmount = "credit_notes_amount" case ongoingUsageBalance = "ongoing_usage_balance" } }