Files
2026-07-13 12:22:33 +08:00

298 lines
9.6 KiB
Swift

import Foundation
// MARK: - API Response Models
/// Top-level response from `GET https://admin.mistral.ai/api/billing/v2/usage`.
struct MistralBillingResponse: Codable {
let completion: MistralModelUsageCategory?
let ocr: MistralModelUsageCategory?
let connectors: MistralModelUsageCategory?
let librariesApi: MistralLibrariesUsageCategory?
let fineTuning: MistralFineTuningCategory?
let audio: MistralModelUsageCategory?
let vibeUsage: Double?
let date: String?
let previousMonth: String?
let nextMonth: String?
let startDate: String?
let endDate: String?
let currency: String?
let currencySymbol: String?
let prices: [MistralPrice]?
enum CodingKeys: String, CodingKey {
case completion, ocr, connectors, audio, date, currency, prices
case librariesApi = "libraries_api"
case fineTuning = "fine_tuning"
case vibeUsage = "vibe_usage"
case previousMonth = "previous_month"
case nextMonth = "next_month"
case startDate = "start_date"
case endDate = "end_date"
case currencySymbol = "currency_symbol"
}
}
struct MistralModelUsageCategory: Codable {
let models: [String: MistralModelUsageData]?
}
struct MistralLibrariesUsageCategory: Codable {
let pages: MistralModelUsageCategory?
let tokens: MistralModelUsageCategory?
}
struct MistralFineTuningCategory: Codable {
let training: [String: MistralModelUsageData]?
let storage: [String: MistralModelUsageData]?
}
struct MistralModelUsageData: Codable {
let input: [MistralUsageEntry]?
let output: [MistralUsageEntry]?
let cached: [MistralUsageEntry]?
}
struct MistralUsageEntry: Codable {
let usageType: String?
let eventType: String?
let billingMetric: String?
let billingDisplayName: String?
let billingGroup: String?
let timestamp: String?
let value: Int?
let valuePaid: Int?
enum CodingKeys: String, CodingKey {
case timestamp, value
case usageType = "usage_type"
case eventType = "event_type"
case billingMetric = "billing_metric"
case billingDisplayName = "billing_display_name"
case billingGroup = "billing_group"
case valuePaid = "value_paid"
}
}
struct MistralPrice: Codable {
let eventType: String?
let billingMetric: String?
let billingGroup: String?
let price: String?
enum CodingKeys: String, CodingKey {
case price
case eventType = "event_type"
case billingMetric = "billing_metric"
case billingGroup = "billing_group"
}
}
// MARK: - Intermediate Snapshot
public struct MistralDailyUsageBucket: Codable, Equatable, Sendable, Identifiable {
public struct ModelBreakdown: Codable, Equatable, Sendable, Identifiable {
public let name: String
public let cost: Double
public let inputTokens: Int
public let cachedTokens: Int
public let outputTokens: Int
public var id: String {
self.name
}
public var totalTokens: Int {
self.inputTokens + self.cachedTokens + self.outputTokens
}
public init(name: String, cost: Double, inputTokens: Int, cachedTokens: Int, outputTokens: Int) {
self.name = name
self.cost = cost
self.inputTokens = inputTokens
self.cachedTokens = cachedTokens
self.outputTokens = outputTokens
}
}
public let day: String
public let cost: Double
public let inputTokens: Int
public let cachedTokens: Int
public let outputTokens: Int
public let models: [ModelBreakdown]
public var id: String {
self.day
}
public var totalTokens: Int {
self.inputTokens + self.cachedTokens + self.outputTokens
}
public init(
day: String,
cost: Double,
inputTokens: Int,
cachedTokens: Int,
outputTokens: Int,
models: [ModelBreakdown])
{
self.day = day
self.cost = cost
self.inputTokens = inputTokens
self.cachedTokens = cachedTokens
self.outputTokens = outputTokens
self.models = models
}
}
public struct MistralUsageSnapshot: Codable, Sendable {
public let totalCost: Double
public let currency: String
public let currencySymbol: String
public let totalInputTokens: Int
public let totalOutputTokens: Int
public let totalCachedTokens: Int
public let modelCount: Int
public let daily: [MistralDailyUsageBucket]
public let credits: MistralCreditsSnapshot?
public let startDate: Date?
public let endDate: Date?
public let updatedAt: Date
public init(
totalCost: Double,
currency: String,
currencySymbol: String,
totalInputTokens: Int,
totalOutputTokens: Int,
totalCachedTokens: Int,
modelCount: Int,
daily: [MistralDailyUsageBucket] = [],
credits: MistralCreditsSnapshot? = nil,
startDate: Date?,
endDate: Date?,
updatedAt: Date)
{
self.totalCost = totalCost
self.currency = currency
self.currencySymbol = currencySymbol
self.totalInputTokens = totalInputTokens
self.totalOutputTokens = totalOutputTokens
self.totalCachedTokens = totalCachedTokens
self.modelCount = modelCount
self.daily = daily.sorted { $0.day < $1.day }
self.credits = credits
self.startDate = startDate
self.endDate = endDate
self.updatedAt = updatedAt
}
public func with(credits: MistralCreditsSnapshot?) -> MistralUsageSnapshot {
MistralUsageSnapshot(
totalCost: self.totalCost,
currency: self.currency,
currencySymbol: self.currencySymbol,
totalInputTokens: self.totalInputTokens,
totalOutputTokens: self.totalOutputTokens,
totalCachedTokens: self.totalCachedTokens,
modelCount: self.modelCount,
daily: self.daily,
credits: credits,
startDate: self.startDate,
endDate: self.endDate,
updatedAt: self.updatedAt)
}
public func toUsageSnapshot() -> UsageSnapshot {
// Negative totalCost means a refund/credit adjustment; clamp to zero rather than
// showing a confusing negative amount in the menu bar.
let spendText = if self.totalCost > 0 {
"\(self.currencySymbol)\(String(format: "%.4f", self.totalCost)) this month"
} else {
"\(self.currencySymbol)0.0000 this month"
}
let identity = ProviderIdentitySnapshot(
providerID: .mistral,
accountEmail: nil,
accountOrganization: nil,
loginMethod: "API spend: \(spendText)")
return UsageSnapshot(
primary: nil,
secondary: nil,
providerCost: nil,
mistralUsage: self,
updatedAt: self.updatedAt,
identity: identity)
}
public func toCostUsageTokenSnapshot(historyDays: Int = 30) -> CostUsageTokenSnapshot {
let clampedHistoryDays = max(1, min(365, historyDays))
let selected = self.daily
let entries = selected.map { bucket in
let modelBreakdowns = bucket.models.map {
CostUsageDailyReport.ModelBreakdown(
modelName: $0.name,
costUSD: max($0.cost, 0),
totalTokens: $0.totalTokens)
}
let modelsUsed = bucket.models.map(\.name)
return CostUsageDailyReport.Entry(
date: bucket.day,
inputTokens: bucket.inputTokens,
outputTokens: bucket.outputTokens,
cacheReadTokens: bucket.cachedTokens,
cacheCreationTokens: nil,
totalTokens: bucket.totalTokens,
costUSD: max(bucket.cost, 0),
modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed,
modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns)
}
let latest = selected.last
let totalCost = max(self.totalCost, 0)
let totalTokens = selected.isEmpty
? self.totalInputTokens + self.totalCachedTokens + self.totalOutputTokens
: selected.reduce(0) { $0 + $1.totalTokens }
let tokens = totalTokens > 0 ? totalTokens : nil
return CostUsageTokenSnapshot(
sessionTokens: latest?.totalTokens,
sessionCostUSD: latest.map { max($0.cost, 0) },
last30DaysTokens: tokens,
last30DaysCostUSD: totalCost,
currencyCode: self.currency,
historyDays: selected.isEmpty ? clampedHistoryDays : max(1, min(365, selected.count)),
historyLabel: "This month",
daily: entries,
updatedAt: self.updatedAt)
}
}
public struct MistralCreditsSnapshot: Codable, Equatable, Sendable {
public let walletAmount: Double
public let creditNotesAmount: Double
public let ongoingUsageBalance: Double
public let currency: String
public init(
walletAmount: Double,
creditNotesAmount: Double,
ongoingUsageBalance: Double,
currency: String)
{
self.walletAmount = walletAmount
self.creditNotesAmount = creditNotesAmount
self.ongoingUsageBalance = ongoingUsageBalance
self.currency = currency
}
public var availableAmount: Double {
let amount = self.walletAmount + self.creditNotesAmount - self.ongoingUsageBalance
return amount.isFinite ? max(0, amount) : 0
}
public var formattedAvailableAmount: String {
UsageFormatter.currencyString(self.availableAmount, currencyCode: self.currency)
}
}