diff --git a/README.md b/README.md index 5e86f43181..fe47e58b82 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Sakana AI](docs/sakana.md) — Manual Cookie header for 5-hour and weekly quota windows. - [Abacus AI](docs/abacus.md) — Browser cookie auth for ChatLLM/RouteLLM compute credit tracking. - [Mistral](docs/mistral.md) — Browser cookies for API spend, credit balance, and monthly-plan usage. -- [DeepSeek](docs/deepseek.md) — API key for credit balance tracking (paid vs. granted breakdown). +- [DeepSeek](docs/deepseek.md) — credit balance plus optional rolling 5-hour/weekly token and spend totals. - [Fireworks](docs/fireworks.md) — API key + account slug for 30-day spend from the billing summary API. - [DeepInfra](docs/deepinfra.md) — API key for prepaid balance, current-month spend, and spending-limit tracking. - [Moonshot / Kimi API](docs/moonshot.md) — API key for Moonshot/Kimi API account balance tracking. diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekRollingUsageParser.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekRollingUsageParser.swift new file mode 100644 index 0000000000..f026904650 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekRollingUsageParser.swift @@ -0,0 +1,223 @@ +import Foundation + +struct DeepSeekRollingUsage: Sendable, Equatable { + let tokens: Int? + let cost: Double? + let currency: String? + + init(tokens: Int? = nil, cost: Double? = nil, currency: String? = nil) { + self.tokens = tokens + self.cost = cost + self.currency = currency + } +} + +enum DeepSeekRollingUsageParser { + static func parseAmount(_ data: Data) throws -> Int { + let payload: DeepSeekByAPIKeyAmountData = try self.decodeBizData(data, label: "rolling amount") + var total = 0 + for series in payload.series { + for bucket in series.buckets { + total = self.addClamped(total, bucket.usage.responseToken.value) + total = self.addClamped(total, bucket.usage.promptCacheHitToken.value) + total = self.addClamped(total, bucket.usage.promptCacheMissToken.value) + total = self.addClamped(total, bucket.usage.promptToken.value) + } + } + return total + } + + static func parseCost(_ data: Data, preferredCurrency: String?) throws -> (cost: Double, currency: String) { + let payload: DeepSeekByAPIKeyCostData = try self.decodeBizData(data, label: "rolling cost") + guard let group = self.preferredCostGroup(payload.data, currency: preferredCurrency) else { + return (0, preferredCurrency ?? "CNY") + } + let total = group.series + .flatMap(\.buckets) + .reduce(0) { partial, bucket in + let value = bucket.cost.value + return value.isFinite && value > 0 ? partial + value : partial + } + return (total, group.currency) + } + + private static func decodeBizData(_ data: Data, label: String) throws -> Value { + let object: Any + do { + object = try JSONSerialization.jsonObject(with: data) + } catch { + throw DeepSeekUsageError.parseFailed("\(label): \(error.localizedDescription)") + } + guard let envelope = object as? [String: Any] else { + throw DeepSeekUsageError.parseFailed("\(label): expected an object") + } + try self.validateCode(envelope["code"], label: label) + guard let dataObject = envelope["data"] as? [String: Any] else { + throw DeepSeekUsageError.parseFailed("\(label): missing data") + } + try self.validateCode(dataObject["biz_code"], label: label) + guard let bizData = dataObject["biz_data"] else { + throw DeepSeekUsageError.parseFailed("\(label): missing biz_data") + } + do { + let nestedData = try JSONSerialization.data(withJSONObject: bizData) + return try JSONDecoder().decode(Value.self, from: nestedData) + } catch let error as DeepSeekUsageError { + throw error + } catch { + throw DeepSeekUsageError.parseFailed("\(label): \(String(describing: error))") + } + } + + private static func validateCode(_ rawCode: Any?, label: String) throws { + guard let rawCode else { return } + let code: Int? = if let value = rawCode as? Int { + value + } else if let value = rawCode as? NSNumber { + value.intValue + } else if let value = rawCode as? String { + Int(value) + } else { + nil + } + guard let code else { + throw DeepSeekUsageError.parseFailed("\(label): invalid response code") + } + guard code != 0 else { return } + if code == 40002 || code == 40003 { + throw DeepSeekUsageError.invalidPlatformToken + } + throw DeepSeekUsageError.apiError("\(label) code \(code)") + } + + private static func preferredCostGroup( + _ groups: [DeepSeekByAPIKeyCostCurrency], + currency: String?) -> DeepSeekByAPIKeyCostCurrency? + { + if let currency, + let exact = groups.first(where: { $0.currency.caseInsensitiveCompare(currency) == .orderedSame }) + { + return exact + } + return groups.first + } + + private static func addClamped(_ lhs: Int, _ rhs: Int) -> Int { + guard rhs > 0 else { return lhs } + let (sum, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? Int.max : sum + } +} + +private struct DeepSeekByAPIKeyAmountData: Decodable { + let series: [Series] + + struct Series: Decodable { + let buckets: [Bucket] + } + + struct Bucket: Decodable { + let usage: Usage + } + + struct Usage: Decodable { + let responseToken: DeepSeekRollingInteger + let promptCacheHitToken: DeepSeekRollingInteger + let promptCacheMissToken: DeepSeekRollingInteger + let promptToken: DeepSeekRollingInteger + + private enum CodingKeys: String, CodingKey { + case responseToken = "RESPONSE_TOKEN" + case promptCacheHitToken = "PROMPT_CACHE_HIT_TOKEN" + case promptCacheMissToken = "PROMPT_CACHE_MISS_TOKEN" + case promptToken = "PROMPT_TOKEN" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.responseToken = try container.decodeIfPresent(DeepSeekRollingInteger.self, forKey: .responseToken) + ?? DeepSeekRollingInteger(value: 0) + self.promptCacheHitToken = try container.decodeIfPresent( + DeepSeekRollingInteger.self, + forKey: .promptCacheHitToken) ?? DeepSeekRollingInteger(value: 0) + self.promptCacheMissToken = try container.decodeIfPresent( + DeepSeekRollingInteger.self, + forKey: .promptCacheMissToken) ?? DeepSeekRollingInteger(value: 0) + self.promptToken = try container.decodeIfPresent(DeepSeekRollingInteger.self, forKey: .promptToken) + ?? DeepSeekRollingInteger(value: 0) + } + } + + private enum CodingKeys: String, CodingKey { + case series + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.series = try container.decodeIfPresent([Series].self, forKey: .series) ?? [] + } +} + +private struct DeepSeekByAPIKeyCostData: Decodable { + let data: [DeepSeekByAPIKeyCostCurrency] + + private enum CodingKeys: String, CodingKey { + case data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.data = try container.decodeIfPresent([DeepSeekByAPIKeyCostCurrency].self, forKey: .data) ?? [] + } +} + +private struct DeepSeekByAPIKeyCostCurrency: Decodable { + let currency: String + let series: [Series] + + struct Series: Decodable { + let buckets: [Bucket] + } + + struct Bucket: Decodable { + let cost: DeepSeekRollingDouble + } +} + +private struct DeepSeekRollingInteger: Decodable { + let value: Int + + init(value: Int) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Int.self) { + self.value = max(0, value) + return + } + if let value = try? container.decode(String.self), let parsed = Int(value) { + self.value = max(0, parsed) + return + } + self.value = 0 + } +} + +private struct DeepSeekRollingDouble: Decodable { + let value: Double + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Double.self) { + self.value = value + return + } + if let value = try? container.decode(String.self), let parsed = Double(value) { + self.value = parsed + return + } + self.value = 0 + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift index 7edecc721d..dfd81aa47f 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift @@ -181,6 +181,10 @@ public struct DeepSeekUsageSummary: Sendable, Equatable { public let categoryBreakdown: [DeepSeekCategoryBreakdown] public let daily: [DeepSeekDailyUsage] public let currency: String + public let fiveHourTokens: Int? + public let weeklyTokens: Int? + public let fiveHourCost: Double? + public let weeklyCost: Double? public let updatedAt: Date public init( @@ -194,6 +198,10 @@ public struct DeepSeekUsageSummary: Sendable, Equatable { categoryBreakdown: [DeepSeekCategoryBreakdown], daily: [DeepSeekDailyUsage], currency: String, + fiveHourTokens: Int? = nil, + weeklyTokens: Int? = nil, + fiveHourCost: Double? = nil, + weeklyCost: Double? = nil, updatedAt: Date) { self.todayTokens = todayTokens @@ -206,8 +214,31 @@ public struct DeepSeekUsageSummary: Sendable, Equatable { self.categoryBreakdown = categoryBreakdown self.daily = daily self.currency = currency + self.fiveHourTokens = fiveHourTokens + self.weeklyTokens = weeklyTokens + self.fiveHourCost = fiveHourCost + self.weeklyCost = weeklyCost self.updatedAt = updatedAt } + + func withRollingUsage(fiveHour: DeepSeekRollingUsage?, weekly: DeepSeekRollingUsage?) -> Self { + Self( + todayTokens: self.todayTokens, + currentMonthTokens: self.currentMonthTokens, + todayCost: self.todayCost, + currentMonthCost: self.currentMonthCost, + requestCount: self.requestCount, + currentMonthRequestCount: self.currentMonthRequestCount, + topModel: self.topModel, + categoryBreakdown: self.categoryBreakdown, + daily: self.daily, + currency: self.currency, + fiveHourTokens: fiveHour?.tokens, + weeklyTokens: weekly?.tokens, + fiveHourCost: fiveHour?.cost, + weeklyCost: weekly?.cost, + updatedAt: self.updatedAt) + } } public struct DeepSeekCategoryBreakdown: Sendable, Equatable { diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift index d56a39d7d7..38dccfd02c 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -222,6 +222,16 @@ public struct DeepSeekUsageSnapshot: Sendable { value: "\(cost(usage.currentMonthCost)) · \(usage.currentMonthTokens.formatted()) tokens"), .makeRow(label: "Requests", value: "\(usage.currentMonthRequestCount)"), ] + if usage.fiveHourTokens != nil || usage.weeklyTokens != nil + || usage.fiveHourCost != nil || usage.weeklyCost != nil + { + rows.insert(contentsOf: [ + .makeRow(label: "5h tokens", value: usage.fiveHourTokens?.formatted() ?? "—"), + .makeRow(label: "Weekly tokens", value: usage.weeklyTokens?.formatted() ?? "—"), + .makeRow(label: "5h spend", value: cost(usage.fiveHourCost)), + .makeRow(label: "Weekly spend", value: cost(usage.weeklyCost)), + ], at: 0) + } if let topModel = usage.topModel { rows.append(.makeRow(label: "Top model", value: topModel)) } @@ -280,14 +290,26 @@ public struct DeepSeekUsageFetcher: Sendable { case cost(Data) } + private enum RollingUsagePayload: Sendable { + case fiveHourAmount(Data?) + case fiveHourCost(Data?) + case weeklyAmount(Data?) + case weeklyCost(Data?) + } + private static let log = CodexBarLog.logger(LogCategories.provider(.deepseek, scope: "usage")) 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 usageByAPIKeyAmountURL = URL( + string: "https://platform.deepseek.com/api/v0/usage/by_api_key/amount")! + private static let usageByAPIKeyCostURL = URL( + string: "https://platform.deepseek.com/api/v0/usage/by_api_key/cost")! private static let platformUserSummaryURL = URL( string: "https://platform.deepseek.com/api/v0/users/get_user_summary")! private static let timeoutSeconds: TimeInterval = 15 private static let optionalSummaryJoinGrace: Duration = .seconds(5) + private static let rollingUsageJoinGrace: Duration = .seconds(3) private static var apiCalendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.locale = Locale(identifier: "en_US_POSIX") @@ -489,6 +511,18 @@ public struct DeepSeekUsageFetcher: Sendable { { let calendar = calendar ?? self.apiCalendar let period = try self.usagePeriod(now: now, calendar: calendar) + let rollingTask = Task<( + fiveHour: DeepSeekRollingUsage?, + weekly: DeepSeekRollingUsage?), Error> { + await self.fetchRollingUsage(platformToken: platformToken, now: now) + } + let rollingJoinTask = Task { + await BoundedTaskJoin(sourceTask: rollingTask).value(joinGrace: self.rollingUsageJoinGrace) + } + defer { + rollingTask.cancel() + rollingJoinTask.cancel() + } let payloads = try await self.fetchUsagePayloads( fetchAmount: { try await self.fetchAmount(platformToken: platformToken, month: period.month, year: period.year) @@ -497,11 +531,18 @@ public struct DeepSeekUsageFetcher: Sendable { try await self.fetchCost(platformToken: platformToken, month: period.month, year: period.year) }) - return try DeepSeekUsageCostParser.parse( + let summary = try DeepSeekUsageCostParser.parse( amountData: payloads.amount, costData: payloads.cost, now: now, calendar: calendar) + let rolling: (fiveHour: DeepSeekRollingUsage?, weekly: DeepSeekRollingUsage?)? = switch await rollingJoinTask + .value + { + case let .value(value): value + case .failure, .timedOut: nil + } + return summary.withRollingUsage(fiveHour: rolling?.fiveHour, weekly: rolling?.weekly) } public static func fetchPlatformUsage( @@ -631,6 +672,164 @@ public struct DeepSeekUsageFetcher: Sendable { } } + private static func fetchRollingUsage( + platformToken: String, + now: Date) async -> (fiveHour: DeepSeekRollingUsage?, weekly: DeepSeekRollingUsage?) + { + let ranges = self.rollingUsageRanges(now: now) + let payloads = await withTaskGroup(of: RollingUsagePayload.self) { group in + group.addTask { + await .fiveHourAmount(self.optionalRollingData( + url: self.usageByAPIKeyAmountURL, + platformToken: platformToken, + start: ranges.fiveHourStart, + end: ranges.end)) + } + group.addTask { + await .fiveHourCost(self.optionalRollingData( + url: self.usageByAPIKeyCostURL, + platformToken: platformToken, + start: ranges.fiveHourStart, + end: ranges.end)) + } + group.addTask { + await .weeklyAmount(self.optionalRollingData( + url: self.usageByAPIKeyAmountURL, + platformToken: platformToken, + start: ranges.weeklyStart, + end: ranges.end)) + } + group.addTask { + await .weeklyCost(self.optionalRollingData( + url: self.usageByAPIKeyCostURL, + platformToken: platformToken, + start: ranges.weeklyStart, + end: ranges.end)) + } + + var fiveHourAmount: Data? + var fiveHourCost: Data? + var weeklyAmount: Data? + var weeklyCost: Data? + for await payload in group { + switch payload { + case let .fiveHourAmount(data): fiveHourAmount = data + case let .fiveHourCost(data): fiveHourCost = data + case let .weeklyAmount(data): weeklyAmount = data + case let .weeklyCost(data): weeklyCost = data + } + } + return (fiveHourAmount, fiveHourCost, weeklyAmount, weeklyCost) + } + + return ( + fiveHour: self.rollingUsage( + amountData: payloads.0, + costData: payloads.1, + preferredCurrency: nil), + weekly: self.rollingUsage( + amountData: payloads.2, + costData: payloads.3, + preferredCurrency: nil)) + } + + private static func optionalRollingData( + url: URL, + platformToken: String, + start: Int64, + end: Int64) async -> Data? + { + do { + return try await self.fetchRollingData( + url: url, + platformToken: platformToken, + start: start, + end: end) + } catch { + if error is CancellationError || Task.isCancelled { return nil } + self.log.warning( + "DeepSeek rolling usage unavailable", + metadata: ["endpoint": url.lastPathComponent]) + return nil + } + } + + private static func fetchRollingData( + url: URL, + platformToken: String, + start: Int64, + end: Int64) async throws -> Data + { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw DeepSeekUsageError.networkError("Invalid rolling usage URL") + } + components.queryItems = [ + URLQueryItem(name: "start", value: String(start)), + URLQueryItem(name: "end", value: String(end)), + URLQueryItem(name: "tz", value: "0"), + ] + guard let requestURL = components.url else { + throw DeepSeekUsageError.networkError("Could not construct rolling usage URL") + } + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + request.setValue("Bearer \(platformToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("https://platform.deepseek.com/usage", forHTTPHeaderField: "Referer") + request.setValue( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Safari/537.36", + forHTTPHeaderField: "User-Agent") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw DeepSeekUsageError.invalidPlatformToken + } + throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") + } + return response.data + } + + static func _parseRollingUsageForTesting( + amountData: Data?, + costData: Data?, + preferredCurrency: String? = nil) -> DeepSeekRollingUsage? + { + self.rollingUsage( + amountData: amountData, + costData: costData, + preferredCurrency: preferredCurrency) + } + + private static func rollingUsage( + amountData: Data?, + costData: Data?, + preferredCurrency: String?) -> DeepSeekRollingUsage? + { + let tokens = amountData.flatMap { try? DeepSeekRollingUsageParser.parseAmount($0) } + let costResult = costData.flatMap { + try? DeepSeekRollingUsageParser.parseCost($0, preferredCurrency: preferredCurrency) + } + guard tokens != nil || costResult != nil else { return nil } + return DeepSeekRollingUsage( + tokens: tokens, + cost: costResult?.cost, + currency: costResult?.currency) + } + + static func _rollingUsageRangesForTesting(now: Date) -> (fiveHourStart: Int64, weeklyStart: Int64, end: Int64) { + self.rollingUsageRanges(now: now) + } + + private static func rollingUsageRanges(now: Date) -> (fiveHourStart: Int64, weeklyStart: Int64, end: Int64) { + let end = Int64(now.timeIntervalSince1970.rounded(.down)) + return ( + fiveHourStart: end - 5 * 60 * 60, + weeklyStart: end - 7 * 24 * 60 * 60, + end: end) + } + static func _apiUsagePeriodForTesting(now: Date, calendar: Calendar? = nil) throws -> (month: Int, year: Int) { try self.usagePeriod(now: now, calendar: calendar ?? self.apiCalendar) } diff --git a/Tests/CodexBarTests/DeepSeekRollingUsageParserTests.swift b/Tests/CodexBarTests/DeepSeekRollingUsageParserTests.swift new file mode 100644 index 0000000000..edef48098c --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekRollingUsageParserTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekRollingUsageParserTests { + @Test + func `rolling amount sums token categories across api keys models and buckets`() throws { + let data = Data(Self.amountJSON.utf8) + + let tokens = try DeepSeekRollingUsageParser.parseAmount(data) + + #expect(tokens == 250) + } + + @Test + func `rolling cost selects the requested currency and sums every series`() throws { + let data = Data(Self.costJSON.utf8) + + let result = try DeepSeekRollingUsageParser.parseCost(data, preferredCurrency: "USD") + + #expect(result.currency == "USD") + #expect(abs(result.cost - 1.75) < 0.000_001) + } + + @Test + func `rolling usage keeps whichever endpoint remains available`() { + let amountOnly = DeepSeekUsageFetcher._parseRollingUsageForTesting( + amountData: Data(Self.amountJSON.utf8), + costData: Data("not-json".utf8)) + let costOnly = DeepSeekUsageFetcher._parseRollingUsageForTesting( + amountData: nil, + costData: Data(Self.costJSON.utf8), + preferredCurrency: "CNY") + + #expect(amountOnly?.tokens == 250) + #expect(amountOnly?.cost == nil) + #expect(costOnly?.tokens == nil) + #expect(costOnly?.currency == "CNY") + #expect(abs((costOnly?.cost ?? 0) - 3.5) < 0.000_001) + } + + @Test + func `rolling parser maps nested platform authentication errors`() { + let data = Data(#"{"code":0,"data":{"biz_code":40003,"biz_data":"unexpected"}}"#.utf8) + + #expect { + try DeepSeekRollingUsageParser.parseAmount(data) + } throws: { error in + error as? DeepSeekUsageError == .invalidPlatformToken + } + } + + @Test + func `rolling ranges cover exactly five hours and seven days`() { + let now = Date(timeIntervalSince1970: 1_784_224_800.75) + + let ranges = DeepSeekUsageFetcher._rollingUsageRangesForTesting(now: now) + + #expect(ranges.end == 1_784_224_800) + #expect(ranges.end - ranges.fiveHourStart == 5 * 60 * 60) + #expect(ranges.end - ranges.weeklyStart == 7 * 24 * 60 * 60) + } + + private static let amountJSON = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": { + "start": 1784199600, + "end": 1784224800, + "bucket": 3600, + "models": ["deepseek-chat", "deepseek-reasoner"], + "series": [ + { + "api_key": {"tracking_id":"one","name":"One","sensitive_id":"sk-1","valid":true}, + "model": "deepseek-chat", + "buckets": [ + { + "time": 1784199600, + "usage": { + "PROMPT_CACHE_HIT_TOKEN": "100", + "PROMPT_CACHE_MISS_TOKEN": 20, + "PROMPT_TOKEN": "5", + "RESPONSE_TOKEN": "30", + "REQUEST": 99 + } + } + ] + }, + { + "api_key": {"tracking_id":"two","name":"Two","sensitive_id":"sk-2","valid":true}, + "model": "deepseek-reasoner", + "buckets": [ + { + "time": 1784203200, + "usage": { + "PROMPT_CACHE_HIT_TOKEN": 40, + "PROMPT_CACHE_MISS_TOKEN": "10", + "RESPONSE_TOKEN": 45, + "REQUEST": "2" + } + } + ] + } + ] + } + } + } + """ + + private static let costJSON = """ + { + "code": 0, + "data": { + "biz_code": 0, + "biz_data": { + "start": 1784199600, + "end": 1784224800, + "bucket": 3600, + "models": ["deepseek-chat"], + "data": [ + { + "currency": "CNY", + "series": [ + {"model":"deepseek-chat","buckets":[{"time":1784199600,"cost":"1.25"}]}, + {"model":"deepseek-chat","buckets":[{"time":1784203200,"cost":2.25}]} + ] + }, + { + "currency": "USD", + "series": [ + {"model":"deepseek-chat","buckets":[{"time":1784199600,"cost":"0.50"}]}, + {"model":"deepseek-chat","buckets":[{"time":1784203200,"cost":"1.25"}]} + ] + } + ] + } + } + } + """ +} diff --git a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift index 1a0f4b9b6b..1fcc110d58 100644 --- a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift +++ b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift @@ -22,6 +22,10 @@ struct MenuCardDeepSeekTests { DeepSeekDailyUsage(date: "2026-05-26", totalTokens: 456, cost: 0.0456, requestCount: 8), ], currency: "CNY", + fiveHourTokens: 12345, + weeklyTokens: 67890, + fiveHourCost: 0.1234, + weeklyCost: 1.2345, updatedAt: now) } @@ -148,6 +152,10 @@ struct MenuCardDeepSeekTests { let details = try #require(model.providerDetails.first) #expect(details.chart?.title == "Daily tokens") #expect(details.chart?.points.map(\.value) == [456]) + #expect(details.rows.first { $0.label == "5h tokens" }?.value == "12,345") + #expect(details.rows.first { $0.label == "Weekly tokens" }?.value == "67,890") + #expect(details.rows.first { $0.label == "5h spend" }?.value == "¥0.1234") + #expect(details.rows.first { $0.label == "Weekly spend" }?.value == "¥1.2345") #expect(details.rows.first { $0.label == "Today" }?.value == "¥0.0123 · 123 tokens") #expect(details.rows.first { $0.label == "This month" }?.value == "¥0.0456 · 456 tokens") } diff --git a/docs/deepseek.md b/docs/deepseek.md index e5c369fd6c..dfaeccc13e 100644 --- a/docs/deepseek.md +++ b/docs/deepseek.md @@ -28,7 +28,11 @@ endpoints. 4. **Optional detailed usage endpoints** - `GET https://platform.deepseek.com/api/v0/usage/amount?month=&year=` - `GET https://platform.deepseek.com/api/v0/usage/cost?month=&year=` + - `GET https://platform.deepseek.com/api/v0/usage/by_api_key/amount?start=&end=&tz=0` + - `GET https://platform.deepseek.com/api/v0/usage/by_api_key/cost?start=&end=&tz=0` - Request headers: `Authorization: Bearer `, `Accept: application/json` + - The month/year endpoints supply the existing daily and monthly detail. CodexBar queries each by-API-key endpoint + once for the trailing five hours and once for the trailing seven days, then totals every API key, model, and bucket. - These are private dashboard endpoints rather than documented public API endpoints and may change without notice. ## Platform session @@ -63,9 +67,12 @@ DeepSeek Platform in Chrome. Authentication failures returned as top-level or ne - The menu card shows total balance with the paid vs. granted breakdown: e.g. `$50.00 (Paid: $40.00 / Granted: $10.00)`. - The API separates granted balance from topped-up balance; CodexBar labels these as granted vs. paid credit. -- With optional extra usage enabled, the menu shows today's and the current month's cost and tokens, - request counts, cache/input/output categories, the top model, and a current-month token chart. -- The amount and cost requests run concurrently. After balance arrives, CodexBar waits up to five seconds for +- With optional extra usage enabled, the menu shows rolling 5-hour and 7-day token/spend totals, today's and the + current month's cost and tokens, request counts, cache/input/output categories, the top model, and a current-month + token chart. +- The monthly and four rolling amount/cost requests run concurrently. A rolling endpoint failure is isolated, so any + available rolling metric and the existing monthly detail can still be shown. After balance arrives, CodexBar waits + up to five seconds for automatic Chrome resolution and detailed usage. The deadline remains bounded even if a local Chrome read does not respond to cancellation. If the optional work fails or times out, the balance and previously validated profile list remain available while the menu reports that detailed usage is unavailable. @@ -73,13 +80,16 @@ DeepSeek Platform in Chrome. Authentication failures returned as top-level or ne other account cards remain balance-only so website usage is never duplicated across accounts. - When multiple currencies are present, USD is shown preferentially. - If total balance is zero, CodexBar shows an add-credits message. If balance is nonzero but `is_available` is false, it shows "Balance unavailable for API calls". -- There is no session or weekly window — DeepSeek does not expose per-window quota via API. +- DeepSeek does not expose quota denominators or reset times for these ranges. The 5-hour and weekly values are + absolute rolling usage totals, not percentage-based rate-limit meters. - Token-account selection injects the selected key into the fetch environment; otherwise CodexBar reads `DEEPSEEK_API_KEY` / `DEEPSEEK_KEY`. ## Key files - `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift` (descriptor + fetch strategy) -- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift` (HTTP client + JSON parser) +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift` (HTTP orchestration + balance parsing) +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift` (monthly usage aggregation) +- `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekRollingUsageParser.swift` (5-hour and weekly aggregation) - `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekPlatformTokenImporter.swift` (Chrome Platform session import) - `Sources/CodexBarCore/Providers/DeepSeek/DeepSeekSettingsReader.swift` (env var resolution) - `Sources/CodexBar/Providers/DeepSeek/DeepSeekProviderImplementation.swift` (provider activation and token-account visibility)