dsh-codex-oauth 0.2.8 → 0.3.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/lib/client.js CHANGED
@@ -36,20 +36,55 @@ __export(index_exports, {
36
36
  });
37
37
  module.exports = __toCommonJS(index_exports);
38
38
  var import_react = __toESM(require("react"), 1);
39
- var STATUS = "/plugins/dsh-codex-oauth/auth/status";
40
- var LOGIN = "/plugins/dsh-codex-oauth/auth/login";
41
- var LOGOUT = "/plugins/dsh-codex-oauth/auth/logout";
39
+ var BASE = "/plugins/dsh-codex-oauth/auth";
42
40
  var h = import_react.default.createElement;
43
41
  async function request(path, method = "GET") {
44
- const response = await fetch(path, { method, credentials: "same-origin", headers: { accept: "application/json" } });
42
+ const response = await fetch(`${BASE}/${path}`, { method, credentials: "same-origin", headers: { accept: "application/json" } });
45
43
  const value = await response.json().catch(() => ({}));
46
- if (!response.ok) throw new Error(value && typeof value === "object" && typeof value.error === "string" ? value.error : `HTTP ${response.status}`);
44
+ if (!response.ok) throw new Error(typeof value.error === "string" ? value.error : `HTTP ${response.status}`);
47
45
  return value;
48
46
  }
47
+ function QuotaPanel({ quota }) {
48
+ if (!quota?.available) return h("div", { role: "status" }, "Quota unavailable. No current quota data is available.");
49
+ return h(
50
+ "div",
51
+ { style: { display: "flex", flexDirection: "column", gap: 12 } },
52
+ ...quota.buckets.map((bucket) => h(
53
+ "section",
54
+ { key: bucket.id },
55
+ h("strong", null, bucket.name ?? bucket.id),
56
+ ...bucket.windows.map((window2, index) => {
57
+ const remaining = Math.round(window2.remainingPercent);
58
+ const color = window2.stale ? "#888" : remaining <= 10 ? "#c44" : remaining <= 30 ? "#b88020" : "#38875c";
59
+ return h(
60
+ "div",
61
+ { key: index, style: { marginTop: 8 } },
62
+ h("div", null, `${window2.label}: ${remaining}% remaining${window2.stale ? " (stale)" : ""}`),
63
+ h(
64
+ "div",
65
+ {
66
+ role: "progressbar",
67
+ "aria-label": `${window2.label} quota remaining`,
68
+ "aria-valuemin": 0,
69
+ "aria-valuemax": 100,
70
+ "aria-valuenow": window2.remainingPercent,
71
+ style: { height: 7, background: "var(--dsw-alias-border-l2)", borderRadius: 4, marginTop: 4 }
72
+ },
73
+ h("div", { style: { width: `${window2.remainingPercent}%`, height: "100%", background: color, borderRadius: 4 } })
74
+ ),
75
+ h("small", null, window2.resetsAt ? `Reset: ${new Date(window2.resetsAt * 1e3).toLocaleString("en-US")}` : "Reset time unavailable"),
76
+ h("small", { style: { display: "block" } }, `Updated: ${new Date(window2.checkedAt).toLocaleString("en-US")}`)
77
+ );
78
+ })
79
+ )),
80
+ h("small", null, "Quota checks do not call a model.")
81
+ );
82
+ }
49
83
  function CodexSettings() {
50
84
  const [state, setState] = (0, import_react.useState)({ status: "loading" });
51
85
  const [busy, setBusy] = (0, import_react.useState)(false);
52
- const refresh = (0, import_react.useCallback)(() => request(STATUS).then(setState).catch((error) => setState({ status: "error", message: error.message })), []);
86
+ const [notice, setNotice] = (0, import_react.useState)("");
87
+ const refresh = (0, import_react.useCallback)(() => request("status").then(setState).catch((error) => setState({ status: "error", message: error.message })), []);
53
88
  (0, import_react.useEffect)(() => {
54
89
  refresh();
55
90
  }, [refresh]);
@@ -58,21 +93,22 @@ function CodexSettings() {
58
93
  const timer = setInterval(refresh, 1e3);
59
94
  return () => clearInterval(timer);
60
95
  }, [refresh, state.status]);
