@lyogvce/dsh-codex-subscription 0.2.12 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,8 +11,8 @@
11
11
  1. **与本机 Codex CLI / Desktop 100% 共享登录态**
12
12
  - 直接读取 `$CODEX_HOME/auth.json`(默认 `~/.codex/auth.json`)。
13
13
  - 每次请求前检查 `access_token` 的 JWT `exp`:距离过期不足 5 分钟时自动通过单飞锁(Single-flight)向 `https://auth.openai.com/oauth/token` 刷新,并通过 `realpathSync` + 临时文件 `rename` 原子写回 `auth.json`,保证与 Codex CLI 永不互踢下线。
14
- 2. **零网络轮询的本地模型目录发现、防降级保护与可观测性日志**
15
- - 直接读取 `~/.codex/models_cache.json`(每次按文件 `mtime` 与大小做微秒级 `stat` 校验,仅在文件变化时重读解析),提取 `visibility === "list"` 的模型及其 `context_window`、`supported_reasoning_levels`(自动将 `ultra` 映射为 DSH 的 `max` 档位)与 `default_verbosity`。
14
+ 2. **零网络轮询的本地模型目录实时监听、防降级保护与可观测性日志**
15
+ - 直接读取并监听 `~/.codex/models_cache.json`(同时结合 `realpathSync` 目录级防抖 `fs.watch` 实时监听与微秒级 `stat` 校验,兼容软链接目录与临时文件 `rename` 原子替换),提取 `visibility === "list"` 的模型及其 `context_window`、`supported_reasoning_levels`(自动将 `ultra` 映射为 DSH 的 `max` 档位)与 `default_verbosity`;当本地模型缓存新增或变更模型时,自动调用 `ctx.llm.emitAdaptersUpdated()` 通知 DSH 桌面端前端立即刷新模型下拉框,无需重启或刷新页面。
16
16
  - **防旧版客户端降级污染**:自动将高版本 `models_cache.json` 备份至 `~/.codex/cache/dsh-codex-subscription/models-cache-backup.json`;当外部旧版进程(如 `dsh-subagent-codex` 内置的 `0.153.4`)将 `models_cache.json` 降级覆写为不含新模型的旧缓存时,自动拦截降级并保留高版本模型目录。
17
17
  - **可观测性日志**:自动在 `~/.codex/cache/dsh-codex-subscription/plugin.log`(带 512KB 自动滚动截断,忽略纯 `fetched_at` 心跳刷新)记录目录加载(`catalog.load` / `catalog.snapshot`)、降级拦截(`catalog.downgrade_rejected`)、备份恢复(`catalog.backup_restored`)与凭证续期(`auth.refreshed` / `auth.refresh_failed`)事件。
18
18
  3. **支持按配置屏蔽模型(`disabledModels`)、自定义上下文窗口(`contextWindows`)与原生重试策略(`retryPolicy`)**
@@ -22,7 +22,7 @@
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
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 模型时自动隐藏。
25
+ - 当会话选中 `openai-codex` 模型时,在输入框右下角紧邻模型选择器左侧展示限额胶囊(根据 `wham/usage` 返回的 `limit_window_seconds` 自适应展示 Plus 的 `5h X% · 7d Y%` 双窗口或 Pro 的 `7d X%` 单窗口;由模型请求与流完成结算触发、按 60 秒限流在后台自动更新缓存,并由前端周期静默同步,无请求时零远端轮询,点击胶囊直接展开详情弹窗不触发额外请求),弹窗内可查看窗口进度条、重置倒计时与手动刷新按钮;切换至其他 Provider 模型时自动隐藏。
26
26
  - **⚡ 会话树级 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
27
 
28
28
  ---
