From 3e400bffa72df5954e7fd4cc741db2d9fc1afa32 Mon Sep 17 00:00:00 2001 From: Richard Boisvert Date: Sun, 26 Jul 2026 20:07:01 -0400 Subject: [PATCH] feat(codex): show the credit limit on credit-metered ChatGPT workspaces Signed-off-by: Richard Boisvert --- CHANGELOG.md | 3 + app/electron/quota/codex.test.ts | 144 ++++++++++ app/electron/quota/codex.ts | 74 +++++- docs/providers/codex.md | 104 ++++++++ mac/Sources/CodeBurnMenubar/AppStore.swift | 28 +- .../Data/CodexSubscriptionService.swift | 213 ++++++++++++++- .../CodeBurnMenubar/Data/CodexUsage.swift | 69 ++++- .../Views/HeatmapSection.swift | 41 ++- .../CodeBurnMenubar/Views/SettingsView.swift | 2 +- .../CodexPlanParsingTests.swift | 248 ++++++++++++++++++ .../CodexQuotaSummaryTests.swift | 79 ++++++ 11 files changed, 977 insertions(+), 28 deletions(-) create mode 100644 mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c38076..790d15f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- **Credit-metered ChatGPT workspaces (Business / Edu / Enterprise) now show their limit.** These plans report no rate-limit windows, so the admin-set monthly allowance from `spend_control.individual_limit` is shown as a "Monthly usage limit" bar in the desktop app and the menubar. + ### Fixed - Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611) diff --git a/app/electron/quota/codex.test.ts b/app/electron/quota/codex.test.ts index b8e2de45..874613ea 100644 --- a/app/electron/quota/codex.test.ts +++ b/app/electron/quota/codex.test.ts @@ -38,6 +38,150 @@ describe('Codex quota', () => { expect(quota.details).toHaveLength(1) }) + // Shape captured from a live ChatGPT Enterprise workspace. + const enterpriseBody = { + plan_type: 'business', + rate_limit: null, + additional_rate_limits: null, + credits: { has_credits: false, unlimited: false, balance: null }, + spend_control: { + reached: false, + individual_limit: { + source: 'workspace_spend_controls', + limit: '10000', + used: '3028.9909675121307', + remaining: '6971.009032487869', + used_percent: 30, + remaining_percent: 70, + reset_after_seconds: 441_896, + reset_at: 1_785_542_400, + }, + }, + rate_limit_reset_credits: { available_count: 0 }, + } + + it('surfaces the spend-control credit limit when there are no rate windows', () => { + const quota = decodeCodexUsage(enterpriseBody) + expect(quota.primary).toEqual({ + label: 'Monthly usage limit · 3,029 / 10,000 credits', + percent: 0.3, + resetsAt: new Date(1_785_542_400 * 1000).toISOString(), + }) + expect(quota.details).toEqual([quota.primary]) + expect(quota.planLabel).toBe('Business') + expect(quota.footerLines).toEqual([]) + }) + + it('keeps rate windows primary and appends the credit limit alongside them', () => { + const quota = decodeCodexUsage({ + ...enterpriseBody, + rate_limit: { primary_window: { used_percent: 20, reset_at: 1_800_000_000, limit_window_seconds: 18_000 } }, + }) + expect(quota.primary?.label).toBe('5-hour') + expect(quota.details.map(row => row.label)).toEqual([ + '5-hour', + 'Monthly usage limit · 3,029 / 10,000 credits', + ]) + }) + + it.each([ + ['top level', (limit: unknown) => ({ individual_limit: limit })], + ['camelCase key', (limit: unknown) => ({ spend_control: { individualLimit: limit } })], + ['nested in rate_limit', (limit: unknown) => ({ rate_limit: { individual_limit: limit } })], + ])('reads the credit limit positioned at %s', (_name, wrap) => { + const quota = decodeCodexUsage(wrap({ limit: 10_000, used: 2500, used_percent: 25 })) + expect(quota.primary?.percent).toBe(0.25) + expect(quota.primary?.label).toBe('Monthly usage limit · 2,500 / 10,000 credits') + }) + + it('derives the percent from remaining_percent, then from used/limit', () => { + const fromRemaining = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, remaining_percent: 70 } } }) + expect(fromRemaining.primary?.percent).toBeCloseTo(0.3) + expect(fromRemaining.primary?.label).toBe('Monthly usage limit · 3,000 / 10,000 credits') + + const fromRatio = decodeCodexUsage({ spend_control: { individual_limit: { limit: 400, used: 100 } } }) + expect(fromRatio.primary?.percent).toBeCloseTo(0.25) + }) + + it('ignores a spend control with no usable limit', () => { + for (const individual_limit of [{ limit: 0, used: 5 }, { limit: null }, { used_percent: 40 }, null]) { + const quota = decodeCodexUsage({ spend_control: { individual_limit } }) + expect(quota.primary).toBeNull() + expect(quota.details).toEqual([]) + } + }) + + it('renders no row when the allowance is known but the draw on it is not', () => { + const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, reset_at: 1_785_542_400 } } }) + expect(quota.primary).toBeNull() + expect(quota.details).toEqual([]) + }) + + it('treats a blank numeric string as absent, not as zero', () => { + const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: '10000', used: ' ', used_percent: 30 } } }) + expect(quota.primary?.label).toBe('Monthly usage limit · 3,000 / 10,000 credits') + expect(decodeCodexUsage({ spend_control: { individual_limit: { limit: '' } } }).primary).toBeNull() + }) + + it('marks a spent-out allowance as reached', () => { + const quota = decodeCodexUsage({ + spend_control: { reached: true, individual_limit: { limit: 10_000, used: 10_000, used_percent: 100 } }, + }) + expect(quota.primary?.label).toBe('Monthly usage limit · 10,000 / 10,000 credits · limit reached') + expect(quota.primary?.percent).toBe(1) + }) + + it('keeps overage counts truthful while clamping the bar', () => { + const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, used: 12_000, used_percent: 120 } } }) + expect(quota.primary?.label).toBe('Monthly usage limit · 12,000 / 10,000 credits') + expect(quota.primary?.percent).toBe(1) + }) + + it('keeps the implied overage when only the percent is given', () => { + const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, used_percent: 120 } } }) + expect(quota.primary?.label).toBe('Monthly usage limit · 12,000 / 10,000 credits') + expect(quota.primary?.percent).toBe(1) + }) + + it('skips a garbage alias instead of letting it mask a valid one', () => { + const quota = decodeCodexUsage({ + spend_control: { individual_limit: 'bad', individualLimit: { limit: 100, usedPercent: 25 } }, + }) + expect(quota.primary?.label).toBe('Monthly usage limit · 25 / 100 credits') + const perField = decodeCodexUsage({ + spend_control: { individual_limit: { limit: 100, used_percent: 'bad', usedPercent: 25 } }, + }) + expect(perField.primary?.percent).toBe(0.25) + }) + + it('survives a reset timestamp beyond the Date range', () => { + const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 100, used_percent: 10, reset_at: 9_000_000_000_000 } } }) + expect(quota.primary?.resetsAt).toBeNull() + expect(quota.primary?.percent).toBe(0.1) + }) + + it('says so when the account is credit-metered but uncapped', () => { + const quota = decodeCodexUsage({ plan_type: 'business', credits: { has_credits: true, unlimited: true } }) + expect(quota.footerLines).toEqual(['Credits · Unlimited']) + const capped = decodeCodexUsage({ credits: { unlimited: true }, spend_control: { individual_limit: { limit: 10_000, used_percent: 30 } } }) + expect(capped.footerLines).toEqual([]) + }) + + it('normalizes credit-based-pricing plan tiers', () => { + const label = (plan_type: string) => decodeCodexUsage({ plan_type }).planLabel + expect(label('enterprise_cbp_usage_based')).toBe('Enterprise') + expect(label('self_serve_business_usage_based')).toBe('Business') + expect(label('enterprise')).toBe('Enterprise') + expect(label('some_future_tier')).toBe('Some Future Tier') + }) + + it('labels a credit-settled balance in credits, not dollars', () => { + const inCredits = decodeCodexUsage({ credits: { has_credits: true, balance: 3410.4 } }) + expect(inCredits.footerLines).toEqual(['Credits remaining · 3,410']) + const inDollars = decodeCodexUsage({ credits: { has_credits: false, balance: 3.5 } }) + expect(inDollars.footerLines).toEqual(['Credits remaining · $3.50']) + }) + it('returns disconnected without credentials', async () => { const fetchMock = vi.fn() const result = await fetchCodexQuota({ fetch: fetchMock, readFile: vi.fn(async () => null) }) diff --git a/app/electron/quota/codex.ts b/app/electron/quota/codex.ts index b339d503..0e7e060d 100644 --- a/app/electron/quota/codex.ts +++ b/app/electron/quota/codex.ts @@ -133,10 +133,27 @@ function windowOf(value: unknown, override?: string): QuotaWindow | null { return { label: override ?? labelForSeconds(row.limit_window_seconds), percent, resetsAt: reset } } +// chatgpt.com mixes encodings inside one payload. `Number('')` is 0, not NaN, +// so blank is rejected or an absent `used` decodes as a confident zero. +function num(value: unknown): number | null { + if (typeof value === 'string' && !value.trim()) return null + const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN + return Number.isFinite(parsed) ? parsed : null +} + +// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`). +function normalizePlanType(value: string): string { + return value + .replace(/[_-]usage[_-]based$/, '') + .replace(/^self[_-]serve[_-]/, '') + .replace(/[_-]cbp$/, '') + .replace(/[_-]cbp[_-]/g, '_') +} + function planLabel(value: unknown): string | null { if (typeof value !== 'string' || !value.trim()) return null const raw = value.trim() - const lower = raw.toLowerCase() + const lower = normalizePlanType(raw.toLowerCase()) const known: Record = { guest: 'Guest', free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', prolite: 'Pro Lite', pro_lite: 'Pro Lite', 'pro-lite': 'Pro Lite', @@ -146,6 +163,44 @@ function planLabel(value: unknown): string | null { return known[lower] ?? lower.replace(/(^|[_-])\w/g, match => match.replace(/[_-]/, ' ').toUpperCase()) } +// The admin-set monthly allowance, the only limit a credit-metered workspace +// has. `spend_control` is the live position, the others forward-compat. `any` +// because this walks six optional-chained hops, all validated by `num()`. +function spendControlWindow(data: Record): QuotaWindow | null { + // `find`/`num` per alias, not `??`: a non-null garbage value would stop `??` + // and mask a valid alias further down. Object-shaped garbage still wins the + // position, matching Swift, which likewise commits to the first that decodes. + const row = [ + data.spend_control?.individual_limit, + data.spend_control?.individualLimit, + data.individual_limit, + data.individualLimit, + data.rate_limit?.individual_limit, + data.rate_limit?.individualLimit, + ].find(candidate => candidate && typeof candidate === 'object') + if (!row) return null + const limit = num(row.limit) + if (limit === null || limit <= 0) return null + const remainingPercent = num(row.remaining_percent) ?? num(row.remainingPercent) + const used = num(row.used) + const rawPercent = num(row.used_percent) ?? num(row.usedPercent) + ?? (remainingPercent === null ? null : 100 - remainingPercent) + ?? (used === null ? null : (used / limit) * 100) + if (rawPercent === null) return null + const percent = Math.min(1, Math.max(0, rawPercent / 100)) + const resetRaw = num(row.reset_at) ?? num(row.resets_at) ?? num(row.resetsAt) + // Past 8.64e15 ms `toISOString()` throws RangeError. + const resetsAt = resetRaw !== null && resetRaw > 0 && resetRaw * 1000 <= 8.64e15 + ? new Date(resetRaw * 1000).toISOString() + : null + // Unclamped percent, so a 120% draw still reports 12,000 of 10,000. + const spent = used ?? limit * Math.max(0, rawPercent) / 100 + const round = (n: number) => Math.round(n).toLocaleString('en-US') + const reached = data.spend_control?.reached === true + const label = `Monthly usage limit · ${round(spent)} / ${round(limit)} credits` + return { label: reached ? `${label} · limit reached` : label, percent, resetsAt } +} + export function decodeCodexUsage(body: unknown): QuotaProvider { const data = body && typeof body === 'object' ? body as Record : {} const primaryRaw = windowOf(data.rate_limit?.primary_window) @@ -165,12 +220,21 @@ export function decodeCodexUsage(body: unknown): QuotaProvider { } } } - const rawBalance = data.credits?.balance - const balance = typeof rawBalance === 'number' ? rawBalance : typeof rawBalance === 'string' ? Number(rawBalance) : NaN + const credits = spendControlWindow(data) + if (credits) details.push(credits) + const balance = num(data.credits?.balance) + // Credit-settled accounts denominate in credits, so no currency symbol. + const hasCredits = data.credits?.has_credits === true + const footerLines: string[] = [] + if (balance !== null && balance > 0) { + footerLines.push(`Credits remaining · ${hasCredits ? Math.round(balance).toLocaleString('en-US') : `$${balance.toFixed(2)}`}`) + } + // Uncapped on purpose, so a bar-less card does not read as a failed fetch. + if (!credits && data.credits?.unlimited === true) footerLines.push('Credits · Unlimited') return { - provider: 'codex', connection: 'connected', primary, details, + provider: 'codex', connection: 'connected', primary: primary ?? credits, details, planLabel: planLabel(data.plan_type), - footerLines: Number.isFinite(balance) && balance > 0 ? [`Credits remaining · $${balance.toFixed(2)}`] : [], + footerLines, } } diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 505b3089..b68847dc 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -48,6 +48,110 @@ A session that yielded zero parseable lines does **not** write to the cache (`co - `prev*` token counters are advanced on **every** event, including ones that used `last_token_usage`. Earlier code only updated them on the fallback branch, which double-counted any session that mixed modes. - OpenAI counts cached tokens **inside** `input_tokens`. The parser subtracts them so the rest of the codebase can assume Anthropic semantics (cached are separate). +## Live quota (ChatGPT subscription) + +Separate from the log parser above: the desktop app and the macOS menubar read +live quota from `GET https://chatgpt.com/backend-api/wham/usage` using the Codex +OAuth token. Two independent implementations of the same decoder, which must be +kept in sync: + +- `app/electron/quota/codex.ts`: `decodeCodexUsage()` is the pure, exported decoder. +- `mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift`: `decodeUsage()`. + +### Seat-based plans (Plus, Pro, Team) + +`rate_limit.primary_window` / `secondary_window` carry `used_percent`, +`reset_at` and `limit_window_seconds`. The window *label* is inferred from the +duration (5-hour, Weekly, …), never from the plan, because window size is dynamic per +account. `additional_rate_limits[]` holds per-model limits (Codex Spark, etc.) +and is only surfaced when utilization is non-zero. + +### Credit-metered plans (Business, Edu, Enterprise on flexible pricing) + +These workspaces have **no rate-limit windows**: `rate_limit` comes back +`null`. Usage scales with credits, and an admin sets a monthly per-user credit +allowance. That allowance is the account's only limit and lives in +`spend_control`: + +```jsonc +"spend_control": { + "reached": false, + "individual_limit": { + "source": "workspace_spend_controls", + "limit": "10000", // string + "used": "3028.9909675121307", // string + "used_percent": 30, // number + "remaining_percent": 70, + "reset_after_seconds": 441896, // time *remaining*, not window length + "reset_at": 1785542400 + } +} +``` + +Notes that have bitten us: + +- **Number encodings are mixed within the same object**: `limit` and `used` + arrive as strings while `used_percent` arrives as a number. Every numeric + field is decoded flexibly (number | string) on both sides. +- **`reset_after_seconds` is not the window length.** Pace projection needs the + whole-window duration, so it is derived as the calendar month preceding + `reset_at`, resolved in **UTC**: `reset_at` is a UTC boundary, and a local + calendar would make the month length depend on the viewer's timezone (a + 2026-03-01Z reset spans 28 days in UTC but 31 in Toronto). +- Two other positions for this object have been observed in other clients + (top-level `individual_limit`, and nested under `rate_limit`), in both + snake_case and camelCase. All are accepted; `spend_control` wins. +- `credits.has_credits` means the account settles in **credits, not dollars**, so + `credits.balance` must not be rendered with a currency symbol in that case. + `credits.unlimited` means credit-metered but deliberately uncapped. +- **`has_credits` is not "is credit-metered".** The live Enterprise workspace + above is credit-metered (it has a `spend_control` allowance) yet reports + `has_credits: false` with a `null` balance, so the flag tracks whether the + account holds a *credit balance*, which is orthogonal to the allowance. Do not + derive one from the other. The `has_credits: true` rendering path has not been + observed against a real account; if a seat-based account ever reports it + alongside a dollar balance, the footer would drop the `$` and round to whole + units. + +### `plan_type` cannot distinguish Business from Enterprise + +A live ChatGPT **Enterprise** workspace reports `plan_type: "business"` on this +endpoint, and the `id_token`'s `https://api.openai.com/auth → chatgpt_plan_type` +claim says `"business"` too, even though ChatGPT's own workspace switcher +displays "Enterprise". Neither source carries the distinction, so the label +CodeBurn shows is faithfully what OpenAI returns. Do not try to infer a tier +from the presence of a spend control. + +The switcher renders from the accounts endpoints, and **those are not reachable +with a Codex token**, verified against a live Enterprise workspace: + +| Endpoint | Result | +| --- | --- | +| `/backend-api/accounts/check/v4-2023-04-27` | 403 | +| `/backend-api/accounts/check` | 403 | +| `/backend-api/me` | 403 | +| `/backend-api/settings/account_user_setting` | 403 | + +Not an expiry or a missing-header problem: the same token returns 200 on +`/wham/usage` (and on `/backend-api/gizmo_creator_profile`) in the same run. The +Codex OAuth access token carries scopes `openid profile email offline_access +api.connectors.read api.connectors.invoke` with audience +`https://api.openai.com/v1`, with no ChatGPT web-app account scope, so the accounts +surfaces reject it by design. Adding a `ChatGPT-Account-Id` header does not +change this. **Business is therefore the correct label to display**; closing +this gap would need a different credential, not a different endpoint. + +Composite tiers (`enterprise_cbp_usage_based`, `self_serve_business_usage_based`) +*are* normalized down to their base tier before lookup. + +### Reset credits + +`rate_limit_reset_credits` is carried inline on the usage payload +(`available_count`). The dedicated `GET /wham/rate-limit-reset-credits` +endpoint is only called when the inline block is absent. It is the sole source +of per-credit `expires_at` values, so the "next expires" caption is omitted on +the inline path. + ## When fixing a bug here 1. Reproduce against a real `rollout-*.jsonl` if you can. Drop a redacted copy under `tests/fixtures/codex/` and reference it from `tests/providers/codex.test.ts`. diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 32c9cdd1..bfc37213 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -1378,19 +1378,35 @@ final class AppStore { details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt)) } } + // No rate windows here, so the allowance feeds the bar and badge. + if let credits = usage.creditLimit { + let row = QuotaSummary.Window( + label: credits.shortLabel, + percent: credits.usedPercent / 100, + resetsAt: credits.resetsAt + ) + if primary == nil { primary = row } + details.append(row) + } } let plan = codexUsage?.plan.displayName var footerLines: [String] = [] if let balance = codexUsage?.creditsBalance, balance > 0 { - // Format as plain dollars; ChatGPT settles in USD regardless of - // the user's display-currency preference. + // Credit-settled accounts denominate in credits, so no symbol. + let inCredits = codexUsage?.hasCredits == true let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.currencyCode = "USD" - formatter.maximumFractionDigits = 2 - let formatted = formatter.string(from: NSNumber(value: balance)) ?? "$\(balance)" + formatter.numberStyle = inCredits ? .decimal : .currency + formatter.maximumFractionDigits = inCredits ? 0 : 2 + // `en_US`, not `en_US_POSIX`: the latter drops grouping entirely. + formatter.locale = Locale(identifier: "en_US") + if !inCredits { formatter.currencyCode = "USD" } + let fallback = inCredits ? "\(Int(balance.rounded()))" : "$\(balance)" + let formatted = formatter.string(from: NSNumber(value: balance)) ?? fallback footerLines.append("Credits remaining · \(formatted)") } + if codexUsage?.creditLimit == nil, codexUsage?.creditsUnlimited == true { + footerLines.append("Credits · Unlimited") + } return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines) } diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift index 0eea71f2..d25637c1 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift @@ -111,10 +111,12 @@ enum CodexSubscriptionService { switch http.statusCode { case 200: clearUsageBlock() - // Companion fetch, strictly best-effort: any failure yields nil and - // the Plan view simply omits the row. This endpoint must never be - // able to break the quota display. - let resetCredits = await fetchResetCredits(token: token) + // Skip the companion request only when the inline block says zero. + // Best-effort either way: nil just omits the row. + var resetCredits = inlineResetCreditsShortcut(data: data) + if resetCredits == nil { + resetCredits = await fetchResetCredits(token: token) + } do { return try decodeUsage(data: data, resetCredits: resetCredits) } catch { @@ -144,15 +146,83 @@ enum CodexSubscriptionService { } } + /// chatgpt.com mixes encodings inside one payload: `"limit": "10000"` next + /// to `"used_percent": 30`. Every numeric field decodes through here. + private enum Flexible { + // `decode`, not `decodeIfPresent`: missing, null and wrong-typed all + // mean "not available", without the double-optional footgun. + // Int first keeps precision above 2^53. Infinity and NaN survive + // `Double(_ text:)`, so reject them here. + static func double(_ c: KeyedDecodingContainer, _ key: K) -> Double? { + if let v = try? c.decode(Int.self, forKey: key) { return Double(v) } + if let v = try? c.decode(Double.self, forKey: key) { return v.isFinite ? v : nil } + if let v = try? c.decode(String.self, forKey: key), + let d = Double(v.trimmingCharacters(in: .whitespacesAndNewlines)) { + return d.isFinite ? d : nil + } + return nil + } + // `Int(exactly:)`, never `Int(_:)`: the plain initializer traps on an + // out-of-range Double, and a trap is not a catchable DecodingError. + static func int(_ c: KeyedDecodingContainer, _ key: K) -> Int? { + double(c, key).flatMap { Int(exactly: $0.rounded()) } + } + static func bool(_ c: KeyedDecodingContainer, _ key: K) -> Bool { + (try? c.decode(Bool.self, forKey: key)) ?? false + } + } + + /// Decoding `[T]` is atomic, so one bad entry would discard every sibling. + private struct Lossy: Decodable { + let value: T? + init(from decoder: Decoder) throws { value = try? T(from: decoder) } + } + private struct UsageDTO: Decodable { let plan_type: String? let rate_limit: RateLimit? let additional_rate_limits: [AdditionalLimitDTO]? let credits: Credits? + let spend_control: SpendControl? + /// Forward-compat: some variants hoist this to the top level. + let individual_limit: IndividualLimit? + + enum CodingKeys: String, CodingKey { + case plan_type, rate_limit, additional_rate_limits, credits, spend_control + case individual_limit + case individualLimit + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + plan_type = try? c.decode(String.self, forKey: .plan_type) + rate_limit = try? c.decode(RateLimit.self, forKey: .rate_limit) + additional_rate_limits = (try? c.decode([Lossy].self, forKey: .additional_rate_limits))? + .compactMap(\.value) + credits = try? c.decode(Credits.self, forKey: .credits) + spend_control = try? c.decode(SpendControl.self, forKey: .spend_control) + individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit)) + ?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit)) + } struct RateLimit: Decodable { let primary_window: WindowDTO? let secondary_window: WindowDTO? + /// Forward-compat: another observed position for the spend control. + let individual_limit: IndividualLimit? + + enum CodingKeys: String, CodingKey { + case primary_window, secondary_window, individual_limit + case individualLimit + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + primary_window = try? c.decode(WindowDTO.self, forKey: .primary_window) + secondary_window = try? c.decode(WindowDTO.self, forKey: .secondary_window) + individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit)) + ?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit)) + } } struct AdditionalLimitDTO: Decodable { let limit_name: String? @@ -163,22 +233,72 @@ enum CodexSubscriptionService { let reset_at: Int? let limit_window_seconds: Int? } + /// Credit-metered workspaces report `rate_limit: null` and carry their + /// real limit here: the monthly allowance an admin sets. + struct SpendControl: Decodable { + let reached: Bool + let individualLimit: IndividualLimit? + + enum CodingKeys: String, CodingKey { + case reached + case individual_limit + case individualLimit + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + reached = Flexible.bool(c, .reached) + individualLimit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit)) + ?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit)) + } + } + struct IndividualLimit: Decodable { + let limit: Double? + let used: Double? + let usedPercent: Double? + let remainingPercent: Double? + let resetAt: Int? + + enum CodingKeys: String, CodingKey { + case limit, used + case used_percent, usedPercent + case remaining_percent, remainingPercent + case reset_at, resets_at, resetsAt + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + limit = Flexible.double(c, .limit) + used = Flexible.double(c, .used) + usedPercent = Flexible.double(c, .used_percent) ?? Flexible.double(c, .usedPercent) + remainingPercent = Flexible.double(c, .remaining_percent) + ?? Flexible.double(c, .remainingPercent) + resetAt = Flexible.int(c, .reset_at) + ?? Flexible.int(c, .resets_at) + ?? Flexible.int(c, .resetsAt) + } + } // chatgpt.com sometimes serializes balance as a Double ("balance": 0.0) // and other times as a String ("balance": "0.00"). Mirror CodexBar's // resilient decode so a schema drift on either shape doesn't blow up // the whole quota fetch. struct Credits: Decodable { let balance: Double? - enum CodingKeys: String, CodingKey { case balance } + /// Settles in credits, not dollars, which relabels `balance`. + let hasCredits: Bool + let unlimited: Bool + + enum CodingKeys: String, CodingKey { + case balance + case has_credits + case unlimited + } + init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) - if let n = try? c.decode(Double.self, forKey: .balance) { - balance = n - } else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) { - balance = n - } else { - balance = nil - } + balance = Flexible.double(c, .balance) + hasCredits = Flexible.bool(c, .has_credits) + unlimited = Flexible.bool(c, .unlimited) } } } @@ -204,6 +324,28 @@ enum CodexSubscriptionService { return parseResetCredits(data: data) } + /// The inline block carries no per-credit expiry list, so it is only a safe + /// shortcut at zero, where there is no expiry to report. A non-zero count + /// still pays for the companion request rather than dropping the + /// "next expires" caption the popover would otherwise show. + static func inlineResetCreditsShortcut(data: Data) -> CodexUsage.ResetCredits? { + guard let inline = inlineResetCredits(data: data), inline.availableCount == 0 else { return nil } + return inline + } + + /// Reset-credit inventory carried inline on the usage payload. Nil means + /// absent. + static func inlineResetCredits(data: Data) -> CodexUsage.ResetCredits? { + struct InlineDTO: Decodable { + struct Block: Decodable { let available_count: Int? } + let rate_limit_reset_credits: Block? + } + guard let count = (try? JSONDecoder().decode(InlineDTO.self, from: data))? + .rate_limit_reset_credits?.available_count, count >= 0 + else { return nil } + return CodexUsage.ResetCredits(availableCount: count, nextExpiresAt: nil) + } + /// Internal (not private) so tests can drive it with fixture payloads. /// Returns nil on any unexpected shape — the caller treats nil as /// "feature unavailable", never as an error. @@ -239,7 +381,8 @@ enum CodexSubscriptionService { return plain.date(from: raw) } - private static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage { + /// Internal (not private) so tests can drive it with fixture payloads. + static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage { let root = try JSONDecoder().decode(UsageDTO.self, from: data) let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in guard let name = dto.limit_name, !name.isEmpty else { return nil } @@ -249,17 +392,61 @@ enum CodexSubscriptionService { secondary: makeWindow(dto.rate_limit?.secondary_window) ) } + let limitDTO = root.spend_control?.individualLimit + ?? root.individual_limit + ?? root.rate_limit?.individual_limit return CodexUsage( plan: CodexUsage.planType(from: root.plan_type), primary: makeWindow(root.rate_limit?.primary_window), secondary: makeWindow(root.rate_limit?.secondary_window), additionalLimits: additional, creditsBalance: root.credits?.balance, + hasCredits: root.credits?.hasCredits ?? false, + creditsUnlimited: root.credits?.unlimited ?? false, + creditLimit: makeCreditLimit(limitDTO, reached: root.spend_control?.reached ?? false), resetCredits: resetCredits, fetchedAt: Date() ) } + private static func makeCreditLimit( + _ dto: UsageDTO.IndividualLimit?, + reached: Bool + ) -> CodexUsage.CreditLimit? { + guard let dto, let limit = dto.limit, limit > 0 else { return nil } + // Server percentage, then remaining_percent, then the raw ratio. No + // signal at all means the draw is unknown; a 0% bar would claim otherwise. + guard let raw = dto.usedPercent + ?? dto.remainingPercent.map({ 100 - $0 }) + ?? dto.used.map({ $0 / limit * 100 }) + else { return nil } + let percent = min(max(raw, 0), 100) + let resetsAt = dto.resetAt.flatMap { $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil } + return CodexUsage.CreditLimit( + // Unclamped percent, so a 120% draw still reports 12,000 of 10,000. + used: dto.used ?? limit * max(raw, 0) / 100, + limit: limit, + usedPercent: percent, + resetsAt: resetsAt, + windowSeconds: monthlyWindowSeconds(endingAt: resetsAt), + reached: reached + ) + } + + /// Spend controls reset on a calendar-month boundary, so the window is the + /// month preceding the reset. Not `reset_after_seconds`, which is remaining. + /// UTC, not `Calendar.current`: a 2026-03-01Z reset spans 28 days in UTC + /// but 31 in Toronto, so a local calendar makes pace timezone-dependent. + private static func monthlyWindowSeconds(endingAt resetsAt: Date?) -> Int? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + guard let resetsAt, + let start = calendar.date(byAdding: .month, value: -1, to: resetsAt) + else { return nil } + let seconds = Int(resetsAt.timeIntervalSince(start)) + return seconds > 0 ? seconds : nil + } + private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? { guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else { return nil diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift index 3a5d814d..2fbbdbf1 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -31,7 +31,12 @@ struct CodexUsage: Sendable, Equatable { case .k12: "K-12" case .enterprise: "Enterprise" case .edu: "Edu" - case let .unknown(raw): raw.isEmpty ? "Subscription" : raw.capitalized + case let .unknown(raw): + raw.isEmpty + ? "Subscription" + : raw.replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .capitalized } } } @@ -76,16 +81,55 @@ struct CodexUsage: Sendable, Equatable { let nextExpiresAt: Date? } + /// The monthly allowance an admin sets. Credit-metered workspaces report + /// `rate_limit: null`, so this is their only limit. + struct CreditLimit: Sendable, Equatable { + let used: Double + let limit: Double + let usedPercent: Double // 0.0 ... 100.0 + let resetsAt: Date? + /// Calendar month the allowance resets on, for pace projection. Not the + /// payload's `reset_after_seconds`, which is the time remaining. + let windowSeconds: Int? + /// Allowance already spent: a hard stop, not a near-limit warning. + let reached: Bool + + /// `.halfUp` matches the desktop decoder's `Math.round`. + var displayLabel: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + formatter.roundingMode = .halfUp + // `en_US`, not `en_US_POSIX`: the latter drops grouping entirely. + formatter.locale = Locale(identifier: "en_US") + func text(_ value: Double) -> String { + formatter.string(from: NSNumber(value: value)) ?? "\(Int(value.rounded()))" + } + let base = "Monthly usage limit · \(text(used)) / \(text(limit)) credits" + return reached ? "\(base) · limit reached" : base + } + + var shortLabel: String { + reached ? "Monthly usage limit · limit reached" : "Monthly usage limit" + } + } + let plan: PlanType let primary: Window? let secondary: Window? let additionalLimits: [AdditionalLimit] let creditsBalance: Double? + /// Account settles in credits, not dollars, which changes `creditsBalance`. + let hasCredits: Bool + /// Uncapped on purpose, as distinct from a limit we failed to read. + let creditsUnlimited: Bool + let creditLimit: CreditLimit? let resetCredits: ResetCredits? let fetchedAt: Date static func planType(from raw: String?) -> PlanType { - guard let raw = raw?.lowercased() else { return .unknown("") } + guard let original = raw?.lowercased() else { return .unknown("") } + let raw = normalizePlanType(original) switch raw { case "guest": return .guest case "free": return .free @@ -101,7 +145,28 @@ struct CodexUsage: Sendable, Equatable { case "k12": return .k12 case "enterprise": return .enterprise case "edu": return .edu + // Normalized, so an unknown composite reads "Some Future Tier". default: return .unknown(raw) } } + + /// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`). + private static func normalizePlanType(_ raw: String) -> String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + for suffix in ["_usage_based", "-usage-based", "_usage-based", "-usage_based"] + where value.hasSuffix(suffix) { + value.removeLast(suffix.count) + } + for prefix in ["self_serve_", "self-serve-", "self_serve-", "self-serve_"] + where value.hasPrefix(prefix) { + value.removeFirst(prefix.count) + } + for suffix in ["_cbp", "-cbp"] where value.hasSuffix(suffix) { + value.removeLast(suffix.count) + } + for infix in ["_cbp_", "-cbp-", "_cbp-", "-cbp_"] { + value = value.replacingOccurrences(of: infix, with: "_") + } + return value + } } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 25b128a7..42ae294f 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -2067,7 +2067,7 @@ private struct CodexPlanInsight: View { .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) Spacer() - if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt { + if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt ?? usage.creditLimit?.resetsAt { Text("Resets \(relativeReset(resetsAt))") .font(.system(size: 10.5)) .foregroundStyle(.secondary) @@ -2109,6 +2109,26 @@ private struct CodexPlanInsight: View { ) } } + // No rate windows here, so without this row the card is empty. + if let credits = usage.creditLimit { + UtilizationRow( + label: credits.displayLabel, + percent: credits.usedPercent, + resetsAt: credits.resetsAt, + projection: pace(for: credits) + ) + } else if usage.creditsUnlimited { + // Uncapped on purpose, not a failed fetch. + HStack(alignment: .firstTextBaseline) { + Text("Credits") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Text("Unlimited") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } // Limit-reset credits the account is holding. Hidden at zero so // plans that never receive these grants see no extra row. if let resets = usage.resetCredits, resets.availableCount > 0 { @@ -2147,6 +2167,25 @@ private struct CodexPlanInsight: View { ) } + /// Rate-window pace math over the spend control's calendar month. + private func pace(for credits: CodexUsage.CreditLimit) -> WindowProjection? { + guard let windowSeconds = credits.windowSeconds, + let result = QuotaPace.evaluate( + usedPercent: credits.usedPercent, + resetsAt: credits.resetsAt, + windowSeconds: windowSeconds + ) + else { return nil } + return WindowProjection( + percent: result.projectedPercent, + willOverflow: result.willOverflow, + hitsLimitAt: result.hitsLimitAt, + source: .linear, + deltaPercent: result.deltaPercent, + compact: TimeInterval(windowSeconds) <= QuotaPace.etaSuppressionMaxSeconds + ) + } + private func resetCreditsLabel(_ resets: CodexUsage.ResetCredits) -> String { let count = "\(resets.availableCount) available" guard let next = resets.nextExpiresAt else { return count } diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index f220ba5e..289ad581 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -487,7 +487,7 @@ private struct CodexSettingsTab: View { CodexConnectionRow() } Section { - Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business) is supported — API-key users are billed per request and have a different reporting surface.") + Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.") .font(.system(size: 11)) .foregroundStyle(.secondary) } header: { diff --git a/mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift b/mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift new file mode 100644 index 00000000..bd55447d --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift @@ -0,0 +1,248 @@ +import Foundation +import XCTest +@testable import CodeBurnMenubar + +/// `plan_type` parsing and the credit-metered branch of the wham/usage decoder. +final class CodexPlanParsingTests: XCTestCase { + private func decode(_ json: String) throws -> CodexUsage { + try CodexSubscriptionService.decodeUsage(data: Data(json.utf8)) + } + + func testKnownTiersMapToDisplayNames() { + let expected: [String: String] = [ + "guest": "Guest", "free": "Free", "go": "Go", "plus": "Plus", "pro": "Pro", + "prolite": "Pro Lite", "pro_lite": "Pro Lite", "pro-lite": "Pro Lite", + "free_workspace": "Free Workspace", "team": "Team", "business": "Business", + "education": "Education", "quorum": "Quorum", "k12": "K-12", + "enterprise": "Enterprise", "edu": "Edu", + ] + for (raw, display) in expected { + XCTAssertEqual(CodexUsage.planType(from: raw).displayName, display, "plan_type: \(raw)") + } + } + + func testTierMatchingIsCaseInsensitive() { + XCTAssertEqual(CodexUsage.planType(from: "pLuS"), .plus) + XCTAssertEqual(CodexUsage.planType(from: "ENTERPRISE"), .enterprise) + } + + func testCreditBasedPricingCompositesNormalize() { + XCTAssertEqual(CodexUsage.planType(from: "enterprise_cbp_usage_based"), .enterprise) + XCTAssertEqual(CodexUsage.planType(from: "self_serve_business_usage_based"), .business) + XCTAssertEqual(CodexUsage.planType(from: "business_cbp"), .business) + } + + func testHyphenSeparatedCompositesNormalizeToo() { + XCTAssertEqual(CodexUsage.planType(from: "enterprise-cbp-usage-based"), .enterprise) + XCTAssertEqual(CodexUsage.planType(from: "self-serve-business-usage-based"), .business) + XCTAssertEqual(CodexUsage.planType(from: "business-cbp"), .business) + } + + func testUnknownTierNormalizesAndTitleCasesLikeTheDesktopDecoder() { + XCTAssertEqual(CodexUsage.planType(from: "some_future_tier_usage_based"), + .unknown("some_future_tier")) + XCTAssertEqual(CodexUsage.planType(from: "some_future_tier_usage_based").displayName, + "Some Future Tier") + XCTAssertEqual(CodexUsage.planType(from: nil), .unknown("")) + XCTAssertEqual(CodexUsage.planType(from: nil).displayName, "Subscription") + } + + /// Captured from a live ChatGPT Enterprise workspace (identifiers replaced). + private let enterprisePayload = #""" + { + "plan_type": "business", + "rate_limit": null, + "code_review_rate_limit": null, + "additional_rate_limits": null, + "credits": { + "has_credits": false, "unlimited": false, "overage_limit_reached": false, + "balance": null, "approx_local_messages": null, "approx_cloud_messages": null + }, + "spend_control": { + "reached": false, + "individual_limit": { + "source": "workspace_spend_controls", + "limit": "10000", + "used": "3028.9909675121307", + "remaining": "6971.009032487869", + "used_percent": 30, + "remaining_percent": 70, + "reset_after_seconds": 441896, + "reset_at": 1785542400 + } + }, + "rate_limit_reset_credits": {"available_count": 0, "applicable_available_count": 0} + } + """# + + func testEnterprisePayloadDecodesTheSpendControlLimit() throws { + let usage = try decode(enterprisePayload) + XCTAssertNil(usage.primary) + XCTAssertNil(usage.secondary) + XCTAssertTrue(usage.additionalLimits.isEmpty) + + let credits = try XCTUnwrap(usage.creditLimit) + XCTAssertEqual(credits.limit, 10_000) + XCTAssertEqual(credits.used, 3028.9909675121307, accuracy: 0.0001) + XCTAssertEqual(credits.usedPercent, 30) + XCTAssertEqual(credits.resetsAt, Date(timeIntervalSince1970: 1_785_542_400)) + XCTAssertFalse(credits.reached) + // July 2026, not the payload's `reset_after_seconds`. + XCTAssertEqual(try XCTUnwrap(credits.windowSeconds), 31 * 86_400) + + XCTAssertEqual(usage.plan, .business) + XCTAssertNil(usage.creditsBalance) + XCTAssertFalse(usage.hasCredits) + XCTAssertFalse(usage.creditsUnlimited) + } + + func testSpendControlIsReadAtEveryObservedPosition() throws { + let bodies = [ + #"{"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 25}}}"#, + #"{"spend_control": {"individualLimit": {"limit": 10000, "usedPercent": 25}}}"#, + #"{"individual_limit": {"limit": 10000, "used_percent": 25}}"#, + #"{"rate_limit": {"individual_limit": {"limit": 10000, "used_percent": 25}}}"#, + ] + for body in bodies { + let credits = try XCTUnwrap(decode(body).creditLimit, body) + XCTAssertEqual(credits.usedPercent, 25, body) + XCTAssertEqual(credits.used, 2500, body) + } + } + + func testPercentFallsBackThroughRemainingPercentThenRawRatio() throws { + let fromRemaining = try XCTUnwrap( + decode(#"{"spend_control": {"individual_limit": {"limit": 10000, "remaining_percent": 70}}}"#).creditLimit) + XCTAssertEqual(fromRemaining.usedPercent, 30, accuracy: 0.0001) + + let fromRatio = try XCTUnwrap( + decode(#"{"spend_control": {"individual_limit": {"limit": 400, "used": 100}}}"#).creditLimit) + XCTAssertEqual(fromRatio.usedPercent, 25, accuracy: 0.0001) + } + + func testUnusableSpendControlYieldsNoRow() throws { + let bodies = [ + #"{"spend_control": {"individual_limit": {"limit": 0, "used": 5}}}"#, + #"{"spend_control": {"individual_limit": {"limit": null}}}"#, + #"{"spend_control": {"individual_limit": {"used_percent": 40}}}"#, + #"{"spend_control": {"individual_limit": null}}"#, + #"{"spend_control": null}"#, + "{}", + ] + for body in bodies { + XCTAssertNil(try decode(body).creditLimit, body) + } + } + + func testAllowanceWithoutAnyUsageSignalYieldsNoRow() throws { + let body = #"{"spend_control": {"individual_limit": {"limit": 10000, "reset_at": 1785542400}}}"# + XCTAssertNil(try decode(body).creditLimit) + } + + func testBlankNumericStringIsAbsentNotZero() throws { + let credits = try XCTUnwrap(decode(#""" + {"spend_control": {"individual_limit": {"limit": "10000", "used": " ", "used_percent": 30}}} + """#).creditLimit) + XCTAssertEqual(credits.used, 3000, accuracy: 0.001) + XCTAssertNil(try decode(#"{"spend_control": {"individual_limit": {"limit": ""}}}"#).creditLimit) + } + + func testOverageKeepsCountsTruthfulWhileClampingPercent() throws { + let credits = try XCTUnwrap(decode(#""" + {"spend_control": {"individual_limit": {"limit": 10000, "used": 12000, "used_percent": 120}}} + """#).creditLimit) + XCTAssertEqual(credits.used, 12000, accuracy: 0.001) + XCTAssertEqual(credits.usedPercent, 100) + } + + func testMonthWindowIsTimezoneIndependent() throws { + // 2026-03-01Z reads as 28 days in UTC but 31 through a local calendar. + let body = #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": 1772323200}}}"# + XCTAssertEqual(try XCTUnwrap(decode(body).creditLimit?.windowSeconds), 28 * 86_400) + } + + func testOutOfRangeNumbersDoNotTrap() throws { + // `Int(_:)` on 1e100 traps, and a trap is not a catchable error. + for body in [ + #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": 1e100}}}"#, + #"{"spend_control": {"individual_limit": {"limit": "Infinity", "used_percent": 10}}}"#, + #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": "NaN"}}}"#, + ] { + _ = try? decode(body) + } + let huge = #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": "1e100"}}}"# + XCTAssertNil(try XCTUnwrap(decode(huge).creditLimit).resetsAt) + } + + func testPercentOnlyOverageKeepsTheImpliedCount() throws { + let body = #"{"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 120}}}"# + let credits = try XCTUnwrap(decode(body).creditLimit) + XCTAssertEqual(credits.used, 12000, accuracy: 0.001) + XCTAssertEqual(credits.usedPercent, 100) + XCTAssertEqual(credits.displayLabel, "Monthly usage limit · 12,000 / 10,000 credits") + } + + func testOneMalformedAdditionalLimitDoesNotDiscardTheRest() throws { + let body = #""" + {"additional_rate_limits": [ + null, + {"limit_name": "Spark", "rate_limit": {"primary_window": {"used_percent": 40, "limit_window_seconds": 18000}}} + ]} + """# + XCTAssertEqual(try decode(body).additionalLimits.map(\.name), ["Spark"]) + } + + func testReachedSpendControlIsCarriedThrough() throws { + let credits = try XCTUnwrap(decode(#""" + {"spend_control": {"reached": true, "individual_limit": {"limit": 10000, "used_percent": 100}}} + """#).creditLimit) + XCTAssertTrue(credits.reached) + XCTAssertEqual(credits.usedPercent, 100) + } + + func testCreditFlagsAndMixedNumberEncodings() throws { + let usage = try decode(#""" + {"credits": {"has_credits": true, "unlimited": true, "balance": "3410.40"}} + """#) + XCTAssertTrue(usage.hasCredits) + XCTAssertTrue(usage.creditsUnlimited) + XCTAssertEqual(try XCTUnwrap(usage.creditsBalance), 3410.40, accuracy: 0.0001) + } + + func testRateWindowsStillDecodeAlongsideASpendControl() throws { + let usage = try decode(#""" + { + "plan_type": "plus", + "rate_limit": { + "primary_window": {"used_percent": 20, "reset_at": 1800000000, "limit_window_seconds": 18000} + }, + "spend_control": {"individual_limit": {"limit": 10000, "used_percent": 30}} + } + """#) + XCTAssertEqual(usage.primary?.usedPercent, 20) + XCTAssertEqual(usage.primary?.windowLabel, "5-hour") + XCTAssertEqual(usage.creditLimit?.usedPercent, 30) + } + + func testOnlyAZeroCountSkipsTheCompanionRequest() { + let zero = #"{"rate_limit_reset_credits": {"available_count": 0}}"# + let some = #"{"rate_limit_reset_credits": {"available_count": 3}}"# + XCTAssertEqual(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data(zero.utf8))?.availableCount, 0) + XCTAssertNil(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data(some.utf8))) + XCTAssertNil(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data("{}".utf8))) + XCTAssertEqual(CodexSubscriptionService.inlineResetCredits(data: Data(some.utf8))?.availableCount, 3) + } + + func testInlineResetCreditsParseFromTheUsagePayload() { + let inline = CodexSubscriptionService.inlineResetCredits(data: Data(enterprisePayload.utf8)) + XCTAssertEqual(inline?.availableCount, 0) + XCTAssertNil(inline?.nextExpiresAt) + } + + func testInlineResetCreditsAbsentSignalsFallback() { + XCTAssertNil(CodexSubscriptionService.inlineResetCredits(data: Data("{}".utf8))) + XCTAssertNil(CodexSubscriptionService.inlineResetCredits(data: Data("not json".utf8))) + XCTAssertNil(CodexSubscriptionService.inlineResetCredits( + data: Data(#"{"rate_limit_reset_credits": {}}"#.utf8))) + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift b/mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift new file mode 100644 index 00000000..ba37e106 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift @@ -0,0 +1,79 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +private func usage( + balance: Double? = nil, + hasCredits: Bool = false, + unlimited: Bool = false, + creditLimit: CodexUsage.CreditLimit? = nil +) -> CodexUsage { + CodexUsage( + plan: .business, + primary: nil, + secondary: nil, + additionalLimits: [], + creditsBalance: balance, + hasCredits: hasCredits, + creditsUnlimited: unlimited, + creditLimit: creditLimit, + resetCredits: nil, + fetchedAt: Date() + ) +} + +private func limit(used: Double, of total: Double, reached: Bool = false) -> CodexUsage.CreditLimit { + CodexUsage.CreditLimit( + used: used, + limit: total, + usedPercent: used / total * 100, + resetsAt: Date(timeIntervalSince1970: 1_785_542_400), + windowSeconds: 31 * 86_400, + reached: reached + ) +} + +@MainActor +private func store(_ usage: CodexUsage) -> AppStore { + let store = AppStore() + store.codexUsage = usage + // Pinned: the default depends on whether this machine has Codex connected. + store.codexLoadState = .loaded + return store +} + +@MainActor +struct CodexQuotaSummaryTests { + @Test("credit-settled balances group without a currency symbol") + func creditSettledBalanceIsGroupedAndUnprefixed() { + let store = store(usage(balance: 3410.4, hasCredits: true)) + #expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · 3,410"]) + } + + @Test("dollar balances keep the currency formatting") + func dollarBalanceKeepsCurrencyFormatting() { + let store = store(usage(balance: 3410.4, hasCredits: false)) + #expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · $3,410.40"]) + } + + @Test("an uncapped credit account says so instead of showing nothing") + func uncappedAccountSaysUnlimited() { + let store = store(usage(hasCredits: true, unlimited: true)) + #expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits · Unlimited"]) + } + + @Test("the allowance row drives the chip with the short label") + func allowanceRowUsesTheShortLabel() { + let store = store(usage(creditLimit: limit(used: 3033, of: 10_000))) + let summary = store.quotaSummary(for: .codex) + #expect(summary?.primary?.label == "Monthly usage limit") + #expect(summary?.primary?.percent == 0.3033) + #expect(summary?.footerLines.isEmpty == true) + } + + @Test("a spent-out allowance is called out on the chip") + func reachedAllowanceIsCalledOut() { + let store = store(usage(creditLimit: limit(used: 10_000, of: 10_000, reached: true))) + #expect(store.quotaSummary(for: .codex)?.primary?.label == "Monthly usage limit · limit reached") + } +}