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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to InferenceMeter are documented here. The project follows [

## [Unreleased]

## [0.1.4] - 2026-07-13

Codex quota-window restoration release after its five-hour limit proved to be temporarily absent rather than retired.

### Fixed

- Restore Codex five-hour usage in the menu-bar label, detail popover, and 80%/95% threshold notifications.
- Accept app-server responses that provide the Codex snapshot only in `rateLimitsByLimitId`.
- Keep an omitted five-hour window unavailable until Codex reports it again instead of showing an expired local value.

## [0.1.3] - 2026-07-13

Codex compatibility release following the upstream removal of its five-hour usage window.
Expand Down Expand Up @@ -46,6 +56,7 @@ First public source release. A signed and notarized app archive will be added wh

- Adopt the Monomyth Development bundle identifier `dev.monomyth.InferenceMeter` for public releases. Users of pre-public builds must re-enable Launch at Login.

[Unreleased]: https://github.com/MonomythDevelopment/inference-meter/compare/v0.1.3...HEAD
[Unreleased]: https://github.com/MonomythDevelopment/inference-meter/compare/v0.1.4...HEAD
[0.1.4]: https://github.com/MonomythDevelopment/inference-meter/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/MonomythDevelopment/inference-meter/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/MonomythDevelopment/inference-meter/releases/tag/v0.1.2
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This is the public Monomyth Development repository (`MonomythDevelopment/inferen
- `CHANGELOG.md` follows Keep a Changelog / SemVer — add an `[Unreleased]` entry for user-facing changes.
- Community policy files (`CONTRIBUTING.md`, `SECURITY.md`, `SUPPORT.md`, `CODE_OF_CONDUCT.md`) and GitHub issue/PR templates are in place; releases follow `docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`.
- Signed distribution credential setup, notarization, validation, and CI guidance live in `docs/APPLE_DISTRIBUTION.md`.
- v0.1.3 ships as a source-only release; attaching a signed, notarized binary is blocked on Monomyth Apple distribution credentials.
- v0.1.4 ships as a source-only release; attaching a signed, notarized binary is blocked on Monomyth Apple distribution credentials.

## Build and test

Expand Down
2 changes: 1 addition & 1 deletion InferenceMeter/Core/Notifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ private enum NotificationWindow: CaseIterable, Hashable {
case .claude:
allCases
case .codex:
[.weekly]
[.fiveHour, .weekly]
}
}

Expand Down
6 changes: 4 additions & 2 deletions InferenceMeter/Model/UsageNormalizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ enum UsageNormalizer {
return unavailableUsage(provider: .codex, source: .commandLine, parsedAt: parsedAt)
}

let rateLimits = result.rateLimitsByLimitID?["codex"] ?? result.rateLimits
guard let rateLimits = result.rateLimitsByLimitID?["codex"] ?? result.rateLimits else {
return unavailableUsage(provider: .codex, source: .commandLine, parsedAt: parsedAt)
}

return codexUsage(
windows: [rateLimits.primary, rateLimits.secondary].compactMap { $0 },
Expand Down Expand Up @@ -379,7 +381,7 @@ private struct CodexAppServerRateLimitsResponseDTO: Decodable, Sendable {
}

private struct CodexAppServerRateLimitsResultDTO: Decodable, Sendable {
var rateLimits: CodexAppServerRateLimitSnapshotDTO
var rateLimits: CodexAppServerRateLimitSnapshotDTO?
var rateLimitsByLimitID: [String: CodexAppServerRateLimitSnapshotDTO]?

enum CodingKeys: String, CodingKey {
Expand Down
2 changes: 1 addition & 1 deletion InferenceMeter/UI/DetailPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ private struct ProviderDisplayConfiguration {
glyph: "⬡",
name: "Codex",
signInName: "Codex",
showsFiveHourUsage: false,
showsFiveHourUsage: true,
showsFableUsage: false
)
}
2 changes: 1 addition & 1 deletion InferenceMeter/UI/MenuBarLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ private func displayedPercentages(for usage: Usage, compact: Bool) -> [Double?]
case .claude:
percentages = [usage.fiveHourPct, usage.weeklyPct]
case .codex:
percentages = [usage.weeklyPct]
percentages = [usage.fiveHourPct, usage.weeklyPct]
}

return compact ? Array(percentages.prefix(1)) : percentages
Expand Down
34 changes: 19 additions & 15 deletions InferenceMeterTests/MenuBarLabelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,24 @@ func thresholdPredicateTreatsEqualityAsCrossingBoundary() {
#expect(isAtOrAboveUsageThreshold(95.1, threshold: 95))
}

@Test("Full mode renders Claude windows and the Codex weekly window")
func fullModeRendersClaudeWindowsAndCodexWeeklyWindow() {
@Test("Full mode renders five-hour and weekly windows for both providers")
func fullModeRendersFiveHourAndWeeklyWindowsForBothProviders() {
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 ⬡ 90")
#expect(segmentText(segments) == "✳ 45·70 ⬡ 30·90")
#expect(segments == [
MenuBarLabelSegment(text: "✳ ", color: .primary),
MenuBarLabelSegment(text: "45", color: Color(.systemGreen)),
MenuBarLabelSegment(text: "·", color: .primary),
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))
])
}
Expand All @@ -47,13 +49,13 @@ func compactModeUsesEachProvidersPrimaryDisplayedWindow() {
compact: true
)

#expect(segmentText(segments) == "✳ 45 ⬡ 55")
#expect(segmentText(segments) == "✳ 45 ⬡ 30")
#expect(segments == [
MenuBarLabelSegment(text: "✳ ", color: .primary),
MenuBarLabelSegment(text: "45", color: Color(.systemGreen)),
MenuBarLabelSegment(text: " ", color: .primary),
MenuBarLabelSegment(text: "⬡ ", color: .primary),
MenuBarLabelSegment(text: "55", color: Color(.systemGreen))
MenuBarLabelSegment(text: "30", color: Color(.systemGreen))
])
}

Expand All @@ -70,12 +72,12 @@ func staleProvidersKeepValuesVisibleInSecondaryGray() {
compact: true
)

#expect(segmentText(fullSegments) == "✳ 70·90 ⬡ 100")
#expect(segmentText(fullSegments) == "✳ 70·90 ⬡ 90·100")
#expect(fullSegments[1].color == .secondary)
#expect(fullSegments[3].color == .secondary)
#expect(fullSegments[6].color == Color(.systemRed))

#expect(segmentText(compactSegments) == "✳ 70 ⬡ 100")
#expect(segmentText(compactSegments) == "✳ 70 ⬡ 90")
#expect(compactSegments[1].color == .secondary)
#expect(compactSegments[4].color == Color(.systemRed))
}
Expand Down Expand Up @@ -110,7 +112,7 @@ func unauthorizedProvidersRenderAuthMarkersInFullAndCompactModes() {
#expect(segmentText(fullSegments) == "✳ 45·70 ⬡ !")
#expect(fullSegments.last == MenuBarLabelSegment(text: "!", color: Color(.systemOrange)))

#expect(segmentText(compactSegments) == "✳ ! ⬡ 55")
#expect(segmentText(compactSegments) == "✳ ! ⬡ 30")
#expect(compactSegments[1] == MenuBarLabelSegment(text: "!", color: Color(.systemOrange)))
}

Expand All @@ -127,12 +129,13 @@ func unavailableProvidersRenderDashesInsteadOfZeroValues() {
compact: true
)

#expect(segmentText(fullSegments) == "✳ --·-- ⬡ 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) == "✳ -- ⬡ 70")
#expect(segmentText(compactSegments) == "✳ -- ⬡ 0")
#expect(compactSegments[1].color == .secondary)
#expect(compactSegments[4].color == Color(.systemGreen))
}
Expand All @@ -145,21 +148,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: "10", color: Color(.systemGreen)))
#expect(segments[6] == MenuBarLabelSegment(text: "--", color: .secondary))
#expect(segments[8] == MenuBarLabelSegment(text: "10", color: Color(.systemGreen)))
}

@Test("Codex ignores a legacy five-hour value when weekly usage is absent")
func codexIgnoresLegacyFiveHourValueWhenWeeklyUsageIsAbsent() {
@Test("Codex renders an available five-hour value when weekly usage is absent")
func codexRendersFiveHourValueWhenWeeklyUsageIsAbsent() {
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(segmentText(segments) == "✳ 45·70 ⬡ 99·--")
#expect(segments.last == MenuBarLabelSegment(text: "--", color: .secondary))
}

Expand Down
13 changes: 10 additions & 3 deletions InferenceMeterTests/NotifierTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,20 @@ func weeklyNotificationCopyUsesWeeklyLabelAndMultiDayCountdown() async {
}

@MainActor
@Test("Codex legacy five-hour values never post notifications")
func codexLegacyFiveHourValuesNeverPostNotifications() async {
@Test("Codex five-hour values post threshold notifications")
func codexFiveHourValuesPostThresholdNotifications() async {
let fixture = NotifierFixture()

await fixture.notifier.evaluate(state: fixture.state(codexFiveHourPct: 96))

#expect(fixture.poster.notifications.isEmpty)
#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"
])
#expect(fixture.poster.notifications.map(\.identifier) == [
"inference-meter.threshold.codex.five-hour.80",
"inference-meter.threshold.codex.five-hour.95"
])
}

@MainActor
Expand Down
27 changes: 27 additions & 0 deletions InferenceMeterTests/UsageNormalizerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,33 @@ func codexAppServerResponseKeepsCurrentAvailableWindows() {
#expect(usage.updatedAt == parsedAt)
}

@Test("Codex app-server response maps restored five-hour and weekly windows by duration")
func codexAppServerResponseMapsRestoredWindowsByDuration() {
let parsedAt = Date(timeIntervalSince1970: 1_800_000_000)
let payload = data("""
{
"id": 2,
"result": {
"rateLimitsByLimitId": {
"codex": {
"limitId": "codex",
"primary": {"usedPercent": 42, "windowDurationMins": 10080, "resetsAt": 1800604800},
"secondary": {"usedPercent": 17, "windowDurationMins": 300, "resetsAt": 1800003600}
}
}
}
}
""")

let usage = UsageNormalizer.codexAppServerRateLimits(from: payload, parsedAt: parsedAt)

#expect(usage.state == .ok)
#expect(isClose(usage.fiveHourPct, to: 17))
#expect(isClose(usage.weeklyPct, to: 42))
#expect(usage.fiveHourResetsAt == Date(timeIntervalSince1970: 1_800_003_600))
#expect(usage.weeklyResetsAt == Date(timeIntervalSince1970: 1_800_604_800))
}

@Test("Codex app-server response rejects an expired legacy window")
func codexAppServerResponseRejectsExpiredLegacyWindow() {
let parsedAt = Date(timeIntervalSince1970: 1_800_000_000)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

A private, native macOS menu bar meter for Claude Code and Codex usage limits.

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%.
InferenceMeter keeps Claude's and Codex's provider-reported five-hour and weekly windows 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.
Expand Down
8 changes: 4 additions & 4 deletions docs/APPLE_DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ Apple's supported command-line flow is documented in [Customizing the notarizati

A public binary must match the commit referenced by its Git tag. Do not build an asset from a newer `main` commit and attach it to an older release.

For the existing v0.1.3 source release, use a clean checkout or temporary worktree at that tag:
For the existing v0.1.4 source release, use a clean checkout or temporary worktree at that tag:

```sh
git fetch origin --tags
git worktree add ../inference-meter-v0.1.3 v0.1.3
cd ../inference-meter-v0.1.3
git worktree add ../inference-meter-v0.1.4 v0.1.4
cd ../inference-meter-v0.1.4
git status --short
```

Expand Down Expand Up @@ -148,7 +148,7 @@ Apple's current notarization requirements are documented in [Notarizing macOS so
Set the release version and app paths:

```sh
VERSION="0.1.3"
VERSION="0.1.4"
APP_PATH="DerivedData/Release/Build/Products/Release/InferenceMeter.app"
ZIP_PATH="dist/InferenceMeter-${VERSION}.zip"
```
Expand Down
4 changes: 2 additions & 2 deletions project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ options:
settings:
base:
SWIFT_VERSION: "6.0"
MARKETING_VERSION: "0.1.3"
CURRENT_PROJECT_VERSION: "5"
MARKETING_VERSION: "0.1.4"
CURRENT_PROJECT_VERSION: "6"
CODE_SIGN_STYLE: Automatic
CODE_SIGN_IDENTITY: "-"
ENABLE_HARDENED_RUNTIME: NO
Expand Down
Loading