mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
Revert "feat(macos-usage): add gateway quota menu bar app"
This reverts commit affdef5b1e.
This commit is contained in:
parent
affdef5b1e
commit
cf2f311d59
12 changed files with 0 additions and 1198 deletions
3
apps/macos-usage/.gitignore
vendored
3
apps/macos-usage/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
.build/
|
||||
LiteLLMUsage.app/
|
||||
LiteLLMUsage.dmg
|
||||
|
|
@ -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
|
||||
|
|
@ -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")]
|
||||
)
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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))"
|
||||
}
|
||||
}
|
||||
|
|
@ -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("🚅 !")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue