361 lines
13 KiB
Swift
361 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|