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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ shows only what each provider's API reports for the accounts you sign in to.

### 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.
nothing for them. An account's page still hides any of them with its switch.
- Dashboard: balances always show, even at $0, including Cursor Credits when Cursor reports none. Extra
Usage shows only while the provider has it on, even at $0, and hides while it is off: Claude,
ChatGPT, Cursor, Copilot, and Grok, whose "Disabled" row is gone.
- 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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ and a workspace's credit limit.
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, 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.
reopening the app. A balance shows even at $0. Extra Usage shows while the provider has it on, even
at $0, and hides while it is off.
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: 0 additions & 7 deletions Sources/OpenUsage/Models/WidgetDescriptor+Factories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,6 @@ 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
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ 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))
.showingWhenUnreported([MetricValue(number: 0, kind: .dollars)]),
.exportingLimit("extraUsage", unit: "usd", source: .progressOrValue(kind: .dollars)),
rateLimitResets(key: "rateLimitResets", provider: provider)
]
case .codex:
Expand Down Expand Up @@ -52,8 +51,7 @@ 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))
.showingWhenUnreported([MetricValue(number: 0, kind: .dollars)]),
.exportingLimit("onDemand", unit: "usd", source: .progressOrValue(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 @@ -92,8 +90,7 @@ 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))
.showingWhenUnreported([MetricValue(number: 0, kind: .count)]),
.exportingLimit("extraUsage", unit: "count", source: .value(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
11 changes: 3 additions & 8 deletions Sources/OpenUsage/Providers/Claude/ClaudeUsageMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,22 +105,17 @@ enum ClaudeUsageMapper {
}

private static func appendExtraUsage(_ value: Any?, to lines: inout [MetricLine]) {
guard let object = value as? [String: Any],
object["is_enabled"] as? Bool == true,
let usedCents = ProviderParse.number(object["used_credits"])
else {
return
}
guard let object = value as? [String: Any], object["is_enabled"] as? Bool == true else { return }

let used = ProviderParse.centsToDollars(usedCents)
let used = ProviderParse.centsToDollars(ProviderParse.number(object["used_credits"]) ?? 0)
if let limitCents = ProviderParse.number(object["monthly_limit"]), limitCents > 0 {
lines.append(.progress(
label: "Extra usage spent",
used: used,
limit: ProviderParse.centsToDollars(limitCents),
format: .dollars
))
} else if used > 0 {
} else {
lines.append(.values(label: "Extra usage spent", values: [MetricValue(number: used, kind: .dollars)]))
}
}
Expand Down
13 changes: 4 additions & 9 deletions Sources/OpenUsage/Providers/Codex/CodexUsageMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,15 +213,10 @@ enum CodexUsageMapper {
}

private static func readCreditsRemaining(response: HTTPResponse, body: [String: Any]) -> Double? {
if let credits = body["credits"] as? [String: Any] {
if let balance = ProviderParse.number(credits["balance"]) {
return balance
}
if credits["has_credits"] as? Bool == false {
return 0
}
}
return ProviderParse.number(response.header("x-codex-credits-balance"))
let credits = body["credits"] as? [String: Any]
let hasCredits = ProviderParse.bool(credits?["has_credits"]) ?? ProviderParse.bool(response.header("x-codex-credits-has-credits"))
guard hasCredits != false else { return nil }
return ProviderParse.number(credits?["balance"]) ?? ProviderParse.number(response.header("x-codex-credits-balance"))
}

static func formatCodexPlan(_ value: Any?) -> String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ enum CursorUsageMapper {
let combinedTotalCents = (hasValidGrantData ? grantTotalCents : 0) + stripeBalanceCents
let remainingCents = max(0, combinedTotalCents - (hasValidGrantData ? grantUsedCents : 0))

guard combinedTotalCents > 0 else { return }
guard creditGrants != nil || combinedTotalCents > 0 else { return }
lines.append(.values(
label: "Credits",
values: [MetricValue(number: ProviderParse.centsToDollars(remainingCents), kind: .dollars)]
Expand Down
7 changes: 3 additions & 4 deletions Sources/OpenUsage/Providers/Grok/GrokUsageMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ enum GrokUsageMapper {
let used = ProviderParse.clampPercent(config.usedPercent)
lines.append(.percent("Weekly limit", used, resetsAt: config.periodEnd, periodMs: config.periodDurationMs))
}
lines.append(.badge(
label: "Pay as you go",
text: config.onDemandCap > 0 ? "\(formatUnits(config.onDemandCap)) cap" : "Disabled"
))
if config.onDemandCap > 0 {
lines.append(.badge(label: "Pay as you go", text: "\(formatUnits(config.onDemandCap)) cap"))
}
return ProviderUsage(lines: lines)
}

Expand Down
5 changes: 1 addition & 4 deletions Sources/OpenUsage/Views/DashboardFamilies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,10 @@ struct DashboardLine: Identifiable {
let data: WidgetData
var id: String { descriptor.id }

// Empty prepaid ChatGPT credits and spent reset grants remain available to CLI clients.
// Spent reset grants remain available to CLI clients.
var isVisible: Bool {
guard data.hasData else { return false }
if data.showsResetExpiries { return data.resetCreditCount > 0 }
if descriptor.providerKind == .codex, descriptor.key == "credits" {
return descriptor.balance(in: data)?.number != 0
}
return true
}
}
Expand Down
12 changes: 7 additions & 5 deletions Tests/OpenUsageTests/CodexUsageClientAndMapperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,14 @@ final class CodexUsageClientAndMapperTests: XCTestCase {
}

@MainActor
func testDashboardHidesZeroExtraUsageWhileKeepingTheReportedBalance() throws {
func testExtraUsageCreditsHideWhileCreditsAreOffAndShowAtZeroWhileOn() throws {
let account = Account(kind: .codex, label: "ChatGPT")
let provider = AccountProviderRuntime.provider(for: account)
let descriptor = try XCTUnwrap(AccountProviderRuntime.widgetDescriptors(for: account).first { $0.key == "credits" })
for (credits, balance) in [(#"{ "balance": 0 }"#, 0), (#"{ "has_credits": false }"#, 0),
(#"{ "balance": 0.5 }"#, 0), (#"{ "balance": 1 }"#, 1)] {
let cases: [(String, Double?)] = [(#"{ "has_credits": false, "balance": "0" }"#, nil), (#"{ "has_credits": false }"#, nil),
(#"{ "has_credits": true, "balance": "0" }"#, 0), (#"{ "balance": 0 }"#, 0),
(#"{ "has_credits": true, "balance": "1" }"#, 1)]
for (credits, balance) in cases {
let body = Data(#"{ "credits": \#(credits) }"#.utf8)
let response = HTTPResponse(statusCode: 200, headers: [:], body: body)
let usage = try CodexUsageMapper.mapUsageResponse(response)
Expand All @@ -80,8 +82,8 @@ final class CodexUsageClientAndMapperTests: XCTestCase {
let store = WidgetDataStore.forTesting([runtime], cache: cache, defaults: defaults)
let accounts = store.accounts(in: [ProviderMetrics(provider: provider, metrics: [descriptor])])

XCTAssertEqual(descriptor.balance(in: store.data(for: descriptor))?.number, Double(balance), credits)
XCTAssertEqual(accounts.first?.lines.map(\.descriptor.key), balance == 0 ? [] : ["credits"], credits)
XCTAssertEqual(descriptor.balance(in: store.data(for: descriptor))?.number, balance, credits)
XCTAssertEqual(accounts.first?.lines.map(\.descriptor.key), balance == nil ? [] : ["credits"], credits)
}
}

Expand Down
24 changes: 1 addition & 23 deletions Tests/OpenUsageTests/UnreportedRowsTests.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import XCTest
@testable import OpenUsage

/// A time range or Extra Usage the provider reports nothing for reads zero, so only its switch hides it.
/// A time range the provider reports nothing for reads zero, so only its switch hides it.
@MainActor
final class UnreportedRowsTests: XCTestCase {
private func refreshed(_ kind: ProviderKind, lines: [MetricLine] = []) async -> (WidgetDataStore, StubRuntime) {
Expand Down Expand Up @@ -34,28 +34,6 @@ final class UnreportedRowsTests: XCTestCase {
])
}

func testClaudeExtraUsageTurnedOffReadsZeroSpentWithNoBar() async throws {
let body = #"{"five_hour":{"utilization":10},"seven_day":{"utilization":20},"#
+ #""extra_usage":{"is_enabled":false,"monthly_limit":null,"used_credits":null}}"#
let lines = try ClaudeUsageMapper.mapUsageResponse(HTTPResponse(statusCode: 200, headers: [:], body: Data(body.utf8)))
let (store, runtime) = await refreshed(.claude, lines: lines)
let extra = try XCTUnwrap(runtime.widgetDescriptors.first { $0.key == "extra" })

let data = store.data(for: extra)
XCTAssertNil(data.limit, "no bar")
XCTAssertEqual(data.unboundedDetail, "$0 spent")
let shown = store.accounts(in: [ProviderMetrics(provider: runtime.provider, metrics: [extra])]).flatMap(\.lines)
XCTAssertEqual(shown.map(\.descriptor.key), ["extra"])
}

func testCursorAndCopilotExtraUsageReadZeroWhenNotReported() async throws {
for (kind, expected) in [(ProviderKind.cursor, "$0 spent"), (.copilot, "0")] {
let (store, runtime) = await refreshed(kind)
let extra = try XCTUnwrap(runtime.widgetDescriptors.first { $0.title == "Extra Usage" })
XCTAssertEqual(store.data(for: extra).unboundedDetail, expected, "\(kind)")
}
}

func testAnEmptyRangeIsListedOnTheAccountPageShownUntilSwitchedOffAndCanBePinned() async throws {
let (store, runtime) = await refreshed(.codex)
let layout = LayoutStore(registry: .from([runtime]), defaults: isolatedDefaults())
Expand Down
116 changes: 116 additions & 0 deletions Tests/OpenUsageTests/ZeroBalanceAndExtraUsageTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import XCTest
@testable import OpenUsage

/// A balance shows at $0. Extra Usage shows while the provider has it on, even at $0, and hides while it is off.
@MainActor
final class ZeroBalanceAndExtraUsageTests: XCTestCase {
private func shownRow(_ kind: ProviderKind, _ lines: [MetricLine], key: String) async -> WidgetData? {
let account = Account(kind: kind, label: kind.displayName)
let provider = AccountProviderRuntime.provider(for: account)
let descriptors = AccountProviderRuntime.widgetDescriptors(for: account)
let runtime = StubRuntime(provider: provider, descriptors: descriptors,
snapshot: ProviderSnapshot(provider: provider, plan: nil, lines: lines))
let store = WidgetDataStore.forTesting([runtime], defaults: isolatedDefaults())
await store.refreshAll()
return store.accounts(in: [ProviderMetrics(provider: provider, metrics: descriptors)])
.flatMap(\.lines).first { $0.descriptor.key == key }?.data
}

private func json(_ text: String) throws -> [String: Any] {
try XCTUnwrap(ProviderParse.jsonObject(Data(text.utf8)))
}

private func ok(_ body: String) -> HTTPResponse {
HTTPResponse(statusCode: 200, headers: [:], body: Data(body.utf8))
}

private func cursorLines(_ extra: String, creditGrants: String? = nil) throws -> [MetricLine] {
let usage = try json(#"""
{"billingCycleEnd":"1764547200000","billingCycleStart":"1761955200000","enabled":true,
"planUsage":{"apiPercentUsed":0,"autoPercentUsed":0,"limit":40000,"remaining":40000,"totalPercentUsed":0}\#(extra)}
"""#)
return try CursorUsageMapper.mapUsage(usage: usage, planName: "Pro", creditGrants: creditGrants.map(json),
stripeBalanceCents: 0).lines
}

func testClaudeExtraUsageHidesWhenOffAndShowsZeroSpentWhenOn() async throws {
func lines(_ extra: String) throws -> [MetricLine] {
try ClaudeUsageMapper.mapUsageResponse(ok(#"{"five_hour":{"utilization":10},"extra_usage":\#(extra)}"#))
}
let off = await shownRow(.claude, try lines(#"{"is_enabled":false,"monthly_limit":null,"used_credits":null}"#), key: "extra")
XCTAssertNil(off)

let on = await shownRow(.claude, try lines(#"{"is_enabled":true,"monthly_limit":null,"used_credits":0}"#), key: "extra")
XCTAssertEqual(on?.unboundedDetail, "$0 spent")

let capped = await shownRow(.claude, try lines(#"{"is_enabled":true,"monthly_limit":5000,"used_credits":0}"#), key: "extra")
XCTAssertEqual(capped?.limit, 50)
XCTAssertEqual(capped?.used, 0)
}

func testCursorExtraUsageHidesWithNoLimitAndShowsAZeroBarWithOne() async throws {
let off = await shownRow(.cursor, try cursorLines(#","spendLimitUsage":{"limitType":"user"}"#), key: "onDemand")
XCTAssertNil(off)

let on = await shownRow(.cursor, try cursorLines(
#","spendLimitUsage":{"individualLimit":5000,"individualRemaining":5000,"limitType":"user"}"#), key: "onDemand")
XCTAssertEqual(on?.limit, 50)
XCTAssertEqual(on?.used, 0)
}

func testCopilotExtraUsageHidesWhenNotPermittedAndShowsZeroWhenPermitted() async throws {
func lines(permitted: Bool) throws -> [MetricLine] {
try CopilotUsageMapper.map(body: json(#"""
{"copilot_plan":"individual","quota_reset_date":"2026-12-01","quota_snapshots":{"premium_interactions":
{"entitlement":300,"remaining":300,"percent_remaining":100,"overage_permitted":\#(permitted),"overage_count":0}}}
"""#)).lines
}
let off = await shownRow(.copilot, try lines(permitted: false), key: "extra")
XCTAssertNil(off)

let on = await shownRow(.copilot, try lines(permitted: true), key: "extra")
XCTAssertEqual(on?.unboundedDetail, "0")
}

func testGrokExtraUsageHidesWithNoPayAsYouGoCapAndShowsTheCap() async throws {
func lines(_ cap: String) throws -> [MetricLine] {
try GrokUsageMapper.mapCreditsConfig(ok(#"""
{"config":{"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2026-06-30T21:36:52Z",
"end":"2026-07-07T21:36:52Z"}\#(cap)}}
"""#)).lines
}
let off = await shownRow(.grok, try lines(""), key: "payAsYouGo")
XCTAssertNil(off)

let on = await shownRow(.grok, try lines(#","onDemandCap":{"val":2500}"#), key: "payAsYouGo")
XCTAssertEqual(on?.unboundedDetail, "2500 cap")
}

func testDevinExtraUsageShowsAReportedZeroBalanceAndHidesWhenWindsurfReportsNone() async throws {
func lines(_ balance: String) throws -> [MetricLine] {
try DevinUsageMapper.map(Data(#"{"userStatus":{"planStatus":{"weeklyQuotaRemainingPercent":40\#(balance)}}}"#.utf8)).lines
}
let reported = await shownRow(.devin, try lines(#","overageBalanceMicros":"0""#), key: "extra")
XCTAssertEqual(reported?.unboundedDetail, "$0 left")

let none = await shownRow(.devin, try lines(""), key: "extra")
XCTAssertNil(none)
}

func testBalancesShowAtZero() async throws {
let openRouter = try OpenRouterUsageMapper.creditsLines(Data(#"{"data":{"total_credits":10,"total_usage":10}}"#.utf8))
let balance = await shownRow(.openrouter, openRouter, key: "balance")
XCTAssertEqual(balance?.unboundedDetail, "$0 left")

let ollama = try OllamaUsageMapper.balanceLines(Data(
#"{"included":{"balance_usd":100,"allowance_usd":100},"purchased":{"balance_usd":0}}"#.utf8))
let purchased = await shownRow(.ollama, ollama, key: "purchasedCredits")
XCTAssertEqual(purchased?.unboundedDetail, "$0 left")

let cursor = await shownRow(.cursor, try cursorLines("", creditGrants: #"{"hasCreditGrants":false}"#), key: "credits")
XCTAssertEqual(cursor?.unboundedDetail, "$0 left")

let unread = await shownRow(.cursor, try cursorLines(""), key: "credits")
XCTAssertNil(unread, "a failed credit-grants read is not a $0 balance")
}
}
5 changes: 3 additions & 2 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ running low or used up stay visible.
One provider after another, each a header above a card. The header is the provider's name and logo,
and every account's name and plan sit inside the card above its rows. With several accounts, each
keeps a name you can drag. The card holds every metric that has data and that you have not hidden, in
the order you set. Time ranges and Extra Usage spend always show: when the provider reports nothing for
one, it reads 0.
the order you set. Time ranges always show: when the provider reports nothing for one, it reads 0.
Balances show even at `$0 left`. Extra Usage shows while the provider has it on, even at `$0`, and
hides while it is off.

## Hiding, pinning, and ordering metrics

Expand Down
Loading
Loading