diff --git a/README.md b/README.md index 5e86f43181..8baa9ebf64 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE) [![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app) -CodexBar — every AI coding limit in your menu bar. 69 providers. +CodexBar — every AI coding limit in your menu bar. 70 providers. Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, ClinePass, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons. diff --git a/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift new file mode 100644 index 0000000000..d98f1e57a4 --- /dev/null +++ b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift @@ -0,0 +1,120 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct MuseProviderImplementation: ProviderImplementation { + let id: UsageProvider = .muse + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "web+api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.museAPIToken + _ = settings.museBaseURL + _ = settings.museCookieSource + _ = settings.museCookieHeader + } + + @MainActor + func settingsSnapshot(context: ProviderSettingsSnapshotContext) + -> ProviderSettingsSnapshotContribution? + { + .muse(context.settings.museSettingsSnapshot()) + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if MuseSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + context.settings.ensureMuseAPITokenLoaded() + return context.settings.hasMuseAPIToken + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let cookieBinding = Binding( + get: { context.settings.museCookieSource.rawValue }, + set: { raw in context.settings.museCookieSource = ProviderCookieSource(rawValue: raw) ?? .auto }) + let cookieOptions = ProviderCookieSourceUI.options( + allowsOff: false, + keychainDisabled: context.settings.debugDisableKeychainAccess) + let cookieSubtitle: () -> String? = { + ProviderCookieSourceUI.subtitle( + source: context.settings.museCookieSource, + keychainDisabled: context.settings.debugDisableKeychainAccess, + auto: "Automatic imports dev.meta.ai session (Team usage).", + manual: "Paste Cookie header from dev.meta.ai → DevTools → Network → usage XHR.", + off: "Muse cookies disabled.") + } + return [ + ProviderSettingsPickerDescriptor( + id: "muse-cookie-source", + title: "Team usage (dev.meta.ai)", + subtitle: "Automatic imports dev.meta.ai session.", + dynamicSubtitle: cookieSubtitle, + binding: cookieBinding, + options: cookieOptions, + isVisible: nil, + onChange: nil, + trailingText: { ProviderCookieSourceUI.cachedTrailingText(provider: .muse) }), + ] + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "muse-api-key", + title: "Meta Muse API key", + subtitle: "API key from ai.developer.meta.com. Also accepts META_API_KEY.", + kind: .secure, + placeholder: "sk-...", + binding: context.stringBinding(\.museAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "muse-open-dashboard", + title: "Open Meta developer console", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(URL(string: "https://ai.developer.meta.com/")!) + }), + ProviderSettingsActionDescriptor( + id: "muse-open-usage", + title: "Open Team usage (dev.meta.ai)", + style: .link, + isVisible: nil, + perform: { + NSWorkspace.shared.open(URL(string: "https://dev.meta.ai/usage")!) + }), + ], + isVisible: nil, + onActivate: { context.settings.ensureMuseAPITokenLoaded() }), + ProviderSettingsFieldDescriptor( + id: "muse-base-url", + title: "API base URL (optional)", + subtitle: "Override for self-hosted or proxy. Default: https://api.meta.ai", + kind: .plain, + placeholder: "https://api.meta.ai", + binding: context.stringBinding(\.museBaseURL), + actions: [], + isVisible: nil, + onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "muse-cookie-header", + title: "Cookie header (manual)", + subtitle: "Paste Cookie header from dev.meta.ai → DevTools → Network → usage XHR → Request Headers", + kind: .secure, + placeholder: "Cookie: ...", + binding: context.stringBinding(\.museCookieHeader), + actions: [], + isVisible: { context.settings.museCookieSource == .manual }, + onActivate: nil), + ] + } +} diff --git a/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift b/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift new file mode 100644 index 0000000000..ea10a17754 --- /dev/null +++ b/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift @@ -0,0 +1,80 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var museAPIToken: String { + get { + guard let config = self.configSnapshot.providerConfig(for: .muse) else { return "" } + return config.sanitizedAPIKey ?? "" + } + set { + self.updateProviderConfig(provider: .muse) { entry in + entry.apiKey = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .muse, field: "apiKey", value: newValue) + } + } + + var museBaseURL: String { + get { + guard let config = self.configSnapshot.providerConfig(for: .muse) else { return "" } + return config.sanitizedBaseURL ?? "" + } + set { + self.updateProviderConfig(provider: .muse) { entry in + entry.baseURL = self.normalizedConfigValue(newValue) + } + } + } + + var museCookieHeader: String { + get { self.configSnapshot.providerConfig(for: .muse)?.sanitizedCookieHeader ?? "" } + set { + self.updateProviderConfig(provider: .muse) { entry in + entry.cookieHeader = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .muse, field: "cookieHeader", value: newValue) + } + } + + var museCookieSource: ProviderCookieSource { + get { self.resolvedCookieSource(provider: .muse, fallback: .auto) } + set { + self.updateProviderConfig(provider: .muse) { entry in + entry.cookieSource = newValue + } + self.logProviderModeChange(provider: .muse, field: "cookieSource", value: newValue.rawValue) + } + } + + func ensureMuseAPITokenLoaded() {} + + var hasMuseAPIToken: Bool { + guard let config = self.configSnapshot.providerConfig(for: .muse) else { return false } + return config.sanitizedAPIKey != nil + } + + var configuredMuseBaseURL: String? { + guard let raw = self.configSnapshot.providerConfig(for: .muse)?.baseURL? + .trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { + return nil + } + return raw + } +} + +extension SettingsStore { + func museSettingsSnapshot() -> ProviderSettingsSnapshot.MuseProviderSettings { + let cookie: MuseProviderSettings = self.resolvedCookieSettings( + provider: .muse, + configuredSource: self.museCookieSource, + configuredHeader: self.museCookieHeader, + tokenOverride: nil) + return ProviderSettingsSnapshot.MuseProviderSettings( + baseURL: self.configuredMuseBaseURL, + cookieSource: cookie.cookieSource, + manualCookieHeader: cookie.manualCookieHeader) + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift index 49ee8bb14b..5ba826f3ec 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift @@ -75,5 +75,6 @@ enum ProviderImplementationManifest { { XAIProviderImplementation() }, { NotionProviderImplementation() }, { IBMBobProviderImplementation() }, + { MuseProviderImplementation() }, ] } diff --git a/Sources/CodexBar/Resources/ProviderIcon-muse.svg b/Sources/CodexBar/Resources/ProviderIcon-muse.svg new file mode 100644 index 0000000000..27cf5f7436 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-muse.svg @@ -0,0 +1,6 @@ + + Muse + + + + diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3ad23d1d7a..93d9a1e7b0 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -914,6 +914,13 @@ extension StatusItemController { { return balance } + if provider == .muse, + let balance = Self.museBalanceDisplayText(snapshot: snapshot) + { + return balance + } + // Provider-specific by design: This exact app-runtime bridge coordinates provider-owned state through the + // shared controller. if provider == .mistral { let preference = self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) let hasMonthlyPlan = snapshot?.extraRateWindows?.contains { $0.id == "mistral-monthly-plan" } == true @@ -1059,6 +1066,20 @@ extension StatusItemController { } } + nonisolated static func museBalanceDisplayText(snapshot: UsageSnapshot?) -> String? { + // Provider-specific by design: Muse stores balance in its login-method payload. + self.displayValue( + from: snapshot?.loginMethod(for: .muse), + prefix: "Balance:", + removingSuffix: "") + .flatMap { value in + value + .split(separator: "·", maxSplits: 1) + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + } + nonisolated static func mistralSpendDisplayText(snapshot: UsageSnapshot?) -> String? { self.displayValue( from: snapshot?.identity?.loginMethod, diff --git a/Sources/CodexBarCore/Providers/Muse/MuseCookieImporter.swift b/Sources/CodexBarCore/Providers/Muse/MuseCookieImporter.swift new file mode 100644 index 0000000000..03561ebed1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseCookieImporter.swift @@ -0,0 +1,72 @@ +import Foundation + +#if os(macOS) +import SweetCookieKit + +public enum MuseCookieImporter { + private static let log = CodexBarLog.logger(LogCategories.provider(.muse, scope: "cookie")) + private static let cookieClient = BrowserCookieClient() + private static let cookieDomains = [ + "dev.meta.ai", + "ai.developer.meta.com", + "developer.meta.com", + "www.meta.ai", + "meta.ai", + // Comet/FB infra — dev.meta.ai auth rides on facebook.com cookies (c_user/xs/datr/fb_dtsg) + "facebook.com", + "www.facebook.com", + "meta.com", + "www.meta.com", + ] + + private static let sessionCookieNames: Set = [ + "sessionid", "session_id", "session_token", "c_user", "xs", "fbsr", "datr", + ] + + public struct SessionInfo: Sendable { + public let cookies: [HTTPCookie] + public let sourceLabel: String + + public var cookieHeader: String { + self.cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + } + + public static func importCookieHeader( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) throws -> SessionInfo? + { + let sessions = try self.importSessions(browserDetection: browserDetection, logger: logger) + return sessions.max(by: { $0.cookies.count < $1.cookies.count }) + } + + public static func importSessions( + browserDetection: BrowserDetection, + logger: ((String) -> Void)? = nil) throws -> [SessionInfo] + { + let order = ProviderDefaults.metadata[.muse]?.browserCookieOrder ?? Browser.defaultImportOrder + let candidates = order.cookieImportCandidates(using: browserDetection) + var sessions: [SessionInfo] = [] + for browser in candidates { + do { + let query = BrowserCookieQuery(domains: self.cookieDomains) + let sources = try self.cookieClient.codexBarRecords( + matching: query, + in: browser, + logger: logger) + for source in sources where !source.records.isEmpty { + let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) + let headerCookies = cookies + .filter { !$0.value.isEmpty && $0.expiresDate.map { $0 > Date() } ?? true } + guard !headerCookies.isEmpty else { continue } + sessions.append(SessionInfo(cookies: headerCookies, sourceLabel: source.label)) + } + } catch { + self.log.debug("Muse cookie import failed for \(browser.displayName): \(error)") + continue + } + } + return sessions + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift new file mode 100644 index 0000000000..360d7c0870 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift @@ -0,0 +1,193 @@ +import Foundation + +public enum MuseProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + + private static let credentials = ProviderCredentialAdapter.apiKey( + environmentKey: MuseSettingsReader.apiKeyEnvironmentKeys[0], + precedence: .environment, + environmentHasValue: { MuseSettingsReader.apiKey(environment: $0) != nil }, + resolve: { env in MuseSettingsReader.apiKey(environment: env) }, + missingCredentialMessage: { _ in MuseUsageError.missingCredentials.errorDescription ?? "Missing Muse API key" }) + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .muse, + settingsSection: .init( + MuseProviderSettingsKey.self, + cookieSettings: { settings in + CookieProviderSettings( + cookieSource: settings.cookieSource, + manualCookieHeader: settings.manualCookieHeader) + }, + credentialSettings: { context in + let s = context.cookieSettings(for: .muse) + return MuseProviderSettings( + baseURL: context.config?.sanitizedBaseURL, + cookieSource: s.cookieSource, + manualCookieHeader: s.manualCookieHeader) + }), + credentials: self.credentials, + metadata: ProviderMetadata( + id: .muse, + displayName: "Muse", + shortDisplayName: "Muse", + sessionLabel: "Balance", + weeklyLabel: "Balance", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Muse (Meta) usage", + cliName: "muse", + defaultEnabled: false, + widgetSelectable: false, + isPrimaryProvider: false, + usesAccountFallback: false, + balanceOnly: true, + browserCookieOrder: ProviderBrowserCookieDefaults.defaultImportOrder, + dashboardURL: "https://dev.meta.ai/usage", + statusPageURL: nil, + statusLinkURL: nil), + branding: ProviderBranding( + iconStyle: .init(provider: .muse), + iconResourceName: "ProviderIcon-muse", + color: ProviderColor(red: 0.0 / 255, green: 100 / 255, blue: 224 / 255), + confettiPalette: [ + ProviderColor(hex: 0x0064E0), + ProviderColor(hex: 0x0469FF), + ProviderColor(hex: 0x7B61FF), + ], + widgetColor: ProviderColor(red: 0.0 / 255, green: 100 / 255, blue: 224 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { + "Muse cost history is not available via API. " + + "Billing is pay-as-you-go at $1.25 / $4.25 per 1M tokens." + }), + presentation: ProviderUsagePresentation( + planRow: ProviderPlanRowPresentation(label: "Balance", stripsBalancePrefix: true)), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .web, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { context in + var strategies: [any ProviderFetchStrategy] = [] + if context.sourceMode.usesWeb { strategies.append(MuseWebFetchStrategy()) } + strategies.append(MuseAPIFetchStrategy()) + return strategies + })), + cli: ProviderCLIConfig( + name: "muse", + aliases: ["meta", "metamuse"], + versionDetector: nil)) + } +} + +struct MuseAPIFetchStrategy: ProviderFetchStrategy { + let id = "muse.api" + let kind: ProviderFetchKind = .apiToken + private let transport: any ProviderHTTPTransport + + init(transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { + self.transport = transport + } + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + MuseSettingsReader.apiKey(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = MuseSettingsReader.apiKey(environment: context.env) else { + throw MuseUsageError.missingCredentials + } + + // Prefer config baseURL (via settings), then env, then default + let baseURL: String? = context.settings?.muse?.baseURL ?? MuseSettingsReader.baseURL(environment: context.env) + + let usage = try await MuseUsageFetcher.fetchUsage( + apiKey: apiKey, + baseURLString: baseURL, + session: self.transport) + return self.makeResult(usage: usage.toUsageSnapshot(), sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +struct MuseWebFetchStrategy: ProviderFetchStrategy { + let id = "muse.web" + let kind: ProviderFetchKind = .web + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + guard context.sourceMode.usesWeb else { return false } + let settings = context.settings?.muse + if settings?.cookieSource == .off { return false } + // If manual, require header; if auto, allow browser import attempt + if settings?.cookieSource == .manual { + return CookieHeaderNormalizer.normalize(settings?.manualCookieHeader) != nil + } + return true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + // Manual cookie header (Settings → Providers → Muse → Cookie source = Manual) + if context.settings?.muse?.cookieSource == .manual { + guard let h = CookieHeaderNormalizer.normalize(context.settings?.muse?.manualCookieHeader) else { + throw MuseUsageError.apiError("Missing dev.meta.ai Cookie header (manual)") + } + let usage = try await MuseWebUsageFetcher.fetchUsage(cookieHeader: h, timeout: context.webTimeout) + return self.makeResult(usage: usage, sourceLabel: "web") + } + + // Automatic: try cached header, then fresh browser import for dev.meta.ai + if let cached = CookieHeaderCache.load(provider: UsageProvider.muse), + let header = CookieHeaderNormalizer.normalize(cached.cookieHeader) + { + do { + let usage = try await MuseWebUsageFetcher.fetchUsage(cookieHeader: header, timeout: context.webTimeout) + return self.makeResult(usage: usage, sourceLabel: "web") + } catch { + CookieHeaderCache.clear(provider: UsageProvider.muse) + } + } + #if os(macOS) + do { + let logger: ((String) -> Void)? = context.verbose ? { msg in CodexBarLog.logger(LogCategories.provider( + .muse, + scope: "web-usage")).verbose(msg) } : nil + if let session = try MuseCookieImporter.importCookieHeader( + browserDetection: context.browserDetection, + logger: logger), + let header = CookieHeaderNormalizer.normalize(session.cookieHeader) + { + CookieHeaderCache.store( + provider: UsageProvider.muse, + cookieHeader: header, + sourceLabel: session.sourceLabel) + let usage = try await MuseWebUsageFetcher.fetchUsage(cookieHeader: header, timeout: context.webTimeout) + return self.makeResult(usage: usage, sourceLabel: "web") + } + } catch {} + #endif + throw MuseUsageError.apiError("No dev.meta.ai session cookies found — sign in at dev.meta.ai in Chrome/Safari") + } + + func shouldFallback(on _: Error, context: ProviderFetchContext) -> Bool { + // Web failure should fall back to API-key probe in auto mode + context.sourceMode == .auto + } +} + +// MARK: - ProviderConfig extension for baseURL + +extension ProviderConfig { + public var baseURL: String? { + get { self.extensionValue(forKey: "baseURL") } + set { self.setExtensionValue(newValue, forKey: "baseURL") } + } + + public var sanitizedBaseURL: String? { + Self.clean(self.baseURL) + } +} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseProviderSettings.swift b/Sources/CodexBarCore/Providers/Muse/MuseProviderSettings.swift new file mode 100644 index 0000000000..f499eae83c --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderSettings.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct MuseProviderSettings: Sendable, ProviderCookieSettings { + public let baseURL: String? + public let cookieSource: ProviderCookieSource + public let manualCookieHeader: String? + + public init(cookieSource: ProviderCookieSource, manualCookieHeader: String?) { + self.baseURL = nil + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } + + public init( + baseURL: String? = nil, + cookieSource: ProviderCookieSource = .auto, + manualCookieHeader: String? = nil) + { + self.baseURL = baseURL + self.cookieSource = cookieSource + self.manualCookieHeader = manualCookieHeader + } +} + +public enum MuseProviderSettingsKey: ProviderSettingsSectionKey { + public static let providerID = ProviderInstanceID.muse + public typealias Section = MuseProviderSettings +} + +extension ProviderSettingsSnapshot { + public typealias MuseProviderSettings = CodexBarCore.MuseProviderSettings + public var muse: MuseProviderSettings? { + self[MuseProviderSettingsKey.self] + } + + public static func make(muse: MuseProviderSettings?) -> Self { + self.make(muse, for: MuseProviderSettingsKey.self) + } +} + +extension ProviderSettingsSnapshotContribution { + public static func muse(_ section: MuseProviderSettings) -> Self { + Self(section, for: MuseProviderSettingsKey.self) + } +} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift b/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift new file mode 100644 index 0000000000..757d23a778 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum MuseSettingsReader: Sendable { + public static let apiKeyEnvironmentKeys = ["MUSE_API_KEY", "META_API_KEY", "META_MUSE_API_KEY"] + public static let baseURLEnvironmentKey = "MUSE_API_URL" + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + for key in self.apiKeyEnvironmentKeys { + if let value = cleaned(environment[key]) { + return value + } + } + return nil + } + + public static func baseURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.baseURLEnvironmentKey]) + } + + private static func cleaned(_ raw: String?) -> String? { + guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if (value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'")) { + value = String(value.dropFirst().dropLast()) + } + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } +} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift b/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift new file mode 100644 index 0000000000..68b1b99ead --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift @@ -0,0 +1,322 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct MuseUsageSnapshot: Sendable { + public let summary: MuseUsageSummary + + public init(summary: MuseUsageSummary) { + self.summary = summary + } + + public func toUsageSnapshot() -> UsageSnapshot { + self.summary.toUsageSnapshot() + } +} + +public struct MuseUsageSummary: Sendable { + public let balance: Double? + public let currency: String? + public let modelCount: Int? + public let updatedAt: Date + + public init(balance: Double?, currency: String?, modelCount: Int?, updatedAt: Date) { + self.balance = balance + self.currency = currency + self.modelCount = modelCount + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let loginMethod: String + if let balance, let currency, currency.lowercased() == "usd" { + let formatted = UsageFormatter.usdString(balance) + loginMethod = "Balance: \(formatted)" + } else if let balance { + let formatted = String(format: "%.2f", balance) + if let currency { + loginMethod = "Balance: \(formatted) \(currency.uppercased())" + } else { + loginMethod = "Balance: \(formatted)" + } + } else if let count = modelCount { + loginMethod = "API key valid · \(count) models available" + } else { + loginMethod = "API key valid" + } + + let identity = ProviderIdentitySnapshot( + providerID: .muse, + accountEmail: nil, + accountOrganization: nil, + loginMethod: loginMethod) + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } +} + +public enum MuseUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing Muse API key. Set MUSE_API_KEY or META_API_KEY." + case let .networkError(message): + "Muse network error: \(message)" + case let .apiError(message): + "Muse API error: \(message)" + case let .parseFailed(message): + "Failed to parse Muse response: \(message)" + } + } +} + +public struct MuseUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.provider(.muse, scope: "usage")) + private static let timeoutSeconds: TimeInterval = 15 + public static let defaultBaseURL = "https://api.meta.ai" + + private static func validatedBaseURL(_ base: String) throws -> String { + // Use the shared validator so embedded user info, encoded delimiters, missing hosts, etc. are rejected + guard let url = ProviderEndpointOverrideValidator().validatedURLAllowingLoopbackHTTP(base) else { + throw MuseUsageError.apiError("Insecure base URL — use https:// (or http://localhost for local proxy)") + } + let absolute = url.absoluteString + return absolute.hasSuffix("/") ? String(absolute.dropLast()) : absolute + } + + public static func fetchUsage( + apiKey: String, + baseURLString: String? = nil, + session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MuseUsageSnapshot + { + let cleaned = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleaned.isEmpty else { + throw MuseUsageError.missingCredentials + } + + let base = (baseURLString?.trimmingCharacters(in: .whitespacesAndNewlines)) + .flatMap { $0.isEmpty ? nil : $0 } ?? self.defaultBaseURL + let normalizedBaseRaw = base.hasSuffix("/") ? String(base.dropLast()) : base + let normalizedBase = try validatedBaseURL(normalizedBaseRaw) + + // Strategy: try balance endpoint first, fall back to models probe. + // Meta Model API docs list /v1/models and /v1/chat/completions reliably; billing endpoint + // may evolve (some providers expose /v1/billing/usage or /v1/me/balance). + // We probe in order and return the first successful snapshot. + var lastError: Error? + + // 1) Try known billing endpoints (best-effort) + let billingPaths = [ + "/v1/billing/usage", + "/v1/me/balance", + "/v1/billing/subscription", + "/v1/credits", + ] + for path in billingPaths { + do { + if let snapshot = try await fetchBalance( + base: normalizedBase, path: path, apiKey: cleaned, transport: transport) + { + return snapshot + } + } catch { + lastError = error + // 401/403 means key invalid — surface immediately + if let museError = error as? MuseUsageError, case let .apiError(msg) = museError, + msg.contains("401") || msg.contains("403") + { + throw error + } + continue + } + } + + // 2) Fall back to models probe — proves key validity without billing detail + do { + return try await self.fetchModelsProbe(base: normalizedBase, apiKey: cleaned, transport: transport) + } catch let modelsError { + // Preserve authoritative auth errors from the models probe (401/403 means invalid key) + if let museError = modelsError as? MuseUsageError, case let .apiError(msg) = museError, + msg.contains("401") || msg.contains("403") + { + throw modelsError + } + if let last = lastError { throw last } + throw modelsError + } + } + + private static func fetchBalance( + base: String, + path: String, + apiKey: String, + transport: any ProviderHTTPTransport) async throws -> MuseUsageSnapshot? + { + guard let url = URL(string: "\(base)\(path)") else { return nil } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = self.timeoutSeconds + + let response = try await transport.response(for: request) + + // 404 = endpoint not supported — try next + if response.statusCode == 404 { return nil } + guard (200..<300).contains(response.statusCode) else { + self.log.error("Muse balance \(path) returned HTTP \(response.statusCode)") + throw MuseUsageError.apiError("HTTP \(response.statusCode) at \(path)") + } + + // Try to parse generic balance shapes + let summary = try parseBalanceResponse(data: response.data) + return MuseUsageSnapshot(summary: summary) + } + + private static func fetchModelsProbe( + base: String, apiKey: String, transport: any ProviderHTTPTransport) async throws -> MuseUsageSnapshot + { + // Support both api.meta.ai and llm.meta.com style hosts + let paths = ["/v1/models", "/v1/models/list"] + var lastStatus = 0 + for path in paths { + guard let url = URL(string: "\(base)\(path)") else { continue } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = self.timeoutSeconds + + let response = try await transport.response(for: request) + lastStatus = response.statusCode + if (200..<300).contains(response.statusCode) { + let count = try parseModelsCount(data: response.data) + let summary = MuseUsageSummary(balance: nil, currency: nil, modelCount: count, updatedAt: Date()) + return MuseUsageSnapshot(summary: summary) + } + if response.statusCode == 401 || response.statusCode == 403 { + throw MuseUsageError.apiError("HTTP \(response.statusCode) — invalid API key") + } + if response.statusCode == 404 { continue } + self.log.error("Muse models probe returned HTTP \(response.statusCode)") + throw MuseUsageError.apiError("HTTP \(response.statusCode)") + } + throw MuseUsageError.apiError("HTTP \(lastStatus) — unable to validate Muse API key") + } + + static func parseBalanceResponse(data: Data) throws -> MuseUsageSummary { + // Try multiple JSON shapes: + // - { available_balance, voucher_balance, cash_balance } + // - { balance, currency } + // - { data: { balance, available_balance, currency } } + // - OpenAI-style { total_available, currency } + struct Generic: Decodable { + let available_balance: Double? + let availableBalance: Double? + let balance: Double? + let total_balance: String? + let totalBalance: String? + let currency: String? + let data: GenericData? + } + struct GenericData: Decodable { + let available_balance: Double? + let balance: Double? + let total_balance: String? + let currency: String? + } + + let decoder = JSONDecoder() + if let g = try? decoder.decode(Generic.self, from: data) { + var bal: Double? + var cur: String? + bal = g.available_balance + ?? g.availableBalance + ?? g.balance + ?? g.total_balance.flatMap(Double.init) + ?? g.totalBalance.flatMap(Double.init) + if bal == nil, let d = g.data { + bal = d.available_balance + ?? d.balance + ?? d.total_balance.flatMap(Double.init) + cur = d.currency + } + cur = cur ?? g.currency + if bal != nil { + return MuseUsageSummary(balance: bal, currency: cur, modelCount: nil, updatedAt: Date()) + } + } + + // Fallback: raw dictionary scan + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + func scan(_ dict: [String: Any]) -> (Double?, String?) { + for (k, v) in dict { + let lk = k.lowercased() + if lk.contains("balance") || lk.contains("credit"), v is Double || v is Int || v is String { + let d: Double? = (v as? Double) + ?? (v as? Int).map(Double.init) + ?? (v as? String).flatMap(Double.init) + if let d { return (d, dict["currency"] as? String) } + } + if let nested = v as? [String: Any] { + let r = scan(nested) + if r.0 != nil { return r } + } + } + return (nil, nil) + } + let (b, c) = scan(obj) + if b != nil { + return MuseUsageSummary(balance: b, currency: c, modelCount: nil, updatedAt: Date()) + } + } + + throw MuseUsageError.parseFailed("Unknown balance JSON shape") + } + + static func parseModelsCount(data: Data) throws -> Int? { + struct ModelsResponse: Decodable { + let data: [ModelEntry]? + let models: [ModelEntry]? + } + struct ModelEntry: Decodable { + let id: String? + let name: String? + } + // Require explicit data/models array — {} with no array is malformed and must not be treated as valid auth + let dec = JSONDecoder() + if let r = try? dec.decode(ModelsResponse.self, from: data) { + if let c = r.data?.count ?? r.models?.count { + return c + } + // Both arrays absent → malformed payload, not a valid models list + throw MuseUsageError.parseFailed("Models response missing data/models array") + } + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let arr = obj["data"] as? [Any] { return arr.count } + if let arr = obj["models"] as? [Any] { return arr.count } + // No recognizable array → malformed + throw MuseUsageError.parseFailed("Models response missing data/models array") + } + throw MuseUsageError.parseFailed("Invalid models JSON") + } + + public static func resolveBaseURL(string: String?) -> URL { + let base = (string?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? self + .defaultBaseURL + let normalized = base.hasSuffix("/") ? String(base.dropLast()) : base + return URL(string: "\(normalized)/v1/models")! + } +} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseWebUsageFetcher.swift b/Sources/CodexBarCore/Providers/Muse/MuseWebUsageFetcher.swift new file mode 100644 index 0000000000..eb19107084 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseWebUsageFetcher.swift @@ -0,0 +1,582 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +// Team usage via dev.meta.ai GraphQL — reverse-engineered from HAR 2026-08-15. +// The dashboard at https://dev.meta.ai/usage/?project_id=...&team_id=... +// loads via POST https://dev.meta.ai/api/graphql/ with +// fb_api_caller_class=RelayModern +// fb_api_req_friendly_name=LLMDCUsageQuery +// doc_id=27710687895239709 +// variables={api_key_id, end_date, model_id, start_date, team_id, timezone, ...} +// Auth is browser cookies (dev.meta.ai). We replicate the request with +// Cookie header + standard Comet headers; dynamic __* tokens are best-effort +// (extracted from HTML when available) but many deployments accept minimal +// GraphQL body alone. + +public enum MuseWebUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.provider(.muse, scope: "web-usage")) + private static let dashboardURL = URL(string: "https://dev.meta.ai/usage")! + private static let graphQLURL = URL(string: "https://dev.meta.ai/api/graphql/")! + // doc_id for LLMDCUsageQuery — stable in HAR; if Meta rotates it we + // fall back to HTML/legacy parsing and surface parseFailed + private static let usageDocID = "27710687895239709" + private static let fallbackDocIDAlt = "27710687895239709" + + public static func fetchUsage( + cookieHeader: String, + timeout: TimeInterval = 15, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> UsageSnapshot + { + // 1) Try GraphQL usage (primary) + if let snap = try await self.fetchViaGraphQL( + cookieHeader: cookieHeader, + timeout: timeout, + transport: transport) + { + return snap + } + // 2) Fallback: fetch HTML and look for embedded usage (kept for diagnostics) + var htmlRequest = URLRequest(url: self.dashboardURL, timeoutInterval: timeout) + htmlRequest.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + htmlRequest.setValue( + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + forHTTPHeaderField: "Accept") + let htmlResponse = try await transport.response(for: htmlRequest) + guard (200..<300).contains(htmlResponse.statusCode) else { + if htmlResponse.statusCode == 401 || htmlResponse.statusCode == 403 { + throw MuseUsageError.apiError("HTTP \(htmlResponse.statusCode) — invalid dev.meta.ai session") + } + throw MuseUsageError.apiError("HTTP \(htmlResponse.statusCode) at /usage") + } + if let snap = try self.parseDashboardHTML(data: htmlResponse.data) { return snap } + throw MuseUsageError + .parseFailed( + "No parseable usage on dev.meta.ai/usage — GraphQL returned no usage and HTML had no embedded data") + } + + // MARK: - GraphQL + + private static func fetchViaGraphQL( + cookieHeader: String, + timeout: TimeInterval, + transport: any ProviderHTTPTransport) async throws -> UsageSnapshot? + { + // Fetch dashboard HTML first to harvest dynamic Comet tokens and team_id. + // If that fetch fails we still attempt GraphQL with a minimal body. + var teamId: String? + var lsd: String? + var fbDtsg: String? + var rev: String? + var hsi: String? + do { + var req = URLRequest(url: self.dashboardURL, timeoutInterval: timeout) + req.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + req.setValue( + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + forHTTPHeaderField: "Accept") + let resp = try await transport.response(for: req) + if (200..<300).contains(resp.statusCode), let html = String(data: resp.data, encoding: .utf8) { + teamId = self.extractTeamId(from: html) + lsd = self.extractLSD(from: html) + fbDtsg = self.extractFbDtsg(from: html) + rev = self.extractRev(from: html) + hsi = self.extractHsi(from: html) + if teamId == nil { + teamId = self.extractTeamId(from: resp.data) + } + } + } catch { + self.log.debug("Muse GraphQL prefetch HTML failed: \(error)") + } + + // If we still have no teamId, try to discover via a lightweight + // settings query could be added here. For now, surface a clear error + // so callers fall back to API-key probe rather than 400-looping. + guard let tid = teamId else { + self.log.debug("Muse GraphQL: no team_id found in HTML — skipping GraphQL, falling back to HTML parse") + return nil + } + + let dates = self.weekWindow(timeZone: TimeZone(identifier: "America/Chicago") ?? .current) + let variables: [String: Any] = [ + "api_key_id": NSNull(), + "end_date": dates.end, + "model_id": NSNull(), + "start_date": dates.start, + "team_id": tid, + "timezone": "America/Chicago", + "__relay_internal__pv__Usage_ShouldIncludeBatchMetricsrelayprovider": false, + "__relay_internal__pv__Usage_ShouldIncludeCostMetricsrelayprovider": true, + ] + guard let variablesJSON = try? JSONSerialization.data(withJSONObject: variables), + let variablesString = String(data: variablesJSON, encoding: .utf8) else { return nil } + + // Fallbacks from verified 2026-08-15 HAR — required when HTML harvest fails (manual llm_sess header) + let fallbackLSD = "swTyN9fDdqKU_GTgO9R99v" + let fallbackDTSG = "NAfztM7dBcJpLZtKyHd2yP1ZxiMHdL1phWeJUuHvbBqWTSL1XGLJeqw:41:1786723018" + var bodyParams: [String: String] = [ + "av": "1530727316779396", + "__user": "0", + "__a": "1", + "__req": "1", + "__hs": "20680.HYP:comet_plat_default_pkg.2.1...0", + "dpr": "2", + "__ccg": "EXCELLENT", + "__rev": rev ?? "1045282065", + "__s": "qd8x56:htjz0d:eqz030", + "__hsi": hsi ?? "7674254844748149098", + "__comet_req": "71", + "fb_dtsg": fbDtsg ?? fallbackDTSG, + "jazoest": "25548", + "lsd": lsd ?? fallbackLSD, + "__spin_r": rev ?? "1045282065", + "__spin_b": "trunk", + "__spin_t": String(Int(Date().timeIntervalSince1970)), + "fb_api_caller_class": "RelayModern", + "fb_api_req_friendly_name": "LLMDCUsageQuery", + "server_timestamps": "true", + "variables": variablesString, + "doc_id": self.usageDocID, + ] + // Omit empty fb_dtsg if we didn't find one — some sessions work without it + if bodyParams["fb_dtsg"]?.isEmpty == true { bodyParams.removeValue(forKey: "fb_dtsg") } + + let bodyString = bodyParams.map { k, v in + "\(k)=\(v.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? v)" + }.joined(separator: "&") + guard let bodyData = bodyString.data(using: .utf8) else { return nil } + + var request = URLRequest(url: self.graphQLURL, timeoutInterval: timeout) + request.httpMethod = "POST" + request.httpBody = bodyData + request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("*/*", forHTTPHeaderField: "Accept") + request.setValue("https://dev.meta.ai/usage", forHTTPHeaderField: "Referer") + request.setValue("https://dev.meta.ai", forHTTPHeaderField: "Origin") + request.setValue("LLMDCUsageQuery", forHTTPHeaderField: "X-FB-Friendly-Name") + if let l = lsd { request.setValue(l, forHTTPHeaderField: "X-FB-LSD") } + + let response = try await transport.response(for: request) + if response.statusCode == 401 || response.statusCode == 403 { + throw MuseUsageError.apiError("HTTP \(response.statusCode) — invalid dev.meta.ai session (GraphQL)") + } + guard (200..<300).contains(response.statusCode) else { + self.log.error("Muse GraphQL returned HTTP \(response.statusCode)") + // Don't throw — let caller fall back to HTML + return nil + } + // Response may be JSON or JS-wrapped JSON (for(...);). Strip prefix. + let data = self.stripJSWrapper(response.data) + if let snap = try self.parseUsageGraphQL(data: data) { return snap } + // Log a snippet for diagnostics (capped) + if let raw = String(data: data, encoding: .utf8) { + self.log.debug("Muse GraphQL parseFailed, snippet: \(raw.prefix(600))") + } + return nil + } + + private static func weekWindow(timeZone: TimeZone) -> (start: String, end: String) { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = timeZone + let now = Date() + let end = cal.startOfDay(for: now) + let start = cal.date(byAdding: .day, value: -6, to: end) ?? end + let fmt = DateFormatter() + fmt.calendar = cal + fmt.timeZone = timeZone + fmt.dateFormat = "yyyy-MM-dd" + return (fmt.string(from: start), fmt.string(from: end)) + } + + static func stripJSWrapper(_ data: Data) -> Data { + guard let s = String(data: data, encoding: .utf8) else { return data } + let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("for(;;);") { + let after = String(trimmed.dropFirst("for(;;);".count)) + return Data(after.utf8) + } + return data + } + + static func parseUsageGraphQL(data: Data) throws -> UsageSnapshot? { + // Muse LLMD-C shape: data.team.{requests_metrics,input_token_metrics,output_token_metrics,spend_cost_metrics} + if let snap = parseTeamUsage(data: data) { return snap } + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + /// Common Comet shape: {data: {viewer: {team: {usage: {...}}}}} or {data: {llmdc_usage: {...}}} + /// We recursively search for token/cost keys. + func findMetrics( + _ dict: [String: Any], + depth: Int = 0) -> (tokens: Double?, cost: Double?, requests: Double?)? + { + if depth > 8 { return nil } + var tokens: Double? + var cost: Double? + var requests: Double? + for (k, v) in dict { + let lk = k.lowercased() + if lk.contains("total_tokens") || lk == "totaltokens" || lk == "total_tokens_used" { + if let d = v as? Double { tokens = d } + if tokens == nil, let i = v as? Int { tokens = Double(i) } + if tokens == nil, let s = v as? String, let d = Double(s) { tokens = d } + } + if lk.contains("cost") || lk == "total_cost" || lk == "amount" { + if let d = v as? Double { cost = d } + if cost == nil, let i = v as? Int { cost = Double(i) } + if cost == nil, let s = v as? String, let d = Double(s) { cost = d } + } + if lk.contains("request"), lk.contains("count") || lk.contains("total") { + if let d = v as? Double { requests = d } + if requests == nil, let i = v as? Int { requests = Double(i) } + } + if let nested = v as? [String: Any], let found = findMetrics(nested, depth: depth + 1) { + tokens = tokens ?? found.tokens; cost = cost ?? found.cost; requests = requests ?? found.requests + } + if let arr = v as? [[String: Any]] { + for e in arr { + if let found = findMetrics(e, depth: depth + 1) { + tokens = tokens ?? found.tokens; cost = cost ?? found.cost; requests = requests ?? found + .requests + } + } + } + } + if tokens != nil || cost != nil || requests != nil { return (tokens, cost, requests) } + return nil + } + // Also handle top-level array shape from relay-ef batch + if let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + for e in arr { + if let m = findMetrics(e), + m.tokens != nil || m.cost != nil { return Self.snapshot(from: m) } + } + } + if let metrics = findMetrics(obj), metrics.tokens != nil || metrics.cost != nil || metrics.requests != nil { + return Self.snapshot(from: metrics) + } + // Fallback: look inside "data" key one level deeper + if let dataDict = obj["data"] as? [String: Any], let metrics = findMetrics(dataDict) { + return Self.snapshot(from: metrics) + } + return nil + } + + // MARK: - LLMD-C team usage (meta.ai) + + static func parseTeamUsage(data: Data) -> UsageSnapshot? { + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataDict = obj["data"] as? [String: Any], + let team = dataDict["team"] as? [String: Any] else { return nil } + + func sumCategorical(in metricsKey: String, identifier: String) -> Double? { + guard let arr = team[metricsKey] as? [[String: Any]] else { return nil } + for entry in arr where (entry["identifier"] as? String) == identifier { + guard let cat = entry["categorical_data"] as? [[String: Any]] else { continue } + var sum: Double = 0 + var hasValue = false + for point in cat { + if let v = point["value"] as? Int { sum += Double(v); hasValue = true; continue } + if let v = point["value"] as? Double { sum += v; hasValue = true; continue } + if let dict = point["value"] as? [String: Any], + let amt = dict["amount_with_offset"] as? String, + let cents = Double(amt) + { + sum += cents / 100.0; hasValue = true; continue + } + if let dict = point["value"] as? [String: Any], + let amt = dict["amount_with_offset"] as? Int + { + sum += Double(amt) / 100.0; hasValue = true + } + } + if hasValue { return sum } + } + return nil + } + + let requests = sumCategorical(in: "requests_metrics", identifier: "num_requests") + let promptTokens = sumCategorical(in: "input_token_metrics", identifier: "num_prompt_tokens") + let outputTokens = sumCategorical(in: "output_token_metrics", identifier: "num_completion_tokens") + let totalTokens = (promptTokens ?? 0) + (outputTokens ?? 0) + let cost = sumCategorical(in: "spend_cost_metrics", identifier: "usage_billable_cost") + + guard requests != nil || promptTokens != nil || outputTokens != nil || cost != nil else { return nil } + + var parts: [String] = [] + if let r = requests { + let rStr = Self.localizedCount(Int(r)) + parts.append("\(rStr) requests") + } + if totalTokens > 0 { + let tStr = UsageFormatter.tokenCountString(Int(totalTokens)) + parts.append("\(tStr) tokens") + } + if let c = cost, c > 0 { + let cStr = Self.localizedCost(c) + parts.append(cStr) + } + let login = parts.isEmpty ? "Team usage" : "Team usage: \(parts.joined(separator: " · "))" + let identity = ProviderIdentitySnapshot( + providerID: .muse, + accountEmail: nil, + accountOrganization: nil, + loginMethod: login) + + // Build daily history for detail view and tokenUsage sparkline + let dailyPoints: [(dayKey: String, totalTokens: Int?, costUSD: Double?)] = { + var byDay: [String: (tokens: Int?, cost: Double?)] = [:] + func collectTokens(_ key: String, _ ident: String) { + guard let arr = team[key] as? [[String: Any]] else { return } + for entry in arr where (entry["identifier"] as? String) == ident { + guard let cat = entry["categorical_data"] as? [[String: Any]] else { continue } + for p in cat { + guard let k = p["category"] as? String else { continue } + let v = p["value"] + var tokens: Int? + if let i = v as? Int { tokens = i } + if tokens == nil, let d = v as? Double { tokens = Int(d) } + let cur = byDay[k] ?? (nil, nil) + byDay[k] = (tokens, cur.cost) + } + } + } + func collectPrompt() { + collectTokens("input_token_metrics", "num_prompt_tokens") + } + func collectOutput() { + guard let arr = team["output_token_metrics"] as? [[String: Any]] else { return } + for entry in arr where (entry["identifier"] as? String) == "num_completion_tokens" { + guard let cat = entry["categorical_data"] as? [[String: Any]] else { continue } + for p in cat { + guard let k = p["category"] as? String else { continue } + let add: Int? = (p["value"] as? Int) ?? (p["value"] as? Double).map { Int($0) } + let cur = byDay[k] ?? (nil, nil) + let newTokens = (cur.tokens ?? 0) + (add ?? 0) + byDay[k] = (newTokens == 0 && cur.tokens == nil ? add : newTokens, cur.cost) + } + } + } + func collectCost() { + guard let arr = team["spend_cost_metrics"] as? [[String: Any]] else { return } + for entry in arr where (entry["identifier"] as? String) == "usage_billable_cost" { + guard let cat = entry["categorical_data"] as? [[String: Any]] else { continue } + for p in cat { + guard let k = p["category"] as? String else { continue } + let dict = p["value"] as? [String: Any] + let cents: Double? = (dict?["amount_with_offset"] as? String).flatMap(Double.init) + ?? (dict?["amount_with_offset"] as? Int).map { Double($0) } + let usd = cents.map { $0 / 100.0 } + let cur = byDay[k] ?? (nil, nil) + byDay[k] = (cur.tokens, usd) + } + } + } + collectPrompt(); collectOutput(); collectCost() + return byDay.map { k, v in (dayKey: k, totalTokens: v.tokens, costUSD: v.cost) } + .sorted { $0.dayKey < $1.dayKey } + }() + + let detailRows: [ProviderDetailSection.Row] = dailyPoints.compactMap { pt in + let tok = pt.totalTokens.map { UsageFormatter.tokenCountString($0) + " tokens" } ?? "—" + let usd = pt.costUSD.map { Self.localizedCost($0) } ?? "—" + return try? ProviderDetailSection.Row(label: pt.dayKey, value: "\(tok) · \(usd)") + } + let detail: [ProviderDetailSection] = { + guard !detailRows.isEmpty else { return [] } + return (try? ProviderDetailSection(title: "Daily usage", rows: detailRows)).map { [$0] } ?? [] + }() + + // Surface totals as cost snapshot (balanceOnly provider — bars stay empty) + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: cost.map { + ProviderCostSnapshot( + used: $0, + limit: $0, + currencyCode: "USD", + updatedAt: Date()) + }, + details: detail, + updatedAt: Date(), + identity: identity) + } + + private static func snapshot(from metrics: (tokens: Double?, cost: Double?, requests: Double?)) -> UsageSnapshot { + var parts: [String] = [] + if let t = metrics.tokens { + parts.append("\(UsageFormatter.tokenCountString(Int(t))) tokens") + } + if let c = metrics.cost { parts.append(Self.localizedCost(c)) } + if let r = metrics.requests { + parts.append("\(Self.localizedCount(Int(r))) req") + } + let login = parts.isEmpty ? "Team usage" : "Team usage: \(parts.joined(separator: " · "))" + let identity = ProviderIdentitySnapshot( + providerID: .muse, + accountEmail: nil, + accountOrganization: nil, + loginMethod: login) + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: Date(), + identity: identity) + } + + // MARK: - Helpers for token extraction from HTML + + static func extractTeamId(from html: String) -> String? { + // Handles: team_id=374..., "teamId":"374...", "teamId":374..., team_id:374..., teamId%3D374... + let patterns = [ + #"team_id=(\d{5,})"#, + #"team_id%3D(\d{5,})"#, + #""teamId"\s*:\s*"?(\d{5,})"?""#, + #""team_id"\s*:\s*"?(\d{5,})"?""#, + #"teamId\\":\\?"?(\d{5,})\\?""#, + #"team_id\\":\\?"?(\d{5,})\\?""#, + #"\bteamId\b[^0-9]{0,20}(\d{10,})"#, + #"\bteam_id\b[^0-9]{0,20}(\d{10,})"#, + ] + for pat in patterns { + if let r = html.range(of: pat, options: .regularExpression) { + let sub = String(html[r]) + if let m = sub.range(of: #"\d{10,}"#, options: .regularExpression) { + let candidate = String(sub[m]) + // Avoid tiny IDs (5 digits) — real team_ids are ~16 digits + if candidate.count >= 10 { return candidate } + } + } + } + return nil + } + + static func extractTeamId(from data: Data) -> String? { + guard let s = String(data: data, encoding: .utf8) else { return nil } + return self.extractTeamId(from: s) + } + + static func extractLSD(from html: String) -> String? { + if let r = html.range(of: #""LSD"\s*,\s*\[\]\s*,\s*\{"token"\s*:\s*"([^"]+)""#, options: .regularExpression) { + let sub = String(html[r]) + if let m = sub.range(of: #""token"\s*:\s*"([^"]+)""#, options: .regularExpression) { + let t = String(sub[m]) + return t.components(separatedBy: "\"").dropLast(1).last + } + } + // fallback: lsd=.... + if let r = html.range(of: #"\"lsd\"\s*:\s*\"([^\"]+)\""#, options: .regularExpression) { + let sub = String(html[r]) + if let m = sub.range(of: #"[A-Za-z0-9_-]{6,}"#, options: .regularExpression) { return String(sub[m]) } + } + return nil + } + + static func extractFbDtsg(from html: String) -> String? { + // fb_dtsg or DTSG token + if let r = html.range(of: #"\"dtsg\"\s*:\s*\{"token"\s*:\s*"([^"]+)""#, options: .regularExpression) { + let sub = String(html[r]) + // extract quoted value + let comps = sub.components(separatedBy: "\"") + if let idx = comps.firstIndex(of: "token"), idx + 2 < comps.count { return comps[idx + 2] } + } + if let r = html.range(of: #"fb_dtsg[^"]*"([^"]{10,})"#, options: .regularExpression) { + let sub = String(html[r]) + if let m = sub.range(of: #"[A-Za-z0-9:_-]{10,}"#, options: .regularExpression) { return String(sub[m]) } + } + return nil + } + + static func extractRev(from html: String) -> String? { + if let r = html.range(of: #"\"__rev\"\s*:\s*(\d+)"#, options: .regularExpression) { + let sub = String(html[r]); if let m = sub + .range(of: #"\d{5,}"#, options: .regularExpression) { return String(sub[m]) } + } + if let r = html.range(of: #"__rev[=:](\d+)"#, options: .regularExpression) { + let sub = String(html[r]); if let m = sub + .range(of: #"\d{5,}"#, options: .regularExpression) { return String(sub[m]) } + } + return nil + } + + static func extractHsi(from html: String) -> String? { + if let r = html.range(of: #"\"hsi\"\s*:\s*"(\d+)""#, options: .regularExpression) { + let sub = String(html[r]); if let m = sub + .range(of: #"\d{10,}"#, options: .regularExpression) { return String(sub[m]) } + } + return nil + } + + static func parseUsageAPI(data: Data) throws -> UsageSnapshot? { + // Kept for legacy fallback — same as before + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + let totalKeys = ["total_tokens", "totalTokens", "total_tokens_used", "total"] + let inputKeys = ["input_tokens", "inputTokens"] + let outputKeys = ["output_tokens", "outputTokens"] + var total: Double? + var input: Double? + var output: Double? + for k in totalKeys { + if let v = obj[k] as? Double { total = v; break }; if let v = obj[k] as? Int { total = Double(v); break } + } + for k in inputKeys { + if let v = obj[k] as? Double { input = v; break }; if let v = obj[k] as? Int { input = Double(v); break } + } + for k in outputKeys { + if let v = obj[k] as? Double { output = v; break }; if let v = obj[k] as? Int { output = Double(v); break } + } + if total == nil, let nested = obj["data"] as? [String: Any] { + for k in totalKeys { + if let v = nested[k] as? Double { total = v; break }; if let v = nested[k] as? Int { + total = Double(v); break + } + } + } + guard let t = total ?? (input != nil || output != nil ? (input ?? 0) + (output ?? 0) : nil), + t > 0 else { return nil } + let login = "Team usage: \(UsageFormatter.tokenCountString(Int(t))) tokens" + let identity = ProviderIdentitySnapshot( + providerID: .muse, + accountEmail: nil, + accountOrganization: nil, + loginMethod: login) + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: Date(), + identity: identity) + } + + static func parseDashboardHTML(data: Data) throws -> UsageSnapshot? { + guard let html = String(data: data, encoding: .utf8), + html.contains("Team usage") || html.contains("total tokens") || html.contains("Token usage") + else { return nil } + return nil + } + + // MARK: - Formatting + + private static func localizedCount(_ value: Int) -> String { + let f = NumberFormatter() + f.numberStyle = .decimal + f.locale = Locale.current + return f.string(from: NSNumber(value: value)) ?? "\(value)" + } + + private static func localizedCost(_ value: Double) -> String { + let f = NumberFormatter() + f.numberStyle = .currency + f.currencyCode = "USD" + f.locale = Locale.current + f.maximumFractionDigits = 2 + f.minimumFractionDigits = 2 + return f.string(from: NSNumber(value: value)) ?? String(format: "$%.2f", value) + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift index 1a6682c057..c6d14bad1e 100644 --- a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift +++ b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift @@ -72,6 +72,7 @@ extension ProviderInstanceID { public static let xai = UsageProvider.xai.instanceID public static let notion = UsageProvider.notion.instanceID public static let ibmbob = UsageProvider.ibmbob.instanceID + public static let muse = UsageProvider.muse.instanceID } // swiftformat:enable sortDeclarations diff --git a/Sources/CodexBarCore/Providers/ProviderManifest.swift b/Sources/CodexBarCore/Providers/ProviderManifest.swift index 47631b6564..02c461fa59 100644 --- a/Sources/CodexBarCore/Providers/ProviderManifest.swift +++ b/Sources/CodexBarCore/Providers/ProviderManifest.swift @@ -74,5 +74,6 @@ public enum ProviderManifest { XAIProviderDescriptor.descriptor, NotionProviderDescriptor.descriptor, IBMBobProviderDescriptor.descriptor, + MuseProviderDescriptor.descriptor, ] } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index fbdbdea033..ba92ed59ae 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -88,6 +88,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case xai case notion case ibmbob + case muse } // swiftformat:enable sortDeclarations diff --git a/Tests/CodexBarTests/MuseUsageFetcherTests.swift b/Tests/CodexBarTests/MuseUsageFetcherTests.swift new file mode 100644 index 0000000000..77ee4538eb --- /dev/null +++ b/Tests/CodexBarTests/MuseUsageFetcherTests.swift @@ -0,0 +1,263 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct MuseUsageFetcherTests { + // MARK: - Balance parsing — require numeric balance + + @Test + func `parses available_balance with usd`() throws { + let json = """ + {"available_balance": 12.34, "currency": "usd"} + """ + let summary = try MuseUsageFetcher.parseBalanceForTesting(Data(json.utf8)) + #expect(summary.balance == 12.34) + #expect(summary.currency == "usd") + #expect(summary.toUsageSnapshot().loginMethod(for: .muse) == "Balance: $12.34") + } + + @Test + func `parses nested data balance`() throws { + let json = """ + {"data": {"balance": 99.5, "currency": "eur"}} + """ + let summary = try MuseUsageFetcher.parseBalanceForTesting(Data(json.utf8)) + #expect(summary.balance == 99.5) + #expect(summary.currency == "eur") + } + + @Test + func `rejects currency-only payload`() throws { + let json = """ + {"currency": "usd"} + """ + #expect { + _ = try MuseUsageFetcher.parseBalanceForTesting(Data(json.utf8)) + } throws: { error in + guard case MuseUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `rejects empty object for balance`() throws { + let json = "{}" + #expect { + _ = try MuseUsageFetcher.parseBalanceForTesting(Data(json.utf8)) + } throws: { error in + guard case MuseUsageError.parseFailed = error else { return false } + return true + } + } + + // MARK: - Models parsing — require data/models array + + @Test + func `parses models count`() throws { + let json = """ + {"data": [{"id": "muse-spark-1.1"}, {"id": "muse-spark-1.2"}]} + """ + let count = try MuseUsageFetcher.parseModelsCountForTesting(Data(json.utf8)) + #expect(count == 2) + } + + @Test + func `rejects empty models object`() throws { + let json = "{}" + #expect { + _ = try MuseUsageFetcher.parseModelsCountForTesting(Data(json.utf8)) + } throws: { error in + guard case MuseUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `rejects models response without array`() throws { + let json = """ + {"object": "list"} + """ + #expect { + _ = try MuseUsageFetcher.parseModelsCountForTesting(Data(json.utf8)) + } throws: { error in + guard case MuseUsageError.parseFailed = error else { return false } + return true + } + } + + // MARK: - Bearer + validation + fallback + + @Test + func `fetchUsage sends Bearer and validates https guard`() async throws { + // Insecure http (non-localhost) must be rejected before network + await #expect { + _ = try await MuseUsageFetcher.fetchUsage( + apiKey: "sk-test", + baseURLString: "http://evil.example.com", + session: MuseStubTransport { _ in fatalError("should not reach network") }) + } throws: { error in + guard case let MuseUsageError.apiError(msg) = error else { return false } + return msg.contains("Insecure base URL") + } + } + + @Test + func `rejects user info in base URL`() async throws { + await #expect { + _ = try await MuseUsageFetcher.fetchUsage( + apiKey: "sk-test", + baseURLString: "https://user:pass@evil.example.com", + session: MuseStubTransport { _ in fatalError("should not reach network") }) + } throws: { error in + guard case let MuseUsageError.apiError(msg) = error else { return false } + return msg.contains("Insecure base URL") + } + } + + @Test + func `rejects encoded host delimiters`() async throws { + await #expect { + _ = try await MuseUsageFetcher.fetchUsage( + apiKey: "sk-test", + baseURLString: "https://evil%2Fexample.com", + session: MuseStubTransport { _ in fatalError("should not reach network") }) + } throws: { error in + guard case let MuseUsageError.apiError(msg) = error else { return false } + return msg.contains("Insecure base URL") + } + } + + @Test + func `rejects missing host`() async throws { + await #expect { + _ = try await MuseUsageFetcher.fetchUsage( + apiKey: "sk-test", + baseURLString: "https://", + session: MuseStubTransport { _ in fatalError("should not reach network") }) + } throws: { error in + guard case let MuseUsageError.apiError(msg) = error else { return false } + return msg.contains("Insecure base URL") + } + } + + @Test + func `http localhost is allowed`() async throws { + let transport = MuseStubTransport { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer sk-test") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + #expect(request.timeoutInterval == 15) + let body = #"{"available_balance": 5.0, "currency": "usd"}"# + let resp = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + return (resp, Data(body.utf8)) + } + let snap = try await MuseUsageFetcher.fetchUsage( + apiKey: "sk-test", + baseURLString: "http://127.0.0.1:8765", + session: transport) + #expect(snap.summary.balance == 5.0) + } + + @Test + func `preserves 401 from models probe over stale billing 500`() async throws { + let calls = CallsBox() + let transport = MuseStubTransport { request in + calls.increment() + if request.url?.path.contains("/v1/billing/usage") == true { + let resp = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: [:])! + return (resp, Data()) + } + if request.url?.path.contains("/v1/models") == true { + let resp = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: [:])! + return (resp, Data()) + } + let resp = HTTPURLResponse(url: request.url!, statusCode: 404, httpVersion: nil, headerFields: [:])! + return (resp, Data()) + } + await #expect { + _ = try await MuseUsageFetcher.fetchUsage( + apiKey: "sk-test", + baseURLString: "https://api.meta.ai", + session: transport) + } throws: { error in + guard case let MuseUsageError.apiError(msg) = error else { return false } + return msg.contains("401") + } + #expect(calls.value >= 2) + } + + @Test + func `balance 200 is preferred over models fallback`() async throws { + let transport = MuseStubTransport { request in + if request.url?.path.contains("/v1/billing/usage") == true { + let body = #"{"available_balance": 12.34, "currency": "usd"}"# + let resp = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: [:])! + return (resp, Data(body.utf8)) + } + // models should not be hit if billing succeeds, but stub it anyway + let resp = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: [:])! + return (resp, Data(#"{"data":[]}"#.utf8)) + } + let snap = try await MuseUsageFetcher.fetchUsage( + apiKey: "sk-test", + baseURLString: "https://api.meta.ai", + session: transport) + #expect(snap.summary.balance == 12.34) + } + + @Test + func `missing api key throws`() async throws { + await #expect { + _ = try await MuseUsageFetcher.fetchUsage( + apiKey: " ", + baseURLString: nil, + session: MuseStubTransport { _ in fatalError("should not reach network") }) + } throws: { error in + guard case MuseUsageError.missingCredentials = error else { return false } + return true + } + } +} + +// MARK: - Stub transport (ProviderHTTPTransport) + +private final class CallsBox: @unchecked Sendable { + private let lock = NSLock() + private var _value = 0 + var value: Int { + self.lock.withLock { self._value } + } + + func increment() { + self.lock.withLock { self._value += 1 } + } +} + +private struct MuseStubTransport: ProviderHTTPTransport { + let handler: @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) + + init(_ handler: @escaping @Sendable (URLRequest) throws -> (HTTPURLResponse, Data)) { + self.handler = handler + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let (resp, data) = try handler(request) + return (data, resp) + } +} + +// MARK: - Test hooks (expose private parsers) + +extension MuseUsageFetcher { + static func parseBalanceForTesting(_ data: Data) throws -> MuseUsageSummary { + try parseBalanceResponse(data: data) + } + + static func parseModelsCountForTesting(_ data: Data) throws -> Int? { + try parseModelsCount(data: data) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index e7de6eadd6..7717e90be4 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -154,8 +154,8 @@ struct ProviderArchitectureGatekeeperTests { Self.hash(descriptor.branding.burnDownWidgetColor, into: &burnDownFingerprint) } - #expect(widgetFingerprint == 16_873_014_858_015_536_126) - #expect(burnDownFingerprint == 8_686_456_525_451_224_704) + #expect(widgetFingerprint == 1_384_715_300_343_528_903) + #expect(burnDownFingerprint == 5_817_880_812_814_024_010) } @Test @@ -194,7 +194,7 @@ struct ProviderArchitectureGatekeeperTests { func `small provider capabilities preserve legacy registries`() { let descriptors = ProviderDescriptorRegistry.all #expect(Set(descriptors.filter(\.metadata.balanceOnly).map(\.id)) == [ - .deepseek, .deepinfra, .mistral, .moonshot, .poe, + .deepseek, .deepinfra, .mistral, .moonshot, .muse, .poe, ]) #expect(Set(descriptors.filter(\.metadata.usesDetailBackedWindow).map(\.id)) == [ .warp, .kilo, .mistral, .deepseek, .deepinfra, .qoder, .crof, .chutes, @@ -2442,11 +2442,11 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+Animation.swift", - line: 926, - anchor: "if provider == .kiro {", - expectedProviderIDs: ["cursor", "kiro"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["kiro@0", "cursor@8"], + line: 924, + anchor: "if provider == .mistral {", + expectedProviderIDs: ["cursor", "kiro", "mistral"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["mistral@0", "kiro@9", "cursor@17"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+CostMenuCard.swift", diff --git a/docs/index.html b/docs/index.html index 30e1aa2521..b7d84c164a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ CodexBar — every AI coding limit, in your menu bar @@ -37,7 +37,7 @@ @@ -293,7 +293,7 @@

- 69 providers,{mobileBreak}one menu bar + 70 providers,{mobileBreak}one menu bar

Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus. diff --git a/docs/llms.txt b/docs/llms.txt index 9a14df2595..4007a5ae37 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,9 +1,9 @@ # CodexBar -A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 69 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 70 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Canonical documentation: -- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 69 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 70 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Source: https://github.com/steipete/CodexBar diff --git a/docs/muse.md b/docs/muse.md new file mode 100644 index 0000000000..7434311097 --- /dev/null +++ b/docs/muse.md @@ -0,0 +1,57 @@ +--- +summary: "Meta Muse Model API data sources, API key handling, and balance validation." +read_when: + - Adding or tweaking Muse API parsing + - Updating Muse API key handling + - Documenting Muse provider behavior +--- + +# Muse (Meta) provider + +Muse is Meta's Muse Spark API (https://api.meta.ai) + dev.meta.ai Team usage dashboard. CodexBar shows **API key validity, token usage, and balance** via two sources that match the CodexBar host-API pattern (apiToken + web). + +CodexBar probes **web first, then API**: `web` imports dev.meta.ai session cookies (Auto/Manual) for the Team usage dashboard (`https://dev.meta.ai/usage` — total tokens, daily Token usage / Requests charts, by-model breakdown); `api` validates the Bearer key at `https://api.meta.ai/v1/models` and probes billing endpoints for balance. If web has no session or the dashboard shape hasn't been reverse-engineered yet (capture a HAR for the Team usage XHR), it falls back to `API key valid · N models available` so the tile never goes stale. + +## Rationale + +Meta's Muse Spark 1.1 is priced at $1.25 / $4.25 per 1M input/output tokens via the Meta Model API (https://ai.developer.meta.com/docs). Direct Meta API usage is not visible via OpenRouter/LiteLLM aggregators — use the native Muse provider for direct key tracking, and keep the OpenRouter provider for proxy spend. + +## Data sources + +1. **API key** — stored in `~/.codexbar/config.json` or `~/.config/codexbar/config.json`, or via `MUSE_API_KEY` / `META_API_KEY` / `META_MUSE_API_KEY` env. Required for `api` source. +2. **Base URL** (optional) — default `https://api.meta.ai`. Override via Settings → Providers → Muse → API base URL or `MUSE_API_URL` for proxies. +3. **Team usage (web, auto)** — `dev.meta.ai/usage` via browser cookies. Settings → Providers → Muse → Team usage: `Auto` imports Chrome/Safari session for `dev.meta.ai`; `Manual` pastes a `Cookie` header from DevTools → Network → usage XHR. Shows total tokens (input/output) and daily Token usage / Requests when the dashboard XHR is captured. Falls back to API when no session. +4. **Validation + balance endpoints (api, probed in order)**: + - `GET /v1/billing/usage`, `/v1/me/balance`, `/v1/billing/subscription`, `/v1/credits` — balance if present (supports `available_balance`, `balance`, `data.balance`, string or number). + - `GET /v1/models` — fallback probe that proves key validity and counts models. Returns `API key valid` when billing is unavailable. + - All `api` requests use `Authorization: Bearer ` + `Accept: application/json`, 15s timeout. 404 tries next; 401/403 surfaces as invalid key. + +## Usage details + +- **Web (Team usage):** when dev.meta.ai session is present, menu card shows `Team usage: N tokens` (total) and, once the XHR shape is finalized via HAR, daily windows + Requests. Until then it degrades gracefully to the API tile. +- **API:** menu card shows `Balance: $X.XX` when balance is returned, otherwise `API key valid · N models`. +- No session/weekly quota window on the Model API — pay-as-you-go; rate-limit headers are not yet exposed. Snapshot is `balanceOnly` (identity-only) so the plugin contract stays compatible. Web daily charts will emit real windows once the XHR is captured. +- Custom base URL allows self-hosted gateways (e.g., LiteLLM proxying `api.meta.ai`) while still getting CodexBar visibility. + +## Key files + +- `Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift` (descriptor + `MuseAPIFetchStrategy` + `MuseWebFetchStrategy` pipeline `web+api`) +- `Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift` (API-key HTTP + flexible JSON parser) +- `Sources/CodexBarCore/Providers/Muse/MuseWebUsageFetcher.swift` (dev.meta.ai Team usage via browser cookies — probes `/api/usage*` + HTML, graceful fallback) +- `Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift` (env var resolution) +- `Sources/CodexBarCore/Providers/Muse/MuseProviderSettings.swift` (settings section — baseURL + cookieSource) +- `Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift` (settings pickers/fields + activation) +- `Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift` (SettingsStore extension — token + baseURL + cookie source/header) +- `Sources/CodexBar/Resources/ProviderIcon-muse.svg` (icon) + +## CLI + +```bash +# Store key +printf '%s' "$MUSE_API_KEY" | codexbar config set-api-key --provider muse --stdin + +# Check usage — auto tries web then API, falls back gracefully +codexbar usage --provider muse --format json --pretty --verbose +codexbar usage --provider muse --source web --format json --pretty # dev.meta.ai cookies +codexbar usage --provider muse --source api --format json --pretty # Bearer probe +``` diff --git a/docs/provider-ids.md b/docs/provider-ids.md index 214a88e646..a7dd95df5d 100644 --- a/docs/provider-ids.md +++ b/docs/provider-ids.md @@ -2,4 +2,4 @@ # Provider IDs -`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`, `ibmbob`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`, `ibmbob`, `muse`. diff --git a/docs/providers.md b/docs/providers.md index e4d663a99f..ec34d08ce6 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 69 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 70 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -99,6 +99,7 @@ scan fails, while provider/account configuration changes replace obsolete result | Zed | Zed editor Keychain session → `cloud.zed.dev/client/users/me` for plan and quota data (`local`). | | Notion AI | Browser cookies → workspace resolution and the AI usage allowance API (`web`). | | IBM Bob | API key from config/env → profile and per-team Bobcoin budget APIs (`api`). | +| Muse | API key from config/env → balance + models probe (`api`). | ## Codex - App Auto: OAuth API first; falls back to CLI only when OAuth credentials are missing or auth/refresh is invalid. @@ -610,6 +611,13 @@ JavaScriptCore is the macOS rollback engine. The committed `.js` is generated fr - Prepaid money is not a quota; no session or weekly meters are synthesized. - Details: `docs/xai.md`. +## Muse +- API key from config or `MUSE_API_KEY` / `META_API_KEY` / `META_MUSE_API_KEY`. +- Optional base URL from provider settings or `MUSE_API_URL` (default `https://api.meta.ai`); overrides must be HTTPS or `http://localhost` for loopback. +- Probes `GET /v1/billing/usage`, `/v1/me/balance`, `/v1/billing/subscription`, `/v1/credits` for balance (supports `available_balance`, `balance`, `data.balance` as string or number), falling back to `GET /v1/models` for key validity and model count. +- Balance-only provider; shows `Balance: $X.XX` when available, otherwise `API key valid · N models`. +- Details: `docs/muse.md`. + ## Notion AI - Browser cookies (auto-import or manual Cookie header/cURL capture) for `app.notion.com`; the `token_v2` session cookie is required. - `POST /api/v3/getSpaces` resolves the account and its workspaces, then `POST /api/v3/getCreditRateLimitStatus` returns the allowance for the selected space. diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs index 04603af2b7..a53894e53d 100644 --- a/docs/site-locales.mjs +++ b/docs/site-locales.mjs @@ -98,8 +98,8 @@ export const localeCatalog = [ export const localeMessages = { "en": { "meta.title": "CodexBar — every AI coding limit in your menu bar", - "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 69 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", - "meta.ogDescription": "Track usage windows, credits, and resets across 69 AI coding providers from your macOS menu bar.", + "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 70 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", + "meta.ogDescription": "Track usage windows, credits, and resets across 70 AI coding providers from your macOS menu bar.", "nav.primary": "Primary", "nav.language": "Language", "nav.docs": "docs", @@ -112,7 +112,7 @@ export const localeMessages = { "hero.description": "CodexBar tracks usage windows, credit balances, and reset countdowns across the providers you actually pay for — one status item each, or merge them into one.", "hero.download": "Download for macOS", "hero.fineprint": "Free & open source · macOS 14+ · Universal via GitHub Releases and Homebrew", - "providers.title": "69 providers,{mobileBreak}one menu bar", + "providers.title": "70 providers,{mobileBreak}one menu bar", "providers.description": "Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.", "providers.yourProvider": "Your provider", "providers.authoringGuide": "Authoring guide", @@ -238,8 +238,8 @@ export const localeMessages = { }, "zh-CN": { "meta.title": "CodexBar — 菜单栏中的每个 AI 编码限制", - "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 69 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", - "meta.ogDescription": "从您的 macOS 菜单栏跟踪 69 个 AI 编码提供商的使用窗口、积分和重置。", + "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 70 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", + "meta.ogDescription": "从您的 macOS 菜单栏跟踪 70 个 AI 编码提供商的使用窗口、积分和重置。", "nav.primary": "基本的", "nav.language": "语言", "nav.docs": "文档", @@ -252,7 +252,7 @@ export const localeMessages = { "hero.description": "CodexBar 跟踪您实际付费的提供商的使用窗口、信用余额和重置倒计时 - 每个状态项一项,或将它们合并为一项。", "hero.download": "下载macOS", "hero.fineprint": "免费开源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "69 个提供商,{mobileBreak}一个菜单栏", + "providers.title": "70 个提供商,{mobileBreak}一个菜单栏", "providers.description": "受欢迎的提供商成为状态项目,具有自己的使用窗口、重置倒计时、图表和提供商菜单。", "providers.yourProvider": "您的提供商", "providers.authoringGuide": "创作指南", @@ -378,8 +378,8 @@ export const localeMessages = { }, "zh-TW": { "meta.title": "CodexBar — 功能表列中的每個 AI 編碼限制", - "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 69 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", - "meta.ogDescription": "從您的 macOS 功能表列追蹤 69 個 AI 編碼提供者的使用視窗、積分和重設。", + "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 70 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", + "meta.ogDescription": "從您的 macOS 功能表列追蹤 70 個 AI 編碼提供者的使用視窗、積分和重設。", "nav.primary": "基本的", "nav.language": "語言", "nav.docs": "文件", @@ -392,7 +392,7 @@ export const localeMessages = { "hero.description": "CodexBar 追蹤您實際付費的提供者的使用視窗、信用餘額和重設倒數計時 - 每個狀態項一項,或將它們合併為一項。", "hero.download": "下載macOS", "hero.fineprint": "免費開源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "69 個提供者,{mobileBreak}一個選單列", + "providers.title": "70 個提供者,{mobileBreak}一個選單列", "providers.description": "受歡迎的提供者成為狀態項目,具有自己的使用視窗、重置倒數計時、圖表和提供者選單。", "providers.yourProvider": "您的提供者", "providers.authoringGuide": "創作指南", @@ -518,8 +518,8 @@ export const localeMessages = { }, "ja-JP": { "meta.title": "CodexBar — メニュー バーのすべての AI コーディング制限", - "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、69 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", - "meta.ogDescription": "macOS メニュー バーから、69 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", + "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、70 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", + "meta.ogDescription": "macOS メニュー バーから、70 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", "nav.primary": "主要な", "nav.language": "言語", "nav.docs": "ドキュメント", @@ -532,7 +532,7 @@ export const localeMessages = { "hero.description": "CodexBar は、実際に料金を支払っているプロバイダー全体の使用期間、クレジット残高、リセット カウントダウンを追跡します。ステータス項目を 1 つずつ、または 1 つに統合します。", "hero.download": "macOS のダウンロード", "hero.fineprint": "無料・オープンソース · macOS 14+ · GitHub Releases と Homebrew でユニバーサル版を提供", - "providers.title": "69 プロバイダー、{mobileBreak}1つのメニューバー", + "providers.title": "70 プロバイダー、{mobileBreak}1つのメニューバー", "providers.description": "人気のあるプロバイダーは、独自の使用期間、リセット カウントダウン、グラフ、プロバイダー メニューを備えたステータス アイテムになります。", "providers.yourProvider": "あなたのプロバイダー", "providers.authoringGuide": "オーサリングガイド", @@ -658,8 +658,8 @@ export const localeMessages = { }, "es": { "meta.title": "CodexBar: cada límite de codificación de IA en tu barra de menú", - "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 69 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", - "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 69 proveedores de codificación de IA desde su barra de menú macOS.", + "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 70 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", + "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 70 proveedores de codificación de IA desde su barra de menú macOS.", "nav.primary": "Primario", "nav.language": "Idioma", "nav.docs": "Documentos", @@ -672,7 +672,7 @@ export const localeMessages = { "hero.description": "CodexBar realiza un seguimiento de los períodos de uso, los saldos de crédito y restablece las cuentas regresivas de los proveedores por los que realmente paga: un elemento de estado para cada uno o los fusiona en uno solo.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratis y de código abierto · macOS 14+ · Universal mediante GitHub Releases y Homebrew", - "providers.title": "69 proveedores,{mobileBreak}una barra de menús", + "providers.title": "70 proveedores,{mobileBreak}una barra de menús", "providers.description": "Los proveedores populares se convierten en elementos de estado con sus propias ventanas de uso, restablecen cuentas regresivas, gráficos y menús de proveedores.", "providers.yourProvider": "Tu proveedor", "providers.authoringGuide": "guía de autoría", @@ -798,8 +798,8 @@ export const localeMessages = { }, "pt-BR": { "meta.title": "CodexBar — todos os limites de codificação de IA na sua barra de menu", - "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 69 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", - "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 69 provedores de codificação de IA na barra de menu macOS.", + "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 70 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", + "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 70 provedores de codificação de IA na barra de menu macOS.", "nav.primary": "Primário", "nav.language": "Linguagem", "nav.docs": "Documentos", @@ -812,7 +812,7 @@ export const localeMessages = { "hero.description": "CodexBar rastreia janelas de uso, saldos de crédito e reinicia contagens regressivas nos provedores pelos quais você realmente paga - um item de status cada, ou mescla-os em um.", "hero.download": "Baixar para macOS", "hero.fineprint": "Gratuito e de código aberto · macOS 14+ · Universal via GitHub Releases e Homebrew", - "providers.title": "69 provedores,{mobileBreak}uma barra de menu", + "providers.title": "70 provedores,{mobileBreak}uma barra de menu", "providers.description": "Provedores populares tornam-se itens de status com suas próprias janelas de uso, reiniciam contagens regressivas, gráficos e menus de provedores.", "providers.yourProvider": "Seu provedor", "providers.authoringGuide": "Guia de autoria", @@ -938,8 +938,8 @@ export const localeMessages = { }, "ko": { "meta.title": "CodexBar — 메뉴 표시줄의 모든 AI 코딩 제한", - "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 69개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", - "meta.ogDescription": "macOS 메뉴 표시줄에서 69개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", + "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 70개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", + "meta.ogDescription": "macOS 메뉴 표시줄에서 70개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", "nav.primary": "주요한", "nav.language": "언어", "nav.docs": "문서", @@ -952,7 +952,7 @@ export const localeMessages = { "hero.description": "CodexBar는 귀하가 실제로 비용을 지불한 제공업체 전반에 걸쳐 사용 기간, 크레딧 잔액 및 재설정 카운트다운을 추적합니다. 즉, 각각 하나의 상태 항목 또는 하나로 병합됩니다.", "hero.download": "macOS 동안 다운로드", "hero.fineprint": "무료 오픈 소스 · macOS 14+ · GitHub Releases와 Homebrew에서 유니버설 제공", - "providers.title": "69개 제공자,{mobileBreak}하나의 메뉴 막대", + "providers.title": "70개 제공자,{mobileBreak}하나의 메뉴 막대", "providers.description": "인기 있는 제공업체는 자체 사용 창, 재설정 카운트다운, 차트 및 제공업체 메뉴를 갖춘 상태 항목이 됩니다.", "providers.yourProvider": "귀하의 제공자", "providers.authoringGuide": "저작 가이드", @@ -1078,8 +1078,8 @@ export const localeMessages = { }, "de": { "meta.title": "CodexBar – alle Limits Ihrer KI-Coding-Tools in der Menüleiste", - "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 69 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", - "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 69 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", + "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 70 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", + "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 70 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", "nav.primary": "Primär", "nav.language": "Sprache", "nav.docs": "Dokumente", @@ -1092,7 +1092,7 @@ export const localeMessages = { "hero.description": "CodexBar verfolgt Nutzungsfenster, Guthaben und Reset-Countdowns bei den Anbietern, für die Sie tatsächlich bezahlen – jeweils ein Statuselement oder sie werden zu einem zusammengeführt.", "hero.download": "Herunterladen für macOS", "hero.fineprint": "Kostenlos und Open Source · macOS 14+ · Universal über GitHub Releases und Homebrew", - "providers.title": "69 Provider,{mobileBreak}eine Menüleiste", + "providers.title": "70 Provider,{mobileBreak}eine Menüleiste", "providers.description": "Beliebte Anbieter werden zu Statuselementen mit eigenen Nutzungsfenstern, Reset-Countdowns, Diagrammen und Anbietermenüs.", "providers.yourProvider": "Ihr Anbieter", "providers.authoringGuide": "Autorenleitfaden", @@ -1218,8 +1218,8 @@ export const localeMessages = { }, "fr": { "meta.title": "CodexBar — chaque limite de codage IA dans votre barre de menus", - "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 69 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", - "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 69 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", + "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 70 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", + "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 70 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", "nav.primary": "Primaire", "nav.language": "Langue", "nav.docs": "Documents", @@ -1232,7 +1232,7 @@ export const localeMessages = { "hero.description": "CodexBar suit les fenêtres d'utilisation, les soldes créditeurs et réinitialise les comptes à rebours pour les fournisseurs pour lesquels vous payez réellement - un élément de statut chacun, ou les fusionne en un seul.", "hero.download": "Télécharger pour macOS", "hero.fineprint": "Gratuit et open source · macOS 14+ · Universel via GitHub Releases et Homebrew", - "providers.title": "69 fournisseurs,{mobileBreak}une barre des menus", + "providers.title": "70 fournisseurs,{mobileBreak}une barre des menus", "providers.description": "Les fournisseurs populaires deviennent des éléments de statut avec leurs propres fenêtres d'utilisation, réinitialisent les comptes à rebours, les graphiques et les menus des fournisseurs.", "providers.yourProvider": "Votre fournisseur", "providers.authoringGuide": "Guide de création", @@ -1358,8 +1358,8 @@ export const localeMessages = { }, "ar": { "meta.title": "CodexBar — كل حد لترميز الذكاء الاصطناعي في شريط القائمة", - "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 69 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", - "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 69 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", + "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 70 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", + "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 70 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", "nav.primary": "أساسي", "nav.language": "لغة", "nav.docs": "المستندات", @@ -1372,7 +1372,7 @@ export const localeMessages = { "hero.description": "يتتبع CodexBar فترات الاستخدام والأرصدة الائتمانية وإعادة تعيين العد التنازلي عبر مقدمي الخدمة الذين تدفع مقابلهم فعليًا — عنصر حالة واحد لكل منهم، أو دمجهم في عنصر واحد.", "hero.download": "التنزيل لمدة macOS", "hero.fineprint": "مجاني ومفتوح المصدر · macOS 14+ · إصدار شامل عبر GitHub Releases وHomebrew", - "providers.title": "69 مزودًا،{mobileBreak}شريط قوائم واحد", + "providers.title": "70 مزودًا،{mobileBreak}شريط قوائم واحد", "providers.description": "يصبح الموفرون المشهورون عناصر حالة مع نوافذ الاستخدام الخاصة بهم، وعمليات إعادة تعيين العد التنازلي، والمخططات، وقوائم الموفر.", "providers.yourProvider": "المزود الخاص بك", "providers.authoringGuide": "دليل التأليف", @@ -1498,8 +1498,8 @@ export const localeMessages = { }, "it": { "meta.title": "CodexBar: ogni limite di codifica AI nella barra dei menu", - "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 69 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", - "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 69 fornitori di codifica AI dalla barra dei menu macOS.", + "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 70 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", + "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 70 fornitori di codifica AI dalla barra dei menu macOS.", "nav.primary": "Primario", "nav.language": "Lingua", "nav.docs": "Documenti", @@ -1512,7 +1512,7 @@ export const localeMessages = { "hero.description": "CodexBar tiene traccia delle finestre di utilizzo, dei saldi del credito e reimposta i conti alla rovescia tra i fornitori per cui paghi effettivamente: un elemento di stato ciascuno o uniscili in uno solo.", "hero.download": "Scarica per macOS", "hero.fineprint": "Gratuito e open source · macOS 14+ · Universale tramite GitHub Releases e Homebrew", - "providers.title": "69 provider,{mobileBreak}una barra dei menu", + "providers.title": "70 provider,{mobileBreak}una barra dei menu", "providers.description": "I fornitori più popolari diventano elementi di stato con le proprie finestre di utilizzo, reimpostano i conti alla rovescia, i grafici e i menu dei fornitori.", "providers.yourProvider": "Il tuo fornitore", "providers.authoringGuide": "Guida all'autore", @@ -1638,8 +1638,8 @@ export const localeMessages = { }, "vi": { "meta.title": "CodexBar — mọi giới hạn mã hóa AI trong thanh menu của bạn", - "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 69 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", - "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 69 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", + "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 70 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", + "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 70 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", "nav.primary": "Sơ đẳng", "nav.language": "Ngôn ngữ", "nav.docs": "Tài liệu", @@ -1652,7 +1652,7 @@ export const localeMessages = { "hero.description": "CodexBar theo dõi khoảng thời gian sử dụng, số dư tín dụng và đếm ngược đặt lại trên các nhà cung cấp mà bạn thực sự thanh toán — mỗi mục một trạng thái hoặc hợp nhất chúng thành một.", "hero.download": "Tải xuống cho macOS", "hero.fineprint": "Miễn phí và nguồn mở · macOS 14+ · Bản universal qua GitHub Releases và Homebrew", - "providers.title": "69 nhà cung cấp,{mobileBreak}một thanh menu", + "providers.title": "70 nhà cung cấp,{mobileBreak}một thanh menu", "providers.description": "Các nhà cung cấp phổ biến trở thành các mục trạng thái với cửa sổ sử dụng của riêng họ, đặt lại bộ đếm ngược, biểu đồ và menu nhà cung cấp.", "providers.yourProvider": "Nhà cung cấp của bạn", "providers.authoringGuide": "Hướng dẫn soạn thảo", @@ -1778,8 +1778,8 @@ export const localeMessages = { }, "nl": { "meta.title": "CodexBar — elke AI-coderingslimiet in uw menubalk", - "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 69 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", - "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 69 leveranciers van AI-codering via uw macOS-menubalk.", + "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 70 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", + "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 70 leveranciers van AI-codering via uw macOS-menubalk.", "nav.primary": "Primair", "nav.language": "Taal", "nav.docs": "Documenten", @@ -1792,7 +1792,7 @@ export const localeMessages = { "hero.description": "CodexBar houdt gebruiksperioden, tegoeden en reset-countdowns bij voor de providers waarvoor u daadwerkelijk betaalt: elk één statusitem, of u kunt ze samenvoegen tot één statusitem.", "hero.download": "Downloaden voor macOS", "hero.fineprint": "Gratis en open source · macOS 14+ · Universeel via GitHub Releases en Homebrew", - "providers.title": "69 providers,{mobileBreak}één menubalk", + "providers.title": "70 providers,{mobileBreak}één menubalk", "providers.description": "Populaire providers worden statusitems met hun eigen gebruiksvensters, resetcountdowns, grafieken en providermenu's.", "providers.yourProvider": "Uw aanbieder", "providers.authoringGuide": "Handleiding voor het schrijven", @@ -1918,8 +1918,8 @@ export const localeMessages = { }, "tr": { "meta.title": "CodexBar — menü çubuğunuzdaki tüm AI kodlama limitleri", - "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 69 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", - "meta.ogDescription": "macOS menü çubuğunu kullanarak 69 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", + "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 70 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", + "meta.ogDescription": "macOS menü çubuğunu kullanarak 70 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", "nav.primary": "Öncelik", "nav.language": "Dil", "nav.docs": "Dokümanlar", @@ -1932,7 +1932,7 @@ export const localeMessages = { "hero.description": "CodexBar, gerçekte ödeme yaptığınız sağlayıcılar genelinde kullanım pencerelerini, kredi bakiyelerini ve geri sayımları sıfırlar (her biri bir durum öğesi olacak şekilde) izler veya bunları tek bir öğede birleştirir.", "hero.download": "macOS için indirin", "hero.fineprint": "Ücretsiz ve açık kaynak · macOS 14+ · GitHub Releases ve Homebrew ile evrensel sürüm", - "providers.title": "69 sağlayıcı,{mobileBreak}bir menü çubuğu", + "providers.title": "70 sağlayıcı,{mobileBreak}bir menü çubuğu", "providers.description": "Popüler sağlayıcılar, kendi kullanım pencereleri, sıfırlama geri sayımları, çizelgeleri ve sağlayıcı menüleriyle durum öğeleri haline gelir.", "providers.yourProvider": "Sağlayıcınız", "providers.authoringGuide": "Yazma kılavuzu", @@ -2058,8 +2058,8 @@ export const localeMessages = { }, "uk": { "meta.title": "CodexBar — кожне обмеження кодування AI у вашій панелі меню", - "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 69 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", - "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 69 постачальників кодування ШІ за допомогою панелі меню macOS.", + "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 70 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", + "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 70 постачальників кодування ШІ за допомогою панелі меню macOS.", "nav.primary": "Первинний", "nav.language": "Мова", "nav.docs": "документи", @@ -2072,7 +2072,7 @@ export const localeMessages = { "hero.description": "CodexBar відстежує вікна використання, кредитні баланси та скидає зворотний відлік для постачальників, за яких ви фактично платите, — по одному статусу для кожного або об’єднує їх в один.", "hero.download": "Завантажити для macOS", "hero.fineprint": "Безкоштовний і відкритий код · macOS 14+ · Універсальна версія через GitHub Releases і Homebrew", - "providers.title": "69 провайдерів,{mobileBreak}одна панель меню", + "providers.title": "70 провайдерів,{mobileBreak}одна панель меню", "providers.description": "Популярні постачальники стають елементами статусу з власними вікнами використання, скиданням зворотного відліку, діаграмами та меню постачальників.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Авторський посібник", @@ -2198,8 +2198,8 @@ export const localeMessages = { }, "ru": { "meta.title": "CodexBar — все лимиты AI-кодинга в вашей строке меню", - "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 69 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", - "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 69 AI-провайдеров для кодинга прямо из строки меню macOS.", + "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 70 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", + "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 70 AI-провайдеров для кодинга прямо из строки меню macOS.", "nav.primary": "Основная навигация", "nav.language": "Язык", "nav.docs": "Документация", @@ -2212,7 +2212,7 @@ export const localeMessages = { "hero.description": "CodexBar отслеживает окна использования, балансы кредитов и обратный отсчет до сброса у провайдеров, за которых вы действительно платите, — по одному элементу статуса на каждого или все вместе в одном.", "hero.download": "Скачать для macOS", "hero.fineprint": "Бесплатно и с открытым исходным кодом · macOS 14+ · универсальная сборка через GitHub Releases и Homebrew", - "providers.title": "69 провайдеров,{mobileBreak}одна строка меню", + "providers.title": "70 провайдеров,{mobileBreak}одна строка меню", "providers.description": "Популярные провайдеры становятся элементами статуса со своими окнами использования, обратным отсчетом до сброса, графиками и меню провайдера.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Руководство по добавлению", @@ -2338,8 +2338,8 @@ export const localeMessages = { }, "id": { "meta.title": "CodexBar — setiap batas pengkodean AI di bilah menu Anda", - "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 69 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", - "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 69 penyedia pengkodean AI dari bilah menu macOS Anda.", + "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 70 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", + "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 70 penyedia pengkodean AI dari bilah menu macOS Anda.", "nav.primary": "Utama", "nav.language": "Bahasa", "nav.docs": "dokumen", @@ -2352,7 +2352,7 @@ export const localeMessages = { "hero.description": "CodexBar melacak jangka waktu penggunaan, saldo kredit, dan hitungan mundur penyetelan ulang di seluruh penyedia yang sebenarnya Anda bayar — masing-masing satu item status, atau gabungkan menjadi satu.", "hero.download": "Unduh untuk macOS", "hero.fineprint": "Gratis dan sumber terbuka · macOS 14+ · Universal melalui GitHub Releases dan Homebrew", - "providers.title": "69 penyedia,{mobileBreak}satu bilah menu", + "providers.title": "70 penyedia,{mobileBreak}satu bilah menu", "providers.description": "Penyedia populer menjadi item status dengan jendela penggunaannya sendiri, hitung mundur pengaturan ulang, bagan, dan menu penyedia.", "providers.yourProvider": "Penyedia Anda", "providers.authoringGuide": "Panduan penulisan", @@ -2478,8 +2478,8 @@ export const localeMessages = { }, "pl": { "meta.title": "CodexBar — każdy limit kodowania AI na pasku menu", - "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 69 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", - "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 69 dostawców kodowania AI za pomocą paska menu macOS.", + "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 70 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", + "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 70 dostawców kodowania AI za pomocą paska menu macOS.", "nav.primary": "Podstawowy", "nav.language": "Język", "nav.docs": "Dokumenty", @@ -2492,7 +2492,7 @@ export const localeMessages = { "hero.description": "CodexBar śledzi okna użytkowania, salda kredytów i resetuje odliczanie u dostawców, za których faktycznie płacisz — po jednym statusie dla każdego lub połącz je w jeden.", "hero.download": "Pobierz dla macOS", "hero.fineprint": "Darmowe i otwarte oprogramowanie · macOS 14+ · Wersja uniwersalna przez GitHub Releases i Homebrew", - "providers.title": "69 dostawców,{mobileBreak}jeden pasek menu", + "providers.title": "70 dostawców,{mobileBreak}jeden pasek menu", "providers.description": "Popularni dostawcy stają się elementami statusu z własnymi oknami użytkowania, resetowaniem odliczania, wykresami i menu dostawców.", "providers.yourProvider": "Twój dostawca", "providers.authoringGuide": "Przewodnik autorski", @@ -2618,8 +2618,8 @@ export const localeMessages = { }, "fa": { "meta.title": "CodexBar - هر محدودیت کدنویسی هوش مصنوعی در نوار منو شما", - "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 69 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", - "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 69 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", + "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 70 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", + "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 70 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", "nav.primary": "اولیه", "nav.language": "زبان", "nav.docs": "اسناد", @@ -2632,7 +2632,7 @@ export const localeMessages = { "hero.description": "CodexBar پنجره‌های استفاده، مانده اعتبار و بازنشانی شمارش معکوس را در سراسر ارائه‌دهندگانی که واقعاً برایشان پول پرداخت می‌کنید ردیابی می‌کند - هر کدام یک مورد وضعیت، یا آنها را در یکی ادغام کنید.", "hero.download": "دانلود برای macOS", "hero.fineprint": "رایگان و متن‌باز · macOS 14+ · نسخهٔ یونیورسال از GitHub Releases و Homebrew", - "providers.title": "69 ارائه دهنده،{mobileBreak}یک نوار منو", + "providers.title": "70 ارائه دهنده،{mobileBreak}یک نوار منو", "providers.description": "ارائه‌دهندگان محبوب با پنجره‌های استفاده خاص خود، شمارش معکوس، نمودارها و منوهای ارائه‌دهنده را بازنشانی می‌کنند.", "providers.yourProvider": "ارائه دهنده شما", "providers.authoringGuide": "راهنمای نگارش", @@ -2758,8 +2758,8 @@ export const localeMessages = { }, "th": { "meta.title": "CodexBar — ทุกขีดจำกัดการเข้ารหัส AI ในแถบเมนูของคุณ", - "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 69 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", - "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 69 รายจากแถบเมนู macOS", + "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 70 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", + "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 70 รายจากแถบเมนู macOS", "nav.primary": "หลัก", "nav.language": "ภาษา", "nav.docs": "เอกสาร", @@ -2772,7 +2772,7 @@ export const localeMessages = { "hero.description": "CodexBar ติดตามกรอบเวลาการใช้งาน ยอดเครดิต และรีเซ็ตการนับถอยหลังของผู้ให้บริการที่คุณชำระเงินจริง — รายการสถานะแต่ละรายการ หรือรวมรายการเหล่านั้นเป็นรายการเดียว", "hero.download": "ดาวน์โหลดสำหรับ macOS", "hero.fineprint": "ฟรีและโอเพ่นซอร์ส · macOS 14+ · รุ่น Universal ผ่าน GitHub Releases และ Homebrew", - "providers.title": "ผู้ให้บริการ 69 ราย{mobileBreak}หนึ่งแถบเมนู", + "providers.title": "ผู้ให้บริการ 70 ราย{mobileBreak}หนึ่งแถบเมนู", "providers.description": "ผู้ให้บริการยอดนิยมจะกลายเป็นรายการสถานะที่มีหน้าต่างการใช้งานของตนเอง รีเซ็ตการนับถอยหลัง แผนภูมิ และเมนูของผู้ให้บริการ", "providers.yourProvider": "ผู้ให้บริการของคุณ", "providers.authoringGuide": "คู่มือการเขียน", @@ -2898,8 +2898,8 @@ export const localeMessages = { }, "gl": { "meta.title": "CodexBar — todos os límites de programación con IA na túa barra de menús", - "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 69 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", - "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 69 provedores de programación con IA desde a barra de menús de macOS.", + "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 70 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", + "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 70 provedores de programación con IA desde a barra de menús de macOS.", "nav.primary": "Principal", "nav.language": "Idioma", "nav.docs": "Documentación", @@ -2912,7 +2912,7 @@ export const localeMessages = { "hero.description": "CodexBar controla as xanelas de uso, os saldos de crédito e as contas atrás ata o restablecemento dos provedores polos que realmente pagas — un elemento de estado para cada un ou todos combinados nun só.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratuíto e de código aberto · macOS 14+ · Universal mediante GitHub Releases e Homebrew", - "providers.title": "69 provedores,{mobileBreak}unha barra de menús", + "providers.title": "70 provedores,{mobileBreak}unha barra de menús", "providers.description": "Os provedores populares convértense en elementos de estado coas súas propias xanelas de uso, contas atrás de restablecemento, gráficas e menús.", "providers.yourProvider": "O teu provedor", "providers.authoringGuide": "Guía de creación", @@ -3038,8 +3038,8 @@ export const localeMessages = { }, "ca": { "meta.title": "CodexBar: tots els límits de la programació amb IA a la barra de menús", - "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 69 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", - "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 69 proveïdors de programació amb IA des de la barra de menús del macOS.", + "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 70 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", + "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 70 proveïdors de programació amb IA des de la barra de menús del macOS.", "nav.primary": "Navegació principal", "nav.language": "Llengua", "nav.docs": "Documentació", @@ -3052,7 +3052,7 @@ export const localeMessages = { "hero.description": "El CodexBar fa el seguiment de les finestres d'ús, els saldos de crèdit i els comptes enrere fins al restabliment dels proveïdors pels quals realment pagueu: un element d'estat per a cadascun, o combineu-los tots en un de sol.", "hero.download": "Baixeu per al macOS", "hero.fineprint": "Gratuït i de codi obert · macOS 14+ · Universal mitjançant GitHub Releases i Homebrew", - "providers.title": "69 proveïdors,{mobileBreak}una barra de menús", + "providers.title": "70 proveïdors,{mobileBreak}una barra de menús", "providers.description": "Els proveïdors populars es converteixen en elements d'estat amb les seves pròpies finestres d'ús, comptes enrere de restabliment, gràfics i menús de proveïdor.", "providers.yourProvider": "El vostre proveïdor", "providers.authoringGuide": "Guia de creació", @@ -3178,8 +3178,8 @@ export const localeMessages = { }, "sv": { "meta.title": "CodexBar — varje AI-kodningsgräns i din menyrad", - "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 69 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", - "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 69 AI-kodningsleverantörer från din macOS-menyrad.", + "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 70 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", + "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 70 AI-kodningsleverantörer från din macOS-menyrad.", "nav.primary": "Primär", "nav.language": "Språk", "nav.docs": "Dokument", @@ -3192,7 +3192,7 @@ export const localeMessages = { "hero.description": "CodexBar spårar användningsfönster, kreditsaldon och återställningsnedräkningar för de leverantörer du faktiskt betalar för – en statuspost var, eller slå samman dem till en.", "hero.download": "Ladda ner för macOS", "hero.fineprint": "Gratis och öppen källkod · macOS 14+ · Universal via GitHub Releases och Homebrew", - "providers.title": "69 leverantörer,{mobileBreak}en menyrad", + "providers.title": "70 leverantörer,{mobileBreak}en menyrad", "providers.description": "Populära leverantörer blir statusobjekt med sina egna användningsfönster, återställer nedräkningar, diagram och leverantörsmenyer.", "providers.yourProvider": "Din leverantör", "providers.authoringGuide": "Författarguide", diff --git a/docs/social.html b/docs/social.html index d4ce6c916f..0b850365d9 100644 --- a/docs/social.html +++ b/docs/social.html @@ -199,7 +199,7 @@

Every AI coding limit, in your menu bar.

-

69 providers·usage windows, credits, resets·one status item each, or merged.

+

70 providers·usage windows, credits, resets·one status item each, or merged.