360 lines · 13.3 KB · Swift
RawDownload
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<K: CodingKey>(        _ container: KeyedDecodingContainer<K>,        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<MiniMaxBillingRecord, String?>,        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)    }}