@lyogvce/dsh-codex-subscription 0.1.0 → 0.2.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/README.md CHANGED
@@ -17,6 +17,8 @@
17
17
  - 在插件配置中通过 `disabledModels` 屏蔽指定模型 `slug`,保持模型选择器干净清爽。
18
18
  4. **原生多轮思考链回放与工具调用**
19
19
  - 底层复用 DSH 内置的 `PiAiAdapter` 与 `@earendil-works/pi-ai` 的 `openaiCodexProvider()`,固定使用 `sse` 传输(含 `zstd` 请求体压缩、`session-id` / `thread-id` 缓存头、`reasoning.encrypted_content` 签名回放与图片自动缩放卸载)。
20
+ 5. **Codex 订阅限额内联展示(`conversation.input.right`)**
21
+ - 当会话选中 `openai-codex` 模型时,在输入框右下角紧邻模型选择器左侧展示 `5h X% · 7d Y%` 限额胶囊(数据源来自 `https://chatgpt.com/backend-api/wham/usage`,内置 60 秒缓存、单飞合并与每轮回复结束自动刷新),点击可展开查看 5 小时 / 7 天窗口进度条、重置倒计时与手动刷新按钮;切换至其他 Provider 模型时自动隐藏。
20
22
 
21
23
  ---
22
24
 
