diff --git a/apps/macos-usage/.gitignore b/apps/macos-usage/.gitignore new file mode 100644 index 00000000000..564c4754447 --- /dev/null +++ b/apps/macos-usage/.gitignore @@ -0,0 +1,3 @@ +.build/ +LiteLLMUsage.app/ +LiteLLMUsage.dmg diff --git a/apps/macos-usage/DESIGN.md b/apps/macos-usage/DESIGN.md new file mode 100644 index 00000000000..62b0d981133 --- /dev/null +++ b/apps/macos-usage/DESIGN.md @@ -0,0 +1,84 @@ +# LiteLLM Usage Menu Bar Design System + +## 0. Research Log +- Embedded refs: operational menu bar surface, using the taste-skill restraint rather than a marketing layout +- Lazyweb: skipped, this is a native macOS utility with no web surface +- Imagen drafts: skipped, a system utility has no image-led visual contract +- Apple platform reference: SwiftUI `MenuBarExtra` window style, available on macOS 13+ + +## 1. Atmosphere & Identity + +A quiet glanceable utility. The signature is a single signal, the current personal quota percentage, supported by a small amount of operational detail when the menu opens + +## 2. Color + +| Role | Token | Value | Usage | +|------|------|------|------| +| Surface | surface | system background | Menu popover | +| Text | text | primary | Main labels | +| Muted | muted | secondary | Supporting facts | +| Accent | accent | system blue | Refresh and settings actions | +| Success | success | system green | Healthy quota state | +| Warning | warning | system orange | High quota state | +| Error | error | system red | Failure state | + +## 3. Typography + +Primary: macOS system font. Mono: system monospaced font for amounts and percentages. Body text is at least 13pt + +## 4. Spacing & Layout + +Spacing derives from a 4pt base. The popover uses a 320pt width, 16pt outer padding, 12pt section gaps, and 8pt inline gaps + +## 5. Components + +### Menu bar status +- Structure: compact text label with percentage +- Variants: loading, healthy, warning, error, no quota +- States: default, disabled while loading +- Accessibility: label includes the full percentage and state + +### App icon +- Motif: the official LiteLLM logo aligned with the menu bar's πŸš… identity +- Source: the official square `assets/litellm_logo.jpg` mark with its sky-blue background extended across the canvas, generated into a standard macOS `.icns` bundle resource + +### Usage popover +- Structure: title, quota signal, usage facts, status line, action row +- Variants: configured, needs key, loading, success, failure, unavailable quota +- States: default, loading, error +- Accessibility: buttons have labels and visible text; color is never the only status signal + +### Settings form + - Structure: auto-refresh picker, gateway URL field, username field, secure password field, explanatory label, save action +- Variants: empty, configured +- States: focused, saving, saved; successful save dismisses the settings window +- Accessibility: secure text entry and explicit save action + +### Settings window + - Size: 380pt wide by 360pt high so the refresh picker, gateway URL, fields, explanation, and save action remain visible +- Presentation: a single independent window opened explicitly by the usage popover's settings action, never a sheet attached to the menu bar +- Focus: opening settings activates the app for keyboard input; closing the popover must not close settings +- Storage: username and password share one Keychain item; session tokens remain in memory for the running app + +### Usage scope picker +- Placement: top of the usage popover, above the quota signal +- Options: team total plus the authenticated user's named keys +- Fallback: a key without its own budget uses the team's monthly budget as its limit + +### Auto refresh +- Options: off, adaptive, 1 minute, 5 minutes, 15 minutes, 30 minutes, or 1 hour +- Default: adaptive +- Adaptive behavior: uses current utilization, recent spend rate, estimated time to limit, and reset time +- Behavior: refreshes the selected team or key budget while the menu bar app is running + +## 6. Motion & Interaction + +No decorative animation. Native popover transitions and button press feedback are retained. Refresh is explicit and periodic refresh does not steal focus + +## 7. Depth & Surface + +Use native macOS popover materials and tonal hierarchy. Do not add custom shadows or gradients + +## 8. Accessibility Constraints & Accepted Debt + +Percentage text remains readable without color. The app does not expose a custom chart or notification surface in this version diff --git a/apps/macos-usage/Package.swift b/apps/macos-usage/Package.swift new file mode 100644 index 00000000000..feb3f12f3c0 --- /dev/null +++ b/apps/macos-usage/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "LiteLLMUsageMenuBar", + platforms: [.macOS(.v13)], + products: [.executable(name: "LiteLLMUsageMenuBar", targets: ["LiteLLMUsageMenuBar"])], + targets: [.executableTarget(name: "LiteLLMUsageMenuBar")] +) diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift new file mode 100644 index 00000000000..67efc11ffce --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift @@ -0,0 +1,143 @@ +import Foundation + +struct GatewayClient: Sendable { + let baseURL: URL + + static let defaultBaseURL = URL(string: "https://api.litellm.ai")! + + static func baseURL(from input: String) -> URL? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard var components = URLComponents(string: trimmed), + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = components.host, + !host.isEmpty, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil else { return nil } + while components.path.count > 1, components.path.hasSuffix("/") { + components.path.removeLast() + } + return components.url + } + + func login(username: String, password: String) async throws -> AuthSession { + let url = baseURL.appending(path: "/v2/login") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["username": username, "password": password]) + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 401 { throw GatewayError.unauthorized } + throw GatewayError.server(httpResponse.statusCode) + } + let payload = try JSONDecoder().decode(LoginResponse.self, from: data) + guard let token = payload.token, let session = AuthSession(token: token) else { + throw GatewayError.invalidLoginResponse + } + return session + } + + func fetchUserInfo(apiKey: String) async throws -> UserInfoResponse { + let url = baseURL.appending(path: "/v2/user/info") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + + return try decoder.decode(UserInfoResponse.self, from: data) + } + + func fetchTeamInfo(apiKey: String, teamID: String) async throws -> TeamBudget { + var components = URLComponents(url: baseURL.appending(path: "/team/info"), resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "team_id", value: teamID)] + guard let url = components?.url else { throw GatewayError.invalidResponse } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + return try decoder.decode(TeamInfoResponse.self, from: data).teamInfo + } + + func fetchKeyList(apiKey: String) async throws -> [String] { + let url = baseURL.appending(path: "/key/list") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + return try decoder.decode(KeyListResponse.self, from: data).keys + } + + func fetchKeyInfo(apiKey: String, key: String) async throws -> KeyBudget { + var components = URLComponents(url: baseURL.appending(path: "/key/info"), resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "key", value: key)] + guard let url = components?.url else { throw GatewayError.invalidResponse } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + return try decoder.decode(KeyInfoResponse.self, from: data).info + } + + private var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let value = try decoder.singleValueContainer().decode(String.self) + if let date = ISO8601DateFormatter().date(from: value) { return date } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX" + guard let date = formatter.date(from: value) else { throw GatewayError.invalidResponse } + return date + } + return decoder + } + +} + +private struct LoginResponse: Decodable { + let token: String? +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift new file mode 100644 index 00000000000..aa976a19408 --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift @@ -0,0 +1,80 @@ +import Foundation +import Security +import LocalAuthentication + +struct KeychainStore: Sendable { + private let service = "com.litellm.usage-menubar" + private let credentialsAccount = "ui-credentials" + + func readCredentials() throws -> (username: String, password: String)? { + guard let value = try read(account: credentialsAccount) else { return nil } + return try JSONDecoder().decode(StoredCredentials.self, from: Data(value.utf8)).tuple + } + + func saveCredentials(username: String, password: String) throws { + let value = try JSONEncoder().encode(StoredCredentials(username: username, password: password)) + try save(String(data: value, encoding: .utf8) ?? "", account: credentialsAccount) + } + + private func read(account: String) throws -> String? { + let context = LAContext() + context.interactionNotAllowed = true + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw KeychainError(status) + } + return String(data: data, encoding: .utf8) + } + + private func save(_ value: String, account: String) throws { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + var item = query + item[kSecValueData as String] = data + let addStatus = SecItemAdd(item as CFDictionary, nil) + if addStatus == errSecSuccess { return } + guard addStatus == errSecDuplicateItem else { + throw KeychainError(addStatus) + } + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if updateStatus != errSecSuccess { + throw KeychainError(updateStatus) + } + } +} + +private struct StoredCredentials: Codable { + let username: String + let password: String + + var tuple: (username: String, password: String) { + (username, password) + } +} + +struct KeychainError: LocalizedError, Equatable { + let status: OSStatus + + init(_ status: OSStatus) { self.status = status } + + var errorDescription: String? { + if status == errSecInteractionNotAllowed || status == errSecAuthFailed || status == errSecUserCanceled { + return "μ €μž₯된 둜그인 정보에 μ ‘κ·Όν•  수 μ—†μŠ΅λ‹ˆλ‹€. μ„€μ •μ—μ„œ λ‹€μ‹œ μ €μž₯ν•˜μ„Έμš”. Keychain μŠΉμΈμ€ μ €μž₯ν•  λ•Œλ§Œ μš”μ²­ν•©λ‹ˆλ‹€" + } + return "Keychain μ²˜λ¦¬μ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€ (\(status))" + } +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift new file mode 100644 index 00000000000..cc0d2400c43 --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift @@ -0,0 +1,34 @@ +import SwiftUI + +@main +struct LiteLLMUsageMenuBarApp: App { + @StateObject private var store = UsageStore() + + var body: some Scene { + MenuBarExtra { + UsagePopover().environmentObject(store) + } label: { + MenuBarLabel().environmentObject(store) + } + .menuBarExtraStyle(.window) + + Window("둜그인 μ„€μ •", id: "login-settings") { + SettingsView().environmentObject(store) + } + .windowResizability(.contentSize) + .defaultPosition(.center) + } +} + +private struct MenuBarLabel: View { + @EnvironmentObject private var store: UsageStore + + var body: some View { + switch store.state { + case let .ready(snapshot): Text("πŸš… \(snapshot.percentage.formatted(.number.precision(.fractionLength(0))))%") + case .loading: Text("πŸš… ...") + case .needsKey: Text("πŸš… --") + case .unavailable, .failure: Text("πŸš… !") + } + } +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift new file mode 100644 index 00000000000..a2501916e3a --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift @@ -0,0 +1,176 @@ +import Foundation + +struct UserInfoResponse: Decodable, Sendable { + let userID: String + let spend: Double + let maxBudget: Double? + let budgetDuration: String? + let budgetResetAt: Date? + let teams: [String] + + enum CodingKeys: String, CodingKey { + case userID = "user_id" + case spend + case maxBudget = "max_budget" + case budgetDuration = "budget_duration" + case budgetResetAt = "budget_reset_at" + case teams + } +} + +struct TeamInfoResponse: Decodable, Sendable { + let teamInfo: TeamBudget + + enum CodingKeys: String, CodingKey { + case teamInfo = "team_info" + } +} + +struct TeamBudget: Decodable, Sendable { + let spend: Double + let maxBudget: Double? + let budgetDuration: String? + let budgetResetAt: Date? + + enum CodingKeys: String, CodingKey { + case spend + case maxBudget = "max_budget" + case budgetDuration = "budget_duration" + case budgetResetAt = "budget_reset_at" + } +} + +struct KeyListResponse: Decodable, Sendable { + let keys: [String] +} + +struct KeyInfoResponse: Decodable, Sendable { + let info: KeyBudget + + enum CodingKeys: String, CodingKey { + case info + } +} + +struct KeyBudget: Decodable, Sendable { + let keyAlias: String? + let keyName: String + let spend: Double + let maxBudget: Double? + let budgetDuration: String? + let budgetResetAt: Date? + let teamID: String? + + enum CodingKeys: String, CodingKey { + case keyAlias = "key_alias" + case keyName = "key_name" + case spend + case maxBudget = "max_budget" + case budgetDuration = "budget_duration" + case budgetResetAt = "budget_reset_at" + case teamID = "team_id" + } +} + +struct GatewayKey: Identifiable, Equatable, Sendable { + let value: String + let keyName: String + let alias: String? + + var id: String { keyName } + var displayName: String { alias ?? keyName } +} + +struct AuthSession: Sendable { + let token: String + let apiKey: String + let expiresAt: Date + + init?(token: String) { + let segments: [Substring] = token.split(separator: ".") + guard segments.count == 3 else { return nil } + var encoded = String(segments[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) + guard let data = Data(base64Encoded: encoded), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let apiKey = payload["key"] as? String, + let exp = payload["exp"] as? TimeInterval else { return nil } + self.token = token + self.apiKey = apiKey + self.expiresAt = Date(timeIntervalSince1970: exp) + } +} + +enum UsageState: Equatable, Sendable { + case needsKey + case loading + case ready(UsageSnapshot) + case unavailable(String) + case failure(String) +} + +enum AutoRefreshInterval: Int, CaseIterable, Identifiable, Sendable { + case off = 0 + case adaptive = -1 + case oneMinute = 60 + case fiveMinutes = 300 + case fifteenMinutes = 900 + case thirtyMinutes = 1800 + case oneHour = 3600 + + var id: Self { self } + + var fixedSeconds: TimeInterval? { + switch self { + case .off, .adaptive: nil + case .oneMinute, .fiveMinutes, .fifteenMinutes, .thirtyMinutes, .oneHour: + TimeInterval(rawValue) + } + } + + var title: String { + switch self { + case .off: "끔" + case .adaptive: "μ μ‘ν˜•" + case .oneMinute: "1λΆ„" + case .fiveMinutes: "5λΆ„" + case .fifteenMinutes: "15λΆ„" + case .thirtyMinutes: "30λΆ„" + case .oneHour: "1μ‹œκ°„" + } + } +} + +struct UsageSnapshot: Equatable, Sendable { + let spend: Double + let limit: Double + let percentage: Double + let budgetDuration: String + let resetAt: Date? +} + +enum GatewayError: LocalizedError { + case invalidResponse + case unauthorized + case forbidden + case notFound + case server(Int) + case noUserBudget + case notMonthlyBudget(String) + case invalidLoginResponse + case invalidGatewayURL + + var errorDescription: String? { + switch self { + case .invalidResponse: "κ²Œμ΄νŠΈμ›¨μ΄ 응닡을 읽을 수 μ—†μŠ΅λ‹ˆλ‹€" + case .unauthorized: "둜그인 정보가 μ˜¬λ°”λ₯΄μ§€ μ•Šκ±°λ‚˜ μ„Έμ…˜μ΄ λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€" + case .forbidden: "개인 μ‚¬μš©λŸ‰ μ‘°νšŒκ°€ κ±°λΆ€λ˜μ—ˆμŠ΅λ‹ˆλ‹€ (HTTP 403)" + case .notFound: "μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€" + case let .server(code): "κ²Œμ΄νŠΈμ›¨μ΄ 였λ₯˜ (HTTP \(code))" + case .noUserBudget: "개인 μ˜ˆμ‚° ν•œλ„κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€" + case let .notMonthlyBudget(duration): "개인 μ˜ˆμ‚°μ΄ μ›” λ‹¨μœ„κ°€ μ•„λ‹™λ‹ˆλ‹€ (\(duration))" + case .invalidLoginResponse: "둜그인 응닡을 읽을 수 μ—†μŠ΅λ‹ˆλ‹€" + case .invalidGatewayURL: "μ˜¬λ°”λ₯Έ κ²Œμ΄νŠΈμ›¨μ΄ URL을 μž…λ ₯ν•˜μ„Έμš” (예: https://gateway.example.com)" + } + } +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift new file mode 100644 index 00000000000..40eb7e8e3d0 --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift @@ -0,0 +1,311 @@ +import Foundation +import Combine + +@MainActor +final class UsageStore: ObservableObject { + @Published private(set) var state: UsageState = .needsKey + @Published private(set) var lastUpdated: Date? + @Published private(set) var availableKeys: [GatewayKey] = [] + @Published private(set) var selectedKeyName: String? + @Published private(set) var keyListMessage: String? + @Published private(set) var isLoadingKeys = false + @Published private(set) var autoRefreshInterval: AutoRefreshInterval + private let keychain: KeychainStore + private var client: GatewayClient + private(set) var gatewayURL: URL + private var credentials: (username: String, password: String)? + private var session: AuthSession? + private var isRefreshing = false + private var refreshTimer: Timer? + private var lastSpendSample: SpendSample? + private var smoothedSpendPerDay: Double? + private let selectedKeyDefaultsKey = "selected-key-name" + private let refreshIntervalDefaultsKey = "auto-refresh-interval" + private let gatewayURLDefaultsKey = "gateway-url" + + init(keychain: KeychainStore = KeychainStore(), client: GatewayClient? = nil) { + let configuredURL = client?.baseURL ?? Self.loadGatewayURL() + self.keychain = keychain + self.client = client ?? GatewayClient(baseURL: configuredURL) + gatewayURL = configuredURL + selectedKeyName = UserDefaults.standard.string(forKey: selectedKeyDefaultsKey) + let storedInterval = UserDefaults.standard.object(forKey: refreshIntervalDefaultsKey) as? Int + autoRefreshInterval = AutoRefreshInterval(rawValue: storedInterval ?? AutoRefreshInterval.adaptive.rawValue) ?? .adaptive + do { + credentials = try keychain.readCredentials() + state = hasCredentials ? .loading : .needsKey + } catch { + state = .failure(error.localizedDescription) + } + configureRefreshTimer() + Task { [weak self] in + await self?.loadKeys() + await self?.refresh() + } + } + + var hasCredentials: Bool { credentials != nil } + + var savedUsername: String { credentials?.username ?? "" } + var savedPassword: String { credentials?.password ?? "" } + + func saveSettings(gatewayURL input: String, username: String, password: String) throws { + guard let newGatewayURL = GatewayClient.baseURL(from: input) else { + throw GatewayError.invalidGatewayURL + } + let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUsername.isEmpty, !password.isEmpty else { throw KeychainError(errSecParam) } + try keychain.saveCredentials(username: trimmedUsername, password: password) + UserDefaults.standard.set(newGatewayURL.absoluteString, forKey: gatewayURLDefaultsKey) + gatewayURL = newGatewayURL + client = GatewayClient(baseURL: newGatewayURL) + credentials = (trimmedUsername, password) + session = nil + availableKeys = [] + keyListMessage = nil + lastSpendSample = nil + smoothedSpendPerDay = nil + configureRefreshTimer() + Task { + while isRefreshing { + try? await Task.sleep(for: .milliseconds(50)) + } + await loadKeys() + await refresh() + } + } + + func setAutoRefreshInterval(_ interval: AutoRefreshInterval) { + guard autoRefreshInterval != interval else { return } + autoRefreshInterval = interval + UserDefaults.standard.set(interval.rawValue, forKey: refreshIntervalDefaultsKey) + configureRefreshTimer() + } + + func refresh() async { + guard !isRefreshing, credentials != nil else { return } + isRefreshing = true + defer { + isRefreshing = false + scheduleNextRefresh() + } + do { + let budget: TeamBudget + do { + let auth = try await authenticatedSession() + budget = try await fetchCurrentBudget(apiKey: auth.apiKey) + } catch GatewayError.unauthorized { + session = nil + let auth = try await authenticatedSession() + budget = try await fetchCurrentBudget(apiKey: auth.apiKey) + } + guard let limit = budget.maxBudget, limit > 0 else { throw GatewayError.noUserBudget } + guard let duration = budget.budgetDuration, isMonthly(duration) else { + throw GatewayError.notMonthlyBudget(budget.budgetDuration ?? "μ—†μŒ") + } + let now = Date() + updateSpendRate(spend: budget.spend, at: now) + state = .ready(UsageSnapshot( + spend: budget.spend, + limit: limit, + percentage: max(0, budget.spend / limit * 100), + budgetDuration: duration, + resetAt: budget.budgetResetAt + )) + lastUpdated = now + } catch let error as GatewayError { + state = .failure(error.localizedDescription) + } catch { + state = .failure("κ²Œμ΄νŠΈμ›¨μ΄μ— μ—°κ²°ν•  수 μ—†μŠ΅λ‹ˆλ‹€") + } + } + + func loadKeys() async { + guard !isLoadingKeys, hasCredentials, availableKeys.isEmpty else { return } + isLoadingKeys = true + keyListMessage = nil + defer { isLoadingKeys = false } + do { + let auth: AuthSession + do { + auth = try await authenticatedSession() + } catch GatewayError.unauthorized { + session = nil + auth = try await authenticatedSession() + } + let keys = try await fetchKeyOptions(apiKey: auth.apiKey) + availableKeys = keys + if let selectedKeyName, !keys.contains(where: { $0.keyName == selectedKeyName }) { + self.selectedKeyName = nil + UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) + } + } catch let error as GatewayError { + keyListMessage = error.localizedDescription + } catch { + keyListMessage = "ν‚€ λͺ©λ‘μ„ 뢈러올 수 μ—†μŠ΅λ‹ˆλ‹€" + } + } + + func selectKey(named keyName: String?) { + guard selectedKeyName != keyName else { return } + selectedKeyName = keyName + lastSpendSample = nil + smoothedSpendPerDay = nil + if let keyName { + UserDefaults.standard.set(keyName, forKey: selectedKeyDefaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) + } + Task { await refresh() } + } + + private func configureRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + scheduleNextRefresh() + } + + private func scheduleNextRefresh() { + refreshTimer?.invalidate() + guard hasCredentials, let delay = nextRefreshDelay() else { + refreshTimer = nil + return + } + refreshTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.refresh() + } + } + } + + private func nextRefreshDelay() -> TimeInterval? { + switch autoRefreshInterval { + case .off: + return nil + case .adaptive: + guard case let .ready(snapshot) = state else { return 900 } + return adaptiveRefreshDelay(for: snapshot) + case .oneMinute, .fiveMinutes, .fifteenMinutes, .thirtyMinutes, .oneHour: + return autoRefreshInterval.fixedSeconds + } + } + + private func adaptiveRefreshDelay(for snapshot: UsageSnapshot) -> TimeInterval { + if snapshot.percentage >= 100 { + guard let resetAt = snapshot.resetAt else { return 86_400 } + return min(86_400, max(60, resetAt.timeIntervalSinceNow - 60)) + } + + let remaining = max(0, snapshot.limit - snapshot.spend) + if let smoothedSpendPerDay, smoothedSpendPerDay > 0 { + let secondsUntilLimit = remaining / smoothedSpendPerDay * 86_400 + if secondsUntilLimit <= 6 * 3_600 { return 60 } + if secondsUntilLimit <= 24 * 3_600 { return 300 } + if secondsUntilLimit <= 3 * 86_400 { return 900 } + } + + if snapshot.percentage >= 95 { return 300 } + if snapshot.percentage >= 80 { return 900 } + if snapshot.percentage >= 50 { return 3_600 } + return 14_400 + } + + private func updateSpendRate(spend: Double, at date: Date) { + if let lastSpendSample { + let elapsed = date.timeIntervalSince(lastSpendSample.date) + if elapsed > 0 { + let spendDelta = spend - lastSpendSample.spend + if spendDelta < 0 { + smoothedSpendPerDay = nil + } else { + let spendPerDay = spendDelta / elapsed * 86_400 + smoothedSpendPerDay = smoothedSpendPerDay.map { $0 * 0.7 + spendPerDay * 0.3 } ?? spendPerDay + } + } + } + lastSpendSample = SpendSample(spend: spend, date: date) + } + + private struct SpendSample { + let spend: Double + let date: Date + } + + private func authenticatedSession() async throws -> AuthSession { + if let session, session.expiresAt.timeIntervalSinceNow > 300 { return session } + guard let credentials else { throw GatewayError.unauthorized } + let refreshed = try await client.login(username: credentials.username, password: credentials.password) + session = refreshed + return refreshed + } + + private func fetchBudget(apiKey: String) async throws -> TeamBudget { + let userInfo = try await client.fetchUserInfo(apiKey: apiKey) + if userInfo.maxBudget != nil { + return TeamBudget( + spend: userInfo.spend, + maxBudget: userInfo.maxBudget, + budgetDuration: userInfo.budgetDuration, + budgetResetAt: userInfo.budgetResetAt + ) + } + guard let teamID = userInfo.teams.first else { throw GatewayError.noUserBudget } + return try await client.fetchTeamInfo(apiKey: apiKey, teamID: teamID) + } + + private func fetchCurrentBudget(apiKey: String) async throws -> TeamBudget { + guard let selectedKeyName else { + return try await fetchBudget(apiKey: apiKey) + } + + let keys = availableKeys.isEmpty ? try await fetchKeyOptions(apiKey: apiKey) : availableKeys + availableKeys = keys + guard let selectedKey = keys.first(where: { $0.keyName == selectedKeyName }) else { + self.selectedKeyName = nil + UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) + return try await fetchBudget(apiKey: apiKey) + } + + let keyInfo = try await client.fetchKeyInfo(apiKey: apiKey, key: selectedKey.value) + if let maxBudget = keyInfo.maxBudget { + return TeamBudget( + spend: keyInfo.spend, + maxBudget: maxBudget, + budgetDuration: keyInfo.budgetDuration, + budgetResetAt: keyInfo.budgetResetAt + ) + } + guard let teamID = keyInfo.teamID else { throw GatewayError.noUserBudget } + let teamBudget = try await client.fetchTeamInfo(apiKey: apiKey, teamID: teamID) + return TeamBudget( + spend: keyInfo.spend, + maxBudget: teamBudget.maxBudget, + budgetDuration: teamBudget.budgetDuration, + budgetResetAt: teamBudget.budgetResetAt + ) + } + + private func fetchKeyOptions(apiKey: String) async throws -> [GatewayKey] { + let keys = try await client.fetchKeyList(apiKey: apiKey) + var options: [GatewayKey] = [] + for key in keys { + let info = try await client.fetchKeyInfo(apiKey: apiKey, key: key) + options.append(GatewayKey(value: key, keyName: info.keyName, alias: info.keyAlias)) + } + return options + } + + private func isMonthly(_ duration: String) -> Bool { + let normalized = duration.lowercased() + return ["1mo", "1month", "30d", "monthly"].contains(normalized) + } + + private static func loadGatewayURL() -> URL { + guard let savedURL = UserDefaults.standard.string(forKey: "gateway-url"), + let gatewayURL = GatewayClient.baseURL(from: savedURL) else { + return GatewayClient.defaultBaseURL + } + return gatewayURL + } + +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift new file mode 100644 index 00000000000..99c7927a0bb --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct UsagePopover: View { + @EnvironmentObject private var store: UsageStore + @Environment(\.openWindow) private var openWindow + private let teamSelection = "__team__" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + scopePicker + content + Divider() + actions + } + .padding(16) + .frame(width: 320) + .task { + await store.loadKeys() + await store.refresh() + } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("LiteLLM μ‚¬μš©λŸ‰").font(.headline) + } + Spacer() + statusIcon + } + } + + private var scopePicker: some View { + VStack(alignment: .leading, spacing: 4) { + Picker("쑰회 λŒ€μƒ", selection: Binding( + get: { store.selectedKeyName ?? teamSelection }, + set: { store.selectKey(named: $0 == teamSelection ? nil : $0) } + )) { + Text("νŒ€ 전체").tag(teamSelection) + ForEach(store.availableKeys) { key in + Text(key.displayName).tag(key.keyName) + } + } + .pickerStyle(.menu) + .disabled(!store.hasCredentials || store.isLoadingKeys) + if let keyListMessage = store.keyListMessage, !store.isLoadingKeys { + Text(keyListMessage).font(.caption).foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var content: some View { + switch store.state { + case .needsKey: + MessageView(title: "둜그인 정보λ₯Ό μ„€μ •ν•˜μ„Έμš”", detail: "μ„€μ •μ—μ„œ κ²Œμ΄νŠΈμ›¨μ΄ ID와 λΉ„λ°€λ²ˆν˜Έλ₯Ό μž…λ ₯ν•˜λ©΄ μ‚¬μš©λŸ‰μ„ μ‘°νšŒν•©λ‹ˆλ‹€", symbol: "person.crop.circle.badge.key") + case .loading: + HStack(spacing: 8) { ProgressView(); Text("μ‚¬μš©λŸ‰μ„ ν™•μΈν•˜λŠ” 쀑...").foregroundStyle(.secondary) } + case let .ready(snapshot): + VStack(alignment: .leading, spacing: 10) { + Text(snapshot.percentage.formatted(.number.precision(.fractionLength(0))) + "%") + .font(.system(size: 38, weight: .bold, design: .rounded)) + .foregroundStyle(color(for: snapshot.percentage)) + ProgressView(value: min(snapshot.percentage, 100), total: 100) + .tint(color(for: snapshot.percentage)) + HStack { + Fact(label: "μ‚¬μš©μ•‘", value: currency(snapshot.spend)) + Spacer() + Fact(label: "ν•œλ„", value: currency(snapshot.limit)) + } + if let resetAt = snapshot.resetAt { + Text("λ‹€μŒ μ΄ˆκΈ°ν™” " + resetAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption).foregroundStyle(.secondary) + } + } + case let .unavailable(message), let .failure(message): + MessageView(title: "μ‚¬μš©λŸ‰μ„ ν‘œμ‹œν•  수 μ—†μŠ΅λ‹ˆλ‹€", detail: message, symbol: "exclamationmark.triangle") + } + } + + private var actions: some View { + HStack { + Button("μƒˆλ‘œκ³ μΉ¨") { Task { await store.refresh() } } + .buttonStyle(.borderless) + .disabled(store.state == .loading || !store.hasCredentials) + Spacer() + Button("μ„€μ •") { + openWindow(id: "login-settings") + NSApplication.shared.activate(ignoringOtherApps: true) + } + .buttonStyle(.borderless) + Button("μ’…λ£Œ") { NSApplication.shared.terminate(nil) } + .buttonStyle(.borderless) + } + .font(.callout) + } + + @ViewBuilder + private var statusIcon: some View { + switch store.state { + case .ready(let snapshot): Image(systemName: "circle.fill").foregroundStyle(color(for: snapshot.percentage)) + case .loading: ProgressView().controlSize(.small) + case .needsKey: Image(systemName: "key.fill").foregroundStyle(.secondary) + case .unavailable, .failure: Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red) + } + } + + private func color(for percentage: Double) -> Color { + percentage >= 90 ? .red : percentage >= 75 ? .orange : .green + } + + private func currency(_ value: Double) -> String { value.formatted(.currency(code: "USD")) } +} + +struct SettingsView: View { + @EnvironmentObject private var store: UsageStore + @Environment(\.dismiss) private var dismiss + @State private var gatewayURL = "" + @State private var username = "" + @State private var password = "" + @State private var message: String? + + var body: some View { + Form { + Section("μ‚¬μš©λŸ‰") { + Picker("μžλ™ μƒˆλ‘œκ³ μΉ¨", selection: Binding( + get: { store.autoRefreshInterval }, + set: { store.setAutoRefreshInterval($0) } + )) { + ForEach(AutoRefreshInterval.allCases) { interval in + Text(interval.title).tag(interval) + } + } + Text("νŒμ˜€λ²„κ°€ λ‹«ν˜€ μžˆμ–΄λ„ 메뉴바 μ‚¬μš©λŸ‰μ„ κ°±μ‹ ν•©λ‹ˆλ‹€") + .font(.caption).foregroundStyle(.secondary) + } + Section("κ²Œμ΄νŠΈμ›¨μ΄") { + TextField("μ„œλ²„ μ£Όμ†Œ", text: $gatewayURL) + TextField("아이디", text: $username) + SecureField("λΉ„λ°€λ²ˆν˜Έ", text: $password) + Text("LiteLLM ν”„λ‘μ‹œ μ„œλ²„ μ£Όμ†Œλ₯Ό μž…λ ₯ν•˜μ„Έμš”") + .font(.caption).foregroundStyle(.secondary) + Text("둜그인 μ •λ³΄λŠ” macOS Keychain에 μ €μž₯되며 μ„Έμ…˜μ€ μ•± μ‹€ν–‰ 쀑 λ©”λͺ¨λ¦¬μ—λ§Œ μœ μ§€λ©λ‹ˆλ‹€") + .font(.caption).foregroundStyle(.secondary) + Button("μ €μž₯") { + guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { + message = "아이디와 λΉ„λ°€λ²ˆν˜Έλ₯Ό λͺ¨λ‘ μž…λ ₯ν•˜μ„Έμš”" + return + } + do { + try store.saveSettings(gatewayURL: gatewayURL, username: username, password: password) + username = "" + password = "" + dismiss() + } catch { message = error.localizedDescription } + } + .disabled(gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || password.isEmpty) + if let message { Text(message).font(.caption).foregroundStyle(.secondary) } + } + } + .formStyle(.grouped) + .frame(width: 380, height: 360) + .padding() + .onAppear { + gatewayURL = store.gatewayURL.absoluteString + username = store.savedUsername + password = store.savedPassword + } + } +} + +private struct MessageView: View { + let title: String + let detail: String + let symbol: String + + var body: some View { + Label { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.subheadline.weight(.semibold)) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + } icon: { Image(systemName: symbol).foregroundStyle(.secondary) } + } +} + +private struct Fact: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption).foregroundStyle(.secondary) + Text(value).font(.system(.callout, design: .monospaced)) + } + } +} diff --git a/apps/macos-usage/assets/litellm_logo.jpg b/apps/macos-usage/assets/litellm_logo.jpg new file mode 100644 index 00000000000..6fe96e2ed35 Binary files /dev/null and b/apps/macos-usage/assets/litellm_logo.jpg differ diff --git a/apps/macos-usage/scripts/build-app.sh b/apps/macos-usage/scripts/build-app.sh new file mode 100755 index 00000000000..0184ca8cbd6 --- /dev/null +++ b/apps/macos-usage/scripts/build-app.sh @@ -0,0 +1,56 @@ +#!/bin/sh +set -eu + +ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +BUILD_DIR="$ROOT/.build/release" +APP_DIR="$ROOT/LiteLLMUsage.app" +DMG_PATH="$ROOT/LiteLLMUsage.dmg" +ICON_SOURCE="$ROOT/assets/litellm_logo.jpg" +ICON_RENDER_DIR="$ROOT/.build/icon-render" +ICONSET_DIR="$ROOT/.build/LiteLLMUsage.iconset" + +swift build -c release --package-path "$ROOT" +rm -rf "$APP_DIR" +mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" +cp "$BUILD_DIR/LiteLLMUsageMenuBar" "$APP_DIR/Contents/MacOS/LiteLLMUsageMenuBar" +rm -rf "$ICON_RENDER_DIR" "$ICONSET_DIR" +mkdir -p "$ICON_RENDER_DIR" "$ICONSET_DIR" +swift "$ROOT/scripts/fill-logo-background.swift" "$ICON_SOURCE" "$ICON_RENDER_DIR/logo-mark.png" +ICON_PNG="$ICON_RENDER_DIR/logo-mark.png" +sips -z 16 16 "$ICON_PNG" --out "$ICONSET_DIR/icon_16x16.png" >/dev/null +sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null +sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null +sips -z 64 64 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null +sips -z 128 128 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null +sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null +sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null +sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null +sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null +sips -z 1024 1024 "$ICON_PNG" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null +iconutil -c icns "$ICONSET_DIR" -o "$APP_DIR/Contents/Resources/LiteLLMUsage.icns" +cat > "$APP_DIR/Contents/Info.plist" <<'PLIST' + + + +CFBundleDisplayNameLiteLLM Usage +CFBundleExecutableLiteLLMUsageMenuBar +CFBundleIdentifiercom.litellm.usage-menubar +CFBundleInfoDictionaryVersion6.0 +CFBundleNameLiteLLM Usage +CFBundlePackageTypeAPPL +CFBundleShortVersionString0.1.0 +CFBundleVersion1 +CFBundleIconFileLiteLLMUsage.icns +LSMinimumSystemVersion13.0 +LSUIElement + +PLIST +SIGNING_IDENTITY="${CODESIGN_IDENTITY:-LiteLLM Usage Development}" +if ! security find-identity -v -p codesigning | grep -Fq "\"$SIGNING_IDENTITY\""; then + printf 'Missing valid codesigning identity: %s\n' "$SIGNING_IDENTITY" >&2 + exit 1 +fi +codesign --force --deep --timestamp=none --sign "$SIGNING_IDENTITY" "$APP_DIR" >/dev/null +rm -f "$DMG_PATH" +hdiutil create -volname "LiteLLM Usage" -srcfolder "$APP_DIR" -ov -format UDZO "$DMG_PATH" >/dev/null +printf '%s\n%s\n' "$APP_DIR" "$DMG_PATH" diff --git a/apps/macos-usage/scripts/fill-logo-background.swift b/apps/macos-usage/scripts/fill-logo-background.swift new file mode 100644 index 00000000000..ce2dd011762 --- /dev/null +++ b/apps/macos-usage/scripts/fill-logo-background.swift @@ -0,0 +1,104 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +guard CommandLine.arguments.count == 3 else { + fputs("usage: fill-logo-background.swift input.jpg output.png\n", stderr) + exit(2) +} + +let inputURL = URL(fileURLWithPath: CommandLine.arguments[1]) +let outputURL = URL(fileURLWithPath: CommandLine.arguments[2]) +let targetColor = (red: UInt8(135), green: UInt8(206), blue: UInt8(234)) + +guard let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + fputs("could not read logo\n", stderr) + exit(1) +} + +let imageWidth = image.width +let imageHeight = image.height +let canvasSize = max(imageWidth, imageHeight) +let scale = 1.16 +let scaledWidth = Int((Double(imageWidth) * scale).rounded()) +let scaledHeight = Int((Double(imageHeight) * scale).rounded()) +let origin = CGPoint(x: (canvasSize - scaledWidth) / 2, y: (canvasSize - scaledHeight) / 2) +let colorSpace = CGColorSpaceCreateDeviceRGB() +var pixels = [UInt8](repeating: 0, count: canvasSize * canvasSize * 4) + +guard let context = CGContext( + data: &pixels, + width: canvasSize, + height: canvasSize, + bitsPerComponent: 8, + bytesPerRow: canvasSize * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue +) else { + fputs("could not create image context\n", stderr) + exit(1) +} + +context.setFillColor(red: CGFloat(targetColor.red) / 255, green: CGFloat(targetColor.green) / 255, blue: CGFloat(targetColor.blue) / 255, alpha: 1) +context.fill(CGRect(x: 0, y: 0, width: canvasSize, height: canvasSize)) +context.draw(image, in: CGRect(origin: origin, size: CGSize(width: scaledWidth, height: scaledHeight))) + +func isBackgroundPixel(_ offset: Int) -> Bool { + let red = pixels[offset] + let green = pixels[offset + 1] + let blue = pixels[offset + 2] + let brightest = max(red, max(green, blue)) + let darkest = min(red, min(green, blue)) + return brightest >= 225 && brightest - darkest <= 24 +} + +var background = [Bool](repeating: false, count: canvasSize * canvasSize) +var queue: [Int] = [] + +func seed(_ index: Int) { + guard !background[index], isBackgroundPixel(index * 4) else { return } + background[index] = true + queue.append(index) +} + +for x in 0.. 0 { seed(index - 1) } + if x + 1 < canvasSize { seed(index + 1) } + if y > 0 { seed(index - canvasSize) } + if y + 1 < canvasSize { seed(index + canvasSize) } +} + +for index in queue { + let offset = index * 4 + pixels[offset] = targetColor.red + pixels[offset + 1] = targetColor.green + pixels[offset + 2] = targetColor.blue + pixels[offset + 3] = 255 +} + +guard let result = context.makeImage(), + let destination = CGImageDestinationCreateWithURL(outputURL as CFURL, UTType.png.identifier as CFString, 1, nil) else { + fputs("could not write logo\n", stderr) + exit(1) +} +CGImageDestinationAddImage(destination, result, nil) +guard CGImageDestinationFinalize(destination) else { + fputs("could not finalize logo\n", stderr) + exit(1) +}