299 lines · 9.8 KB · Swift
RawDownload
import Foundation#if canImport(FoundationNetworking)import FoundationNetworking#endif public struct KimiK2UsageSnapshot: Sendable {    public let summary: KimiK2UsageSummary     public init(summary: KimiK2UsageSummary) {        self.summary = summary    }     public func toUsageSnapshot() -> UsageSnapshot {        self.summary.toUsageSnapshot()    }} public struct KimiK2UsageSummary: Sendable {    public let consumed: Double    public let remaining: Double    public let averageTokens: Double?    public let updatedAt: Date     public init(consumed: Double, remaining: Double, averageTokens: Double?, updatedAt: Date) {        self.consumed = consumed        self.remaining = remaining        self.averageTokens = averageTokens        self.updatedAt = updatedAt    }     public func toUsageSnapshot() -> UsageSnapshot {        let identity = ProviderIdentitySnapshot(            providerID: .kimik2,            accountEmail: nil,            accountOrganization: nil,            loginMethod: "Credits: \(UsageFormatter.creditsString(from: self.remaining))")        return UsageSnapshot(            primary: nil,            secondary: nil,            tertiary: nil,            providerCost: nil,            updatedAt: self.updatedAt,            identity: identity)    }} public enum KimiK2UsageError: LocalizedError, Sendable {    case missingCredentials    case networkError(String)    case apiError(String)    case parseFailed(String)     public var errorDescription: String? {        switch self {        case .missingCredentials:            "Missing Kimi K2 API key."        case let .networkError(message):            "Kimi K2 network error: \(message)"        case let .apiError(message):            "Kimi K2 API error: \(message)"        case let .parseFailed(message):            "Failed to parse Kimi K2 response: \(message)"        }    }} public struct KimiK2UsageFetcher: Sendable {    private static let log = CodexBarLog.logger(LogCategories.kimiK2Usage)    private static let creditsURL = URL(string: "https://kimi-k2.ai/api/user/credits")!    private static let jsonSerializer = JSONSerialization.self    private static let consumedPaths: [[String]] = [        ["total_credits_consumed"],        ["totalCreditsConsumed"],        ["total_credits_used"],        ["totalCreditsUsed"],        ["credits_consumed"],        ["creditsConsumed"],        ["consumedCredits"],        ["usedCredits"],        ["total"],        ["usage", "total"],        ["usage", "consumed"],    ]     private static let remainingPaths: [[String]] = [        ["credits_remaining"],        ["creditsRemaining"],        ["remaining_credits"],        ["remainingCredits"],        ["available_credits"],        ["availableCredits"],        ["credits_left"],        ["creditsLeft"],        ["usage", "credits_remaining"],        ["usage", "remaining"],    ]     private static let averageTokenPaths: [[String]] = [        ["average_tokens_per_request"],        ["averageTokensPerRequest"],        ["average_tokens"],        ["averageTokens"],        ["avg_tokens"],        ["avgTokens"],    ]     private static let timestampPaths: [[String]] = [        ["updated_at"],        ["updatedAt"],        ["timestamp"],        ["time"],        ["last_update"],        ["lastUpdated"],    ]     public static func fetchUsage(apiKey: String) async throws -> KimiK2UsageSnapshot {        guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {            throw KimiK2UsageError.missingCredentials        }         var request = URLRequest(url: self.creditsURL)        request.httpMethod = "GET"        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")        request.setValue("application/json", forHTTPHeaderField: "Accept")         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) ?? "HTTP \(response.statusCode)"            Self.log.error("Kimi K2 API returned \(response.statusCode): \(body)")            throw KimiK2UsageError.apiError(body)        }         if let jsonString = String(data: data, encoding: .utf8) {            Self.log.debug("Kimi K2 API response: \(jsonString)")        }         let summary = try Self.parseSummary(data: data, headers: response.response.allHeaderFields)        return KimiK2UsageSnapshot(summary: summary)    }     static func _parseSummaryForTesting(_ data: Data, headers: [AnyHashable: Any] = [:]) throws -> KimiK2UsageSummary {        try self.parseSummary(data: data, headers: headers)    }     private static func parseSummary(data: Data, headers: [AnyHashable: Any]) throws -> KimiK2UsageSummary {        guard let json = try? jsonSerializer.jsonObject(with: data),              let dictionary = json as? [String: Any]        else {            throw KimiK2UsageError.parseFailed("Root JSON is not an object.")        }         let contexts = Self.contexts(from: dictionary)        let consumed = Self.doubleValue(for: Self.consumedPaths, in: contexts) ?? 0        let remaining = Self.doubleValue(for: Self.remainingPaths, in: contexts)            ?? Self.doubleValueFromHeaders(headers: headers, key: "x-credits-remaining")            ?? 0        let averageTokens = Self.doubleValue(for: Self.averageTokenPaths, in: contexts)        let updatedAt = Self.dateValue(for: Self.timestampPaths, in: contexts) ?? Date()         return KimiK2UsageSummary(            consumed: consumed,            remaining: max(0, remaining),            averageTokens: averageTokens,            updatedAt: updatedAt)    }     private static func contexts(from dictionary: [String: Any]) -> [[String: Any]] {        var contexts: [[String: Any]] = [dictionary]        if let data = dictionary["data"] as? [String: Any] {            contexts.append(data)            if let dataUsage = data["usage"] as? [String: Any] {                contexts.append(dataUsage)            }            if let dataCredits = data["credits"] as? [String: Any] {                contexts.append(dataCredits)            }        }        if let result = dictionary["result"] as? [String: Any] {            contexts.append(result)            if let resultUsage = result["usage"] as? [String: Any] {                contexts.append(resultUsage)            }            if let resultCredits = result["credits"] as? [String: Any] {                contexts.append(resultCredits)            }        }        if let usage = dictionary["usage"] as? [String: Any] {            contexts.append(usage)        }        if let credits = dictionary["credits"] as? [String: Any] {            contexts.append(credits)        }        return contexts    }     private static func doubleValue(        for paths: [[String]],        in contexts: [[String: Any]]) -> Double?    {        for path in paths {            if let raw = self.value(for: path, in: contexts),               let value = self.double(from: raw)            {                return value            }        }        return nil    }     private static func dateValue(        for paths: [[String]],        in contexts: [[String: Any]]) -> Date?    {        for path in paths {            if let raw = self.value(for: path, in: contexts) {                if let date = self.date(from: raw) {                    return date                }            }        }        return nil    }     private static func value(for path: [String], in contexts: [[String: Any]]) -> Any? {        for context in contexts {            var cursor: Any? = context            for key in path {                if let dict = cursor as? [String: Any] {                    cursor = dict[key]                } else {                    cursor = nil                }            }            if cursor != nil {                return cursor            }        }        return nil    }     private static func double(from raw: Any) -> Double? {        if let value = raw as? Double {            return value        }        if let value = raw as? Int {            return Double(value)        }        if let value = raw as? String {            return Double(value)        }        return nil    }     private static func date(from raw: Any) -> Date? {        if let value = raw as? Date {            return value        }        if let value = raw as? Double {            return self.dateFromNumeric(value)        }        if let value = raw as? Int {            return self.dateFromNumeric(Double(value))        }        if let value = raw as? String {            let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)            if let numeric = Double(trimmed) {                return self.dateFromNumeric(numeric)            }            let formatter = ISO8601DateFormatter()            formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]            if let date = formatter.date(from: value) {                return date            }            let fallback = ISO8601DateFormatter()            fallback.formatOptions = [.withInternetDateTime]            return fallback.date(from: value)        }        return nil    }     private static func dateFromNumeric(_ value: Double) -> Date? {        guard value > 0 else { return nil }        if value > 1_000_000_000_000 {            return Date(timeIntervalSince1970: value / 1000)        }        return Date(timeIntervalSince1970: value)    }     private static func doubleValueFromHeaders(headers: [AnyHashable: Any], key: String) -> Double? {        for (headerKey, value) in headers {            guard let headerKey = headerKey as? String else { continue }            if headerKey.lowercased() == key.lowercased() {                return self.double(from: value)            }        }        return nil    }}