Skip to content

Commit 3c1bd80

Browse files
lidge-junclaude
andcommitted
[agent] feat: individual credit details with FIFO expiry display
- Add GET /api/codex-auth/reset-credits endpoint proxying /wham/rate-limit-reset-credits for individual credit details - Popup now fetches and shows each credit with granted_at/expires_at - Credits sorted FIFO (oldest first = next to use), first item highlighted with amber border + NEXT badge - Days remaining shown per credit, red if <= 7 days - Confirmation dialog shows which credit will be consumed - 7 new i18n keys across en/ko/zh Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9474aef commit 3c1bd80

6 files changed

Lines changed: 137 additions & 6 deletions

File tree

gui/src/i18n/en.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ export const en = {
206206
"codexAuth.resetNothingToReset": "No rate-limit window needs resetting right now.",
207207
"codexAuth.resetNoCredit": "No reset credits available.",
208208
"codexAuth.resetError": "Failed to redeem reset credit. Please try again.",
209+
"codexAuth.fifoNote": "The oldest credit is used first.",
210+
"codexAuth.confirmWhichCredit": "Credit from {date} will be used.",
211+
"codexAuth.creditNext": "Next to use",
212+
"codexAuth.creditLabel": "Credit #{n}",
213+
"codexAuth.creditNextBadge": "NEXT",
214+
"codexAuth.creditGranted": "Granted {date}",
215+
"codexAuth.creditExpires": "Expires {date} ({days}d left)",
209216
} as const;
210217

211218
export type TKey = keyof typeof en;

gui/src/i18n/ko.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,4 +206,11 @@ export const ko: Record<TKey, string> = {
206206
"codexAuth.resetNothingToReset": "현재 초기화할 사용량 윈도우가 없습니다.",
207207
"codexAuth.resetNoCredit": "사용 가능한 리셋 크레딧이 없습니다.",
208208
"codexAuth.resetError": "리셋 크레딧 사용에 실패했습니다. 다시 시도해 주세요.",
209+
"codexAuth.fifoNote": "가장 오래된 크레딧부터 사용됩니다.",
210+
"codexAuth.confirmWhichCredit": "{date}에 획득한 크레딧이 사용됩니다.",
211+
"codexAuth.creditNext": "다음 사용 대상",
212+
"codexAuth.creditLabel": "크레딧 #{n}",
213+
"codexAuth.creditNextBadge": "NEXT",
214+
"codexAuth.creditGranted": "획득 {date}",
215+
"codexAuth.creditExpires": "만료 {date} ({days}일 남음)",
209216
};

gui/src/i18n/zh.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,4 +206,11 @@ export const zh: Record<TKey, string> = {
206206
"codexAuth.resetNothingToReset": "当前没有需要重置的使用窗口。",
207207
"codexAuth.resetNoCredit": "没有可用的重置额度。",
208208
"codexAuth.resetError": "重置额度使用失败,请重试。",
209+
"codexAuth.fifoNote": "最早获得的额度优先使用。",
210+
"codexAuth.confirmWhichCredit": "将使用 {date} 获得的额度。",
211+
"codexAuth.creditNext": "即将使用",
212+
"codexAuth.creditLabel": "额度 #{n}",
213+
"codexAuth.creditNextBadge": "NEXT",
214+
"codexAuth.creditGranted": "获得 {date}",
215+
"codexAuth.creditExpires": "过期 {date}(剩余 {days} 天)",
209216
};

gui/src/pages/CodexAuth.tsx

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
3232
const [resetPopup, setResetPopup] = useState<AccountEntry | null>(null);
3333
const [resetConfirm, setResetConfirm] = useState(false);
3434
const [redeeming, setRedeeming] = useState(false);
35+
const [creditDetails, setCreditDetails] = useState<{ granted_at: string; expires_at: string }[] | null>(null);
36+
const [creditDetailsLoading, setCreditDetailsLoading] = useState(false);
3537

3638
const load = async (refreshQuota = false) => {
3739
try {
@@ -87,6 +89,24 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
8789
}
8890
};
8991

