Files
steipete--codexbar/Sources/CodexBarCore/Providers/MiniMax/MiniMaxBillingHistory.swift
T
2026-07-13 12:22:33 +08:00

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)
}
}