import Foundation public struct MiniMaxBillingSummary: Sendable { public let todayTokens: Int public let last30DaysTokens: Int public let todayCash: Double? public let last30DaysCash: Double? public let daily: [MiniMaxBillingDay] public let topMethods: [MiniMaxBillingBreakdown] public let topModels: [MiniMaxBillingBreakdown] public let updatedAt: Date public init( todayTokens: Int, last30DaysTokens: Int, todayCash: Double?, last30DaysCash: Double?, daily: [MiniMaxBillingDay], topMethods: [MiniMaxBillingBreakdown], topModels: [MiniMaxBillingBreakdown], updatedAt: Date) { self.todayTokens = todayTokens self.last30DaysTokens = last30DaysTokens self.todayCash = todayCash self.last30DaysCash = last30DaysCash self.daily = daily self.topMethods = topMethods self.topModels = topModels self.updatedAt = updatedAt } } public struct MiniMaxBillingDay: Sendable, Equatable { public let day: String public let tokens: Int public let cash: Double? public init(day: String, tokens: Int, cash: Double?) { self.day = day self.tokens = tokens self.cash = cash } } public struct MiniMaxBillingBreakdown: Sendable, Equatable { public let name: String public let tokens: Int public let cash: Double? public init(name: String, tokens: Int, cash: Double?) { self.name = name self.tokens = tokens self.cash = cash } } struct MiniMaxBillingHistoryPayload: Decodable { let baseResp: MiniMaxBaseResponse? let chargeRecords: [MiniMaxBillingRecord] let totalCount: Int? private enum CodingKeys: String, CodingKey { case baseResp = "base_resp" case chargeRecords = "charge_records" case totalCount = "total_cnt" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.baseResp = try container.decodeIfPresent(MiniMaxBaseResponse.self, forKey: .baseResp) self.chargeRecords = try container.decodeIfPresent([MiniMaxBillingRecord].self, forKey: .chargeRecords) ?? [] self.totalCount = MiniMaxDecoding.decodeInt(container, forKey: .totalCount) } } struct MiniMaxBillingRecord: Decodable { let consumeToken: Int? let consumeInputToken: Int? let consumeOutputToken: Int? let consumeCash: Double? let consumeCashAfterVoucher: Double? let createdAt: Int? let ymd: String? let consumeTime: String? let method: String? let model: String? let result: String? let status: String? private enum CodingKeys: String, CodingKey { case consumeToken = "consume_token" case consumeInputToken = "consume_input_token" case consumeOutputToken = "consume_output_token" case consumeCash = "consume_cash" case consumeCashAfterVoucher = "consume_cash_after_voucher" case createdAt = "created_at" case ymd case consumeTime = "consume_time" case method case model case result case status } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.consumeToken = MiniMaxDecoding.decodeInt(container, forKey: .consumeToken) self.consumeInputToken = MiniMaxDecoding.decodeInt(container, forKey: .consumeInputToken) self.consumeOutputToken = MiniMaxDecoding.decodeInt(container, forKey: .consumeOutputToken) self.consumeCash = MiniMaxDecoding.decodeDouble(container, forKey: .consumeCash) self.consumeCashAfterVoucher = MiniMaxDecoding.decodeDouble(container, forKey: .consumeCashAfterVoucher) self.createdAt = MiniMaxDecoding.decodeInt(container, forKey: .createdAt) self.ymd = try container.decodeIfPresent(String.self, forKey: .ymd) self.consumeTime = try container.decodeIfPresent(String.self, forKey: .consumeTime) self.method = try container.decodeIfPresent(String.self, forKey: .method) self.model = try container.decodeIfPresent(String.self, forKey: .model) self.result = Self.decodeOptionalScalarString(container, forKey: .result) self.status = Self.decodeOptionalScalarString(container, forKey: .status) } var recordResult: String? { if let result = self.result?.trimmingCharacters(in: .whitespacesAndNewlines), !result.isEmpty { return result } if let status = self.status?.trimmingCharacters(in: .whitespacesAndNewlines), !status.isEmpty { return status } return nil } var tokenCount: Int { if let consumeToken, consumeToken > 0 { return consumeToken } return max(0, (self.consumeInputToken ?? 0) + (self.consumeOutputToken ?? 0)) } var cashValue: Double? { self.consumeCashAfterVoucher ?? self.consumeCash } private static func decodeOptionalScalarString( _ container: KeyedDecodingContainer, forKey key: K) -> String? { if let value = try? container.decodeIfPresent(String.self, forKey: key) { return value } if let value = try? container.decodeIfPresent(Int.self, forKey: key) { return String(value) } if let value = try? container.decodeIfPresent(Double.self, forKey: key) { return String(value) } if let value = try? container.decodeIfPresent(Bool.self, forKey: key) { return String(value) } return nil } } enum MiniMaxBillingHistoryParser { static func decodePayload(data: Data) throws -> MiniMaxBillingHistoryPayload { try JSONDecoder().decode(MiniMaxBillingHistoryPayload.self, from: data) } static func parse( data: Data, now: Date = Date(), calendar: Calendar = .current) throws -> MiniMaxBillingSummary { let payload = try self.decodePayload(data: data) if let status = payload.baseResp?.statusCode, status != 0 { let message = payload.baseResp?.statusMessage ?? "status_code \(status)" throw MiniMaxUsageError.apiError(message) } guard !payload.chargeRecords.isEmpty || (payload.totalCount ?? 0) == 0 else { throw MiniMaxUsageError.parseFailed("Missing MiniMax billing records.") } return self.aggregate(records: payload.chargeRecords, now: now, calendar: calendar) } static func aggregate( records: [MiniMaxBillingRecord], now: Date = Date(), calendar inputCalendar: Calendar = .current) -> MiniMaxBillingSummary { var calendar = inputCalendar calendar.timeZone = inputCalendar.timeZone let startOfToday = calendar.startOfDay(for: now) let startOf30Days = calendar.date(byAdding: .day, value: -29, to: startOfToday) ?? startOfToday var daily: [String: (date: Date, tokens: Int, cash: Double, hasCash: Bool)] = [:] var methodTotals: [String: (tokens: Int, cash: Double, hasCash: Bool)] = [:] var modelTotals: [String: (tokens: Int, cash: Double, hasCash: Bool)] = [:] for record in records { if let recordResult = record.recordResult, recordResult.caseInsensitiveCompare("SUCCESS") != .orderedSame { continue } guard let date = self.recordDate(record, calendar: calendar), date >= startOf30Days, date <= now else { continue } let day = self.dayString(date, calendar: calendar) let tokens = record.tokenCount let cash = record.cashValue var bucket = daily[day] ?? (calendar.startOfDay(for: date), 0, 0, false) bucket.tokens += tokens if let cash { bucket.cash += cash bucket.hasCash = true } daily[day] = bucket self.add(record, tokens: tokens, cash: cash, keyPath: \.method, totals: &methodTotals) self.add(record, tokens: tokens, cash: cash, keyPath: \.model, totals: &modelTotals) } let sortedDays = daily .sorted { $0.value.date < $1.value.date } .map { key, value in MiniMaxBillingDay( day: key, tokens: value.tokens, cash: value.hasCash ? value.cash : nil) } let todayKey = self.dayString(now, calendar: calendar) let today = daily[todayKey] let last30Tokens = sortedDays.reduce(0) { $0 + $1.tokens } let last30CashValues = sortedDays.compactMap(\.cash) let last30Cash = last30CashValues.isEmpty ? nil : last30CashValues.reduce(0, +) return MiniMaxBillingSummary( todayTokens: today?.tokens ?? 0, last30DaysTokens: last30Tokens, todayCash: (today?.hasCash == true) ? today?.cash : nil, last30DaysCash: last30Cash, daily: sortedDays, topMethods: self.breakdowns(from: methodTotals), topModels: self.breakdowns(from: modelTotals), updatedAt: now) } private static func add( _ record: MiniMaxBillingRecord, tokens: Int, cash: Double?, keyPath: KeyPath, totals: inout [String: (tokens: Int, cash: Double, hasCash: Bool)]) { let rawName = record[keyPath: keyPath]?.trimmingCharacters(in: .whitespacesAndNewlines) guard let rawName, !rawName.isEmpty else { return } var total = totals[rawName] ?? (0, 0, false) total.tokens += tokens if let cash { total.cash += cash total.hasCash = true } totals[rawName] = total } private static func breakdowns(from totals: [String: (tokens: Int, cash: Double, hasCash: Bool)]) -> [MiniMaxBillingBreakdown] { totals .map { name, value in MiniMaxBillingBreakdown( name: name, tokens: value.tokens, cash: value.hasCash ? value.cash : nil) } .sorted { if $0.tokens == $1.tokens { return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } return $0.tokens > $1.tokens } .prefix(3) .map(\.self) } static func containsRecordBefore30DayWindow( _ records: [MiniMaxBillingRecord], now: Date = Date(), calendar inputCalendar: Calendar = .current) -> Bool { var calendar = inputCalendar calendar.timeZone = inputCalendar.timeZone let startOfToday = calendar.startOfDay(for: now) let startOf30Days = calendar.date(byAdding: .day, value: -29, to: startOfToday) ?? startOfToday return records.contains { record in guard let date = self.recordDate(record, calendar: calendar) else { return false } return date < startOf30Days } } private static func recordDate(_ record: MiniMaxBillingRecord, calendar: Calendar) -> Date? { if let createdAt = record.createdAt { let interval = createdAt > 1_000_000_000_000 ? TimeInterval(createdAt) / 1000 : TimeInterval(createdAt) return Date(timeIntervalSince1970: interval) } if let ymd = record.ymd { return self.parseDateOnly(ymd, calendar: calendar) } if let consumeTime = record.consumeTime { return self.parseDate(consumeTime, formats: [ "yyyy-MM-dd HH:mm:ss", "yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ssXXXXX", ]) } return nil } private static func parseDateOnly(_ text: String, calendar: Calendar) -> Date? { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.calendar = calendar formatter.timeZone = calendar.timeZone for format in ["yyyy-MM-dd", "yyyyMMdd", "yyyy/MM/dd"] { formatter.dateFormat = format if let date = formatter.date(from: trimmed) { return calendar.startOfDay(for: date) } } return nil } private static func parseDate(_ text: String, formats: [String]) -> Date? { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) for format in formats { formatter.dateFormat = format if let date = formatter.date(from: trimmed) { return date } } return nil } private static func dayString(_ date: Date, calendar: Calendar) -> String { let components = calendar.dateComponents([.year, .month, .day], from: date) guard let year = components.year, let month = components.month, let day = components.day else { return "" } return String(format: "%04d-%02d-%02d", year, month, day) } }