diff --git a/CHANGELOG.md b/CHANGELOG.md index 83b633b..70f3bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index e6777da..4e2906d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/InferenceMeter/Core/Notifier.swift b/InferenceMeter/Core/Notifier.swift index cbaefe3..b4468ed 100644 --- a/InferenceMeter/Core/Notifier.swift +++ b/InferenceMeter/Core/Notifier.swift @@ -248,7 +248,7 @@ private enum NotificationWindow: CaseIterable, Hashable { case .claude: allCases case .codex: - [.weekly] + [.fiveHour, .weekly] } } diff --git a/InferenceMeter/Model/UsageNormalizer.swift b/InferenceMeter/Model/UsageNormalizer.swift index 603fa59..b69b316 100644 --- a/InferenceMeter/Model/UsageNormalizer.swift +++ b/InferenceMeter/Model/UsageNormalizer.swift @@ -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 }, @@ -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 { diff --git a/InferenceMeter/UI/DetailPopover.swift b/InferenceMeter/UI/DetailPopover.swift index 05bf477..6393f02 100644 --- a/InferenceMeter/UI/DetailPopover.swift +++ b/InferenceMeter/UI/DetailPopover.swift @@ -496,7 +496,7 @@ private struct ProviderDisplayConfiguration { glyph: "⬡", name: "Codex", signInName: "Codex", - showsFiveHourUsage: false, + showsFiveHourUsage: true, showsFableUsage: false ) } diff --git a/InferenceMeter/UI/MenuBarLabel.swift b/InferenceMeter/UI/MenuBarLabel.swift index 3db6ce8..01885f5 100644 --- a/InferenceMeter/UI/MenuBarLabel.swift +++ b/InferenceMeter/UI/MenuBarLabel.swift @@ -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 diff --git a/InferenceMeterTests/MenuBarLabelTests.swift b/InferenceMeterTests/MenuBarLabelTests.swift index a91d273..ac90fa7 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 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)), @@ -35,6 +35,8 @@ func fullModeRendersClaudeWindowsAndCodexWeeklyWindow() { 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)) ]) } @@ -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)) ]) } @@ -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)) } @@ -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))) } @@ -127,12 +129,13 @@ func unavailableProvidersRenderDashesInsteadOfZeroValues() { compact: true ) - #expect(segmentText(fullSegments) == "✳ --·-- ⬡ 70") + #expect(segmentText(fullSegments) == "✳ --·-- ⬡ 0·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)) } @@ -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)) } diff --git a/InferenceMeterTests/NotifierTests.swift b/InferenceMeterTests/NotifierTests.swift index 609047a..ff78a18 100644 --- a/InferenceMeterTests/NotifierTests.swift +++ b/InferenceMeterTests/NotifierTests.swift @@ -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 diff --git a/InferenceMeterTests/UsageNormalizerTests.swift b/InferenceMeterTests/UsageNormalizerTests.swift index 81c0579..79a40fb 100644 --- a/InferenceMeterTests/UsageNormalizerTests.swift +++ b/InferenceMeterTests/UsageNormalizerTests.swift @@ -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) diff --git a/README.md b/README.md index 0aa8fc0..70acf98 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 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. diff --git a/docs/APPLE_DISTRIBUTION.md b/docs/APPLE_DISTRIBUTION.md index fa45574..920e62d 100644 --- a/docs/APPLE_DISTRIBUTION.md +++ b/docs/APPLE_DISTRIBUTION.md @@ -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 ``` @@ -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" ``` diff --git a/project.yml b/project.yml index 5bf196c..d757abc 100644 --- a/project.yml +++ b/project.yml @@ -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