diff --git a/CHANGELOG.md b/CHANGELOG.md index f26f75433..32ca356d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 67c851585..c68ff3691 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/OpenUsage/Models/WidgetDescriptor+Factories.swift b/Sources/OpenUsage/Models/WidgetDescriptor+Factories.swift index 2f9b41a41..4df2acfe2 100644 --- a/Sources/OpenUsage/Models/WidgetDescriptor+Factories.swift +++ b/Sources/OpenUsage/Models/WidgetDescriptor+Factories.swift @@ -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, diff --git a/Sources/OpenUsage/Providers/AccountProviderRuntime+Descriptors.swift b/Sources/OpenUsage/Providers/AccountProviderRuntime+Descriptors.swift index 5058e875b..a4cb27446 100644 --- a/Sources/OpenUsage/Providers/AccountProviderRuntime+Descriptors.swift +++ b/Sources/OpenUsage/Providers/AccountProviderRuntime+Descriptors.swift @@ -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: @@ -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, @@ -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) diff --git a/Sources/OpenUsage/Providers/Claude/ClaudeUsageMapper.swift b/Sources/OpenUsage/Providers/Claude/ClaudeUsageMapper.swift index b65ac8a88..df15b7746 100644 --- a/Sources/OpenUsage/Providers/Claude/ClaudeUsageMapper.swift +++ b/Sources/OpenUsage/Providers/Claude/ClaudeUsageMapper.swift @@ -105,14 +105,9 @@ 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", @@ -120,7 +115,7 @@ enum ClaudeUsageMapper { limit: ProviderParse.centsToDollars(limitCents), format: .dollars )) - } else if used > 0 { + } else { lines.append(.values(label: "Extra usage spent", values: [MetricValue(number: used, kind: .dollars)])) } } diff --git a/Sources/OpenUsage/Providers/Codex/CodexUsageMapper.swift b/Sources/OpenUsage/Providers/Codex/CodexUsageMapper.swift index f688eb9ee..c9ade8803 100644 --- a/Sources/OpenUsage/Providers/Codex/CodexUsageMapper.swift +++ b/Sources/OpenUsage/Providers/Codex/CodexUsageMapper.swift @@ -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? { diff --git a/Sources/OpenUsage/Providers/Cursor/CursorUsageMapper.swift b/Sources/OpenUsage/Providers/Cursor/CursorUsageMapper.swift index 77930cd85..e6f7abe28 100644 --- a/Sources/OpenUsage/Providers/Cursor/CursorUsageMapper.swift +++ b/Sources/OpenUsage/Providers/Cursor/CursorUsageMapper.swift @@ -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)] diff --git a/Sources/OpenUsage/Providers/Grok/GrokUsageMapper.swift b/Sources/OpenUsage/Providers/Grok/GrokUsageMapper.swift index 9d53d0060..0bab87c1f 100644 --- a/Sources/OpenUsage/Providers/Grok/GrokUsageMapper.swift +++ b/Sources/OpenUsage/Providers/Grok/GrokUsageMapper.swift @@ -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) } diff --git a/Sources/OpenUsage/Views/DashboardFamilies.swift b/Sources/OpenUsage/Views/DashboardFamilies.swift index ccc33acfd..8a6c04273 100644 --- a/Sources/OpenUsage/Views/DashboardFamilies.swift +++ b/Sources/OpenUsage/Views/DashboardFamilies.swift @@ -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 } } diff --git a/Tests/OpenUsageTests/CodexUsageClientAndMapperTests.swift b/Tests/OpenUsageTests/CodexUsageClientAndMapperTests.swift index 3e03db49e..c92ab15ed 100644 --- a/Tests/OpenUsageTests/CodexUsageClientAndMapperTests.swift +++ b/Tests/OpenUsageTests/CodexUsageClientAndMapperTests.swift @@ -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) @@ -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) } } diff --git a/Tests/OpenUsageTests/UnreportedRowsTests.swift b/Tests/OpenUsageTests/UnreportedRowsTests.swift index f986397b4..8fa27d504 100644 --- a/Tests/OpenUsageTests/UnreportedRowsTests.swift +++ b/Tests/OpenUsageTests/UnreportedRowsTests.swift @@ -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) { @@ -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()) diff --git a/Tests/OpenUsageTests/ZeroBalanceAndExtraUsageTests.swift b/Tests/OpenUsageTests/ZeroBalanceAndExtraUsageTests.swift new file mode 100644 index 000000000..edf7442a0 --- /dev/null +++ b/Tests/OpenUsageTests/ZeroBalanceAndExtraUsageTests.swift @@ -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") + } +} diff --git a/docs/dashboard.md b/docs/dashboard.md index 00ce1feab..5be238266 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -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 diff --git a/docs/providers.md b/docs/providers.md index 405969563..0c260904f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -3,8 +3,9 @@ How each provider signs in and what its card shows. Every value comes from the provider's own API, except ChatGPT's credit value, an estimate at 4¢ a credit. OpenUsage computes no spend from logs and applies no model pricing; a metric the provider does not -report stays hidden. Time ranges (Today, Yesterday, This Week, and the like) and the Extra Usage rows of -Claude, Cursor, and Copilot always show, at 0 when the provider reports nothing for them. +report stays hidden. Time ranges (Today, Yesterday, This Week, and the like) always show, at 0 when the +provider reports nothing for them. Balances show even at `$0 left`. Extra Usage shows while the +provider has it on, even at `$0`, and hides while the provider reports it off. | Provider | Sign-in | Card shows | |---|---|---| @@ -54,7 +55,7 @@ one instead of returning to the app. The card reads `/api/oauth/usage` for the s Sonnet, and Fable windows and the extra-usage spend, and `/api/oauth/profile` once an hour for the plan, such as Pro, Max, or Ultra. A verified Ultra account has no 5-hour limit, so its card has no Session row. Extra Usage is a bar against the monthly limit, or reads like `$12.34 spent` when the -account sets none. With extra usage off, it reads `$0 spent`. +account sets none. With extra usage off (`is_enabled` false), the row hides. The **Limit Resets** row counts the account's available rate-limit resets. Click the count to see when each expires. Choose **Use**, then **Reset** to spend one. @@ -65,7 +66,8 @@ Sign in through OpenAI in your browser. The account shows as ChatGPT; its provid takes it, is `codex`. When the app cannot listen on port 1455 for the browser's return, it offers a device code instead. The card reads `/backend-api/wham/usage` for the session and weekly windows, the Spark windows, and extra-usage credits, and the plan (Plus, Pro, Team, Business). The Extra Usage row shows -the dollar value of the credits you have, an estimate at 4¢ a credit, and hides when you have none. Rate-limit reset credits work like Claude's +the dollar value of the credits you have, an estimate at 4¢ a credit, even at `$0`. It hides while +ChatGPT reports credits off (`has_credits` false). Rate-limit reset credits work like Claude's Limit Resets row. Credit Limit shows the credits a workspace lets the member spend and when that resets; it shows only when the workspace sets one. @@ -103,8 +105,9 @@ The card reads the history one local day at a time, at most 4 days at once and f refresh. Today and yesterday are read on every refresh; an older day is read until one read of it succeeds. The days are kept in memory for each account, so the first refresh after a launch reads all 30 days again. Last 30 Days sums the days read so far, even if today or yesterday could not be read. -A failed day adds nothing, and a range with no day read reads $0. Extra Usage reads `$0 spent` when -on-demand usage is off. If Cursor rate-limits a +A failed day adds nothing, and a range with no day read reads $0. Extra Usage hides when on-demand +usage is off, which Cursor reports as no spend limit or a $0 one. Credits reads `$0 left` when Cursor +reports no credits. If Cursor rate-limits a history read, retries wait for its Retry-After or an hour when it gives no time. @@ -119,8 +122,8 @@ If the project lookup failed, try refreshing again. ## Copilot Sign in with a GitHub device code. The card reads `/copilot_internal/user` for premium credits, -extra-usage spend, and the chat and completion counts, and the plan. Extra Usage reads 0 when the -plan doesn't allow it. +extra-usage spend, and the chat and completion counts, and the plan. Extra Usage hides when the plan +doesn't allow it (`overage_permitted` false). To see an organization's billing, add it as its own account: choose Copilot, then **Organization Billing**, and enter the organization and a token from an owner or billing manager. The card reads @@ -133,7 +136,9 @@ has not yet been tried against a real organization token. Sign in on windsurf.com in your browser, then paste the token the page shows. Its instructions name VS Code, but the token works the same here. OpenUsage trades the token for the account's API key with Windsurf's `RegisterUser` and reads `GetUserStatus` on every refresh for the daily and weekly quotas, -the extra-usage balance, and the plan. A plan that hides its daily quota shows it in the Weekly row when +the extra-usage balance, and the plan. Extra Usage shows the balance whenever Windsurf reports one, +even `$0 left`. Windsurf reports no on or off flag and leaves out a zero balance, so a balance it +leaves out hides. A plan that hides its daily quota shows it in the Weekly row when the plan has no weekly quota. An account with neither quota nor a balance has nothing to show, and the card says so. @@ -141,14 +146,16 @@ card says so. ## Grok Sign in with an xAI device code. The card reads the Grok settings and billing endpoints for the weekly -shared pool, pay-as-you-go extra usage, and the plan. A team account has no personal quota to read. +shared pool, pay-as-you-go extra usage, and the plan. Extra Usage shows the pay-as-you-go cap, and +hides when xAI reports no cap or a $0 one. A team account has no personal quota to read. Its card shows the plan and says so, with no rows. ## Ollama Paste an API key from [ollama.com/settings/keys](https://ollama.com/settings/keys). The card reads the balance API for the session, weekly, and monthly windows that your plan has, plus purchased credits, -and the plan. A new account is named after the email ollama.com reports for the key, or "Ollama +and the plan. Every plan can buy credits, so Extra Usage shows the purchased balance whenever Ollama +reports it, even `$0 left`. A new account is named after the email ollama.com reports for the key, or "Ollama Account" if it reports none. The card's right-click menu adds an **API Keys** link to [ollama.com/settings/keys](https://ollama.com/settings/keys).