@lyogvce/dsh-codex-subscription 0.2.13 → 0.3.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
@@ -21,8 +21,9 @@
21
21
  - 通过 `retryPolicy` 暴露 DSH 原生 `RetryPolicySchema`(支持 `normal` / `always` 模式、`maxRetries`、`retryableCodes` 与 `backoff`,并随 `registration.replace` 热更新生效)。未配置时保持宿主默认策略(`mode: "normal"`、`maxRetries: 5`、默认 5 类错误码 `EMPTY_RESPONSE / RATE_LIMIT / SERVER / TIMEOUT / TRANSPORT` 及 `500ms..10000ms` 退避)。**注意:`retryableCodes` 为完整覆盖集合而非追加列表**,显式配置时需同时列出默认错误码与新增错误码。
22
22
  4. **原生多轮思考链回放、工具调用与出站指纹净化**
23
23
  - 底层复用 DSH 内置的 `PiAiAdapter` 与 `@earendil-works/pi-ai` 的 `openaiCodexProvider()`,固定使用 `sse` 传输(含 `zstd` 请求体压缩、规范化 36 位 RFC 4122 UUID `session-id` 缓存头与 `prompt_cache_key`、`reasoning.encrypted_content` 签名回放与图片自动缩放卸载),并通过自定义 `fetch` 与 `onPayload` 剥除 `OpenAI-Beta: responses=experimental`、静态 `x-client-request-id`、`msg_pi_*` 等非官方指纹。
24
- 5. **Codex 订阅限额内联展示与 ⚡ Fast 极速模式一键切换(`conversation.input.right`)**
25
- - 当会话选中 `openai-codex` 模型时,在输入框右下角紧邻模型选择器左侧展示限额胶囊(根据 `wham/usage` 返回的 `limit_window_seconds` 自适应展示 Plus 的 `5h X% · 7d Y%` 双窗口或 Pro 的 `7d X%` 单窗口,内置 60 秒缓存、单飞合并与每轮回复结束自动刷新),点击可展开查看对应窗口进度条、重置倒计时与手动刷新按钮;切换至其他 Provider 模型时自动隐藏。
24
+ 5. **Codex 订阅限额、限额重置卡(Rate Limit Reset Credits)与 ⚡ Fast 极速模式一键切换(`conversation.input.right`)**
25
+ - 当会话选中 `openai-codex` 模型时,在输入框右下角紧邻模型选择器左侧展示限额胶囊(根据 `wham/usage` 返回的 `limit_window_seconds` 自适应展示 Plus 的 `5h X% · 7d Y%` 双窗口或 Pro 的 `7d X%` 单窗口;由模型请求与流完成结算触发、按 60 秒限流在后台自动更新缓存,并由前端周期静默同步,无请求时零远端轮询,点击胶囊直接展开详情弹窗不触发额外请求),弹窗内可查看窗口进度条、重置倒计时与手动刷新按钮;切换至其他 Provider 模型时自动隐藏。
26
+ - **🎫 限额重置卡展示与二次确认核销**:自动解析 `wham/usage` 中的 `rate_limit_reset_credits`,并在持有可用重置卡(`available_count > 0`)时按需拉取 `wham/rate-limit-reset-credits` 详情,在弹窗内展示每张重置卡各自的过期时间,支持带内联二次确认的核销操作(`POST /wham/rate-limit-reset-credits/consume`)并在核销后自动刷新最新额度。
26
27
  - **⚡ 会话树级 Fast 极速模式(`service_tier: "priority"`,最高 2x 推理速度,主会话与 Subagent / 智能体团队同开同关)**:每个主会话可在限额弹窗底部独立开关 Fast 模式(不同主会话互不影响,新会话默认继承插件设置中的 `fastMode`,未配置时默认 `false` 关闭),该主会话派生的所有 `subagent`、`subagent_fork`、`workflow` 子任务以及智能体团队成员(`spawn_teammate`)均自动绑定至同一主会话开关,实时联动、同开同关;状态自动持久化至 `~/.codex/cache/dsh-codex-subscription/runtime-state.json`。开启后胶囊显示闪电标识 `⚡`,并在请求体中自动注入 `"service_tier": "priority"`。
27
28
 
28
29
  ---