package/lib/client.js ADDED
@@ -0,0 +1,426 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@lyogvce/dsh-codex-subscription",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ //#region src/catalog.ts
8
+ const PROVIDER = "openai-codex";
9
+ Object.freeze([
10
+ "gpt-6-sol",
11
+ "gpt-6-luna",
12
+ "gpt-5.6-sol",
13
+ "gpt-5.6-terra",
14
+ "gpt-5.6-luna"
15
+ ]);
16
+ //#endregion
17
+ //#region src/usage.ts
18
+ const USAGE_ROUTE_PATH = "/api/codex-subscription/usage";
19
+ function clampPercent(value) {
20
+ return Math.max(0, Math.min(100, Math.round(value)));
21
+ }
22
+ //#endregion
23
+ //#region src/client/quota.ts
24
+ const RING_CIRCUMFERENCE = 2 * Math.PI * 5.5;
25
+ function resolveActiveProvider(directorySnapshot, projectedSelection) {
26
+ const dir = directorySnapshot && typeof directorySnapshot === "object" ? directorySnapshot : void 0;
27
+ const proj = projectedSelection && typeof projectedSelection === "object" ? projectedSelection : void 0;
28
+ return [
29
+ dir?.pending?.provider,
30
+ dir?.current?.provider,
31
+ proj?.next?.provider,
32
+ proj?.provider
33
+ ].find((candidate) => typeof candidate === "string" && candidate !== "");
34
+ }
35
+ function isCodexProvider(provider) {
36
+ return provider === PROVIDER;
37
+ }
38
+ function didTurnFinish(previousRunning, currentRunning) {
39
+ return previousRunning === true && currentRunning === false;
40
+ }
41
+ function windowLevelOf(usedPercent, limitReached = false) {
42
+ if (limitReached || usedPercent >= 100) return "error";
43
+ if (usedPercent >= 80) return "warn";
44
+ return "ok";
45
+ }
46
+ function formatQuotaSummary(snapshot) {
47
+ const primaryPercent = clampPercent(snapshot.primary.usedPercent);
48
+ const secondaryPercent = snapshot.secondary !== null ? clampPercent(snapshot.secondary.usedPercent) : null;
49
+ return {
50
+ primaryPercent,
51
+ secondaryPercent,
52
+ level: windowLevelOf(secondaryPercent !== null ? Math.max(primaryPercent, secondaryPercent) : primaryPercent, snapshot.limitReached),
53
+ pillText: secondaryPercent !== null ? `5h ${primaryPercent}% · 7d ${secondaryPercent}%` : `5h ${primaryPercent}%`,
54
+ ringDash: `${(RING_CIRCUMFERENCE * primaryPercent / 100).toFixed(2)} ${RING_CIRCUMFERENCE.toFixed(2)}`
55
+ };
56
+ }
57
+ function padTwo(n) {
58
+ return String(n).padStart(2, "0");
59
+ }
60
+ const COUNTDOWN_FORMATTERS = {
61
+ zh: {
62
+ soon: () => "即将重置",
63
+ minutes: (m, clock) => `${m} 分钟后重置 (${clock})`,
64
+ hours: (h, m) => m > 0 ? `${h} 小时 ${m} 分钟后重置` : `${h} 小时后重置`,
65
+ days: (d, h) => h > 0 ? `${d} 天 ${h} 小时后重置` : `${d} 天后重置`
66
+ },
67
+ en: {
68
+ soon: () => "Resetting soon",
69
+ minutes: (m, clock) => `Resets in ${m}m (${clock})`,
70
+ hours: (h, m) => m > 0 ? `Resets in ${h}h ${m}m` : `Resets in ${h}h`,
71
+ days: (d, h) => h > 0 ? `Resets in ${d}d ${h}h` : `Resets in ${d}d`
72
+ }
73
+ };
74
+ function formatResetCountdown(resetAtSec, nowMs, locale) {
75
+ if (typeof resetAtSec !== "number" || !Number.isFinite(resetAtSec) || resetAtSec <= 0) return "";
76
+ const fmt = COUNTDOWN_FORMATTERS[locale];
77
+ const diffSec = Math.max(0, Math.round(resetAtSec - nowMs / 1e3));
78
+ if (diffSec === 0) return fmt.soon();
79
+ const totalMinutes = Math.max(1, Math.ceil(diffSec / 60));
80
+ if (totalMinutes < 60) {
81
+ const resetDate = new Date(resetAtSec * 1e3);
82
+ const clock = `${padTwo(resetDate.getHours())}:${padTwo(resetDate.getMinutes())}`;
83
+ return fmt.minutes(totalMinutes, clock);
84
+ }
85
+ const totalHours = Math.floor(totalMinutes / 60);
86
+ const remMinutes = totalMinutes % 60;
87
+ if (totalHours < 24) return fmt.hours(totalHours, remMinutes);
88
+ const days = Math.floor(totalHours / 24);
89
+ const remHours = totalHours % 24;
90
+ return fmt.days(days, remHours);
91
+ }
92
+ function createQuotaStore(options = {}) {
93
+ const fetchFn = options.fetchImpl ?? globalThis.fetch;
94
+ const nowFn = options.now ?? Date.now;
95
+ const ttlMs = options.ttlMs ?? 6e4;
96
+ let state = {
97
+ quota: null,
98
+ loading: false,
99
+ error: null
100
+ };
101
+ let inflight = null;
102
+ let inflightForce = false;
103
+ let requestSeq = 0;
104
+ const listeners = new Set();
105
+ function setState(next) {
106
+ state = next;
107
+ for (const listener of listeners) listener();
108
+ }
109
+ async function refresh(force = false) {
110
+ if (!force && state.quota !== null && nowFn() - state.quota.fetchedAt < ttlMs) return;
111
+ if (inflight !== null && (!force || inflightForce)) return inflight;
112
+ const seq = ++requestSeq;
113
+ inflightForce = force;
114
+ setState({
115
+ ...state,
116
+ loading: true,
117
+ error: null
118
+ });
119
+ const url = force ? `${USAGE_ROUTE_PATH}?force=1` : USAGE_ROUTE_PATH;
120
+ const task = (async () => {
121
+ try {
122
+ const response = await fetchFn(url, {
123
+ method: "GET",
124
+ headers: { Accept: "application/json" }
125
+ });
126
+ if (seq !== requestSeq) return;
127
+ if (!response.ok) {
128
+ setState({
129
+ quota: state.quota,
130
+ loading: false,
131
+ error: `HTTP ${response.status}`
132
+ });
133
+ return;
134
+ }
135
+ const payload = await response.json();
136
+ if (seq !== requestSeq) return;
137
+ if (payload && payload.ok === true && payload.quota) setState({
138
+ quota: payload.quota,
139
+ loading: false,
140
+ error: null
141
+ });
142
+ else {
143
+ const fallbackQuota = payload && !payload.ok && payload.quota ? payload.quota : state.quota;
144
+ const errMsg = payload && !payload.ok && typeof payload.error === "string" ? payload.error : "Invalid response";
145
+ setState({
146
+ quota: fallbackQuota ?? null,
147
+ loading: false,
148
+ error: errMsg
149
+ });
150
+ }
151
+ } catch (error) {
152
+ if (seq !== requestSeq) return;
153
+ setState({
154
+ quota: state.quota,
155
+ loading: false,
156
+ error: error instanceof Error ? error.message : String(error)
157
+ });
158
+ } finally {
159
+ if (seq === requestSeq) {
160
+ inflight = null;
161
+ inflightForce = false;
162
+ }
163
+ }
164
+ })();
165
+ inflight = task;
166
+ return task;
167
+ }
168
+ return {
169
+ getSnapshot: () => state,
170
+ subscribe(listener) {
171
+ listeners.add(listener);
172
+ return () => {
173
+ listeners.delete(listener);
174
+ };
175
+ },
176
+ refresh
177
+ };
178
+ }
179
+ //#endregion
180
+ //#region src/client/styles.ts
181
+ const STYLESHEET = [
182
+ ".dsh-codex-quota-root{position:relative;display:inline-flex;align-items:center;flex:none}",
183
+ ".dsh-codex-quota-trigger{height:28px;padding:0 8px;border-radius:var(--dsw-radius-sm,6px);border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:12.5px;font-variant-numeric:tabular-nums;line-height:20px;white-space:nowrap;cursor:pointer;display:inline-flex;align-items:center;gap:5px;transition:background-color .12s,color .12s}",
184
+ ".dsh-codex-quota-trigger:hover,.dsh-codex-quota-trigger[aria-expanded=\"true\"]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
185
+ ".dsh-codex-quota-trigger[data-level=\"warn\"]{color:var(--dsw-alias-state-warn-primary)}",
186
+ ".dsh-codex-quota-trigger[data-level=\"error\"]{color:var(--dsw-alias-state-error-primary)}",
187
+ "[data-model-compact] .dsh-codex-quota-text{display:none}",
188
+ ".dsh-codex-quota-ring-track{fill:none;stroke:var(--dsw-alias-border-l2);stroke-width:2px}",
189
+ ".dsh-codex-quota-ring-fill{fill:none;stroke:var(--dsw-alias-brand-primary);stroke-width:2px;stroke-linecap:round}",
190
+ ".dsh-codex-quota-trigger[data-level=\"warn\"] .dsh-codex-quota-ring-fill{stroke:var(--dsw-alias-state-warn-primary)}",
191
+ ".dsh-codex-quota-trigger[data-level=\"error\"] .dsh-codex-quota-ring-fill{stroke:var(--dsw-alias-state-error-primary)}",
192
+ ".dsh-codex-quota-popover{position:absolute;bottom:calc(100% + 10px);right:0;z-index:1200;width:280px;box-sizing:border-box;border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-overlay));backdrop-filter:var(--dsw-menu-backdrop-filter,blur(16px));box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,0.16));border:1px solid var(--dsw-alias-border-l1);padding:12px 14px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;cursor:default;user-select:none}",
193
+ ".dsh-codex-quota-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}",
194
+ ".dsh-codex-quota-title{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;color:var(--dsw-alias-label-primary)}",
195
+ ".dsh-codex-quota-plan{font-size:10px;font-weight:600;text-transform:uppercase;padding:1px 6px;border-radius:4px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-brand-primary)}",
196
+ ".dsh-codex-quota-refresh{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);font-family:inherit;font-size:11.5px;padding:2px 6px;border-radius:4px;cursor:pointer;display:inline-flex;align-items:center;gap:4px}",
197
+ ".dsh-codex-quota-refresh:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
198
+ ".dsh-codex-quota-refresh:disabled{opacity:0.55;cursor:default}",
199
+ ".dsh-codex-quota-section{margin-bottom:10px}",
200
+ ".dsh-codex-quota-section:last-of-type{margin-bottom:4px}",
201
+ ".dsh-codex-quota-row{display:flex;align-items:baseline;justify-content:space-between;font-variant-numeric:tabular-nums}",
202
+ ".dsh-codex-quota-label{color:var(--dsw-alias-label-primary);font-weight:500}",
203
+ ".dsh-codex-quota-pct{color:var(--dsw-alias-label-primary);font-weight:600}",
204
+ ".dsh-codex-quota-bar{height:5px;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover);margin:5px 0 4px;overflow:hidden}",
205
+ ".dsh-codex-quota-bar-fill{height:100%;border-radius:999px;background:var(--dsw-alias-brand-primary);transition:width .2s ease}",
206
+ ".dsh-codex-quota-bar-fill[data-level=\"warn\"]{background:var(--dsw-alias-state-warn-primary)}",
207
+ ".dsh-codex-quota-bar-fill[data-level=\"error\"]{background:var(--dsw-alias-state-error-primary)}",
208
+ ".dsh-codex-quota-meta{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
209
+ ".dsh-codex-quota-error{margin-top:6px;padding:4px 6px;border-radius:4px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-state-error-primary);font-size:11px;word-break:break-word}"
210
+ ].join("");
211
+ //#endregion
212
+ //#region src/client/index.ts
213
+ const React = require("react");
214
+ const h = React.createElement;
215
+ const NS = "codex-subscription";
216
+ const SLOT_NAME = "conversation.input.right";
217
+ const SLOT_ENTRY_ID = "codex-subscription:quota";
218
+ const zh = {
219
+ "quota.title": "Codex 订阅限额",
220
+ "quota.loading": "正在获取 Codex 限额…",
221
+ "quota.refresh": "↻ 刷新",
222
+ "quota.refreshing": "↻ 刷新中…",
223
+ "quota.window5h": "5 小时窗口",
224
+ "quota.window7d": "7 天窗口",
225
+ "quota.usedPrefix": "已用 ",
226
+ "quota.remainPrefix": "剩余 "
227
+ };
228
+ const en = {
229
+ "quota.title": "Codex Subscription Quota",
230
+ "quota.loading": "Loading Codex quota…",
231
+ "quota.refresh": "↻ Refresh",
232
+ "quota.refreshing": "↻ Refreshing…",
233
+ "quota.window5h": "5-hour window",
234
+ "quota.window7d": "7-day window",
235
+ "quota.usedPrefix": "Used ",
236
+ "quota.remainPrefix": "Remaining "
237
+ };
238
+ const EMPTY_MODEL_STORE = {
239
+ getSnapshot: () => null,
240
+ subscribe: () => () => {}
241
+ };
242
+ function CodexQuotaPill(props) {
243
+ const { sessionId, quotaStore, getModelStore, useProjection, useSession, t, getLocale } = props;
244
+ const modelStore = getModelStore(sessionId);
245
+ const directorySnap = React.useSyncExternalStore(modelStore.subscribe, modelStore.getSnapshot);
246
+ const projectedSelection = typeof useProjection === "function" ? useProjection("modelSelection") : void 0;
247
+ const running = typeof useSession === "function" ? Boolean(useSession((s) => s?.running)) : false;
248
+ const isCodex = isCodexProvider(resolveActiveProvider(directorySnap, projectedSelection));
249
+ const quotaSnap = React.useSyncExternalStore(quotaStore.subscribe, quotaStore.getSnapshot);
250
+ const [open, setOpen] = React.useState(false);
251
+ const rootRef = React.useRef(null);
252
+ const prevRunningRef = React.useRef(running);
253
+ const prevSessionIdRef = React.useRef(sessionId);
254
+ React.useEffect(() => {
255
+ const sameSession = prevSessionIdRef.current === sessionId;
256
+ prevSessionIdRef.current = sessionId;
257
+ if (!isCodex) {
258
+ setOpen(false);
259
+ prevRunningRef.current = running;
260
+ return;
261
+ }
262
+ quotaStore.refresh(false);
263
+ if (sameSession && didTurnFinish(prevRunningRef.current, running)) quotaStore.refresh(true);
264
+ prevRunningRef.current = running;
265
+ }, [
266
+ isCodex,
267
+ running,
268
+ sessionId,
269
+ quotaStore
270
+ ]);
271
+ React.useEffect(() => {
272
+ if (!open || !isCodex || typeof globalThis.document === "undefined") return;
273
+ const onPointerDown = (event) => {
274
+ if (rootRef.current && typeof rootRef.current.contains === "function") {
275
+ if (!rootRef.current.contains(event.target)) setOpen(false);
276
+ }
277
+ };
278
+ const onKeyDown = (event) => {
279
+ if (event.key === "Escape") setOpen(false);
280
+ };
281
+ globalThis.document.addEventListener("pointerdown", onPointerDown);
282
+ globalThis.document.addEventListener("keydown", onKeyDown);
283
+ return () => {
284
+ globalThis.document.removeEventListener("pointerdown", onPointerDown);
285
+ globalThis.document.removeEventListener("keydown", onKeyDown);
286
+ };
287
+ }, [open, isCodex]);
288
+ if (!isCodex) return null;
289
+ const summary = quotaSnap.quota !== null ? formatQuotaSummary(quotaSnap.quota) : null;
290
+ const level = summary?.level ?? "ok";
291
+ const pillText = summary !== null ? summary.pillText : quotaSnap.loading ? "Codex …" : "Codex --";
292
+ const ringDash = summary?.ringDash ?? "0 34.56";
293
+ const locale = getLocale();
294
+ const nowMs = Date.now();
295
+ const renderWindowSection = (title, win, windowLimitReached) => {
296
+ const usedPercent = clampPercent(win.usedPercent);
297
+ const winLevel = windowLevelOf(usedPercent, windowLimitReached);
298
+ const remainPercent = 100 - usedPercent;
299
+ const countdown = formatResetCountdown(win.resetAt, nowMs, locale);
300
+ return h("div", { className: "dsh-codex-quota-section" }, h("div", { className: "dsh-codex-quota-row" }, h("span", { className: "dsh-codex-quota-label" }, title), h("span", { className: "dsh-codex-quota-pct" }, `${t("quota.usedPrefix")}${usedPercent}%`)), h("div", { className: "dsh-codex-quota-bar" }, h("div", {
301
+ className: "dsh-codex-quota-bar-fill",
302
+ "data-level": winLevel,
303
+ style: { width: `${usedPercent}%` }
304
+ })), h("div", { className: "dsh-codex-quota-meta" }, h("span", null, `${t("quota.remainPrefix")}${remainPercent}%`), countdown !== "" ? h("span", null, countdown) : null));
305
+ };
306
+ const quota = quotaSnap.quota;
307
+ const secondaryReached = Boolean(quota?.secondary && quota.secondary.usedPercent >= 100);
308
+ const primaryReached = Boolean(quota?.limitReached && !secondaryReached);
309
+ return h("span", {
310
+ ref: rootRef,
311
+ className: "dsh-codex-quota-root",
312
+ "data-composer-overlay": true
313
+ }, h("button", {
314
+ type: "button",
315
+ className: "dsh-codex-quota-trigger",
316
+ "data-level": level,
317
+ "aria-label": `${t("quota.title")}: ${pillText}`,
318
+ "aria-haspopup": "dialog",
319
+ "aria-expanded": open,
320
+ title: `${t("quota.title")} (${pillText})`,
321
+ onClick: () => {
322
+ const nextOpen = !open;
323
+ setOpen(nextOpen);
324
+ if (nextOpen) quotaStore.refresh(false);
325
+ }
326
+ }, h("svg", {
327
+ viewBox: "0 0 14 14",
328
+ width: 14,
329
+ height: 14,
330
+ "aria-hidden": true
331
+ }, h("circle", {
332
+ className: "dsh-codex-quota-ring-track",
333
+ cx: 7,
334
+ cy: 7,
335
+ r: 5.5
336
+ }), h("circle", {
337
+ className: "dsh-codex-quota-ring-fill",
338
+ cx: 7,
339
+ cy: 7,
340
+ r: 5.5,
341
+ strokeDasharray: ringDash,
342
+ transform: "rotate(-90 7 7)"
343
+ })), h("span", { className: "dsh-codex-quota-text" }, pillText)), open ? h("div", {
344
+ className: "dsh-codex-quota-popover",
345
+ role: "dialog",
346
+ "aria-label": t("quota.title"),
347
+ onMouseDown: (e) => {
348
+ e.stopPropagation?.();
349
+ }
350
+ }, h("div", { className: "dsh-codex-quota-head" }, h("span", { className: "dsh-codex-quota-title" }, t("quota.title"), quota?.planType ? h("span", { className: "dsh-codex-quota-plan" }, quota.planType) : null), h("button", {
351
+ type: "button",
352
+ className: "dsh-codex-quota-refresh",
353
+ disabled: quotaSnap.loading,
354
+ onClick: () => {
355
+ quotaStore.refresh(true);
356
+ }
357
+ }, quotaSnap.loading ? t("quota.refreshing") : t("quota.refresh"))), quota !== null ? renderWindowSection(t("quota.window5h"), quota.primary, primaryReached) : quotaSnap.loading || quotaSnap.error === null ? h("div", { className: "dsh-codex-quota-meta" }, t("quota.loading")) : null, quota?.secondary !== null && quota?.secondary !== void 0 ? renderWindowSection(t("quota.window7d"), quota.secondary, secondaryReached) : null, quotaSnap.error !== null ? h("div", { className: "dsh-codex-quota-error" }, quotaSnap.error) : null) : null);
358
+ }
359
+ const inject = ["slots", "locale"];
360
+ function apply(ctx) {
361
+ ctx.effect(() => {
362
+ if (typeof globalThis.document === "undefined") return;
363
+ const tag = globalThis.document.createElement("style");
364
+ tag.dataset.pluginCss = "@lyogvce/dsh-codex-subscription";
365
+ tag.textContent = STYLESHEET;
366
+ globalThis.document.head.appendChild(tag);
367
+ return () => {
368
+ tag.remove();
369
+ };
370
+ }, "codex-subscription: stylesheet");
371
+ ctx.effect(() => ctx.locale.register(NS, {
372
+ zh,
373
+ en
374
+ }), "codex-subscription: dictionaries");
375
+ const t = ctx.locale.bind(NS);
376
+ const quotaStore = createQuotaStore();
377
+ const getLocale = () => {
378
+ return (ctx.locale.getLocale?.()?.id ?? "").toLowerCase().startsWith("en") ? "en" : "zh";
379
+ };
380
+ const getModelStore = (sessionId) => {
381
+ if (typeof sessionId !== "string" || sessionId === "") return EMPTY_MODEL_STORE;
382
+ const models = ctx.get("modelDirectories");
383
+ if (!models || typeof models.directoryFor !== "function") return EMPTY_MODEL_STORE;
384
+ try {
385
+ return models.directoryFor(sessionId)?.store ?? EMPTY_MODEL_STORE;
386
+ } catch {
387
+ return EMPTY_MODEL_STORE;
388
+ }
389
+ };
390
+ ctx.effect(() => ctx.slots.inject(SLOT_NAME, () => ctx.slots.register({
391
+ name: SLOT_NAME,
392
+ id: SLOT_ENTRY_ID,
393
+ order: 10,
394
+ locale: NS,
395
+ inject: (sessionId) => ({
396
+ sessionId,
397
+ quotaStore,
398
+ getModelStore,
399
+ t,
400
+ getLocale
401
+ })
402
+ }, CodexQuotaPill)), "codex-subscription: quota pill");
403
+ }
404
+ const __test = Object.freeze({
405
+ PROVIDER_ID: PROVIDER,
406
+ USAGE_API_PATH: USAGE_ROUTE_PATH,
407
+ resolveActiveProvider,
408
+ isCodexProvider,
409
+ didTurnFinish,
410
+ windowLevelOf,
411
+ formatQuotaSummary,
412
+ formatResetCountdown,
413
+ createQuotaStore,
414
+ CodexQuotaPill
415
+ });
416
+ //#endregion
417
+ exports.CodexQuotaPill = CodexQuotaPill;
418
+ exports.NS = NS;
419
+ exports.__test = __test;
420
+ exports.apply = apply;
421
+ exports.en = en;
422
+ exports.inject = inject;
423
+ exports.zh = zh;
424
+ return module.exports;
425
+ }
426
+ });
package/lib/index.d.ts CHANGED
@@ -1,5 +1,17 @@
1
1
  import { PiCodexModel } from "@earendil-works/pi-ai/providers/openai-codex";
