@lyogvce/dsh-codex-subscription 0.3.0 → 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`)**
24
+ 5. **Codex 订阅限额、限额重置卡(Rate Limit Reset Credits)与 ⚡ Fast 极速模式一键切换(`conversation.input.right`)**
25
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,8 +182,36 @@ 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
216
  const explicitTtlMs = options.ttlMs;
175
217
  let state = {
@@ -177,6 +219,9 @@ window.__ModuleLoader__.load({
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,6 +319,9 @@ 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
326
  if (!force && explicitTtlMs !== void 0 && state.quota !== null && nowFn() - state.quota.fetchedAt < explicitTtlMs) return;
277
327
  if (inflight !== null && (!force || inflightForce)) return inflight;
@@ -280,15 +330,16 @@ window.__ModuleLoader__.load({
280
330
  const fastEpochAtStart = fastMutationEpoch;
281
331
  inflightForce = force;
282
332
  const showLoading = force || state.quota === null;
283
- if (showLoading || state.error !== null) setState({
333
+ if (showLoading || state.error !== null || state.redeemNoticeOutcome !== null) setState({
284
334
  ...state,
285
335
  loading: showLoading ? true : state.loading,
336
+ redeemNoticeOutcome: null,
286
337
  error: null
287
338
  });
288
339
  const url = force ? `${USAGE_ROUTE_PATH}?force=1` : USAGE_ROUTE_PATH;
289
340
  const task = (async () => {
290
341
  try {
291
- const response = await fetchFn(url, {
342
+ const response = await getFetch()(url, {
292
343
  method: "GET",
293
344
  headers: { Accept: "application/json" }
294
345
  });
@@ -297,6 +348,7 @@ window.__ModuleLoader__.load({
297
348
  setState({
298
349
  ...state,
299
350
  loading: false,
351
+ redeemNoticeOutcome: null,
300
352
  error: `HTTP ${response.status}`
301
353
  });
302
354
  return;
@@ -305,18 +357,22 @@ window.__ModuleLoader__.load({
305
357
  if (seq !== requestSeq) return;
306
358
  const fastState = mergeFastStateFromPayload(payload, canReplaceFastAtStart && pendingBySeq.size === 0 && fastEpochAtStart === fastMutationEpoch);
307
359
  if (payload && payload.ok === true && payload.quota) setState({
360
+ ...state,
308
361
  quota: payload.quota,
309
362
  ...fastState,
310
363
  loading: false,
364
+ redeemNoticeOutcome: null,
311
365
  error: null
312
366
  });
313
367
  else {
314
368
  const fallbackQuota = payload && !payload.ok && payload.quota ? payload.quota : state.quota;
315
- const errMsg = payload && !payload.ok && typeof payload.error === "string" ? payload.error : "Invalid response";
369
+ const errMsg = extractPayloadError(payload && !payload.ok ? payload : null, "Invalid response");
316
370
  setState({
371
+ ...state,
317
372
  quota: fallbackQuota ?? null,
318
373
  ...fastState,
319
374
  loading: false,
375
+ redeemNoticeOutcome: null,
320
376
  error: errMsg
321
377
  });
322
378
  }
@@ -325,6 +381,7 @@ window.__ModuleLoader__.load({
325
381
  setState({
326
382
  ...state,
327
383
  loading: false,
384
+ redeemNoticeOutcome: null,
328
385
  error: safeErrorMessage(error)
329
386
  });
330
387
  } finally {
@@ -362,6 +419,7 @@ window.__ModuleLoader__.load({
362
419
  ...state,
363
420
  sessionFastModes: deriveSessionFastModes(),
364
421
  sessionParents: knownSessionParents,
422
+ redeemNoticeOutcome: null,
365
423
  error: errorMsg
366
424
  });
367
425
  };
@@ -369,10 +427,11 @@ window.__ModuleLoader__.load({
369
427
  ...state,
370
428
  sessionFastModes: deriveSessionFastModes(),
371
429
  sessionParents: knownSessionParents,
430
+ redeemNoticeOutcome: null,
372
431
  error: null
373
432
  });
374
433
  try {
375
- const response = await fetchFn(USAGE_ROUTE_PATH, {
434
+ const response = await getFetch()(USAGE_ROUTE_PATH, {
376
435
  method: "POST",
377
436
  headers: {
378
437
  "Content-Type": "application/json",
@@ -386,7 +445,7 @@ window.__ModuleLoader__.load({
386
445
  if (!response.ok) {
387
446
  const errPayload = await response.json().catch(() => null);
388
447
  mergeServerSessionParents(errPayload?.sessionParents);
389
- rollback(typeof errPayload?.error === "string" && errPayload.error !== "" ? errPayload.error : `HTTP ${response.status}`);
448
+ rollback(extractPayloadError(errPayload, `HTTP ${response.status}`));
390
449
  return;
391
450
  }
392
451
  const payload = await response.json();
@@ -396,7 +455,7 @@ window.__ModuleLoader__.load({
396
455
  return;
397
456
  }
398
457
  if (!payload || payload.ok === false) {
399
- rollback(typeof payload?.error === "string" && payload.error !== "" ? payload.error : "Invalid response");
458
+ rollback(extractPayloadError(payload, "Invalid response"));
400
459
  return;
401
460
  }
402
461
  fastMutationEpoch += 1;
@@ -422,6 +481,7 @@ window.__ModuleLoader__.load({
422
481
  defaultFastMode,
423
482
  sessionFastModes: deriveSessionFastModes(),
424
483
  sessionParents: knownSessionParents,
484
+ redeemNoticeOutcome: null,
425
485
  error: null
426
486
  });
427
487
  } catch (error) {
@@ -430,6 +490,99 @@ window.__ModuleLoader__.load({
430
490
  pendingBySeq.delete(seq);
431
491
  }
432
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
+ }
433
586
  return {
434
587
  getSnapshot: () => state,
435
588
  subscribe(listener) {
@@ -439,7 +592,8 @@ window.__ModuleLoader__.load({
439
592
  };
440
593
  },
441
594
  refresh,
442
- setFastMode
595
+ setFastMode,
596
+ consumeResetCredit
443
597
  };
444
598
  }
445
599
  //#endregion
@@ -481,6 +635,26 @@ window.__ModuleLoader__.load({
481
635
  ".dsh-codex-fast-switch[data-checked=\"true\"]{background:var(--dsw-alias-brand-primary,#3b82f6)}",
482
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}",
483
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}",
484
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}"
485
659
  ].join("");
486
660
  //#endregion
@@ -500,7 +674,22 @@ window.__ModuleLoader__.load({
500
674
  "quota.usedPrefix": "已用 ",
501
675
  "quota.remainPrefix": "剩余 ",
502
676
  "quota.fastTitle": "Fast 极速模式",
503
- "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": "该重置卡已被使用"
504
693
  };
505
694
  const en = {
506
695
  "quota.title": "Codex Subscription Quota",
@@ -510,7 +699,22 @@ window.__ModuleLoader__.load({
510
699
  "quota.usedPrefix": "Used ",
511
700
  "quota.remainPrefix": "Remaining ",
512
701
  "quota.fastTitle": "Fast Mode",
513
- "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"
514
718
  };
515
719
  const EMPTY_MODEL_STORE = {
516
720
  getSnapshot: () => null,
@@ -528,6 +732,25 @@ window.__ModuleLoader__.load({
528
732
  fill: "currentColor"
529
733
  }));
530
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
+ };
531
754
  function CodexQuotaPill(props) {
532
755
  const { sessionId, quotaStore, getModelStore, useProjection, useSession, t, getLocale } = props;
533
756
  const modelStore = getModelStore(sessionId);
@@ -539,6 +762,8 @@ window.__ModuleLoader__.load({
539
762
  const isCodex = isCodexProvider(resolveActiveProvider(directorySnap, projectedSelection));
540
763
  const quotaSnap = React.useSyncExternalStore(quotaStore.subscribe, quotaStore.getSnapshot);
541
764
  const [open, setOpen] = React.useState(false);
765
+ const [detailsOpen, setDetailsOpen] = React.useState(true);
766
+ const [confirmingCreditKey, setConfirmingCreditKey] = React.useState(null);
542
767
  const rootRef = React.useRef(null);
543
768
  const prevRunningRef = React.useRef(running);
544
769
  const prevSessionIdRef = React.useRef(sessionId);
@@ -612,6 +837,71 @@ window.__ModuleLoader__.load({
612
837
  const isFastMode = resolveSessionFastMode(quotaSnap, sessionId, parentSessionId);
613
838
  const secondaryReached = Boolean(quota?.secondary && quota.secondary.usedPercent >= 100);
614
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
+ };
615
905
  return h("span", {
616
906
  ref: rootRef,
617
907
  className: "dsh-codex-quota-root",
@@ -626,6 +916,7 @@ window.__ModuleLoader__.load({
626
916
  "aria-expanded": open,
627
917
  title: `${t("quota.title")} (${pillText})`,
628
918
  onClick: () => {
919
+ setConfirmingCreditKey(null);
629
920
  setOpen(!open);
630
921
  }
631
922
  }, h("svg", {
@@ -659,7 +950,7 @@ window.__ModuleLoader__.load({
659
950
  onClick: () => {
660
951
  quotaStore.refresh(true);
661
952
  }
662
- }, 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", {
663
954
  type: "button",
664
955
  role: "switch",
665
956
  className: "dsh-codex-fast-switch",
@@ -669,7 +960,7 @@ window.__ModuleLoader__.load({
669
960
  onClick: () => {
670
961
  quotaStore.setFastMode(sessionId, !isFastMode, parentSessionId);
671
962
  }
672
- }, 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);
673
964
  }
674
965
  const inject = ["slots", "locale"];
675
966
  function apply(ctx) {
@@ -728,6 +1019,7 @@ window.__ModuleLoader__.load({
728
1019
  formatWindowTitle,
729
1020
  formatQuotaSummary,
730
1021
  formatResetCountdown,
1022
+ formatResetCreditExpiry,
731
1023
  createQuotaStore,
732
1024
  CodexQuotaPill
733
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>;
@@ -162,9 +186,17 @@ interface UsageFetcherOptions {
162
186
  readonly minForceIntervalMs?: number | undefined;
163
187
  readonly autoRefresh?: boolean | undefined;
164
188
  }
189
+ declare function parseResetCreditsPayload(raw: unknown, nowMs?: number | undefined): {
190
+ readonly availableCount: number;
191
+ readonly credits: readonly CodexResetCreditItem[];
192
+ } | null;
165
193
  declare function parseUsagePayload(raw: unknown, fetchedAt: number): CodexQuotaSnapshot | null;
166
194
  declare function createUsageFetcher(options: UsageFetcherOptions): {
167
195
  getUsage: (force?: boolean) => Promise<CodexQuotaResponse>;
196
+ consumeResetCredit: (params?: {
197
+ readonly creditId?: string | undefined;
198
+ readonly redeemRequestId?: string | undefined;
199
+ }) => Promise<CodexConsumeResetCreditResponse>;
168
200
  onRequestStart: () => void;
169
201
  invalidate: () => void;
170
202
  dispose: () => void;
@@ -202,7 +234,8 @@ export declare const __test: Readonly<{
202
234
  normalizeCodexSessionId: typeof normalizeCodexSessionId;
203
235
  toCodexWireSessionId: typeof toCodexWireSessionId;
204
236
  parseUsagePayload: typeof parseUsagePayload;
237
+ parseResetCreditsPayload: typeof parseResetCreditsPayload;
205
238
  createUsageFetcher: typeof createUsageFetcher;
206
239
  }>;
207
240
  //#endregion
208
- 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,11 +467,15 @@ 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
  }
@@ -411,8 +488,22 @@ function isWindowResetDue(snapshot, nowMs) {
411
488
  if (secondaryReset !== null && fetchedSec < secondaryReset && nowSec >= secondaryReset) return true;
412
489
  return false;
413
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
+ }
414
505
  function createUsageFetcher(options) {
415
- const fetchFn = options.fetchImpl ?? globalThis.fetch;
506
+ const getFetch = () => options.fetchImpl ?? globalThis.fetch;
416
507
  const nowFn = options.now ?? Date.now;
417
508
  const ttlMs = options.ttlMs ?? 6e4;
418
509
  const minForceIntervalMs = options.minForceIntervalMs ?? 5e3;
@@ -426,6 +517,15 @@ function createUsageFetcher(options) {
426
517
  let trailingTimer;
427
518
  let disposed = false;
428
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
+ }
429
529
  function clearTrailingTimer() {
430
530
  if (trailingTimer !== void 0) {
431
531
  clearTimeout(trailingTimer);
@@ -456,12 +556,8 @@ function createUsageFetcher(options) {
456
556
  async function fetchFresh() {
457
557
  const revAtStart = dirtyRevision;
458
558
  try {
459
- const creds = await options.resolveCredentials();
460
- const headers = options.buildHeaders({
461
- Authorization: `Bearer ${creds.accessToken}`,
462
- Accept: "application/json",
463
- ...creds.accountId !== void 0 ? { "ChatGPT-Account-Id": creds.accountId } : {}
464
- });
559
+ const fetchFn = getFetch();
560
+ const headers = await resolveAuthHeaders();
465
561
  const response = await fetchFn(CODEX_USAGE_URL, {
466
562
  method: "GET",
467
563
  headers,
@@ -475,12 +571,33 @@ function createUsageFetcher(options) {
475
571
  error: `Codex 限额查询失败 (HTTP ${response.status})`,
476
572
  ...cached !== void 0 ? { quota: cached } : {}
477
573
  };
478
- const parsed = parseUsagePayload(await response.json(), finishedAt);
574
+ let parsed = parseUsagePayload(await response.json(), finishedAt);
479
575
  if (parsed === null) return {
480
576
  ok: false,
481
577
  error: "Codex 限额响应格式无法解析",
482
578
  ...cached !== void 0 ? { quota: cached } : {}
483
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 {}
484
601
  cached = parsed;
485
602
  return {
486
603
  ok: true,
@@ -517,6 +634,52 @@ function createUsageFetcher(options) {
517
634
  disposed = true;
518
635
  clearTrailingTimer();
519
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 ? {
666
+ ok: true,
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 } : {}
674
+ };
675
+ } catch (error) {
676
+ return {
677
+ ok: false,
678
+ error: safeErrorMessage(error),
679
+ ...cached !== void 0 ? { quota: cached } : {}
680
+ };
681
+ }
682
+ },
520
683
  async getUsage(force = false) {
521
684
  hasConsumer = true;
522
685
  if (cached !== void 0) {
@@ -1419,6 +1582,33 @@ function apply(ctx, config = {}) {
1419
1582
  body = null;
1420
1583
  }
1421
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
+ }
1422
1612
  if (record === null || typeof record.fastMode !== "boolean" || record.sessionId !== void 0 && typeof record.sessionId !== "string") return new Response(JSON.stringify({
1423
1613
  ok: false,
1424
1614
  error: "Invalid request body: boolean fastMode is required"
@@ -1521,6 +1711,7 @@ const __test = Object.freeze({
1521
1711
  normalizeCodexSessionId,
1522
1712
  toCodexWireSessionId,
1523
1713
  parseUsagePayload,
1714
+ parseResetCreditsPayload,
1524
1715
  createUsageFetcher
1525
1716
  });
1526
1717
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyogvce/dsh-codex-subscription",
3
- "version": "0.3.0",
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",
@@ -50,6 +50,17 @@
50
50
  "tsdown": "0.23.0",
51
51
  "typescript": "5.9.3"
52
52
  },
53
+ "scripts": {
54
+ "build": "tsdown",
55
+ "dev": "tsdown --watch",
56
+ "typecheck": "tsc --noEmit",
57
+ "pretest": "pnpm build",
58
+ "test": "node --test tests/*.test.mjs",
59
+ "test:isolation": "./tools/test-isolation.sh",
60
+ "verify": "pnpm typecheck && pnpm test && pnpm test:isolation",
61
+ "prepack": "pnpm build",
62
+ "prepublishOnly": "pnpm verify"
63
+ },
53
64
  "publishConfig": {
54
65
  "access": "public"
55
66
  },
@@ -60,14 +71,5 @@
60
71
  "codex",
61
72
  "openai-codex",
62
73
  "subscription"
63
- ],
64
- "scripts": {
65
- "build": "tsdown",
66
- "dev": "tsdown --watch",
67
- "typecheck": "tsc --noEmit",
68
- "pretest": "pnpm build",
69
- "test": "node --test tests/*.test.mjs",
70
- "test:isolation": "./tools/test-isolation.sh",
71
- "verify": "pnpm typecheck && pnpm test && pnpm test:isolation"
72
- }
73
- }
74
+ ]
75
+ }