dsh-connect-modelscope-token-plan 0.3.0 → 0.4.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 obj, D as str, E as redactSecrets, O as verbatim, S as errMsg, T as pluginError, a as resolveSettings, c as LLM_PROVIDER_ID, d as isVisionModel, f as normalizeEntry, g as parseAllowedList, h as enabledCountOf, i as name, m as textToImageModelIds, n as inject, p as rosterWithAvailability, r as isAdmitted, s as LLM_DISPLAY_NAME, t as PLUGIN_VERSION, u as imageGenModelIds, v as hasHardQuotaWordingIn, w as optional, x as degrade, y as looksLikeRateLimit } 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),空白值视为没输入。
@@ -146,36 +184,11 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
146
184
  };
147
185
  },
148
186
  /**
149
- * 解析当前令牌与来源。返回类型显式注解:这是快照 TokenStatus.source
150
- * 联合类型的上游,靠推断会把字面量联合拓宽成 string。
187
+ * 解析当前令牌与来源。
151
188
  * @returns {Promise<{value: string, source: ("credentials"|"memory"|"env"|null)}>}
152
189
  */
153
190
  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
- };
191
+ return resolveToken();
179
192
  },
180
193
  /**
181
194
  * 无秘密的状态描述(快照与面板消费)。
@@ -192,7 +205,7 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
192
205
  * `memory` 才真的只在本次进程存活期间存在。
193
206
  */
