From 7cc7f8b2cc9ed2b7850e623f025de26e20986cdf Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Mon, 31 Aug 2026 20:11:32 -0700 Subject: [PATCH 1/5] Add Muse provider (Meta Muse) - Add UsageProvider.muse with descriptor, settings, fetcher - Support META_API_KEY / MUSE_API_KEY and CLI probe (muse --version) - Probe api.meta.ai/v1/usage candidates with fallback to identity card - Add BinaryLocator.resolveMuseBinary, icon, docs, manifests (70 providers) - Update README and providers overview --- README.md | 1 + .../Muse/MuseProviderImplementation.swift | 63 +++++ .../Providers/Muse/MuseSettingsStore.swift | 25 ++ .../ProviderImplementationManifest.swift | 1 + .../CodexBar/Resources/ProviderIcon-muse.svg | 6 + Sources/CodexBarCore/PathEnvironment.swift | 25 ++ .../Muse/MuseProviderDescriptor.swift | 224 ++++++++++++++++++ .../Providers/Muse/MuseSettingsReader.swift | 55 +++++ .../Providers/Muse/MuseUsageFetcher.swift | 195 +++++++++++++++ .../Providers/Muse/MuseUsageSnapshot.swift | 40 ++++ .../ProviderInstanceIDAliases.generated.swift | 1 + .../Providers/ProviderManifest.swift | 1 + .../CodexBarCore/Providers/Providers.swift | 1 + docs/muse.md | 52 ++++ docs/provider-ids.md | 2 +- docs/providers.md | 3 +- 16 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift create mode 100644 Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-muse.svg create mode 100644 Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift create mode 100644 Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift create mode 100644 Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift create mode 100644 docs/muse.md diff --git a/README.md b/README.md index 1bcd33ced0..91ee74b866 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [ZenMux](docs/zenmux.md) — Management API key for rolling five-hour and seven-day quota windows plus PAYG balance. - [xAI](docs/xai.md) — Management API key + team ID for prepaid credit balance and daily platform spend. - [IBM Bob](docs/ibm-bob.md) — API key for monthly Bobcoin budget and usage across subscription teams. +- [Muse](docs/muse.md) — API key (`META_API_KEY`) or local CLI (`muse login` / `muse auth`) for usage probing; falls back to CLI version check until Meta publishes a usage endpoint. - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot diff --git a/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift new file mode 100644 index 0000000000..2757171f6f --- /dev/null +++ b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift @@ -0,0 +1,63 @@ +import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +struct MuseProviderImplementation: ProviderImplementation { + let id: UsageProvider = .muse + + @MainActor + func presentation(context _: ProviderPresentationContext) -> ProviderPresentation { + ProviderPresentation { _ in "api" } + } + + @MainActor + func observeSettings(_ settings: SettingsStore) { + _ = settings.museAPIToken + _ = settings.museBaseURL + } + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + if MuseSettingsReader.apiKey(environment: context.environment) != nil { + return true + } + if BinaryLocator.resolveMuseBinary() != nil { + return true + } + context.settings.ensureMuseAPITokenLoaded() + return !context.settings.museAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @MainActor + func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { + [ + ProviderSettingsFieldDescriptor( + id: "muse-api-key", + title: "API key", + subtitle: "Stored in ~/.codexbar/config.json. Paste META_API_KEY from https://dev.meta.ai or run `muse login`.", + kind: .secure, + placeholder: "Paste META_API_KEY…", + binding: context.stringBinding(\.museAPIToken), + actions: [ + ProviderSettingsActionDescriptor( + id: "muse-open-dev", + title: "Open dev.meta.ai", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://dev.meta.ai") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: { context.settings.ensureMuseAPITokenLoaded() }), + ] + } + + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + [] + } +} diff --git a/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift b/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift new file mode 100644 index 0000000000..97f3a9a734 --- /dev/null +++ b/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift @@ -0,0 +1,25 @@ +import CodexBarCore +import Foundation + +extension SettingsStore { + var museAPIToken: String { + get { self.configSnapshot.providerConfig(for: .muse)?.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 { self.configSnapshot.providerConfig(for: .muse)?.sanitizedEnterpriseHost ?? "" } + set { + self.updateProviderConfig(provider: .muse) { entry in + entry.enterpriseHost = self.normalizedConfigValue(newValue) + } + } + } + + func ensureMuseAPITokenLoaded() {} +} 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..19e14fbb07 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-muse.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 3930c48231..5a4bd13090 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -348,6 +348,31 @@ public enum BinaryLocator { home: home) } + public static func resolveMuseBinary( + env: [String: String] = ProcessInfo.processInfo.environment, + loginPATH: [String]? = LoginShellPathCache.shared.current, + commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, + aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator + .resolveAlias, + fileManager: FileManager = .default, + home: String = NSHomeDirectory()) -> String? + { + self.resolveBinary( + name: "muse", + overrideKey: "MUSE_CLI_PATH", + env: env, + loginPATH: loginPATH, + commandV: commandV, + aliasResolver: aliasResolver, + wellKnownPaths: [ + "\(home)/.local/bin/muse", + "/opt/homebrew/bin/muse", + "/usr/local/bin/muse", + ], + fileManager: fileManager, + home: home) + } + // swiftlint:disable function_parameter_count private static func resolveBinary( name: String, diff --git a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift new file mode 100644 index 0000000000..84c26856e0 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift @@ -0,0 +1,224 @@ +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: MuseSettingsReader.apiKey, + tokenAccountSupport: TokenAccountSupport( + title: "API keys", + subtitle: "Store multiple Muse API keys (META_API_KEY).", + placeholder: "Paste META_API_KEY…", + injection: .environment(key: MuseSettingsReader.apiKeyEnvironmentKeys[0]), + requiresManualCookieSource: false, + cookieName: nil), + missingCredentialMessage: { _ in MuseUsageError.missingCredentials.errorDescription }) + + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .muse, + credentials: self.credentials, + metadata: ProviderMetadata( + id: .muse, + displayName: "Muse", + shortDisplayName: "Muse", + sessionLabel: "Session", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Muse usage", + cliName: "muse", + defaultEnabled: false, + widgetSelectable: false, + isPrimaryProvider: false, + usesAccountFallback: false, + sharePlanLabels: [ + "free": "Free", + "pro": "Pro", + "team": "Team", + "enterprise": "Enterprise", + ], + dashboardURL: "https://dev.meta.ai", + subscriptionDashboardURL: "https://accountscenter.meta.com/muse_code/", + changelogURL: "https://github.com/meta/muse-code/releases", + statusPageURL: nil, + statusLinkURL: "https://developers.facebook.com/status/"), + branding: ProviderBranding( + iconStyle: .init(provider: .muse), + iconResourceName: "ProviderIcon-muse", + color: ProviderColor(red: 6 / 255, green: 104 / 255, blue: 225 / 255), + confettiPalette: [ + ProviderColor(hex: 0x0668E1), + ProviderColor(hex: 0x00AEFF), + ProviderColor(hex: 0xFFFFFF), + ], + burnDownWidgetColor: ProviderColor(red: 6 / 255, green: 104 / 255, blue: 225 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Muse cost summary is not yet available. Set META_API_KEY or run `muse login`." }), + fetchPlan: self.fetchPlan(), + cli: ProviderCLIConfig( + name: "muse", + aliases: ["muse-code"], + binaryLocator: { BinaryLocator.resolveMuseBinary() }, + versionDetector: { _ in Self.detectVersion() }, + supportsCostCommand: false)) + } + + private static func fetchPlan() -> ProviderFetchPlan { + ProviderFetchPlan( + sourceModes: [.auto, .api, .cli], + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)) + } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + // CLI strategy is fallback when no API key is present but CLI is installed. + let hasKey = MuseSettingsReader.apiKey(environment: context.env) != nil + let hasCLI = BinaryLocator.resolveMuseBinary() != nil + + switch context.sourceMode { + case .api: + return [MuseAPIFetchStrategy()] + case .cli: + return hasCLI ? [MuseCLIFetchStrategy()] : [] + case .auto: + if hasKey { + return [MuseAPIFetchStrategy()] + } + if hasCLI { + return [MuseCLIFetchStrategy()] + } + // Keep strategy available so missing-credentials surfaces as friendly error. + return [MuseAPIFetchStrategy()] + case .web, .oauth: + return [] + } + } + + private static func detectVersion() -> String? { + guard let binary = BinaryLocator.resolveMuseBinary() else { return nil } + let result = ShellCommand.run(binary, args: ["--version"], timeoutSeconds: 5) + guard result.exitCode == 0 else { return nil } + let output = (result.stdout + result.stderr).trimmingCharacters(in: .whitespacesAndNewlines) + return output.isEmpty ? nil : output + } +} + +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 { + // Always available so missing-credentials error is user-friendly. + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = MuseSettingsReader.apiKey(environment: context.env) else { + throw MuseUsageError.missingCredentials + } + let baseURL = MuseSettingsReader.baseURL(environment: context.env) + let snapshot = try await MuseUsageFetcher.fetchUsage( + apiKey: apiKey, + baseURL: baseURL, + transport: self.transport) + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +struct MuseCLIFetchStrategy: ProviderFetchStrategy { + let id = "muse.cli" + let kind: ProviderFetchKind = .cli + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + BinaryLocator.resolveMuseBinary() != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let binary = BinaryLocator.resolveMuseBinary() else { + throw MuseUsageError.missingCredentials + } + // Check that CLI is authenticated — `muse login` stores in Keychain. + // We do not parse quota from CLI yet; return identity-only snapshot + // that proves CLI is installed and reachable. + let versionResult = ShellCommand.run(binary, args: ["--version"], timeoutSeconds: 5) + let version = (versionResult.stdout + versionResult.stderr) + .trimmingCharacters(in: .whitespacesAndNewlines) + + let loginCheck = ShellCommand.run(binary, args: ["auth", "--help"], timeoutSeconds: 5) + let isAuthenticated = loginCheck.exitCode == 0 + + let snapshot = MuseUsageSnapshot( + primary: nil, + secondary: nil, + accountEmail: nil, + plan: isAuthenticated ? "Muse CLI (\(version))" : "CLI (not logged in)", + updatedAt: Date()) + + if !isAuthenticated, MuseSettingsReader.apiKey(environment: context.env) == nil { + throw MuseUsageError.missingCredentials + } + + return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "cli") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} + +/// Minimal shell helper local to Muse. +private enum ShellCommand { + struct Result { + let stdout: String + let stderr: String + let exitCode: Int32 + } + + static func run(_ executable: String, args: [String], timeoutSeconds: Int) -> Result { + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = args + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + return Result(stdout: "", stderr: error.localizedDescription, exitCode: 127) + } + + let timeout = DispatchTime.now() + .seconds(timeoutSeconds) + while process.isRunning, DispatchTime.now() < timeout { + Thread.sleep(forTimeInterval: 0.05) + } + if process.isRunning { + process.terminate() + return Result(stdout: "", stderr: "timed out", exitCode: 124) + } + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + return Result( + stdout: String(data: stdoutData, encoding: .utf8) ?? "", + stderr: String(data: stderrData, encoding: .utf8) ?? "", + exitCode: process.terminationStatus) + } +} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift b/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift new file mode 100644 index 0000000000..c771d0b863 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift @@ -0,0 +1,55 @@ +import Foundation + +public enum MuseSettingsReader { + public static let apiKeyEnvironmentKeys = ["META_API_KEY", "MUSE_API_KEY"] + public static let baseURLEnvironmentKey = "MUSE_BASE_URL" + public static let defaultBaseURL = URL(string: "https://api.meta.ai/v1")! + + public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + for key in self.apiKeyEnvironmentKeys { + if let value = self.cleaned(environment[key]) { + return value + } + } + return nil + } + + public static func baseURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let raw = self.cleaned(environment[self.baseURLEnvironmentKey]), + let url = URL(string: raw), url.scheme?.hasPrefix("http") == true + { + return url + } + return self.defaultBaseURL + } + + 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 + } +} + +public enum MuseUsageError: LocalizedError, Sendable, Equatable { + case missingCredentials + case invalidAPIKey + case networkError(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Muse API key not found. Set META_API_KEY or add a token account for Muse, or run `muse login`." + case .invalidAPIKey: + "Muse API key was rejected. Run `muse login` or set a valid META_API_KEY." + case let .networkError(message): + message + } + } +} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift b/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift new file mode 100644 index 0000000000..7e3f7678bd --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift @@ -0,0 +1,195 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum MuseUsageFetcher { + private static let requestTimeoutSeconds: TimeInterval = 15 + + public static func fetchUsage( + apiKey: String, + baseURL: URL = MuseSettingsReader.baseURL(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MuseUsageSnapshot + { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw MuseUsageError.missingCredentials + } + + // Try to fetch account/usage from Meta API. If the endpoint is not yet + // published or returns non-2xx, fall back to a minimal snapshot that + // proves the key is present. This keeps the provider useful on day one + // while allowing a real quota fetch once Meta publishes the endpoint. + if let snapshot = try await self.tryFetchQuota(apiKey: trimmed, baseURL: baseURL, transport: transport) { + return snapshot + } + + // Fallback: key is present but no quota endpoint responded. + // Return identity-only snapshot so the menu shows "API key configured". + return MuseUsageSnapshot( + primary: nil, + secondary: nil, + accountEmail: nil, + plan: "API Key", + updatedAt: Date()) + } + + private static func tryFetchQuota( + apiKey: String, + baseURL: URL, + transport: any ProviderHTTPTransport) async throws -> MuseUsageSnapshot? + { + // Candidate endpoints — Meta has not published a stable usage endpoint yet. + // We probe a small list and treat 404/501 as "not available". + let candidates = [ + baseURL.appendingPathComponent("usage"), + baseURL.appendingPathComponent("billing/usage"), + baseURL.appendingPathComponent("me"), + URL(string: "https://api.meta.ai/v1/usage")!, + ] + + for url in candidates { + do { + let snapshot = try await self.fetchFromURL(url, apiKey: apiKey, transport: transport) + if snapshot != nil { + return snapshot + } + } catch let error as MuseUsageError { + // Invalid key should surface immediately. + if case .invalidAPIKey = error { + throw error + } + continue + } catch { + continue + } + } + return nil + } + + private static func fetchFromURL( + _ url: URL, + apiKey: String, + transport: any ProviderHTTPTransport) async throws -> MuseUsageSnapshot? + { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = self.requestTimeoutSeconds + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodexBar/1.0 (Muse)", forHTTPHeaderField: "User-Agent") + + let (data, response) = try await transport.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw MuseUsageError.networkError("Invalid response") + } + + switch http.statusCode { + case 200...299: + break + case 401, 403: + throw MuseUsageError.invalidAPIKey + case 404, 501: + return nil + default: + throw MuseUsageError.networkError("HTTP \(http.statusCode)") + } + + // Try to parse a flexible JSON shape. We support multiple possible + // server schemas so we can adapt once Meta publishes the real one. + return self.parseSnapshot(data: data) + } + + static func parseSnapshot(data: Data) -> MuseUsageSnapshot? { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + // Common patterns: {usage:{...}}, {data:{...}}, or flat. + let root = (json["data"] as? [String: Any]) ?? json + let usage = (root["usage"] as? [String: Any]) ?? root + + var primary: RateWindow? + var secondary: RateWindow? + + if let session = usage["session"] as? [String: Any] ?? usage["five_hour"] as? [String: Any] { + primary = self.rateWindow(from: session, label: "Session") + } else if let used = usage["used_percent"] as? Double { + primary = RateWindow(usedPercent: used, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + } + + if let weekly = usage["weekly"] as? [String: Any] ?? usage["seven_day"] as? [String: Any] { + secondary = self.rateWindow(from: weekly, label: "Weekly") + } + + let email = (root["email"] as? String) ?? (root["account"] as? [String: Any])?["email"] as? String + let plan = (root["plan"] as? String) ?? (root["tier"] as? String) ?? (root["subscription"] as? String) + + // If we parsed nothing useful, return nil to try next endpoint. + if primary == nil, secondary == nil, email == nil, plan == nil { + // Check for flat balance style: {balance, limit} + if let balance = root["balance"] as? Double, let limit = root["limit"] as? Double, limit > 0 { + let used = max(0, min(100, ((limit - balance) / limit) * 100)) + primary = RateWindow(usedPercent: used, windowMinutes: nil, resetsAt: nil, resetDescription: nil) + return MuseUsageSnapshot(primary: primary, accountEmail: email, plan: plan, updatedAt: Date()) + } + return nil + } + + return MuseUsageSnapshot( + primary: primary, + secondary: secondary, + accountEmail: email, + plan: plan, + updatedAt: Date()) + } + + private static func rateWindow(from dict: [String: Any], label _: String) -> RateWindow? { + let used: Double? = (dict["used_percent"] as? Double) + ?? (dict["usedPercent"] as? Double) + ?? (dict["percent_used"] as? Double) + ?? { + if let used = dict["used"] as? Double, let limit = dict["limit"] as? Double, limit > 0 { + return (used / limit) * 100 + } + if let remaining = dict["remaining_percent"] as? Double { + return 100 - remaining + } + return nil + }() + + guard let percent = used else { return nil } + + var resetsAt: Date? + if let resetStr = dict["resets_at"] as? String ?? dict["resetsAt"] as? String { + resetsAt = ISO8601DateFormatter().date(from: resetStr) ?? Self.parseDate(resetStr) + } else if let resetInterval = dict["reset_in_seconds"] as? Double { + resetsAt = Date().addingTimeInterval(resetInterval) + } + + let resetDescription = dict["reset_description"] as? String ?? dict["resetDescription"] as? String + let windowMinutes = dict["window_minutes"] as? Int ?? dict["windowMinutes"] as? Int + + return RateWindow( + usedPercent: max(0, min(100, percent)), + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: resetDescription) + } + + private static func parseDate(_ string: String) -> Date? { + let formatters: [DateFormatter] = { + let f1 = DateFormatter() + f1.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + f1.locale = Locale(identifier: "en_US_POSIX") + let f2 = DateFormatter() + f2.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + f2.locale = Locale(identifier: "en_US_POSIX") + return [f1, f2] + }() + for f in formatters { + if let d = f.date(from: string) { return d } + } + return nil + } +} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift new file mode 100644 index 0000000000..b3126a69ff --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct MuseUsageSnapshot: Sendable { + public let primary: RateWindow? + public let secondary: RateWindow? + public let tertiary: RateWindow? + public let accountEmail: String? + public let plan: String? + public let updatedAt: Date + + public init( + primary: RateWindow? = nil, + secondary: RateWindow? = nil, + tertiary: RateWindow? = nil, + accountEmail: String? = nil, + plan: String? = nil, + updatedAt: Date = Date()) + { + self.primary = primary + self.secondary = secondary + self.tertiary = tertiary + self.accountEmail = accountEmail + self.plan = plan + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let identity = ProviderIdentitySnapshot( + providerID: .muse, + accountEmail: self.accountEmail, + accountOrganization: nil, + loginMethod: self.plan ?? "API Key") + return UsageSnapshot( + primary: self.primary, + secondary: self.secondary, + tertiary: self.tertiary, + updatedAt: self.updatedAt, + identity: identity) + } +} 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/docs/muse.md b/docs/muse.md new file mode 100644 index 0000000000..9f747ba6cf --- /dev/null +++ b/docs/muse.md @@ -0,0 +1,52 @@ +--- +summary: "Muse provider data sources: Meta API key (META_API_KEY) and CLI probe." +read_when: + - Debugging Muse usage/availability + - Updating Muse API endpoints + - Adjusting Muse CLI probe +--- + +# Muse provider + +Muse (Meta's terminal coding agent) is supported via API key or local CLI detection. Usage quotas are probed via the Meta API when available; until Meta publishes a stable usage endpoint, the provider shows API key / CLI authentication status. + +## Data sources + selection order + +- **Auto** (default): API (`META_API_KEY` / `MUSE_API_KEY` / token account) → CLI (`muse` binary). +- **API**: `META_API_KEY` or `MUSE_API_KEY` from environment, or a token account stored in `~/.codexbar/config.json`. +- **CLI**: local `muse` binary (`~/.local/bin/muse`, Homebrew, `/usr/local/bin/muse`, or `MUSE_CLI_PATH` override). Reports `muse --version` and checks `muse auth --help` reachability. + +Manual account tokens: add entries to `~/.codexbar/config.json` (`tokenAccounts`) with Muse API keys. Each account appears as a separate card when selected. + +## API key + +- Environment: `META_API_KEY` (preferred), `MUSE_API_KEY` (fallback). +- Config file: `~/.codexbar/config.json` → `providers[].apiKey` for instance `muse`, or `tokenAccounts`. +- CLI/env: `printf '%s' "$META_API_KEY" | codexbar config set-api-key --provider muse --stdin`. +- Base URL override: `MUSE_BASE_URL` (default `https://api.meta.ai/v1`). + +## CLI + +- Binary: `muse` (`muse --version` for version, `muse auth --help` for auth probe). +- Override: `MUSE_CLI_PATH`. +- Well-known paths: `~/.local/bin/muse`, `/opt/homebrew/bin/muse`, `/usr/local/bin/muse`. +- `muse login` stores credentials in macOS Keychain (`ai.meta.dev.credentials`, account `meta`). `META_API_KEY` always takes priority over the Keychain login. + +## Endpoints probed (API mode) + +When an API key is present, CodexBar probes candidate usage endpoints with `Authorization: Bearer `: + +- `{baseURL}/usage` +- `{baseURL}/billing/usage` +- `{baseURL}/me` +- `https://api.meta.ai/v1/usage` + +`200` responses are parsed as flexible JSON (`session`/`weekly` windows, `used_percent`/`limit`/`remaining_percent`, `email`/`plan`). `401`/`403` surface as invalid-key, `404`/`501` fall back to the next candidate. If no endpoint responds with usable data, the menu shows an identity-only card ("API Key") so the provider is visibly configured while the quota fetch remains best-effort. + +## Key files + +- Descriptor: `Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift` +- Settings: `Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift`, `Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift` +- Fetch: `Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift`, `Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift` +- Implementation: `Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift` +- Icon: `Sources/CodexBar/Resources/ProviderIcon-muse.svg` 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 3956c6fb4d..37c7197794 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) @@ -120,6 +120,7 @@ complete when the available scan window covers fewer days. | 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 `META_API_KEY`/`MUSE_API_KEY` or token accounts (`api`) → probing `api.meta.ai/v1/usage` candidates, falling back to CLI `muse --version`/`auth` reachability (`cli`). | ## Codex - App Auto: OAuth API first; falls back to CLI only when OAuth credentials are missing or auth/refresh is invalid. From 181e5c5dc9d9c69747e0744b8aaf6e9c630cbc8c Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Mon, 31 Aug 2026 20:56:24 -0700 Subject: [PATCH 2/5] Read Muse quota from documented rate-limit headers The Meta Model API publishes no usage, billing, or account endpoint, so the previous probe guessed at /usage, /billing/usage and /me and presented an "API Key" card whenever they failed. Replace the guesswork with the rate-limit headers Meta documents, read from one GET /v1/models so a refresh never spends tokens. - Derive tokens-per-minute and requests-per-minute windows from x-ratelimit-limit/remaining-tokens/requests. - Keep the credential on the configured host: drop the hardcoded api.meta.ai candidate, and validate MUSE_BASE_URL like every other provider endpoint instead of silently falling back to Meta. - Surface transport, server and endpoint failures instead of reporting a configured-and-healthy provider. - Read account identity from ~/.config/muse/auth.json, which muse login writes, rather than inferring login state from `muse auth --help` (it exits 0 whether or not anyone is logged in). Only the plaintext metadata is parsed, so no Keychain prompt is possible. - Drop the local process runner in favour of no CLI spawn at all, which also removes the Muse binaryLocator the architecture gatekeeper rejects. - Accept the documented MODEL_API_KEY alongside META_API_KEY, drop the invented MUSE_API_KEY, and fix the dashboard, changelog and status links. - Remove the unreachable museBaseURL setting and the empty token-load stub. - Add MuseProviderTests and refresh docs/muse.md. --- README.md | 2 +- .../Muse/MuseProviderImplementation.swift | 14 +- .../Providers/Muse/MuseSettingsStore.swift | 11 - Sources/CodexBarCore/PathEnvironment.swift | 25 -- .../Providers/Muse/MuseLocalAuthReader.swift | 75 ++++ .../Muse/MuseProviderDescriptor.swift | 146 ++----- .../Providers/Muse/MuseSettingsReader.swift | 45 +- .../Providers/Muse/MuseUsageFetcher.swift | 218 +++------- .../Providers/Muse/MuseUsageSnapshot.swift | 10 +- Tests/CodexBarTests/MuseProviderTests.swift | 389 ++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 4 +- docs/muse.md | 76 ++-- docs/providers.md | 2 +- 13 files changed, 677 insertions(+), 340 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Muse/MuseLocalAuthReader.swift create mode 100644 Tests/CodexBarTests/MuseProviderTests.swift diff --git a/README.md b/README.md index 91ee74b866..864c8c47c9 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [ZenMux](docs/zenmux.md) — Management API key for rolling five-hour and seven-day quota windows plus PAYG balance. - [xAI](docs/xai.md) — Management API key + team ID for prepaid credit balance and daily platform spend. - [IBM Bob](docs/ibm-bob.md) — API key for monthly Bobcoin budget and usage across subscription teams. -- [Muse](docs/muse.md) — API key (`META_API_KEY`) or local CLI (`muse login` / `muse auth`) for usage probing; falls back to CLI version check until Meta publishes a usage endpoint. +- [Muse](docs/muse.md) — API key (`META_API_KEY`/`MODEL_API_KEY`) for per-minute token and request quota from the Meta Model API rate-limit headers; `muse login` supplies account identity. - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot diff --git a/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift index 2757171f6f..40add198f7 100644 --- a/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift @@ -14,7 +14,6 @@ struct MuseProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.museAPIToken - _ = settings.museBaseURL } @MainActor @@ -22,11 +21,14 @@ struct MuseProviderImplementation: ProviderImplementation { if MuseSettingsReader.apiKey(environment: context.environment) != nil { return true } - if BinaryLocator.resolveMuseBinary() != nil { + if !context.settings.museAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } - context.settings.ensureMuseAPITokenLoaded() - return !context.settings.museAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + // A rejected MUSE_BASE_URL must still reach the fetch path so the override error is visible. + if MuseSettingsReader.hasBaseURLOverride(environment: context.environment) { + return true + } + return MuseLocalAuthReader.read() != nil } @MainActor @@ -35,7 +37,7 @@ struct MuseProviderImplementation: ProviderImplementation { ProviderSettingsFieldDescriptor( id: "muse-api-key", title: "API key", - subtitle: "Stored in ~/.codexbar/config.json. Paste META_API_KEY from https://dev.meta.ai or run `muse login`.", + subtitle: "Stored in ~/.codexbar/config.json. Create a key at https://dev.meta.ai, or run `muse login`.", kind: .secure, placeholder: "Paste META_API_KEY…", binding: context.stringBinding(\.museAPIToken), @@ -52,7 +54,7 @@ struct MuseProviderImplementation: ProviderImplementation { }), ], isVisible: nil, - onActivate: { context.settings.ensureMuseAPITokenLoaded() }), + onActivate: nil), ] } diff --git a/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift b/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift index 97f3a9a734..2d125fc256 100644 --- a/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift +++ b/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift @@ -11,15 +11,4 @@ extension SettingsStore { self.logSecretUpdate(provider: .muse, field: "apiKey", value: newValue) } } - - var museBaseURL: String { - get { self.configSnapshot.providerConfig(for: .muse)?.sanitizedEnterpriseHost ?? "" } - set { - self.updateProviderConfig(provider: .muse) { entry in - entry.enterpriseHost = self.normalizedConfigValue(newValue) - } - } - } - - func ensureMuseAPITokenLoaded() {} } diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 5a4bd13090..3930c48231 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -348,31 +348,6 @@ public enum BinaryLocator { home: home) } - public static func resolveMuseBinary( - env: [String: String] = ProcessInfo.processInfo.environment, - loginPATH: [String]? = LoginShellPathCache.shared.current, - commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, - aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator - .resolveAlias, - fileManager: FileManager = .default, - home: String = NSHomeDirectory()) -> String? - { - self.resolveBinary( - name: "muse", - overrideKey: "MUSE_CLI_PATH", - env: env, - loginPATH: loginPATH, - commandV: commandV, - aliasResolver: aliasResolver, - wellKnownPaths: [ - "\(home)/.local/bin/muse", - "/opt/homebrew/bin/muse", - "/usr/local/bin/muse", - ], - fileManager: fileManager, - home: home) - } - // swiftlint:disable function_parameter_count private static func resolveBinary( name: String, diff --git a/Sources/CodexBarCore/Providers/Muse/MuseLocalAuthReader.swift b/Sources/CodexBarCore/Providers/Muse/MuseLocalAuthReader.swift new file mode 100644 index 0000000000..46cf2767bb --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseLocalAuthReader.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Account metadata `muse login` writes beside its credential. +/// +/// The secret itself lives in the macOS Keychain (`storage: "keychain"`); this reader deliberately +/// only parses the plaintext metadata file, so resolving a Muse identity never issues a SecItem read +/// and never raises a Keychain prompt. +public struct MuseLocalAuth: Sendable, Equatable { + public let accountEmail: String? + public let accountName: String? + /// `oauth` for `muse login`, `api_key` for `muse auth set`. + public let mechanism: String? + public let apiBaseURL: URL? + + public init(accountEmail: String?, accountName: String?, mechanism: String?, apiBaseURL: URL?) { + self.accountEmail = accountEmail + self.accountName = accountName + self.mechanism = mechanism + self.apiBaseURL = apiBaseURL + } + + /// Human-readable login source for the identity card. + public var loginMethod: String { + switch self.mechanism { + case "oauth": "Meta account" + case "api_key": "API key" + default: "muse CLI" + } + } +} + +public enum MuseLocalAuthReader { + /// `~/.config/muse/auth.json`, written by `muse login` / `muse auth set`. + public static func defaultPath(home: String = NSHomeDirectory()) -> String { + "\(home)/.config/muse/auth.json" + } + + public static func read( + path: String? = nil, + home: String = NSHomeDirectory(), + fileManager: FileManager = .default) -> MuseLocalAuth? + { + let resolved = path ?? self.defaultPath(home: home) + guard fileManager.fileExists(atPath: resolved), + let data = fileManager.contents(atPath: resolved) + else { + return nil + } + return self.parse(data: data) + } + + static func parse(data: Data) -> MuseLocalAuth? { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let providers = root["providers"] as? [String: Any], + let meta = providers["meta"] as? [String: Any] + else { + return nil + } + + let baseURL = (meta["api_base_url"] as? String) + .flatMap { ProviderEndpointOverrideValidator().validatedURLAllowingPrivateNetworkHTTP($0) } + + let auth = MuseLocalAuth( + accountEmail: MuseSettingsReader.cleaned(meta["user_email"] as? String), + accountName: MuseSettingsReader.cleaned(meta["user_full_name"] as? String), + mechanism: MuseSettingsReader.cleaned(meta["mechanism"] as? String), + apiBaseURL: baseURL) + + // An entry with no usable field at all is the same as having no login. + if auth.accountEmail == nil, auth.accountName == nil, auth.mechanism == nil, auth.apiBaseURL == nil { + return nil + } + return auth + } +} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift index 84c26856e0..0743e17cd0 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift @@ -5,7 +5,6 @@ public enum MuseProviderDescriptor { private static let credentials = ProviderCredentialAdapter.apiKey( environmentKey: MuseSettingsReader.apiKeyEnvironmentKeys[0], - precedence: .environment, environmentHasValue: { MuseSettingsReader.apiKey(environment: $0) != nil }, resolve: MuseSettingsReader.apiKey, tokenAccountSupport: TokenAccountSupport( @@ -25,8 +24,8 @@ public enum MuseProviderDescriptor { id: .muse, displayName: "Muse", shortDisplayName: "Muse", - sessionLabel: "Session", - weeklyLabel: "Weekly", + sessionLabel: "Tokens", + weeklyLabel: "Requests", opusLabel: nil, supportsOpus: false, supportsCredits: false, @@ -37,17 +36,12 @@ public enum MuseProviderDescriptor { widgetSelectable: false, isPrimaryProvider: false, usesAccountFallback: false, - sharePlanLabels: [ - "free": "Free", - "pro": "Pro", - "team": "Team", - "enterprise": "Enterprise", - ], + sharePlanLabels: [:], dashboardURL: "https://dev.meta.ai", - subscriptionDashboardURL: "https://accountscenter.meta.com/muse_code/", - changelogURL: "https://github.com/meta/muse-code/releases", + subscriptionDashboardURL: "https://dev.meta.ai/docs/pricing-rate-limits", + changelogURL: "https://dev.meta.ai/docs/muse-code/changelog", statusPageURL: nil, - statusLinkURL: "https://developers.facebook.com/status/"), + statusLinkURL: nil), branding: ProviderBranding( iconStyle: .init(provider: .muse), iconResourceName: "ProviderIcon-muse", @@ -60,13 +54,13 @@ public enum MuseProviderDescriptor { burnDownWidgetColor: ProviderColor(red: 6 / 255, green: 104 / 255, blue: 225 / 255)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, - noDataMessage: { "Muse cost summary is not yet available. Set META_API_KEY or run `muse login`." }), + noDataMessage: { "Muse does not publish a cost endpoint. Set META_API_KEY or run `muse login`." }), fetchPlan: self.fetchPlan(), cli: ProviderCLIConfig( name: "muse", aliases: ["muse-code"], - binaryLocator: { BinaryLocator.resolveMuseBinary() }, - versionDetector: { _ in Self.detectVersion() }, + binaryLocator: nil, + versionDetector: nil, supportsCostCommand: false)) } @@ -77,36 +71,27 @@ public enum MuseProviderDescriptor { } private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { - // CLI strategy is fallback when no API key is present but CLI is installed. let hasKey = MuseSettingsReader.apiKey(environment: context.env) != nil - let hasCLI = BinaryLocator.resolveMuseBinary() != nil switch context.sourceMode { case .api: return [MuseAPIFetchStrategy()] case .cli: - return hasCLI ? [MuseCLIFetchStrategy()] : [] + return [MuseLocalFetchStrategy()] case .auto: + // Only the API key can produce quota; the local login supplies identity when it cannot. if hasKey { - return [MuseAPIFetchStrategy()] + return [MuseAPIFetchStrategy(), MuseLocalFetchStrategy()] } - if hasCLI { - return [MuseCLIFetchStrategy()] + if MuseLocalAuthReader.read() != nil { + return [MuseLocalFetchStrategy()] } - // Keep strategy available so missing-credentials surfaces as friendly error. + // No credentials anywhere: keep the API strategy so the miss surfaces as a friendly error. return [MuseAPIFetchStrategy()] case .web, .oauth: return [] } } - - private static func detectVersion() -> String? { - guard let binary = BinaryLocator.resolveMuseBinary() else { return nil } - let result = ShellCommand.run(binary, args: ["--version"], timeoutSeconds: 5) - guard result.exitCode == 0 else { return nil } - let output = (result.stdout + result.stderr).trimmingCharacters(in: .whitespacesAndNewlines) - return output.isEmpty ? nil : output - } } struct MuseAPIFetchStrategy: ProviderFetchStrategy { @@ -119,7 +104,7 @@ struct MuseAPIFetchStrategy: ProviderFetchStrategy { } func isAvailable(_ context: ProviderFetchContext) async -> Bool { - // Always available so missing-credentials error is user-friendly. + // Always available so a missing key surfaces as an actionable error rather than an empty menu. true } @@ -127,98 +112,57 @@ struct MuseAPIFetchStrategy: ProviderFetchStrategy { guard let apiKey = MuseSettingsReader.apiKey(environment: context.env) else { throw MuseUsageError.missingCredentials } - let baseURL = MuseSettingsReader.baseURL(environment: context.env) + let localAuth = MuseLocalAuthReader.read() + let baseURL = try MuseSettingsReader.baseURL(environment: context.env, localAuth: localAuth) let snapshot = try await MuseUsageFetcher.fetchUsage( apiKey: apiKey, baseURL: baseURL, + localAuth: localAuth, transport: self.transport) return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "api") } - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false + /// Fall back to the local identity only when the key itself is the problem; a network or endpoint + /// failure must stay visible instead of being papered over with an identity card. + func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { + guard let error = error as? MuseUsageError else { return false } + switch error { + case .missingCredentials, .invalidAPIKey: + return MuseLocalAuthReader.read() != nil + case .invalidEndpointOverride, .usageUnavailable, .networkError: + return false + } } } -struct MuseCLIFetchStrategy: ProviderFetchStrategy { - let id = "muse.cli" - let kind: ProviderFetchKind = .cli +/// Identity from the credential metadata `muse login` writes to `~/.config/muse/auth.json`. +/// +/// Muse exposes no non-interactive auth-status command (`muse auth` only offers `auth set`), so login +/// state is read from that file rather than inferred from a CLI exit code. Reporting quota is not +/// possible here: the rate-limit headers only accompany an authenticated API call. +struct MuseLocalFetchStrategy: ProviderFetchStrategy { + let id = "muse.local" + let kind: ProviderFetchKind = .localProbe func isAvailable(_ context: ProviderFetchContext) async -> Bool { - BinaryLocator.resolveMuseBinary() != nil + MuseLocalAuthReader.read() != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let binary = BinaryLocator.resolveMuseBinary() else { + guard let localAuth = MuseLocalAuthReader.read() else { throw MuseUsageError.missingCredentials } - // Check that CLI is authenticated — `muse login` stores in Keychain. - // We do not parse quota from CLI yet; return identity-only snapshot - // that proves CLI is installed and reachable. - let versionResult = ShellCommand.run(binary, args: ["--version"], timeoutSeconds: 5) - let version = (versionResult.stdout + versionResult.stderr) - .trimmingCharacters(in: .whitespacesAndNewlines) - - let loginCheck = ShellCommand.run(binary, args: ["auth", "--help"], timeoutSeconds: 5) - let isAuthenticated = loginCheck.exitCode == 0 - let snapshot = MuseUsageSnapshot( - primary: nil, - secondary: nil, - accountEmail: nil, - plan: isAuthenticated ? "Muse CLI (\(version))" : "CLI (not logged in)", + accountEmail: localAuth.accountEmail, + plan: localAuth.loginMethod, updatedAt: Date()) - - if !isAuthenticated, MuseSettingsReader.apiKey(environment: context.env) == nil { - throw MuseUsageError.missingCredentials - } - - return self.makeResult(usage: snapshot.toUsageSnapshot(), sourceLabel: "cli") + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "local", + diagnostic: "Muse reports quota only through API rate-limit headers; set META_API_KEY for usage.") } func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { false } } - -/// Minimal shell helper local to Muse. -private enum ShellCommand { - struct Result { - let stdout: String - let stderr: String - let exitCode: Int32 - } - - static func run(_ executable: String, args: [String], timeoutSeconds: Int) -> Result { - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = args - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - do { - try process.run() - } catch { - return Result(stdout: "", stderr: error.localizedDescription, exitCode: 127) - } - - let timeout = DispatchTime.now() + .seconds(timeoutSeconds) - while process.isRunning, DispatchTime.now() < timeout { - Thread.sleep(forTimeInterval: 0.05) - } - if process.isRunning { - process.terminate() - return Result(stdout: "", stderr: "timed out", exitCode: 124) - } - - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - return Result( - stdout: String(data: stdoutData, encoding: .utf8) ?? "", - stderr: String(data: stderrData, encoding: .utf8) ?? "", - exitCode: process.terminationStatus) - } -} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift b/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift index c771d0b863..54ff660a87 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift @@ -1,8 +1,12 @@ import Foundation public enum MuseSettingsReader { - public static let apiKeyEnvironmentKeys = ["META_API_KEY", "MUSE_API_KEY"] + /// `muse login --help` states "META_API_KEY always takes priority over the account login", and the + /// Meta Model API SDKs read `MODEL_API_KEY` (https://dev.meta.ai/docs/authentication). Both are + /// documented; nothing else is. + public static let apiKeyEnvironmentKeys = ["META_API_KEY", "MODEL_API_KEY"] public static let baseURLEnvironmentKey = "MUSE_BASE_URL" + /// https://dev.meta.ai/docs/overview — also what `muse login` records as `api_base_url`. public static let defaultBaseURL = URL(string: "https://api.meta.ai/v1")! public static func apiKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { @@ -14,16 +18,35 @@ public enum MuseSettingsReader { return nil } - public static func baseURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { - if let raw = self.cleaned(environment[self.baseURLEnvironmentKey]), - let url = URL(string: raw), url.scheme?.hasPrefix("http") == true - { + /// Resolves the endpoint the API key is sent to. + /// + /// The key travels to this host as a bearer token, so an override is validated like every other + /// provider endpoint: HTTPS anywhere, HTTP only for loopback/private-network gateways, never with + /// embedded credentials. An override that fails validation throws instead of silently falling back + /// to `api.meta.ai`, so a key meant for a private gateway is never disclosed to Meta. + public static func baseURL( + environment: [String: String] = ProcessInfo.processInfo.environment, + localAuth: MuseLocalAuth? = nil) throws -> URL + { + if let raw = self.cleaned(environment[self.baseURLEnvironmentKey]) { + guard let url = ProviderEndpointOverrideValidator().validatedURLAllowingPrivateNetworkHTTP(raw) else { + throw MuseUsageError.invalidEndpointOverride(raw) + } return url } - return self.defaultBaseURL + return localAuth?.apiBaseURL ?? self.defaultBaseURL + } + + /// True when an override is configured at all, even one that fails validation, so availability + /// checks still route to the fetch path and surface ``MuseUsageError/invalidEndpointOverride(_:)`` + /// instead of hiding the provider as unconfigured. + public static func hasBaseURLOverride( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + self.cleaned(environment[self.baseURLEnvironmentKey]) != nil } - private static func cleaned(_ raw: String?) -> String? { + static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } @@ -40,14 +63,20 @@ public enum MuseSettingsReader { public enum MuseUsageError: LocalizedError, Sendable, Equatable { case missingCredentials case invalidAPIKey + case invalidEndpointOverride(String) + case usageUnavailable case networkError(String) public var errorDescription: String? { switch self { case .missingCredentials: - "Muse API key not found. Set META_API_KEY or add a token account for Muse, or run `muse login`." + "Muse credentials not found. Set META_API_KEY, add an API key for Muse, or run `muse login`." case .invalidAPIKey: "Muse API key was rejected. Run `muse login` or set a valid META_API_KEY." + case let .invalidEndpointOverride(raw): + "MUSE_BASE_URL is not a usable endpoint: \(raw). Use HTTPS, or HTTP only for a private-network host." + case .usageUnavailable: + "Muse did not report rate-limit headers for this request." case let .networkError(message): message } diff --git a/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift b/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift index 7e3f7678bd..cf74ca7693 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift @@ -3,12 +3,31 @@ import Foundation import FoundationNetworking #endif +/// Reads Muse quota from the Meta Model API. +/// +/// The Model API publishes no usage or billing endpoint (see the endpoint list in +/// https://dev.meta.ai/docs/api-reference). What it does document is a set of rate-limit response +/// headers returned alongside successful responses, so quota is read from the headers of the +/// cheapest documented read-only call, `GET /v1/models`, rather than from a request that would +/// spend tokens. public enum MuseUsageFetcher { private static let requestTimeoutSeconds: TimeInterval = 15 + /// https://dev.meta.ai/docs/pricing-rate-limits + enum RateLimitHeader { + static let limitTokens = "x-ratelimit-limit-tokens" + static let remainingTokens = "x-ratelimit-remaining-tokens" + static let limitRequests = "x-ratelimit-limit-requests" + static let remainingRequests = "x-ratelimit-remaining-requests" + } + + /// Documented limits are per minute, per team. + private static let windowMinutes = 1 + public static func fetchUsage( apiKey: String, - baseURL: URL = MuseSettingsReader.baseURL(), + baseURL: URL = MuseSettingsReader.defaultBaseURL, + localAuth: MuseLocalAuth? = nil, transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MuseUsageSnapshot { let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) @@ -16,72 +35,23 @@ public enum MuseUsageFetcher { throw MuseUsageError.missingCredentials } - // Try to fetch account/usage from Meta API. If the endpoint is not yet - // published or returns non-2xx, fall back to a minimal snapshot that - // proves the key is present. This keeps the provider useful on day one - // while allowing a real quota fetch once Meta publishes the endpoint. - if let snapshot = try await self.tryFetchQuota(apiKey: trimmed, baseURL: baseURL, transport: transport) { - return snapshot - } - - // Fallback: key is present but no quota endpoint responded. - // Return identity-only snapshot so the menu shows "API key configured". - return MuseUsageSnapshot( - primary: nil, - secondary: nil, - accountEmail: nil, - plan: "API Key", - updatedAt: Date()) - } - - private static func tryFetchQuota( - apiKey: String, - baseURL: URL, - transport: any ProviderHTTPTransport) async throws -> MuseUsageSnapshot? - { - // Candidate endpoints — Meta has not published a stable usage endpoint yet. - // We probe a small list and treat 404/501 as "not available". - let candidates = [ - baseURL.appendingPathComponent("usage"), - baseURL.appendingPathComponent("billing/usage"), - baseURL.appendingPathComponent("me"), - URL(string: "https://api.meta.ai/v1/usage")!, - ] - - for url in candidates { - do { - let snapshot = try await self.fetchFromURL(url, apiKey: apiKey, transport: transport) - if snapshot != nil { - return snapshot - } - } catch let error as MuseUsageError { - // Invalid key should surface immediately. - if case .invalidAPIKey = error { - throw error - } - continue - } catch { - continue - } - } - return nil - } - - private static func fetchFromURL( - _ url: URL, - apiKey: String, - transport: any ProviderHTTPTransport) async throws -> MuseUsageSnapshot? - { - var request = URLRequest(url: url) + var request = URLRequest(url: baseURL.appendingPathComponent("models")) request.httpMethod = "GET" request.timeoutInterval = self.requestTimeoutSeconds - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue("CodexBar/1.0 (Muse)", forHTTPHeaderField: "User-Agent") - let (data, response) = try await transport.data(for: request) + let response: URLResponse + do { + (_, response) = try await transport.data(for: request) + } catch is CancellationError { + throw CancellationError() + } catch { + throw MuseUsageError.networkError(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse else { - throw MuseUsageError.networkError("Invalid response") + throw MuseUsageError.networkError("Muse returned an unexpected response.") } switch http.statusCode { @@ -89,107 +59,49 @@ public enum MuseUsageFetcher { break case 401, 403: throw MuseUsageError.invalidAPIKey - case 404, 501: - return nil default: - throw MuseUsageError.networkError("HTTP \(http.statusCode)") - } - - // Try to parse a flexible JSON shape. We support multiple possible - // server schemas so we can adapt once Meta publishes the real one. - return self.parseSnapshot(data: data) - } - - static func parseSnapshot(data: Data) -> MuseUsageSnapshot? { - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - - // Common patterns: {usage:{...}}, {data:{...}}, or flat. - let root = (json["data"] as? [String: Any]) ?? json - let usage = (root["usage"] as? [String: Any]) ?? root - - var primary: RateWindow? - var secondary: RateWindow? - - if let session = usage["session"] as? [String: Any] ?? usage["five_hour"] as? [String: Any] { - primary = self.rateWindow(from: session, label: "Session") - } else if let used = usage["used_percent"] as? Double { - primary = RateWindow(usedPercent: used, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - } - - if let weekly = usage["weekly"] as? [String: Any] ?? usage["seven_day"] as? [String: Any] { - secondary = self.rateWindow(from: weekly, label: "Weekly") - } - - let email = (root["email"] as? String) ?? (root["account"] as? [String: Any])?["email"] as? String - let plan = (root["plan"] as? String) ?? (root["tier"] as? String) ?? (root["subscription"] as? String) - - // If we parsed nothing useful, return nil to try next endpoint. - if primary == nil, secondary == nil, email == nil, plan == nil { - // Check for flat balance style: {balance, limit} - if let balance = root["balance"] as? Double, let limit = root["limit"] as? Double, limit > 0 { - let used = max(0, min(100, ((limit - balance) / limit) * 100)) - primary = RateWindow(usedPercent: used, windowMinutes: nil, resetsAt: nil, resetDescription: nil) - return MuseUsageSnapshot(primary: primary, accountEmail: email, plan: plan, updatedAt: Date()) - } - return nil + throw MuseUsageError.networkError("Muse usage request failed (HTTP \(http.statusCode)).") } + let windows = self.rateWindows(from: http) return MuseUsageSnapshot( - primary: primary, - secondary: secondary, - accountEmail: email, - plan: plan, + primary: windows.tokens, + secondary: windows.requests, + accountEmail: localAuth?.accountEmail, + plan: localAuth?.loginMethod ?? "API key", updatedAt: Date()) } - private static func rateWindow(from dict: [String: Any], label _: String) -> RateWindow? { - let used: Double? = (dict["used_percent"] as? Double) - ?? (dict["usedPercent"] as? Double) - ?? (dict["percent_used"] as? Double) - ?? { - if let used = dict["used"] as? Double, let limit = dict["limit"] as? Double, limit > 0 { - return (used / limit) * 100 - } - if let remaining = dict["remaining_percent"] as? Double { - return 100 - remaining - } - return nil - }() - - guard let percent = used else { return nil } + /// Maps the documented rate-limit headers onto token and request windows. + /// + /// A response without the headers yields no windows; the caller still has a verified-credential + /// identity to show, because the request itself succeeded. + static func rateWindows(from response: HTTPURLResponse) -> (tokens: RateWindow?, requests: RateWindow?) { + ( + tokens: self.window( + limit: self.headerValue(response, RateLimitHeader.limitTokens), + remaining: self.headerValue(response, RateLimitHeader.remainingTokens)), + requests: self.window( + limit: self.headerValue(response, RateLimitHeader.limitRequests), + remaining: self.headerValue(response, RateLimitHeader.remainingRequests))) + } - var resetsAt: Date? - if let resetStr = dict["resets_at"] as? String ?? dict["resetsAt"] as? String { - resetsAt = ISO8601DateFormatter().date(from: resetStr) ?? Self.parseDate(resetStr) - } else if let resetInterval = dict["reset_in_seconds"] as? Double { - resetsAt = Date().addingTimeInterval(resetInterval) + private static func headerValue(_ response: HTTPURLResponse, _ name: String) -> Double? { + guard let raw = response.value(forHTTPHeaderField: name)? + .trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty + else { + return nil } - - let resetDescription = dict["reset_description"] as? String ?? dict["resetDescription"] as? String - let windowMinutes = dict["window_minutes"] as? Int ?? dict["windowMinutes"] as? Int - - return RateWindow( - usedPercent: max(0, min(100, percent)), - windowMinutes: windowMinutes, - resetsAt: resetsAt, - resetDescription: resetDescription) + return Double(raw) } - private static func parseDate(_ string: String) -> Date? { - let formatters: [DateFormatter] = { - let f1 = DateFormatter() - f1.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - f1.locale = Locale(identifier: "en_US_POSIX") - let f2 = DateFormatter() - f2.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" - f2.locale = Locale(identifier: "en_US_POSIX") - return [f1, f2] - }() - for f in formatters { - if let d = f.date(from: string) { return d } - } - return nil + private static func window(limit: Double?, remaining: Double?) -> RateWindow? { + guard let limit, let remaining, limit > 0 else { return nil } + let used = ((limit - remaining) / limit) * 100 + return RateWindow( + usedPercent: max(0, min(100, used)), + windowMinutes: self.windowMinutes, + resetsAt: nil, + resetDescription: nil) } } diff --git a/Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift index b3126a69ff..e24b90ce97 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift @@ -1,9 +1,10 @@ import Foundation -public struct MuseUsageSnapshot: Sendable { +public struct MuseUsageSnapshot: Sendable, Equatable { + /// Tokens-per-minute window, from `x-ratelimit-*-tokens`. public let primary: RateWindow? + /// Requests-per-minute window, from `x-ratelimit-*-requests`. public let secondary: RateWindow? - public let tertiary: RateWindow? public let accountEmail: String? public let plan: String? public let updatedAt: Date @@ -11,14 +12,12 @@ public struct MuseUsageSnapshot: Sendable { public init( primary: RateWindow? = nil, secondary: RateWindow? = nil, - tertiary: RateWindow? = nil, accountEmail: String? = nil, plan: String? = nil, updatedAt: Date = Date()) { self.primary = primary self.secondary = secondary - self.tertiary = tertiary self.accountEmail = accountEmail self.plan = plan self.updatedAt = updatedAt @@ -29,11 +28,10 @@ public struct MuseUsageSnapshot: Sendable { providerID: .muse, accountEmail: self.accountEmail, accountOrganization: nil, - loginMethod: self.plan ?? "API Key") + loginMethod: self.plan) return UsageSnapshot( primary: self.primary, secondary: self.secondary, - tertiary: self.tertiary, updatedAt: self.updatedAt, identity: identity) } diff --git a/Tests/CodexBarTests/MuseProviderTests.swift b/Tests/CodexBarTests/MuseProviderTests.swift new file mode 100644 index 0000000000..9d51851d64 --- /dev/null +++ b/Tests/CodexBarTests/MuseProviderTests.swift @@ -0,0 +1,389 @@ +import Foundation +import Testing +@testable import CodexBarCore +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Muse reads quota from the rate-limit headers the Meta Model API documents at +/// https://dev.meta.ai/docs/pricing-rate-limits. The Model API publishes no usage, billing, or +/// account endpoint, so these tests pin the header mapping, the endpoint the key is sent to, and the +/// local identity file rather than any speculative JSON body. +struct MuseProviderTests { + private static let defaultHeaders = [ + "x-ratelimit-limit-tokens": "3000000", + "x-ratelimit-remaining-tokens": "2250000", + "x-ratelimit-limit-requests": "100", + "x-ratelimit-remaining-requests": "75", + ] + + // MARK: - Credentials + + @Test + func `api key prefers META_API_KEY and accepts the documented MODEL_API_KEY`() { + #expect(MuseSettingsReader.apiKey(environment: ["META_API_KEY": "meta-key"]) == "meta-key") + #expect(MuseSettingsReader.apiKey(environment: ["MODEL_API_KEY": "model-key"]) == "model-key") + #expect(MuseSettingsReader.apiKey( + environment: ["META_API_KEY": "meta-key", "MODEL_API_KEY": "model-key"]) == "meta-key") + #expect(MuseSettingsReader.apiKey(environment: [:]) == nil) + #expect(MuseSettingsReader.apiKey(environment: ["META_API_KEY": " "]) == nil) + } + + @Test + func `api key strips wrapping quotes copied out of a shell profile`() { + #expect(MuseSettingsReader.apiKey(environment: ["META_API_KEY": "\"quoted\""]) == "quoted") + #expect(MuseSettingsReader.apiKey(environment: ["META_API_KEY": "'quoted'"]) == "quoted") + } + + // MARK: - Endpoint override + + @Test + func `base URL defaults to the documented Meta host`() throws { + #expect(try MuseSettingsReader.baseURL(environment: [:]) == MuseSettingsReader.defaultBaseURL) + #expect(MuseSettingsReader.defaultBaseURL.absoluteString == "https://api.meta.ai/v1") + } + + @Test + func `base URL honours the local login's recorded host before the built-in default`() throws { + let localAuth = MuseLocalAuth( + accountEmail: nil, + accountName: nil, + mechanism: "oauth", + apiBaseURL: URL(string: "https://gateway.example.com/v1")) + let resolved = try MuseSettingsReader.baseURL(environment: [:], localAuth: localAuth) + #expect(resolved.absoluteString == "https://gateway.example.com/v1") + } + + @Test + func `base URL override accepts HTTPS and private-network HTTP`() throws { + let https = try MuseSettingsReader.baseURL(environment: ["MUSE_BASE_URL": "https://proxy.internal/v1"]) + #expect(https.absoluteString == "https://proxy.internal/v1") + + let loopback = try MuseSettingsReader.baseURL(environment: ["MUSE_BASE_URL": "http://127.0.0.1:8080/v1"]) + #expect(loopback.absoluteString == "http://127.0.0.1:8080/v1") + } + + @Test + func `base URL override rejects remote plaintext HTTP before the key is sent`() { + #expect(throws: MuseUsageError.invalidEndpointOverride("http://example.com/v1")) { + try MuseSettingsReader.baseURL(environment: ["MUSE_BASE_URL": "http://example.com/v1"]) + } + } + + /// A key scoped to a private gateway must never reach `api.meta.ai` because the override failed + /// validation; the fetch has to fail loudly instead of silently retargeting Meta. + @Test + func `rejected base URL override never falls back to the Meta host`() { + #expect(throws: MuseUsageError.self) { + try MuseSettingsReader.baseURL(environment: ["MUSE_BASE_URL": "ftp://example.com/v1"]) + } + #expect(MuseSettingsReader.hasBaseURLOverride(environment: ["MUSE_BASE_URL": "ftp://example.com/v1"])) + #expect(!MuseSettingsReader.hasBaseURLOverride(environment: [:])) + } + + // MARK: - Usage fetch + + @Test + func `fetch requests only the documented read-only models endpoint`() async throws { + let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: Self.defaultHeaders)]) + _ = try await MuseUsageFetcher.fetchUsage( + apiKey: "key", + baseURL: #require(URL(string: "https://api.meta.ai/v1")), + transport: transport) + + let requests = await transport.captured() + #expect(requests.count == 1) + #expect(requests[0].url == "https://api.meta.ai/v1/models") + #expect(requests[0].method == "GET") + #expect(requests[0].authorization == "Bearer key") + } + + @Test + func `fetch keeps the credential on the configured host`() async throws { + let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: Self.defaultHeaders)]) + _ = try await MuseUsageFetcher.fetchUsage( + apiKey: "gateway-key", + baseURL: #require(URL(string: "https://proxy.internal/v1")), + transport: transport) + + let hosts = await transport.captured().map(\.host) + #expect(hosts == ["proxy.internal"]) + #expect(!hosts.contains("api.meta.ai")) + } + + @Test + func `rate-limit headers map onto token and request windows`() async throws { + let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: Self.defaultHeaders)]) + let snapshot = try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: transport) + + let tokens = try #require(snapshot.primary) + #expect(tokens.usedPercent == 25) + #expect(tokens.windowMinutes == 1) + + let requests = try #require(snapshot.secondary) + #expect(requests.usedPercent == 25) + #expect(requests.windowMinutes == 1) + } + + @Test + func `usage percent stays clamped when a header reports more than the limit`() async throws { + let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: [ + "x-ratelimit-limit-tokens": "1000", + "x-ratelimit-remaining-tokens": "-500", + ])]) + let snapshot = try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: transport) + #expect(snapshot.primary?.usedPercent == 100) + } + + @Test + func `a response without rate-limit headers yields identity without inventing a window`() async throws { + let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: [:])]) + let localAuth = MuseLocalAuth( + accountEmail: "dev@example.com", + accountName: "Dev", + mechanism: "oauth", + apiBaseURL: nil) + let snapshot = try await MuseUsageFetcher.fetchUsage( + apiKey: "key", + localAuth: localAuth, + transport: transport) + + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + #expect(snapshot.accountEmail == "dev@example.com") + #expect(snapshot.plan == "Meta account") + } + + @Test + func `a zero or malformed limit produces no window`() async throws { + let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: [ + "x-ratelimit-limit-tokens": "0", + "x-ratelimit-remaining-tokens": "0", + "x-ratelimit-limit-requests": "not-a-number", + "x-ratelimit-remaining-requests": "5", + ])]) + let snapshot = try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: transport) + #expect(snapshot.primary == nil) + #expect(snapshot.secondary == nil) + } + + @Test + func `an empty API key fails before any request is made`() async { + let transport = MuseScriptedTransport(results: []) + await #expect(throws: MuseUsageError.missingCredentials) { + try await MuseUsageFetcher.fetchUsage(apiKey: " ", transport: transport) + } + #expect(await transport.captured().isEmpty) + } + + @Test + func `rejected credentials surface as an invalid key`() async { + for status in [401, 403] { + let transport = MuseScriptedTransport(results: [.response(statusCode: status, headers: [:])]) + await #expect(throws: MuseUsageError.invalidAPIKey) { + try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: transport) + } + } + } + + /// A server or transport failure must never be reported as a healthy configured provider. + @Test + func `server and transport failures surface instead of a placeholder snapshot`() async { + let serverError = MuseScriptedTransport(results: [.response(statusCode: 500, headers: [:])]) + await #expect(throws: MuseUsageError.networkError("Muse usage request failed (HTTP 500).")) { + try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: serverError) + } + + let notFound = MuseScriptedTransport(results: [.response(statusCode: 404, headers: [:])]) + await #expect(throws: MuseUsageError.networkError("Muse usage request failed (HTTP 404).")) { + try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: notFound) + } + + let offline = MuseScriptedTransport(results: [.failure(URLError(.notConnectedToInternet))]) + await #expect(throws: MuseUsageError.self) { + try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: offline) + } + } + + // MARK: - Local login metadata + + @Test + func `local auth reads the account recorded by muse login`() throws { + let json = """ + {"schema_version":2,"providers":{"meta":{"mechanism":"oauth","storage":"keychain",\ + "obtained_via":"device_code","api_base_url":"https://api.meta.ai/v1",\ + "user_full_name":"Ada Lovelace","user_email":"ada@example.com"}}} + """ + let data = try #require(json.data(using: .utf8)) + let auth = try #require(MuseLocalAuthReader.parse(data: data)) + #expect(auth.accountEmail == "ada@example.com") + #expect(auth.accountName == "Ada Lovelace") + #expect(auth.mechanism == "oauth") + #expect(auth.apiBaseURL?.absoluteString == "https://api.meta.ai/v1") + #expect(auth.loginMethod == "Meta account") + } + + @Test + func `local auth labels a stored API key distinctly from an account login`() throws { + let json = """ + {"schema_version":2,"providers":{"meta":{"mechanism":"api_key","storage":"keychain"}}} + """ + let data = try #require(json.data(using: .utf8)) + let auth = try #require(MuseLocalAuthReader.parse(data: data)) + #expect(auth.loginMethod == "API key") + #expect(auth.accountEmail == nil) + } + + @Test + func `local auth returns nothing for a logged-out or unusable file`() throws { + let empty = """ + {"schema_version":2,"providers":{}} + """ + let emptyData = try #require(empty.data(using: .utf8)) + #expect(MuseLocalAuthReader.parse(data: emptyData) == nil) + + let blankEntry = """ + {"schema_version":2,"providers":{"meta":{}}} + """ + let blankData = try #require(blankEntry.data(using: .utf8)) + #expect(MuseLocalAuthReader.parse(data: blankData) == nil) + #expect(MuseLocalAuthReader.parse(data: Data("not json".utf8)) == nil) + } + + @Test + func `local auth path is the documented muse config location`() { + #expect(MuseLocalAuthReader.defaultPath(home: "/Users/example") == "/Users/example/.config/muse/auth.json") + } + + @Test + func `local auth ignores a recorded base URL that fails endpoint validation`() throws { + let json = """ + {"schema_version":2,"providers":{"meta":{"mechanism":"oauth","api_base_url":"http://evil.example.com/v1"}}} + """ + let data = try #require(json.data(using: .utf8)) + let auth = try #require(MuseLocalAuthReader.parse(data: data)) + #expect(auth.apiBaseURL == nil) + } + + // MARK: - Snapshot mapping + + @Test + func `snapshot maps onto the shared usage snapshot under the Muse identity`() { + let snapshot = MuseUsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: 1, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 10, windowMinutes: 1, resetsAt: nil, resetDescription: nil), + accountEmail: "dev@example.com", + plan: "Meta account") + let usage = snapshot.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 40) + #expect(usage.secondary?.usedPercent == 10) + #expect(usage.identity?.providerID == .muse) + #expect(usage.identity?.accountEmail == "dev@example.com") + #expect(usage.identity?.loginMethod == "Meta account") + } + + // MARK: - Descriptor + + @Test + func `descriptor is registered with the documented Muse surfaces`() { + let descriptor = MuseProviderDescriptor.descriptor + #expect(descriptor.id == .muse) + #expect(descriptor.metadata.cliName == "muse") + #expect(descriptor.metadata.dashboardURL == "https://dev.meta.ai") + #expect(descriptor.metadata.changelogURL == "https://dev.meta.ai/docs/muse-code/changelog") + // Muse exposes no cost endpoint, and no version probe is spawned for it. + #expect(!descriptor.tokenCost.supportsTokenCost) + #expect(descriptor.cli.versionDetector == nil) + } + + @Test + func `fetch plan offers only the sources Muse actually supports`() { + let modes = MuseProviderDescriptor.descriptor.fetchPlan.sourceModes + #expect(modes.contains(.api)) + #expect(modes.contains(.cli)) + #expect(!modes.contains(.web)) + #expect(!modes.contains(.oauth)) + } + + /// A transport or endpoint failure must stay visible; only a credential problem may degrade to the + /// local identity card, and only when a login actually exists. + @Test + func `the API strategy never degrades a transport failure to an identity card`() { + let strategy = MuseAPIFetchStrategy() + let context = Self.makeContext(sourceMode: .api) + #expect(!strategy.shouldFallback(on: MuseUsageError.networkError("boom"), context: context)) + #expect(!strategy.shouldFallback(on: MuseUsageError.usageUnavailable, context: context)) + #expect(!strategy.shouldFallback( + on: MuseUsageError.invalidEndpointOverride("http://example.com"), + context: context)) + #expect(!strategy.shouldFallback(on: URLError(.timedOut), context: context)) + } + + private static func makeContext( + sourceMode: ProviderSourceMode, + env: [String: String] = [:]) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection) + } +} + +private actor MuseScriptedTransport: ProviderHTTPTransport { + enum Result { + case response(statusCode: Int, headers: [String: String]) + case failure(URLError) + } + + struct CapturedRequest { + let url: String? + let method: String? + let host: String? + let authorization: String? + } + + private var results: [Result] + private var capturedRequests: [CapturedRequest] = [] + + init(results: [Result]) { + self.results = results + } + + func captured() -> [CapturedRequest] { + self.capturedRequests + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + self.capturedRequests.append(CapturedRequest( + url: request.url?.absoluteString, + method: request.httpMethod, + host: request.url?.host, + authorization: request.value(forHTTPHeaderField: "Authorization"))) + + guard !self.results.isEmpty else { + throw URLError(.badServerResponse) + } + switch self.results.removeFirst() { + case let .response(statusCode, headers): + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://api.meta.ai/v1/models")!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headers)! + return (Data("{\"data\":[]}".utf8), response) + case let .failure(error): + throw error + } + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 38a228ac9e..74f85155cc 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 == 17_184_495_725_827_104_867) + #expect(burnDownFingerprint == 4_350_345_193_267_510_817) } @Test diff --git a/docs/muse.md b/docs/muse.md index 9f747ba6cf..df10983116 100644 --- a/docs/muse.md +++ b/docs/muse.md @@ -1,52 +1,76 @@ --- -summary: "Muse provider data sources: Meta API key (META_API_KEY) and CLI probe." +summary: "Muse provider data sources: Meta Model API rate-limit headers and the local muse login metadata." read_when: - Debugging Muse usage/availability - Updating Muse API endpoints - - Adjusting Muse CLI probe + - Adjusting the Muse local identity probe --- # Muse provider -Muse (Meta's terminal coding agent) is supported via API key or local CLI detection. Usage quotas are probed via the Meta API when available; until Meta publishes a stable usage endpoint, the provider shows API key / CLI authentication status. +Muse Code is Meta's terminal coding agent, backed by the Meta Model API. CodexBar reads its quota from +the rate-limit headers the Model API documents, and its account identity from the metadata `muse login` +writes to disk. -## Data sources + selection order +## Where the numbers come from -- **Auto** (default): API (`META_API_KEY` / `MUSE_API_KEY` / token account) → CLI (`muse` binary). -- **API**: `META_API_KEY` or `MUSE_API_KEY` from environment, or a token account stored in `~/.codexbar/config.json`. -- **CLI**: local `muse` binary (`~/.local/bin/muse`, Homebrew, `/usr/local/bin/muse`, or `MUSE_CLI_PATH` override). Reports `muse --version` and checks `muse auth --help` reachability. +The Meta Model API publishes **no usage, billing, credits, or account endpoint**. The documented +surface is `POST /v1/responses`, `POST /v1/chat/completions`, `POST /v1/messages`, `/v1/files`, +`GET /v1/models`, and `GET /v1/status` +([API reference](https://dev.meta.ai/docs/api-reference)). -Manual account tokens: add entries to `~/.codexbar/config.json` (`tokenAccounts`) with Muse API keys. Each account appears as a separate card when selected. +What it does document is a set of rate-limit response headers returned with successful responses +([pricing and rate limits](https://dev.meta.ai/docs/pricing-rate-limits)): -## API key +| Header | Window | +| --- | --- | +| `x-ratelimit-limit-tokens` / `x-ratelimit-remaining-tokens` | Tokens per minute, per team | +| `x-ratelimit-limit-requests` / `x-ratelimit-remaining-requests` | Requests per minute, per team | -- Environment: `META_API_KEY` (preferred), `MUSE_API_KEY` (fallback). -- Config file: `~/.codexbar/config.json` → `providers[].apiKey` for instance `muse`, or `tokenAccounts`. -- CLI/env: `printf '%s' "$META_API_KEY" | codexbar config set-api-key --provider muse --stdin`. -- Base URL override: `MUSE_BASE_URL` (default `https://api.meta.ai/v1`). +CodexBar therefore issues one `GET {baseURL}/models` — the cheapest documented read-only call, so a +refresh never spends tokens — and derives both windows from its response headers. Limits apply per +team, not per key. -## CLI +## Data sources + selection order -- Binary: `muse` (`muse --version` for version, `muse auth --help` for auth probe). -- Override: `MUSE_CLI_PATH`. -- Well-known paths: `~/.local/bin/muse`, `/opt/homebrew/bin/muse`, `/usr/local/bin/muse`. -- `muse login` stores credentials in macOS Keychain (`ai.meta.dev.credentials`, account `meta`). `META_API_KEY` always takes priority over the Keychain login. +- **Auto**: API when a key is present, otherwise the local login for identity only. +- **API**: `META_API_KEY` or `MODEL_API_KEY` from the environment, a token account, or the key stored + in `~/.codexbar/config.json`. This is the only source that can report quota. +- **CLI**: `~/.config/muse/auth.json`, written by `muse login` / `muse auth set`. Supplies the account + email and login method. It cannot report quota, because the rate-limit headers only accompany an + authenticated API request. -## Endpoints probed (API mode) +Muse exposes no non-interactive auth-status command — `muse auth` offers only `auth set` — so login +state is read from that file rather than inferred from a CLI exit code. Only the plaintext metadata is +parsed; the credential itself stays in the Keychain and is never read, so refreshing Muse never raises +a Keychain prompt. -When an API key is present, CodexBar probes candidate usage endpoints with `Authorization: Bearer `: +## API key + +- Environment: `META_API_KEY` (the Muse CLI honours this above its own login) or `MODEL_API_KEY` (the + variable the Meta Model API SDKs read). +- Config file: `~/.codexbar/config.json` → `providers[].apiKey` for instance `muse`, or `tokenAccounts`. +- CLI: `printf '%s' "$META_API_KEY" | codexbar config set-api-key --provider muse --stdin`. +- Base URL override: `MUSE_BASE_URL`. The key is sent to this host as a bearer token, so the override is + validated like every other provider endpoint — HTTPS anywhere, HTTP only for loopback and + private-network gateways, never with embedded credentials. An override that fails validation surfaces + an error; it never silently falls back to `api.meta.ai`. With no override, the base URL recorded by + `muse login` is used, then `https://api.meta.ai/v1`. -- `{baseURL}/usage` -- `{baseURL}/billing/usage` -- `{baseURL}/me` -- `https://api.meta.ai/v1/usage` +## Errors -`200` responses are parsed as flexible JSON (`session`/`weekly` windows, `used_percent`/`limit`/`remaining_percent`, `email`/`plan`). `401`/`403` surface as invalid-key, `404`/`501` fall back to the next candidate. If no endpoint responds with usable data, the menu shows an identity-only card ("API Key") so the provider is visibly configured while the quota fetch remains best-effort. +- `401`/`403` → invalid API key. +- Any other non-2xx, or a transport failure → a visible error. A failed refresh is never presented as a + configured-and-healthy provider. +- A `200` without rate-limit headers → identity only, with no invented usage window. The credential is + known good because the request succeeded. ## Key files -- Descriptor: `Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift` +- Descriptor and strategies: `Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift` - Settings: `Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift`, `Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift` +- Local login metadata: `Sources/CodexBarCore/Providers/Muse/MuseLocalAuthReader.swift` - Fetch: `Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift`, `Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift` - Implementation: `Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift` - Icon: `Sources/CodexBar/Resources/ProviderIcon-muse.svg` +- Tests: `Tests/CodexBarTests/MuseProviderTests.swift` diff --git a/docs/providers.md b/docs/providers.md index 37c7197794..f2ca8060ff 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -120,7 +120,7 @@ complete when the available scan window covers fewer days. | 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 `META_API_KEY`/`MUSE_API_KEY` or token accounts (`api`) → probing `api.meta.ai/v1/usage` candidates, falling back to CLI `muse --version`/`auth` reachability (`cli`). | +| Muse | API key `META_API_KEY`/`MODEL_API_KEY` or token accounts → `GET /v1/models` on the Meta Model API, reading the documented `x-ratelimit-*` headers for per-minute token and request windows (`api`); `~/.config/muse/auth.json` supplies account identity (`local`). | ## Codex - App Auto: OAuth API first; falls back to CLI only when OAuth credentials are missing or auth/refresh is invalid. From 825e7c84619bbbc26e8008f01e20f4b0fbb24e8e Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Mon, 31 Aug 2026 23:23:20 -0700 Subject: [PATCH 3/5] Read Muse token usage from local session logs Muse Code records every model turn to ~/.local/share/muse/sessions/////session.jsonl, so CodexBar can build the same local token history it already derives for Claude and Codex, with no network call, no credential and no Keychain access. This replaces the placeholder card with real numbers. Token semantics were checked against 1,431 recorded events: reasoning_tokens is a subset of output_tokens, and cached_tokens/cache_read_tokens are subsets of input_tokens and always equal each other. A turn therefore totals input + output; summing the cache or reasoning counters would double-count, in one sampled turn by 41,201 tokens against a 41,231-token input. The automated_review_completed shape carries its own total_tokens, which matched input + output in every observed event. Two record kinds also carry a usage object and are excluded: resource_usage_sampled holds CPU and RSS gauges, and workflow_child_lifecycle repeats a child's turns. An unrecognized kind carrying token counts downgrades coverage instead of vanishing from the totals. Scanning stays cheap on large trees. Day directories outside the history window are skipped unopened, lines without an input_tokens field are rejected before JSON parsing, and each file's size, mtime and per-day totals are cached so an unchanged log is never reread. On an 883 MB tree of 4,388 logs a cold scan took 16s and a warm scan 0.26s, for totals identical to an independent reference implementation. A scan that exhausts its budget keeps the files it finished, so the next refresh resumes. No quota is shown. Every usage, billing and account path returns 404 with a valid key, and the documented x-ratelimit-* headers ride only on billed inference responses, so reading them would spend tokens on every refresh and consume the limit being reported. The API key is now used only to validate itself against GET /v1/models. Also register Muse in the architecture gatekeeper, the token-account credential catalog and the dashboard cost contract, which the provider needed and did not have. --- README.md | 2 +- Sources/CodexBarCore/CostUsageFetcher.swift | 134 ++++- .../Providers/Muse/MuseLocalUsageCache.swift | 104 ++++ .../Providers/Muse/MuseLocalUsageReader.swift | 493 ++++++++++++++++++ .../Muse/MuseProviderDescriptor.swift | 5 +- .../Providers/Muse/MuseUsageFetcher.swift | 74 +-- .../MuseLocalUsageReaderTests.swift | 348 +++++++++++++ Tests/CodexBarTests/MuseProviderTests.swift | 67 +-- .../ProviderArchitectureGatekeeperTests.swift | 31 +- ...viderCredentialCharacterizationTests.swift | 1 + .../SpendDashboardModelTests.swift | 3 +- docs/muse.md | 87 +++- docs/providers.md | 2 +- 13 files changed, 1179 insertions(+), 172 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift create mode 100644 Sources/CodexBarCore/Providers/Muse/MuseLocalUsageReader.swift create mode 100644 Tests/CodexBarTests/MuseLocalUsageReaderTests.swift diff --git a/README.md b/README.md index 864c8c47c9..35f5cd08aa 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [ZenMux](docs/zenmux.md) — Management API key for rolling five-hour and seven-day quota windows plus PAYG balance. - [xAI](docs/xai.md) — Management API key + team ID for prepaid credit balance and daily platform spend. - [IBM Bob](docs/ibm-bob.md) — API key for monthly Bobcoin budget and usage across subscription teams. -- [Muse](docs/muse.md) — API key (`META_API_KEY`/`MODEL_API_KEY`) for per-minute token and request quota from the Meta Model API rate-limit headers; `muse login` supplies account identity. +- [Muse](docs/muse.md) — local session logs for daily token usage; `muse login` supplies account identity. Meta publishes no usage endpoint, so no quota is shown. - Open to new providers: [provider authoring guide](docs/provider.md). ## Icon & Screenshot diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index 5285b651e7..2cb012b3af 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -451,25 +451,23 @@ public struct CostUsageFetcher: Sendable { calendar: fallbackCalendar, historyCoverageIsEstablished: false) } - // Provider-specific by design: Antigravity uses recognized local stores without generic pricing or cache scans. - if provider == .antigravity { - if let local = try await self.loadAntigravityLocalSnapshot( - context: AntigravityLocalReader.Context(environment: environment), + // Provider-specific by design: Muse records durable session logs and publishes no usage endpoint. + if provider == .muse { + return try await self.museLocalSnapshotOrEmpty( + environment: environment, now: now, historyDays: clampedHistoryDays, - calendar: fallbackCalendar) - { - return local - } - if let remoteError { - throw remoteError - } - return Self.tokenSnapshot( - from: CostUsageDailyReport(data: [], summary: nil), + calendar: fallbackCalendar, + remoteError: remoteError) + } + // Provider-specific by design: Antigravity uses recognized local stores without generic pricing or cache scans. + if provider == .antigravity { + return try await self.antigravityLocalSnapshotOrEmpty( + environment: environment, now: now, historyDays: clampedHistoryDays, calendar: fallbackCalendar, - historyCoverageIsEstablished: false) + remoteError: remoteError) } if let remoteError { throw remoteError @@ -1267,6 +1265,114 @@ public struct CostUsageFetcher: Sendable { costProvenance: .unknown) } + /// Mirrors ``museLocalSnapshotOrEmpty`` for Antigravity's local stores. + private static func antigravityLocalSnapshotOrEmpty( + environment: [String: String], + now: Date, + historyDays: Int, + calendar: Calendar, + remoteError: (any Error)?) async throws -> CostUsageTokenSnapshot + { + if let local = try await self.loadAntigravityLocalSnapshot( + context: AntigravityLocalReader.Context(environment: environment), + now: now, + historyDays: historyDays, + calendar: calendar) + { + return local + } + if let remoteError { + throw remoteError + } + return Self.tokenSnapshot( + from: CostUsageDailyReport(data: [], summary: nil), + now: now, + historyDays: historyDays, + calendar: calendar, + historyCoverageIsEstablished: false) + } + + /// Muse's only usage source is local, so a missing read falls through to an empty snapshot rather + /// than to a remote retry. + private static func museLocalSnapshotOrEmpty( + environment: [String: String], + now: Date, + historyDays: Int, + calendar: Calendar, + remoteError: (any Error)?) async throws -> CostUsageTokenSnapshot + { + if let local = try await self.loadMuseLocalSnapshot( + context: MuseLocalUsageReader.Context(environment: environment), + now: now, + historyDays: historyDays, + calendar: calendar) + { + return local + } + if let remoteError { + throw remoteError + } + return Self.tokenSnapshot( + from: CostUsageDailyReport(data: [], summary: nil), + now: now, + historyDays: historyDays, + calendar: calendar, + historyCoverageIsEstablished: false) + } + + /// Builds a Muse token snapshot from the CLI's local session logs. + /// + /// Muse exposes no usage endpoint, so this is the provider's only quota-free data source. Costs + /// stay `nil`: the logs record tokens, not billed amounts, and Meta prices per tier. + private static func loadMuseLocalSnapshot( + context: MuseLocalUsageReader.Context, + now: Date, + historyDays: Int, + cacheRoot: URL? = nil, + calendar: Calendar = .current) async throws -> CostUsageTokenSnapshot? + { + let cal = calendar + let windowStart = cal.date(byAdding: .day, value: -(historyDays - 1), to: cal.startOfDay(for: now)) ?? now + let sinceDayKey = CostUsageLocalDay.key(from: windowStart, calendar: cal) + let reportResult = try await CostUsageScanExecutor.run { checkCancellation in + try MuseLocalUsageReader.makeDailyReportWithStatus( + context: context, + calendar: cal, + sinceDayKey: sinceDayKey, + cacheRoot: cacheRoot, + checkCancellation: checkCancellation) + } + guard reportResult.isAvailable else { return nil } + let report = reportResult.report + if report.data.isEmpty { + guard reportResult.isComplete else { return nil } + return Self.tokenSnapshot( + from: CostUsageDailyReport(data: [], summary: nil), + now: now, + historyDays: historyDays, + useCurrentLocalDayForSession: true, + calendar: cal, + historyCoverageIsEstablished: true, + costProvenance: .unknown) + } + let nowKey = CostUsageLocalDay.key(from: now, calendar: cal) + let filtered = report.data.filter { $0.date >= sinceDayKey && $0.date <= nowKey } + let totalTokens = MuseLocalUsageReader.checkedSum(filtered.compactMap(\.totalTokens)) + let filteredSummary: CostUsageDailyReport.Summary? = filtered.isEmpty ? nil : .init( + totalInputTokens: MuseLocalUsageReader.checkedSum(filtered.compactMap(\.inputTokens)), + totalOutputTokens: MuseLocalUsageReader.checkedSum(filtered.compactMap(\.outputTokens)), + totalTokens: totalTokens, + totalCostUSD: nil) + return Self.tokenSnapshot( + from: CostUsageDailyReport(data: filtered, summary: filteredSummary), + now: now, + historyDays: historyDays, + useCurrentLocalDayForSession: true, + calendar: cal, + historyCoverageIsEstablished: reportResult.isComplete, + costProvenance: .unknown) + } + static func tokenSnapshot( from daily: CostUsageDailyReport, now: Date, diff --git a/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift new file mode 100644 index 0000000000..89d2256de1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Per-file scan cache for Muse session logs. +/// +/// Session logs are append-only and dominated by telemetry the reader discards, so re-reading an +/// unchanged file on every refresh is pure waste. Each entry stores the file's size and modification +/// time alongside the per-day totals it contributed; a file whose size and mtime both match is reused +/// without opening it, turning a full rescan into a stat of each path. +struct MuseLocalUsageCache: Codable { + struct DayTotals: Codable, Equatable { + var inputTokens: Int + var outputTokens: Int + var cacheReadTokens: Int + var cacheWriteTokens: Int + var reasoningTokens: Int + var totalTokens: Int + var requestCount: Int + var models: [String: Int] + + init( + inputTokens: Int = 0, + outputTokens: Int = 0, + cacheReadTokens: Int = 0, + cacheWriteTokens: Int = 0, + reasoningTokens: Int = 0, + totalTokens: Int = 0, + requestCount: Int = 0, + models: [String: Int] = [:]) + { + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.cacheReadTokens = cacheReadTokens + self.cacheWriteTokens = cacheWriteTokens + self.reasoningTokens = reasoningTokens + self.totalTokens = totalTokens + self.requestCount = requestCount + self.models = models + } + } + + struct FileEntry: Codable { + var size: Int + var modifiedAtMs: Int64 + /// Event ids this file contributed, so a turn copied into a second log is still counted once. + var eventIDs: [String] + var days: [String: DayTotals] + var isComplete: Bool + } + + var version: Int + var timeZoneIdentifier: String? + var files: [String: FileEntry] = [:] +} + +enum MuseLocalUsageCacheIO { + /// Artifact schema version; bump when the parser or the stored shape changes. + private static let artifactVersion = 1 + + private static func defaultCacheRoot() -> URL { + let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + return root.appendingPathComponent("CodexBar", isDirectory: true) + } + + static func cacheFileURL(cacheRoot: URL? = nil) -> URL { + let root = cacheRoot ?? self.defaultCacheRoot() + return root + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("muse-sessions-v\(Self.artifactVersion).json", isDirectory: false) + } + + static func load(cacheRoot: URL? = nil, calendar: Calendar = .current) -> MuseLocalUsageCache { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(MuseLocalUsageCache.self, from: data), + decoded.version == Self.artifactVersion, + // Day keys are timezone-dependent, so a moved machine must rebucket from scratch. + decoded.timeZoneIdentifier == calendar.timeZone.identifier + else { + return MuseLocalUsageCache(version: Self.artifactVersion) + } + return decoded + } + + static func save(cache: MuseLocalUsageCache, cacheRoot: URL? = nil, calendar: Calendar = .current) { + let url = self.cacheFileURL(cacheRoot: cacheRoot) + let dir = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + var cache = cache + cache.timeZoneIdentifier = calendar.timeZone.identifier + let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) + guard let data = try? JSONEncoder().encode(cache) else { return } + do { + try data.write(to: tmp, options: [.atomic]) + if FileManager.default.fileExists(atPath: url.path) { + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } else { + try FileManager.default.moveItem(at: tmp, to: url) + } + } catch { + try? FileManager.default.removeItem(at: tmp) + } + } +} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageReader.swift b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageReader.swift new file mode 100644 index 0000000000..d1ff0907d2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageReader.swift @@ -0,0 +1,493 @@ +import Foundation + +/// Reads Muse token usage from the durable session logs the CLI writes locally. +/// +/// The Meta Model API publishes no usage or billing endpoint, and its documented `x-ratelimit-*` +/// headers ride only on billed inference responses. Muse Code does, however, record every model turn +/// to `~/.local/share/muse/sessions///
//session.jsonl`, which gives CodexBar the +/// same local token history it already derives for Claude and Codex — with no network call, no +/// credential, and no Keychain access. +/// +/// Token semantics were verified against 1,431 recorded events: `reasoning_tokens` is a subset of +/// `output_tokens`, and `cached_tokens`/`cache_read_tokens` are subsets of `input_tokens` (and are +/// always equal to each other). A turn therefore totals `input_tokens + output_tokens`; adding the +/// cache or reasoning counters would double-count, in one sampled turn by 41,201 tokens against a +/// 41,231-token input. The `automated_review_completed` shape carries its own `total_tokens`, which +/// matched `input + output` in every observed event. +enum MuseLocalUsageReader { + enum Coverage: Sendable { + case complete + case partial + case unavailable + } + + struct DailyReportResult: Sendable { + let report: CostUsageDailyReport + let coverage: Coverage + + var isAvailable: Bool { + self.coverage != .unavailable + } + + var isComplete: Bool { + self.coverage == .complete + } + } + + struct Context: Sendable { + let sessionsRoot: URL + + init(environment: [String: String]) { + let home = environment["HOME"].map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? FileManager.default.homeDirectoryForCurrentUser + let dataHome = environment["XDG_DATA_HOME"].flatMap { $0.isEmpty ? nil : $0 } + .map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? home.appendingPathComponent(".local/share", isDirectory: true) + self.sessionsRoot = dataHome.appendingPathComponent("muse/sessions", isDirectory: true) + } + + init(sessionsRoot: URL) { + self.sessionsRoot = sessionsRoot + } + } + + struct Limits: Sendable { + var files = 20000 + var lineBytes = 4 * 1024 * 1024 + var fileBytes = 256 * 1024 * 1024 + var totalBytes = 2 * 1024 * 1024 * 1024 + /// The bulk of a session log is `resource_usage_sampled` telemetry rather than model turns, so a + /// busy tree reaches hundreds of megabytes. Day pruning and the file cache keep the usual scan + /// far inside this ceiling; a first scan that does exhaust it keeps the files it finished, so + /// the next refresh resumes instead of restarting. + var duration: TimeInterval = 30 + } + + enum ScanFailure: Error { + case exhausted + } + + /// One budget per executor job, so a huge session tree degrades to partial coverage instead of + /// blocking a refresh. The tree is routinely gigabytes across thousands of files. + final class Budget { + let limits: Limits + private let cancellation: () throws -> Void + private let clock: () -> TimeInterval + private let started: TimeInterval + private(set) var files = 0 + private(set) var bytes = 0 + + init( + limits: Limits, + clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + cancellation: @escaping () throws -> Void) + { + self.limits = limits + self.clock = clock + self.started = clock() + self.cancellation = cancellation + } + + func check() throws { + try self.cancellation() + guard self.clock() - self.started < self.limits.duration else { throw ScanFailure.exhausted } + } + + func chargeFile(_ count: Int) throws { + try self.check() + self.files += 1 + guard self.files <= self.limits.files else { throw ScanFailure.exhausted } + let (total, overflow) = self.bytes.addingReportingOverflow(count) + self.bytes = overflow ? Int.max : total + guard self.bytes <= self.limits.totalBytes else { throw ScanFailure.exhausted } + } + } + + /// One recorded model turn. + struct Event: Equatable { + let id: String + let recordedAt: Date + let model: String + let inputTokens: Int + let outputTokens: Int + let cacheReadTokens: Int + let cacheWriteTokens: Int + let reasoningTokens: Int + let totalTokens: Int + } + + /// Event kinds that carry a `usage` object. Only the two inference kinds are counted. + /// + /// `resource_usage_sampled` reuses the `usage` key for CPU and RSS telemetry, and + /// `workflow_child_lifecycle` reports a child workflow's rollup whose turns are recorded on their + /// own; counting either would corrupt the totals. Any other kind carrying token counts is unknown + /// drift and downgrades coverage rather than being silently dropped. + private static let countedKinds: Set = ["model_completed", "automated_review_completed"] + private static let ignoredKinds: Set = ["resource_usage_sampled", "workflow_child_lifecycle"] + + /// Byte pattern every token-bearing record carries, used to skip JSON parsing on lines that + /// cannot hold token counts. + /// + /// Session logs are dominated by `resource_usage_sampled` telemetry, whose `usage` object holds CPU + /// and RSS gauges and no `input_tokens`: on a sampled 883 MB tree, 4,393 such records accompanied + /// only 1,235 model turns. Filtering on the token field rather than on the known kinds keeps an + /// unrecognized future kind reaching the parser, so drift still downgrades coverage. + private static let tokenFieldPattern = Data("\"input_tokens\"".utf8) + + static func makeDailyReportWithStatus( + context: Context, + calendar: Calendar = .current, + sinceDayKey: String? = nil, + cacheRoot: URL? = nil, + limits: Limits = Limits(), + clock: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + checkCancellation: @escaping () throws -> Void = {}) throws -> DailyReportResult + { + let budget = Budget(limits: limits, clock: clock, cancellation: checkCancellation) + var cache = MuseLocalUsageCacheIO.load(cacheRoot: cacheRoot, calendar: calendar) + var isComplete = true + var scannedPaths = Set() + var seenEventIDs = Set() + var days: [String: MuseLocalUsageCache.DayTotals] = [:] + + do { + let discovery = try self.discoverSessionLogs( + root: context.sessionsRoot, sinceDayKey: sinceDayKey, budget: budget) + guard !discovery.urls.isEmpty || !discovery.isComplete else { + return DailyReportResult(report: .init(data: [], summary: nil), coverage: .unavailable) + } + isComplete = discovery.isComplete + + for url in discovery.urls { + try budget.check() + scannedPaths.insert(url.path) + let entry = try self.fileEntry(url: url, cache: cache, calendar: calendar, budget: budget) + cache.files[url.path] = entry + if !entry.isComplete { isComplete = false } + self.accumulate(entry: entry, into: &days, seenEventIDs: &seenEventIDs) + } + } catch ScanFailure.exhausted { + // Keep what was aggregated before the budget ran out; discarding it would report a busy + // tree as "no usage" rather than as incomplete usage. The cache keeps the finished files so + // the next refresh resumes instead of restarting. + isComplete = false + } + + // Drop files that disappeared, but only when the scan actually completed; a budget stop leaves + // paths unvisited and must not evict them. + if isComplete { + for path in cache.files.keys where !scannedPaths.contains(path) { + cache.files.removeValue(forKey: path) + } + } + MuseLocalUsageCacheIO.save(cache: cache, cacheRoot: cacheRoot, calendar: calendar) + return self.result(days: days, isComplete: isComplete) + } + + /// Reuses a cached entry when the file's size and modification time both match, otherwise reparses. + private static func fileEntry( + url: URL, + cache: MuseLocalUsageCache, + calendar: Calendar, + budget: Budget) throws -> MuseLocalUsageCache.FileEntry + { + let values = try? url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + let size = values?.fileSize ?? -1 + let modifiedAtMs = values?.contentModificationDate.map { Int64($0.timeIntervalSince1970 * 1000) } ?? -1 + if let cached = cache.files[url.path], cached.size == size, cached.modifiedAtMs == modifiedAtMs { + return cached + } + + let parsed = try self.parseSessionLog(url: url, budget: budget) + var days: [String: MuseLocalUsageCache.DayTotals] = [:] + var eventIDs: [String] = [] + for event in parsed.events { + eventIDs.append(event.id) + let key = CostUsageLocalDay.key(from: event.recordedAt, calendar: calendar) + var totals = days[key] ?? MuseLocalUsageCache.DayTotals() + totals.inputTokens += event.inputTokens + totals.outputTokens += event.outputTokens + totals.cacheReadTokens += event.cacheReadTokens + totals.cacheWriteTokens += event.cacheWriteTokens + totals.reasoningTokens += event.reasoningTokens + totals.totalTokens += event.totalTokens + totals.requestCount += 1 + totals.models[event.model, default: 0] += event.totalTokens + days[key] = totals + } + return MuseLocalUsageCache.FileEntry( + size: size, + modifiedAtMs: modifiedAtMs, + eventIDs: eventIDs, + days: days, + isComplete: parsed.isComplete) + } + + /// Adds a file's cached totals, skipping any turn already contributed by another log. + private static func accumulate( + entry: MuseLocalUsageCache.FileEntry, + into days: inout [String: MuseLocalUsageCache.DayTotals], + seenEventIDs: inout Set) + { + // The record id is unique per durable event, so a copied log cannot double-count. + let isDuplicate = entry.eventIDs.contains { seenEventIDs.contains($0) } + for id in entry.eventIDs { + seenEventIDs.insert(id) + } + guard !isDuplicate else { return } + + for (day, totals) in entry.days { + var merged = days[day] ?? MuseLocalUsageCache.DayTotals() + merged.inputTokens += totals.inputTokens + merged.outputTokens += totals.outputTokens + merged.cacheReadTokens += totals.cacheReadTokens + merged.cacheWriteTokens += totals.cacheWriteTokens + merged.reasoningTokens += totals.reasoningTokens + merged.totalTokens += totals.totalTokens + merged.requestCount += totals.requestCount + for (model, tokens) in totals.models { + merged.models[model, default: 0] += tokens + } + days[day] = merged + } + } + + private static func result( + days: [String: MuseLocalUsageCache.DayTotals], + isComplete: Bool) -> DailyReportResult + { + let daily = days.map { date, totals in + CostUsageDailyReport.Entry( + date: date, + inputTokens: totals.inputTokens, + outputTokens: totals.outputTokens, + cacheReadTokens: totals.cacheReadTokens, + cacheCreationTokens: totals.cacheWriteTokens, + reasoningTokens: totals.reasoningTokens, + totalTokens: totals.totalTokens, + requestCount: totals.requestCount, + costUSD: nil, + modelsUsed: nil, + modelBreakdowns: totals.models.keys.sorted().map { model in + .init(modelName: model, costUSD: nil, totalTokens: totals.models[model], requestCount: nil) + }) + }.sorted { $0.date < $1.date } + return DailyReportResult( + report: .init( + data: daily, + summary: daily.isEmpty ? nil : .init( + totalInputTokens: self.checkedSum(daily.compactMap(\.inputTokens)), + totalOutputTokens: self.checkedSum(daily.compactMap(\.outputTokens)), + totalTokens: self.checkedSum(daily.compactMap(\.totalTokens)), + totalCostUSD: nil)), + coverage: isComplete ? .complete : .partial) + } + + // MARK: - Discovery + + private struct Discovery { + var urls: [URL] = [] + var isComplete = true + } + + /// Enumerates `///
//session.jsonl`. + /// + /// Only the date-partitioned tree is walked. Sibling stores such as `.msp-view-v1` hold snapshots + /// and indexes, never a session log, so skipping dot directories cannot lose a turn. + private static func discoverSessionLogs( + root: URL, + sinceDayKey: String?, + budget: Budget) throws -> Discovery + { + var discovery = Discovery() + let fileManager = FileManager.default + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: root.path, isDirectory: &isDirectory), isDirectory.boolValue else { + return discovery + } + + func children(of url: URL) throws -> [URL] { + try budget.check() + let contents = try? fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + guard let contents else { + discovery.isComplete = false + return [] + } + return contents.sorted { $0.path < $1.path } + } + + for year in try children(of: root) { + for month in try children(of: year) { + for day in try children(of: month) { + // The tree is partitioned as YYYY/MM/DD, so whole days outside the requested + // history window are skipped without opening a single log. + if let sinceDayKey, let dayKey = Self.dayKey( + year: year.lastPathComponent, + month: month.lastPathComponent, + day: day.lastPathComponent), + dayKey < sinceDayKey + { + continue + } + for session in try children(of: day) { + try budget.check() + let log = session.appendingPathComponent("session.jsonl", isDirectory: false) + if fileManager.fileExists(atPath: log.path) { + discovery.urls.append(log) + } + } + } + } + } + return discovery + } + + /// Rebuilds the `YYYY-MM-DD` key from the tree's path components, or nil when they are not the + /// expected numeric segments (in which case the directory is scanned rather than skipped). + static func dayKey(year: String, month: String, day: String) -> String? { + guard year.count == 4, month.count == 2, day.count == 2, + year.allSatisfy(\.isNumber), month.allSatisfy(\.isNumber), day.allSatisfy(\.isNumber) + else { + return nil + } + return "\(year)-\(month)-\(day)" + } + + // MARK: - Parsing + + private struct ParsedLog { + var events: [Event] = [] + var isComplete = true + } + + private static func parseSessionLog(url: URL, budget: Budget) throws -> ParsedLog { + var parsed = ParsedLog() + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + guard size <= budget.limits.fileBytes else { + parsed.isComplete = false + return parsed + } + try budget.chargeFile(size) + + guard let data = try? Data(contentsOf: url, options: .mappedIfSafe) else { + parsed.isComplete = false + return parsed + } + + for line in data.split(separator: 0x0A, omittingEmptySubsequences: true) { + try budget.check() + guard line.count <= budget.limits.lineBytes else { + parsed.isComplete = false + continue + } + let lineData = Data(line) + guard Self.mayContainTokenCounts(lineData) else { continue } + switch self.parseLine(lineData) { + case let .event(event): + parsed.events.append(event) + case .ignored: + continue + case .unrecognized: + parsed.isComplete = false + } + } + return parsed + } + + /// Cheap rejection for lines that cannot carry token counts. + static func mayContainTokenCounts(_ data: Data) -> Bool { + data.range(of: self.tokenFieldPattern) != nil + } + + enum LineResult: Equatable { + case event(Event) + case ignored + case unrecognized + } + + static func parseLine(_ data: Data) -> LineResult { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .unrecognized + } + // Fail closed on a schema the shapes below were not verified against. + guard object["schema_version"] as? Int == 1, + object["record_type"] as? String == "event", + object["payload_type"] as? String == "runtime.session", + object["payload_schema_version"] as? Int == 1 + else { + return .ignored + } + guard let payload = object["payload"] as? [String: Any], + let event = payload["event"] as? [String: Any], + let usage = event["usage"] as? [String: Any] + else { + return .ignored + } + + let kind = event["kind"] as? String ?? "" + if self.ignoredKinds.contains(kind) { return .ignored } + guard self.countedKinds.contains(kind) else { + // An unknown kind with token counts is drift worth surfacing as partial coverage. + return usage["input_tokens"] is Int ? .unrecognized : .ignored + } + + guard let id = object["id"] as? String, !id.isEmpty, + let recordedAt = object["recorded_at"] as? Int, + let input = usage["input_tokens"] as? Int, + let output = usage["output_tokens"] as? Int, + input >= 0, output >= 0, + let total = self.checkedAdd(input, output) + else { + return .unrecognized + } + + // `model` is a plain id for a model turn and a descriptor object for an automated review. + let model: String = if let name = event["model"] as? String { + name + } else if let descriptor = event["model"] as? [String: Any], + let name = descriptor["model_id"] as? String + { + name + } else { + "unknown" + } + + // Recorded in microseconds since the epoch. + let timestamp = Date(timeIntervalSince1970: Double(recordedAt) / 1_000_000) + let cacheRead = max(0, usage["cache_read_tokens"] as? Int ?? usage["cached_input_tokens"] as? Int ?? 0) + return .event(Event( + id: id, + recordedAt: timestamp, + model: self.normalizeModelID(model), + inputTokens: input, + outputTokens: output, + cacheReadTokens: cacheRead, + cacheWriteTokens: max(0, usage["cache_write_tokens"] as? Int ?? 0), + reasoningTokens: max(0, usage["reasoning_tokens"] as? Int ?? 0), + totalTokens: total)) + } + + // MARK: - Aggregation + + static func normalizeModelID(_ raw: String) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "unknown" : trimmed + } + + static func checkedAdd(_ lhs: Int, _ rhs: Int) -> Int? { + let (result, overflow) = lhs.addingReportingOverflow(rhs) + return overflow ? nil : result + } + + static func checkedSum(_ values: [Int]) -> Int? { + var total = 0 + for value in values { + guard let next = self.checkedAdd(total, value) else { return nil } + total = next + } + return total + } +} diff --git a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift index 0743e17cd0..cf50e80d62 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift @@ -53,8 +53,9 @@ public enum MuseProviderDescriptor { ], burnDownWidgetColor: ProviderColor(red: 6 / 255, green: 104 / 255, blue: 225 / 255)), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "Muse does not publish a cost endpoint. Set META_API_KEY or run `muse login`." }), + supportsTokenCost: true, + noDataMessage: { "No Muse session logs found yet. Run `muse` once to record local usage." }, + supportsTokenSnapshot: true), fetchPlan: self.fetchPlan(), cli: ProviderCLIConfig( name: "muse", diff --git a/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift b/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift index cf74ca7693..2f22ad92d8 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift @@ -3,27 +3,19 @@ import Foundation import FoundationNetworking #endif -/// Reads Muse quota from the Meta Model API. +/// Validates a Muse API key against the Meta Model API. /// -/// The Model API publishes no usage or billing endpoint (see the endpoint list in -/// https://dev.meta.ai/docs/api-reference). What it does document is a set of rate-limit response -/// headers returned alongside successful responses, so quota is read from the headers of the -/// cheapest documented read-only call, `GET /v1/models`, rather than from a request that would -/// spend tokens. +/// This reports no usage. The Model API publishes no usage or billing endpoint (see the endpoint list +/// in https://dev.meta.ai/docs/api-reference), and its documented `x-ratelimit-*` headers accompany +/// only billed inference responses — `GET /v1/models` and `GET /v1/status` return none — so reading a +/// quota would mean spending tokens on every refresh and consuming the limit being reported. Token +/// history comes from ``MuseLocalUsageReader`` instead. +/// +/// `GET /v1/models` is the cheapest documented read-only call, and answers the one question a key can +/// settle for free: whether it works. public enum MuseUsageFetcher { private static let requestTimeoutSeconds: TimeInterval = 15 - /// https://dev.meta.ai/docs/pricing-rate-limits - enum RateLimitHeader { - static let limitTokens = "x-ratelimit-limit-tokens" - static let remainingTokens = "x-ratelimit-remaining-tokens" - static let limitRequests = "x-ratelimit-limit-requests" - static let remainingRequests = "x-ratelimit-remaining-requests" - } - - /// Documented limits are per minute, per team. - private static let windowMinutes = 1 - public static func fetchUsage( apiKey: String, baseURL: URL = MuseSettingsReader.defaultBaseURL, @@ -56,52 +48,14 @@ public enum MuseUsageFetcher { switch http.statusCode { case 200...299: - break + return MuseUsageSnapshot( + accountEmail: localAuth?.accountEmail, + plan: localAuth?.loginMethod ?? "API key", + updatedAt: Date()) case 401, 403: throw MuseUsageError.invalidAPIKey default: - throw MuseUsageError.networkError("Muse usage request failed (HTTP \(http.statusCode)).") - } - - let windows = self.rateWindows(from: http) - return MuseUsageSnapshot( - primary: windows.tokens, - secondary: windows.requests, - accountEmail: localAuth?.accountEmail, - plan: localAuth?.loginMethod ?? "API key", - updatedAt: Date()) - } - - /// Maps the documented rate-limit headers onto token and request windows. - /// - /// A response without the headers yields no windows; the caller still has a verified-credential - /// identity to show, because the request itself succeeded. - static func rateWindows(from response: HTTPURLResponse) -> (tokens: RateWindow?, requests: RateWindow?) { - ( - tokens: self.window( - limit: self.headerValue(response, RateLimitHeader.limitTokens), - remaining: self.headerValue(response, RateLimitHeader.remainingTokens)), - requests: self.window( - limit: self.headerValue(response, RateLimitHeader.limitRequests), - remaining: self.headerValue(response, RateLimitHeader.remainingRequests))) - } - - private static func headerValue(_ response: HTTPURLResponse, _ name: String) -> Double? { - guard let raw = response.value(forHTTPHeaderField: name)? - .trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty - else { - return nil + throw MuseUsageError.networkError("Muse key check failed (HTTP \(http.statusCode)).") } - return Double(raw) - } - - private static func window(limit: Double?, remaining: Double?) -> RateWindow? { - guard let limit, let remaining, limit > 0 else { return nil } - let used = ((limit - remaining) / limit) * 100 - return RateWindow( - usedPercent: max(0, min(100, used)), - windowMinutes: self.windowMinutes, - resetsAt: nil, - resetDescription: nil) } } diff --git a/Tests/CodexBarTests/MuseLocalUsageReaderTests.swift b/Tests/CodexBarTests/MuseLocalUsageReaderTests.swift new file mode 100644 index 0000000000..c75cd59966 --- /dev/null +++ b/Tests/CodexBarTests/MuseLocalUsageReaderTests.swift @@ -0,0 +1,348 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Muse records every model turn to `~/.local/share/muse/sessions/////session.jsonl`. +/// These fixtures mirror the record shapes observed in real logs, including the two `usage` payloads +/// that must never be counted. +struct MuseLocalUsageReaderTests { + // MARK: - Fixtures + + private static func record( + id: String, + recordedAt: Int, + kind: String, + usage: String, + model: String? = "\"muse-spark-1.2\"") -> String + { + let modelField = model.map { "\"model\":\($0)," } ?? "" + return """ + {"schema_version":1,"id":"\(id)","stream":{"kind":"session","id":"s1"},"sequence":1,\ + "recorded_at":\(recordedAt),"record_type":"event","durability":"durable",\ + "payload_type":"runtime.session","payload_schema_version":1,\ + "payload":{"kind":"run","run_id":"r1","event":{"kind":"\(kind)",\(modelField)"usage":\(usage)}}} + """ + } + + private static let modelTurnUsage = """ + {"input_tokens":34893,"output_tokens":229,"cached_tokens":0,"cache_write_tokens":0,\ + "cache_read_tokens":0,"reasoning_tokens":52} + """ + + /// A real turn where the cache counters nearly equal the input; summing them would double-count. + private static let cachedTurnUsage = """ + {"input_tokens":41231,"output_tokens":100,"cached_tokens":41201,"cache_write_tokens":0,\ + "cache_read_tokens":41201,"reasoning_tokens":44} + """ + + private static let resourceSampleUsage = """ + {"cpu_children_ms":12,"cpu_self_ms":8,"fds_open":30,"procs_live":2,\ + "rss_self_bytes":123456,"rss_tree_bytes":234567,"unified_exec_live_sessions":1} + """ + + /// 2026-08-31 12:00:00 UTC in microseconds. + private static let baseMicros = 1_788_177_600_000_000 + + private static func makeTree( + _ logs: [(day: String, session: String, lines: [String])]) throws -> URL + { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("muse-reader-tests-\(UUID().uuidString)", isDirectory: true) + for log in logs { + let parts = log.day.split(separator: "-").map(String.init) + let dir = root + .appendingPathComponent(parts[0], isDirectory: true) + .appendingPathComponent(parts[1], isDirectory: true) + .appendingPathComponent(parts[2], isDirectory: true) + .appendingPathComponent(log.session, isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try log.lines.joined(separator: "\n").write( + to: dir.appendingPathComponent("session.jsonl"), + atomically: true, + encoding: .utf8) + } + return root + } + + private static func read( + root: URL, + sinceDayKey: String? = nil, + cacheRoot: URL? = nil) throws -> MuseLocalUsageReader.DailyReportResult + { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return try MuseLocalUsageReader.makeDailyReportWithStatus( + context: .init(sessionsRoot: root), + calendar: calendar, + sinceDayKey: sinceDayKey, + cacheRoot: cacheRoot ?? URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("muse-cache-\(UUID().uuidString)", isDirectory: true)) + } + + // MARK: - Token semantics + + /// Verified across 1,431 recorded events: reasoning is a subset of output and the cache counters + /// are subsets of input, so a turn totals input + output. + @Test + func `a turn totals input plus output without double-counting cache or reasoning`() throws { + let root = try Self.makeTree([( + day: "2026-08-31", + session: "sess-1", + lines: [Self.record( + id: "e1", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.cachedTurnUsage)])]) + let result = try Self.read(root: root) + + #expect(result.coverage == .complete) + let entry = try #require(result.report.data.first) + #expect(entry.date == "2026-08-31") + #expect(entry.inputTokens == 41231) + #expect(entry.outputTokens == 100) + // 41,231 + 100 — not 41,231 + 100 + 41,201 + 44. + #expect(entry.totalTokens == 41331) + // The cache and reasoning counters are still reported, just never added to the total. + #expect(entry.cacheReadTokens == 41201) + #expect(entry.reasoningTokens == 44) + #expect(entry.requestCount == 1) + #expect(entry.costUSD == nil) + } + + /// `resource_usage_sampled` reuses the `usage` key for CPU and RSS telemetry. + @Test + func `resource telemetry is never counted as tokens`() throws { + let root = try Self.makeTree([( + day: "2026-08-31", + session: "sess-1", + lines: [ + Self.record( + id: "e1", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.modelTurnUsage), + Self.record( + id: "e2", + recordedAt: Self.baseMicros, + kind: "resource_usage_sampled", + usage: Self.resourceSampleUsage, + model: nil), + ])]) + let result = try Self.read(root: root) + + #expect(result.coverage == .complete) + let entry = try #require(result.report.data.first) + #expect(entry.requestCount == 1) + #expect(entry.totalTokens == 35122) + } + + /// A child workflow's rollup repeats turns that are recorded on their own. + @Test + func `child workflow rollups are not counted twice`() throws { + let root = try Self.makeTree([( + day: "2026-08-31", + session: "sess-1", + lines: [ + Self.record( + id: "e1", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.modelTurnUsage), + Self.record( + id: "e2", + recordedAt: Self.baseMicros, + kind: "workflow_child_lifecycle", + usage: Self.modelTurnUsage, + model: nil), + ])]) + let result = try Self.read(root: root) + #expect(result.report.data.first?.requestCount == 1) + } + + @Test + func `automated review turns are counted and use their descriptor model id`() throws { + let usage = """ + {"input_tokens":900,"output_tokens":100,"cached_input_tokens":300,\ + "non_cached_input_tokens":600,"reasoning_tokens":40,"total_tokens":1000} + """ + let model = """ + {"provider_id":"meta","model_id":"muse-spark-1.2","reasoning_effort":"low"} + """ + let root = try Self.makeTree([( + day: "2026-08-31", + session: "sess-1", + lines: [Self.record( + id: "e1", + recordedAt: Self.baseMicros, + kind: "automated_review_completed", + usage: usage, + model: model)])]) + let result = try Self.read(root: root) + + let entry = try #require(result.report.data.first) + #expect(entry.totalTokens == 1000) + #expect(entry.cacheReadTokens == 300) + #expect(entry.modelBreakdowns?.first?.modelName == "muse-spark-1.2") + } + + // MARK: - Identity and drift + + @Test + func `a turn copied into a second log is counted once`() throws { + let line = Self.record( + id: "shared-event", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.modelTurnUsage) + let root = try Self.makeTree([ + (day: "2026-08-31", session: "sess-1", lines: [line]), + (day: "2026-08-31", session: "sess-2", lines: [line]), + ]) + let result = try Self.read(root: root) + #expect(result.report.data.first?.requestCount == 1) + } + + @Test + func `an unknown schema version is ignored rather than guessed at`() throws { + let line = Self.record( + id: "e1", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.modelTurnUsage) + .replacingOccurrences(of: "\"schema_version\":1", with: "\"schema_version\":2") + let root = try Self.makeTree([(day: "2026-08-31", session: "sess-1", lines: [line])]) + let result = try Self.read(root: root) + #expect(result.report.data.isEmpty) + } + + /// An unrecognized kind carrying token counts is drift, and must downgrade coverage instead of + /// silently vanishing from the totals. + @Test + func `an unknown token-bearing kind downgrades coverage`() throws { + let root = try Self.makeTree([( + day: "2026-08-31", + session: "sess-1", + lines: [ + Self.record( + id: "e1", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.modelTurnUsage), + Self.record( + id: "e2", + recordedAt: Self.baseMicros, + kind: "future_inference_kind", + usage: Self.modelTurnUsage), + ])]) + let result = try Self.read(root: root) + #expect(result.coverage == .partial) + #expect(result.report.data.first?.requestCount == 1) + } + + @Test + func `an absent sessions tree reports unavailable rather than zero usage`() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("muse-missing-\(UUID().uuidString)", isDirectory: true) + let result = try Self.read(root: root) + #expect(result.coverage == .unavailable) + #expect(!result.isAvailable) + } + + // MARK: - Windowing and cache + + @Test + func `days outside the requested window are skipped`() throws { + let root = try Self.makeTree([ + (day: "2026-08-20", session: "old", lines: [Self.record( + id: "old-1", + recordedAt: Self.baseMicros - 950_400_000_000, + kind: "model_completed", + usage: Self.modelTurnUsage)]), + (day: "2026-08-31", session: "new", lines: [Self.record( + id: "new-1", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.modelTurnUsage)]), + ]) + let result = try Self.read(root: root, sinceDayKey: "2026-08-25") + #expect(result.report.data.map(\.date) == ["2026-08-31"]) + } + + @Test + func `day keys come from the tree's own path components`() { + #expect(MuseLocalUsageReader.dayKey(year: "2026", month: "08", day: "31") == "2026-08-31") + #expect(MuseLocalUsageReader.dayKey(year: "2026", month: "8", day: "31") == nil) + #expect(MuseLocalUsageReader.dayKey(year: "abcd", month: "08", day: "31") == nil) + } + + /// A second scan must reuse the cache and still report the same totals. + @Test + func `a warm scan reproduces the cold scan totals`() throws { + let root = try Self.makeTree([( + day: "2026-08-31", + session: "sess-1", + lines: [ + Self.record( + id: "e1", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.modelTurnUsage), + Self.record( + id: "e2", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.cachedTurnUsage), + ])]) + let cacheRoot = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("muse-cache-\(UUID().uuidString)", isDirectory: true) + + let cold = try Self.read(root: root, cacheRoot: cacheRoot) + let warm = try Self.read(root: root, cacheRoot: cacheRoot) + + #expect(cold.coverage == .complete) + #expect(warm.coverage == .complete) + #expect(cold.report.data.first?.totalTokens == 76453) + #expect(warm.report.data.first?.totalTokens == cold.report.data.first?.totalTokens) + #expect(warm.report.data.first?.requestCount == 2) + } + + /// Appending to a log changes its size and mtime, so the cached entry must be replaced. + @Test + func `an appended log is rescanned rather than served from cache`() throws { + let root = try Self.makeTree([( + day: "2026-08-31", + session: "sess-1", + lines: [Self.record( + id: "e1", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.modelTurnUsage)])]) + let cacheRoot = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("muse-cache-\(UUID().uuidString)", isDirectory: true) + let cold = try Self.read(root: root, cacheRoot: cacheRoot) + #expect(cold.report.data.first?.requestCount == 1) + + let log = root.appendingPathComponent("2026/08/31/sess-1/session.jsonl") + let appended = try String(contentsOf: log, encoding: .utf8) + "\n" + Self.record( + id: "e2", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.cachedTurnUsage) + try appended.write(to: log, atomically: true, encoding: .utf8) + + let warm = try Self.read(root: root, cacheRoot: cacheRoot) + #expect(warm.report.data.first?.requestCount == 2) + #expect(warm.report.data.first?.totalTokens == 76453) + } + + /// The pre-filter keys on the token field, not on the known kinds, so an unrecognized future kind + /// still reaches the parser and downgrades coverage. + @Test + func `lines without token counts are rejected before parsing`() { + #expect(MuseLocalUsageReader.mayContainTokenCounts(Data(#"{"usage":{"input_tokens":1}}"#.utf8))) + #expect(MuseLocalUsageReader.mayContainTokenCounts( + Data(#"{"kind":"future_kind","usage":{"input_tokens":1}}"#.utf8))) + #expect(!MuseLocalUsageReader.mayContainTokenCounts( + Data(#"{"usage":{"cpu_self_ms":8,"rss_self_bytes":1}}"#.utf8))) + } +} diff --git a/Tests/CodexBarTests/MuseProviderTests.swift b/Tests/CodexBarTests/MuseProviderTests.swift index 9d51851d64..1b8744bc5b 100644 --- a/Tests/CodexBarTests/MuseProviderTests.swift +++ b/Tests/CodexBarTests/MuseProviderTests.swift @@ -5,18 +5,10 @@ import Testing import FoundationNetworking #endif -/// Muse reads quota from the rate-limit headers the Meta Model API documents at -/// https://dev.meta.ai/docs/pricing-rate-limits. The Model API publishes no usage, billing, or -/// account endpoint, so these tests pin the header mapping, the endpoint the key is sent to, and the -/// local identity file rather than any speculative JSON body. +/// The Meta Model API publishes no usage, billing, or account endpoint, so the API path only validates +/// a key. These tests pin the endpoint the key is sent to, the host it must never leave, and the local +/// identity file. Token usage is covered by ``MuseLocalUsageReaderTests``. struct MuseProviderTests { - private static let defaultHeaders = [ - "x-ratelimit-limit-tokens": "3000000", - "x-ratelimit-remaining-tokens": "2250000", - "x-ratelimit-limit-requests": "100", - "x-ratelimit-remaining-requests": "75", - ] - // MARK: - Credentials @Test @@ -85,7 +77,7 @@ struct MuseProviderTests { @Test func `fetch requests only the documented read-only models endpoint`() async throws { - let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: Self.defaultHeaders)]) + let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: [:])]) _ = try await MuseUsageFetcher.fetchUsage( apiKey: "key", baseURL: #require(URL(string: "https://api.meta.ai/v1")), @@ -100,7 +92,7 @@ struct MuseProviderTests { @Test func `fetch keeps the credential on the configured host`() async throws { - let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: Self.defaultHeaders)]) + let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: [:])]) _ = try await MuseUsageFetcher.fetchUsage( apiKey: "gateway-key", baseURL: #require(URL(string: "https://proxy.internal/v1")), @@ -111,32 +103,9 @@ struct MuseProviderTests { #expect(!hosts.contains("api.meta.ai")) } + /// A successful key check reports identity only; Muse exposes no free quota to report. @Test - func `rate-limit headers map onto token and request windows`() async throws { - let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: Self.defaultHeaders)]) - let snapshot = try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: transport) - - let tokens = try #require(snapshot.primary) - #expect(tokens.usedPercent == 25) - #expect(tokens.windowMinutes == 1) - - let requests = try #require(snapshot.secondary) - #expect(requests.usedPercent == 25) - #expect(requests.windowMinutes == 1) - } - - @Test - func `usage percent stays clamped when a header reports more than the limit`() async throws { - let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: [ - "x-ratelimit-limit-tokens": "1000", - "x-ratelimit-remaining-tokens": "-500", - ])]) - let snapshot = try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: transport) - #expect(snapshot.primary?.usedPercent == 100) - } - - @Test - func `a response without rate-limit headers yields identity without inventing a window`() async throws { + func `a successful key check yields identity without any usage window`() async throws { let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: [:])]) let localAuth = MuseLocalAuth( accountEmail: "dev@example.com", @@ -154,19 +123,6 @@ struct MuseProviderTests { #expect(snapshot.plan == "Meta account") } - @Test - func `a zero or malformed limit produces no window`() async throws { - let transport = MuseScriptedTransport(results: [.response(statusCode: 200, headers: [ - "x-ratelimit-limit-tokens": "0", - "x-ratelimit-remaining-tokens": "0", - "x-ratelimit-limit-requests": "not-a-number", - "x-ratelimit-remaining-requests": "5", - ])]) - let snapshot = try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: transport) - #expect(snapshot.primary == nil) - #expect(snapshot.secondary == nil) - } - @Test func `an empty API key fails before any request is made`() async { let transport = MuseScriptedTransport(results: []) @@ -190,12 +146,12 @@ struct MuseProviderTests { @Test func `server and transport failures surface instead of a placeholder snapshot`() async { let serverError = MuseScriptedTransport(results: [.response(statusCode: 500, headers: [:])]) - await #expect(throws: MuseUsageError.networkError("Muse usage request failed (HTTP 500).")) { + await #expect(throws: MuseUsageError.networkError("Muse key check failed (HTTP 500).")) { try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: serverError) } let notFound = MuseScriptedTransport(results: [.response(statusCode: 404, headers: [:])]) - await #expect(throws: MuseUsageError.networkError("Muse usage request failed (HTTP 404).")) { + await #expect(throws: MuseUsageError.networkError("Muse key check failed (HTTP 404).")) { try await MuseUsageFetcher.fetchUsage(apiKey: "key", transport: notFound) } @@ -292,8 +248,9 @@ struct MuseProviderTests { #expect(descriptor.metadata.cliName == "muse") #expect(descriptor.metadata.dashboardURL == "https://dev.meta.ai") #expect(descriptor.metadata.changelogURL == "https://dev.meta.ai/docs/muse-code/changelog") - // Muse exposes no cost endpoint, and no version probe is spawned for it. - #expect(!descriptor.tokenCost.supportsTokenCost) + // Token history comes from local session logs; no version probe is spawned. + #expect(descriptor.tokenCost.supportsTokenCost) + #expect(descriptor.tokenCost.supportsTokenSnapshot) #expect(descriptor.cli.versionDetector == nil) } diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 74f85155cc..7ee7a7cb03 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -202,12 +202,13 @@ struct ProviderArchitectureGatekeeperTests { #expect(Set(descriptors.filter(\.tokenCost.preservesCalendarDaysInCharts).map(\.id)) == [.codex]) #if os(macOS) // Antigravity joined via the tokscale-compatible local usage readers. + // Muse joined via its local session-log reader; it publishes no usage endpoint. #expect(Set(descriptors.filter(\.tokenCost.supportsTokenSnapshot).map(\.id)) == [ - .codex, .claude, .cursor, .vertexai, .bedrock, .antigravity, + .codex, .claude, .cursor, .vertexai, .bedrock, .antigravity, .muse, ]) #else #expect(Set(descriptors.filter(\.tokenCost.supportsTokenSnapshot).map(\.id)) == [ - .codex, .claude, .vertexai, .bedrock, .antigravity, + .codex, .claude, .vertexai, .bedrock, .antigravity, .muse, ]) #endif #expect(Set(descriptors.filter { $0.cli.binaryLocator != nil }.map(\.id)) == [ @@ -1373,19 +1374,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 819, + line: 817, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 897, + line: 895, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 983, + line: 981, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -3509,7 +3510,15 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 624, + line: 455, + anchor: "if provider == .muse {", + expectedProviderIDs: ["antigravity", "muse"], + expectedReferenceCount: 2, + expectedReferenceFingerprint: ["muse@0", "antigravity@9"], + reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/CostUsageFetcher.swift", + line: 622, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3517,7 +3526,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 648, + line: 646, anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 5, @@ -3525,7 +3534,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 695, + line: 693, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3533,7 +3542,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 726, + line: 724, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 3, @@ -3541,7 +3550,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1394, + line: 1500, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3549,7 +3558,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1750, + line: 1856, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/ProviderCredentialCharacterizationTests.swift b/Tests/CodexBarTests/ProviderCredentialCharacterizationTests.swift index 7dab269a5b..6e99db617d 100644 --- a/Tests/CodexBarTests/ProviderCredentialCharacterizationTests.swift +++ b/Tests/CodexBarTests/ProviderCredentialCharacterizationTests.swift @@ -208,6 +208,7 @@ struct ProviderCredentialCharacterizationTests { (.neuralwatt, "NEURALWATT_API_KEY"), (.groq, "GROQ_API_KEY"), (.llmproxy, "LLM_PROXY_API_KEY"), + (.muse, "META_API_KEY"), (.litellm, "LITELLM_API_KEY"), (.sub2api, "SUB2API_API_KEY"), (.ibmbob, "BOBSHELL_API_KEY"), diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index cff0854560..1bb360fcb0 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -77,7 +77,8 @@ struct SpendDashboardModelTests { .mistral, .bedrock, .cursor, - .grok, + // Muse reports tokens from local session logs; Meta publishes no cost endpoint. + .grok, .muse, .opencodego, .openrouter, .xai, diff --git a/docs/muse.md b/docs/muse.md index df10983116..ef1cf3b52b 100644 --- a/docs/muse.md +++ b/docs/muse.md @@ -1,5 +1,5 @@ --- -summary: "Muse provider data sources: Meta Model API rate-limit headers and the local muse login metadata." +summary: "Muse provider data sources: local session-log token usage and the muse login metadata." read_when: - Debugging Muse usage/availability - Updating Muse API endpoints @@ -8,42 +8,73 @@ read_when: # Muse provider -Muse Code is Meta's terminal coding agent, backed by the Meta Model API. CodexBar reads its quota from -the rate-limit headers the Model API documents, and its account identity from the metadata `muse login` +Muse Code is Meta's terminal coding agent, backed by the Meta Model API. CodexBar reads its token usage +from the session logs the CLI writes locally, and its account identity from the metadata `muse login` writes to disk. ## Where the numbers come from -The Meta Model API publishes **no usage, billing, credits, or account endpoint**. The documented -surface is `POST /v1/responses`, `POST /v1/chat/completions`, `POST /v1/messages`, `/v1/files`, -`GET /v1/models`, and `GET /v1/status` -([API reference](https://dev.meta.ai/docs/api-reference)). +**Token usage comes from local session logs.** Muse Code records every model turn to +`~/.local/share/muse/sessions///
//session.jsonl`, so CodexBar derives the same +local token history it already builds for Claude and Codex — no network call, no credential, and no +Keychain access. `XDG_DATA_HOME` is honoured. -What it does document is a set of rate-limit response headers returned with successful responses -([pricing and rate limits](https://dev.meta.ai/docs/pricing-rate-limits)): +Counted records are `model_completed` and `automated_review_completed`. Two other record kinds also +carry a `usage` object and are deliberately excluded: -| Header | Window | +- `resource_usage_sampled` — CPU and RSS gauges, not tokens. +- `workflow_child_lifecycle` — a child workflow's rollup, whose turns are already recorded on their own. + +An unrecognized kind carrying token counts downgrades coverage to partial rather than disappearing from +the totals. + +### Token math + +A turn totals `input_tokens + output_tokens`. Verified across 1,431 recorded events, without exception: + +| Relation | Meaning | | --- | --- | -| `x-ratelimit-limit-tokens` / `x-ratelimit-remaining-tokens` | Tokens per minute, per team | -| `x-ratelimit-limit-requests` / `x-ratelimit-remaining-requests` | Requests per minute, per team | +| `reasoning_tokens` ≤ `output_tokens` | reasoning is part of output | +| `cached_tokens` ≤ `input_tokens` | cached is part of input | +| `cached_tokens` == `cache_read_tokens` | the two counters are the same value | + +Adding the cache or reasoning counters would double-count badly — one sampled turn reported 41,201 +cached tokens against a 41,231-token input. The `automated_review_completed` shape carries its own +`total_tokens`, which equalled `input + output` in every observed event. + +Costs are not reported. The logs record tokens, not billed amounts, and Meta prices per tier. + +### Scanning cost + +Session trees get large: a sampled tree held 883 MB across 4,388 logs, of which only 1,235 records were +model turns. Three things keep a refresh cheap: + +- Day directories outside the requested history window are skipped without opening a log. +- Lines without an `input_tokens` field are rejected before JSON parsing. +- Each file's size, modification time, and per-day totals are cached in + `~/Library/Caches/CodexBar/cost-usage/muse-sessions-v1.json`, so an unchanged log is never reread. + +On that tree a cold scan took 16 s and a warm scan 0.26 s, for identical totals. A scan that exhausts +its budget keeps the files it finished and reports partial coverage, so the next refresh resumes. + +## Quota -CodexBar therefore issues one `GET {baseURL}/models` — the cheapest documented read-only call, so a -refresh never spends tokens — and derives both windows from its response headers. Limits apply per -team, not per key. +CodexBar does not display a Muse quota, because there is no free way to read one. -## Data sources + selection order +The Meta Model API publishes no usage, billing, or account endpoint. Its documented surface is +`POST /v1/responses`, `POST /v1/chat/completions`, `POST /v1/messages`, `/v1/files`, `GET /v1/models`, +and `GET /v1/status` ([API reference](https://dev.meta.ai/docs/api-reference)); every other path tested +with a valid key returned `404`, indistinguishable from a nonexistent one. -- **Auto**: API when a key is present, otherwise the local login for identity only. -- **API**: `META_API_KEY` or `MODEL_API_KEY` from the environment, a token account, or the key stored - in `~/.codexbar/config.json`. This is the only source that can report quota. -- **CLI**: `~/.config/muse/auth.json`, written by `muse login` / `muse auth set`. Supplies the account - email and login method. It cannot report quota, because the rate-limit headers only accompany an - authenticated API request. +The documented `x-ratelimit-limit-tokens`, `x-ratelimit-remaining-tokens`, +`x-ratelimit-limit-requests` and `x-ratelimit-remaining-requests` headers +([pricing and rate limits](https://dev.meta.ai/docs/pricing-rate-limits)) are real, but they ride only +on billed inference responses — `GET /v1/models` and `GET /v1/status` return none. Reading them would +mean issuing a billed completion on every refresh, which would also consume the very limit it reports. +They describe a per-minute rate limit rather than a standing budget, so they would read at or near 0% +except during a burst. -Muse exposes no non-interactive auth-status command — `muse auth` offers only `auth set` — so login -state is read from that file rather than inferred from a CLI exit code. Only the plaintext metadata is -parsed; the credential itself stays in the Keychain and is never read, so refreshing Muse never raises -a Keychain prompt. +An API key is still useful: it is validated with a free `GET /v1/models` (200 versus 401). ## API key @@ -51,6 +82,7 @@ a Keychain prompt. variable the Meta Model API SDKs read). - Config file: `~/.codexbar/config.json` → `providers[].apiKey` for instance `muse`, or `tokenAccounts`. - CLI: `printf '%s' "$META_API_KEY" | codexbar config set-api-key --provider muse --stdin`. +- Used only to validate the key; usage never depends on it. - Base URL override: `MUSE_BASE_URL`. The key is sent to this host as a bearer token, so the override is validated like every other provider endpoint — HTTPS anywhere, HTTP only for loopback and private-network gateways, never with embedded credentials. An override that fails validation surfaces @@ -67,10 +99,11 @@ a Keychain prompt. ## Key files +- Local token usage: `Sources/CodexBarCore/Providers/Muse/MuseLocalUsageReader.swift`, `Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift` - Descriptor and strategies: `Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift` - Settings: `Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift`, `Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift` - Local login metadata: `Sources/CodexBarCore/Providers/Muse/MuseLocalAuthReader.swift` - Fetch: `Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift`, `Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift` - Implementation: `Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift` - Icon: `Sources/CodexBar/Resources/ProviderIcon-muse.svg` -- Tests: `Tests/CodexBarTests/MuseProviderTests.swift` +- Tests: `Tests/CodexBarTests/MuseProviderTests.swift`, `Tests/CodexBarTests/MuseLocalUsageReaderTests.swift` diff --git a/docs/providers.md b/docs/providers.md index f2ca8060ff..32c69efc80 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -120,7 +120,7 @@ complete when the available scan window covers fewer days. | 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 `META_API_KEY`/`MODEL_API_KEY` or token accounts → `GET /v1/models` on the Meta Model API, reading the documented `x-ratelimit-*` headers for per-minute token and request windows (`api`); `~/.config/muse/auth.json` supplies account identity (`local`). | +| Muse | Local `session.jsonl` logs under `~/.local/share/muse/sessions` → daily token usage (`local`); `~/.config/muse/auth.json` supplies account identity, and `META_API_KEY`/`MODEL_API_KEY` validates the key against `GET /v1/models` (`api`). Meta publishes no usage endpoint, so no quota is shown. | ## Codex - App Auto: OAuth API first; falls back to CLI only when OAuth credentials are missing or auth/refresh is invalid. From 6a2d063be6b2514cfc0625d80be8663e1b567d87 Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Tue, 1 Sep 2026 06:50:21 -0700 Subject: [PATCH 4/5] Deduplicate Muse turns per event, not per log The cache aggregated each log's turns into per-day totals before storing them, so overlap could only be resolved whole-file: a log holding one already-counted event alongside unique ones was dropped entirely, silently under-reporting. Store one row per recorded turn instead. Deduplication now skips exactly the repeated ids and keeps the rest, and a partial-overlap regression covers it. Totals on a frozen 883 MB tree are unchanged (1,235 requests, 113,824,253 tokens); the warm scan stays at ~0.2s. Also correct the local strategy's diagnostic. It still advised setting META_API_KEY for usage, left over from the earlier rate-limit design; usage comes from the local logs and an API key only validates identity. --- .../Providers/Muse/MuseLocalUsageCache.swift | 28 ++++++-- .../Providers/Muse/MuseLocalUsageReader.swift | 65 ++++++++----------- .../Muse/MuseProviderDescriptor.swift | 2 +- .../MuseLocalUsageReaderTests.swift | 26 ++++++++ 4 files changed, 76 insertions(+), 45 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift index 89d2256de1..63466a44cf 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift @@ -4,8 +4,8 @@ import Foundation /// /// Session logs are append-only and dominated by telemetry the reader discards, so re-reading an /// unchanged file on every refresh is pure waste. Each entry stores the file's size and modification -/// time alongside the per-day totals it contributed; a file whose size and mtime both match is reused -/// without opening it, turning a full rescan into a stat of each path. +/// time alongside the turns it recorded; a file whose size and mtime both match is reused without +/// opening it, turning a full rescan into a stat of each path. struct MuseLocalUsageCache: Codable { struct DayTotals: Codable, Equatable { var inputTokens: Int @@ -38,12 +38,28 @@ struct MuseLocalUsageCache: Codable { } } + /// One recorded turn, kept per event rather than pre-aggregated per file. + /// + /// Aggregating a file's turns before caching would make overlap unresolvable: a log holding one + /// already-counted event alongside unique ones could only be taken whole or dropped whole, and + /// dropping it would silently under-report. Per-event rows let deduplication skip exactly the + /// repeated ids and keep the rest. + struct Event: Codable, Equatable { + var id: String + var day: String + var model: String + var inputTokens: Int + var outputTokens: Int + var cacheReadTokens: Int + var cacheWriteTokens: Int + var reasoningTokens: Int + var totalTokens: Int + } + struct FileEntry: Codable { var size: Int var modifiedAtMs: Int64 - /// Event ids this file contributed, so a turn copied into a second log is still counted once. - var eventIDs: [String] - var days: [String: DayTotals] + var events: [Event] var isComplete: Bool } @@ -54,7 +70,7 @@ struct MuseLocalUsageCache: Codable { enum MuseLocalUsageCacheIO { /// Artifact schema version; bump when the parser or the stored shape changes. - private static let artifactVersion = 1 + private static let artifactVersion = 2 private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! diff --git a/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageReader.swift b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageReader.swift index d1ff0907d2..5a65c2363f 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageReader.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageReader.swift @@ -199,56 +199,45 @@ enum MuseLocalUsageReader { } let parsed = try self.parseSessionLog(url: url, budget: budget) - var days: [String: MuseLocalUsageCache.DayTotals] = [:] - var eventIDs: [String] = [] - for event in parsed.events { - eventIDs.append(event.id) - let key = CostUsageLocalDay.key(from: event.recordedAt, calendar: calendar) - var totals = days[key] ?? MuseLocalUsageCache.DayTotals() - totals.inputTokens += event.inputTokens - totals.outputTokens += event.outputTokens - totals.cacheReadTokens += event.cacheReadTokens - totals.cacheWriteTokens += event.cacheWriteTokens - totals.reasoningTokens += event.reasoningTokens - totals.totalTokens += event.totalTokens - totals.requestCount += 1 - totals.models[event.model, default: 0] += event.totalTokens - days[key] = totals + let events = parsed.events.map { event in + MuseLocalUsageCache.Event( + id: event.id, + day: CostUsageLocalDay.key(from: event.recordedAt, calendar: calendar), + model: event.model, + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + cacheReadTokens: event.cacheReadTokens, + cacheWriteTokens: event.cacheWriteTokens, + reasoningTokens: event.reasoningTokens, + totalTokens: event.totalTokens) } return MuseLocalUsageCache.FileEntry( size: size, modifiedAtMs: modifiedAtMs, - eventIDs: eventIDs, - days: days, + events: events, isComplete: parsed.isComplete) } - /// Adds a file's cached totals, skipping any turn already contributed by another log. + /// Adds a file's turns, skipping only the individual ids another log already contributed. private static func accumulate( entry: MuseLocalUsageCache.FileEntry, into days: inout [String: MuseLocalUsageCache.DayTotals], seenEventIDs: inout Set) { - // The record id is unique per durable event, so a copied log cannot double-count. - let isDuplicate = entry.eventIDs.contains { seenEventIDs.contains($0) } - for id in entry.eventIDs { - seenEventIDs.insert(id) - } - guard !isDuplicate else { return } - - for (day, totals) in entry.days { - var merged = days[day] ?? MuseLocalUsageCache.DayTotals() - merged.inputTokens += totals.inputTokens - merged.outputTokens += totals.outputTokens - merged.cacheReadTokens += totals.cacheReadTokens - merged.cacheWriteTokens += totals.cacheWriteTokens - merged.reasoningTokens += totals.reasoningTokens - merged.totalTokens += totals.totalTokens - merged.requestCount += totals.requestCount - for (model, tokens) in totals.models { - merged.models[model, default: 0] += tokens - } - days[day] = merged + for event in entry.events { + // The record id is unique per durable event, so a turn copied into a second log is counted + // once while that log's other turns still count. + guard seenEventIDs.insert(event.id).inserted else { continue } + var totals = days[event.day] ?? MuseLocalUsageCache.DayTotals() + totals.inputTokens += event.inputTokens + totals.outputTokens += event.outputTokens + totals.cacheReadTokens += event.cacheReadTokens + totals.cacheWriteTokens += event.cacheWriteTokens + totals.reasoningTokens += event.reasoningTokens + totals.totalTokens += event.totalTokens + totals.requestCount += 1 + totals.models[event.model, default: 0] += event.totalTokens + days[event.day] = totals } } diff --git a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift index cf50e80d62..50c5245059 100644 --- a/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift @@ -160,7 +160,7 @@ struct MuseLocalFetchStrategy: ProviderFetchStrategy { return self.makeResult( usage: snapshot.toUsageSnapshot(), sourceLabel: "local", - diagnostic: "Muse reports quota only through API rate-limit headers; set META_API_KEY for usage.") + diagnostic: "Token usage comes from local Muse session logs; an API key only validates identity.") } func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { diff --git a/Tests/CodexBarTests/MuseLocalUsageReaderTests.swift b/Tests/CodexBarTests/MuseLocalUsageReaderTests.swift index c75cd59966..61945afaa3 100644 --- a/Tests/CodexBarTests/MuseLocalUsageReaderTests.swift +++ b/Tests/CodexBarTests/MuseLocalUsageReaderTests.swift @@ -202,6 +202,32 @@ struct MuseLocalUsageReaderTests { #expect(result.report.data.first?.requestCount == 1) } + /// A second log may hold one already-counted turn alongside unique ones. Dropping the whole file + /// on that overlap would silently under-report; only the repeated id may be skipped. + @Test + func `a partially overlapping log keeps its unique turns`() throws { + let shared = Self.record( + id: "shared-event", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.modelTurnUsage) + let unique = Self.record( + id: "unique-event", + recordedAt: Self.baseMicros, + kind: "model_completed", + usage: Self.cachedTurnUsage) + let root = try Self.makeTree([ + (day: "2026-08-31", session: "sess-1", lines: [shared]), + (day: "2026-08-31", session: "sess-2", lines: [shared, unique]), + ]) + let result = try Self.read(root: root) + + let entry = try #require(result.report.data.first) + // The shared turn counts once; the second log's unique turn is still counted. + #expect(entry.requestCount == 2) + #expect(entry.totalTokens == 76453) + } + @Test func `an unknown schema version is ignored rather than guessed at`() throws { let line = Self.record( From 9d37b79264a72aee6917c10f8ce2060752525c97 Mon Sep 17 00:00:00 2001 From: Sanjay Ramadugu Date: Tue, 1 Sep 2026 07:15:58 -0700 Subject: [PATCH 5/5] Correct the documented Muse cache filename and shape The cache moved to per-event rows in v2; docs still named v1 and per-day totals. --- docs/muse.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/muse.md b/docs/muse.md index ef1cf3b52b..120c567eeb 100644 --- a/docs/muse.md +++ b/docs/muse.md @@ -51,8 +51,10 @@ model turns. Three things keep a refresh cheap: - Day directories outside the requested history window are skipped without opening a log. - Lines without an `input_tokens` field are rejected before JSON parsing. -- Each file's size, modification time, and per-day totals are cached in - `~/Library/Caches/CodexBar/cost-usage/muse-sessions-v1.json`, so an unchanged log is never reread. +- Each file's size, modification time, and the individual turns it recorded are cached in + `~/Library/Caches/CodexBar/cost-usage/muse-sessions-v2.json`, so an unchanged log is never reread. + Turns are stored per event rather than pre-aggregated, so a log that repeats one already-counted + turn still contributes its remaining unique ones. On that tree a cold scan took 16 s and a warm scan 0.26 s, for identical totals. A scan that exhausts its budget keeps the files it finished and reports partial coverage, so the next refresh resumes.