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)
+}