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. Load more
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_length public 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")