61
- const signIn = async () => {
96
+ const signIn = async (newAccount = false) => {
62
97
  const popup = window.open("about:blank", "_blank");
63
98
  if (!popup) {
64
- setState({ status: "error", message: "Please allow popups to sign in" });
99
+ setState({ ...state, status: "error", message: "Allow pop-ups to complete sign-in." });
65
100
  return;
66
101
  }
67
102
  popup.opener = null;
68
103
  setBusy(true);
69
- setState({ status: "signing-in" });
104
+ setState({ ...state, status: "signing-in" });
105
+ setNotice("");
70
106
  try {
71
- const challenge = await request(LOGIN, "POST");
107
+ const challenge = await request(`login${newAccount ? "?new_account=1" : ""}`, "POST");
72
108
  popup.location.replace(challenge.url);
73
109
  } catch (error) {
74
- popup?.close();
75
- setState({ status: "error", message: error.message });
110
+ popup.close();
111
+ setState({ ...state, status: "error", message: error.message });
76
112
  } finally {
77
113
  setBusy(false);
78
114
  }
@@ -80,28 +116,77 @@ function CodexSettings() {
80
116
  const signOut = async () => {
81
117
  setBusy(true);
82
118
  try {
83
- await request(LOGOUT, "POST");
84
- setState({ status: "signed-out" });
119
+ const result = await request("logout", "POST");
120
+ if (!result.remoteRevoked) setNotice("Signed out locally. Remote revocation was not confirmed; disconnect the app in ChatGPT usage settings.");
121
+ await refresh();
85
122
  } catch (error) {
86
- setState({ status: "error", message: error.message });
123
+ setState({ ...state, status: "error", message: error.message });
87
124
  } finally {
88
125
  setBusy(false);
89
126
  }
90
127
  };
91
- const signedIn = state.status === "signed-in";
92
- const label = state.status === "signed-in" ? "\u5DF2\u767B\u5F55" : state.status === "signing-in" ? "\u7B49\u5F85\u6D4F\u89C8\u5668\u5B8C\u6210\u767B\u5F55\u2026" : state.status === "loading" ? "\u68C0\u67E5\u767B\u5F55\u72B6\u6001\u2026" : state.status === "error" ? "\u767B\u5F55\u5931\u8D25" : "\u672A\u767B\u5F55";
128
+ const selectAccount = async (clientId) => {
129
+ setBusy(true);
130
+ setNotice("");
131
+ try {
132
+ setState(await request(`account?client_id=${encodeURIComponent(clientId)}`, "POST"));
133
+ } catch (error) {
134
+ setState({ ...state, status: "error", message: error.message });
135
+ } finally {
136
+ setBusy(false);
137
+ }
138
+ };
139
+ const refreshModels = async () => {
140
+ setBusy(true);
141
+ try {
142
+ const { models } = await request("models", "POST");
143
+ setNotice(`Refreshed ${models.length} available models.`);
144
+ } catch (error) {
145
+ setNotice(error.message);
146
+ } finally {
147
+ setBusy(false);
148
+ }
149
+ };
150
+ const signedIn = state.authenticated && state.planUsage;
151
+ (0, import_react.useEffect)(() => {
152
+ if (!signedIn) return void 0;
153
+ const timer = setInterval(refresh, 3e4);
154
+ return () => clearInterval(timer);
155
+ }, [signedIn, refresh]);
156
+ const signingIn = state.status === "signing-in";
157
+ const label = signingIn ? "Waiting for browser sign-in\u2026" : signedIn ? `Signed in${state.label ? `: ${state.label}` : ""}` : state.status === "loading" ? "Checking sign-in status\u2026" : state.authenticated ? "Signed in; ChatGPT plan usage is not authorized" : "Signed out";
93
158
  return h(
94
159
  "section",
95
160
  { style: { display: "flex", flexDirection: "column", gap: 12, maxWidth: 640 } },
96
161
  h("h2", { style: { margin: 0 } }, "OpenAI Codex"),
97
- h("p", { style: { margin: 0, color: "var(--dsw-alias-label-secondary)" } }, "\u4F7F\u7528 ChatGPT Plus/Pro \u7684 Codex \u8BA2\u9605\uFF0C\u65E0\u9700 OpenAI API Key\u3002"),
162
+ h("p", { style: { margin: 0 } }, "Use your ChatGPT plan without an OpenAI API key. Models are provided by the active account."),
98
163
  h(
99
164
  "div",
100
- { style: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: 16, border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 10 } },
165
+ { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: 16, border: "1px solid var(--dsw-alias-border-l2)", borderRadius: 10 } },
101
166
  h("span", null, label),
102
- state.status === "loading" ? null : h("button", { type: "button", disabled: busy || state.status === "signing-in", onClick: signedIn ? signOut : signIn }, signedIn ? "\u9000\u51FA\u767B\u5F55" : "\u4F7F\u7528 ChatGPT \u767B\u5F55")
167
+ state.status === "loading" ? null : h("button", { type: "button", disabled: busy, onClick: signedIn || signingIn ? signOut : () => signIn() }, signedIn ? "Sign out" : signingIn ? "Cancel sign-in" : "Continue with ChatGPT")
103
168
  ),
104
- state.status === "error" ? h("p", { style: { color: "var(--dsw-alias-state-error-primary)" } }, state.message) : null
169
+ state.accounts?.length ? h(
170
+ "div",
171
+ { style: { display: "flex", gap: 8 } },
172
+ h(
173
+ "select",
174
+ { "aria-label": "ChatGPT account", disabled: busy || signingIn, value: state.accounts.find((account) => account.active)?.clientId ?? "", onChange: (event) => selectAccount(event.target.value) },
175
+ ...state.accounts.map((account) => h("option", { key: account.clientId, value: account.clientId }, `${account.label}${account.authenticated ? "" : " (signed out)"}`))
176
+ ),
177
+ h("button", { type: "button", disabled: busy || signingIn, onClick: () => signIn(true) }, "Add account")
178
+ ) : null,
179
+ state.authenticated && !state.planUsage && !signingIn ? h("button", { type: "button", disabled: busy, onClick: signOut }, "Sign out") : null,
180
+ signedIn && !signingIn ? h(
181
+ "div",
182
+ { style: { display: "flex", alignItems: "center", gap: 12 } },
183
+ h("span", null, "Using ChatGPT plan"),
184
+ h("a", { href: "https://chatgpt.com/settings/usage", target: "_blank", rel: "noopener noreferrer" }, "Manage usage"),
185
+ h("button", { type: "button", disabled: busy, onClick: refreshModels }, "Refresh models")
186
+ ) : null,
187
+ signedIn ? h(QuotaPanel, { quota: state.quota }) : null,
188
+ state.status === "error" ? h("p", { style: { color: "var(--dsw-alias-state-error-primary)" } }, state.message) : null,
189
+ notice ? h("p", { role: "status" }, notice) : null
105
190
  );
106
191
  }
