agent-dag 1.23.3 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ // Codex (ChatGPT) OAuth credentials: read, refresh, persist.
2
+ //
3
+ // Ports the flow the Codex CLI itself uses (openai/codex, crate `codex-login`)
4
+ // so agents-deck keeps a live token instead of going dark the moment the one
5
+ // written by `codex login` rotates server-side.
6
+ //
7
+ // The important subtlety: OpenAI ROTATES the refresh token, single-use. A
8
+ // refresh whose result does not reach disk burns the credential outright —
9
+ // the next attempt fails with `refresh_token_reused` and the user has to run
10
+ // `codex login` again. Everything defensive in this file follows from that:
11
+ // refreshes are serialized, the token is re-read from disk inside the lock
12
+ // immediately before it is spent, a response that does not clearly carry a
13
+ // new access token is never treated as success, and nothing here throws —
14
+ // a rejected promise from a background poll would take the server down.
15
+ import { readFile, writeFile, rename, chmod, unlink, realpath, open } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { homedir } from "node:os";
18
+
19
+ const CODEX_HOME = process.env.CODEX_HOME ?? join(homedir(), ".codex");
20
+ const AUTH_PATH = join(CODEX_HOME, "auth.json");
21
+
22
+ // Same client id + endpoint the Codex CLI uses (codex-rs/login/src/auth/manager.rs).
23
+ const CLIENT_ID = process.env.CODEX_APP_SERVER_LOGIN_CLIENT_ID ?? "app_EMoamEEZ73f0CkXaXp7hrann";
24
+ const REFRESH_URL = process.env.CODEX_REFRESH_TOKEN_URL_OVERRIDE ?? "https://auth.openai.com/oauth/token";
25
+
26
+ // Refresh once the access token is within this much of expiring. Deliberately
27
+ // tighter than the CLI's 5 minutes: matching it would wake both processes into
28
+ // the same window to race for the same single-use token, and the loser gets a
29
+ // `refresh_token_reused` that reads to the user as "your login is broken".
30
+ const EXPIRY_SKEW_MS = 90 * 1000;
31
+ // Fallback only, for tokens whose `exp` we cannot read.
32
+ const MAX_TOKEN_AGE_MS = 8 * 24 * 60 * 60 * 1000;
33
+
34
+ // Refresh failures that will never succeed on retry — the credential is gone
35
+ // and only `codex login` brings it back.
36
+ const PERMANENT_CODES = new Set([
37
+ "refresh_token_expired",
38
+ "refresh_token_reused",
39
+ "refresh_token_invalidated",
40
+ "invalid_grant",
41
+ ]);
42
+
43
+ /** Decode a JWT payload. Returns null for anything that isn't a 3-part JWT. */
44
+ export function decodeJwt(token) {
45
+ if (typeof token !== "string") return null;
46
+ const parts = token.split(".");
47
+ if (parts.length !== 3 || !parts[1]) return null;
48
+ try {
49
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
50
+ return (payload && typeof payload === "object") ? payload : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** Access-token expiry in ms, or null when the token carries no readable `exp`. */
57
+ function expiryMs(accessToken) {
58
+ const exp = decodeJwt(accessToken)?.exp;
59
+ return typeof exp === "number" ? exp * 1000 : null;
60
+ }
61
+
62
+ async function readAuthFile() {
63
+ try {
64
+ const parsed = JSON.parse(await readFile(AUTH_PATH, "utf8"));
65
+ return (parsed && typeof parsed === "object") ? parsed : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Write auth.json back atomically: write a sibling tmp file, fsync it, then
73
+ * rename over the target. A reader can never observe a half-written file, and
74
+ * the fsync means a machine crash cannot leave an empty one behind after we
75
+ * have already spent the old refresh token.
76
+ *
77
+ * Resolves symlinks first — `~/.codex/auth.json` is often a link into a
78
+ * dotfiles repo or an encrypted volume, and renaming onto the link would
79
+ * replace it with a regular file, quietly detaching the user's setup.
80
+ *
81
+ * Throws on failure; the caller must treat that as "the refresh did not
82
+ * happen" rather than swallowing it.
83
+ */
84
+ async function persistAuth(auth) {
85
+ const target = await realpath(AUTH_PATH).catch(() => AUTH_PATH);
86
+ const tmp = `${target}.agents-deck-${process.pid}.tmp`;
87
+
88
+ let ok = false;
89
+ try {
90
+ await writeFile(tmp, JSON.stringify(auth, null, 2), { mode: 0o600 });
91
+ // writeFile ignores `mode` when the file already exists, so pin it again.
92
+ await chmod(tmp, 0o600);
93
+ const fd = await open(tmp, "r+");
94
+ try { await fd.sync(); } finally { await fd.close(); }
95
+ await rename(tmp, target);
96
+ ok = true;
97
+ } finally {
98
+ // A tmp left behind holds a live rotated refresh token in cleartext.
99
+ if (!ok) await unlink(tmp).catch(() => {});
100
+ }
101
+ }
102
+
103
+ /** True when the stored access token is expired, near-expiry, or stale. */
104
+ function shouldRefresh(auth) {
105
+ const tokens = auth?.tokens;
106
+ if (!tokens?.refresh_token) return false;
107
+ if (!tokens.access_token) return true;
108
+
109
+ const exp = expiryMs(tokens.access_token);
110
+ if (exp != null) return exp <= Date.now() + EXPIRY_SKEW_MS;
111
+
112
+ // No readable expiry — fall back to how long ago the CLI last refreshed.
113
+ const last = auth.last_refresh ? Date.parse(auth.last_refresh) : NaN;
114
+ if (isNaN(last)) return false;
115
+ return last < Date.now() - MAX_TOKEN_AGE_MS;
116
+ }
117
+
118
+ /** Pull the failure code out of the several shapes the endpoint returns it in. */
119
+ function refreshErrorCode(body) {
120
+ const raw = typeof body?.error === "object" ? body?.error?.code
121
+ : typeof body?.error === "string" ? body.error
122
+ : body?.code;
123
+ return typeof raw === "string" ? raw.toLowerCase() : null;
124
+ }
125
+
126
+ /**
127
+ * Spend the refresh token. Never throws — every failure is a return value,
128
+ * because callers include a 60s background poll whose rejection would reach
129
+ * the HTTP router as an unhandled rejection and kill the process.
130
+ */
131
+ async function doRefresh(auth) {
132
+ let res, body;
133
+ try {
134
+ res = await fetch(REFRESH_URL, {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/json" },
137
+ body: JSON.stringify({
138
+ client_id: CLIENT_ID,
139
+ grant_type: "refresh_token",
140
+ refresh_token: auth.tokens.refresh_token,
141
+ }),
142
+ signal: AbortSignal.timeout(15_000),
143
+ });
144
+ body = await res.json().catch(() => null);
145
+ } catch {
146
+ // Network error or timeout: the server may or may not have rotated the
147
+ // token. Nothing is written, so the next attempt retries with what we
148
+ // have — the only safe move when the outcome is unknown.
149
+ return { ok: false, reason: "refresh_failed" };
150
+ }
151
+
152
+ if (!res.ok) {
153
+ const code = refreshErrorCode(body);
154
+ const permanent = (code && PERMANENT_CODES.has(code)) || res.status === 401;
155
+ return { ok: false, reason: permanent ? "refresh_rejected" : "refresh_failed", code };
156
+ }
157
+
158
+ // A 2xx whose body did not survive the trip (truncated response, captive
159
+ // portal, proxy error page) is NOT success: writing here would persist the
160
+ // old, now-consumed token plus a fresh `last_refresh`, reporting a working
161
+ // login while guaranteeing the next call fails as `refresh_token_reused`.
162
+ if (typeof body?.access_token !== "string" || body.access_token === "") {
163
+ return { ok: false, reason: "refresh_failed", code: "no_access_token" };
164
+ }
165
+
166
+ // Write back only the fields that came in, leaving the rest of auth.json
167
+ // (OPENAI_API_KEY, auth_mode, account_id, anything unknown) untouched.
168
+ const next = { ...auth, tokens: { ...auth.tokens } };
169
+ next.tokens.access_token = body.access_token;
170
+ if (body.id_token) next.tokens.id_token = body.id_token;
171
+ if (body.refresh_token) next.tokens.refresh_token = body.refresh_token;
172
+ next.last_refresh = new Date().toISOString();
173
+
174
+ try {
175
+ await persistAuth(next);
176
+ } catch (err) {
177
+ // The rotated token exists server-side but never reached disk. Say so
178
+ // plainly — the credential on disk is now dead and only a re-login fixes
179
+ // it, so reporting a transient failure would just mislead.
180
+ console.error("agents-deck codex-auth: could not write auth.json:", err?.message ?? err);
181
+ return { ok: false, reason: "refresh_rejected", code: "persist_failed" };
182
+ }
183
+
184
+ return { ok: true, auth: next };
185
+ }
186
+
187
+ // Refreshes run strictly one at a time per process. A queue rather than a
188
+ // shared promise: callers that arrive during a refresh must be able to make
189
+ // their own decision afterwards (see the staleness checks below) instead of
190
+ // inheriting a result produced before their token failed.
191
+ let _chain = Promise.resolve();
192
+ function serialize(fn) {
193
+ const run = _chain.then(fn, fn);
194
+ _chain = run.then(() => {}, () => {});
195
+ return run;
196
+ }
197
+
198
+ /**
199
+ * Refresh under the lock.
200
+ *
201
+ * `ifStale` — only refresh when the credentials on disk still look expiring.
202
+ * `staleAccessToken` — only refresh when disk still holds the token the caller
203
+ * saw fail. Both exist for the same reason: auth.json is re-read *inside* the
204
+ * lock, so a caller that queued behind another refresh discovers it already
205
+ * got what it needed and does not spend a second single-use token.
206
+ */
207
+ function refreshCredentials({ ifStale = false, staleAccessToken = null } = {}) {
208
+ return serialize(async () => {
209
+ const auth = await readAuthFile();
210
+ if (!auth?.tokens?.refresh_token) return { ok: false, reason: "no_token" };
211
+ if (ifStale && !shouldRefresh(auth)) return { ok: true, auth };
212
+ if (staleAccessToken && auth.tokens.access_token !== staleAccessToken) {
213
+ return { ok: true, auth }; // someone else already rotated past it
214
+ }
215
+ return doRefresh(auth);
216
+ });
217
+ }
218
+
219
+ function identityFrom(auth, refreshed) {
220
+ // Claims live in the id_token, not the access token. account_id is seeded at
221
+ // login into tokens.account_id; the id_token claim is the fallback for
222
+ // credential files written before that field existed.
223
+ const claims = decodeJwt(auth.tokens.id_token) ?? {};
224
+ const oai = claims["https://api.openai.com/auth"] ?? {};
225
+ return {
226
+ ok: true,
227
+ accessToken: auth.tokens.access_token,
228
+ accountId: auth.tokens.account_id ?? oai.chatgpt_account_id ?? null,
229
+ isFedramp: oai.chatgpt_account_is_fedramp === true,
230
+ planType: oai.chatgpt_plan_type ?? null,
231
+ email: claims.email ?? null,
232
+ refreshed,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Current Codex credentials, refreshed if needed.
238
+ *
239
+ * Returns { ok: true, accessToken, accountId, … } or { ok: false, reason }
240
+ * where reason is `no_token` (never logged in) / `refresh_rejected` (re-login
241
+ * required) / `refresh_failed` (transient). Never throws.
242
+ */
243
+ export async function getCodexAuth({ allowRefresh = true } = {}) {
244
+ let auth = await readAuthFile();
245
+
246
+ // An `OPENAI_API_KEY` login is a platform credential, not a ChatGPT session.
247
+ // Flagged rather than rejected so the caller can say so plainly instead of
248
+ // sending the key to chatgpt.com and reporting the resulting 401 as a bug.
249
+ const apiKey = typeof auth?.OPENAI_API_KEY === "string" ? auth.OPENAI_API_KEY.trim() : "";
250
+ if (auth?.auth_mode === "apikey" || (apiKey && !auth?.tokens?.access_token)) {
251
+ return { ok: true, apiKeyMode: true, accessToken: null, accountId: null };
252
+ }
253
+
254
+ if (!auth?.tokens?.access_token) return { ok: false, reason: "no_token" };
255
+
256
+ let refreshed = false;
257
+ if (allowRefresh && shouldRefresh(auth)) {
258
+ const r = await refreshCredentials({ ifStale: true });
259
+ if (!r.ok) return r;
260
+ refreshed = r.auth.tokens.access_token !== auth.tokens.access_token;
261
+ auth = r.auth;
262
+ }
263
+
264
+ return identityFrom(auth, refreshed);
265
+ }
266
+
267
+ /**
268
+ * Refresh because the backend rejected a token that looked valid locally —
269
+ * OpenAI revokes server-side, so the JWT's own `exp` is not the last word.
270
+ *
271
+ * Pass the access token that was rejected: if disk has already moved past it
272
+ * (a concurrent refresh, or the Codex CLI), this returns the newer credentials
273
+ * without spending another single-use refresh token.
274
+ */
275
+ export async function forceCodexRefresh(rejectedAccessToken = null) {
276
+ const r = await refreshCredentials({ staleAccessToken: rejectedAccessToken });
277
+ return r.ok ? identityFrom(r.auth, true) : r;
278
+ }
@@ -1,111 +1,321 @@
1
- // Fetches Codex/ChatGPT quota percentages from chatgpt.com/backend-api/wham/usage.
2
- // Auth: reads access_token from ~/.codex/auth.json (written by the Codex CLI login).
3
- // Returns the same shape as Claude quota so the UI can use identical QuotaBar components.
1
+ // Fetches Codex/ChatGPT quota from the same endpoint the Codex CLI uses.
2
+ // Auth (including token refresh) lives in codex-auth.mjs.
3
+ //
4
+ // The response is deliberately parsed defensively: OpenAI ships new plan
5
+ // types, new limit families and new numeric encodings without warning, and a
6
+ // menu-bar-style readout is worth more when it degrades to "some lanes" than
7
+ // when one unrecognised field blanks the whole panel. So every section is
8
+ // optional, unknown values pass through verbatim, and a `partial` flag tells
9
+ // the UI when something was dropped instead of silently showing less.
4
10
  import { readFile } from "node:fs/promises";
5
11
  import { join } from "node:path";
6
12
  import { homedir } from "node:os";
13
+ import { getCodexAuth, forceCodexRefresh } from "./codex-auth.mjs";
7
14
 
8
- const CODEX_HOME = process.env.CODEX_HOME ?? join(homedir(), ".codex");
9
- const AUTH_PATH = join(CODEX_HOME, "auth.json");
10
- const WHAM_URL = "https://chatgpt.com/backend-api/wham/usage";
15
+ const CODEX_HOME = process.env.CODEX_HOME ?? join(homedir(), ".codex");
16
+ const CONFIG_PATH = join(CODEX_HOME, "config.toml");
17
+ const DEFAULT_BASE = "https://chatgpt.com/backend-api";
11
18
 
12
- let _cache = null;
13
- let _cacheAt = 0;
19
+ let _cache = null;
20
+ let _cacheAt = 0;
14
21
  const CACHE_MS = 60_000;
15
22
 
16
- async function readAccessToken() {
23
+ // ── base URL ───────────────────────────────────────────────────────────────
24
+ // `chatgpt_base_url` in config.toml can point at a proxy, and the path style
25
+ // follows from its shape exactly as in the CLI: a /backend-api base speaks
26
+ // /wham/*, anything else speaks /api/codex/*.
27
+ async function readBaseUrl() {
28
+ let raw = null;
17
29
  try {
18
- const raw = await readFile(AUTH_PATH, "utf8");
19
- const auth = JSON.parse(raw);
20
- return auth?.tokens?.access_token ?? null;
21
- } catch {
22
- return null;
30
+ const text = await readFile(CONFIG_PATH, "utf8");
31
+ for (const line of text.split("\n")) {
32
+ const m = line.replace(/#.*$/, "").match(/^\s*chatgpt_base_url\s*=\s*(.+?)\s*$/);
33
+ if (m) { raw = m[1].replace(/^["']|["']$/g, "").trim(); break; }
34
+ }
35
+ } catch { /* no config.toml — use the default */ }
36
+
37
+ let base = (raw || DEFAULT_BASE).replace(/\/+$/, "");
38
+ if (/^https:\/\/(chatgpt\.com|chat\.openai\.com)/.test(base) && !base.includes("/backend-api")) {
39
+ base += "/backend-api";
40
+ }
41
+ return base;
42
+ }
43
+
44
+ function usagePath(base) { return base.includes("/backend-api") ? "/wham/usage" : "/api/codex/usage"; }
45
+ function resetCreditsPath(base) { return base.includes("/backend-api") ? "/wham/rate-limit-reset-credits" : "/api/codex/rate-limit-reset-credits"; }
46
+
47
+ // ── lenient field readers ──────────────────────────────────────────────────
48
+ // Team and enterprise payloads send numbers as strings ("limit": "1000"), and
49
+ // reset timestamps answer to three different spellings depending on which
50
+ // sub-object you are in.
51
+ function num(v) {
52
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
53
+ if (typeof v === "string" && v.trim() !== "") {
54
+ const n = Number(v);
55
+ return Number.isFinite(n) ? n : null;
23
56
  }
57
+ return null;
58
+ }
59
+ function resetAt(o) {
60
+ return num(o?.resets_at) ?? num(o?.resetsAt) ?? num(o?.reset_at) ?? null;
24
61
  }
25
62
 
26
- // Format a unix timestamp into "Jun 18, 4:09pm" style (same as claude quota output).
63
+ /** "Jun 18, 4:09pm" — matches the Claude quota formatting so both read alike. */
27
64
  function fmtReset(unixSec) {
28
65
  if (!unixSec) return null;
29
- const d = new Date(unixSec * 1000);
30
- return d.toLocaleString("en-US", {
31
- month: "short",
32
- day: "numeric",
33
- hour: "numeric",
34
- minute: "2-digit",
35
- hour12: true,
66
+ return new Date(unixSec * 1000).toLocaleString("en-US", {
67
+ month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true,
36
68
  }).replace(",", "").toLowerCase().replace(/\s+am/, "am").replace(/\s+pm/, "pm");
37
69
  }
38
70
 
39
- export async function fetchCodexQuota({ force = false } = {}) {
40
- const now = Date.now();
41
- if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
71
+ // ── window classification ──────────────────────────────────────────────────
72
+ // Slot position is NOT the lane. Free plans return a weekly window in the
73
+ // primary slot, and a 30-day lane can arrive in either slot — labelling by
74
+ // slot is how a weekly cap ends up displayed as a 5-hour one. Duration is the
75
+ // only trustworthy signal.
76
+ const HOUR = 3600;
77
+ function laneFor(windowSec) {
78
+ if (windowSec == null || windowSec <= 0) return { key: "unknown", label: "Rate limit", rank: 9 };
79
+ if (windowSec <= 6 * HOUR) return { key: "session", label: `${Math.round(windowSec / HOUR)}-hour window`, rank: 0 };
80
+ if (windowSec <= 8 * 24 * HOUR) return { key: "weekly", label: "7-day window", rank: 1 };
81
+ return { key: "monthly", label: "30-day window", rank: 2 };
82
+ }
83
+
84
+ /** One rate-limit lane, or null when the window carries no usable reading. */
85
+ function toWindow(w, idFallback) {
86
+ const pct = num(w?.used_percent ?? w?.usedPercent);
87
+ if (pct == null) return null;
88
+ const windowSec = num(w?.limit_window_seconds ?? w?.limitWindowSeconds);
89
+ const lane = laneFor(windowSec);
90
+ const reset = resetAt(w);
91
+ return {
92
+ id: idFallback ? `${idFallback}-${lane.key}` : lane.key,
93
+ key: lane.key,
94
+ label: lane.label,
95
+ rank: lane.rank,
96
+ pct, // never clamped — over-quota is real information
97
+ windowSec: windowSec ?? null,
98
+ resetAt: reset,
99
+ reset: fmtReset(reset),
100
+ };
101
+ }
102
+
103
+ /** Both slots of a rate_limit object, ordered by lane rather than by slot. */
104
+ function windowsFrom(rl, idPrefix) {
105
+ return [toWindow(rl?.primary_window, idPrefix), toWindow(rl?.secondary_window, idPrefix)]
106
+ .filter(Boolean)
107
+ .sort((a, b) => a.rank - b.rank);
108
+ }
42
109
 
43
- const token = await readAccessToken();
44
- if (!token) {
45
- const r = { ok: false, reason: "no_token", fetchedAt: now };
46
- _cache = r; _cacheAt = now;
47
- return r;
110
+ // ── spend control / monthly credit limit ───────────────────────────────────
111
+ // Three places can carry it, in this precedence. Whichever answers first wins.
112
+ function creditLimitFrom(data) {
113
+ const src = data?.individual_limit
114
+ ?? data?.rate_limit?.individual_limit
115
+ ?? data?.spend_control?.individual_limit;
116
+ const limit = num(src?.limit);
117
+ if (!limit || limit <= 0) return null;
118
+
119
+ const remainingPct = num(src?.remaining_percent ?? src?.remainingPercent);
120
+ const used = num(src?.used) ?? (remainingPct != null ? limit * Math.max(0, Math.min(100, 100 - remainingPct)) / 100 : 0);
121
+ const pct = remainingPct != null ? Math.max(0, Math.min(100, 100 - remainingPct)) : (used / limit) * 100;
122
+ const reset = resetAt(src);
123
+
124
+ return {
125
+ limit,
126
+ used,
127
+ usedPct: pct,
128
+ remaining: num(src?.remaining) ?? Math.max(0, limit - used),
129
+ source: src?.source ?? null,
130
+ resetAt: reset,
131
+ reset: fmtReset(reset),
132
+ };
133
+ }
134
+
135
+ // ── plan labels ────────────────────────────────────────────────────────────
136
+ // OpenAI's marketing names, since "pro" alone tells the user nothing about
137
+ // which of the two Pro tiers they are on.
138
+ const PLAN_LABELS = { pro: "Pro 20x", prolite: "Pro 5x", pro_lite: "Pro 5x", "pro-lite": "Pro 5x" };
139
+ function planLabel(plan) {
140
+ if (!plan || typeof plan !== "string") return null;
141
+ const k = plan.toLowerCase().replace(/\s+/g, "_");
142
+ if (PLAN_LABELS[k]) return PLAN_LABELS[k];
143
+ if (k === "k12" || k === "cbp") return k.toUpperCase();
144
+ return k.split(/[_-]/).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
145
+ }
146
+
147
+ // ── extra limit families (Codex Spark and friends) ────────────────────────
148
+ // `additional_rate_limits` is an ARRAY of {limit_name, metered_feature,
149
+ // rate_limit}, not a map. Decoded element-wise so one malformed entry costs
150
+ // only itself.
151
+ function extraLimits(data) {
152
+ const arr = data?.additional_rate_limits;
153
+ if (!Array.isArray(arr)) return { extras: [], damaged: arr != null };
154
+
155
+ const extras = [];
156
+ let damaged = false;
157
+ for (const entry of arr) {
158
+ try {
159
+ const slug = String(entry?.metered_feature ?? entry?.limit_name ?? "extra")
160
+ .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
161
+ const title = entry?.limit_name ?? entry?.metered_feature ?? "Codex extra limit";
162
+ for (const w of windowsFrom(entry?.rate_limit, slug)) {
163
+ extras.push({ ...w, label: `${title} · ${w.label}`, family: slug });
164
+ }
165
+ } catch { damaged = true; }
48
166
  }
167
+ return { extras, damaged };
168
+ }
169
+
170
+ // ── fetch ──────────────────────────────────────────────────────────────────
171
+ function authHeaders(auth, { accountIdHeader = "ChatGPT-Account-Id", extra = {} } = {}) {
172
+ const h = {
173
+ "Authorization": `Bearer ${auth.accessToken}`,
174
+ "Accept": "application/json",
175
+ "User-Agent": "codex-cli",
176
+ ...extra,
177
+ };
178
+ if (auth.accountId) h[accountIdHeader] = auth.accountId;
179
+ if (auth.isFedramp) h["X-OpenAI-Fedramp"] = "true";
180
+ return h;
181
+ }
49
182
 
50
- let result;
183
+ /**
184
+ * Reset credits ("one free rate limit reset" grants). Best-effort and short-
185
+ * timeout: it is a nice-to-have next to the gauges, never a reason to fail
186
+ * the quota read. Read-only — we never redeem.
187
+ */
188
+ async function fetchResetCredits(base, auth) {
51
189
  try {
52
- const res = await fetch(WHAM_URL, {
53
- headers: {
54
- "Authorization": `Bearer ${token}`,
55
- "Content-Type": "application/json",
56
- "User-Agent": "Mozilla/5.0 (compatible; agents-deck)",
57
- },
58
- signal: AbortSignal.timeout(12_000),
190
+ const res = await fetch(base + resetCreditsPath(base), {
191
+ // This endpoint alone wants the uppercase-ID spelling and the beta
192
+ // headers; sending the wham/usage set here returns nothing useful.
193
+ headers: authHeaders(auth, {
194
+ accountIdHeader: "ChatGPT-Account-ID",
195
+ extra: { "OpenAI-Beta": "codex-1", "originator": "Codex Desktop" },
196
+ }),
197
+ signal: AbortSignal.timeout(5_000),
59
198
  });
199
+ if (!res.ok) return null;
200
+ const body = await res.json();
201
+ const count = num(body?.available_count);
202
+ if (count == null || count < 0) return null;
203
+ const next = (body?.credits ?? [])
204
+ .filter(c => c?.status === "available" && c?.expires_at)
205
+ .map(c => Date.parse(c.expires_at))
206
+ .filter(t => !isNaN(t))
207
+ .sort((a, b) => a - b)[0] ?? null;
208
+ return { availableCount: count, nextExpiryAt: next };
209
+ } catch { return null; }
210
+ }
60
211
 
61
- if (!res.ok) {
62
- const r = { ok: false, reason: `http_${res.status}`, fetchedAt: now };
63
- _cache = r; _cacheAt = now;
64
- return r;
212
+ async function requestUsage(base, auth) {
213
+ return fetch(base + usagePath(base), {
214
+ headers: authHeaders(auth),
215
+ // Quota is per-account state; a cached response is how one account's
216
+ // gauges end up shown for another.
217
+ cache: "no-store",
218
+ signal: AbortSignal.timeout(12_000),
219
+ });
220
+ }
221
+
222
+ // One outstanding fetch at a time. Several browser tabs mounting at once
223
+ // otherwise each force their own round trip — and each one is another chance
224
+ // to race over the single-use refresh token.
225
+ let _inflight = null;
226
+
227
+ export function fetchCodexQuota({ force = false } = {}) {
228
+ if (!force && _cache && Date.now() - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
229
+ _inflight ??= doFetchCodexQuota().finally(() => { _inflight = null; });
230
+ return _inflight;
231
+ }
232
+
233
+ async function doFetchCodexQuota() {
234
+ const started = Date.now();
235
+ // Stamped at completion, not at entry: the two calls below can take up to
236
+ // 17s between them, and a cache entry that is already stale on arrival
237
+ // shortens the effective TTL for no reason.
238
+ const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
239
+ const fail = (reason) => finish({ ok: false, reason, fetchedAt: started });
240
+
241
+ let auth, base, res;
242
+ try {
243
+ auth = await getCodexAuth();
244
+ if (!auth.ok) return fail(auth.reason);
245
+
246
+ // An API key in auth.json is a platform credential, not a ChatGPT session —
247
+ // sending it here only produces a confusing 401.
248
+ if (auth.apiKeyMode) return fail("api_key_mode");
249
+
250
+ base = await readBaseUrl();
251
+ res = await requestUsage(base, auth);
252
+
253
+ // The JWT's own `exp` is not the last word: OpenAI revokes server-side, so
254
+ // a token that looks valid locally can still come back expired. One forced
255
+ // refresh + retry turns that from "bar goes dark" into a hiccup.
256
+ //
257
+ // 401 only. A 403 from chatgpt.com is usually a bot check or a blocked
258
+ // egress IP rather than a bad token, and rotating a single-use credential
259
+ // once a minute against a network-layer block is how a working login gets
260
+ // destroyed.
261
+ if (res.status === 401) {
262
+ const refreshed = await forceCodexRefresh(auth.accessToken);
263
+ if (!refreshed.ok) return fail(refreshed.reason);
264
+ auth = refreshed;
265
+ res = await requestUsage(base, auth);
65
266
  }
66
267
 
67
- const data = await res.json();
68
- const rl = data?.rate_limit;
69
- const pw = rl?.primary_window; // 5-hour session window
70
- const sw = rl?.secondary_window; // 7-day weekly window
71
-
72
- const creds = data?.credits;
73
- result = {
74
- ok: true,
75
- limitReached: rl?.limit_reached ?? false,
76
- session5hPct: pw?.used_percent ?? null,
77
- session5hReset: pw?.reset_at ? fmtReset(pw.reset_at) : null,
78
- session5hResetAt: pw?.reset_at ?? null, // unix seconds
79
- session5hWindowSec: pw?.limit_window_seconds ?? 18000,
80
- week7dPct: sw?.used_percent ?? null,
81
- week7dReset: sw?.reset_at ? fmtReset(sw.reset_at) : null,
82
- week7dResetAt: sw?.reset_at ?? null, // unix seconds
83
- week7dWindowSec: sw?.limit_window_seconds ?? 604800,
84
- // credits (ChatGPT Plus top-up credits if any)
85
- creditsBalance: creds?.has_credits ? creds.balance : null,
86
- creditsUnlimited: creds?.unlimited ?? false,
87
- planType: data?.plan_type ?? null,
88
- fetchedAt: now,
89
- };
90
-
91
- // Additional model-specific limits (e.g. Codex Spark)
92
- const extra = rl?.additional_rate_limits;
93
- if (extra && typeof extra === "object") {
94
- for (const [key, win] of Object.entries(extra)) {
95
- if (win?.used_percent != null) {
96
- result[`extra_${key}_pct`] = win.used_percent;
97
- if (win.reset_at) result[`extra_${key}_reset`] = fmtReset(win.reset_at);
98
- }
99
- }
268
+ if (!res.ok) {
269
+ return fail(res.status === 401 ? "refresh_rejected" : `http_${res.status}`);
100
270
  }
101
271
  } catch (err) {
102
272
  console.error("agents-deck codex-quota: fetch failed:", err?.message ?? err);
103
- result = { ok: false, reason: "fetch_error", fetchedAt: now };
273
+ return fail("fetch_error");
104
274
  }
105
275
 
106
- _cache = result;
107
- _cacheAt = now;
108
- return result;
276
+ let data;
277
+ try { data = await res.json(); }
278
+ catch { return fail("decode_error"); }
279
+
280
+ const rl = data?.rate_limit;
281
+ const windows = windowsFrom(rl);
282
+ const { extras, damaged } = extraLimits(data);
283
+ const creditsRaw = data?.credits;
284
+ const balance = num(creditsRaw?.balance);
285
+
286
+ const result = {
287
+ ok: true,
288
+ limitReached: rl?.limit_reached ?? false,
289
+ allowed: rl?.allowed ?? true,
290
+
291
+ // Lanes, already ordered session → weekly → monthly and labelled by the
292
+ // window duration the API actually reported.
293
+ windows,
294
+ extraWindows: extras,
295
+
296
+ plan: data?.plan_type ?? auth.planType ?? null,
297
+ planLabel: planLabel(data?.plan_type ?? auth.planType),
298
+ email: data?.email ?? auth.email ?? null,
299
+
300
+ creditsBalance: balance != null && balance > 0 ? String(creditsRaw.balance) : null,
301
+ creditsUnlimited: creditsRaw?.unlimited === true,
302
+ overageReached: creditsRaw?.overage_limit_reached === true,
303
+ creditLimit: creditLimitFrom(data),
304
+
305
+ spendControlReached: data?.spend_control?.reached === true,
306
+ reachedType: data?.rate_limit_reached_type?.type ?? data?.rate_limit_reached_type ?? null,
307
+ promo: data?.promo?.message ?? null,
308
+
309
+ // True when something in the payload did not decode — the UI says "partial"
310
+ // rather than pretending the missing lanes do not exist.
311
+ partial: damaged || windows.length === 0,
312
+ refreshed: auth.refreshed === true,
313
+ fetchedAt: started,
314
+ };
315
+
316
+ result.resetCredits = await fetchResetCredits(base, auth);
317
+
318
+ return finish(result);
109
319
  }
110
320
 
111
321
  export function invalidateCodexQuotaCache() {