Revert "feat(macos-usage): add gateway quota menu bar app"

This reverts commit affdef5b1e.
This commit is contained in:
leecoder 2026-09-22 14:55:36 +09:00
parent affdef5b1e
commit cf2f311d59
12 changed files with 0 additions and 1198 deletions

View file

@ -1,3 +0,0 @@
.build/
LiteLLMUsage.app/
LiteLLMUsage.dmg

View file

@ -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

View file

@ -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")]
)

View file

@ -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?
}

View file

@ -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))"
}
}

View file

@ -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("🚅 !")
}
}
}

View file

@ -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)"
}
}
}

View file

@ -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
}
}

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

View file

@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleDisplayName</key><string>LiteLLM Usage</string>
<key>CFBundleExecutable</key><string>LiteLLMUsageMenuBar</string>
<key>CFBundleIdentifier</key><string>com.litellm.usage-menubar</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>LiteLLM Usage</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleIconFile</key><string>LiteLLMUsage.icns</string>
<key>LSMinimumSystemVersion</key><string>13.0</string>
<key>LSUIElement</key><true/>
</dict></plist>
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"

View file

@ -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..<canvasSize {
seed(x)
seed((canvasSize - 1) * canvasSize + x)
}
for y in 0..<canvasSize {
seed(y * canvasSize)
seed(y * canvasSize + canvasSize - 1)
}
var cursor = 0
while cursor < queue.count {
let index = queue[cursor]
cursor += 1
let x = index % canvasSize
let y = index / canvasSize
if x > 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)
}