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.