diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index fd0b70df0..060e9d441 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -52,6 +52,17 @@ extension UsageMenuCardView.Model { ] } + if input.provider == .deepseek, + let usage = input.snapshot?.deepseekUsage + { + let symbol = usage.currency == "CNY" ? "¥" : "$" + let todayCostStr = usage.todayCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—" + return [ + "Today: \(todayCostStr) · \(UsageFormatter.tokenCountString(usage.todayTokens)) tokens", + "This month: \(UsageFormatter.tokenCountString(usage.currentMonthTokens)) tokens", + ] + } + if input.provider == .ollama, input.snapshot?.identity?.loginMethod == "API key" { @@ -115,6 +126,12 @@ extension UsageMenuCardView.Model { { return Self.minimaxInlineDashboard(billing) } + if input.provider == .deepseek, + let usage = input.snapshot?.deepseekUsage, + !usage.daily.isEmpty + { + return Self.deepseekInlineDashboard(usage) + } if [.codex, .claude, .vertexai, .bedrock].contains(input.provider), input.tokenCostUsageEnabled, let tokenSnapshot = input.tokenSnapshot, @@ -422,6 +439,59 @@ extension UsageMenuCardView.Model { detailLines: details) } + private static func deepseekInlineDashboard(_ usage: DeepSeekUsageSummary) -> InlineUsageDashboardModel { + let symbol = usage.currency == "CNY" ? "¥" : "$" + let points = usage.daily.suffix(30).map { + InlineUsageDashboardModel.Point( + id: $0.date, + label: Self.shortDayLabel($0.date), + value: Double($0.totalTokens), + accessibilityValue: "\($0.date): \(UsageFormatter.tokenCountString($0.totalTokens)) tokens") + } + var details: [String] = [] + if let topModel = usage.topModel { + details.append("Top model: \(Self.shortModelName(topModel))") + } + if let cacheHit = usage.categoryBreakdown.first(where: { $0.category == .promptCacheHitToken }) { + details.append("cache-hit input: \(UsageFormatter.tokenCountString(cacheHit.tokens))") + } + if let cacheMiss = usage.categoryBreakdown.first(where: { $0.category == .promptCacheMissToken }) { + details.append("cache-miss input: \(UsageFormatter.tokenCountString(cacheMiss.tokens))") + } + if let output = usage.categoryBreakdown.first(where: { $0.category == .responseToken }) { + details.append("output: \(UsageFormatter.tokenCountString(output.tokens))") + } + details.append("requests: \(usage.currentMonthRequestCount)") + + let todayCostStr = usage.todayCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—" + let monthCostStr = usage.currentMonthCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—" + let monthTokensStr = UsageFormatter.tokenCountString(usage.currentMonthTokens) + + return InlineUsageDashboardModel( + accessibilityLabel: "DeepSeek 30 day token usage trend", + valueStyle: .tokens, + kpis: [ + .init( + title: "Today", + value: "\(todayCostStr) · \(UsageFormatter.tokenCountString(usage.todayTokens))", + emphasis: true), + .init( + title: "This month", + value: "\(monthCostStr) · \(monthTokensStr)", + emphasis: false), + .init( + title: "Models", + value: usage.topModel.map { Self.shortModelName($0) } ?? "—", + emphasis: false), + .init( + title: "Requests", + value: "\(usage.currentMonthRequestCount)", + emphasis: false), + ], + points: points, + detailLines: details) + } + private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? { var tokens: [String: Int] = [:] for entry in entries { diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift index 56db22889..fec26d578 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -54,7 +54,10 @@ struct DeepSeekAPIFetchStrategy: ProviderFetchStrategy { guard let apiKey = Self.resolveToken(environment: context.env) else { throw DeepSeekUsageError.missingCredentials } - let usage = try await DeepSeekUsageFetcher.fetchUsage(apiKey: apiKey) + let usage = try await DeepSeekUsageFetcher.fetchUsage( + apiKey: apiKey, + includeOptionalUsage: context.includeOptionalUsage) + return self.makeResult( usage: usage.toUsageSnapshot(), sourceLabel: "api") diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift new file mode 100644 index 000000000..21e0902a3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift @@ -0,0 +1,708 @@ +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 + + init( + dailyAmounts: [DeepSeekDayUsage], + dailyCosts: [DeepSeekCostDayUsage], + now: Date, + calendar: Calendar) + { + var cal = calendar + cal.timeZone = cal.timeZone + self.calendar = cal + self.now = now + self.todayString = Self.dayString(now, calendar: cal) + + var components = cal.dateComponents([.year, .month], from: now) + components.day = 1 + self.startOfMonth = cal.date(from: components) ?? now + + self.dailyAmountMap = Self.buildAmountMap(from: dailyAmounts) + self.dailyCostMap = Self.buildCostMap(from: dailyCosts) + + var dates: Set = [] + 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 + 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 { $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) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift index 7365c1cd5..cc4e37b66 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -37,6 +37,7 @@ public struct DeepSeekUsageSnapshot: Sendable { public let totalBalance: Double public let grantedBalance: Double public let toppedUpBalance: Double + public let usageSummary: DeepSeekUsageSummary? public let updatedAt: Date public init( @@ -45,6 +46,7 @@ public struct DeepSeekUsageSnapshot: Sendable { totalBalance: Double, grantedBalance: Double, toppedUpBalance: Double, + usageSummary: DeepSeekUsageSummary? = nil, updatedAt: Date) { self.isAvailable = isAvailable @@ -52,6 +54,7 @@ public struct DeepSeekUsageSnapshot: Sendable { self.totalBalance = totalBalance self.grantedBalance = grantedBalance self.toppedUpBalance = toppedUpBalance + self.usageSummary = usageSummary self.updatedAt = updatedAt } @@ -90,6 +93,7 @@ public struct DeepSeekUsageSnapshot: Sendable { secondary: nil, tertiary: nil, providerCost: nil, + deepseekUsage: self.usageSummary, updatedAt: self.updatedAt, identity: identity) } @@ -122,13 +126,86 @@ public enum DeepSeekUsageError: LocalizedError, Sendable { public struct DeepSeekUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.deepSeekUsage) private static let balanceURL = URL(string: "https://api.deepseek.com/user/balance")! + private static let usageAmountURL = URL(string: "https://platform.deepseek.com/api/v0/usage/amount")! + private static let usageCostURL = URL(string: "https://platform.deepseek.com/api/v0/usage/cost")! private static let timeoutSeconds: TimeInterval = 15 + private static let optionalSummaryJoinGrace: Duration = .seconds(2) - public static func fetchUsage(apiKey: String) async throws -> DeepSeekUsageSnapshot { + public static func fetchUsage( + apiKey: String, + includeOptionalUsage: Bool = true) async throws -> DeepSeekUsageSnapshot + { + try await self.fetchUsage( + apiKey: apiKey, + includeOptionalUsage: includeOptionalUsage, + optionalSummaryJoinGrace: self.optionalSummaryJoinGrace, + fetchBalanceData: { key in + try await self.fetchBalanceData(apiKey: key) + }, + fetchSummary: { key in + try await self.fetchUsageSummary(apiKey: key) + }) + } + + static func _fetchUsageForTesting( + apiKey: String, + includeOptionalUsage: Bool, + optionalSummaryJoinGrace: Duration = .zero, + fetchBalanceData: @escaping @Sendable (String) async throws -> Data, + fetchSummary: @escaping @Sendable (String) async throws -> DeepSeekUsageSummary) + async throws -> DeepSeekUsageSnapshot + { + try await self.fetchUsage( + apiKey: apiKey, + includeOptionalUsage: includeOptionalUsage, + optionalSummaryJoinGrace: optionalSummaryJoinGrace, + fetchBalanceData: fetchBalanceData, + fetchSummary: fetchSummary) + } + + private static func fetchUsage( + apiKey: String, + includeOptionalUsage: Bool, + optionalSummaryJoinGrace: Duration, + fetchBalanceData: @escaping @Sendable (String) async throws -> Data, + fetchSummary: @escaping @Sendable (String) async throws -> DeepSeekUsageSummary) + async throws -> DeepSeekUsageSnapshot + { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw DeepSeekUsageError.missingCredentials } + let summaryTask: Task? = if includeOptionalUsage { + Task { + try await fetchSummary(apiKey) + } + } else { + nil + } + + let balanceData = try await fetchBalanceData(apiKey) + var snapshot = try Self.parseSnapshot(data: balanceData) + + if let summaryTask { + let summary = await self.completedOptionalUsageSummary( + from: summaryTask, + joinGrace: optionalSummaryJoinGrace) + if let summary { + snapshot = DeepSeekUsageSnapshot( + isAvailable: snapshot.isAvailable, + currency: snapshot.currency, + totalBalance: snapshot.totalBalance, + grantedBalance: snapshot.grantedBalance, + toppedUpBalance: snapshot.toppedUpBalance, + usageSummary: summary, + updatedAt: snapshot.updatedAt) + } + } + + return snapshot + } + + private static func fetchBalanceData(apiKey: String) async throws -> Data { var request = URLRequest(url: self.balanceURL) request.httpMethod = "GET" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") @@ -138,22 +215,139 @@ public struct DeepSeekUsageFetcher: Sendable { let response = try await ProviderHTTPClient.shared.response(for: request) let data = response.data guard response.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) ?? "" - Self.log.error("DeepSeek API returned \(response.statusCode): \(body)") + Self.log.error("DeepSeek balance endpoint returned HTTP \(response.statusCode)") throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") } - if let jsonString = String(data: data, encoding: .utf8) { - Self.log.debug("DeepSeek API response: \(jsonString)") - } + return data + } + + private static func completedOptionalUsageSummary( + from task: Task, + joinGrace: Duration) async -> DeepSeekUsageSummary? + { + do { + return try await withThrowingTaskGroup(of: DeepSeekUsageSummary?.self) { group in + group.addTask { + try await task.value + } + group.addTask { + if joinGrace > .zero { + try await Task.sleep(for: joinGrace) + } + return nil + } - return try Self.parseSnapshot(data: data) + let result = try await group.next().flatMap(\.self) + group.cancelAll() + if result == nil { + task.cancel() + } + return result + } + } catch { + task.cancel() + return nil + } } static func _parseSnapshotForTesting(_ data: Data) throws -> DeepSeekUsageSnapshot { try self.parseSnapshot(data: data) } + public static func fetchUsageSummary( + apiKey: String, + now: Date = Date(), + calendar: Calendar = .current) async throws -> DeepSeekUsageSummary + { + let monthComponents = calendar.dateComponents([.month, .year], from: now) + guard let month = monthComponents.month, let year = monthComponents.year else { + throw DeepSeekUsageError.parseFailed("Could not determine current month/year") + } + + let amountData = try await self.fetchAmount(apiKey: apiKey, month: month, year: year) + let costData = try await self.fetchCost(apiKey: apiKey, month: month, year: year) + + return try DeepSeekUsageCostParser.parse( + amountData: amountData, + costData: costData, + now: now, + calendar: calendar) + } + + private static func fetchAmount(apiKey: String, month: Int, year: Int) async throws -> Data { + guard var components = URLComponents(url: self.usageAmountURL, resolvingAgainstBaseURL: false) else { + throw DeepSeekUsageError.networkError("Invalid URL") + } + components.queryItems = [ + URLQueryItem(name: "month", value: String(month)), + URLQueryItem(name: "year", value: String(year)), + ] + guard let url = components.url else { + throw DeepSeekUsageError.networkError("Could not construct URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw DeepSeekUsageError.missingCredentials + } + throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") + } + + return data + } + + private static func fetchCost(apiKey: String, month: Int, year: Int) async throws -> Data { + guard var components = URLComponents(url: self.usageCostURL, resolvingAgainstBaseURL: false) else { + throw DeepSeekUsageError.networkError("Invalid URL") + } + components.queryItems = [ + URLQueryItem(name: "month", value: String(month)), + URLQueryItem(name: "year", value: String(year)), + ] + guard let url = components.url else { + throw DeepSeekUsageError.networkError("Could not construct URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw DeepSeekUsageError.missingCredentials + } + throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") + } + + return data + } + + static func _parseUsageSummaryForTesting( + amountData: Data, + costData: Data, + now: Date = Date(), + calendar: Calendar = .current) throws -> DeepSeekUsageSummary + { + try DeepSeekUsageCostParser.parse( + amountData: amountData, + costData: costData, + now: now, + calendar: calendar) + } + private static func parseSnapshot(data: Data) throws -> DeepSeekUsageSnapshot { let decoded: DeepSeekBalanceResponse do { diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 91a6a9ab9..471f1147f 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -88,6 +88,7 @@ public struct UsageSnapshot: Codable, Sendable { public let kiroUsage: KiroUsageDetails? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? + public let deepseekUsage: DeepSeekUsageSummary? public let openRouterUsage: OpenRouterUsageSnapshot? public let openAIAPIUsage: OpenAIAPIUsageSnapshot? public let claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? @@ -125,6 +126,7 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, + deepseekUsage: DeepSeekUsageSummary? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, claudeAdminAPIUsage: ClaudeAdminAPIUsageSnapshot? = nil, @@ -142,6 +144,7 @@ public struct UsageSnapshot: Codable, Sendable { self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage + self.deepseekUsage = deepseekUsage self.openRouterUsage = openRouterUsage self.openAIAPIUsage = openAIAPIUsage self.claudeAdminAPIUsage = claudeAdminAPIUsage @@ -162,6 +165,7 @@ public struct UsageSnapshot: Codable, Sendable { self.kiroUsage = try container.decodeIfPresent(KiroUsageDetails.self, forKey: .kiroUsage) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time + self.deepseekUsage = nil // Not persisted, fetched fresh each time self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) self.claudeAdminAPIUsage = try container.decodeIfPresent( @@ -299,6 +303,7 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, + deepseekUsage: self.deepseekUsage, openRouterUsage: self.openRouterUsage, openAIAPIUsage: self.openAIAPIUsage, claudeAdminAPIUsage: self.claudeAdminAPIUsage, @@ -333,6 +338,7 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, + deepseekUsage: self.deepseekUsage, openRouterUsage: self.openRouterUsage, openAIAPIUsage: self.openAIAPIUsage, claudeAdminAPIUsage: self.claudeAdminAPIUsage, diff --git a/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift new file mode 100644 index 000000000..c7cc32b92 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift @@ -0,0 +1,855 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekUsageCostParserTests { + // Fixtures use date 2026-05-26 + private let fixtureNow = Date(timeIntervalSince1970: 1_779_796_800) // 2026-05-26 12:00:00 UTC + private let fixtureCalendar: Calendar = { + var cal = Calendar.current + cal.timeZone = TimeZone(identifier: "UTC") ?? .current + return cal + }() + + // MARK: - Amount Parser Tests + + @Test + func `amount parser decodes total and days`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1305432"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"}, + {"type": "REQUEST", "amount": "1212"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1305432"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"}, + {"type": "REQUEST", "amount": "1212"} + ] + } + ] + } + ] + } + } + } + """ + let payload = try DeepSeekUsageCostParser.decodeAmountPayload(data: Data(json.utf8)) + #expect(payload.code == 0) + #expect(payload.data?.bizCode == 0) + #expect(payload.data?.bizData?.total?.count == 1) + #expect(payload.data?.bizData?.total?[0].model == "deepseek-v4-flash") + #expect(payload.data?.bizData?.days?.count == 1) + #expect(payload.data?.bizData?.days?[0].date == "2026-05-26") + } + + @Test + func `amount parser handles missing biz_data gracefully`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": null + } + """ + let payload = try DeepSeekUsageCostParser.decodeAmountPayload(data: Data(json.utf8)) + #expect(payload.code == 0) + #expect(payload.data?.bizData?.total == nil) + #expect(payload.data?.bizData?.days == nil) + } + + // MARK: - Cost Parser Tests + + @Test + func `cost parser decodes total, days, and currency`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.3054320000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"}, + {"type": "REQUEST", "amount": "0"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.3054320000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"}, + {"type": "REQUEST", "amount": "0"} + ] + } + ] + } + ], + "currency": "CNY" + } + ] + } + } + """ + let payload = try DeepSeekUsageCostParser.decodeCostPayload(data: Data(json.utf8)) + #expect(payload.code == 0) + #expect(payload.data?.bizCode == 0) + #expect(payload.data?.bizData?[0].currency == "CNY") + #expect(payload.data?.bizData?[0].total?.count == 1) + #expect(payload.data?.bizData?[0].days?.count == 1) + #expect(payload.data?.bizData?[0].days?[0].date == "2026-05-26") + } + + @Test + func `cost parser handles empty biz_data`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + let payload = try DeepSeekUsageCostParser.decodeCostPayload(data: Data(json.utf8)) + #expect(payload.code == 0) + #expect(payload.data?.bizData?.isEmpty == true) + } + + // MARK: - String Parsing Tests + + @Test + func `string token parsing works`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"} + ] + } + ], + "days": [] + } + } + } + """ + let payload = try DeepSeekUsageCostParser.decodeAmountPayload(data: Data(json.utf8)) + #expect(payload.data?.bizData?.total?[0].usage?[0].type == "PROMPT_CACHE_HIT_TOKEN") + #expect(payload.data?.bizData?.total?[0].usage?[0].amount == "100686720") + } + + @Test + func `decimal cost parsing works`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"} + ] + } + ], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + let payload = try DeepSeekUsageCostParser.decodeCostPayload(data: Data(json.utf8)) + #expect(payload.data?.bizData?[0].total?[0].usage?[0].amount == "2.0137344000000000") + } + + // MARK: - Aggregation Tests + + @Test + func `aggregation computes today token totals`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1305432"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"}, + {"type": "REQUEST", "amount": "1212"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1305432"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"}, + {"type": "REQUEST", "amount": "1212"} + ] + } + ] + } + ] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.3054320000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"}, + {"type": "REQUEST", "amount": "0"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.3054320000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"}, + {"type": "REQUEST", "amount": "0"} + ] + } + ] + } + ], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + // Today is 2026-05-26 per the test data + #expect(summary.todayTokens == 102_648_490) // 100_686_720 + 1_305_432 + 656_338 + #expect(summary.requestCount == 1212) + #expect(summary.currency == "CNY") + } + + @Test + func `aggregation uses injected now and calendar for today bucket`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100"}, + {"type": "REQUEST", "amount": "1"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100"}, + {"type": "REQUEST", "amount": "1"} + ] + } + ] + } + ] + } + } + } + """ + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + var nextMonthUTC = Calendar(identifier: .gregorian) + nextMonthUTC.timeZone = TimeZone(identifier: "UTC") ?? .current + let injectedNow = try #require(nextMonthUTC.date(from: DateComponents(year: 2026, month: 6, day: 1, hour: 12))) + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: injectedNow, + calendar: nextMonthUTC) + + #expect(summary.todayTokens == 0) + #expect(summary.currentMonthTokens == 0) + } + + @Test + func `aggregation computes today cost totals`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"} + ] + } + ] + } + ] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"} + ] + } + ] + } + ], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + #expect(abs((summary.todayCost ?? 0) - 3.3264104) < 0.0001) + #expect(summary.currentMonthCost != nil) + } + + @Test + func `aggregation computes model and category breakdown`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1305432"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"} + ] + } + ], + "days": [] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.3054320000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"} + ] + } + ], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + #expect(summary.topModel == "deepseek-v4-flash") + #expect(summary.categoryBreakdown.count == 3) + + let cacheHit = summary.categoryBreakdown.first { $0.category == DeepSeekUsageCategory.promptCacheHitToken } + #expect(cacheHit?.tokens == 100_686_720) + #expect(abs((cacheHit?.cost ?? 0) - 2.0137344) < 0.0001) + + let cacheMiss = summary.categoryBreakdown.first { $0.category == DeepSeekUsageCategory.promptCacheMissToken } + #expect(cacheMiss?.tokens == 1_305_432) + #expect(abs((cacheMiss?.cost ?? 0) - 1.305432) < 0.0001) + + let response = summary.categoryBreakdown.first { $0.category == DeepSeekUsageCategory.responseToken } + #expect(response?.tokens == 656_338) + #expect(abs((response?.cost ?? 0) - 1.312676) < 0.0001) + } + + // MARK: - Unknown Types Handling + + @Test + func `unknown usage types are ignored safely`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100"}, + {"type": "UNKNOWN_TYPE", "amount": "999"}, + {"type": "RESPONSE_TOKEN", "amount": "200"} + ] + } + ], + "days": [] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "1.0"}, + {"type": "UNKNOWN_TYPE", "amount": "99.0"}, + {"type": "RESPONSE_TOKEN", "amount": "2.0"} + ] + } + ], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + // Unknown type should be ignored - only known categories with non-zero tokens appear in breakdown + // todayTokens comes from daily data which is empty in this test, so it's 0 + #expect(summary.todayTokens == 0) + #expect(summary.categoryBreakdown.count == 3) // Always 3 categories, even if some have 0 tokens + } + + // MARK: - Error Handling + + @Test + func `missing fields fails closed`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": null + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: fixtureNow, + calendar: fixtureCalendar) + } throws: { error in + guard case DeepSeekUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `non-zero biz_code fails closed`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 1001, + "biz_msg": "some error", + "biz_data": { + "total": [], + "days": [] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8)) + } throws: { error in + guard case DeepSeekUsageError.apiError = error else { return false } + return true + } + } + + @Test + func `invalid JSON fails closed`() { + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data("not json".utf8), + costData: Data("{}".utf8)) + } throws: { error in + guard case DeepSeekUsageError.parseFailed = error else { return false } + return true + } + } + + // MARK: - Edge Cases + + @Test + func `empty days array works`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [], + "days": [] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + #expect(summary.todayTokens == 0) + #expect(summary.currentMonthTokens == 0) + #expect(summary.todayCost == nil) + #expect(summary.currentMonthCost == nil) + #expect(summary.daily.isEmpty) + } + + @Test + func `multiple models works`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100"}, + {"type": "RESPONSE_TOKEN", "amount": "50"} + ] + }, + { + "model": "deepseek-chat", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "200"}, + {"type": "RESPONSE_TOKEN", "amount": "100"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100"}, + {"type": "RESPONSE_TOKEN", "amount": "50"} + ] + }, + { + "model": "deepseek-chat", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "200"}, + {"type": "RESPONSE_TOKEN", "amount": "100"} + ] + } + ] + } + ] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "1.0"}, + {"type": "RESPONSE_TOKEN", "amount": "0.5"} + ] + }, + { + "model": "deepseek-chat", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0"}, + {"type": "RESPONSE_TOKEN", "amount": "1.0"} + ] + } + ], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + #expect(summary.topModel == "deepseek-chat") // 300 tokens vs 150 tokens + #expect(summary.todayTokens == 450) // 150 + 300 + } +} diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift index 8bac9bd25..8646834d4 100644 --- a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -3,6 +3,37 @@ import Testing @testable import CodexBarCore struct DeepSeekUsageFetcherTests { + private static let sampleBalanceJSON = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "50.00", + "granted_balance": "10.00", + "topped_up_balance": "40.00" + } + ] + } + """ + + private static func sampleSummary(updatedAt: Date = Date()) -> DeepSeekUsageSummary { + DeepSeekUsageSummary( + todayTokens: 123, + currentMonthTokens: 456, + todayCost: 1.23, + currentMonthCost: 4.56, + requestCount: 7, + currentMonthRequestCount: 8, + topModel: "deepseek-v4-flash", + categoryBreakdown: [ + DeepSeekCategoryBreakdown(category: .promptCacheHitToken, tokens: 123, cost: 1.23), + ], + daily: [], + currency: "USD", + updatedAt: updatedAt) + } + @Test func `parses USD balance response`() throws { let json = """ @@ -237,4 +268,80 @@ struct DeepSeekUsageFetcherTests { let detail = usage.primary?.resetDescription ?? "" #expect(detail.contains("¥")) } + + @Test + func `balance snapshot has nil usage summary`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "50.00", + "granted_balance": "10.00", + "topped_up_balance": "40.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.deepseekUsage == nil) + } + + @Test + func `balance returns promptly when optional usage summary is slow`() async throws { + let startedAt = Date() + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(2), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + try await Task.sleep(for: .seconds(3)) + return Self.sampleSummary() + }) + + let elapsed = Date().timeIntervalSince(startedAt) + #expect(elapsed < 2.5) + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + } + + @Test + func `balance returns when optional usage summary fails closed`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(2), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + throw DeepSeekUsageError.networkError("simulated failure") + }) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + } + + @Test + func `production path can populate usage summary when optional fetch succeeds`() async throws { + let expected = Self.sampleSummary() + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(2), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + expected + }) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == expected) + } }