dsh-connect-modelscope-token-plan 0.3.0 → 0.5.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/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { C as optional, D as verbatim, E as str, S as obj, T as redactSecrets$1, _ as hasHardQuotaWordingIn, a as resolveSettings, b as degrade, c as LLM_PROVIDER_ID, d as isVisionModel, f as normalizeEntry, h as textToImageModelIds, i as name, m as rosterWithAvailability, n as inject, p as resolveAllowedList, r as isAdmitted, s as LLM_DISPLAY_NAME, t as PLUGIN_VERSION, u as imageGenModelIds, v as looksLikeRateLimit, w as pluginError, x as errMsg } from "./host-config.js";
2
- import { join } from "node:path";
1
+ import { C as errMsg, D as redactSecrets, E as pluginError, O as str, S as degrade, T as optional, _ as parseAllowedList, a as resolveSettings, b as looksLikeRateLimit, c as LLM_PROVIDER_ID, d as imageGenModelIds, f as isVisionModel, g as enabledCountOf, h as rosterWithAvailability, i as name, k as verbatim, m as resolveDrawModelChoice, n as inject, p as normalizeEntry, r as isAdmitted, s as LLM_DISPLAY_NAME, t as PLUGIN_VERSION, u as drawCandidates, w as obj, y as hasHardQuotaWordingIn } from "./host-config.js";
2
+ import { basename, join } from "node:path";
3
3
  import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { homedir } from "node:os";
@@ -90,6 +90,44 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
90
90
  const resolveService = () => {
91
91
  return (typeof credentials === "function" ? credentials() : credentials) ?? null;
92
92
  };
