Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion InferenceMeter/Core/Notifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions InferenceMeter/Model/Usage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ enum Provider: Sendable, Hashable {
}

enum UsageSource: Sendable {
case commandLine
case endpoint
case localFile
}
Expand Down
140 changes: 113 additions & 27 deletions InferenceMeter/Model/UsageNormalizer.swift
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
)
}

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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?
Expand All @@ -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?
Expand Down
Loading
Loading