2
2
  //#region src/auth.d.ts
3
+ interface CodexAuthTokens {
4
+ access_token?: string;
5
+ refresh_token?: string;
6
+ id_token?: string;
7
+ account_id?: string;
8
+ [key: string]: unknown;
9
+ }
10
+ interface CodexAuthFile {
11
+ tokens?: CodexAuthTokens;
12
+ last_refresh?: string;
13
+ [key: string]: unknown;
14
+ }
3
15
  interface JwtClaims {
4
16
  exp?: number;
5
17
  [key: string]: unknown;
@@ -11,7 +23,13 @@ interface AuthManagerOptions {
11
23
  }
12
24
  declare function resolveCodexHome(envCodexHome?: string | undefined): string;
13
25
  declare function decodeJwtPayload(token: unknown): JwtClaims | undefined;
26
+ declare function extractAccountId(parsed: CodexAuthFile | undefined, accessToken: string): string | undefined;
27
+ interface CodexResolvedCredentials {
28
+ readonly accessToken: string;
29
+ readonly accountId?: string | undefined;
30
+ }
14
31
  declare function createAuthManager(options: AuthManagerOptions): {
32
+ resolveCredentials: () => Promise<CodexResolvedCredentials>;
15
33
  resolveAccessToken: () => Promise<string>;
16
34
  };
17
35
  //#endregion
@@ -56,6 +74,39 @@ declare function buildCodexUserAgent(version?: string): string;
56
74
  declare function buildCodexHeaders(existingHeaders: Record<string, string> | undefined, clientVersion: string, sessionId?: string | undefined): Record<string, string>;
57
75
  declare function sanitizeCodexPayload(payload: Record<string, unknown> | null | undefined, modelMeta?: CodexModelMetadata | undefined): Record<string, unknown> | null | undefined;
58
76
  //#endregion
77
+ //#region src/usage.d.ts
78
+ interface CodexQuotaWindow {
79
+ readonly usedPercent: number;
80
+ readonly resetAt: number | null;
81
+ }
82
+ interface CodexQuotaSnapshot {
83
+ readonly planType: string;
84
+ readonly limitReached: boolean;
85
+ readonly primary: CodexQuotaWindow;
86
+ readonly secondary: CodexQuotaWindow | null;
87
+ readonly fetchedAt: number;
88
+ }
89
+ type CodexQuotaResponse = {
90
+ readonly ok: true;
91
+ readonly quota: CodexQuotaSnapshot;
92
+ } | {
93
+ readonly ok: false;
94
+ readonly error: string;
95
+ readonly quota?: CodexQuotaSnapshot | undefined;
96
+ };
97
+ interface UsageFetcherOptions {
98
+ readonly resolveCredentials: () => Promise<CodexResolvedCredentials>;
99
+ readonly buildHeaders: (authHeaders: Record<string, string>) => Record<string, string>;
100
+ readonly fetchImpl?: typeof fetch | undefined;
101
+ readonly now?: (() => number) | undefined;
102
+ readonly ttlMs?: number | undefined;
103
+ }
104
+ declare function parseUsagePayload(raw: unknown, fetchedAt: number): CodexQuotaSnapshot | null;
105
+ declare function createUsageFetcher(options: UsageFetcherOptions): {
106
+ getUsage: (force?: boolean) => Promise<CodexQuotaResponse>;
107
+ invalidate: () => void;
108
+ };
109
+ //#endregion
59
110
  //#region src/host-types.d.ts
60
111
  interface LlmServiceLike {
61
112
  registerAdapter: (providers: string[], adapter: unknown) => (() => void) & {
@@ -67,6 +118,8 @@ interface PluginContextLike {
67
118
  llm: LlmServiceLike;
68
119
  get?: (name: string) => unknown;
69
120
  on?: (event: string, listener: () => void) => () => void;
121
+ effect?: (factory: () => void | (() => void), label?: string) => () => void;
122
+ inject?: (services: readonly string[], callback: (ctx: PluginContextLike) => void) => () => void;
70
123
  }
71
124
  //#endregion
72
125
  //#region src/index.d.ts
@@ -78,14 +131,20 @@ export declare const __test: Readonly<{
78
131
  PROVIDER: "openai-codex";
79
132
  DISPLAY_NAME: "Codex";
80
133
  DEFAULT_DISABLED_MODELS: readonly string[];
134
+ CODEX_USAGE_URL: "https://chatgpt.com/backend-api/wham/usage";
135
+ USAGE_ROUTE_PATH: "/api/codex-subscription/usage";
136
+ DEFAULT_USAGE_TTL_MS: 60000;
81
137
  resolveCodexHome: typeof resolveCodexHome;
82
138
  decodeJwtPayload: typeof decodeJwtPayload;
139
+ extractAccountId: typeof extractAccountId;
83
140
  createAuthManager: typeof createAuthManager;
84
141
  buildThinkingLevelMap: typeof buildThinkingLevelMap;
85
142
  parseModelsCache: typeof parseModelsCache;
86
143
  sanitizeCodexPayload: typeof sanitizeCodexPayload;
87
144
  buildCodexHeaders: typeof buildCodexHeaders;
88
145
  buildCodexUserAgent: typeof buildCodexUserAgent;
146
+ parseUsagePayload: typeof parseUsagePayload;
147
+ createUsageFetcher: typeof createUsageFetcher;
89
148
  }>;
90
149
  //#endregion
91
- export type { CodexModelMetadata, ParsedModelsCatalog, PluginContextLike, RawModelsCache };
150
+ export type { CodexModelMetadata, CodexQuotaResponse, CodexQuotaSnapshot, CodexQuotaWindow, CodexResolvedCredentials, ParsedModelsCatalog, PluginContextLike, RawModelsCache };
package/lib/index.js CHANGED
@@ -28,6 +28,15 @@ function decodeJwtPayload(token) {
28
28
  return;
29
29
  }
30
30
  }
31
+ function extractAccountId(parsed, accessToken) {
32
+ const fileAccountId = parsed?.tokens?.account_id;
33
+ if (typeof fileAccountId === "string" && fileAccountId.trim() !== "") return fileAccountId.trim();
34
+ const authClaim = decodeJwtPayload(accessToken)?.["https://api.openai.com/auth"];
35
+ if (authClaim && typeof authClaim === "object" && !Array.isArray(authClaim)) {
36
+ const jwtAccountId = authClaim.chatgpt_account_id;
37
+ if (typeof jwtAccountId === "string" && jwtAccountId.trim() !== "") return jwtAccountId.trim();
38
+ }
39
+ }
31
40
  async function writeJsonAtomic(filePath, data) {
32
41
  let targetPath = filePath;
33
42
  try {
@@ -118,20 +127,35 @@ function createAuthManager(options) {
118
127
  } catch (error) {
119
128
  console.error(`[dsh-codex-subscription] 写回刷新后的 Codex auth.json 失败 (${authPath}): ${error instanceof Error ? error.message : String(error)}`);
120
129
  }
121
- return fresh.access_token;
130
+ return {
131
+ accessToken: fresh.access_token,
132
+ accountId: extractAccountId(nextAuth, fresh.access_token)
133
+ };
122
134
  }
123
- return { async resolveAccessToken() {
135
+ function toCredentials(state) {
136
+ return {
137
+ accessToken: state.accessToken,
138
+ accountId: extractAccountId(state.parsed, state.accessToken)
139
+ };
140
+ }
141
+ async function resolveCredentials() {
124
142
  const current = readAuthState();
125
- if (isTokenFresh(current.accessToken)) return current.accessToken;
143
+ if (isTokenFresh(current.accessToken)) return toCredentials(current);
126
144
  if (!inflightRefresh) inflightRefresh = (async () => {
127
145
  const latest = readAuthState();
128
- if (isTokenFresh(latest.accessToken)) return latest.accessToken;
146
+ if (isTokenFresh(latest.accessToken)) return toCredentials(latest);
129
147
  return refreshToken(latest.authPath, latest.parsed);
130
148
  })().finally(() => {
131
149
  inflightRefresh = void 0;
132
150
  });
133
151
  return inflightRefresh;
134
- } };
152
+ }
153
+ return {
154
+ resolveCredentials,
155
+ async resolveAccessToken() {
156
+ return (await resolveCredentials()).accessToken;
157
+ }
158
+ };
135
159
  }
136
160
  //#endregion
137
161
  //#region src/catalog.ts
@@ -327,6 +351,108 @@ function sanitizeCodexPayload(payload, modelMeta) {
327
351
  return cleaned;
328
352
  }
329
353
  //#endregion
354
+ //#region src/usage.ts
355
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
356
+ const USAGE_ROUTE_PATH = "/api/codex-subscription/usage";
357
+ const DEFAULT_USAGE_TTL_MS = 6e4;
358
+ const USAGE_FETCH_TIMEOUT_MS = 15e3;
359
+ function clampPercent(value) {
360
+ return Math.max(0, Math.min(100, Math.round(value)));
361
+ }
362
+ function parseWindow(raw, fetchedAtMs) {
363
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
364
+ const record = raw;
365
+ const usedRaw = record.used_percent;
366
+ if (typeof usedRaw !== "number" || !Number.isFinite(usedRaw)) return null;
367
+ const resetAtRaw = record.reset_at;
368
+ const resetAfterRaw = record.reset_after_seconds;
369
+ let resetAt = null;
370
+ if (typeof resetAtRaw === "number" && Number.isFinite(resetAtRaw) && resetAtRaw > 0) resetAt = Math.round(resetAtRaw);
371
+ else if (typeof resetAfterRaw === "number" && Number.isFinite(resetAfterRaw) && resetAfterRaw >= 0) resetAt = Math.floor(fetchedAtMs / 1e3) + Math.round(resetAfterRaw);
372
+ return {
373
+ usedPercent: clampPercent(usedRaw),
374
+ resetAt
375
+ };
376
+ }
377
+ function parseUsagePayload(raw, fetchedAt) {
378
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
379
+ const record = raw;
380
+ const rateLimit = record.rate_limit;
381
+ if (!rateLimit || typeof rateLimit !== "object" || Array.isArray(rateLimit)) return null;
382
+ const rateRecord = rateLimit;
383
+ const primary = parseWindow(rateRecord.primary_window, fetchedAt);
384
+ if (primary === null) return null;
385
+ const secondary = parseWindow(rateRecord.secondary_window, fetchedAt);
386
+ return {
387
+ planType: typeof record.plan_type === "string" && record.plan_type.trim() !== "" ? record.plan_type.trim() : "plus",
388
+ limitReached: rateRecord.limit_reached === true || rateRecord.allowed === false || primary.usedPercent >= 100 || secondary !== null && secondary.usedPercent >= 100,
389
+ primary,
390
+ secondary,
391
+ fetchedAt
392
+ };
393
+ }
394
+ function createUsageFetcher(options) {
395
+ const fetchFn = options.fetchImpl ?? globalThis.fetch;
396
+ const nowFn = options.now ?? Date.now;
397
+ const ttlMs = options.ttlMs ?? 6e4;
398
+ let cached;
399
+ let stale = false;
400
+ let inflight;
401
+ async function fetchFresh() {
402
+ try {
403
+ const creds = await options.resolveCredentials();
404
+ const headers = options.buildHeaders({
405
+ Authorization: `Bearer ${creds.accessToken}`,
406
+ Accept: "application/json",
407
+ ...creds.accountId !== void 0 ? { "ChatGPT-Account-Id": creds.accountId } : {}
408
+ });
409
+ const response = await fetchFn(CODEX_USAGE_URL, {
410
+ method: "GET",
411
+ headers,
412
+ signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS)
413
+ });
414
+ if (!response.ok) return {
415
+ ok: false,
416
+ error: `Codex 限额查询失败 (HTTP ${response.status})`,
417
+ ...cached !== void 0 ? { quota: cached } : {}
418
+ };
419
+ const parsed = parseUsagePayload(await response.json(), nowFn());
420
+ if (parsed === null) return {
421
+ ok: false,
422
+ error: "Codex 限额响应格式无法解析",
423
+ ...cached !== void 0 ? { quota: cached } : {}
424
+ };
425
+ cached = parsed;
426
+ stale = false;
427
+ return {
428
+ ok: true,
429
+ quota: parsed
430
+ };
431
+ } catch (error) {
432
+ return {
433
+ ok: false,
434
+ error: error instanceof Error ? error.message : String(error),
435
+ ...cached !== void 0 ? { quota: cached } : {}
436
+ };
437
+ }
438
+ }
439
+ return {
440
+ invalidate() {
441
+ stale = true;
442
+ },
443
+ async getUsage(force = false) {
444
+ if (!force && !stale && cached !== void 0 && nowFn() - cached.fetchedAt < ttlMs) return {
445
+ ok: true,
446
+ quota: cached
447
+ };
448
+ if (!inflight) inflight = fetchFresh().finally(() => {
449
+ inflight = void 0;
450
+ });
451
+ return inflight;
452
+ }
453
+ };
454
+ }
455
+ //#endregion
330
456
  //#region src/index.ts
331
457
  const name = "codex-subscription";
332
458
  const inject = ["llm"];
@@ -398,6 +524,10 @@ function apply(ctx, config = {}) {
398
524
  }
399
525
  };
