diff --git a/apps/macos-usage/.gitignore b/apps/macos-usage/.gitignore deleted file mode 100644 index 564c4754447..00000000000 --- a/apps/macos-usage/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.build/ -LiteLLMUsage.app/ -LiteLLMUsage.dmg diff --git a/apps/macos-usage/DESIGN.md b/apps/macos-usage/DESIGN.md deleted file mode 100644 index 62b0d981133..00000000000 --- a/apps/macos-usage/DESIGN.md +++ /dev/null @@ -1,84 +0,0 @@ -# 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 deleted file mode 100644 index feb3f12f3c0..00000000000 --- a/apps/macos-usage/Package.swift +++ /dev/null @@ -1,9 +0,0 @@ -// 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 deleted file mode 100644 index 67efc11ffce..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift +++ /dev/null @@ -1,143 +0,0 @@ -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 deleted file mode 100644 index aa976a19408..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift +++ /dev/null @@ -1,80 +0,0 @@ -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 deleted file mode 100644 index cc0d2400c43..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index a2501916e3a..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift +++ /dev/null @@ -1,176 +0,0 @@ -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 deleted file mode 100644 index 40eb7e8e3d0..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift +++ /dev/null @@ -1,311 +0,0 @@ -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 deleted file mode 100644 index 99c7927a0bb..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift +++ /dev/null @@ -1,198 +0,0 @@ -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 deleted file mode 100644 index 6fe96e2ed35..00000000000 Binary files a/apps/macos-usage/assets/litellm_logo.jpg and /dev/null differ diff --git a/apps/macos-usage/scripts/build-app.sh b/apps/macos-usage/scripts/build-app.sh deleted file mode 100755 index 0184ca8cbd6..00000000000 --- a/apps/macos-usage/scripts/build-app.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/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 deleted file mode 100644 index ce2dd011762..00000000000 --- a/apps/macos-usage/scripts/fill-logo-background.swift +++ /dev/null @@ -1,104 +0,0 @@ -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) -}