Skip to content

Commit 57b4a8b

Browse files
committed
fix(codex-auth): allow team reset credit tickets
1 parent fbdfb9d commit 57b4a8b

3 files changed

Lines changed: 65 additions & 63 deletions

File tree

devlog/290_rate-limit-reset-credits/02_ui-spec.md

Lines changed: 31 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
# 290-02 Rate-Limit Reset Credits — UI Specification
22

3-
## Design Decision: Workspace Account Behavior
3+
## Design Decision: Account Plan Behavior
4+
5+
**Correction (2026-06-27): do not exclude workspace/team plans from reset-credit lookup.**
6+
Live WHAM probes showed `team` accounts can return `rate_limit_reset_credits.available_count`
7+
and can be queried through the same reset-credit endpoint as personal accounts. The UI must
8+
therefore treat the upstream `resetCredits` value as authoritative instead of disabling the
9+
ticket badge from `plan_type`.
410

511
Per codex-rs source (`protocol/src/account.rs:50-59`):
612

@@ -13,18 +19,15 @@ pub fn is_workspace_account(self) -> bool {
1319
}
1420
```
1521

16-
Workspace plans: `team`, `business`, `enterprise`, `edu`, `self_serve_business_usage_based`, `enterprise_cbp_usage_based`
17-
Workspace aliases (auth.rs): `hc` → enterprise, `education` → edu
18-
Personal plans: `free`, `go`, `plus`, `pro`, `prolite`
19-
20-
**codex-rs TUI behavior** (`usage.rs:13-14`):
21-
- Workspace accounts → reset menu item **visible but disabled** + "No rate-limit resets available."
22-
- Personal accounts → reset menu item enabled
22+
The old codex-rs-derived assumption was:
23+
- Workspace accounts always have no reset credits.
24+
- Workspace accounts should show a disabled reset menu.
2325

24-
**opencodex decision**: Match codex-rs — show ticket badge on ALL accounts,
25-
but workspace accounts show `0` count with disabled/grayed styling.
26-
Server-side, workspace accounts return `available_count: 0` (or null),
27-
so this naturally resolves.
26+
That assumption is not true for current ChatGPT team accounts. Current opencodex behavior:
27+
- If `resetCredits` is a number, show a clickable ticket badge for any plan.
28+
- If `resetCredits > 0`, use the amber badge.
29+
- If `resetCredits === 0`, use the muted badge and allow the empty-state popup.
30+
- If `resetCredits === undefined`, hide the badge because the value has not been fetched.
2831

2932
## Component Architecture
3033

@@ -38,23 +41,21 @@ Placement: between plan badge and NEXT SESSION badge.
3841
└──────────────────────────────────────────────────────────┘
3942
```
4043

41-
For workspace accounts:
44+
For team accounts with credits:
4245
```
4346
┌─ card-head ──────────────────────────────────────────────┐
44-
│ ● p***1@gmail.com [team] [🎫 0] [×] │
45-
│ ↑ grayed out, no click
47+
│ ● p***1@gmail.com [team] [🎫 1] [×] │
48+
│ ↑ clickable; opens ticket popup
4649
└──────────────────────────────────────────────────────────┘
4750
```
4851

4952
### Badge Variants
5053

5154
| Condition | Style | Clickable |
5255
|-----------|-------|-----------|
53-
| `resetCredits > 0` + personal plan | amber bg, amber text, ticket icon + count | Yes → opens popup |
54-
| `resetCredits === 0` + personal plan | muted bg, muted text, ticket icon + "0" | Yes → opens popup (shows empty state) |
55-
| Workspace plan (any count) | muted bg, faint text, ticket icon + "–" | No (disabled) |
56-
| Workspace plan + `resetCredits === undefined` | muted bg, faint text, ticket icon + "–" | No (disabled) |
57-
| Personal plan + `resetCredits === undefined` | Hidden ||
56+
| `resetCredits > 0` | amber bg, amber text, ticket icon + count | Yes → opens popup |
57+
| `resetCredits === 0` | muted bg, muted text, ticket icon + "0" | Yes → opens popup (shows empty state) |
58+
| `resetCredits === undefined` | Hidden ||
5859

5960
### Ticket List Popup (Modal)
6061

