dsh-connect-modelscope-token-plan 0.2.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,6 +1,6 @@
1
- import { C as redactSecrets, S as pluginError, T as verbatim, a as resolveSettings, b as obj, c as LLM_PROVIDER_ID, d as normalizeEntry, f as resolveAllowedList, g as looksLikeRateLimit, h as hasHardQuotaWordingIn, i as name, n as inject, p as rosterWithAvailability, r as isAdmitted, s as LLM_DISPLAY_NAME, t as PLUGIN_VERSION, u as isVisionModel, v as degrade, w as str, x as optional, y as errMsg } from "./host-config-C4bWNPWU.js";
2
- import { join } from "node:path";
3
- import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
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
+ 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";
6
6
 
@@ -64,14 +64,70 @@ function isPlausibleToken(value) {
64
64
  * @param {object|Function|null} [options.credentials] - `ctx.credentials` 服务
65
65
  * 或其 resolver;**每次使用时解析**(服务可能在本插件挂载之后才注册,
66
66
  * 一次性查找会把错误的 "ephemeral" 断言冻进后续每个轮询)。
67
- * @param {object} [options.env] - 环境源;默认 `process.env`。
67
+ * @param {object} [options.env] - 环境源;**默认 `process.env`**。
68
+ * 测试经 `apply()` 的 `deps.env` 注入 `{}` 以隔离宿主环境——本文件时不能假设
69
+ * 「没有传就是要用真环境」以外的语义,但调用方可以(详见 types.ts 的 env 缝)。
70
+ *
71
+ * `| undefined` 不是冗余:`tsconfig` 开了 `exactOptionalPropertyTypes`,
72
+ * 「可选」不允许被显式赋值为 `undefined`,而 `apply()` 是原样转发 `deps.env`
73
+ * (产品侧不传时它就是 `undefined`)。不写这一半,转发点编译不过。
68
74
  */
69
75
  function createTokenStore({ credentials = null, env = process.env } = {}) {
70
76
  /** 无凭据服务的 Host 的内存兜底。 */
71
77
  const memory = /* @__PURE__ */ new Map();
78
+ /**
79
+ * 最后一次零额度探针的结论(`TokenStatus.valid` / `checkedAt` 的唯一生产者)。
80
+ *
81
+ * 只活在本进程:重启即回到「未检查过」,那是诚实答案——`valid: null` 的语义本来
82
+ * 就是「没检查过」,不是「检查过但没结论」。
83
+ *
84
+ * **只在令牌换手时清**(`save` / `forget`):上一次的结论只对**那一把**令牌成立。
85
+ * 换令牌后留着旧结论,等于让一个关于别的值的判断继续替新令牌说话——正是
86
+ * `ephemeral` 那条修正反对的「不是判出来的、是碰巧对的」。env 在 DSH 启动时读
87
+ * 一次,进程内不会换手,所以这两个入口是全部。
88
+ */
89
+ let lastCheck = null;
72
90
  const resolveService = () => {
73
91
  return (typeof credentials === "function" ? credentials() : credentials) ?? null;
74
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
+ };
75
131
  return {
76
132
  /**
77
133
  * 把面板输入的令牌存成引用;原样存(不 trim),空白值视为没输入。
@@ -95,6 +151,7 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
95
151
  await service.set(TOKEN_REF, value);
96
152
  memory.set(TOKEN_REF, value);
97
153
  } else memory.set(TOKEN_REF, value);
154
+ lastCheck = null;
98
155
  },
99
156
  /**
100
157
  * 忘掉面板保存的令牌。env 回退**不动**——清面板引用不能删操作者的 .env。
@@ -107,46 +164,39 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
107
164
  */
108
165
  async forget() {
109
166
  memory.delete(TOKEN_REF);
167
+ lastCheck = null;
110
168
  const service = resolveService();
111
169
  if (service !== null && typeof service.unset === "function") await service.unset(TOKEN_REF);
112
170
  },
113
171
  /**
114
- * 解析当前令牌与来源。返回类型显式注解:这是快照 TokenStatus.source
115
- * 联合类型的上游,靠推断会把字面量联合拓宽成 string。
172
+ * 记录一次**零额度探针**的结论(probe 路由是唯一调用方)。
173
+ *
174
+ * 只接已经判出来的结论:`true`(200/400,令牌好)或 `false`(401/403,令牌坏)。
175
+ * 429 / 上游错误 / 配置错误**不调用**——那些回答的是「上游现在忙不忙」或
176
+ * 「这次请求成没成」,不是「这把令牌好不好」,把它们记进 `valid` 就是把限频
177
+ * 说成令牌坏(正是 429 分诊那条修正反对的误判)。不调用即留着上一次的结论,
178
+ * 那是已知的最好答案。
179
+ */
180
+ noteValidity(valid) {
181
+ lastCheck = {
182
+ valid,
183
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
184
+ };
185
+ },
186
+ /**
187
+ * 解析当前令牌与来源。
116
188
  * @returns {Promise<{value: string, source: ("credentials"|"memory"|"env"|null)}>}
117
189
  */
118
190
  async resolve() {
119
- try {
120
- const service = resolveService();
121
- if (service !== null && typeof service.resolve === "function") {
122
- const resolved = await service.resolve(TOKEN_REF).catch(() => void 0);
123
- const value = verbatim(resolved?.value, "");
124
- if (typeof value === "string" && value.trim() !== "") return {
125
- value,
126
- source: "credentials"
127
- };
128
- }
129
- } catch {}
130
- const held = memory.get(TOKEN_REF);
131
- if (typeof held === "string" && held.trim() !== "") return {
132
- value: held,
133
- source: "memory"
134
- };
135
- const fromEnv = verbatim(env[TOKEN_REF], "");
136
- if (typeof fromEnv === "string" && fromEnv.trim() !== "") return {
137
- value: fromEnv,
138
- source: "env"
139
- };
140
- return {
141
- value: "",
142
- source: null
143
- };
191
+ return resolveToken();
144
192
  },
145
193
  /**
146
194
  * 无秘密的状态描述(快照与面板消费)。
147
- * `valid` 恒为 null:本模块不做有效性断言(零额度探针在 probe 路由,
148
- * 语义已于 2026-10-04 真机回填——判定 401/403 = 令牌坏、200/400 = 令牌好;
149
- * SPIKE.md §结论 5)。
195
+ *
196
+ * `valid` / `checkedAt` 来自 `noteValidity`(零额度探针的结论),**本模块自己
197
+ * 不做有效性断言**——判定规则在 probe 路由(401/403 = 令牌坏、200/400 = 令牌好,
198
+ * 2026-10-04 真机回填;SPIKE.md §结论 5)。没探过就是 `null`,那不是「无效」,
199
+ * 是「没检查过」。
150
200
  *
151
201
  * `ephemeral` 答的是「**手里这个值**重启会不会丢」,不是「有没有凭据服务」
152
202
  * ——wire 的定义是前者。曾经只按「服务缺席」置位,于是两种都说谎:没有服务
@@ -155,12 +205,12 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
155
205
  * `memory` 才真的只在本次进程存活期间存在。
156
206
  */
157
207
  async state() {
158
- const { source } = await this.resolve();
208
+ const { source } = await resolveToken();
159
209
  return {
160
210
  present: source !== null,
161
211
  source: source === null ? "none" : source,
162
- valid: null,
163
- checkedAt: null,
212
+ valid: lastCheck === null ? null : lastCheck.valid,
213
+ checkedAt: lastCheck === null ? null : lastCheck.checkedAt,
164
214
  ephemeral: source === "memory"
165
215
  };
166
216
  }
@@ -170,16 +220,16 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
170
220
  //#endregion
171
221
  //#region src/host/state-store.ts
172
222
  /**
173
- * 状态文件公共原语 —— 把本插件的各 store(usage-store)从
174
- * 各自手写的同一段"版本载荷 + temp 文件 + rename 原子 + 0600 + 损坏即忽略"
175
- * 收敛到这里(家规:peer-free、离线可测)。。
223
+ * 状态文件公共原语。**TL;DR**:本文件只做四件事——`dshHome`/`stateDir`(定位
224
+ * 状态目录)、`profileSegment`(按 profile 分段路径)、{@link createStateReadCache}
225
+ * (统一读缓存)、以及写侧三件套({@link createStateWriteQueue} 写串行化 /
226
+ * {@link guardStateWrite} ADR-006 写守卫 / {@link stateVersionNote} 读侧版本说明)。
227
+ * 其余一切遵守下方「行为约定」。
176
228
  *
177
- * 第二步收敛的是**读缓存**:provider / draw 早有 1s TTL,而 catalog 完全没有
178
- * (进程内永不失效)——同一个「两个进程共享一个 state 目录」的问题修了两个、
179
- * 漏了第三个。现在统一走 {@link createStateReadCache},一个 TTL 三个调用方。
180
- *
181
- * peer-free 与四个 store 同纪律:不 import 任何 Host peer,纯 `node:fs`,
182
- * 离线可测(store.test.mjs 直接注入 dir 构造即可)。
229
+ * 把本插件各 store(usage-store / provider / draw)各自手写的同一段
230
+ * "版本载荷 + temp 文件 + rename 原子 + 0600 + 损坏即忽略" 收敛到这里。
231
+ * 家规:peer-free(不 import 任何 Host peer,纯 `node:fs`)、离线可测
232
+ * (store.test.mjs 直接注入 dir 构造即可)。
183
233
  *
184
234
  * 行为约定(与本插件 store 的约定逐一对齐):
185
235
  * - 目录:`$DSH_HOME/state/<name>`——与 Host 自己的目录并列,而不是在
@@ -187,29 +237,39 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
187
237
  * - 写:临时文件(0600,owner-only)→ `rename` 原子落位。**失败抛错**,
188
238
  * 是否吞错是各 store 的语义(usage-store 面对只读 Home 选择吞、
189
239
  * provider 面板开关交给调用方的错误路径),原语不做决定。
190
- * - 临时名:进程 + 时间戳 + 随机 UUID 后缀。固定临时名会让两个 Host 进程的
191
- * 写落到同一路径、互相 `rename` 掉对方写了一半的文件;同一进程同一毫秒的
192
- * 两次异步写也会撞名(`writeFile` 截断覆盖后一次 `rename` 静默丢写),
193
- * 随机后缀让每个 temp 路径唯一,`rename` 原子性借此成立(见 `temporaryOf`)。
194
- * 此前的写法曾只有进程 + 时间戳,同毫秒并发写会静默丢一条。
240
+ * - 临时名:进程 + 时间戳 + 随机 UUID 后缀。随机后缀让每个 temp 路径唯一,
241
+ * `rename` 原子性借此成立(见 `temporaryOf`)。
195
242
  * - 读:缺失、不可读、非 JSON 一律返回 `null`——"损坏即忽略"的方向。是否
196
243
  * 缓存、缓存多久由 {@link createStateReadCache} 决定,不是每个 store 各自的
197
244
  * 即兴实现。
198
245
  *
246
+ * ## 演变记录(历史,非契约)
247
+ * 1. 第一步收敛的是上述「版本载荷 + 临时文件 + 原子落位」;临时名此前只有
248
+ * 进程 + 时间戳,同毫秒并发写会静默丢一条,故加随机后缀。
249
+ * 2. 第二步收敛的是**读缓存**:provider / draw 早有 1s TTL,而 catalog 完全
250
+ * 没有(进程内永不失效)——同一个「两个进程共享一个 state 目录」的问题修了
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 都是调用方,不再各写一遍。
259
+ *
199
260
  * @module dsh-connect-modelscope-token-plan/state-store
200
261
  */
201
262
  /**
202
- * The DSH home: `$DSH_HOME` when the operator exported one, else `~/.dsh`.
203
- * @returns {string} the home directory.
263
+ * DSH 主目录:操作者导出了 `DSH_HOME` 就用它,否则回落到 `~/.dsh`。
264
+ * @returns {string} 主目录。
204
265
  */
205
266
  function dshHome() {
206
267
  return str(process.env.DSH_HOME, join(homedir(), ".dsh"));
207
268
  }
208
269
  /**
209
- * Where this plugin keeps state: `$DSH_HOME/state/<name>`.
210
- * @param {string} name - the plugin's own state directory name
211
- * (`host-config.ts`'s `name`).
212
- * @returns {string} the directory.
270
+ * 本插件存放状态的位置:`$DSH_HOME/state/<name>`。
271
+ * @param {string} name - 插件自己的状态目录名(即 `host-config.ts` 的 `name`)。
272
+ * @returns {string} 目录路径。
213
273
  */
214
274
  function stateDir(name) {
215
275
  return join(dshHome(), "state", name);
@@ -229,9 +289,9 @@ function stateDir(name) {
229
289
  const PROFILE_SEGMENT_MAX = 64;
230
290
  const PROFILE_SEGMENT_RE = /^(?!\.)[A-Za-z0-9._-]+$/;
231
291
  /**
232
- * Is this string safe to use as ONE path segment?
233
- * @param {unknown} value - candidate profile name.
234
- * @returns {boolean} true when it survives {@link PROFILE_SEGMENT_RE}.
292
+ * 该字符串能否安全地作为**一个**路径段使用?
293
+ * @param {unknown} value - 候选 profile 名。
294
+ * @returns {boolean} 通过 {@link PROFILE_SEGMENT_RE} 即为 true。
235
295
  */
236
296
  function isProfileSegment(value) {
237
297
  if (typeof value !== "string") return false;
@@ -263,8 +323,8 @@ function isProfileSegment(value) {
263
323
  *
264
324
  * 取到 = 调用方据此分段;取不到 = **退回当前的全局路径**,行为零漂移。
265
325
  *
266
- * @param {object} [ctx] - the Cordis context the Host handed `apply()`.
267
- * @returns {string|null} the profile name, or `null` when unavailable/unsafe.
326
+ * @param {object} [ctx] - Host 交给 `apply()` 的 Cordis context。
327
+ * @returns {string|null} profile 名,取不到或不安全时为 `null`。
268
328
  */
269
329
  function profileSegment(ctx) {
270
330
  if (ctx === null || typeof ctx !== "object") return null;
@@ -510,22 +570,46 @@ async function readStateJson(file) {
510
570
  }
511
571
  }
512
572
  /**
513
- * Read the persisted `version` field of one state file, or `null` when it is
514
- * absent, unreadable, non-JSON, or carries no numeric `version`.
573
+ * Read the persisted `version` field of one state file.
515
574
  *
516
575
  * This is the ADR-006 write-side guard's probe: a writer must know what it is
517
- * about to overwrite. "No version" is the same as "no record" (nothing to
518
- * protect), while a NUMERIC version this build does not recognise means the
519
- * file was written by a NEWER build and must not be clobbered.
576
+ * about to overwrite. Three outcomes, and the difference between the first two
577
+ * is load-bearing:
578
+ *
579
+ * - `null` — ABSENT (ENOENT), non-JSON, or carrying no numeric `version`.
580
+ * Nothing here the guard could protect, so a write belongs: a corrupt file
581
+ * must stay replaceable, or an operator would be stuck with it forever.
582
+ * - a number — the version on disk. One this build does not recognise was
583
+ * written by a NEWER build and must not be clobbered.
584
+ * - **throws** — the path EXISTS but could not be read (EACCES/EBUSY/EIO, or a
585
+ * directory sitting at the path). The guard must read that as "there is
586
+ * something here and I cannot see it", never as "nothing stored": collapsing
587
+ * it into `null` made the anti-clobber guard fail OPEN, so the very
588
+ * downgrade it exists to prevent came through that seam.
520
589
  *
521
590
  * Kept apart from `readStateJson` on purpose: the guard needs the version even
522
- * when the rest of the payload is unparseable, and it must not depend on any
523
- * store's parse semantics.
591
+ * when the rest of the payload is unparseable, it must not depend on any store's
592
+ * parse semantics, and it must NOT inherit the read path's deliberate "anything
593
+ * unrecognised reads as not set" permissiveness — that permissiveness is right
594
+ * for a reader and wrong for a writer.
524
595
  * @param {string} file - the state file path.
525
- * @returns {Promise<number|null>} the persisted version, or `null`.
596
+ * @returns {Promise<number|null>} the persisted version, or `null` when absent/unusable.
597
+ * @throws when the path exists but cannot be read — callers must refuse to write.
526
598
  */
527
599
  async function readStateVersion(file) {
528
- const raw = await readStateJson(file);
600
+ let text;
601
+ try {
602
+ text = await readFile(file, "utf8");
603
+ } catch (error) {
604
+ if (error?.code === "ENOENT") return null;
605
+ throw error;
606
+ }
607
+ let raw;
608
+ try {
609
+ raw = JSON.parse(text);
610
+ } catch {
611
+ return null;
612
+ }
529
613
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
530
614
  const version = raw.version;
531
615
  return typeof version === "number" ? version : null;
@@ -543,11 +627,113 @@ async function readStateVersion(file) {
543
627
  function isKnownStateVersion(version, known) {
544
628
  return version === null || known.includes(version);
545
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
+ }
546
723
 
547
724
  //#endregion
548
- //#region src/host/codes.ts
725
+ //#region src/shared/codes.ts
549
726
  /**
550
- * 错误分类的单一真源(对齐姊妹插件的 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 独有的行为。
551
737
  *
552
738
  * 魔搭的分类现实(SPIKE.md):上游不带任何额度头,额度耗尽与每分钟限频都只
553
739
  * 会以 429 + 文案出现,所以 429 的分诊是**按 body 文案**做的保守二分——
@@ -556,7 +742,10 @@ function isKnownStateVersion(version, known) {
556
742
  *
557
743
  * @module dsh-connect-modelscope-token-plan/codes
558
744
  */
559
- /** 面板按它分流的稳定码表。 */
745
+ /**
746
+ * 面板按它分流的稳定码表 —— Client 的 `GUIDANCE_BY_CODE` 以这份表的键为契约
747
+ * (且以 `CodeValue` 为**类型**契约:缺键是编译错误,不再只靠测试红)。
748
+ */
560
749
  const CODE = Object.freeze({
561
750
  CONFIG_ERROR: "config_error",
562
751
  NETWORK_ERROR: "network_error",
@@ -573,19 +762,41 @@ const CODE = Object.freeze({
573
762
  * 「每个键必须在 CODE 里」),最需要人看的内部错误反而没有引导文案。
574
763
  * 正式收进码表,两端从此同一份真源。
575
764
  */
576
- INTERNAL_ERROR: "internal_error"
765
+ INTERNAL_ERROR: "internal_error",
766
+ /**
767
+ * Client 自产码:Host 响应无法解读成快照(非 JSON / 缺 ok 字段)时,
768
+ * 面板用它走引导文案,而不是把裸英文串("unexpected payload")上屏。
769
+ *
770
+ * 本表是 `GUIDANCE_BY_CODE` 的键集真源——新增 client 码必须同时进
771
+ * 这张表,两侧才同步。
772
+ */
773
+ PAYLOAD_ERROR: "payload_error"
577
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
+ */
578
790
  /**
579
791
  * 把上游 HTTP 状态归类成稳定码。
580
792
  *
581
793
  * 401/403 → AUTH_ERROR(换令牌能修,绝不自动重试——魔搭没有锁号问题,
582
794
  * 但重试一个坏令牌只会刷屏);429 由调用方先走 {@link classifyRateLimit}
583
- * 分诊,这里只兜底;5xx → UPSTREAM_ERROR;其余 → NETWORK_ERROR。
795
+ * 分诊,这里只兜底;其余 4xx/5xx → UPSTREAM_ERROR;低于 400 → NETWORK_ERROR。
584
796
  */
585
797
  function classifyStatus(status) {
586
798
  if (status === 401 || status === 403) return CODE.AUTH_ERROR;
587
799
  if (status === 429) return CODE.RATE_LIMITED;
588
- if (status >= 500) return CODE.UPSTREAM_ERROR;
589
800
  if (status >= 400) return CODE.UPSTREAM_ERROR;
590
801
  return CODE.NETWORK_ERROR;
591
802
  }
@@ -617,11 +828,7 @@ function classifyStatus(status) {
617
828
  function classifyRateLimit(message) {
618
829
  if (typeof message !== "string" || message.length === 0) return "rate_limit";
619
830
  if (looksLikeRateLimit(message)) return "rate_limit";
620
- return hasHardQuotaWording(message) ? "quota" : "rate_limit";
621
- }
622
- /** 文本是否含硬额度措辞(真耗尽,不纠正)——直接问 llm-error-fix 的那张表。 */
623
- function hasHardQuotaWording(message) {
624
- return hasHardQuotaWordingIn(message);
831
+ return hasHardQuotaWordingIn(message) ? "quota" : "rate_limit";
625
832
  }
626
833
  /**
627
834
  * 解析 Retry-After(秒或 HTTP 日期)与平台文案里写明的等待时长。
@@ -684,6 +891,19 @@ function parseRetryAfterMs(headers, message) {
684
891
  function localDateKey(now) {
685
892
  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
686
893
  }
894
+ /**
895
+ * 路径在不在(任何 fs 错误都读作「不在」——存在性问的是能不能 `ls` 到它。
896
+ * EACCES 让 `stat` 失败时,真相其实是「在,只是看不见」,而对下面的判据来说
897
+ * 那同样不能被当成「没有记录」)。
898
+ */
899
+ async function pathExists(file) {
900
+ try {
901
+ await stat(file);
902
+ return true;
903
+ } catch {
904
+ return false;
905
+ }
906
+ }
687
907
  const STATE_VERSION = 1;
688
908
  const KNOWN_VERSIONS = [STATE_VERSION];
689
909
  /** 解析并校验磁盘载荷;形状不对读作 null(损坏即忽略),并标记异常。 */
@@ -773,8 +993,10 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
773
993
  events: []
774
994
  };
775
995
  let loaded = false;
776
- /** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。 */
777
- let writeChain = Promise.resolve();
996
+ /** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。
997
+ 队列实现在 state-store({@link createStateWriteQueue})——provider / draw
998
+ 同款,三份私有副本正是 draw-store 曾经三件全缺的入口。 */
999
+ const writeQueue = createStateWriteQueue();
778
1000
  /**
779
1001
  * 磁盘版本闸 —— 读路径与写路径**各自**调用,不共享副作用。
780
1002
  *
@@ -790,7 +1012,16 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
790
1012
  * @returns `true` = 磁盘版本比本构建新(写必须拒绝);`false` = 可以写。
791
1013
  */
792
1014
  const diskVersionBlocksWrites = async () => {
793
- const onDisk = await readStateVersion(file);
1015
+ let onDisk;
1016
+ try {
1017
+ onDisk = await readStateVersion(file);
1018
+ } catch (error) {
1019
+ const note = `usage-state: usage.json exists but cannot be read (${redactSecrets(errMsg(error))})`;
1020
+ if (!readOnly) logger?.warn?.(`${name}: ${note} — recording disabled to avoid clobbering`);
1021
+ anomaly = note;
1022
+ readOnly = true;
1023
+ return true;
1024
+ }
794
1025
  if (onDisk !== null && !isKnownStateVersion(onDisk, KNOWN_VERSIONS)) {
795
1026
  const note = `usage-state: on-disk version ${onDisk} is newer than this build knows`;
796
1027
  if (!readOnly) logger?.warn?.(`${name}: ${note} — recording disabled to avoid clobbering`);
@@ -820,23 +1051,19 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
820
1051
  cache.remember(payload);
821
1052
  };
822
1053
  /** 排队执行一次读-改-写;只读闸置位时整个变更是 no-op。 */
823
- const enqueue = (mutate) => {
824
- const run = writeChain.then(async () => {
825
- if (await diskVersionBlocksWrites()) return;
826
- const payload = (loaded ? current : await cache.read() ?? null) ?? {
827
- version: STATE_VERSION,
828
- days: {},
829
- models: {},
830
- events: []
831
- };
832
- mutate(payload);
833
- current = payload;
834
- loaded = true;
835
- await persist(payload);
836
- });
837
- writeChain = run.catch(() => {});
838
- return run;
839
- };
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
+ });
840
1067
  /** 修剪:只留最近 trendDays 个天桶(按 dateKey 排序,今天不删)。 */
841
1068
  const pruneDays = (payload, todayKey) => {
842
1069
  const keep = new Set(Object.keys(payload.days).sort().slice(-Math.max(1, trendDays)));
@@ -891,16 +1118,47 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
891
1118
  }, ...payload.events].slice(0, maxEvents);
892
1119
  });
893
1120
  },
894
- /** 今天(本地时区)的账户级计数;没有任何记录时是 0,不是 null。 */
1121
+ /**
1122
+ * 今天(本地时区)的账户级计数。
1123
+ *
1124
+ * 三种答案,三种含义,**不可合并**(wire.ts DailyUsage 的语义基线):
1125
+ * - `0` —— 确实一次都没有(没记录,或今天还没有桶);这是可信的日常值;
1126
+ * - `null` —— **读不到**:文件在,但本构建看不懂它(损坏 / 形状不对 / 更新
1127
+ * 构建写的版本 / 存在却不可读)。面板渲染「—」;
1128
+ * - 数字 —— 今日次数。
1129
+ *
1130
+ * 判据是「文件在不在」而不是「载荷读没读出来」:**文件不存在**才是真实的 0
1131
+ * (这台 Host 从未经本插件调用过,不是数据丢失),而**文件在却读成空**代表
1132
+ * 我们失去了那部分信息——把它报成 0 会让操作者以为今天没调用过,真相是他
1133
+ * 不知道。
1134
+ *
1135
+ * 这是 2026-10-16 补上的一块拼图:`routes.test.mjs` 早有一条
1136
+ * 「本地计数读不到 → null,绝不是 0」的断言,但它只能用一个**会抛错的桩**
1137
+ * 造出那条路径(注释原话:「挂载的真实 store 造不出『读的时候抛』」)——
1138
+ * 真实 store 把所有读失败都吞成了空载荷。于是 null 那份语义在 wire.ts、
1139
+ * `cards.ts`、`snapshot-aggregate.ts` 里写了三遍,却从未在本机真的产出过。
1140
+ *
1141
+ * @returns {{dateKey: string, calls: number|null, tokens: number|null}}
1142
+ */
895
1143
  async daily() {
896
- const day = (await cache.read())?.days[localDateKey(now())] ?? null;
1144
+ const payload = await cache.read();
1145
+ const dateKey = localDateKey(now());
1146
+ if (payload === null) {
1147
+ const stored = await pathExists(file);
1148
+ return {
1149
+ dateKey,
1150
+ calls: stored ? null : 0,
1151
+ tokens: stored ? null : 0
1152
+ };
1153
+ }
1154
+ const day = payload.days[dateKey] ?? null;
897
1155
  return day === null ? {
898
- dateKey: localDateKey(now()),
1156
+ dateKey,
899
1157
  calls: 0,
900
1158
  tokens: 0
901
1159
  } : {
902
1160
  ...day,
903
- dateKey: localDateKey(now())
1161
+ dateKey
904
1162
  };
905
1163
  },
906
1164
  /** 今天的单模型计数(只含有记录的模型,dayCalls/dayTokens 口径,均跨日清零)。 */
@@ -1005,6 +1263,13 @@ const MAX_CACHE_AGE_MS = 36e5;
1005
1263
  * flight's answer would be served to the account that just signed in.
1006
1264
  * Hanging the counters off the map makes every instance over that map share
1007
1265
  * one generation state, so the guard holds no matter how the caller builds it.
1266
+ *
1267
+ * `seq` is the ONE monotonic source both bumps draw from (see
1268
+ * {@link clearCoalescedFetch}): `global` is a high-water mark that only a global
1269
+ * clear moves, and `keys` holds one absolute number per individually-cleared
1270
+ * key. `keys` is therefore bounded by the number of distinct keys cleared since
1271
+ * the last global clear — not swept, because a per-key number must outlive any
1272
+ * in-flight flight born under it, while a later global clear wipes them all.
1008
1273
  */
1009
1274
  const GENERATIONS = /* @__PURE__ */ new WeakMap();
1010
1275
  /** The (created-on-demand) generation state belonging to one cache map. */
@@ -1013,6 +1278,7 @@ function generationsOf(cache) {
1013
1278
  if (state === void 0) {
1014
1279
  state = {
1015
1280
  global: 0,
1281
+ seq: 0,
1016
1282
  keys: /* @__PURE__ */ new Map()
1017
1283
  };
1018
1284
  GENERATIONS.set(cache, state);
@@ -1034,14 +1300,15 @@ function generationsOf(cache) {
1034
1300
  */
1035
1301
  function clearCoalescedFetch(cache, inflight, key) {
1036
1302
  const gens = generationsOf(cache);
1303
+ gens.seq += 1;
1037
1304
  if (key === void 0) {
1038
- gens.global += 1;
1305
+ gens.global = gens.seq;
1039
1306
  cache.clear();
1040
1307
  inflight.clear();
1041
1308
  gens.keys.clear();
1042
1309
  return;
1043
1310
  }
1044
- gens.keys.set(key, (gens.keys.get(key) ?? gens.global) + 1);
1311
+ gens.keys.set(key, gens.seq);
1045
1312
  cache.delete(key);
1046
1313
  inflight.delete(key);
1047
1314
  }
@@ -1116,6 +1383,66 @@ function createCoalescedFetch(options = {}) {
1116
1383
  };
1117
1384
  }
1118
1385
 
1386
+ //#endregion
1387
+ //#region src/host/context-catalog.ts
1388
+ /**
1389
+ * 内置上下文窗口策展表——魔搭目录不声明窗口时的第三方真值来源。
1390
+ *
1391
+ * 魔搭 `/v1/models`(及模型详情端点)只回基础标识字段(id/object/owned_by/
1392
+ * created + Tasks 标签),没有窗口字段;未声明窗口的条目会一路落到
1393
+ * `FALLBACK_CONTEXT_WINDOW`(128k)。这张表在「用户全局档位」之外补第三级
1394
+ * 真值:目录声明 > 用户覆盖 > **策展表** > 兜底。
1395
+ *
1396
+ * 数据来自 models.dev 聚合(github.com/anomalyco/models.dev,MIT;api.json
1397
+ * 构建产物)。魔搭 provider 自身在 api.json 只有 7 个不在当前目录的旧模型,
1398
+ * 所以每条真值取的是**同 id 模型在各厂自家 host 下的主流 limit.context**——
1399
+ * 跨 host 抄写的开量窗口是模型出厂规格,不是各网关的部署配额,取多数而非
1400
+ * 最大值:个别网关把窗口抄大(如 openrouter 抄 1M)不代表魔搭侧真开那么宽。
1401
+ *
1402
+ * 维护纪律:
1403
+ * 1. 只收**当前魔搭目录里真实存在**的 id(2026-10-16 实拉 `/v1/models` 全集
1404
+ * 37 个中筛出 19 个);目录下架的条目应随手删除,表不是历史档案。
1405
+ * 2. 每条注明来源 host 与核对日期;有多个数据源时写主流值并留离群说明。
1406
+ * 3. 覆盖优先级由 `curatedWindowOf` 的调用方(inference-client 打标)保证:
1407
+ * 目录声明过窗口的条目不进打标,策展表永不推翻平台声明。
1408
+ * 4. 生图/语音模型(Qwen-Image-Edit、ACE-Step 等)无对话上下文,不收。
1409
+ */
1410
+ /** id → 标称上下文窗口(tokens)。键必须与魔搭目录 id 逐字一致。 */
1411
+ const CURATED_CONTEXT_WINDOWS = {
1412
+ "deepseek-ai/DeepSeek-V4-Flash-0731": 1048576,
1413
+ "deepseek-ai/DeepSeek-V4-Pro": 1048576,
1414
+ "deepseek-ai/DeepSeek-V4-Pro-0813": 1048576,
1415
+ "deepseek-ai/DeepSeek-V4.1-Flash": 1048576,
1416
+ "meituan-longcat/LongCat-Flash-Lite": 256e3,
1417
+ "MiniMax/MiniMax-M1-80k": 1e6,
1418
+ "MiniMax/MiniMax-M3": 1048576,
1419
+ "nex-agi/Nex-N2.5-mini": 262144,
1420
+ "nex-agi/Nex-N2.5-Pro": 262144,
1421
+ "Qwen/Qwen3.5-122B-A10B": 262144,
1422
+ "Qwen/Qwen3.5-27B": 262144,
1423
+ "Qwen/Qwen3.5-35B-A3B": 262144,
1424
+ "Qwen/Qwen3.5-397B-A17B": 262144,
1425
+ "Qwen/Qwen3.8-27B": 262144,
1426
+ "Qwen/Qwen3.8-Flash-Next": 262144,
1427
+ "stepfun-ai/Step-3.5-Flash": 262144,
1428
+ "stepfun-ai/Step-3.7-Flash": 262144,
1429
+ "ZhipuAI/GLM-4.7-Flash": 2e5,
1430
+ "ZhipuAI/GLM-5.2": 1e6
1431
+ };
1432
+ /**
1433
+ * 查策展窗口:命中返回正数,未命中返回 null(调用方继续走兜底)。
1434
+ *
1435
+ * 只认逐字 id——不做大小写/前后缀模糊匹配,宁可查不到落兜底,也不把
1436
+ * 相近 id 的窗口张冠李戴。值合法性再挡一道(表内只应出现正数)。
1437
+ * @param {string} id - 模型 id(魔搭目录原样拼写)。
1438
+ * @returns {number|null} 标称窗口,未收录为 null。
1439
+ */
1440
+ function curatedWindowOf(id) {
1441
+ if (typeof id !== "string" || id === "") return null;
1442
+ const value = CURATED_CONTEXT_WINDOWS[id];
1443
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
1444
+ }
1445
+
1119
1446
  //#endregion
1120
1447
  //#region src/host/inference-client.ts
1121
1448
  /**
@@ -1245,10 +1572,29 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1245
1572
  * cacheSeconds。返回归一后的目录条目(entries,M4 用来建 descriptor)与
1246
1573
  * 派生的 id 列表(ids,旧消费方继续可用)——两条路线读同一份条目,不会漂移;
1247
1574
  * 形状漂移抛 UPSTREAM_ERROR 并带 detail。
1575
+ *
1576
+ * `contextOverride`(tokens)是面板保存的全局上下文窗口覆盖:非 null 时在
1577
+ * 归一**之前**改写每条原始条目——删掉目录可能携带窗口的四个原始拼写键、写上
1578
+ * `contextWindow`。这是唯一能可靠生效的注入点:`normalizeEntry` 按
1579
+ * `context_length/context_window/contextWindow/max_context_tokens` 顺序读
1580
+ * 首个正数,任何只改归一后字段的窄打标都会被 `...source` 展开里的旧键回读
1581
+ * 污染。删四键再写宽形状键,不依赖键序,目录声明与覆盖不可能同时在场。
1582
+ * descriptor、roster、catalogSignature 都从这份归一条目派生,一处打标全链
1583
+ * 路生效;签名含窗口值,覆盖变化自动触发重新注册。
1584
+ *
1585
+ * 覆盖值**参与缓存键**:coalesced 缓存里存的条目已是打标后的形状,若仍用
1586
+ * 字面量 `"models"` 键,保存覆盖后的一个 TTL 内会读到未打标的旧条目(面板
1587
+ * 显示变了、注册却没变)。带指纹后不同覆盖值各自缓存,互不串味。
1588
+ *
1589
+ * 窗口三级真值链(fetchModels 内单点判定):目录声明 > 用户覆盖 >
1590
+ * **策展表**(context-catalog,models.dev 交叉真值,零网络依赖)> 兜底
1591
+ * 128k。目录声明过的条目任何下层都不碰;策展表只在用户没设覆盖时兜在
1592
+ * 兜底之前——它是「更聪明的 128k」,不是新权威。
1593
+ * @param {number|null} [contextOverride] - 全局窗口覆盖(tokens),null = 跟随目录。
1248
1594
  */
1249
- async fetchModels() {
1595
+ async fetchModels(contextOverride) {
1250
1596
  const url = settings.apiBase + "/models";
1251
- return await read("models", async () => {
1597
+ return await read(`models:${contextOverride ?? "catalog"}`, async () => {
1252
1598
  const response = await fetchWithTimeout(url, { method: "GET" });
1253
1599
  if (!response.ok) {
1254
1600
  const message = await readErrorMessage(response);
@@ -1265,10 +1611,34 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1265
1611
  const entries = raws.map((raw) => {
1266
1612
  const id = str(raw.id, "");
1267
1613
  const tags = tagsById.get(id) ?? [];
1268
- return normalizeEntry({
1614
+ const source = {
1269
1615
  ...raw,
1270
1616
  tasks: tags.length > 0 ? tags : void 0
1271
- });
1617
+ };
1618
+ const CONTEXT_KEYS = [
1619
+ "context_length",
1620
+ "context_window",
1621
+ "contextWindow",
1622
+ "max_context_tokens"
1623
+ ];
1624
+ if (!CONTEXT_KEYS.some((key) => {
1625
+ const value = source[key];
1626
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
1627
+ })) {
1628
+ if (contextOverride != null) {
1629
+ for (const key of CONTEXT_KEYS) delete source[key];
1630
+ source.contextWindow = contextOverride;
1631
+ source.__contextSource = "override";
1632
+ } else {
1633
+ const curated = curatedWindowOf(id);
1634
+ if (curated !== null) {
1635
+ for (const key of CONTEXT_KEYS) delete source[key];
1636
+ source.contextWindow = curated;
1637
+ source.__contextSource = "curated";
1638
+ }
1639
+ }
1640
+ }
1641
+ return normalizeEntry(source);
1272
1642
  }).filter((entry) => entry.id !== "");
1273
1643
  return {
1274
1644
  entries,
@@ -1314,8 +1684,9 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1314
1684
  }, settings.cacheSeconds * 1e3);
1315
1685
  },
1316
1686
  /**
1317
- * 一次 probe 调用:故意缺 messages 的请求,预期 401(令牌坏)先于 400
1318
- * (请求坏)返回,**零额度**鉴权探针(语义已真机回填,SPIKE.md)。
1687
+ * 一次 probe 调用:故意缺 messages 的请求(**零额度**鉴权探针,语义已真机
1688
+ * 回填,SPIKE.md)。魔搭**不先校验 messages**——缺 messages 返回 200(空壳)
1689
+ * 而非 400,故判定规则是 401/403 = 令牌坏、200/400 = 令牌好、429 = 限频中。
1319
1690
  *
1320
1691
  * 失败抛 pluginError;调用方(probe 路由)负责把它记进事件流。
1321
1692
  */
@@ -1389,16 +1760,32 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1389
1760
  * Integrity follows `state-store.ts`: a versioned payload, a temp file plus an
1390
1761
  * atomic rename (two Host processes can share the directory), owner-only
1391
1762
  * modes, and "anything unrecognised reads as not set" — a corrupted or
1392
- * downgraded file costs one re-toggle, never a crash.
1763
+ * downgraded file costs one re-toggle, never a crash. The write side refuses
1764
+ * to overwrite a version this build cannot read (ADR-006); the read side stays
1765
+ * permissive but REPORTS the disagreement via `versionNote()`, because for the
1766
+ * allow-list "unreadable" and "never saved" would otherwise collapse to the
1767
+ * same answer — and that answer is "offer every model".
1393
1768
  *
1394
1769
  * The allow-list's empty-vs-absent distinction is the one this module guards:
1395
1770
  * an EMPTY list means "no filter, offer every model", while `HIDE_ALL_MODELS`
1396
1771
  * (`llm-models.ts`) is the caller-side sentinel for "offer nothing". Neither
1397
1772
  * spelling is stored here as `null` — absence of a saved list reads as "no
1398
- * filter", which is the only safe default for a fresh install.
1773
+ * filter", which is the only safe default for a fresh install. A foreign file
1774
+ * reads the same way, which is precisely why `versionNote()` exists.
1399
1775
  *
1400
1776
  * @module dsh-connect-modelscope-token-plan/provider-store
1401
1777
  */
1778
+ /**
1779
+ * The "nothing stored" payload. Every absent / unreadable / foreign read
1780
+ * collapses here, so the three call sites that produce it stay in step instead
1781
+ * of each spelling out the same three `null`s.
1782
+ */
1783
+ const EMPTY_PAYLOAD$1 = {
1784
+ enabled: null,
1785
+ enabledIds: null,
1786
+ contextWindow: null,
1787
+ version: null
1788
+ };
1402
1789
  /** Shape version, bumped when the persisted form changes incompatibly. */
1403
1790
  const PROVIDER_VERSION = 1;
1404
1791
  /**
@@ -1406,12 +1793,36 @@ const PROVIDER_VERSION = 1;
1406
1793
  * historical ones. Bumping {@link PROVIDER_VERSION} means adding the new number
1407
1794
  * here too — otherwise this build would refuse its own newest files.
1408
1795
  *
1409
- * This is the ADR-006 write-side guard's whitelist: an on-disk version not in
1410
- * this list was written by a NEWER build, and must not be clobbered (see
1411
- * {@link writePayload}).
1796
+ * This list is asked in THREE places:
1797
+ * - the write side ({@link writePayload}, ADR-006): an on-disk version not in
1798
+ * this list was written by a NEWER build and must not be clobbered. That
1799
+ * caller asks `isKnownStateVersion(existing, ...)` rather than
1800
+ * {@link isKnownProviderVersion} on purpose — it must also admit `null`
1801
+ * ("no file at all"), which is the one situation where a write belongs.
1802
+ * - the READ side ({@link parseAt}) and the report ({@link versionNote} /
1803
+ * {@link describeProviderPayload}), which ask {@link isKnownProviderVersion}.
1804
+ * Keeping the read side and the report on one predicate is what makes them
1805
+ * unable to disagree about what a foreign version means.
1412
1806
  */
1413
1807
  const KNOWN_PROVIDER_VERSIONS = [1];
1414
1808
  /**
1809
+ * Whether this build can read a file declaring `version`.
1810
+ *
1811
+ * THE single answer to that question. Note it is deliberately NOT
1812
+ * `version === PROVIDER_VERSION`: the whitelist includes shapes this build can
1813
+ * read but no longer writes, so comparing against the write version would make a
1814
+ * version bump unreadable-of-its-own-files. The original bug behind this function
1815
+ * was exactly that split — `parseAt` compared against the write version while the
1816
+ * report asked the whitelist, so a bumped build read its own v1 files as "not
1817
+ * set" while its report said "nothing to say": the silent collapse the read side
1818
+ * exists to kill, resurrected by the upgrade procedure.
1819
+ * @param {unknown} version - the value read off disk (may not be a number at all).
1820
+ * @returns {boolean}
1821
+ */
1822
+ function isKnownProviderVersion(version) {
1823
+ return typeof version === "number" && KNOWN_PROVIDER_VERSIONS.includes(version);
1824
+ }
1825
+ /**
1415
1826
  * The directory this plugin's state lives in — per-profile when the Host names
1416
1827
  * one, shared otherwise. Unlike the THROTTLE, which is deliberately shared
1417
1828
  * across profiles, this answers "does THIS profile want the provider registered"
@@ -1431,6 +1842,18 @@ function normalizeEnabled(raw) {
1431
1842
  return typeof raw === "boolean" ? raw : null;
1432
1843
  }
1433
1844
  /**
1845
+ * Normalize a context-window value: a positive finite number, or `null`
1846
+ * ("follow the catalog"). Anything else — strings, zero, negative, NaN,
1847
+ * Infinity — reads as no override, mirroring how {@link normalizeEnabled}
1848
+ * answers "nothing usable" instead of inventing a default.
1849
+ * @param {unknown} raw - the persisted or posted value.
1850
+ * @returns {number|null} the window in tokens, or `null` when nothing usable.
1851
+ */
1852
+ function normalizeContextWindow(raw) {
1853
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return null;
1854
+ return raw;
1855
+ }
1856
+ /**
1434
1857
  * Normalize a persisted allow-list: an array of non-empty strings, de-duplicated
1435
1858
  * and order-preserved. Anything else reads as "no saved list".
1436
1859
  *
@@ -1454,6 +1877,33 @@ function normalizeEnabledIds(raw) {
1454
1877
  return out;
1455
1878
  }
1456
1879
  /**
1880
+ * Resolve the allow-list to publish/display, from the persisted value and the
1881
+ * list this process last published.
1882
+ *
1883
+ * THE single answer to "which list wins", so that every consumer asks it the
1884
+ * same way: a READABLE persisted list wins — including an EMPTY one, because
1885
+ * this module's contract defines `[]` as "no filter, offer every model" — and
1886
+ * the in-memory list is consulted only when the persisted side could not be read
1887
+ * at all (the caller's `.catch` turns a rejection into `null`).
1888
+ *
1889
+ * The three call sites used to spell this out separately, and two of them had
1890
+ * drifted: `snapshot-aggregate.ts` and `routes/provider.ts` honoured an empty
1891
+ * persisted list, while `index.ts` treated it as "no answer" and republished the
1892
+ * remembered list. So after the operator deleted `provider.json` (the reset
1893
+ * `versionNote` itself recommends) the panel reported `allowed: "all"` — its
1894
+ * `enabledCount` counting every model — while the poller kept registering the
1895
+ * OLD restriction: the panel and the Host disagreeing about what is served,
1896
+ * which is precisely what the publish queue and the idle guards exist to
1897
+ * prevent. One copy now, asked by all three.
1898
+ * @param {unknown} stored - the persisted list, or `null` when unreadable.
1899
+ * @param {unknown} remembered - `publisher.state.enabledIds` (last published).
1900
+ * @returns {string[]} the list to use.
1901
+ */
1902
+ function resolveEnabledIds(stored, remembered) {
1903
+ if (Array.isArray(stored)) return stored;
1904
+ return Array.isArray(remembered) ? remembered : [];
1905
+ }
1906
+ /**
1457
1907
  * The file-backed provider switch + allow-list.
1458
1908
  * @param {object} [options]
1459
1909
  * @param {string} [options.dir] - override the state directory (tests).
@@ -1477,40 +1927,92 @@ function createFileProviderStore(options = {}) {
1477
1927
  * read. An unknown NUMERIC version means a NEWER build wrote it; clobbering
1478
1928
  * it destroys data we cannot even see. So the write is refused with a
1479
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.
1480
1933
  * @param {object} body - the JSON body to persist.
1481
1934
  * @returns {Promise<string|null>} the refusal reason when the write was
1482
1935
  * refused (already `degrade`-logged), or `null` when it landed — the caller
1483
1936
  * decides whether to surface the refusal to a user.
1484
1937
  */
1485
1938
  const writePayload = async (body) => {
1486
- const existing = await readStateVersion(filePath);
1487
- if (!isKnownStateVersion(existing, KNOWN_PROVIDER_VERSIONS)) {
1488
- const reason = `provider: refusing to overwrite provider.json holding version ${existing} (this build knows ${KNOWN_PROVIDER_VERSIONS.join("/")})`;
1489
- degrade(reason, null, logger, null);
1490
- return reason;
1491
- }
1492
- 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;
1493
1944
  await ensureStateDir(stateDir$1);
1494
- await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary });
1945
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$1, "provider.json") });
1495
1946
  return null;
1496
1947
  };
1497
1948
  const legacyFile = dir === void 0 && profile ? join(stateDir(name), "provider.json") : null;
1498
- /** Read ONE payload off the disk, shape-checked. */
1499
- const parsePayload = async () => {
1500
- const raw = await readStateJson(filePath);
1501
- const source = obj(raw);
1502
- if (source.version !== 1) return {
1949
+ /**
1950
+ * Read ONE payload off the disk, shape-checked.
1951
+ *
1952
+ * "Can this build read it" is asked through {@link isKnownProviderVersion} —
1953
+ * the same predicate the report and the write guard use. Comparing against
1954
+ * {@link PROVIDER_VERSION} instead would say "cannot read" about the OLDEST
1955
+ * shapes this build still knows, which is exactly wrong when it is bumped:
1956
+ * the header's upgrade rule adds the new number to {@link KNOWN_PROVIDER_VERSIONS},
1957
+ * so a v1 file must stay readable while v2 is written. Getting that wrong
1958
+ * makes this reader disagree with {@link versionNote} — reads "not set" while
1959
+ * the note says "nothing to say", which is the silent collapse this module
1960
+ * exists to kill. That split once existed in production code for one release.
1961
+ *
1962
+ * A version this build cannot read reads as EMPTY, but REMEMBERS which version
1963
+ * it was. The ADR-006 guard already refuses to overwrite such a file, so the
1964
+ * read side has to be able to say "there is an answer in there, it just is
1965
+ * not one we can see" — collapsing it to "never saved" is what made a saved
1966
+ * allow-list look like a deliberate "offer every model". See {@link versionNote}.
1967
+ */
1968
+ const parseAt = async (file) => {
1969
+ const source = obj(await readStateJson(file));
1970
+ const version = typeof source.version === "number" ? source.version : null;
1971
+ if (!isKnownProviderVersion(version)) return {
1503
1972
  enabled: null,
1504
- enabledIds: null
1973
+ enabledIds: null,
1974
+ contextWindow: null,
1975
+ version
1505
1976
  };
1506
1977
  return {
1507
1978
  enabled: normalizeEnabled(source.enabled),
1508
- enabledIds: normalizeEnabledIds(source.enabledIds)
1979
+ enabledIds: normalizeEnabledIds(source.enabledIds),
1980
+ contextWindow: normalizeContextWindow(source.contextWindow),
1981
+ version
1509
1982
  };
1510
1983
  };
1984
+ /** Read the profile's own file, shape-checked. */
1985
+ const parsePayload = async () => parseAt(filePath);
1986
+ /** Read one payload off an explicit path (the legacy shared file). */
1987
+ const parsePayloadFrom = async (file) => parseAt(file);
1988
+ const writeQueue = createStateWriteQueue();
1989
+ const cache = createStateReadCache(async () => {
1990
+ const own = await parsePayload();
1991
+ if (own.enabled !== null || own.enabledIds !== null || legacyFile === null) return own;
1992
+ if (own.version !== null) return own;
1993
+ const legacy = await parsePayloadFrom(legacyFile);
1994
+ if (legacy.enabled === null && legacy.enabledIds === null) return own;
1995
+ const inherited = {
1996
+ version: 1,
1997
+ ...legacy.enabled === null ? {} : { enabled: legacy.enabled },
1998
+ ...legacy.enabledIds === null ? {} : { enabledIds: legacy.enabledIds },
1999
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2000
+ };
2001
+ await writeQueue.enqueue(() => writePayload(inherited).then(() => void 0, () => void 0));
2002
+ return legacy;
2003
+ }, { ttlMs });
2004
+ const read = () => cache.read();
2005
+ /**
2006
+ * The payload the callers actually read. The short-TTL cache's `read()` is
2007
+ * typed `Promise<T | null>` because it doubles as "never read" signalling for
2008
+ * the §23 legacy adoption; a provider payload is always an object, so the
2009
+ * `null` branch collapses here, in one place, rather than at every caller.
2010
+ * @returns {Promise<ProviderPayload>} the payload; the empty payload when nothing is stored.
2011
+ */
2012
+ const readPayload = async () => await read() ?? EMPTY_PAYLOAD$1;
1511
2013
  /**
1512
- * Read one payload, then hand it to `write` — the read-modify-write that keeps
1513
- * the switch and the list from clobbering each other.
2014
+ * Read one payload, then hand it to {@link writePayload} — the read-modify-
2015
+ * write that keeps the switch and the list from clobbering each other.
1514
2016
  *
1515
2017
  * Three-state keys, and they are SYMMETRIC between `enabled` and `enabledIds`:
1516
2018
  * a key the caller omits (`undefined`) keeps whatever is on disk, an explicit
@@ -1519,28 +2021,30 @@ function createFileProviderStore(options = {}) {
1519
2021
  * value is stored as given. An earlier `mergeEnabled` flag applied the
1520
2022
  * "keep what is on disk" rule to `enabled` unconditionally, which silently
1521
2023
  * discarded the very value a switch save was asked to store.
1522
- * @param {object} patch - `{ enabled?, enabledIds? }` to persist.
1523
- * @returns {Promise<string|null>} the write refusal reason, or `null`.
1524
- */
1525
- /**
1526
- * 读-改-写**整段排队**:两个并发patch 不能互相覆盖。
1527
- *
1528
- * 这个 store 以前完全没有写串行化(对比 `usage-store.ts` 的 `writeChain`),于是
1529
- * 面板开关POST 与 roster POST —— 两个独立请求,外加 `index.ts` 轮询同时在调
1530
- * `enabledIds()` —— 会各自读到同一个 `current`,后写的赢。实测:先存清单,再并发
1531
- * `save(true)` + `saveEnabledIds([...])`,其中一次保存被**整个丢掉**(用户勾了
1532
- * 模型又开了开关,两次点击只活下来一次)。
1533
2024
  *
1534
- * 文件头说「两个字段必须作为一个载荷读写,否则开关会覆盖清单」——设计意图是对的,
1535
- * 但那只防住了「**不同字段**互相覆盖」;缺了串行化,**同字段**与「两次都基于同一
1536
- * 快照」的互相覆盖照样发生。链的形状与 `usage-store` 一致,且必须把
1537
- * `writePayload` 的版本拒绝语义一起搬进临界区(否则拒绝判断会落在锁外)。
2025
+ * 读-改-写**整段排队**:两个并发 patch 不能互相覆盖。这个 store 以前完全没有
2026
+ * 写串行化(对比 `usage-store.ts` 的 `writeChain`),于是面板开关 POST 与
2027
+ * roster POST —— 两个独立请求,外加 `index.ts` 轮询同时在调 `enabledIds()` ——
2028
+ * 会各自读到同一个 `current`,后写的赢。实测:先存清单,再并发 `save(true)` +
2029
+ * `saveEnabledIds([...])`,其中一次保存被**整个丢掉**(用户勾了模型又开了开关,
2030
+ * 两次点击只活下来一次)。文件头说「两个字段必须作为一个载荷读写,否则开关会
2031
+ * 覆盖清单」——设计意图是对的,但那只防住了「**不同字段**互相覆盖」;缺了串行化,
2032
+ * **同字段**与「两次都基于同一快照」的互相覆盖照样发生。链的形状与
2033
+ * `usage-store` 一致,且必须把 `writePayload` 的版本拒绝语义一起搬进临界区
2034
+ * (否则拒绝判断会落在锁外)。
1538
2035
  *
1539
2036
  * 链吞掉自己的失败继续走:一次只读 Home 上的写失败不该毒化后续所有保存。
2037
+ *
2038
+ * 写成功的同时更新读缓存,调用方不再各自回读一遍:缓存里永远是刚写下去的那份,
2039
+ * 而 `patchPayload` 的 `current` 来自磁盘(`parsePayload`,不走缓存),所以合并
2040
+ * 结果不会带进过期值。此前的写法是写完再 `readPayload()` 读回另一半——那条回读
2041
+ * 走的是 TTL 缓存,拿到的是**写入前**的值,并发时会把一个较新的缓存项顶成旧值。
2042
+ *
2043
+ * @param {object} patch - `{ enabled?, enabledIds?, contextWindow? }` to persist.
2044
+ * @returns {Promise<string|null>} the write refusal reason, or `null`.
1540
2045
  */
1541
- let writeChain = Promise.resolve(null);
1542
2046
  const patchPayload = (patch) => {
1543
- const run = writeChain.then(async () => {
2047
+ return writeQueue.enqueue(async () => {
1544
2048
  const current = await parsePayload();
1545
2049
  /** `undefined` keeps the stored key; `null` clears it (key absent). */
1546
2050
  const body = { version: 1 };
@@ -1548,50 +2052,18 @@ function createFileProviderStore(options = {}) {
1548
2052
  if (enabled !== null) body.enabled = enabled;
1549
2053
  const enabledIds = patch.enabledIds === void 0 ? current.enabledIds : patch.enabledIds;
1550
2054
  if (enabledIds !== null) body.enabledIds = enabledIds;
2055
+ const contextWindow = patch.contextWindow === void 0 ? current.contextWindow : patch.contextWindow;
2056
+ if (contextWindow !== null) body.contextWindow = contextWindow;
1551
2057
  body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1552
- return writePayload(body);
2058
+ const refusal = await writePayload(body);
2059
+ if (refusal === null) cache.remember({
2060
+ enabled,
2061
+ enabledIds,
2062
+ contextWindow,
2063
+ version: 1
2064
+ });
2065
+ return refusal;
1553
2066
  });
1554
- writeChain = run.catch(() => null);
1555
- return run;
1556
- };
1557
- const cache = createStateReadCache(async () => {
1558
- const own = await parsePayload();
1559
- if (own.enabled !== null || own.enabledIds !== null || legacyFile === null) return own;
1560
- const legacy = await parsePayloadFrom(legacyFile);
1561
- if (legacy.enabled === null && legacy.enabledIds === null) return own;
1562
- const inherited = {
1563
- version: 1,
1564
- ...legacy.enabled === null ? {} : { enabled: legacy.enabled },
1565
- ...legacy.enabledIds === null ? {} : { enabledIds: legacy.enabledIds },
1566
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1567
- };
1568
- await writeChain.then(() => writePayload(inherited).then(() => void 0, () => void 0), () => writePayload(inherited).then(() => void 0, () => void 0));
1569
- return legacy;
1570
- }, { ttlMs });
1571
- const read = () => cache.read();
1572
- /**
1573
- * The payload the callers actually read. The short-TTL cache's `read()` is
1574
- * typed `Promise<T | null>` because it doubles as "never read" signalling for
1575
- * the §23 legacy adoption; a provider payload is always an object, so the
1576
- * `null` branch collapses here, in one place, rather than at every caller.
1577
- * @returns {Promise<ProviderPayload>} the payload; `{enabled:null,enabledIds:null}` when nothing is stored.
1578
- */
1579
- const readPayload = async () => await read() ?? {
1580
- enabled: null,
1581
- enabledIds: null
1582
- };
1583
- /** Read one payload off an explicit path (the legacy shared file). */
1584
- const parsePayloadFrom = async (file) => {
1585
- const raw = await readStateJson(file);
1586
- const source = obj(raw);
1587
- if (source.version !== 1) return {
1588
- enabled: null,
1589
- enabledIds: null
1590
- };
1591
- return {
1592
- enabled: normalizeEnabled(source.enabled),
1593
- enabledIds: normalizeEnabledIds(source.enabledIds)
1594
- };
1595
2067
  };
1596
2068
  return {
1597
2069
  /**
@@ -1610,6 +2082,16 @@ function createFileProviderStore(options = {}) {
1610
2082
  return (await readPayload()).enabledIds ?? [];
1611
2083
  },
1612
2084
  /**
2085
+ * The saved context-window override (tokens), or `null` = follow the
2086
+ * catalog. `null` is a real answer here, not absence: the override is
2087
+ * optional by design, and the caller (snapshot / stamping) decides what
2088
+ * "follow the catalog" means.
2089
+ * @returns {Promise<number|null>}
2090
+ */
2091
+ async contextWindow() {
2092
+ return (await readPayload()).contextWindow;
2093
+ },
2094
+ /**
1613
2095
  * Whether the panel has ever saved a value here.
1614
2096
  * @returns {Promise<boolean>}
1615
2097
  */
@@ -1618,6 +2100,33 @@ function createFileProviderStore(options = {}) {
1618
2100
  return payload.enabled !== null || payload.enabledIds !== null;
1619
2101
  },
1620
2102
  /**
2103
+ * A human-readable note when the file holds a version this build cannot
2104
+ * read, or `null` when there is nothing to say.
2105
+ *
2106
+ * The READ side is permissive on purpose: a foreign file reads as "not set",
2107
+ * so the panel keeps working instead of failing the whole snapshot. The cost
2108
+ * is that it cannot tell "never saved" from "saved but unreadable" — and
2109
+ * for the allow-list those two read differently in the dangerous direction
2110
+ * (an empty list means "offer every model"). Writes are already protected by
2111
+ * the ADR-006 guard in {@link writePayload}; this note is the read side's
2112
+ * half of the same promise. The snapshot aggregate pushes it into
2113
+ * `shapeWarnings`, and `doctor.ts` reports the same file, so the operator
2114
+ * sees the disagreement instead of a switch that looks merely unset.
2115
+ *
2116
+ * @returns {Promise<string|null>}
2117
+ */
2118
+ async versionNote() {
2119
+ const version = (await readPayload()).version;
2120
+ if (version === null || isKnownProviderVersion(version)) return null;
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
+ });
2128
+ },
2129
+ /**
1621
2130
  * Persist a switch value. The write is atomic (temp file + rename) so a
1622
2131
  * concurrent reader never sees a partial payload, and it PRESERVES the
1623
2132
  * saved allow-list.
@@ -1629,10 +2138,6 @@ function createFileProviderStore(options = {}) {
1629
2138
  if (enabled === null) throw new TypeError("provider switch expects a boolean");
1630
2139
  const refusal = await patchPayload({ enabled });
1631
2140
  if (refusal !== null) throw new Error(refusal);
1632
- cache.remember({
1633
- enabled,
1634
- enabledIds: (await readPayload()).enabledIds ?? []
1635
- });
1636
2141
  },
1637
2142
  /**
1638
2143
  * Persist the model allow-list, preserving the saved switch.
@@ -1644,75 +2149,1221 @@ function createFileProviderStore(options = {}) {
1644
2149
  if (list === null) throw new TypeError("provider allow-list expects an array of strings");
1645
2150
  const refusal = await patchPayload({ enabledIds: list });
1646
2151
  if (refusal !== null) throw new Error(refusal);
1647
- cache.remember({
1648
- enabled: (await readPayload()).enabled ?? null,
1649
- enabledIds: list
1650
- });
2152
+ },
2153
+ /**
2154
+ * Persist the context-window override, preserving the saved switch and
2155
+ * allow-list. `null` clears the override back to "follow the catalog".
2156
+ * @param {number|null} value - the window in tokens, or `null` to clear.
2157
+ * @returns {Promise<void>}
2158
+ */
2159
+ async saveContextWindow(value) {
2160
+ const window = normalizeContextWindow(value);
2161
+ if (value !== null && window === null) throw new TypeError("provider context window expects a positive finite number");
2162
+ const refusal = await patchPayload({ contextWindow: window });
2163
+ if (refusal !== null) throw new Error(refusal);
1651
2164
  },
1652
2165
  /**
1653
2166
  * Forget the panel-saved values: the config default and "no filter" rule
1654
- * again.
2167
+ * again. The context override is forgotten WITH them — the reset button
2168
+ * means "back to the way this plugin ships", and the override is panel
2169
+ * state in exactly the same sense.
1655
2170
  * @returns {Promise<void>}
1656
2171
  */
1657
2172
  async forget() {
1658
2173
  const refusal = await patchPayload({
1659
2174
  enabled: null,
1660
- enabledIds: null
2175
+ enabledIds: null,
2176
+ contextWindow: null
1661
2177
  });
1662
2178
  if (refusal !== null) throw new Error(refusal);
1663
- cache.remember({
1664
- enabled: null,
1665
- enabledIds: null
1666
- });
1667
2179
  }
1668
2180
  };
1669
2181
  }
1670
2182
 
1671
2183
  //#endregion
1672
- //#region src/host/switch-precedence.ts
2184
+ //#region src/host/draw-store.ts
1673
2185
  /**
1674
- * The single adjudicator for "panel-saved value vs config default" across
1675
- * every opt-in switch.
2186
+ * 出图工具的开关与模型偏好——本插件**自己的**状态文件,与 provider 那条开关分开。
1676
2187
  *
1677
- * Historically the rule was hand-copied at three call sites (the provider
1678
- * route, the models route, the draw route) in two look-alike dialects:
2188
+ * 为什么不是第二个字段塞进 `provider.json`:两者回答的是不同问题。
2189
+ * `provider.json` 说「这个 profile 要不要把魔搭作为 **chat provider** 提供」;
2190
+ * 本文件说「要不要给 agent 一个**出图工具**」。用户完全可能只要其一(用工具出图但
2191
+ * 不污染模型选择器,或反之)。合成一个 payload 会让「关掉出图」连带改掉 provider
2192
+ * 清单的写入时序——provider-store 的注释已说明为什么它的两个字段必须同生共死,
2193
+ * 而那组理由**不适用于**这两个开关(它们之间没有 atomicity 要求)。
1679
2194
  *
1680
- * - the boolean switch: `(panel ?? config) === true` plus
1681
- * `panel === null ? "config" : "panel"`;
1682
- * - the model preference: `panel ?? config` plus the same source probe.
2195
+ * 落点与 `provider-store.ts` 一致:`$DSH_HOME/state/<plugin>/draw.json`(无 profile
2196
+ * 时)或 `$DSH_HOME/state/<profile>/<plugin>/draw.json`。profile 分段的意思是
2197
+ * 「这个 profile 要不要暴露出图工具」,两个 profile 不能互相覆盖。
1683
2198
  *
1684
- * A fourth dialect existed where no config default exists at all (a switch that
1685
- * is purely panel-owned — a profile without a saved value falls to `off` with no
1686
- * fallback), which is exactly the shape this module does NOT serve: that one has
1687
- * no config default to adjudicate against, so it is the caller's plain read.
2199
+ * 优先级与所有 opt-in 开关同一份裁决(`switch-precedence.ts`):面板保存值赢,
2200
+ * 否则 `cordis.patch.yml` 的 `drawEnabled` 默认值说了算。
1688
2201
  *
1689
- * This module is peer-free and deliberately tiny: the whole point is that a
1690
- * switch can never again invent its own precedence dialect, and the source
1691
- * label (`panel` / `config`) always rides with the answer so the panel can
1692
- * say which side is in charge. `test/switch-precedence.test.mjs` pins the
1693
- * dialect so a new caller copying the shape is the odd one out.
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
+ * 值被忽略」呈现成「从未保存过」。
1694
2216
  *
1695
- * 注:本模块自带的 `test/switch-precedence.test.mjs` 钉死纯函数行为;部分旧注释里
1696
- * 提到的 `test/peer-contract.test.mjs`(钉死真 peer 的 classifyPiAiError /
1697
- * resolveRetryPolicy 协议)依赖未 vendored 进本仓库的运行时 peer,不在 `npm test`
1698
- * 离线门禁内。
2217
+ * 与 provider-store 的一处**有意差异**:没有 legacy(pre-profile 共享目录)继承。
2218
+ * draw.json 随出图工具一起诞生,晚于按 profile 分段,市面上不存在共享布局里的
2219
+ * 旧 draw.json;provider.json 则需要(§23 之前它一直在共享目录)。
1699
2220
  *
1700
- * @module dsh-connect-modelscope-token-plan/switch-precedence
2221
+ * doctor 覆盖:`describeDrawPayload` 给只读速览,`doctor.ts` 据此把 draw.json
2222
+ * 的「损坏 / 未知版本」报进症状表——写侧守卫挡覆盖,doctor 报静默降级。
2223
+ *
2224
+ * @module dsh-connect-modelscope-token-plan/draw-store
1701
2225
  */
2226
+ /** 本 store 落盘 payload 的版本。 */
2227
+ const DRAW_STORE_VERSION = 1;
2228
+ /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
2229
+ const KNOWN_DRAW_VERSIONS = Object.freeze([1]);
2230
+ const EMPTY_PAYLOAD = {
2231
+ enabled: null,
2232
+ modelId: null,
2233
+ version: null
2234
+ };
1702
2235
  /**
1703
- * Resolve the effective boolean switch: a panel-saved value always wins,
1704
- * otherwise the config default rules.
1705
- * @param {boolean|null} panel - the panel-saved value (`null` = never saved).
1706
- * @param {boolean} config - the patch-declared default.
1707
- * @returns {boolean} the effective switch.
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}
1708
2245
  */
1709
- function resolveSwitchEnabled(panel, config) {
1710
- return (panel ?? config) === true;
2246
+ function isKnownDrawVersion(version) {
2247
+ return typeof version === "number" && KNOWN_DRAW_VERSIONS.includes(version);
1711
2248
  }
1712
2249
  /**
1713
- * Where the effective value came from — the panel when it saved one, the
1714
- * config otherwise. Rides with every resolved answer so the panel can name
1715
- * the side in charge.
2250
+ * 归一一个保存的模型偏好:非空字符串,或 `null`(= 自动挑——具体挑谁由
2251
+ * `draw-runtime.ts` 的 `pickDrawModel` 优先级决定,不是简单取目录第一个)。
2252
+ *
2253
+ * 不校验「这个 id 是否真存在」——目录会变,存一个暂时不在目录里的 id 是合法的
2254
+ * (用户可能先配好、等模型上线)。调用侧解析不出来时退回自动。
2255
+ * @param {unknown} value - 原始值。
2256
+ * @returns {string|null} 归一后的 id 或 `null`。
2257
+ */
2258
+ function normalizeDrawModelId(value) {
2259
+ if (typeof value !== "string") return null;
2260
+ const trimmed = value.trim();
2261
+ return trimmed === "" ? null : trimmed;
2262
+ }
2263
+ /**
2264
+ * 归一保存的开关值:只有真正的 boolean 才算数,其余读作「没设过」。
2265
+ * @param {unknown} value - 原始值。
2266
+ * @returns {boolean|null} 归一后的开关。
2267
+ */
2268
+ function normalizeDrawEnabled(value) {
2269
+ return typeof value === "boolean" ? value : null;
2270
+ }
2271
+ /** 本 store 的目录(与 provider-store 同一套分段规则)。 */
2272
+ function drawStoreDir(profile, dir) {
2273
+ if (typeof dir === "string" && dir !== "") return dir;
2274
+ return profile ? profileStateDir(name, profile) : stateDir(name);
2275
+ }
2276
+ /**
2277
+ * 文件支撑的出图开关 + 模型偏好。
2278
+ * @param {StoreOptions} [options] - `{ dir, profile, ttlMs, logger }`。
2279
+ * @returns {object} store 句柄。
2280
+ */
2281
+ function createFileDrawStore(options = {}) {
2282
+ const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
2283
+ const stateDir = drawStoreDir(profile, dir);
2284
+ const filePath = join(stateDir, "draw.json");
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
+ */
2310
+ const parsePayload = async () => {
2311
+ const source = obj(await readStateJson(filePath));
2312
+ const version = typeof source.version === "number" ? source.version : null;
2313
+ if (!isKnownDrawVersion(version)) return {
2314
+ enabled: null,
2315
+ modelId: null,
2316
+ version
2317
+ };
2318
+ return {
2319
+ enabled: normalizeDrawEnabled(source.enabled),
2320
+ modelId: normalizeDrawModelId(source.modelId),
2321
+ version
2322
+ };
2323
+ };
2324
+ const cache = createStateReadCache(parsePayload, { ttlMs });
2325
+ /**
2326
+ * 读当前 payload。
2327
+ * @returns {Promise<DrawPayload>}
2328
+ */
2329
+ const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
2330
+ /**
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>}
2344
+ */
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
+ });
2361
+ return {
2362
+ /** 面板保存的出图开关;`null` = 回退 patch 默认值。 */
2363
+ async enabled() {
2364
+ return (await readPayload()).enabled;
2365
+ },
2366
+ /** 面板保存的模型偏好;`null` = 自动。 */
2367
+ async modelId() {
2368
+ return (await readPayload()).modelId;
2369
+ },
2370
+ /** 这个 profile 是否被显式配置过。 */
2371
+ async isConfigured() {
2372
+ const payload = await readPayload();
2373
+ return payload.enabled !== null || payload.modelId !== null;
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
+ },
2394
+ /** 保存开关。 */
2395
+ async saveEnabled(enabled) {
2396
+ if (typeof enabled !== "boolean") throw new TypeError("draw switch expects a boolean");
2397
+ await patchPayload({ enabled });
2398
+ },
2399
+ /** 保存模型偏好(`null` = 清掉,回到自动)。 */
2400
+ async saveModelId(modelId) {
2401
+ if (modelId !== null && (typeof modelId !== "string" || modelId.trim() === "")) throw new TypeError("draw model expects a non-empty string or null");
2402
+ await patchPayload({ modelId });
2403
+ },
2404
+ /** 清掉两个字段(回到「没配过」)。 */
2405
+ async reset() {
2406
+ await patchPayload({
2407
+ enabled: null,
2408
+ modelId: null
2409
+ });
2410
+ },
2411
+ /** 落盘路径(诊断与测试用)。 */
2412
+ filePath
2413
+ };
2414
+ }
2415
+
2416
+ //#endregion
2417
+ //#region src/host/draw.ts
2418
+ /**
2419
+ * 魔搭出图(`/v1/images/generations`)的**纯逻辑半边**——协议形状的解析与判定。
2420
+ *
2421
+ * 为什么是这个形状:魔搭的生图是**异步两段式**(提交得 `task_id`,轮询才出图),
2422
+ * 与姊妹插件 `dsh-connect-sensenova-token-plan/src/host/draw.ts` 的**同步单发**
2423
+ * (`drawOnce` 一次 fetch 即 `parseDrawResponse` 取 `data[0].url`)形状不兼容,
2424
+ * **不能照抄**。可复用的只有外壳(endpoint 拼接、冷却门、失败文案),核心必须新写。
2425
+ * 协议依据全部来自 `docs/SPIKE-image-gen.md` 的真机实测,非文档转述。
2426
+ *
2427
+ * 本模块**只做纯函数**:endpoint 构造、请求体构造、响应解析、状态判定、失败分诊。
2428
+ * 它**不发起任何请求、不碰凭据、不注册工具**——那半边(fetch 注入、Host 工具注册)
2429
+ * 与 `llm-adapter.ts` 一样 peer-dependent,测试覆盖同样是零(docs/ROADMAP.md Backlog)。
2430
+ * 刻意 peer-free:纯函数部分能进离线 `npm test` 门禁,`test/draw.test.mjs` 钉住形状。
2431
+ *
2432
+ * 三条承重结论(全部实测,推翻了几条广泛流传的错误写法):
2433
+ *
2434
+ * 1. **轮询必须带 `X-ModelScope-Task-Type: image_generation`**,否则上游一律返回
2435
+ * `task not found` + `task_status: FAILED`——**那是假失败**,任务实际正常。
2436
+ * 缺这个头的实现会在第一次轮询就误判失败并放弃一张已经能出的图。
2437
+ * 2. **提交响应的 `task_status` 不可信**:实测每次提交都回 `SUCCEED`,包括最终确实
2438
+ * `FAILED` 的那次。所以提交只认 `task_id`,**成功与否只看轮询终态**。
2439
+ * 3. **`FAILED` 没有可分诊的形状**:`errors.code` 实测为 `0`、`errors.message` 为
2440
+ * 空串,没有顶层 `message`。任何「从错误里读出原因」的承诺都是编造——本模块的
2441
+ * `describeDrawFailure` 因此**明确承认原因不可知**,只报可确定的形态差异。
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
+ *
2450
+ * @module dsh-connect-modelscope-token-plan/draw
2451
+ */
2452
+ /**
2453
+ * 出图请求的截止时间(墙钟毫秒)。
2454
+ *
2455
+ * **15 分钟**:实测同一模型的耗时在 61s–537s 之间大幅波动(免费池排队),
2456
+ * 且**超时放弃不等于任务死亡**——上游任务会继续跑完(记录之后还会被回收,
2457
+ * 见 docs/SPIKE-image-gen.md §坑 5)。宁可多等,不要扔掉一张已在生成的图。
2458
+ */
2459
+ const DRAW_TIMEOUT_MS = 9e5;
2460
+ /**
2461
+ * 两次轮询之间的间隔(毫秒)。
2462
+ *
2463
+ * 取 15s 的**真正理由**是「够用且不浪费」:实测最慢一次 537s,15s 间隔意味着
2464
+ * 最多几十次查询,代价可忽略;而更密(社区示例的 5s)没有观测收益。
2465
+ *
2466
+ * ⚠️ 早先曾写「更密的轮询拖慢任务」,**该归因已推翻**——后续对照显示同一模型、
2467
+ * 同样 15s 轮询的耗时是 61s → 261s → 537s 单调恶化,是**上游排队时长在波动**。
2468
+ * 那次构成「证据」的 5s vs 30s 对照,控制变量没做,不足以支撑因果。
2469
+ * 详见 docs/SPIKE-image-gen.md §坑 4(含撤回记录)。
2470
+ */
2471
+ const DRAW_POLL_INTERVAL_MS = 15e3;
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;
2485
+ /**
2486
+ * 轮询必须携带的头名与取值。
2487
+ *
2488
+ * 漏掉它 → 上游返回 `task not found` + `FAILED`,而任务其实在正常跑。这是本协议
2489
+ * 最贵的单点知识,也是第一份外部资料缺失的那一块。
2490
+ */
2491
+ const TASK_TYPE_HEADER = "X-ModelScope-Task-Type";
2492
+ const TASK_TYPE_IMAGE_GENERATION = "image_generation";
2493
+ /**
2494
+ * 一次出图请求由几个 HTTP 请求组成:一次提交 + N 次轮询。
2495
+ * 只作为文档性常量,供工具描述与测试引用。
2496
+ */
2497
+ const DRAW_PHASE = Object.freeze({
2498
+ submit: "submit",
2499
+ poll: "poll"
2500
+ });
2501
+ /**
2502
+ * 读一个响应的 `task_status`,取不到返回 `null`。
2503
+ *
2504
+ * 刻意**不猜**:字段缺失或类型不对都返回 `null`,由 `isTerminalStatus` 把它当作
2505
+ * 「非终态」继续等——这样一次形状漂移最多变成多轮轮询,不会变成误报失败。
2506
+ * @param {unknown} data - 已解析的 JSON。
2507
+ * @returns {DrawTaskStatus|null} 规范化状态,取不到为 `null`。
2508
+ */
2509
+ function readTaskStatus(data) {
2510
+ if (data === null || typeof data !== "object") return null;
2511
+ const raw = data.task_status;
2512
+ if (typeof raw !== "string") return null;
2513
+ const upper = raw.trim().toUpperCase();
2514
+ if (upper === "PENDING" || upper === "RUNNING" || upper === "SUCCEED" || upper === "FAILED") return upper;
2515
+ return null;
2516
+ }
2517
+ /**
2518
+ * 一个状态是不是终态。
2519
+ *
2520
+ * **只有** `SUCCEED` / `FAILED` 是终态;`null`(读不到)与一切未知值都**不是**。
2521
+ * 「未知值继续等」是刻意的:把上游新增的状态误判成失败会丢掉一张已经能出的图,
2522
+ * 而误判成进行中最多多等几轮(受 `DRAW_POLL_DEADLINE_MS` 兜底)。
2523
+ * @param {DrawTaskStatus|null} status - 规范化状态。
2524
+ * @returns {boolean} 是否终态。
2525
+ */
2526
+ function isTerminalStatus(status) {
2527
+ return status === "SUCCEED" || status === "FAILED";
2528
+ }
2529
+ /**
2530
+ * 从提交响应里取 `task_id`。
2531
+ *
2532
+ * 提交响应里那个 `task_status: "SUCCEED"` **一律忽略**(见模块头第 2 条:它与终态
2533
+ * 无关)。本函数只认 `task_id`,所以一个「提交即失败」的上游只要给了 id 就仍会被
2534
+ * 轮询,而不给 id 会被当作提交失败——这两种都符合实测。
2535
+ * @param {unknown} data - 已解析的提交响应。
2536
+ * @returns {string|null} 任务 id,取不到为 `null`。
2537
+ */
2538
+ function readTaskId(data) {
2539
+ if (data === null || typeof data !== "object") return null;
2540
+ const raw = data.task_id;
2541
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
2542
+ }
2543
+ /**
2544
+ * 从终态响应里取出成品图的 URL 列表。
2545
+ *
2546
+ * 实测形状是顶层 `output_images: string[]`。**刻意不读** `data[0].url`(OpenAI
2547
+ * 同步形状)与 `output.image_url`(外部资料误传的形状)——两者在魔搭上都不存在。
2548
+ * @param {unknown} data - 已解析的轮询响应。
2549
+ * @returns {string[]} 可用的图片 URL(保持上游顺序,已去空)。
2550
+ */
2551
+ function readOutputImages(data) {
2552
+ if (data === null || typeof data !== "object") return [];
2553
+ const raw = data.output_images;
2554
+ if (!Array.isArray(raw)) return [];
2555
+ const out = [];
2556
+ for (const item of raw) if (typeof item === "string" && item.trim() !== "") out.push(item.trim());
2557
+ return out;
2558
+ }
2559
+ /**
2560
+ * 构造出图请求体。
2561
+ *
2562
+ * 只发上游**确认消费**的字段:`model` / `prompt` / `image_url` / `size` / `steps` /
2563
+ * `guidance` / `seed` / `negative_prompt` 均被实测接受(SPIKE 实测 #9);`loras`
2564
+ * 也被确认消费(无效 LoRA id 在提交阶段直接 500,实测 #18)。**不发** `ref_image`
2565
+ * ——那是别家平台的写法,魔搭上不存在(SPIKE §对照)。
2566
+ *
2567
+ * `image_url` **一律发数组**,哪怕只有一张:官方示例的形状是数组(多图编辑要用),
2568
+ * 实测单张以数组发也通(#17)、以裸字符串发亦通(#8)。取数组是因为它的超集更贴近
2569
+ * 上游本意,也省掉一个分支。
2570
+ *
2571
+ * `n` 刻意**不作为请求字段**:实测目录里的出图模型都是单出模型,`n` 的支持度未
2572
+ * 逐项验证,传一个上游不认的参数比不传更危险。调用方要几张就发几次。
2573
+ * @param {object} options - 请求参数。
2574
+ * @param {string} options.model - 模型 id。
2575
+ * @param {string} options.prompt - 提示词。
2576
+ * @param {string|string[]} [options.imageUrl] - 输入图 URL,**字符串或数组均可**,
2577
+ * 内部一律归一为数组(官方形状;多图编辑要用数组)。
2578
+ * @param {string} [options.size] - `"宽x高"`,如 `"1024x1024"`(64 对齐)。
2579
+ * @param {number} [options.steps] - 采样步数。
2580
+ * @param {number} [options.guidance] - 引导系数。
2581
+ * @param {number} [options.seed] - 随机种子。
2582
+ * @param {string} [options.negativePrompt] - 负向提示词。
2583
+ * @param {string|Record<string, number>} [options.loras] - LoRA 配置:单个 repo id,
2584
+ * 或 `{id: weight}` 映射(官方:最多 6 个,权重和须为 1.0)。
2585
+ * @returns {object} 请求体。
2586
+ */
2587
+ function buildDrawBody(options) {
2588
+ const body = {
2589
+ model: str(options.model, ""),
2590
+ prompt: str(options.prompt, "")
2591
+ };
2592
+ const imageUrl = normalizeImageUrlList(options.imageUrl);
2593
+ if (imageUrl.length > 0) body.image_url = imageUrl;
2594
+ const size = str(options.size, "");
2595
+ if (size !== "") body.size = size;
2596
+ if (Number.isFinite(options.steps)) body.steps = options.steps;
2597
+ if (Number.isFinite(options.guidance)) body.guidance = options.guidance;
2598
+ if (Number.isFinite(options.seed)) body.seed = options.seed;
2599
+ const negativePrompt = str(options.negativePrompt, "");
2600
+ if (negativePrompt !== "") body.negative_prompt = negativePrompt;
2601
+ const loras = options.loras;
2602
+ if (typeof loras === "string" && loras.trim() !== "") body.loras = loras.trim();
2603
+ else if (loras !== null && typeof loras === "object") {
2604
+ const entries = Object.entries(loras).filter(([id, weight]) => id.trim() !== "" && Number.isFinite(weight));
2605
+ if (entries.length > 0) body.loras = Object.fromEntries(entries);
2606
+ }
2607
+ return body;
2608
+ }
2609
+ /**
2610
+ * 把「一张或多张输入图」归一成 URL 数组。
2611
+ *
2612
+ * 单张给字符串上游也吃(实测 #8),但官方示例与多图编辑都用数组(#17 实测通),
2613
+ * 故一律归一为数组。逐项过滤空串,**保持调用方给定的顺序**——多图编辑里顺序即语义
2614
+ * (官方示例:「图一中的狗,去追图二中的飞盘」)。
2615
+ * @param {unknown} value - 单个 URL、URL 数组,或缺省。
2616
+ * @returns {string[]} 归一后的 URL 数组(可为空)。
2617
+ */
2618
+ function normalizeImageUrlList(value) {
2619
+ const raw = Array.isArray(value) ? value : [value];
2620
+ const out = [];
2621
+ for (const item of raw) if (typeof item === "string" && item.trim() !== "") out.push(item.trim());
2622
+ return out;
2623
+ }
2624
+ /**
2625
+ * 从任意配置里读出「可用作输入图」的 URL。
2626
+ *
2627
+ * 承接 agent 工具的 `image_url` 参数。**只接受 http(s) 与 data URL**——其它形态
2628
+ * (本地路径)上游取不到,转发过去只会换回一个原因不可知的 `FAILED`。实测尚未
2629
+ * 验证 data URL 是否被上游接受(SPIKE 待办),但**转发它不会更糟**,而拦掉它会让
2630
+ * 本地图片彻底不可用;故放行并在档案里留待验证。
2631
+ * @param {unknown} value - 原始参数。
2632
+ * @returns {string|null} 规范化后的 URL,不可用时为 `null`。
2633
+ */
2634
+ function normalizeImageUrlInput(value) {
2635
+ const raw = typeof value === "string" ? value.trim() : "";
2636
+ if (raw === "") return null;
2637
+ if (/^https?:\/\//i.test(raw)) return raw;
2638
+ if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(raw)) return raw;
2639
+ return null;
2640
+ }
2641
+ /**
2642
+ * 把一次失败转成 agent 与面板都能读的一句话。
2643
+ *
2644
+ * **关于魔搭 `FAILED` 的诚实边界**:实测唯一的失败样本是
2645
+ * `{"errors":{"code":0,"message":""}, "task_status":"FAILED"}`——**原因不可知**。
2646
+ * 所以这里只报「可确定的形态事实」(HTTP 码 / 轮询未出终态 / 明确的原因文本),
2647
+ * **不从空错误里编造原因**,并在原因确实拿不到时明说「上游未给出原因」。这是本仓库
2648
+ * README「诚实声明」的要求。
2649
+ *
2650
+ * 凭据永不入消息:body 文本可能回显 `ms-` 令牌,过 `redactSecrets` 后再截断。
2651
+ * @param {object} failure - 失败信息。
2652
+ * @param {number} [failure.status] - HTTP 状态码(提交/轮询的传输层失败)。
2653
+ * @param {string} [failure.bodyText] - 上游 body 原文。
2654
+ * @param {string} [failure.taskStatus] - 轮询读到的状态。
2655
+ * @param {string} [failure.cause] - 已知原因(超时 / 没有可用图片 / 拿不到 task_id 等)。
2656
+ * @returns {string} 一行说明。
2657
+ */
2658
+ function describeDrawFailure(failure) {
2659
+ const text = redactSecrets(typeof failure.bodyText === "string" ? failure.bodyText : "").slice(0, 300);
2660
+ const status = Number.isFinite(failure.status) ? Number(failure.status) : 0;
2661
+ const tail = text === "" ? "" : `; body: ${text}`;
2662
+ if (status === 401 || status === 403) return `出图失败:HTTP ${status} —— 访问令牌无效、无权访问该模型,或账号未完成邮箱验证/实名(后者是实测遇到过的真实拦截)。请在面板确认令牌与账号状态${tail}`;
2663
+ if (status === 404) return `出图失败:HTTP 404 —— 端点或模型不存在(检查 apiBase 与模型 id)${tail}`;
2664
+ if (status === 429) return `出图失败:HTTP 429 —— 上游限流,请稍后重试${tail}`;
2665
+ if (status >= 500) return `出图失败:HTTP ${status} —— 上游服务异常,稍后重试${tail}`;
2666
+ const cause = str(failure.cause, "");
2667
+ if (cause !== "") return `出图失败:${cause}${tail}`;
2668
+ const taskStatus = str(failure.taskStatus, "");
2669
+ if (taskStatus === "FAILED") return "出图失败:上游任务 FAILED,但未给出失败原因(实测 errors.code=0、message 为空),换模型或稍后重试";
2670
+ if (taskStatus !== "") return `出图失败:任务停在 ${taskStatus}(非终态),已超过截止时间`;
2671
+ return `出图失败:上游未给出可诊断的失败信息${tail}`;
2672
+ }
2673
+ /**
2674
+ * 冷却门:一次失败后的一段时间内拒绝再试。
2675
+ *
2676
+ * 借 sensenova 那份的形状(它也从 `dsh-draw-router` 抄来):出图慢且贵,一次失败
2677
+ * 后立刻重试既容易撞限流又浪费等待窗口。纯闭包,无依赖,可离线测。
2678
+ * @param {number} cooldownMs - 冷却窗口(毫秒)。
2679
+ * @returns {{fail: () => void, remainingMs: () => number, clear: () => void}} 冷却门。
2680
+ */
2681
+ function createDrawCooldown(cooldownMs) {
2682
+ const window = Math.max(0, Math.floor(cooldownMs));
2683
+ let until = 0;
2684
+ return {
2685
+ /** 记一次失败,从此刻起进入冷却。 */
2686
+ fail() {
2687
+ until = Date.now() + window;
2688
+ },
2689
+ /** 剩余冷却毫秒;0 表示可以发起。 */
2690
+ remainingMs() {
2691
+ return Math.max(0, until - Date.now());
2692
+ },
2693
+ /** 清掉冷却(例如面板里手动排障后)。 */
2694
+ clear() {
2695
+ until = 0;
2696
+ }
2697
+ };
2698
+ }
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
+ };
2754
+ }
2755
+
2756
+ //#endregion
2757
+ //#region src/host/draw-runtime.ts
2758
+ /**
2759
+ * 出图的 **peer-dependent 半边**:真正发起请求、驱动异步两段式、注册成 agent 工具。
2760
+ *
2761
+ * 与 `llm-adapter.ts` 同一个处境——import Host 发行的 peer(`@deepseek-ai/dsh-tools`
2762
+ * 的 `defineTool` 由调用方惰性注入),**不能由离线单测套件导入**。纯逻辑全部住在
2763
+ * `draw.ts`(已由 `test/draw.test.mjs` 覆盖 39 组),本文件只做「接线 + I/O」:
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
+ * 这个套件之后已经过时。
2772
+ *
2773
+ * 与姊妹插件的关键差异(形状不兼容,不能照抄):
2774
+ * `sensenova` 的出图是**同步单发**——一次 `fetch` 拿 `data[0].url` 就结束。魔搭是
2775
+ * **异步两段式**:提交拿 `task_id`,再轮询到终态取 `output_images[0]`。所以这里多
2776
+ * 一个 `pollTask` 循环,且它必须带 `X-ModelScope-Task-Type`(漏了 = 假失败)。
2777
+ *
2778
+ * 三条承重纪律:
2779
+ * 1. 提交响应的 `task_status` **一律忽略**(实测恒为 SUCCEED,连最终 FAILED 的那次
2780
+ * 也是),成功与否只看轮询终态。`readTaskId` 已把这条钉在纯逻辑层。
2781
+ * 2. 轮询间隔 15s(`DRAW_POLL_INTERVAL_MS`),**不是**社区常见的 5s——实测更密的
2782
+ * 轮询只与更慢相关。
2783
+ * 3. 失败原因实测可能**不可知**(`errors.code:0` + 空 message)。`describeDrawFailure`
2784
+ * 因此明说「未给出原因」,本文件也**不从空错误里编造**。
2785
+ *
2786
+ * @module dsh-connect-modelscope-token-plan/draw-runtime
2787
+ */
2788
+ /** agent 工具名。带插件前缀,避免与 `dsh-draw-router` 的 `draw_image` 撞名。 */
2789
+ const DRAW_TOOL_NAME = "modelscope_draw_image";
2790
+ /**
2791
+ * 已知能**纯文生图**的目录内模型 id(2026-10-16 真机复核)。
2792
+ *
2793
+ * 为什么需要手工策展:api-inference 目录里至今**没有**平台声明的 `text-to-image`
2794
+ * 条目,只有 2 条 `image-to-image`——而两者行为**不同**:
2795
+ * `Qwen/Qwen-Image-Edit` 不带 `image_url` 纯文生图可用(本轮实测:提交成功、两次
2796
+ * 15min 排队超时后于 9.7min 出图);`MusePublic/Qwen-Image-Edit` 纯文生图被上游
2797
+ * 直接拒绝(提交阶段 HTTP 4xx:`image_url is required for model: …`)。
2798
+ * 能力枚举把两者都归为 `image-to-image`,区分不了这个差异,所以这里用实测知识兜底:
2799
+ * 目录里没有 `text-to-image` 可挑时,默认偏向这个已知能用的 id,而不是「目录第一个」
2800
+ * (真实目录顺序 `MusePublic/…` 在前,照旧逻辑纯文生图必挂)。
2801
+ */
2802
+ const KNOWN_T2I_MODEL_ID = "Qwen/Qwen-Image-Edit";
2803
+ /** 一次失败的冷却窗:出图慢,失败后立刻重试既易撞限流又白等。 */
2804
+ const DRAW_FAILURE_COOLDOWN_MS = 6e4;
2805
+ /**
2806
+ * 把 OpenAI 兼容 base 拼成出图端点。
2807
+ *
2808
+ * `…/v1` → `…/v1/images/generations`;已带该后缀则原样;更深路径回卷到 `/v1`。
2809
+ * 与 sensenova 的同名函数同源(那是受控复制:endpoint 拼接与上游是谁无关)。
2810
+ * @param {string} apiBase - 配置里的 apiBase。
2811
+ * @returns {string} 完整端点。
2812
+ */
2813
+ function buildGenerationsEndpoint(apiBase) {
2814
+ const trimmed = str(apiBase, "").replace(/\/+$/, "");
2815
+ if (/\/images\/generations$/.test(trimmed)) return trimmed;
2816
+ if (/\/v1$/.test(trimmed)) return `${trimmed}/images/generations`;
2817
+ if (/\/v1\//.test(trimmed)) return trimmed.replace(/\/v1\/.*$/, "/v1/images/generations");
2818
+ return `${trimmed}/v1/images/generations`;
2819
+ }
2820
+ /**
2821
+ * 拼轮询端点。task_id 上游是 UUID(实测失败态出现过短 id),一律走 path。
2822
+ * @param {string} apiBase - 配置里的 apiBase。
2823
+ * @param {string} taskId - 任务 id。
2824
+ * @returns {string} 完整端点。
2825
+ */
2826
+ function buildTasksEndpoint(apiBase, taskId) {
2827
+ return `${buildGenerationsEndpoint(apiBase).replace(/\/images\/generations$/, "")}/tasks/${encodeURIComponent(taskId)}`;
2828
+ }
2829
+ /**
2830
+ * 一个响应体到底是不是「终态」——读不到就当还没好,继续等。
2831
+ *
2832
+ * 与 `isTerminalStatus` 的分工:那个管状态语义,这个管「响应本身能不能读」。
2833
+ * 上游偶发返回非 JSON(CDN 错误页)时,当作暂时性抖动继续等,而不是判失败——
2834
+ * 实测过一个 task 在正常轮询下始终 SUCCEED,没有理由把一次解析失败升级成终局。
2835
+ * @param {unknown} data - 已解析的 JSON。
2836
+ * @returns {boolean} 是否终态。
2837
+ */
2838
+ function isTerminalPayload(data) {
2839
+ return isTerminalStatus(readTaskStatus(data));
2840
+ }
2841
+ /**
2842
+ * **第一段**:提交一次出图,只要 `task_id`。
2843
+ *
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`。
2850
+ */
2851
+ async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl, size, steps, guidance, seed, negativePrompt }) {
2852
+ const headers = {
2853
+ Authorization: `Bearer ${apiKey}`,
2854
+ "Content-Type": "application/json"
2855
+ };
2856
+ const body = buildDrawBody({
2857
+ model,
2858
+ prompt,
2859
+ ...imageUrl !== void 0 ? { imageUrl } : {},
2860
+ ...size !== void 0 ? { size } : {},
2861
+ ...steps !== void 0 ? { steps } : {},
2862
+ ...guidance !== void 0 ? { guidance } : {},
2863
+ ...seed !== void 0 ? { seed } : {},
2864
+ ...negativePrompt !== void 0 ? { negativePrompt } : {}
2865
+ });
2866
+ let submitted;
2867
+ try {
2868
+ submitted = await fetchImpl(buildGenerationsEndpoint(apiBase), {
2869
+ method: "POST",
2870
+ headers: {
2871
+ ...headers,
2872
+ "X-ModelScope-Async-Mode": "true"
2873
+ },
2874
+ body: JSON.stringify(body)
2875
+ });
2876
+ } catch (error) {
2877
+ throw new Error(describeDrawFailure({ cause: `提交请求失败:${error instanceof Error ? error.message : String(error)}` }));
2878
+ }
2879
+ const submitText = await safeText(submitted);
2880
+ if (!submitted.ok) throw new Error(describeDrawFailure({
2881
+ status: submitted.status,
2882
+ bodyText: submitText
2883
+ }));
2884
+ const taskId = readTaskId(await safeJson(submitted, submitText));
2885
+ if (taskId === null) throw new Error(describeDrawFailure({
2886
+ bodyText: submitText,
2887
+ cause: "提交响应里没有 task_id"
2888
+ }));
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));
2915
+ const interval = Math.max(1, Math.floor(pollIntervalMs));
2916
+ let polls = 0;
2917
+ let lastStatus;
2918
+ for (;;) {
2919
+ if (Date.now() >= deadline) return {
2920
+ kind: "pending",
2921
+ polls
2922
+ };
2923
+ await sleep(interval);
2924
+ polls++;
2925
+ let polled;
2926
+ try {
2927
+ polled = await fetchImpl(buildTasksEndpoint(apiBase, taskId), {
2928
+ method: "GET",
2929
+ headers: {
2930
+ ...headers,
2931
+ [TASK_TYPE_HEADER]: TASK_TYPE_IMAGE_GENERATION
2932
+ }
2933
+ });
2934
+ } catch {
2935
+ continue;
2936
+ }
2937
+ const pollText = await safeText(polled);
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
+ };
2946
+ const data = await safeJson(polled, pollText);
2947
+ const status = readTaskStatus(data);
2948
+ if (status !== null) lastStatus = status;
2949
+ if (!isTerminalPayload(data)) continue;
2950
+ if (status === "FAILED") return {
2951
+ kind: "failed",
2952
+ message: describeDrawFailure({
2953
+ taskStatus: "FAILED",
2954
+ bodyText: pollText
2955
+ })
2956
+ };
2957
+ const images = readOutputImages(data);
2958
+ if (images.length === 0) return {
2959
+ kind: "failed",
2960
+ message: describeDrawFailure({
2961
+ taskStatus: "SUCCEED",
2962
+ bodyText: pollText,
2963
+ cause: "任务报 SUCCEED 但没有可用图片"
2964
+ })
2965
+ };
2966
+ return {
2967
+ kind: "done",
2968
+ images,
2969
+ polls
2970
+ };
2971
+ }
2972
+ }
2973
+ /**
2974
+ * 从目录里挑一个出图模型。
2975
+ *
2976
+ * 优先级(高 → 低):
2977
+ * 1. **工具显式请求**——即使不在目录里也放行(手工覆盖,上游会自己报错);
2978
+ * 2. **首选模型**(面板保存的模型偏好,或配置 `drawModelId`)——用户点了谁就是
2979
+ * 谁,同样不校验在不在目录(用户可能先配好、等模型上线)。此前这个偏好只在
2980
+ * `pickDrawModel` 返回 null 时才生效,目录非空时等于死配置——面板显示的
2981
+ * preferredModel 与实际挑中的模型会说两套话;
2982
+ * 3. 目录里**平台声明**为 `text-to-image` 的条目(`textToImageModelIds`,将来目录
2983
+ * 补上真文生图模型时自动优先生效);
2984
+ * 4. **已知能纯文生图**的模型(`KNOWN_T2I_MODEL_ID`)——今天目录的坑位,见该
2985
+ * 常量的注释:真实目录里它是第二个,照旧取「第一个」会挑中纯文生图必挂的
2986
+ * `MusePublic/…`;
2987
+ * 5. 目录里第一个出图模型(`isImageGenModel` 的严格方向:只认明确声明,缺字段
2988
+ * 不算)。
2989
+ *
2990
+ * 目录为空且既无显式请求也无首选模型 → `null`,由调用方给出可操作的错误,
2991
+ * 而不是发一个注定失败的请求。
2992
+ * @param {unknown} entries - 归一目录条目。
2993
+ * @param {string} [requested] - 工具调用里显式给的 model。
2994
+ * @param {string} [preferred] - 首选模型 id(面板偏好或配置 `drawModelId`)。
2995
+ * @returns {string|null} 选中的 id。
2996
+ */
2997
+ function pickDrawModel(entries, requested, preferred) {
2998
+ const want = str(requested, "");
2999
+ if (want !== "") return want;
3000
+ const preferredId = str(preferred, "");
3001
+ if (preferredId !== "") return preferredId;
3002
+ const ids = imageGenModelIds(entries);
3003
+ if (ids.length === 0) return null;
3004
+ const t2i = textToImageModelIds(entries);
3005
+ if (t2i.length > 0) return t2i[0] ?? null;
3006
+ if (ids.includes("Qwen/Qwen-Image-Edit")) return KNOWN_T2I_MODEL_ID;
3007
+ return ids[0] ?? null;
3008
+ }
3009
+ /**
3010
+ * 目录里可用于出图的模型 id(工具结果里回显,方便 agent 与用户看到实际候选)。
3011
+ * @param {unknown} entries - 归一目录条目。
3012
+ * @returns {string[]} id 列表。
3013
+ */
3014
+ function drawModelIds(entries) {
3015
+ return imageGenModelIds(entries);
3016
+ }
3017
+ /**
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
+ * 越过它才报「放弃」。
3033
+ *
3034
+ * ## 两个刻意的判定
3035
+ *
3036
+ * - `pending` **不是失败**:不抛错、不进冷却门。它只表示「这次调用愿意等的时间里
3037
+ * 没等到」,进冷却就会把续查这条路堵死——那正是分段查询的目的所在。
3038
+ * - 只有**真失败**(提交失败 / 上游 `FAILED` / 越过总放弃线)才 `cooldown.fail()`。
3039
+ *
3040
+ * 工具仍只做「参数归一 → 跑一次 → 组装结果文本」,所有判定都委托给 `draw.ts` 的纯函数。
3041
+ * @param {object} options - 装配参数。
3042
+ * @param {(definition: object) => unknown} options.defineTool - peer 的工厂。
3043
+ * @param {DrawFetch} options.fetchImpl - 注入的 fetch。
3044
+ * @param {() => Promise<string>} options.resolveApiKey - 每次调用现读令牌。
3045
+ * @param {() => unknown|Promise<unknown>} [options.getEntries] - 目录条目读取器。
3046
+ * @param {object} [options.settings] - `{ apiBase, drawModelId, drawTimeoutMs, drawWaitMs }`。
3047
+ * @param {ReturnType<typeof createDrawCooldown>} [options.cooldown] - 冷却门。
3048
+ * @param {DrawJobs} [options.jobs] - 作业表(可注入,便于测试)。
3049
+ * @param {() => boolean} [options.isDisposed] - 卸载闸。
3050
+ * @returns {unknown} 已定义的 agent 工具。
3051
+ */
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
+ /** 总放弃线:从提交算起,跨多次调用累计。 */
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)));
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
+ };
3115
+ return defineTool({
3116
+ name: DRAW_TOOL_NAME,
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.",
3118
+ parameters: {
3119
+ prompt: {
3120
+ type: "string",
3121
+ required: true,
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)"
3127
+ },
3128
+ model: {
3129
+ type: "string",
3130
+ description: "ModelScope image model id; defaults to the catalogue's image model"
3131
+ },
3132
+ imageUrl: {
3133
+ type: "string",
3134
+ description: "Input image URL for editing (public http(s) or data:image/...;base64). For multi-image editing pass several URLs separated by newlines — order is significant."
3135
+ },
3136
+ size: {
3137
+ type: "string",
3138
+ description: "Image size as WIDTHxHEIGHT, e.g. 1024x1024 (64-aligned)"
3139
+ },
3140
+ steps: {
3141
+ type: "number",
3142
+ description: "Sampling steps (platform range 1-100)"
3143
+ },
3144
+ guidance: {
3145
+ type: "number",
3146
+ description: "Prompt guidance scale (platform range 1.5-20)"
3147
+ },
3148
+ seed: {
3149
+ type: "number",
3150
+ description: "Random seed"
3151
+ },
3152
+ negativePrompt: {
3153
+ type: "string",
3154
+ description: "Negative prompt"
3155
+ }
3156
+ },
3157
+ output: {
3158
+ schema: {
3159
+ type: "object",
3160
+ additionalProperties: false,
3161
+ properties: {
3162
+ source: { type: "string" },
3163
+ status: { type: "string" },
3164
+ jobId: { type: "string" },
3165
+ model: { type: "string" },
3166
+ url: { type: "string" },
3167
+ prompt: { type: "string" },
3168
+ hint: { type: "string" }
3169
+ }
3170
+ },
3171
+ render: (_args, result) => [{
3172
+ type: "text",
3173
+ text: result?.hint || "图片已生成"
3174
+ }]
3175
+ },
3176
+ timeoutMs: waitMs + 1e4,
3177
+ async execute(params) {
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
+ }
3192
+ const prompt = str(params?.prompt, "");
3193
+ if (prompt === "") throw new Error("prompt is required");
3194
+ const remaining = cooldown.remainingMs();
3195
+ if (remaining > 0) throw new Error(`draw cooldown: 上一次出图失败,${Math.ceil(remaining / 1e3)}s 后再试`);
3196
+ let picked = [];
3197
+ try {
3198
+ picked = await getEntries?.() ?? [];
3199
+ } catch {
3200
+ picked = [];
3201
+ }
3202
+ const entries = Array.isArray(picked) ? picked : [];
3203
+ const configuredPreferred = str(settings?.drawModelId, "");
3204
+ const panelPreferred = typeof getPreferredModelId === "function" ? await getPreferredModelId().catch(() => null) : null;
3205
+ const preferred = typeof panelPreferred === "string" && panelPreferred.trim() !== "" ? panelPreferred : configuredPreferred;
3206
+ const model = pickDrawModel(entries, str(params?.model, ""), preferred);
3207
+ if (model === null) throw new Error("目录里没有出图模型(image-to-image / text-to-image 条目为空):确认令牌已配置、面板已至少轮询一次,且套餐含该模型");
3208
+ const rawImages = splitImageUrls(params?.imageUrl);
3209
+ const images = [];
3210
+ for (const raw of rawImages) {
3211
+ const normalized = normalizeImageUrlInput(raw);
3212
+ if (normalized === null) throw new Error(`imageUrl 不可用:${raw.slice(0, 80)} —— 只接受公网 http(s) 链接或 data:image/...;base64 内联数据(本地文件上游取不到,请先上传)`);
3213
+ images.push(normalized);
3214
+ }
3215
+ try {
3216
+ const size = str(params?.size, "");
3217
+ const steps = toPositiveInt(params?.steps);
3218
+ const guidance = toPositiveNumber(params?.guidance);
3219
+ const seed = toNonNegativeInt(params?.seed);
3220
+ const negativePrompt = str(params?.negativePrompt, "");
3221
+ const { taskId } = await submitDrawTask({
3222
+ fetchImpl,
3223
+ apiBase,
3224
+ apiKey,
3225
+ model,
3226
+ prompt,
3227
+ ...images.length > 0 ? { imageUrl: images } : {},
3228
+ ...size !== "" ? { size } : {},
3229
+ ...steps !== void 0 ? { steps } : {},
3230
+ ...guidance !== void 0 ? { guidance } : {},
3231
+ ...seed !== void 0 ? { seed } : {},
3232
+ ...negativePrompt !== "" ? { negativePrompt } : {}
3233
+ });
3234
+ const job = jobs.create({
3235
+ taskId,
3236
+ model,
3237
+ prompt
3238
+ });
3239
+ return await waitAndReport(apiKey, job, drawModelIds(entries));
3240
+ } catch (error) {
3241
+ cooldown.fail();
3242
+ throw error;
3243
+ }
3244
+ }
3245
+ });
3246
+ }
3247
+ /**
3248
+ * 把工具参数里的输入图拆成列表。
3249
+ *
3250
+ * 官方支持多图(数组形状),而 agent 工具的参数类型是标量,所以约定**用换行分隔**
3251
+ * 多张。同时容忍调用方直接传数组(内部调用与测试会这么用)。
3252
+ * @param {unknown} value - 原始参数。
3253
+ * @returns {string[]} 拆好的 URL(未归一,调用方逐项过闸)。
3254
+ */
3255
+ function splitImageUrls(value) {
3256
+ if (Array.isArray(value)) return value.map((v) => String(v)).filter((v) => v.trim() !== "");
3257
+ const raw = str(value, "");
3258
+ if (raw === "") return [];
3259
+ return raw.split(/\r?\n/).map((v) => v.trim()).filter((v) => v !== "");
3260
+ }
3261
+ /** 读正整数,非法返回 undefined(不下发无意义参数)。 */
3262
+ function toPositiveInt(value) {
3263
+ const n = Number(value);
3264
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : void 0;
3265
+ }
3266
+ /** 读非负整数(seed 可为 0)。 */
3267
+ function toNonNegativeInt(value) {
3268
+ const n = Number(value);
3269
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : void 0;
3270
+ }
3271
+ /** 读正浮点(guidance 可为小数)。 */
3272
+ function toPositiveNumber(value) {
3273
+ const n = Number(value);
3274
+ return Number.isFinite(n) && n > 0 ? n : void 0;
3275
+ }
3276
+ /** 读 body 文本,读不到给空串(错误分诊不能因为读 body 失败而失败)。 */
3277
+ async function safeText(response) {
3278
+ try {
3279
+ return await response.text();
3280
+ } catch {
3281
+ return "";
3282
+ }
3283
+ }
3284
+ /** 解析 JSON;读不到就返回 `null`,由调用方按「非终态/无 id」处理。 */
3285
+ async function safeJson(response, text) {
3286
+ try {
3287
+ return await response.json();
3288
+ } catch {
3289
+ try {
3290
+ return text === "" ? null : JSON.parse(text);
3291
+ } catch {
3292
+ return null;
3293
+ }
3294
+ }
3295
+ }
3296
+ /** 可注入的 sleep(测试可覆写;这里只提供默认实现)。 */
3297
+ function sleep(ms) {
3298
+ return new Promise((resolve) => setTimeout(resolve, ms));
3299
+ }
3300
+
3301
+ //#endregion
3302
+ //#region src/host/draw-tool-state.ts
3303
+ /** 当前缺席原因(`null` = 未登记 / 已清掉)。 */
3304
+ let note = null;
3305
+ /**
3306
+ * 记下缺席原因(`null` 清空)。
3307
+ * @param {DrawToolAbsentReason|null} reason - 原因码。
3308
+ * @returns {void}
3309
+ */
3310
+ function setDrawToolNote(reason) {
3311
+ note = reason;
3312
+ }
3313
+ /**
3314
+ * 读当前缺席原因。
3315
+ * @returns {DrawToolAbsentReason|null} 原因码。
3316
+ */
3317
+ function drawToolNote() {
3318
+ return note;
3319
+ }
3320
+ /**
3321
+ * 清空(卸载时调用:下一个 Host 生命周期从干净的「未登记」开始)。
3322
+ * @returns {void}
3323
+ */
3324
+ function resetDrawToolNote() {
3325
+ note = null;
3326
+ }
3327
+
3328
+ //#endregion
3329
+ //#region src/host/switch-precedence.ts
3330
+ /**
3331
+ * 所有 opt-in 开关共用的唯一裁决:「面板保存值 vs 配置默认值」。
3332
+ *
3333
+ * 规则只有一条:**面板保存值赢,否则配置默认值说了算**。来源标签(`panel` /
3334
+ * `config`)随答案一起返回,面板才能说出是谁在管。规则收在一处,是为了让开关
3335
+ * 再也无法各自发明一套优先级方言;`test/switch-precedence.test.mjs` 钉死这个
3336
+ * 方言,照抄形状的新调用方会被它标成异类。
3337
+ *
3338
+ * 不服务「纯面板拥有、无配置默认值」的开关——那种没有可裁决的配置默认值,
3339
+ * 调用方直接读即可。peer-free,纯函数。
3340
+ *
3341
+ * @module dsh-connect-modelscope-token-plan/switch-precedence
3342
+ */
3343
+ /**
3344
+ * Resolve the effective boolean switch: a panel-saved value always wins,
3345
+ * otherwise the config default rules.
3346
+ * @param {boolean|null} panel - the panel-saved value (`null` = never saved).
3347
+ * @param {boolean} config - the patch-declared default.
3348
+ * @returns {boolean} the effective switch.
3349
+ */
3350
+ function resolveSwitchEnabled(panel, config) {
3351
+ return (panel ?? config) === true;
3352
+ }
3353
+ /**
3354
+ * Resolve the effective string preference (e.g. a draw-model id): a
3355
+ * panel-saved value wins, otherwise the config default rules.
3356
+ * @param {string|null} panel - the panel-saved value (`null` = never saved).
3357
+ * @param {string|undefined} config - the patch-declared default.
3358
+ * @returns {string|undefined} the effective preference.
3359
+ */
3360
+ function resolveSwitchValue(panel, config) {
3361
+ return panel ?? config;
3362
+ }
3363
+ /**
3364
+ * Where the effective value came from — the panel when it saved one, the
3365
+ * config otherwise. Rides with every resolved answer so the panel can name
3366
+ * the side in charge.
1716
3367
  * @param {boolean|string|null} panel - the panel-saved value.
1717
3368
  * @returns {"panel"|"config"}
1718
3369
  */