92+
const openResetPopup = async (account: AccountEntry) => {
93+
setResetPopup(account);
94+
setResetConfirm(false);
95+
setCreditDetails(null);
96+
setCreditDetailsLoading(true);
97+
try {
98+
const resp = await fetch(`${apiBase}/api/codex-auth/reset-credits?accountId=${encodeURIComponent(account.id)}`);
99+
if (resp.ok) {
100+
const data = (await resp.json()) as { credits?: { granted_at: string; expires_at: string }[] };
101+
const sorted = (data.credits ?? []).sort((a, b) =>
102+
new Date(a.granted_at).getTime() - new Date(b.granted_at).getTime()
103+
);
104+
setCreditDetails(sorted);
105+
}
106+
} catch { /* detail fetch is non-blocking */ }
107+
finally { setCreditDetailsLoading(false); }
108+
};
109+
90110
const handleRedeem = async (accountId: string) => {
91111
setRedeeming(true);
92112
try {
@@ -139,7 +159,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
139159
<div className="card-head">
140160
<span className="dot dot-green" />
141161
<strong>{t("codexAuth.mainAccount")}</strong>
142-
{main && <TicketBadge account={{ ...main, id: "__main__" } as AccountEntry} onClick={() => !isWorkspaceAccount(main?.plan) && setResetPopup({ ...main, id: "__main__" } as AccountEntry)} />}
162+
{main && <TicketBadge account={{ ...main, id: "__main__" } as AccountEntry} onClick={() => !isWorkspaceAccount(main?.plan) && openResetPopup({ ...main, id: "__main__" } as AccountEntry)} />}
143163
<span className={`badge ${!activeId ? "badge-primary" : "badge-muted"}`}>
144164
{!activeId ? t("codexAuth.nextSession") : t("codexAuth.current")}
145165
</span>
@@ -166,7 +186,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
166186
<span className={`dot ${a.needsReauth ? "dot-amber" : isNext(a.id) ? "dot-blue" : "dot-muted"}`} />
167187
<strong>{a.email}</strong>
168188
{a.plan && <span className="badge badge-green">{a.plan}</span>}
169-
<TicketBadge account={a} onClick={() => !isWorkspaceAccount(a.plan) && setResetPopup(a)} />
189+
<TicketBadge account={a} onClick={() => !isWorkspaceAccount(a.plan) && openResetPopup(a)} />
170190
{a.needsReauth && <span className="badge badge-amber">{t("codexAuth.needsReauth")}</span>}
171191
{isNext(a.id) && !a.needsReauth && <span className="badge badge-primary">{t("codexAuth.nextSession")}</span>}
172192
<button
@@ -218,7 +238,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
218238
)}
219239

