diff --git a/CHANGELOG.md b/CHANGELOG.md index 39dac68..068e0bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to InferenceMeter are documented here. The project follows [ ## [Unreleased] +### Fixed + +- Prefer Codex's live CLI rate-limit snapshot and ignore expired legacy windows left in rollout files after an upstream quota change. + +### Changed + +- Use the installed Codex CLI's read-only app-server interface before falling back to recent session data. +- Remove Codex's retired five-hour meter and threshold notifications while keeping Claude's five-hour window unchanged. + ## [0.1.2] - 2026-07-12 First public source release. A signed and notarized app archive will be added when Monomyth Development's Apple distribution credentials are available. diff --git a/CLAUDE.md b/CLAUDE.md index 542d17d..4eddaf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,10 +29,10 @@ Tests use Swift Testing (`@Test` and `#expect`), not XCTest test cases. Keep net Data flows from a provider through `RefreshEngine` and `AppState` to SwiftUI. -- `Model/Usage.swift` defines the shared value: optional five-hour, weekly, and Fable percentages and reset dates; endpoint or local-file source; and `ok`, `stale`, `refreshRequired`, `unauthorized`, or `unavailable` state. +- `Model/Usage.swift` defines the shared value: optional five-hour, weekly, and Fable percentages and reset dates; command-line, endpoint, or local-file source; and `ok`, `stale`, `refreshRequired`, `unauthorized`, or `unavailable` state. - `Providers/UsageProvider.swift` defines `refresh() async -> Usage` and `reauthenticate() async -> Bool`. - `Providers/ClaudeProvider.swift` reads Claude Code's Keychain credential and calls the Claude usage endpoint. -- `Providers/CodexProvider.swift` merges rate-limit snapshots across recent Codex rollout files. Endpoint support is injectable but is not configured by the app. +- `Providers/CodexProvider.swift` asks the installed Codex CLI app-server for current rate limits, then falls back to merging non-expired snapshots across recent rollout files. Direct endpoint support remains injectable but is not configured by the app. - `Core/RefreshEngine.swift` handles polling, provider-specific minimum intervals, exponential backoff, staleness, wake events, and Codex filesystem events. - `Core/Notifier.swift` emits deduplicated 80% and 95% notifications for available windows. - `UI/` contains the menu bar label and detail popover. diff --git a/InferenceMeter/Core/Notifier.swift b/InferenceMeter/Core/Notifier.swift index 0a3ad9d..cbaefe3 100644 --- a/InferenceMeter/Core/Notifier.swift +++ b/InferenceMeter/Core/Notifier.swift @@ -96,7 +96,7 @@ private extension Notifier { return } - for window in NotificationWindow.allCases { + for window in NotificationWindow.windows(for: usage.provider) { guard let percentage = window.percentage(in: usage) else { continue } @@ -243,6 +243,15 @@ private enum NotificationWindow: CaseIterable, Hashable { case weekly case fable + static func windows(for provider: Provider) -> [NotificationWindow] { + switch provider { + case .claude: + allCases + case .codex: + [.weekly] + } + } + var label: String { switch self { case .fiveHour: diff --git a/InferenceMeter/Model/Usage.swift b/InferenceMeter/Model/Usage.swift index ec06033..4862cf8 100644 --- a/InferenceMeter/Model/Usage.swift +++ b/InferenceMeter/Model/Usage.swift @@ -6,6 +6,7 @@ enum Provider: Sendable, Hashable { } enum UsageSource: Sendable { + case commandLine case endpoint case localFile } diff --git a/InferenceMeter/Model/UsageNormalizer.swift b/InferenceMeter/Model/UsageNormalizer.swift index d186fe6..603fa59 100644 --- a/InferenceMeter/Model/UsageNormalizer.swift +++ b/InferenceMeter/Model/UsageNormalizer.swift @@ -1,6 +1,27 @@ import Foundation enum UsageNormalizer { + static func codexAppServerRateLimits( + from data: Data, + parsedAt: Date = Date() + ) -> Usage { + let decoder = JSONDecoder() + + guard let response = try? decoder.decode(CodexAppServerRateLimitsResponseDTO.self, from: data), + let result = response.result else { + return unavailableUsage(provider: .codex, source: .commandLine, parsedAt: parsedAt) + } + + let rateLimits = result.rateLimitsByLimitID?["codex"] ?? result.rateLimits + + return codexUsage( + windows: [rateLimits.primary, rateLimits.secondary].compactMap { $0 }, + parsedAt: parsedAt, + source: .commandLine, + rejectsExpiredWindows: true + ) + } + static func codexRateLimits( from data: Data, source: UsageSource = .localFile, @@ -10,32 +31,11 @@ enum UsageNormalizer { return unavailableUsage(provider: .codex, source: source, parsedAt: parsedAt) } - var fiveHourPct: Double? - var weeklyPct: Double? - var fiveHourResetsAt: Date? - var weeklyResetsAt: Date? - - for window in [rateLimits.primary, rateLimits.secondary].compactMap({ $0 }) { - switch window.windowMinutes { - case 300: - fiveHourPct = window.usedPercent.map(clampPercentage) - fiveHourResetsAt = window.resetsAt - case 10_080: - weeklyPct = window.usedPercent.map(clampPercentage) - weeklyResetsAt = window.resetsAt - default: - continue - } - } - - return usage( - provider: .codex, - fiveHourPct: fiveHourPct, - weeklyPct: weeklyPct, - fiveHourResetsAt: fiveHourResetsAt, - weeklyResetsAt: weeklyResetsAt, - updatedAt: parsedAt, - source: source + return codexUsage( + windows: [rateLimits.primary, rateLimits.secondary].compactMap { $0 }, + parsedAt: parsedAt, + source: source, + rejectsExpiredWindows: false ) } @@ -100,6 +100,47 @@ private extension UsageNormalizer { case weekly } + static func codexUsage( + windows: [CodexRateLimitWindow], + parsedAt: Date, + source: UsageSource, + rejectsExpiredWindows: Bool + ) -> Usage { + var fiveHourPct: Double? + var weeklyPct: Double? + var fiveHourResetsAt: Date? + var weeklyResetsAt: Date? + + for window in windows { + if rejectsExpiredWindows, + let resetsAt = window.resetsAt, + resetsAt <= parsedAt { + continue + } + + switch window.windowMinutes { + case 300: + fiveHourPct = window.usedPercent.map(clampPercentage) + fiveHourResetsAt = window.resetsAt + case 10_080: + weeklyPct = window.usedPercent.map(clampPercentage) + weeklyResetsAt = window.resetsAt + default: + continue + } + } + + return usage( + provider: .codex, + fiveHourPct: fiveHourPct, + weeklyPct: weeklyPct, + fiveHourResetsAt: fiveHourResetsAt, + weeklyResetsAt: weeklyResetsAt, + updatedAt: parsedAt, + source: source + ) + } + static func decodeCodexRateLimits(from data: Data) -> CodexRateLimitsDTO? { let decoder = JSONDecoder() @@ -307,7 +348,13 @@ private struct CodexRateLimitsSearchDTO: Decodable, Sendable { } } -private struct CodexRateLimitWindowDTO: Codable, Sendable { +private protocol CodexRateLimitWindow { + var usedPercent: Double? { get } + var windowMinutes: Int? { get } + var resetsAt: Date? { get } +} + +private struct CodexRateLimitWindowDTO: Codable, Sendable, CodexRateLimitWindow { var usedPercent: Double? var windowMinutes: Int? var resetsAt: Date? @@ -327,6 +374,45 @@ private struct CodexRateLimitWindowDTO: Codable, Sendable { } } +private struct CodexAppServerRateLimitsResponseDTO: Decodable, Sendable { + var result: CodexAppServerRateLimitsResultDTO? +} + +private struct CodexAppServerRateLimitsResultDTO: Decodable, Sendable { + var rateLimits: CodexAppServerRateLimitSnapshotDTO + var rateLimitsByLimitID: [String: CodexAppServerRateLimitSnapshotDTO]? + + enum CodingKeys: String, CodingKey { + case rateLimits + case rateLimitsByLimitID = "rateLimitsByLimitId" + } +} + +private struct CodexAppServerRateLimitSnapshotDTO: Decodable, Sendable { + var primary: CodexAppServerRateLimitWindowDTO? + var secondary: CodexAppServerRateLimitWindowDTO? +} + +private struct CodexAppServerRateLimitWindowDTO: Decodable, Sendable, CodexRateLimitWindow { + var usedPercent: Double? + var windowMinutes: Int? + var resetsAt: Date? + + enum CodingKeys: String, CodingKey { + case usedPercent + case windowMinutes = "windowDurationMins" + case resetsAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + usedPercent = container.decodeLossyDoubleIfPresent(forKey: .usedPercent) + windowMinutes = container.decodeLossyIntIfPresent(forKey: .windowMinutes) + resetsAt = container.decodeFlexibleDateIfPresent(forKey: .resetsAt) + } +} + private struct DynamicCodingKey: CodingKey, Sendable { var stringValue: String var intValue: Int? diff --git a/InferenceMeter/Providers/CodexAppServerClient.swift b/InferenceMeter/Providers/CodexAppServerClient.swift new file mode 100644 index 0000000..1ec97d3 --- /dev/null +++ b/InferenceMeter/Providers/CodexAppServerClient.swift @@ -0,0 +1,173 @@ +import Darwin +import Foundation + +struct CodexAppServerClient: Sendable { + private let fetchRateLimitsData: @Sendable () async -> Data? + + init(fetchRateLimitsData: @escaping @Sendable () async -> Data?) { + self.fetchRateLimitsData = fetchRateLimitsData + } + + func fetchRateLimits() async -> Data? { + await fetchRateLimitsData() + } +} + +extension CodexAppServerClient { + static func live( + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> CodexAppServerClient? { + guard let executableURL = codexExecutableURL( + homeDirectory: homeDirectory, + environment: environment + ) else { + return nil + } + + return CodexAppServerClient { + await Task.detached(priority: .utility) { + fetchRateLimits(executableURL: executableURL) + }.value + } + } +} + +private extension CodexAppServerClient { + static let responseID = 2 + static let timeoutMilliseconds: Int32 = 10_000 + static let maximumResponseBytes = 2 * 1_024 * 1_024 + + static func codexExecutableURL( + homeDirectory: URL, + environment: [String: String] + ) -> URL? { + let codexHome = environment["CODEX_HOME"] + .map { URL(fileURLWithPath: $0, isDirectory: true) } + ?? homeDirectory.appendingPathComponent(".codex", isDirectory: true) + var candidates = [ + homeDirectory.appendingPathComponent(".local/bin/codex", isDirectory: false), + codexHome.appendingPathComponent("packages/standalone/current/bin/codex", isDirectory: false), + URL(fileURLWithPath: "/opt/homebrew/bin/codex", isDirectory: false), + URL(fileURLWithPath: "/usr/local/bin/codex", isDirectory: false) + ] + + if let path = environment["PATH"] { + candidates.append(contentsOf: path.split(separator: ":").map { directory in + URL(fileURLWithPath: String(directory), isDirectory: true) + .appendingPathComponent("codex", isDirectory: false) + }) + } + + return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) } + } + + static func fetchRateLimits(executableURL: URL) -> Data? { + let process = Process() + let standardInput = Pipe() + let standardOutput = Pipe() + + process.executableURL = executableURL + process.arguments = ["app-server", "--stdio"] + process.standardInput = standardInput + process.standardOutput = standardOutput + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return nil + } + + defer { + try? standardInput.fileHandleForWriting.close() + try? standardOutput.fileHandleForReading.close() + + if process.isRunning { + process.terminate() + process.waitUntilExit() + } + } + + do { + try standardInput.fileHandleForWriting.write(contentsOf: requestData()) + } catch { + return nil + } + + let outputHandle = standardOutput.fileHandleForReading + var descriptor = pollfd( + fd: outputHandle.fileDescriptor, + events: Int16(POLLIN | POLLHUP), + revents: 0 + ) + var bufferedData = Data() + let deadline = ContinuousClock.now.advanced(by: .milliseconds(Int(timeoutMilliseconds))) + + while ContinuousClock.now < deadline { + let remaining = ContinuousClock.now.duration(to: deadline) + let remainingMilliseconds = max( + 1, + min( + Int(timeoutMilliseconds), + Int(remaining.components.seconds * 1_000) + + Int(remaining.components.attoseconds / 1_000_000_000_000_000) + ) + ) + descriptor.revents = 0 + + guard poll(&descriptor, 1, Int32(remainingMilliseconds)) > 0 else { + continue + } + + let chunk = outputHandle.availableData + guard !chunk.isEmpty else { + return nil + } + + bufferedData.append(chunk) + guard bufferedData.count <= maximumResponseBytes else { + return nil + } + + while let newlineIndex = bufferedData.firstIndex(of: 10) { + let line = Data(bufferedData[.. Data { + let version = Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "development" + let messages = [ + #"{"method":"initialize","id":1,"params":{"clientInfo":{"name":"inference_meter","title":"Inference Meter","version":"\#(version)"}}}"#, + #"{"method":"initialized","params":{}}"#, + #"{"method":"account/rateLimits/read","id":2}"# + ] + + return Data((messages.joined(separator: "\n") + "\n").utf8) + } + + static func isRateLimitsResponse(_ data: Data) -> Bool { + guard let response = try? JSONDecoder().decode(CodexAppServerResponseIdentifier.self, from: data) else { + return false + } + + return response.id == responseID && response.result != nil + } +} + +private struct CodexAppServerResponseIdentifier: Decodable { + var id: Int? + var result: EmptyCodable? +} + +private struct EmptyCodable: Decodable {} diff --git a/InferenceMeter/Providers/CodexProvider.swift b/InferenceMeter/Providers/CodexProvider.swift index 91f8d0e..0d2d0ee 100644 --- a/InferenceMeter/Providers/CodexProvider.swift +++ b/InferenceMeter/Providers/CodexProvider.swift @@ -18,41 +18,63 @@ struct CodexProvider: UsageProvider { private let session: URLSession private let authFileReader: FileReader private let tokenStore: TokenStore + private let appServerClient: CodexAppServerClient? init( homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, endpointConfiguration: CodexEndpointConfiguration? = nil, session: URLSession = .shared, authFileReader: FileReader = FileReader(), - tokenStore: TokenStore = TokenStore() + tokenStore: TokenStore = TokenStore(), + appServerClient: CodexAppServerClient? = nil ) { self.homeDirectory = homeDirectory self.endpointConfiguration = endpointConfiguration self.session = session self.authFileReader = authFileReader self.tokenStore = tokenStore + self.appServerClient = appServerClient ?? Self.defaultAppServerClient( + homeDirectory: homeDirectory + ) } func refresh() async -> Usage { - guard endpointConfiguration != nil else { - return refreshFromLocalFile() - } + guard endpointConfiguration == nil else { + let endpointUsage = await refreshFromEndpoint() + guard endpointUsage.state != .ok else { + return endpointUsage + } - let endpointUsage = await refreshFromEndpoint() - guard endpointUsage.state != .ok else { - return endpointUsage + guard endpointUsage.state != .unauthorized else { + return endpointUsage + } + + let localUsage = refreshFromLocalFile() + if localUsage.state == .ok { + return localUsage + } + + return localUsage } - guard endpointUsage.state != .unauthorized else { - return endpointUsage + if let appServerData = await appServerClient?.fetchRateLimits() { + let appServerUsage = UsageNormalizer.codexAppServerRateLimits(from: appServerData) + + if appServerUsage.state == .ok { + return appServerUsage + } } - let localUsage = refreshFromLocalFile() - if localUsage.state == .ok { - return localUsage + return refreshFromLocalFile() + } + + private static func defaultAppServerClient(homeDirectory: URL) -> CodexAppServerClient? { + guard homeDirectory.standardizedFileURL + == FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL else { + return nil } - return localUsage + return .live(homeDirectory: homeDirectory) } func reauthenticate() async -> Bool { @@ -451,6 +473,11 @@ private struct CodexUsageAccumulator { return true } + if let resetsAt, + resetsAt <= (observedAt ?? updatedAt) { + return false + } + if observedAt == updatedAt { return true } diff --git a/InferenceMeter/UI/DetailPopover.swift b/InferenceMeter/UI/DetailPopover.swift index fb939fc..f91468a 100644 --- a/InferenceMeter/UI/DetailPopover.swift +++ b/InferenceMeter/UI/DetailPopover.swift @@ -228,13 +228,15 @@ private struct ProviderUsageSection: View { } VStack(alignment: .leading, spacing: 8) { - UsageWindowRow( - title: "5-hour", - percentage: usage.fiveHourPct, - resetsAt: usage.fiveHourResetsAt, - state: usage.state, - now: now - ) + if configuration.showsFiveHourUsage { + UsageWindowRow( + title: "5-hour", + percentage: usage.fiveHourPct, + resetsAt: usage.fiveHourResetsAt, + state: usage.state, + now: now + ) + } UsageWindowRow( title: "Weekly", @@ -297,6 +299,8 @@ private struct ProviderUsageSection: View { private var sourceText: String { switch usage.source { + case .commandLine: + "Codex CLI" case .endpoint: "endpoint" case .localFile: @@ -429,12 +433,14 @@ private struct ProviderDisplayConfiguration { let glyph: String let name: String let signInName: String + let showsFiveHourUsage: Bool let showsFableUsage: Bool static let claude = ProviderDisplayConfiguration( glyph: "✳", name: "Claude Code", signInName: "Claude Code", + showsFiveHourUsage: true, showsFableUsage: true ) @@ -442,6 +448,7 @@ private struct ProviderDisplayConfiguration { glyph: "⬡", name: "Codex", signInName: "Codex", + showsFiveHourUsage: false, showsFableUsage: false ) } diff --git a/InferenceMeter/UI/MenuBarLabel.swift b/InferenceMeter/UI/MenuBarLabel.swift index b38b2e7..3db6ce8 100644 --- a/InferenceMeter/UI/MenuBarLabel.swift +++ b/InferenceMeter/UI/MenuBarLabel.swift @@ -40,9 +40,9 @@ func thresholdColor(_ pct: Double) -> Color { } func labelSegments(claude: Usage, codex: Usage, compact: Bool) -> [MenuBarLabelSegment] { - providerSegments(symbol: "✳", usage: claude, compact: compact) + providerSegments(usage: claude, compact: compact) + [MenuBarLabelSegment(text: compact ? " " : " ", color: .primary)] - + providerSegments(symbol: "⬡", usage: codex, compact: compact) + + providerSegments(usage: codex, compact: compact) } private func composedLabelText(_ segments: [MenuBarLabelSegment]) -> Text { @@ -51,59 +51,62 @@ private func composedLabelText(_ segments: [MenuBarLabelSegment]) -> Text { } } -private func providerSegments(symbol: String, usage: Usage, compact: Bool) -> [MenuBarLabelSegment] { +private func providerSegments(usage: Usage, compact: Bool) -> [MenuBarLabelSegment] { + let displayedPercentages = displayedPercentages(for: usage, compact: compact) var segments = [ - MenuBarLabelSegment(text: "\(symbol) ", color: .primary) + MenuBarLabelSegment(text: "\(usage.provider.menuBarSymbol) ", color: .primary) ] switch usage.state { case .ok: - segments.append(contentsOf: valueSegments(usage: usage, colorOverride: nil, compact: compact)) + segments.append(contentsOf: valueSegments(displayedPercentages, colorOverride: nil)) case .stale: - segments.append(contentsOf: valueSegments(usage: usage, colorOverride: .secondary, compact: compact)) + segments.append(contentsOf: valueSegments(displayedPercentages, colorOverride: .secondary)) case .refreshRequired: - if usage.fiveHourPct != nil || usage.weeklyPct != nil { - segments.append(contentsOf: valueSegments(usage: usage, colorOverride: .secondary, compact: compact)) + if displayedPercentages.contains(where: { $0 != nil }) { + segments.append(contentsOf: valueSegments(displayedPercentages, colorOverride: .secondary)) } else { segments.append(MenuBarLabelSegment(text: "↻", color: Color(.systemOrange))) } case .unauthorized: segments.append(MenuBarLabelSegment(text: "!", color: Color(.systemOrange))) case .unavailable: - segments.append(contentsOf: unavailableSegments(compact: compact)) + segments.append(contentsOf: unavailableSegments(windowCount: displayedPercentages.count)) } return segments } -private func valueSegments( - usage: Usage, - colorOverride: Color?, - compact: Bool -) -> [MenuBarLabelSegment] { - var segments = [percentageSegment(usage.fiveHourPct, colorOverride: colorOverride)] +private func displayedPercentages(for usage: Usage, compact: Bool) -> [Double?] { + let percentages: [Double?] - guard !compact else { - return segments + switch usage.provider { + case .claude: + percentages = [usage.fiveHourPct, usage.weeklyPct] + case .codex: + percentages = [usage.weeklyPct] } - segments.append(MenuBarLabelSegment(text: "·", color: .primary)) - segments.append(percentageSegment(usage.weeklyPct, colorOverride: colorOverride)) - return segments + return compact ? Array(percentages.prefix(1)) : percentages } -private func unavailableSegments(compact: Bool) -> [MenuBarLabelSegment] { - var segments = [ - MenuBarLabelSegment(text: "--", color: .secondary) - ] +private func valueSegments( + _ percentages: [Double?], + colorOverride: Color? +) -> [MenuBarLabelSegment] { + percentages.enumerated().flatMap { index, percentage in + let value = percentageSegment(percentage, colorOverride: colorOverride) - guard !compact else { - return segments + guard index > 0 else { + return [value] + } + + return [MenuBarLabelSegment(text: "·", color: .primary), value] } +} - segments.append(MenuBarLabelSegment(text: "·", color: .primary)) - segments.append(MenuBarLabelSegment(text: "--", color: .secondary)) - return segments +private func unavailableSegments(windowCount: Int) -> [MenuBarLabelSegment] { + valueSegments(Array(repeating: nil, count: windowCount), colorOverride: .secondary) } private func percentageSegment(_ pct: Double?, colorOverride: Color?) -> MenuBarLabelSegment { @@ -126,3 +129,14 @@ private func formattedPercentage(_ pct: Double) -> String { return String(format: "%2d", roundedPct) } + +private extension Provider { + var menuBarSymbol: String { + switch self { + case .claude: + "✳" + case .codex: + "⬡" + } + } +} diff --git a/InferenceMeterTests/CodexProviderTests.swift b/InferenceMeterTests/CodexProviderTests.swift index f5c95e4..943fd86 100644 --- a/InferenceMeterTests/CodexProviderTests.swift +++ b/InferenceMeterTests/CodexProviderTests.swift @@ -16,6 +16,73 @@ func codexProviderReturnsUnavailableWhenSessionsDirectoryIsMissing() async throw } } +@Test("CodexProvider prefers live CLI rate limits over stale rollout snapshots") +func codexProviderPrefersLiveCLIRateLimits() async throws { + try await withTemporaryHome { home in + try writeRollout( + home: home, + contents: """ + {"timestamp":"2026-07-13T16:56:25Z","rate_limits":{"primary":{"used_percent":17.0,"window_minutes":300,"resets_at":1783000000},"secondary":{"used_percent":29.0,"window_minutes":10080,"resets_at":1784500000}}} + """ + ) + let appServerClient = CodexAppServerClient { + Data(""" + {"id":2,"result":{"rateLimits":{"limitId":"codex","primary":{"usedPercent":42,"windowDurationMins":10080,"resetsAt":4000000000},"secondary":null}}} + """.utf8) + } + let provider = CodexProvider( + homeDirectory: home, + appServerClient: appServerClient + ) + + let usage = await provider.refresh() + + #expect(usage.state == .ok) + #expect(usage.source == .commandLine) + #expect(usage.fiveHourPct == nil) + #expect(isClose(usage.weeklyPct, to: 42)) + } +} + +@Test("CodexProvider falls back to rollout snapshots when the CLI interface is unavailable") +func codexProviderFallsBackWhenCLIInterfaceIsUnavailable() async throws { + try await withTemporaryHome { home in + try writeRollout( + home: home, + contents: rateLimitsLine(fiveHourPct: 17, weeklyPct: 29) + ) + let provider = CodexProvider( + homeDirectory: home, + appServerClient: CodexAppServerClient { nil } + ) + + let usage = await provider.refresh() + + #expect(usage.state == .ok) + #expect(usage.source == .localFile) + #expect(isClose(usage.fiveHourPct, to: 17)) + #expect(isClose(usage.weeklyPct, to: 29)) + } +} + +@Test("CodexProvider discards an expired legacy window from the latest rollout event") +func codexProviderDiscardsExpiredLegacyWindowFromLatestEvent() async throws { + try await withTemporaryHome { home in + try writeRollout( + home: home, + contents: """ + {"timestamp":"2026-07-13T16:56:25Z","rate_limits":{"primary":{"used_percent":17.0,"window_minutes":300,"resets_at":1783000000},"secondary":{"used_percent":29.0,"window_minutes":10080,"resets_at":1784500000}}} + """ + ) + + let usage = await CodexProvider(homeDirectory: home).refresh() + + #expect(usage.state == .ok) + #expect(usage.fiveHourPct == nil) + #expect(isClose(usage.weeklyPct, to: 29)) + } +} + @Test("CodexProvider returns unavailable when newest rollout has no rate limits") func codexProviderReturnsUnavailableWhenNewestRolloutHasNoRateLimits() async throws { try await withTemporaryHome { home in @@ -166,7 +233,7 @@ func codexProviderUsesFileModificationDateWhenLineHasNoTimestamp() async throws try writeRollout( home: home, contents: """ - {"rate_limits":{"primary":{"used_percent":21.0,"window_minutes":300,"resets_at":1782948546},"secondary":{"used_percent":34.0,"window_minutes":10080,"resets_at":1783469995},"plan_type":"pro"}} + {"rate_limits":{"primary":{"used_percent":21.0,"window_minutes":300,"resets_at":1800002000},"secondary":{"used_percent":34.0,"window_minutes":10080,"resets_at":1800100000},"plan_type":"pro"}} """, modificationDate: modificationDate ) diff --git a/InferenceMeterTests/MenuBarLabelTests.swift b/InferenceMeterTests/MenuBarLabelTests.swift index 8139edb..a91d273 100644 --- a/InferenceMeterTests/MenuBarLabelTests.swift +++ b/InferenceMeterTests/MenuBarLabelTests.swift @@ -19,15 +19,15 @@ func thresholdPredicateTreatsEqualityAsCrossingBoundary() { #expect(isAtOrAboveUsageThreshold(95.1, threshold: 95)) } -@Test("Full mode renders both providers with independently colored values") -func fullModeRendersBothProvidersWithIndependentValueColors() { +@Test("Full mode renders Claude windows and the Codex weekly window") +func fullModeRendersClaudeWindowsAndCodexWeeklyWindow() { let segments = labelSegments( claude: usage(provider: .claude, fiveHourPct: 45, weeklyPct: 70), codex: usage(provider: .codex, fiveHourPct: 30, weeklyPct: 90), compact: false ) - #expect(segmentText(segments) == "✳ 45·70 ⬡ 30·90") + #expect(segmentText(segments) == "✳ 45·70 ⬡ 90") #expect(segments == [ MenuBarLabelSegment(text: "✳ ", color: .primary), MenuBarLabelSegment(text: "45", color: Color(.systemGreen)), @@ -35,27 +35,25 @@ func fullModeRendersBothProvidersWithIndependentValueColors() { MenuBarLabelSegment(text: "70", color: Color(.systemOrange)), MenuBarLabelSegment(text: " ", color: .primary), MenuBarLabelSegment(text: "⬡ ", color: .primary), - MenuBarLabelSegment(text: "30", color: Color(.systemGreen)), - MenuBarLabelSegment(text: "·", color: .primary), MenuBarLabelSegment(text: "90", color: Color(.systemRed)) ]) } -@Test("Compact mode drops weekly values and keeps five-hour colors") -func compactModeDropsWeeklyValuesAndKeepsFiveHourColors() { +@Test("Compact mode uses each provider's primary displayed window") +func compactModeUsesEachProvidersPrimaryDisplayedWindow() { let segments = labelSegments( claude: usage(provider: .claude, fiveHourPct: 45, weeklyPct: 70), codex: usage(provider: .codex, fiveHourPct: 30, weeklyPct: 55), compact: true ) - #expect(segmentText(segments) == "✳ 45 ⬡ 30") + #expect(segmentText(segments) == "✳ 45 ⬡ 55") #expect(segments == [ MenuBarLabelSegment(text: "✳ ", color: .primary), MenuBarLabelSegment(text: "45", color: Color(.systemGreen)), MenuBarLabelSegment(text: " ", color: .primary), MenuBarLabelSegment(text: "⬡ ", color: .primary), - MenuBarLabelSegment(text: "30", color: Color(.systemGreen)) + MenuBarLabelSegment(text: "55", color: Color(.systemGreen)) ]) } @@ -72,13 +70,12 @@ func staleProvidersKeepValuesVisibleInSecondaryGray() { compact: true ) - #expect(segmentText(fullSegments) == "✳ 70·90 ⬡ 90·100") + #expect(segmentText(fullSegments) == "✳ 70·90 ⬡ 100") #expect(fullSegments[1].color == .secondary) #expect(fullSegments[3].color == .secondary) #expect(fullSegments[6].color == Color(.systemRed)) - #expect(fullSegments[8].color == Color(.systemRed)) - #expect(segmentText(compactSegments) == "✳ 70 ⬡ 90") + #expect(segmentText(compactSegments) == "✳ 70 ⬡ 100") #expect(compactSegments[1].color == .secondary) #expect(compactSegments[4].color == Color(.systemRed)) } @@ -113,7 +110,7 @@ func unauthorizedProvidersRenderAuthMarkersInFullAndCompactModes() { #expect(segmentText(fullSegments) == "✳ 45·70 ⬡ !") #expect(fullSegments.last == MenuBarLabelSegment(text: "!", color: Color(.systemOrange))) - #expect(segmentText(compactSegments) == "✳ ! ⬡ 30") + #expect(segmentText(compactSegments) == "✳ ! ⬡ 55") #expect(compactSegments[1] == MenuBarLabelSegment(text: "!", color: Color(.systemOrange))) } @@ -130,13 +127,12 @@ func unavailableProvidersRenderDashesInsteadOfZeroValues() { compact: true ) - #expect(segmentText(fullSegments) == "✳ --·-- ⬡ 0·70") + #expect(segmentText(fullSegments) == "✳ --·-- ⬡ 70") #expect(fullSegments[1].color == .secondary) #expect(fullSegments[3].color == .secondary) #expect(fullSegments[6].color == Color(.systemGreen)) - #expect(fullSegments[8].color == Color(.systemGreen)) - #expect(segmentText(compactSegments) == "✳ -- ⬡ 0") + #expect(segmentText(compactSegments) == "✳ -- ⬡ 70") #expect(compactSegments[1].color == .secondary) #expect(compactSegments[4].color == Color(.systemGreen)) } @@ -149,11 +145,22 @@ func nilValuesRenderAsDashesAndSingleDigitsUseStableSlot() { compact: false ) - #expect(segmentText(segments) == "✳ 9·-- ⬡ --·10") + #expect(segmentText(segments) == "✳ 9·-- ⬡ 10") #expect(segments[1] == MenuBarLabelSegment(text: " 9", color: Color(.systemGreen))) #expect(segments[3] == MenuBarLabelSegment(text: "--", color: .secondary)) - #expect(segments[6] == MenuBarLabelSegment(text: "--", color: .secondary)) - #expect(segments[8] == MenuBarLabelSegment(text: "10", color: Color(.systemGreen))) + #expect(segments[6] == MenuBarLabelSegment(text: "10", color: Color(.systemGreen))) +} + +@Test("Codex ignores a legacy five-hour value when weekly usage is absent") +func codexIgnoresLegacyFiveHourValueWhenWeeklyUsageIsAbsent() { + let segments = labelSegments( + claude: usage(provider: .claude, fiveHourPct: 45, weeklyPct: 70), + codex: usage(provider: .codex, fiveHourPct: 99, weeklyPct: nil), + compact: false + ) + + #expect(segmentText(segments) == "✳ 45·70 ⬡ --") + #expect(segments.last == MenuBarLabelSegment(text: "--", color: .secondary)) } private func segmentText(_ segments: [MenuBarLabelSegment]) -> String { diff --git a/InferenceMeterTests/NotifierTests.swift b/InferenceMeterTests/NotifierTests.swift index 6f109ca..609047a 100644 --- a/InferenceMeterTests/NotifierTests.swift +++ b/InferenceMeterTests/NotifierTests.swift @@ -7,20 +7,20 @@ import Testing func crossingEightyPercentPostsOnceUntilRecovery() async { let fixture = NotifierFixture() - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 79)) - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 80)) - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 88)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 79)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 80)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 88)) #expect(fixture.poster.notifications.map(\.renderedText) == [ - "Codex 5-hour window at 80% — resets in 1h 40m" + "Claude 5-hour window at 80% — resets in 1h 40m" ]) - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 79)) - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 81)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 79)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 81)) #expect(fixture.poster.notifications.map(\.renderedText) == [ - "Codex 5-hour window at 80% — resets in 1h 40m", - "Codex 5-hour window at 81% — resets in 1h 40m" + "Claude 5-hour window at 80% — resets in 1h 40m", + "Claude 5-hour window at 81% — resets in 1h 40m" ]) } @@ -29,16 +29,16 @@ func crossingEightyPercentPostsOnceUntilRecovery() async { func jumpAcrossBothThresholdsPostsEightyAndNinetyFive() async { let fixture = NotifierFixture() - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 60)) - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 96)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 60)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 96)) #expect(fixture.poster.notifications.map(\.renderedText) == [ - "Codex 5-hour window at 96% — resets in 1h 40m", - "Codex 5-hour window at 96% — resets in 1h 40m" + "Claude 5-hour window at 96% — resets in 1h 40m", + "Claude 5-hour window at 96% — resets in 1h 40m" ]) #expect(fixture.poster.notifications.map(\.identifier) == [ - "inference-meter.threshold.codex.five-hour.80", - "inference-meter.threshold.codex.five-hour.95" + "inference-meter.threshold.claude.five-hour.80", + "inference-meter.threshold.claude.five-hour.95" ]) } @@ -46,17 +46,17 @@ func jumpAcrossBothThresholdsPostsEightyAndNinetyFive() async { @Test("Window reset re-arms sent thresholds") func windowResetRearmsSentThresholds() async { let fixture = NotifierFixture() - let nextReset = fixture.codexFiveHourReset.addingTimeInterval(5 * 60 * 60) + let nextReset = fixture.fiveHourReset.addingTimeInterval(5 * 60 * 60) - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 85)) - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 87)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 85)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 87)) await fixture.notifier.evaluate( - state: fixture.state(codexFiveHourPct: 86, codexFiveHourResetsAt: nextReset) + state: fixture.state(claudeFiveHourPct: 86, claudeFiveHourResetsAt: nextReset) ) #expect(fixture.poster.notifications.map(\.renderedText) == [ - "Codex 5-hour window at 85% — resets in 1h 40m", - "Codex 5-hour window at 86% — resets in 6h 40m" + "Claude 5-hour window at 85% — resets in 1h 40m", + "Claude 5-hour window at 86% — resets in 6h 40m" ]) } @@ -67,17 +67,17 @@ func nonOkStatesDoNotPostOrCorruptMarkers() async { for state in [UsageState.stale, .refreshRequired, .unauthorized, .unavailable] { await fixture.notifier.evaluate( - state: fixture.state(codexFiveHourPct: 96, codexState: state) + state: fixture.state(claudeFiveHourPct: 96, claudeState: state) ) } #expect(fixture.poster.notifications.isEmpty) - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 96)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 96)) #expect(fixture.poster.notifications.map(\.identifier) == [ - "inference-meter.threshold.codex.five-hour.80", - "inference-meter.threshold.codex.five-hour.95" + "inference-meter.threshold.claude.five-hour.80", + "inference-meter.threshold.claude.five-hour.95" ]) } @@ -86,7 +86,7 @@ func nonOkStatesDoNotPostOrCorruptMarkers() async { func disabledNotificationSettingMakesEvaluationNoOp() async { let fixture = NotifierFixture(notificationsEnabled: false) - await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 96)) + await fixture.notifier.evaluate(state: fixture.state(claudeFiveHourPct: 96)) #expect(fixture.poster.notifications.isEmpty) } @@ -120,6 +120,16 @@ func weeklyNotificationCopyUsesWeeklyLabelAndMultiDayCountdown() async { ]) } +@MainActor +@Test("Codex legacy five-hour values never post notifications") +func codexLegacyFiveHourValuesNeverPostNotifications() async { + let fixture = NotifierFixture() + + await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 96)) + + #expect(fixture.poster.notifications.isEmpty) +} + @MainActor @Test("Fable notification copy uses scoped model label") func fableNotificationCopyUsesScopedModelLabel() async { @@ -166,7 +176,7 @@ func firstEnableRequestsAuthorizationOnlyWhenUndetermined() async { @MainActor private final class NotifierFixture { let now = Date(timeIntervalSince1970: 1_800_000_000) - let codexFiveHourReset: Date + let fiveHourReset: Date let weeklyReset: Date let settingsStore: MemoryNotificationSettingsStore let poster: RecordingThresholdNotificationPoster @@ -176,7 +186,7 @@ private final class NotifierFixture { notificationsEnabled: Bool = true, authorizationStatus: ThresholdNotificationAuthorizationStatus = .authorized ) { - codexFiveHourReset = now.addingTimeInterval(100 * 60) + fiveHourReset = now.addingTimeInterval(100 * 60) weeklyReset = now.addingTimeInterval((3 * 24 * 60 * 60) + (5 * 60 * 60)) settingsStore = MemoryNotificationSettingsStore(notificationsEnabled: notificationsEnabled) poster = RecordingThresholdNotificationPoster(authorizationStatus: authorizationStatus) @@ -206,7 +216,7 @@ private final class NotifierFixture { provider: .claude, fiveHourPct: claudeFiveHourPct, weeklyPct: claudeWeeklyPct, - fiveHourResetsAt: claudeFiveHourResetsAt ?? codexFiveHourReset, + fiveHourResetsAt: claudeFiveHourResetsAt ?? fiveHourReset, weeklyResetsAt: claudeWeeklyResetsAt ?? weeklyReset, state: claudeState, fablePct: claudeFablePct, @@ -216,7 +226,7 @@ private final class NotifierFixture { provider: .codex, fiveHourPct: codexFiveHourPct, weeklyPct: codexWeeklyPct, - fiveHourResetsAt: codexFiveHourResetsAt ?? codexFiveHourReset, + fiveHourResetsAt: codexFiveHourResetsAt ?? fiveHourReset, weeklyResetsAt: codexWeeklyResetsAt ?? weeklyReset, state: codexState ) diff --git a/InferenceMeterTests/UsageNormalizerTests.swift b/InferenceMeterTests/UsageNormalizerTests.swift index 1eae044..81c0579 100644 --- a/InferenceMeterTests/UsageNormalizerTests.swift +++ b/InferenceMeterTests/UsageNormalizerTests.swift @@ -20,6 +20,68 @@ func codexJSONLFixtureMapsWindowsByMinutes() throws { #expect(usage.updatedAt == parsedAt) } +@Test("Codex app-server response keeps the current weekly window and absent five-hour window") +func codexAppServerResponseKeepsCurrentAvailableWindows() { + let parsedAt = Date(timeIntervalSince1970: 1_800_000_000) + let payload = data(""" + { + "id": 2, + "result": { + "rateLimits": { + "limitId": "codex", + "primary": {"usedPercent": 29, "windowDurationMins": 10080, "resetsAt": 1800604800}, + "secondary": {"usedPercent": 17, "windowDurationMins": 300, "resetsAt": 1799000000} + }, + "rateLimitsByLimitId": { + "codex": { + "limitId": "codex", + "primary": {"usedPercent": 42, "windowDurationMins": 10080, "resetsAt": 1800604800}, + "secondary": null + }, + "codex_bengalfox": { + "limitId": "codex_bengalfox", + "primary": {"usedPercent": 87, "windowDurationMins": 10080, "resetsAt": 1784566657}, + "secondary": null + } + } + } + } + """) + + let usage = UsageNormalizer.codexAppServerRateLimits(from: payload, parsedAt: parsedAt) + + #expect(usage.provider == .codex) + #expect(usage.source == .commandLine) + #expect(usage.state == .ok) + #expect(usage.fiveHourPct == nil) + #expect(isClose(usage.weeklyPct, to: 42)) + #expect(usage.fiveHourResetsAt == nil) + #expect(usage.weeklyResetsAt == Date(timeIntervalSince1970: 1_800_604_800)) + #expect(usage.updatedAt == parsedAt) +} + +@Test("Codex app-server response rejects an expired legacy window") +func codexAppServerResponseRejectsExpiredLegacyWindow() { + let parsedAt = Date(timeIntervalSince1970: 1_800_000_000) + let payload = data(""" + { + "id": 2, + "result": { + "rateLimits": { + "primary": {"usedPercent": 17, "windowDurationMins": 300, "resetsAt": 1799000000}, + "secondary": null + } + } + } + """) + + let usage = UsageNormalizer.codexAppServerRateLimits(from: payload, parsedAt: parsedAt) + + #expect(usage.state == .unavailable) + #expect(usage.fiveHourPct == nil) + #expect(usage.weeklyPct == nil) +} + @Test("Codex JSONL maps reordered primary and secondary windows by descriptor") func codexJSONLMapsReorderedWindowsByDescriptor() { let payload = data(""" diff --git a/README.md b/README.md index 3f44408..10eed79 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A private, native macOS menu bar meter for Claude Code and Codex usage limits. -InferenceMeter keeps the current five-hour and weekly usage windows visible without opening either CLI. Claude's scoped Fable limit is shown when available, and threshold notifications can warn at 80% and 95%. +InferenceMeter keeps Claude's five-hour and weekly windows plus Codex's provider-reported weekly window visible without opening either CLI. Claude's scoped Fable limit is shown when available, and threshold notifications can warn at 80% and 95%. > [!IMPORTANT] > InferenceMeter relies on provider-owned, undocumented usage data. Claude Code or Codex updates can change those formats without notice. The project is maintained on a best-effort basis and is not affiliated with, endorsed by, or supported by Anthropic or OpenAI. @@ -15,7 +15,7 @@ InferenceMeter keeps the current five-hour and weekly usage windows visible with - Native SwiftUI menu bar app with no Dock icon - Claude Code five-hour, weekly, and Fable usage -- Codex five-hour and weekly usage across recent CLI sessions +- Codex usage windows reported by the installed CLI, with recent-session fallback - Reset times and stale-data indicators - Optional 80% and 95% macOS notifications - Wake-from-sleep and Codex session-file refreshes @@ -52,19 +52,20 @@ The open-source release uses the Monomyth Development bundle identifier `dev.mon InferenceMeter is a read-only monitor. It reads credentials already owned by the CLIs: - Claude Code's `Claude Code-credentials` item in macOS Keychain +- The installed Codex CLI's read-only `account/rateLimits/read` app-server method - Codex session data under `~/.codex/sessions` - `~/.codex/auth.json` only when a Codex endpoint is explicitly configured in code The app never sends a refresh-token request and never writes OAuth credentials. Refresh tokens can rotate when used; independently refreshing one would invalidate the CLI's stored copy and cause repeated login prompts. When Claude's access token expires, InferenceMeter asks the installed Claude Code CLI to inspect its own auth state and adopts a newer Keychain token only if the CLI writes one. -Tokens, credentials, and authorization headers are not logged. The app contains no analytics, advertising, or update tracker. Network requests go directly to the configured provider endpoint. +Tokens, credentials, and authorization headers are not logged. The app contains no analytics, advertising, or update tracker. Claude network requests go directly to its provider endpoint. Codex network requests are delegated to the installed CLI so Codex remains the sole owner of its authentication lifecycle. The first Claude refresh may trigger a macOS Keychain permission prompt. Choose **Always Allow** if you want future usage refreshes to occur without another prompt. ## Data freshness and limitations - Claude requests are limited to one attempt every five minutes. The last successful reading remains visible through transient failures and is marked stale after fifteen minutes. -- Codex reads rate-limit snapshots from up to 20 recent rollout files and combines windows by their declared duration. Values depend on the CLI continuing to write those events. +- Codex asks the installed CLI for its current account rate limits. If that interface is unavailable, Inference Meter falls back to snapshots from up to 20 recent rollout files and ignores windows whose reset time had already passed when they were recorded. - A missing window is displayed as unavailable rather than inferred from another window. - Provider schema changes may temporarily make a meter unavailable until the parser is updated.