@@ -1720,6 +3371,116 @@ function switchSource(panel) {
1720
3371
  return panel === null ? "config" : "panel";
1721
3372
  }
1722
3373
 
3374
+ //#endregion
3375
+ //#region src/host/draw-register.ts
3376
+ /**
3377
+ * 出图工具的挂载副作用:读开关 → 解析目录 → 注册 agent 工具。
3378
+ *
3379
+ * 与 `provider-publish.ts` 的分工:那个管「魔搭作为 chat provider 的注册」,本文件
3380
+ * 管「给 agent 一个出图工具」。两者是**独立的开关**——回答的是不同问题,合并没有
3381
+ * 好处(用户可能想用工具出图但不想把魔搭塞进模型选择器,反之亦然)。
3382
+ *
3383
+ * 承重约束:**peer 的 tools registry 没有 unregister**。所以
3384
+ * - 注册只在挂载时尝试**一次**,不在任何轮询/路由里重复调用;
3385
+ * - 开关在挂载后翻转,要到下次 Host 启动才生效(面板文案必须说这句,否则用户
3386
+ * 会以为开关坏了);
3387
+ * - 目录变化**不需要**重注册:工具在**调用时**才读目录(`getEntries` 是个函数),
3388
+ * 所以一次目录刷新会自然被下一次出图看到。
3389
+ *
3390
+ * 失败一律降级为「工具缺席」,绝不炸掉插件:tools 服务缺失、peer 加载失败、
3391
+ * registry 拒收——三种都由 `draw-tool-state.ts` 记下原因供面板显示。本文件不吞
3392
+ * 错误(那会让「工具为什么不在」无从诊断),只把它们翻译成可展示的原因码。
3393
+ *
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 之后已经失效。
3401
+ *
3402
+ * @module dsh-connect-modelscope-token-plan/draw-register
3403
+ */
3404
+ /**
3405
+ * 解析一个可选服务,允许它稍后才注册。
3406
+ *
3407
+ * 承 sensenova 同名函数的纪律:**重试的是读,不是注册**——工具不能注销,重复注册
3408
+ * 比缺席更糟。这里只做有限次立即读取,不做长退避(服务要么已注册,要么这次挂载
3409
+ * 就没有)。
3410
+ * @param {object} ctx - Host 上下文。
3411
+ * @param {string} service - 服务名。
3412
+ * @param {() => boolean} isDisposed - 卸载闸。
3413
+ * @returns {Promise<object|null>} 服务实例或 `null`。
3414
+ */
3415
+ async function resolveService(ctx, service, isDisposed) {
3416
+ for (let attempt = 0; attempt < 3; attempt++) {
3417
+ if (isDisposed()) return null;
3418
+ const found = ctx.get?.(service);
3419
+ if (found !== null && found !== void 0) return found;
3420
+ if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 50));
3421
+ }
3422
+ return null;
3423
+ }
3424
+ /**
3425
+ * 读一次开关,决定是否注册出图工具。
3426
+ * @param {object} ctx - Host 上下文。
3427
+ * @param {object} wiring - `{ settings, configError, tokenStore, inference, providerStore, publisher, logger }`。
3428
+ * @param {object} deps - 测试缝(`loadToolsModule`、`drawFetch`)。
3429
+ * @returns {Promise<void>}
3430
+ */
3431
+ async function registerDrawTool(ctx, wiring, deps = {}) {
3432
+ const { settings, configError, tokenStore, inference, drawStore, publisher, logger } = wiring;
3433
+ const loggerFor = logger ?? ctx.logger;
3434
+ setDrawToolNote(null);
3435
+ try {
3436
+ if (configError !== null) return;
3437
+ const panelValue = drawStore ? await drawStore.enabled().catch(() => null) : null;
3438
+ if (resolveSwitchEnabled(panelValue, settings.drawEnabled === true) !== true) return;
3439
+ const isDisposed = () => publisher?.isDisposed?.() === true;
3440
+ const tools = await resolveService(ctx, "tools", isDisposed);
3441
+ if (tools === null || typeof tools.register !== "function") {
3442
+ setDrawToolNote("no-tools-service");
3443
+ return;
3444
+ }
3445
+ let defineTool = null;
3446
+ try {
3447
+ const mod = await Promise.resolve(deps.loadToolsModule ? deps.loadToolsModule() : import("@deepseek-ai/dsh-tools"));
3448
+ defineTool = mod?.defineTool ?? mod?.default?.defineTool ?? null;
3449
+ } catch (error) {
3450
+ setDrawToolNote("peer-load-failed");
3451
+ loggerFor?.warn?.(`${name}: draw tool peer module failed to load (${redactSecrets(errMsg(error))}) — the tool is absent`);
3452
+ return;
3453
+ }
3454
+ if (typeof defineTool !== "function") {
3455
+ setDrawToolNote("peer-load-failed");
3456
+ return;
3457
+ }
3458
+ const drawFetch = deps.drawFetch ?? ((url, options) => fetch(url, options));
3459
+ tools.register(defineDrawTool({
3460
+ defineTool,
3461
+ fetchImpl: drawFetch,
3462
+ resolveApiKey: async () => (await tokenStore.resolve()).value,
3463
+ getEntries: async () => {
3464
+ return (await inference.fetchModels().catch(() => null))?.entries ?? [];
3465
+ },
3466
+ getPreferredModelId: async () => {
3467
+ const panelValue = drawStore ? await drawStore.modelId().catch(() => null) : null;
3468
+ return resolveSwitchValue(panelValue, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
3469
+ },
3470
+ settings: {
3471
+ apiBase: settings.apiBase,
3472
+ drawModelId: settings.drawModelId,
3473
+ drawTimeoutMs: settings.drawTimeoutMs,
3474
+ drawWaitMs: settings.drawWaitMs
3475
+ },
3476
+ isDisposed
3477
+ }));
3478
+ } catch (error) {
3479
+ setDrawToolNote("registry-refused");
3480
+ loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets(errMsg(error))}) — the tool is absent`);
3481
+ }
3482
+ }
3483
+
1723
3484
  //#endregion
1724
3485
  //#region src/host/publish-core.ts
1725
3486
  /**
@@ -1857,6 +3618,15 @@ function registerProviderPair(llm, built, target, { providerId, displayName }) {
1857
3618
  *
1858
3619
  * The module is loaded once and the factory is read off it once, so a Host
1859
3620
  * whose peers resolve slowly pays that cost one time, not per publish.
3621
+ *
3622
+ * Only the SUCCESS is memoized. The memoized value used to be the promise
3623
+ * itself, which pinned a rejection in the slot for the life of the process:
3624
+ * after one failed `import()` every later publish rethrew the same error and
3625
+ * the provider could never register again — even after the operator fixed the
3626
+ * missing peer, which is exactly the remedy this file's own
3627
+ * `describeBuildFailure` prints for `ERR_MODULE_NOT_FOUND`. A recoverable
3628
+ * install problem cost a Host restart. Clearing the slot on failure keeps
3629
+ * "loaded once" for the happy path and gives the next publish a real retry.
1860
3630
  * @param {() => Promise<object>} loadModule - resolves the adapter module.
1861
3631
  * @param {string} exportName - the factory export to read off the module.
1862
3632
  * @returns {() => Promise<Function>} the memoized factory resolver.
@@ -1864,7 +3634,10 @@ function registerProviderPair(llm, built, target, { providerId, displayName }) {
1864
3634
  function createAdapterFactoryResolver(loadModule, exportName) {
1865
3635
  let adapterFactoryPromise;
1866
3636
  return async () => {
1867
- if (adapterFactoryPromise === void 0) adapterFactoryPromise = Promise.resolve(loadModule()).then((mod) => mod?.[exportName]);
3637
+ if (adapterFactoryPromise === void 0) adapterFactoryPromise = Promise.resolve(loadModule()).then((mod) => mod?.[exportName]).catch((error) => {
3638
+ adapterFactoryPromise = void 0;
3639
+ throw error;
3640
+ });
1868
3641
  return adapterFactoryPromise;
1869
3642
  };
1870
3643
  }
@@ -1917,6 +3690,13 @@ function warnBuildFailure(logger, label, described) {
1917
3690
  * fact about the Host — not about the provider — so it is one copy: a Host
1918
3691
  * with no `llm` service gets no registration and a stated reason, never a
1919
3692
  * half-built one.
3693
+ *
3694
+ * The absence routes through {@link unregister}, not a hand-rolled
3695
+ * `release(); registered = false`: `state.built` must go down with it. A
3696
+ * stale `built` survives into the NEXT publish as its rollback target, so a
3697
+ * later failed publish would re-register an adapter whose release has already
3698
+ * been called — a rollback path running against a dead pair, which is the
3699
+ * worst possible moment to discover the divergence.
1920
3700
  * @param {object} job
1921
3701
  * @param {object} job.state - the publisher state.
1922
3702
  * @param {(service: string) => object|null} job.getLlm - the service resolver.
@@ -1927,9 +3707,11 @@ function resolveRegistrationService({ state, getLlm, release }) {
1927
3707
  const llm = getLlm("llm");
1928
3708
  state.llmAvailable = llm !== null && typeof llm.registerAdapter === "function";
1929
3709
  if (!state.llmAvailable) {
1930
- release();
1931
- state.registered = false;
1932
- state.error = NO_LLM_SERVICE_ERROR;
3710
+ unregister({
3711
+ state,
3712
+ release,
3713
+ error: NO_LLM_SERVICE_ERROR
3714
+ });
1933
3715
  return null;
1934
3716
  }
1935
3717
  return llm;
@@ -1993,15 +3775,13 @@ function swapRegistration({ llm, built, previousBuilt, state, release, registerP
1993
3775
  state.built = null;
1994
3776
  onRollback?.();
1995
3777
  state.error = redactSecrets(errMsg(error));
1996
- if (previousBuilt !== null) try {
1997
- registerPair(llm, previousBuilt, state);
1998
- state.built = previousBuilt;
1999
- state.registered = true;
2000
- } catch {
2001
- state.built = null;
2002
- state.registered = false;
2003
- }
2004
- else state.registered = false;
3778
+ restorePreviousPair({
3779
+ llm,
3780
+ previousBuilt,
3781
+ state,
3782
+ registerPair,
3783
+ release
3784
+ });
2005
3785
  return {
2006
3786
  ok: false,
2007
3787
  error
@@ -2014,6 +3794,30 @@ function swapRegistration({ llm, built, previousBuilt, state, release, registerP
2014
3794
  return { ok: true };
2015
3795
  }
2016
3796
  /**
3797
+ * 注册失败后恢复先前在服务的 provider 对(如果有)。
3798
+ *
3799
+ * 从 {@link swapRegistration} 提取,把 catch 块里的嵌套从 4 层降到 2 层——
3800
+ * 原实现里 `registerPair(llm, previousBuilt, ...)` 住在 try 内、try 住在
3801
+ * catch 内,读起来要数四层缩进才能确认它在做什么。行为完全不变:先摘掉
3802
+ * 新对(`release()` 已在调用方做过),再尝试挂回旧对;旧对也注册不上就
3803
+ * 诚实地标 `registered: false`。
3804
+ */
3805
+ function restorePreviousPair({ llm, previousBuilt, state, registerPair, release }) {
3806
+ if (previousBuilt === null) {
3807
+ state.registered = false;
3808
+ return;
3809
+ }
3810
+ try {
3811
+ registerPair(llm, previousBuilt, state);
3812
+ state.built = previousBuilt;
3813
+ state.registered = true;
3814
+ } catch {
3815
+ release();
3816
+ state.built = null;
3817
+ state.registered = false;
3818
+ }
3819
+ }
3820
+ /**
2017
3821
  * Emit the adapter-update event, tolerating a Host that has no `emit` and a
2018
3822
  * Host whose `emit` refuses.
2019
3823
  *
@@ -2049,9 +3853,20 @@ function emitAdaptersUpdated(emit) {
2049
3853
  * - 单点 `registerPair`(带 factory-await + shape 检查)被 publish 与 rollback
2050
3854
  * 两条路共用;失败时**恢复旧对**,坏 publish 不会把已在服务的模型也拉下来。
2051
3855
  *
2052
- * 与姊妹插件的差异**只有**:工厂导出名 `"createModelScopeAdapter"`、`registerPair`
2053
- * 用的 `LLM_PROVIDER_ID`/`LLM_DISPLAY_NAME`、`warnBuildFailure` 的 label
2054
- * `"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% 同源);具体代码不可照搬。
2055
3870
  *
2056
3871
  * peer-free:不 import 任何运行时 peer。适配器工厂由调用方注入
2057
3872
  * (`loadAdapterModule`,默认 `import("./llm-adapter.ts")`),所以离线套件可以换
@@ -2063,6 +3878,29 @@ function emitAdaptersUpdated(emit) {
2063
3878
  * @module dsh-connect-modelscope-token-plan/provider-publish
2064
3879
  */
2065
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
+ /**
2066
3904
  * provider 发布器。
2067
3905
  *
2068
3906
  * 持有活注册状态(`state`);publish 队列、`disposed` 闸与单点 `registerPair` 是
@@ -2086,7 +3924,7 @@ function createProviderPublisher(deps = {}) {
2086
3924
  const { settings, panelSwitch, loadAdapterModule, getLlm, resolveApiKey, usage, emit, logger } = deps;
2087
3925
  const effectiveSettings = settings ?? {};
2088
3926
  const effectivePanelSwitch = panelSwitch ?? (async () => null);
2089
- const effectiveLoadAdapterModule = loadAdapterModule ?? (() => import("./llm-adapter-DnaNNORe.js"));
3927
+ const effectiveLoadAdapterModule = loadAdapterModule ?? (() => import("./llm-adapter.js"));
2090
3928
  const effectiveGetLlm = getLlm ?? (() => null);
2091
3929
  const effectiveResolveApiKey = resolveApiKey ?? (async () => "");
2092
3930
  const effectiveLogger = logger ?? { warn: () => {} };
@@ -2134,20 +3972,46 @@ function createProviderPublisher(deps = {}) {
2134
3972
  displayName: LLM_DISPLAY_NAME
2135
3973
  });
2136
3974
  /**
2137
- * 为一个目录/允许清单快照(重)构建并注册 provider。
3975
+ * 空转守卫的公共判据:目录 offer 与额度耗尽集合是否都没变。
2138
3976
  *
2139
- * 重建并重注册而非原地改:`PiAiAdapter` 内部 memoize profiles 快照,只有新的注册
2140
- * 才能改变提供的模型列表。注册失败时**恢复上一对**,所以坏 publish 永远不能把
2141
- * 已在服务的模型拉下来。
2142
- * @param {object[]} entries - 归一目录条目。
2143
- * @param {string[]} enabledIds - 允许清单(空 = 全部)。
2144
- * @param {string[]} [unavailableModelIds] - 要从 picker 的 offer 里丢掉的额度耗尽 id。
2145
- * @returns {Promise<{ok: boolean, skipped?: boolean, error?: unknown}>}
3977
+ * 两处空转守卫(开关关着 / 开关开着)共用这个签名比对——公式与轮询同源
3978
+ * (由 {@link syncSignaturesAfterPublish} 内部调用导出的 {@link catalogSignature}
3979
+ * 与 {@link quotaSignatureOf}),不会漂移。提取成函数避免两处各写一遍。
3980
+ * @returns {boolean} true = offer 没变,可以跳过这次 publish。
2146
3981
  */
2147
- const publishProviderOnce = async (entries, enabledIds, unavailableModelIds = []) => {
2148
- if (queue.isDisposed()) return {
2149
- ok: false,
2150
- skipped: true
3982
+ const offerUnchanged = () => state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds);
3983
+ /**
3984
+ * 发布相位——两道空转守卫的判断条件从此有名字(推导见 {@link publisherPhaseOf})。
3985
+ * @param {boolean} disposed - 当前是否已 dispose。
3986
+ * @returns {PublisherPhase}
3987
+ */
3988
+ const phaseOf = (disposed) => publisherPhaseOf(state, disposed);
3989
+ /**
3990
+ * gather 阶段:一次 publish 的**可挂起**半边。
3991
+ *
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>} 执行计划。
4007
+ */
4008
+ const gatherPublish = async (entries, enabledIds, unavailableModelIds) => {
4009
+ if (queue.isDisposed()) return {
4010
+ kind: "done",
4011
+ result: {
4012
+ ok: false,
4013
+ skipped: true
4014
+ }
2151
4015
  };
2152
4016
  const previousBuilt = state.built;
2153
4017
  const previousEntries = state.entries;
@@ -2158,16 +4022,22 @@ function createProviderPublisher(deps = {}) {
2158
4022
  state.unavailableIds = Array.isArray(unavailableModelIds) ? unavailableModelIds : [];
2159
4023
  const panelValue = await effectivePanelSwitch().catch(() => null);
2160
4024
  if (!resolveSwitchEnabled(panelValue, effectiveSettings.registerProvider === true)) {
2161
- if (state.registered === false && state.error === null && state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds)) return {
2162
- ok: true,
2163
- skipped: true
4025
+ if (phaseOf(false) === "idle" && offerUnchanged()) return {
4026
+ kind: "done",
4027
+ result: {
4028
+ ok: true,
4029
+ skipped: true
4030
+ }
2164
4031
  };
2165
4032
  const result = unregister({
2166
4033
  state,
2167
4034
  release
2168
4035
  });
2169
4036
  syncSignaturesAfterPublish(state);
2170
- return result;
4037
+ return {
4038
+ kind: "done",
4039
+ result
4040
+ };
2171
4041
  }
2172
4042
  const llm = resolveRegistrationService({
2173
4043
  state,
@@ -2175,13 +4045,15 @@ function createProviderPublisher(deps = {}) {
2175
4045
  release
2176
4046
  });
2177
4047
  if (llm === null) return {
2178
- ok: false,
2179
- error: state.error
4048
+ kind: "done",
4049
+ result: {
4050
+ ok: false,
4051
+ error: state.error
4052
+ }
2180
4053
  };
2181
- let createModelScopeAdapter;
2182
4054
  let built;
2183
4055
  try {
2184
- createModelScopeAdapter = await resolveAdapterFactory();
4056
+ const createModelScopeAdapter = await resolveAdapterFactory();
2185
4057
  if (createModelScopeAdapter === void 0) throw new Error(BAD_FACTORY_SHAPE_ERROR);
2186
4058
  built = await createModelScopeAdapter({
2187
4059
  entries: state.entries,
@@ -2198,36 +4070,98 @@ function createProviderPublisher(deps = {}) {
2198
4070
  state.error = described.note;
2199
4071
  warnBuildFailure(effectiveLogger, "ModelScope", described);
2200
4072
  return {
2201
- ok: false,
2202
- error: described.error
4073
+ kind: "done",
4074
+ result: {
4075
+ ok: false,
4076
+ error: described.error
4077
+ }
2203
4078
  };
2204
4079
  }
2205
- if (state.registered === true && state.error === null && state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds)) return {
2206
- ok: true,
2207
- 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
2208
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) => {
2209
4125
  if (queue.isDisposed()) return {
2210
4126
  ok: false,
2211
4127
  skipped: true
2212
4128
  };
4129
+ const requestedSignature = catalogSignature(state.entries, state.enabledIds);
2213
4130
  const result = swapRegistration({
2214
- llm,
2215
- built,
2216
- previousBuilt,
4131
+ llm: plan.llm,
4132
+ built: plan.built,
4133
+ previousBuilt: plan.previousBuilt,
2217
4134
  state,
2218
4135
  release,
2219
4136
  registerPair,
2220
4137
  emit,
2221
4138
  onRollback: () => {
2222
- state.entries = previousEntries;
2223
- state.enabledIds = previousEnabledIds;
2224
- state.unavailableIds = previousUnavailable;
4139
+ state.entries = plan.previousEntries;
4140
+ state.enabledIds = plan.previousEnabledIds;
4141
+ state.unavailableIds = plan.previousUnavailable;
2225
4142
  }
2226
4143
  });
2227
4144
  syncSignaturesAfterPublish(state);
4145
+ if (result.ok === false && state.registered === true && state.signature === requestedSignature) state.error = null;
2228
4146
  return result;
2229
4147
  };
2230
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
+ /**
2231
4165
  * publish,排到所有其他在途 publish 之后。
2232
4166
  *
2233
4167
  * 包装的存在是为了没有任何调用方要记得队列:挂载 seed、目录轮询、provider 开关、
@@ -2301,11 +4235,134 @@ function syncSignaturesAfterPublish(state) {
2301
4235
  state.quotaSignature = quotaSignatureOf(unavailable);
2302
4236
  }
2303
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
+
2304
4359
  //#endregion
2305
4360
  //#region src/host/routes/paths.ts
2306
4361
  /**
2307
4362
  * 路由字面量。浏览器 bundle(src/client/const.ts)无法从 host-config 导入,
2308
- * 两边各写一份字面量——由 test/config.test.mjs 钉住相等,改名时先红再改。
4363
+ * 两边各写一份字面量——由 test/panel.test.mjs 的 §6/§6b 钉住两端解析出的完整
4364
+ * 路径集合双向相等,改名时先红再改。(原注释指向 test/config.test.mjs,但那套
4365
+ * 钉的是 CONFIG_DEFAULTS ↔ cordis.patch.yml,不含路由字面量。)
2309
4366
  * @module dsh-connect-modelscope-token-plan/routes/paths
2310
4367
  */
2311
4368
  const SNAPSHOT_PATH = "/api/dsh-connect-modelscope-token-plan/snapshot";
@@ -2314,6 +4371,7 @@ const TOKEN_PATH = "/api/dsh-connect-modelscope-token-plan/token";
2314
4371
  const TOKEN_FORGET_PATH = "/api/dsh-connect-modelscope-token-plan/token/forget";
2315
4372
  const PROBE_PATH = "/api/dsh-connect-modelscope-token-plan/probe";
2316
4373
  const PROVIDER_PATH = "/api/dsh-connect-modelscope-token-plan/provider";
4374
+ const DRAW_PATH = "/api/dsh-connect-modelscope-token-plan/draw";
2317
4375
 
2318
4376
  //#endregion
2319
4377
  //#region src/host/snapshot-aggregate.ts
@@ -2357,61 +4415,47 @@ async function soft(promise) {
2357
4415
  };
2358
4416
  }
2359
4417
  }
2360
- /**
2361
- * Provider 状态块降级形状:读不到开关/注册状态/目录时,面板仍然得到一个完整
2362
- * 可渲染的块(开关关、roster 空、error 说明原因),绝不让整条快照失败。
2363
- */
2364
- function providerDegraded(error) {
2365
- return {
2366
- enabled: false,
2367
- source: "config",
2368
- llmAvailable: false,
2369
- registered: false,
2370
- error,
2371
- modelCount: 0,
2372
- enabledCount: 0,
2373
- allowed: "all",
2374
- enabledIds: [],
2375
- roster: []
2376
- };
2377
- }
2378
4418
  /** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
2379
4419
  async function buildSnapshotBody(wiring) {
2380
- const { settings, tokenStore, usageStore, inference, providerStore, publisher } = wiring;
2381
- const [tokenState, daily, perModel, trend, events] = await Promise.all([
4420
+ const { settings, tokenStore, usageStore, inference, providerStore, drawStore, publisher } = wiring;
4421
+ const [tokenState, daily, perModel, trend, events, providerNote] = await Promise.all([
2382
4422
  soft(tokenStore.state()),
2383
4423
  soft(usageStore.daily()),
2384
4424
  soft(usageStore.perModelToday()),
2385
4425
  soft(usageStore.trend(settings.trendDays)),
2386
- soft(usageStore.events())
4426
+ soft(usageStore.events()),
4427
+ soft(providerStore.versionNote())
2387
4428
  ]);
2388
4429
  const shapeWarnings = [];
2389
4430
  const usageAnomaly = usageStore.anomaly();
2390
4431
  if (usageAnomaly !== null) shapeWarnings.push(usageAnomaly);
2391
- for (const path of [
2392
- tokenState,
2393
- daily,
2394
- perModel,
2395
- trend,
2396
- events
2397
- ]) if (!path.ok) shapeWarnings.push(`usage-source failed (${path.code}): ${path.error}`);
2398
- const tokenPresent = tokenState.ok ? tokenState.value.present : false;
2399
- const balance = await soft(inference.fetchBalance());
2400
- const models = await soft(inference.fetchModels());
4432
+ for (const [label, path] of [
4433
+ ["token", tokenState],
4434
+ ["daily", daily],
4435
+ ["per-model", perModel],
4436
+ ["trend", trend],
4437
+ ["events", events]
4438
+ ]) if (!path.ok) shapeWarnings.push(`${label} source failed (${path.code}): ${path.error}`);
4439
+ const tokenKnownAbsent = tokenState.ok && tokenState.value.present === false;
4440
+ const contextOverride = await providerStore.contextWindow().catch(() => null);
4441
+ const [balance, models] = await Promise.all([soft(inference.fetchBalance()), soft(inference.fetchModels(contextOverride))]);
2401
4442
  const modelIds = models.ok ? models.value.ids : [];
2402
4443
  const modelEntries = models.ok ? models.value.entries : [];
2403
4444
  if (!models.ok && models.code !== "network_error") shapeWarnings.push(`models: ${models.error}`);
2404
- if (tokenPresent && !balance.ok && balance.code !== "network_error") shapeWarnings.push(`balance: ${balance.error}`);
4445
+ if (!tokenKnownAbsent && !balance.ok && balance.code !== "network_error") shapeWarnings.push(`balance: ${balance.error}`);
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);
2405
4449
  const provider = await soft((async () => {
2406
4450
  const panel = await providerStore.enabled().catch(() => null);
2407
4451
  const enabled = resolveSwitchEnabled(panel, settings.registerProvider);
2408
4452
  const source = switchSource(panel);
2409
4453
  const roster = rosterWithAvailability(modelEntries, []);
2410
4454
  const stored = await providerStore.enabledIds().catch(() => null);
2411
- const enabledIds = Array.isArray(stored) ? stored : Array.isArray(publisher.state.enabledIds) ? publisher.state.enabledIds : [];
2412
- const allowed = resolveAllowedList(enabledIds);
2413
- const allowSet = new Set(enabledIds);
2414
- const enabledCount = allowed === "all" ? roster.length : allowed === "list" ? roster.filter((row) => allowSet.has(row.id)).length : 0;
4455
+ const enabledIds = resolveEnabledIds(stored, publisher.state.enabledIds);
4456
+ const allowedList = parseAllowedList(enabledIds);
4457
+ const enabledCount = enabledCountOf(allowedList, roster.map((row) => row.id));
4458
+ const contextWindow = await providerStore.contextWindow().catch(() => null);
2415
4459
  return {
2416
4460
  enabled,
2417
4461
  source,
@@ -2420,12 +4464,31 @@ async function buildSnapshotBody(wiring) {
2420
4464
  error: typeof publisher.state.error === "string" ? publisher.state.error : null,
2421
4465
  modelCount: roster.length,
2422
4466
  enabledCount,
2423
- allowed,
4467
+ allowed: allowedList.mode,
2424
4468
  enabledIds,
4469
+ contextWindow,
2425
4470
  roster
2426
4471
  };
2427
4472
  })());
2428
- const usedLocal = daily.ok ? daily.value.calls : 0;
4473
+ const draw = await soft((async () => {
4474
+ const panel = await drawStore.enabled().catch(() => null);
4475
+ const panelModel = await drawStore.modelId().catch(() => null);
4476
+ const candidateIds = imageGenModelIds(modelEntries);
4477
+ return {
4478
+ enabled: resolveSwitchEnabled(panel, settings.drawEnabled),
4479
+ source: switchSource(panel),
4480
+ candidateIds,
4481
+ candidateCount: candidateIds.length,
4482
+ preferredModel: resolveSwitchValue(panelModel, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null,
4483
+ preferredSource: switchSource(panelModel),
4484
+ absentReason: drawToolNote(),
4485
+ mountPending: resolveSwitchEnabled(panel, settings.drawEnabled) === true && drawToolNote() !== null,
4486
+ toolName: "modelscope_draw_image"
4487
+ };
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}`);
4491
+ const usedLocal = daily.ok ? daily.value.calls : null;
2429
4492
  return {
2430
4493
  ok: true,
2431
4494
  name,
@@ -2433,12 +4496,16 @@ async function buildSnapshotBody(wiring) {
2433
4496
  now: (/* @__PURE__ */ new Date()).toISOString(),
2434
4497
  pollSeconds: settings.pollSeconds,
2435
4498
  cacheSeconds: settings.cacheSeconds,
2436
- token: tokenState.ok ? tokenState.value : {
4499
+ token: tokenState.ok ? {
4500
+ ...tokenState.value,
4501
+ readError: null
4502
+ } : {
2437
4503
  present: false,
2438
4504
  source: "none",
2439
4505
  valid: null,
2440
4506
  checkedAt: null,
2441
- ephemeral: true
4507
+ ephemeral: true,
4508
+ readError: tokenState.error
2442
4509
  },
2443
4510
  balance: balance.ok ? {
2444
4511
  available: balance.value.available,
@@ -2451,7 +4518,7 @@ async function buildSnapshotBody(wiring) {
2451
4518
  total: null,
2452
4519
  frozen: null,
2453
4520
  fetchedAt: null,
2454
- error: tokenPresent ? balance.error : null
4521
+ error: tokenKnownAbsent ? null : balance.error
2455
4522
  },
2456
4523
  quota: {
2457
4524
  daily: { usedLocal },
@@ -2469,7 +4536,8 @@ async function buildSnapshotBody(wiring) {
2469
4536
  sample: modelIds.slice(0, 20),
2470
4537
  error: models.ok ? null : models.error
2471
4538
  },
2472
- provider: provider.ok ? provider.value : providerDegraded(provider.error),
4539
+ ...provider.ok ? { provider: provider.value } : {},
4540
+ ...draw.ok ? { draw: draw.value } : {},
2473
4541
  shapeWarnings,
2474
4542
  quotaError: null
2475
4543
  };
@@ -2570,6 +4638,22 @@ function redactedError(error) {
2570
4638
  const text = redactSecrets(errMsg(error));
2571
4639
  return text.trim() === "" ? "request failed" : text;
2572
4640
  }
4641
+ /**
4642
+ * 写盘/操作失败的统一响应:HTTP 恒 200、成败看 body(与快照路由同一语义,
4643
+ * 一次写失败不许顺着渲染树炸穿)。**用户显式操作的失败必须上屏**,所以
4644
+ * 调用方不吞、这里只负责脱敏后的标准形状。provider 开关/清单/reset 与
4645
+ * token save/forget 五处 catch 曾各写一遍这个 writeJson——集中成一份,
4646
+ * 措辞与形状不会漂移。
4647
+ * @param {object} response - webServer 的响应对象。
4648
+ * @param {unknown} error - catch 到的任意值(未必是 Error)。
4649
+ * @returns {void}
4650
+ */
4651
+ function respondError(response, error) {
4652
+ writeJson(response, 200, {
4653
+ ok: false,
4654
+ error: redactedError(error)
4655
+ }, { "cache-control": "no-store" });
4656
+ }
2573
4657
  /** 读并校验 JSON body,失败时写 400 并返回 null(调用方的停手信号)。 */
2574
4658
  async function readJsonBodyOr400(request, response, limit = MAX_JSON_BODY_BYTES) {
2575
4659
  const body = await readJsonBody(request, limit);
@@ -2702,7 +4786,14 @@ function registerModelsRoute(ctx, wiring) {
2702
4786
  * 简化(SPIKE.md §结论 3)。
2703
4787
  * @module dsh-connect-modelscope-token-plan/routes/token
2704
4788
  */
2705
- /** 注册令牌路由(POST /token 保存,POST /token/forget 忘掉)。 */
4789
+ /**
4790
+ * 注册令牌路由(POST /token 保存,POST /token/forget 忘掉)。
4791
+ *
4792
+ * 返回类型是本族唯一的一个**数组**:token 注册两条路由(save + forget),其余
4793
+ * 路由族各只注册一条、返回单个 off()。标注在**这里**而不是在 routes.ts 里对
4794
+ * 调用结果做 `as Array<() => void>` 断言——断言放在消费端等于把「这两条返回的是
4795
+ * 注销回调而不是 handler」这个约定藏起来,标注在产地才是声明。
4796
+ */
2706
4797
  function registerTokenRoute(ctx, wiring) {
2707
4798
  const { settings, tokenStore } = wiring;
2708
4799
  const save = withOrigin(async (request, response) => {
@@ -2727,10 +4818,7 @@ function registerTokenRoute(ctx, wiring) {
2727
4818
  ...await tokenStore.state()
2728
4819
  }, { "cache-control": "no-store" });
2729
4820
  } catch (error) {
2730
- writeJson(response, 200, {
2731
- ok: false,
2732
- error: redactedError(error)
2733
- }, { "cache-control": "no-store" });
4821
+ respondError(response, error);
2734
4822
  }
2735
4823
  }, settings.allowedHosts);
2736
4824
  const forget = withOrigin(async (request, response) => {
@@ -2745,10 +4833,7 @@ function registerTokenRoute(ctx, wiring) {
2745
4833
  ...await tokenStore.state()
2746
4834
  }, { "cache-control": "no-store" });
2747
4835
  } catch (error) {
2748
- writeJson(response, 200, {
2749
- ok: false,
2750
- error: redactedError(error)
2751
- }, { "cache-control": "no-store" });
4836
+ respondError(response, error);
2752
4837
  }
2753
4838
  }, settings.allowedHosts);
2754
4839
  return [ctx.webServer.register({
@@ -2807,13 +4892,15 @@ function registerProbeRoute(ctx, wiring) {
2807
4892
  }
2808
4893
  try {
2809
4894
  const result = await inference.probe({ modelId });
4895
+ wiring.tokenStore.noteValidity(true);
2810
4896
  writeJson(response, 200, {
2811
4897
  ...result,
2812
- tokenState: await wiring.tokenStore.state()
4898
+ tokenState: await wiring.tokenStore.state().catch(() => null)
2813
4899
  }, { "cache-control": "no-store" });
2814
4900
  } catch (error) {
2815
4901
  const code = error.code;
2816
4902
  const message = redactedError(error);
4903
+ if (code === CODE.AUTH_ERROR) wiring.tokenStore.noteValidity(false);
2817
4904
  const eventKind = code === CODE.QUOTA_EXCEEDED ? "quota" : code === CODE.RATE_LIMITED ? "rate_limit" : "error";
2818
4905
  await usageStore.recordEvent({
2819
4906
  modelId,
@@ -2837,13 +4924,15 @@ function registerProbeRoute(ctx, wiring) {
2837
4924
  //#endregion
2838
4925
  //#region src/host/routes/provider.ts
2839
4926
  /**
2840
- * provider 注册路由:开关读/写、允许清单保存、回退默认。
4927
+ * provider 注册路由:开关读/写、允许清单保存、上下文窗口覆盖、回退默认。
2841
4928
  *
2842
- * 三件事(§9):
2843
- * - GET `PROVIDER_PATH` 读当前开关 + 目录 + 允许清单 + 注册状态(无副作用);
2844
- * - POST `PROVIDER_PATH` body `{enabled?: boolean}` → 保存面板开关并重注册;
2845
- * - POST `PROVIDER_PATH/roster` body `{enabledIds?: string[]}` → 保存允许清单并重注册;
2846
- * - POST `PROVIDER_PATH/reset` 忘掉面板开关与允许清单,回到 patch 默认。
4929
+ * 四件事(§9):
4930
+ * - GET `PROVIDER_PATH` 读当前开关 + 目录 + 允许清单 + 注册状态(无副作用);
4931
+ * - POST `PROVIDER_PATH` body `{enabled?: boolean}` → 保存面板开关并重注册;
4932
+ * - POST `PROVIDER_PATH/roster` body `{enabledIds?: string[]}` → 保存允许清单并重注册;
4933
+ * - POST `PROVIDER_PATH/context` body `{contextWindow: number|null}` → 保存全局上下文
4934
+ * 窗口覆盖并重注册(null = 跟随目录);
4935
+ * - POST `PROVIDER_PATH/reset` 忘掉面板开关、允许清单与窗口覆盖,回到 patch 默认。
2847
4936
  *
2848
4937
  * `withOrigin` 围栏与 `refuseMethod` 照现有路由;写开关/清单失败**必须传播**
2849
4938
  * (`ok:false` + error),不静默吞——这是用户的显式操作,面板必须看见它没生效。
@@ -2853,9 +4942,16 @@ function registerProbeRoute(ctx, wiring) {
2853
4942
  */
2854
4943
  /**
2855
4944
  * 读目录条目,读不到按空目录降级(GET 不能因目录失败把整个面板状态打掉)。
4945
+ *
4946
+ * 读目录前先取**当前窗口覆盖**传给 `fetchModels`:条目在归一前打上覆盖标记,
4947
+ * descriptor / roster / catalogSignature 都从这份条目派生。每个写路由(开关、
4948
+ * 清单、窗口、reset)都经这里取条目再 publish,所以任何一次重注册都带着最新
4949
+ * 覆盖——否则先存窗口、再存清单会把未打标的目录重新注册上去,覆盖静默失效到
4950
+ * 下一轮轮询。
2856
4951
  */
2857
- async function readEntries(inference) {
2858
- const catalog = await inference.fetchModels().catch((error) => {
4952
+ async function readEntries$1(inference, providerStore) {
4953
+ const contextWindow = await optional(providerStore?.contextWindow?.(), null);
4954
+ const catalog = await inference.fetchModels(contextWindow).catch((error) => {
2859
4955
  return {
2860
4956
  entries: [],
2861
4957
  __error: error
@@ -2865,25 +4961,20 @@ async function readEntries(inference) {
2865
4961
  }
2866
4962
  /**
2867
4963
  * 读「当前」允许清单:优先持久化的,否则 publisher 最近 publish 的那份。
4964
+ *
4965
+ * 判据是**共用**的 resolveEnabledIds(provider-store)——本文件与面板聚合、
4966
+ * index.ts 的轮询必须对「空清单」给同一个答案,否则三个界面各说一套。
2868
4967
  */
2869
4968
  async function readEnabledIds(store, published) {
2870
- const stored = await optional(store.enabledIds?.(), null);
2871
- if (Array.isArray(stored)) return stored;
2872
- return Array.isArray(published) ? published : [];
4969
+ return resolveEnabledIds(await optional(store.enabledIds?.(), null), published);
2873
4970
  }
2874
4971
  /**
2875
- * `allowed` 的分类:委托给 `llm-models.ts` 的单一口径
2876
- * {@link resolveAllowedList}(fix D:快照与 provider 路由以前各写一套分类,会漂移)。
2877
- *
2878
- * 旧版 `allowedOf` 只认「恰好哨兵」为 `"none"`,`["__hide_all__","other"]` 会被错判成
2879
- * `"list"`;统一到 `resolveAllowedList` 后,含哨兵即 `"none"`,与 `filterByEnabled` /
2880
- * `isModelEnabled` 口径一致。
2881
- * @param {string[]} enabledIds - 允许清单。
2882
- * @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` 同一份解释)。
2883
4977
  */
2884
- function allowedOf(enabledIds) {
2885
- return resolveAllowedList(enabledIds);
2886
- }
2887
4978
  /**
2888
4979
  * 注册 provider 路由(三条路径)。wiring 子集:settings / providerStore /
2889
4980
  * publisher / inference / logger。
@@ -2892,7 +4983,7 @@ function allowedOf(enabledIds) {
2892
4983
  * @returns {Function} 依次调用三个 `off()` 的注销回调。
2893
4984
  */
2894
4985
  function registerProviderRoute(ctx, wiring) {
2895
- const { settings, providerStore, publisher, inference, logger } = wiring;
4986
+ const { settings, providerStore, publisher, reconcile, inference, logger } = wiring;
2896
4987
  /**
2897
4988
  * 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
2898
4989
  * 状态是什么」说两套话。
@@ -2902,11 +4993,11 @@ function registerProviderRoute(ctx, wiring) {
2902
4993
  const snapshot = async (extra = {}) => {
2903
4994
  const panelSwitch = await optional(providerStore.enabled());
2904
4995
  const enabledIds = await readEnabledIds(providerStore, publisher.state.enabledIds);
2905
- const entries = await readEntries(inference);
4996
+ const contextWindow = await optional(providerStore.contextWindow?.(), null);
4997
+ const entries = await readEntries$1(inference, providerStore);
2906
4998
  const roster = rosterWithAvailability(entries, []);
2907
- const allowed = allowedOf(enabledIds);
4999
+ const allowedList = parseAllowedList(enabledIds);
2908
5000
  const modelCount = roster.length;
2909
- const allowSet = new Set(enabledIds);
2910
5001
  return {
2911
5002
  ok: true,
2912
5003
  enabled: resolveSwitchEnabled(panelSwitch, settings.registerProvider),
@@ -2915,9 +5006,10 @@ function registerProviderRoute(ctx, wiring) {
2915
5006
  registered: publisher.state.registered === true,
2916
5007
  error: typeof publisher.state.error === "string" ? publisher.state.error : null,
2917
5008
  modelCount,
2918
- enabledCount: allowed === "all" ? modelCount : allowed === "none" ? 0 : roster.filter((row) => allowSet.has(row.id)).length,
2919
- allowed,
5009
+ enabledCount: enabledCountOf(allowedList, roster.map((row) => row.id)),
5010
+ allowed: allowedList.mode,
2920
5011
  enabledIds,
5012
+ contextWindow,
2921
5013
  roster,
2922
5014
  ...extra
2923
5015
  };
@@ -2947,13 +5039,9 @@ function registerProviderRoute(ctx, wiring) {
2947
5039
  }
2948
5040
  try {
2949
5041
  await providerStore.save(body.enabled);
2950
- const entries = await readEntries(inference);
2951
- await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
5042
+ await reconcile();
2952
5043
  } catch (error) {
2953
- writeJson(response, 200, {
2954
- ok: false,
2955
- error: redactedError(error)
2956
- }, { "cache-control": "no-store" });
5044
+ respondError(response, error);
2957
5045
  return;
2958
5046
  }
2959
5047
  writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
@@ -2979,15 +5067,38 @@ function registerProviderRoute(ctx, wiring) {
2979
5067
  const ids = body.enabledIds.filter((id) => typeof id === "string" && id !== "");
2980
5068
  try {
2981
5069
  await providerStore.saveEnabledIds?.(ids);
2982
- const entries = await readEntries(inference);
2983
- await publisher.publish(entries, ids, []);
5070
+ await reconcile();
2984
5071
  } catch (error) {
2985
- writeJson(response, 200, {
5072
+ respondError(response, error);
5073
+ return;
5074
+ }
5075
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5076
+ }, settings.allowedHosts)
5077
+ }));
5078
+ offs.push(ctx.webServer.register({
5079
+ kind: "exact",
5080
+ path: `${PROVIDER_PATH}/context`,
5081
+ handler: withOrigin(async (request, response) => {
5082
+ if ((request.method === void 0 ? "POST" : request.method) !== "POST") {
5083
+ refuseMethod(response);
5084
+ return;
5085
+ }
5086
+ const body = await readJsonBodyOr400(request, response);
5087
+ if (body === null) return;
5088
+ if (body.contextWindow !== null && (typeof body.contextWindow !== "number" || !Number.isFinite(body.contextWindow) || body.contextWindow <= 0)) {
5089
+ writeJson(response, 400, {
2986
5090
  ok: false,
2987
- error: redactedError(error)
5091
+ error: "expected { contextWindow: number | null }"
2988
5092
  }, { "cache-control": "no-store" });
2989
5093
  return;
2990
5094
  }
5095
+ try {
5096
+ await providerStore.saveContextWindow?.(body.contextWindow);
5097
+ await reconcile();
5098
+ } catch (error) {
5099
+ respondError(response, error);
5100
+ return;
5101
+ }
2991
5102
  writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
2992
5103
  }, settings.allowedHosts)
2993
5104
  }));
@@ -3006,15 +5117,165 @@ function registerProviderRoute(ctx, wiring) {
3006
5117
  try {
3007
5118
  await providerStore.forget();
3008
5119
  await providerStore.saveEnabledIds?.([]);
3009
- const entries = await readEntries(inference);
3010
- await publisher.publish(entries, [], []);
5120
+ await reconcile();
3011
5121
  } catch (error) {
3012
- writeJson(response, 200, {
5122
+ respondError(response, error);
5123
+ return;
5124
+ }
5125
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5126
+ }, settings.allowedHosts)
5127
+ }));
5128
+ return () => {
5129
+ for (const off of offs) try {
5130
+ off();
5131
+ } catch (error) {
5132
+ logger?.warn?.(`provider route unregister failed: ${redactedError(error)}`);
5133
+ }
5134
+ };
5135
+ }
5136
+
5137
+ //#endregion
5138
+ //#region src/host/routes/draw.ts
5139
+ /**
5140
+ * 出图工具路由:开关读/写、模型偏好保存、回到默认。
5141
+ *
5142
+ * 三条路径:
5143
+ * - GET `DRAW_PATH` 读开关 + 出图候选模型 + 工具挂载状态(无副作用);
5144
+ * - POST `DRAW_PATH` body `{enabled?: boolean, modelId?: string|null}` → 保存;
5145
+ * - POST `DRAW_PATH/reset` 忘掉面板值,回到 patch 默认。
5146
+ *
5147
+ * **与 provider 路由刻意分开**:provider 管「魔搭作为 chat provider」,本路由管
5148
+ * 「给 agent 一个出图工具」。两者的开关、状态文件、候选集合都不同,合并会让
5149
+ * 「关掉出图」波及 provider 的注册时序(详见 draw-store.ts 的头注)。
5150
+ *
5151
+ * 一处**与 provider 路由不同**的地方必须说清:provider 开关翻转会**立即**重注册
5152
+ * (publish 是活的重建),而出图工具的开关翻转**要等下次 Host 启动**——peer 的
5153
+ * tools registry 没有 unregister,工具一旦注册就摘不下来。所以本路由保存成功后
5154
+ * 不改挂载状态,只在响应里给出 `mountPending`,让面板能说清这件事,而不是让用户
5155
+ * 以为开关坏了。
5156
+ *
5157
+ * peer-free:不 import 任何 Host peer。
5158
+ * @module dsh-connect-modelscope-token-plan/routes/draw
5159
+ */
5160
+ /**
5161
+ * 读目录条目,读不到按空目录降级。
5162
+ *
5163
+ * 与 provider 路由同一条纪律:GET 不能因为一次目录失败就把整个面板状态打掉——
5164
+ * 出图候选为空只影响「能不能挑模型」,不影响开关的显示。
5165
+ * @param {object} inference - inference client。
5166
+ * @returns {Promise<unknown[]>} 归一目录条目。
5167
+ */
5168
+ async function readEntries(inference) {
5169
+ const catalog = await inference.fetchModels().catch(() => ({ entries: [] }));
5170
+ return Array.isArray(catalog?.entries) ? catalog.entries : [];
5171
+ }
5172
+ /**
5173
+ * 注册出图路由(两条路径)。
5174
+ * @param ctx - host 根上下文(只用 `ctx.webServer`)。
5175
+ * @param {DrawRouteWiring} wiring - 本路由读到的子集。
5176
+ * @returns {Function} 注销回调。
5177
+ */
5178
+ function registerDrawRoute(ctx, wiring) {
5179
+ const { settings, drawStore, inference, logger } = wiring;
5180
+ /**
5181
+ * 组装响应。GET 与写成功后的响应共用,两个方向不可能对「当前状态」说两套话。
5182
+ * @param {object} [extra] - 追加/覆盖字段。
5183
+ * @returns {Promise<object>} 响应体。
5184
+ */
5185
+ const snapshot = async (extra = {}) => {
5186
+ const panelSwitch = await optional(drawStore.enabled());
5187
+ const panelModelId = await optional(drawStore.modelId());
5188
+ const entries = await readEntries(inference);
5189
+ const candidateIds = imageGenModelIds(entries);
5190
+ const effectiveModelId = resolveSwitchValue(panelModelId, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
5191
+ return {
5192
+ ok: true,
5193
+ enabled: resolveSwitchEnabled(panelSwitch, settings.drawEnabled),
5194
+ source: switchSource(panelSwitch),
5195
+ candidateIds,
5196
+ candidateCount: candidateIds.length,
5197
+ preferredModel: effectiveModelId,
5198
+ preferredSource: switchSource(panelModelId),
5199
+ absentReason: drawToolNote(),
5200
+ /**
5201
+ * 开关的保存**不会**改变本次挂载的工具。
5202
+ *
5203
+ * 工具无法注销(peer 的 tools registry 没有 unregister),所以开关翻转只在
5204
+ * 下次 Host 启动时生效。面板据此显示一句说明——否则用户会以为开关坏了。
5205
+ * 判据:开关为开且本次挂载尚未注册成功(或缺席原因未知)。
5206
+ */
5207
+ mountPending: resolveSwitchEnabled(panelSwitch, settings.drawEnabled) === true && drawToolNote() !== null,
5208
+ toolName: "modelscope_draw_image",
5209
+ ...extra
5210
+ };
5211
+ };
5212
+ const offs = [];
5213
+ offs.push(ctx.webServer.register({
5214
+ kind: "exact",
5215
+ path: DRAW_PATH,
5216
+ handler: withOrigin(async (request, response) => {
5217
+ const method = request.method === void 0 ? "GET" : request.method;
5218
+ if (method === "GET") {
5219
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5220
+ return;
5221
+ }
5222
+ if (method !== "POST") {
5223
+ refuseMethod(response);
5224
+ return;
5225
+ }
5226
+ const body = await readJsonBodyOr400(request, response);
5227
+ if (body === null) return;
5228
+ const hasEnabled = typeof body.enabled === "boolean";
5229
+ const hasModel = body.modelId === null || typeof body.modelId === "string";
5230
+ if (!hasEnabled && !hasModel) {
5231
+ writeJson(response, 400, {
3013
5232
  ok: false,
3014
- error: redactedError(error)
5233
+ error: "expected { enabled?: boolean, modelId?: string|null }"
5234
+ }, { "cache-control": "no-store" });
5235
+ return;
5236
+ }
5237
+ if (body.enabled !== void 0 && !hasEnabled) {
5238
+ writeJson(response, 400, {
5239
+ ok: false,
5240
+ error: "enabled must be a boolean"
5241
+ }, { "cache-control": "no-store" });
5242
+ return;
5243
+ }
5244
+ if (body.modelId !== void 0 && !hasModel) {
5245
+ writeJson(response, 400, {
5246
+ ok: false,
5247
+ error: "modelId must be a string or null"
3015
5248
  }, { "cache-control": "no-store" });
3016
5249
  return;
3017
5250
  }
5251
+ try {
5252
+ if (hasEnabled) await drawStore.saveEnabled(body.enabled);
5253
+ if (body.modelId !== void 0) await drawStore.saveModelId(body.modelId);
5254
+ } catch (error) {
5255
+ respondError(response, error);
5256
+ return;
5257
+ }
5258
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
5259
+ }, settings.allowedHosts)
5260
+ }));
5261
+ offs.push(ctx.webServer.register({
5262
+ kind: "exact",
5263
+ path: `${DRAW_PATH}/reset`,
5264
+ handler: withOrigin(async (request, response) => {
5265
+ if ((request.method === void 0 ? "POST" : request.method) !== "POST") {
5266
+ refuseMethod(response);
5267
+ return;
5268
+ }
5269
+ await readJsonBody(request, MAX_JSON_BODY_BYTES).catch(() => ({
5270
+ ok: false,
5271
+ error: "ignored"
5272
+ }));
5273
+ try {
5274
+ await drawStore.reset();
5275
+ } catch (error) {
5276
+ respondError(response, error);
5277
+ return;
5278
+ }
3018
5279
  writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
3019
5280
  }, settings.allowedHosts)
3020
5281
  }));
@@ -3022,7 +5283,7 @@ function registerProviderRoute(ctx, wiring) {
3022
5283
  for (const off of offs) try {
3023
5284
  off();
3024
5285
  } catch (error) {
3025
- logger?.warn?.(`provider route unregister failed: ${redactedError(error)}`);
5286
+ logger?.warn?.(`draw route unregister failed: ${redactedError(error)}`);
3026
5287
  }
3027
5288
  };
3028
5289
  }
@@ -3042,7 +5303,8 @@ function registerRoutes(ctx, wiring) {
3042
5303
  registerModelsRoute(ctx, wiring),
3043
5304
  ...registerTokenRoute(ctx, wiring),
3044
5305
  registerProbeRoute(ctx, wiring),
3045
- registerProviderRoute(ctx, wiring)
5306
+ registerProviderRoute(ctx, wiring),
5307
+ registerDrawRoute(ctx, wiring)
3046
5308
  ];
3047
5309
  }
3048
5310
 
@@ -3051,8 +5313,12 @@ function registerRoutes(ctx, wiring) {
3051
5313
  /**
3052
5314
  * dsh-connect-modelscope-token-plan — Host 半边(thin router)。
3053
5315
  *
3054
- * 读魔搭 API-Inference 的**本地用量**(上游没有余额接口,见 docs/SPIKE.md)
3055
- * 并经一条只读 /api 路由喂给 Client 面板。重活都在专注的兄弟模块里,本文件
5316
+ * 读魔搭 API-Inference 的**本地用量**,并把它与官方魔粒余额一起经一条只读 /api
5317
+ * 路由喂给 Client 面板。口径分工:推理响应**不携带**额度头(SPIKE.md §结论 1),但
5318
+ * 官方另有魔粒余额端点(`GET {siteBase}/openapi/v1/magicubes/balance`,SPIKE.md
5319
+ * 文首后记,2026-10-04 实测可用),面板头条读的是它;本文件下的兄弟模块只负责本地
5320
+ * 计数那一半(按模型分布 / 本地趋势 / 429 事件流),回答官方余额答不了的问题。
5321
+ * 重活都在专注的兄弟模块里,本文件
3056
5322
  * 只保留 Cordis 入口(name/inject/apply)、wiring 装配与卸载副作用:
3057
5323
  *
3058
5324
  * - `host-config.ts` 配置契约 + isAdmitted 信任围栏
@@ -3072,11 +5338,16 @@ function registerRoutes(ctx, wiring) {
3072
5338
  * @param ctx - Host 根上下文。
3073
5339
  * @param config - 行的原始 patch 配置(无 schema,未校验;坏配置以
3074
5340
  * configError 经快照呈现,而不是在挂载时炸掉整台插件)。
3075
- * @param deps - 测试缝(fetchImpl)。真实 Loader 不传。
5341
+ * @param deps - 测试缝(`HostDeps`:fetchImpl / getLlm / emit / credentials / env
5342
+ * 与出图那两条)。真实 Loader 不传;**每个缝不传时都必须落回 ctx 或全局默认**,
5343
+ * 产品行为零漂移。声明与接线由 `test/deps-wiring.test.mjs` 双向扫描守着。
3076
5344
  */
3077
5345
  function apply(ctx, config = {}, deps = {}) {
3078
5346
  const { settings, configError } = resolveSettings(config);
3079
- const tokenStore = createTokenStore({ credentials: () => ctx.get("credentials") ?? null });
5347
+ const tokenStore = createTokenStore({
5348
+ credentials: deps.credentials !== void 0 ? deps.credentials : () => ctx.get("credentials") ?? null,
5349
+ env: deps.env
5350
+ });
3080
5351
  const profile = profileSegment(ctx);
3081
5352
  const usageStore = createFileUsageStore({
3082
5353
  name,
@@ -3095,10 +5366,14 @@ function apply(ctx, config = {}, deps = {}) {
3095
5366
  profile,
3096
5367
  logger: ctx.logger
3097
5368
  });
5369
+ const drawStore = createFileDrawStore({
5370
+ profile,
5371
+ logger: ctx.logger
5372
+ });
3098
5373
  const publisher = createProviderPublisher({
3099
5374
  settings,
3100
5375
  panelSwitch: () => providerStore.enabled(),
3101
- getLlm: (service) => ctx.get?.(service) ?? null,
5376
+ getLlm: deps.getLlm ?? ((service) => ctx.get?.(service) ?? null),
3102
5377
  resolveApiKey: async () => (await tokenStore.resolve()).value,
3103
5378
  usage: {
3104
5379
  recordCall: (input) => usageStore.recordCall({
@@ -3107,25 +5382,17 @@ function apply(ctx, config = {}, deps = {}) {
3107
5382
  }).catch(() => {}),
3108
5383
  recordEvent: (input) => usageStore.recordEvent(input).catch(() => {})
3109
5384
  },
3110
- emit: (event) => ctx.emit?.(event),
5385
+ emit: deps.emit ?? ((event) => ctx.emit?.(event)),
5386
+ ...deps.loadAdapterModule ? { loadAdapterModule: deps.loadAdapterModule } : {},
5387
+ logger: ctx.logger
5388
+ });
5389
+ const { reconcile } = createProviderReconciler({
5390
+ settings,
5391
+ providerStore,
5392
+ inference,
5393
+ publisher,
3111
5394
  logger: ctx.logger
3112
5395
  });
3113
- /**
3114
- * 一次目录轮询:拉目录 + 读落盘允许清单,再 publish 当前 offer。
3115
- *
3116
- * 允许清单必须走 `providerStore.enabledIds()`——清单是**落盘**的(provider-store
3117
- * 按 profile 分段持久化),重启后内存里的 `publisher.state.enabledIds` 早已清空,
3118
- * 若从它读,用户勾选的清单会随重启静默丢失(见 fix A)。
3119
- * @returns {Promise<void>}
3120
- */
3121
- const runPoll = async () => {
3122
- try {
3123
- const catalog = await inference.fetchModels();
3124
- const stored = await providerStore.enabledIds().catch(() => null);
3125
- const enabledIds = Array.isArray(stored) && stored.length > 0 ? stored : Array.isArray(publisher.state.enabledIds) ? publisher.state.enabledIds : [];
3126
- await publisher.publish(catalog.entries, enabledIds, []);
3127
- } catch {}
3128
- };
3129
5396
  const wiring = {
3130
5397
  settings,
3131
5398
  configError,
@@ -3133,17 +5400,21 @@ function apply(ctx, config = {}, deps = {}) {
3133
5400
  usageStore,
3134
5401
  inference,
3135
5402
  providerStore,
5403
+ drawStore,
3136
5404
  publisher,
5405
+ reconcile,
3137
5406
  logger: ctx.logger
3138
5407
  };
3139
5408
  const offs = registerRoutes(ctx, wiring);
3140
5409
  ctx.effect(() => {
3141
- runPoll();
5410
+ reconcile();
5411
+ registerDrawTool(ctx, wiring, deps);
3142
5412
  const timer = setInterval(() => {
3143
- runPoll();
5413
+ reconcile();
3144
5414
  }, settings.pollSeconds * 1e3);
3145
5415
  return () => {
3146
5416
  clearInterval(timer);
5417
+ resetDrawToolNote();
3147
5418
  publisher.dispose();
3148
5419
  publisher.release();
3149
5420
  for (const off of offs) try {