@@ -186,18 +187,6 @@ interface AccountQuota {
186187
}
187188
```
188189

189-
### isWorkspaceAccount Helper
190-
191-
```tsx
192-
function isWorkspaceAccount(plan?: string): boolean {
193-
if (!plan) return false;
194-
const ws = ["team", "business", "enterprise", "edu",
195-
"self_serve_business_usage_based", "enterprise_cbp_usage_based",
196-
"hc", "education"]; // aliases from codex-rs auth.rs
197-
return ws.includes(plan.toLowerCase());
198-
}
199-
```
200-
201190
### Ticket Badge in card-head (pool cards, line ~129-134)
202191

203192
Insert after plan badge, before NEXT SESSION badge:
@@ -206,7 +195,7 @@ Insert after plan badge, before NEXT SESSION badge:
206195
{/* After: {a.plan && <span className="badge badge-green">{a.plan}</span>} */}
207196
<TicketBadge
208197
account={a}
209-
onClick={() => !isWorkspaceAccount(a.plan) && setResetPopup(a)}
198+
onClick={() => setResetPopup(a)}
210199
/>
211200
{/* Before: {isNext(a.id) && ... NEXT SESSION ...} */}
212201
```
@@ -224,7 +213,7 @@ Insert between `<strong>` (line 106) and CURRENT/NEXT badge (line 107):
224213
{main && (
225214
<TicketBadge
226215
account={{ ...main, id: "__main__" } as AccountEntry}
227-
onClick={() => !isWorkspaceAccount(main?.plan) && setResetPopup({ ...main, id: "__main__" } as AccountEntry)}
216+
onClick={() => setResetPopup({ ...main, id: "__main__" } as AccountEntry)}
228217
/>
229218
)}
230219
<span className={`badge ${!activeId ? "badge-primary" : "badge-muted"}`}>
@@ -238,23 +227,19 @@ not `getValidCodexToken()`. This is already handled in `00_plan.md` §1.2.
238227
```tsx
239228
function TicketBadge({ account, onClick }: { account: AccountEntry; onClick: () => void }) {
240229
const credits = account.quota?.resetCredits;
241-
const workspace = isWorkspaceAccount(account.plan);
242-
243-
// Workspace: always show disabled badge. Personal: hide if not fetched.
244-
if (!workspace && credits === undefined) return null;
230+
if (credits === undefined) return null;
245231

246-
const hasCredits = typeof credits === "number" && credits > 0 && !workspace;
232+
const hasCredits = typeof credits === "number" && credits > 0;
247233

248234
return (
249235
<button
250236
type="button"
251-
className={`badge ${hasCredits ? "badge-amber" : "badge-muted"} ${workspace ? "badge-disabled" : "badge-clickable"}`}
252-
onClick={workspace ? undefined : (e) => { e.stopPropagation(); onClick(); }}
253-
disabled={workspace}
254-
aria-label={workspace ? "Not available for workspace accounts" : `${credits ?? 0} reset credit(s)`}
237+
className={`badge ${hasCredits ? "badge-amber" : "badge-muted"} badge-clickable`}
238+
onClick={(e) => { e.stopPropagation(); onClick(); }}
239+
aria-label={`${credits} reset credit(s)`}
255240
>
256241
<IconTicket width={12} />
257-
{workspace ? "" : (credits ?? 0)}
242+
{credits}
258243
</button>
259244
);
260245
}
@@ -425,13 +410,13 @@ export const IconTicket = (p: P) => (
425410
| # | Severity | Issue | Fix |
426411
|---|----------|-------|-----|
427412
| 1 | High | Main card insertion point wrong | Corrected: between `<strong>` and CURRENT badge |
428-
| 2 | High | Workspace + undefined = hidden, contradicts "always show" | Workspace always shows disabled badge regardless of `resetCredits` |
413+
| 2 | High | Workspace disabled assumption contradicted live team credits | Plan no longer gates reset-credit badge; upstream `resetCredits` is authoritative |
429414
| 3 | High | `__main__` consume path | Noted: backend handles via `readCodexTokens()` (see 00_plan.md §1.2) |
430415
| 4 | Medium | `handleRedeem` stale closure | Capture `prevCredits` before clearing `resetPopup` |
431416
| 5 | Medium | `ko.ts`/`zh.ts` missing | Added note: must add keys to all locale files |
432417
| 6 | Medium | IconTicket pattern mismatch | Changed to arrow fn + `P` type + `S()` helper |
433418
| 7 | Medium | Dynamic i18n key type violation | Changed to explicit `switch` mapping |
434-
| 8 | Low | `isWorkspaceAccount` missing aliases | Added `hc`, `education` |
419+
| 8 | Low | `isWorkspaceAccount` missing aliases | Obsolete: removed workspace gating entirely |
435420
| 9 | Low | `resp.ok` check missing | Added `if (!resp.ok)` guard |
436421
| 10 | Low | Emoji in modal title | Changed to `<IconTicket>` |
437422
| 11 | Low | `<span>` for clickable badge | Changed to `<button>` with `aria-label` |

gui/src/pages/CodexAuth.tsx

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
159159
<div className="card-head">
160160
<span className="dot dot-green" />
161161
<strong>{t("codexAuth.mainAccount")}</strong>
162-
{main && <TicketBadge account={{ ...main, id: "__main__" } as AccountEntry} onClick={() => !isWorkspaceAccount(main?.plan) && openResetPopup({ ...main, id: "__main__" } as AccountEntry)} />}
162+
{main && <TicketBadge account={{ ...main, id: "__main__" } as AccountEntry} onClick={() => openResetPopup({ ...main, id: "__main__" } as AccountEntry)} />}
163163
<span className={`badge ${!activeId ? "badge-primary" : "badge-muted"}`}>
164164
{!activeId ? t("codexAuth.nextSession") : t("codexAuth.current")}
165165
</span>
@@ -186,7 +186,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
186186
<span className={`dot ${a.needsReauth ? "dot-amber" : isNext(a.id) ? "dot-blue" : "dot-muted"}`} />
187187
<strong>{a.email}</strong>
188188
{a.plan && <span className="badge badge-green">{a.plan}</span>}
189-
<TicketBadge account={a} onClick={() => !isWorkspaceAccount(a.plan) && openResetPopup(a)} />
189+
<TicketBadge account={a} onClick={() => openResetPopup(a)} />
190190
{a.needsReauth && <span className="badge badge-amber">{t("codexAuth.needsReauth")}</span>}
191191
{isNext(a.id) && !a.needsReauth && <span className="badge badge-primary">{t("codexAuth.nextSession")}</span>}
192192
<button
@@ -391,27 +391,18 @@ function CreditItem({ index, grantedAt, expiresAt, isNext, t }: {
391391
);
392392
}
393393

394-
function isWorkspaceAccount(plan?: string): boolean {
395-
if (!plan) return false;
396-
return ["team", "business", "enterprise", "edu",
397-
"self_serve_business_usage_based", "enterprise_cbp_usage_based",
398-
"hc", "education"].includes(plan.toLowerCase());
399-
}
400-
401394
function TicketBadge({ account, onClick }: { account: AccountEntry; onClick: () => void }) {
402395
const credits = account.quota?.resetCredits;
403-
const workspace = isWorkspaceAccount(account.plan);
404-
if (!workspace && credits === undefined) return null;
405-
const hasCredits = typeof credits === "number" && credits > 0 && !workspace;
396+
if (credits === undefined) return null;
397+
const hasCredits = typeof credits === "number" && credits > 0;
406398
return (
407399
<button type="button"
408-
className={`badge ${hasCredits ? "badge-amber" : "badge-muted"} ${workspace ? "badge-disabled" : "badge-clickable"}`}
409-
onClick={workspace ? undefined : (e) => { e.stopPropagation(); onClick(); }}
410-
disabled={workspace}
411-
aria-label={workspace ? "Not available for workspace accounts" : `${credits ?? 0} reset credit(s)`}
400+
className={`badge ${hasCredits ? "badge-amber" : "badge-muted"} badge-clickable`}
401+
onClick={(e) => { e.stopPropagation(); onClick(); }}
402+
aria-label={`${credits} reset credit(s)`}
412403
>
413404
<IconTicket width={12} />
414-
{workspace ? "–" : (credits ?? 0)}
405+
{credits}
415406
</button>
416407
);
417408
}

tests/rate-limit-reset-credits.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,32 @@ describe("rate-limit reset credits", () => {
7272
expect(quota).not.toBeNull();
7373
expect(quota!.resetCredits).toBe(0);
7474
});
75+
76+
it("keeps reset credits for team plans", () => {
77+
const data: WhamUsageResponse = {
78+
plan_type: "team",
79+
rate_limit: {
80+
primary_window: { used_percent: 12, reset_at: 1700000000 },
81+
secondary_window: { used_percent: 34, reset_at: 1700100000 },
82+
},
83+
rate_limit_reset_credits: { available_count: 1 },
84+
};
85+
const quota = parseUsageQuota(data);
86+
expect(quota).not.toBeNull();
87+
expect(quota!.resetCredits).toBe(1);
88+
expect(quota!.fiveHourPercent).toBe(12);
89+
expect(quota!.weeklyPercent).toBe(34);
90+
});
91+
});
92+
93+
describe("CodexAuth reset credit UI", () => {
94+
it("does not exclude team or workspace plans from ticket badges", async () => {
95+
const source = await Bun.file("gui/src/pages/CodexAuth.tsx").text();
96+
expect(source).not.toContain("isWorkspaceAccount");
97+
expect(source).not.toContain("Not available for workspace accounts");
98+
expect(source).toContain("if (credits === undefined) return null;");
99+
expect(source).toContain("className={`badge ${hasCredits ? \"badge-amber\" : \"badge-muted\"} badge-clickable`}");
100+
});
75101
});
76102

77103
describe("updateAccountQuota resetCredits", () => {

0 commit comments

Comments
 (0)