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

## [Unreleased]

### Changed

- Show the five-hour window for Claude and Codex only when the provider reports it. Accounts without one no longer see `--` in the menu bar label or a "5-hour Unavailable" row, and the compact label shows the weekly value instead. The window returns on the next refresh if the provider reports it again.

### Fixed

- Stop a Codex session file from reviving a five-hour value whose window has since reset when the CLI cannot report current limits.
- Stop the repeated macOS Keychain password prompts for `Claude Code-credentials`. The app now reads the credential through `/usr/bin/security`, as Claude Code does, instead of requesting direct access that **Always Allow** could not satisfy.

## [0.1.4] - 2026-07-13
Expand Down
7 changes: 7 additions & 0 deletions InferenceMeter/Model/Usage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ struct Usage: Sendable, Equatable {
}

extension Usage {
/// The single rule for showing a five-hour window, for every provider: the current
/// data has a value for it. Plans do not predict this, and an idle window is still
/// reported at 0%, so an absent value means the window does not apply right now.
var reportsFiveHourWindow: Bool {
fiveHourPct != nil
}

static func unavailable(
provider: Provider,
source: UsageSource = .localFile,
Expand Down
28 changes: 26 additions & 2 deletions InferenceMeter/Providers/CodexProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ struct CodexProvider: UsageProvider {
private let authFileReader: FileReader
private let tokenStore: TokenStore
private let appServerClient: CodexAppServerClient?
private let now: @Sendable () -> Date

init(
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
endpointConfiguration: CodexEndpointConfiguration? = nil,
session: URLSession = .shared,
authFileReader: FileReader = FileReader(),
tokenStore: TokenStore = TokenStore(),
appServerClient: CodexAppServerClient? = nil
appServerClient: CodexAppServerClient? = nil,
now: @escaping @Sendable () -> Date = { Date() }
) {
self.homeDirectory = homeDirectory
self.endpointConfiguration = endpointConfiguration
Expand All @@ -36,6 +38,7 @@ struct CodexProvider: UsageProvider {
self.appServerClient = appServerClient ?? Self.defaultAppServerClient(
homeDirectory: homeDirectory
)
self.now = now
}

func refresh() async -> Usage {
Expand Down Expand Up @@ -135,7 +138,28 @@ private extension CodexProvider {
}
}

return accumulator.usage ?? unavailableUsage(source: .localFile)
guard let usage = accumulator.usage else {
return unavailableUsage(source: .localFile)
}

return withoutElapsedFiveHourWindow(usage)
}

/// The scan judges a window against when its event was written. A snapshot can outlive
/// that window, and it must not bring back a five-hour window that has since reset.
func withoutElapsedFiveHourWindow(_ usage: Usage) -> Usage {
guard let resetsAt = usage.fiveHourResetsAt, resetsAt <= now() else {
return usage
}

guard usage.weeklyPct != nil else {
return unavailableUsage(source: .localFile)
}

var currentUsage = usage
currentUsage.fiveHourPct = nil
currentUsage.fiveHourResetsAt = nil
return currentUsage
}

func recentRolloutFiles(limit: Int = Self.maxRecentRolloutFiles) throws -> [URL] {
Expand Down
70 changes: 44 additions & 26 deletions InferenceMeter/UI/DetailPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,29 +276,11 @@ private struct ProviderUsageSection: View {
}

VStack(alignment: .leading, spacing: 8) {
if configuration.showsFiveHourUsage {
ForEach(configuration.displayedWindows(for: usage), id: \.title) { window in
UsageWindowRow(
title: "5-hour",
percentage: usage.fiveHourPct,
resetsAt: usage.fiveHourResetsAt,
state: usage.state,
now: now
)
}

UsageWindowRow(
title: "Weekly",
percentage: usage.weeklyPct,
resetsAt: usage.weeklyResetsAt,
state: usage.state,
now: now
)

if configuration.showsFableUsage {
UsageWindowRow(
title: "Fable",
percentage: usage.fablePct,
resetsAt: usage.fableResetsAt,
title: window.title,
percentage: window.percentage,
resetsAt: window.resetsAt,
state: usage.state,
now: now
)
Expand Down Expand Up @@ -477,26 +459,62 @@ private struct StatusBadge: View {
}
}

private struct ProviderDisplayConfiguration {
struct DisplayedUsageWindow: Equatable {
let title: String
let percentage: Double?
let resetsAt: Date?
}

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
)

static let codex = ProviderDisplayConfiguration(
glyph: "⬡",
name: "Codex",
signInName: "Codex",
showsFiveHourUsage: true,
showsFableUsage: false
)

func displayedWindows(for usage: Usage) -> [DisplayedUsageWindow] {
var windows = [
DisplayedUsageWindow(
title: "Weekly",
percentage: usage.weeklyPct,
resetsAt: usage.weeklyResetsAt
)
]

if usage.reportsFiveHourWindow {
windows.insert(
DisplayedUsageWindow(
title: "5-hour",
percentage: usage.fiveHourPct,
resetsAt: usage.fiveHourResetsAt
),
at: 0
)
}

if showsFableUsage {
windows.append(
DisplayedUsageWindow(
title: "Fable",
percentage: usage.fablePct,
resetsAt: usage.fableResetsAt
)
)
}

return windows
}
}
11 changes: 3 additions & 8 deletions InferenceMeter/UI/MenuBarLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,9 @@ private func providerSegments(usage: Usage, compact: Bool) -> [MenuBarLabelSegme
}

private func displayedPercentages(for usage: Usage, compact: Bool) -> [Double?] {
let percentages: [Double?]

switch usage.provider {
case .claude:
percentages = [usage.fiveHourPct, usage.weeklyPct]
case .codex:
percentages = [usage.fiveHourPct, usage.weeklyPct]
}
let percentages = usage.reportsFiveHourWindow
? [usage.fiveHourPct, usage.weeklyPct]
: [usage.weeklyPct]

return compact ? Array(percentages.prefix(1)) : percentages
}
Expand Down
20 changes: 12 additions & 8 deletions InferenceMeterTests/CodexProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ func codexProviderFallsBackWhenCLIInterfaceIsUnavailable() async throws {
)
let provider = CodexProvider(
homeDirectory: home,
appServerClient: CodexAppServerClient { nil }
appServerClient: CodexAppServerClient { nil },
now: { beforeFixtureResets }
)

let usage = await provider.refresh()
Expand Down Expand Up @@ -107,7 +108,7 @@ func codexProviderMapsShuffledWindowsByMinutes() async throws {
contents: try fixtureString(named: "codex-rollout-shuffled-windows.jsonl")
)

let usage = await CodexProvider(homeDirectory: home).refresh()
let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh()

#expect(usage.state == .ok)
#expect(usage.source == .localFile)
Expand All @@ -127,7 +128,7 @@ func codexProviderTailScanSelectsLastRateLimitEvent() async throws {
contents: try fixtureString(named: "codex-rollout-multi-event.jsonl")
)

let usage = await CodexProvider(homeDirectory: home).refresh()
let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh()

#expect(usage.state == .ok)
#expect(isClose(usage.fiveHourPct, to: 33))
Expand All @@ -148,7 +149,7 @@ func codexProviderMergesSparseRateLimitUpdatesByWindowDuration() async throws {
"""
)

let usage = await CodexProvider(homeDirectory: home).refresh()
let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh()

#expect(usage.state == .ok)
#expect(isClose(usage.fiveHourPct, to: 3))
Expand Down Expand Up @@ -179,7 +180,7 @@ func codexProviderMergesCurrentWindowsAcrossRecentRolloutFiles() async throws {
modificationDate: isoDate("2026-07-12T19:55:32Z")
)

let usage = await CodexProvider(homeDirectory: home).refresh()
let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh()

#expect(usage.state == .ok)
#expect(isClose(usage.fiveHourPct, to: 3))
Expand Down Expand Up @@ -217,7 +218,7 @@ func codexProviderParsesNestedRateLimitPayload() async throws {
"""
)

let usage = await CodexProvider(homeDirectory: home).refresh()
let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh()

#expect(usage.state == .ok)
#expect(isClose(usage.fiveHourPct, to: 17))
Expand Down Expand Up @@ -278,7 +279,7 @@ func codexProviderWalksNewestSessionDirectoryWithoutGlobalFileSort() async throw
modificationDate: Date(timeIntervalSince1970: 1_800_000_200)
)

let usage = await CodexProvider(homeDirectory: home).refresh()
let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh()

#expect(usage.state == .ok)
#expect(isClose(usage.fiveHourPct, to: 55))
Expand All @@ -300,7 +301,7 @@ func codexProviderReadsLargeRolloutTail() async throws {

try writeRollout(home: home, contents: contents)

let usage = await CodexProvider(homeDirectory: home).refresh()
let usage = await CodexProvider(homeDirectory: home, now: { beforeFixtureResets }).refresh()

#expect(usage.state == .ok)
#expect(isClose(usage.fiveHourPct, to: 61))
Expand Down Expand Up @@ -508,6 +509,9 @@ func codexProviderUnauthorizedEndpointDoesNotFallBackToLocalUsage() async throws
}
}

// Earlier than every reset time in the rollout data these tests write and read.
private let beforeFixtureResets = Date(timeIntervalSince1970: 1_782_000_000)

private final class CodexProviderFixtureBundleMarker {}

private func withTemporaryHome(_ body: (URL) async throws -> Void) async throws {
Expand Down
Loading
Loading