package/lib/client.js CHANGED
@@ -171,7 +171,7 @@ window.__ModuleLoader__.load({
171
171
  function createQuotaStore(options = {}) {
172
172
  const fetchFn = options.fetchImpl ?? globalThis.fetch;
173
173
  const nowFn = options.now ?? Date.now;
174
- const ttlMs = options.ttlMs ?? 6e4;
174
+ const explicitTtlMs = options.ttlMs;
175
175
  let state = {
176
176
  quota: null,
177
177
  defaultFastMode: false,
@@ -273,15 +273,16 @@ window.__ModuleLoader__.load({
273
273
  };
274
274
  }
275
275
  async function refresh(force = false) {
276
- if (!force && state.quota !== null && nowFn() - state.quota.fetchedAt < ttlMs) return;
276
+ if (!force && explicitTtlMs !== void 0 && state.quota !== null && nowFn() - state.quota.fetchedAt < explicitTtlMs) return;
277
277
  if (inflight !== null && (!force || inflightForce)) return inflight;
278
278
  const seq = ++requestSeq;
279
279
  const canReplaceFastAtStart = pendingBySeq.size === 0;
280
280
  const fastEpochAtStart = fastMutationEpoch;
281
281
  inflightForce = force;
282
- setState({
282
+ const showLoading = force || state.quota === null;
283
+ if (showLoading || state.error !== null) setState({
283
284
  ...state,
284
- loading: true,
285
+ loading: showLoading ? true : state.loading,
285
286
  error: null
286
287
  });
287
288
  const url = force ? `${USAGE_ROUTE_PATH}?force=1` : USAGE_ROUTE_PATH;
@@ -489,6 +490,8 @@ window.__ModuleLoader__.load({
489
490
  const NS = "codex-subscription";
490
491
  const SLOT_NAME = "conversation.input.right";
491
492
  const SLOT_ENTRY_ID = "codex-subscription:quota";
493
+ const ACTIVE_QUOTA_SYNC_INTERVAL_MS = 5e3;
494
+ const IDLE_QUOTA_SYNC_INTERVAL_MS = 15e3;
492
495
  const zh = {
493
496
  "quota.title": "Codex 订阅限额",
494
497
  "quota.loading": "正在获取 Codex 限额…",
@@ -547,8 +550,8 @@ window.__ModuleLoader__.load({
547
550
  prevRunningRef.current = running;
548
551
  return;
549
552
  }
550
- quotaStore.refresh(false);
551
553
  if (sameSession && didTurnFinish(prevRunningRef.current, running)) quotaStore.refresh(true);
554
+ else if (!sameSession || !running || quotaStore.getSnapshot().quota === null) quotaStore.refresh(false);
552
555
  prevRunningRef.current = running;
553
556
  }, [
554
557
  isCodex,
@@ -556,6 +559,20 @@ window.__ModuleLoader__.load({
556
559
  sessionId,
557
560
  quotaStore
558
561
  ]);
562
+ React.useEffect(() => {
563
+ if (!isCodex || typeof globalThis.setInterval !== "function") return;
564
+ const intervalMs = running ? ACTIVE_QUOTA_SYNC_INTERVAL_MS : IDLE_QUOTA_SYNC_INTERVAL_MS;
565
+ const timer = globalThis.setInterval(() => {
566
+ quotaStore.refresh(false);
567
+ }, intervalMs);
568
+ return () => {
569
+ globalThis.clearInterval(timer);
570
+ };
571
+ }, [
572
+ isCodex,
573
+ running,
574
+ quotaStore
575
+ ]);
559
576
  React.useEffect(() => {
560
577
  if (!open || !isCodex || typeof globalThis.document === "undefined") return;
561
578
  const onPointerDown = (event) => {
@@ -609,9 +626,7 @@ window.__ModuleLoader__.load({
609
626
  "aria-expanded": open,
610
627
  title: `${t("quota.title")} (${pillText})`,
611
628
  onClick: () => {
612
- const nextOpen = !open;
613
- setOpen(nextOpen);
614
- if (nextOpen) quotaStore.refresh(false);
629
+ setOpen(!open);
615
630
  }
616
631
  }, h("svg", {
617
632
  viewBox: "0 0 14 14",
package/lib/index.d.ts CHANGED
@@ -160,17 +160,22 @@ interface UsageFetcherOptions {
160
160
  readonly now?: (() => number) | undefined;
161
161
  readonly ttlMs?: number | undefined;
162
162
  readonly minForceIntervalMs?: number | undefined;
163
+ readonly autoRefresh?: boolean | undefined;
163
164
  }
164
165
  declare function parseUsagePayload(raw: unknown, fetchedAt: number): CodexQuotaSnapshot | null;
165
166
  declare function createUsageFetcher(options: UsageFetcherOptions): {
166
167
  getUsage: (force?: boolean) => Promise<CodexQuotaResponse>;
168
+ onRequestStart: () => void;
167
169
  invalidate: () => void;
170
+ dispose: () => void;
168
171
  };
169
172
  //#endregion
170
173
  //#region src/index.d.ts
171
174
  export declare const name = "codex-subscription";
172
175
  export declare const inject: string[];
173
176
  export declare const LOG_FILE_NAME = "plugin.log";
177
+ export declare const MODELS_CACHE_FILE_NAME = "models_cache.json";
178
+ export declare const MODELS_CACHE_WATCH_DEBOUNCE_MS = 100;
174
179
  export declare const Config: any;
175
180
  export declare function apply(ctx: PluginContextLike, config?: Record<string, unknown>): void;
176
181
  export declare const __test: Readonly<{
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { appendFileSync, chmodSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { appendFileSync, chmodSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { LlmError, RetryPolicySchema, resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
@@ -402,15 +402,59 @@ function parseUsagePayload(raw, fetchedAt) {
402
402
  fetchedAt
403
403
  };
404
404
  }
405
+ function isWindowResetDue(snapshot, nowMs) {
406
+ const nowSec = nowMs / 1e3;
407
+ const fetchedSec = snapshot.fetchedAt / 1e3;
408
+ const primaryReset = snapshot.primary.resetAt;
409
+ if (primaryReset !== null && fetchedSec < primaryReset && nowSec >= primaryReset) return true;
410
+ const secondaryReset = snapshot.secondary?.resetAt ?? null;
411
+ if (secondaryReset !== null && fetchedSec < secondaryReset && nowSec >= secondaryReset) return true;
412
+ return false;
413
+ }
405
414
  function createUsageFetcher(options) {
406
415
  const fetchFn = options.fetchImpl ?? globalThis.fetch;
407
416
  const nowFn = options.now ?? Date.now;
408
417
  const ttlMs = options.ttlMs ?? 6e4;
409
418
  const minForceIntervalMs = options.minForceIntervalMs ?? 5e3;
419
+ const autoRefresh = options.autoRefresh === true;
410
420
  let cached;
411
- let stale = false;
421
+ let lastAttemptAt;
422
+ let hasConsumer = false;
423
+ let dirtyRevision = 0;
424
+ let fetchedRevision = 0;
412
425
  let inflight;
426
+ let trailingTimer;
427
+ let disposed = false;
428
+ const isStale = () => dirtyRevision > fetchedRevision;
429
+ function clearTrailingTimer() {
430
+ if (trailingTimer !== void 0) {
431
+ clearTimeout(trailingTimer);
432
+ trailingTimer = void 0;
433
+ }
434
+ }
435
+ function scheduleBackgroundRefresh(deferIfCached = false) {
436
+ if (!autoRefresh || !hasConsumer || disposed || !isStale() || inflight !== void 0) return;
437
+ const baseTime = cached?.fetchedAt ?? lastAttemptAt;
438
+ if (baseTime === void 0) {
439
+ runSingleFlight();
440
+ return;
441
+ }
442
+ const ageMs = Math.max(0, nowFn() - baseTime);
443
+ if (!deferIfCached && ageMs >= ttlMs) {
444
+ runSingleFlight();
445
+ return;
446
+ }
447
+ if (trailingTimer !== void 0) return;
448
+ const delayMs = deferIfCached ? Math.max(1, ageMs >= ttlMs ? ttlMs : ttlMs - ageMs) : Math.max(1, ttlMs - ageMs);
449
+ trailingTimer = setTimeout(() => {
450
+ trailingTimer = void 0;
451
+ if (disposed || !isStale()) return;
452
+ runSingleFlight();
453
+ }, delayMs);
454
+ trailingTimer.unref?.();
455
+ }
413
456
  async function fetchFresh() {
457
+ const revAtStart = dirtyRevision;
414
458
  try {
415
459
  const creds = await options.resolveCredentials();
416
460
  const headers = options.buildHeaders({
@@ -423,24 +467,28 @@ function createUsageFetcher(options) {
423
467
  headers,
424
468
  signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS)
425
469
  });
470
+ const finishedAt = nowFn();
471
+ lastAttemptAt = finishedAt;
472
+ fetchedRevision = Math.max(fetchedRevision, revAtStart);
426
473
  if (!response.ok) return {
427
474
  ok: false,
428
475
  error: `Codex 限额查询失败 (HTTP ${response.status})`,
429
476
  ...cached !== void 0 ? { quota: cached } : {}
430
477
  };
431
- const parsed = parseUsagePayload(await response.json(), nowFn());
478
+ const parsed = parseUsagePayload(await response.json(), finishedAt);
432
479
  if (parsed === null) return {
433
480
  ok: false,
434
481
  error: "Codex 限额响应格式无法解析",
435
482
  ...cached !== void 0 ? { quota: cached } : {}
436
483
  };
437
484
  cached = parsed;
438
- stale = false;
439
485
  return {
440
486
  ok: true,
441
487
  quota: parsed
442
488
  };
443
489
  } catch (error) {
490
+ lastAttemptAt = nowFn();
491
+ fetchedRevision = Math.max(fetchedRevision, revAtStart);
444
492
  return {
445
493
  ok: false,
446
494
  error: safeErrorMessage(error),
@@ -448,26 +496,52 @@ function createUsageFetcher(options) {
448
496
  };
449
497
  }
450
498
  }
499
+ function runSingleFlight() {
500
+ clearTrailingTimer();
501
+ if (!inflight) inflight = fetchFresh().finally(() => {
502
+ inflight = void 0;
503
+ if (autoRefresh && !disposed && isStale()) scheduleBackgroundRefresh();
504
+ });
505
+ return inflight;
506
+ }
451
507
  return {
508
+ onRequestStart() {
509
+ dirtyRevision += 1;
510
+ scheduleBackgroundRefresh(true);
511
+ },
452
512
  invalidate() {
453
- stale = true;
513
+ dirtyRevision += 1;
514
+ scheduleBackgroundRefresh(false);
515
+ },
516
+ dispose() {
517
+ disposed = true;
518
+ clearTrailingTimer();
454
519
  },
455
520
  async getUsage(force = false) {
456
- if (!stale && cached !== void 0) {
457
- const ageMs = nowFn() - cached.fetchedAt;
458
- if (!force && ageMs < ttlMs) return {
459
- ok: true,
460
- quota: cached
461
- };
462
- if (force && ageMs >= 0 && ageMs < minForceIntervalMs) return {
521
+ hasConsumer = true;
522
+ if (cached !== void 0) {
523
+ const nowMs = nowFn();
524
+ const ageMs = nowMs - cached.fetchedAt;
525
+ const stale = isStale();
526
+ const resetDue = isWindowResetDue(cached, nowMs);
527
+ if (!force) {
528
+ if (ageMs >= 0 && ageMs < ttlMs && (!stale || autoRefresh)) {
529
+ if (stale) scheduleBackgroundRefresh(false);
530
+ return {
531
+ ok: true,
532
+ quota: cached
533
+ };
534
+ }
535
+ if (autoRefresh && !stale && !resetDue) return {
536
+ ok: true,
537
+ quota: cached
538
+ };
539
+ } else if (!stale && ageMs >= 0 && ageMs < minForceIntervalMs) return {
463
540
  ok: true,
464
541
  quota: cached
465
542
  };
466
543
  }
467
- if (!inflight) inflight = fetchFresh().finally(() => {
468
- inflight = void 0;
469
- });
470
- return inflight;
544
+ return runSingleFlight();
471
545
  }
472
546
  };
473
547
  }
@@ -704,6 +778,8 @@ function createAuthManager(options) {
704
778
  const name = "codex-subscription";
705
779
  const inject = ["llm"];
706
780
  const LOG_FILE_NAME = "plugin.log";
781
+ const MODELS_CACHE_FILE_NAME = "models_cache.json";
782
+ const MODELS_CACHE_WATCH_DEBOUNCE_MS = 100;
707
783
  const STREAM_IDLE_TIMEOUT_MS = 3e5;
708
784
  const MAX_REQUEST_IMAGE_BYTES = 20971520;
709
785
  const REQUEST_IMAGE_PIXEL_BUDGET = 4194304;
@@ -711,6 +787,27 @@ const REQUEST_IMAGE_MAX_BYTES = 1048576;
711
787
  const MAX_LOG_FILE_BYTES = 524288;
712
788
  const MAX_PERSISTED_SESSIONS = 200;
713
789
  const MAX_PERSISTED_PARENTS = 1e3;
790
+ function resolveModelsCacheWatchTargets(codexHome) {
791
+ const dirs = new Set();
792
+ const fileNames = new Set([MODELS_CACHE_FILE_NAME]);
793
+ try {
794
+ dirs.add(realpathSync(codexHome));
795
+ } catch {
796
+ try {
797
+ if (statSync(codexHome).isDirectory()) dirs.add(codexHome);
798
+ } catch {}
799
+ }
800
+ const cachePath = join(codexHome, MODELS_CACHE_FILE_NAME);
801
+ try {
802
+ const realCachePath = realpathSync(cachePath);
803
+ dirs.add(dirname(realCachePath));
804
+ fileNames.add(basename(realCachePath));
805
+ } catch {}
806
+ return {
807
+ dirs: [...dirs],
808
+ fileNames
809
+ };
810
+ }
714
811
  function resolvePluginCacheDir(codexHome) {
715
812
  return join(codexHome, PLUGIN_CACHE_DIR_NAME);
716
813
  }
@@ -840,7 +937,8 @@ var CodexPiAiAdapter = class extends PiAiAdapter {
840
937
  function apply(ctx, config = {}) {
841
938
  const baseProvider = openaiCodexProvider();
842
939
  const baseModels = baseProvider.getModels();
843
- const getCodexHome = () => resolveCodexHome();
940
+ const boundCodexHome = resolveCodexHome();
941
+ const getCodexHome = () => boundCodexHome;
844
942
  const getConfigFastMode = () => readVolatile(config.fastMode, false) === true;
845
943
  const getDisabledModels = () => readVolatile(config.disabledModels, DEFAULT_DISABLED_MODELS);
846
944
  const getContextWindows = () => readVolatile(config.contextWindows, {});
@@ -993,7 +1091,77 @@ function apply(ctx, config = {}) {
993
1091
  let rawRevision = 0;
994
1092
  let activeRetryPolicy = resolveConfiguredRetryPolicy();
995
1093
  let currentState;
1094
+ let adapterRegistered = false;
996
1095
  const catalogByProfiles = new WeakMap();
1096
+ const activeWatchers = new Map();
1097
+ let watchedFileNames = new Set([MODELS_CACHE_FILE_NAME]);
1098
+ let watchDebounceTimer;
1099
+ let watcherDisposed = false;
1100
+ const closeModelsCacheWatchers = () => {
1101
+ if (watchDebounceTimer !== void 0) {
1102
+ clearTimeout(watchDebounceTimer);
1103
+ watchDebounceTimer = void 0;
1104
+ }
1105
+ for (const watcher of activeWatchers.values()) try {
1106
+ watcher.close();
1107
+ } catch {}
1108
+ activeWatchers.clear();
1109
+ };
1110
+ const scheduleModelsCacheCheck = () => {
1111
+ if (watcherDisposed) return;
1112
+ if (watchDebounceTimer !== void 0) clearTimeout(watchDebounceTimer);
1113
+ watchDebounceTimer = setTimeout(() => {
1114
+ watchDebounceTimer = void 0;
1115
+ if (watcherDisposed) return;
1116
+ try {
1117
+ if (!statSync(getCodexHome()).isDirectory()) {
1118
+ watcherDisposed = true;
1119
+ closeModelsCacheWatchers();
1120
+ return;
1121
+ }
1122
+ } catch {
1123
+ watcherDisposed = true;
1124
+ closeModelsCacheWatchers();
1125
+ return;
1126
+ }
1127
+ syncModelsCacheWatchers();
1128
+ ensureSnapshot();
1129
+ }, 100);
1130
+ watchDebounceTimer.unref?.();
1131
+ };
1132
+ const syncModelsCacheWatchers = () => {
1133
+ if (watcherDisposed) return;
1134
+ const { dirs, fileNames } = resolveModelsCacheWatchTargets(getCodexHome());
1135
+ watchedFileNames = fileNames;
1136
+ const nextDirSet = new Set(dirs);
1137
+ for (const [dir, watcher] of activeWatchers.entries()) if (!nextDirSet.has(dir)) {
1138
+ try {
1139
+ watcher.close();
1140
+ } catch {}
1141
+ activeWatchers.delete(dir);
1142
+ }
1143
+ for (const dir of nextDirSet) {
1144
+ if (activeWatchers.has(dir)) continue;
1145
+ try {
1146
+ const watcher = watch(dir, { persistent: false }, (_eventType, rawFilename) => {
1147
+ if (watcherDisposed) return;
1148
+ if (rawFilename != null) {
1149
+ const name = typeof rawFilename === "string" ? rawFilename : String(rawFilename);
1150
+ if (name !== "" && !watchedFileNames.has(name)) return;
1151
+ }
1152
+ scheduleModelsCacheCheck();
1153
+ });
1154
+ watcher.on("error", () => {
1155
+ try {
1156
+ watcher.close();
1157
+ } catch {}
1158
+ activeWatchers.delete(dir);
1159
+ });
1160
+ watcher.unref?.();
1161
+ activeWatchers.set(dir, watcher);
1162
+ } catch {}
1163
+ }
1164
+ };
997
1165
  const requestTokenAuth = Object.freeze({
998
1166
  name: "Codex CLI auth.json token",
999
1167
  async resolve({ credential }) {
@@ -1007,7 +1175,7 @@ function apply(ctx, config = {}) {
1007
1175
  });
1008
1176
  function wrapOptions(catalog, model, options = {}) {
1009
1177
  const modelMeta = catalog.metadataById.get(model?.id);
1010
- const { temperature: _temperature, sessionId, onPayload, ...rest } = options;
1178
+ const { temperature: _temperature, sessionId, onPayload, onProviderStreamEvent, ...rest } = options;
1011
1179
  const initiatorId = (ctx.get?.("agents"))?.currentInitiator?.()?.id;
1012
1180
  const normalizedSessionId = normalizeCodexSessionId(sessionId) ?? normalizeCodexSessionId(initiatorId);
1013
1181
  const wireSessionId = toCodexWireSessionId(normalizedSessionId);
@@ -1023,12 +1191,18 @@ function apply(ctx, config = {}) {
1023
1191
  }),
1024
1192
  async onPayload(payload, requestModel) {
1025
1193
  return sanitizeCodexPayload(await onPayload?.(payload, requestModel) ?? payload, modelMeta, getEffectiveFastMode(normalizedSessionId));
1194
+ },
1195
+ async onProviderStreamEvent(event, eventModel) {
1196
+ await onProviderStreamEvent?.(event, eventModel);
1197
+ const eventType = event !== null && typeof event === "object" ? event.type : void 0;
1198
+ if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") usageFetcher.invalidate();
1026
1199
  }
1027
1200
  };
1028
1201
  }
1029
1202
  const usageFetcher = createUsageFetcher({
1030
1203
  resolveCredentials: () => authManager.resolveCredentials(),
1031
- buildHeaders: (authHeaders) => buildCodexHeaders(authHeaders, ensureSnapshot().catalog.clientVersion)
1204
+ buildHeaders: (authHeaders) => buildCodexHeaders(authHeaders, ensureSnapshot().catalog.clientVersion),
1205
+ autoRefresh: true
1032
1206
  });
1033
1207
  function createSnapshotPiProvider(snapshotCatalog) {
1034
1208
  return Object.freeze({
@@ -1041,19 +1215,20 @@ function apply(ctx, config = {}) {
1041
1215
  }),
1042
1216
  getModels: () => snapshotCatalog.models,
1043
1217
  stream: (model, context, options) => {
1044
- usageFetcher.invalidate();
1218
+ usageFetcher.onRequestStart();
1045
1219
  return baseProvider.stream(model, context, wrapOptions(snapshotCatalog, model, options));
1046
1220
  },
1047
1221
  streamSimple: (model, context, options) => {
1048
- usageFetcher.invalidate();
1222
+ usageFetcher.onRequestStart();
1049
1223
  return baseProvider.streamSimple(model, context, wrapOptions(snapshotCatalog, model, options));
1050
1224
  }
1051
1225
  });
1052
1226
  }
1053
- function ensureSnapshot() {
1227
+ function ensureSnapshot(options) {
1228
+ const prevCatalogSig = currentState?.catalogSig;
1054
1229
  const retryPolicy = activeRetryPolicy;
1055
1230
  const codexHome = getCodexHome();
1056
- const cachePath = join(codexHome, "models_cache.json");
1231
+ const cachePath = join(codexHome, MODELS_CACHE_FILE_NAME);
1057
1232
  const disabled = getDisabledModels();
1058
1233
  const contextWindows = getContextWindows();
1059
1234
  const setActiveRaw = (next) => {
@@ -1172,6 +1347,11 @@ function apply(ctx, config = {}) {
1172
1347
  retryPolicySig
1173
1348
  });
1174
1349
  }
1350
+ if (adapterRegistered && !watcherDisposed) syncModelsCacheWatchers();
1351
+ if (adapterRegistered && options?.notifyAdaptersUpdated !== false && prevCatalogSig !== void 0 && nextCatalogSig !== prevCatalogSig) {
1352
+ committedState = currentState;
1353
+ ctx.llm.emitAdaptersUpdated?.();
1354
+ }
1175
1355
  return currentState;
1176
1356
  }
1177
1357
  const adapter = new CodexPiAiAdapter({
@@ -1202,8 +1382,26 @@ function apply(ctx, config = {}) {
1202
1382
  const snapProfiles = snapshot?.profiles;
1203
1383
  return ((snapProfiles !== void 0 ? catalogByProfiles.get(snapProfiles) : void 0) ?? ensureSnapshot().catalog).metadataById.get(modelId);
1204
1384
  });
1205
- let committedState = ensureSnapshot();
1385
+ let committedState = ensureSnapshot({ notifyAdaptersUpdated: false });
1206
1386
  const registration = ctx.llm.registerAdapter([PROVIDER], adapter);
1387
+ adapterRegistered = true;
1388
+ if (typeof ctx.effect === "function") ctx.effect(() => {
1389
+ watcherDisposed = false;
1390
+ syncModelsCacheWatchers();
1391
+ return () => {
1392
+ watcherDisposed = true;
1393
+ closeModelsCacheWatchers();
1394
+ usageFetcher.dispose();
1395
+ };
1396
+ }, "codex-subscription: models_cache watcher");
1397
+ else {
1398
+ syncModelsCacheWatchers();
1399
+ ctx.on?.("dispose", () => {
1400
+ watcherDisposed = true;
1401
+ closeModelsCacheWatchers();
1402
+ usageFetcher.dispose();
1403
+ });
1404
+ }
1207
1405
  const mountUsageRoute = (connCtx) => {
1208
1406
  const connection = connCtx.get?.("connection");
1209
1407
  const registerFetch = connection?.fetch?.register;
@@ -1284,7 +1482,7 @@ function apply(ctx, config = {}) {
1284
1482
  activeRetryPolicy = candidateRetryPolicy;
1285
1483
  let nextState;
1286
1484
  try {
1287
- nextState = ensureSnapshot();
1485
+ nextState = ensureSnapshot({ notifyAdaptersUpdated: false });
1288
1486
  if (nextState.retryPolicySig !== prevCommitted.retryPolicySig) {
1289
1487
  registration.replace([PROVIDER]);
1290
1488
  committedState = nextState;
@@ -1326,4 +1524,4 @@ const __test = Object.freeze({
1326
1524
  createUsageFetcher
1327
1525
  });
1328
1526
  //#endregion
1329
- export { BACKUP_CACHE_FILE_NAME, Config, LOG_FILE_NAME, PLUGIN_CACHE_DIR_NAME, RUNTIME_STATE_FILE_NAME, __test, apply, inject, name };
1527
+ export { BACKUP_CACHE_FILE_NAME, Config, LOG_FILE_NAME, MODELS_CACHE_FILE_NAME, MODELS_CACHE_WATCH_DEBOUNCE_MS, PLUGIN_CACHE_DIR_NAME, RUNTIME_STATE_FILE_NAME, __test, apply, inject, name };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyogvce/dsh-codex-subscription",
3
- "version": "0.2.12",
3
+ "version": "0.3.0",
4
4
  "description": "复用本机 ~/.codex 订阅凭证与 models_cache.json 模型缓存,在 DSH 中通过 SSE 直连调用 Codex 模型。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -50,17 +50,6 @@
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
- },
64
53
  "publishConfig": {
65
54
  "access": "public"
66
55
  },
@@ -71,5 +60,14 @@
71
60
  "codex",
72
61
  "openai-codex",
73
62
  "subscription"
74
- ]
75
- }
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
+ }