diff --git a/README.md b/README.md
index 1bcd33ced0..35f5cd08aa 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) — 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/CodexBar/Providers/Muse/MuseProviderImplementation.swift b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift
new file mode 100644
index 0000000000..40add198f7
--- /dev/null
+++ b/Sources/CodexBar/Providers/Muse/MuseProviderImplementation.swift
@@ -0,0 +1,65 @@
+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
+ }
+
+ @MainActor
+ func isAvailable(context: ProviderAvailabilityContext) -> Bool {
+ if MuseSettingsReader.apiKey(environment: context.environment) != nil {
+ return true
+ }
+ if !context.settings.museAPIToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return true
+ }
+ // 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
+ func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
+ [
+ ProviderSettingsFieldDescriptor(
+ id: "muse-api-key",
+ title: "API key",
+ 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),
+ 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: nil),
+ ]
+ }
+
+ @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..2d125fc256
--- /dev/null
+++ b/Sources/CodexBar/Providers/Muse/MuseSettingsStore.swift
@@ -0,0 +1,14 @@
+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)
+ }
+ }
+}
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/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/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/MuseLocalUsageCache.swift b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift
new file mode 100644
index 0000000000..63466a44cf
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageCache.swift
@@ -0,0 +1,120 @@
+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 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
+ 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
+ }
+ }
+
+ /// 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
+ var events: [Event]
+ 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 = 2
+
+ 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..5a65c2363f
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Muse/MuseLocalUsageReader.swift
@@ -0,0 +1,482 @@
+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)
+ 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,
+ events: events,
+ isComplete: parsed.isComplete)
+ }
+
+ /// 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)
+ {
+ 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
+ }
+ }
+
+ 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
new file mode 100644
index 0000000000..50c5245059
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift
@@ -0,0 +1,169 @@
+import Foundation
+
+public enum MuseProviderDescriptor {
+ public static let descriptor: ProviderDescriptor = Self.makeDescriptor()
+
+ private static let credentials = ProviderCredentialAdapter.apiKey(
+ environmentKey: MuseSettingsReader.apiKeyEnvironmentKeys[0],
+ 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: "Tokens",
+ weeklyLabel: "Requests",
+ opusLabel: nil,
+ supportsOpus: false,
+ supportsCredits: false,
+ creditsHint: "",
+ toggleTitle: "Show Muse usage",
+ cliName: "muse",
+ defaultEnabled: false,
+ widgetSelectable: false,
+ isPrimaryProvider: false,
+ usesAccountFallback: false,
+ sharePlanLabels: [:],
+ dashboardURL: "https://dev.meta.ai",
+ subscriptionDashboardURL: "https://dev.meta.ai/docs/pricing-rate-limits",
+ changelogURL: "https://dev.meta.ai/docs/muse-code/changelog",
+ statusPageURL: nil,
+ statusLinkURL: nil),
+ 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: true,
+ noDataMessage: { "No Muse session logs found yet. Run `muse` once to record local usage." },
+ supportsTokenSnapshot: true),
+ fetchPlan: self.fetchPlan(),
+ cli: ProviderCLIConfig(
+ name: "muse",
+ aliases: ["muse-code"],
+ binaryLocator: nil,
+ versionDetector: nil,
+ 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] {
+ let hasKey = MuseSettingsReader.apiKey(environment: context.env) != nil
+
+ switch context.sourceMode {
+ case .api:
+ return [MuseAPIFetchStrategy()]
+ case .cli:
+ return [MuseLocalFetchStrategy()]
+ case .auto:
+ // Only the API key can produce quota; the local login supplies identity when it cannot.
+ if hasKey {
+ return [MuseAPIFetchStrategy(), MuseLocalFetchStrategy()]
+ }
+ if MuseLocalAuthReader.read() != nil {
+ return [MuseLocalFetchStrategy()]
+ }
+ // No credentials anywhere: keep the API strategy so the miss surfaces as a friendly error.
+ return [MuseAPIFetchStrategy()]
+ case .web, .oauth:
+ return []
+ }
+ }
+}
+
+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 a missing key surfaces as an actionable error rather than an empty menu.
+ true
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let apiKey = MuseSettingsReader.apiKey(environment: context.env) else {
+ throw MuseUsageError.missingCredentials
+ }
+ 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")
+ }
+
+ /// 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
+ }
+ }
+}
+
+/// 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 {
+ MuseLocalAuthReader.read() != nil
+ }
+
+ func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
+ guard let localAuth = MuseLocalAuthReader.read() else {
+ throw MuseUsageError.missingCredentials
+ }
+ let snapshot = MuseUsageSnapshot(
+ accountEmail: localAuth.accountEmail,
+ plan: localAuth.loginMethod,
+ updatedAt: Date())
+ return self.makeResult(
+ usage: snapshot.toUsageSnapshot(),
+ sourceLabel: "local",
+ diagnostic: "Token usage comes from local Muse session logs; an API key only validates identity.")
+ }
+
+ func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
+ false
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift b/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift
new file mode 100644
index 0000000000..54ff660a87
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Muse/MuseSettingsReader.swift
@@ -0,0 +1,84 @@
+import Foundation
+
+public enum MuseSettingsReader {
+ /// `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? {
+ for key in self.apiKeyEnvironmentKeys {
+ if let value = self.cleaned(environment[key]) {
+ return value
+ }
+ }
+ return nil
+ }
+
+ /// 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 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
+ }
+
+ 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 invalidEndpointOverride(String)
+ case usageUnavailable
+ case networkError(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "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
new file mode 100644
index 0000000000..2f22ad92d8
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Muse/MuseUsageFetcher.swift
@@ -0,0 +1,61 @@
+import Foundation
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+/// Validates a Muse API key against the Meta Model API.
+///
+/// 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
+
+ public static func fetchUsage(
+ apiKey: String,
+ baseURL: URL = MuseSettingsReader.defaultBaseURL,
+ localAuth: MuseLocalAuth? = nil,
+ transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MuseUsageSnapshot
+ {
+ let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ throw MuseUsageError.missingCredentials
+ }
+
+ var request = URLRequest(url: baseURL.appendingPathComponent("models"))
+ request.httpMethod = "GET"
+ request.timeoutInterval = self.requestTimeoutSeconds
+ request.setValue("Bearer \(trimmed)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+
+ 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("Muse returned an unexpected response.")
+ }
+
+ switch http.statusCode {
+ case 200...299:
+ return MuseUsageSnapshot(
+ accountEmail: localAuth?.accountEmail,
+ plan: localAuth?.loginMethod ?? "API key",
+ updatedAt: Date())
+ case 401, 403:
+ throw MuseUsageError.invalidAPIKey
+ default:
+ throw MuseUsageError.networkError("Muse key check failed (HTTP \(http.statusCode)).")
+ }
+ }
+}
diff --git a/Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift
new file mode 100644
index 0000000000..e24b90ce97
--- /dev/null
+++ b/Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift
@@ -0,0 +1,38 @@
+import Foundation
+
+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 accountEmail: String?
+ public let plan: String?
+ public let updatedAt: Date
+
+ public init(
+ primary: RateWindow? = nil,
+ secondary: RateWindow? = nil,
+ accountEmail: String? = nil,
+ plan: String? = nil,
+ updatedAt: Date = Date())
+ {
+ self.primary = primary
+ self.secondary = secondary
+ 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)
+ return UsageSnapshot(
+ primary: self.primary,
+ secondary: self.secondary,
+ 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/Tests/CodexBarTests/MuseLocalUsageReaderTests.swift b/Tests/CodexBarTests/MuseLocalUsageReaderTests.swift
new file mode 100644
index 0000000000..61945afaa3
--- /dev/null
+++ b/Tests/CodexBarTests/MuseLocalUsageReaderTests.swift
@@ -0,0 +1,374 @@
+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)
+ }
+
+ /// 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(
+ 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
new file mode 100644
index 0000000000..1b8744bc5b
--- /dev/null
+++ b/Tests/CodexBarTests/MuseProviderTests.swift
@@ -0,0 +1,346 @@
+import Foundation
+import Testing
+@testable import CodexBarCore
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+/// 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 {
+ // 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: [:])])
+ _ = 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: [:])])
+ _ = 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"))
+ }
+
+ /// A successful key check reports identity only; Muse exposes no free quota to report.
+ @Test
+ 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",
+ 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 `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 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 key check 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")
+ // 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)
+ }
+
+ @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..7ee7a7cb03 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
@@ -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
new file mode 100644
index 0000000000..120c567eeb
--- /dev/null
+++ b/docs/muse.md
@@ -0,0 +1,111 @@
+---
+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
+ - Adjusting the Muse local identity probe
+---
+
+# Muse provider
+
+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
+
+**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.
+
+Counted records are `model_completed` and `automated_review_completed`. Two other record kinds also
+carry a `usage` object and are deliberately excluded:
+
+- `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 |
+| --- | --- |
+| `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 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.
+
+## Quota
+
+CodexBar does not display a Muse quota, because there is no free way to read one.
+
+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.
+
+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.
+
+An API key is still useful: it is validated with a free `GET /v1/models` (200 versus 401).
+
+## 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`.
+- 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
+ 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`.
+
+## Errors
+
+- `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
+
+- 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/CodexBarTests/MuseLocalUsageReaderTests.swift`
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..32c69efc80 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 | 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.