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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ shows only what each provider's API reports for the accounts you sign in to.
Animations follow macOS's Reduce Motion setting.

### Changed
- Dashboard: Today, Yesterday, and every other time range always show, at 0 when the provider reports
nothing for them. Claude, Cursor, and Copilot show Extra Usage at 0 when it's off. An account's page
still hides any of them with its switch.
- Panel: content scrolls cleanly under the top bar, with no glass backdrop, and blurs softly under the
bottom bar only while something is under it. The account name in an account page's header fades its
hover highlight in.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,9 @@ and a workspace's credit limit.
every row between used and left, a reset between countdown and exact time. Rest the pointer on a bar
to read where your pace lands at the reset, on a value to read its other side, on a reset to read
the other format, and on the warning or clock after an account's name to read why it is there.
A usage range shows the provider's total or sums the days it reported. Open its breakdown to see
model usage and whether any days are missing, including after reopening the app.
A usage range shows the provider's total or sums the days it reported, and reads 0 when it reported
none. Open its breakdown to see model usage and whether any days are missing, including after
reopening the app.
Right-click a header, a card, an account, or a row for its actions. Drag to reorder.
- **Metrics per account.** An account's page in Accounts has a switch per metric to hide it everywhere,
a pin to list it under Pinned at the top of Priority, and a handle to drag its metrics into order.
Expand Down
7 changes: 5 additions & 2 deletions Sources/OpenUsage/Models/HistoryRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ enum HistoryRow: Hashable, Sendable {
guard let history else { return }
var breakdowns: [String: UsageBreakdown] = [:]
for descriptor in descriptors {
guard let row = descriptor.historyRow, let line = row.line(label: descriptor.metricLabel, in: history) else { continue }
snapshot.lines.append(line)
guard let row = descriptor.historyRow else { continue }
if let line = row.line(label: descriptor.metricLabel, in: history) { snapshot.lines.append(line) }
breakdowns[descriptor.key] = row.breakdown(in: history)
}
snapshot.usageBreakdowns = breakdowns.isEmpty ? nil : breakdowns
Expand All @@ -59,6 +59,9 @@ extension WidgetDescriptor {
static func history(key: String, provider: Provider, _ row: HistoryRow) -> WidgetDescriptor {
var descriptor = values(key: key, provider: provider, title: row.title)
descriptor.historyRow = row
if case .window(_, let first, let then) = row {
descriptor.unreportedValues = [first, then].compactMap(\.self).map { $0.metricValue(0) }
}
return descriptor
}
}
9 changes: 5 additions & 4 deletions Sources/OpenUsage/Models/UsageHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ struct UsageBreakdown: Codable, Hashable, Sendable {
/// dollars nor tokens were reported by model.
var modelFigures: [ModelFigure] { ModelFigure.allCases.filter { rankings[$0] != nil } }

init?(_ window: UsageWindow, in history: UsageHistory) {
guard let totals = history.totals(over: window) else { return nil }
init(_ window: UsageWindow, in history: UsageHistory) {
let totals = history.totals(over: window) ?? UsageTotals()
let models = history.models(over: window)
func isReported(_ measure: UsageMeasure) -> Bool {
models.values.contains { ($0[measure] ?? 0) > 0 }
Expand Down Expand Up @@ -285,9 +285,10 @@ struct UsageBreakdown: Codable, Hashable, Sendable {
return ranked.prefix(Self.shownModels) + [Model(name: "Other", dollars: sum(\.dollars), tokens: sum(\.tokens), share: sum(\.share))]
}

/// Every figure is zero, so there is nothing to show beyond a zero total. Unread days can still have activity.
/// Every figure is zero, so there is nothing to show beyond a zero total. Unread days can still have
/// activity, unless nothing at all was reported for the range.
var isEmpty: Bool {
unreadDays == 0 && totals.values.values.allSatisfy { $0 == 0 }
(unreadDays == 0 || totals.values.isEmpty) && totals.values.values.allSatisfy { $0 == 0 }
}

/// What the window charged, then its tokens and any spend on the user's own keys. A window that
Expand Down
7 changes: 7 additions & 0 deletions Sources/OpenUsage/Models/WidgetDescriptor+Factories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ extension WidgetDescriptor {
sample: WidgetData(title: title, kind: .count, used: 0, limit: nil))
}

/// The row shows `values` when the provider reports nothing for it, instead of hiding.
func showingWhenUnreported(_ values: [MetricValue]) -> WidgetDescriptor {
var descriptor = self
descriptor.unreportedValues = values
return descriptor
}

private static func make(
key: String,
provider: Provider,
Expand Down
2 changes: 2 additions & 0 deletions Sources/OpenUsage/Models/WidgetDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ struct WidgetDescriptor: Identifiable, Hashable {
let sample: WidgetData
var limitResources: [LimitResourceDescriptor] = []
var historyRow: HistoryRow?
/// What the row reads when the provider reports nothing for it; nil hides the row instead.
var unreportedValues: [MetricValue]?

init(provider: Provider, key: String, metricLabel: String, sample: WidgetData) {
id = "\(provider.id).\(key)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ extension AccountProviderRuntime {
.exportingLimit("fable", unit: "percent"),
.boundedDollars(key: "extra", provider: provider, title: "Extra Usage", metricLabel: "Extra usage spent",
limit: 100, valueWord: "spent")
.exportingLimit("extraUsage", unit: "usd", source: .progressOrValue(kind: .dollars)),
.exportingLimit("extraUsage", unit: "usd", source: .progressOrValue(kind: .dollars))
.showingWhenUnreported([MetricValue(number: 0, kind: .dollars)]),
rateLimitResets(key: "rateLimitResets", provider: provider)
]
case .codex:
Expand Down Expand Up @@ -51,7 +52,8 @@ extension AccountProviderRuntime {
.exportingLimit("apiUsage", unit: "percent"),
.boundedDollars(key: "onDemand", provider: provider, title: "Extra Usage", metricLabel: "On-demand",
limit: 100, valueWord: "spent")
.exportingLimit("onDemand", unit: "usd", source: .progressOrValue(kind: .dollars)),
.exportingLimit("onDemand", unit: "usd", source: .progressOrValue(kind: .dollars))
.showingWhenUnreported([MetricValue(number: 0, kind: .dollars)]),
.percent(key: "grokBot", provider: provider, title: "Grok Bot", metricLabel: "Grok Bot usage")
.exportingLimit("grokBot", unit: "percent"),
.boundedCount(key: "requests", provider: provider, title: "Requests", limit: 500,
Expand Down Expand Up @@ -90,7 +92,8 @@ extension AccountProviderRuntime {
.percent(key: "premium", provider: provider, title: "Credits", metricLabel: "Credits")
.exportingLimit("premiumCredits", unit: "credits", source: .progressOrValue(kind: .count)),
.values(key: "extra", provider: provider, title: "Extra Usage")
.exportingLimit("extraUsage", unit: "count", source: .value(kind: .count)),
.exportingLimit("extraUsage", unit: "count", source: .value(kind: .count))
.showingWhenUnreported([MetricValue(number: 0, kind: .count)]),
.boundedCount(key: "chat", provider: provider, title: "Chat", limit: 0)
.exportingLimit("chat", unit: "percent"),
.boundedCount(key: "completions", provider: provider, title: "Completions", limit: 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct OpenRouterUsageClient: Sendable {

/// Disabled and expired keys count too: what they spent before they stopped is still this period's spend.
func listedKeysSpend(apiKey: String) async throws -> [UsageWindow: UsageTotals] {
var total = OpenRouterUsageMapper.noSpend
var total: [UsageWindow: UsageTotals] = [:]
for page in 0..<Self.maxKeysPages {
let url = "\(Self.keysURL)?include_disabled=true&include_expired=true&offset=\(page * Self.keysPageSize)"
let keys = try OpenRouterUsageMapper.keysPage(try await get(url, apiKey: apiKey))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,6 @@ struct OpenRouterKey: Equatable, Sendable {
}

enum OpenRouterUsageMapper {
/// Spend today, this week and this month, before any key is added.
static let noSpend: [UsageWindow: UsageTotals] = [
.today: .zero([.spend, .byokSpend]), .thisWeek: .zero([.spend, .byokSpend]), .thisMonth: .zero([.spend, .byokSpend])
]

/// A UTC day with no activity row.
static let quietDay = UsageDay(
totals: .zero([.spend, .byokSpend, .tokens, .inputTokens, .cacheReadTokens, .outputTokens, .reasoningTokens]),
Expand Down
3 changes: 2 additions & 1 deletion Sources/OpenUsage/Stores/WidgetDataStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,8 @@ final class WidgetDataStore {
func data(for descriptor: WidgetDescriptor) -> WidgetData {
var result: WidgetData
if let snapshot = snapshots[descriptor.providerID],
let line = snapshot.line(label: descriptor.metricLabel),
let line = snapshot.line(label: descriptor.metricLabel)
?? descriptor.unreportedValues.map({ .values(label: descriptor.metricLabel, values: $0) }),
let data = resolve(line, descriptor: descriptor) {
result = data
} else {
Expand Down
12 changes: 7 additions & 5 deletions Tests/OpenUsageTests/CodexUsageHistoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,14 +224,15 @@ final class CodexUsageHistoryTests: XCTestCase {
}
}

func testADayTheCountsDoNotReportYetShowsNoTokensUntilALaterReadReportsIt() async throws {
func testADayTheCountsDoNotReportYetReadsZeroTokensWithNothingToShow() async throws {
let reportsToday = TestSwitch(false)
let (store, runtime, _, clock) = try makeAccount(reportsToday: reportsToday)

_ = await store.refresh(providerID: runtime.provider.id, force: true)
XCTAssertEqual(rows(store, runtime), [
"yesterday": "8.1M tokens", "last30": "2B tokens"
], "an unreported day is not a day of zero tokens, and the range sums the days that report")
"today": "0 tokens", "yesterday": "8.1M tokens", "last30": "2B tokens"
], "a day with no use still shows Today, and the range sums the days that report")
XCTAssertTrue(try XCTUnwrap(store.usageBreakdown(for: try descriptor(runtime, "today"))).isEmpty)

reportsToday.value = true
clock.set(now.addingTimeInterval(61 * 60))
Expand Down Expand Up @@ -271,7 +272,7 @@ final class CodexUsageHistoryTests: XCTestCase {

_ = await store.refresh(providerID: runtime.provider.id, force: true)
XCTAssertNil(store.errorMessage(for: runtime.provider.id))
XCTAssertEqual(rows(store, runtime), [:])
XCTAssertEqual(rows(store, runtime), ["today": "0 tokens", "yesterday": "0 tokens", "last30": "0 tokens"])
XCTAssertEqual(store.data(for: try descriptor(runtime, "weekly")).used, 29)

reports.value = nil
Expand All @@ -287,7 +288,8 @@ final class CodexUsageHistoryTests: XCTestCase {
reports.value = 503
clock.set(now.addingTimeInterval(122 * 60))
_ = await store.refresh(providerID: runtime.provider.id, force: true)
XCTAssertEqual(Set(rows(store, runtime).keys), ["last30"])
XCTAssertEqual(rows(store, runtime)["today"], "0 tokens")
XCTAssertEqual(rows(store, runtime)["yesterday"], "0 tokens")
XCTAssertEqual(store.usageBreakdown(for: month)?.totals[.tokens], 2_006_800_581,
"Oct 2 and Oct 29 remain available after the reread of today and yesterday fails")
}
Expand Down
6 changes: 4 additions & 2 deletions Tests/OpenUsageTests/CursorMetricsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ final class CursorMetricsTests: XCTestCase {
let (plan, data, paths) = try await refresh(usage: Self.individualUsage, planName: "Ultra")

XCTAssertEqual(plan, "Ultra")
XCTAssertEqual(Set(data.keys), ["Total Usage", "Cursor Models", "Other Models", "Extra Usage", "Grok Bot", "Credits"])
XCTAssertEqual(Set(data.keys), ["Total Usage", "Cursor Models", "Other Models", "Extra Usage", "Grok Bot", "Credits",
"Today", "Yesterday", "Last 30 Days"])
XCTAssertEqual(data["Total Usage"]?.used, 25)
XCTAssertEqual(data["Cursor Models"]?.used, 20)
XCTAssertEqual(data["Other Models"]?.used, 40)
Expand All @@ -136,7 +137,8 @@ final class CursorMetricsTests: XCTestCase {
)

XCTAssertEqual(plan, "Enterprise", "GetPlanInfo says \(planName)")
XCTAssertEqual(Set(data.keys), ["Total Usage", "Requests", "Extra Usage", "Grok Bot"], planName)
XCTAssertEqual(Set(data.keys), ["Total Usage", "Requests", "Extra Usage", "Grok Bot", "Today", "Yesterday", "Last 30 Days"],
planName)
XCTAssertEqual(data["Requests"]?.kind, .count)
XCTAssertEqual(data["Requests"]?.used, 120)
XCTAssertEqual(data["Requests"]?.limit, 500)
Expand Down
7 changes: 4 additions & 3 deletions Tests/OpenUsageTests/CursorUsageHistoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ final class CursorUsageHistoryTests: XCTestCase {

_ = await store.refresh(providerID: runtime.provider.id, force: true)
XCTAssertNil(store.errorMessage(for: runtime.provider.id))
XCTAssertEqual(spend(store, runtime), [:], "nothing read yet, so no row claims a value")
XCTAssertEqual(spend(store, runtime), ["Today": 0, "Yesterday": 0, "Last 30 Days": 0], "nothing read yet, so every range reads $0")
let totalUsage = try XCTUnwrap(runtime.widgetDescriptors.first { $0.key == "usage" })
XCTAssertEqual(store.data(for: totalUsage).used, 25)

Expand All @@ -179,7 +179,8 @@ final class CursorUsageHistoryTests: XCTestCase {
_ = await store.refresh(providerID: runtime.provider.id, force: true)

XCTAssertNil(store.errorMessage(for: runtime.provider.id))
XCTAssertEqual(spend(store, runtime), ["Last 30 Days": 35], "28 complete days at $1.25 after today and yesterday failed")
XCTAssertEqual(spend(store, runtime), ["Today": 0, "Yesterday": 0, "Last 30 Days": 35],
"28 complete days at $1.25 after today and yesterday failed")
}

/// Each rate-limited request takes 15 seconds, so the wait has to start when the 429 arrives.
Expand All @@ -198,7 +199,7 @@ final class CursorUsageHistoryTests: XCTestCase {
clock.set(answered.addingTimeInterval(wait - 1))
_ = await store.refresh(providerID: runtime.provider.id, force: true)
XCTAssertEqual(eventRequests(http).count, firstSweep, "no history read before the wait ends")
XCTAssertEqual(spend(store, runtime), [:])
XCTAssertEqual(spend(store, runtime), ["Today": 0, "Yesterday": 0, "Last 30 Days": 0])

clock.set(answered.addingTimeInterval(wait + 1))
_ = await store.refresh(providerID: runtime.provider.id, force: true)
Expand Down
11 changes: 7 additions & 4 deletions Tests/OpenUsageTests/OllamaUsageHistoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,14 @@ final class OllamaUsageHistoryTests: XCTestCase {
XCTAssertEqual(rows(store, runtime), Self.fullRows)
}

func testALegacyPlanThatReportsOnlyRequestsShowsOnlyTheRequestsRow() async throws {
func testALegacyPlanThatReportsOnlyRequestsShowsItsRequestsAndZeroRanges() async throws {
let (store, runtime, _, _) = try makeAccount(legacy: true)

_ = await store.refresh(providerID: runtime.provider.id, force: true)

XCTAssertEqual(rows(store, runtime), ["requests": "120 in 30 days"])
XCTAssertEqual(rows(store, runtime), [
"today": "$0 · 0 tokens", "yesterday": "$0 · 0 tokens", "last30": "$0 · 0 tokens", "requests": "120 in 30 days"
])
}

func testASuccessfulReadCoversOmittedDaysWithoutInventingFigures() async {
Expand All @@ -133,13 +135,14 @@ final class OllamaUsageHistoryTests: XCTestCase {
XCTAssertTrue(failed.days.isEmpty)
}

func testAFailedReadHidesTheRowsKeepsTheCardAndWaitsAnHour() async throws {
func testAFailedReadZeroesTheRangesKeepsTheCardAndWaitsAnHour() async throws {
let status = TestSwitch<Int?>(500)
let (store, runtime, http, clock) = try makeAccount(usage: status)

_ = await store.refresh(providerID: runtime.provider.id, force: true)
XCTAssertNil(store.errorMessage(for: runtime.provider.id))
XCTAssertEqual(rows(store, runtime), [:])
XCTAssertEqual(rows(store, runtime), ["today": "$0 · 0 tokens", "yesterday": "$0 · 0 tokens", "last30": "$0 · 0 tokens"],
"Requests has no range to show")
let monthly = try XCTUnwrap(runtime.widgetDescriptors.first { $0.key == "monthly" })
XCTAssertTrue(store.data(for: monthly).hasData)

Expand Down
16 changes: 11 additions & 5 deletions Tests/OpenUsageTests/OpenRouterProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ final class OpenRouterProviderTests: XCTestCase {
return values.first?.number
}

/// A standard key and a Management key whose activity can't be read, each with nothing spent: Today,
/// This Week and This Month read $0 with nothing beside it, and the Today popup has nothing to show.
/// A standard key, and a Management key whose activity can't be read or is empty, each with nothing spent:
/// every range reads $0 with nothing beside it, and each popup has nothing to show.
func testAKeyThatSpentNothingShowsZeroDollarsAndNoBYOK() async throws {
let standard = OpenRouterFixtures.standardKey
.replacingOccurrences(of: #""usage_daily":1.5,"usage_weekly":4,"usage_monthly":12"#, with: #""usage_daily":0,"usage_weekly":0,"usage_monthly":0"#)
Expand All @@ -92,6 +92,13 @@ final class OpenRouterProviderTests: XCTestCase {
case "/api/v1/activity": OpenRouterFixtures.response(500)
default: OpenRouterFixtures.managementAccount(request)
}
},
{ request in
switch request.url.path() {
case "/api/v1/keys": OpenRouterFixtures.response(200, OpenRouterFixtures.keysPage(1, daily: 0, weekly: 0, monthly: 0))
case "/api/v1/activity": OpenRouterFixtures.response(200, #"{"data":[]}"#)
default: OpenRouterFixtures.managementAccount(request)
}
}
]
for handler in handlers {
Expand All @@ -100,12 +107,11 @@ final class OpenRouterProviderTests: XCTestCase {

_ = await store.refresh(providerID: runtime.provider.id, force: true)

for key in ["today", "week", "month"] {
for key in ["today", "yesterday", "week", "month", "prior30"] {
let descriptor = try XCTUnwrap(runtime.widgetDescriptors.first { $0.key == key })
XCTAssertEqual(store.data(for: descriptor).dashboardRow(now: Self.now).mark, .value("$0"), key)
XCTAssertTrue(try XCTUnwrap(store.usageBreakdown(for: descriptor), key).isEmpty, key)
}
let today = try XCTUnwrap(runtime.widgetDescriptors.first { $0.key == "today" })
XCTAssertTrue(try XCTUnwrap(store.usageBreakdown(for: today)).isEmpty)
}
}

Expand Down
4 changes: 2 additions & 2 deletions Tests/OpenUsageTests/OpenRouterUsageHistoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ final class OpenRouterUsageHistoryTests: XCTestCase {
XCTAssertEqual(rows(store, runtime)["prior30"], "$43.50")
}

func testAFailedActivityReadHidesItsRowsKeepsTheCardAndWaitsAnHour() async throws {
func testAFailedActivityReadZeroesItsRangesKeepsTheCardAndWaitsAnHour() async throws {
let activity = TestSwitch<Int?>(500)
let clock = TestClock(Self.now)
let (store, runtime, http) = try makeAccount(activity: activity, clock: clock)
Expand All @@ -131,7 +131,7 @@ final class OpenRouterUsageHistoryTests: XCTestCase {
await refresh(store, runtime)

XCTAssertNil(store.errorMessage(for: runtime.provider.id))
XCTAssertEqual(rows(store, runtime), ["today": "$26.50", "week": "$54", "month": "$112"])
XCTAssertEqual(rows(store, runtime), ["today": "$26.50", "yesterday": "$0", "week": "$54", "month": "$112", "prior30": "$0"])
XCTAssertEqual(activityDates(http), ["all"])

clock.set(Self.now.addingTimeInterval(3_660))
Expand Down
Loading
Loading