@lyogvce/dsh-codex-subscription 0.1.0 → 0.2.1

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
@@ -13,10 +13,13 @@
13
13
  - 每次请求前检查 `access_token` 的 JWT `exp`:距离过期不足 5 分钟时自动通过单飞锁(Single-flight)向 `https://auth.openai.com/oauth/token` 刷新,并通过 `realpathSync` + 临时文件 `rename` 原子写回 `auth.json`,保证与 Codex CLI 永不互踢下线。
14
14
  2. **零网络轮询的本地模型目录发现**
15
15
  - 直接读取 `~/.codex/models_cache.json`(按文件 `mtime` 与大小自动感知更新),提取 `visibility === "list"` 的模型及其 `context_window`、`supported_reasoning_levels`(自动将 `ultra` 映射为 DSH 的 `max` 档位)与 `default_verbosity`。
16
- 3. **支持按配置屏蔽不想看到的模型(`disabledModels`)**
16
+ 3. **支持按配置屏蔽模型(`disabledModels`)与自定义上下文窗口(`contextWindows`)**
17
17
  - 在插件配置中通过 `disabledModels` 屏蔽指定模型 `slug`,保持模型选择器干净清爽。
18
+ - 通过 `contextWindows` 按模型 `slug` 自定义向 DSH 会话与自动压缩器暴露的预算窗口(例如设为 `400000`);未配置时默认使用模型物理上限 `max_context_window`(`872000`)。同时底层 `PiCodexModel.contextWindow` 始终保持物理上限 `872000`,彻底防止 `dsh-llm-pi-ai` 在预算窗口与物理上限之间误判 `CONTEXT_WINDOW_EXCEEDED`。
18
19
  4. **原生多轮思考链回放与工具调用**
19
20
  - 底层复用 DSH 内置的 `PiAiAdapter` 与 `@earendil-works/pi-ai` 的 `openaiCodexProvider()`,固定使用 `sse` 传输(含 `zstd` 请求体压缩、`session-id` / `thread-id` 缓存头、`reasoning.encrypted_content` 签名回放与图片自动缩放卸载)。
21
+ 5. **Codex 订阅限额内联展示(`conversation.input.right`)**
22
+ - 当会话选中 `openai-codex` 模型时,在输入框右下角紧邻模型选择器左侧展示 `5h X% · 7d Y%` 限额胶囊(数据源来自 `https://chatgpt.com/backend-api/wham/usage`,内置 60 秒缓存、单飞合并与每轮回复结束自动刷新),点击可展开查看 5 小时 / 7 天窗口进度条、重置倒计时与手动刷新按钮;切换至其他 Provider 模型时自动隐藏。
20
23
 
21
24
  ---
22
25
 
@@ -30,9 +33,9 @@
30
33
  dsh plugin --profile desktop add @lyogvce/dsh-codex-subscription
31
34
  ```
32
35
 
33
- ### 2. 配置屏蔽模型(`disabledModels`)
36
+ ### 2. 配置屏蔽模型(`disabledModels`)与上下文窗口(`contextWindows`)
34
37
 
35
- 默认配置(见 [cordis.patch.yml](cordis.patch.yml))已屏蔽旧模型与备用模型,仅保留主力模型(如 `gpt-6.1-sol`、`gpt-6-astra`):
38
+ 默认配置(见 [cordis.patch.yml](cordis.patch.yml))已屏蔽旧模型与备用模型,仅保留主力模型(如 `gpt-6.1-sol`、`gpt-6-astra`)。未配置 `contextWindows` 时,模型默认使用 `872000` 上下文窗口;若希望主动限制长上下文预算(例如统一设为 400K),可在 Profile 的 `cordis.patch.yml` 中配置:
36
39
 
37
40
  ```yaml
38
41
  - id: codex-subscription
@@ -44,9 +47,12 @@ dsh plugin --profile desktop add @lyogvce/dsh-codex-subscription
44
47
  - gpt-5.6-sol
45
48
  - gpt-5.6-terra
46
49
  - gpt-5.6-luna
