1199 lines · 41.3 KB · Swift
RawDownload
import Foundation#if canImport(FoundationNetworking)import FoundationNetworking#endif public struct KiloUsageSnapshot: Sendable {    public let creditsUsed: Double?    public let creditsTotal: Double?    public let creditsRemaining: Double?    public let passUsed: Double?    public let passTotal: Double?    public let passRemaining: Double?    public let passBonus: Double?    public let passResetsAt: Date?    public let planName: String?    public let autoTopUpEnabled: Bool?    public let autoTopUpMethod: String?    public let updatedAt: Date     public init(        creditsUsed: Double?,        creditsTotal: Double?,        creditsRemaining: Double?,        passUsed: Double? = nil,        passTotal: Double? = nil,        passRemaining: Double? = nil,        passBonus: Double? = nil,        passResetsAt: Date? = nil,        planName: String?,        autoTopUpEnabled: Bool?,        autoTopUpMethod: String?,        updatedAt: Date)    {        self.creditsUsed = creditsUsed        self.creditsTotal = creditsTotal        self.creditsRemaining = creditsRemaining        self.passUsed = passUsed        self.passTotal = passTotal        self.passRemaining = passRemaining        self.passBonus = passBonus        self.passResetsAt = passResetsAt        self.planName = planName        self.autoTopUpEnabled = autoTopUpEnabled        self.autoTopUpMethod = autoTopUpMethod        self.updatedAt = updatedAt    }     public func toUsageSnapshot() -> UsageSnapshot {        let total = self.resolvedTotal        let used = self.resolvedUsed         let primary: RateWindow?        if let total {            let usedPercent: Double = if total > 0 {                min(100, max(0, (used / total) * 100))            } else {                // Preserve a visible exhausted state for valid zero-total snapshots.                100            }            let usedText = Self.compactNumber(used)            let totalText = Self.compactNumber(total)            primary = RateWindow(                usedPercent: usedPercent,                windowMinutes: nil,                resetsAt: nil,                resetDescription: "\(usedText)/\(totalText) credits")        } else {            primary = nil        }         let loginMethod = Self.makeLoginMethod(            planName: self.planName,            autoTopUpEnabled: self.autoTopUpEnabled,            autoTopUpMethod: self.autoTopUpMethod)         return UsageSnapshot(            primary: primary,            secondary: self.passWindow,            tertiary: nil,            providerCost: nil,            updatedAt: self.updatedAt,            identity: ProviderIdentitySnapshot(                providerID: .kilo,                accountEmail: nil,                accountOrganization: nil,                loginMethod: loginMethod))    }     private var resolvedTotal: Double? {        if let creditsTotal { return max(0, creditsTotal) }        if let creditsUsed, let creditsRemaining {            return max(0, creditsUsed + creditsRemaining)        }        return nil    }     private var resolvedUsed: Double {        if let creditsUsed {            return max(0, creditsUsed)        }        if let total = self.resolvedTotal,           let creditsRemaining        {            return max(0, total - creditsRemaining)        }        return 0    }     private var resolvedPassTotal: Double? {        if let passTotal { return max(0, passTotal) }        if let passUsed, let passRemaining {            return max(0, passUsed + passRemaining)        }        return nil    }     private var resolvedPassUsed: Double {        if let passUsed {            return max(0, passUsed)        }        if let total = self.resolvedPassTotal,           let passRemaining        {            return max(0, total - passRemaining)        }        return 0    }     private var passWindow: RateWindow? {        guard let total = self.resolvedPassTotal else {            return nil        }         let used = self.resolvedPassUsed        let bonus = max(0, self.passBonus ?? 0)        let baseCredits = max(0, total - bonus)        let usedPercent: Double = if total > 0 {            min(100, max(0, (used / total) * 100))        } else {            100        }         var detail = "$\(Self.currencyNumber(used)) / $\(Self.currencyNumber(baseCredits))"        if bonus > 0 {            detail += " (+ $\(Self.currencyNumber(bonus)) bonus)"        }         return RateWindow(            usedPercent: usedPercent,            windowMinutes: nil,            resetsAt: self.passResetsAt,            resetDescription: detail)    }     private static func compactNumber(_ value: Double) -> String {        if value.rounded(.towardZero) == value {            return String(Int(value))        }        return String(format: "%.2f", value)    }     private static func currencyNumber(_ value: Double) -> String {        String(format: "%.2f", max(0, value))    }     private static func makeLoginMethod(        planName: String?,        autoTopUpEnabled: Bool?,        autoTopUpMethod: String?) -> String?    {        var parts: [String] = []         if let planName = Self.trimmed(planName) {            parts.append(planName)        }         if let autoTopUpEnabled {            if autoTopUpEnabled {                if let method = Self.trimmed(autoTopUpMethod) {                    parts.append("Auto top-up: \(method)")                } else {                    parts.append("Auto top-up: enabled")                }            } else {                parts.append("Auto top-up: off")            }        }         return parts.isEmpty ? nil : parts.joined(separator: " · ")    }     private static func trimmed(_ raw: String?) -> String? {        guard let raw else { return nil }        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)        return trimmed.isEmpty ? nil : trimmed    }} public enum KiloUsageError: LocalizedError, Sendable, Equatable {    case missingCredentials    case cliSessionMissing(String)    case cliSessionUnreadable(String)    case cliSessionInvalid(String)    case unauthorized    case endpointNotFound    case serviceUnavailable(Int)    case networkError(String)    case parseFailed(String)    case apiError(Int)     public var errorDescription: String? {        switch self {        case .missingCredentials:            "Kilo API credentials missing. Set KILO_API_KEY."        case let .cliSessionMissing(path):            "Kilo CLI session not found at \(path). Run `kilo login` to create ~/.local/share/kilo/auth.json."        case let .cliSessionUnreadable(path):            "Kilo CLI session file is unreadable at \(path). Fix permissions or run `kilo login` again."        case let .cliSessionInvalid(path):            "Kilo CLI session file is invalid at \(path). Run `kilo login` to refresh auth.json."        case .unauthorized:            "Kilo authentication failed (401/403). Refresh KILO_API_KEY or run `kilo login`."        case .endpointNotFound:            "Kilo API endpoint not found (404). Verify the tRPC batch path and procedure names."        case let .serviceUnavailable(statusCode):            "Kilo API is currently unavailable (HTTP \(statusCode)). Try again later."        case let .networkError(message):            "Kilo network error: \(message)"        case .parseFailed:            "Failed to parse Kilo API response. Response format may have changed."        case let .apiError(statusCode):            "Kilo API request failed (HTTP \(statusCode))."        }    }} // swiftlint:disable:next type_body_lengthpublic struct KiloUsageFetcher: Sendable {    private struct KiloPassFields {        let used: Double?        let total: Double?        let remaining: Double?        let bonus: Double?        let resetsAt: Date?    }     static let procedures = [        "user.getCreditBlocks",        "kiloPass.getState",        "user.getAutoTopUpPaymentMethod",    ]     private static let optionalProcedures: Set<String> = [        "user.getAutoTopUpPaymentMethod",    ]     private static let maxTopLevelEntries = procedures.count     public static func fetchUsage(        apiKey: String,        scope: KiloUsageScope = .personal,        environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> KiloUsageSnapshot    {        guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {            throw KiloUsageError.missingCredentials        }         let baseURL = KiloSettingsReader.apiURL(environment: environment)        let request = try self.makeRequest(baseURL: baseURL, apiKey: apiKey, scope: scope)         let response: ProviderHTTPResponse        do {            response = try await ProviderHTTPClient.shared.response(for: request)        } catch {            throw KiloUsageError.networkError(error.localizedDescription)        }         if let mapped = self.statusError(for: response.statusCode) {            throw mapped        }         guard response.statusCode == 200 else {            throw KiloUsageError.apiError(response.statusCode)        }         return try self.parseSnapshot(data: response.data)    }     static func _buildBatchURLForTesting(baseURL: URL) throws -> URL {        try self.makeBatchURL(baseURL: baseURL)    }     static func _buildRequestForTesting(        baseURL: URL,        apiKey: String,        scope: KiloUsageScope) throws -> URLRequest    {        try self.makeRequest(baseURL: baseURL, apiKey: apiKey, scope: scope)    }     private static func makeRequest(        baseURL: URL,        apiKey: String,        scope: KiloUsageScope) throws -> URLRequest    {        let batchURL = try self.makeBatchURL(baseURL: baseURL)        var request = URLRequest(url: batchURL)        request.httpMethod = "GET"        request.timeoutInterval = 15        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")        request.setValue("application/json", forHTTPHeaderField: "Accept")        if let orgId = scope.organizationID {            request.setValue(orgId, forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID")        }        return request    }     public static func fetchOrganizations(        apiKey: String,        environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> [KiloOrganization]    {        guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {            throw KiloUsageError.missingCredentials        }         let baseURL = KiloSettingsReader.apiURL(environment: environment)        let trpcRequest = try self.makeOrgListTRPCRequest(baseURL: baseURL, apiKey: apiKey)         do {            let response = try await ProviderHTTPClient.shared.response(for: trpcRequest)            if response.statusCode == 404 {                return try await self.fetchOrganizationsRESTFallback(apiKey: apiKey)            }            if let mapped = self.statusError(for: response.statusCode) {                throw mapped            }            return try self.parseOrganizations(data: response.data)        } catch let error as KiloUsageError {            throw error        } catch {            throw KiloUsageError.networkError(error.localizedDescription)        }    }     static func _parseOrganizationsForTesting(_ data: Data) throws -> [KiloOrganization] {        try self.parseOrganizations(data: data)    }     private static func makeOrgListTRPCRequest(        baseURL: URL,        apiKey: String) throws -> URLRequest    {        let endpoint = baseURL.appendingPathComponent("user.getOrganizations")        let inputData = try JSONSerialization.data(            withJSONObject: ["0": ["json": NSNull()]] as [String: Any])        guard let inputString = String(data: inputData, encoding: .utf8),              var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)        else {            throw KiloUsageError.parseFailed("Invalid org list endpoint")        }        components.queryItems = [            URLQueryItem(name: "batch", value: "1"),            URLQueryItem(name: "input", value: inputString),        ]        guard let url = components.url else {            throw KiloUsageError.parseFailed("Invalid org list endpoint")        }        var request = URLRequest(url: url)        request.httpMethod = "GET"        request.timeoutInterval = 15        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")        request.setValue("application/json", forHTTPHeaderField: "Accept")        return request    }     private static func fetchOrganizationsRESTFallback(apiKey: String) async throws -> [KiloOrganization] {        guard let url = URL(string: "https://api.kilo.ai/api/profile") else {            throw KiloUsageError.parseFailed("Invalid REST fallback URL")        }        var request = URLRequest(url: url)        request.httpMethod = "GET"        request.timeoutInterval = 15        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")        request.setValue("application/json", forHTTPHeaderField: "Accept")         let response = try await ProviderHTTPClient.shared.response(for: request)        if let mapped = self.statusError(for: response.statusCode) {            throw mapped        }        guard response.statusCode == 200 else {            throw KiloUsageError.apiError(response.statusCode)        }        return try self.parseOrganizations(data: response.data)    }     private static func parseOrganizations(data: Data) throws -> [KiloOrganization] {        guard let root = try? JSONSerialization.jsonObject(with: data) else {            throw KiloUsageError.parseFailed("Invalid JSON")        }         // tRPC batch shape: [ { result: { data: { json: [orgs] } } } ]        if let entries = root as? [[String: Any]],           let first = entries.first,           let resultObject = first["result"] as? [String: Any]        {            if let dataObject = resultObject["data"] as? [String: Any],               let payload = dataObject["json"] as? [[String: Any]]            {                return self.decodeOrganizations(payload)            }            if let payload = resultObject["data"] as? [[String: Any]] {                return self.decodeOrganizations(payload)            }        }         // REST profile shape: { user: ..., organizations: [orgs] }        if let dictionary = root as? [String: Any] {            if let orgs = dictionary["organizations"] as? [[String: Any]] {                return self.decodeOrganizations(orgs)            }            // Some single-procedure tRPC shapes flatten to { result: { data: { json: { organizations: [...] }}}}            if let resultObject = dictionary["result"] as? [String: Any],               let dataObject = resultObject["data"] as? [String: Any]            {                if let payload = dataObject["json"] as? [[String: Any]] {                    return self.decodeOrganizations(payload)                }                if let payload = dataObject["json"] as? [String: Any],                   let orgs = payload["organizations"] as? [[String: Any]]                {                    return self.decodeOrganizations(orgs)                }            }        }         return []    }     private static func decodeOrganizations(_ raw: [[String: Any]]) -> [KiloOrganization] {        raw.compactMap { item -> KiloOrganization? in            guard let id = item["id"] as? String, !id.isEmpty else { return nil }            let name = (item["name"] as? String).map {                $0.trimmingCharacters(in: .whitespacesAndNewlines)            } ?? id            let role = (item["role"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)            let normalizedRole = (role?.isEmpty ?? true) ? nil : role            return KiloOrganization(id: id, name: name.isEmpty ? id : name, role: normalizedRole)        }    }     static func _parseSnapshotForTesting(_ data: Data) throws -> KiloUsageSnapshot {        try self.parseSnapshot(data: data)    }     static func _statusErrorForTesting(_ statusCode: Int) -> KiloUsageError? {        self.statusError(for: statusCode)    }     private static func statusError(for statusCode: Int) -> KiloUsageError? {        switch statusCode {        case 401, 403:            .unauthorized        case 404:            .endpointNotFound        case 500...599:            .serviceUnavailable(statusCode)        default:            nil        }    }     private static func makeBatchURL(baseURL: URL) throws -> URL {        let joinedProcedures = self.procedures.joined(separator: ",")        let endpoint = baseURL.appendingPathComponent(joinedProcedures)         let inputMap = Dictionary(uniqueKeysWithValues: self.procedures.indices.map {            (String($0), ["json": NSNull()])        })        let inputData = try JSONSerialization.data(withJSONObject: inputMap)        guard let inputString = String(data: inputData, encoding: .utf8) else {            throw KiloUsageError.parseFailed("Invalid batch input")        }         guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else {            throw KiloUsageError.parseFailed("Invalid batch endpoint")        }        components.queryItems = [            URLQueryItem(name: "batch", value: "1"),            URLQueryItem(name: "input", value: inputString),        ]         guard let url = components.url else {            throw KiloUsageError.parseFailed("Invalid batch endpoint")        }        return url    }     private static func parseSnapshot(data: Data) throws -> KiloUsageSnapshot {        guard let root = try? JSONSerialization.jsonObject(with: data) else {            throw KiloUsageError.parseFailed("Invalid JSON")

Lines 1–500 of 1199.