mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-11 09:34:45 +00:00
feat(codex): show the credit limit on credit-metered ChatGPT workspaces
Signed-off-by: Richard Boisvert <rboisvert@devolutions.net>
This commit is contained in:
parent
85781999a5
commit
3e400bffa7
11 changed files with 977 additions and 28 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
|
|
@ -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.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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
248
mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift
Normal file
248
mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift
Normal 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)))
|
||||
}
|
||||
}
|
||||
79
mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift
Normal file
79
mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue