agent-dag 1.23.2 → 1.24.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.
@@ -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() {
@@ -1095,16 +1095,26 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1095
1095
  // already. Just keep the buffer + seq counter primed.
1096
1096
  }
1097
1097
  }
1098
+ // The async handlers below are dispatched as floating promises. Node's
1099
+ // default for an unhandled rejection is to kill the process, which would
1100
+ // take the whole deck down — SSE stream, hook ingest and all — because one
1101
+ // background quota poll hit a network error. Answer the request instead.
1102
+ const guard = (p, res) => Promise.resolve(p).catch(err => {
1103
+ console.error("agents-deck: request handler failed:", err?.message ?? err);
1104
+ if (!res.headersSent) send(res, 500, { error: "internal error" });
1105
+ else res.end();
1106
+ });
1107
+
1098
1108
  const server = createServer((req, res) => {
1099
1109
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
1100
1110
 
1101
- if (req.method === "POST" && url.pathname === "/api/event") return handleEventIngest(req, res);
1111
+ if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res), res);
1102
1112
  if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
1103
1113
  if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
1104
- if (req.method === "GET" && url.pathname === "/api/quota") return handleQuota(req, res);
1105
- if (req.method === "GET" && url.pathname === "/api/codex-usage") return handleCodexUsage(req, res);
1106
- if (req.method === "GET" && url.pathname === "/api/codex-quota") return handleCodexQuota(req, res);
1107
- if (req.method === "GET" && url.pathname === "/api/ccusage") return handleCcusage(req, res);
1114
+ if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
1115
+ if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
1116
+ if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
1117
+ if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
1108
1118
 
1109
1119
  if (req.method === "GET" && url.pathname === "/api/events") {
1110
1120
  const since = Number(url.searchParams.get("since") ?? 0);