194
207
  async state() {
195
- const { source } = await this.resolve();
208
+ const { source } = await resolveToken();
196
209
  return {
197
210
  present: source !== null,
198
211
  source: source === null ? "none" : source,
@@ -207,9 +220,11 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
207
220
  //#endregion
208
221
  //#region src/host/state-store.ts
209
222
  /**
210
- * 状态文件公共原语。**TL;DR**:本文件只做三件事——`dshHome`/`stateDir`(定位
211
- * 状态目录)、`profileSegment`(按 profile 分段路径)、以及 {@link createStateReadCache}
212
- * (统一读缓存)。其余一切遵守下方「行为约定」。
223
+ * 状态文件公共原语。**TL;DR**:本文件只做四件事——`dshHome`/`stateDir`(定位
224
+ * 状态目录)、`profileSegment`(按 profile 分段路径)、{@link createStateReadCache}
225
+ * (统一读缓存)、以及写侧三件套({@link createStateWriteQueue} 写串行化 /
226
+ * {@link guardStateWrite} ADR-006 写守卫 / {@link stateVersionNote} 读侧版本说明)。
227
+ * 其余一切遵守下方「行为约定」。
213
228
  *
214
229
  * 把本插件各 store(usage-store / provider / draw)各自手写的同一段
215
230
  * "版本载荷 + temp 文件 + rename 原子 + 0600 + 损坏即忽略" 收敛到这里。
@@ -234,6 +249,13 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
234
249
  * 2. 第二步收敛的是**读缓存**:provider / draw 早有 1s TTL,而 catalog 完全
235
250
  * 没有(进程内永不失效)——同一个「两个进程共享一个 state 目录」的问题修了
236
251
  * 两个、漏了第三个。现在统一走 {@link createStateReadCache},一个 TTL 三个调用方。
252
+ * 3. 第三步收敛的是**写侧三件套**。这三个 store 曾各自手写:provider 有
253
+ * writeChain、usage 有 writeChain、draw **两个都没有**(并发保存丢一次、
254
+ * 磁盘 version 比本构建新时降级覆写、读侧静默读作「没设过」——2026-10-17
255
+ * 实测复现,正是 provider-store 注释里记着「咬过三次」的那类漂移的第四条)。
256
+ * 现在三件套各有一份公共实现:串行化是{@link createStateWriteQueue}、写守卫是
257
+ * {@link guardStateWrite}、读侧说明是{@link stateVersionNote};provider /
258
+ * usage / draw 三个 store 都是调用方,不再各写一遍。
237
259
  *
238
260
  * @module dsh-connect-modelscope-token-plan/state-store
239
261
  */
@@ -605,11 +627,113 @@ async function readStateVersion(file) {
605
627
  function isKnownStateVersion(version, known) {
606
628
  return version === null || known.includes(version);
607
629
  }
630
+ /**
631
+ * 写串行化队列——store 的「读-改-写必须排队」的那把链。
632
+ *
633
+ * 为什么这不是某个 store 的私事:三个 store(provider / usage / draw)各写过
634
+ * 一份一模一样的 `let writeChain = Promise.resolve(...)`,而 draw 曾经**两份都
635
+ * 没有**——并发 `saveEnabled(true)` + `saveModelId(...)` 各自读到同一个 current,
636
+ * 后写的赢,前一次保存被整个丢掉(2026-10-17 实测:落盘只剩其中一个字段)。
637
+ * provider-store 的注释记着这类漂移「咬过三次」,draw 是第四条。抽到这里,
638
+ * 第四个 store 起只需调用,没有「再手写一遍」这个选项。
639
+ *
640
+ * 语义(与 provider / usage 此前的私有实现逐字一致):
641
+ * - `task` 在上一个任务落幕后才起动,临界区内重新读盘(基线不来自 TTL 缓存);
642
+ * - **调用方拿到的是 `run` 本身**——拒绝照旧传播(provider 的 `save()` 要抛给
643
+ * 路由、usage 的 `recordCall()` 也要抛),因为吞掉失败是调用方的语义决定;
644
+ * - 链自己吞掉失败继续走:一次只读 Home 上的写失败不该毒化后续所有保存。
645
+ *
646
+ * peer-free,与其余原语同纪律。
647
+ * @returns {{
648
+ * enqueue: <T>(task: () => Promise<T>) => Promise<T>
649
+ * }} 排队执行一次任务。
650
+ */
651
+ function createStateWriteQueue() {
652
+ let chain = Promise.resolve();
653
+ return { enqueue(task) {
654
+ const run = chain.then(task);
655
+ chain = run.then(() => void 0, () => void 0);
656
+ return run;
657
+ } };
658
+ }
659
+ /**
660
+ * ADR-006 写侧守卫:本构建读不懂的版本,不许覆盖。
661
+ *
662
+ * 磁盘上出现一个**本构建不认识的数字版本**,只可能是更新的构建写的;用一个旧
663
+ * 载荷盖掉它,销毁的是我们根本看不见的数据(实测过一次 version 2 → 1 的降级
664
+ * 覆写,110 次历史归零)。所以写入前先问盘,三种答案:
665
+ * - `null`(没有文件 / 没有版本 / 损坏)——没有什么可保护,写是恢复手段,放行;
666
+ * - 已知版本——放行;
667
+ * - 未知数字版本,或文件**在场却读不出来**(EACCES/EBUSY/EIO)——拒写。
668
+ * 后者由 {@link readStateVersion} 抛出来(它只把 ENOENT 读作「没有文件」),
669
+ * 这里接住并拒写:「读不到」不等于「没有东西要保护」。
670
+ *
671
+ * 拒写返回一句**已 degrade 记录**的原因(调用方决定要不要再抛给用户——provider
672
+ * 的 save 抛、draw 的 saveEnabled 抛、usage 的 recordCall 静默 no-op),而不是
673
+ * 静默 no-op: swallow the failure, not the reason。
674
+ *
675
+ * @param {string} file - 目标状态文件路径(消息里只取 basename)。
676
+ * @param {readonly number[]} known - 本构建读得懂的版本集合。
677
+ * @param {{label: string, logger?: {warn?: (m: string) => void}|undefined}} options -
678
+ * `label` 进拒绝消息的前缀(如 `"provider"` / `"draw-state"`)。`logger` 的
679
+ * `| undefined` 不是冗余:tsconfig 开了 `exactOptionalPropertyTypes`,而各 store
680
+ * 解构出来的 `logger` 本身就带着 `undefined`(属性可选),不写这一半编译不过。
681
+ * @returns {Promise<string|null>} 拒绝原因(已记日志),放行时为 `null`。
682
+ */
683
+ async function guardStateWrite(file, known, options) {
684
+ const base = basename(file);
685
+ let existing;
686
+ try {
687
+ existing = await readStateVersion(file);
688
+ } catch (error) {
689
+ const reason = `${options.label}: refusing to overwrite ${base} (cannot read it: ${redactSecrets(errMsg(error))})`;
690
+ degrade(reason, null, options.logger, null);
691
+ return reason;
692
+ }
693
+ if (!isKnownStateVersion(existing, known)) {
694
+ const reason = `${options.label}: refusing to overwrite ${base} holding version ${existing} (this build knows ${known.join("/")})`;
695
+ degrade(reason, null, options.logger, null);
696
+ return reason;
697
+ }
698
+ return null;
699
+ }
700
+ /**
701
+ * 读侧版本说明:磁盘上那份文件带着本构建读不懂的版本时,给操作者的一句话。
702
+ *
703
+ * 为什么读侧宽容还要说话:写侧守卫挡住了覆盖,读侧按设计把未知版本**宽容地**
704
+ * 读作「没设过」——于是「保存的值被忽略」在面板上呈现成「从未保存过」。对
705
+ * provider 的允许清单,这两者读出的方向是危险的(空清单 = 不过滤 = 提供全部
706
+ * 模型);对 draw 的开关,回退方向是 patch 默认(通常 = 关)。闸与说明是同一条
707
+ * 承诺的两半,缺了哪半都静默。
708
+ *
709
+ * `consequence` 由调用方给:每个 store 知道自己「读作没设过」意味着什么,公共
710
+ * 原语只负责把文件名、版本号、已知版本集合与下一步动作说全。
711
+ *
712
+ * @param {object} options
713
+ * @param {string} options.label - 消息前缀(如 `"provider-state"`)。
714
+ * @param {string} options.file - 状态文件路径(消息里只取 basename)。
715
+ * @param {number} options.version - 盘上的版本号(调用方已判过是未知的)。
716
+ * @param {readonly number[]} options.known - 本构建读得懂的版本集合。
717
+ * @param {string} options.consequence - 「读作没设过」对这个 store 意味着什么。
718
+ * @returns {string} 人读的一句警告。
719
+ */
720
+ function stateVersionNote(options) {
721
+ 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.`;
722
+ }
608
723
 
609
724
  //#endregion
610
- //#region src/host/codes.ts
725
+ //#region src/shared/codes.ts
611
726
  /**
612
- * 错误分类的单一真源(对齐姊妹插件的 codes.ts 纪律)。
727
+ * 错误分类码表的**单一真源**——Host 与 Client 同读这一份。
728
+ *
729
+ * 为什么从 `host/codes.ts` 搬来:这张表曾经在 Client 侧有一份「刻意副本」
730
+ * (`client/snapshot.ts` 的 `CLIENT_CODE`),理由是「浏览器无法 import Host
731
+ * 模块」。那个理由已经过期——`shared/wire.ts` 的运行时值(如
732
+ * `SNAPSHOT_REQUIRED_KEYS`)早就在双端 import,浏览器 bundle 拿得到 shared/
733
+ * 下的任何模块。两份表必然漂移,而漂移的后果是最需要人看的内部错误没有引导
734
+ * 文案(INTERNAL_ERROR 当初就是这么漏的)。码表住 shared,Host 的分类函数
735
+ * (`classifyStatus` / `classifyRateLimit` / `parseRetryAfterMs`)留在
736
+ * `host/codes.ts`——那些读上游响应,是 Host 独有的行为。
613
737
  *
614
738
  * 魔搭的分类现实(SPIKE.md):上游不带任何额度头,额度耗尽与每分钟限频都只
615
739
  * 会以 429 + 文案出现,所以 429 的分诊是**按 body 文案**做的保守二分——
@@ -619,8 +743,8 @@ function isKnownStateVersion(version, known) {
619
743
  * @module dsh-connect-modelscope-token-plan/codes
620
744
  */
621
745
  /**
622
- * 面板按它分流的稳定码表 —— Client 的 `GUIDANCE_BY_CODE` 以这份表的键为契约,
623
- * 新增/改名必须同步 Client,否则内部错误没有引导文案(见下 `INTERNAL_ERROR`)。
746
+ * 面板按它分流的稳定码表 —— Client 的 `GUIDANCE_BY_CODE` 以这份表的键为契约
747
+ * (且以 `CodeValue` 为**类型**契约:缺键是编译错误,不再只靠测试红)。
624
748
  */
625
749
  const CODE = Object.freeze({
626
750
  CONFIG_ERROR: "config_error",
@@ -643,12 +767,26 @@ const CODE = Object.freeze({
643
767
  * Client 自产码:Host 响应无法解读成快照(非 JSON / 缺 ok 字段)时,
644
768
  * 面板用它走引导文案,而不是把裸英文串("unexpected payload")上屏。
645
769
  *
646
- * 本表是 `GUIDANCE_BY_CODE` 的键集真源(panel.test §2 钉「每个键必须在
647
- * CODE 里」、§3c 钉「每个码必须有引导」)——新增 client 码必须同时进
770
+ * 本表是 `GUIDANCE_BY_CODE` 的键集真源——新增 client 码必须同时进
648
771
  * 这张表,两侧才同步。
649
772
  */
650
773
  PAYLOAD_ERROR: "payload_error"
651
774
  });
775
+
776
+ //#endregion
777
+ //#region src/host/codes.ts
778
+ /**
779
+ * Host 侧的 429 分诊分类函数。**码表本身已迁至 `shared/codes.ts`**(双端同读,
780
+ * 见那里的搬迁理由);本文件只剩「读上游响应」的 Host 独有行为,并把 CODE
781
+ * 再导出一次,让 Host 各模块的 `from "./codes.ts"` 导入路径不变。
782
+ *
783
+ * 魔搭的分类现实(SPIKE.md):上游不带任何额度头,额度耗尽与每分钟限频都只
784
+ * 会以 429 + 文案出现,所以 429 的分诊是**按 body 文案**做的保守二分——
785
+ * QUOTA_EXCEEDED 快速失败不重试,RATE_LIMITED 交给调用方退避。分不清时归
786
+ * RATE_LIMITED(退避是两方向里更安全的那个)。
787
+ *
788
+ * @module dsh-connect-modelscope-token-plan/codes
789
+ */
652
790
  /**
653
791
  * 把上游 HTTP 状态归类成稳定码。
654
792
  *
@@ -690,11 +828,7 @@ function classifyStatus(status) {
690
828
  function classifyRateLimit(message) {
691
829
  if (typeof message !== "string" || message.length === 0) return "rate_limit";
692
830
  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);
831
+ return hasHardQuotaWordingIn(message) ? "quota" : "rate_limit";
698
832
  }
699
833
  /**
700
834
  * 解析 Retry-After(秒或 HTTP 日期)与平台文案里写明的等待时长。
@@ -859,8 +993,10 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
859
993
  events: []
860
994
  };
861
995
  let loaded = false;
862
- /** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。 */
863
- let writeChain = Promise.resolve();
996
+ /** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。
997
+ 队列实现在 state-store({@link createStateWriteQueue})——provider / draw
998
+ 同款,三份私有副本正是 draw-store 曾经三件全缺的入口。 */
999
+ const writeQueue = createStateWriteQueue();
864
1000
  /**
865
1001
  * 磁盘版本闸 —— 读路径与写路径**各自**调用,不共享副作用。
866
1002
  *
@@ -880,7 +1016,7 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
880
1016
  try {
881
1017
  onDisk = await readStateVersion(file);
882
1018
  } catch (error) {
883
- const note = `usage-state: usage.json exists but cannot be read (${redactSecrets$1(errMsg(error))})`;
1019
+ const note = `usage-state: usage.json exists but cannot be read (${redactSecrets(errMsg(error))})`;
884
1020
  if (!readOnly) logger?.warn?.(`${name}: ${note} — recording disabled to avoid clobbering`);
885
1021
  anomaly = note;
886
1022
  readOnly = true;
@@ -915,23 +1051,19 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
915
1051
  cache.remember(payload);
916
1052
  };
917
1053
  /** 排队执行一次读-改-写;只读闸置位时整个变更是 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
- };
1054
+ const enqueue = (mutate) => writeQueue.enqueue(async () => {
1055
+ if (await diskVersionBlocksWrites()) return;
1056
+ const payload = await readThrough() ?? (loaded ? current : null) ?? {
1057
+ version: STATE_VERSION,
1058
+ days: {},
1059
+ models: {},
1060
+ events: []
1061
+ };
1062
+ mutate(payload);
1063
+ current = payload;
1064
+ loaded = true;
1065
+ await persist(payload);
1066
+ });
935
1067
  /** 修剪:只留最近 trendDays 个天桶(按 dateKey 排序,今天不删)。 */
936
1068
  const pruneDays = (payload, todayKey) => {
937
1069
  const keep = new Set(Object.keys(payload.days).sort().slice(-Math.max(1, trendDays)));
@@ -1372,7 +1504,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1372
1504
  });
1373
1505
  } catch (error) {
1374
1506
  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)));
1507
+ throw pluginError(CODE.NETWORK_ERROR, redactSecrets(errMsg(error)));
1376
1508
  } finally {
1377
1509
  clearTimeout(timer);
1378
1510
  }
@@ -1467,7 +1599,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1467
1599
  if (!response.ok) {
1468
1600
  const message = await readErrorMessage(response);
1469
1601
  const code = classifyStatus(response.status);
1470
- throw pluginError(code, redactSecrets$1("model catalog failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1602
+ throw pluginError(code, redactSecrets("model catalog failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1471
1603
  }
1472
1604
  const json = await response.json().catch(() => null);
1473
1605
  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:[...]})");
@@ -1536,7 +1668,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1536
1668
  if (!response.ok) {
1537
1669
  const message = await readErrorMessage(response);
1538
1670
  const code = classifyStatus(response.status);
1539
- throw pluginError(code, redactSecrets$1("magicube balance failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1671
+ throw pluginError(code, redactSecrets("magicube balance failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1540
1672
  }
1541
1673
  const json = await response.json().catch(() => null);
1542
1674
  const data = json !== null && typeof json === "object" ? json.data : null;
@@ -1578,14 +1710,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1578
1710
  const elapsedMs = Date.now() - startedAt;
1579
1711
  if (response.status === 401 || response.status === 403) {
1580
1712
  const message = await readErrorMessage(response);
1581
- throw pluginError(CODE.AUTH_ERROR, redactSecrets$1(message === "" ? "token rejected (HTTP 401/403)" : message));
1713
+ throw pluginError(CODE.AUTH_ERROR, redactSecrets(message === "" ? "token rejected (HTTP 401/403)" : message));
1582
1714
  }
1583
1715
  if (response.status === 429) {
1584
1716
  const message = await readErrorMessage(response);
1585
1717
  const retryAfterMs = parseRetryAfterMs(response.headers, message);
1586
1718
  const triage = classifyRateLimit(message);
1587
1719
  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);
1720
+ throw pluginError(triage === "quota" ? CODE.QUOTA_EXCEEDED : CODE.RATE_LIMITED, redactSecrets(message === "" ? "modelscope returned 429 (probe on " + modelId + ")" : message), extra);
1589
1721
  }
1590
1722
  if (response.status >= 500) throw pluginError(CODE.UPSTREAM_ERROR, "modelscope returned HTTP " + String(response.status) + " (validity probe)");
1591
1723
  response.body?.cancel();
@@ -1795,28 +1927,22 @@ function createFileProviderStore(options = {}) {
1795
1927
  * read. An unknown NUMERIC version means a NEWER build wrote it; clobbering
1796
1928
  * it destroys data we cannot even see. So the write is refused with a
1797
1929
  * `degrade` signal, not a silent no-op — swallow the failure, not the reason.
1930
+ * The guard itself lives in `state-store.ts` ({@link guardStateWrite}) — it is
1931
+ * the same promise draw-store makes, and two copies of "what may be
1932
+ * overwritten" is one copy too many.
1798
1933
  * @param {object} body - the JSON body to persist.
1799
1934
  * @returns {Promise<string|null>} the refusal reason when the write was
1800
1935
  * refused (already `degrade`-logged), or `null` when it landed — the caller
1801
1936
  * decides whether to surface the refusal to a user.
1802
1937
  */
1803
1938
  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");
1939
+ const refusal = await guardStateWrite(filePath, KNOWN_PROVIDER_VERSIONS, {
1940
+ label: "provider",
1941
+ logger
1942
+ });
1943
+ if (refusal !== null) return refusal;
1818
1944
  await ensureStateDir(stateDir$1);
1819
- await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary });
1945
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$1, "provider.json") });
1820
1946
  return null;
1821
1947
  };
1822
1948
  const legacyFile = dir === void 0 && profile ? join(stateDir(name), "provider.json") : null;
@@ -1859,7 +1985,7 @@ function createFileProviderStore(options = {}) {
1859
1985
  const parsePayload = async () => parseAt(filePath);
1860
1986
  /** Read one payload off an explicit path (the legacy shared file). */
1861
1987
  const parsePayloadFrom = async (file) => parseAt(file);
1862
- let writeChain = Promise.resolve(null);
1988
+ const writeQueue = createStateWriteQueue();
1863
1989
  const cache = createStateReadCache(async () => {
1864
1990
  const own = await parsePayload();
1865
1991
  if (own.enabled !== null || own.enabledIds !== null || legacyFile === null) return own;
@@ -1872,7 +1998,7 @@ function createFileProviderStore(options = {}) {
1872
1998
  ...legacy.enabledIds === null ? {} : { enabledIds: legacy.enabledIds },
1873
1999
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1874
2000
  };
1875
- await writeChain.then(() => writePayload(inherited).then(() => void 0, () => void 0), () => writePayload(inherited).then(() => void 0, () => void 0));
2001
+ await writeQueue.enqueue(() => writePayload(inherited).then(() => void 0, () => void 0));
1876
2002
  return legacy;
1877
2003
  }, { ttlMs });
1878
2004
  const read = () => cache.read();
@@ -1918,7 +2044,7 @@ function createFileProviderStore(options = {}) {
1918
2044
  * @returns {Promise<string|null>} the write refusal reason, or `null`.
1919
2045
  */
1920
2046
  const patchPayload = (patch) => {
1921
- const run = writeChain.then(async () => {
2047
+ return writeQueue.enqueue(async () => {
1922
2048
  const current = await parsePayload();
1923
2049
  /** `undefined` keeps the stored key; `null` clears it (key absent). */
1924
2050
  const body = { version: 1 };
@@ -1938,8 +2064,6 @@ function createFileProviderStore(options = {}) {
1938
2064
  });
1939
2065
  return refusal;
1940
2066
  });
1941
- writeChain = run.catch(() => null);
1942
- return run;
1943
2067
  };
1944
2068
  return {
1945
2069
  /**
@@ -1994,7 +2118,13 @@ function createFileProviderStore(options = {}) {
1994
2118
  async versionNote() {
1995
2119
  const version = (await readPayload()).version;
1996
2120
  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.`;
2121
+ return stateVersionNote({
2122
+ label: "provider-state",
2123
+ file: filePath,
2124
+ version,
2125
+ known: KNOWN_PROVIDER_VERSIONS,
2126
+ 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."
2127
+ });
1998
2128
  },
1999
2129
  /**
2000
2130
  * Persist a switch value. The write is atomic (temp file + rename) so a
@@ -2069,8 +2199,27 @@ function createFileProviderStore(options = {}) {
2069
2199
  * 优先级与所有 opt-in 开关同一份裁决(`switch-precedence.ts`):面板保存值赢,
2070
2200
  * 否则 `cordis.patch.yml` 的 `drawEnabled` 默认值说了算。
2071
2201
  *
2072
- * 完整性沿用 `state-store.ts`:版本化 payload、临时文件 + 原子改名 + fsync、属主
2073
- * 可读、「认不出的内容一律读作没设过」。写入侧拒绝覆盖本版本读不懂的版本(ADR-006)。
2202
+ * 完整性三件套(与 provider / usage 同源,全部上收在 `state-store.ts`,本文件只做
2203
+ * 调用方——2026-10-17 收敛:此前这里**三件全缺**,并发 `saveEnabled` +
2204
+ * `saveModelId` 会丢一次保存、磁盘 version 比本构建新时降级覆写、读侧静默读作
2205
+ * 「没设过」,正是 provider-store 注释里记着「咬过三次」的那类漂移的第四条):
2206
+ * - **写串行化**({@link createStateWriteQueue}):读-改-写整体排队,临界区内
2207
+ * 从磁盘重读基线(不用 TTL 缓存—— provider §8d 的回归用例),链吞自己的
2208
+ * 失败继续走;
2209
+ * - **写守卫**({@link guardStateWrite},ADR-006):盘上版本本构建读不懂时
2210
+ * 拒写并给出理由,绝不降级覆写;「文件在场却读不出来」同样拒写(读不到 ≠
2211
+ * 没有东西要保护);
2212
+ * - **读侧说明**({@link stateVersionNote}):未知版本宽容读作「没设过」,但
2213
+ * {@link createFileDrawStore} 的 `versionNote()` 把「保存的开关与模型偏好
2214
+ * 被忽略」说出口,快照聚合把它转进 `shapeWarnings`——不说,面板就把「保存的
2215
+ * 值被忽略」呈现成「从未保存过」。
2216
+ *
2217
+ * 与 provider-store 的一处**有意差异**:没有 legacy(pre-profile 共享目录)继承。
2218
+ * draw.json 随出图工具一起诞生,晚于按 profile 分段,市面上不存在共享布局里的
2219
+ * 旧 draw.json;provider.json 则需要(§23 之前它一直在共享目录)。
2220
+ *
2221
+ * doctor 覆盖:`describeDrawPayload` 给只读速览,`doctor.ts` 据此把 draw.json
2222
+ * 的「损坏 / 未知版本」报进症状表——写侧守卫挡覆盖,doctor 报静默降级。
2074
2223
  *
2075
2224
  * @module dsh-connect-modelscope-token-plan/draw-store
2076
2225
  */
@@ -2083,9 +2232,19 @@ const EMPTY_PAYLOAD = {
2083
2232
  modelId: null,
2084
2233
  version: null
2085
2234
  };
2086
- /** 本 build 读不读得懂这个版本。 */
2235
+ /**
2236
+ * 本 build 读不读得懂这个版本。
2237
+ *
2238
+ * 与 provider-store 的 {@link isKnownProviderVersion} 同形:**白名单**判据,
2239
+ * 不与 {@link DRAW_STORE_VERSION} 比较——升级后旧号仍要可读。读侧(parseAt)、
2240
+ * 报告侧(versionNote / describeDrawPayload)问的都是这一个函数,三者不可能就
2241
+ * 「什么是未知版本」说两套话。写侧问的是 `state-store` 的
2242
+ * `isKnownStateVersion`(它额外放行 `null` = 没有文件,那是唯一该写的情形)。
2243
+ * @param {unknown} version - 盘上读到的值(可能根本不是数字)。
2244
+ * @returns {boolean}
2245
+ */
2087
2246
  function isKnownDrawVersion(version) {
2088
- return isKnownStateVersion(version, KNOWN_DRAW_VERSIONS);
2247
+ return typeof version === "number" && KNOWN_DRAW_VERSIONS.includes(version);
2089
2248
  }
2090
2249
  /**
2091
2250
  * 归一一个保存的模型偏好:非空字符串,或 `null`(= 自动挑——具体挑谁由
@@ -2123,7 +2282,31 @@ function createFileDrawStore(options = {}) {
2123
2282
  const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
2124
2283
  const stateDir = drawStoreDir(profile, dir);
2125
2284
  const filePath = join(stateDir, "draw.json");
2126
- /** 解析一份 payload;认不出的版本读作「没设过」但**记住**版本号。 */
2285
+ /**
2286
+ * 写一份补丁并落盘(内部用;对调用方暴露的是 saveEnabled / saveModelId / reset)。
2287
+ *
2288
+ * ADR-006 写守卫({@link guardStateWrite})先问盘:版本不认或文件读不出 →
2289
+ * 返回拒绝理由(已 degrade 记录),不落笔。守卫与随后的写入在同一个临界区里
2290
+ * (由 patchPayload 的链保证),判据与执行之间不隔任何 await。
2291
+ * @param {object} body - 要持久化的 JSON 体。
2292
+ * @returns {Promise<string|null>} 拒绝原因,或 `null` = 已写入。
2293
+ */
2294
+ const writePayload = async (body) => {
2295
+ const refusal = await guardStateWrite(filePath, KNOWN_DRAW_VERSIONS, {
2296
+ label: "draw-state",
2297
+ logger
2298
+ });
2299
+ if (refusal !== null) return refusal;
2300
+ await ensureStateDir(stateDir);
2301
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "draw.json") });
2302
+ return null;
2303
+ };
2304
+ /** 写串行化:读-改-写必须排队(详见{@link createStateWriteQueue}与文件头)。 */
2305
+ const writeQueue = createStateWriteQueue();
2306
+ /**
2307
+ * 解析一份 payload;认不出的版本读作「没设过」但**记住**版本号——读侧宽容、
2308
+ * versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是 ADR-006)。
2309
+ */
2127
2310
  const parsePayload = async () => {
2128
2311
  const source = obj(await readStateJson(filePath));
2129
2312
  const version = typeof source.version === "number" ? source.version : null;
@@ -2143,60 +2326,84 @@ function createFileDrawStore(options = {}) {
2143
2326
  * 读当前 payload。
2144
2327
  * @returns {Promise<DrawPayload>}
2145
2328
  */
2146
- const current = async () => await cache.read() ?? EMPTY_PAYLOAD;
2329
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
2147
2330
  /**
2148
- * 写一份补丁并落盘。
2149
- * @param {{enabled?: boolean|null, modelId?: string|null}} patch - 要改的字段。
2150
- * @returns {Promise<string|null>} 拒绝原因(`null` = 已写入)。
2331
+ * 读-改-写一份补丁,**整段排队**。
2332
+ *
2333
+ * 基线每次从磁盘重读(`parsePayload` 直读,不走 TTL 缓存):两个 Host 进程
2334
+ * 共享一个状态目录,而缓存里可能是写入前的值——provider §8d 的回归用例
2335
+ * (两个写入者共享一个目录,回读把较新的缓存顶成旧值)在 draw 上同样成立。
2336
+ * 写成功即 `cache.remember`,调用方不必各自回读一遍。
2337
+ *
2338
+ * 失败语义与 provider-store 对齐:拒绝原因与 IO 失败都**向上抛**(save 抛、
2339
+ * 路由 catch 后 `respondError` 呈报),链自己吞掉失败继续走后面的保存。
2340
+ *
2341
+ * @param {{enabled?: boolean|null, modelId?: string|null}} patch - 要改的字段;
2342
+ * `undefined` = 保留盘上的值,`null` = 清除(回到「没设过」)。
2343
+ * @returns {Promise<void>}
2151
2344
  */
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
- };
2345
+ const patchPayload = (patch) => writeQueue.enqueue(async () => {
2346
+ const current = await parsePayload();
2347
+ const enabled = patch.enabled === void 0 ? current.enabled : patch.enabled;
2348
+ const modelId = patch.modelId === void 0 ? current.modelId : patch.modelId;
2349
+ const body = { version: 1 };
2350
+ if (enabled !== null) body.enabled = enabled;
2351
+ if (modelId !== null) body.modelId = modelId;
2352
+ body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2353
+ const refusal = await writePayload(body);
2354
+ if (refusal !== null) throw new Error(refusal);
2355
+ cache.remember({
2356
+ enabled,
2357
+ modelId,
2358
+ version: 1
2359
+ });
2360
+ });
2173
2361
  return {
2174
2362
  /** 面板保存的出图开关;`null` = 回退 patch 默认值。 */
2175
2363
  async enabled() {
2176
- return (await current()).enabled;
2364
+ return (await readPayload()).enabled;
2177
2365
  },
2178
2366
  /** 面板保存的模型偏好;`null` = 自动。 */
2179
2367
  async modelId() {
2180
- return (await current()).modelId;
2368
+ return (await readPayload()).modelId;
2181
2369
  },
2182
2370
  /** 这个 profile 是否被显式配置过。 */
2183
2371
  async isConfigured() {
2184
- const payload = await current();
2372
+ const payload = await readPayload();
2185
2373
  return payload.enabled !== null || payload.modelId !== null;
2186
2374
  },
2375
+ /**
2376
+ * 盘上带着本构建读不懂的版本时的一句说明,否则 `null`。
2377
+ *
2378
+ * 读侧宽容(未知版本读作「没设过」)的另一半:快照聚合把它转进
2379
+ * `shapeWarnings`,doctor 报同一份文件。没有它,「保存的开关与模型偏好
2380
+ * 被忽略」在面板上就是「从未保存过」——而回退方向是 patch 默认(通常=关)。
2381
+ * @returns {Promise<string|null>}
2382
+ */
2383
+ async versionNote() {
2384
+ const version = (await readPayload()).version;
2385
+ if (version === null || isKnownDrawVersion(version)) return null;
2386
+ return stateVersionNote({
2387
+ label: "draw-state",
2388
+ file: filePath,
2389
+ version,
2390
+ known: KNOWN_DRAW_VERSIONS,
2391
+ 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."
2392
+ });
2393
+ },
2187
2394
  /** 保存开关。 */
2188
2395
  async saveEnabled(enabled) {
2189
2396
  if (typeof enabled !== "boolean") throw new TypeError("draw switch expects a boolean");
2190
- return patchPayload({ enabled });
2397
+ await patchPayload({ enabled });
2191
2398
  },
2192
2399
  /** 保存模型偏好(`null` = 清掉,回到自动)。 */
2193
2400
  async saveModelId(modelId) {
2194
2401
  if (modelId !== null && (typeof modelId !== "string" || modelId.trim() === "")) throw new TypeError("draw model expects a non-empty string or null");
2195
- return patchPayload({ modelId });
2402
+ await patchPayload({ modelId });
2196
2403
  },
2197
2404
  /** 清掉两个字段(回到「没配过」)。 */
2198
2405
  async reset() {
2199
- return patchPayload({
2406
+ await patchPayload({
2200
2407
  enabled: null,
2201
2408
  modelId: null
2202
2409
  });
@@ -2233,6 +2440,13 @@ function createFileDrawStore(options = {}) {
2233
2440
  * 空串,没有顶层 `message`。任何「从错误里读出原因」的承诺都是编造——本模块的
2234
2441
  * `describeDrawFailure` 因此**明确承认原因不可知**,只报可确定的形态差异。
2235
2442
  *
2443
+ * 关于两个小工具(`str` / `redactSecrets`):它们曾以**本地私有副本**的形式活在
2444
+ * 本文件里,理由是「避免为 3 行代码引 util 的依赖面」。那条理由不成立——util.ts
2445
+ * 自己只有 type-only import(peer-free),同包的 `draw-runtime.ts` 早就 import 它;
2446
+ * 而私有副本已经漂移:脱敏正则少了 `sk-` 与密码键值对两组、`ms-` 的 charset 与
2447
+ * util 版不一致(`[A-Za-z0-9-]` vs `[0-9a-f-]`)——脱敏闸漏一条就是凭据外泄
2448
+ * (红线 1)。现在一律用 util 的单一副本。
2449
+ *
2236
2450
  * @module dsh-connect-modelscope-token-plan/draw
2237
2451
  */
2238
2452
  /**
@@ -2255,8 +2469,19 @@ const DRAW_TIMEOUT_MS = 9e5;
2255
2469
  * 详见 docs/SPIKE-image-gen.md §坑 4(含撤回记录)。
2256
2470
  */
2257
2471
  const DRAW_POLL_INTERVAL_MS = 15e3;
2258
- /** 轮询的总截止时间;与 `DRAW_TIMEOUT_MS` 同量级但独立可调(诊断时可放宽)。 */
2259
- const DRAW_POLL_DEADLINE_MS = DRAW_TIMEOUT_MS;
2472
+ /**
2473
+ * 一次**工具调用**里愿意等的时间(毫秒)。
2474
+ *
2475
+ * 出图工具是给 agent 用的,一次调用阻塞 = 一整轮对话被占住。15 分钟的总预算
2476
+ * ({@link DRAW_TIMEOUT_MS})是「这张图值不值得继续等」的上限,不是「一次调用
2477
+ * 该不该堵住 agent」的上限——两者同一个数,就是把上游排队的代价转嫁成对话的
2478
+ * 停顿。
2479
+ *
2480
+ * 取 90s:实测耗时 61s–537s 大幅波动,90s 能一次拿完最快那一档(61s),而
2481
+ * 更慢的本来就必须分段查(见 `draw-runtime.ts` 的两段式工具)。再往上加只是
2482
+ * 把「等得久」换成「卡得久」,并不改变「慢的图要分几次查」这个事实。
2483
+ */
2484
+ const DRAW_WAIT_BUDGET_MS = 9e4;
2260
2485
  /**
2261
2486
  * 轮询必须携带的头名与取值。
2262
2487
  *
@@ -2361,17 +2586,17 @@ function readOutputImages(data) {
2361
2586
  */
2362
2587
  function buildDrawBody(options) {
2363
2588
  const body = {
2364
- model: strOrEmpty(options.model),
2365
- prompt: strOrEmpty(options.prompt)
2589
+ model: str(options.model, ""),
2590
+ prompt: str(options.prompt, "")
2366
2591
  };
2367
2592
  const imageUrl = normalizeImageUrlList(options.imageUrl);
2368
2593
  if (imageUrl.length > 0) body.image_url = imageUrl;
2369
- const size = strOrEmpty(options.size);
2594
+ const size = str(options.size, "");
2370
2595
  if (size !== "") body.size = size;
2371
2596
  if (Number.isFinite(options.steps)) body.steps = options.steps;
2372
2597
  if (Number.isFinite(options.guidance)) body.guidance = options.guidance;
2373
2598
  if (Number.isFinite(options.seed)) body.seed = options.seed;
2374
- const negativePrompt = strOrEmpty(options.negativePrompt);
2599
+ const negativePrompt = str(options.negativePrompt, "");
2375
2600
  if (negativePrompt !== "") body.negative_prompt = negativePrompt;
2376
2601
  const loras = options.loras;
2377
2602
  if (typeof loras === "string" && loras.trim() !== "") body.loras = loras.trim();
@@ -2438,9 +2663,9 @@ function describeDrawFailure(failure) {
2438
2663
  if (status === 404) return `出图失败:HTTP 404 —— 端点或模型不存在(检查 apiBase 与模型 id)${tail}`;
2439
2664
  if (status === 429) return `出图失败:HTTP 429 —— 上游限流,请稍后重试${tail}`;
2440
2665
  if (status >= 500) return `出图失败:HTTP ${status} —— 上游服务异常,稍后重试${tail}`;
2441
- const cause = strOrEmpty(failure.cause);
2666
+ const cause = str(failure.cause, "");
2442
2667
  if (cause !== "") return `出图失败:${cause}${tail}`;
2443
- const taskStatus = strOrEmpty(failure.taskStatus);
2668
+ const taskStatus = str(failure.taskStatus, "");
2444
2669
  if (taskStatus === "FAILED") return "出图失败:上游任务 FAILED,但未给出失败原因(实测 errors.code=0、message 为空),换模型或稍后重试";
2445
2670
  if (taskStatus !== "") return `出图失败:任务停在 ${taskStatus}(非终态),已超过截止时间`;
2446
2671
  return `出图失败:上游未给出可诊断的失败信息${tail}`;
@@ -2471,13 +2696,61 @@ function createDrawCooldown(cooldownMs) {
2471
2696
  }
2472
2697
  };
2473
2698
  }
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]");
2699
+
2700
+ //#endregion
2701
+ //#region src/host/draw-jobs.ts
2702
+ /**
2703
+ * 建一张出图作业表。
2704
+ * @param {DrawJobsOptions} [options] - 容量上限。
2705
+ * @returns {DrawJobs} 作业表。
2706
+ */
2707
+ function createDrawJobs(options = {}) {
2708
+ const maxJobs = Math.max(1, Math.floor(options.maxJobs ?? 64));
2709
+ /** @type {Map<string, DrawJob>} 插入顺序即淘汰顺序(Map 保序)。 */
2710
+ const jobs = /* @__PURE__ */ new Map();
2711
+ const evictIfNeeded = () => {
2712
+ while (jobs.size > maxJobs) {
2713
+ const oldest = jobs.keys().next();
2714
+ if (oldest.done === true) return;
2715
+ jobs.delete(oldest.value);
2716
+ }
2717
+ };
2718
+ return {
2719
+ create({ taskId, model, prompt, now }) {
2720
+ const jobId = taskId;
2721
+ const job = {
2722
+ jobId,
2723
+ taskId,
2724
+ model,
2725
+ prompt,
2726
+ submittedAt: typeof now === "number" && Number.isFinite(now) ? now : Date.now(),
2727
+ polls: 0
2728
+ };
2729
+ const previous = jobs.get(jobId);
2730
+ if (previous !== void 0) job.polls = previous.polls;
2731
+ jobs.set(jobId, job);
2732
+ evictIfNeeded();
2733
+ return { ...job };
2734
+ },
2735
+ get(jobId) {
2736
+ const found = jobs.get(jobId);
2737
+ return found === void 0 ? null : { ...found };
2738
+ },
2739
+ notePolls(jobId, polls) {
2740
+ const found = jobs.get(jobId);
2741
+ if (found === void 0) return;
2742
+ found.polls += Math.max(0, Math.floor(polls));
2743
+ },
2744
+ drop(jobId) {
2745
+ jobs.delete(jobId);
2746
+ },
2747
+ dispose() {
2748
+ jobs.clear();
2749
+ },
2750
+ size() {
2751
+ return jobs.size;
2752
+ }
2753
+ };
2481
2754
  }
2482
2755
 
2483
2756
  //#endregion
@@ -2488,7 +2761,14 @@ function redactSecrets(text) {
2488
2761
  * 与 `llm-adapter.ts` 同一个处境——import Host 发行的 peer(`@deepseek-ai/dsh-tools`
2489
2762
  * 的 `defineTool` 由调用方惰性注入),**不能由离线单测套件导入**。纯逻辑全部住在
2490
2763
  * `draw.ts`(已由 `test/draw.test.mjs` 覆盖 39 组),本文件只做「接线 + I/O」:
2491
- * 轮询循环、fetch、工具定义。改错本文件**不会让任何门禁变红**。
2764
+ * 轮询循环、fetch、工具定义。
2765
+ *
2766
+ * **覆盖边界**(2026-10-16 复核):`runDrawOnce` / `pickDrawModel` / `drawModelIds` /
2767
+ * `splitImageUrls` / `buildGenerationsEndpoint` / `buildTasksEndpoint` /
2768
+ * `DRAW_TOOL_NAME` 由 `test/draw-runtime.test.mjs` 钉住;**没被覆盖的是
2769
+ * `defineDrawTool` 落到 peer registry 的那一段**(真 `defineTool` 形状与注册结果)
2770
+ * ——只改那一段不会让任何门禁变红。本文件此前整段自称「无覆盖」,该陈述在补上
2771
+ * 这个套件之后已经过时。
2492
2772
  *
2493
2773
  * 与姊妹插件的关键差异(形状不兼容,不能照抄):
2494
2774
  * `sensenova` 的出图是**同步单发**——一次 `fetch` 拿 `data[0].url` 就结束。魔搭是
@@ -2559,28 +2839,16 @@ function isTerminalPayload(data) {
2559
2839
  return isTerminalStatus(readTaskStatus(data));
2560
2840
  }
2561
2841
  /**
2562
- * 真正跑一次出图:提交 + 轮询到终态。
2842
+ * **第一段**:提交一次出图,只要 `task_id`。
2563
2843
  *
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`。
2844
+ * 提交必须带 `X-ModelScope-Async-Mode`(实测可省但官方示例都带,带上无害)。
2845
+ * 响应里的 `task_status` **一律忽略**——实测恒为 `SUCCEED`,连最终 `FAILED` 的那次
2846
+ * 也是,所以成功与否只看轮询终态(这条由 `readTaskId` 钉在纯逻辑层)。
2847
+ * @param {DrawSubmitInput} input - 提交参数。
2848
+ * @returns {Promise<{taskId: string}>} 上游任务 id(= 续查用的 jobId)。
2849
+ * @throws {Error} 提交失败——消息已过 `describeDrawFailure`。
2582
2850
  */
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 }) {
2851
+ async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl, size, steps, guidance, seed, negativePrompt }) {
2584
2852
  const headers = {
2585
2853
  Authorization: `Bearer ${apiKey}`,
2586
2854
  "Content-Type": "application/json"
@@ -2618,12 +2886,40 @@ async function runDrawOnce({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl
2618
2886
  bodyText: submitText,
2619
2887
  cause: "提交响应里没有 task_id"
2620
2888
  }));
2621
- const deadline = Date.now() + Math.max(1e3, Math.floor(pollDeadlineMs));
2889
+ return { taskId };
2890
+ }
2891
+ /**
2892
+ * **第二段**:在给定预算内轮询一个任务,直到终态或预算耗尽。
2893
+ *
2894
+ * 与 `runDrawOnce` 的差别只有一处:预算耗尽时**返回 `pending` 而不是抛错**——
2895
+ * 抛错是「这件事做不成了」,`pending` 是「还没成,可以再查」。同一段循环因此
2896
+ * 同时服务于两种调用形状(一次性跑完 / 分段续查),不写两份。
2897
+ *
2898
+ * 轮询带 `X-ModelScope-Task-Type`(**必需**,漏了必假失败);传输层抖动与非
2899
+ * JSON 响应体都**不升级为终局**,继续等下一轮。
2900
+ * @param {object} options - 等待参数。
2901
+ * @param {DrawFetch} options.fetchImpl - 注入的 fetch。
2902
+ * @param {string} options.apiBase - apiBase。
2903
+ * @param {string} options.apiKey - 访问令牌。
2904
+ * @param {string} options.taskId - 上游任务 id。
2905
+ * @param {number} [options.waitMs] - 本次调用的等待预算。
2906
+ * @param {number} [options.pollIntervalMs] - 轮询间隔(测试可缩短)。
2907
+ * @returns {Promise<DrawAwait>} 三种结局之一。
2908
+ */
2909
+ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS }) {
2910
+ const headers = {
2911
+ Authorization: `Bearer ${apiKey}`,
2912
+ "Content-Type": "application/json"
2913
+ };
2914
+ const deadline = Date.now() + Math.max(1, Math.floor(waitMs));
2622
2915
  const interval = Math.max(1, Math.floor(pollIntervalMs));
2623
2916
  let polls = 0;
2624
2917
  let lastStatus;
2625
2918
  for (;;) {
2626
- if (Date.now() >= deadline) throw new Error(describeDrawFailure({ cause: `超过截止时间(${Math.round(pollDeadlineMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${taskId})**可能仍在生成**——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关。稍后可重试;注意超时后原任务的结果不保证还能取回` }));
2919
+ if (Date.now() >= deadline) return {
2920
+ kind: "pending",
2921
+ polls
2922
+ };
2627
2923
  await sleep(interval);
2628
2924
  polls++;
2629
2925
  let polled;
@@ -2639,29 +2935,37 @@ async function runDrawOnce({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl
2639
2935
  continue;
2640
2936
  }
2641
2937
  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
- }));
2938
+ if (!polled.ok) return {
2939
+ kind: "failed",
2940
+ message: describeDrawFailure({
2941
+ ...polled.status !== void 0 ? { status: polled.status } : {},
2942
+ ...pollText !== "" ? { bodyText: pollText } : {},
2943
+ ...lastStatus !== void 0 ? { taskStatus: lastStatus } : {}
2944
+ })
2945
+ };
2647
2946
  const data = await safeJson(polled, pollText);
2648
2947
  const status = readTaskStatus(data);
2649
2948
  if (status !== null) lastStatus = status;
2650
2949
  if (!isTerminalPayload(data)) continue;
2651
- if (status === "FAILED") throw new Error(describeDrawFailure({
2652
- taskStatus: "FAILED",
2653
- bodyText: pollText
2654
- }));
2950
+ if (status === "FAILED") return {
2951
+ kind: "failed",
2952
+ message: describeDrawFailure({
2953
+ taskStatus: "FAILED",
2954
+ bodyText: pollText
2955
+ })
2956
+ };
2655
2957
  const images = readOutputImages(data);
2656
- if (images.length === 0) throw new Error(describeDrawFailure({
2657
- taskStatus: "SUCCEED",
2658
- bodyText: pollText,
2659
- cause: "任务报 SUCCEED 但没有可用图片"
2660
- }));
2958
+ if (images.length === 0) return {
2959
+ kind: "failed",
2960
+ message: describeDrawFailure({
2961
+ taskStatus: "SUCCEED",
2962
+ bodyText: pollText,
2963
+ cause: "任务报 SUCCEED 但没有可用图片"
2964
+ })
2965
+ };
2661
2966
  return {
2662
- model,
2967
+ kind: "done",
2663
2968
  images,
2664
- taskId,
2665
2969
  polls
2666
2970
  };
2667
2971
  }
