587 lines · 20.8 KB · Swift
RawDownload
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 {

Lines 1–500 of 587.