50
+ contextWindows:
51
+ gpt-6.1-sol: 400000
52
+ gpt-6-astra: 400000
47
53
  ```
48
54
 
49
- 可在 DSH 的 **设置 → 插件 → Codex 订阅直连** 页面或 `cordis.patch.yml` 中随时修改 `disabledModels`,实时生效无需重启。
55
+ 可在 DSH 的 **设置 → 插件 → Codex 订阅直连** 页面或 `cordis.patch.yml` 中随时修改 `disabledModels` 与 `contextWindows`,实时生效无需重启。
50
56
 
51
57
  ---
52
58
 
package/lib/client.js ADDED
@@ -0,0 +1,429 @@
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
+ function safeErrorMessage(error) {
19
+ return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted]").replace(/\b(Bearer\s+)[^\s,;'"&]+/giu, "$1[redacted]").replace(/(\b(?:code|token|access_token|refresh_token|id_token)=)[^&\s"']+/giu, "$1[redacted]").replace(/("(?:access_token|refresh_token|id_token)"\s*:\s*")[^"]+"/giu, "$1[redacted]\"").slice(0, 1e3);
20
+ }
21
+ const USAGE_ROUTE_PATH = "/api/codex-subscription/usage";
22
+ function clampPercent(value) {
23
+ return Math.max(0, Math.min(100, Math.round(value)));
24
+ }
25
+ //#endregion
26
+ //#region src/client/quota.ts
27
+ const RING_CIRCUMFERENCE = 2 * Math.PI * 5.5;
28
+ function resolveActiveProvider(directorySnapshot, projectedSelection) {
29
+ const dir = directorySnapshot && typeof directorySnapshot === "object" ? directorySnapshot : void 0;
30
+ const proj = projectedSelection && typeof projectedSelection === "object" ? projectedSelection : void 0;
31
+ return [
32
+ dir?.pending?.provider,
33
+ dir?.current?.provider,
34
+ proj?.next?.provider,
35
+ proj?.provider
36
+ ].find((candidate) => typeof candidate === "string" && candidate !== "");
37
+ }
38
+ function isCodexProvider(provider) {
39
+ return provider === PROVIDER;
40
+ }
41
+ function didTurnFinish(previousRunning, currentRunning) {
42
+ return previousRunning === true && currentRunning === false;
43
+ }
44
+ function windowLevelOf(usedPercent, limitReached = false) {
45
+ if (limitReached || usedPercent >= 100) return "error";
46
+ if (usedPercent >= 80) return "warn";
47
+ return "ok";
48
+ }
49
+ function formatQuotaSummary(snapshot) {
50
+ const primaryPercent = clampPercent(snapshot.primary.usedPercent);
51
+ const secondaryPercent = snapshot.secondary !== null ? clampPercent(snapshot.secondary.usedPercent) : null;
52
+ return {
53
+ primaryPercent,
54
+ secondaryPercent,
55
+ level: windowLevelOf(secondaryPercent !== null ? Math.max(primaryPercent, secondaryPercent) : primaryPercent, snapshot.limitReached),
56
+ pillText: secondaryPercent !== null ? `5h ${primaryPercent}% · 7d ${secondaryPercent}%` : `5h ${primaryPercent}%`,
57
+ ringDash: `${(RING_CIRCUMFERENCE * primaryPercent / 100).toFixed(2)} ${RING_CIRCUMFERENCE.toFixed(2)}`
58
+ };
59
+ }
60
+ function padTwo(n) {
61
+ return String(n).padStart(2, "0");
62
+ }
63
+ const COUNTDOWN_FORMATTERS = {
64
+ zh: {
65
+ soon: () => "即将重置",
66
+ minutes: (m, clock) => `${m} 分钟后重置 (${clock})`,
67
+ hours: (h, m) => m > 0 ? `${h} 小时 ${m} 分钟后重置` : `${h} 小时后重置`,
68
+ days: (d, h) => h > 0 ? `${d} 天 ${h} 小时后重置` : `${d} 天后重置`
69
+ },
70
+ en: {
71
+ soon: () => "Resetting soon",
72
+ minutes: (m, clock) => `Resets in ${m}m (${clock})`,
73
+ hours: (h, m) => m > 0 ? `Resets in ${h}h ${m}m` : `Resets in ${h}h`,
74
+ days: (d, h) => h > 0 ? `Resets in ${d}d ${h}h` : `Resets in ${d}d`
75
+ }
76
+ };
77
+ function formatResetCountdown(resetAtSec, nowMs, locale) {
78
+ if (typeof resetAtSec !== "number" || !Number.isFinite(resetAtSec) || resetAtSec <= 0) return "";
79
+ const fmt = COUNTDOWN_FORMATTERS[locale];
80
+ const diffSec = Math.max(0, Math.round(resetAtSec - nowMs / 1e3));
81
+ if (diffSec === 0) return fmt.soon();
82
+ const totalMinutes = Math.max(1, Math.ceil(diffSec / 60));
83
+ if (totalMinutes < 60) {
84
+ const resetDate = new Date(resetAtSec * 1e3);
85
+ const clock = `${padTwo(resetDate.getHours())}:${padTwo(resetDate.getMinutes())}`;
86
+ return fmt.minutes(totalMinutes, clock);
87
+ }
88
+ const totalHours = Math.floor(totalMinutes / 60);
89
+ const remMinutes = totalMinutes % 60;
90
+ if (totalHours < 24) return fmt.hours(totalHours, remMinutes);
91
+ const days = Math.floor(totalHours / 24);
92
+ const remHours = totalHours % 24;
93
+ return fmt.days(days, remHours);
94
+ }
95
+ function createQuotaStore(options = {}) {
96
+ const fetchFn = options.fetchImpl ?? globalThis.fetch;
97
+ const nowFn = options.now ?? Date.now;
98
+ const ttlMs = options.ttlMs ?? 6e4;
99
+ let state = {
100
+ quota: null,
101
+ loading: false,
102
+ error: null
103
+ };
104
+ let inflight = null;
105
+ let inflightForce = false;
106
+ let requestSeq = 0;
107
+ const listeners = new Set();
108
+ function setState(next) {
109
+ state = next;
110
+ for (const listener of listeners) listener();
111
+ }
112
+ async function refresh(force = false) {
113
+ if (!force && state.quota !== null && nowFn() - state.quota.fetchedAt < ttlMs) return;
114
+ if (inflight !== null && (!force || inflightForce)) return inflight;
115
+ const seq = ++requestSeq;
116
+ inflightForce = force;
117
+ setState({
118
+ ...state,
119
+ loading: true,
120
+ error: null
121
+ });
122
+ const url = force ? `${USAGE_ROUTE_PATH}?force=1` : USAGE_ROUTE_PATH;
123
+ const task = (async () => {
124
+ try {
125
+ const response = await fetchFn(url, {
126
+ method: "GET",
127
+ headers: { Accept: "application/json" }
128
+ });
129
+ if (seq !== requestSeq) return;
130
+ if (!response.ok) {
131
+ setState({
132
+ quota: state.quota,
133
+ loading: false,
134
+ error: `HTTP ${response.status}`
135
+ });
136
+ return;
137
+ }
138
+ const payload = await response.json();
139
+ if (seq !== requestSeq) return;
140
+ if (payload && payload.ok === true && payload.quota) setState({
141
+ quota: payload.quota,
142
+ loading: false,
143
+ error: null
144
+ });
145
+ else {
146
+ const fallbackQuota = payload && !payload.ok && payload.quota ? payload.quota : state.quota;
147
+ const errMsg = payload && !payload.ok && typeof payload.error === "string" ? payload.error : "Invalid response";
148
+ setState({
149
+ quota: fallbackQuota ?? null,
150
+ loading: false,
151
+ error: errMsg
152
+ });
153
+ }
154
+ } catch (error) {
155
+ if (seq !== requestSeq) return;
156
+ setState({
157
+ quota: state.quota,
158
+ loading: false,
159
+ error: safeErrorMessage(error)
160
+ });
161
+ } finally {
162
+ if (seq === requestSeq) {
163
+ inflight = null;
164
+ inflightForce = false;
165
+ }
166
+ }
167
+ })();
168
+ inflight = task;
169
+ return task;
170
+ }
171
+ return {
172
+ getSnapshot: () => state,
173
+ subscribe(listener) {
174
+ listeners.add(listener);
175
+ return () => {
176
+ listeners.delete(listener);
177
+ };
178
+ },
179
+ refresh
180
+ };
181
+ }
182
+ //#endregion
183
+ //#region src/client/styles.ts
184
+ const STYLESHEET = [
185
+ ".dsh-codex-quota-root{position:relative;display:inline-flex;align-items:center;flex:none}",
186
+ ".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}",
187
+ ".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)}",
188
+ ".dsh-codex-quota-trigger[data-level=\"warn\"]{color:var(--dsw-alias-state-warn-primary)}",
189
+ ".dsh-codex-quota-trigger[data-level=\"error\"]{color:var(--dsw-alias-state-error-primary)}",
190
+ "[data-model-compact] .dsh-codex-quota-text{display:none}",
191
+ ".dsh-codex-quota-ring-track{fill:none;stroke:var(--dsw-alias-border-l2);stroke-width:2px}",
192
+ ".dsh-codex-quota-ring-fill{fill:none;stroke:var(--dsw-alias-brand-primary);stroke-width:2px;stroke-linecap:round}",
193
+ ".dsh-codex-quota-trigger[data-level=\"warn\"] .dsh-codex-quota-ring-fill{stroke:var(--dsw-alias-state-warn-primary)}",
194
+ ".dsh-codex-quota-trigger[data-level=\"error\"] .dsh-codex-quota-ring-fill{stroke:var(--dsw-alias-state-error-primary)}",
195
+ ".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}",
196
+ ".dsh-codex-quota-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}",
197
+ ".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)}",
198
+ ".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)}",
199
+ ".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}",
200
+ ".dsh-codex-quota-refresh:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
201
+ ".dsh-codex-quota-refresh:disabled{opacity:0.55;cursor:default}",
202
+ ".dsh-codex-quota-section{margin-bottom:10px}",
203
+ ".dsh-codex-quota-section:last-of-type{margin-bottom:4px}",
204
+ ".dsh-codex-quota-row{display:flex;align-items:baseline;justify-content:space-between;font-variant-numeric:tabular-nums}",
205
+ ".dsh-codex-quota-label{color:var(--dsw-alias-label-primary);font-weight:500}",
206
+ ".dsh-codex-quota-pct{color:var(--dsw-alias-label-primary);font-weight:600}",
207
+ ".dsh-codex-quota-bar{height:5px;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover);margin:5px 0 4px;overflow:hidden}",
208
+ ".dsh-codex-quota-bar-fill{height:100%;border-radius:999px;background:var(--dsw-alias-brand-primary);transition:width .2s ease}",
209
+ ".dsh-codex-quota-bar-fill[data-level=\"warn\"]{background:var(--dsw-alias-state-warn-primary)}",
210
+ ".dsh-codex-quota-bar-fill[data-level=\"error\"]{background:var(--dsw-alias-state-error-primary)}",
211
+ ".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}",
212
+ ".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}"
213
+ ].join("");
214
+ //#endregion
215
+ //#region src/client/index.ts
216
+ const React = require("react");
217
+ const h = React.createElement;
218
+ const NS = "codex-subscription";
219
+ const SLOT_NAME = "conversation.input.right";
220
+ const SLOT_ENTRY_ID = "codex-subscription:quota";
221
+ const zh = {
222
+ "quota.title": "Codex 订阅限额",
223
+ "quota.loading": "正在获取 Codex 限额…",
224
+ "quota.refresh": "↻ 刷新",
225
+ "quota.refreshing": "↻ 刷新中…",
226
+ "quota.window5h": "5 小时窗口",
227
+ "quota.window7d": "7 天窗口",
228
+ "quota.usedPrefix": "已用 ",
229
+ "quota.remainPrefix": "剩余 "
230
+ };
231
+ const en = {
232
+ "quota.title": "Codex Subscription Quota",
233
+ "quota.loading": "Loading Codex quota…",
234
+ "quota.refresh": "↻ Refresh",
235
+ "quota.refreshing": "↻ Refreshing…",
236
+ "quota.window5h": "5-hour window",
237
+ "quota.window7d": "7-day window",
238
+ "quota.usedPrefix": "Used ",
239
+ "quota.remainPrefix": "Remaining "
240
+ };
241
+ const EMPTY_MODEL_STORE = {
242
+ getSnapshot: () => null,
243
+ subscribe: () => () => {}
244
+ };
245
+ function CodexQuotaPill(props) {
246
+ const { sessionId, quotaStore, getModelStore, useProjection, useSession, t, getLocale } = props;
247
+ const modelStore = getModelStore(sessionId);
248
+ const directorySnap = React.useSyncExternalStore(modelStore.subscribe, modelStore.getSnapshot);
249
+ const projectedSelection = typeof useProjection === "function" ? useProjection("modelSelection") : void 0;
250
+ const running = typeof useSession === "function" ? Boolean(useSession((s) => s?.running)) : false;
251
+ const isCodex = isCodexProvider(resolveActiveProvider(directorySnap, projectedSelection));
252
+ const quotaSnap = React.useSyncExternalStore(quotaStore.subscribe, quotaStore.getSnapshot);
253
+ const [open, setOpen] = React.useState(false);
254
+ const rootRef = React.useRef(null);
255
+ const prevRunningRef = React.useRef(running);
256
+ const prevSessionIdRef = React.useRef(sessionId);
257
+ React.useEffect(() => {
258
+ const sameSession = prevSessionIdRef.current === sessionId;
259
+ prevSessionIdRef.current = sessionId;
260
+ if (!isCodex) {
261
+ setOpen(false);
262
+ prevRunningRef.current = running;
263
+ return;
264
+ }
265
+ quotaStore.refresh(false);
266
+ if (sameSession && didTurnFinish(prevRunningRef.current, running)) quotaStore.refresh(true);
267
+ prevRunningRef.current = running;
268
+ }, [
269
+ isCodex,
270
+ running,
271
+ sessionId,
272
+ quotaStore
273
+ ]);
274
+ React.useEffect(() => {
275
+ if (!open || !isCodex || typeof globalThis.document === "undefined") return;
276
+ const onPointerDown = (event) => {
277
+ if (rootRef.current && typeof rootRef.current.contains === "function") {
278
+ if (!rootRef.current.contains(event.target)) setOpen(false);
279
+ }
280
+ };
281
+ const onKeyDown = (event) => {
282
+ if (event.key === "Escape") setOpen(false);
283
+ };
284
+ globalThis.document.addEventListener("pointerdown", onPointerDown);
285
+ globalThis.document.addEventListener("keydown", onKeyDown);
286
+ return () => {
287
+ globalThis.document.removeEventListener("pointerdown", onPointerDown);
288
+ globalThis.document.removeEventListener("keydown", onKeyDown);
289
+ };
290
+ }, [open, isCodex]);
291
+ if (!isCodex) return null;
292
+ const summary = quotaSnap.quota !== null ? formatQuotaSummary(quotaSnap.quota) : null;
293
+ const level = summary?.level ?? "ok";
294
+ const pillText = summary !== null ? summary.pillText : quotaSnap.loading ? "Codex …" : "Codex --";
295
+ const ringDash = summary?.ringDash ?? "0 34.56";
296
+ const locale = getLocale();
297
+ const nowMs = Date.now();
298
+ const renderWindowSection = (title, win, windowLimitReached) => {
299
+ const usedPercent = clampPercent(win.usedPercent);
300
+ const winLevel = windowLevelOf(usedPercent, windowLimitReached);
301
+ const remainPercent = 100 - usedPercent;
302
+ const countdown = formatResetCountdown(win.resetAt, nowMs, locale);
303
+ 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", {
304
+ className: "dsh-codex-quota-bar-fill",
305
+ "data-level": winLevel,
306
+ style: { width: `${usedPercent}%` }
307
+ })), h("div", { className: "dsh-codex-quota-meta" }, h("span", null, `${t("quota.remainPrefix")}${remainPercent}%`), countdown !== "" ? h("span", null, countdown) : null));
308
+ };
309
+ const quota = quotaSnap.quota;
310
+ const secondaryReached = Boolean(quota?.secondary && quota.secondary.usedPercent >= 100);
311
+ const primaryReached = Boolean(quota?.limitReached && !secondaryReached);
312
+ return h("span", {
313
+ ref: rootRef,
314
+ className: "dsh-codex-quota-root",
315
+ "data-composer-overlay": true
316
+ }, h("button", {
317
+ type: "button",
318
+ className: "dsh-codex-quota-trigger",
319
+ "data-level": level,
320
+ "aria-label": `${t("quota.title")}: ${pillText}`,
321
+ "aria-haspopup": "dialog",
322
+ "aria-expanded": open,
323
+ title: `${t("quota.title")} (${pillText})`,
324
+ onClick: () => {
325
+ const nextOpen = !open;
326
+ setOpen(nextOpen);
327
+ if (nextOpen) quotaStore.refresh(false);
328
+ }
329
+ }, h("svg", {
330
+ viewBox: "0 0 14 14",
331
+ width: 14,
332
+ height: 14,
333
+ "aria-hidden": true
334
+ }, h("circle", {
335
+ className: "dsh-codex-quota-ring-track",
336
+ cx: 7,
337
+ cy: 7,
338
+ r: 5.5
339
+ }), h("circle", {
340
+ className: "dsh-codex-quota-ring-fill",
341
+ cx: 7,
342
+ cy: 7,
343
+ r: 5.5,
344
+ strokeDasharray: ringDash,
345
+ transform: "rotate(-90 7 7)"
346
+ })), h("span", { className: "dsh-codex-quota-text" }, pillText)), open ? h("div", {
347
+ className: "dsh-codex-quota-popover",
348
+ role: "dialog",
349
+ "aria-label": t("quota.title"),
350
+ onMouseDown: (e) => {
351
+ e.stopPropagation?.();
352
+ }
353
+ }, 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", {
354
+ type: "button",
355
+ className: "dsh-codex-quota-refresh",
356
+ disabled: quotaSnap.loading,
357
+ onClick: () => {
358
+ quotaStore.refresh(true);
359
+ }
360
+ }, 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);
361
+ }
362
+ const inject = ["slots", "locale"];
363
+ function apply(ctx) {
364
+ ctx.effect(() => {
365
+ if (typeof globalThis.document === "undefined") return;
366
+ const tag = globalThis.document.createElement("style");
367
+ tag.dataset.pluginCss = "@lyogvce/dsh-codex-subscription";
368
+ tag.textContent = STYLESHEET;
369
+ globalThis.document.head.appendChild(tag);
370
+ return () => {
371
+ tag.remove();
372
+ };
373
+ }, "codex-subscription: stylesheet");
374
+ ctx.effect(() => ctx.locale.register(NS, {
375
+ zh,
376
+ en
377
+ }), "codex-subscription: dictionaries");
378
+ const t = ctx.locale.bind(NS);
379
+ const quotaStore = createQuotaStore();
380
+ const getLocale = () => {
381
+ return (ctx.locale.getLocale?.()?.id ?? "").toLowerCase().startsWith("en") ? "en" : "zh";
382
+ };
383
+ const getModelStore = (sessionId) => {
384
+ if (typeof sessionId !== "string" || sessionId === "") return EMPTY_MODEL_STORE;
385
+ const models = ctx.get("modelDirectories");
386
+ if (!models || typeof models.directoryFor !== "function") return EMPTY_MODEL_STORE;
387
+ try {
388
+ return models.directoryFor(sessionId)?.store ?? EMPTY_MODEL_STORE;
389
+ } catch {
390
+ return EMPTY_MODEL_STORE;
391
+ }
392
+ };
393
+ ctx.effect(() => ctx.slots.inject(SLOT_NAME, () => ctx.slots.register({
394
+ name: SLOT_NAME,
395
+ id: SLOT_ENTRY_ID,
396
+ order: 10,
397
+ locale: NS,
398
+ inject: (sessionId) => ({
399
+ sessionId,
400
+ quotaStore,
401
+ getModelStore,
402
+ t,
403
+ getLocale
404
+ })
405
+ }, CodexQuotaPill)), "codex-subscription: quota pill");
406
+ }
407
+ const __test = Object.freeze({
408
+ PROVIDER_ID: PROVIDER,
409
+ USAGE_API_PATH: USAGE_ROUTE_PATH,
410
+ resolveActiveProvider,
411
+ isCodexProvider,
412
+ didTurnFinish,
413
+ windowLevelOf,
414
+ formatQuotaSummary,
415
+ formatResetCountdown,
416
+ createQuotaStore,
417
+ CodexQuotaPill
418
+ });
419
+ //#endregion
420
+ exports.CodexQuotaPill = CodexQuotaPill;
421
+ exports.NS = NS;
422
+ exports.__test = __test;
423
+ exports.apply = apply;
424
+ exports.en = en;
425
+ exports.inject = inject;
426
+ exports.zh = zh;
427
+ return module.exports;
428
+ }
429
+ });
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
@@ -39,6 +57,7 @@ interface RawModelsCache {
39
57
  models?: RawCodexModelEntry[];
40
58
  }
41
59
  interface CodexModelMetadata {
60
+ readonly contextWindow: number;
42
61
  readonly supportVerbosity: boolean;
43
62
  readonly defaultVerbosity: 'low' | 'medium' | 'high';
44
63
  readonly defaultReasoningLevel?: string | undefined;
@@ -49,13 +68,47 @@ interface ParsedModelsCatalog {
49
68
  readonly metadataById: ReadonlyMap<string, CodexModelMetadata>;
50
69
  }
51
70
  declare function buildThinkingLevelMap(supportedLevels: unknown): Record<string, string | null>;
52
- declare function parseModelsCache(rawCache: RawModelsCache | null | undefined, baseModels: readonly PiCodexModel[], disabledModels?: readonly string[]): ParsedModelsCatalog;
71
+ declare function parseModelsCache(rawCache: RawModelsCache | null | undefined, baseModels: readonly PiCodexModel[], disabledModels?: readonly string[], contextWindows?: Readonly<Record<string, number>>): ParsedModelsCatalog;
53
72
  //#endregion
54
73
  //#region src/request.d.ts
55
74
  declare function buildCodexUserAgent(version?: string): string;
56
75
  declare function buildCodexHeaders(existingHeaders: Record<string, string> | undefined, clientVersion: string, sessionId?: string | undefined): Record<string, string>;
57
76
  declare function sanitizeCodexPayload(payload: Record<string, unknown> | null | undefined, modelMeta?: CodexModelMetadata | undefined): Record<string, unknown> | null | undefined;
58
77
  //#endregion
78
+ //#region src/usage.d.ts
79
+ declare function safeErrorMessage(error: unknown): string;
80
+ interface CodexQuotaWindow {
81
+ readonly usedPercent: number;
82
+ readonly resetAt: number | null;
83
+ }
84
+ interface CodexQuotaSnapshot {
85
+ readonly planType: string;
86
+ readonly limitReached: boolean;
87
+ readonly primary: CodexQuotaWindow;
88
+ readonly secondary: CodexQuotaWindow | null;
89
+ readonly fetchedAt: number;
90
+ }
91
+ type CodexQuotaResponse = {
92
+ readonly ok: true;
93
+ readonly quota: CodexQuotaSnapshot;
94
+ } | {
95
+ readonly ok: false;
96
+ readonly error: string;
97
+ readonly quota?: CodexQuotaSnapshot | undefined;
98
+ };
99
+ interface UsageFetcherOptions {
100
+ readonly resolveCredentials: () => Promise<CodexResolvedCredentials>;
101
+ readonly buildHeaders: (authHeaders: Record<string, string>) => Record<string, string>;
102
+ readonly fetchImpl?: typeof fetch | undefined;
103
+ readonly now?: (() => number) | undefined;
104
+ readonly ttlMs?: number | undefined;
105
+ }
106
+ declare function parseUsagePayload(raw: unknown, fetchedAt: number): CodexQuotaSnapshot | null;
107
+ declare function createUsageFetcher(options: UsageFetcherOptions): {
108
+ getUsage: (force?: boolean) => Promise<CodexQuotaResponse>;
109
+ invalidate: () => void;
110
+ };
111
+ //#endregion
59
112
  //#region src/host-types.d.ts
60
113
  interface LlmServiceLike {
61
114
  registerAdapter: (providers: string[], adapter: unknown) => (() => void) & {
@@ -67,6 +120,8 @@ interface PluginContextLike {
67
120
  llm: LlmServiceLike;
68
121
  get?: (name: string) => unknown;
69
122
  on?: (event: string, listener: () => void) => () => void;
123
+ effect?: (factory: () => void | (() => void), label?: string) => () => void;
124
+ inject?: (services: readonly string[], callback: (ctx: PluginContextLike) => void) => () => void;
70
125
  }
71
126
  //#endregion
72
127
  //#region src/index.d.ts
@@ -78,14 +133,22 @@ export declare const __test: Readonly<{
78
133
  PROVIDER: "openai-codex";
79
134
  DISPLAY_NAME: "Codex";
80
135
  DEFAULT_DISABLED_MODELS: readonly string[];
136
+ DEFAULT_CONTEXT_WINDOW: 872000;
137
+ CODEX_USAGE_URL: "https://chatgpt.com/backend-api/wham/usage";
138
+ USAGE_ROUTE_PATH: "/api/codex-subscription/usage";
139
+ DEFAULT_USAGE_TTL_MS: 60000;
81
140
  resolveCodexHome: typeof resolveCodexHome;
82
141
  decodeJwtPayload: typeof decodeJwtPayload;
142
+ extractAccountId: typeof extractAccountId;
143
+ safeErrorMessage: typeof safeErrorMessage;
83
144
  createAuthManager: typeof createAuthManager;
84
145
  buildThinkingLevelMap: typeof buildThinkingLevelMap;
85
146
  parseModelsCache: typeof parseModelsCache;
86
147
  sanitizeCodexPayload: typeof sanitizeCodexPayload;
87
148
  buildCodexHeaders: typeof buildCodexHeaders;
88
149
  buildCodexUserAgent: typeof buildCodexUserAgent;
150
+ parseUsagePayload: typeof parseUsagePayload;
151
+ createUsageFetcher: typeof createUsageFetcher;
89
152
  }>;
90
153
  //#endregion
91
- export type { CodexModelMetadata, ParsedModelsCatalog, PluginContextLike, RawModelsCache };
154
+ export type { CodexModelMetadata, CodexQuotaResponse, CodexQuotaSnapshot, CodexQuotaWindow, CodexResolvedCredentials, ParsedModelsCatalog, PluginContextLike, RawModelsCache };
package/lib/index.js CHANGED
@@ -1,13 +1,118 @@
1
- import { readFileSync, realpathSync, statSync } from "node:fs";
1
+ import { chmodSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import z from "@deepseek-ai/schemastery";
4
- import { LlmError, resolveImageAttachmentAccess } from "@deepseek-ai/dsh-llm";
4
+ import { LlmError, resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
5
5
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
6
6
  import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { chmod, rename, writeFile } from "node:fs/promises";
9
9
  import { arch, homedir, platform, release } from "node:os";
10
10
  import { execFileSync } from "node:child_process";
11
+ //#region src/usage.ts
12
+ function safeErrorMessage(error) {
13
+ return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted]").replace(/\b(Bearer\s+)[^\s,;'"&]+/giu, "$1[redacted]").replace(/(\b(?:code|token|access_token|refresh_token|id_token)=)[^&\s"']+/giu, "$1[redacted]").replace(/("(?:access_token|refresh_token|id_token)"\s*:\s*")[^"]+"/giu, "$1[redacted]\"").slice(0, 1e3);
14
+ }
15
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
16
+ const USAGE_ROUTE_PATH = "/api/codex-subscription/usage";
17
+ const DEFAULT_USAGE_TTL_MS = 6e4;
18
+ const USAGE_FETCH_TIMEOUT_MS = 15e3;
19
+ function clampPercent(value) {
20
+ return Math.max(0, Math.min(100, Math.round(value)));
21
+ }
22
+ function parseWindow(raw, fetchedAtMs) {
23
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
24
+ const record = raw;
25
+ const usedRaw = record.used_percent;
26
+ if (typeof usedRaw !== "number" || !Number.isFinite(usedRaw)) return null;
27
+ const resetAtRaw = record.reset_at;
28
+ const resetAfterRaw = record.reset_after_seconds;
29
+ let resetAt = null;
30
+ if (typeof resetAtRaw === "number" && Number.isFinite(resetAtRaw) && resetAtRaw > 0) resetAt = Math.round(resetAtRaw);
31
+ else if (typeof resetAfterRaw === "number" && Number.isFinite(resetAfterRaw) && resetAfterRaw >= 0) resetAt = Math.floor(fetchedAtMs / 1e3) + Math.round(resetAfterRaw);
32
+ return {
33
+ usedPercent: clampPercent(usedRaw),
34
+ resetAt
35
+ };
36
+ }
37
+ function parseUsagePayload(raw, fetchedAt) {
38
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
39
+ const record = raw;
40
+ const rateLimit = record.rate_limit;
41
+ if (!rateLimit || typeof rateLimit !== "object" || Array.isArray(rateLimit)) return null;
42
+ const rateRecord = rateLimit;
43
+ const primary = parseWindow(rateRecord.primary_window, fetchedAt);
44
+ if (primary === null) return null;
45
+ const secondary = parseWindow(rateRecord.secondary_window, fetchedAt);
46
+ return {
47
+ planType: typeof record.plan_type === "string" && record.plan_type.trim() !== "" ? record.plan_type.trim() : "plus",
48
+ limitReached: rateRecord.limit_reached === true || rateRecord.allowed === false || primary.usedPercent >= 100 || secondary !== null && secondary.usedPercent >= 100,
49
+ primary,
50
+ secondary,
51
+ fetchedAt
52
+ };
53
+ }
54
+ function createUsageFetcher(options) {
55
+ const fetchFn = options.fetchImpl ?? globalThis.fetch;
56
+ const nowFn = options.now ?? Date.now;
57
+ const ttlMs = options.ttlMs ?? 6e4;
58
+ let cached;
59
+ let stale = false;
60
+ let inflight;
61
+ async function fetchFresh() {
62
+ try {
63
+ const creds = await options.resolveCredentials();
64
+ const headers = options.buildHeaders({
65
+ Authorization: `Bearer ${creds.accessToken}`,
66
+ Accept: "application/json",
67
+ ...creds.accountId !== void 0 ? { "ChatGPT-Account-Id": creds.accountId } : {}
68
+ });
69
+ const response = await fetchFn(CODEX_USAGE_URL, {
70
+ method: "GET",
71
+ headers,
72
+ signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS)
73
+ });
74
+ if (!response.ok) return {
75
+ ok: false,
76
+ error: `Codex 限额查询失败 (HTTP ${response.status})`,
77
+ ...cached !== void 0 ? { quota: cached } : {}
78
+ };
79
+ const parsed = parseUsagePayload(await response.json(), nowFn());
80
+ if (parsed === null) return {
81
+ ok: false,
82
+ error: "Codex 限额响应格式无法解析",
83
+ ...cached !== void 0 ? { quota: cached } : {}
84
+ };
85
+ cached = parsed;
86
+ stale = false;
87
+ return {
88
+ ok: true,
89
+ quota: parsed
90
+ };
91
+ } catch (error) {
92
+ return {
93
+ ok: false,
94
+ error: safeErrorMessage(error),
95
+ ...cached !== void 0 ? { quota: cached } : {}
96
+ };
97
+ }
98
+ }
99
+ return {
100
+ invalidate() {
101
+ stale = true;
102
+ },
103
+ async getUsage(force = false) {
104
+ if (!force && !stale && cached !== void 0 && nowFn() - cached.fetchedAt < ttlMs) return {
105
+ ok: true,
106
+ quota: cached
107
+ };
108
+ if (!inflight) inflight = fetchFresh().finally(() => {
109
+ inflight = void 0;
110
+ });
111
+ return inflight;
112
+ }
113
+ };
114
+ }
115
+ //#endregion
11
116
  //#region src/auth.ts
12
117
  const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
13
118
  const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
@@ -28,6 +133,15 @@ function decodeJwtPayload(token) {
28
133
  return;
29
134
  }
30
135
  }
136
+ function extractAccountId(parsed, accessToken) {
137
+ const fileAccountId = parsed?.tokens?.account_id;
138
+ if (typeof fileAccountId === "string" && fileAccountId.trim() !== "") return fileAccountId.trim();
139
+ const authClaim = decodeJwtPayload(accessToken)?.["https://api.openai.com/auth"];
140
+ if (authClaim && typeof authClaim === "object" && !Array.isArray(authClaim)) {
141
+ const jwtAccountId = authClaim.chatgpt_account_id;
142
+ if (typeof jwtAccountId === "string" && jwtAccountId.trim() !== "") return jwtAccountId.trim();
143
+ }
144
+ }
31
145
  async function writeJsonAtomic(filePath, data) {
32
146
  let targetPath = filePath;
33
147
  try {
@@ -50,11 +164,26 @@ function createAuthManager(options) {
50
164
  let memoryFallback;
51
165
  function readAuthState() {
52
166
  const authPath = join(getCodexHome(), "auth.json");
167
+ if (process.platform !== "win32") try {
168
+ const stat = statSync(authPath);
169
+ if ((stat.mode & 256) === 0) {
170
+ if (memoryFallback?.authPath === authPath) return memoryFallback;
171
+ const octal = (stat.mode & 511).toString(8).padStart(4, "0");
172
+ throw new LlmError(`Codex 凭证文件属主不可读 (${authPath}, mode ${octal}),请执行 chmod 600 "${authPath}"`, "INVALID_CREDENTIAL");
173
+ }
174
+ if ((stat.mode & 63) !== 0) try {
175
+ chmodSync(realpathSync(authPath), 384);
176
+ } catch {}
177
+ } catch (error) {
178
+ if (error instanceof LlmError) throw error;
179
+ }
53
180
  let rawText;
54
181
  try {
55
182
  rawText = readFileSync(authPath, "utf8");
56
183
  } catch (error) {
57
184
  if (memoryFallback?.authPath === authPath) return memoryFallback;
185
+ const code = error?.code;
186
+ if (code === "EACCES" || code === "EPERM") throw new LlmError(`Codex 凭证文件无读取权限 (${authPath}),请执行 chmod 600 "${authPath}"`, "INVALID_CREDENTIAL", { cause: error });
58
187
  throw new LlmError(`无法读取 Codex 登录凭证 (${authPath}),请先在终端运行 codex login`, "MISSING_CREDENTIAL", { cause: error });
59
188
  }
60
189
  let parsed;
@@ -116,28 +245,44 @@ function createAuthManager(options) {
116
245
  try {
117
246
  await writeJsonAtomic(authPath, nextAuth);
118
247
  } catch (error) {
119
- console.error(`[dsh-codex-subscription] 写回刷新后的 Codex auth.json 失败 (${authPath}): ${error instanceof Error ? error.message : String(error)}`);
248
+ console.error(`[dsh-codex-subscription] 写回刷新后的 Codex auth.json 失败 (${authPath}): ${safeErrorMessage(error)}`);
120
249
  }
121
- return fresh.access_token;
250
+ return {
251
+ accessToken: fresh.access_token,
252
+ accountId: extractAccountId(nextAuth, fresh.access_token)
253
+ };
254
+ }
255
+ function toCredentials(state) {
256
+ return {
257
+ accessToken: state.accessToken,
258
+ accountId: extractAccountId(state.parsed, state.accessToken)
259
+ };
122
260
  }
123
- return { async resolveAccessToken() {
261
+ async function resolveCredentials() {
124
262
  const current = readAuthState();
125
- if (isTokenFresh(current.accessToken)) return current.accessToken;
263
+ if (isTokenFresh(current.accessToken)) return toCredentials(current);
126
264
  if (!inflightRefresh) inflightRefresh = (async () => {
127
265
  const latest = readAuthState();
128
- if (isTokenFresh(latest.accessToken)) return latest.accessToken;
266
+ if (isTokenFresh(latest.accessToken)) return toCredentials(latest);
129
267
  return refreshToken(latest.authPath, latest.parsed);
130
268
  })().finally(() => {
131
269
  inflightRefresh = void 0;
132
270
  });
133
271
  return inflightRefresh;
134
- } };
272
+ }
273
+ return {
274
+ resolveCredentials,
275
+ async resolveAccessToken() {
276
+ return (await resolveCredentials()).accessToken;
277
+ }
278
+ };
135
279
  }
136
280
  //#endregion
137
281
  //#region src/catalog.ts
138
282
  const PROVIDER = "openai-codex";
139
283
  const DISPLAY_NAME = "Codex";
140
284
  const DEFAULT_CLIENT_VERSION = "0.163.0";
285
+ const DEFAULT_CONTEXT_WINDOW = 872e3;
141
286
  const THINKING_LEVELS = [
142
287
  "off",
143
288
  "minimal",
@@ -178,7 +323,10 @@ function normalizeDefaultReasoningLevel(rawLevel, thinkingLevelMap) {
178
323
  function isVerbosity(value) {
179
324
  return value === "low" || value === "medium" || value === "high";
180
325
  }
181
- function parseModelsCache(rawCache, baseModels, disabledModels = []) {
326
+ function isPositiveSafeInt(value) {
327
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
328
+ }
329
+ function parseModelsCache(rawCache, baseModels, disabledModels = [], contextWindows = {}) {
182
330
  const disabledSet = new Set((Array.isArray(disabledModels) ? disabledModels : []).filter((id) => typeof id === "string").map((id) => id.trim()).filter(Boolean));
183
331
  const clientVersion = typeof rawCache?.client_version === "string" && rawCache.client_version.trim() !== "" ? rawCache.client_version.trim() : DEFAULT_CLIENT_VERSION;
184
332
  const rawModels = Array.isArray(rawCache?.models) ? rawCache.models : [];
@@ -194,8 +342,11 @@ function parseModelsCache(rawCache, baseModels, disabledModels = []) {
194
342
  if (seen.has(id)) continue;
195
343
  seen.add(id);
196
344
  const base = baseModels.find((model) => model.id === id) ?? templateModel;
197
- const contextWindow = typeof entry.context_window === "number" && Number.isSafeInteger(entry.context_window) && entry.context_window > 0 ? entry.context_window : typeof entry.max_context_window === "number" && Number.isSafeInteger(entry.max_context_window) && entry.max_context_window > 0 ? entry.max_context_window : base?.contextWindow ?? 272e3;
198
- const maxTokens = Math.min(128e3, Math.max(32768, Math.floor(contextWindow / 4)));
345
+ const maxContextWindow = isPositiveSafeInt(entry.max_context_window) ? entry.max_context_window : DEFAULT_CONTEXT_WINDOW;
346
+ const configuredOverride = contextWindows && typeof contextWindows === "object" ? contextWindows[id] : void 0;
347
+ const targetContextWindow = isPositiveSafeInt(configuredOverride) ? configuredOverride : maxContextWindow;
348
+ const wireContextWindow = Math.max(maxContextWindow, targetContextWindow);
349
+ const maxTokens = Math.min(128e3, Math.max(32768, Math.floor(targetContextWindow / 4)));
199
350
  const inputs = Array.isArray(entry.input_modalities) ? entry.input_modalities.filter((m) => m === "text" || m === "image") : ["text", "image"];
200
351
  const thinkingLevelMap = buildThinkingLevelMap(entry.supported_reasoning_levels);
201
352
  const hasReasoning = Object.entries(thinkingLevelMap).some(([k, v]) => k !== "off" && v !== null);
@@ -203,6 +354,7 @@ function parseModelsCache(rawCache, baseModels, disabledModels = []) {
203
354
  const defaultVerbosity = isVerbosity(entry.default_verbosity) ? entry.default_verbosity : "low";
204
355
  const defaultReasoningLevel = normalizeDefaultReasoningLevel(entry.default_reasoning_level, thinkingLevelMap);
205
356
  metadataById.set(id, {
357
+ contextWindow: targetContextWindow,
206
358
  supportVerbosity,
207
359
  defaultVerbosity,
208
360
  defaultReasoningLevel
@@ -216,7 +368,7 @@ function parseModelsCache(rawCache, baseModels, disabledModels = []) {
216
368
  provider: PROVIDER,
217
369
  baseUrl: "https://chatgpt.com/backend-api",
218
370
  input: inputs.length > 0 ? inputs : ["text"],
219
- contextWindow,
371
+ contextWindow: wireContextWindow,
220
372
  maxTokens,
221
373
  reasoning: hasReasoning,
222
374
  thinkingLevelMap,
@@ -334,7 +486,10 @@ const STREAM_IDLE_TIMEOUT_MS = 3e5;
334
486
  const MAX_REQUEST_IMAGE_BYTES = 20971520;
335
487
  const REQUEST_IMAGE_PIXEL_BUDGET = 4194304;
336
488
  const REQUEST_IMAGE_MAX_BYTES = 1048576;
337
- const Config = z.object({ disabledModels: z.array(z.string()).default([...DEFAULT_DISABLED_MODELS]).description("在模型选择器中屏蔽的模型 slug 列表").volatile() });
489
+ const Config = z.object({
490
+ disabledModels: z.array(z.string()).default([...DEFAULT_DISABLED_MODELS]).description("在模型选择器中屏蔽的模型 slug 列表").volatile(),
491
+ contextWindows: z.dict(z.number()).default({}).description("按模型 slug 覆盖上下文窗口大小(tokens),未配置时默认使用 872000").volatile()
492
+ });
338
493
  function readVolatile(field, fallback) {
339
494
  const value = typeof field?.get === "function" ? field.get() : field;
340
495
  return value === void 0 || value === null ? fallback : value;
@@ -347,22 +502,30 @@ var CodexPiAiAdapter = class extends PiAiAdapter {
347
502
  }
348
503
  modelInfo(snapshot, provider, model) {
349
504
  const info = super.modelInfo(snapshot, provider, model);
350
- const defaultLevel = this.#getModelMetadata(model)?.defaultReasoningLevel;
351
- if (defaultLevel !== void 0 && info.reasoning !== void 0 && info.reasoning.efforts.some((e) => e.id === defaultLevel)) return {
505
+ const meta = this.#getModelMetadata(model);
506
+ if (meta === void 0) return info;
507
+ const defaultLevel = meta.defaultReasoningLevel;
508
+ const hasDefaultLevel = defaultLevel !== void 0 && info.reasoning !== void 0 && info.reasoning.efforts.some((e) => e.id === defaultLevel);
509
+ return {
352
510
  ...info,
353
- reasoning: {
511
+ context: {
512
+ ...info.context ?? {},
513
+ contextWindow: meta.contextWindow
514
+ },
515
+ ...hasDefaultLevel && info.reasoning !== void 0 ? { reasoning: {
354
516
  ...info.reasoning,
355
517
  defaultEffort: defaultLevel
356
- }
518
+ } } : {}
357
519
  };
358
- return info;
359
520
  }
360
521
  };
361
522
  function apply(ctx, config = {}) {
362
523
  const baseProvider = openaiCodexProvider();
363
524
  const baseModels = baseProvider.getModels();
525
+ const retryPolicy = resolveRetryPolicy(void 0, "codex-subscription retryPolicy");
364
526
  const getCodexHome = () => resolveCodexHome();
365
527
  const getDisabledModels = () => readVolatile(config.disabledModels, DEFAULT_DISABLED_MODELS);
528
+ const getContextWindows = () => readVolatile(config.contextWindows, {});
366
529
  const authManager = createAuthManager({ getCodexHome });
367
530
  let fileSignature;
368
531
  let cachedRaw;
@@ -398,6 +561,10 @@ function apply(ctx, config = {}) {
398
561
  }
399
562
  };
400
563
  }
564
+ const usageFetcher = createUsageFetcher({
565
+ resolveCredentials: () => authManager.resolveCredentials(),
566
+ buildHeaders: (authHeaders) => buildCodexHeaders(authHeaders, ensureSnapshot().catalog.clientVersion)
567
+ });
401
568
  const piProvider = Object.freeze({
402
569
  ...baseProvider,
403
570
  id: PROVIDER,
@@ -407,12 +574,19 @@ function apply(ctx, config = {}) {
407
574
  apiKey: requestTokenAuth
408
575
  }),
409
576
  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))
577
+ stream: (model, context, options) => {
578
+ usageFetcher.invalidate();
579
+ return baseProvider.stream(model, context, wrapOptions(model, options));
580
+ },
581
+ streamSimple: (model, context, options) => {
582
+ usageFetcher.invalidate();
583
+ return baseProvider.streamSimple(model, context, wrapOptions(model, options));
584
+ }
412
585
  });
413
586
  function ensureSnapshot() {
414
587
  const cachePath = join(getCodexHome(), "models_cache.json");
415
588
  const disabled = getDisabledModels();
589
+ const contextWindows = getContextWindows();
416
590
  try {
417
591
  const stat = statSync(cachePath);
418
592
  const nextFileSig = `${cachePath}:${stat.mtimeMs}:${stat.size}`;
@@ -424,12 +598,16 @@ function apply(ctx, config = {}) {
424
598
  } catch (error) {
425
599
  if (!warnedMissingFile) {
426
600
  warnedMissingFile = true;
427
- console.error(`[dsh-codex-subscription] 读取 Codex models_cache.json 失败 (${cachePath}): ${error instanceof Error ? error.message : String(error)}`);
601
+ console.error(`[dsh-codex-subscription] 读取 Codex models_cache.json 失败 (${cachePath}): ${safeErrorMessage(error)}`);
428
602
  }
429
603
  }
430
- const nextSnapshotSig = JSON.stringify([fileSignature ?? "none", disabled]);
604
+ const nextSnapshotSig = JSON.stringify([
605
+ fileSignature ?? "none",
606
+ disabled,
607
+ contextWindows
608
+ ]);
431
609
  if (nextSnapshotSig !== snapshotSignature || currentProfilesMap === void 0) {
432
- currentCatalog = cachedRaw !== void 0 ? parseModelsCache(cachedRaw, baseModels, disabled) : {
610
+ currentCatalog = cachedRaw !== void 0 ? parseModelsCache(cachedRaw, baseModels, disabled, contextWindows) : {
433
611
  clientVersion: DEFAULT_CLIENT_VERSION,
434
612
  models: [],
435
613
  metadataById: new Map()
@@ -438,6 +616,7 @@ function apply(ctx, config = {}) {
438
616
  provider: PROVIDER,
439
617
  displayName: DISPLAY_NAME,
440
618
  piProvider,
619
+ retryPolicy,
441
620
  configuredMaxTokens: new Map(),
442
621
  modelErrors: new Map(),
443
622
  maxRequestImageBytes: MAX_REQUEST_IMAGE_BYTES,
@@ -480,6 +659,28 @@ function apply(ctx, config = {}) {
480
659
  }
481
660
  }, (modelId) => ensureSnapshot().catalog.metadataById.get(modelId));
482
661
  ctx.llm.registerAdapter([PROVIDER], adapter);
662
+ const mountUsageRoute = (connCtx) => {
663
+ const connection = connCtx.get?.("connection");
664
+ const registerFetch = connection?.fetch?.register;
665
+ if (typeof registerFetch !== "function") return;
666
+ const registerRoute = () => registerFetch.call(connection?.fetch, {
667
+ path: USAGE_ROUTE_PATH,
668
+ methods: ["GET"],
669
+ requestBody: "buffered",
670
+ fetch: async (request) => {
671
+ const force = new URL(request.url, "http://localhost").searchParams.get("force") === "1";
672
+ const result = await usageFetcher.getUsage(force);
673
+ return new Response(JSON.stringify(result), {
674
+ status: 200,
675
+ headers: { "content-type": "application/json" }
676
+ });
677
+ }
678
+ });
679
+ if (typeof connCtx.effect === "function") connCtx.effect(() => registerRoute(), "codex-subscription: usage route");
680
+ else registerRoute();
681
+ };
682
+ if (typeof ctx.inject === "function") ctx.inject(["connection"], mountUsageRoute);
683
+ else mountUsageRoute(ctx);
483
684
  ctx.on?.("loader/volatile-update", () => {
484
685
  ensureSnapshot();
485
686
  ctx.llm.emitAdaptersUpdated?.();
@@ -489,14 +690,22 @@ const __test = Object.freeze({
489
690
  PROVIDER,
490
691
  DISPLAY_NAME,
491
692
  DEFAULT_DISABLED_MODELS,
693
+ DEFAULT_CONTEXT_WINDOW,
694
+ CODEX_USAGE_URL,
695
+ USAGE_ROUTE_PATH,
696
+ DEFAULT_USAGE_TTL_MS,
492
697
  resolveCodexHome,
493
698
  decodeJwtPayload,
699
+ extractAccountId,
700
+ safeErrorMessage,
494
701
  createAuthManager,
495
702
  buildThinkingLevelMap,
496
703
  parseModelsCache,
497
704
  sanitizeCodexPayload,
498
705
  buildCodexHeaders,
499
- buildCodexUserAgent
706
+ buildCodexUserAgent,
707
+ parseUsagePayload,
708
+ createUsageFetcher
500
709
  });
501
710
  //#endregion
502
711
  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.1",
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": {