@@ -2711,30 +3015,115 @@ function drawModelIds(entries) {
2711
3015
  return imageGenModelIds(entries);
2712
3016
  }
2713
3017
  /**
2714
- * 装配 agent 工具定义。
3018
+ * 装配 agent 工具定义——**两段式**,一次调用不再阻塞整轮对话。
3019
+ *
3020
+ * ## 为什么是两段式
3021
+ *
3022
+ * 上游出图要排队,实测 61s–537s(免费池)。旧形状是「一次调用 await 到终态」,
3023
+ * 于是 `drawTimeoutMs`(15 分钟)既是「这张图值不值得等」的上限,也成了「一轮
3024
+ * 对话被卡多久」的上限——把上游排队的代价直接转嫁成对话停顿。
3025
+ *
3026
+ * 现在一次调用只做两件事之一:
3027
+ * - **不带 `jobId`** → 提交任务,然后只在 `waitMs`(默认 {@link DRAW_WAIT_BUDGET_MS})
3028
+ * 内等;等到就返回 `done`,没等到就返回 `pending` + `jobId`;
3029
+ * - **带 `jobId`** → 只查那个任务,同样最多等 `waitMs`。
3030
+ *
3031
+ * `drawTimeoutMs` 因此回到它本来的语义:**从提交算起的总放弃线**(跨调用累计),
3032
+ * 越过它才报「放弃」。
2715
3033
  *
2716
- * 工具只做「参数归一 → 跑一次 → 组装结果文本」,所有判定都委托给 `draw.ts` 的纯函数。
3034
+ * ## 两个刻意的判定
3035
+ *
3036
+ * - `pending` **不是失败**:不抛错、不进冷却门。它只表示「这次调用愿意等的时间里
3037
+ * 没等到」,进冷却就会把续查这条路堵死——那正是分段查询的目的所在。
3038
+ * - 只有**真失败**(提交失败 / 上游 `FAILED` / 越过总放弃线)才 `cooldown.fail()`。
3039
+ *
3040
+ * 工具仍只做「参数归一 → 跑一次 → 组装结果文本」,所有判定都委托给 `draw.ts` 的纯函数。
2717
3041
  * @param {object} options - 装配参数。
2718
3042
  * @param {(definition: object) => unknown} options.defineTool - peer 的工厂。
2719
3043
  * @param {DrawFetch} options.fetchImpl - 注入的 fetch。
2720
3044
  * @param {() => Promise<string>} options.resolveApiKey - 每次调用现读令牌。
2721
3045
  * @param {() => unknown|Promise<unknown>} [options.getEntries] - 目录条目读取器。
2722
- * @param {object} [options.settings] - `{ apiBase, drawModelId, drawTimeoutMs }`。
3046
+ * @param {object} [options.settings] - `{ apiBase, drawModelId, drawTimeoutMs, drawWaitMs }`。
2723
3047
  * @param {ReturnType<typeof createDrawCooldown>} [options.cooldown] - 冷却门。
3048
+ * @param {DrawJobs} [options.jobs] - 作业表(可注入,便于测试)。
2724
3049
  * @param {() => boolean} [options.isDisposed] - 卸载闸。
2725
3050
  * @returns {unknown} 已定义的 agent 工具。
2726
3051
  */