400
526
  }
527
+ const usageFetcher = createUsageFetcher({
528
+ resolveCredentials: () => authManager.resolveCredentials(),
529
+ buildHeaders: (authHeaders) => buildCodexHeaders(authHeaders, ensureSnapshot().catalog.clientVersion)
530
+ });
401
531
  const piProvider = Object.freeze({
402
532
  ...baseProvider,
403
533
  id: PROVIDER,
@@ -407,8 +537,14 @@ function apply(ctx, config = {}) {
407
537
  apiKey: requestTokenAuth
408
538
  }),
409
539
  getModels: () => ensureSnapshot().catalog.models,
410
- stream: (model, context, options) => baseProvider.stream(model, context, wrapOptions(model, options)),
411
- streamSimple: (model, context, options) => baseProvider.streamSimple(model, context, wrapOptions(model, options))
540
+ stream: (model, context, options) => {
541
+ usageFetcher.invalidate();
542
+ return baseProvider.stream(model, context, wrapOptions(model, options));
543
+ },
544
+ streamSimple: (model, context, options) => {
545
+ usageFetcher.invalidate();
546
+ return baseProvider.streamSimple(model, context, wrapOptions(model, options));
547
+ }
412
548
  });
413
549
  function ensureSnapshot() {
414
550
  const cachePath = join(getCodexHome(), "models_cache.json");
@@ -480,6 +616,28 @@ function apply(ctx, config = {}) {
480
616
  }
481
617
  }, (modelId) => ensureSnapshot().catalog.metadataById.get(modelId));
