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? = 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, 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) } }