2727
- function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, settings, getPreferredModelId, cooldown = createDrawCooldown(DRAW_FAILURE_COOLDOWN_MS), isDisposed = () => false }) {
3052
+ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, settings, getPreferredModelId, cooldown = createDrawCooldown(DRAW_FAILURE_COOLDOWN_MS), jobs = createDrawJobs(), pollIntervalMs = DRAW_POLL_INTERVAL_MS, isDisposed = () => false }) {
3053
+ /** 总放弃线:从提交算起,跨多次调用累计。 */
2728
3054
  const timeoutMs = Math.max(3e4, Math.floor(Number(settings?.drawTimeoutMs) > 0 ? Number(settings?.drawTimeoutMs) : DRAW_TIMEOUT_MS));
3055
+ /**
3056
+ * 单次调用愿意等的时间。
3057
+ *
3058
+ * 只夹**上界**(不该超过总放弃线,超过它的等待没有意义);下界交给
3059
+ * `host-config` 的 `drawWaitMs` clamp——同一个数在两处各夹一次,上限/下限就会
3060
+ * 各说一套话(`drawTimeoutMs` 的 clamp 早已是本仓库的教训)。
3061
+ */
3062
+ const waitMs = Math.min(timeoutMs, Math.max(1, Math.floor(Number(settings?.drawWaitMs) > 0 ? Number(settings?.drawWaitMs) : DRAW_WAIT_BUDGET_MS)));
2729
3063
  const apiBase = str(settings?.apiBase, "https://api-inference.modelscope.cn/v1");
3064
+ /**
3065
+ * 等一个作业,把 {@link awaitDrawTask} 的三种结局翻译成工具结果或错误。
3066
+ *
3067
+ * 两段共用这一段——「刚提交的」与「续查的」在等待这件事上没有区别,唯一的区别
3068
+ * 是作业表里那条记录从哪来。
3069
+ * @param {string} apiKey - 访问令牌。
3070
+ * @param {DrawJob} job - 作业(含提交时刻与累计轮询数)。
3071
+ * @param {string[]} available - 目录里的出图模型(续查时为空,不必再拉目录)。
3072
+ * @returns {Promise<DrawToolResult>} 工具结果。
3073
+ * @throws {Error} 真失败(上游 FAILED / 轮询 HTTP 失败)或越过总放弃线。
3074
+ */
3075
+ const waitAndReport = async (apiKey, job, available) => {
3076
+ const awaited = await awaitDrawTask({
3077
+ fetchImpl,
3078
+ apiBase,
3079
+ apiKey,
3080
+ taskId: job.taskId,
3081
+ waitMs,
3082
+ pollIntervalMs
3083
+ });
3084
+ if (awaited.kind === "failed") {
3085
+ jobs.drop(job.jobId);
3086
+ throw new Error(awaited.message);
3087
+ }
3088
+ jobs.notePolls(job.jobId, awaited.polls);
3089
+ const polls = job.polls + awaited.polls;
3090
+ if (awaited.kind === "done") {
3091
+ jobs.drop(job.jobId);
3092
+ const urls = awaited.images.join("\n");
3093
+ return {
3094
+ source: "modelscope",
3095
+ status: "done",
3096
+ jobId: job.jobId,
3097
+ model: job.model,
3098
+ url: awaited.images[0] ?? "",
3099
+ prompt: job.prompt,
3100
+ hint: `图片已生成(上游异步任务,${polls} 次轮询,task ${job.taskId})\n模型: ${job.model}\n${urls}` + (available.length > 0 ? `\n可用出图模型: ${available.join(", ")}` : "")
3101
+ };
3102
+ }
3103
+ const elapsedMs = Date.now() - job.submittedAt;
3104
+ if (elapsedMs >= timeoutMs) throw new Error(describeDrawFailure({ cause: `超过总预算(${Math.round(timeoutMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${job.taskId})**可能仍在生成**——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关;注意放弃后原任务的结果不保证还能取回` }));
3105
+ return {
3106
+ source: "modelscope",
3107
+ status: "pending",
3108
+ jobId: job.jobId,
3109
+ model: job.model,
3110
+ url: "",
3111
+ prompt: job.prompt,
3112
+ hint: `仍在生成中(已等 ${Math.round(elapsedMs / 1e3)}s,${polls} 次轮询,task ${job.taskId}):上游排队通常 1–15 分钟。稍后带 jobId ${job.jobId} 再调用本工具即可继续查询(只查询、不会重复出图)`
3113
+ };
3114
+ };
2730
3115
  return defineTool({
2731
3116
  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.",
3117
+ 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. 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
3118
  parameters: {
2734
3119
  prompt: {
2735
3120
  type: "string",
2736
3121
  required: true,
2737
- description: "Image prompt; describe the target image (for edits, describe the intended change)"
3122
+ description: "Image prompt; describe the target image (for edits, describe the intended change). Ignored when resuming with `jobId`."
3123
+ },
3124
+ jobId: {
3125
+ type: "string",
3126
+ description: "Resume an earlier task: pass the `jobId` returned by a previous call to keep polling it (no new image is submitted)"
2738
3127
  },
2739
3128
  model: {
2740
3129
  type: "string",
@@ -2771,6 +3160,8 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
2771
3160
  additionalProperties: false,
2772
3161
  properties: {
2773
3162
  source: { type: "string" },
3163
+ status: { type: "string" },
3164
+ jobId: { type: "string" },
2774
3165
  model: { type: "string" },
2775
3166
  url: { type: "string" },
2776
3167
  prompt: { type: "string" },
@@ -2782,15 +3173,26 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
2782
3173
  text: result?.hint || "图片已生成"
2783
3174
  }]
2784
3175
  },
2785
- timeoutMs: timeoutMs + 1e4,
3176
+ timeoutMs: waitMs + 1e4,
2786
3177
  async execute(params) {
2787
3178
  if (isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
3179
+ const apiKey = await resolveApiKey();
3180
+ if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("MODELSCOPE_API_KEY 未配置:在面板粘贴 ms- 访问令牌,或设置该环境变量");
3181
+ const resumeId = str(params?.jobId, "");
3182
+ if (resumeId !== "") {
3183
+ const job = jobs.get(resumeId);
3184
+ if (job === null) throw new Error(`jobId ${resumeId} 不在本次会话的作业表里(插件重启过,或该 id 不是本插件发出的):原任务可能仍在生成,但它的结果不保证还能取回(上游终态记录会被回收),请重新出图`);
3185
+ try {
3186
+ return await waitAndReport(apiKey, job, []);
3187
+ } catch (error) {
3188
+ cooldown.fail();
3189
+ throw error;
3190
+ }
3191
+ }
2788
3192
  const prompt = str(params?.prompt, "");
2789
3193
  if (prompt === "") throw new Error("prompt is required");
2790
3194
  const remaining = cooldown.remainingMs();
2791
3195
  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
3196
  let picked = [];
2795
3197
  try {
2796
3198
  picked = await getEntries?.() ?? [];
@@ -2816,7 +3218,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
2816
3218
  const guidance = toPositiveNumber(params?.guidance);
2817
3219
  const seed = toNonNegativeInt(params?.seed);
2818
3220
  const negativePrompt = str(params?.negativePrompt, "");
2819
- const outcome = await runDrawOnce({
3221
+ const { taskId } = await submitDrawTask({
2820
3222
  fetchImpl,
2821
3223
  apiBase,
2822
3224
  apiKey,
@@ -2827,18 +3229,14 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
2827
3229
  ...steps !== void 0 ? { steps } : {},
2828
3230
  ...guidance !== void 0 ? { guidance } : {},
2829
3231
  ...seed !== void 0 ? { seed } : {},
2830
- ...negativePrompt !== "" ? { negativePrompt } : {},
2831
- pollDeadlineMs: timeoutMs
3232
+ ...negativePrompt !== "" ? { negativePrompt } : {}
2832
3233
  });
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
- };
3234
+ const job = jobs.create({
3235
+ taskId,
3236
+ model,
3237
+ prompt
3238
+ });
3239
+ return await waitAndReport(apiKey, job, drawModelIds(entries));
2842
3240
  } catch (error) {
2843
3241
  cooldown.fail();
2844
3242
  throw error;
@@ -2993,9 +3391,13 @@ function switchSource(panel) {
2993
3391
  * registry 拒收——三种都由 `draw-tool-state.ts` 记下原因供面板显示。本文件不吞
2994
3392
  * 错误(那会让「工具为什么不在」无从诊断),只把它们翻译成可展示的原因码。
2995
3393
  *
2996
- * peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule` 惰性注入,
2997
- * 因此本文件与 `llm-adapter.ts` 一样**不能被离线测试导入**,也没有测试覆盖
2998
- * (docs/ROADMAP.md Backlog 记着这条缺口)。
3394
+ * peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule` 惰性注入。
3395
+ *
3396
+ * **本文件目前没有测试覆盖**——`registerDrawTool` 在 `test/` 里零引用
3397
+ * (docs/ROADMAP.md Backlog 记着这条缺口)。但**这不是「不能」**:同一处境的
3398
+ * `llm-adapter.ts` / `draw-runtime.ts` 已经用 `test/helpers/peer-stub-loader.mjs`
3399
+ * 证明这类模块能在离线套件里原样加载执行。此前的措辞把 `llm-adapter.ts` 当作
3400
+ * 「同样无法导入」的例证拉来作证,那条类比在补上那个 loader 之后已经失效。
2999
3401
  *
3000
3402
  * @module dsh-connect-modelscope-token-plan/draw-register
3001
3403
  */
@@ -3046,7 +3448,7 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
3046
3448
  defineTool = mod?.defineTool ?? mod?.default?.defineTool ?? null;
3047
3449
  } catch (error) {
3048
3450
  setDrawToolNote("peer-load-failed");
3049
- loggerFor?.warn?.(`${name}: draw tool peer module failed to load (${redactSecrets$1(errMsg(error))}) — the tool is absent`);
3451
+ loggerFor?.warn?.(`${name}: draw tool peer module failed to load (${redactSecrets(errMsg(error))}) — the tool is absent`);
3050
3452
  return;
3051
3453
  }
3052
3454
  if (typeof defineTool !== "function") {
@@ -3068,13 +3470,14 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
3068
3470
  settings: {
3069
3471
  apiBase: settings.apiBase,
3070
3472
  drawModelId: settings.drawModelId,
3071
- drawTimeoutMs: settings.drawTimeoutMs
3473
+ drawTimeoutMs: settings.drawTimeoutMs,
3474
+ drawWaitMs: settings.drawWaitMs
3072
3475
  },
3073
3476
  isDisposed
3074
3477
  }));
3075
3478
  } catch (error) {
3076
3479
  setDrawToolNote("registry-refused");
3077
- loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets$1(errMsg(error))}) — the tool is absent`);
3480
+ loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets(errMsg(error))}) — the tool is absent`);
3078
3481
  }
3079
3482
  }
