571 lines
21 KiB
Swift
571 lines
21 KiB
Swift
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"
|
|
}
|
|
}
|