package/lib/client.js CHANGED
@@ -47,6 +47,20 @@ window.__ModuleLoader__.load({
47
47
  function clampPercent(value) {
48
48
  return Math.max(0, Math.min(100, Math.round(value)));
49
49
  }
50
+ function normalizeConsumeOutcome(raw) {
51
+ if (typeof raw !== "string") return null;
52
+ switch (raw.trim()) {
53
+ case "reset":
54
+ case "nothing_to_reset":
55
+ case "no_credit":
56
+ case "already_redeemed": return raw.trim();
57
+ default: return null;
58
+ }
59
+ }
60
+ function generateRedeemRequestId(nowMs) {
61
+ const rand = Math.random().toString(16).slice(2, 14).padEnd(12, "0");
62
+ return `redeem-${nowMs.toString(16)}-${rand}`;
63
+ }
50
64
  //#endregion
51
65
  //#region src/client/quota.ts
52
66
  const RING_CIRCUMFERENCE = 2 * Math.PI * 5.5;
@@ -168,15 +182,46 @@ window.__ModuleLoader__.load({
168
182
  const remHours = totalHours % 24;
169
183
  return fmt.days(days, remHours);
170
184
  }
185
+ const CREDIT_EXPIRY_FORMATTERS = {
186
+ zh: {
187
+ never: () => "永久有效",
188
+ expired: () => "已过期",
189
+ minutes: (m, dateStr) => `${m} 分钟后过期 (${dateStr})`,
190
+ hours: (h, dateStr) => `${h} 小时后过期 (${dateStr})`,
191
+ days: (d, dateStr) => `${d} 天后过期 (${dateStr})`
192
+ },
193
+ en: {
194
+ never: () => "Never expires",
195
+ expired: () => "Expired",
196
+ minutes: (m, dateStr) => `Expires in ${m}m (${dateStr})`,
197
+ hours: (h, dateStr) => `Expires in ${h}h (${dateStr})`,
198
+ days: (d, dateStr) => `Expires in ${d}d (${dateStr})`
199
+ }
200
+ };
201
+ function formatResetCreditExpiry(expiresAtSec, nowMs, locale) {
202
+ const fmt = CREDIT_EXPIRY_FORMATTERS[locale];
203
+ if (expiresAtSec === null || expiresAtSec === void 0) return fmt.never();
204
+ if (typeof expiresAtSec !== "number" || !Number.isFinite(expiresAtSec) || expiresAtSec <= 0) return "";
205
+ const diffSec = Math.round(expiresAtSec - nowMs / 1e3);
206
+ if (diffSec <= 0) return fmt.expired();
207
+ const expDate = new Date(expiresAtSec * 1e3);
208
+ const dateStr = `${padTwo(expDate.getMonth() + 1)}-${padTwo(expDate.getDate())}`;
209
+ if (diffSec < 3600) return fmt.minutes(Math.max(1, Math.ceil(diffSec / 60)), dateStr);
210
+ if (diffSec < 86400) return fmt.hours(Math.max(1, Math.ceil(diffSec / 3600)), dateStr);
211
+ return fmt.days(Math.max(1, Math.ceil(diffSec / 86400)), dateStr);
212
+ }
171
213
  function createQuotaStore(options = {}) {
172
- const fetchFn = options.fetchImpl ?? globalThis.fetch;
214
+ const getFetch = () => options.fetchImpl ?? globalThis.fetch;
173
215
  const nowFn = options.now ?? Date.now;
174
- const ttlMs = options.ttlMs ?? 6e4;
216
+ const explicitTtlMs = options.ttlMs;
175
217
  let state = {
176
218
  quota: null,
177
219
  defaultFastMode: false,
178
220
  sessionFastModes: {},
179
221
  sessionParents: {},
222
+ redeeming: false,
223
+ redeemingCreditId: null,
224
+ redeemNoticeOutcome: null,
180
225
  loading: false,
181
226
  error: null
182
227
  };
@@ -186,9 +231,11 @@ window.__ModuleLoader__.load({
186
231
  const pendingBySeq = new Map();
187
232
  let inflight = null;
188
233
  let inflightForce = false;
234
+ let redeemingInflight = null;
189
235
  let requestSeq = 0;
190
236
  let fastModeSeq = 0;
191
237
  let fastMutationEpoch = 0;
238
+ let redeemSeq = 0;
192
239
  const listeners = new Set();
193
240
  function setState(next) {
194
241
  state = next;
@@ -272,22 +319,27 @@ window.__ModuleLoader__.load({
272
319
  sessionParents: knownSessionParents
273
320
  };
274
321
  }
322
+ function extractPayloadError(payload, fallback) {
323
+ return typeof payload?.error === "string" && payload.error !== "" ? payload.error : fallback;
324
+ }
275
325
  async function refresh(force = false) {
276
- if (!force && state.quota !== null && nowFn() - state.quota.fetchedAt < ttlMs) return;
326
+ if (!force && explicitTtlMs !== void 0 && state.quota !== null && nowFn() - state.quota.fetchedAt < explicitTtlMs) return;
277
327
  if (inflight !== null && (!force || inflightForce)) return inflight;
278
328
  const seq = ++requestSeq;
279
329
  const canReplaceFastAtStart = pendingBySeq.size === 0;
280
330
  const fastEpochAtStart = fastMutationEpoch;
281
331
  inflightForce = force;
282
- setState({
332
+ const showLoading = force || state.quota === null;
333
+ if (showLoading || state.error !== null || state.redeemNoticeOutcome !== null) setState({
283
334
  ...state,
284
- loading: true,
335
+ loading: showLoading ? true : state.loading,
336
+ redeemNoticeOutcome: null,
285
337
  error: null
286
338
  });
287
339
  const url = force ? `${USAGE_ROUTE_PATH}?force=1` : USAGE_ROUTE_PATH;
288
340
  const task = (async () => {
289
341
  try {
290
- const response = await fetchFn(url, {
342
+ const response = await getFetch()(url, {
291
343
  method: "GET",
292
344
  headers: { Accept: "application/json" }
293
345
  });
@@ -296,6 +348,7 @@ window.__ModuleLoader__.load({
296
348
  setState({
297
349
  ...state,
298
350
  loading: false,
351
+ redeemNoticeOutcome: null,
299
352
  error: `HTTP ${response.status}`
300
353
  });
301
354
  return;
@@ -304,18 +357,22 @@ window.__ModuleLoader__.load({
304
357
  if (seq !== requestSeq) return;
305
358
  const fastState = mergeFastStateFromPayload(payload, canReplaceFastAtStart && pendingBySeq.size === 0 && fastEpochAtStart === fastMutationEpoch);
306
359
  if (payload && payload.ok === true && payload.quota) setState({
360
+ ...state,
307
361
  quota: payload.quota,
308
362
  ...fastState,
309
363
  loading: false,
364
+ redeemNoticeOutcome: null,
310
365
  error: null
311
366
  });
312
367
  else {
313
368
  const fallbackQuota = payload && !payload.ok && payload.quota ? payload.quota : state.quota;
314
- const errMsg = payload && !payload.ok && typeof payload.error === "string" ? payload.error : "Invalid response";
369
+ const errMsg = extractPayloadError(payload && !payload.ok ? payload : null, "Invalid response");
315
370
  setState({
371
+ ...state,
316
372
  quota: fallbackQuota ?? null,
317
373
  ...fastState,
318
374
  loading: false,
375
+ redeemNoticeOutcome: null,
319
376
  error: errMsg
320
377
  });
321
378
  }
@@ -324,6 +381,7 @@ window.__ModuleLoader__.load({
324
381
  setState({
325
382
  ...state,
326
383
  loading: false,
384
+ redeemNoticeOutcome: null,
327
385
  error: safeErrorMessage(error)
328
386
  });
329
387
  } finally {
@@ -361,6 +419,7 @@ window.__ModuleLoader__.load({
361
419
  ...state,
362
420
  sessionFastModes: deriveSessionFastModes(),
363
421
  sessionParents: knownSessionParents,
422
+ redeemNoticeOutcome: null,
364
423
  error: errorMsg
365
424
  });
366
425
  };
@@ -368,10 +427,11 @@ window.__ModuleLoader__.load({
368
427
  ...state,
369
428
  sessionFastModes: deriveSessionFastModes(),
370
429
  sessionParents: knownSessionParents,
430
+ redeemNoticeOutcome: null,
371
431
  error: null
372
432
  });
373
433
  try {
374
- const response = await fetchFn(USAGE_ROUTE_PATH, {
434
+ const response = await getFetch()(USAGE_ROUTE_PATH, {
375
435
  method: "POST",
376
436
  headers: {
377
437
  "Content-Type": "application/json",
@@ -385,7 +445,7 @@ window.__ModuleLoader__.load({
385
445
  if (!response.ok) {
386
446
  const errPayload = await response.json().catch(() => null);
387
447
  mergeServerSessionParents(errPayload?.sessionParents);
388
- rollback(typeof errPayload?.error === "string" && errPayload.error !== "" ? errPayload.error : `HTTP ${response.status}`);
448
+ rollback(extractPayloadError(errPayload, `HTTP ${response.status}`));
389
449
  return;
390
450
  }
391
451
  const payload = await response.json();
@@ -395,7 +455,7 @@ window.__ModuleLoader__.load({
395
455
  return;
396
456
  }
397
457
  if (!payload || payload.ok === false) {
398
- rollback(typeof payload?.error === "string" && payload.error !== "" ? payload.error : "Invalid response");
458
+ rollback(extractPayloadError(payload, "Invalid response"));
399
459
  return;
400
460
  }
401
461
  fastMutationEpoch += 1;
@@ -421,6 +481,7 @@ window.__ModuleLoader__.load({
421
481
  defaultFastMode,
422
482
  sessionFastModes: deriveSessionFastModes(),
423
483
  sessionParents: knownSessionParents,
484
+ redeemNoticeOutcome: null,
424
485
  error: null
425
486
  });
426
487
  } catch (error) {
@@ -429,6 +490,99 @@ window.__ModuleLoader__.load({
429
490
  pendingBySeq.delete(seq);
430
491
  }
431
492
  }
493
+ async function consumeResetCredit(creditId) {
494
+ if (redeemingInflight !== null) return redeemingInflight;
495
+ const normalizedId = typeof creditId === "string" && creditId.trim() !== "" ? creditId.trim() : null;
496
+ redeemSeq += 1;
497
+ const redeemRequestId = generateRedeemRequestId(nowFn() + redeemSeq);
498
+ setState({
499
+ ...state,
500
+ redeeming: true,
501
+ redeemingCreditId: normalizedId,
502
+ redeemNoticeOutcome: null,
503
+ error: null
504
+ });
505
+ const task = (async () => {
506
+ try {
507
+ const response = await getFetch()(USAGE_ROUTE_PATH, {
508
+ method: "POST",
509
+ headers: {
510
+ "Content-Type": "application/json",
511
+ Accept: "application/json"
512
+ },
513
+ body: JSON.stringify({
514
+ action: "consumeResetCredit",
515
+ ...normalizedId !== null ? { creditId: normalizedId } : {},
516
+ redeemRequestId
517
+ })
518
+ });
519
+ if (!response.ok) {
520
+ const errMsg = extractPayloadError(await response.json().catch(() => null), `HTTP ${response.status}`);
521
+ setState({
522
+ ...state,
523
+ redeeming: false,
524
+ redeemingCreditId: null,
525
+ redeemNoticeOutcome: null,
526
+ error: errMsg
527
+ });
528
+ return {
529
+ ok: false,
530
+ error: errMsg
531
+ };
532
+ }
533
+ const payload = await response.json();
534
+ const fastState = mergeFastStateFromPayload(payload, pendingBySeq.size === 0);
535
+ if (!payload || payload.ok !== true) {
536
+ const errMsg = extractPayloadError(payload, "Invalid response");
537
+ setState({
538
+ ...state,
539
+ quota: payload?.quota ?? state.quota,
540
+ ...fastState,
541
+ redeeming: false,
542
+ redeemingCreditId: null,
543
+ redeemNoticeOutcome: null,
544
+ error: errMsg
545
+ });
546
+ return {
547
+ ok: false,
548
+ error: errMsg
549
+ };
550
+ }
551
+ const outcome = normalizeConsumeOutcome(payload.outcome) ?? "reset";
552
+ setState({
553
+ ...state,
554
+ quota: payload.quota ?? state.quota,
555
+ ...fastState,
556
+ redeeming: false,
557
+ redeemingCreditId: null,
558
+ redeemNoticeOutcome: outcome === "reset" ? null : outcome,
559
+ error: null
560
+ });
561
+ return {
562
+ ok: true,
563
+ outcome,
564
+ ...payload.quota !== void 0 ? { quota: payload.quota } : {}
565
+ };
566
+ } catch (error) {
567
+ const errMsg = safeErrorMessage(error);
568
+ setState({
569
+ ...state,
570
+ redeeming: false,
571
+ redeemingCreditId: null,
572
+ redeemNoticeOutcome: null,
573
+ error: errMsg
574
+ });
575
+ return {
576
+ ok: false,
577
+ error: errMsg
578
+ };
579
+ } finally {
580
+ redeemingInflight = null;
581
+ }
582
+ })();
583
+ redeemingInflight = task;
584
+ return task;
585
+ }
432
586
  return {
433
587
  getSnapshot: () => state,
434
588
  subscribe(listener) {
@@ -438,7 +592,8 @@ window.__ModuleLoader__.load({
438
592
  };
439
593
  },
440
594
  refresh,
441
- setFastMode
595
+ setFastMode,
596
+ consumeResetCredit
442
597
  };
443
598
  }
444
599
  //#endregion
@@ -480,6 +635,26 @@ window.__ModuleLoader__.load({
480
635
  ".dsh-codex-fast-switch[data-checked=\"true\"]{background:var(--dsw-alias-brand-primary,#3b82f6)}",
481
636
  ".dsh-codex-fast-switch-thumb{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,0.2);transition:transform .15s ease}",
482
637
  ".dsh-codex-fast-switch[data-checked=\"true\"] .dsh-codex-fast-switch-thumb{transform:translateX(14px)}",
638
+ ".dsh-codex-reset-section{margin-top:8px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l1)}",
639
+ ".dsh-codex-reset-row{display:flex;align-items:center;justify-content:space-between;gap:10px}",
640
+ ".dsh-codex-reset-icon{color:var(--dsw-alias-brand-primary,#3b82f6);flex:none}",
641
+ ".dsh-codex-reset-pill{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;border:none;font-family:inherit;font-size:11px;font-weight:600;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-brand-primary);white-space:nowrap;font-variant-numeric:tabular-nums;cursor:pointer;flex:none;transition:background-color .12s,color .12s}",
642
+ ".dsh-codex-reset-pill[data-zero=\"true\"]{color:var(--dsw-alias-label-tertiary);font-weight:500;cursor:default}",
643
+ ".dsh-codex-reset-list{margin-top:6px;display:flex;flex-direction:column;gap:5px}",
644
+ ".dsh-codex-reset-card{padding:6px 8px;border-radius:6px;background:var(--dsw-alias-interactive-bg-hover);border:1px dashed var(--dsw-alias-border-l2);display:flex;flex-direction:column;gap:5px}",
645
+ ".dsh-codex-reset-item{display:flex;align-items:center;justify-content:space-between;gap:8px}",
646
+ ".dsh-codex-reset-item-info{display:flex;flex-direction:column;gap:1px;min-width:0}",
647
+ ".dsh-codex-reset-item-title{font-size:11.5px;font-weight:500;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
648
+ ".dsh-codex-reset-item-expiry{font-size:10.5px;line-height:14px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}",
649
+ ".dsh-codex-reset-use-btn{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-brand-primary);font-family:inherit;font-size:11px;font-weight:600;padding:2px 8px;border-radius:5px;cursor:pointer;white-space:nowrap;flex:none;transition:background-color .12s,color .12s,border-color .12s}",
650
+ ".dsh-codex-reset-use-btn:hover:not(:disabled){background:var(--dsw-alias-brand-primary,#3b82f6);border-color:var(--dsw-alias-brand-primary,#3b82f6);color:#fff}",
651
+ ".dsh-codex-reset-use-btn:disabled{opacity:0.45;cursor:default;color:var(--dsw-alias-label-tertiary)}",
652
+ ".dsh-codex-reset-confirm{padding-top:5px;border-top:1px dashed var(--dsw-alias-border-l1);display:flex;align-items:center;justify-content:space-between;gap:6px}",
653
+ ".dsh-codex-reset-confirm-text{font-size:11px;line-height:14px;color:var(--dsw-alias-state-warn-primary)}",
654
+ ".dsh-codex-reset-confirm-actions{display:inline-flex;align-items:center;gap:4px;flex:none}",
655
+ ".dsh-codex-reset-confirm-cancel,.dsh-codex-reset-confirm-submit{border:none;font-family:inherit;font-size:11px;font-weight:600;padding:2px 7px;border-radius:4px;cursor:pointer;white-space:nowrap}",
656
+ ".dsh-codex-reset-confirm-cancel{background:transparent;color:var(--dsw-alias-label-secondary)}",
657
+ ".dsh-codex-reset-confirm-submit{background:var(--dsw-alias-state-warn-primary,#f59e0b);color:#111}",
483
658
  ".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}"
484
659
  ].join("");
485
660
  //#endregion
@@ -489,6 +664,8 @@ window.__ModuleLoader__.load({
489
664
  const NS = "codex-subscription";
490
665
  const SLOT_NAME = "conversation.input.right";
491
666
  const SLOT_ENTRY_ID = "codex-subscription:quota";
667
+ const ACTIVE_QUOTA_SYNC_INTERVAL_MS = 5e3;
668
+ const IDLE_QUOTA_SYNC_INTERVAL_MS = 15e3;
492
669
  const zh = {
493
670
  "quota.title": "Codex 订阅限额",
494
671
  "quota.loading": "正在获取 Codex 限额…",
@@ -497,7 +674,22 @@ window.__ModuleLoader__.load({
497
674
  "quota.usedPrefix": "已用 ",
498
675
  "quota.remainPrefix": "剩余 ",
499
676
  "quota.fastTitle": "Fast 极速模式",
500
- "quota.fastDesc": "2x 推理速度,消耗更多限额"
677
+ "quota.fastDesc": "2x 推理速度,消耗更多限额",
678
+ "quota.resetTitle": "限额重置卡",
679
+ "quota.resetZero": "0 张可用",
680
+ "quota.resetZeroDesc": "暂无可用重置卡",
681
+ "quota.resetAvailableSuffix": " 张可用",
682
+ "quota.resetNextExpiryPrefix": "下次到期:",
683
+ "quota.resetReadyDesc": "可重置当前周期已用限额",
684
+ "quota.resetItemDefaultTitle": "重置卡",
685
+ "quota.resetUseBtn": "使用",
686
+ "quota.resetUsingBtn": "核销中…",
687
+ "quota.resetConfirmPrompt": "确认使用此卡重置当前已用限额?",
688
+ "quota.resetConfirmYes": "确认使用",
689
+ "quota.resetConfirmNo": "取消",
690
+ "quota.resetNothingToReset": "当前无需重置限额",
691
+ "quota.resetNoCredit": "没有可用的重置卡",
692
+ "quota.resetAlreadyRedeemed": "该重置卡已被使用"
501
693
  };
502
694
  const en = {
503
695
  "quota.title": "Codex Subscription Quota",
@@ -507,7 +699,22 @@ window.__ModuleLoader__.load({
507
699
  "quota.usedPrefix": "Used ",
508
700
  "quota.remainPrefix": "Remaining ",
509
701
  "quota.fastTitle": "Fast Mode",
510
- "quota.fastDesc": "2x speed, increased usage"
702
+ "quota.fastDesc": "2x speed, increased usage",
703
+ "quota.resetTitle": "Reset Credits",
704
+ "quota.resetZero": "0 available",
705
+ "quota.resetZeroDesc": "No reset credits available",
706
+ "quota.resetAvailableSuffix": " available",
707
+ "quota.resetNextExpiryPrefix": "Next expiry: ",
708
+ "quota.resetReadyDesc": "Reset current window usage",
709
+ "quota.resetItemDefaultTitle": "Reset Credit",
710
+ "quota.resetUseBtn": "Use",
711
+ "quota.resetUsingBtn": "Using…",
712
+ "quota.resetConfirmPrompt": "Use this credit to reset current usage?",
713
+ "quota.resetConfirmYes": "Confirm",
714
+ "quota.resetConfirmNo": "Cancel",
715
+ "quota.resetNothingToReset": "Your usage does not need a reset right now",
716
+ "quota.resetNoCredit": "No reset credits available",
717
+ "quota.resetAlreadyRedeemed": "This reset credit was already redeemed"
511
718
  };
512
719
  const EMPTY_MODEL_STORE = {
513
720
  getSnapshot: () => null,
@@ -525,6 +732,25 @@ window.__ModuleLoader__.load({
525
732
  fill: "currentColor"
526
733
  }));
527
734
  }
735
+ function renderTicketIcon(size) {
736
+ return h("svg", {
737
+ className: "dsh-codex-reset-icon",
738
+ viewBox: "0 0 16 16",
739
+ width: size,
740
+ height: size,
741
+ fill: "none",
742
+ stroke: "currentColor",
743
+ strokeWidth: 1.5,
744
+ strokeLinecap: "round",
745
+ strokeLinejoin: "round",
746
+ "aria-hidden": true
747
+ }, h("path", { d: "M2 5a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1.6a1.4 1.4 0 0 0 0 2.8V11a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V9.4a1.4 1.4 0 0 0 0-2.8V5z" }), h("path", { d: "M6.5 4v8" }));
748
+ }
749
+ const REDEEM_OUTCOME_LOCALE_KEYS = {
750
+ nothing_to_reset: "quota.resetNothingToReset",
751
+ no_credit: "quota.resetNoCredit",
752
+ already_redeemed: "quota.resetAlreadyRedeemed"
753
+ };
528
754
  function CodexQuotaPill(props) {
529
755
  const { sessionId, quotaStore, getModelStore, useProjection, useSession, t, getLocale } = props;
530
756
  const modelStore = getModelStore(sessionId);
@@ -536,6 +762,8 @@ window.__ModuleLoader__.load({
536
762
  const isCodex = isCodexProvider(resolveActiveProvider(directorySnap, projectedSelection));
537
763
  const quotaSnap = React.useSyncExternalStore(quotaStore.subscribe, quotaStore.getSnapshot);
538
764
  const [open, setOpen] = React.useState(false);
765
+ const [detailsOpen, setDetailsOpen] = React.useState(true);
766
+ const [confirmingCreditKey, setConfirmingCreditKey] = React.useState(null);
539
767
  const rootRef = React.useRef(null);
540
768
  const prevRunningRef = React.useRef(running);
541
769
  const prevSessionIdRef = React.useRef(sessionId);
@@ -547,8 +775,8 @@ window.__ModuleLoader__.load({
547
775
  prevRunningRef.current = running;
548
776
  return;
549
777
  }
550
- quotaStore.refresh(false);
551
778
  if (sameSession && didTurnFinish(prevRunningRef.current, running)) quotaStore.refresh(true);
779
+ else if (!sameSession || !running || quotaStore.getSnapshot().quota === null) quotaStore.refresh(false);
552
780
  prevRunningRef.current = running;
553
781
  }, [
554
782
  isCodex,
@@ -556,6 +784,20 @@ window.__ModuleLoader__.load({
556
784
  sessionId,
557
785
  quotaStore
558
786
  ]);
787
+ React.useEffect(() => {
788
+ if (!isCodex || typeof globalThis.setInterval !== "function") return;
789
+ const intervalMs = running ? ACTIVE_QUOTA_SYNC_INTERVAL_MS : IDLE_QUOTA_SYNC_INTERVAL_MS;
790
+ const timer = globalThis.setInterval(() => {
791
+ quotaStore.refresh(false);
792
+ }, intervalMs);
793
+ return () => {
794
+ globalThis.clearInterval(timer);
795
+ };
796
+ }, [
797
+ isCodex,
798
+ running,
799
+ quotaStore
800
+ ]);
559
801
  React.useEffect(() => {
560
802
  if (!open || !isCodex || typeof globalThis.document === "undefined") return;
561
803
  const onPointerDown = (event) => {
@@ -595,6 +837,71 @@ window.__ModuleLoader__.load({
595
837
  const isFastMode = resolveSessionFastMode(quotaSnap, sessionId, parentSessionId);
596
838
  const secondaryReached = Boolean(quota?.secondary && quota.secondary.usedPercent >= 100);
597
839
  const primaryReached = Boolean(quota?.limitReached && !secondaryReached);
840
+ const resetCredits = quota?.resetCredits;
841
+ const availableResetCount = resetCredits?.availableCount ?? 0;
842
+ const applicableResetCount = resetCredits?.applicableAvailableCount ?? availableResetCount;
843
+ const nowSec = Math.floor(nowMs / 1e3);
844
+ const displayCredits = availableResetCount > 0 ? resetCredits?.credits && resetCredits.credits.length > 0 ? resetCredits.credits.map((item) => ({
845
+ id: item.id,
846
+ slotKey: item.id,
847
+ title: item.title,
848
+ expiresAt: item.expiresAt
849
+ })) : Array.from({ length: availableResetCount }, (_unused, idx) => ({
850
+ id: null,
851
+ slotKey: `placeholder-${idx}`,
852
+ title: null,
853
+ expiresAt: null
854
+ })) : [];
855
+ const renderResetCreditsSection = () => {
856
+ const firstCard = displayCredits[0];
857
+ const subDesc = availableResetCount === 0 ? t("quota.resetZeroDesc") : firstCard !== void 0 && firstCard.expiresAt !== null ? `${t("quota.resetNextExpiryPrefix")}${formatResetCreditExpiry(firstCard.expiresAt, nowMs, locale)}` : t("quota.resetReadyDesc");
858
+ return h("div", { className: "dsh-codex-reset-section" }, h("div", { className: "dsh-codex-reset-row" }, h("div", { className: "dsh-codex-fast-info" }, h("div", { className: "dsh-codex-fast-title" }, renderTicketIcon(12), h("span", null, t("quota.resetTitle"))), h("div", { className: "dsh-codex-fast-desc" }, subDesc)), availableResetCount === 0 ? h("span", {
859
+ className: "dsh-codex-reset-pill",
860
+ "data-zero": "true"
861
+ }, t("quota.resetZero")) : h("button", {
862
+ type: "button",
863
+ className: "dsh-codex-reset-pill",
864
+ "data-zero": "false",
865
+ "aria-expanded": detailsOpen,
866
+ onClick: () => {
867
+ setDetailsOpen(!detailsOpen);
868
+ setConfirmingCreditKey(null);
869
+ }
870
+ }, `${availableResetCount}${t("quota.resetAvailableSuffix")} ${detailsOpen ? "▴" : "▾"}`)), availableResetCount > 0 && detailsOpen ? h("div", { className: "dsh-codex-reset-list" }, ...displayCredits.map((item, idx) => {
871
+ const itemKey = item.slotKey;
872
+ const itemTitle = item.title ?? `${t("quota.resetItemDefaultTitle")} #${idx + 1}`;
873
+ const itemExpiry = formatResetCreditExpiry(item.expiresAt, nowMs, locale);
874
+ const isItemExpired = item.expiresAt !== null && item.expiresAt <= nowSec;
875
+ const isRedeemingAny = quotaSnap.redeeming === true;
876
+ const isRedeemingThis = isRedeemingAny && (item.id !== null ? quotaSnap.redeemingCreditId === item.id : quotaSnap.redeemingCreditId === null);
877
+ const isConfirming = confirmingCreditKey === itemKey;
878
+ return h("div", {
879
+ key: itemKey,
880
+ className: "dsh-codex-reset-card"
881
+ }, h("div", { className: "dsh-codex-reset-item" }, h("div", { className: "dsh-codex-reset-item-info" }, h("div", { className: "dsh-codex-reset-item-title" }, itemTitle), h("div", { className: "dsh-codex-reset-item-expiry" }, itemExpiry)), h("button", {
882
+ type: "button",
883
+ className: "dsh-codex-reset-use-btn",
884
+ disabled: applicableResetCount <= 0 || isRedeemingAny || isItemExpired,
885
+ onClick: () => {
886
+ setConfirmingCreditKey(isConfirming ? null : itemKey);
887
+ }
888
+ }, isRedeemingThis ? t("quota.resetUsingBtn") : t("quota.resetUseBtn"))), isConfirming ? h("div", { className: "dsh-codex-reset-confirm" }, h("span", { className: "dsh-codex-reset-confirm-text" }, t("quota.resetConfirmPrompt")), h("div", { className: "dsh-codex-reset-confirm-actions" }, h("button", {
889
+ type: "button",
890
+ className: "dsh-codex-reset-confirm-cancel",
891
+ onClick: () => {
892
+ setConfirmingCreditKey(null);
893
+ }
894
+ }, t("quota.resetConfirmNo")), h("button", {
895
+ type: "button",
896
+ className: "dsh-codex-reset-confirm-submit",
897
+ disabled: isRedeemingAny || isItemExpired,
898
+ onClick: async () => {
899
+ setConfirmingCreditKey(null);
900
+ await quotaStore.consumeResetCredit(item.id ?? void 0);
901
+ }
902
+ }, t("quota.resetConfirmYes")))) : null);
903
+ })) : null);
904
+ };
598
905
  return h("span", {
599
906
  ref: rootRef,
600
907
  className: "dsh-codex-quota-root",
@@ -609,9 +916,8 @@ window.__ModuleLoader__.load({
609
916
  "aria-expanded": open,
610
917
  title: `${t("quota.title")} (${pillText})`,
611
918
  onClick: () => {
612
- const nextOpen = !open;
613
- setOpen(nextOpen);
614
- if (nextOpen) quotaStore.refresh(false);
919
+ setConfirmingCreditKey(null);
920
+ setOpen(!open);
615
921
  }
616
922
  }, h("svg", {
617
923
  viewBox: "0 0 14 14",
@@ -644,7 +950,7 @@ window.__ModuleLoader__.load({
644
950
  onClick: () => {
645
951
  quotaStore.refresh(true);
646
952
  }
647
- }, quotaSnap.loading ? t("quota.refreshing") : t("quota.refresh"))), quota !== null ? renderWindowSection(formatWindowTitle(quota.primary.windowSeconds, locale, DEFAULT_PRIMARY_WINDOW_SECONDS), 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(formatWindowTitle(quota.secondary.windowSeconds, locale, DEFAULT_SECONDARY_WINDOW_SECONDS), quota.secondary, secondaryReached) : null, h("div", { className: "dsh-codex-fast-row" }, h("div", { className: "dsh-codex-fast-info" }, h("div", { className: "dsh-codex-fast-title" }, renderFastBolt(12), h("span", null, t("quota.fastTitle"))), h("div", { className: "dsh-codex-fast-desc" }, t("quota.fastDesc"))), h("button", {
953
+ }, quotaSnap.loading ? t("quota.refreshing") : t("quota.refresh"))), quota !== null ? renderWindowSection(formatWindowTitle(quota.primary.windowSeconds, locale, DEFAULT_PRIMARY_WINDOW_SECONDS), 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(formatWindowTitle(quota.secondary.windowSeconds, locale, DEFAULT_SECONDARY_WINDOW_SECONDS), quota.secondary, secondaryReached) : null, quota !== null ? renderResetCreditsSection() : null, h("div", { className: "dsh-codex-fast-row" }, h("div", { className: "dsh-codex-fast-info" }, h("div", { className: "dsh-codex-fast-title" }, renderFastBolt(12), h("span", null, t("quota.fastTitle"))), h("div", { className: "dsh-codex-fast-desc" }, t("quota.fastDesc"))), h("button", {
648
954
  type: "button",
649
955
  role: "switch",
650
956
  className: "dsh-codex-fast-switch",
@@ -654,7 +960,7 @@ window.__ModuleLoader__.load({
654
960
  onClick: () => {
655
961
  quotaStore.setFastMode(sessionId, !isFastMode, parentSessionId);
656
962
  }
657
- }, h("span", { className: "dsh-codex-fast-switch-thumb" }))), quotaSnap.error !== null ? h("div", { className: "dsh-codex-quota-error" }, quotaSnap.error) : null) : null);
963
+ }, h("span", { className: "dsh-codex-fast-switch-thumb" }))), quotaSnap.error !== null || quotaSnap.redeemNoticeOutcome != null ? h("div", { className: "dsh-codex-quota-error" }, quotaSnap.error !== null ? quotaSnap.error : t(REDEEM_OUTCOME_LOCALE_KEYS[quotaSnap.redeemNoticeOutcome])) : null) : null);
658
964
  }
659
965
  const inject = ["slots", "locale"];
660
966
  function apply(ctx) {
@@ -713,6 +1019,7 @@ window.__ModuleLoader__.load({
713
1019
  formatWindowTitle,
714
1020
  formatQuotaSummary,
715
1021
  formatResetCountdown,
1022
+ formatResetCreditExpiry,
716
1023
  createQuotaStore,
717
1024
  CodexQuotaPill
718
1025
  });
package/lib/index.d.ts CHANGED
@@ -132,27 +132,51 @@ interface CodexQuotaWindow {
132
132
  readonly windowSeconds?: number | null | undefined;
133
133
  readonly resetAt: number | null;
134
134
  }
135
+ interface CodexResetCreditItem {
136
+ readonly id: string;
137
+ readonly resetType: string;
138
+ readonly status: string;
139
+ readonly grantedAt: number | null;
140
+ readonly expiresAt: number | null;
141
+ readonly title: string | null;
142
+ readonly description: string | null;
143
+ }
144
+ interface CodexResetCreditsSnapshot {
145
+ readonly availableCount: number;
146
+ readonly applicableAvailableCount: number;
147
+ readonly credits: readonly CodexResetCreditItem[] | null;
148
+ }
135
149
  interface CodexQuotaSnapshot {
136
150
  readonly planType: string;
137
151
  readonly limitReached: boolean;
138
152
  readonly primary: CodexQuotaWindow;
139
153
  readonly secondary: CodexQuotaWindow | null;
154
+ readonly resetCredits?: CodexResetCreditsSnapshot | undefined;
140
155
  readonly fetchedAt: number;
141
156
  }
142
- type CodexQuotaResponse = {
143
- readonly ok: true;
144
- readonly quota: CodexQuotaSnapshot;
157
+ type ConsumeResetCreditOutcome = 'reset' | 'nothing_to_reset' | 'no_credit' | 'already_redeemed';
158
+ interface CodexRouteStateProjection {
145
159
  readonly defaultFastMode?: boolean | undefined;
146
160
  readonly sessionFastModes?: Readonly<Record<string, boolean>> | undefined;
147
161
  readonly sessionParents?: Readonly<Record<string, string>> | undefined;
148
- } | {
162
+ }
163
+ type CodexConsumeResetCreditResponse = ({
164
+ readonly ok: true;
165
+ readonly outcome: ConsumeResetCreditOutcome;
166
+ readonly quota?: CodexQuotaSnapshot | undefined;
167
+ } & CodexRouteStateProjection) | ({
149
168
  readonly ok: false;
150
169
  readonly error: string;
151
170
  readonly quota?: CodexQuotaSnapshot | undefined;
152
- readonly defaultFastMode?: boolean | undefined;
153
- readonly sessionFastModes?: Readonly<Record<string, boolean>> | undefined;
154
- readonly sessionParents?: Readonly<Record<string, string>> | undefined;
155
- };
171
+ } & CodexRouteStateProjection);
172
+ type CodexQuotaResponse = ({
173
+ readonly ok: true;
174
+ readonly quota: CodexQuotaSnapshot;
175
+ } & CodexRouteStateProjection) | ({
176
+ readonly ok: false;
177
+ readonly error: string;
178
+ readonly quota?: CodexQuotaSnapshot | undefined;
179
+ } & CodexRouteStateProjection);
156
180
  interface UsageFetcherOptions {
157
181
  readonly resolveCredentials: () => Promise<CodexResolvedCredentials>;
158
182
  readonly buildHeaders: (authHeaders: Record<string, string>) => Record<string, string>;
@@ -160,11 +184,22 @@ interface UsageFetcherOptions {
160
184
  readonly now?: (() => number) | undefined;
161
185
  readonly ttlMs?: number | undefined;
162
186
  readonly minForceIntervalMs?: number | undefined;
187
+ readonly autoRefresh?: boolean | undefined;
163
188
  }
189
+ declare function parseResetCreditsPayload(raw: unknown, nowMs?: number | undefined): {
190
+ readonly availableCount: number;
191
+ readonly credits: readonly CodexResetCreditItem[];
192
+ } | null;
164
193
  declare function parseUsagePayload(raw: unknown, fetchedAt: number): CodexQuotaSnapshot | null;
165
194
  declare function createUsageFetcher(options: UsageFetcherOptions): {
166
195
  getUsage: (force?: boolean) => Promise<CodexQuotaResponse>;
196
+ consumeResetCredit: (params?: {
197
+ readonly creditId?: string | undefined;
198
+ readonly redeemRequestId?: string | undefined;
199
+ }) => Promise<CodexConsumeResetCreditResponse>;
200
+ onRequestStart: () => void;
167
201
  invalidate: () => void;
202
+ dispose: () => void;
168
203
  };
169
204
  //#endregion
170
205
  //#region src/index.d.ts
@@ -199,7 +234,8 @@ export declare const __test: Readonly<{
199
234
  normalizeCodexSessionId: typeof normalizeCodexSessionId;
200
235
  toCodexWireSessionId: typeof toCodexWireSessionId;
201
236
  parseUsagePayload: typeof parseUsagePayload;
237
+ parseResetCreditsPayload: typeof parseResetCreditsPayload;
202
238
  createUsageFetcher: typeof createUsageFetcher;
203
239
  }>;
204
240
  //#endregion
205
- export type { CodexModelMetadata, CodexQuotaResponse, CodexQuotaSnapshot, CodexQuotaWindow, CodexResolvedCredentials, ParsedModelsCatalog, PluginContextLike, PluginLogLevel, RawModelsCache };
241
+ export type { CodexConsumeResetCreditResponse, CodexModelMetadata, CodexQuotaResponse, CodexQuotaSnapshot, CodexQuotaWindow, CodexResetCreditItem, CodexResetCreditsSnapshot, CodexResolvedCredentials, ConsumeResetCreditOutcome, ParsedModelsCatalog, PluginContextLike, PluginLogLevel, RawModelsCache };
package/lib/index.js CHANGED
@@ -361,6 +361,8 @@ function safeErrorMessage(error) {
361
361
  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);
362
362
  }
363
363
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
364
+ const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
365
+ const CODEX_RESET_CREDITS_CONSUME_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
364
366
  const USAGE_ROUTE_PATH = "/api/codex-subscription/usage";
365
367
  const DEFAULT_USAGE_TTL_MS = 6e4;
366
368
  const USAGE_FETCH_TIMEOUT_MS = 15e3;
@@ -385,6 +387,77 @@ function parseWindow(raw, fetchedAtMs) {
385
387
  resetAt
386
388
  };
387
389
  }
390
+ function parseTimestampSeconds(raw) {
391
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) return Math.round(raw);
392
+ if (typeof raw === "string") {
393
+ const trimmed = raw.trim();
394
+ if (trimmed === "") return null;
395
+ const parsedMs = Date.parse(trimmed);
396
+ if (Number.isFinite(parsedMs) && parsedMs > 0) return Math.floor(parsedMs / 1e3);
397
+ }
398
+ return null;
399
+ }
400
+ function nonEmptyString(raw) {
401
+ if (typeof raw !== "string") return null;
402
+ const trimmed = raw.trim();
403
+ return trimmed !== "" ? trimmed : null;
404
+ }
405
+ function parseResetCreditsPayload(raw, nowMs) {
406
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
407
+ const record = raw;
408
+ const rawList = Array.isArray(record.credits) ? record.credits : null;
409
+ if (rawList === null) return null;
410
+ const nowSec = typeof nowMs === "number" && Number.isFinite(nowMs) && nowMs > 0 ? Math.floor(nowMs / 1e3) : null;
411
+ const credits = [];
412
+ for (const entry of rawList) {
413
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
414
+ const item = entry;
415
+ const id = nonEmptyString(item.id);
416
+ if (id === null) continue;
417
+ const statusRaw = (nonEmptyString(item.status) ?? "available").toLowerCase();
418
+ if (statusRaw !== "available") continue;
419
+ const expiresAt = parseTimestampSeconds(item.expires_at);
420
+ if (nowSec !== null && expiresAt !== null && expiresAt <= nowSec) continue;
421
+ const resetType = nonEmptyString(item.reset_type) ?? "codex_rate_limits";
422
+ const grantedAt = parseTimestampSeconds(item.granted_at);
423
+ const title = nonEmptyString(item.title);
424
+ const description = nonEmptyString(item.description);
425
+ credits.push({
426
+ id,
427
+ resetType,
428
+ status: statusRaw,
429
+ grantedAt,
430
+ expiresAt,
431
+ title,
432
+ description
433
+ });
434
+ }
435
+ credits.sort((a, b) => {
436
+ if (a.expiresAt !== null && b.expiresAt !== null) {
437
+ if (a.expiresAt !== b.expiresAt) return a.expiresAt - b.expiresAt;
438
+ } else if (a.expiresAt !== null) return -1;
439
+ else if (b.expiresAt !== null) return 1;
440
+ return (a.grantedAt ?? 0) - (b.grantedAt ?? 0);
441
+ });
442
+ const availRaw = record.available_count;
443
+ return {
444
+ availableCount: rawList.length > 0 ? credits.length : typeof availRaw === "number" && Number.isFinite(availRaw) && availRaw >= 0 ? Math.round(availRaw) : 0,
445
+ credits
446
+ };
447
+ }
448
+ function parseUsageResetCreditsSummary(raw) {
449
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
450
+ const record = raw;
451
+ const availRaw = record.available_count;
452
+ if (typeof availRaw !== "number" || !Number.isFinite(availRaw) || availRaw < 0) return;
453
+ const availableCount = Math.round(availRaw);
454
+ const appRaw = record.applicable_available_count;
455
+ return {
456
+ availableCount,
457
+ applicableAvailableCount: typeof appRaw === "number" && Number.isFinite(appRaw) && appRaw >= 0 ? Math.min(availableCount, Math.round(appRaw)) : availableCount,
458
+ credits: availableCount === 0 ? [] : null
459
+ };
460
+ }
388
461
  function parseUsagePayload(raw, fetchedAt) {
389
462
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
390
463
  const record = raw;
@@ -394,53 +467,145 @@ function parseUsagePayload(raw, fetchedAt) {
394
467
  const primary = parseWindow(rateRecord.primary_window, fetchedAt);
395
468
  if (primary === null) return null;
396
469
  const secondary = parseWindow(rateRecord.secondary_window, fetchedAt);
470
+ const planType = typeof record.plan_type === "string" && record.plan_type.trim() !== "" ? record.plan_type.trim() : "plus";
471
+ const limitReached = rateRecord.limit_reached === true || rateRecord.allowed === false || primary.usedPercent >= 100 || secondary !== null && secondary.usedPercent >= 100;
472
+ const resetCredits = parseUsageResetCreditsSummary(record.rate_limit_reset_credits);
397
473
  return {
398
- planType: typeof record.plan_type === "string" && record.plan_type.trim() !== "" ? record.plan_type.trim() : "plus",
399
- limitReached: rateRecord.limit_reached === true || rateRecord.allowed === false || primary.usedPercent >= 100 || secondary !== null && secondary.usedPercent >= 100,
474
+ planType,
475
+ limitReached,
400
476
  primary,
401
477
  secondary,
478
+ ...resetCredits !== void 0 ? { resetCredits } : {},
402
479
  fetchedAt
403
480
  };
404
481
  }
482
+ function isWindowResetDue(snapshot, nowMs) {
483
+ const nowSec = nowMs / 1e3;
484
+ const fetchedSec = snapshot.fetchedAt / 1e3;
485
+ const primaryReset = snapshot.primary.resetAt;
486
+ if (primaryReset !== null && fetchedSec < primaryReset && nowSec >= primaryReset) return true;
487
+ const secondaryReset = snapshot.secondary?.resetAt ?? null;
488
+ if (secondaryReset !== null && fetchedSec < secondaryReset && nowSec >= secondaryReset) return true;
489
+ return false;
490
+ }
491
+ function normalizeConsumeOutcome(raw) {
492
+ if (typeof raw !== "string") return null;
493
+ switch (raw.trim()) {
494
+ case "reset":
495
+ case "nothing_to_reset":
496
+ case "no_credit":
497
+ case "already_redeemed": return raw.trim();
498
+ default: return null;
499
+ }
500
+ }
501
+ function generateRedeemRequestId(nowMs) {
502
+ const rand = Math.random().toString(16).slice(2, 14).padEnd(12, "0");
503
+ return `redeem-${nowMs.toString(16)}-${rand}`;
504
+ }
405
505
  function createUsageFetcher(options) {
406
- const fetchFn = options.fetchImpl ?? globalThis.fetch;
506
+ const getFetch = () => options.fetchImpl ?? globalThis.fetch;
407
507
  const nowFn = options.now ?? Date.now;
408
508
  const ttlMs = options.ttlMs ?? 6e4;
409
509
  const minForceIntervalMs = options.minForceIntervalMs ?? 5e3;
510
+ const autoRefresh = options.autoRefresh === true;
410
511
  let cached;
411
- let stale = false;
512
+ let lastAttemptAt;
513
+ let hasConsumer = false;
514
+ let dirtyRevision = 0;
515
+ let fetchedRevision = 0;
412
516
  let inflight;
517
+ let trailingTimer;
518
+ let disposed = false;
519
+ const isStale = () => dirtyRevision > fetchedRevision;
520
+ async function resolveAuthHeaders(extra) {
521
+ const creds = await options.resolveCredentials();
522
+ return options.buildHeaders({
523
+ Authorization: `Bearer ${creds.accessToken}`,
524
+ Accept: "application/json",
525
+ ...creds.accountId !== void 0 ? { "ChatGPT-Account-Id": creds.accountId } : {},
526
+ ...extra
527
+ });
528
+ }
529
+ function clearTrailingTimer() {
530
+ if (trailingTimer !== void 0) {
531
+ clearTimeout(trailingTimer);
532
+ trailingTimer = void 0;
533
+ }
534
+ }
535
+ function scheduleBackgroundRefresh(deferIfCached = false) {
536
+ if (!autoRefresh || !hasConsumer || disposed || !isStale() || inflight !== void 0) return;
537
+ const baseTime = cached?.fetchedAt ?? lastAttemptAt;
538
+ if (baseTime === void 0) {
539
+ runSingleFlight();
540
+ return;
541
+ }
542
+ const ageMs = Math.max(0, nowFn() - baseTime);
543
+ if (!deferIfCached && ageMs >= ttlMs) {
544
+ runSingleFlight();
545
+ return;
546
+ }
547
+ if (trailingTimer !== void 0) return;
548
+ const delayMs = deferIfCached ? Math.max(1, ageMs >= ttlMs ? ttlMs : ttlMs - ageMs) : Math.max(1, ttlMs - ageMs);
549
+ trailingTimer = setTimeout(() => {
550
+ trailingTimer = void 0;
551
+ if (disposed || !isStale()) return;
552
+ runSingleFlight();
553
+ }, delayMs);
554
+ trailingTimer.unref?.();
555
+ }
413
556
  async function fetchFresh() {
557
+ const revAtStart = dirtyRevision;
414
558
  try {
415
- const creds = await options.resolveCredentials();
416
- const headers = options.buildHeaders({
417
- Authorization: `Bearer ${creds.accessToken}`,
418
- Accept: "application/json",
419
- ...creds.accountId !== void 0 ? { "ChatGPT-Account-Id": creds.accountId } : {}
420
- });
559
+ const fetchFn = getFetch();
560
+ const headers = await resolveAuthHeaders();
421
561
  const response = await fetchFn(CODEX_USAGE_URL, {
422
562
  method: "GET",
423
563
  headers,
424
564
  signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS)
425
565
  });
566
+ const finishedAt = nowFn();
567
+ lastAttemptAt = finishedAt;
568
+ fetchedRevision = Math.max(fetchedRevision, revAtStart);
426
569
  if (!response.ok) return {
427
570
  ok: false,
428
571
  error: `Codex 限额查询失败 (HTTP ${response.status})`,
429
572
  ...cached !== void 0 ? { quota: cached } : {}
430
573
  };
431
- const parsed = parseUsagePayload(await response.json(), nowFn());
574
+ let parsed = parseUsagePayload(await response.json(), finishedAt);
432
575
  if (parsed === null) return {
433
576
  ok: false,
434
577
  error: "Codex 限额响应格式无法解析",
435
578
  ...cached !== void 0 ? { quota: cached } : {}
436
579
  };
580
+ if (parsed.resetCredits !== void 0 && parsed.resetCredits.availableCount > 0) try {
581
+ const detailRes = await fetchFn(CODEX_RESET_CREDITS_URL, {
582
+ method: "GET",
583
+ headers,
584
+ signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS)
585
+ });
586
+ if (detailRes.ok) {
587
+ const detailParsed = parseResetCreditsPayload(await detailRes.json(), finishedAt);
588
+ if (detailParsed !== null) {
589
+ const nextAvailable = detailParsed.availableCount;
590
+ parsed = {
591
+ ...parsed,
592
+ resetCredits: {
593
+ availableCount: nextAvailable,
594
+ applicableAvailableCount: Math.min(nextAvailable, parsed.resetCredits.applicableAvailableCount),
595
+ credits: detailParsed.credits
596
+ }
597
+ };
598
+ }
599
+ }
600
+ } catch {}
437
601
  cached = parsed;
438
- stale = false;
439
602
  return {
440
603
  ok: true,
441
604
  quota: parsed
442
605
  };
443
606
  } catch (error) {
607
+ lastAttemptAt = nowFn();
608
+ fetchedRevision = Math.max(fetchedRevision, revAtStart);
444
609
  return {
445
610
  ok: false,
446
611
  error: safeErrorMessage(error),
@@ -448,26 +613,98 @@ function createUsageFetcher(options) {
448
613
  };
449
614
  }
450
615
  }
616
+ function runSingleFlight() {
617
+ clearTrailingTimer();
618
+ if (!inflight) inflight = fetchFresh().finally(() => {
619
+ inflight = void 0;
620
+ if (autoRefresh && !disposed && isStale()) scheduleBackgroundRefresh();
621
+ });
622
+ return inflight;
623
+ }
451
624
  return {
625
+ onRequestStart() {
626
+ dirtyRevision += 1;
627
+ scheduleBackgroundRefresh(true);
628
+ },
452
629
  invalidate() {
453
- stale = true;
630
+ dirtyRevision += 1;
631
+ scheduleBackgroundRefresh(false);
454
632
  },
455
- async getUsage(force = false) {
456
- if (!stale && cached !== void 0) {
457
- const ageMs = nowFn() - cached.fetchedAt;
458
- if (!force && ageMs < ttlMs) return {
633
+ dispose() {
634
+ disposed = true;
635
+ clearTrailingTimer();
636
+ },
637
+ async consumeResetCredit(params) {
638
+ const creditId = nonEmptyString(params?.creditId) ?? void 0;
639
+ const redeemRequestId = nonEmptyString(params?.redeemRequestId) ?? generateRedeemRequestId(nowFn());
640
+ try {
641
+ const fetchFn = getFetch();
642
+ const headers = await resolveAuthHeaders({ "Content-Type": "application/json" });
643
+ const response = await fetchFn(CODEX_RESET_CREDITS_CONSUME_URL, {
644
+ method: "POST",
645
+ headers,
646
+ body: JSON.stringify({
647
+ redeem_request_id: redeemRequestId,
648
+ ...creditId !== void 0 ? { credit_id: creditId } : {}
649
+ }),
650
+ signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS)
651
+ });
652
+ if (!response.ok) return {
653
+ ok: false,
654
+ error: `Codex 重置卡核销失败 (HTTP ${response.status})`,
655
+ ...cached !== void 0 ? { quota: cached } : {}
656
+ };
657
+ const outcome = normalizeConsumeOutcome((await response.json())?.outcome);
658
+ if (outcome === null) return {
659
+ ok: false,
660
+ error: "Codex 重置卡核销响应格式无法解析",
661
+ ...cached !== void 0 ? { quota: cached } : {}
662
+ };
663
+ dirtyRevision += 1;
664
+ if (inflight !== void 0) await inflight;
665
+ const refreshed = isStale() ? await runSingleFlight() : cached !== void 0 ? {
459
666
  ok: true,
460
667
  quota: cached
668
+ } : await runSingleFlight();
669
+ const latestQuota = refreshed.ok ? refreshed.quota : cached;
670
+ return {
671
+ ok: true,
672
+ outcome,
673
+ ...latestQuota !== void 0 ? { quota: latestQuota } : {}
461
674
  };
462
- if (force && ageMs >= 0 && ageMs < minForceIntervalMs) return {
675
+ } catch (error) {
676
+ return {
677
+ ok: false,
678
+ error: safeErrorMessage(error),
679
+ ...cached !== void 0 ? { quota: cached } : {}
680
+ };
681
+ }
682
+ },
683
+ async getUsage(force = false) {
684
+ hasConsumer = true;
685
+ if (cached !== void 0) {
686
+ const nowMs = nowFn();
687
+ const ageMs = nowMs - cached.fetchedAt;
688
+ const stale = isStale();
689
+ const resetDue = isWindowResetDue(cached, nowMs);
690
+ if (!force) {
691
+ if (ageMs >= 0 && ageMs < ttlMs && (!stale || autoRefresh)) {
692
+ if (stale) scheduleBackgroundRefresh(false);
693
+ return {
694
+ ok: true,
695
+ quota: cached
696
+ };
697
+ }
698
+ if (autoRefresh && !stale && !resetDue) return {
699
+ ok: true,
700
+ quota: cached
701
+ };
702
+ } else if (!stale && ageMs >= 0 && ageMs < minForceIntervalMs) return {
463
703
  ok: true,
464
704
  quota: cached
465
705
  };
466
706
  }
467
- if (!inflight) inflight = fetchFresh().finally(() => {
468
- inflight = void 0;
469
- });
470
- return inflight;
707
+ return runSingleFlight();
471
708
  }
472
709
  };
473
710
  }
@@ -1101,7 +1338,7 @@ function apply(ctx, config = {}) {
1101
1338
  });
1102
1339
  function wrapOptions(catalog, model, options = {}) {
1103
1340
  const modelMeta = catalog.metadataById.get(model?.id);
1104
- const { temperature: _temperature, sessionId, onPayload, ...rest } = options;
1341
+ const { temperature: _temperature, sessionId, onPayload, onProviderStreamEvent, ...rest } = options;
1105
1342
  const initiatorId = (ctx.get?.("agents"))?.currentInitiator?.()?.id;
1106
1343
  const normalizedSessionId = normalizeCodexSessionId(sessionId) ?? normalizeCodexSessionId(initiatorId);
1107
1344
  const wireSessionId = toCodexWireSessionId(normalizedSessionId);
@@ -1117,12 +1354,18 @@ function apply(ctx, config = {}) {
1117
1354
  }),
1118
1355
  async onPayload(payload, requestModel) {
1119
1356
  return sanitizeCodexPayload(await onPayload?.(payload, requestModel) ?? payload, modelMeta, getEffectiveFastMode(normalizedSessionId));
1357
+ },
1358
+ async onProviderStreamEvent(event, eventModel) {
1359
+ await onProviderStreamEvent?.(event, eventModel);
1360
+ const eventType = event !== null && typeof event === "object" ? event.type : void 0;
1361
+ if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") usageFetcher.invalidate();
1120
1362
  }
1121
1363
  };
1122
1364
  }
1123
1365
  const usageFetcher = createUsageFetcher({
1124
1366
  resolveCredentials: () => authManager.resolveCredentials(),
1125
- buildHeaders: (authHeaders) => buildCodexHeaders(authHeaders, ensureSnapshot().catalog.clientVersion)
1367
+ buildHeaders: (authHeaders) => buildCodexHeaders(authHeaders, ensureSnapshot().catalog.clientVersion),
1368
+ autoRefresh: true
1126
1369
  });
1127
1370
  function createSnapshotPiProvider(snapshotCatalog) {
1128
1371
  return Object.freeze({
@@ -1135,11 +1378,11 @@ function apply(ctx, config = {}) {
1135
1378
  }),
1136
1379
  getModels: () => snapshotCatalog.models,
1137
1380
  stream: (model, context, options) => {
1138
- usageFetcher.invalidate();
1381
+ usageFetcher.onRequestStart();
1139
1382
  return baseProvider.stream(model, context, wrapOptions(snapshotCatalog, model, options));
1140
1383
  },
1141
1384
  streamSimple: (model, context, options) => {
1142
- usageFetcher.invalidate();
1385
+ usageFetcher.onRequestStart();
1143
1386
  return baseProvider.streamSimple(model, context, wrapOptions(snapshotCatalog, model, options));
1144
1387
  }
1145
1388
  });
@@ -1311,6 +1554,7 @@ function apply(ctx, config = {}) {
1311
1554
  return () => {
1312
1555
  watcherDisposed = true;
1313
1556
  closeModelsCacheWatchers();
1557
+ usageFetcher.dispose();
1314
1558
  };
1315
1559
  }, "codex-subscription: models_cache watcher");
1316
1560
  else {
@@ -1318,6 +1562,7 @@ function apply(ctx, config = {}) {
1318
1562
  ctx.on?.("dispose", () => {
1319
1563
  watcherDisposed = true;
1320
1564
  closeModelsCacheWatchers();
1565
+ usageFetcher.dispose();
1321
1566
  });
1322
1567
  }
1323
1568
  const mountUsageRoute = (connCtx) => {
@@ -1337,6 +1582,33 @@ function apply(ctx, config = {}) {
1337
1582
  body = null;
1338
1583
  }
1339
1584
  const record = body !== null && typeof body === "object" && !Array.isArray(body) ? body : null;
1585
+ if (record !== null && record.action === "consumeResetCredit") {
1586
+ if (record.creditId !== void 0 && typeof record.creditId !== "string" || record.redeemRequestId !== void 0 && typeof record.redeemRequestId !== "string") return new Response(JSON.stringify({
1587
+ ok: false,
1588
+ error: "Invalid request body: creditId and redeemRequestId must be strings when provided"
1589
+ }), {
1590
+ status: 400,
1591
+ headers: { "content-type": "application/json" }
1592
+ });
1593
+ const consumeResult = await usageFetcher.consumeResetCredit({
1594
+ creditId: record.creditId,
1595
+ redeemRequestId: record.redeemRequestId
1596
+ });
1597
+ appendPluginLog(getCodexHome(), ctx, consumeResult.ok ? "INFO" : "WARN", "reset_credit.consume", {
1598
+ creditId: record.creditId ?? null,
1599
+ ok: consumeResult.ok,
1600
+ ...consumeResult.ok ? { outcome: consumeResult.outcome } : { error: consumeResult.error }
1601
+ });
1602
+ return new Response(JSON.stringify({
1603
+ ...consumeResult,
1604
+ defaultFastMode: getConfigFastMode(),
1605
+ sessionFastModes: buildProjectedSessionFastModes(),
1606
+ sessionParents: Object.fromEntries(sessionParents)
1607
+ }), {
1608
+ status: 200,
1609
+ headers: { "content-type": "application/json" }
1610
+ });
1611
+ }
1340
1612
  if (record === null || typeof record.fastMode !== "boolean" || record.sessionId !== void 0 && typeof record.sessionId !== "string") return new Response(JSON.stringify({
1341
1613
  ok: false,
1342
1614
  error: "Invalid request body: boolean fastMode is required"
@@ -1439,6 +1711,7 @@ const __test = Object.freeze({
1439
1711
  normalizeCodexSessionId,
1440
1712
  toCodexWireSessionId,
1441
1713
  parseUsagePayload,
1714
+ parseResetCreditsPayload,
1442
1715
  createUsageFetcher
1443
1716
  });
1444
1717
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyogvce/dsh-codex-subscription",
3
- "version": "0.2.13",
3
+ "version": "0.3.1",
4
4
  "description": "复用本机 ~/.codex 订阅凭证与 models_cache.json 模型缓存,在 DSH 中通过 SSE 直连调用 Codex 模型。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",