3080
3483
 
@@ -3265,7 +3668,7 @@ function describeBuildFailure(error) {
3265
3668
  const annotated = error;
3266
3669
  const why = errMsg(error);
3267
3670
  return {
3268
- note: redactSecrets$1(why),
3671
+ note: redactSecrets(why),
3269
3672
  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
3673
  error
3271
3674
  };
@@ -3371,7 +3774,7 @@ function swapRegistration({ llm, built, previousBuilt, state, release, registerP
3371
3774
  release();
3372
3775
  state.built = null;
3373
3776
  onRollback?.();
3374
- state.error = redactSecrets$1(errMsg(error));
3777
+ state.error = redactSecrets(errMsg(error));
3375
3778
  restorePreviousPair({
3376
3779
  llm,
3377
3780
  previousBuilt,
@@ -3450,9 +3853,20 @@ function emitAdaptersUpdated(emit) {
3450
3853
  * - 单点 `registerPair`(带 factory-await + shape 检查)被 publish 与 rollback
3451
3854
  * 两条路共用;失败时**恢复旧对**,坏 publish 不会把已在服务的模型也拉下来。
3452
3855
  *
3453
- * 与姊妹插件的差异**只有**:工厂导出名 `"createModelScopeAdapter"`、`registerPair`
3454
- * 用的 `LLM_PROVIDER_ID`/`LLM_DISPLAY_NAME`、`warnBuildFailure` 的 label
3455
- * `"ModelScope"`。上面的四条语义原样保留——它们是 `publish-core.ts` 的骨头。
3856
+ * 结构性纪律(本文件新立的):一次 publish 拆成 **gather(可挂起)**与
3857
+ * **commit(不可挂起)**两半,commit 是**非 async 函数**——「disposed 复查与
3858
+ * registerAdapter 之间没有 await」由此前的注释论证升级为语言级保证。相位
3859
+ * (idle/serving/failing/broken/disposed)从契约字段推导,不双写。
3860
+ *
3861
+ * 与 sensenova 版的关系:**结构同源、代码不等价**——去注释后代码行重叠 77%
3862
+ * (2026-10-16 实测;复核:`node tools/dev/cross-repo-drift.mjs`)。所以差异**不止**
3863
+ * 三个标签:除工厂导出名 `"createModelScopeAdapter"`、`registerPair` 用的
3864
+ * `LLM_PROVIDER_ID`/`LLM_DISPLAY_NAME`、`warnBuildFailure` 的 label `"ModelScope"`
3865
+ * 之外,vision 判定这里取自 `llm-models.ts`(sensenova 侧走 `parsers.ts` 的
3866
+ * `identifyVisionModel`),usage 观察层的接线位置也不同。
3867
+ *
3868
+ * 可以跨仓库**对着读**的是三条承重语义(队列 / disposed 闸 / 单点 registerPair,
3869
+ * 它们是 `publish-core.ts` 的骨头,三处 91% 同源);具体代码不可照搬。
3456
3870
  *
3457
3871
  * peer-free:不 import 任何运行时 peer。适配器工厂由调用方注入
3458
3872
  * (`loadAdapterModule`,默认 `import("./llm-adapter.ts")`),所以离线套件可以换
@@ -3464,6 +3878,29 @@ function emitAdaptersUpdated(emit) {
3464
3878
  * @module dsh-connect-modelscope-token-plan/provider-publish
3465
3879
  */
3466
3880
  /**
3881
+ * 从 publisher 的契约状态**推导**相位——单源,不双写。
3882
+ *
3883
+ * `registered` / `error` 是 `publish-core.ts` 的共享契约字段(`swapRegistration`
3884
+ * 与 `unregister` 直接写它们)。并行维护一个 `phase` 字段就是双写,双写必然
3885
+ * 漂移且方向静默;推导让相位永远不可能与那两个字段矛盾。
3886
+ *
3887
+ * 相位与两道空转守卫的对应(旧版注释里的条件,现在是有名字的状态):
3888
+ * - `idle` = 没注册且无错:开关关着的稳态 → 关侧空转守卫可跳过;
3889
+ * - `serving` = 已注册且无错:offer 已 live → 开侧空转守卫可跳过;
3890
+ * - `failing` = 已注册但有错:回滚到了旧对、但新 offer 没落地 → **必须继续
3891
+ * 重试**(跳过就是静默丢弃用户刚保存的清单);
3892
+ * - `broken` = 没注册且有错:上次发布失败且什么都没在服务 → 继续重试自愈;
3893
+ * - `disposed` = 插件已 dispose:任何 publish 都是 no-op。
3894
+ * @param {{registered: boolean, error: string|null}} state - publisher 状态。
3895
+ * @param {boolean} disposed - publish 队列的 disposed 闸状态。
3896
+ * @returns {PublisherPhase} 相位。
3897
+ */
3898
+ function publisherPhaseOf(state, disposed) {
3899
+ if (disposed) return "disposed";
3900
+ if (state.registered) return state.error === null ? "serving" : "failing";
3901
+ return state.error === null ? "idle" : "broken";
3902
+ }
3903
+ /**
3467
3904
  * provider 发布器。
3468
3905
  *
3469
3906
  * 持有活注册状态(`state`);publish 队列、`disposed` 闸与单点 `registerPair` 是
@@ -3544,20 +3981,37 @@ function createProviderPublisher(deps = {}) {
3544
3981
  */
3545
3982
  const offerUnchanged = () => state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds);
3546
3983
  /**
3547
- * 为一个目录/允许清单快照(重)构建并注册 provider。
3984
+ * 发布相位——两道空转守卫的判断条件从此有名字(推导见 {@link publisherPhaseOf})。
3985
+ * @param {boolean} disposed - 当前是否已 dispose。
3986
+ * @returns {PublisherPhase}
3987
+ */
3988
+ const phaseOf = (disposed) => publisherPhaseOf(state, disposed);
3989
+ /**
3990
+ * gather 阶段:一次 publish 的**可挂起**半边。
3548
3991
  *
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}>}
3992
+ * 读面板开关、解析 llm 服务、解析适配器工厂、构建适配器——四个 await 全住
3993
+ * 在这里。产出两种计划:`done`(跳过 / 注销 / 构建失败,都已执行完)或
3994
+ * `commit`(适配器构建好了,只剩同步提交)。
3995
+ *
3996
+ * 领域字段(entries / enabledIds / unavailableIds)在查询开关**之前**写入,
3997
+ * 开关关分支刻意不回滚它们——这个不对称是承重的,不是疏忽:`routes/provider.ts`
3998
+ * 与 roster 保存会在开关重新打开时正好从这些字段重新 publish,所以用户的
3999
+ * 允许清单编辑与最后一次拉到的目录**必须**熬过关闭期。把 `previous*` 还回去
4000
+ * 会静默丢掉两者——开关关闭期间做的保存,会在开关重新打开时消失。
4001
+ * `swapRegistration` 的 `onRollback` 会还它们,但只对注册真的抛了的路径——
4002
+ * 那种失败必须让先前在服务的对继续服务,这是与「开关关着」不同的承诺。
4003
+ * @param {unknown} entries - 归一目录条目。
4004
+ * @param {unknown} enabledIds - 允许清单(空 = 全部)。
4005
+ * @param {string[]} unavailableModelIds - 额度耗尽的模型 id。
4006
+ * @returns {Promise<PublishPlan>} 执行计划。
3556
4007
  */
3557
- const publishProviderOnce = async (entries, enabledIds, unavailableModelIds = []) => {
4008
+ const gatherPublish = async (entries, enabledIds, unavailableModelIds) => {
3558
4009
  if (queue.isDisposed()) return {
3559
- ok: false,
3560
- skipped: true
4010
+ kind: "done",
4011
+ result: {
4012
+ ok: false,
4013
+ skipped: true
4014
+ }
3561
4015
  };
3562
4016
  const previousBuilt = state.built;
3563
4017
  const previousEntries = state.entries;
@@ -3568,16 +4022,22 @@ function createProviderPublisher(deps = {}) {
3568
4022
  state.unavailableIds = Array.isArray(unavailableModelIds) ? unavailableModelIds : [];
3569
4023
  const panelValue = await effectivePanelSwitch().catch(() => null);
3570
4024
  if (!resolveSwitchEnabled(panelValue, effectiveSettings.registerProvider === true)) {
3571
- if (state.registered === false && state.error === null && offerUnchanged()) return {
3572
- ok: true,
3573
- skipped: true
4025
+ if (phaseOf(false) === "idle" && offerUnchanged()) return {
4026
+ kind: "done",
4027
+ result: {
4028
+ ok: true,
4029
+ skipped: true
4030
+ }
3574
4031
  };
3575
4032
  const result = unregister({
3576
4033
  state,
3577
4034
  release
3578
4035
  });
3579
4036
  syncSignaturesAfterPublish(state);
3580
- return result;
4037
+ return {
4038
+ kind: "done",
4039
+ result
4040
+ };
3581
4041
  }
3582
4042
  const llm = resolveRegistrationService({
3583
4043
  state,
@@ -3585,13 +4045,15 @@ function createProviderPublisher(deps = {}) {
3585
4045
  release
3586
4046
  });
3587
4047
  if (llm === null) return {
3588
- ok: false,
3589
- error: state.error
4048
+ kind: "done",
4049
+ result: {
4050
+ ok: false,
4051
+ error: state.error
4052
+ }
3590
4053
  };
3591
- let createModelScopeAdapter;
3592
4054
  let built;
3593
4055
  try {
3594
- createModelScopeAdapter = await resolveAdapterFactory();
4056
+ const createModelScopeAdapter = await resolveAdapterFactory();
3595
4057
  if (createModelScopeAdapter === void 0) throw new Error(BAD_FACTORY_SHAPE_ERROR);
3596
4058
  built = await createModelScopeAdapter({
3597
4059
  entries: state.entries,
@@ -3608,31 +4070,75 @@ function createProviderPublisher(deps = {}) {
3608
4070
  state.error = described.note;
3609
4071
  warnBuildFailure(effectiveLogger, "ModelScope", described);
3610
4072
  return {
3611
- ok: false,
3612
- error: described.error
4073
+ kind: "done",
4074
+ result: {
4075
+ ok: false,
4076
+ error: described.error
4077
+ }
3613
4078
  };
3614
4079
  }
3615
- if (state.registered === true && state.error === null && offerUnchanged()) return {
3616
- ok: true,
3617
- skipped: true
4080
+ if (phaseOf(false) === "serving" && offerUnchanged()) return {
4081
+ kind: "done",
4082
+ result: {
4083
+ ok: true,
4084
+ skipped: true
4085
+ }
4086
+ };
4087
+ return {
4088
+ kind: "commit",
4089
+ llm,
4090
+ built,
4091
+ previousBuilt,
4092
+ previousEntries,
4093
+ previousEnabledIds,
4094
+ previousUnavailable
3618
4095
  };
4096
+ };
4097
+ /**
4098
+ * commit 阶段:一次 publish 的**不可挂起**半边。
4099
+ *
4100
+ * **这个函数刻意不是 `async`**——语言禁止在非 async 函数里写 `await`,所以
4101
+ * 从下面的 disposed 复查到 `swapRegistration` 内部的 `registerAdapter` 之间
4102
+ * **不可能**插入挂起点。旧版靠一句「这个位置严密:从这里到 registerAdapter
4103
+ * 之间没有任何 await」的注释论证这件事;现在由函数签名本身保证——想在这里
4104
+ * 加 await,编译期就过不去。临界区的正确性由结构承载,不由注释承载。
4105
+ *
4106
+ * 为什么这道复查必须有(gather 入口那次只覆盖到第一个 await,而注册发生在
4107
+ * **最后一次** await 之后):teardown 落在这段窗口里时,入口闸门不会重跑,
4108
+ * 于是 registerAdapter 把适配器注册进一个**已经把本插件撤下的 Host**,而
4109
+ * teardown 早就跑完、release 永不调用——泄漏的对持有 tokenStore 与整个
4110
+ * Cordis ctx 的闭包,形成僵尸 provider:picker 里那个 provider 还在、还能
4111
+ * 路由,而面板路由已注销,用户没有界面能关掉它。
4112
+ *
4113
+ * 为什么 peer 的 fiber 兜底救不了:`registerAdapter` 用的是 **llm 服务自己的**
4114
+ * `ctx.effect`(peer 源码里 `.bind(this)` 到 llm 服务),插件卸载不会 dispose
4115
+ * 它,宿主注册表永久持有该 adapter。
4116
+ *
4117
+ * 若 teardown 发生在本次检查**之后**,它执行 release() 时能看到新写入的
4118
+ * releaseAdapter 并摘掉——两种时序都正确。检查刻意留在本文件(publisher)而
4119
+ * **不**放进 `swapRegistration`:那是 publish-core 的共享骨头,不该知道
4120
+ * disposed 的概念。
4121
+ * @param {Extract<PublishPlan, {kind: "commit"}>} plan - gather 产出的提交计划。
4122
+ * @returns {{ok: boolean, skipped?: boolean, error?: unknown}}
4123
+ */
4124
+ const commitPublish = (plan) => {
3619
4125
  if (queue.isDisposed()) return {
3620
4126
  ok: false,
3621
4127
  skipped: true
3622
4128
  };
3623
4129
  const requestedSignature = catalogSignature(state.entries, state.enabledIds);
3624
4130
  const result = swapRegistration({
3625
- llm,
3626
- built,
3627
- previousBuilt,
4131
+ llm: plan.llm,
4132
+ built: plan.built,
4133
+ previousBuilt: plan.previousBuilt,
3628
4134
  state,
3629
4135
  release,
3630
4136
  registerPair,
3631
4137
  emit,
3632
4138
  onRollback: () => {
3633
- state.entries = previousEntries;
3634
- state.enabledIds = previousEnabledIds;
3635
- state.unavailableIds = previousUnavailable;
4139
+ state.entries = plan.previousEntries;
4140
+ state.enabledIds = plan.previousEnabledIds;
4141
+ state.unavailableIds = plan.previousUnavailable;
3636
4142
  }
3637
4143
  });
3638
4144
  syncSignaturesAfterPublish(state);
@@ -3640,6 +4146,22 @@ function createProviderPublisher(deps = {}) {
3640
4146
  return result;
3641
4147
  };
3642
4148
  /**
4149
+ * 为一个目录/允许清单快照(重)构建并注册 provider。
4150
+ *
4151
+ * 形状 = gather(可挂起)+ commit(不可挂起):全部 await 住在前者,后者是
4152
+ * 非 async 函数。重建并重注册而非原地改:`PiAiAdapter` 内部 memoize profiles
4153
+ * 快照,只有新的注册才能改变提供的模型列表。注册失败时**恢复上一对**,所以
4154
+ * 坏 publish 永远不能把已在服务的模型拉下来。
4155
+ * @param {object[]} entries - 归一目录条目。
4156
+ * @param {string[]} enabledIds - 允许清单(空 = 全部)。
4157
+ * @param {string[]} [unavailableModelIds] - 要从 picker 的 offer 里丢掉的额度耗尽 id。
4158
+ * @returns {Promise<{ok: boolean, skipped?: boolean, error?: unknown}>}
4159
+ */
4160
+ const publishProviderOnce = async (entries, enabledIds, unavailableModelIds = []) => {
4161
+ const plan = await gatherPublish(entries, enabledIds, unavailableModelIds);
4162
+ return plan.kind === "done" ? plan.result : commitPublish(plan);
4163
+ };
4164
+ /**
3643
4165
  * publish,排到所有其他在途 publish 之后。
3644
4166
  *
3645
4167
  * 包装的存在是为了没有任何调用方要记得队列:挂载 seed、目录轮询、provider 开关、
@@ -3713,6 +4235,127 @@ function syncSignaturesAfterPublish(state) {
3713
4235
  state.quotaSignature = quotaSignatureOf(unavailable);
3714
4236
  }
3715
4237
 
4238
+ //#endregion
4239
+ //#region src/host/provider-reconcile.ts
4240
+ /**
4241
+ * provider 注册的 **desired-state reconciler**——把两个各自为政的驱动收编成
4242
+ * 一个控制器。
4243
+ *
4244
+ * ## 它取代什么
4245
+ *
4246
+ * 改之前,往同一个注册态里写的有两个 driver:挂载 seed + 每 `pollSeconds`
4247
+ * 一次的目录轮询(`index.ts` 的 `runPoll`),以及四条 provider 路由的
4248
+ * 「保存后立即 publish」。两者没有共同的状态词汇表,于是 `runPoll` 不得不把
4249
+ * 窗口覆盖**读两遍**(fetch 前一次、fetch 后一次),「期间被改就跳过本轮」——
4250
+ * 那是没有 desired-state 控制器的典型补丁:丢一次更新、再等一个轮询周期。
4251
+ *
4252
+ * ## 模型
4253
+ *
4254
+ * - **desired state**(操作者想要什么)= `providerStore` 里的开关 + 允许
4255
+ * 清单 + 窗口覆盖。它只有一个家,轮询和路由都只读它、不各写一份;
4256
+ * - **reconcile 一遍** = 读 desired → 带窗口覆盖拉目录 → 复查 desired
4257
+ * (fetch 期间被改就重跑,不等下一轮)→ 把「当前目录 + 当前清单」交给
4258
+ * publisher(它自己重读开关,single source 不变);
4259
+ * - **触发方全部降级为「标记 dirty」**:挂载、轮询定时器、四条写路由。
4260
+ * 谁都不用记得「别人是不是也在 publish」——队列性质由本模块的串行链保证。
4261
+ *
4262
+ * ## 两个刻意的行为改变(相对旧 runPoll)
4263
+ *
4264
+ * 1. **fetch 失败不再发布空目录**。旧 `readEntries` 把 fetch 失败降级成
4265
+ * `entries: []` 再 publish——一次瞬时的目录抖动会把已服务的模型全部撤下
4266
+ * (破坏,不是重试)。这里失败只记原因、保留当前 offer,下一轮自然重试;
4267
+ * 轮询侧本来就该如此(旧 runPoll 正是这么做的),路由侧现在与它一致。
4268
+ * 2. **fetch 期间 desired 被改 → 重跑,不是跳过**。旧代码「跳过本轮、等
4269
+ * pollSeconds」;这里原地收敛,保存路由的 await 返回时新值一定已落地。
4270
+ *
4271
+ * peer-free:不 import 任何 Host peer。
4272
+ *
4273
+ * @module dsh-connect-modelscope-token-plan/provider-reconcile
4274
+ */
4275
+ /**
4276
+ * 创建 provider reconciler。
4277
+ *
4278
+ * 串行链 + dirty 标志:每次触发(轮询/路由/挂载)都排在链尾,链上每一环先看
4279
+ * dirty 再决定要不要真跑——在途那一轮会替后来者消费 dirty,所以 N 次连续触发
4280
+ * 最多收敛成两遍 reconcile,不会排 N 遍。调用方 await 返回的 promise,即
4281
+ * 「我的触发已被消费」。
4282
+ * @param {ProviderReconcilerDeps} deps - wiring 子集。
4283
+ * @returns {{reconcile: () => Promise<void>}} 触发一次收敛。
4284
+ */
4285
+ function createProviderReconciler(deps) {
4286
+ const { providerStore, inference, publisher, logger } = deps;
4287
+ /**
4288
+ * 读一次 desired。三路都 `.catch(() => null)`:读不到按「未设置」降级,
4289
+ * 与开关/清单/覆盖各自的读侧纪律一致——一次读失败不该把收敛打掉。
4290
+ * @returns {Promise<Desired>}
4291
+ */
4292
+ const readDesired = async () => {
4293
+ const [enabled, enabledIds, contextWindow] = await Promise.all([
4294
+ providerStore.enabled().catch(() => null),
4295
+ providerStore.enabledIds().catch(() => null),
4296
+ providerStore.contextWindow().catch(() => null)
4297
+ ]);
4298
+ return {
4299
+ enabled,
4300
+ enabledIds,
4301
+ contextWindow
4302
+ };
4303
+ };
4304
+ /** desired 的稳定键:用于「fetch 期间被改了吗」的比对。 */
4305
+ const desiredKey = (desired) => JSON.stringify([
4306
+ desired.enabled,
4307
+ desired.enabledIds,
4308
+ desired.contextWindow
4309
+ ]);
4310
+ /**
4311
+ * 收敛一遍:读 desired → 拉目录 → 复查 desired → publish。
4312
+ * @returns {Promise<void>}
4313
+ */
4314
+ const runOnce = async () => {
4315
+ const before = await readDesired();
4316
+ let catalog;
4317
+ try {
4318
+ catalog = await inference.fetchModels(before.contextWindow);
4319
+ } catch (error) {
4320
+ logger?.warn?.(`${name}: catalog fetch failed (${redactSecrets(errMsg(error))}) — the current offer stays registered; the next pass retries`);
4321
+ return;
4322
+ }
4323
+ const after = await readDesired();
4324
+ if (desiredKey(after) !== desiredKey(before)) {
4325
+ dirty = true;
4326
+ return;
4327
+ }
4328
+ const entries = Array.isArray(catalog?.entries) ? catalog.entries : [];
4329
+ const enabledIds = resolveEnabledIds(after.enabledIds, publisher.state.enabledIds);
4330
+ try {
4331
+ await publisher.publish(entries, enabledIds, []);
4332
+ } catch (error) {
4333
+ logger?.warn?.(`${name}: publish failed (${redactSecrets(errMsg(error))}) — the catalog is current, only the provider registration did not land`);
4334
+ }
4335
+ };
4336
+ /** 串行链:每次触发排到链尾;在途那一轮替后来者消费 dirty。 */
4337
+ let chain = Promise.resolve();
4338
+ /** 有新触发待消费。闭包状态,不暴露——触发方只看得见 {@link reconcile}。 */
4339
+ let dirty = false;
4340
+ /**
4341
+ * 触发一次收敛。幂等且可重入:在途时只置 dirty,由在途那一轮替本调用消费
4342
+ * (N 次连续触发最多收敛成两遍 reconcile,不会排 N 遍)。
4343
+ * @returns {Promise<void>} 本触发已被消费(注册态已反映 desired)后 resolve。
4344
+ */
4345
+ const reconcile = () => {
4346
+ dirty = true;
4347
+ return chain = chain.then(async () => {
4348
+ let guard = 0;
4349
+ while (dirty) {
4350
+ dirty = false;
4351
+ await runOnce();
4352
+ if (++guard > 100) break;
4353
+ }
4354
+ });
4355
+ };
4356
+ return { reconcile };
4357
+ }
4358
+
3716
4359
  //#endregion
3717
4360
  //#region src/host/routes/paths.ts
3718
4361
  /**
@@ -3767,30 +4410,11 @@ async function soft(promise) {
3767
4410
  const code = error.code;
3768
4411
  return {
3769
4412
  ok: false,
3770
- error: redactSecrets$1(errMsg(error)),
4413
+ error: redactSecrets(errMsg(error)),
3771
4414
  code: typeof code === "string" ? code : CODE.INTERNAL_ERROR
3772
4415
  };
3773
4416
  }
3774
4417
  }
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
4418
  /** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
3795
4419
  async function buildSnapshotBody(wiring) {
3796
4420
  const { settings, tokenStore, usageStore, inference, providerStore, drawStore, publisher } = wiring;
@@ -3820,6 +4444,8 @@ async function buildSnapshotBody(wiring) {
3820
4444
  if (!models.ok && models.code !== "network_error") shapeWarnings.push(`models: ${models.error}`);
3821
4445
  if (!tokenKnownAbsent && !balance.ok && balance.code !== "network_error") shapeWarnings.push(`balance: ${balance.error}`);
3822
4446
  if (providerNote.ok && providerNote.value !== null) shapeWarnings.push(providerNote.value);
4447
+ const drawNote = await soft((async () => drawStore.versionNote())());
4448
+ if (drawNote.ok && drawNote.value !== null) shapeWarnings.push(drawNote.value);
3823
4449
  const provider = await soft((async () => {
3824
4450
  const panel = await providerStore.enabled().catch(() => null);
3825
4451
  const enabled = resolveSwitchEnabled(panel, settings.registerProvider);
@@ -3827,10 +4453,9 @@ async function buildSnapshotBody(wiring) {
3827
4453
  const roster = rosterWithAvailability(modelEntries, []);
3828
4454
  const stored = await providerStore.enabledIds().catch(() => null);
3829
4455
  const enabledIds = resolveEnabledIds(stored, publisher.state.enabledIds);
4456
+ const allowedList = parseAllowedList(enabledIds);
4457
+ const enabledCount = enabledCountOf(allowedList, roster.map((row) => row.id));
3830
4458
  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
4459
  return {
3835
4460
  enabled,
3836
4461
  source,
@@ -3839,7 +4464,7 @@ async function buildSnapshotBody(wiring) {
3839
4464
  error: typeof publisher.state.error === "string" ? publisher.state.error : null,
3840
4465
  modelCount: roster.length,
3841
4466
  enabledCount,
3842
- allowed,
4467
+ allowed: allowedList.mode,
3843
4468
  enabledIds,
3844
4469
  contextWindow,
3845
4470
  roster
@@ -3861,6 +4486,8 @@ async function buildSnapshotBody(wiring) {
3861
4486
  toolName: "modelscope_draw_image"
3862
4487
  };
3863
4488
  })());
4489
+ if (!provider.ok) shapeWarnings.push(`provider source failed (${provider.code}): ${provider.error}`);
4490
+ if (!draw.ok) shapeWarnings.push(`draw source failed (${draw.code}): ${draw.error}`);
3864
4491
  const usedLocal = daily.ok ? daily.value.calls : null;
3865
4492
  return {
3866
4493
  ok: true,
@@ -3909,7 +4536,7 @@ async function buildSnapshotBody(wiring) {
3909
4536
  sample: modelIds.slice(0, 20),
3910
4537
  error: models.ok ? null : models.error
3911
4538
  },
3912
- provider: provider.ok ? provider.value : providerDegraded(provider.error),
4539
+ ...provider.ok ? { provider: provider.value } : {},
3913
4540
  ...draw.ok ? { draw: draw.value } : {},
3914
4541
  shapeWarnings,
3915
4542
  quotaError: null
@@ -4008,7 +4635,7 @@ function refuseMethod(response) {
4008
4635
  * 日志或响应)。redactSecrets 幂等,双重应用无害。
4009
4636
  */
4010
4637
  function redactedError(error) {
4011
- const text = redactSecrets$1(errMsg(error));
4638
+ const text = redactSecrets(errMsg(error));
4012
4639
  return text.trim() === "" ? "request failed" : text;
4013
4640
  }
4014
4641
  /**
@@ -4342,18 +4969,12 @@ async function readEnabledIds(store, published) {
4342
4969
  return resolveEnabledIds(await optional(store.enabledIds?.(), null), published);
4343
4970
  }
4344
4971
  /**
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"}
4972
+ * `allowed` 的分类与 `enabledCount` 的计数:都从 `shared/allow-list.ts` 的
4973
+ * {@link parseAllowedList} 联合上取(fix D:快照与 provider 路由以前各写一套
4974
+ * 分类,会漂移;旧版 `allowedOf` 只认「恰好哨兵」为 `"none"`,
4975
+ * `["__hide_all__","other"]` 会被错判成 `"list"`——统一到联合后,含哨兵即
4976
+ * `"none"`,与 `filterByEnabled` / `isModelEnabled` 同一份解释)。
4353
4977
  */
4354
- function allowedOf(enabledIds) {
4355
- return resolveAllowedList(enabledIds);
4356
- }
4357
4978
  /**
4358
4979
  * 注册 provider 路由(三条路径)。wiring 子集:settings / providerStore /
4359
4980
  * publisher / inference / logger。
@@ -4362,7 +4983,7 @@ function allowedOf(enabledIds) {
4362
4983
  * @returns {Function} 依次调用三个 `off()` 的注销回调。
4363
4984
  */
4364
4985
  function registerProviderRoute(ctx, wiring) {
4365
- const { settings, providerStore, publisher, inference, logger } = wiring;
4986
+ const { settings, providerStore, publisher, reconcile, inference, logger } = wiring;
4366
4987
  /**
4367
4988
  * 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
4368
4989
  * 状态是什么」说两套话。
@@ -4375,9 +4996,8 @@ function registerProviderRoute(ctx, wiring) {
4375
4996
  const contextWindow = await optional(providerStore.contextWindow?.(), null);
4376
4997
  const entries = await readEntries$1(inference, providerStore);
4377
4998
  const roster = rosterWithAvailability(entries, []);
4378
- const allowed = allowedOf(enabledIds);
4999
+ const allowedList = parseAllowedList(enabledIds);
4379
5000
  const modelCount = roster.length;
4380
- const allowSet = new Set(enabledIds);
4381
5001
  return {
4382
5002
  ok: true,
4383
5003
  enabled: resolveSwitchEnabled(panelSwitch, settings.registerProvider),
@@ -4386,8 +5006,8 @@ function registerProviderRoute(ctx, wiring) {
4386
5006
  registered: publisher.state.registered === true,
4387
5007
  error: typeof publisher.state.error === "string" ? publisher.state.error : null,
4388
5008
  modelCount,
4389
- enabledCount: allowed === "all" ? modelCount : allowed === "none" ? 0 : roster.filter((row) => allowSet.has(row.id)).length,
4390
- allowed,
5009
+ enabledCount: enabledCountOf(allowedList, roster.map((row) => row.id)),
5010
+ allowed: allowedList.mode,
4391
5011
  enabledIds,
4392
5012
  contextWindow,
4393
5013
  roster,
@@ -4419,8 +5039,7 @@ function registerProviderRoute(ctx, wiring) {
4419
5039
  }
4420
5040
  try {
4421
5041
  await providerStore.save(body.enabled);
4422
- const entries = await readEntries$1(inference, providerStore);
4423
- await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
5042
+ await reconcile();
4424
5043
  } catch (error) {
4425
5044
  respondError(response, error);
4426
5045
  return;
@@ -4448,8 +5067,7 @@ function registerProviderRoute(ctx, wiring) {
4448
5067
  const ids = body.enabledIds.filter((id) => typeof id === "string" && id !== "");
4449
5068
  try {
4450
5069
  await providerStore.saveEnabledIds?.(ids);
4451
- const entries = await readEntries$1(inference, providerStore);
4452
- await publisher.publish(entries, ids, []);
5070
+ await reconcile();
4453
5071
  } catch (error) {
4454
5072
  respondError(response, error);
4455
5073
  return;
@@ -4476,8 +5094,7 @@ function registerProviderRoute(ctx, wiring) {
4476
5094
  }
4477
5095
  try {
4478
5096
  await providerStore.saveContextWindow?.(body.contextWindow);
4479
- const entries = await readEntries$1(inference, providerStore);
4480
- await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
5097
+ await reconcile();
4481
5098
  } catch (error) {
4482
5099
  respondError(response, error);
4483
5100
  return;
@@ -4500,8 +5117,7 @@ function registerProviderRoute(ctx, wiring) {
4500
5117
  try {
4501
5118
  await providerStore.forget();
4502
5119
  await providerStore.saveEnabledIds?.([]);
4503
- const entries = await readEntries$1(inference, providerStore);
4504
- await publisher.publish(entries, [], []);
5120
+ await reconcile();
4505
5121
  } catch (error) {
4506
5122
  respondError(response, error);
4507
5123
  return;
@@ -4633,20 +5249,8 @@ function registerDrawRoute(ctx, wiring) {
4633
5249
  return;
4634
5250
  }
4635
5251
  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
- }
5252
+ if (hasEnabled) await drawStore.saveEnabled(body.enabled);
5253
+ if (body.modelId !== void 0) await drawStore.saveModelId(body.modelId);
4650
5254
  } catch (error) {
4651
5255
  respondError(response, error);
4652
5256
  return;
@@ -4667,11 +5271,7 @@ function registerDrawRoute(ctx, wiring) {
4667
5271
  error: "ignored"
4668
5272
  }));
4669
5273
  try {
4670
- const refusal = await drawStore.reset();
4671
- if (refusal !== null) {
4672
- respondError(response, new Error(refusal));
4673
- return;
4674
- }
5274
+ await drawStore.reset();
4675
5275
  } catch (error) {
4676
5276
  respondError(response, error);
4677
5277
  return;
@@ -4786,49 +5386,13 @@ function apply(ctx, config = {}, deps = {}) {
4786
5386
  ...deps.loadAdapterModule ? { loadAdapterModule: deps.loadAdapterModule } : {},
4787
5387
  logger: ctx.logger
4788
5388
  });
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
- };
5389
+ const { reconcile } = createProviderReconciler({
5390
+ settings,
5391
+ providerStore,
5392
+ inference,
5393
+ publisher,
5394
+ logger: ctx.logger
5395
+ });
4832
5396
  const wiring = {
4833
5397
  settings,
4834
5398
  configError,
@@ -4838,14 +5402,15 @@ function apply(ctx, config = {}, deps = {}) {
4838
5402
  providerStore,
4839
5403
  drawStore,
4840
5404
  publisher,
5405
+ reconcile,
4841
5406
  logger: ctx.logger
4842
5407
  };
4843
5408
  const offs = registerRoutes(ctx, wiring);
4844
5409
  ctx.effect(() => {
4845
- runPoll();
5410
+ reconcile();
4846
5411
  registerDrawTool(ctx, wiring, deps);
4847
5412
  const timer = setInterval(() => {
4848
- runPoll();
5413
+ reconcile();
4849
5414
  }, settings.pollSeconds * 1e3);
4850
5415
  return () => {
4851
5416
  clearInterval(timer);
@@ -4855,7 +5420,7 @@ function apply(ctx, config = {}, deps = {}) {
4855
5420
  for (const off of offs) try {
4856
5421
  off?.();
4857
5422
  } catch (error) {
4858
- ctx.logger?.warn?.(`${name}: route unregister failed: ${redactSecrets$1(errMsg(error))}`);
5423
+ ctx.logger?.warn?.(`${name}: route unregister failed: ${redactSecrets(errMsg(error))}`);
4859
5424
  }
4860
5425
  };
4861
5426
  }, `${name}: routes`);