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.
- package/bin/agent-dag.js +34 -0
- package/dist/web/assets/index-BNdzNL8H.js +62 -0
- package/dist/web/assets/index-BTHaNKgB.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/src/server/ccusage.mjs +41 -6
- package/src/server/claude-accounts.mjs +220 -0
- package/src/server/codex-auth.mjs +278 -0
- package/src/server/codex-quota.mjs +289 -79
- package/src/server/cswap-install.mjs +160 -0
- package/src/server/index.mjs +56 -5
- package/dist/web/assets/index-BRoNoC-Z.css +0 -1
- package/dist/web/assets/index-C2kmaj_2.js +0 -62
|
@@ -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
|
|
2
|
-
// Auth
|
|
3
|
-
//
|
|
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
|
|
9
|
-
const
|
|
10
|
-
const
|
|
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
|
|
13
|
-
let _cacheAt
|
|
19
|
+
let _cache = null;
|
|
20
|
+
let _cacheAt = 0;
|
|
14
21
|
const CACHE_MS = 60_000;
|
|
15
22
|
|
|
16
|
-
|
|
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
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
273
|
+
return fail("fetch_error");
|
|
104
274
|
}
|
|
105
275
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return
|
|
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() {
|