import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif public enum LiteLLMUsageError: LocalizedError, Sendable { case missingCredentials case missingBaseURL case missingUserID case invalidURL case apiError(String) case parseFailed(String) public var errorDescription: String? { switch self { case .missingCredentials: "Missing LiteLLM API key. Set apiKey in ~/.codexbar/config.json or LITELLM_API_KEY." case .missingBaseURL: "Missing LiteLLM base URL. Set enterpriseHost in ~/.codexbar/config.json or LITELLM_BASE_URL." case .missingUserID: "LiteLLM key info did not include a user_id or team_id." case .invalidURL: "LiteLLM URL is invalid." case let .apiError(message): "LiteLLM API error: \(message)" case let .parseFailed(message): "LiteLLM parse error: \(message)" } } } public struct LiteLLMKeyInfoSnapshot: Codable, Sendable, Equatable { public let userID: String? public let teamID: String? public let keyName: String? public let spendUSD: Double public let expiresAt: Date? public init(userID: String?, teamID: String?, keyName: String?, spendUSD: Double, expiresAt: Date?) { self.userID = userID self.teamID = teamID self.keyName = keyName self.spendUSD = spendUSD self.expiresAt = expiresAt } } public struct LiteLLMUsageSnapshot: Codable, Sendable, Equatable { public let userID: String? public let accountEmail: String? public let personalSpendUSD: Double public let personalBudgetUSD: Double? public let personalResetAt: Date? public let teamUsage: TeamUsage? public let keyName: String? public let keyExpiresAt: Date? public let updatedAt: Date public struct TeamUsage: Codable, Sendable, Equatable { public let id: String public let alias: String? public let spendUSD: Double public let budgetUSD: Double? public let resetAt: Date? public let budgetDuration: String? } public func toUsageSnapshot() -> UsageSnapshot { let primary = Self.rateWindow( spend: self.personalSpendUSD, budget: self.personalBudgetUSD, resetAt: self.personalResetAt, description: Self.budgetDescription(spend: self.personalSpendUSD, budget: self.personalBudgetUSD)) let secondary = self.teamUsage.flatMap { team in Self.rateWindow( spend: team.spendUSD, budget: team.budgetUSD, resetAt: team.resetAt, description: Self.teamDescription(team)) } let providerCost = self.providerCostSnapshot() return UsageSnapshot( primary: primary, secondary: secondary, tertiary: nil, providerCost: providerCost, subscriptionExpiresAt: self.keyExpiresAt, updatedAt: self.updatedAt, identity: ProviderIdentitySnapshot( providerID: .litellm, accountEmail: self.accountEmail, accountOrganization: self.teamUsage?.alias, loginMethod: "api")) } private static func rateWindow( spend: Double, budget: Double?, resetAt: Date?, description: String?) -> RateWindow? { guard let budget, budget > 0 else { return nil } return RateWindow( usedPercent: min(100, max(0, (spend / budget) * 100)), windowMinutes: nil, resetsAt: resetAt, resetDescription: description) } private static func budgetDescription(spend: Double, budget: Double?) -> String? { guard let budget, budget > 0 else { return UsageFormatter.usdString(spend) } return "\(UsageFormatter.usdString(spend)) / \(UsageFormatter.usdString(budget))" } private static func teamDescription(_ team: TeamUsage) -> String? { let label = team.alias.map { "Team \($0)" } ?? "Team" guard let budget = team.budgetUSD, budget > 0 else { return "\(label): \(UsageFormatter.usdString(team.spendUSD))" } return "\(label): \(UsageFormatter.usdString(team.spendUSD)) / \(UsageFormatter.usdString(budget))" } private func providerCostSnapshot() -> ProviderCostSnapshot? { let spend: Double let budget: Double? let period: String let resetsAt: Date? if self.userID == nil, let team = self.teamUsage { spend = team.spendUSD budget = team.budgetUSD period = (team.budgetUSD ?? 0) > 0 ? "Team budget" : "Team spend" resetsAt = team.resetAt } else { spend = self.personalSpendUSD budget = self.personalBudgetUSD period = (self.personalBudgetUSD ?? 0) > 0 ? "Personal budget" : "Personal spend" resetsAt = self.personalResetAt } guard spend > 0 || (budget ?? 0) > 0 else { return nil } return ProviderCostSnapshot( used: spend, limit: max(0, budget ?? 0), currencyCode: "USD", period: period, resetsAt: resetsAt, updatedAt: self.updatedAt) } } private struct LiteLLMKeyInfoResponse: Decodable { struct Info: Decodable { let keyName: String? let spend: Double? let expires: String? let userID: String? let teamID: String? private enum CodingKeys: String, CodingKey { case keyName = "key_name" case spend case expires case userID = "user_id" case teamID = "team_id" } } let info: Info } private struct LiteLLMUserInfoResponse: Decodable { struct UserInfo: Decodable { struct Metadata: Decodable { let preferredUsername: String? private enum CodingKeys: String, CodingKey { case preferredUsername = "preferred_username" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.preferredUsername = try? container.decodeIfPresent(String.self, forKey: .preferredUsername) } } let userID: String? let userAlias: String? let maxBudget: Double? let spend: Double? let userEmail: String? let budgetResetAt: String? let metadata: Metadata? private enum CodingKeys: String, CodingKey { case userID = "user_id" case userAlias = "user_alias" case maxBudget = "max_budget" case spend case userEmail = "user_email" case budgetResetAt = "budget_reset_at" case metadata } } struct Team: Decodable { let teamAlias: String? let teamID: String let maxBudget: Double? let spend: Double? let budgetResetAt: String? let budgetDuration: String? private enum CodingKeys: String, CodingKey { case teamAlias = "team_alias" case teamID = "team_id" case maxBudget = "max_budget" case spend case budgetResetAt = "budget_reset_at" case budgetDuration = "budget_duration" } } let userID: String? let userInfo: UserInfo let teams: [Team]? private enum CodingKeys: String, CodingKey { case userID = "user_id" case userInfo = "user_info" case teams } } private struct LiteLLMTeamInfoResponse: Decodable { struct TeamInfo: Decodable { let teamAlias: String? let teamID: String? let maxBudget: Double? let spend: Double? let budgetResetAt: String? let budgetDuration: String? private enum CodingKeys: String, CodingKey { case teamAlias = "team_alias" case teamID = "team_id" case maxBudget = "max_budget" case spend case budgetResetAt = "budget_reset_at" case budgetDuration = "budget_duration" } } let teamID: String? let teamInfo: TeamInfo private enum CodingKeys: String, CodingKey { case teamID = "team_id" case teamInfo = "team_info" } } public struct LiteLLMUsageFetcher: Sendable { public init() {} public static func fetchUsage( apiKey: String, baseURL: URL, transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, updatedAt: Date = Date()) async throws -> LiteLLMUsageSnapshot { let cleanedAPIKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !cleanedAPIKey.isEmpty else { throw LiteLLMUsageError.missingCredentials } let keyInfo = try await self.fetchKeyInfo( apiKey: cleanedAPIKey, baseURL: baseURL, transport: transport) if keyInfo.userID != nil { return try await self.fetchUserInfo( apiKey: cleanedAPIKey, baseURL: baseURL, keyInfo: keyInfo, transport: transport, updatedAt: updatedAt) } if keyInfo.teamID != nil { return try await self.fetchTeamInfo( apiKey: cleanedAPIKey, baseURL: baseURL, keyInfo: keyInfo, transport: transport, updatedAt: updatedAt) } throw LiteLLMUsageError.missingUserID } public static func _parseUserInfoForTesting( _ data: Data, keyInfo: LiteLLMKeyInfoSnapshot, updatedAt: Date) throws -> LiteLLMUsageSnapshot { try self.parseUserInfo(data: data, keyInfo: keyInfo, updatedAt: updatedAt) } public static func _parseKeyInfoForTesting(_ data: Data) throws -> LiteLLMKeyInfoSnapshot { try self.parseKeyInfo(data: data) } public static func _parseTeamInfoForTesting( _ data: Data, keyInfo: LiteLLMKeyInfoSnapshot, updatedAt: Date) throws -> LiteLLMUsageSnapshot { try self.parseTeamInfo(data: data, keyInfo: keyInfo, updatedAt: updatedAt) } public static func _keyInfoURLForTesting(baseURL: URL) -> URL { self.keyInfoURL(baseURL: baseURL) } public static func _userInfoURLForTesting(baseURL: URL, userID: String) -> URL { self.userInfoURL(baseURL: baseURL, userID: userID) } public static func _teamInfoURLForTesting(baseURL: URL, teamID: String) -> URL { self.teamInfoURL(baseURL: baseURL, teamID: teamID) } private static func fetchKeyInfo( apiKey: String, baseURL: URL, transport: any ProviderHTTPTransport) async throws -> LiteLLMKeyInfoSnapshot { // Virtual keys may read their own metadata; omit ?key= to avoid requiring or exposing a master key. let request = self.request( url: self.keyInfoURL(baseURL: baseURL), apiKey: apiKey) let response = try await transport.response(for: request) guard (200..<300).contains(response.statusCode) else { throw LiteLLMUsageError.apiError("HTTP \(response.statusCode): \(Self.responseSummary(response.data))") } return try self.parseKeyInfo(data: response.data) } private static func fetchUserInfo( apiKey: String, baseURL: URL, keyInfo: LiteLLMKeyInfoSnapshot, transport: any ProviderHTTPTransport, updatedAt: Date) async throws -> LiteLLMUsageSnapshot { guard let userID = keyInfo.userID else { throw LiteLLMUsageError.parseFailed("/user/info requested without a user_id") } let request = self.request( url: self.userInfoURL(baseURL: baseURL, userID: userID), apiKey: apiKey) let response = try await transport.response(for: request) guard (200..<300).contains(response.statusCode) else { throw LiteLLMUsageError.apiError("HTTP \(response.statusCode): \(Self.responseSummary(response.data))") } return try self.parseUserInfo(data: response.data, keyInfo: keyInfo, updatedAt: updatedAt) } private static func fetchTeamInfo( apiKey: String, baseURL: URL, keyInfo: LiteLLMKeyInfoSnapshot, transport: any ProviderHTTPTransport, updatedAt: Date) async throws -> LiteLLMUsageSnapshot { guard let teamID = keyInfo.teamID else { throw LiteLLMUsageError.parseFailed("/team/info requested without a team_id") } let request = self.request( url: self.teamInfoURL(baseURL: baseURL, teamID: teamID), apiKey: apiKey) let response = try await transport.response(for: request) guard (200..<300).contains(response.statusCode) else { throw LiteLLMUsageError.apiError("HTTP \(response.statusCode): \(Self.responseSummary(response.data))") } return try self.parseTeamInfo(data: response.data, keyInfo: keyInfo, updatedAt: updatedAt) } private static func request(url: URL, apiKey: String) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") return request } private static func keyInfoURL(baseURL: URL) -> URL { self.managementBaseURL(baseURL) .appendingPathComponent("key") .appendingPathComponent("info") } private static func userInfoURL(baseURL: URL, userID: String) -> URL { self.managementBaseURL(baseURL).appending( queryItems: [URLQueryItem(name: "user_id", value: userID)], pathComponents: ["user", "info"]) } private static func teamInfoURL(baseURL: URL, teamID: String) -> URL { self.managementBaseURL(baseURL).appending( queryItems: [URLQueryItem(name: "team_id", value: teamID)], pathComponents: ["team", "info"]) } private static func managementBaseURL(_ baseURL: URL) -> URL { let path = baseURL.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard path.split(separator: "/").last == "v1" else { return baseURL } var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) let parts = path.split(separator: "/").dropLast() components?.path = parts.isEmpty ? "" : "/" + parts.joined(separator: "/") return components?.url ?? baseURL } private static func parseKeyInfo(data: Data) throws -> LiteLLMKeyInfoSnapshot { do { let decoded = try JSONDecoder().decode(LiteLLMKeyInfoResponse.self, from: data) let userID = self.nonEmpty(decoded.info.userID) let teamID = self.nonEmpty(decoded.info.teamID) guard userID != nil || teamID != nil else { throw LiteLLMUsageError.missingUserID } return LiteLLMKeyInfoSnapshot( userID: userID, teamID: teamID, keyName: decoded.info.keyName, spendUSD: decoded.info.spend ?? 0, expiresAt: self.parseDate(decoded.info.expires)) } catch let error as LiteLLMUsageError { throw error } catch { throw LiteLLMUsageError.parseFailed(error.localizedDescription) } } private static func parseUserInfo( data: Data, keyInfo: LiteLLMKeyInfoSnapshot, updatedAt: Date) throws -> LiteLLMUsageSnapshot { do { let decoded = try JSONDecoder().decode(LiteLLMUserInfoResponse.self, from: data) guard let expectedUserID = keyInfo.userID else { throw LiteLLMUsageError.parseFailed("/user/info requested without a user_id") } if let responseUserID = decoded.userInfo.userID ?? decoded.userID, responseUserID != expectedUserID { throw LiteLLMUsageError.parseFailed("user_id did not match /key/info") } let accountEmail = self.firstNonEmpty( decoded.userInfo.userEmail, decoded.userInfo.userAlias, decoded.userInfo.metadata?.preferredUsername) let team = self.preferredTeam(from: decoded.teams, keyTeamID: keyInfo.teamID) return LiteLLMUsageSnapshot( userID: expectedUserID, accountEmail: accountEmail, personalSpendUSD: decoded.userInfo.spend ?? 0, personalBudgetUSD: decoded.userInfo.maxBudget, personalResetAt: self.parseDate(decoded.userInfo.budgetResetAt), teamUsage: team.map { LiteLLMUsageSnapshot.TeamUsage( id: $0.teamID, alias: $0.teamAlias, spendUSD: $0.spend ?? 0, budgetUSD: $0.maxBudget, resetAt: self.parseDate($0.budgetResetAt), budgetDuration: $0.budgetDuration) }, keyName: keyInfo.keyName, keyExpiresAt: keyInfo.expiresAt, updatedAt: updatedAt) } catch let error as LiteLLMUsageError { throw error } catch { throw LiteLLMUsageError.parseFailed(error.localizedDescription) } } private static func parseTeamInfo( data: Data, keyInfo: LiteLLMKeyInfoSnapshot, updatedAt: Date) throws -> LiteLLMUsageSnapshot { do { let decoded = try JSONDecoder().decode(LiteLLMTeamInfoResponse.self, from: data) guard let expectedTeamID = keyInfo.teamID else { throw LiteLLMUsageError.parseFailed("/team/info requested without a team_id") } if let responseTeamID = self.firstNonEmpty(decoded.teamInfo.teamID, decoded.teamID), responseTeamID != expectedTeamID { throw LiteLLMUsageError.parseFailed("team_id did not match /key/info") } let team = decoded.teamInfo return LiteLLMUsageSnapshot( userID: nil, accountEmail: nil, personalSpendUSD: 0, personalBudgetUSD: nil, personalResetAt: nil, teamUsage: LiteLLMUsageSnapshot.TeamUsage( id: expectedTeamID, alias: team.teamAlias, spendUSD: team.spend ?? 0, budgetUSD: team.maxBudget, resetAt: self.parseDate(team.budgetResetAt), budgetDuration: team.budgetDuration), keyName: keyInfo.keyName, keyExpiresAt: keyInfo.expiresAt, updatedAt: updatedAt) } catch let error as LiteLLMUsageError { throw error } catch { throw LiteLLMUsageError.parseFailed(error.localizedDescription) } } private static func preferredTeam( from teams: [LiteLLMUserInfoResponse.Team]?, keyTeamID: String?) -> LiteLLMUserInfoResponse.Team? { guard let teams, let keyTeamID else { return nil } return teams.first(where: { $0.teamID == keyTeamID }) } private static func parseDate(_ raw: String?) -> Date? { guard let raw else { return nil } if let date = self.iso8601DateFormatter(fractionalSeconds: true).date(from: raw) { return date } return self.iso8601DateFormatter(fractionalSeconds: false).date(from: raw) } private static func iso8601DateFormatter(fractionalSeconds: Bool) -> ISO8601DateFormatter { let formatter = ISO8601DateFormatter() if fractionalSeconds { formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] } return formatter } private static func firstNonEmpty(_ values: String?...) -> String? { values.lazy .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .first { !$0.isEmpty } } private static func nonEmpty(_ value: String?) -> String? { self.firstNonEmpty(value) } private static func responseSummary(_ data: Data) -> String { String(bytes: data.prefix(500), encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" } } extension URL { fileprivate func appending(queryItems: [URLQueryItem], pathComponents: [String]) -> URL { let url = pathComponents.reduce(self) { partial, component in partial.appendingPathComponent(component) } guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } components.queryItems = queryItems return components.url ?? url } }