107
192
  var name = "dsh-codex-oauth-client";
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createCodexAdapter } from "./adapter.js";
2
2
  import { CodexCredentialStore, OPENAI_CODEX_PROVIDER, authPath } from "./store.js";
3
3
  import { registerWebAuth } from "./web-auth.js";
4
+ import { registerQuotaUi } from "./quota-ui.js";
4
5
  export { login, logout, status } from "./auth.js";
5
6
 
6
7
  export const name = "llm-codex-oauth";
@@ -10,9 +11,15 @@ export { OPENAI_CODEX_PROVIDER, CodexCredentialStore, authPath, createCodexAdapt
10
11
 
11
12
  export function apply(ctx) {
12
13
  const credentials = new CodexCredentialStore();
14
+ const adapter = createCodexAdapter(credentials, () => ctx.get?.("attachments"), {
15
+ mapHostPath: (path) => ctx.get?.("fs")?.processPathFromHostPath(path),
16
+ onModelsChanged: () => ctx.emit?.("llm/adapters-updated"),
17
+ });
13
18
  ctx.llm.registerAdapter(
14
19
  [OPENAI_CODEX_PROVIDER],
15
- createCodexAdapter(credentials, () => ctx.get?.("attachments")),
20
+ adapter,
16
21
  );
17
- registerWebAuth(ctx, credentials);
22
+ ctx.effect?.(() => () => adapter.dispose());
23
+ registerWebAuth(ctx, credentials, { adapter });
24
+ registerQuotaUi(ctx, adapter);
18
25
  }
package/lib/models.js ADDED
@@ -0,0 +1,53 @@
1
+ import { LlmError } from "@deepseek-ai/dsh-llm";
2
+ import { getCredential } from "./auth.js";
3
+ import { OPENAI_CODEX_PROVIDER } from "./store.js";
4
+ import { fetchJSON, OPENAI_API } from "./openai-http.js";
5
+
6
+ const CACHE_TTL_MS = 5 * 60_000;
7
+ function modelInfo(entry) {
8
+ if (typeof entry.slug !== "string" || !entry.slug) throw new LlmError("OpenAI returned an invalid model catalog entry", "INVALID_RESPONSE");
9
+ const modalities = Array.isArray(entry.input_modalities) ? entry.input_modalities.filter((value) => value === "text" || value === "image") : undefined;
10
+ const levels = Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels.filter((level) => typeof level.effort === "string" && level.effort)
11
+ .map((level) => ({ id: level.effort, name: level.effort, ...(typeof level.description === "string" ? { description: level.description } : {}) })) : [];
12
+ return {
13
+ provider: OPENAI_CODEX_PROVIDER,
14
+ id: entry.slug,
15
+ name: typeof entry.display_name === "string" ? entry.display_name : entry.slug,
16
+ ...(typeof entry.description === "string" ? { description: entry.description } : {}),
17
+ ...(modalities ? { inputModalities: modalities } : {}),
18
+ ...(Number.isSafeInteger(entry.context_window) && entry.context_window > 0 ? { context: { contextWindow: entry.context_window } } : {}),
19
+ ...(levels.length ? { reasoning: {
20
+ efforts: levels,
21
+ ...(levels.some((level) => level.id === entry.default_reasoning_level) ? { defaultEffort: entry.default_reasoning_level } : {}),
22
+ } } : {}),
23
+ };
24
+ }
25
+
26
+ export class CodexModelCatalog {
27
+ #cached;
28
+ constructor(credentials, options = {}) { this.credentials = credentials; this.options = options; }
29
+ invalidate() { this.#cached = undefined; }
30
+ async read({ signal, refresh = false } = {}) {
31
+ const initial = await this.credentials.readDocument();
32
+ const selected = await this.credentials.read(OPENAI_CODEX_PROVIDER);
33
+ if (!selected?.scopes.includes("chatgpt.tokens.use.direct")) { this.invalidate(); return undefined; }
34
+ const credential = await getCredential(this.credentials, { ...this.options, signal, clientId: selected.clientId });
35
+ const authorized = await this.credentials.readDocument();
36
+ if (authorized?.activeClientId !== initial?.activeClientId || (authorized?.generation ?? 0) !== (initial?.generation ?? 0)) throw new LlmError("The selected ChatGPT account changed during model discovery", "AUTH");
37
+ const now = this.options.now?.() ?? Date.now();
38
+ const generation = initial?.generation ?? 0;
39
+ if (!refresh && this.#cached?.clientId === credential.clientId && this.#cached.generation === generation && this.#cached.expires > now) return structuredClone(this.#cached);
40
+ const response = await fetchJSON(`${OPENAI_API}/models`, { headers: { authorization: `Bearer ${credential.access}` }, signal }, this.options.fetch);
41
+ if (!Array.isArray(response.models)) throw new LlmError("OpenAI returned an invalid model catalog", "INVALID_RESPONSE");
42
+ const all = response.models.map((entry) => ({ info: modelInfo(entry), visible: entry.visibility === "list" }));
43
+ if (new Set(all.map((entry) => entry.info.id)).size !== all.length) throw new LlmError("OpenAI returned duplicate model catalog entries", "INVALID_RESPONSE");
44
+ const current = await this.credentials.readDocument();
45
+ if (current?.activeClientId !== initial?.activeClientId || current?.activeClientId !== credential.clientId || (current?.generation ?? 0) !== generation) throw new LlmError("The selected ChatGPT account changed during model discovery", "AUTH");
46
+ this.#cached = { clientId: credential.clientId, generation, expires: now + CACHE_TTL_MS, all };
47
+ return structuredClone(this.#cached);
48
+ }
49
+ async list(options) { return (await this.read(options))?.all.filter((entry) => entry.visible).map((entry) => entry.info) ?? []; }
50
+ async resolve(model, signal) {
51
+ return (await this.read({ signal }))?.all.find((entry) => entry.info.id === model)?.info ?? { provider: OPENAI_CODEX_PROVIDER, id: model, name: model };
52
+ }
53
+ }
@@ -0,0 +1,64 @@
1
+ import { LlmError, attributionHeaders, isContextWindowExceededError } from "@deepseek-ai/dsh-llm";
2
+ import { safeMessage } from "./safe-message.js";
3
+
4
+ export const OPENAI_API = "https://api.openai.com/v1";
5
+ export const OPENAI_ISSUER = "https://auth.openai.com";
6
+
7
+ function failureCode(code, status, message) {
8
+ if (code === "subscription_sharing_usage_limit_exceeded") return "ACCOUNT_QUOTA_EXCEEDED";
9
+ if (["subscription_sharing_usage_unavailable", "subscription_sharing_user_unavailable"].includes(code)) return "SERVER";
10
+ if (["subscription_sharing_invalid_user", "subscription_sharing_user_not_eligible", "chatpass_v2_scope_not_authorized", "chatpass_v2_invalid_authorization_context"].includes(code)) return "AUTH";
11
+ if (code === "subscription_sharing_unsupported_capability") return "INVALID_REQUEST";
12
+ if (code === "context_length_exceeded" || isContextWindowExceededError(message)) return "CONTEXT_WINDOW_EXCEEDED";
13
+ if (status === 401 || status === 403) return "AUTH";
14
+ if (status === 429) return "RATE_LIMIT";
15
+ if (status >= 500) return "SERVER";
16
+ return status >= 400 ? "INVALID_REQUEST" : "OPENAI_ERROR";
17
+ }
18
+
19
+ export function responseError(body, status, headers = new Headers(), secrets = []) {
20
+ const detail = body?.error;
21
+ const providerCode = typeof detail === "string" ? detail : detail?.code;
22
+ let message = detail?.message ?? body?.error_description ?? body?.detail ?? providerCode ?? `OpenAI request failed${status ? ` (HTTP ${status})` : ""}`;
23
+ if (typeof message !== "string") message = "OpenAI request failed";
24
+ for (const secret of secrets) if (secret) message = message.replaceAll(secret, "[redacted]");
25
+ const retryAfter = headers.get("retry-after");
26
+ const delay = retryAfter === null ? NaN : /^\d+(?:\.\d+)?$/.test(retryAfter) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now();
27
+ const error = new LlmError(safeMessage(message), failureCode(providerCode, status, message), {
28
+ ...(status ? { status } : {}),
29
+ ...(headers.get("x-request-id") ? { requestId: headers.get("x-request-id") } : {}),
30
+ ...(Number.isFinite(delay) && delay > 0 ? { providerRetryAfterMs: delay } : {}),
31
+ });
32
+ error.providerCode = providerCode;
33
+ error.param = detail?.param;
34
+ return error;
35
+ }
36
+
37
+ export async function readJSON(response) {
38
+ try { return await response.json(); }
39
+ catch { throw new LlmError(`OpenAI returned invalid JSON (HTTP ${response.status})`, "INVALID_RESPONSE", { status: response.status }); }
40
+ }
41
+
42
+ export async function openaiFetch(url, options = {}, fetcher = globalThis.fetch) {
43
+ const headers = new Headers(attributionHeaders());
44
+ for (const [name, value] of new Headers(options.headers)) headers.set(name, value);
45
+ try { return await fetcher(url, { ...options, headers, redirect: "error" }); }
46
+ catch (error) {
47
+ if (options.signal?.aborted) throw options.signal.reason;
48
+ throw new LlmError("Could not reach OpenAI", "TRANSPORT", { cause: error });
49
+ }
50
+ }
51
+
52
+ export async function fetchJSON(url, options = {}, fetcher) {
53
+ const signal = AbortSignal.any([...(options.signal ? [options.signal] : []), AbortSignal.timeout(15_000)]);
54
+ const response = await openaiFetch(url, { ...options, signal }, fetcher);
55
+ const body = await readJSON(response);
56
+ if (!response.ok) {
57
+ const form = options.body instanceof URLSearchParams ? options.body : undefined;
58
+ throw responseError(body, response.status, response.headers, [
59
+ new Headers(options.headers).get("authorization")?.replace(/^Bearer /, ""),
60
+ form?.get("code"), form?.get("code_verifier"), form?.get("refresh_token"), form?.get("token"),
61
+ ]);
62
+ }
63
+ return body;
64
+ }
@@ -0,0 +1,114 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { QuotaTracker } from "./quota.js";
5
+
6
+ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
7
+ const REFRESH_MS = 60_000;
8
+ const FAILURE_BACKOFF_MS = 30_000;
9
+ const empty = () => ({ available: false, buckets: [] });
10
+ function claims(token) {
11
+ try { return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()); }
12
+ catch { return {}; }
13
+ }
14
+ function email(token) {
15
+ const value = claims(token);
16
+ const address = value.email ?? value["https://api.openai.com/profile"]?.email;
17
+ return typeof address === "string" ? address.trim().toLowerCase() : undefined;
18
+ }
19
+ async function readLegacy(filename) {
20
+ for (const path of [dirname(filename), filename]) {
21
+ const info = await lstat(path);
22
+ if (info.isSymbolicLink() || (process.platform !== "win32" && info.mode & 0o077)) return;
23
+ if (path === filename && (!info.isFile() || info.size > 1_048_576)) return;
24
+ }
25
+ return JSON.parse(await readFile(filename, "utf8")).credential;
26
+ }
27
+ export function quotaUsage(body) {
28
+ const groups = [{ id: "codex", rate_limit: body?.rate_limit },
29
+ ...(Array.isArray(body?.additional_rate_limits) ? body.additional_rate_limits.map((value) => ({
30
+ id: value?.metered_feature ?? value?.limit_name, rate_limit: value?.rate_limit,
31
+ })) : [])];
32
+ return groups.flatMap((group) => {
33
+ if (typeof group.id !== "string" || !group.id || !group.rate_limit) return [];
34
+ const windows = {};
35
+ for (const slot of ["primary", "secondary"]) {
36
+ const value = group.rate_limit[`${slot}_window`];
37
+ if (!value) continue;
38
+ const usedPercent = value.used_percent;
39
+ if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent) || usedPercent < 0 || usedPercent > 100) continue;
40
+ windows[slot] = { usedPercent, remainingPercent: 100 - usedPercent,
41
+ ...(Number.isSafeInteger(value.limit_window_seconds) && value.limit_window_seconds > 0 ? { windowMinutes: Math.ceil(value.limit_window_seconds / 60) } : {}),
42
+ ...(Number.isSafeInteger(value.reset_at) && value.reset_at > 0 && value.reset_at <= 8_640_000_000_000 ? { resetsAt: value.reset_at } : {}),
43
+ };
44
+ }
45
+ return Object.keys(windows).length ? [{ id: group.id.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 80), windows }] : [];
46
+ });
47
+ }
48
+
49
+ // SIWC inference tokens do not authorize WHAM. Query with the plugin's retained
50
+ // legacy Codex login only; never borrow Pi/Codex app credentials or refresh their
51
+ // rotating tokens. Matching emails guard against unrelated accounts, but do not
52
+ // prove identical workspaces or SIWC application limits (see README).
53
+ export class CodexQuotaLookup {
54
+ #tracker = new QuotaTracker();
55
+ #key;
56
+ #pending;
57
+ #controller;
58
+ #nextCheck = 0;
59
+ #epoch = 0;
60
+ #disposed = false;
61
+ constructor(filename, options = {}) {
62
+ this.readCredential = options.readCredential ?? (() => readLegacy(join(dirname(filename), ".openai-codex-auth.json")));
63
+ this.fetch = options.fetch ?? globalThis.fetch;
64
+ this.now = options.now ?? Date.now;
65
+ }
66
+ reset() {
67
+ this.#epoch++;
68
+ this.#controller?.abort(); this.#controller = undefined;
69
+ this.#pending = undefined; this.#key = undefined; this.#nextCheck = 0;
70
+ this.#tracker.clear();
71
+ }
72
+ dispose() { this.#disposed = true; this.reset(); }
73
+ async snapshot(account, binding) {
74
+ if (this.#disposed) return empty();
75
+ const epoch = this.#epoch;
76
+ let credential;
77
+ try { credential = await this.readCredential(); } catch { this.reset(); return empty(); }
78
+ if (this.#disposed) return empty();
79
+ const address = email(account.idToken);
80
+ if (!credential?.access || !(credential.expires > this.now()) || !address || address !== email(credential.access)) {
81
+ this.reset(); return empty();
82
+ }
83
+ const key = createHash("sha256").update(JSON.stringify([binding.clientId, binding.generation, credential.accountId, credential.access, account.idToken])).digest("hex");
84
+ if (epoch !== this.#epoch && key !== this.#key) return empty();
85
+ if (key !== this.#key) { this.reset(); this.#key = key; this.#tracker.activate(binding); }
86
+ if (!this.#pending && this.now() >= this.#nextCheck) {
87
+ const controller = new AbortController();
88
+ this.#controller = controller;
89
+ const requestEpoch = this.#epoch;
90
+ const request = (async () => {
91
+ this.#nextCheck = this.now() + FAILURE_BACKOFF_MS;
92
+ try {
93
+ const response = await this.fetch(USAGE_URL, {
94
+ redirect: "error", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)]),
95
+ headers: { authorization: `Bearer ${credential.access}`, ...(credential.accountId ? { "chatgpt-account-id": credential.accountId } : {}) },
96
+ });
97
+ if (!response.ok) { await response.body?.cancel(); return; }
98
+ const updates = quotaUsage(await response.json());
99
+ if (this.#disposed || requestEpoch !== this.#epoch || controller.signal.aborted) return;
100
+ // WHAM is a complete snapshot: null/missing windows must retire old data.
101
+ this.#tracker.clear(); this.#tracker.activate(binding);
102
+ this.#tracker.update(binding, updates, 0, this.now());
103
+ this.#nextCheck = this.now() + REFRESH_MS;
104
+ } catch { /* Quota failures never affect inference or disclose credentials. */ }
105
+ })();
106
+ this.#pending = request;
107
+ void request.finally(() => {
108
+ if (this.#pending === request) { this.#pending = undefined; this.#controller = undefined; }
109
+ });
110
+ }
111
+ await this.#pending;
112
+ return this.#disposed || this.#key !== key ? empty() : this.#tracker.snapshot(this.now());
113
+ }
114
+ }
@@ -0,0 +1,24 @@
1
+ import { quotaSummary, quotaDetails } from "./quota.js";
2
+ export function registerQuotaUi(ctx, adapter) {
3
+ ctx.inject(["commands"], (commands) => commands.effect(() => commands.commands.register({
4
+ name: "codex-usage", description: "Show Codex quota windows and reset times (no model request)",
5
+ handler: async ({ rawInput }) => rawInput.trim() ? { kind: "error", text: "Usage: /codex-usage" }
6
+ : { kind: "success", text: quotaDetails(await adapter.quotaSnapshot()) },
7
+ })));
8
+ ctx.inject(["tuiStatus"], (ui) => ui.effect(() => {
9
+ let clear, disposed = false, refreshing = false;
10
+ const refresh = async () => {
11
+ if (disposed || refreshing) return;
12
+ refreshing = true;
13
+ try {
14
+ const snapshot = await adapter.quotaSnapshot();
15
+ if (!disposed) { clear?.(); clear = ui.tuiStatus.set("dsh-codex-oauth:quota", quotaSummary(snapshot), ui); }
16
+ } catch { /* Status display must not prevent the TUI from starting. */ }
17
+ finally { refreshing = false; }
18
+ };
19
+ const unsubscribe = adapter.quotas.subscribe(refresh);
20
+ const timer = setInterval(refresh, 30_000); timer.unref?.();
21
+ void refresh();
22
+ return () => { disposed = true; clearInterval(timer); unsubscribe(); clear?.(); };
23
+ }));
24
+ }
package/lib/quota.js ADDED
@@ -0,0 +1,111 @@
1
+ const MAX_RESET_SECONDS = 8_640_000_000_000;
2
+ const safeLabel = (value) => typeof value === "string" ? value.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 80) : undefined;
3
+ function windowValue(used, minutes, reset) {
4
+ if (used === null || used === undefined || (typeof used === "string" && !used.trim())) return undefined;
5
+ const usedPercent = Number(used);
6
+ if (!Number.isFinite(usedPercent) || usedPercent < 0 || usedPercent > 100) return undefined;
7
+ const windowMinutes = Number(minutes);
8
+ const resetsAt = Number(reset);
9
+ if (usedPercent === 0 && minutes !== null && minutes !== undefined && windowMinutes === 0 && !reset) return null;
10
+ return { usedPercent, remainingPercent: 100 - usedPercent,
11
+ ...(Number.isSafeInteger(windowMinutes) && windowMinutes > 0 ? { windowMinutes } : {}),
12
+ ...(Number.isSafeInteger(resetsAt) && resetsAt > 0 && resetsAt <= MAX_RESET_SECONDS ? { resetsAt } : {}),
13
+ };
14
+ }
15
+ export function quotaHeaders(headers) {
16
+ const buckets = [];
17
+ const prefixes = new Set();
18
+ for (const key of headers.keys()) {
19
+ const match = /^(x-codex(?:-[a-z0-9-]+)?)-(?:primary|secondary)-used-percent$/.exec(key);
20
+ if (match) prefixes.add(match[1]);
21
+ }
22
+ for (const prefix of prefixes) {
23
+ const bucket = { id: prefix.slice(2), name: safeLabel(headers.get(`${prefix}-limit-name`)), windows: {} };
24
+ for (const slot of ["primary", "secondary"]) {
25
+ const value = windowValue(headers.get(`${prefix}-${slot}-used-percent`), headers.get(`${prefix}-${slot}-window-minutes`), headers.get(`${prefix}-${slot}-reset-at`));
26
+ if (value !== undefined) bucket.windows[slot] = value;
27
+ }
28
+ if (Object.keys(bucket.windows).length) buckets.push(bucket);
29
+ }
30
+ return buckets;
31
+ }
32
+ export function quotaEvent(event) {
33
+ if (event?.type !== "codex.rate_limits" || !event.rate_limits || typeof event.rate_limits !== "object") return [];
34
+ const windows = {};
35
+ for (const slot of ["primary", "secondary"]) {
36
+ const source = event.rate_limits[slot];
37
+ if (source === null) windows[slot] = null;
38
+ else if (source && typeof source.used_percent === "number") {
39
+ const value = windowValue(source.used_percent, source.window_minutes, source.reset_at);
40
+ if (value !== undefined) windows[slot] = value;
41
+ }
42
+ }
43
+ if (!Object.keys(windows).length) return [];
44
+ const id = safeLabel(event.metered_limit_name ?? event.limit_name) || "codex";
45
+ return [{ id: id.replaceAll("_", "-"), windows }];
46
+ }
47
+ export class QuotaTracker {
48
+ #binding;
49
+ #buckets = new Map();
50
+ #listeners = new Set();
51
+ subscribe(listener) { this.#listeners.add(listener); return () => this.#listeners.delete(listener); }
52
+ #emit() { for (const listener of this.#listeners) { try { listener(); } catch { /* Quota display must not interrupt inference. */ } } }
53
+ matches(binding) { return !!this.#binding && this.#binding.clientId === binding.clientId && this.#binding.generation === binding.generation; }
54
+ activate(binding) {
55
+ if (this.matches(binding)) return;
56
+ this.#binding = binding; this.#buckets.clear(); this.#emit();
57
+ }
58
+ accountChanged(state) { if (!state.authenticated || state.clientId !== this.#binding?.clientId) this.clear(); }
59
+ clear() { this.#binding = undefined; this.#buckets.clear(); this.#emit(); }
60
+ update(binding, updates, startedAt, now = Date.now()) {
61
+ if (!this.matches(binding)) return;
62
+ let changed = false;
63
+ for (const update of updates) {
64
+ const bucket = this.#buckets.get(update.id) ?? { id: update.id, name: update.name, windows: {}, sequence: {} };
65
+ if (update.name) bucket.name = update.name;
66
+ for (const [slot, value] of Object.entries(update.windows)) {
67
+ if (startedAt < (bucket.sequence[slot] ?? -Infinity)) continue;
68
+ bucket.sequence[slot] = startedAt;
69
+ if (value === null) delete bucket.windows[slot];
70
+ else bucket.windows[slot] = { ...value, checkedAt: now, startedAt };
71
+ changed = true;
72
+ }
73
+ this.#buckets.set(update.id, bucket);
74
+ }
75
+ if (changed) this.#emit();
76
+ }
77
+ snapshot(now = Date.now()) {
78
+ const buckets = [...this.#buckets.values()].map((bucket) => ({ id: bucket.id, ...(bucket.name ? { name: bucket.name } : {}),
79
+ windows: Object.values(bucket.windows).map(({ startedAt, ...window }) => ({ ...window, label: windowLabel(window), stale: now - window.checkedAt > 300_000 || (window.resetsAt !== undefined && window.resetsAt * 1000 <= now) })),
80
+ })).filter((bucket) => bucket.windows.length);
81
+ return { available: buckets.length > 0, buckets };
82
+ }
83
+ }
84
+ export function windowLabel(window) {
85
+ if (window.windowMinutes === 300) return "5h";
86
+ if (window.windowMinutes === 10080) return "Weekly";
87
+ return window.windowMinutes ? `${window.windowMinutes}m` : "Quota";
88
+ }
89
+ function resetText(window, now) {
90
+ if (!window.resetsAt) return "reset time unavailable";
91
+ const minutes = Math.ceil((window.resetsAt * 1000 - now) / 60000);
92
+ if (minutes <= 0) return "awaiting update";
93
+ const hours = Math.floor(minutes / 60);
94
+ return hours >= 24 ? `resets in ${Math.floor(hours / 24)}d ${hours % 24}h` : hours ? `resets in ${hours}h ${minutes % 60}m` : `resets in ${minutes}m`;
95
+ }
96
+ export function quotaSummary(snapshot, now = Date.now()) {
97
+ const bucket = snapshot.buckets.find((value) => value.id === "codex") ?? snapshot.buckets[0];
98
+ if (!bucket) return "codex unavailable";
99
+ const windows = [...bucket.windows].sort((a, b) => (a.windowMinutes ?? Infinity) - (b.windowMinutes ?? Infinity));
100
+ return `codex ${windows.map((window) => `${Math.round(window.remainingPercent)}%${window.stale ? "*" : ""} ${compactReset(window, now)}`).join(" ")}`;
101
+ }
102
+ function compactReset(window, now) {
103
+ if (!window.resetsAt) return "?";
104
+ const total = Math.max(0, Math.ceil((window.resetsAt * 1000 - now) / 60000));
105
+ const days = Math.floor(total / 1440), hours = Math.floor(total % 1440 / 60), minutes = total % 60;
106
+ return `${days ? `${days}d` : ""}${hours ? `${hours}h` : ""}${minutes ? `${minutes}m` : ""}` || "0m";
107
+ }
108
+ export function quotaDetails(snapshot, now = Date.now()) {
109
+ if (!snapshot.available) return "Codex quota unavailable. No quota data is currently available for this account. This command does not call a model.\nManage usage: https://chatgpt.com/settings/usage";
110
+ return snapshot.buckets.map((bucket) => `${bucket.name ?? bucket.id}\n${bucket.windows.map((window) => `${window.label}: ${window.remainingPercent.toFixed(1)}% remaining${window.stale ? " (stale)" : ""}\n ${resetText(window, now)}${window.resetsAt ? ` · ${new Date(window.resetsAt * 1000).toLocaleString("en-US")}` : ""}\n Updated: ${new Date(window.checkedAt).toLocaleString("en-US")}`).join("\n")}`).join("\n\n");
111
+ }