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

710 lines
23 KiB
Swift

import Foundation
// MARK: - Amount Response Models
struct DeepSeekAmountPayload: Decodable {
let code: Int?
let msg: String?
let data: DeepSeekAmountData?
private enum CodingKeys: String, CodingKey {
case code, msg, data
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.code = try container.decodeIfPresent(Int.self, forKey: .code)
self.msg = try container.decodeIfPresent(String.self, forKey: .msg)
if container.contains(.data) {
if let dataValue = try? container.decodeIfPresent(DeepSeekAmountData.self, forKey: .data) {
self.data = dataValue
} else {
self.data = nil
}
} else {
self.data = nil
}
}
}
struct DeepSeekAmountData: Decodable {
let bizCode: Int?
let bizMsg: String?
let bizData: DeepSeekAmountBizData?
private enum CodingKeys: String, CodingKey {
case bizCode = "biz_code"
case bizMsg = "biz_msg"
case bizData = "biz_data"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.bizCode = try container.decodeIfPresent(Int.self, forKey: .bizCode)
self.bizMsg = try container.decodeIfPresent(String.self, forKey: .bizMsg)
if container.contains(.bizData) {
if let dataValue = try? container.decodeIfPresent(DeepSeekAmountBizData.self, forKey: .bizData) {
self.bizData = dataValue
} else {
self.bizData = nil
}
} else {
self.bizData = nil
}
}
}
struct DeepSeekAmountBizData: Decodable {
let total: [DeepSeekModelUsage]?
let days: [DeepSeekDayUsage]?
private enum CodingKeys: String, CodingKey {
case total, days
}
}
// MARK: - Cost Response Models
struct DeepSeekCostPayload: Decodable {
let code: Int?
let msg: String?
let data: DeepSeekCostData?
private enum CodingKeys: String, CodingKey {
case code, msg, data
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.code = try container.decodeIfPresent(Int.self, forKey: .code)
self.msg = try container.decodeIfPresent(String.self, forKey: .msg)
if container.contains(.data) {
if let dataValue = try? container.decodeIfPresent(DeepSeekCostData.self, forKey: .data) {
self.data = dataValue
} else {
self.data = nil
}
} else {
self.data = nil
}
}
}
struct DeepSeekCostData: Decodable {
let bizCode: Int?
let bizMsg: String?
let bizData: [DeepSeekCostBizDataItem]?
private enum CodingKeys: String, CodingKey {
case bizCode = "biz_code"
case bizMsg = "biz_msg"
case bizData = "biz_data"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.bizCode = try container.decodeIfPresent(Int.self, forKey: .bizCode)
self.bizMsg = try container.decodeIfPresent(String.self, forKey: .bizMsg)
self.bizData = try container.decodeIfPresent([DeepSeekCostBizDataItem].self, forKey: .bizData)
}
}
struct DeepSeekCostBizDataItem: Decodable {
let total: [DeepSeekCostModelUsage]?
let days: [DeepSeekCostDayUsage]?
let currency: String?
private enum CodingKeys: String, CodingKey {
case total, days, currency
}
}
// MARK: - Shared Models
struct DeepSeekModelUsage: Decodable {
let model: String?
let usage: [DeepSeekUsageItem]?
private enum CodingKeys: String, CodingKey {
case model, usage
}
}
struct DeepSeekDayUsage: Decodable {
let date: String?
let data: [DeepSeekModelUsage]?
private enum CodingKeys: String, CodingKey {
case date, data
}
}
struct DeepSeekUsageItem: Decodable {
let type: String?
let amount: String?
private enum CodingKeys: String, CodingKey {
case type, amount
}
}
struct DeepSeekCostModelUsage: Decodable {
let model: String?
let usage: [DeepSeekCostItem]?
private enum CodingKeys: String, CodingKey {
case model, usage
}
}
struct DeepSeekCostDayUsage: Decodable {
let date: String?
let data: [DeepSeekCostModelUsage]?
private enum CodingKeys: String, CodingKey {
case date, data
}
}
struct DeepSeekCostItem: Decodable {
let type: String?
let amount: String?
private enum CodingKeys: String, CodingKey {
case type, amount
}
}
// MARK: - Domain Models
public struct DeepSeekUsageSummary: Sendable, Equatable {
public let todayTokens: Int
public let currentMonthTokens: Int
public let todayCost: Double?
public let currentMonthCost: Double?
public let requestCount: Int
public let currentMonthRequestCount: Int
public let topModel: String?
public let categoryBreakdown: [DeepSeekCategoryBreakdown]
public let daily: [DeepSeekDailyUsage]
public let currency: String
public let updatedAt: Date
public init(
todayTokens: Int,
currentMonthTokens: Int,
todayCost: Double?,
currentMonthCost: Double?,
requestCount: Int,
currentMonthRequestCount: Int,
topModel: String?,
categoryBreakdown: [DeepSeekCategoryBreakdown],
daily: [DeepSeekDailyUsage],
currency: String,
updatedAt: Date)
{
self.todayTokens = todayTokens
self.currentMonthTokens = currentMonthTokens
self.todayCost = todayCost
self.currentMonthCost = currentMonthCost
self.requestCount = requestCount
self.currentMonthRequestCount = currentMonthRequestCount
self.topModel = topModel
self.categoryBreakdown = categoryBreakdown
self.daily = daily
self.currency = currency
self.updatedAt = updatedAt
}
}
public struct DeepSeekCategoryBreakdown: Sendable, Equatable {
public let category: DeepSeekUsageCategory
public let tokens: Int
public let cost: Double?
public init(category: DeepSeekUsageCategory, tokens: Int, cost: Double?) {
self.category = category
self.tokens = tokens
self.cost = cost
}
}
public enum DeepSeekUsageCategory: String, Sendable, Equatable {
case promptCacheHitToken = "PROMPT_CACHE_HIT_TOKEN"
case promptCacheMissToken = "PROMPT_CACHE_MISS_TOKEN"
case responseToken = "RESPONSE_TOKEN"
case request = "REQUEST"
public init?(rawValue: String) {
switch rawValue.uppercased() {
case "PROMPT_CACHE_HIT_TOKEN":
self = .promptCacheHitToken
case "PROMPT_CACHE_MISS_TOKEN":
self = .promptCacheMissToken
case "RESPONSE_TOKEN":
self = .responseToken
case "REQUEST":
self = .request
default:
return nil
}
}
}
public struct DeepSeekDailyUsage: Sendable, Equatable {
public let date: String
public let totalTokens: Int
public let cost: Double?
public let requestCount: Int
public init(date: String, totalTokens: Int, cost: Double?, requestCount: Int) {
self.date = date
self.totalTokens = totalTokens
self.cost = cost
self.requestCount = requestCount
}
}
// MARK: - Parsing
enum DeepSeekUsageCostParser {
static func decodeAmountPayload(data: Data) throws -> DeepSeekAmountPayload {
try JSONDecoder().decode(DeepSeekAmountPayload.self, from: data)
}
static func decodeCostPayload(data: Data) throws -> DeepSeekCostPayload {
try JSONDecoder().decode(DeepSeekCostPayload.self, from: data)
}
static func parse(
amountData: Data,
costData: Data,
now: Date = Date(),
calendar: Calendar = .current) throws -> DeepSeekUsageSummary
{
let amountPayload: DeepSeekAmountPayload
let costPayload: DeepSeekCostPayload
do {
amountPayload = try self.decodeAmountPayload(data: amountData)
} catch {
throw DeepSeekUsageError.parseFailed("amount: \(error.localizedDescription)")
}
do {
costPayload = try self.decodeCostPayload(data: costData)
} catch {
throw DeepSeekUsageError.parseFailed("cost: \(error.localizedDescription)")
}
// Validate responses
if let code = amountPayload.code, code != 0 {
throw DeepSeekUsageError.apiError("amount code \(code)")
}
if let bizCode = amountPayload.data?.bizCode, bizCode != 0 {
throw DeepSeekUsageError.apiError("amount biz_code \(bizCode)")
}
if let code = costPayload.code, code != 0 {
throw DeepSeekUsageError.apiError("cost code \(code)")
}
if let bizCode = costPayload.data?.bizCode, bizCode != 0 {
throw DeepSeekUsageError.apiError("cost biz_code \(bizCode)")
}
guard let amountBizData = amountPayload.data?.bizData else {
throw DeepSeekUsageError.parseFailed("Missing amount biz_data")
}
let currency = costPayload.data?.bizData?.first?.currency ?? "CNY"
// Parse total amounts
let totalAmounts = amountBizData.total ?? []
let totalCosts = costPayload.data?.bizData?.first?.total ?? []
// Parse daily data
let dailyAmounts = amountBizData.days ?? []
let dailyCosts = costPayload.data?.bizData?.first?.days ?? []
return self.aggregate(input: AggregationInput(
totalAmounts: totalAmounts,
totalCosts: totalCosts,
dailyAmounts: dailyAmounts,
dailyCosts: dailyCosts,
currency: currency,
now: now,
calendar: calendar))
}
// MARK: - Aggregation
private struct AggregationContext {
let calendar: Calendar
let todayString: String
let startOfMonth: Date
let now: Date
let dailyAmountMap: [String: [String: [DeepSeekUsageItem]]]
let dailyCostMap: [String: [String: [DeepSeekCostItem]]]
let allDates: Set<String>
init(
dailyAmounts: [DeepSeekDayUsage],
dailyCosts: [DeepSeekCostDayUsage],
now: Date,
calendar: Calendar)
{
self.calendar = calendar
self.now = now
self.todayString = Self.dayString(now, calendar: calendar)
var components = calendar.dateComponents([.year, .month], from: now)
components.day = 1
self.startOfMonth = calendar.date(from: components) ?? now
self.dailyAmountMap = Self.buildAmountMap(from: dailyAmounts)
self.dailyCostMap = Self.buildCostMap(from: dailyCosts)
var dates: Set<String> = []
for date in self.dailyAmountMap.keys {
dates.insert(date)
}
for date in self.dailyCostMap.keys {
dates.insert(date)
}
self.allDates = dates
}
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)
}
static func buildAmountMap(
from dailyAmounts: [DeepSeekDayUsage]) -> [String: [String: [DeepSeekUsageItem]]]
{
var result: [String: [String: [DeepSeekUsageItem]]] = [:]
for dayUsage in dailyAmounts {
guard let date = dayUsage.date else { continue }
var modelMap: [String: [DeepSeekUsageItem]] = [:]
for modelUsage in dayUsage.data ?? [] {
guard let model = modelUsage.model else { continue }
let items = modelUsage.usage ?? []
if !items.isEmpty {
modelMap[model] = items
}
}
if !modelMap.isEmpty {
result[date] = modelMap
}
}
return result
}
static func buildCostMap(
from dailyCosts: [DeepSeekCostDayUsage]) -> [String: [String: [DeepSeekCostItem]]]
{
var result: [String: [String: [DeepSeekCostItem]]] = [:]
for dayUsage in dailyCosts {
guard let date = dayUsage.date else { continue }
var modelMap: [String: [DeepSeekCostItem]] = [:]
for modelUsage in dayUsage.data ?? [] {
guard let model = modelUsage.model else { continue }
let items = modelUsage.usage ?? []
if !items.isEmpty {
modelMap[model] = items
}
}
if !modelMap.isEmpty {
result[date] = modelMap
}
}
return result
}
}
private struct AggregationInput {
let totalAmounts: [DeepSeekModelUsage]
let totalCosts: [DeepSeekCostModelUsage]
let dailyAmounts: [DeepSeekDayUsage]
let dailyCosts: [DeepSeekCostDayUsage]
let currency: String
let now: Date
let calendar: Calendar
}
private static func aggregate(input: AggregationInput) -> DeepSeekUsageSummary {
let ctx = AggregationContext(
dailyAmounts: input.dailyAmounts,
dailyCosts: input.dailyCosts,
now: input.now,
calendar: input.calendar)
// Today aggregation
let todayResult = self.aggregateDay(
dateString: ctx.todayString,
amountMap: ctx.dailyAmountMap,
costMap: ctx.dailyCostMap,
calendar: ctx.calendar)
// Month aggregation
let dailyCtx = DailyAggregationContext(
allDates: ctx.allDates,
startOfMonth: ctx.startOfMonth,
now: ctx.now,
amountMap: ctx.dailyAmountMap,
costMap: ctx.dailyCostMap,
calendar: ctx.calendar)
let monthResult = self.aggregateMonth(ctx: dailyCtx)
// Model and category breakdown from totals
let (topModel, categoryBreakdown) = self.buildBreakdowns(
totalAmounts: input.totalAmounts,
totalCosts: input.totalCosts)
// Daily usage array
let dailyUsages = self.buildDailyUsages(ctx: dailyCtx)
return DeepSeekUsageSummary(
todayTokens: todayResult.tokens,
currentMonthTokens: monthResult.tokens,
todayCost: todayResult.cost,
currentMonthCost: monthResult.cost,
requestCount: todayResult.requests,
currentMonthRequestCount: monthResult.requests,
topModel: topModel,
categoryBreakdown: categoryBreakdown,
daily: dailyUsages,
currency: input.currency,
updatedAt: input.now)
}
private struct DayAggregationResult {
let tokens: Int
let cost: Double?
let requests: Int
}
private struct DailyAggregationContext {
let allDates: Set<String>
let startOfMonth: Date
let now: Date
let amountMap: [String: [String: [DeepSeekUsageItem]]]
let costMap: [String: [String: [DeepSeekCostItem]]]
let calendar: Calendar
}
private static func aggregateDay(
dateString: String,
amountMap: [String: [String: [DeepSeekUsageItem]]],
costMap: [String: [String: [DeepSeekCostItem]]],
calendar: Calendar) -> DayAggregationResult
{
var tokens = 0
var cost: Double?
var requests = 0
if let amounts = amountMap[dateString] {
for items in amounts.values {
for item in items {
guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue }
if category == .request {
requests += self.parseTokenAmount(item.amount)
} else {
tokens += self.parseTokenAmount(item.amount)
}
}
}
}
if let costs = costMap[dateString] {
for items in costs.values {
for item in items {
guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue }
if category != .request {
let amount = Self.parseCostAmount(item.amount)
if let existing = cost {
cost = existing + amount
} else {
cost = amount
}
}
}
}
}
return DayAggregationResult(tokens: tokens, cost: cost, requests: requests)
}
private static func aggregateMonth(ctx: DailyAggregationContext) -> DayAggregationResult {
var tokens = 0
var cost: Double?
var requests = 0
for date in ctx.allDates {
guard let parsed = self.parseDate(date, calendar: ctx.calendar),
parsed >= ctx.startOfMonth,
parsed <= ctx.now
else { continue }
if let amounts = ctx.amountMap[date] {
for items in amounts.values {
for item in items {
guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue }
if category == .request {
requests += Self.parseTokenAmount(item.amount)
} else {
tokens += Self.parseTokenAmount(item.amount)
}
}
}
}
if let costs = ctx.costMap[date] {
for items in costs.values {
for item in items {
guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue }
if category != .request {
let amount = Self.parseCostAmount(item.amount)
if let existing = cost {
cost = existing + amount
} else {
cost = amount
}
}
}
}
}
}
return DayAggregationResult(tokens: tokens, cost: cost, requests: requests)
}
private static func buildBreakdowns(
totalAmounts: [DeepSeekModelUsage],
totalCosts: [DeepSeekCostModelUsage]) -> (String?, [DeepSeekCategoryBreakdown])
{
var modelTokens: [String: Int] = [:]
var categoryTokens: [DeepSeekUsageCategory: Int] = [:]
var categoryCosts: [DeepSeekUsageCategory: Double] = [:]
for modelUsage in totalAmounts {
guard let model = modelUsage.model else { continue }
var total = 0
for item in modelUsage.usage ?? [] {
guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue }
if category != .request {
let amount = Self.parseTokenAmount(item.amount)
total += amount
categoryTokens[category, default: 0] += amount
}
}
modelTokens[model] = total
}
for costUsage in totalCosts {
guard costUsage.model != nil else { continue }
for item in costUsage.usage ?? [] {
guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue }
if category != .request {
let amount = Self.parseCostAmount(item.amount)
categoryCosts[category, default: 0] += amount
}
}
}
let topModel = modelTokens.max {
if $0.value == $1.value { return $0.key > $1.key }
return $0.value < $1.value
}?.key
var breakdown: [DeepSeekCategoryBreakdown] = []
for category in [DeepSeekUsageCategory.promptCacheHitToken, .promptCacheMissToken, .responseToken] {
breakdown.append(DeepSeekCategoryBreakdown(
category: category,
tokens: categoryTokens[category] ?? 0,
cost: categoryCosts[category]))
}
return (topModel, breakdown)
}
private static func buildDailyUsages(ctx: DailyAggregationContext) -> [DeepSeekDailyUsage] {
var result: [DeepSeekDailyUsage] = []
for date in ctx.allDates.sorted() {
guard let parsed = self.parseDate(date, calendar: ctx.calendar),
parsed >= ctx.startOfMonth,
parsed <= ctx.now
else { continue }
var dayTokens = 0
var dayCost: Double?
var dayRequests = 0
if let amounts = ctx.amountMap[date] {
for items in amounts.values {
for item in items {
guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue }
if category == .request {
dayRequests += Self.parseTokenAmount(item.amount)
} else {
dayTokens += Self.parseTokenAmount(item.amount)
}
}
}
}
if let costs = ctx.costMap[date] {
for items in costs.values {
for item in items {
guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue }
if category != .request {
let amount = Self.parseCostAmount(item.amount)
if let existing = dayCost {
dayCost = existing + amount
} else {
dayCost = amount
}
}
}
}
}
result.append(DeepSeekDailyUsage(
date: date,
totalTokens: dayTokens,
cost: dayCost,
requestCount: dayRequests))
}
return result
}
// MARK: - Helpers
private static func parseTokenAmount(_ value: String?) -> Int {
guard let value, let intValue = Int64(value.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return 0
}
return Int(intValue)
}
private static func parseCostAmount(_ value: String?) -> Double {
guard let value else { return 0 }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return Double(trimmed) ?? 0
}
private static func parseDate(_ 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
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: trimmed)
}
}