Merge pull request #833 from rbstp/chatgpt-enterprise-support

feat(codex): show the credit limit on credit-metered ChatGPT workspaces
This commit is contained in:
Resham Joshi 2026-08-01 13:38:00 -07:00 committed by GitHub
commit fee91be92b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 990 additions and 28 deletions

View file

@ -2,11 +2,15 @@
## 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.
### Added (CLI)
- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)
### Fixed (CLI)
- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)
### Fixed
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)

View file

@ -38,6 +38,153 @@ 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'])
// Thousands separators must match the menubar's en_US currency formatter.
const inDollarsLarge = decodeCodexUsage({ credits: { has_credits: false, balance: 12500 } })
expect(inDollarsLarge.footerLines).toEqual(['Credits remaining · $12,500.00'])
})
it('returns disconnected without credentials', async () => {
const fetchMock = vi.fn()
const result = await fetchCodexQuota({ fetch: fetchMock, readFile: vi.fn(async () => null) })

View file

@ -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<string, string> = {
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<string, any>): 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<string, any> : {}
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.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`)
}
// 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,
}
}

View file

@ -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`.

View file

@ -1378,19 +1378,38 @@ 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
// Half-up matches the desktop decoder's Math.round; the default is
// half-even, which disagrees on exact-half balances.
formatter.roundingMode = .halfUp
// `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)
}

View file

@ -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<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ 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<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Int? {
double(c, key).flatMap { Int(exactly: $0.rounded()) }
}
static func bool<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ 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<T: Decodable>: 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<AdditionalLimitDTO>].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

View file

@ -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
}
}

View file

@ -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 }

View file

@ -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: {

View file

@ -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)))
}
}

View file

@ -0,0 +1,85 @@
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 exact-half credit balance rounds up, matching the desktop decoder")
func creditBalanceRoundsHalfUp() {
let store = store(usage(balance: 3410.5, hasCredits: true))
#expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · 3,411"])
}
@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")
}
}