Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
144 changes: 144 additions & 0 deletions app/electron/quota/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down
74 changes: 69 additions & 5 deletions app/electron/quota/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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)
Expand All @@ -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,
}
}

Expand Down
104 changes: 104 additions & 0 deletions docs/providers/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading