433 lines
15 KiB
Swift
433 lines
15 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
// MARK: - API response types
|
|
|
|
public struct DeepSeekBalanceResponse: Decodable, Sendable {
|
|
public let isAvailable: Bool
|
|
public let balanceInfos: [DeepSeekBalanceInfo]
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case isAvailable = "is_available"
|
|
case balanceInfos = "balance_infos"
|
|
}
|
|
}
|
|
|
|
public struct DeepSeekBalanceInfo: Decodable, Sendable {
|
|
public let currency: String
|
|
public let totalBalance: String
|
|
public let grantedBalance: String
|
|
public let toppedUpBalance: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case currency
|
|
case totalBalance = "total_balance"
|
|
case grantedBalance = "granted_balance"
|
|
case toppedUpBalance = "topped_up_balance"
|
|
}
|
|
}
|
|
|
|
// MARK: - Domain snapshot
|
|
|
|
public struct DeepSeekUsageSnapshot: Sendable {
|
|
public let isAvailable: Bool
|
|
public let currency: String
|
|
public let totalBalance: Double
|
|
public let grantedBalance: Double
|
|
public let toppedUpBalance: Double
|
|
public let usageSummary: DeepSeekUsageSummary?
|
|
public let updatedAt: Date
|
|
|
|
public init(
|
|
isAvailable: Bool,
|
|
currency: String,
|
|
totalBalance: Double,
|
|
grantedBalance: Double,
|
|
toppedUpBalance: Double,
|
|
usageSummary: DeepSeekUsageSummary? = nil,
|
|
updatedAt: Date)
|
|
{
|
|
self.isAvailable = isAvailable
|
|
self.currency = currency
|
|
self.totalBalance = totalBalance
|
|
self.grantedBalance = grantedBalance
|
|
self.toppedUpBalance = toppedUpBalance
|
|
self.usageSummary = usageSummary
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
public func toUsageSnapshot() -> UsageSnapshot {
|
|
let symbol = self.currency == "CNY" ? "¥" : "$"
|
|
|
|
let balanceDetail: String
|
|
let usedPercent: Double
|
|
if self.totalBalance <= 0 {
|
|
balanceDetail = "\(symbol)0.00 — add credits at platform.deepseek.com"
|
|
usedPercent = 100
|
|
} else if !self.isAvailable {
|
|
balanceDetail = "Balance unavailable for API calls"
|
|
usedPercent = 100
|
|
} else {
|
|
let total = String(format: "\(symbol)%.2f", self.totalBalance)
|
|
let paid = String(format: "\(symbol)%.2f", self.toppedUpBalance)
|
|
let granted = String(format: "\(symbol)%.2f", self.grantedBalance)
|
|
balanceDetail = "\(total) (Paid: \(paid) / Granted: \(granted))"
|
|
usedPercent = 0
|
|
}
|
|
|
|
let identity = ProviderIdentitySnapshot(
|
|
providerID: .deepseek,
|
|
accountEmail: nil,
|
|
accountOrganization: nil,
|
|
loginMethod: nil)
|
|
let balanceWindow = RateWindow(
|
|
usedPercent: usedPercent,
|
|
windowMinutes: nil,
|
|
resetsAt: nil,
|
|
resetDescription: balanceDetail)
|
|
|
|
return UsageSnapshot(
|
|
primary: balanceWindow,
|
|
secondary: nil,
|
|
tertiary: nil,
|
|
providerCost: nil,
|
|
deepseekUsage: self.usageSummary,
|
|
updatedAt: self.updatedAt,
|
|
identity: identity)
|
|
}
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
public enum DeepSeekUsageError: LocalizedError, Sendable {
|
|
case missingCredentials
|
|
case networkError(String)
|
|
case apiError(String)
|
|
case parseFailed(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .missingCredentials:
|
|
"Missing DeepSeek API key."
|
|
case let .networkError(message):
|
|
"DeepSeek network error: \(message)"
|
|
case let .apiError(message):
|
|
"DeepSeek API error: \(message)"
|
|
case let .parseFailed(message):
|
|
"Failed to parse DeepSeek response: \(message)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Fetcher
|
|
|
|
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)
|
|
private static var apiCalendar: Calendar {
|
|
var calendar = Calendar(identifier: .gregorian)
|
|
calendar.locale = Locale(identifier: "en_US_POSIX")
|
|
calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt
|
|
return calendar
|
|
}
|
|
|
|
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<DeepSeekUsageSummary, Error>? = if includeOptionalUsage {
|
|
Task {
|
|
try await fetchSummary(apiKey)
|
|
}
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
let balanceData: Data
|
|
do {
|
|
balanceData = try await withTaskCancellationHandler {
|
|
try await fetchBalanceData(apiKey)
|
|
} onCancel: {
|
|
summaryTask?.cancel()
|
|
}
|
|
} catch {
|
|
summaryTask?.cancel()
|
|
throw error
|
|
}
|
|
var snapshot: DeepSeekUsageSnapshot
|
|
do {
|
|
snapshot = try Self.parseSnapshot(data: balanceData)
|
|
} catch {
|
|
summaryTask?.cancel()
|
|
throw error
|
|
}
|
|
|
|
if let summaryTask {
|
|
let summary = try 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")
|
|
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 {
|
|
Self.log.error("DeepSeek balance endpoint returned HTTP \(response.statusCode)")
|
|
throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)")
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
private static func completedOptionalUsageSummary(
|
|
from task: Task<DeepSeekUsageSummary, Error>,
|
|
joinGrace: Duration) async throws -> DeepSeekUsageSummary?
|
|
{
|
|
let race = BoundedTaskJoin(sourceTask: task)
|
|
switch await race.value(joinGrace: joinGrace) {
|
|
case let .value(summary):
|
|
try Task.checkCancellation()
|
|
return summary
|
|
case .timedOut:
|
|
try Task.checkCancellation()
|
|
return nil
|
|
case let .failure(error):
|
|
task.cancel()
|
|
if Task.isCancelled {
|
|
throw error
|
|
}
|
|
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? = nil) async throws -> DeepSeekUsageSummary
|
|
{
|
|
let calendar = calendar ?? self.apiCalendar
|
|
let period = try self.usagePeriod(now: now, calendar: calendar)
|
|
|
|
let amountData = try await self.fetchAmount(apiKey: apiKey, month: period.month, year: period.year)
|
|
let costData = try await self.fetchCost(apiKey: apiKey, month: period.month, year: period.year)
|
|
|
|
return try DeepSeekUsageCostParser.parse(
|
|
amountData: amountData,
|
|
costData: costData,
|
|
now: now,
|
|
calendar: calendar)
|
|
}
|
|
|
|
static func _apiUsagePeriodForTesting(now: Date, calendar: Calendar? = nil) throws -> (month: Int, year: Int) {
|
|
try self.usagePeriod(now: now, calendar: calendar ?? self.apiCalendar)
|
|
}
|
|
|
|
private static func usagePeriod(now: Date, calendar: Calendar) throws -> (month: Int, year: Int) {
|
|
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")
|
|
}
|
|
return (month: month, year: year)
|
|
}
|
|
|
|
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 {
|
|
decoded = try JSONDecoder().decode(DeepSeekBalanceResponse.self, from: data)
|
|
} catch {
|
|
throw DeepSeekUsageError.parseFailed(error.localizedDescription)
|
|
}
|
|
|
|
let balances = try decoded.balanceInfos.map(Self.parseBalanceInfo)
|
|
guard !balances.isEmpty else {
|
|
return DeepSeekUsageSnapshot(
|
|
isAvailable: false,
|
|
currency: "USD",
|
|
totalBalance: 0,
|
|
grantedBalance: 0,
|
|
toppedUpBalance: 0,
|
|
updatedAt: Date())
|
|
}
|
|
|
|
// Prefer USD when it is funded, but do not hide a positive CNY balance behind
|
|
// an empty USD row returned by the API.
|
|
let selected = balances.first { $0.currency == "USD" && $0.totalBalance > 0 }
|
|
?? balances.first { $0.totalBalance > 0 }
|
|
?? balances.first { $0.currency == "USD" }
|
|
?? balances[0]
|
|
|
|
return DeepSeekUsageSnapshot(
|
|
isAvailable: decoded.isAvailable,
|
|
currency: selected.currency,
|
|
totalBalance: selected.totalBalance,
|
|
grantedBalance: selected.grantedBalance,
|
|
toppedUpBalance: selected.toppedUpBalance,
|
|
updatedAt: Date())
|
|
}
|
|
|
|
private struct ParsedBalanceInfo {
|
|
let currency: String
|
|
let totalBalance: Double
|
|
let grantedBalance: Double
|
|
let toppedUpBalance: Double
|
|
}
|
|
|
|
private static func parseBalanceInfo(_ info: DeepSeekBalanceInfo) throws -> ParsedBalanceInfo {
|
|
guard
|
|
let total = Double(info.totalBalance),
|
|
let granted = Double(info.grantedBalance),
|
|
let toppedUp = Double(info.toppedUpBalance)
|
|
else {
|
|
throw DeepSeekUsageError.parseFailed("Non-numeric balance value in response.")
|
|
}
|
|
|
|
return ParsedBalanceInfo(
|
|
currency: info.currency,
|
|
totalBalance: total,
|
|
grantedBalance: granted,
|
|
toppedUpBalance: toppedUp)
|
|
}
|
|
}
|