220240
{resetPopup && (
221-
<div className="modal-overlay" onClick={() => { setResetPopup(null); setResetConfirm(false); }}>
241+
<div className="modal-overlay" onClick={() => { setResetPopup(null); setResetConfirm(false); setCreditDetails(null); }}>
222242
<div className="modal-card" onClick={e => e.stopPropagation()}>
223243
{!resetConfirm ? (
224244
<>
@@ -227,12 +247,20 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
227247
<div style={{ margin: "16px 0" }}>
228248
{(resetPopup.quota?.resetCredits ?? 0) > 0 ? (
229249
<>
230-
<p>{t("codexAuth.resetCreditsAvailable", { count: String(resetPopup.quota?.resetCredits ?? 0) })}</p>
231-
<p className="modal-desc">{t("codexAuth.resetCreditsDesc")}</p>
250+
<p style={{ marginBottom: 12 }}>{t("codexAuth.resetCreditsAvailable", { count: String(resetPopup.quota?.resetCredits ?? 0) })}</p>
251+
{creditDetailsLoading && <p className="faint" style={{ fontSize: 12 }}>Loading...</p>}
252+
{creditDetails && creditDetails.length > 0 && (
253+
<div className="credit-list">
254+
{creditDetails.map((c, i) => (
255+
<CreditItem key={i} index={i} grantedAt={c.granted_at} expiresAt={c.expires_at} isNext={i === 0} t={t} />
256+
))}
257+
</div>
258+
)}
232259
<button className="btn btn-primary" style={{ marginTop: 12, width: "100%" }}
233260
onClick={() => setResetConfirm(true)} disabled={redeeming}>
234261
{t("codexAuth.useOneCredit")}
235262
</button>
263+
<p className="card-sub" style={{ fontSize: 11, marginTop: 8, textAlign: "center" }}>{t("codexAuth.fifoNote")}</p>
236264
</>
237265
) : (
238266
<>
@@ -241,14 +269,18 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) {
241269
</>
242270
)}
243271
</div>
244-
<p className="card-sub" style={{ fontSize: 11 }}>{t("codexAuth.creditsExpireNote")}</p>
245272
</>
246273
) : (
247274
<>
248275
<div style={{ textAlign: "center", padding: "12px 0" }}>
249276
<div className="confirm-icon"><IconAlert width={22} /></div>
250277
<h3>{t("codexAuth.confirmResetTitle")}</h3>
251278
<p className="modal-desc">{t("codexAuth.confirmResetDesc", { count: String(resetPopup.quota?.resetCredits ?? 0) })}</p>
279+
{creditDetails && creditDetails[0] && (
280+
<p className="faint" style={{ fontSize: 12 }}>
281+
{t("codexAuth.confirmWhichCredit", { date: formatCreditDate(creditDetails[0].granted_at) })}
282+
</p>
283+
)}
252284
<p className="faint" style={{ fontSize: 12 }}>{t("codexAuth.irreversible")}</p>
253285
</div>
254286
<div className="modal-actions">
@@ -328,6 +360,37 @@ function QuotaRow({ label, percent, resetAt, threshold, t }: { label: string; pe
328360
);
329361
}
330362

363+
function formatCreditDate(iso: string): string {
364+
const d = new Date(iso);
365+
return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format(d);
366+
}
367+
368+
function daysUntil(iso: string): number {
369+
return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000));
370+
}
371+
372+
function CreditItem({ index, grantedAt, expiresAt, isNext, t }: {
373+
index: number; grantedAt: string; expiresAt: string; isNext: boolean; t: TFn;
374+
}) {
375+
const days = daysUntil(expiresAt);
376+
const urgent = days <= 7;
377+
return (
378+
<div className={`credit-item${isNext ? " credit-next" : ""}`}>
379+
<div className="credit-item-head">
380+
<IconTicket width={13} />
381+
<span className="credit-item-label">
382+
{isNext ? t("codexAuth.creditNext") : t("codexAuth.creditLabel", { n: String(index + 1) })}
383+
</span>
384+
{isNext && <span className="badge badge-amber" style={{ fontSize: 10, padding: "1px 6px" }}>{t("codexAuth.creditNextBadge")}</span>}
385+
</div>
386+
<div className="credit-item-dates">
387+
<span>{t("codexAuth.creditGranted", { date: formatCreditDate(grantedAt) })}</span>
388+
<span className={urgent ? "credit-urgent" : ""}>{t("codexAuth.creditExpires", { date: formatCreditDate(expiresAt), days: String(days) })}</span>
389+
</div>
390+
</div>
391+
);
392+
}
393+
331394
function isWorkspaceAccount(plan?: string): boolean {
332395
if (!plan) return false;
333396
return ["team", "business", "enterprise", "edu",

gui/src/styles.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,13 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }
192192
.badge-clickable:hover { filter: brightness(1.1); }
193193
.badge-disabled { opacity: 0.5; cursor: default; }
194194
.confirm-icon { width: 44px; height: 44px; border-radius: 50%; background: var(--amber-soft); display: flex; align-items: center; justify-content: center; margin: 0 auto 12px; color: var(--amber); }
195+
.credit-list { display: flex; flex-direction: column; gap: 6px; }
196+
.credit-item { padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--raised); transition: border-color 0.12s; }
197+
.credit-next { border-color: var(--amber); background: var(--amber-soft); }
198+
.credit-item-head { display: flex; align-items: center; gap: 6px; color: var(--text); font-size: 12px; font-weight: 600; margin-bottom: 3px; }
199+
.credit-item-head svg { color: var(--amber); flex-shrink: 0; }
200+
.credit-item-dates { display: flex; justify-content: space-between; font-size: 11px; font-family: var(--mono); color: var(--muted); }
201+
.credit-urgent { color: var(--red); font-weight: 600; }
195202
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
196203
.dot-green { background: var(--green); box-shadow: 0 0 0 3px var(--green-soft); }
197204
.dot-red { background: var(--red); box-shadow: 0 0 0 3px var(--red-soft); }

src/codex-auth-api.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,46 @@ export async function handleCodexAuthAPI(
297297
return jsonResponse({ quotas });
298298
}
299299

300+
if (url.pathname === "/api/codex-auth/reset-credits" && req.method === "GET") {
301+
const accountId = url.searchParams.get("accountId");
302+
if (!accountId) return jsonResponse({ error: "accountId required" }, 400);
303+
304+
const isMain = accountId === "__main__";
305+
let accessToken: string;
306+
let chatgptAccountId: string;
307+
308+
try {
309+
if (isMain) {
310+
const tokens = readCodexTokens();
311+
if (!tokens) return jsonResponse({ error: "Main Codex account not logged in" }, 401);
312+
accessToken = tokens.access_token;
313+
chatgptAccountId = tokens.account_id;
314+
} else {
315+
const cred = await getValidCodexToken(accountId);
316+
accessToken = cred.accessToken;
317+
chatgptAccountId = cred.chatgptAccountId;
318+
}
319+
320+
const resp = await fetch(
321+
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
322+
{
323+
headers: {
324+
Authorization: `Bearer ${accessToken}`,
325+
"ChatGPT-Account-Id": chatgptAccountId,
326+
},
327+
signal: AbortSignal.timeout(8000),
328+
},
329+
);
330+
if (!resp.ok) {
331+
const text = await resp.text().catch(() => "");
332+
return jsonResponse({ error: `Upstream error ${resp.status}`, detail: text }, resp.status);
333+
}
334+
return jsonResponse(await resp.json());
335+
} catch (e) {
336+
return jsonResponse({ error: String(e) }, 500);
337+
}
338+
}
339+
300340
if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") {
301341
const body = (await req.json().catch(() => ({}))) as { accountId?: string };
302342
if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400);

0 commit comments

Comments
 (0)