93
+ /**
94
+ * 解析当前令牌与来源(实现体)。
95
+ *
96
+ * 提成局部函数而不是只做返回对象上的方法:`state()` 也要走同一条路,而方法里
97
+ * 调 `this.resolve()` 是一个**解构即崩**的 footgun——`const { state } =
98
+ * createTokenStore(...)` 之后 `this` 是 undefined,调用直接 TypeError。当前
99
+ * 调用点都规规矩矩用方法调用所以没炸,但那是运气,不是设计。返回类型显式
100
+ * 注解:这是快照 TokenStatus.source 联合类型的上游,靠推断会把字面量联合
101
+ * 拓宽成 string。
102
+ * @returns {Promise<{value: string, source: ("credentials"|"memory"|"env"|null)}>}
103
+ */
104
+ const resolveToken = async () => {
105
+ try {
106
+ const service = resolveService();
107
+ if (service !== null && typeof service.resolve === "function") {
108
+ const resolved = await service.resolve(TOKEN_REF).catch(() => void 0);
109
+ const value = verbatim(resolved?.value, "");
110
+ if (typeof value === "string" && value.trim() !== "") return {
111
+ value,
112
+ source: "credentials"
113
+ };
114
+ }
115
+ } catch {}
116
+ const held = memory.get(TOKEN_REF);
117
+ if (typeof held === "string" && held.trim() !== "") return {
118
+ value: held,
119
+ source: "memory"
120
+ };
121
+ const fromEnv = verbatim(env[TOKEN_REF], "");
122
+ if (typeof fromEnv === "string" && fromEnv.trim() !== "") return {
123
+ value: fromEnv,
124
+ source: "env"
125
+ };
126
+ return {
127
+ value: "",
128
+ source: null
129
+ };
130
+ };
93
131
  return {
94
132
  /**
95
133
  * 把面板输入的令牌存成引用;原样存(不 trim),空白值视为没输入。
@@ -107,7 +145,7 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
107
145
  async save(token) {
108
146
  const value = verbatim(token, "");
109
147
  if (typeof value !== "string" || value.trim() === "") throw new Error("a ModelScope token is required");
110
- if (!isPlausibleToken(value.trim())) throw new Error("that does not look like a ModelScope access token (expected ms- followed by hex digits)");
148
+ if (!isPlausibleToken(value.trim())) throw new Error("that does not look like a ModelScope access token (expected ms- followed by at least 16 hex digits); if you are sure the token is correct, this plugin's shape check may be out of date");
111
149
  const service = resolveService();
112
150
  if (service !== null && typeof service.set === "function") {
113
151
  await service.set(TOKEN_REF, value);
@@ -122,13 +160,18 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
122
160
  * 服务实现里 unset 抛错时,吞掉错误会让路由回 `ok: true` + `source: credentials`,
123
161
  * 而令牌**仍在 `~/.dsh/.credentials.yaml` 里并且仍被 resolve() 读回来**。
124
162
  * 「忘掉令牌」是一个安全动作,谎报成功比失败更糟——用户以为凭据已删干净。
125
- * 内存副本无论如何都先清掉(它才是本进程真正持有的那份)。
163
+ *
164
+ * **先 unset 成功、再删内存**:反过来的顺序有一个竞态——unset 抛错时内存
165
+ * 已删,下次 resolve() 会走 env 回退读回那个本该被删掉的令牌(如果 env
166
+ * 里也有 `MODELSCOPE_API_KEY`)。用户以为凭据已删干净,但它「复活」了。
167
+ * 现在 unset 失败时内存保留,resolve() 会读到内存里的旧值并报错(因为
168
+ * `source` 不是 `null`,但用户认为已经删了)——至少不会静默回退到 env。
126
169
  */
127
170
  async forget() {
128
- memory.delete(TOKEN_REF);
129
- lastCheck = null;
130
171
  const service = resolveService();
131
172
  if (service !== null && typeof service.unset === "function") await service.unset(TOKEN_REF);
173
+ memory.delete(TOKEN_REF);
174
+ lastCheck = null;
132
175
  },
133
176
  /**
134
177
  * 记录一次**零额度探针**的结论(probe 路由是唯一调用方)。
@@ -146,36 +189,11 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
146
189
  };
147
190
  },
148
191
  /**
149
- * 解析当前令牌与来源。返回类型显式注解:这是快照 TokenStatus.source
150
- * 联合类型的上游,靠推断会把字面量联合拓宽成 string。
192
+ * 解析当前令牌与来源。
151
193
  * @returns {Promise<{value: string, source: ("credentials"|"memory"|"env"|null)}>}
152
194
  */
153
195
  async resolve() {
154
- try {
155
- const service = resolveService();
156
- if (service !== null && typeof service.resolve === "function") {
157
- const resolved = await service.resolve(TOKEN_REF).catch(() => void 0);
158
- const value = verbatim(resolved?.value, "");
159
- if (typeof value === "string" && value.trim() !== "") return {
160
- value,
161
- source: "credentials"
162
- };
163
- }
164
- } catch {}
165
- const held = memory.get(TOKEN_REF);
166
- if (typeof held === "string" && held.trim() !== "") return {
167
- value: held,
168
- source: "memory"
169
- };
170
- const fromEnv = verbatim(env[TOKEN_REF], "");
171
- if (typeof fromEnv === "string" && fromEnv.trim() !== "") return {
172
- value: fromEnv,
173
- source: "env"
174
- };
175
- return {
176
- value: "",
177
- source: null
178
- };
196
+ return resolveToken();
179
197
  },
180
198
  /**
181
199
  * 无秘密的状态描述(快照与面板消费)。
@@ -192,7 +210,7 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
192
210
  * `memory` 才真的只在本次进程存活期间存在。
193
211
  */
194
212
  async state() {
195
- const { source } = await this.resolve();
213
+ const { source } = await resolveToken();
196
214
  return {
197
215
  present: source !== null,
198
216
  source: source === null ? "none" : source,
@@ -207,9 +225,11 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
207
225
  //#endregion
208
226
  //#region src/host/state-store.ts
209
227
  /**
210
- * 状态文件公共原语。**TL;DR**:本文件只做三件事——`dshHome`/`stateDir`(定位
211
- * 状态目录)、`profileSegment`(按 profile 分段路径)、以及 {@link createStateReadCache}
212
- * (统一读缓存)。其余一切遵守下方「行为约定」。
228
+ * 状态文件公共原语。**TL;DR**:本文件只做四件事——`dshHome`/`stateDir`(定位
229
+ * 状态目录)、`profileSegment`(按 profile 分段路径)、{@link createStateReadCache}
230
+ * (统一读缓存)、以及写侧三件套({@link createStateWriteQueue} 写串行化 /
231
+ * {@link guardStateWrite} ADR-006 写守卫 / {@link stateVersionNote} 读侧版本说明)。
232
+ * 其余一切遵守下方「行为约定」。
213
233
  *
214
234
  * 把本插件各 store(usage-store / provider / draw)各自手写的同一段
215
235
  * "版本载荷 + temp 文件 + rename 原子 + 0600 + 损坏即忽略" 收敛到这里。
@@ -234,6 +254,13 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
234
254
  * 2. 第二步收敛的是**读缓存**:provider / draw 早有 1s TTL,而 catalog 完全
235
255
  * 没有(进程内永不失效)——同一个「两个进程共享一个 state 目录」的问题修了
236
256
  * 两个、漏了第三个。现在统一走 {@link createStateReadCache},一个 TTL 三个调用方。
257
+ * 3. 第三步收敛的是**写侧三件套**。这三个 store 曾各自手写:provider 有
258
+ * writeChain、usage 有 writeChain、draw **两个都没有**(并发保存丢一次、
259
+ * 磁盘 version 比本构建新时降级覆写、读侧静默读作「没设过」——2026-10-17
260
+ * 实测复现,正是 provider-store 注释里记着「咬过三次」的那类漂移的第四条)。
261
+ * 现在三件套各有一份公共实现:串行化是{@link createStateWriteQueue}、写守卫是
262
+ * {@link guardStateWrite}、读侧说明是{@link stateVersionNote};provider /
263
+ * usage / draw 三个 store 都是调用方,不再各写一遍。
237
264
  *
238
265
  * @module dsh-connect-modelscope-token-plan/state-store
239
266
  */
@@ -605,11 +632,113 @@ async function readStateVersion(file) {
605
632
  function isKnownStateVersion(version, known) {
606
633
  return version === null || known.includes(version);
607
634
  }
635
+ /**
636
+ * 写串行化队列——store 的「读-改-写必须排队」的那把链。
637
+ *
638
+ * 为什么这不是某个 store 的私事:三个 store(provider / usage / draw)各写过
639
+ * 一份一模一样的 `let writeChain = Promise.resolve(...)`,而 draw 曾经**两份都
640
+ * 没有**——并发 `saveEnabled(true)` + `saveModelId(...)` 各自读到同一个 current,
641
+ * 后写的赢,前一次保存被整个丢掉(2026-10-17 实测:落盘只剩其中一个字段)。
642
+ * provider-store 的注释记着这类漂移「咬过三次」,draw 是第四条。抽到这里,
643
+ * 第四个 store 起只需调用,没有「再手写一遍」这个选项。
644
+ *
645
+ * 语义(与 provider / usage 此前的私有实现逐字一致):
646
+ * - `task` 在上一个任务落幕后才起动,临界区内重新读盘(基线不来自 TTL 缓存);
647
+ * - **调用方拿到的是 `run` 本身**——拒绝照旧传播(provider 的 `save()` 要抛给
648
+ * 路由、usage 的 `recordCall()` 也要抛),因为吞掉失败是调用方的语义决定;
649
+ * - 链自己吞掉失败继续走:一次只读 Home 上的写失败不该毒化后续所有保存。
650
+ *
651
+ * peer-free,与其余原语同纪律。
652
+ * @returns {{
653
+ * enqueue: <T>(task: () => Promise<T>) => Promise<T>
654
+ * }} 排队执行一次任务。
655
+ */
656
+ function createStateWriteQueue() {
657
+ let chain = Promise.resolve();
658
+ return { enqueue(task) {
659
+ const run = chain.then(task);
660
+ chain = run.then(() => void 0, () => void 0);
661
+ return run;
662
+ } };
663
+ }
664
+ /**
665
+ * ADR-006 写侧守卫:本构建读不懂的版本,不许覆盖。
666
+ *
667
+ * 磁盘上出现一个**本构建不认识的数字版本**,只可能是更新的构建写的;用一个旧
668
+ * 载荷盖掉它,销毁的是我们根本看不见的数据(实测过一次 version 2 → 1 的降级
669
+ * 覆写,110 次历史归零)。所以写入前先问盘,三种答案:
670
+ * - `null`(没有文件 / 没有版本 / 损坏)——没有什么可保护,写是恢复手段,放行;
671
+ * - 已知版本——放行;
672
+ * - 未知数字版本,或文件**在场却读不出来**(EACCES/EBUSY/EIO)——拒写。
673
+ * 后者由 {@link readStateVersion} 抛出来(它只把 ENOENT 读作「没有文件」),
674
+ * 这里接住并拒写:「读不到」不等于「没有东西要保护」。
675
+ *
676
+ * 拒写返回一句**已 degrade 记录**的原因(调用方决定要不要再抛给用户——provider
677
+ * 的 save 抛、draw 的 saveEnabled 抛、usage 的 recordCall 静默 no-op),而不是
678
+ * 静默 no-op: swallow the failure, not the reason。
679
+ *
680
+ * @param {string} file - 目标状态文件路径(消息里只取 basename)。
681
+ * @param {readonly number[]} known - 本构建读得懂的版本集合。
682
+ * @param {{label: string, logger?: {warn?: (m: string) => void}|undefined}} options -
683
+ * `label` 进拒绝消息的前缀(如 `"provider"` / `"draw-state"`)。`logger` 的
684
+ * `| undefined` 不是冗余:tsconfig 开了 `exactOptionalPropertyTypes`,而各 store
685
+ * 解构出来的 `logger` 本身就带着 `undefined`(属性可选),不写这一半编译不过。
686
+ * @returns {Promise<string|null>} 拒绝原因(已记日志),放行时为 `null`。
687
+ */
688
+ async function guardStateWrite(file, known, options) {
689
+ const base = basename(file);
690
+ let existing;
691
+ try {
692
+ existing = await readStateVersion(file);
693
+ } catch (error) {
694
+ const reason = `${options.label}: refusing to overwrite ${base} (cannot read it: ${redactSecrets(errMsg(error))})`;
695
+ degrade(reason, null, options.logger, null);
696
+ return reason;
697
+ }
698
+ if (!isKnownStateVersion(existing, known)) {
699
+ const reason = `${options.label}: refusing to overwrite ${base} holding version ${existing} (this build knows ${known.join("/")})`;
700
+ degrade(reason, null, options.logger, null);
701
+ return reason;
702
+ }
703
+ return null;
704
+ }
705
+ /**
706
+ * 读侧版本说明:磁盘上那份文件带着本构建读不懂的版本时,给操作者的一句话。
707
+ *
708
+ * 为什么读侧宽容还要说话:写侧守卫挡住了覆盖,读侧按设计把未知版本**宽容地**
709
+ * 读作「没设过」——于是「保存的值被忽略」在面板上呈现成「从未保存过」。对
710
+ * provider 的允许清单,这两者读出的方向是危险的(空清单 = 不过滤 = 提供全部
711
+ * 模型);对 draw 的开关,回退方向是 patch 默认(通常 = 关)。闸与说明是同一条
712
+ * 承诺的两半,缺了哪半都静默。
713
+ *
714
+ * `consequence` 由调用方给:每个 store 知道自己「读作没设过」意味着什么,公共
715
+ * 原语只负责把文件名、版本号、已知版本集合与下一步动作说全。
716
+ *
717
+ * @param {object} options
718
+ * @param {string} options.label - 消息前缀(如 `"provider-state"`)。
719
+ * @param {string} options.file - 状态文件路径(消息里只取 basename)。
720
+ * @param {number} options.version - 盘上的版本号(调用方已判过是未知的)。
721
+ * @param {readonly number[]} options.known - 本构建读得懂的版本集合。
722
+ * @param {string} options.consequence - 「读作没设过」对这个 store 意味着什么。
723
+ * @returns {string} 人读的一句警告。
724
+ */
725
+ function stateVersionNote(options) {
726
+ return `${options.label}: ${basename(options.file)} holds version ${options.version}, which this build cannot read (this build knows ${options.known.join("/")}); ${options.consequence} Upgrade this build to see the saved values, or delete the file to reset them.`;
727
+ }
608
728
 
609
729
  //#endregion
610
- //#region src/host/codes.ts
730
+ //#region src/shared/codes.ts
611
731
  /**
612
- * 错误分类的单一真源(对齐姊妹插件的 codes.ts 纪律)。
732
+ * 错误分类码表的**单一真源**——Host 与 Client 同读这一份。
733
+ *
734
+ * 为什么从 `host/codes.ts` 搬来:这张表曾经在 Client 侧有一份「刻意副本」
735
+ * (`client/snapshot.ts` 的 `CLIENT_CODE`),理由是「浏览器无法 import Host
736
+ * 模块」。那个理由已经过期——`shared/wire.ts` 的运行时值(如
737
+ * `SNAPSHOT_REQUIRED_KEYS`)早就在双端 import,浏览器 bundle 拿得到 shared/
738
+ * 下的任何模块。两份表必然漂移,而漂移的后果是最需要人看的内部错误没有引导
739
+ * 文案(INTERNAL_ERROR 当初就是这么漏的)。码表住 shared,Host 的分类函数
740
+ * (`classifyStatus` / `classifyRateLimit` / `parseRetryAfterMs`)留在
741
+ * `host/codes.ts`——那些读上游响应,是 Host 独有的行为。
613
742
  *
614
743
  * 魔搭的分类现实(SPIKE.md):上游不带任何额度头,额度耗尽与每分钟限频都只
615
744
  * 会以 429 + 文案出现,所以 429 的分诊是**按 body 文案**做的保守二分——
@@ -619,8 +748,8 @@ function isKnownStateVersion(version, known) {
619
748
  * @module dsh-connect-modelscope-token-plan/codes
620
749
  */
621
750
  /**
622
- * 面板按它分流的稳定码表 —— Client 的 `GUIDANCE_BY_CODE` 以这份表的键为契约,
623
- * 新增/改名必须同步 Client,否则内部错误没有引导文案(见下 `INTERNAL_ERROR`)。
751
+ * 面板按它分流的稳定码表 —— Client 的 `GUIDANCE_BY_CODE` 以这份表的键为契约
752
+ * (且以 `CodeValue` 为**类型**契约:缺键是编译错误,不再只靠测试红)。
624
753
  */
625
754
  const CODE = Object.freeze({
626
755
  CONFIG_ERROR: "config_error",
@@ -643,12 +772,26 @@ const CODE = Object.freeze({
643
772
  * Client 自产码:Host 响应无法解读成快照(非 JSON / 缺 ok 字段)时,
644
773
  * 面板用它走引导文案,而不是把裸英文串("unexpected payload")上屏。
645
774
  *
646
- * 本表是 `GUIDANCE_BY_CODE` 的键集真源(panel.test §2 钉「每个键必须在
647
- * CODE 里」、§3c 钉「每个码必须有引导」)——新增 client 码必须同时进
775
+ * 本表是 `GUIDANCE_BY_CODE` 的键集真源——新增 client 码必须同时进
648
776
  * 这张表,两侧才同步。
649
777
  */
650
778
  PAYLOAD_ERROR: "payload_error"
651
779
  });
780
+
781
+ //#endregion
782
+ //#region src/host/codes.ts
783
+ /**
784
+ * Host 侧的 429 分诊分类函数。**码表本身已迁至 `shared/codes.ts`**(双端同读,
785
+ * 见那里的搬迁理由);本文件只剩「读上游响应」的 Host 独有行为,并把 CODE
786
+ * 再导出一次,让 Host 各模块的 `from "./codes.ts"` 导入路径不变。
787
+ *
788
+ * 魔搭的分类现实(SPIKE.md):上游不带任何额度头,额度耗尽与每分钟限频都只
789
+ * 会以 429 + 文案出现,所以 429 的分诊是**按 body 文案**做的保守二分——
790
+ * QUOTA_EXCEEDED 快速失败不重试,RATE_LIMITED 交给调用方退避。分不清时归
791
+ * RATE_LIMITED(退避是两方向里更安全的那个)。
792
+ *
793
+ * @module dsh-connect-modelscope-token-plan/codes
794
+ */
652
795
  /**
653
796
  * 把上游 HTTP 状态归类成稳定码。
654
797
  *
@@ -690,11 +833,7 @@ function classifyStatus(status) {
690
833
  function classifyRateLimit(message) {
691
834
  if (typeof message !== "string" || message.length === 0) return "rate_limit";
692
835
  if (looksLikeRateLimit(message)) return "rate_limit";
693
- return hasHardQuotaWording(message) ? "quota" : "rate_limit";
694
- }
695
- /** 文本是否含硬额度措辞(真耗尽,不纠正)——直接问 llm-error-fix 的那张表。 */
696
- function hasHardQuotaWording(message) {
697
- return hasHardQuotaWordingIn(message);
836
+ return hasHardQuotaWordingIn(message) ? "quota" : "rate_limit";
698
837
  }
699
838
  /**
700
839
  * 解析 Retry-After(秒或 HTTP 日期)与平台文案里写明的等待时长。
@@ -838,13 +977,14 @@ function parsePayload(raw) {
838
977
  * @param {object} options
839
978
  * @param {string} options.name - 插件 slug(状态目录名,来自 host-config)。
840
979
  * @param {string|null} [options.profile] - profile 名;null = 共享目录。
841
- * @param {number} [options.trendDays] - 天桶保留数(超出修剪;从配置注入,
842
- * 让「配置改小」在下次写入时生效,而不是等一个固定的内部常数)。
980
+ * @param {number} [options.retentionDays] - 天桶**保留**数(超出即修剪;从配置注入,
981
+ * 让「配置改小」在下次写入时生效,而不是等一个固定的内部常数)。与「显示多少天」
982
+ * 是两件事——显示窗口由调用方传给 `trend(days)`,保留窗口恒 ≥ 它(host-config 夹)。
843
983
  * @param {number} [options.maxEvents] - 事件流上限。
844
984
  * @param {() => Date} [options.now] - 时钟源;测试注入。
845
985
  * @param {{warn?: (message: string) => void}} [options.logger] - ctx.logger。
846
986
  */
847
- function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents = 50, now = () => /* @__PURE__ */ new Date(), logger }) {
987
+ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEvents = 50, now = () => /* @__PURE__ */ new Date(), logger }) {
848
988
  const dir = profileStateDir(name, profile);
849
989
  const file = join(dir, "usage.json");
850
990
  /** 最近一次解析异常(损坏即忽略的痕迹),快照转 shapeWarnings。 */
@@ -859,8 +999,10 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
859
999
  events: []
860
1000
  };
861
1001
  let loaded = false;
862
- /** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。 */
863
- let writeChain = Promise.resolve();
1002
+ /** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。
1003
+ 队列实现在 state-store({@link createStateWriteQueue})——provider / draw
1004
+ 同款,三份私有副本正是 draw-store 曾经三件全缺的入口。 */
1005
+ const writeQueue = createStateWriteQueue();
864
1006
  /**
865
1007
  * 磁盘版本闸 —— 读路径与写路径**各自**调用,不共享副作用。
866
1008
  *
@@ -880,7 +1022,7 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
880
1022
  try {
881
1023
  onDisk = await readStateVersion(file);
882
1024
  } catch (error) {
883
- const note = `usage-state: usage.json exists but cannot be read (${redactSecrets$1(errMsg(error))})`;
1025
+ const note = `usage-state: usage.json exists but cannot be read (${redactSecrets(errMsg(error))})`;
884
1026
  if (!readOnly) logger?.warn?.(`${name}: ${note} — recording disabled to avoid clobbering`);
885
1027
  anomaly = note;
886
1028
  readOnly = true;
@@ -915,26 +1057,22 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
915
1057
  cache.remember(payload);
916
1058
  };
917
1059
  /** 排队执行一次读-改-写;只读闸置位时整个变更是 no-op。 */
918
- const enqueue = (mutate) => {
919
- const run = writeChain.then(async () => {
920
- if (await diskVersionBlocksWrites()) return;
921
- const payload = await readThrough() ?? (loaded ? current : null) ?? {
922
- version: STATE_VERSION,
923
- days: {},
924
- models: {},
925
- events: []
926
- };
927
- mutate(payload);
928
- current = payload;
929
- loaded = true;
930
- await persist(payload);
931
- });
932
- writeChain = run.catch(() => {});
933
- return run;
934
- };
935
- /** 修剪:只留最近 trendDays 个天桶(按 dateKey 排序,今天不删)。 */
1060
+ const enqueue = (mutate) => writeQueue.enqueue(async () => {
1061
+ if (await diskVersionBlocksWrites()) return;
1062
+ const payload = await readThrough() ?? (loaded ? current : null) ?? {
1063
+ version: STATE_VERSION,
1064
+ days: {},
1065
+ models: {},
1066
+ events: []
1067
+ };
1068
+ mutate(payload);
1069
+ current = payload;
1070
+ loaded = true;
1071
+ await persist(payload);
1072
+ });
1073
+ /** 修剪:只留最近 retentionDays 个天桶(按 dateKey 排序,今天不删)。 */
936
1074
  const pruneDays = (payload, todayKey) => {
937
- const keep = new Set(Object.keys(payload.days).sort().slice(-Math.max(1, trendDays)));
1075
+ const keep = new Set(Object.keys(payload.days).sort().slice(-Math.max(1, retentionDays)));
938
1076
  keep.add(todayKey);
939
1077
  for (const key of Object.keys(payload.days)) if (!keep.has(key)) delete payload.days[key];
940
1078
  };
@@ -1047,7 +1185,7 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
1047
1185
  return out;
1048
1186
  },
1049
1187
  /** 最近 N 天趋势(含今天;缺桶 = 该日 0 次,真实零不是数据丢失)。 */
1050
- async trend(days = trendDays) {
1188
+ async trend(days = retentionDays) {
1051
1189
  const payload = await cache.read();
1052
1190
  const out = [];
1053
1191
  const cursor = now();
@@ -1275,6 +1413,39 @@ function createCoalescedFetch(options = {}) {
1275
1413
  * 目录声明过窗口的条目不进打标,策展表永不推翻平台声明。
1276
1414
  * 4. 生图/语音模型(Qwen-Image-Edit、ACE-Step 等)无对话上下文,不收。
1277
1415
  */
1416
+ /**
1417
+ * 本表最后一次与 models.dev / 真机目录核对的日期(ISO 日期,UTC)。
1418
+ *
1419
+ * 存在的理由:策展表是**静态知识**,而目录会上下架。维护纪律要求「目录下架的条目
1420
+ * 应随手删除」,但没有任何机制会自动知道下架发生了——没有这个日期,表会悄悄腐烂,
1421
+ * 而使用者无从判断一个值是三个月前还是三年前核的。快照侧据此在表过期时给一条
1422
+ * shapeWarning(见 {@link curatedCatalogStale})。
1423
+ */
1424
+ const CURATED_AS_OF = "2026-10-16";
1425
+ /**
1426
+ * 策展表距上次核对已经过去多少天;日期解析不了返回 `null`。
1427
+ * @param {Date} [now] - 时钟源(测试注入)。
1428
+ * @returns {number|null} 天数(向下取整),或 null。
1429
+ */
1430
+ function curatedCatalogAgeDays(now = /* @__PURE__ */ new Date()) {
1431
+ const asOf = Date.parse("2026-10-16T00:00:00Z");
1432
+ if (Number.isNaN(asOf)) return null;
1433
+ const delta = now.getTime() - asOf;
1434
+ return delta < 0 ? 0 : Math.floor(delta / 864e5);
1435
+ }
1436
+ /**
1437
+ * 策展表是否该被复核了:距 {@link CURATED_AS_OF} 超过 `maxAgeDays` 天。
1438
+ *
1439
+ * 只回答「该提醒了吗」,不回答「表里的值错了」——过期的表往往仍然正确,只是
1440
+ * 不再有核对背书。快照把它转成一条 shapeWarning,而不是拒绝使用表里的值。
1441
+ * @param {Date} [now] - 时钟源(测试注入)。
1442
+ * @param {number} [maxAgeDays] - 容忍的天数,默认 180。
1443
+ * @returns {boolean}
1444
+ */
1445
+ function curatedCatalogStale(now = /* @__PURE__ */ new Date(), maxAgeDays = 180) {
1446
+ const age = curatedCatalogAgeDays(now);
1447
+ return age !== null && age > maxAgeDays;
1448
+ }
1278
1449
  /** id → 标称上下文窗口(tokens)。键必须与魔搭目录 id 逐字一致。 */
1279
1450
  const CURATED_CONTEXT_WINDOWS = {
1280
1451
  "deepseek-ai/DeepSeek-V4-Flash-0731": 1048576,
@@ -1361,6 +1532,25 @@ async function mapWithConcurrency(items, limit, fn) {
1361
1532
  function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1362
1533
  const doFetch = deps.fetchImpl ?? fetch;
1363
1534
  const { read } = createCoalescedFetch();
1535
+ /**
1536
+ * 详情端点任务标签的**独立**缓存与 TTL——与目录/余额的 `read` 刻意分开。
1537
+ *
1538
+ * 标签回答「这个模型吃不吃图」,是出厂能力,几乎不变;目录/余额回答「现在有
1539
+ * 什么、还剩多少」——两者需要的陈旧度差着量级。它们曾共用 `cacheSeconds`
1540
+ * (默认 60 秒),而面板与对话框胶囊按 `pollSeconds`(默认 30s)轮询快照、
1541
+ * 快照每次都调 `fetchModels`:穿透时对目录里每个模型各打一次
1542
+ * `siteBase/api/v1/models/{id}`。当前目录 ~37 条 = **每轮 38 个 HTTP 请求、
1543
+ * 稳定每分钟一轮**(约 5 万/天),只为判一个布尔值,打的还是 modelscope.cn
1544
+ * 网站上一个非公开、非契约的接口。那不叫新鲜,叫白打。
1545
+ *
1546
+ * `maxAgeMs` 必须一起抬:coalesced-fetch 的清扫上限默认 1 小时(那是「任何
1547
+ * 调用方的最长 TTL」),不抬的话 6 小时的 TTL 会被清扫提前作废。键数等于目录
1548
+ * 里的模型数(约 40 条),无界增长不成立。
1549
+ */
1550
+ const TAGS_TTL_MS = 216e5;
1551
+ const { read: readTags } = createCoalescedFetch({ maxAgeMs: TAGS_TTL_MS });
1552
+ /** 详情端点的并发:长 TTL 之后扇出已经很稀,没必要一上来 6 路齐发去撞主站。 */
1553
+ const TAG_FETCH_CONCURRENCY = 3;
1364
1554
  /** 带超时的一次 fetch(AbortController;abort → TIMEOUT_ERROR)。 */
1365
1555
  const fetchWithTimeout = async (url, init) => {
1366
1556
  const controller = new AbortController();
@@ -1372,7 +1562,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1372
1562
  });
1373
1563
  } catch (error) {
1374
1564
  if (error instanceof Error && error.name === "AbortError") throw pluginError(CODE.TIMEOUT_ERROR, "modelscope request timed out");
1375
- throw pluginError(CODE.NETWORK_ERROR, redactSecrets$1(errMsg(error)));
1565
+ throw pluginError(CODE.NETWORK_ERROR, redactSecrets(errMsg(error)));
1376
1566
  } finally {
1377
1567
  clearTimeout(timer);
1378
1568
  }
@@ -1380,13 +1570,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1380
1570
  /**
1381
1571
  * 丢弃一个我们不读的响应体。
1382
1572
  *
1383
- * undici 里未消费的 body 会**占住连接**直到 GC。目录刷新会对 35 个模型并发打
1384
- * 详情端点(`Promise.all` 分批),其中任何一条走「!ok」或「形状不符」分支,
1385
- * 连接就一直挂着——短时间内连接池被这些挂起的响应吃满,后面的请求开始排队。
1573
+ * undici 里未消费的 body 会**占住连接**直到 GC。标签扇出会对目录里 30–40 个
1574
+ * 模型并发打详情端点(`mapWithConcurrency` 分批),其中任何一条走「!ok」或
1575
+ * 「形状不符」分支,连接就一直挂着——短时间内连接池被这些挂起的响应吃满,
1576
+ * 后面的请求开始排队。
1386
1577
  *
1387
1578
  * `cancel()` 是显式的弃权:告诉 undici「这个 body 我不要了」,它会立刻释放
1388
1579
  * 连接而不是等GC。用 `void` 前缀是因为这是best-effort——取消失败不影响我们
1389
- * 已经决定返回 `[]` 的结论。
1580
+ * 已经决定的结果。
1390
1581
  */
1391
1582
  const discardBody = (response) => {
1392
1583
  try {
@@ -1407,8 +1598,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1407
1598
  /**
1408
1599
  * 单个模型的详情端点任务标签(modelscope.cn/api/v1/models/{id})。
1409
1600
  * 免认证 GET,零推理额度(实测匿名 200)。返回 `Data.Tasks[].Name`(+
1410
- * `widgets[].task` 兜底)去重后的标签数组;任意失败 / 非预期形状返回空数组,
1411
- * 调用方据此回退策展清单 + 名字启发。
1601
+ * `widgets[].task` 兜底)去重后的标签数组。
1602
+ *
1603
+ * **失败一律抛,绝不返回 `[]`**:本函数的结果会被 {@link TAGS_TTL_MS} 缓存
1604
+ * 六小时,而 `[]` 是一个**结论**(「这个模型没有任务标签」)。把 429 / 5xx /
1605
+ * 形状漂移降级成 `[]` 再缓存,等于让一次网络抖动把某个模型的视觉标签钉死半天
1606
+ * ——正是「读不到 ≠ 没有」的反面。coalesced-fetch 不缓存 rejection(失败被当
1607
+ * 「不是答案」共享,但不落盘),所以抛出去的那一轮不会被记住;调用方 catch 成
1608
+ * `[]`,只影响这一轮,下一轮照常重试。
1412
1609
  */
1413
1610
  const fetchTaskTags = async (id) => {
1414
1611
  const detailUrl = settings.siteBase + "/api/v1/models/" + encodeURI(id);
@@ -1418,10 +1615,16 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1418
1615
  });
1419
1616
  if (!response.ok) {
1420
1617
  discardBody(response);
1421
- return [];
1618
+ throw new Error("model detail failed with HTTP " + String(response.status));
1619
+ }
1620
+ let json;
1621
+ try {
1622
+ json = await response.json();
1623
+ } catch {
1624
+ discardBody(response);
1625
+ throw new Error("model detail body was not JSON");
1422
1626
  }
1423
- const json = await response.json().catch(() => null);
1424
- if (json === null || typeof json !== "object" || json.Code !== 200) return [];
1627
+ if (json === null || typeof json !== "object" || json.Code !== 200) throw new Error("model detail shape drifted (expected {Code:200, Data:{Tasks:[...]}})");
1425
1628
  const data = json.Data ?? {};
1426
1629
  const tasks = [];
1427
1630
  for (const t of Array.isArray(data.Tasks) ? data.Tasks : []) {
@@ -1462,18 +1665,27 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1462
1665
  */
1463
1666
  async fetchModels(contextOverride) {
1464
1667
  const url = settings.apiBase + "/models";
1465
- return await read(`models:${contextOverride ?? "catalog"}`, async () => {
1668
+ const cacheKey = contextOverride != null ? `models:override:${contextOverride}` : "models:catalog";
1669
+ const cacheTtl = contextOverride != null ? Math.min(settings.cacheSeconds * 1e3, 5e3) : settings.cacheSeconds * 1e3;
1670
+ return await read(cacheKey, async () => {
1466
1671
  const response = await fetchWithTimeout(url, { method: "GET" });
1467
1672
  if (!response.ok) {
1468
1673
  const message = await readErrorMessage(response);
1469
1674
  const code = classifyStatus(response.status);
1470
- throw pluginError(code, redactSecrets$1("model catalog failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1675
+ throw pluginError(code, redactSecrets("model catalog failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1471
1676
  }
1472
1677
  const json = await response.json().catch(() => null);
1473
1678
  if (json === null || typeof json !== "object" || json.object !== "list" || !Array.isArray(json.data)) throw pluginError(CODE.UPSTREAM_ERROR, "model catalog shape drifted (expected {object:'list', data:[...]})");
1474
1679
  const raws = json.data;
1475
1680
  const ids = raws.map((r) => str(r.id, "")).filter((id) => id !== "");
1476
- const tagArrays = await mapWithConcurrency(ids, 6, (id) => fetchTaskTags(id).catch(() => []));
1681
+ const tagArrays = await mapWithConcurrency(ids, TAG_FETCH_CONCURRENCY, async (id) => {
1682
+ try {
1683
+ const cached = await readTags(`model-tags:${id}`, () => fetchTaskTags(id), TAGS_TTL_MS);
1684
+ return Array.isArray(cached) ? cached : [];
1685
+ } catch {
1686
+ return [];
1687
+ }
1688
+ });
1477
1689
  const tagsById = /* @__PURE__ */ new Map();
1478
1690
  ids.forEach((id, i) => tagsById.set(id, tagArrays[i] ?? []));
1479
1691
  const entries = raws.map((raw) => {
@@ -1513,7 +1725,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1513
1725
  ids: entries.map((entry) => entry.id),
1514
1726
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
1515
1727
  };
1516
- }, settings.cacheSeconds * 1e3);
1728
+ }, cacheTtl);
1517
1729
  },
1518
1730
  /**
1519
1731
  * 官方「魔粒」余额:GET siteBase/openapi/v1/magicubes/balance(Bearer
@@ -1536,7 +1748,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1536
1748
  if (!response.ok) {
1537
1749
  const message = await readErrorMessage(response);
1538
1750
  const code = classifyStatus(response.status);
1539
- throw pluginError(code, redactSecrets$1("magicube balance failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1751
+ throw pluginError(code, redactSecrets("magicube balance failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1540
1752
  }
1541
1753
  const json = await response.json().catch(() => null);
1542
1754
  const data = json !== null && typeof json === "object" ? json.data : null;
@@ -1578,14 +1790,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1578
1790
  const elapsedMs = Date.now() - startedAt;
1579
1791
  if (response.status === 401 || response.status === 403) {
1580
1792
  const message = await readErrorMessage(response);
1581
- throw pluginError(CODE.AUTH_ERROR, redactSecrets$1(message === "" ? "token rejected (HTTP 401/403)" : message));
1793
+ throw pluginError(CODE.AUTH_ERROR, redactSecrets(message === "" ? "token rejected (HTTP 401/403)" : message));
1582
1794
  }
1583
1795
  if (response.status === 429) {
1584
1796
  const message = await readErrorMessage(response);
1585
1797
  const retryAfterMs = parseRetryAfterMs(response.headers, message);
1586
1798
  const triage = classifyRateLimit(message);
1587
1799
  const extra = retryAfterMs !== null && retryAfterMs !== void 0 ? { retryAfterMs } : {};
1588
- throw pluginError(triage === "quota" ? CODE.QUOTA_EXCEEDED : CODE.RATE_LIMITED, redactSecrets$1(message === "" ? "modelscope returned 429 (probe on " + modelId + ")" : message), extra);
1800
+ throw pluginError(triage === "quota" ? CODE.QUOTA_EXCEEDED : CODE.RATE_LIMITED, redactSecrets(message === "" ? "modelscope returned 429 (probe on " + modelId + ")" : message), extra);
1589
1801
  }
1590
1802
  if (response.status >= 500) throw pluginError(CODE.UPSTREAM_ERROR, "modelscope returned HTTP " + String(response.status) + " (validity probe)");
1591
1803
  response.body?.cancel();
@@ -1648,7 +1860,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1648
1860
  * collapses here, so the three call sites that produce it stay in step instead
1649
1861
  * of each spelling out the same three `null`s.
1650
1862
  */
1651
- const EMPTY_PAYLOAD$1 = {
1863
+ const EMPTY_PAYLOAD$2 = {
1652
1864
  enabled: null,
1653
1865
  enabledIds: null,
1654
1866
  contextWindow: null,
@@ -1795,28 +2007,22 @@ function createFileProviderStore(options = {}) {
1795
2007
  * read. An unknown NUMERIC version means a NEWER build wrote it; clobbering
1796
2008
  * it destroys data we cannot even see. So the write is refused with a
1797
2009
  * `degrade` signal, not a silent no-op — swallow the failure, not the reason.
2010
+ * The guard itself lives in `state-store.ts` ({@link guardStateWrite}) — it is
2011
+ * the same promise draw-store makes, and two copies of "what may be
2012
+ * overwritten" is one copy too many.
1798
2013
  * @param {object} body - the JSON body to persist.
1799
2014
  * @returns {Promise<string|null>} the refusal reason when the write was
1800
2015
  * refused (already `degrade`-logged), or `null` when it landed — the caller
1801
2016
  * decides whether to surface the refusal to a user.
1802
2017
  */
1803
2018
  const writePayload = async (body) => {
1804
- let existing;
1805
- try {
1806
- existing = await readStateVersion(filePath);
1807
- } catch (error) {
1808
- const reason = `provider: refusing to overwrite provider.json (cannot read it: ${redactSecrets$1(errMsg(error))})`;
1809
- degrade(reason, null, logger, null);
1810
- return reason;
1811
- }
1812
- if (!isKnownStateVersion(existing, KNOWN_PROVIDER_VERSIONS)) {
1813
- const reason = `provider: refusing to overwrite provider.json holding version ${existing} (this build knows ${KNOWN_PROVIDER_VERSIONS.join("/")})`;
1814
- degrade(reason, null, logger, null);
1815
- return reason;
1816
- }
1817
- const temporary = temporaryOf(stateDir$1, "provider.json");
2019
+ const refusal = await guardStateWrite(filePath, KNOWN_PROVIDER_VERSIONS, {
2020
+ label: "provider",
2021
+ logger
2022
+ });
2023
+ if (refusal !== null) return refusal;
1818
2024
  await ensureStateDir(stateDir$1);
1819
- await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary });
2025
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$1, "provider.json") });
1820
2026
  return null;
1821
2027
  };
1822
2028
  const legacyFile = dir === void 0 && profile ? join(stateDir(name), "provider.json") : null;
@@ -1859,7 +2065,7 @@ function createFileProviderStore(options = {}) {
1859
2065
  const parsePayload = async () => parseAt(filePath);
1860
2066
  /** Read one payload off an explicit path (the legacy shared file). */
1861
2067
  const parsePayloadFrom = async (file) => parseAt(file);
1862
- let writeChain = Promise.resolve(null);
2068
+ const writeQueue = createStateWriteQueue();
1863
2069
  const cache = createStateReadCache(async () => {
1864
2070
  const own = await parsePayload();
1865
2071
  if (own.enabled !== null || own.enabledIds !== null || legacyFile === null) return own;
@@ -1872,7 +2078,7 @@ function createFileProviderStore(options = {}) {
1872
2078
  ...legacy.enabledIds === null ? {} : { enabledIds: legacy.enabledIds },
1873
2079
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1874
2080
  };
1875
- await writeChain.then(() => writePayload(inherited).then(() => void 0, () => void 0), () => writePayload(inherited).then(() => void 0, () => void 0));
2081
+ await writeQueue.enqueue(() => writePayload(inherited).then(() => void 0, () => void 0));
1876
2082
  return legacy;
1877
2083
  }, { ttlMs });
1878
2084
  const read = () => cache.read();
@@ -1883,7 +2089,7 @@ function createFileProviderStore(options = {}) {
1883
2089
  * `null` branch collapses here, in one place, rather than at every caller.
1884
2090
  * @returns {Promise<ProviderPayload>} the payload; the empty payload when nothing is stored.
1885
2091
  */
1886
- const readPayload = async () => await read() ?? EMPTY_PAYLOAD$1;
2092
+ const readPayload = async () => await read() ?? EMPTY_PAYLOAD$2;
1887
2093
  /**
1888
2094
  * Read one payload, then hand it to {@link writePayload} — the read-modify-
1889
2095
  * write that keeps the switch and the list from clobbering each other.
@@ -1918,7 +2124,7 @@ function createFileProviderStore(options = {}) {
1918
2124
  * @returns {Promise<string|null>} the write refusal reason, or `null`.
1919
2125
  */
1920
2126
  const patchPayload = (patch) => {
1921
- const run = writeChain.then(async () => {
2127
+ return writeQueue.enqueue(async () => {
1922
2128
  const current = await parsePayload();
1923
2129
  /** `undefined` keeps the stored key; `null` clears it (key absent). */
1924
2130
  const body = { version: 1 };
@@ -1938,8 +2144,6 @@ function createFileProviderStore(options = {}) {
1938
2144
  });
1939
2145
  return refusal;
1940
2146
  });
1941
- writeChain = run.catch(() => null);
1942
- return run;
1943
2147
  };
1944
2148
  return {
1945
2149
  /**
@@ -1994,7 +2198,13 @@ function createFileProviderStore(options = {}) {
1994
2198
  async versionNote() {
1995
2199
  const version = (await readPayload()).version;
1996
2200
  if (version === null || isKnownProviderVersion(version)) return null;
1997
- return `provider-state: provider.json holds version ${version}, which this build cannot read (this build knows ${KNOWN_PROVIDER_VERSIONS.join("/")}); the saved switch and allow-list are being answered as "not set", so the allow-list reads as "no filter, offer every model" while the switch falls back to the config default. Upgrade this build to see the saved values, or delete the file to reset them.`;
2201
+ return stateVersionNote({
2202
+ label: "provider-state",
2203
+ file: filePath,
2204
+ version,
2205
+ known: KNOWN_PROVIDER_VERSIONS,
2206
+ consequence: "the saved switch and allow-list are being answered as \"not set\", so the allow-list reads as \"no filter, offer every model\" while the switch falls back to the config default."
2207
+ });
1998
2208
  },
1999
2209
  /**
2000
2210
  * Persist a switch value. The write is atomic (temp file + rename) so a
@@ -2069,8 +2279,27 @@ function createFileProviderStore(options = {}) {
2069
2279
  * 优先级与所有 opt-in 开关同一份裁决(`switch-precedence.ts`):面板保存值赢,
2070
2280
  * 否则 `cordis.patch.yml` 的 `drawEnabled` 默认值说了算。
2071
2281
  *
2072
- * 完整性沿用 `state-store.ts`:版本化 payload、临时文件 + 原子改名 + fsync、属主
2073
- * 可读、「认不出的内容一律读作没设过」。写入侧拒绝覆盖本版本读不懂的版本(ADR-006)。
2282
+ * 完整性三件套(与 provider / usage 同源,全部上收在 `state-store.ts`,本文件只做
2283
+ * 调用方——2026-10-17 收敛:此前这里**三件全缺**,并发 `saveEnabled` +
2284
+ * `saveModelId` 会丢一次保存、磁盘 version 比本构建新时降级覆写、读侧静默读作
2285
+ * 「没设过」,正是 provider-store 注释里记着「咬过三次」的那类漂移的第四条):
2286
+ * - **写串行化**({@link createStateWriteQueue}):读-改-写整体排队,临界区内
2287
+ * 从磁盘重读基线(不用 TTL 缓存—— provider §8d 的回归用例),链吞自己的
2288
+ * 失败继续走;
2289
+ * - **写守卫**({@link guardStateWrite},ADR-006):盘上版本本构建读不懂时
2290
+ * 拒写并给出理由,绝不降级覆写;「文件在场却读不出来」同样拒写(读不到 ≠
2291
+ * 没有东西要保护);
2292
+ * - **读侧说明**({@link stateVersionNote}):未知版本宽容读作「没设过」,但
2293
+ * {@link createFileDrawStore} 的 `versionNote()` 把「保存的开关与模型偏好
2294
+ * 被忽略」说出口,快照聚合把它转进 `shapeWarnings`——不说,面板就把「保存的
2295
+ * 值被忽略」呈现成「从未保存过」。
2296
+ *
2297
+ * 与 provider-store 的一处**有意差异**:没有 legacy(pre-profile 共享目录)继承。
2298
+ * draw.json 随出图工具一起诞生,晚于按 profile 分段,市面上不存在共享布局里的
2299
+ * 旧 draw.json;provider.json 则需要(§23 之前它一直在共享目录)。
2300
+ *
2301
+ * doctor 覆盖:`describeDrawPayload` 给只读速览,`doctor.ts` 据此把 draw.json
2302
+ * 的「损坏 / 未知版本」报进症状表——写侧守卫挡覆盖,doctor 报静默降级。
2074
2303
  *
2075
2304
  * @module dsh-connect-modelscope-token-plan/draw-store
2076
2305
  */
@@ -2078,14 +2307,24 @@ function createFileProviderStore(options = {}) {
2078
2307
  const DRAW_STORE_VERSION = 1;
2079
2308
  /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
2080
2309
  const KNOWN_DRAW_VERSIONS = Object.freeze([1]);
2081
- const EMPTY_PAYLOAD = {
2310
+ const EMPTY_PAYLOAD$1 = {
2082
2311
  enabled: null,
2083
2312
  modelId: null,
2084
2313
  version: null
2085
2314
  };
2086
- /** 本 build 读不读得懂这个版本。 */
2315
+ /**
2316
+ * 本 build 读不读得懂这个版本。
2317
+ *
2318
+ * 与 provider-store 的 {@link isKnownProviderVersion} 同形:**白名单**判据,
2319
+ * 不与 {@link DRAW_STORE_VERSION} 比较——升级后旧号仍要可读。读侧(parseAt)、
2320
+ * 报告侧(versionNote / describeDrawPayload)问的都是这一个函数,三者不可能就
2321
+ * 「什么是未知版本」说两套话。写侧问的是 `state-store` 的
2322
+ * `isKnownStateVersion`(它额外放行 `null` = 没有文件,那是唯一该写的情形)。
2323
+ * @param {unknown} version - 盘上读到的值(可能根本不是数字)。
2324
+ * @returns {boolean}
2325
+ */
2087
2326
  function isKnownDrawVersion(version) {
2088
- return isKnownStateVersion(version, KNOWN_DRAW_VERSIONS);
2327
+ return typeof version === "number" && KNOWN_DRAW_VERSIONS.includes(version);
2089
2328
  }
2090
2329
  /**
2091
2330
  * 归一一个保存的模型偏好:非空字符串,或 `null`(= 自动挑——具体挑谁由
@@ -2123,7 +2362,31 @@ function createFileDrawStore(options = {}) {
2123
2362
  const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
2124
2363
  const stateDir = drawStoreDir(profile, dir);
2125
2364
  const filePath = join(stateDir, "draw.json");
2126
- /** 解析一份 payload;认不出的版本读作「没设过」但**记住**版本号。 */
2365
+ /**
2366
+ * 写一份补丁并落盘(内部用;对调用方暴露的是 saveEnabled / saveModelId / reset)。
2367
+ *
2368
+ * ADR-006 写守卫({@link guardStateWrite})先问盘:版本不认或文件读不出 →
2369
+ * 返回拒绝理由(已 degrade 记录),不落笔。守卫与随后的写入在同一个临界区里
2370
+ * (由 patchPayload 的链保证),判据与执行之间不隔任何 await。
2371
+ * @param {object} body - 要持久化的 JSON 体。
2372
+ * @returns {Promise<string|null>} 拒绝原因,或 `null` = 已写入。
2373
+ */
2374
+ const writePayload = async (body) => {
2375
+ const refusal = await guardStateWrite(filePath, KNOWN_DRAW_VERSIONS, {
2376
+ label: "draw-state",
2377
+ logger
2378
+ });
2379
+ if (refusal !== null) return refusal;
2380
+ await ensureStateDir(stateDir);
2381
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "draw.json") });
2382
+ return null;
2383
+ };
2384
+ /** 写串行化:读-改-写必须排队(详见{@link createStateWriteQueue}与文件头)。 */
2385
+ const writeQueue = createStateWriteQueue();
2386
+ /**
2387
+ * 解析一份 payload;认不出的版本读作「没设过」但**记住**版本号——读侧宽容、
2388
+ * versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是 ADR-006)。
2389
+ */
2127
2390
  const parsePayload = async () => {
2128
2391
  const source = obj(await readStateJson(filePath));
2129
2392
  const version = typeof source.version === "number" ? source.version : null;
@@ -2143,60 +2406,84 @@ function createFileDrawStore(options = {}) {
2143
2406
  * 读当前 payload。
2144
2407
  * @returns {Promise<DrawPayload>}
2145
2408
  */
2146
- const current = async () => await cache.read() ?? EMPTY_PAYLOAD;
2409
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$1;
2147
2410
  /**
2148
- * 写一份补丁并落盘。
2149
- * @param {{enabled?: boolean|null, modelId?: string|null}} patch - 要改的字段。
2150
- * @returns {Promise<string|null>} 拒绝原因(`null` = 已写入)。
2411
+ * 读-改-写一份补丁,**整段排队**。
2412
+ *
2413
+ * 基线每次从磁盘重读(`parsePayload` 直读,不走 TTL 缓存):两个 Host 进程
2414
+ * 共享一个状态目录,而缓存里可能是写入前的值——provider §8d 的回归用例
2415
+ * (两个写入者共享一个目录,回读把较新的缓存顶成旧值)在 draw 上同样成立。
2416
+ * 写成功即 `cache.remember`,调用方不必各自回读一遍。
2417
+ *
2418
+ * 失败语义与 provider-store 对齐:拒绝原因与 IO 失败都**向上抛**(save 抛、
2419
+ * 路由 catch 后 `respondError` 呈报),链自己吞掉失败继续走后面的保存。
2420
+ *
2421
+ * @param {{enabled?: boolean|null, modelId?: string|null}} patch - 要改的字段;
2422
+ * `undefined` = 保留盘上的值,`null` = 清除(回到「没设过」)。
2423
+ * @returns {Promise<void>}
2151
2424
  */
2152
- const patchPayload = async (patch) => {
2153
- const before = await current();
2154
- const enabled = patch.enabled === void 0 ? before.enabled : patch.enabled;
2155
- const modelId = patch.modelId === void 0 ? before.modelId : patch.modelId;
2156
- try {
2157
- await ensureStateDir(stateDir);
2158
- const body = { version: 1 };
2159
- if (enabled !== null) body.enabled = enabled;
2160
- if (modelId !== null) body.modelId = modelId;
2161
- body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2162
- await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "draw.json") });
2163
- cache.remember({
2164
- enabled,
2165
- modelId,
2166
- version: 1
2167
- });
2168
- return null;
2169
- } catch (error) {
2170
- return degrade("draw store write failed", error, logger, "draw store write failed");
2171
- }
2172
- };
2425
+ const patchPayload = (patch) => writeQueue.enqueue(async () => {
2426
+ const current = await parsePayload();
2427
+ const enabled = patch.enabled === void 0 ? current.enabled : patch.enabled;
2428
+ const modelId = patch.modelId === void 0 ? current.modelId : patch.modelId;
2429
+ const body = { version: 1 };
2430
+ if (enabled !== null) body.enabled = enabled;
2431
+ if (modelId !== null) body.modelId = modelId;
2432
+ body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2433
+ const refusal = await writePayload(body);
2434
+ if (refusal !== null) throw new Error(refusal);
2435
+ cache.remember({
2436
+ enabled,
2437
+ modelId,
2438
+ version: 1
2439
+ });
2440
+ });
2173
2441
  return {
2174
2442
  /** 面板保存的出图开关;`null` = 回退 patch 默认值。 */
2175
2443
  async enabled() {
2176
- return (await current()).enabled;
2444
+ return (await readPayload()).enabled;
2177
2445
  },
2178
2446
  /** 面板保存的模型偏好;`null` = 自动。 */
2179
2447
  async modelId() {
2180
- return (await current()).modelId;
2448
+ return (await readPayload()).modelId;
2181
2449
  },
2182
2450
  /** 这个 profile 是否被显式配置过。 */
2183
2451
  async isConfigured() {
2184
- const payload = await current();
2452
+ const payload = await readPayload();
2185
2453
  return payload.enabled !== null || payload.modelId !== null;
2186
2454
  },
2455
+ /**
2456
+ * 盘上带着本构建读不懂的版本时的一句说明,否则 `null`。
2457
+ *
2458
+ * 读侧宽容(未知版本读作「没设过」)的另一半:快照聚合把它转进
2459
+ * `shapeWarnings`,doctor 报同一份文件。没有它,「保存的开关与模型偏好
2460
+ * 被忽略」在面板上就是「从未保存过」——而回退方向是 patch 默认(通常=关)。
2461
+ * @returns {Promise<string|null>}
2462
+ */
2463
+ async versionNote() {
2464
+ const version = (await readPayload()).version;
2465
+ if (version === null || isKnownDrawVersion(version)) return null;
2466
+ return stateVersionNote({
2467
+ label: "draw-state",
2468
+ file: filePath,
2469
+ version,
2470
+ known: KNOWN_DRAW_VERSIONS,
2471
+ consequence: "the saved draw switch and model preference are being answered as \"not set\", so the switch falls back to the config default and the model back to automatic selection."
2472
+ });
2473
+ },
2187
2474
  /** 保存开关。 */
2188
2475
  async saveEnabled(enabled) {
2189
2476
  if (typeof enabled !== "boolean") throw new TypeError("draw switch expects a boolean");
2190
- return patchPayload({ enabled });
2477
+ await patchPayload({ enabled });
2191
2478
  },
2192
2479
  /** 保存模型偏好(`null` = 清掉,回到自动)。 */
2193
2480
  async saveModelId(modelId) {
2194
2481
  if (modelId !== null && (typeof modelId !== "string" || modelId.trim() === "")) throw new TypeError("draw model expects a non-empty string or null");
2195
- return patchPayload({ modelId });
2482
+ await patchPayload({ modelId });
2196
2483
  },
2197
2484
  /** 清掉两个字段(回到「没配过」)。 */
2198
2485
  async reset() {
2199
- return patchPayload({
2486
+ await patchPayload({
2200
2487
  enabled: null,
2201
2488
  modelId: null
2202
2489
  });
@@ -2207,92 +2494,368 @@ function createFileDrawStore(options = {}) {
2207
2494
  }
2208
2495
 
2209
2496
  //#endregion
2210
- //#region src/host/draw.ts
2497
+ //#region src/host/switch-precedence.ts
2211
2498
  /**
2212
- * 魔搭出图(`/v1/images/generations`)的**纯逻辑半边**——协议形状的解析与判定。
2213
- *
2214
- * 为什么是这个形状:魔搭的生图是**异步两段式**(提交得 `task_id`,轮询才出图),
2215
- * 与姊妹插件 `dsh-connect-sensenova-token-plan/src/host/draw.ts` 的**同步单发**
2216
- * (`drawOnce` 一次 fetch 即 `parseDrawResponse` 取 `data[0].url`)形状不兼容,
2217
- * **不能照抄**。可复用的只有外壳(endpoint 拼接、冷却门、失败文案),核心必须新写。
2218
- * 协议依据全部来自 `docs/SPIKE-image-gen.md` 的真机实测,非文档转述。
2219
- *
2220
- * 本模块**只做纯函数**:endpoint 构造、请求体构造、响应解析、状态判定、失败分诊。
2221
- * 它**不发起任何请求、不碰凭据、不注册工具**——那半边(fetch 注入、Host 工具注册)
2222
- * 与 `llm-adapter.ts` 一样 peer-dependent,测试覆盖同样是零(docs/ROADMAP.md Backlog)。
2223
- * 刻意 peer-free:纯函数部分能进离线 `npm test` 门禁,`test/draw.test.mjs` 钉住形状。
2499
+ * 所有 opt-in 开关共用的唯一裁决:「面板保存值 vs 配置默认值」。
2224
2500
  *
2225
- * 三条承重结论(全部实测,推翻了几条广泛流传的错误写法):
2501
+ * 规则只有一条:**面板保存值赢,否则配置默认值说了算**。来源标签(`panel` /
2502
+ * `config`)随答案一起返回,面板才能说出是谁在管。规则收在一处,是为了让开关
2503
+ * 再也无法各自发明一套优先级方言;`test/switch-precedence.test.mjs` 钉死这个
2504
+ * 方言,照抄形状的新调用方会被它标成异类。
2226
2505
  *
2227
- * 1. **轮询必须带 `X-ModelScope-Task-Type: image_generation`**,否则上游一律返回
2228
- * `task not found` + `task_status: FAILED`——**那是假失败**,任务实际正常。
2229
- * 缺这个头的实现会在第一次轮询就误判失败并放弃一张已经能出的图。
2230
- * 2. **提交响应的 `task_status` 不可信**:实测每次提交都回 `SUCCEED`,包括最终确实
2231
- * `FAILED` 的那次。所以提交只认 `task_id`,**成功与否只看轮询终态**。
2232
- * 3. **`FAILED` 没有可分诊的形状**:`errors.code` 实测为 `0`、`errors.message` 为
2233
- * 空串,没有顶层 `message`。任何「从错误里读出原因」的承诺都是编造——本模块的
2234
- * `describeDrawFailure` 因此**明确承认原因不可知**,只报可确定的形态差异。
2506
+ * 不服务「纯面板拥有、无配置默认值」的开关——那种没有可裁决的配置默认值,
2507
+ * 调用方直接读即可。peer-free,纯函数。
2235
2508
  *
2236
- * @module dsh-connect-modelscope-token-plan/draw
2509
+ * @module dsh-connect-modelscope-token-plan/switch-precedence
2237
2510
  */
2238
2511
  /**
2239
- * 出图请求的截止时间(墙钟毫秒)。
2240
- *
2241
- * **15 分钟**:实测同一模型的耗时在 61s–537s 之间大幅波动(免费池排队),
2242
- * 且**超时放弃不等于任务死亡**——上游任务会继续跑完(记录之后还会被回收,
2243
- * 见 docs/SPIKE-image-gen.md §坑 5)。宁可多等,不要扔掉一张已在生成的图。
2512
+ * Resolve the effective boolean switch: a panel-saved value always wins,
2513
+ * otherwise the config default rules.
2514
+ * @param {boolean|null} panel - the panel-saved value (`null` = never saved).
2515
+ * @param {boolean} config - the patch-declared default.
2516
+ * @returns {boolean} the effective switch.
2244
2517
  */
2245
- const DRAW_TIMEOUT_MS = 9e5;
2518
+ function resolveSwitchEnabled(panel, config) {
2519
+ return (panel ?? config) === true;
2520
+ }
2246
2521
  /**
2247
- * 两次轮询之间的间隔(毫秒)。
2248
- *
2249
- * 取 15s 的**真正理由**是「够用且不浪费」:实测最慢一次 537s,15s 间隔意味着
2250
- * 最多几十次查询,代价可忽略;而更密(社区示例的 5s)没有观测收益。
2251
- *
2252
- * ⚠️ 早先曾写「更密的轮询拖慢任务」,**该归因已推翻**——后续对照显示同一模型、
2253
- * 同样 15s 轮询的耗时是 61s → 261s → 537s 单调恶化,是**上游排队时长在波动**。
2254
- * 那次构成「证据」的 5s vs 30s 对照,控制变量没做,不足以支撑因果。
2255
- * 详见 docs/SPIKE-image-gen.md §坑 4(含撤回记录)。
2522
+ * Resolve the effective string preference (e.g. a draw-model id): a
2523
+ * panel-saved value wins, otherwise the config default rules.
2524
+ * @param {string|null} panel - the panel-saved value (`null` = never saved).
2525
+ * @param {string|undefined} config - the patch-declared default.
2526
+ * @returns {string|undefined} the effective preference.
2256
2527
  */
2257
- const DRAW_POLL_INTERVAL_MS = 15e3;
2258
- /** 轮询的总截止时间;与 `DRAW_TIMEOUT_MS` 同量级但独立可调(诊断时可放宽)。 */
2259
- const DRAW_POLL_DEADLINE_MS = DRAW_TIMEOUT_MS;
2528
+ function resolveSwitchValue(panel, config) {
2529
+ return panel ?? config;
2530
+ }
2260
2531
  /**
2261
- * 轮询必须携带的头名与取值。
2532
+ * Resolve the effective numeric preference (e.g. how many days the trend chart
2533
+ * shows): the same `panel ?? config` dialect as the other two shapes.
2262
2534
  *
2263
- * 漏掉它 → 上游返回 `task not found` + `FAILED`,而任务其实在正常跑。这是本协议
2264
- * 最贵的单点知识,也是第一份外部资料缺失的那一块。
2535
+ * Range clamping is deliberately NOT done here — the bound (a retention
2536
+ * window) belongs to the caller that knows it (`host-config` / the snapshot
2537
+ * aggregate), not to the precedence rule.
2538
+ * @param {number|null} panel - the panel-saved value (`null` = never saved).
2539
+ * @param {number} config - the patch-declared default.
2540
+ * @returns {number} the effective preference.
2265
2541
  */
2266
- const TASK_TYPE_HEADER = "X-ModelScope-Task-Type";
2267
- const TASK_TYPE_IMAGE_GENERATION = "image_generation";
2542
+ function resolveSwitchNumber(panel, config) {
2543
+ return panel ?? config;
2544
+ }
2268
2545
  /**
2269
- * 一次出图请求由几个 HTTP 请求组成:一次提交 + N 次轮询。
2270
- * 只作为文档性常量,供工具描述与测试引用。
2546
+ * Where the effective value came from — the panel when it saved one, the
2547
+ * config otherwise. Rides with every resolved answer so the panel can name
2548
+ * the side in charge.
2549
+ * @param {boolean|string|number|null} panel - the panel-saved value.
2550
+ * @returns {"panel"|"config"}
2271
2551
  */
2272
- const DRAW_PHASE = Object.freeze({
2273
- submit: "submit",
2274
- poll: "poll"
2275
- });
2552
+ function switchSource(panel) {
2553
+ return panel === null ? "config" : "panel";
2554
+ }
2555
+
2556
+ //#endregion
2557
+ //#region src/host/usage-prefs-store.ts
2276
2558
  /**
2277
- * 读一个响应的 `task_status`,取不到返回 `null`。
2559
+ * 用量面板的**显示偏好**——目前只有一个字段:趋势图的显示窗口(画多少天)。
2278
2560
  *
2279
- * 刻意**不猜**:字段缺失或类型不对都返回 `null`,由 `isTerminalStatus` 把它当作
2280
- * 「非终态」继续等——这样一次形状漂移最多变成多轮轮询,不会变成误报失败。
2281
- * @param {unknown} data - 已解析的 JSON。
2282
- * @returns {DrawTaskStatus|null} 规范化状态,取不到为 `null`。
2561
+ * ## 为什么单独一个文件
2562
+ *
2563
+ * - `usage.json` 是**数据**(天桶 / 单模型 / 事件),写路径是观测热路径。把一个
2564
+ * 显示偏好混进去,会让「偏好读不到」与「计数文件损坏」共用一条降级语义;更要命
2565
+ * 的是版本漂移——旧构建重写那份 payload 时会**静默丢掉**它不认识的键。
2566
+ * - `provider.json` 是 provider 注册的 desired state(开关 / 允许清单 / 窗口覆盖),
2567
+ * 与「趋势图画多少天」无关;合成一份 payload 会让两边的写入时序互相牵连。
2568
+ *
2569
+ * 落点与 provider / draw 同一套分段规则:`$DSH_HOME/state/<profile>/<plugin>/usage-prefs.json`
2570
+ * (无 profile 时落共享目录)。分段的意思是「这个 profile 想看多长」,两个 profile
2571
+ * 不互相覆盖。
2572
+ *
2573
+ * 优先级与所有 opt-in 开关同一份裁决(`switch-precedence.ts`):面板保存值赢,
2574
+ * 否则 `cordis.patch.yml` 的 `trendDays` 说了算;两者都会在 host-config 里被夹进
2575
+ * 保留窗口之内(`retentionDays`)——**画得比留得多**会把已剪的日子画成 0。
2576
+ *
2577
+ * 完整性三件套与 provider / usage / draw **同源**(全部上收在 `state-store.ts`,
2578
+ * 本文件只做调用方,论述见 `draw-store.ts` 头注,不在此重复):写串行化
2579
+ * ({@link createStateWriteQueue})、写守卫({@link guardStateWrite},ADR-006)、
2580
+ * 读侧说明({@link stateVersionNote})。
2581
+ *
2582
+ * doctor 覆盖:{@link describeUsagePrefsPayload} 给只读速览,`doctor.ts` 据此把
2583
+ * usage-prefs.json 的「损坏 / 未知版本」报进症状表。
2584
+ *
2585
+ * @module dsh-connect-modelscope-token-plan/usage-prefs-store
2283
2586
  */
2284
- function readTaskStatus(data) {
2285
- if (data === null || typeof data !== "object") return null;
2286
- const raw = data.task_status;
2287
- if (typeof raw !== "string") return null;
2288
- const upper = raw.trim().toUpperCase();
2289
- if (upper === "PENDING" || upper === "RUNNING" || upper === "SUCCEED" || upper === "FAILED") return upper;
2290
- return null;
2291
- }
2587
+ /** 本 store 落盘 payload 的版本。 */
2588
+ const USAGE_PREFS_VERSION = 1;
2589
+ /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
2590
+ const KNOWN_USAGE_PREFS_VERSIONS = Object.freeze([1]);
2591
+ const EMPTY_PAYLOAD = {
2592
+ windowDays: null,
2593
+ version: null
2594
+ };
2292
2595
  /**
2293
- * 一个状态是不是终态。
2596
+ * 本 build 读不读得懂这个版本。
2294
2597
  *
2295
- * **只有** `SUCCEED` / `FAILED` 是终态;`null`(读不到)与一切未知值都**不是**。
2598
+ * 与 provider / draw 的 `isKnown*Version` 同形:**白名单**判据,不与
2599
+ * {@link USAGE_PREFS_VERSION} 比较——升级后旧号仍要可读。读侧、报告侧
2600
+ * (versionNote / describeUsagePrefsPayload)问的都是这一个函数,三者不可能就
2601
+ * 「什么是未知版本」说两套话。写侧问的是 `state-store` 的 `isKnownStateVersion`
2602
+ * (它额外放行 `null` = 没有文件,那是唯一该写的情形)。
2603
+ * @param {unknown} version - 盘上读到的值(可能根本不是数字)。
2604
+ * @returns {boolean}
2605
+ */
2606
+ function isKnownUsagePrefsVersion(version) {
2607
+ return typeof version === "number" && KNOWN_USAGE_PREFS_VERSIONS.includes(version);
2608
+ }
2609
+ /**
2610
+ * 归一一个保存的显示窗口:正整数(向下取整),或 `null`(= 跟随配置默认)。
2611
+ *
2612
+ * **不在这里夹上界**:上界是保留窗口(`retentionDays`),那是 host-config 的事,
2613
+ * 此模块不该知道它。传进来的越界值由调用方(host-config / 快照聚合 / 路由)夹。
2614
+ * @param {unknown} value - 原始值。
2615
+ * @returns {number|null} 归一后的天数或 `null`。
2616
+ */
2617
+ function normalizeWindowDays(value) {
2618
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
2619
+ const days = Math.floor(value);
2620
+ return days > 0 ? days : null;
2621
+ }
2622
+ /** 本 store 的目录(与 provider / draw 同一套分段规则)。 */
2623
+ function usagePrefsDir(profile, dir) {
2624
+ if (typeof dir === "string" && dir !== "") return dir;
2625
+ return profile ? profileStateDir(name, profile) : stateDir(name);
2626
+ }
2627
+ /**
2628
+ * 显示窗口的**唯一**裁决 + 夹取:面板保存值优先(沿用全仓唯一方言
2629
+ * {@link resolveSwitchNumber}),否则配置默认;随后落进 `[1, maxDays]`。
2630
+ *
2631
+ * 夹取放在这里而不是 store 里,是因为 store 不该知道上界;放在**本模块**而不是
2632
+ * 两个调用方各写一遍,是因为「面板值 vs 默认」和「夹到保留窗口」加起来只有一条
2633
+ * 正确算法——路由保存时与快照聚合渲染时各写一份,就会出现「面板显示 90 天、
2634
+ * 图里只画了 14 天」这类两处不一致。
2635
+ * @param {number|null} panel - 面板保存值(`null` = 未保存)。
2636
+ * @param {number} fallback - 配置默认(已由 host-config 夹过)。
2637
+ * @param {number} maxDays - 上界(保留窗口,`retentionDays`)。
2638
+ * @returns {number} 生效天数。
2639
+ */
2640
+ function resolveWindowDays(panel, fallback, maxDays) {
2641
+ const want = resolveSwitchNumber(panel, fallback);
2642
+ return Math.min(Math.max(1, Math.floor(want)), Math.max(1, Math.floor(maxDays)));
2643
+ }
2644
+ /**
2645
+ * 文件支撑的用量显示偏好。
2646
+ * @param {StoreOptions} [options] - `{ dir, profile, ttlMs, logger }`。
2647
+ * @returns {object} store 句柄。
2648
+ */
2649
+ function createFileUsagePrefsStore(options = {}) {
2650
+ const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
2651
+ const stateDir = usagePrefsDir(profile, dir);
2652
+ const filePath = join(stateDir, "usage-prefs.json");
2653
+ /**
2654
+ * 写一份 payload 并落盘。
2655
+ *
2656
+ * ADR-006 写守卫({@link guardStateWrite})先问盘:版本不认或文件读不出 →
2657
+ * 返回拒绝理由(已 degrade 记录),不落笔。守卫与随后的写入在同一个临界区里
2658
+ * (由 patchPayload 的链保证),判据与执行之间不隔任何 await。
2659
+ * @param {object} body - 要持久化的 JSON 体。
2660
+ * @returns {Promise<string|null>} 拒绝原因,或 `null` = 已写入。
2661
+ */
2662
+ const writePayload = async (body) => {
2663
+ const refusal = await guardStateWrite(filePath, KNOWN_USAGE_PREFS_VERSIONS, {
2664
+ label: "usage-prefs-state",
2665
+ logger
2666
+ });
2667
+ if (refusal !== null) return refusal;
2668
+ await ensureStateDir(stateDir);
2669
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "usage-prefs.json") });
2670
+ return null;
2671
+ };
2672
+ /** 写串行化:读-改-写必须排队(详见{@link createStateWriteQueue})。 */
2673
+ const writeQueue = createStateWriteQueue();
2674
+ /**
2675
+ * 解析一份 payload;认不出的版本读作「没设过」但**记住**版本号——读侧宽容、
2676
+ * versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是 ADR-006)。
2677
+ */
2678
+ const parsePayload = async () => {
2679
+ const source = obj(await readStateJson(filePath));
2680
+ const version = typeof source.version === "number" ? source.version : null;
2681
+ if (!isKnownUsagePrefsVersion(version)) return {
2682
+ windowDays: null,
2683
+ version
2684
+ };
2685
+ return {
2686
+ windowDays: normalizeWindowDays(source.windowDays),
2687
+ version
2688
+ };
2689
+ };
2690
+ const cache = createStateReadCache(parsePayload, { ttlMs });
2691
+ /**
2692
+ * 读当前 payload。
2693
+ * @returns {Promise<UsagePrefsPayload>}
2694
+ */
2695
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
2696
+ /**
2697
+ * 读-改-写,**整段排队**。
2698
+ *
2699
+ * 基线每次从磁盘重读(`parsePayload` 直读,不走 TTL 缓存):两个 Host 进程共享
2700
+ * 一个状态目录,而缓存里可能是写入前的值(provider §8d 的回归用例在 draw 上
2701
+ * 同样成立,见 draw-store.ts)。写成功即 `cache.remember`。
2702
+ *
2703
+ * 失败语义与 provider / draw 对齐:拒绝原因与 IO 失败都**向上抛**(save 抛、
2704
+ * 路由 catch 后 `respondError` 呈报),链自己吞掉失败继续走后面的保存。
2705
+ *
2706
+ * @param {number|null} windowDays - 新的显示窗口;`null` = 清除(回到配置默认)。
2707
+ * @returns {Promise<void>}
2708
+ */
2709
+ const save = (windowDays) => writeQueue.enqueue(async () => {
2710
+ const body = { version: 1 };
2711
+ if (windowDays !== null) body.windowDays = windowDays;
2712
+ body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2713
+ const refusal = await writePayload(body);
2714
+ if (refusal !== null) throw new Error(refusal);
2715
+ cache.remember({
2716
+ windowDays,
2717
+ version: 1
2718
+ });
2719
+ });
2720
+ return {
2721
+ /** 面板保存的显示窗口天数;`null` = 跟随配置默认。 */
2722
+ async windowDays() {
2723
+ return (await readPayload()).windowDays;
2724
+ },
2725
+ /**
2726
+ * 盘上带着本构建读不懂的版本时的一句说明,否则 `null`。
2727
+ *
2728
+ * 读侧宽容(未知版本读作「没设过」)的另一半:快照聚合把它转进
2729
+ * `shapeWarnings`,doctor 报同一份文件。没有它,「保存的显示窗口被忽略」在
2730
+ * 面板上就是「从未保存过」——而回退方向是配置默认。
2731
+ * @returns {Promise<string|null>}
2732
+ */
2733
+ async versionNote() {
2734
+ const version = (await readPayload()).version;
2735
+ if (version === null || isKnownUsagePrefsVersion(version)) return null;
2736
+ return stateVersionNote({
2737
+ label: "usage-prefs-state",
2738
+ file: filePath,
2739
+ version,
2740
+ known: KNOWN_USAGE_PREFS_VERSIONS,
2741
+ consequence: "the saved trend window is being answered as \"not set\", so the chart falls back to the config default."
2742
+ });
2743
+ },
2744
+ /** 保存显示窗口(`null` = 清掉,回到配置默认)。 */
2745
+ async saveWindowDays(windowDays) {
2746
+ if (windowDays !== null && !(typeof windowDays === "number" && Number.isFinite(windowDays) && Math.floor(windowDays) > 0)) throw new TypeError("trend window expects a positive integer or null");
2747
+ await save(windowDays === null ? null : Math.floor(windowDays));
2748
+ },
2749
+ /** 落盘路径(诊断与测试用)。 */
2750
+ filePath
2751
+ };
2752
+ }
2753
+
2754
+ //#endregion
2755
+ //#region src/host/draw.ts
2756
+ /**
2757
+ * 魔搭出图(`/v1/images/generations`)的**纯逻辑半边**——协议形状的解析与判定。
2758
+ *
2759
+ * 为什么是这个形状:魔搭的生图是**异步两段式**(提交得 `task_id`,轮询才出图),
2760
+ * 与姊妹插件 `dsh-connect-sensenova-token-plan/src/host/draw.ts` 的**同步单发**
2761
+ * (`drawOnce` 一次 fetch 即 `parseDrawResponse` 取 `data[0].url`)形状不兼容,
2762
+ * **不能照抄**。可复用的只有外壳(endpoint 拼接、冷却门、失败文案),核心必须新写。
2763
+ * 协议依据全部来自 `docs/SPIKE-image-gen.md` 的真机实测,非文档转述。
2764
+ *
2765
+ * 本模块**只做纯函数**:endpoint 构造、请求体构造、响应解析、状态判定、失败分诊。
2766
+ * 它**不发起任何请求、不碰凭据、不注册工具**——那半边(fetch 注入、Host 工具注册)
2767
+ * 与 `llm-adapter.ts` 一样 peer-dependent,测试覆盖同样是零(docs/ROADMAP.md Backlog)。
2768
+ * 刻意 peer-free:纯函数部分能进离线 `npm test` 门禁,`test/draw.test.mjs` 钉住形状。
2769
+ *
2770
+ * 三条承重结论(全部实测,推翻了几条广泛流传的错误写法):
2771
+ *
2772
+ * 1. **轮询必须带 `X-ModelScope-Task-Type: image_generation`**,否则上游一律返回
2773
+ * `task not found` + `task_status: FAILED`——**那是假失败**,任务实际正常。
2774
+ * 缺这个头的实现会在第一次轮询就误判失败并放弃一张已经能出的图。
2775
+ * 2. **提交响应的 `task_status` 不可信**:实测每次提交都回 `SUCCEED`,包括最终确实
2776
+ * `FAILED` 的那次。所以提交只认 `task_id`,**成功与否只看轮询终态**。
2777
+ * 3. **`FAILED` 没有可分诊的形状**:`errors.code` 实测为 `0`、`errors.message` 为
2778
+ * 空串,没有顶层 `message`。任何「从错误里读出原因」的承诺都是编造——本模块的
2779
+ * `describeDrawFailure` 因此**明确承认原因不可知**,只报可确定的形态差异。
2780
+ *
2781
+ * 关于两个小工具(`str` / `redactSecrets`):它们曾以**本地私有副本**的形式活在
2782
+ * 本文件里,理由是「避免为 3 行代码引 util 的依赖面」。那条理由不成立——util.ts
2783
+ * 自己只有 type-only import(peer-free),同包的 `draw-runtime.ts` 早就 import 它;
2784
+ * 而私有副本已经漂移:脱敏正则少了 `sk-` 与密码键值对两组、`ms-` 的 charset 与
2785
+ * util 版不一致(`[A-Za-z0-9-]` vs `[0-9a-f-]`)——脱敏闸漏一条就是凭据外泄
2786
+ * (红线 1)。现在一律用 util 的单一副本。
2787
+ *
2788
+ * @module dsh-connect-modelscope-token-plan/draw
2789
+ */
2790
+ /**
2791
+ * 出图请求的截止时间(墙钟毫秒)。
2792
+ *
2793
+ * **15 分钟**:实测同一模型的耗时在 61s–537s 之间大幅波动(免费池排队),
2794
+ * 且**超时放弃不等于任务死亡**——上游任务会继续跑完(记录之后还会被回收,
2795
+ * 见 docs/SPIKE-image-gen.md §坑 5)。宁可多等,不要扔掉一张已在生成的图。
2796
+ */
2797
+ const DRAW_TIMEOUT_MS = 9e5;
2798
+ /**
2799
+ * 两次轮询之间的间隔(毫秒)。
2800
+ *
2801
+ * 取 15s 的**真正理由**是「够用且不浪费」:实测最慢一次 537s,15s 间隔意味着
2802
+ * 最多几十次查询,代价可忽略;而更密(社区示例的 5s)没有观测收益。
2803
+ *
2804
+ * ⚠️ 早先曾写「更密的轮询拖慢任务」,**该归因已推翻**——后续对照显示同一模型、
2805
+ * 同样 15s 轮询的耗时是 61s → 261s → 537s 单调恶化,是**上游排队时长在波动**。
2806
+ * 那次构成「证据」的 5s vs 30s 对照,控制变量没做,不足以支撑因果。
2807
+ * 详见 docs/SPIKE-image-gen.md §坑 4(含撤回记录)。
2808
+ */
2809
+ const DRAW_POLL_INTERVAL_MS = 15e3;
2810
+ /**
2811
+ * 一次**工具调用**里愿意等的时间(毫秒)。
2812
+ *
2813
+ * 出图工具是给 agent 用的,一次调用阻塞 = 一整轮对话被占住。15 分钟的总预算
2814
+ * ({@link DRAW_TIMEOUT_MS})是「这张图值不值得继续等」的上限,不是「一次调用
2815
+ * 该不该堵住 agent」的上限——两者同一个数,就是把上游排队的代价转嫁成对话的
2816
+ * 停顿。
2817
+ *
2818
+ * 取 90s:实测耗时 61s–537s 大幅波动,90s 能一次拿完最快那一档(61s),而
2819
+ * 更慢的本来就必须分段查(见 `draw-runtime.ts` 的两段式工具)。再往上加只是
2820
+ * 把「等得久」换成「卡得久」,并不改变「慢的图要分几次查」这个事实。
2821
+ */
2822
+ const DRAW_WAIT_BUDGET_MS = 9e4;
2823
+ /**
2824
+ * 轮询必须携带的头名与取值。
2825
+ *
2826
+ * 漏掉它 → 上游返回 `task not found` + `FAILED`,而任务其实在正常跑。这是本协议
2827
+ * 最贵的单点知识,也是第一份外部资料缺失的那一块。
2828
+ */
2829
+ const TASK_TYPE_HEADER = "X-ModelScope-Task-Type";
2830
+ const TASK_TYPE_IMAGE_GENERATION = "image_generation";
2831
+ /**
2832
+ * 一次出图请求由几个 HTTP 请求组成:一次提交 + N 次轮询。
2833
+ * 只作为文档性常量,供工具描述与测试引用。
2834
+ */
2835
+ const DRAW_PHASE = Object.freeze({
2836
+ submit: "submit",
2837
+ poll: "poll"
2838
+ });
2839
+ /**
2840
+ * 读一个响应的 `task_status`,取不到返回 `null`。
2841
+ *
2842
+ * 刻意**不猜**:字段缺失或类型不对都返回 `null`,由 `isTerminalStatus` 把它当作
2843
+ * 「非终态」继续等——这样一次形状漂移最多变成多轮轮询,不会变成误报失败。
2844
+ * @param {unknown} data - 已解析的 JSON。
2845
+ * @returns {DrawTaskStatus|null} 规范化状态,取不到为 `null`。
2846
+ */
2847
+ function readTaskStatus(data) {
2848
+ if (data === null || typeof data !== "object") return null;
2849
+ const raw = data.task_status;
2850
+ if (typeof raw !== "string") return null;
2851
+ const upper = raw.trim().toUpperCase();
2852
+ if (upper === "PENDING" || upper === "RUNNING" || upper === "SUCCEED" || upper === "FAILED") return upper;
2853
+ return null;
2854
+ }
2855
+ /**
2856
+ * 一个状态是不是终态。
2857
+ *
2858
+ * **只有** `SUCCEED` / `FAILED` 是终态;`null`(读不到)与一切未知值都**不是**。
2296
2859
  * 「未知值继续等」是刻意的:把上游新增的状态误判成失败会丢掉一张已经能出的图,
2297
2860
  * 而误判成进行中最多多等几轮(受 `DRAW_POLL_DEADLINE_MS` 兜底)。
2298
2861
  * @param {DrawTaskStatus|null} status - 规范化状态。
@@ -2361,17 +2924,17 @@ function readOutputImages(data) {
2361
2924
  */
2362
2925
  function buildDrawBody(options) {
2363
2926
  const body = {
2364
- model: strOrEmpty(options.model),
2365
- prompt: strOrEmpty(options.prompt)
2927
+ model: str(options.model, ""),
2928
+ prompt: str(options.prompt, "")
2366
2929
  };
2367
2930
  const imageUrl = normalizeImageUrlList(options.imageUrl);
2368
2931
  if (imageUrl.length > 0) body.image_url = imageUrl;
2369
- const size = strOrEmpty(options.size);
2932
+ const size = str(options.size, "");
2370
2933
  if (size !== "") body.size = size;
2371
2934
  if (Number.isFinite(options.steps)) body.steps = options.steps;
2372
2935
  if (Number.isFinite(options.guidance)) body.guidance = options.guidance;
2373
2936
  if (Number.isFinite(options.seed)) body.seed = options.seed;
2374
- const negativePrompt = strOrEmpty(options.negativePrompt);
2937
+ const negativePrompt = str(options.negativePrompt, "");
2375
2938
  if (negativePrompt !== "") body.negative_prompt = negativePrompt;
2376
2939
  const loras = options.loras;
2377
2940
  if (typeof loras === "string" && loras.trim() !== "") body.loras = loras.trim();
@@ -2438,9 +3001,9 @@ function describeDrawFailure(failure) {
2438
3001
  if (status === 404) return `出图失败:HTTP 404 —— 端点或模型不存在(检查 apiBase 与模型 id)${tail}`;
2439
3002
  if (status === 429) return `出图失败:HTTP 429 —— 上游限流,请稍后重试${tail}`;
2440
3003
  if (status >= 500) return `出图失败:HTTP ${status} —— 上游服务异常,稍后重试${tail}`;
2441
- const cause = strOrEmpty(failure.cause);
3004
+ const cause = str(failure.cause, "");
2442
3005
  if (cause !== "") return `出图失败:${cause}${tail}`;
2443
- const taskStatus = strOrEmpty(failure.taskStatus);
3006
+ const taskStatus = str(failure.taskStatus, "");
2444
3007
  if (taskStatus === "FAILED") return "出图失败:上游任务 FAILED,但未给出失败原因(实测 errors.code=0、message 为空),换模型或稍后重试";
2445
3008
  if (taskStatus !== "") return `出图失败:任务停在 ${taskStatus}(非终态),已超过截止时间`;
2446
3009
  return `出图失败:上游未给出可诊断的失败信息${tail}`;
@@ -2471,13 +3034,61 @@ function createDrawCooldown(cooldownMs) {
2471
3034
  }
2472
3035
  };
2473
3036
  }
2474
- /** 读一个字符串,否则空串(本地化的小工具,避免为 3 行代码再引 util 的依赖面)。 */
2475
- function strOrEmpty(value) {
2476
- return typeof value === "string" ? value.trim() : "";
2477
- }
2478
- /** 凭据脱敏:出图失败消息可能带上游回显的 body,令牌不能随它进日志或面板。 */
2479
- function redactSecrets(text) {
2480
- return text.replace(/(["']?[Aa]uthorization["']?\s*[:=]\s*["']?)(?!Bearer\s)[^"',;\s]+/g, "$1[REDACTED]").replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [REDACTED]").replace(/\bms-[A-Za-z0-9-]{16,}/g, "ms-[REDACTED]");
3037
+
3038
+ //#endregion
3039
+ //#region src/host/draw-jobs.ts
3040
+ /**
3041
+ * 建一张出图作业表。
3042
+ * @param {DrawJobsOptions} [options] - 容量上限。
3043
+ * @returns {DrawJobs} 作业表。
3044
+ */
3045
+ function createDrawJobs(options = {}) {
3046
+ const maxJobs = Math.max(1, Math.floor(options.maxJobs ?? 64));
3047
+ /** @type {Map<string, DrawJob>} 插入顺序即淘汰顺序(Map 保序)。 */
3048
+ const jobs = /* @__PURE__ */ new Map();
3049
+ const evictIfNeeded = () => {
3050
+ while (jobs.size > maxJobs) {
3051
+ const oldest = jobs.keys().next();
3052
+ if (oldest.done === true) return;
3053
+ jobs.delete(oldest.value);
3054
+ }
3055
+ };
3056
+ return {
3057
+ create({ taskId, model, prompt, now }) {
3058
+ const jobId = taskId;
3059
+ const job = {
3060
+ jobId,
3061
+ taskId,
3062
+ model,
3063
+ prompt,
3064
+ submittedAt: typeof now === "number" && Number.isFinite(now) ? now : Date.now(),
3065
+ polls: 0
3066
+ };
3067
+ const previous = jobs.get(jobId);
3068
+ if (previous !== void 0) job.polls = previous.polls;
3069
+ jobs.set(jobId, job);
3070
+ evictIfNeeded();
3071
+ return { ...job };
3072
+ },
3073
+ get(jobId) {
3074
+ const found = jobs.get(jobId);
3075
+ return found === void 0 ? null : { ...found };
3076
+ },
3077
+ notePolls(jobId, polls) {
3078
+ const found = jobs.get(jobId);
3079
+ if (found === void 0) return;
3080
+ found.polls += Math.max(0, Math.floor(polls));
3081
+ },
3082
+ drop(jobId) {
3083
+ jobs.delete(jobId);
3084
+ },
3085
+ dispose() {
3086
+ jobs.clear();
3087
+ },
3088
+ size() {
3089
+ return jobs.size;
3090
+ }
3091
+ };
2481
3092
  }
2482
3093
 
2483
3094
  //#endregion
@@ -2488,7 +3099,14 @@ function redactSecrets(text) {
2488
3099
  * 与 `llm-adapter.ts` 同一个处境——import Host 发行的 peer(`@deepseek-ai/dsh-tools`
2489
3100
  * 的 `defineTool` 由调用方惰性注入),**不能由离线单测套件导入**。纯逻辑全部住在
2490
3101
  * `draw.ts`(已由 `test/draw.test.mjs` 覆盖 39 组),本文件只做「接线 + I/O」:
2491
- * 轮询循环、fetch、工具定义。改错本文件**不会让任何门禁变红**。
3102
+ * 轮询循环、fetch、工具定义。
3103
+ *
3104
+ * **覆盖边界**(2026-10-16 复核):`runDrawOnce` / `pickDrawModel` / `drawModelIds` /
3105
+ * `splitImageUrls` / `buildGenerationsEndpoint` / `buildTasksEndpoint` /
3106
+ * `DRAW_TOOL_NAME` 由 `test/draw-runtime.test.mjs` 钉住;**没被覆盖的是
3107
+ * `defineDrawTool` 落到 peer registry 的那一段**(真 `defineTool` 形状与注册结果)
3108
+ * ——只改那一段不会让任何门禁变红。本文件此前整段自称「无覆盖」,该陈述在补上
3109
+ * 这个套件之后已经过时。
2492
3110
  *
2493
3111
  * 与姊妹插件的关键差异(形状不兼容,不能照抄):
2494
3112
  * `sensenova` 的出图是**同步单发**——一次 `fetch` 拿 `data[0].url` 就结束。魔搭是
@@ -2507,19 +3125,6 @@ function redactSecrets(text) {
2507
3125
  */
2508
3126
  /** agent 工具名。带插件前缀,避免与 `dsh-draw-router` 的 `draw_image` 撞名。 */
2509
3127
  const DRAW_TOOL_NAME = "modelscope_draw_image";
2510
- /**
2511
- * 已知能**纯文生图**的目录内模型 id(2026-10-16 真机复核)。
2512
- *
2513
- * 为什么需要手工策展:api-inference 目录里至今**没有**平台声明的 `text-to-image`
2514
- * 条目,只有 2 条 `image-to-image`——而两者行为**不同**:
2515
- * `Qwen/Qwen-Image-Edit` 不带 `image_url` 纯文生图可用(本轮实测:提交成功、两次
2516
- * 15min 排队超时后于 9.7min 出图);`MusePublic/Qwen-Image-Edit` 纯文生图被上游
2517
- * 直接拒绝(提交阶段 HTTP 4xx:`image_url is required for model: …`)。
2518
- * 能力枚举把两者都归为 `image-to-image`,区分不了这个差异,所以这里用实测知识兜底:
2519
- * 目录里没有 `text-to-image` 可挑时,默认偏向这个已知能用的 id,而不是「目录第一个」
2520
- * (真实目录顺序 `MusePublic/…` 在前,照旧逻辑纯文生图必挂)。
2521
- */
2522
- const KNOWN_T2I_MODEL_ID = "Qwen/Qwen-Image-Edit";
2523
3128
  /** 一次失败的冷却窗:出图慢,失败后立刻重试既易撞限流又白等。 */
2524
3129
  const DRAW_FAILURE_COOLDOWN_MS = 6e4;
2525
3130
  /**
@@ -2559,28 +3164,16 @@ function isTerminalPayload(data) {
2559
3164
  return isTerminalStatus(readTaskStatus(data));
2560
3165
  }
2561
3166
  /**
2562
- * 真正跑一次出图:提交 + 轮询到终态。
3167
+ * **第一段**:提交一次出图,只要 `task_id`。
2563
3168
  *
2564
- * 两段都必须带各自必需的头:提交带 `X-ModelScope-Async-Mode`(实测可省但官方示例
2565
- * 都带,带上无害),轮询带 `X-ModelScope-Task-Type`(**必需**,漏了必假失败)。
2566
- * @param {object} options - 调用参数。
2567
- * @param {DrawFetch} options.fetchImpl - 注入的 fetch。
2568
- * @param {string} options.apiBase - apiBase。
2569
- * @param {string} options.apiKey - 访问令牌(绝不出现在返回文本里)。
2570
- * @param {string} options.model - 模型 id。
2571
- * @param {string} options.prompt - 提示词。
2572
- * @param {string|string[]} [options.imageUrl] - 输入图(图生图 / 多图编辑)。
2573
- * @param {string} [options.size] - `"宽x高"`。
2574
- * @param {number} [options.steps] - 采样步数。
2575
- * @param {number} [options.guidance] - 引导系数。
2576
- * @param {number} [options.seed] - 种子。
2577
- * @param {string} [options.negativePrompt] - 负向提示词。
2578
- * @param {number} [options.pollDeadlineMs] - 轮询截止。
2579
- * @param {number} [options.pollIntervalMs] - 轮询间隔(测试可缩短)。
2580
- * @returns {Promise<DrawOutcome>} 出图结果。
2581
- * @throws {Error} 提交失败、任务 FAILED、或超时——消息已过 `describeDrawFailure`。
3169
+ * 提交必须带 `X-ModelScope-Async-Mode`(实测可省但官方示例都带,带上无害)。
3170
+ * 响应里的 `task_status` **一律忽略**——实测恒为 `SUCCEED`,连最终 `FAILED` 的那次
3171
+ * 也是,所以成功与否只看轮询终态(这条由 `readTaskId` 钉在纯逻辑层)。
3172
+ * @param {DrawSubmitInput} input - 提交参数。
3173
+ * @returns {Promise<{taskId: string}>} 上游任务 id(= 续查用的 jobId)。
3174
+ * @throws {Error} 提交失败——消息已过 `describeDrawFailure`。
2582
3175
  */
2583
- async function runDrawOnce({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl, size, steps, guidance, seed, negativePrompt, pollDeadlineMs = DRAW_POLL_DEADLINE_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS }) {
3176
+ async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl, size, steps, guidance, seed, negativePrompt }) {
2584
3177
  const headers = {
2585
3178
  Authorization: `Bearer ${apiKey}`,
2586
3179
  "Content-Type": "application/json"
@@ -2618,13 +3211,48 @@ async function runDrawOnce({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl
2618
3211
  bodyText: submitText,
2619
3212
  cause: "提交响应里没有 task_id"
2620
3213
  }));
2621
- const deadline = Date.now() + Math.max(1e3, Math.floor(pollDeadlineMs));
3214
+ return { taskId };
3215
+ }
3216
+ /**
3217
+ * **第二段**:在给定预算内轮询一个任务,直到终态或预算耗尽。
3218
+ *
3219
+ * 与 `runDrawOnce` 的差别只有一处:预算耗尽时**返回 `pending` 而不是抛错**——
3220
+ * 抛错是「这件事做不成了」,`pending` 是「还没成,可以再查」。同一段循环因此
3221
+ * 同时服务于两种调用形状(一次性跑完 / 分段续查),不写两份。
3222
+ *
3223
+ * 轮询带 `X-ModelScope-Task-Type`(**必需**,漏了必假失败);传输层抖动与非
3224
+ * JSON 响应体都**不升级为终局**,继续等下一轮。
3225
+ * @param {object} options - 等待参数。
3226
+ * @param {DrawFetch} options.fetchImpl - 注入的 fetch。
3227
+ * @param {string} options.apiBase - apiBase。
3228
+ * @param {string} options.apiKey - 访问令牌。
3229
+ * @param {string} options.taskId - 上游任务 id。
3230
+ * @param {number} [options.waitMs] - 本次调用的等待预算。
3231
+ * @param {number} [options.pollIntervalMs] - 轮询间隔(测试可缩短)。
3232
+ * @returns {Promise<DrawAwait>} 三种结局之一。
3233
+ */
3234
+ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS, signal }) {
3235
+ const headers = {
3236
+ Authorization: `Bearer ${apiKey}`,
3237
+ "Content-Type": "application/json"
3238
+ };
3239
+ const deadline = Date.now() + Math.max(1, Math.floor(waitMs));
2622
3240
  const interval = Math.max(1, Math.floor(pollIntervalMs));
2623
3241
  let polls = 0;
2624
3242
  let lastStatus;
2625
3243
  for (;;) {
2626
- if (Date.now() >= deadline) throw new Error(describeDrawFailure({ cause: `超过截止时间(${Math.round(pollDeadlineMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${taskId})**可能仍在生成**——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关。稍后可重试;注意超时后原任务的结果不保证还能取回` }));
2627
- await sleep(interval);
3244
+ if (Date.now() >= deadline) return {
3245
+ kind: "pending",
3246
+ polls
3247
+ };
3248
+ try {
3249
+ await sleep(interval, signal);
3250
+ } catch {
3251
+ return {
3252
+ kind: "pending",
3253
+ polls
3254
+ };
3255
+ }
2628
3256
  polls++;
2629
3257
  let polled;
2630
3258
  try {
@@ -2639,29 +3267,37 @@ async function runDrawOnce({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl
2639
3267
  continue;
2640
3268
  }
2641
3269
  const pollText = await safeText(polled);
2642
- if (!polled.ok) throw new Error(describeDrawFailure({
2643
- ...polled.status !== void 0 ? { status: polled.status } : {},
2644
- ...pollText !== "" ? { bodyText: pollText } : {},
2645
- ...lastStatus !== void 0 ? { taskStatus: lastStatus } : {}
2646
- }));
3270
+ if (!polled.ok) return {
3271
+ kind: "failed",
3272
+ message: describeDrawFailure({
3273
+ ...polled.status !== void 0 ? { status: polled.status } : {},
3274
+ ...pollText !== "" ? { bodyText: pollText } : {},
3275
+ ...lastStatus !== void 0 ? { taskStatus: lastStatus } : {}
3276
+ })
3277
+ };
2647
3278
  const data = await safeJson(polled, pollText);
2648
3279
  const status = readTaskStatus(data);
2649
3280
  if (status !== null) lastStatus = status;
2650
3281
  if (!isTerminalPayload(data)) continue;
2651
- if (status === "FAILED") throw new Error(describeDrawFailure({
2652
- taskStatus: "FAILED",
2653
- bodyText: pollText
2654
- }));
3282
+ if (status === "FAILED") return {
3283
+ kind: "failed",
3284
+ message: describeDrawFailure({
3285
+ taskStatus: "FAILED",
3286
+ bodyText: pollText
3287
+ })
3288
+ };
2655
3289
  const images = readOutputImages(data);
2656
- if (images.length === 0) throw new Error(describeDrawFailure({
2657
- taskStatus: "SUCCEED",
2658
- bodyText: pollText,
2659
- cause: "任务报 SUCCEED 但没有可用图片"
2660
- }));
3290
+ if (images.length === 0) return {
3291
+ kind: "failed",
3292
+ message: describeDrawFailure({
3293
+ taskStatus: "SUCCEED",
3294
+ bodyText: pollText,
3295
+ cause: "任务报 SUCCEED 但没有可用图片"
3296
+ })
3297
+ };
2661
3298
  return {
2662
- model,
3299
+ kind: "done",
2663
3300
  images,
2664
- taskId,
2665
3301
  polls
2666
3302
  };
2667
3303
  }
@@ -2691,16 +3327,7 @@ async function runDrawOnce({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl
2691
3327
  * @returns {string|null} 选中的 id。
2692
3328
  */
2693
3329
  function pickDrawModel(entries, requested, preferred) {
2694
- const want = str(requested, "");
2695
- if (want !== "") return want;
2696
- const preferredId = str(preferred, "");
2697
- if (preferredId !== "") return preferredId;
2698
- const ids = imageGenModelIds(entries);
2699
- if (ids.length === 0) return null;
2700
- const t2i = textToImageModelIds(entries);
2701
- if (t2i.length > 0) return t2i[0] ?? null;
2702
- if (ids.includes("Qwen/Qwen-Image-Edit")) return KNOWN_T2I_MODEL_ID;
2703
- return ids[0] ?? null;
3330
+ return resolveDrawModelChoice(entries, requested, preferred).id;
2704
3331
  }
2705
3332
  /**
2706
3333
  * 目录里可用于出图的模型 id(工具结果里回显,方便 agent 与用户看到实际候选)。
@@ -2711,30 +3338,121 @@ function drawModelIds(entries) {
2711
3338
  return imageGenModelIds(entries);
2712
3339
  }
2713
3340
  /**
2714
- * 装配 agent 工具定义。
3341
+ * 装配 agent 工具定义——**两段式**,一次调用不再阻塞整轮对话。
3342
+ *
3343
+ * ## 为什么是两段式
2715
3344
  *
2716
- * 工具只做「参数归一 → 跑一次 → 组装结果文本」,所有判定都委托给 `draw.ts` 的纯函数。
3345
+ * 上游出图要排队,实测 61s–537s(免费池)。旧形状是「一次调用 await 到终态」,
3346
+ * 于是 `drawTimeoutMs`(15 分钟)既是「这张图值不值得等」的上限,也成了「一轮
3347
+ * 对话被卡多久」的上限——把上游排队的代价直接转嫁成对话停顿。
3348
+ *
3349
+ * 现在一次调用只做两件事之一:
3350
+ * - **不带 `jobId`** → 提交任务,然后只在 `waitMs`(默认 {@link DRAW_WAIT_BUDGET_MS})
3351
+ * 内等;等到就返回 `done`,没等到就返回 `pending` + `jobId`;
3352
+ * - **带 `jobId`** → 只查那个任务,同样最多等 `waitMs`。
3353
+ *
3354
+ * `drawTimeoutMs` 因此回到它本来的语义:**从提交算起的总放弃线**(跨调用累计),
3355
+ * 越过它才报「放弃」。
3356
+ *
3357
+ * ## 两个刻意的判定
3358
+ *
3359
+ * - `pending` **不是失败**:不抛错、不进冷却门。它只表示「这次调用愿意等的时间里
3360
+ * 没等到」,进冷却就会把续查这条路堵死——那正是分段查询的目的所在。
3361
+ * - 只有**真失败**(提交失败 / 上游 `FAILED` / 越过总放弃线)才 `cooldown.fail()`。
3362
+ *
3363
+ * 工具仍只做「参数归一 → 跑一次 → 组装结果文本」,所有判定都委托给 `draw.ts` 的纯函数。
2717
3364
  * @param {object} options - 装配参数。
2718
3365
  * @param {(definition: object) => unknown} options.defineTool - peer 的工厂。
2719
3366
  * @param {DrawFetch} options.fetchImpl - 注入的 fetch。
2720
3367
  * @param {() => Promise<string>} options.resolveApiKey - 每次调用现读令牌。
2721
3368
  * @param {() => unknown|Promise<unknown>} [options.getEntries] - 目录条目读取器。
2722
- * @param {object} [options.settings] - `{ apiBase, drawModelId, drawTimeoutMs }`。
3369
+ * @param {object} [options.settings] - `{ apiBase, drawModelId, drawTimeoutMs, drawWaitMs }`。
2723
3370
  * @param {ReturnType<typeof createDrawCooldown>} [options.cooldown] - 冷却门。
3371
+ * @param {DrawJobs} [options.jobs] - 作业表(可注入,便于测试)。
2724
3372
  * @param {() => boolean} [options.isDisposed] - 卸载闸。
2725
3373
  * @returns {unknown} 已定义的 agent 工具。
2726
3374
  */
2727
- function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, settings, getPreferredModelId, cooldown = createDrawCooldown(DRAW_FAILURE_COOLDOWN_MS), isDisposed = () => false }) {
3375
+ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, settings, getPreferredModelId, cooldown = createDrawCooldown(DRAW_FAILURE_COOLDOWN_MS), jobs = createDrawJobs(), pollIntervalMs = DRAW_POLL_INTERVAL_MS, isDisposed = () => false }) {
3376
+ /** 总放弃线:从提交算起,跨多次调用累计。 */
2728
3377
  const timeoutMs = Math.max(3e4, Math.floor(Number(settings?.drawTimeoutMs) > 0 ? Number(settings?.drawTimeoutMs) : DRAW_TIMEOUT_MS));
3378
+ /**
3379
+ * 单次调用愿意等的时间。
3380
+ *
3381
+ * 只夹**上界**(不该超过总放弃线,超过它的等待没有意义);下界交给
3382
+ * `host-config` 的 `drawWaitMs` clamp——同一个数在两处各夹一次,上限/下限就会
3383
+ * 各说一套话(`drawTimeoutMs` 的 clamp 早已是本仓库的教训)。
3384
+ */
3385
+ const waitMs = Math.min(timeoutMs, Math.max(1, Math.floor(Number(settings?.drawWaitMs) > 0 ? Number(settings?.drawWaitMs) : DRAW_WAIT_BUDGET_MS)));
2729
3386
  const apiBase = str(settings?.apiBase, "https://api-inference.modelscope.cn/v1");
3387
+ const abortController = new AbortController();
3388
+ const abortIfDisposed = () => {
3389
+ if (isDisposed()) abortController.abort();
3390
+ };
3391
+ /**
3392
+ * 等一个作业,把 {@link awaitDrawTask} 的三种结局翻译成工具结果或错误。
3393
+ *
3394
+ * 两段共用这一段——「刚提交的」与「续查的」在等待这件事上没有区别,唯一的区别
3395
+ * 是作业表里那条记录从哪来。
3396
+ * @param {string} apiKey - 访问令牌。
3397
+ * @param {DrawJob} job - 作业(含提交时刻与累计轮询数)。
3398
+ * @param {string[]} available - 目录里的出图模型(续查时为空,不必再拉目录)。
3399
+ * @returns {Promise<DrawToolResult>} 工具结果。
3400
+ * @throws {Error} 真失败(上游 FAILED / 轮询 HTTP 失败)或越过总放弃线。
3401
+ */
3402
+ const waitAndReport = async (apiKey, job, available) => {
3403
+ abortIfDisposed();
3404
+ const awaited = await awaitDrawTask({
3405
+ fetchImpl,
3406
+ apiBase,
3407
+ apiKey,
3408
+ taskId: job.taskId,
3409
+ waitMs,
3410
+ pollIntervalMs,
3411
+ signal: abortController.signal
3412
+ });
3413
+ if (awaited.kind === "failed") {
3414
+ jobs.drop(job.jobId);
3415
+ throw new Error(awaited.message);
3416
+ }
3417
+ jobs.notePolls(job.jobId, awaited.polls);
3418
+ const polls = job.polls + awaited.polls;
3419
+ if (awaited.kind === "done") {
3420
+ jobs.drop(job.jobId);
3421
+ const urls = awaited.images.join("\n");
3422
+ return {
3423
+ source: "modelscope",
3424
+ status: "done",
3425
+ jobId: job.jobId,
3426
+ model: job.model,
3427
+ url: awaited.images[0] ?? "",
3428
+ prompt: job.prompt,
3429
+ hint: `图片已生成(上游异步任务,${polls} 次轮询,task ${job.taskId})\n模型: ${job.model}\n${urls}` + (available.length > 0 ? `\n可用出图模型: ${available.join(", ")}` : "")
3430
+ };
3431
+ }
3432
+ const elapsedMs = Date.now() - job.submittedAt;
3433
+ if (elapsedMs >= timeoutMs) throw new Error(describeDrawFailure({ cause: `超过总预算(${Math.round(timeoutMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${job.taskId})可能仍在生成——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关;注意放弃后原任务的结果不保证还能取回` }));
3434
+ return {
3435
+ source: "modelscope",
3436
+ status: "pending",
3437
+ jobId: job.jobId,
3438
+ model: job.model,
3439
+ url: "",
3440
+ prompt: job.prompt,
3441
+ hint: `仍在生成中(已等 ${Math.round(elapsedMs / 1e3)}s,${polls} 次轮询,task ${job.taskId}):上游排队通常 1–15 分钟。稍后带 jobId ${job.jobId} 再调用本工具即可继续查询(只查询、不会重复出图)`
3442
+ };
3443
+ };
2730
3444
  return defineTool({
2731
3445
  name: DRAW_TOOL_NAME,
2732
- description: "Generate or edit an image with the ModelScope (魔搭) API-Inference token. Text-to-image by default; pass `imageUrl` to edit an existing image (image-to-image), or an array of URLs for multi-image editing. Omit `model` to use the catalogue's image model. This call is ASYNCHRONOUS upstream: the model queues first, so latency varies from ~1 minute to 15+ minutes depending on the free pool; it polls until the task reaches a terminal state. Available image model ids are reported in the result.",
3446
+ description: "Generate or edit an image with the ModelScope (魔搭) API-Inference token. Text-to-image by default; pass `imageUrl` to edit an existing image (image-to-image), or several newline-separated URLs for multi-image editing. Omit `model` to use the catalogue's image model. TWO-PHASE: upstream generation queues, so latency varies from ~1 minute to 15+ minutes. Call without `jobId` to submit and wait briefly; if the result is `status: pending`, call again with the returned `jobId` to keep polling. A pending result is NOT a failure — the image is still being generated. Available image model ids are reported in the result.",
2733
3447
  parameters: {
2734
3448
  prompt: {
2735
3449
  type: "string",
2736
3450
  required: true,
2737
- description: "Image prompt; describe the target image (for edits, describe the intended change)"
3451
+ description: "Image prompt; describe the target image (for edits, describe the intended change). Ignored when resuming with `jobId`."
3452
+ },
3453
+ jobId: {
3454
+ type: "string",
3455
+ description: "Resume an earlier task: pass the `jobId` returned by a previous call to keep polling it (no new image is submitted)"
2738
3456
  },
2739
3457
  model: {
2740
3458
  type: "string",
@@ -2771,6 +3489,8 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
2771
3489
  additionalProperties: false,
2772
3490
  properties: {
2773
3491
  source: { type: "string" },
3492
+ status: { type: "string" },
3493
+ jobId: { type: "string" },
2774
3494
  model: { type: "string" },
2775
3495
  url: { type: "string" },
2776
3496
  prompt: { type: "string" },
@@ -2782,15 +3502,26 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
2782
3502
  text: result?.hint || "图片已生成"
2783
3503
  }]
2784
3504
  },
2785
- timeoutMs: timeoutMs + 1e4,
3505
+ timeoutMs: waitMs + 1e4,
2786
3506
  async execute(params) {
2787
- if (isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
3507
+ if (abortController.signal.aborted || isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
3508
+ const apiKey = await resolveApiKey();
3509
+ if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("MODELSCOPE_API_KEY 未配置:在面板粘贴 ms- 访问令牌,或设置该环境变量");
3510
+ const resumeId = str(params?.jobId, "");
3511
+ if (resumeId !== "") {
3512
+ const job = jobs.get(resumeId);
3513
+ if (job === null) throw new Error(`jobId ${resumeId} 不在本次会话的作业表里(插件重启过,或该 id 不是本插件发出的):原任务可能仍在生成,但它的结果不保证还能取回(上游终态记录会被回收),请重新出图`);
3514
+ try {
3515
+ return await waitAndReport(apiKey, job, []);
3516
+ } catch (error) {
3517
+ cooldown.fail();
3518
+ throw error;
3519
+ }
3520
+ }
2788
3521
  const prompt = str(params?.prompt, "");
2789
3522
  if (prompt === "") throw new Error("prompt is required");
2790
3523
  const remaining = cooldown.remainingMs();
2791
3524
  if (remaining > 0) throw new Error(`draw cooldown: 上一次出图失败,${Math.ceil(remaining / 1e3)}s 后再试`);
2792
- const apiKey = await resolveApiKey();
2793
- if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("MODELSCOPE_API_KEY 未配置:在面板粘贴 ms- 访问令牌,或设置该环境变量");
2794
3525
  let picked = [];
2795
3526
  try {
2796
3527
  picked = await getEntries?.() ?? [];
@@ -2816,7 +3547,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
2816
3547
  const guidance = toPositiveNumber(params?.guidance);
2817
3548
  const seed = toNonNegativeInt(params?.seed);
2818
3549
  const negativePrompt = str(params?.negativePrompt, "");
2819
- const outcome = await runDrawOnce({
3550
+ const { taskId } = await submitDrawTask({
2820
3551
  fetchImpl,
2821
3552
  apiBase,
2822
3553
  apiKey,
@@ -2827,18 +3558,14 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
2827
3558
  ...steps !== void 0 ? { steps } : {},
2828
3559
  ...guidance !== void 0 ? { guidance } : {},
2829
3560
  ...seed !== void 0 ? { seed } : {},
2830
- ...negativePrompt !== "" ? { negativePrompt } : {},
2831
- pollDeadlineMs: timeoutMs
3561
+ ...negativePrompt !== "" ? { negativePrompt } : {}
2832
3562
  });
2833
- const available = drawModelIds(entries);
2834
- const urls = outcome.images.join("\n");
2835
- return {
2836
- source: "modelscope",
2837
- model: outcome.model,
2838
- url: outcome.images[0] ?? "",
2839
- prompt,
2840
- hint: `图片已生成(上游异步任务,${outcome.polls} 次轮询,task ${outcome.taskId})\n模型: ${outcome.model}\n${urls}` + (available.length > 0 ? `\n可用出图模型: ${available.join(", ")}` : "")
2841
- };
3563
+ const job = jobs.create({
3564
+ taskId,
3565
+ model,
3566
+ prompt
3567
+ });
3568
+ return await waitAndReport(apiKey, job, drawModelIds(entries));
2842
3569
  } catch (error) {
2843
3570
  cooldown.fail();
2844
3571
  throw error;
@@ -2895,82 +3622,56 @@ async function safeJson(response, text) {
2895
3622
  }
2896
3623
  }
2897
3624
  }
2898
- /** 可注入的 sleep(测试可覆写;这里只提供默认实现)。 */
2899
- function sleep(ms) {
2900
- return new Promise((resolve) => setTimeout(resolve, ms));
2901
- }
2902
-
2903
- //#endregion
2904
- //#region src/host/draw-tool-state.ts
2905
- /** 当前缺席原因(`null` = 未登记 / 已清掉)。 */
2906
- let note = null;
2907
3625
  /**
2908
- * 记下缺席原因(`null` 清空)。
2909
- * @param {DrawToolAbsentReason|null} reason - 原因码。
2910
- * @returns {void}
2911
- */
2912
- function setDrawToolNote(reason) {
2913
- note = reason;
2914
- }
2915
- /**
2916
- * 读当前缺席原因。
2917
- * @returns {DrawToolAbsentReason|null} 原因码。
2918
- */
2919
- function drawToolNote() {
2920
- return note;
2921
- }
2922
- /**
2923
- * 清空(卸载时调用:下一个 Host 生命周期从干净的「未登记」开始)。
2924
- * @returns {void}
3626
+ * 可注入的 sleep(测试可覆写;这里只提供默认实现)。
3627
+ *
3628
+ * 支持 `AbortSignal`:插件 dispose 时可以立即中断等待,而不是等 sleep 自然结束。
3629
+ * 默认 15s 的轮询间隔意味着用户点「停止」后最多要等 15s 才真正停——abort 让
3630
+ * 停止信号立即生效。
2925
3631
  */
2926
- function resetDrawToolNote() {
2927
- note = null;
3632
+ function sleep(ms, signal) {
3633
+ return new Promise((resolve, reject) => {
3634
+ if (signal?.aborted) {
3635
+ reject(/* @__PURE__ */ new Error("sleep aborted"));
3636
+ return;
3637
+ }
3638
+ const timer = setTimeout(() => {
3639
+ signal?.removeEventListener("abort", onAbort);
3640
+ resolve();
3641
+ }, ms);
3642
+ const onAbort = () => {
3643
+ clearTimeout(timer);
3644
+ reject(/* @__PURE__ */ new Error("sleep aborted"));
3645
+ };
3646
+ signal?.addEventListener("abort", onAbort, { once: true });
3647
+ });
2928
3648
  }
2929
3649
 
2930
3650
  //#endregion
2931
- //#region src/host/switch-precedence.ts
2932
- /**
2933
- * 所有 opt-in 开关共用的唯一裁决:「面板保存值 vs 配置默认值」。
2934
- *
2935
- * 规则只有一条:**面板保存值赢,否则配置默认值说了算**。来源标签(`panel` /
2936
- * `config`)随答案一起返回,面板才能说出是谁在管。规则收在一处,是为了让开关
2937
- * 再也无法各自发明一套优先级方言;`test/switch-precedence.test.mjs` 钉死这个
2938
- * 方言,照抄形状的新调用方会被它标成异类。
2939
- *
2940
- * 不服务「纯面板拥有、无配置默认值」的开关——那种没有可裁决的配置默认值,
2941
- * 调用方直接读即可。peer-free,纯函数。
2942
- *
2943
- * @module dsh-connect-modelscope-token-plan/switch-precedence
2944
- */
3651
+ //#region src/host/draw-tool-state.ts
3652
+ /** 当前缺席原因(`null` = 未登记 / 已清掉)。 */
3653
+ let note = null;
2945
3654
  /**
2946
- * Resolve the effective boolean switch: a panel-saved value always wins,
2947
- * otherwise the config default rules.
2948
- * @param {boolean|null} panel - the panel-saved value (`null` = never saved).
2949
- * @param {boolean} config - the patch-declared default.
2950
- * @returns {boolean} the effective switch.
3655
+ * 记下缺席原因(`null` 清空)。
3656
+ * @param {DrawToolAbsentReason|null} reason - 原因码。
3657
+ * @returns {void}
2951
3658
  */
2952
- function resolveSwitchEnabled(panel, config) {
2953
- return (panel ?? config) === true;
3659
+ function setDrawToolNote(reason) {
3660
+ note = reason;
2954
3661
  }
2955
3662
  /**
2956
- * Resolve the effective string preference (e.g. a draw-model id): a
2957
- * panel-saved value wins, otherwise the config default rules.
2958
- * @param {string|null} panel - the panel-saved value (`null` = never saved).
2959
- * @param {string|undefined} config - the patch-declared default.
2960
- * @returns {string|undefined} the effective preference.
3663
+ * 读当前缺席原因。
3664
+ * @returns {DrawToolAbsentReason|null} 原因码。
2961
3665
  */
2962
- function resolveSwitchValue(panel, config) {
2963
- return panel ?? config;
3666
+ function drawToolNote() {
3667
+ return note;
2964
3668
  }
2965
3669
  /**
2966
- * Where the effective value came from — the panel when it saved one, the
2967
- * config otherwise. Rides with every resolved answer so the panel can name
2968
- * the side in charge.
2969
- * @param {boolean|string|null} panel - the panel-saved value.
2970
- * @returns {"panel"|"config"}
3670
+ * 清空(卸载时调用:下一个 Host 生命周期从干净的「未登记」开始)。
3671
+ * @returns {void}
2971
3672
  */
2972
- function switchSource(panel) {
2973
- return panel === null ? "config" : "panel";
3673
+ function resetDrawToolNote() {
3674
+ note = null;
2974
3675
  }
2975
3676
 
2976
3677
  //#endregion
@@ -2993,9 +3694,16 @@ function switchSource(panel) {
2993
3694
  * registry 拒收——三种都由 `draw-tool-state.ts` 记下原因供面板显示。本文件不吞
2994
3695
  * 错误(那会让「工具为什么不在」无从诊断),只把它们翻译成可展示的原因码。
2995
3696
  *
2996
- * peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule` 惰性注入,
2997
- * 因此本文件与 `llm-adapter.ts` 一样**不能被离线测试导入**,也没有测试覆盖
2998
- * (docs/ROADMAP.md Backlog 记着这条缺口)。
3697
+ * peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule` 惰性注入。
3698
+ *
3699
+ * **测试覆盖**:`test/draw-register.test.mjs`(2026-10-17 补上,此前这里是零引用)
3700
+ * 用 `deps.loadToolsModule` 这条现成的缝注入伪 `defineTool`,让真装配链原样跑起来
3701
+ * ——**不需要** peer-stub-loader。教训是:本文件此前把「peer-dependent」直接读成
3702
+ * 「测不了」,而缝早就留好了;同处境的 `llm-adapter.ts` / `draw-runtime.ts` 用
3703
+ * `test/helpers/peer-stub-loader.mjs` 证明过另一条路(那条 loader 是给**没有缝**
3704
+ * 的直连 import 用的)。`tools/dev/mutate-spec.json` 里有本文件的一组变异,用来
3705
+ * 保证那套测试真的会红——第一版测试里 `mount` 助手自己清了缺席原因,于是「过期
3706
+ * 原因会不会被清掉」那条断言是测试自己满足的,是变异测试把它抓出来的。
2999
3707
  *
3000
3708
  * @module dsh-connect-modelscope-token-plan/draw-register
3001
3709
  */
@@ -3046,7 +3754,7 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
3046
3754
  defineTool = mod?.defineTool ?? mod?.default?.defineTool ?? null;
3047
3755
  } catch (error) {
3048
3756
  setDrawToolNote("peer-load-failed");
3049
- loggerFor?.warn?.(`${name}: draw tool peer module failed to load (${redactSecrets$1(errMsg(error))}) — the tool is absent`);
3757
+ loggerFor?.warn?.(`${name}: draw tool peer module failed to load (${redactSecrets(errMsg(error))}) — the tool is absent`);
3050
3758
  return;
3051
3759
  }
3052
3760
  if (typeof defineTool !== "function") {
@@ -3068,13 +3776,14 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
3068
3776
  settings: {
3069
3777
  apiBase: settings.apiBase,
3070
3778
  drawModelId: settings.drawModelId,
3071
- drawTimeoutMs: settings.drawTimeoutMs
3779
+ drawTimeoutMs: settings.drawTimeoutMs,
3780
+ drawWaitMs: settings.drawWaitMs
3072
3781
  },
3073
3782
  isDisposed
3074
3783
  }));
3075
3784
  } catch (error) {
3076
3785
  setDrawToolNote("registry-refused");
3077
- loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets$1(errMsg(error))}) — the tool is absent`);
3786
+ loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets(errMsg(error))}) — the tool is absent`);
3078
3787
  }
3079
3788
  }
3080
3789
 
@@ -3265,7 +3974,7 @@ function describeBuildFailure(error) {
3265
3974
  const annotated = error;
3266
3975
  const why = errMsg(error);
3267
3976
  return {
3268
- note: redactSecrets$1(why),
3977
+ note: redactSecrets(why),
3269
3978
  hint: annotated?.code === "ERR_MODULE_NOT_FOUND" ? " — the llm peer packages ship with the Host; install this plugin where they resolve (or link them into its own node_modules)" : "",
3270
3979
  error
3271
3980
  };
@@ -3371,7 +4080,7 @@ function swapRegistration({ llm, built, previousBuilt, state, release, registerP
3371
4080
  release();
3372
4081
  state.built = null;
3373
4082
  onRollback?.();
3374
- state.error = redactSecrets$1(errMsg(error));
4083
+ state.error = redactSecrets(errMsg(error));
3375
4084
  restorePreviousPair({
3376
4085
  llm,
3377
4086
  previousBuilt,
@@ -3450,9 +4159,20 @@ function emitAdaptersUpdated(emit) {
3450
4159
  * - 单点 `registerPair`(带 factory-await + shape 检查)被 publish 与 rollback
3451
4160
  * 两条路共用;失败时**恢复旧对**,坏 publish 不会把已在服务的模型也拉下来。
3452
4161
  *
3453
- * 与姊妹插件的差异**只有**:工厂导出名 `"createModelScopeAdapter"`、`registerPair`
3454
- * 用的 `LLM_PROVIDER_ID`/`LLM_DISPLAY_NAME`、`warnBuildFailure` 的 label
3455
- * `"ModelScope"`。上面的四条语义原样保留——它们是 `publish-core.ts` 的骨头。
4162
+ * 结构性纪律(本文件新立的):一次 publish 拆成 **gather(可挂起)**与
4163
+ * **commit(不可挂起)**两半,commit 是**非 async 函数**——「disposed 复查与
4164
+ * registerAdapter 之间没有 await」由此前的注释论证升级为语言级保证。相位
4165
+ * (idle/serving/failing/broken/disposed)从契约字段推导,不双写。
4166
+ *
4167
+ * 与 sensenova 版的关系:**结构同源、代码不等价**——去注释后代码行重叠 77%
4168
+ * (2026-10-16 实测;复核:`node tools/dev/cross-repo-drift.mjs`)。所以差异**不止**
4169
+ * 三个标签:除工厂导出名 `"createModelScopeAdapter"`、`registerPair` 用的
4170
+ * `LLM_PROVIDER_ID`/`LLM_DISPLAY_NAME`、`warnBuildFailure` 的 label `"ModelScope"`
4171
+ * 之外,vision 判定这里取自 `llm-models.ts`(sensenova 侧走 `parsers.ts` 的
4172
+ * `identifyVisionModel`),usage 观察层的接线位置也不同。
4173
+ *
4174
+ * 可以跨仓库**对着读**的是三条承重语义(队列 / disposed 闸 / 单点 registerPair,
4175
+ * 它们是 `publish-core.ts` 的骨头,三处 91% 同源);具体代码不可照搬。
3456
4176
  *
3457
4177
  * peer-free:不 import 任何运行时 peer。适配器工厂由调用方注入
3458
4178
  * (`loadAdapterModule`,默认 `import("./llm-adapter.ts")`),所以离线套件可以换
@@ -3464,6 +4184,29 @@ function emitAdaptersUpdated(emit) {
3464
4184
  * @module dsh-connect-modelscope-token-plan/provider-publish
3465
4185
  */
3466
4186
  /**
4187
+ * 从 publisher 的契约状态**推导**相位——单源,不双写。
4188
+ *
4189
+ * `registered` / `error` 是 `publish-core.ts` 的共享契约字段(`swapRegistration`
4190
+ * 与 `unregister` 直接写它们)。并行维护一个 `phase` 字段就是双写,双写必然
4191
+ * 漂移且方向静默;推导让相位永远不可能与那两个字段矛盾。
4192
+ *
4193
+ * 相位与两道空转守卫的对应(旧版注释里的条件,现在是有名字的状态):
4194
+ * - `idle` = 没注册且无错:开关关着的稳态 → 关侧空转守卫可跳过;
4195
+ * - `serving` = 已注册且无错:offer 已 live → 开侧空转守卫可跳过;
4196
+ * - `failing` = 已注册但有错:回滚到了旧对、但新 offer 没落地 → **必须继续
4197
+ * 重试**(跳过就是静默丢弃用户刚保存的清单);
4198
+ * - `broken` = 没注册且有错:上次发布失败且什么都没在服务 → 继续重试自愈;
4199
+ * - `disposed` = 插件已 dispose:任何 publish 都是 no-op。
4200
+ * @param {{registered: boolean, error: string|null}} state - publisher 状态。
4201
+ * @param {boolean} disposed - publish 队列的 disposed 闸状态。
4202
+ * @returns {PublisherPhase} 相位。
4203
+ */
4204
+ function publisherPhaseOf(state, disposed) {
4205
+ if (disposed) return "disposed";
4206
+ if (state.registered) return state.error === null ? "serving" : "failing";
4207
+ return state.error === null ? "idle" : "broken";
4208
+ }
4209
+ /**
3467
4210
  * provider 发布器。
3468
4211
  *
3469
4212
  * 持有活注册状态(`state`);publish 队列、`disposed` 闸与单点 `registerPair` 是
@@ -3544,20 +4287,37 @@ function createProviderPublisher(deps = {}) {
3544
4287
  */
3545
4288
  const offerUnchanged = () => state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds);
3546
4289
  /**
3547
- * 为一个目录/允许清单快照(重)构建并注册 provider。
4290
+ * 发布相位——两道空转守卫的判断条件从此有名字(推导见 {@link publisherPhaseOf})。
4291
+ * @param {boolean} disposed - 当前是否已 dispose。
4292
+ * @returns {PublisherPhase}
4293
+ */
4294
+ const phaseOf = (disposed) => publisherPhaseOf(state, disposed);
4295
+ /**
4296
+ * gather 阶段:一次 publish 的**可挂起**半边。
3548
4297
  *
3549
- * 重建并重注册而非原地改:`PiAiAdapter` 内部 memoize profiles 快照,只有新的注册
3550
- * 才能改变提供的模型列表。注册失败时**恢复上一对**,所以坏 publish 永远不能把
3551
- * 已在服务的模型拉下来。
3552
- * @param {object[]} entries - 归一目录条目。
3553
- * @param {string[]} enabledIds - 允许清单(空 = 全部)。
3554
- * @param {string[]} [unavailableModelIds] - 要从 picker 的 offer 里丢掉的额度耗尽 id。
3555
- * @returns {Promise<{ok: boolean, skipped?: boolean, error?: unknown}>}
4298
+ * 读面板开关、解析 llm 服务、解析适配器工厂、构建适配器——四个 await 全住
4299
+ * 在这里。产出两种计划:`done`(跳过 / 注销 / 构建失败,都已执行完)或
4300
+ * `commit`(适配器构建好了,只剩同步提交)。
4301
+ *
4302
+ * 领域字段(entries / enabledIds / unavailableIds)在查询开关**之前**写入,
4303
+ * 开关关分支刻意不回滚它们——这个不对称是承重的,不是疏忽:`routes/provider.ts`
4304
+ * 与 roster 保存会在开关重新打开时正好从这些字段重新 publish,所以用户的
4305
+ * 允许清单编辑与最后一次拉到的目录**必须**熬过关闭期。把 `previous*` 还回去
4306
+ * 会静默丢掉两者——开关关闭期间做的保存,会在开关重新打开时消失。
4307
+ * `swapRegistration` 的 `onRollback` 会还它们,但只对注册真的抛了的路径——
4308
+ * 那种失败必须让先前在服务的对继续服务,这是与「开关关着」不同的承诺。
4309
+ * @param {unknown} entries - 归一目录条目。
4310
+ * @param {unknown} enabledIds - 允许清单(空 = 全部)。
4311
+ * @param {string[]} unavailableModelIds - 额度耗尽的模型 id。
4312
+ * @returns {Promise<PublishPlan>} 执行计划。
3556
4313
  */
3557
- const publishProviderOnce = async (entries, enabledIds, unavailableModelIds = []) => {
4314
+ const gatherPublish = async (entries, enabledIds, unavailableModelIds) => {
3558
4315
  if (queue.isDisposed()) return {
3559
- ok: false,
3560
- skipped: true
4316
+ kind: "done",
4317
+ result: {
4318
+ ok: false,
4319
+ skipped: true
4320
+ }
3561
4321
  };
3562
4322
  const previousBuilt = state.built;
3563
4323
  const previousEntries = state.entries;
@@ -3568,16 +4328,22 @@ function createProviderPublisher(deps = {}) {
3568
4328
  state.unavailableIds = Array.isArray(unavailableModelIds) ? unavailableModelIds : [];
3569
4329
  const panelValue = await effectivePanelSwitch().catch(() => null);
3570
4330
  if (!resolveSwitchEnabled(panelValue, effectiveSettings.registerProvider === true)) {
3571
- if (state.registered === false && state.error === null && offerUnchanged()) return {
3572
- ok: true,
3573
- skipped: true
4331
+ if (phaseOf(false) === "idle" && offerUnchanged()) return {
4332
+ kind: "done",
4333
+ result: {
4334
+ ok: true,
4335
+ skipped: true
4336
+ }
3574
4337
  };
3575
4338
  const result = unregister({
3576
4339
  state,
3577
4340
  release
3578
4341
  });
3579
4342
  syncSignaturesAfterPublish(state);
3580
- return result;
4343
+ return {
4344
+ kind: "done",
4345
+ result
4346
+ };
3581
4347
  }
3582
4348
  const llm = resolveRegistrationService({
3583
4349
  state,
@@ -3585,13 +4351,15 @@ function createProviderPublisher(deps = {}) {
3585
4351
  release
3586
4352
  });
3587
4353
  if (llm === null) return {
3588
- ok: false,
3589
- error: state.error
4354
+ kind: "done",
4355
+ result: {
4356
+ ok: false,
4357
+ error: state.error
4358
+ }
3590
4359
  };
3591
- let createModelScopeAdapter;
3592
4360
  let built;
3593
4361
  try {
3594
- createModelScopeAdapter = await resolveAdapterFactory();
4362
+ const createModelScopeAdapter = await resolveAdapterFactory();
3595
4363
  if (createModelScopeAdapter === void 0) throw new Error(BAD_FACTORY_SHAPE_ERROR);
3596
4364
  built = await createModelScopeAdapter({
3597
4365
  entries: state.entries,
@@ -3600,7 +4368,8 @@ function createProviderPublisher(deps = {}) {
3600
4368
  resolveApiKey: effectiveResolveApiKey,
3601
4369
  get: effectiveGetLlm,
3602
4370
  unavailableModelIds: state.unavailableIds,
3603
- ...usage !== void 0 ? { usage } : {}
4371
+ ...usage !== void 0 ? { usage } : {},
4372
+ ...usage !== void 0 ? { onError: (error) => effectiveLogger?.warn?.(`usage record failed: ${typeof error === "object" && error !== null && "message" in error ? String(error.message) : String(error)}`) } : {}
3604
4373
  });
3605
4374
  if (!isBuiltAdapter(built)) throw new Error(BAD_FACTORY_SHAPE_ERROR);
3606
4375
  } catch (e) {
@@ -3608,31 +4377,75 @@ function createProviderPublisher(deps = {}) {
3608
4377
  state.error = described.note;
3609
4378
  warnBuildFailure(effectiveLogger, "ModelScope", described);
3610
4379
  return {
3611
- ok: false,
3612
- error: described.error
4380
+ kind: "done",
4381
+ result: {
4382
+ ok: false,
4383
+ error: described.error
4384
+ }
3613
4385
  };
3614
4386
  }
3615
- if (state.registered === true && state.error === null && offerUnchanged()) return {
3616
- ok: true,
3617
- skipped: true
4387
+ if (phaseOf(false) === "serving" && offerUnchanged()) return {
4388
+ kind: "done",
4389
+ result: {
4390
+ ok: true,
4391
+ skipped: true
4392
+ }
4393
+ };
4394
+ return {
4395
+ kind: "commit",
4396
+ llm,
4397
+ built,
4398
+ previousBuilt,
4399
+ previousEntries,
4400
+ previousEnabledIds,
4401
+ previousUnavailable
3618
4402
  };
4403
+ };
4404
+ /**
4405
+ * commit 阶段:一次 publish 的**不可挂起**半边。
4406
+ *
4407
+ * **这个函数刻意不是 `async`**——语言禁止在非 async 函数里写 `await`,所以
4408
+ * 从下面的 disposed 复查到 `swapRegistration` 内部的 `registerAdapter` 之间
4409
+ * **不可能**插入挂起点。旧版靠一句「这个位置严密:从这里到 registerAdapter
4410
+ * 之间没有任何 await」的注释论证这件事;现在由函数签名本身保证——想在这里
4411
+ * 加 await,编译期就过不去。临界区的正确性由结构承载,不由注释承载。
4412
+ *
4413
+ * 为什么这道复查必须有(gather 入口那次只覆盖到第一个 await,而注册发生在
4414
+ * **最后一次** await 之后):teardown 落在这段窗口里时,入口闸门不会重跑,
4415
+ * 于是 registerAdapter 把适配器注册进一个**已经把本插件撤下的 Host**,而
4416
+ * teardown 早就跑完、release 永不调用——泄漏的对持有 tokenStore 与整个
4417
+ * Cordis ctx 的闭包,形成僵尸 provider:picker 里那个 provider 还在、还能
4418
+ * 路由,而面板路由已注销,用户没有界面能关掉它。
4419
+ *
4420
+ * 为什么 peer 的 fiber 兜底救不了:`registerAdapter` 用的是 **llm 服务自己的**
4421
+ * `ctx.effect`(peer 源码里 `.bind(this)` 到 llm 服务),插件卸载不会 dispose
4422
+ * 它,宿主注册表永久持有该 adapter。
4423
+ *
4424
+ * 若 teardown 发生在本次检查**之后**,它执行 release() 时能看到新写入的
4425
+ * releaseAdapter 并摘掉——两种时序都正确。检查刻意留在本文件(publisher)而
4426
+ * **不**放进 `swapRegistration`:那是 publish-core 的共享骨头,不该知道
4427
+ * disposed 的概念。
4428
+ * @param {Extract<PublishPlan, {kind: "commit"}>} plan - gather 产出的提交计划。
4429
+ * @returns {{ok: boolean, skipped?: boolean, error?: unknown}}
4430
+ */
4431
+ const commitPublish = (plan) => {
3619
4432
  if (queue.isDisposed()) return {
3620
4433
  ok: false,
3621
4434
  skipped: true
3622
4435
  };
3623
4436
  const requestedSignature = catalogSignature(state.entries, state.enabledIds);
3624
4437
  const result = swapRegistration({
3625
- llm,
3626
- built,
3627
- previousBuilt,
4438
+ llm: plan.llm,
4439
+ built: plan.built,
4440
+ previousBuilt: plan.previousBuilt,
3628
4441
  state,
3629
4442
  release,
3630
4443
  registerPair,
3631
4444
  emit,
3632
4445
  onRollback: () => {
3633
- state.entries = previousEntries;
3634
- state.enabledIds = previousEnabledIds;
3635
- state.unavailableIds = previousUnavailable;
4446
+ state.entries = plan.previousEntries;
4447
+ state.enabledIds = plan.previousEnabledIds;
4448
+ state.unavailableIds = plan.previousUnavailable;
3636
4449
  }
3637
4450
  });
3638
4451
  syncSignaturesAfterPublish(state);
@@ -3640,6 +4453,22 @@ function createProviderPublisher(deps = {}) {
3640
4453
  return result;
3641
4454
  };
3642
4455
  /**
4456
+ * 为一个目录/允许清单快照(重)构建并注册 provider。
4457
+ *
4458
+ * 形状 = gather(可挂起)+ commit(不可挂起):全部 await 住在前者,后者是
4459
+ * 非 async 函数。重建并重注册而非原地改:`PiAiAdapter` 内部 memoize profiles
4460
+ * 快照,只有新的注册才能改变提供的模型列表。注册失败时**恢复上一对**,所以
4461
+ * 坏 publish 永远不能把已在服务的模型拉下来。
4462
+ * @param {object[]} entries - 归一目录条目。
4463
+ * @param {string[]} enabledIds - 允许清单(空 = 全部)。
4464
+ * @param {string[]} [unavailableModelIds] - 要从 picker 的 offer 里丢掉的额度耗尽 id。
4465
+ * @returns {Promise<{ok: boolean, skipped?: boolean, error?: unknown}>}
4466
+ */
4467
+ const publishProviderOnce = async (entries, enabledIds, unavailableModelIds = []) => {
4468
+ const plan = await gatherPublish(entries, enabledIds, unavailableModelIds);
4469
+ return plan.kind === "done" ? plan.result : commitPublish(plan);
4470
+ };
4471
+ /**
3643
4472
  * publish,排到所有其他在途 publish 之后。
3644
4473
  *
3645
4474
  * 包装的存在是为了没有任何调用方要记得队列:挂载 seed、目录轮询、provider 开关、
@@ -3669,6 +4498,10 @@ function createProviderPublisher(deps = {}) {
3669
4498
  * 它只回答「重建会不会改变什么」:目录顺序里的模型 id,每个带上 descriptor 用的
3670
4499
  * 同一个 vision 判定(id 没变但模态翻了也必须重建),加上允许清单。目录条目里其他
3671
4500
  * 东西变了不影响注册的 offer。
4501
+ *
4502
+ * **不含展示名 `name`**:模型展示名变化不影响注册的 offer(descriptor 里的 `name`
4503
+ * 只是显示用的),重新注册是浪费——期间在途请求可能被打断。展示名变化不改变
4504
+ * 模型的能力、窗口或输出上限,所以不参与签名。
3672
4505
  * @param {object[]} entries - 归一目录条目。
3673
4506
  * @param {string[]} enabledIds - 允许清单(空 = 全部)。
3674
4507
  * @returns {string}
@@ -3676,8 +4509,7 @@ function createProviderPublisher(deps = {}) {
3676
4509
  function catalogSignature(entries, enabledIds) {
3677
4510
  return `${(Array.isArray(entries) ? entries : []).map((entry) => {
3678
4511
  const e = entry;
3679
- const id = str(e?.id, "");
3680
- return `${id}:v${isVisionModel(e) ? 1 : 0}:n${str(e?.name, id)}:c${typeof e?.contextWindow === "number" ? e.contextWindow : ""}:o${typeof e?.maxOutputLength === "number" ? e.maxOutputLength : ""}`;
4512
+ return `${str(e?.id, "")}:v${isVisionModel(e) ? 1 : 0}:c${typeof e?.contextWindow === "number" ? e.contextWindow : ""}:o${typeof e?.maxOutputLength === "number" ? e.maxOutputLength : ""}`;
3681
4513
  }).join(",")}|${(Array.isArray(enabledIds) ? enabledIds : []).join(",")}`;
3682
4514
  }
3683
4515
  /**
@@ -3713,6 +4545,127 @@ function syncSignaturesAfterPublish(state) {
3713
4545
  state.quotaSignature = quotaSignatureOf(unavailable);
3714
4546
  }
3715
4547
 
4548
+ //#endregion
4549
+ //#region src/host/provider-reconcile.ts
4550
+ /**
4551
+ * provider 注册的 **desired-state reconciler**——把两个各自为政的驱动收编成
4552
+ * 一个控制器。
4553
+ *
4554
+ * ## 它取代什么
4555
+ *
4556
+ * 改之前,往同一个注册态里写的有两个 driver:挂载 seed + 每 `pollSeconds`
4557
+ * 一次的目录轮询(`index.ts` 的 `runPoll`),以及四条 provider 路由的
4558
+ * 「保存后立即 publish」。两者没有共同的状态词汇表,于是 `runPoll` 不得不把
4559
+ * 窗口覆盖**读两遍**(fetch 前一次、fetch 后一次),「期间被改就跳过本轮」——
4560
+ * 那是没有 desired-state 控制器的典型补丁:丢一次更新、再等一个轮询周期。
4561
+ *
4562
+ * ## 模型
4563
+ *
4564
+ * - **desired state**(操作者想要什么)= `providerStore` 里的开关 + 允许
4565
+ * 清单 + 窗口覆盖。它只有一个家,轮询和路由都只读它、不各写一份;
4566
+ * - **reconcile 一遍** = 读 desired → 带窗口覆盖拉目录 → 复查 desired
4567
+ * (fetch 期间被改就重跑,不等下一轮)→ 把「当前目录 + 当前清单」交给
4568
+ * publisher(它自己重读开关,single source 不变);
4569
+ * - **触发方全部降级为「标记 dirty」**:挂载、轮询定时器、四条写路由。
4570
+ * 谁都不用记得「别人是不是也在 publish」——队列性质由本模块的串行链保证。
4571
+ *
4572
+ * ## 两个刻意的行为改变(相对旧 runPoll)
4573
+ *
4574
+ * 1. **fetch 失败不再发布空目录**。旧 `readEntries` 把 fetch 失败降级成
4575
+ * `entries: []` 再 publish——一次瞬时的目录抖动会把已服务的模型全部撤下
4576
+ * (破坏,不是重试)。这里失败只记原因、保留当前 offer,下一轮自然重试;
4577
+ * 轮询侧本来就该如此(旧 runPoll 正是这么做的),路由侧现在与它一致。
4578
+ * 2. **fetch 期间 desired 被改 → 重跑,不是跳过**。旧代码「跳过本轮、等
4579
+ * pollSeconds」;这里原地收敛,保存路由的 await 返回时新值一定已落地。
4580
+ *
4581
+ * peer-free:不 import 任何 Host peer。
4582
+ *
4583
+ * @module dsh-connect-modelscope-token-plan/provider-reconcile
4584
+ */
4585
+ /**
4586
+ * 创建 provider reconciler。
4587
+ *
4588
+ * 串行链 + dirty 标志:每次触发(轮询/路由/挂载)都排在链尾,链上每一环先看
4589
+ * dirty 再决定要不要真跑——在途那一轮会替后来者消费 dirty,所以 N 次连续触发
4590
+ * 最多收敛成两遍 reconcile,不会排 N 遍。调用方 await 返回的 promise,即
4591
+ * 「我的触发已被消费」。
4592
+ * @param {ProviderReconcilerDeps} deps - wiring 子集。
4593
+ * @returns {{reconcile: () => Promise<void>}} 触发一次收敛。
4594
+ */
4595
+ function createProviderReconciler(deps) {
4596
+ const { providerStore, inference, publisher, logger } = deps;
4597
+ /**
4598
+ * 读一次 desired。三路都 `.catch(() => null)`:读不到按「未设置」降级,
4599
+ * 与开关/清单/覆盖各自的读侧纪律一致——一次读失败不该把收敛打掉。
4600
+ * @returns {Promise<Desired>}
4601
+ */
4602
+ const readDesired = async () => {
4603
+ const [enabled, enabledIds, contextWindow] = await Promise.all([
4604
+ providerStore.enabled().catch(() => null),
4605
+ providerStore.enabledIds().catch(() => null),
4606
+ providerStore.contextWindow().catch(() => null)
4607
+ ]);
4608
+ return {
4609
+ enabled,
4610
+ enabledIds,
4611
+ contextWindow
4612
+ };
4613
+ };
4614
+ /** desired 的稳定键:用于「fetch 期间被改了吗」的比对。 */
4615
+ const desiredKey = (desired) => JSON.stringify([
4616
+ desired.enabled,
4617
+ desired.enabledIds,
4618
+ desired.contextWindow
4619
+ ]);
4620
+ /**
4621
+ * 收敛一遍:读 desired → 拉目录 → 复查 desired → publish。
4622
+ * @returns {Promise<void>}
4623
+ */
4624
+ const runOnce = async () => {
4625
+ const before = await readDesired();
4626
+ let catalog;
4627
+ try {
4628
+ catalog = await inference.fetchModels(before.contextWindow);
4629
+ } catch (error) {
4630
+ logger?.warn?.(`${name}: catalog fetch failed (${redactSecrets(errMsg(error))}) — the current offer stays registered; the next pass retries`);
4631
+ return;
4632
+ }
4633
+ const after = await readDesired();
4634
+ if (desiredKey(after) !== desiredKey(before)) {
4635
+ dirty = true;
4636
+ return;
4637
+ }
4638
+ const entries = Array.isArray(catalog?.entries) ? catalog.entries : [];
4639
+ const enabledIds = resolveEnabledIds(after.enabledIds, publisher.state.enabledIds);
4640
+ try {
4641
+ await publisher.publish(entries, enabledIds, []);
4642
+ } catch (error) {
4643
+ logger?.warn?.(`${name}: publish failed (${redactSecrets(errMsg(error))}) — the catalog is current, only the provider registration did not land`);
4644
+ }
4645
+ };
4646
+ /** 串行链:每次触发排到链尾;在途那一轮替后来者消费 dirty。 */
4647
+ let chain = Promise.resolve();
4648
+ /** 有新触发待消费。闭包状态,不暴露——触发方只看得见 {@link reconcile}。 */
4649
+ let dirty = false;
4650
+ /**
4651
+ * 触发一次收敛。幂等且可重入:在途时只置 dirty,由在途那一轮替本调用消费
4652
+ * (N 次连续触发最多收敛成两遍 reconcile,不会排 N 遍)。
4653
+ * @returns {Promise<void>} 本触发已被消费(注册态已反映 desired)后 resolve。
4654
+ */
4655
+ const reconcile = () => {
4656
+ dirty = true;
4657
+ return chain = chain.then(async () => {
4658
+ let guard = 0;
4659
+ while (dirty) {
4660
+ dirty = false;
4661
+ await runOnce();
4662
+ if (++guard > 20) break;
4663
+ }
4664
+ });
4665
+ };
4666
+ return { reconcile };
4667
+ }
4668
+
3716
4669
  //#endregion
3717
4670
  //#region src/host/routes/paths.ts
3718
4671
  /**
@@ -3729,6 +4682,7 @@ const TOKEN_FORGET_PATH = "/api/dsh-connect-modelscope-token-plan/token/forget";
3729
4682
  const PROBE_PATH = "/api/dsh-connect-modelscope-token-plan/probe";
3730
4683
  const PROVIDER_PATH = "/api/dsh-connect-modelscope-token-plan/provider";
3731
4684
  const DRAW_PATH = "/api/dsh-connect-modelscope-token-plan/draw";
4685
+ const TREND_WINDOW_PATH = "/api/dsh-connect-modelscope-token-plan/trend/window";
3732
4686
 
3733
4687
  //#endregion
3734
4688
  //#region src/host/snapshot-aggregate.ts
@@ -3767,38 +4721,21 @@ async function soft(promise) {
3767
4721
  const code = error.code;
3768
4722
  return {
3769
4723
  ok: false,
3770
- error: redactSecrets$1(errMsg(error)),
4724
+ error: redactSecrets(errMsg(error)),
3771
4725
  code: typeof code === "string" ? code : CODE.INTERNAL_ERROR
3772
4726
  };
3773
4727
  }
3774
4728
  }
3775
- /**
3776
- * Provider 状态块降级形状:读不到开关/注册状态/目录时,面板仍然得到一个完整
3777
- * 可渲染的块(开关关、roster 空、error 说明原因),绝不让整条快照失败。
3778
- */
3779
- function providerDegraded(error) {
3780
- return {
3781
- enabled: false,
3782
- source: "config",
3783
- llmAvailable: false,
3784
- registered: false,
3785
- error,
3786
- modelCount: 0,
3787
- enabledCount: 0,
3788
- allowed: "all",
3789
- enabledIds: [],
3790
- contextWindow: null,
3791
- roster: []
3792
- };
3793
- }
3794
4729
  /** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
3795
4730
  async function buildSnapshotBody(wiring) {
3796
- const { settings, tokenStore, usageStore, inference, providerStore, drawStore, publisher } = wiring;
4731
+ const { settings, tokenStore, usageStore, inference, providerStore, drawStore, usagePrefsStore, publisher } = wiring;
4732
+ const panelWindow = await usagePrefsStore.windowDays().catch(() => null);
4733
+ const windowDays = resolveWindowDays(panelWindow, settings.trendDays, settings.retentionDays);
3797
4734
  const [tokenState, daily, perModel, trend, events, providerNote] = await Promise.all([
3798
4735
  soft(tokenStore.state()),
3799
4736
  soft(usageStore.daily()),
3800
4737
  soft(usageStore.perModelToday()),
3801
- soft(usageStore.trend(settings.trendDays)),
4738
+ soft(usageStore.trend(windowDays)),
3802
4739
  soft(usageStore.events()),
3803
4740
  soft(providerStore.versionNote())
3804
4741
  ]);
@@ -3820,6 +4757,14 @@ async function buildSnapshotBody(wiring) {
3820
4757
  if (!models.ok && models.code !== "network_error") shapeWarnings.push(`models: ${models.error}`);
3821
4758
  if (!tokenKnownAbsent && !balance.ok && balance.code !== "network_error") shapeWarnings.push(`balance: ${balance.error}`);
3822
4759
  if (providerNote.ok && providerNote.value !== null) shapeWarnings.push(providerNote.value);
4760
+ const drawNote = await soft((async () => drawStore.versionNote())());
4761
+ if (drawNote.ok && drawNote.value !== null) shapeWarnings.push(drawNote.value);
4762
+ const windowNote = await soft((async () => usagePrefsStore.versionNote())());
4763
+ if (windowNote.ok && windowNote.value !== null) shapeWarnings.push(windowNote.value);
4764
+ if (curatedCatalogStale()) {
4765
+ const age = curatedCatalogAgeDays();
4766
+ shapeWarnings.push(`context-catalog: curated windows were last verified on ${CURATED_AS_OF}` + (age === null ? "" : ` (${age} days ago)`) + " — re-check them against the current catalogue");
4767
+ }
3823
4768
  const provider = await soft((async () => {
3824
4769
  const panel = await providerStore.enabled().catch(() => null);
3825
4770
  const enabled = resolveSwitchEnabled(panel, settings.registerProvider);
@@ -3827,10 +4772,9 @@ async function buildSnapshotBody(wiring) {
3827
4772
  const roster = rosterWithAvailability(modelEntries, []);
3828
4773
  const stored = await providerStore.enabledIds().catch(() => null);
3829
4774
  const enabledIds = resolveEnabledIds(stored, publisher.state.enabledIds);
4775
+ const allowedList = parseAllowedList(enabledIds);
4776
+ const enabledCount = enabledCountOf(allowedList, roster.map((row) => row.id));
3830
4777
  const contextWindow = await providerStore.contextWindow().catch(() => null);
3831
- const allowed = resolveAllowedList(enabledIds);
3832
- const allowSet = new Set(enabledIds);
3833
- const enabledCount = allowed === "all" ? roster.length : allowed === "list" ? roster.filter((row) => allowSet.has(row.id)).length : 0;
3834
4778
  return {
3835
4779
  enabled,
3836
4780
  source,
@@ -3839,7 +4783,7 @@ async function buildSnapshotBody(wiring) {
3839
4783
  error: typeof publisher.state.error === "string" ? publisher.state.error : null,
3840
4784
  modelCount: roster.length,
3841
4785
  enabledCount,
3842
- allowed,
4786
+ allowed: allowedList.mode,
3843
4787
  enabledIds,
3844
4788
  contextWindow,
3845
4789
  roster
@@ -3848,19 +4792,26 @@ async function buildSnapshotBody(wiring) {
3848
4792
  const draw = await soft((async () => {
3849
4793
  const panel = await drawStore.enabled().catch(() => null);
3850
4794
  const panelModel = await drawStore.modelId().catch(() => null);
3851
- const candidateIds = imageGenModelIds(modelEntries);
4795
+ const candidates = drawCandidates(modelEntries);
4796
+ const preferred = resolveSwitchValue(panelModel, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
4797
+ const choice = resolveDrawModelChoice(modelEntries, void 0, preferred ?? void 0);
3852
4798
  return {
3853
4799
  enabled: resolveSwitchEnabled(panel, settings.drawEnabled),
3854
4800
  source: switchSource(panel),
3855
- candidateIds,
3856
- candidateCount: candidateIds.length,
3857
- preferredModel: resolveSwitchValue(panelModel, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null,
4801
+ candidateIds: candidates.map((row) => row.id),
4802
+ candidateCount: candidates.length,
4803
+ candidates,
4804
+ preferredModel: preferred,
3858
4805
  preferredSource: switchSource(panelModel),
4806
+ effectiveModel: choice.id,
4807
+ effectiveSource: choice.source,
3859
4808
  absentReason: drawToolNote(),
3860
4809
  mountPending: resolveSwitchEnabled(panel, settings.drawEnabled) === true && drawToolNote() !== null,
3861
4810
  toolName: "modelscope_draw_image"
3862
4811
  };
3863
4812
  })());
4813
+ if (!provider.ok) shapeWarnings.push(`provider source failed (${provider.code}): ${provider.error}`);
4814
+ if (!draw.ok) shapeWarnings.push(`draw source failed (${draw.code}): ${draw.error}`);
3864
4815
  const usedLocal = daily.ok ? daily.value.calls : null;
3865
4816
  return {
3866
4817
  ok: true,
@@ -3900,7 +4851,9 @@ async function buildSnapshotBody(wiring) {
3900
4851
  },
3901
4852
  events: events.ok ? events.value : [],
3902
4853
  trend: {
3903
- days: settings.trendDays,
4854
+ days: windowDays,
4855
+ windowDays: panelWindow,
4856
+ maxDays: settings.retentionDays,
3904
4857
  buckets: trend.ok ? trend.value : []
3905
4858
  },
3906
4859
  models: {
@@ -3909,7 +4862,7 @@ async function buildSnapshotBody(wiring) {
3909
4862
  sample: modelIds.slice(0, 20),
3910
4863
  error: models.ok ? null : models.error
3911
4864
  },
3912
- provider: provider.ok ? provider.value : providerDegraded(provider.error),
4865
+ ...provider.ok ? { provider: provider.value } : {},
3913
4866
  ...draw.ok ? { draw: draw.value } : {},
3914
4867
  shapeWarnings,
3915
4868
  quotaError: null
@@ -3958,7 +4911,7 @@ async function readJsonBody(request, limit = MAX_JSON_BODY_BYTES) {
3958
4911
  received += buffer.byteLength;
3959
4912
  if (received > limit) return {
3960
4913
  ok: false,
3961
- error: "request body is too large"
4914
+ error: `request body is too large (max ${limit} bytes)`
3962
4915
  };
3963
4916
  chunks.push(buffer);
3964
4917
  }
@@ -4008,7 +4961,7 @@ function refuseMethod(response) {
4008
4961
  * 日志或响应)。redactSecrets 幂等,双重应用无害。
4009
4962
  */
4010
4963
  function redactedError(error) {
4011
- const text = redactSecrets$1(errMsg(error));
4964
+ const text = redactSecrets(errMsg(error));
4012
4965
  return text.trim() === "" ? "request failed" : text;
4013
4966
  }
4014
4967
  /**
@@ -4314,22 +5267,22 @@ function registerProbeRoute(ctx, wiring) {
4314
5267
  * @module dsh-connect-modelscope-token-plan/routes/provider
4315
5268
  */
4316
5269
  /**
4317
- * 读目录条目,读不到按空目录降级(GET 不能因目录失败把整个面板状态打掉)。
5270
+ * 读目录条目,读不到**抛错**(让 snapshot() 走 respondError 返回 ok:false)。
4318
5271
  *
4319
5272
  * 读目录前先取**当前窗口覆盖**传给 `fetchModels`:条目在归一前打上覆盖标记,
4320
5273
  * descriptor / roster / catalogSignature 都从这份条目派生。每个写路由(开关、
4321
5274
  * 清单、窗口、reset)都经这里取条目再 publish,所以任何一次重注册都带着最新
4322
5275
  * 覆盖——否则先存窗口、再存清单会把未打标的目录重新注册上去,覆盖静默失效到
4323
5276
  * 下一轮轮询。
5277
+ *
5278
+ * **为什么抛错而不是降级成空目录**:空 roster 与「真的没有模型」在面板上长得
5279
+ * 一模一样(modelCount=0),而真相是「目录拉不到」——这与 snapshot-aggregate
5280
+ * 的 `models.available=false` 语义矛盾(后者明确区分「0 个模型」与「读不到」)。
5281
+ * 抛错让 GET 返回 ok:false + error,面板能显示「目录拉不到」而不是「0 个模型」。
4324
5282
  */
4325
5283
  async function readEntries$1(inference, providerStore) {
4326
5284
  const contextWindow = await optional(providerStore?.contextWindow?.(), null);
4327
- const catalog = await inference.fetchModels(contextWindow).catch((error) => {
4328
- return {
4329
- entries: [],
4330
- __error: error
4331
- };
4332
- });
5285
+ const catalog = await inference.fetchModels(contextWindow);
4333
5286
  return Array.isArray(catalog?.entries) ? catalog.entries : [];
4334
5287
  }
4335
5288
  /**
@@ -4342,18 +5295,12 @@ async function readEnabledIds(store, published) {
4342
5295
  return resolveEnabledIds(await optional(store.enabledIds?.(), null), published);
4343
5296
  }
4344
5297
  /**
4345
- * `allowed` 的分类:委托给 `llm-models.ts` 的单一口径
4346
- * {@link resolveAllowedList}(fix D:快照与 provider 路由以前各写一套分类,会漂移)。
4347
- *
4348
- * 旧版 `allowedOf` 只认「恰好哨兵」为 `"none"`,`["__hide_all__","other"]` 会被错判成
4349
- * `"list"`;统一到 `resolveAllowedList` 后,含哨兵即 `"none"`,与 `filterByEnabled` /
4350
- * `isModelEnabled` 口径一致。
4351
- * @param {string[]} enabledIds - 允许清单。
4352
- * @returns {"all"|"none"|"list"}
5298
+ * `allowed` 的分类与 `enabledCount` 的计数:都从 `shared/allow-list.ts` 的
5299
+ * {@link parseAllowedList} 联合上取(fix D:快照与 provider 路由以前各写一套
5300
+ * 分类,会漂移;旧版 `allowedOf` 只认「恰好哨兵」为 `"none"`,
5301
+ * `["__hide_all__","other"]` 会被错判成 `"list"`——统一到联合后,含哨兵即
5302
+ * `"none"`,与 `filterByEnabled` / `isModelEnabled` 同一份解释)。
4353
5303
  */
4354
- function allowedOf(enabledIds) {
4355
- return resolveAllowedList(enabledIds);
4356
- }
4357
5304
  /**
4358
5305
  * 注册 provider 路由(三条路径)。wiring 子集:settings / providerStore /
4359
5306
  * publisher / inference / logger。
@@ -4362,22 +5309,34 @@ function allowedOf(enabledIds) {
4362
5309
  * @returns {Function} 依次调用三个 `off()` 的注销回调。
4363
5310
  */
4364
5311
  function registerProviderRoute(ctx, wiring) {
4365
- const { settings, providerStore, publisher, inference, logger } = wiring;
5312
+ const { settings, providerStore, publisher, reconcile, inference, logger } = wiring;
4366
5313
  /**
4367
5314
  * 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
4368
5315
  * 状态是什么」说两套话。
5316
+ *
5317
+ * **目录读不到怎么办**:GET 是权威读路径,读不到就让它失败(handler 转
5318
+ * `respondError`,面板看见「目录拉不到」而不是「0 个模型」)。POST 的响应只是
5319
+ * 保存后的回读,而此时**保存已经成功**——不能把一次成功的写报成失败,所以
5320
+ * `tolerateCatalogFailure` 让它降级为空 roster 并带上 `catalogError` 说明原因。
4369
5321
  * @param {object} [extra] - 调用方要追加/覆盖的字段。
5322
+ * @param {object} [options] - `{ tolerateCatalogFailure }`:写成功后的回读宽容模式。
4370
5323
  * @returns {Promise<object>} 响应体。
4371
5324
  */
4372
- const snapshot = async (extra = {}) => {
5325
+ const snapshot = async (extra = {}, options = {}) => {
4373
5326
  const panelSwitch = await optional(providerStore.enabled());
4374
5327
  const enabledIds = await readEnabledIds(providerStore, publisher.state.enabledIds);
4375
5328
  const contextWindow = await optional(providerStore.contextWindow?.(), null);
4376
- const entries = await readEntries$1(inference, providerStore);
5329
+ let entries = [];
5330
+ let catalogError = null;
5331
+ try {
5332
+ entries = await readEntries$1(inference, providerStore);
5333
+ } catch (error) {
5334
+ if (options.tolerateCatalogFailure !== true) throw error;
5335
+ catalogError = redactedError(error);
5336
+ }
4377
5337
  const roster = rosterWithAvailability(entries, []);
4378
- const allowed = allowedOf(enabledIds);
5338
+ const allowedList = parseAllowedList(enabledIds);
4379
5339
  const modelCount = roster.length;
4380
- const allowSet = new Set(enabledIds);
4381
5340
  return {
4382
5341
  ok: true,
4383
5342
  enabled: resolveSwitchEnabled(panelSwitch, settings.registerProvider),
@@ -4386,11 +5345,12 @@ function registerProviderRoute(ctx, wiring) {
4386
5345
  registered: publisher.state.registered === true,
4387
5346
  error: typeof publisher.state.error === "string" ? publisher.state.error : null,
4388
5347
  modelCount,
4389
- enabledCount: allowed === "all" ? modelCount : allowed === "none" ? 0 : roster.filter((row) => allowSet.has(row.id)).length,
4390
- allowed,
5348
+ enabledCount: enabledCountOf(allowedList, roster.map((row) => row.id)),
5349
+ allowed: allowedList.mode,
4391
5350
  enabledIds,
4392
5351
  contextWindow,
4393
5352
  roster,
5353
+ ...catalogError !== null ? { catalogError } : {},
4394
5354
  ...extra
4395
5355
  };
4396
5356
  };
@@ -4401,7 +5361,11 @@ function registerProviderRoute(ctx, wiring) {
4401
5361
  handler: withOrigin(async (request, response) => {
4402
5362
  const method = request.method === void 0 ? "GET" : request.method;
4403
5363
  if (method === "GET") {
4404
- writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5364
+ try {
5365
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5366
+ } catch (error) {
5367
+ respondError(response, error);
5368
+ }
4405
5369
  return;
4406
5370
  }
4407
5371
  if (method !== "POST") {
@@ -4419,13 +5383,12 @@ function registerProviderRoute(ctx, wiring) {
4419
5383
  }
4420
5384
  try {
4421
5385
  await providerStore.save(body.enabled);
4422
- const entries = await readEntries$1(inference, providerStore);
4423
- await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
5386
+ await reconcile();
4424
5387
  } catch (error) {
4425
5388
  respondError(response, error);
4426
5389
  return;
4427
5390
  }
4428
- writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5391
+ writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
4429
5392
  }, settings.allowedHosts)
4430
5393
  }));
4431
5394
  offs.push(ctx.webServer.register({
@@ -4448,13 +5411,12 @@ function registerProviderRoute(ctx, wiring) {
4448
5411
  const ids = body.enabledIds.filter((id) => typeof id === "string" && id !== "");
4449
5412
  try {
4450
5413
  await providerStore.saveEnabledIds?.(ids);
4451
- const entries = await readEntries$1(inference, providerStore);
4452
- await publisher.publish(entries, ids, []);
5414
+ await reconcile();
4453
5415
  } catch (error) {
4454
5416
  respondError(response, error);
4455
5417
  return;
4456
5418
  }
4457
- writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5419
+ writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
4458
5420
  }, settings.allowedHosts)
4459
5421
  }));
4460
5422
  offs.push(ctx.webServer.register({
@@ -4476,13 +5438,12 @@ function registerProviderRoute(ctx, wiring) {
4476
5438
  }
4477
5439
  try {
4478
5440
  await providerStore.saveContextWindow?.(body.contextWindow);
4479
- const entries = await readEntries$1(inference, providerStore);
4480
- await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
5441
+ await reconcile();
4481
5442
  } catch (error) {
4482
5443
  respondError(response, error);
4483
5444
  return;
4484
5445
  }
4485
- writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5446
+ writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
4486
5447
  }, settings.allowedHosts)
4487
5448
  }));
4488
5449
  offs.push(ctx.webServer.register({
@@ -4500,13 +5461,12 @@ function registerProviderRoute(ctx, wiring) {
4500
5461
  try {
4501
5462
  await providerStore.forget();
4502
5463
  await providerStore.saveEnabledIds?.([]);
4503
- const entries = await readEntries$1(inference, providerStore);
4504
- await publisher.publish(entries, [], []);
5464
+ await reconcile();
4505
5465
  } catch (error) {
4506
5466
  respondError(response, error);
4507
5467
  return;
4508
5468
  }
4509
- writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5469
+ writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
4510
5470
  }, settings.allowedHosts)
4511
5471
  }));
4512
5472
  return () => {
@@ -4570,16 +5530,20 @@ function registerDrawRoute(ctx, wiring) {
4570
5530
  const panelSwitch = await optional(drawStore.enabled());
4571
5531
  const panelModelId = await optional(drawStore.modelId());
4572
5532
  const entries = await readEntries(inference);
4573
- const candidateIds = imageGenModelIds(entries);
4574
- const effectiveModelId = resolveSwitchValue(panelModelId, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
5533
+ const candidates = drawCandidates(entries);
5534
+ const preferredModelId = resolveSwitchValue(panelModelId, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
5535
+ const choice = resolveDrawModelChoice(entries, void 0, preferredModelId ?? void 0);
4575
5536
  return {
4576
5537
  ok: true,
4577
5538
  enabled: resolveSwitchEnabled(panelSwitch, settings.drawEnabled),
4578
5539
  source: switchSource(panelSwitch),
4579
- candidateIds,
4580
- candidateCount: candidateIds.length,
4581
- preferredModel: effectiveModelId,
5540
+ candidateIds: candidates.map((row) => row.id),
5541
+ candidateCount: candidates.length,
5542
+ candidates,
5543
+ preferredModel: preferredModelId,
4582
5544
  preferredSource: switchSource(panelModelId),
5545
+ effectiveModel: choice.id,
5546
+ effectiveSource: choice.source,
4583
5547
  absentReason: drawToolNote(),
4584
5548
  /**
4585
5549
  * 开关的保存**不会**改变本次挂载的工具。
@@ -4633,20 +5597,8 @@ function registerDrawRoute(ctx, wiring) {
4633
5597
  return;
4634
5598
  }
4635
5599
  try {
4636
- if (hasEnabled) {
4637
- const refusal = await drawStore.saveEnabled(body.enabled);
4638
- if (refusal !== null) {
4639
- respondError(response, new Error(refusal));
4640
- return;
4641
- }
4642
- }
4643
- if (body.modelId !== void 0) {
4644
- const refusal = await drawStore.saveModelId(body.modelId);
4645
- if (refusal !== null) {
4646
- respondError(response, new Error(refusal));
4647
- return;
4648
- }
4649
- }
5600
+ if (hasEnabled) await drawStore.saveEnabled(body.enabled);
5601
+ if (body.modelId !== void 0) await drawStore.saveModelId(body.modelId);
4650
5602
  } catch (error) {
4651
5603
  respondError(response, error);
4652
5604
  return;
@@ -4667,11 +5619,7 @@ function registerDrawRoute(ctx, wiring) {
4667
5619
  error: "ignored"
4668
5620
  }));
4669
5621
  try {
4670
- const refusal = await drawStore.reset();
4671
- if (refusal !== null) {
4672
- respondError(response, new Error(refusal));
4673
- return;
4674
- }
5622
+ await drawStore.reset();
4675
5623
  } catch (error) {
4676
5624
  respondError(response, error);
4677
5625
  return;
@@ -4688,6 +5636,95 @@ function registerDrawRoute(ctx, wiring) {
4688
5636
  };
4689
5637
  }
4690
5638
 
5639
+ //#endregion
5640
+ //#region src/host/routes/usage-prefs.ts
5641
+ /**
5642
+ * 趋势图**显示窗口**路由:读当前窗口(GET)与保存(POST)。
5643
+ *
5644
+ * 一条路径两件事:
5645
+ * - GET `TREND_WINDOW_PATH` 读面板覆盖 + 生效天数 + 上界(无副作用);
5646
+ * - POST `TREND_WINDOW_PATH` body `{ windowDays: number|null }` → 保存
5647
+ * (`null` = 清除覆盖,回到 `trendDays` 配置默认)。
5648
+ *
5649
+ * **与 provider / draw 路由不同,本路由保存后不触发任何收敛**:显示窗口只影响
5650
+ * 快照里 `trend` 那一段,面板保存成功后自己调一次 `load()` 就当场回显;没有
5651
+ * 需要重建的注册态。
5652
+ *
5653
+ * 越界值**夹取而不是拒绝**:上界是保留窗口(`settings.retentionDays`),而
5654
+ * host-config 对配置里的同一个值也是夹——两处若一个夹一个拒,同一个越界值会因
5655
+ * 来源不同得到两个下场。类型给错(字符串 / 缺字段)仍然按 400 拒:那是形状错,
5656
+ * 不是范围错。
5657
+ *
5658
+ * peer-free:不 import 任何 Host peer。
5659
+ * @module dsh-connect-modelscope-token-plan/routes/usage-prefs
5660
+ */
5661
+ /**
5662
+ * 注册趋势窗口路由。
5663
+ * @param ctx - host 根上下文(只用 `ctx.webServer`)。
5664
+ * @param {UsagePrefsRouteWiring} wiring - 本路由读到的子集。
5665
+ * @returns {Function} 注销回调。
5666
+ */
5667
+ function registerUsagePrefsRoute(ctx, wiring) {
5668
+ const { settings, usagePrefsStore, logger } = wiring;
5669
+ /**
5670
+ * 组装响应。GET 与写成功后的响应共用,两个方向不可能对「当前窗口」说两套话。
5671
+ * @param {object} [extra] - 追加/覆盖字段。
5672
+ * @returns {Promise<object>} 响应体。
5673
+ */
5674
+ const snapshot = async (extra = {}) => {
5675
+ const panel = await optional(usagePrefsStore.windowDays());
5676
+ return {
5677
+ ok: true,
5678
+ windowDays: panel,
5679
+ days: resolveWindowDays(panel, settings.trendDays, settings.retentionDays),
5680
+ maxDays: settings.retentionDays,
5681
+ source: switchSource(panel),
5682
+ ...extra
5683
+ };
5684
+ };
5685
+ const off = ctx.webServer.register({
5686
+ kind: "exact",
5687
+ path: TREND_WINDOW_PATH,
5688
+ handler: withOrigin(async (request, response) => {
5689
+ const method = request.method === void 0 ? "GET" : request.method;
5690
+ if (method === "GET") {
5691
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5692
+ return;
5693
+ }
5694
+ if (method !== "POST") {
5695
+ refuseMethod(response);
5696
+ return;
5697
+ }
5698
+ const body = await readJsonBodyOr400(request, response);
5699
+ if (body === null) return;
5700
+ const isNull = body.windowDays === null;
5701
+ const isNumber = typeof body.windowDays === "number" && Number.isFinite(body.windowDays);
5702
+ if (!isNull && !isNumber) {
5703
+ writeJson(response, 400, {
5704
+ ok: false,
5705
+ error: "expected { windowDays: number | null }"
5706
+ }, { "cache-control": "no-store" });
5707
+ return;
5708
+ }
5709
+ const saved = isNull ? null : Math.min(Math.max(1, Math.floor(body.windowDays)), settings.retentionDays);
5710
+ try {
5711
+ await usagePrefsStore.saveWindowDays(saved);
5712
+ } catch (error) {
5713
+ respondError(response, error);
5714
+ return;
5715
+ }
5716
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5717
+ }, settings.allowedHosts)
5718
+ });
5719
+ return () => {
5720
+ try {
5721
+ off();
5722
+ } catch (error) {
5723
+ logger?.warn?.(`trend window route unregister failed: ${redactedError(error)}`);
5724
+ }
5725
+ };
5726
+ }
5727
+
4691
5728
  //#endregion
4692
5729
  //#region src/host/routes.ts
4693
5730
  /**
@@ -4704,7 +5741,8 @@ function registerRoutes(ctx, wiring) {
4704
5741
  ...registerTokenRoute(ctx, wiring),
4705
5742
  registerProbeRoute(ctx, wiring),
4706
5743
  registerProviderRoute(ctx, wiring),
4707
- registerDrawRoute(ctx, wiring)
5744
+ registerDrawRoute(ctx, wiring),
5745
+ registerUsagePrefsRoute(ctx, wiring)
4708
5746
  ];
4709
5747
  }
4710
5748
 
@@ -4752,7 +5790,7 @@ function apply(ctx, config = {}, deps = {}) {
4752
5790
  const usageStore = createFileUsageStore({
4753
5791
  name,
4754
5792
  profile,
4755
- trendDays: settings.trendDays,
5793
+ retentionDays: settings.retentionDays,
4756
5794
  maxEvents: settings.maxEvents,
4757
5795
  logger: ctx.logger
4758
5796
  });
@@ -4770,6 +5808,10 @@ function apply(ctx, config = {}, deps = {}) {
4770
5808
  profile,
4771
5809
  logger: ctx.logger
4772
5810
  });
5811
+ const usagePrefsStore = createFileUsagePrefsStore({
5812
+ profile,
5813
+ logger: ctx.logger
5814
+ });
4773
5815
  const publisher = createProviderPublisher({
4774
5816
  settings,
4775
5817
  panelSwitch: () => providerStore.enabled(),
@@ -4786,49 +5828,13 @@ function apply(ctx, config = {}, deps = {}) {
4786
5828
  ...deps.loadAdapterModule ? { loadAdapterModule: deps.loadAdapterModule } : {},
4787
5829
  logger: ctx.logger
4788
5830
  });
4789
- /**
4790
- * 当前允许清单:落盘优先,读不到回退内存最近一次 publish 的清单。
4791
- *
4792
- * 判据与另外两个消费者(`snapshot-aggregate.ts` 的面板块、`routes/provider.ts`
4793
- * 的 GET)**共用** {@link resolveEnabledIds}——空清单是「不过滤,提供全部模型」,
4794
- * 那是 provider-store 的契约,不是「没有答案」。本函数曾经自己写成
4795
- * `stored.length > 0` 才算数,于是**可读的空清单**(操作者按 versionNote 的建议
4796
- * 删掉 provider.json,或清单被保存为空)会让面板说 all、轮询却继续按内存里的旧
4797
- * 清单注册:面板与 Host 对「正在提供什么」各说一套。只有读**抛错**(`.catch`
4798
- * → null)才回退内存。
4799
- * @returns {Promise<string[]>}
4800
- */
4801
- const currentEnabledIds = async () => resolveEnabledIds(await providerStore.enabledIds().catch(() => null), publisher.state.enabledIds);
4802
- /**
4803
- * 一次目录轮询:拉目录,再 publish 当前 offer。
4804
- *
4805
- * **窗口覆盖必须参与**:catalogSignature 含窗口值,轮询若用未打标条目 publish,
4806
- * 用户保存覆盖后的每次轮询都会因「签名不同」把注册摘下重挂(覆盖每 pollSeconds
4807
- * 静默回退一次、适配器每轮重建)。所以 fetch **前**先读落盘覆盖传给 fetchModels,
4808
- * 与 `currentEnabledIds` 同一模式。
4809
- *
4810
- * fetch **后**再读一次:目录拉取可能耗时数百毫秒,期间用户可能保存了新覆盖——
4811
- * 保存路由已经用新值 publish 过,此时用旧标条目 publish 反而把注册**倒退**一个
4812
- * 周期;这种窗口内跳过本轮即可(下一轮轮询自然带新值)。清单沿用 fetch 后读的
4813
- * 老顺序(捕获 fetch 期间的编辑),两条时序理由同源。
4814
- * @returns {Promise<void>}
4815
- */
4816
- const runPoll = async () => {
4817
- const contextWindow = await providerStore.contextWindow().catch(() => null);
4818
- let catalog = null;
4819
- try {
4820
- catalog = await inference.fetchModels(contextWindow);
4821
- } catch (error) {
4822
- ctx.logger?.warn?.(`${name}: catalog fetch failed (${redactSecrets$1(errMsg(error))}) — the next poll retries; the panel keeps its degraded models block`);
4823
- return;
4824
- }
4825
- if (await providerStore.contextWindow().catch(() => null) !== contextWindow) return;
4826
- try {
4827
- await publisher.publish(catalog.entries, await currentEnabledIds(), []);
4828
- } catch (error) {
4829
- ctx.logger?.warn?.(`${name}: publish failed (${redactSecrets$1(errMsg(error))}) — the catalog is current, only the provider registration did not land`);
4830
- }
4831
- };
5831
+ const { reconcile } = createProviderReconciler({
5832
+ settings,
5833
+ providerStore,
5834
+ inference,
5835
+ publisher,
5836
+ logger: ctx.logger
5837
+ });
4832
5838
  const wiring = {
4833
5839
  settings,
4834
5840
  configError,
@@ -4837,15 +5843,17 @@ function apply(ctx, config = {}, deps = {}) {
4837
5843
  inference,
4838
5844
  providerStore,
4839
5845
  drawStore,
5846
+ usagePrefsStore,
4840
5847
  publisher,
5848
+ reconcile,
4841
5849
  logger: ctx.logger
4842
5850
  };
4843
5851
  const offs = registerRoutes(ctx, wiring);
4844
5852
  ctx.effect(() => {
4845
- runPoll();
5853
+ reconcile();
4846
5854
  registerDrawTool(ctx, wiring, deps);
4847
5855
  const timer = setInterval(() => {
4848
- runPoll();
5856
+ reconcile();
4849
5857
  }, settings.pollSeconds * 1e3);
4850
5858
  return () => {
4851
5859
  clearInterval(timer);
@@ -4855,7 +5863,7 @@ function apply(ctx, config = {}, deps = {}) {
4855
5863
  for (const off of offs) try {
4856
5864
  off?.();
4857
5865
  } catch (error) {
4858
- ctx.logger?.warn?.(`${name}: route unregister failed: ${redactSecrets$1(errMsg(error))}`);
5866
+ ctx.logger?.warn?.(`${name}: route unregister failed: ${redactSecrets(errMsg(error))}`);
4859
5867
  }
4860
5868
  };
4861
5869
  }, `${name}: routes`);