482
618
  ctx.llm.registerAdapter([PROVIDER], adapter);
619
+ const mountUsageRoute = (connCtx) => {
620
+ const connection = connCtx.get?.("connection");
621
+ const registerFetch = connection?.fetch?.register;
622
+ if (typeof registerFetch !== "function") return;
623
+ const registerRoute = () => registerFetch.call(connection?.fetch, {
624
+ path: USAGE_ROUTE_PATH,
625
+ methods: ["GET"],
626
+ requestBody: "buffered",
627
+ fetch: async (request) => {
628
+ const force = new URL(request.url, "http://localhost").searchParams.get("force") === "1";
629
+ const result = await usageFetcher.getUsage(force);
630
+ return new Response(JSON.stringify(result), {
631
+ status: 200,
632
+ headers: { "content-type": "application/json" }
633
+ });
634
+ }
635
+ });
636
+ if (typeof connCtx.effect === "function") connCtx.effect(() => registerRoute(), "codex-subscription: usage route");
637
+ else registerRoute();
638
+ };
639
+ if (typeof ctx.inject === "function") ctx.inject(["connection"], mountUsageRoute);
640
+ else mountUsageRoute(ctx);
483
641
  ctx.on?.("loader/volatile-update", () => {
484
642
  ensureSnapshot();
485
643
  ctx.llm.emitAdaptersUpdated?.();
@@ -489,14 +647,20 @@ const __test = Object.freeze({
489
647
  PROVIDER,
490
648
  DISPLAY_NAME,
491
649
  DEFAULT_DISABLED_MODELS,
650
+ CODEX_USAGE_URL,
651
+ USAGE_ROUTE_PATH,
652
+ DEFAULT_USAGE_TTL_MS,
492
653
  resolveCodexHome,
493
654
  decodeJwtPayload,
655
+ extractAccountId,
494
656
  createAuthManager,
495
657
  buildThinkingLevelMap,
496
658
  parseModelsCache,
497
659
  sanitizeCodexPayload,
498
660
  buildCodexHeaders,
499
- buildCodexUserAgent
661
+ buildCodexUserAgent,
662
+ parseUsagePayload,
663
+ createUsageFetcher
500
664
  });
501
665
  //#endregion
502
666
  export { Config, __test, apply, inject, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyogvce/dsh-codex-subscription",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "复用本机 ~/.codex 订阅凭证与 models_cache.json 模型缓存,在 DSH 中通过 SSE 直连调用 Codex 模型。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -11,6 +11,7 @@
11
11
  "types": "./lib/index.d.ts",
12
12
  "default": "./lib/index.js"
13
13
  },
14
+ "./client": "./lib/client.js",
14
15
  "./cordis.patch.yml": "./cordis.patch.yml",
15
16
  "./package.json": "./package.json",
16
17
  "./locale/*.json": "./locale/*.json",
@@ -26,6 +27,16 @@
26
27
  "dsh": {
27
28
  "bundle": {
28
29
  "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "platform": "web",
33
+ "immediately": true,
34
+ "inject": [
35
+ "@deepseek-ai/dsh-client-locale",
36
+ "@deepseek-ai/dsh-client-ui-slots",
37
+ "@deepseek-ai/dsh-client-ui-conversation",
38
+ "@deepseek-ai/dsh-client-ui-model-selection"
39
+ ]
29
40
  }
30
41
  },
31
42
  "peerDependencies": {