dsh-connect-modelscope-token-plan 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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";
1
+ import { C as optional, D as verbatim, E as str, S as obj, T as redactSecrets$1, _ as hasHardQuotaWordingIn, a as resolveSettings, b as degrade, c as LLM_PROVIDER_ID, d as isVisionModel, f as normalizeEntry, h as textToImageModelIds, i as name, m as rosterWithAvailability, n as inject, p as resolveAllowedList, r as isAdmitted, s as LLM_DISPLAY_NAME, t as PLUGIN_VERSION, u as imageGenModelIds, v as looksLikeRateLimit, w as pluginError, x as errMsg } from "./host-config.js";
2
2
  import { join } from "node:path";
3
- import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
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,11 +64,29 @@ 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
  };
@@ -95,6 +113,7 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
95
113
  await service.set(TOKEN_REF, value);
96
114
  memory.set(TOKEN_REF, value);
97
115
  } else memory.set(TOKEN_REF, value);
116
+ lastCheck = null;
98
117
  },
99
118
  /**
100
119
  * 忘掉面板保存的令牌。env 回退**不动**——清面板引用不能删操作者的 .env。
@@ -107,10 +126,26 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
107
126
  */
108
127
  async forget() {
109
128
  memory.delete(TOKEN_REF);
129
+ lastCheck = null;
110
130
  const service = resolveService();
111
131
  if (service !== null && typeof service.unset === "function") await service.unset(TOKEN_REF);
112
132
  },
113
133
  /**
134
+ * 记录一次**零额度探针**的结论(probe 路由是唯一调用方)。
135
+ *
136
+ * 只接已经判出来的结论:`true`(200/400,令牌好)或 `false`(401/403,令牌坏)。
137
+ * 429 / 上游错误 / 配置错误**不调用**——那些回答的是「上游现在忙不忙」或
138
+ * 「这次请求成没成」,不是「这把令牌好不好」,把它们记进 `valid` 就是把限频
139
+ * 说成令牌坏(正是 429 分诊那条修正反对的误判)。不调用即留着上一次的结论,
140
+ * 那是已知的最好答案。
141
+ */
142
+ noteValidity(valid) {
143
+ lastCheck = {
144
+ valid,
145
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
146
+ };
147
+ },
148
+ /**
114
149
  * 解析当前令牌与来源。返回类型显式注解:这是快照 TokenStatus.source
115
150
  * 联合类型的上游,靠推断会把字面量联合拓宽成 string。
116
151
  * @returns {Promise<{value: string, source: ("credentials"|"memory"|"env"|null)}>}
@@ -144,9 +179,11 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
144
179
  },
145
180
  /**
146
181
  * 无秘密的状态描述(快照与面板消费)。
147
- * `valid` 恒为 null:本模块不做有效性断言(零额度探针在 probe 路由,
148
- * 语义已于 2026-10-04 真机回填——判定 401/403 = 令牌坏、200/400 = 令牌好;
149
- * SPIKE.md §结论 5)。
182
+ *
183
+ * `valid` / `checkedAt` 来自 `noteValidity`(零额度探针的结论),**本模块自己
184
+ * 不做有效性断言**——判定规则在 probe 路由(401/403 = 令牌坏、200/400 = 令牌好,
185
+ * 2026-10-04 真机回填;SPIKE.md §结论 5)。没探过就是 `null`,那不是「无效」,
186
+ * 是「没检查过」。
150
187
  *
151
188
  * `ephemeral` 答的是「**手里这个值**重启会不会丢」,不是「有没有凭据服务」
152
189
  * ——wire 的定义是前者。曾经只按「服务缺席」置位,于是两种都说谎:没有服务
@@ -159,8 +196,8 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
159
196
  return {
160
197
  present: source !== null,
161
198
  source: source === null ? "none" : source,
162
- valid: null,
163
- checkedAt: null,
199
+ valid: lastCheck === null ? null : lastCheck.valid,
200
+ checkedAt: lastCheck === null ? null : lastCheck.checkedAt,
164
201
  ephemeral: source === "memory"
165
202
  };
166
203
  }
@@ -170,16 +207,14 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
170
207
  //#endregion
171
208
  //#region src/host/state-store.ts
172
209
  /**
173
- * 状态文件公共原语 —— 把本插件的各 store(usage-store)从
174
- * 各自手写的同一段"版本载荷 + temp 文件 + rename 原子 + 0600 + 损坏即忽略"
175
- * 收敛到这里(家规:peer-free、离线可测)。。
176
- *
177
- * 第二步收敛的是**读缓存**:provider / draw 早有 1s TTL,而 catalog 完全没有
178
- * (进程内永不失效)——同一个「两个进程共享一个 state 目录」的问题修了两个、
179
- * 漏了第三个。现在统一走 {@link createStateReadCache},一个 TTL 三个调用方。
210
+ * 状态文件公共原语。**TL;DR**:本文件只做三件事——`dshHome`/`stateDir`(定位
211
+ * 状态目录)、`profileSegment`(按 profile 分段路径)、以及 {@link createStateReadCache}
212
+ * (统一读缓存)。其余一切遵守下方「行为约定」。
180
213
  *
181
- * peer-free 与四个 store 同纪律:不 import 任何 Host peer,纯 `node:fs`,
182
- * 离线可测(store.test.mjs 直接注入 dir 构造即可)。
214
+ * 把本插件各 store(usage-store / provider / draw)各自手写的同一段
215
+ * "版本载荷 + temp 文件 + rename 原子 + 0600 + 损坏即忽略" 收敛到这里。
216
+ * 家规:peer-free(不 import 任何 Host peer,纯 `node:fs`)、离线可测
217
+ * (store.test.mjs 直接注入 dir 构造即可)。
183
218
  *
184
219
  * 行为约定(与本插件 store 的约定逐一对齐):
185
220
  * - 目录:`$DSH_HOME/state/<name>`——与 Host 自己的目录并列,而不是在
@@ -187,29 +222,32 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
187
222
  * - 写:临时文件(0600,owner-only)→ `rename` 原子落位。**失败抛错**,
188
223
  * 是否吞错是各 store 的语义(usage-store 面对只读 Home 选择吞、
189
224
  * provider 面板开关交给调用方的错误路径),原语不做决定。
190
- * - 临时名:进程 + 时间戳 + 随机 UUID 后缀。固定临时名会让两个 Host 进程的
191
- * 写落到同一路径、互相 `rename` 掉对方写了一半的文件;同一进程同一毫秒的
192
- * 两次异步写也会撞名(`writeFile` 截断覆盖后一次 `rename` 静默丢写),
193
- * 随机后缀让每个 temp 路径唯一,`rename` 原子性借此成立(见 `temporaryOf`)。
194
- * 此前的写法曾只有进程 + 时间戳,同毫秒并发写会静默丢一条。
225
+ * - 临时名:进程 + 时间戳 + 随机 UUID 后缀。随机后缀让每个 temp 路径唯一,
226
+ * `rename` 原子性借此成立(见 `temporaryOf`)。
195
227
  * - 读:缺失、不可读、非 JSON 一律返回 `null`——"损坏即忽略"的方向。是否
196
228
  * 缓存、缓存多久由 {@link createStateReadCache} 决定,不是每个 store 各自的
197
229
  * 即兴实现。
198
230
  *
231
+ * ## 演变记录(历史,非契约)
232
+ * 1. 第一步收敛的是上述「版本载荷 + 临时文件 + 原子落位」;临时名此前只有
233
+ * 进程 + 时间戳,同毫秒并发写会静默丢一条,故加随机后缀。
234
+ * 2. 第二步收敛的是**读缓存**:provider / draw 早有 1s TTL,而 catalog 完全
235
+ * 没有(进程内永不失效)——同一个「两个进程共享一个 state 目录」的问题修了
236
+ * 两个、漏了第三个。现在统一走 {@link createStateReadCache},一个 TTL 三个调用方。
237
+ *
199
238
  * @module dsh-connect-modelscope-token-plan/state-store
200
239
  */
201
240
  /**
202
- * The DSH home: `$DSH_HOME` when the operator exported one, else `~/.dsh`.
203
- * @returns {string} the home directory.
241
+ * DSH 主目录:操作者导出了 `DSH_HOME` 就用它,否则回落到 `~/.dsh`。
242
+ * @returns {string} 主目录。
204
243
  */
205
244
  function dshHome() {
206
245
  return str(process.env.DSH_HOME, join(homedir(), ".dsh"));
207
246
  }
208
247
  /**
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.
248
+ * 本插件存放状态的位置:`$DSH_HOME/state/<name>`。
249
+ * @param {string} name - 插件自己的状态目录名(即 `host-config.ts` 的 `name`)。
250
+ * @returns {string} 目录路径。
213
251
  */
214
252
  function stateDir(name) {
215
253
  return join(dshHome(), "state", name);
@@ -229,9 +267,9 @@ function stateDir(name) {
229
267
  const PROFILE_SEGMENT_MAX = 64;
230
268
  const PROFILE_SEGMENT_RE = /^(?!\.)[A-Za-z0-9._-]+$/;
231
269
  /**
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}.
270
+ * 该字符串能否安全地作为**一个**路径段使用?
271
+ * @param {unknown} value - 候选 profile 名。
272
+ * @returns {boolean} 通过 {@link PROFILE_SEGMENT_RE} 即为 true。
235
273
  */
236
274
  function isProfileSegment(value) {
237
275
  if (typeof value !== "string") return false;
@@ -263,8 +301,8 @@ function isProfileSegment(value) {
263
301
  *
264
302
  * 取到 = 调用方据此分段;取不到 = **退回当前的全局路径**,行为零漂移。
265
303
  *
266
- * @param {object} [ctx] - the Cordis context the Host handed `apply()`.
267
- * @returns {string|null} the profile name, or `null` when unavailable/unsafe.
304
+ * @param {object} [ctx] - Host 交给 `apply()` 的 Cordis context。
305
+ * @returns {string|null} profile 名,取不到或不安全时为 `null`。
268
306
  */
269
307
  function profileSegment(ctx) {
270
308
  if (ctx === null || typeof ctx !== "object") return null;
@@ -510,22 +548,46 @@ async function readStateJson(file) {
510
548
  }
511
549
  }
512
550
  /**
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`.
551
+ * Read the persisted `version` field of one state file.
515
552
  *
516
553
  * 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.
554
+ * about to overwrite. Three outcomes, and the difference between the first two
555
+ * is load-bearing:
556
+ *
557
+ * - `null` — ABSENT (ENOENT), non-JSON, or carrying no numeric `version`.
558
+ * Nothing here the guard could protect, so a write belongs: a corrupt file
559
+ * must stay replaceable, or an operator would be stuck with it forever.
560
+ * - a number — the version on disk. One this build does not recognise was
561
+ * written by a NEWER build and must not be clobbered.
562
+ * - **throws** — the path EXISTS but could not be read (EACCES/EBUSY/EIO, or a
563
+ * directory sitting at the path). The guard must read that as "there is
564
+ * something here and I cannot see it", never as "nothing stored": collapsing
565
+ * it into `null` made the anti-clobber guard fail OPEN, so the very
566
+ * downgrade it exists to prevent came through that seam.
520
567
  *
521
568
  * 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.
569
+ * when the rest of the payload is unparseable, it must not depend on any store's
570
+ * parse semantics, and it must NOT inherit the read path's deliberate "anything
571
+ * unrecognised reads as not set" permissiveness — that permissiveness is right
572
+ * for a reader and wrong for a writer.
524
573
  * @param {string} file - the state file path.
525
- * @returns {Promise<number|null>} the persisted version, or `null`.
574
+ * @returns {Promise<number|null>} the persisted version, or `null` when absent/unusable.
575
+ * @throws when the path exists but cannot be read — callers must refuse to write.
526
576
  */
527
577
  async function readStateVersion(file) {
528
- const raw = await readStateJson(file);
578
+ let text;
579
+ try {
580
+ text = await readFile(file, "utf8");
581
+ } catch (error) {
582
+ if (error?.code === "ENOENT") return null;
583
+ throw error;
584
+ }
585
+ let raw;
586
+ try {
587
+ raw = JSON.parse(text);
588
+ } catch {
589
+ return null;
590
+ }
529
591
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
530
592
  const version = raw.version;
531
593
  return typeof version === "number" ? version : null;
@@ -556,7 +618,10 @@ function isKnownStateVersion(version, known) {
556
618
  *
557
619
  * @module dsh-connect-modelscope-token-plan/codes
558
620
  */
559
- /** 面板按它分流的稳定码表。 */
621
+ /**
622
+ * 面板按它分流的稳定码表 —— Client 的 `GUIDANCE_BY_CODE` 以这份表的键为契约,
623
+ * 新增/改名必须同步 Client,否则内部错误没有引导文案(见下 `INTERNAL_ERROR`)。
624
+ */
560
625
  const CODE = Object.freeze({
561
626
  CONFIG_ERROR: "config_error",
562
627
  NETWORK_ERROR: "network_error",
@@ -573,19 +638,27 @@ const CODE = Object.freeze({
573
638
  * 「每个键必须在 CODE 里」),最需要人看的内部错误反而没有引导文案。
574
639
  * 正式收进码表,两端从此同一份真源。
575
640
  */
576
- INTERNAL_ERROR: "internal_error"
641
+ INTERNAL_ERROR: "internal_error",
642
+ /**
643
+ * Client 自产码:Host 响应无法解读成快照(非 JSON / 缺 ok 字段)时,
644
+ * 面板用它走引导文案,而不是把裸英文串("unexpected payload")上屏。
645
+ *
646
+ * 本表是 `GUIDANCE_BY_CODE` 的键集真源(panel.test §2 钉「每个键必须在
647
+ * CODE 里」、§3c 钉「每个码必须有引导」)——新增 client 码必须同时进
648
+ * 这张表,两侧才同步。
649
+ */
650
+ PAYLOAD_ERROR: "payload_error"
577
651
  });
578
652
  /**
579
653
  * 把上游 HTTP 状态归类成稳定码。
580
654
  *
581
655
  * 401/403 → AUTH_ERROR(换令牌能修,绝不自动重试——魔搭没有锁号问题,
582
656
  * 但重试一个坏令牌只会刷屏);429 由调用方先走 {@link classifyRateLimit}
583
- * 分诊,这里只兜底;5xx → UPSTREAM_ERROR;其余 → NETWORK_ERROR。
657
+ * 分诊,这里只兜底;其余 4xx/5xx → UPSTREAM_ERROR;低于 400 → NETWORK_ERROR。
584
658
  */
585
659
  function classifyStatus(status) {
586
660
  if (status === 401 || status === 403) return CODE.AUTH_ERROR;
587
661
  if (status === 429) return CODE.RATE_LIMITED;
588
- if (status >= 500) return CODE.UPSTREAM_ERROR;
589
662
  if (status >= 400) return CODE.UPSTREAM_ERROR;
590
663
  return CODE.NETWORK_ERROR;
591
664
  }
@@ -684,6 +757,19 @@ function parseRetryAfterMs(headers, message) {
684
757
  function localDateKey(now) {
685
758
  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
686
759
  }
760
+ /**
761
+ * 路径在不在(任何 fs 错误都读作「不在」——存在性问的是能不能 `ls` 到它。
762
+ * EACCES 让 `stat` 失败时,真相其实是「在,只是看不见」,而对下面的判据来说
763
+ * 那同样不能被当成「没有记录」)。
764
+ */
765
+ async function pathExists(file) {
766
+ try {
767
+ await stat(file);
768
+ return true;
769
+ } catch {
770
+ return false;
771
+ }
772
+ }
687
773
  const STATE_VERSION = 1;
688
774
  const KNOWN_VERSIONS = [STATE_VERSION];
689
775
  /** 解析并校验磁盘载荷;形状不对读作 null(损坏即忽略),并标记异常。 */
@@ -790,7 +876,16 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
790
876
  * @returns `true` = 磁盘版本比本构建新(写必须拒绝);`false` = 可以写。
791
877
  */
792
878
  const diskVersionBlocksWrites = async () => {
793
- const onDisk = await readStateVersion(file);
879
+ let onDisk;
880
+ try {
881
+ onDisk = await readStateVersion(file);
882
+ } catch (error) {
883
+ const note = `usage-state: usage.json exists but cannot be read (${redactSecrets$1(errMsg(error))})`;
884
+ if (!readOnly) logger?.warn?.(`${name}: ${note} — recording disabled to avoid clobbering`);
885
+ anomaly = note;
886
+ readOnly = true;
887
+ return true;
888
+ }
794
889
  if (onDisk !== null && !isKnownStateVersion(onDisk, KNOWN_VERSIONS)) {
795
890
  const note = `usage-state: on-disk version ${onDisk} is newer than this build knows`;
796
891
  if (!readOnly) logger?.warn?.(`${name}: ${note} — recording disabled to avoid clobbering`);
@@ -823,7 +918,7 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
823
918
  const enqueue = (mutate) => {
824
919
  const run = writeChain.then(async () => {
825
920
  if (await diskVersionBlocksWrites()) return;
826
- const payload = (loaded ? current : await cache.read() ?? null) ?? {
921
+ const payload = await readThrough() ?? (loaded ? current : null) ?? {
827
922
  version: STATE_VERSION,
828
923
  days: {},
829
924
  models: {},
@@ -891,16 +986,47 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
891
986
  }, ...payload.events].slice(0, maxEvents);
892
987
  });
893
988
  },
894
- /** 今天(本地时区)的账户级计数;没有任何记录时是 0,不是 null。 */
989
+ /**
990
+ * 今天(本地时区)的账户级计数。
991
+ *
992
+ * 三种答案,三种含义,**不可合并**(wire.ts DailyUsage 的语义基线):
993
+ * - `0` —— 确实一次都没有(没记录,或今天还没有桶);这是可信的日常值;
994
+ * - `null` —— **读不到**:文件在,但本构建看不懂它(损坏 / 形状不对 / 更新
995
+ * 构建写的版本 / 存在却不可读)。面板渲染「—」;
996
+ * - 数字 —— 今日次数。
997
+ *
998
+ * 判据是「文件在不在」而不是「载荷读没读出来」:**文件不存在**才是真实的 0
999
+ * (这台 Host 从未经本插件调用过,不是数据丢失),而**文件在却读成空**代表
1000
+ * 我们失去了那部分信息——把它报成 0 会让操作者以为今天没调用过,真相是他
1001
+ * 不知道。
1002
+ *
1003
+ * 这是 2026-10-16 补上的一块拼图:`routes.test.mjs` 早有一条
1004
+ * 「本地计数读不到 → null,绝不是 0」的断言,但它只能用一个**会抛错的桩**
1005
+ * 造出那条路径(注释原话:「挂载的真实 store 造不出『读的时候抛』」)——
1006
+ * 真实 store 把所有读失败都吞成了空载荷。于是 null 那份语义在 wire.ts、
1007
+ * `cards.ts`、`snapshot-aggregate.ts` 里写了三遍,却从未在本机真的产出过。
1008
+ *
1009
+ * @returns {{dateKey: string, calls: number|null, tokens: number|null}}
1010
+ */
895
1011
  async daily() {
896
- const day = (await cache.read())?.days[localDateKey(now())] ?? null;
1012
+ const payload = await cache.read();
1013
+ const dateKey = localDateKey(now());
1014
+ if (payload === null) {
1015
+ const stored = await pathExists(file);
1016
+ return {
1017
+ dateKey,
1018
+ calls: stored ? null : 0,
1019
+ tokens: stored ? null : 0
1020
+ };
1021
+ }
1022
+ const day = payload.days[dateKey] ?? null;
897
1023
  return day === null ? {
898
- dateKey: localDateKey(now()),
1024
+ dateKey,
899
1025
  calls: 0,
900
1026
  tokens: 0
901
1027
  } : {
902
1028
  ...day,
903
- dateKey: localDateKey(now())
1029
+ dateKey
904
1030
  };
905
1031
  },
906
1032
  /** 今天的单模型计数(只含有记录的模型,dayCalls/dayTokens 口径,均跨日清零)。 */
@@ -1005,6 +1131,13 @@ const MAX_CACHE_AGE_MS = 36e5;
1005
1131
  * flight's answer would be served to the account that just signed in.
1006
1132
  * Hanging the counters off the map makes every instance over that map share
1007
1133
  * one generation state, so the guard holds no matter how the caller builds it.
1134
+ *
1135
+ * `seq` is the ONE monotonic source both bumps draw from (see
1136
+ * {@link clearCoalescedFetch}): `global` is a high-water mark that only a global
1137
+ * clear moves, and `keys` holds one absolute number per individually-cleared
1138
+ * key. `keys` is therefore bounded by the number of distinct keys cleared since
1139
+ * the last global clear — not swept, because a per-key number must outlive any
1140
+ * in-flight flight born under it, while a later global clear wipes them all.
1008
1141
  */
1009
1142
  const GENERATIONS = /* @__PURE__ */ new WeakMap();
1010
1143
  /** The (created-on-demand) generation state belonging to one cache map. */
@@ -1013,6 +1146,7 @@ function generationsOf(cache) {
1013
1146
  if (state === void 0) {
1014
1147
  state = {
1015
1148
  global: 0,
1149
+ seq: 0,
1016
1150
  keys: /* @__PURE__ */ new Map()
1017
1151
  };
1018
1152
  GENERATIONS.set(cache, state);
@@ -1034,14 +1168,15 @@ function generationsOf(cache) {
1034
1168
  */
1035
1169
  function clearCoalescedFetch(cache, inflight, key) {
1036
1170
  const gens = generationsOf(cache);
1171
+ gens.seq += 1;
1037
1172
  if (key === void 0) {
1038
- gens.global += 1;
1173
+ gens.global = gens.seq;
1039
1174
  cache.clear();
1040
1175
  inflight.clear();
1041
1176
  gens.keys.clear();
1042
1177
  return;
1043
1178
  }
1044
- gens.keys.set(key, (gens.keys.get(key) ?? gens.global) + 1);
1179
+ gens.keys.set(key, gens.seq);
1045
1180
  cache.delete(key);
1046
1181
  inflight.delete(key);
1047
1182
  }
@@ -1116,6 +1251,66 @@ function createCoalescedFetch(options = {}) {
1116
1251
  };
1117
1252
  }
1118
1253
 
1254
+ //#endregion
1255
+ //#region src/host/context-catalog.ts
1256
+ /**
1257
+ * 内置上下文窗口策展表——魔搭目录不声明窗口时的第三方真值来源。
1258
+ *
1259
+ * 魔搭 `/v1/models`(及模型详情端点)只回基础标识字段(id/object/owned_by/
1260
+ * created + Tasks 标签),没有窗口字段;未声明窗口的条目会一路落到
1261
+ * `FALLBACK_CONTEXT_WINDOW`(128k)。这张表在「用户全局档位」之外补第三级
1262
+ * 真值:目录声明 > 用户覆盖 > **策展表** > 兜底。
1263
+ *
1264
+ * 数据来自 models.dev 聚合(github.com/anomalyco/models.dev,MIT;api.json
1265
+ * 构建产物)。魔搭 provider 自身在 api.json 只有 7 个不在当前目录的旧模型,
1266
+ * 所以每条真值取的是**同 id 模型在各厂自家 host 下的主流 limit.context**——
1267
+ * 跨 host 抄写的开量窗口是模型出厂规格,不是各网关的部署配额,取多数而非
1268
+ * 最大值:个别网关把窗口抄大(如 openrouter 抄 1M)不代表魔搭侧真开那么宽。
1269
+ *
1270
+ * 维护纪律:
1271
+ * 1. 只收**当前魔搭目录里真实存在**的 id(2026-10-16 实拉 `/v1/models` 全集
1272
+ * 37 个中筛出 19 个);目录下架的条目应随手删除,表不是历史档案。
1273
+ * 2. 每条注明来源 host 与核对日期;有多个数据源时写主流值并留离群说明。
1274
+ * 3. 覆盖优先级由 `curatedWindowOf` 的调用方(inference-client 打标)保证:
1275
+ * 目录声明过窗口的条目不进打标,策展表永不推翻平台声明。
1276
+ * 4. 生图/语音模型(Qwen-Image-Edit、ACE-Step 等)无对话上下文,不收。
1277
+ */
1278
+ /** id → 标称上下文窗口(tokens)。键必须与魔搭目录 id 逐字一致。 */
1279
+ const CURATED_CONTEXT_WINDOWS = {
1280
+ "deepseek-ai/DeepSeek-V4-Flash-0731": 1048576,
1281
+ "deepseek-ai/DeepSeek-V4-Pro": 1048576,
1282
+ "deepseek-ai/DeepSeek-V4-Pro-0813": 1048576,
1283
+ "deepseek-ai/DeepSeek-V4.1-Flash": 1048576,
1284
+ "meituan-longcat/LongCat-Flash-Lite": 256e3,
1285
+ "MiniMax/MiniMax-M1-80k": 1e6,
1286
+ "MiniMax/MiniMax-M3": 1048576,
1287
+ "nex-agi/Nex-N2.5-mini": 262144,
1288
+ "nex-agi/Nex-N2.5-Pro": 262144,
1289
+ "Qwen/Qwen3.5-122B-A10B": 262144,
1290
+ "Qwen/Qwen3.5-27B": 262144,
1291
+ "Qwen/Qwen3.5-35B-A3B": 262144,
1292
+ "Qwen/Qwen3.5-397B-A17B": 262144,
1293
+ "Qwen/Qwen3.8-27B": 262144,
1294
+ "Qwen/Qwen3.8-Flash-Next": 262144,
1295
+ "stepfun-ai/Step-3.5-Flash": 262144,
1296
+ "stepfun-ai/Step-3.7-Flash": 262144,
1297
+ "ZhipuAI/GLM-4.7-Flash": 2e5,
1298
+ "ZhipuAI/GLM-5.2": 1e6
1299
+ };
1300
+ /**
1301
+ * 查策展窗口:命中返回正数,未命中返回 null(调用方继续走兜底)。
1302
+ *
1303
+ * 只认逐字 id——不做大小写/前后缀模糊匹配,宁可查不到落兜底,也不把
1304
+ * 相近 id 的窗口张冠李戴。值合法性再挡一道(表内只应出现正数)。
1305
+ * @param {string} id - 模型 id(魔搭目录原样拼写)。
1306
+ * @returns {number|null} 标称窗口,未收录为 null。
1307
+ */
1308
+ function curatedWindowOf(id) {
1309
+ if (typeof id !== "string" || id === "") return null;
1310
+ const value = CURATED_CONTEXT_WINDOWS[id];
1311
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
1312
+ }
1313
+
1119
1314
  //#endregion
1120
1315
  //#region src/host/inference-client.ts
1121
1316
  /**
@@ -1177,7 +1372,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1177
1372
  });
1178
1373
  } catch (error) {
1179
1374
  if (error instanceof Error && error.name === "AbortError") throw pluginError(CODE.TIMEOUT_ERROR, "modelscope request timed out");
1180
- throw pluginError(CODE.NETWORK_ERROR, redactSecrets(errMsg(error)));
1375
+ throw pluginError(CODE.NETWORK_ERROR, redactSecrets$1(errMsg(error)));
1181
1376
  } finally {
1182
1377
  clearTimeout(timer);
1183
1378
  }
@@ -1245,15 +1440,34 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1245
1440
  * cacheSeconds。返回归一后的目录条目(entries,M4 用来建 descriptor)与
1246
1441
  * 派生的 id 列表(ids,旧消费方继续可用)——两条路线读同一份条目,不会漂移;
1247
1442
  * 形状漂移抛 UPSTREAM_ERROR 并带 detail。
1443
+ *
1444
+ * `contextOverride`(tokens)是面板保存的全局上下文窗口覆盖:非 null 时在
1445
+ * 归一**之前**改写每条原始条目——删掉目录可能携带窗口的四个原始拼写键、写上
1446
+ * `contextWindow`。这是唯一能可靠生效的注入点:`normalizeEntry` 按
1447
+ * `context_length/context_window/contextWindow/max_context_tokens` 顺序读
1448
+ * 首个正数,任何只改归一后字段的窄打标都会被 `...source` 展开里的旧键回读
1449
+ * 污染。删四键再写宽形状键,不依赖键序,目录声明与覆盖不可能同时在场。
1450
+ * descriptor、roster、catalogSignature 都从这份归一条目派生,一处打标全链
1451
+ * 路生效;签名含窗口值,覆盖变化自动触发重新注册。
1452
+ *
1453
+ * 覆盖值**参与缓存键**:coalesced 缓存里存的条目已是打标后的形状,若仍用
1454
+ * 字面量 `"models"` 键,保存覆盖后的一个 TTL 内会读到未打标的旧条目(面板
1455
+ * 显示变了、注册却没变)。带指纹后不同覆盖值各自缓存,互不串味。
1456
+ *
1457
+ * 窗口三级真值链(fetchModels 内单点判定):目录声明 > 用户覆盖 >
1458
+ * **策展表**(context-catalog,models.dev 交叉真值,零网络依赖)> 兜底
1459
+ * 128k。目录声明过的条目任何下层都不碰;策展表只在用户没设覆盖时兜在
1460
+ * 兜底之前——它是「更聪明的 128k」,不是新权威。
1461
+ * @param {number|null} [contextOverride] - 全局窗口覆盖(tokens),null = 跟随目录。
1248
1462
  */
1249
- async fetchModels() {
1463
+ async fetchModels(contextOverride) {
1250
1464
  const url = settings.apiBase + "/models";
1251
- return await read("models", async () => {
1465
+ return await read(`models:${contextOverride ?? "catalog"}`, async () => {
1252
1466
  const response = await fetchWithTimeout(url, { method: "GET" });
1253
1467
  if (!response.ok) {
1254
1468
  const message = await readErrorMessage(response);
1255
1469
  const code = classifyStatus(response.status);
1256
- throw pluginError(code, redactSecrets("model catalog failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1470
+ throw pluginError(code, redactSecrets$1("model catalog failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1257
1471
  }
1258
1472
  const json = await response.json().catch(() => null);
1259
1473
  if (json === null || typeof json !== "object" || json.object !== "list" || !Array.isArray(json.data)) throw pluginError(CODE.UPSTREAM_ERROR, "model catalog shape drifted (expected {object:'list', data:[...]})");
@@ -1265,10 +1479,34 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1265
1479
  const entries = raws.map((raw) => {
1266
1480
  const id = str(raw.id, "");
1267
1481
  const tags = tagsById.get(id) ?? [];
1268
- return normalizeEntry({
1482
+ const source = {
1269
1483
  ...raw,
1270
1484
  tasks: tags.length > 0 ? tags : void 0
1271
- });
1485
+ };
1486
+ const CONTEXT_KEYS = [
1487
+ "context_length",
1488
+ "context_window",
1489
+ "contextWindow",
1490
+ "max_context_tokens"
1491
+ ];
1492
+ if (!CONTEXT_KEYS.some((key) => {
1493
+ const value = source[key];
1494
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
1495
+ })) {
1496
+ if (contextOverride != null) {
1497
+ for (const key of CONTEXT_KEYS) delete source[key];
1498
+ source.contextWindow = contextOverride;
1499
+ source.__contextSource = "override";
1500
+ } else {
1501
+ const curated = curatedWindowOf(id);
1502
+ if (curated !== null) {
1503
+ for (const key of CONTEXT_KEYS) delete source[key];
1504
+ source.contextWindow = curated;
1505
+ source.__contextSource = "curated";
1506
+ }
1507
+ }
1508
+ }
1509
+ return normalizeEntry(source);
1272
1510
  }).filter((entry) => entry.id !== "");
1273
1511
  return {
1274
1512
  entries,
@@ -1298,7 +1536,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1298
1536
  if (!response.ok) {
1299
1537
  const message = await readErrorMessage(response);
1300
1538
  const code = classifyStatus(response.status);
1301
- throw pluginError(code, redactSecrets("magicube balance failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1539
+ throw pluginError(code, redactSecrets$1("magicube balance failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
1302
1540
  }
1303
1541
  const json = await response.json().catch(() => null);
1304
1542
  const data = json !== null && typeof json === "object" ? json.data : null;
@@ -1314,8 +1552,9 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1314
1552
  }, settings.cacheSeconds * 1e3);
1315
1553
  },
1316
1554
  /**
1317
- * 一次 probe 调用:故意缺 messages 的请求,预期 401(令牌坏)先于 400
1318
- * (请求坏)返回,**零额度**鉴权探针(语义已真机回填,SPIKE.md)。
1555
+ * 一次 probe 调用:故意缺 messages 的请求(**零额度**鉴权探针,语义已真机
1556
+ * 回填,SPIKE.md)。魔搭**不先校验 messages**——缺 messages 返回 200(空壳)
1557
+ * 而非 400,故判定规则是 401/403 = 令牌坏、200/400 = 令牌好、429 = 限频中。
1319
1558
  *
1320
1559
  * 失败抛 pluginError;调用方(probe 路由)负责把它记进事件流。
1321
1560
  */
@@ -1339,14 +1578,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1339
1578
  const elapsedMs = Date.now() - startedAt;
1340
1579
  if (response.status === 401 || response.status === 403) {
1341
1580
  const message = await readErrorMessage(response);
1342
- throw pluginError(CODE.AUTH_ERROR, redactSecrets(message === "" ? "token rejected (HTTP 401/403)" : message));
1581
+ throw pluginError(CODE.AUTH_ERROR, redactSecrets$1(message === "" ? "token rejected (HTTP 401/403)" : message));
1343
1582
  }
1344
1583
  if (response.status === 429) {
1345
1584
  const message = await readErrorMessage(response);
1346
1585
  const retryAfterMs = parseRetryAfterMs(response.headers, message);
1347
1586
  const triage = classifyRateLimit(message);
1348
1587
  const extra = retryAfterMs !== null && retryAfterMs !== void 0 ? { retryAfterMs } : {};
1349
- throw pluginError(triage === "quota" ? CODE.QUOTA_EXCEEDED : CODE.RATE_LIMITED, redactSecrets(message === "" ? "modelscope returned 429 (probe on " + modelId + ")" : message), extra);
1588
+ throw pluginError(triage === "quota" ? CODE.QUOTA_EXCEEDED : CODE.RATE_LIMITED, redactSecrets$1(message === "" ? "modelscope returned 429 (probe on " + modelId + ")" : message), extra);
1350
1589
  }
1351
1590
  if (response.status >= 500) throw pluginError(CODE.UPSTREAM_ERROR, "modelscope returned HTTP " + String(response.status) + " (validity probe)");
1352
1591
  response.body?.cancel();
@@ -1389,16 +1628,32 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1389
1628
  * Integrity follows `state-store.ts`: a versioned payload, a temp file plus an
1390
1629
  * atomic rename (two Host processes can share the directory), owner-only
1391
1630
  * modes, and "anything unrecognised reads as not set" — a corrupted or
1392
- * downgraded file costs one re-toggle, never a crash.
1631
+ * downgraded file costs one re-toggle, never a crash. The write side refuses
1632
+ * to overwrite a version this build cannot read (ADR-006); the read side stays
1633
+ * permissive but REPORTS the disagreement via `versionNote()`, because for the
1634
+ * allow-list "unreadable" and "never saved" would otherwise collapse to the
1635
+ * same answer — and that answer is "offer every model".
1393
1636
  *
1394
1637
  * The allow-list's empty-vs-absent distinction is the one this module guards:
1395
1638
  * an EMPTY list means "no filter, offer every model", while `HIDE_ALL_MODELS`
1396
1639
  * (`llm-models.ts`) is the caller-side sentinel for "offer nothing". Neither
1397
1640
  * 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.
1641
+ * filter", which is the only safe default for a fresh install. A foreign file
1642
+ * reads the same way, which is precisely why `versionNote()` exists.
1399
1643
  *
1400
1644
  * @module dsh-connect-modelscope-token-plan/provider-store
1401
1645
  */
1646
+ /**
1647
+ * The "nothing stored" payload. Every absent / unreadable / foreign read
1648
+ * collapses here, so the three call sites that produce it stay in step instead
1649
+ * of each spelling out the same three `null`s.
1650
+ */
1651
+ const EMPTY_PAYLOAD$1 = {
1652
+ enabled: null,
1653
+ enabledIds: null,
1654
+ contextWindow: null,
1655
+ version: null
1656
+ };
1402
1657
  /** Shape version, bumped when the persisted form changes incompatibly. */
1403
1658
  const PROVIDER_VERSION = 1;
1404
1659
  /**
@@ -1406,12 +1661,36 @@ const PROVIDER_VERSION = 1;
1406
1661
  * historical ones. Bumping {@link PROVIDER_VERSION} means adding the new number
1407
1662
  * here too — otherwise this build would refuse its own newest files.
1408
1663
  *
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}).
1664
+ * This list is asked in THREE places:
1665
+ * - the write side ({@link writePayload}, ADR-006): an on-disk version not in
1666
+ * this list was written by a NEWER build and must not be clobbered. That
1667
+ * caller asks `isKnownStateVersion(existing, ...)` rather than
1668
+ * {@link isKnownProviderVersion} on purpose — it must also admit `null`
1669
+ * ("no file at all"), which is the one situation where a write belongs.
1670
+ * - the READ side ({@link parseAt}) and the report ({@link versionNote} /
1671
+ * {@link describeProviderPayload}), which ask {@link isKnownProviderVersion}.
1672
+ * Keeping the read side and the report on one predicate is what makes them
1673
+ * unable to disagree about what a foreign version means.
1412
1674
  */
1413
1675
  const KNOWN_PROVIDER_VERSIONS = [1];
1414
1676
  /**
1677
+ * Whether this build can read a file declaring `version`.
1678
+ *
1679
+ * THE single answer to that question. Note it is deliberately NOT
1680
+ * `version === PROVIDER_VERSION`: the whitelist includes shapes this build can
1681
+ * read but no longer writes, so comparing against the write version would make a
1682
+ * version bump unreadable-of-its-own-files. The original bug behind this function
1683
+ * was exactly that split — `parseAt` compared against the write version while the
1684
+ * report asked the whitelist, so a bumped build read its own v1 files as "not
1685
+ * set" while its report said "nothing to say": the silent collapse the read side
1686
+ * exists to kill, resurrected by the upgrade procedure.
1687
+ * @param {unknown} version - the value read off disk (may not be a number at all).
1688
+ * @returns {boolean}
1689
+ */
1690
+ function isKnownProviderVersion(version) {
1691
+ return typeof version === "number" && KNOWN_PROVIDER_VERSIONS.includes(version);
1692
+ }
1693
+ /**
1415
1694
  * The directory this plugin's state lives in — per-profile when the Host names
1416
1695
  * one, shared otherwise. Unlike the THROTTLE, which is deliberately shared
1417
1696
  * across profiles, this answers "does THIS profile want the provider registered"
@@ -1431,6 +1710,18 @@ function normalizeEnabled(raw) {
1431
1710
  return typeof raw === "boolean" ? raw : null;
1432
1711
  }
1433
1712
  /**
1713
+ * Normalize a context-window value: a positive finite number, or `null`
1714
+ * ("follow the catalog"). Anything else — strings, zero, negative, NaN,
1715
+ * Infinity — reads as no override, mirroring how {@link normalizeEnabled}
1716
+ * answers "nothing usable" instead of inventing a default.
1717
+ * @param {unknown} raw - the persisted or posted value.
1718
+ * @returns {number|null} the window in tokens, or `null` when nothing usable.
1719
+ */
1720
+ function normalizeContextWindow(raw) {
1721
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return null;
1722
+ return raw;
1723
+ }
1724
+ /**
1434
1725
  * Normalize a persisted allow-list: an array of non-empty strings, de-duplicated
1435
1726
  * and order-preserved. Anything else reads as "no saved list".
1436
1727
  *
@@ -1454,6 +1745,33 @@ function normalizeEnabledIds(raw) {
1454
1745
  return out;
1455
1746
  }
1456
1747
  /**
1748
+ * Resolve the allow-list to publish/display, from the persisted value and the
1749
+ * list this process last published.
1750
+ *
1751
+ * THE single answer to "which list wins", so that every consumer asks it the
1752
+ * same way: a READABLE persisted list wins — including an EMPTY one, because
1753
+ * this module's contract defines `[]` as "no filter, offer every model" — and
1754
+ * the in-memory list is consulted only when the persisted side could not be read
1755
+ * at all (the caller's `.catch` turns a rejection into `null`).
1756
+ *
1757
+ * The three call sites used to spell this out separately, and two of them had
1758
+ * drifted: `snapshot-aggregate.ts` and `routes/provider.ts` honoured an empty
1759
+ * persisted list, while `index.ts` treated it as "no answer" and republished the
1760
+ * remembered list. So after the operator deleted `provider.json` (the reset
1761
+ * `versionNote` itself recommends) the panel reported `allowed: "all"` — its
1762
+ * `enabledCount` counting every model — while the poller kept registering the
1763
+ * OLD restriction: the panel and the Host disagreeing about what is served,
1764
+ * which is precisely what the publish queue and the idle guards exist to
1765
+ * prevent. One copy now, asked by all three.
1766
+ * @param {unknown} stored - the persisted list, or `null` when unreadable.
1767
+ * @param {unknown} remembered - `publisher.state.enabledIds` (last published).
1768
+ * @returns {string[]} the list to use.
1769
+ */
1770
+ function resolveEnabledIds(stored, remembered) {
1771
+ if (Array.isArray(stored)) return stored;
1772
+ return Array.isArray(remembered) ? remembered : [];
1773
+ }
1774
+ /**
1457
1775
  * The file-backed provider switch + allow-list.
1458
1776
  * @param {object} [options]
1459
1777
  * @param {string} [options.dir] - override the state directory (tests).
@@ -1483,7 +1801,14 @@ function createFileProviderStore(options = {}) {
1483
1801
  * decides whether to surface the refusal to a user.
1484
1802
  */
1485
1803
  const writePayload = async (body) => {
1486
- const existing = await readStateVersion(filePath);
1804
+ let existing;
1805
+ try {
1806
+ existing = await readStateVersion(filePath);
1807
+ } catch (error) {
1808
+ const reason = `provider: refusing to overwrite provider.json (cannot read it: ${redactSecrets$1(errMsg(error))})`;
1809
+ degrade(reason, null, logger, null);
1810
+ return reason;
1811
+ }
1487
1812
  if (!isKnownStateVersion(existing, KNOWN_PROVIDER_VERSIONS)) {
1488
1813
  const reason = `provider: refusing to overwrite provider.json holding version ${existing} (this build knows ${KNOWN_PROVIDER_VERSIONS.join("/")})`;
1489
1814
  degrade(reason, null, logger, null);
@@ -1495,22 +1820,73 @@ function createFileProviderStore(options = {}) {
1495
1820
  return null;
1496
1821
  };
1497
1822
  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 {
1823
+ /**
1824
+ * Read ONE payload off the disk, shape-checked.
1825
+ *
1826
+ * "Can this build read it" is asked through {@link isKnownProviderVersion} —
1827
+ * the same predicate the report and the write guard use. Comparing against
1828
+ * {@link PROVIDER_VERSION} instead would say "cannot read" about the OLDEST
1829
+ * shapes this build still knows, which is exactly wrong when it is bumped:
1830
+ * the header's upgrade rule adds the new number to {@link KNOWN_PROVIDER_VERSIONS},
1831
+ * so a v1 file must stay readable while v2 is written. Getting that wrong
1832
+ * makes this reader disagree with {@link versionNote} — reads "not set" while
1833
+ * the note says "nothing to say", which is the silent collapse this module
1834
+ * exists to kill. That split once existed in production code for one release.
1835
+ *
1836
+ * A version this build cannot read reads as EMPTY, but REMEMBERS which version
1837
+ * it was. The ADR-006 guard already refuses to overwrite such a file, so the
1838
+ * read side has to be able to say "there is an answer in there, it just is
1839
+ * not one we can see" — collapsing it to "never saved" is what made a saved
1840
+ * allow-list look like a deliberate "offer every model". See {@link versionNote}.
1841
+ */
1842
+ const parseAt = async (file) => {
1843
+ const source = obj(await readStateJson(file));
1844
+ const version = typeof source.version === "number" ? source.version : null;
1845
+ if (!isKnownProviderVersion(version)) return {
1503
1846
  enabled: null,
1504
- enabledIds: null
1847
+ enabledIds: null,
1848
+ contextWindow: null,
1849
+ version
1505
1850
  };
1506
1851
  return {
1507
1852
  enabled: normalizeEnabled(source.enabled),
1508
- enabledIds: normalizeEnabledIds(source.enabledIds)
1853
+ enabledIds: normalizeEnabledIds(source.enabledIds),
1854
+ contextWindow: normalizeContextWindow(source.contextWindow),
1855
+ version
1509
1856
  };
1510
1857
  };
1858
+ /** Read the profile's own file, shape-checked. */
1859
+ const parsePayload = async () => parseAt(filePath);
1860
+ /** Read one payload off an explicit path (the legacy shared file). */
1861
+ const parsePayloadFrom = async (file) => parseAt(file);
1862
+ let writeChain = Promise.resolve(null);
1863
+ const cache = createStateReadCache(async () => {
1864
+ const own = await parsePayload();
1865
+ if (own.enabled !== null || own.enabledIds !== null || legacyFile === null) return own;
1866
+ if (own.version !== null) return own;
1867
+ const legacy = await parsePayloadFrom(legacyFile);
1868
+ if (legacy.enabled === null && legacy.enabledIds === null) return own;
1869
+ const inherited = {
1870
+ version: 1,
1871
+ ...legacy.enabled === null ? {} : { enabled: legacy.enabled },
1872
+ ...legacy.enabledIds === null ? {} : { enabledIds: legacy.enabledIds },
1873
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1874
+ };
1875
+ await writeChain.then(() => writePayload(inherited).then(() => void 0, () => void 0), () => writePayload(inherited).then(() => void 0, () => void 0));
1876
+ return legacy;
1877
+ }, { ttlMs });
1878
+ const read = () => cache.read();
1879
+ /**
1880
+ * The payload the callers actually read. The short-TTL cache's `read()` is
1881
+ * typed `Promise<T | null>` because it doubles as "never read" signalling for
1882
+ * the §23 legacy adoption; a provider payload is always an object, so the
1883
+ * `null` branch collapses here, in one place, rather than at every caller.
1884
+ * @returns {Promise<ProviderPayload>} the payload; the empty payload when nothing is stored.
1885
+ */
1886
+ const readPayload = async () => await read() ?? EMPTY_PAYLOAD$1;
1511
1887
  /**
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.
1888
+ * Read one payload, then hand it to {@link writePayload} — the read-modify-
1889
+ * write that keeps the switch and the list from clobbering each other.
1514
1890
  *
1515
1891
  * Three-state keys, and they are SYMMETRIC between `enabled` and `enabledIds`:
1516
1892
  * a key the caller omits (`undefined`) keeps whatever is on disk, an explicit
@@ -1519,26 +1895,28 @@ function createFileProviderStore(options = {}) {
1519
1895
  * value is stored as given. An earlier `mergeEnabled` flag applied the
1520
1896
  * "keep what is on disk" rule to `enabled` unconditionally, which silently
1521
1897
  * 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
1898
  *
1534
- * 文件头说「两个字段必须作为一个载荷读写,否则开关会覆盖清单」——设计意图是对的,
1535
- * 但那只防住了「**不同字段**互相覆盖」;缺了串行化,**同字段**与「两次都基于同一
1536
- * 快照」的互相覆盖照样发生。链的形状与 `usage-store` 一致,且必须把
1537
- * `writePayload` 的版本拒绝语义一起搬进临界区(否则拒绝判断会落在锁外)。
1899
+ * 读-改-写**整段排队**:两个并发 patch 不能互相覆盖。这个 store 以前完全没有
1900
+ * 写串行化(对比 `usage-store.ts` 的 `writeChain`),于是面板开关 POST 与
1901
+ * roster POST —— 两个独立请求,外加 `index.ts` 轮询同时在调 `enabledIds()` ——
1902
+ * 会各自读到同一个 `current`,后写的赢。实测:先存清单,再并发 `save(true)` +
1903
+ * `saveEnabledIds([...])`,其中一次保存被**整个丢掉**(用户勾了模型又开了开关,
1904
+ * 两次点击只活下来一次)。文件头说「两个字段必须作为一个载荷读写,否则开关会
1905
+ * 覆盖清单」——设计意图是对的,但那只防住了「**不同字段**互相覆盖」;缺了串行化,
1906
+ * **同字段**与「两次都基于同一快照」的互相覆盖照样发生。链的形状与
1907
+ * `usage-store` 一致,且必须把 `writePayload` 的版本拒绝语义一起搬进临界区
1908
+ * (否则拒绝判断会落在锁外)。
1538
1909
  *
1539
1910
  * 链吞掉自己的失败继续走:一次只读 Home 上的写失败不该毒化后续所有保存。
1911
+ *
1912
+ * 写成功的同时更新读缓存,调用方不再各自回读一遍:缓存里永远是刚写下去的那份,
1913
+ * 而 `patchPayload` 的 `current` 来自磁盘(`parsePayload`,不走缓存),所以合并
1914
+ * 结果不会带进过期值。此前的写法是写完再 `readPayload()` 读回另一半——那条回读
1915
+ * 走的是 TTL 缓存,拿到的是**写入前**的值,并发时会把一个较新的缓存项顶成旧值。
1916
+ *
1917
+ * @param {object} patch - `{ enabled?, enabledIds?, contextWindow? }` to persist.
1918
+ * @returns {Promise<string|null>} the write refusal reason, or `null`.
1540
1919
  */
1541
- let writeChain = Promise.resolve(null);
1542
1920
  const patchPayload = (patch) => {
1543
1921
  const run = writeChain.then(async () => {
1544
1922
  const current = await parsePayload();
@@ -1548,51 +1926,21 @@ function createFileProviderStore(options = {}) {
1548
1926
  if (enabled !== null) body.enabled = enabled;
1549
1927
  const enabledIds = patch.enabledIds === void 0 ? current.enabledIds : patch.enabledIds;
1550
1928
  if (enabledIds !== null) body.enabledIds = enabledIds;
1929
+ const contextWindow = patch.contextWindow === void 0 ? current.contextWindow : patch.contextWindow;
1930
+ if (contextWindow !== null) body.contextWindow = contextWindow;
1551
1931
  body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1552
- return writePayload(body);
1932
+ const refusal = await writePayload(body);
1933
+ if (refusal === null) cache.remember({
1934
+ enabled,
1935
+ enabledIds,
1936
+ contextWindow,
1937
+ version: 1
1938
+ });
1939
+ return refusal;
1553
1940
  });
1554
1941
  writeChain = run.catch(() => null);
1555
1942
  return run;
1556
1943
  };
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
- };
1596
1944
  return {
1597
1945
  /**
1598
1946
  * The saved switch value.
@@ -1610,6 +1958,16 @@ function createFileProviderStore(options = {}) {
1610
1958
  return (await readPayload()).enabledIds ?? [];
1611
1959
  },
1612
1960
  /**
1961
+ * The saved context-window override (tokens), or `null` = follow the
1962
+ * catalog. `null` is a real answer here, not absence: the override is
1963
+ * optional by design, and the caller (snapshot / stamping) decides what
1964
+ * "follow the catalog" means.
1965
+ * @returns {Promise<number|null>}
1966
+ */
1967
+ async contextWindow() {
1968
+ return (await readPayload()).contextWindow;
1969
+ },
1970
+ /**
1613
1971
  * Whether the panel has ever saved a value here.
1614
1972
  * @returns {Promise<boolean>}
1615
1973
  */
@@ -1618,6 +1976,27 @@ function createFileProviderStore(options = {}) {
1618
1976
  return payload.enabled !== null || payload.enabledIds !== null;
1619
1977
  },
1620
1978
  /**
1979
+ * A human-readable note when the file holds a version this build cannot
1980
+ * read, or `null` when there is nothing to say.
1981
+ *
1982
+ * The READ side is permissive on purpose: a foreign file reads as "not set",
1983
+ * so the panel keeps working instead of failing the whole snapshot. The cost
1984
+ * is that it cannot tell "never saved" from "saved but unreadable" — and
1985
+ * for the allow-list those two read differently in the dangerous direction
1986
+ * (an empty list means "offer every model"). Writes are already protected by
1987
+ * the ADR-006 guard in {@link writePayload}; this note is the read side's
1988
+ * half of the same promise. The snapshot aggregate pushes it into
1989
+ * `shapeWarnings`, and `doctor.ts` reports the same file, so the operator
1990
+ * sees the disagreement instead of a switch that looks merely unset.
1991
+ *
1992
+ * @returns {Promise<string|null>}
1993
+ */
1994
+ async versionNote() {
1995
+ const version = (await readPayload()).version;
1996
+ if (version === null || isKnownProviderVersion(version)) return null;
1997
+ return `provider-state: provider.json holds version ${version}, which this build cannot read (this build knows ${KNOWN_PROVIDER_VERSIONS.join("/")}); the saved switch and allow-list are being answered as "not set", so the allow-list reads as "no filter, offer every model" while the switch falls back to the config default. Upgrade this build to see the saved values, or delete the file to reset them.`;
1998
+ },
1999
+ /**
1621
2000
  * Persist a switch value. The write is atomic (temp file + rename) so a
1622
2001
  * concurrent reader never sees a partial payload, and it PRESERVES the
1623
2002
  * saved allow-list.
@@ -1629,10 +2008,6 @@ function createFileProviderStore(options = {}) {
1629
2008
  if (enabled === null) throw new TypeError("provider switch expects a boolean");
1630
2009
  const refusal = await patchPayload({ enabled });
1631
2010
  if (refusal !== null) throw new Error(refusal);
1632
- cache.remember({
1633
- enabled,
1634
- enabledIds: (await readPayload()).enabledIds ?? []
1635
- });
1636
2011
  },
1637
2012
  /**
1638
2013
  * Persist the model allow-list, preserving the saved switch.
@@ -1644,108 +2019,1091 @@ function createFileProviderStore(options = {}) {
1644
2019
  if (list === null) throw new TypeError("provider allow-list expects an array of strings");
1645
2020
  const refusal = await patchPayload({ enabledIds: list });
1646
2021
  if (refusal !== null) throw new Error(refusal);
1647
- cache.remember({
1648
- enabled: (await readPayload()).enabled ?? null,
1649
- enabledIds: list
1650
- });
2022
+ },
2023
+ /**
2024
+ * Persist the context-window override, preserving the saved switch and
2025
+ * allow-list. `null` clears the override back to "follow the catalog".
2026
+ * @param {number|null} value - the window in tokens, or `null` to clear.
2027
+ * @returns {Promise<void>}
2028
+ */
2029
+ async saveContextWindow(value) {
2030
+ const window = normalizeContextWindow(value);
2031
+ if (value !== null && window === null) throw new TypeError("provider context window expects a positive finite number");
2032
+ const refusal = await patchPayload({ contextWindow: window });
2033
+ if (refusal !== null) throw new Error(refusal);
1651
2034
  },
1652
2035
  /**
1653
2036
  * Forget the panel-saved values: the config default and "no filter" rule
1654
- * again.
2037
+ * again. The context override is forgotten WITH them — the reset button
2038
+ * means "back to the way this plugin ships", and the override is panel
2039
+ * state in exactly the same sense.
1655
2040
  * @returns {Promise<void>}
1656
2041
  */
1657
2042
  async forget() {
1658
2043
  const refusal = await patchPayload({
1659
2044
  enabled: null,
1660
- enabledIds: null
2045
+ enabledIds: null,
2046
+ contextWindow: null
1661
2047
  });
1662
2048
  if (refusal !== null) throw new Error(refusal);
1663
- cache.remember({
1664
- enabled: null,
1665
- enabledIds: null
1666
- });
1667
2049
  }
1668
2050
  };
1669
2051
  }
1670
2052
 
1671
2053
  //#endregion
1672
- //#region src/host/switch-precedence.ts
2054
+ //#region src/host/draw-store.ts
1673
2055
  /**
1674
- * The single adjudicator for "panel-saved value vs config default" across
1675
- * every opt-in switch.
2056
+ * 出图工具的开关与模型偏好——本插件**自己的**状态文件,与 provider 那条开关分开。
1676
2057
  *
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:
2058
+ * 为什么不是第二个字段塞进 `provider.json`:两者回答的是不同问题。
2059
+ * `provider.json` 说「这个 profile 要不要把魔搭作为 **chat provider** 提供」;
2060
+ * 本文件说「要不要给 agent 一个**出图工具**」。用户完全可能只要其一(用工具出图但
2061
+ * 不污染模型选择器,或反之)。合成一个 payload 会让「关掉出图」连带改掉 provider
2062
+ * 清单的写入时序——provider-store 的注释已说明为什么它的两个字段必须同生共死,
2063
+ * 而那组理由**不适用于**这两个开关(它们之间没有 atomicity 要求)。
1679
2064
  *
1680
- * - the boolean switch: `(panel ?? config) === true` plus
1681
- * `panel === null ? "config" : "panel"`;
1682
- * - the model preference: `panel ?? config` plus the same source probe.
2065
+ * 落点与 `provider-store.ts` 一致:`$DSH_HOME/state/<plugin>/draw.json`(无 profile
2066
+ * 时)或 `$DSH_HOME/state/<profile>/<plugin>/draw.json`。profile 分段的意思是
2067
+ * 「这个 profile 要不要暴露出图工具」,两个 profile 不能互相覆盖。
1683
2068
  *
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.
2069
+ * 优先级与所有 opt-in 开关同一份裁决(`switch-precedence.ts`):面板保存值赢,
2070
+ * 否则 `cordis.patch.yml` 的 `drawEnabled` 默认值说了算。
1688
2071
  *
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.
2072
+ * 完整性沿用 `state-store.ts`:版本化 payload、临时文件 + 原子改名 + fsync、属主
2073
+ * 可读、「认不出的内容一律读作没设过」。写入侧拒绝覆盖本版本读不懂的版本(ADR-006)。
1694
2074
  *
1695
- * 注:本模块自带的 `test/switch-precedence.test.mjs` 钉死纯函数行为;部分旧注释里
1696
- * 提到的 `test/peer-contract.test.mjs`(钉死真 peer 的 classifyPiAiError /
1697
- * resolveRetryPolicy 协议)依赖未 vendored 进本仓库的运行时 peer,不在 `npm test`
1698
- * 离线门禁内。
2075
+ * @module dsh-connect-modelscope-token-plan/draw-store
2076
+ */
2077
+ /** 本 store 落盘 payload 的版本。 */
2078
+ const DRAW_STORE_VERSION = 1;
2079
+ /** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
2080
+ const KNOWN_DRAW_VERSIONS = Object.freeze([1]);
2081
+ const EMPTY_PAYLOAD = {
2082
+ enabled: null,
2083
+ modelId: null,
2084
+ version: null
2085
+ };
2086
+ /** 本 build 读不读得懂这个版本。 */
2087
+ function isKnownDrawVersion(version) {
2088
+ return isKnownStateVersion(version, KNOWN_DRAW_VERSIONS);
2089
+ }
2090
+ /**
2091
+ * 归一一个保存的模型偏好:非空字符串,或 `null`(= 自动挑——具体挑谁由
2092
+ * `draw-runtime.ts` 的 `pickDrawModel` 优先级决定,不是简单取目录第一个)。
1699
2093
  *
1700
- * @module dsh-connect-modelscope-token-plan/switch-precedence
2094
+ * 不校验「这个 id 是否真存在」——目录会变,存一个暂时不在目录里的 id 是合法的
2095
+ * (用户可能先配好、等模型上线)。调用侧解析不出来时退回自动。
2096
+ * @param {unknown} value - 原始值。
2097
+ * @returns {string|null} 归一后的 id 或 `null`。
1701
2098
  */
2099
+ function normalizeDrawModelId(value) {
2100
+ if (typeof value !== "string") return null;
2101
+ const trimmed = value.trim();
2102
+ return trimmed === "" ? null : trimmed;
2103
+ }
1702
2104
  /**
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.
2105
+ * 归一保存的开关值:只有真正的 boolean 才算数,其余读作「没设过」。
2106
+ * @param {unknown} value - 原始值。
2107
+ * @returns {boolean|null} 归一后的开关。
1708
2108
  */
1709
- function resolveSwitchEnabled(panel, config) {
1710
- return (panel ?? config) === true;
2109
+ function normalizeDrawEnabled(value) {
2110
+ return typeof value === "boolean" ? value : null;
2111
+ }
2112
+ /** 本 store 的目录(与 provider-store 同一套分段规则)。 */
2113
+ function drawStoreDir(profile, dir) {
2114
+ if (typeof dir === "string" && dir !== "") return dir;
2115
+ return profile ? profileStateDir(name, profile) : stateDir(name);
1711
2116
  }
1712
2117
  /**
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.
1716
- * @param {boolean|string|null} panel - the panel-saved value.
1717
- * @returns {"panel"|"config"}
2118
+ * 文件支撑的出图开关 + 模型偏好。
2119
+ * @param {StoreOptions} [options] - `{ dir, profile, ttlMs, logger }`。
2120
+ * @returns {object} store 句柄。
1718
2121
  */
1719
- function switchSource(panel) {
1720
- return panel === null ? "config" : "panel";
2122
+ function createFileDrawStore(options = {}) {
2123
+ const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
2124
+ const stateDir = drawStoreDir(profile, dir);
2125
+ const filePath = join(stateDir, "draw.json");
2126
+ /** 解析一份 payload;认不出的版本读作「没设过」但**记住**版本号。 */
2127
+ const parsePayload = async () => {
2128
+ const source = obj(await readStateJson(filePath));
2129
+ const version = typeof source.version === "number" ? source.version : null;
2130
+ if (!isKnownDrawVersion(version)) return {
2131
+ enabled: null,
2132
+ modelId: null,
2133
+ version
2134
+ };
2135
+ return {
2136
+ enabled: normalizeDrawEnabled(source.enabled),
2137
+ modelId: normalizeDrawModelId(source.modelId),
2138
+ version
2139
+ };
2140
+ };
2141
+ const cache = createStateReadCache(parsePayload, { ttlMs });
2142
+ /**
2143
+ * 读当前 payload。
2144
+ * @returns {Promise<DrawPayload>}
2145
+ */
2146
+ const current = async () => await cache.read() ?? EMPTY_PAYLOAD;
2147
+ /**
2148
+ * 写一份补丁并落盘。
2149
+ * @param {{enabled?: boolean|null, modelId?: string|null}} patch - 要改的字段。
2150
+ * @returns {Promise<string|null>} 拒绝原因(`null` = 已写入)。
2151
+ */
2152
+ const patchPayload = async (patch) => {
2153
+ const before = await current();
2154
+ const enabled = patch.enabled === void 0 ? before.enabled : patch.enabled;
2155
+ const modelId = patch.modelId === void 0 ? before.modelId : patch.modelId;
2156
+ try {
2157
+ await ensureStateDir(stateDir);
2158
+ const body = { version: 1 };
2159
+ if (enabled !== null) body.enabled = enabled;
2160
+ if (modelId !== null) body.modelId = modelId;
2161
+ body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2162
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "draw.json") });
2163
+ cache.remember({
2164
+ enabled,
2165
+ modelId,
2166
+ version: 1
2167
+ });
2168
+ return null;
2169
+ } catch (error) {
2170
+ return degrade("draw store write failed", error, logger, "draw store write failed");
2171
+ }
2172
+ };
2173
+ return {
2174
+ /** 面板保存的出图开关;`null` = 回退 patch 默认值。 */
2175
+ async enabled() {
2176
+ return (await current()).enabled;
2177
+ },
2178
+ /** 面板保存的模型偏好;`null` = 自动。 */
2179
+ async modelId() {
2180
+ return (await current()).modelId;
2181
+ },
2182
+ /** 这个 profile 是否被显式配置过。 */
2183
+ async isConfigured() {
2184
+ const payload = await current();
2185
+ return payload.enabled !== null || payload.modelId !== null;
2186
+ },
2187
+ /** 保存开关。 */
2188
+ async saveEnabled(enabled) {
2189
+ if (typeof enabled !== "boolean") throw new TypeError("draw switch expects a boolean");
2190
+ return patchPayload({ enabled });
2191
+ },
2192
+ /** 保存模型偏好(`null` = 清掉,回到自动)。 */
2193
+ async saveModelId(modelId) {
2194
+ if (modelId !== null && (typeof modelId !== "string" || modelId.trim() === "")) throw new TypeError("draw model expects a non-empty string or null");
2195
+ return patchPayload({ modelId });
2196
+ },
2197
+ /** 清掉两个字段(回到「没配过」)。 */
2198
+ async reset() {
2199
+ return patchPayload({
2200
+ enabled: null,
2201
+ modelId: null
2202
+ });
2203
+ },
2204
+ /** 落盘路径(诊断与测试用)。 */
2205
+ filePath
2206
+ };
1721
2207
  }
1722
2208
 
1723
2209
  //#endregion
1724
- //#region src/host/publish-core.ts
2210
+ //#region src/host/draw.ts
1725
2211
  /**
1726
- * The shared bones of a provider publisher — the parts that MUST NOT differ
1727
- * between upstreams, factored out so they cannot drift.
2212
+ * 魔搭出图(`/v1/images/generations`)的**纯逻辑半边**——协议形状的解析与判定。
1728
2213
  *
1729
- * The publisher (`provider-publish.ts` for the ModelScope provider) runs the
1730
- * same control plane: a publish queue, a `disposed` gate, and a single-point
1731
- * pair registration that doubles as the rollback path. Those three are
1732
- * load-bearing and pinned by tests — and the rollback is the worst place to
1733
- * discover a divergence, because it only runs once something has already failed.
1734
- * When they were written twice, keeping them in sync relied on comments in one
1735
- * file pointing at the other; here there is one copy.
2214
+ * 为什么是这个形状:魔搭的生图是**异步两段式**(提交得 `task_id`,轮询才出图),
2215
+ * 与姊妹插件 `dsh-connect-sensenova-token-plan/src/host/draw.ts` 的**同步单发**
2216
+ * (`drawOnce` 一次 fetch 即 `parseDrawResponse` 取 `data[0].url`)形状不兼容,
2217
+ * **不能照抄**。可复用的只有外壳(endpoint 拼接、冷却门、失败文案),核心必须新写。
2218
+ * 协议依据全部来自 `docs/SPIKE-image-gen.md` 的真机实测,非文档转述。
1736
2219
  *
1737
- * What is deliberately NOT shared: the publish GATE and the state shape. The
1738
- * ModelScope side decides from "switch on?" plus a persisted catalog and an
1739
- * allow-list, and restores `entries`/`enabledIds`/quota ids on a rollback;
1740
- * another provider's publisher decides from "switch on?" plus "is there a
1741
- * credential?" and restores its roster. That difference is real domain
1742
- * difference, and folding it into one parameterized state machine would make a
1743
- * publish unreadable — every reader would have to read the configuration to
1744
- * know what one does.
2220
+ * 本模块**只做纯函数**:endpoint 构造、请求体构造、响应解析、状态判定、失败分诊。
2221
+ * 它**不发起任何请求、不碰凭据、不注册工具**——那半边(fetch 注入、Host 工具注册)
2222
+ * 与 `llm-adapter.ts` 一样 peer-dependent,测试覆盖同样是零(docs/ROADMAP.md Backlog)。
2223
+ * 刻意 peer-free:纯函数部分能进离线 `npm test` 门禁,`test/draw.test.mjs` 钉住形状。
1745
2224
  *
1746
- * Peer-free: touches no runtime peer.
2225
+ * 三条承重结论(全部实测,推翻了几条广泛流传的错误写法):
1747
2226
  *
1748
- * @module dsh-connect-modelscope-token-plan/publish-core
2227
+ * 1. **轮询必须带 `X-ModelScope-Task-Type: image_generation`**,否则上游一律返回
2228
+ * `task not found` + `task_status: FAILED`——**那是假失败**,任务实际正常。
2229
+ * 缺这个头的实现会在第一次轮询就误判失败并放弃一张已经能出的图。
2230
+ * 2. **提交响应的 `task_status` 不可信**:实测每次提交都回 `SUCCEED`,包括最终确实
2231
+ * `FAILED` 的那次。所以提交只认 `task_id`,**成功与否只看轮询终态**。
2232
+ * 3. **`FAILED` 没有可分诊的形状**:`errors.code` 实测为 `0`、`errors.message` 为
2233
+ * 空串,没有顶层 `message`。任何「从错误里读出原因」的承诺都是编造——本模块的
2234
+ * `describeDrawFailure` 因此**明确承认原因不可知**,只报可确定的形态差异。
2235
+ *
2236
+ * @module dsh-connect-modelscope-token-plan/draw
2237
+ */
2238
+ /**
2239
+ * 出图请求的截止时间(墙钟毫秒)。
2240
+ *
2241
+ * **15 分钟**:实测同一模型的耗时在 61s–537s 之间大幅波动(免费池排队),
2242
+ * 且**超时放弃不等于任务死亡**——上游任务会继续跑完(记录之后还会被回收,
2243
+ * 见 docs/SPIKE-image-gen.md §坑 5)。宁可多等,不要扔掉一张已在生成的图。
2244
+ */
2245
+ const DRAW_TIMEOUT_MS = 9e5;
2246
+ /**
2247
+ * 两次轮询之间的间隔(毫秒)。
2248
+ *
2249
+ * 取 15s 的**真正理由**是「够用且不浪费」:实测最慢一次 537s,15s 间隔意味着
2250
+ * 最多几十次查询,代价可忽略;而更密(社区示例的 5s)没有观测收益。
2251
+ *
2252
+ * ⚠️ 早先曾写「更密的轮询拖慢任务」,**该归因已推翻**——后续对照显示同一模型、
2253
+ * 同样 15s 轮询的耗时是 61s → 261s → 537s 单调恶化,是**上游排队时长在波动**。
2254
+ * 那次构成「证据」的 5s vs 30s 对照,控制变量没做,不足以支撑因果。
2255
+ * 详见 docs/SPIKE-image-gen.md §坑 4(含撤回记录)。
2256
+ */
2257
+ const DRAW_POLL_INTERVAL_MS = 15e3;
2258
+ /** 轮询的总截止时间;与 `DRAW_TIMEOUT_MS` 同量级但独立可调(诊断时可放宽)。 */
2259
+ const DRAW_POLL_DEADLINE_MS = DRAW_TIMEOUT_MS;
2260
+ /**
2261
+ * 轮询必须携带的头名与取值。
2262
+ *
2263
+ * 漏掉它 → 上游返回 `task not found` + `FAILED`,而任务其实在正常跑。这是本协议
2264
+ * 最贵的单点知识,也是第一份外部资料缺失的那一块。
2265
+ */
2266
+ const TASK_TYPE_HEADER = "X-ModelScope-Task-Type";
2267
+ const TASK_TYPE_IMAGE_GENERATION = "image_generation";
2268
+ /**
2269
+ * 一次出图请求由几个 HTTP 请求组成:一次提交 + N 次轮询。
2270
+ * 只作为文档性常量,供工具描述与测试引用。
2271
+ */
2272
+ const DRAW_PHASE = Object.freeze({
2273
+ submit: "submit",
2274
+ poll: "poll"
2275
+ });
2276
+ /**
2277
+ * 读一个响应的 `task_status`,取不到返回 `null`。
2278
+ *
2279
+ * 刻意**不猜**:字段缺失或类型不对都返回 `null`,由 `isTerminalStatus` 把它当作
2280
+ * 「非终态」继续等——这样一次形状漂移最多变成多轮轮询,不会变成误报失败。
2281
+ * @param {unknown} data - 已解析的 JSON。
2282
+ * @returns {DrawTaskStatus|null} 规范化状态,取不到为 `null`。
2283
+ */
2284
+ function readTaskStatus(data) {
2285
+ if (data === null || typeof data !== "object") return null;
2286
+ const raw = data.task_status;
2287
+ if (typeof raw !== "string") return null;
2288
+ const upper = raw.trim().toUpperCase();
2289
+ if (upper === "PENDING" || upper === "RUNNING" || upper === "SUCCEED" || upper === "FAILED") return upper;
2290
+ return null;
2291
+ }
2292
+ /**
2293
+ * 一个状态是不是终态。
2294
+ *
2295
+ * **只有** `SUCCEED` / `FAILED` 是终态;`null`(读不到)与一切未知值都**不是**。
2296
+ * 「未知值继续等」是刻意的:把上游新增的状态误判成失败会丢掉一张已经能出的图,
2297
+ * 而误判成进行中最多多等几轮(受 `DRAW_POLL_DEADLINE_MS` 兜底)。
2298
+ * @param {DrawTaskStatus|null} status - 规范化状态。
2299
+ * @returns {boolean} 是否终态。
2300
+ */
2301
+ function isTerminalStatus(status) {
2302
+ return status === "SUCCEED" || status === "FAILED";
2303
+ }
2304
+ /**
2305
+ * 从提交响应里取 `task_id`。
2306
+ *
2307
+ * 提交响应里那个 `task_status: "SUCCEED"` **一律忽略**(见模块头第 2 条:它与终态
2308
+ * 无关)。本函数只认 `task_id`,所以一个「提交即失败」的上游只要给了 id 就仍会被
2309
+ * 轮询,而不给 id 会被当作提交失败——这两种都符合实测。
2310
+ * @param {unknown} data - 已解析的提交响应。
2311
+ * @returns {string|null} 任务 id,取不到为 `null`。
2312
+ */
2313
+ function readTaskId(data) {
2314
+ if (data === null || typeof data !== "object") return null;
2315
+ const raw = data.task_id;
2316
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
2317
+ }
2318
+ /**
2319
+ * 从终态响应里取出成品图的 URL 列表。
2320
+ *
2321
+ * 实测形状是顶层 `output_images: string[]`。**刻意不读** `data[0].url`(OpenAI
2322
+ * 同步形状)与 `output.image_url`(外部资料误传的形状)——两者在魔搭上都不存在。
2323
+ * @param {unknown} data - 已解析的轮询响应。
2324
+ * @returns {string[]} 可用的图片 URL(保持上游顺序,已去空)。
2325
+ */
2326
+ function readOutputImages(data) {
2327
+ if (data === null || typeof data !== "object") return [];
2328
+ const raw = data.output_images;
2329
+ if (!Array.isArray(raw)) return [];
2330
+ const out = [];
2331
+ for (const item of raw) if (typeof item === "string" && item.trim() !== "") out.push(item.trim());
2332
+ return out;
2333
+ }
2334
+ /**
2335
+ * 构造出图请求体。
2336
+ *
2337
+ * 只发上游**确认消费**的字段:`model` / `prompt` / `image_url` / `size` / `steps` /
2338
+ * `guidance` / `seed` / `negative_prompt` 均被实测接受(SPIKE 实测 #9);`loras`
2339
+ * 也被确认消费(无效 LoRA id 在提交阶段直接 500,实测 #18)。**不发** `ref_image`
2340
+ * ——那是别家平台的写法,魔搭上不存在(SPIKE §对照)。
2341
+ *
2342
+ * `image_url` **一律发数组**,哪怕只有一张:官方示例的形状是数组(多图编辑要用),
2343
+ * 实测单张以数组发也通(#17)、以裸字符串发亦通(#8)。取数组是因为它的超集更贴近
2344
+ * 上游本意,也省掉一个分支。
2345
+ *
2346
+ * `n` 刻意**不作为请求字段**:实测目录里的出图模型都是单出模型,`n` 的支持度未
2347
+ * 逐项验证,传一个上游不认的参数比不传更危险。调用方要几张就发几次。
2348
+ * @param {object} options - 请求参数。
2349
+ * @param {string} options.model - 模型 id。
2350
+ * @param {string} options.prompt - 提示词。
2351
+ * @param {string|string[]} [options.imageUrl] - 输入图 URL,**字符串或数组均可**,
2352
+ * 内部一律归一为数组(官方形状;多图编辑要用数组)。
2353
+ * @param {string} [options.size] - `"宽x高"`,如 `"1024x1024"`(64 对齐)。
2354
+ * @param {number} [options.steps] - 采样步数。
2355
+ * @param {number} [options.guidance] - 引导系数。
2356
+ * @param {number} [options.seed] - 随机种子。
2357
+ * @param {string} [options.negativePrompt] - 负向提示词。
2358
+ * @param {string|Record<string, number>} [options.loras] - LoRA 配置:单个 repo id,
2359
+ * 或 `{id: weight}` 映射(官方:最多 6 个,权重和须为 1.0)。
2360
+ * @returns {object} 请求体。
2361
+ */
2362
+ function buildDrawBody(options) {
2363
+ const body = {
2364
+ model: strOrEmpty(options.model),
2365
+ prompt: strOrEmpty(options.prompt)
2366
+ };
2367
+ const imageUrl = normalizeImageUrlList(options.imageUrl);
2368
+ if (imageUrl.length > 0) body.image_url = imageUrl;
2369
+ const size = strOrEmpty(options.size);
2370
+ if (size !== "") body.size = size;
2371
+ if (Number.isFinite(options.steps)) body.steps = options.steps;
2372
+ if (Number.isFinite(options.guidance)) body.guidance = options.guidance;
2373
+ if (Number.isFinite(options.seed)) body.seed = options.seed;
2374
+ const negativePrompt = strOrEmpty(options.negativePrompt);
2375
+ if (negativePrompt !== "") body.negative_prompt = negativePrompt;
2376
+ const loras = options.loras;
2377
+ if (typeof loras === "string" && loras.trim() !== "") body.loras = loras.trim();
2378
+ else if (loras !== null && typeof loras === "object") {
2379
+ const entries = Object.entries(loras).filter(([id, weight]) => id.trim() !== "" && Number.isFinite(weight));
2380
+ if (entries.length > 0) body.loras = Object.fromEntries(entries);
2381
+ }
2382
+ return body;
2383
+ }
2384
+ /**
2385
+ * 把「一张或多张输入图」归一成 URL 数组。
2386
+ *
2387
+ * 单张给字符串上游也吃(实测 #8),但官方示例与多图编辑都用数组(#17 实测通),
2388
+ * 故一律归一为数组。逐项过滤空串,**保持调用方给定的顺序**——多图编辑里顺序即语义
2389
+ * (官方示例:「图一中的狗,去追图二中的飞盘」)。
2390
+ * @param {unknown} value - 单个 URL、URL 数组,或缺省。
2391
+ * @returns {string[]} 归一后的 URL 数组(可为空)。
2392
+ */
2393
+ function normalizeImageUrlList(value) {
2394
+ const raw = Array.isArray(value) ? value : [value];
2395
+ const out = [];
2396
+ for (const item of raw) if (typeof item === "string" && item.trim() !== "") out.push(item.trim());
2397
+ return out;
2398
+ }
2399
+ /**
2400
+ * 从任意配置里读出「可用作输入图」的 URL。
2401
+ *
2402
+ * 承接 agent 工具的 `image_url` 参数。**只接受 http(s) 与 data URL**——其它形态
2403
+ * (本地路径)上游取不到,转发过去只会换回一个原因不可知的 `FAILED`。实测尚未
2404
+ * 验证 data URL 是否被上游接受(SPIKE 待办),但**转发它不会更糟**,而拦掉它会让
2405
+ * 本地图片彻底不可用;故放行并在档案里留待验证。
2406
+ * @param {unknown} value - 原始参数。
2407
+ * @returns {string|null} 规范化后的 URL,不可用时为 `null`。
2408
+ */
2409
+ function normalizeImageUrlInput(value) {
2410
+ const raw = typeof value === "string" ? value.trim() : "";
2411
+ if (raw === "") return null;
2412
+ if (/^https?:\/\//i.test(raw)) return raw;
2413
+ if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(raw)) return raw;
2414
+ return null;
2415
+ }
2416
+ /**
2417
+ * 把一次失败转成 agent 与面板都能读的一句话。
2418
+ *
2419
+ * **关于魔搭 `FAILED` 的诚实边界**:实测唯一的失败样本是
2420
+ * `{"errors":{"code":0,"message":""}, "task_status":"FAILED"}`——**原因不可知**。
2421
+ * 所以这里只报「可确定的形态事实」(HTTP 码 / 轮询未出终态 / 明确的原因文本),
2422
+ * **不从空错误里编造原因**,并在原因确实拿不到时明说「上游未给出原因」。这是本仓库
2423
+ * README「诚实声明」的要求。
2424
+ *
2425
+ * 凭据永不入消息:body 文本可能回显 `ms-` 令牌,过 `redactSecrets` 后再截断。
2426
+ * @param {object} failure - 失败信息。
2427
+ * @param {number} [failure.status] - HTTP 状态码(提交/轮询的传输层失败)。
2428
+ * @param {string} [failure.bodyText] - 上游 body 原文。
2429
+ * @param {string} [failure.taskStatus] - 轮询读到的状态。
2430
+ * @param {string} [failure.cause] - 已知原因(超时 / 没有可用图片 / 拿不到 task_id 等)。
2431
+ * @returns {string} 一行说明。
2432
+ */
2433
+ function describeDrawFailure(failure) {
2434
+ const text = redactSecrets(typeof failure.bodyText === "string" ? failure.bodyText : "").slice(0, 300);
2435
+ const status = Number.isFinite(failure.status) ? Number(failure.status) : 0;
2436
+ const tail = text === "" ? "" : `; body: ${text}`;
2437
+ if (status === 401 || status === 403) return `出图失败:HTTP ${status} —— 访问令牌无效、无权访问该模型,或账号未完成邮箱验证/实名(后者是实测遇到过的真实拦截)。请在面板确认令牌与账号状态${tail}`;
2438
+ if (status === 404) return `出图失败:HTTP 404 —— 端点或模型不存在(检查 apiBase 与模型 id)${tail}`;
2439
+ if (status === 429) return `出图失败:HTTP 429 —— 上游限流,请稍后重试${tail}`;
2440
+ if (status >= 500) return `出图失败:HTTP ${status} —— 上游服务异常,稍后重试${tail}`;
2441
+ const cause = strOrEmpty(failure.cause);
2442
+ if (cause !== "") return `出图失败:${cause}${tail}`;
2443
+ const taskStatus = strOrEmpty(failure.taskStatus);
2444
+ if (taskStatus === "FAILED") return "出图失败:上游任务 FAILED,但未给出失败原因(实测 errors.code=0、message 为空),换模型或稍后重试";
2445
+ if (taskStatus !== "") return `出图失败:任务停在 ${taskStatus}(非终态),已超过截止时间`;
2446
+ return `出图失败:上游未给出可诊断的失败信息${tail}`;
2447
+ }
2448
+ /**
2449
+ * 冷却门:一次失败后的一段时间内拒绝再试。
2450
+ *
2451
+ * 借 sensenova 那份的形状(它也从 `dsh-draw-router` 抄来):出图慢且贵,一次失败
2452
+ * 后立刻重试既容易撞限流又浪费等待窗口。纯闭包,无依赖,可离线测。
2453
+ * @param {number} cooldownMs - 冷却窗口(毫秒)。
2454
+ * @returns {{fail: () => void, remainingMs: () => number, clear: () => void}} 冷却门。
2455
+ */
2456
+ function createDrawCooldown(cooldownMs) {
2457
+ const window = Math.max(0, Math.floor(cooldownMs));
2458
+ let until = 0;
2459
+ return {
2460
+ /** 记一次失败,从此刻起进入冷却。 */
2461
+ fail() {
2462
+ until = Date.now() + window;
2463
+ },
2464
+ /** 剩余冷却毫秒;0 表示可以发起。 */
2465
+ remainingMs() {
2466
+ return Math.max(0, until - Date.now());
2467
+ },
2468
+ /** 清掉冷却(例如面板里手动排障后)。 */
2469
+ clear() {
2470
+ until = 0;
2471
+ }
2472
+ };
2473
+ }
2474
+ /** 读一个字符串,否则空串(本地化的小工具,避免为 3 行代码再引 util 的依赖面)。 */
2475
+ function strOrEmpty(value) {
2476
+ return typeof value === "string" ? value.trim() : "";
2477
+ }
2478
+ /** 凭据脱敏:出图失败消息可能带上游回显的 body,令牌不能随它进日志或面板。 */
2479
+ function redactSecrets(text) {
2480
+ return text.replace(/(["']?[Aa]uthorization["']?\s*[:=]\s*["']?)(?!Bearer\s)[^"',;\s]+/g, "$1[REDACTED]").replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [REDACTED]").replace(/\bms-[A-Za-z0-9-]{16,}/g, "ms-[REDACTED]");
2481
+ }
2482
+
2483
+ //#endregion
2484
+ //#region src/host/draw-runtime.ts
2485
+ /**
2486
+ * 出图的 **peer-dependent 半边**:真正发起请求、驱动异步两段式、注册成 agent 工具。
2487
+ *
2488
+ * 与 `llm-adapter.ts` 同一个处境——import Host 发行的 peer(`@deepseek-ai/dsh-tools`
2489
+ * 的 `defineTool` 由调用方惰性注入),**不能由离线单测套件导入**。纯逻辑全部住在
2490
+ * `draw.ts`(已由 `test/draw.test.mjs` 覆盖 39 组),本文件只做「接线 + I/O」:
2491
+ * 轮询循环、fetch、工具定义。改错本文件**不会让任何门禁变红**。
2492
+ *
2493
+ * 与姊妹插件的关键差异(形状不兼容,不能照抄):
2494
+ * `sensenova` 的出图是**同步单发**——一次 `fetch` 拿 `data[0].url` 就结束。魔搭是
2495
+ * **异步两段式**:提交拿 `task_id`,再轮询到终态取 `output_images[0]`。所以这里多
2496
+ * 一个 `pollTask` 循环,且它必须带 `X-ModelScope-Task-Type`(漏了 = 假失败)。
2497
+ *
2498
+ * 三条承重纪律:
2499
+ * 1. 提交响应的 `task_status` **一律忽略**(实测恒为 SUCCEED,连最终 FAILED 的那次
2500
+ * 也是),成功与否只看轮询终态。`readTaskId` 已把这条钉在纯逻辑层。
2501
+ * 2. 轮询间隔 15s(`DRAW_POLL_INTERVAL_MS`),**不是**社区常见的 5s——实测更密的
2502
+ * 轮询只与更慢相关。
2503
+ * 3. 失败原因实测可能**不可知**(`errors.code:0` + 空 message)。`describeDrawFailure`
2504
+ * 因此明说「未给出原因」,本文件也**不从空错误里编造**。
2505
+ *
2506
+ * @module dsh-connect-modelscope-token-plan/draw-runtime
2507
+ */
2508
+ /** agent 工具名。带插件前缀,避免与 `dsh-draw-router` 的 `draw_image` 撞名。 */
2509
+ const DRAW_TOOL_NAME = "modelscope_draw_image";
2510
+ /**
2511
+ * 已知能**纯文生图**的目录内模型 id(2026-10-16 真机复核)。
2512
+ *
2513
+ * 为什么需要手工策展:api-inference 目录里至今**没有**平台声明的 `text-to-image`
2514
+ * 条目,只有 2 条 `image-to-image`——而两者行为**不同**:
2515
+ * `Qwen/Qwen-Image-Edit` 不带 `image_url` 纯文生图可用(本轮实测:提交成功、两次
2516
+ * 15min 排队超时后于 9.7min 出图);`MusePublic/Qwen-Image-Edit` 纯文生图被上游
2517
+ * 直接拒绝(提交阶段 HTTP 4xx:`image_url is required for model: …`)。
2518
+ * 能力枚举把两者都归为 `image-to-image`,区分不了这个差异,所以这里用实测知识兜底:
2519
+ * 目录里没有 `text-to-image` 可挑时,默认偏向这个已知能用的 id,而不是「目录第一个」
2520
+ * (真实目录顺序 `MusePublic/…` 在前,照旧逻辑纯文生图必挂)。
2521
+ */
2522
+ const KNOWN_T2I_MODEL_ID = "Qwen/Qwen-Image-Edit";
2523
+ /** 一次失败的冷却窗:出图慢,失败后立刻重试既易撞限流又白等。 */
2524
+ const DRAW_FAILURE_COOLDOWN_MS = 6e4;
2525
+ /**
2526
+ * 把 OpenAI 兼容 base 拼成出图端点。
2527
+ *
2528
+ * `…/v1` → `…/v1/images/generations`;已带该后缀则原样;更深路径回卷到 `/v1`。
2529
+ * 与 sensenova 的同名函数同源(那是受控复制:endpoint 拼接与上游是谁无关)。
2530
+ * @param {string} apiBase - 配置里的 apiBase。
2531
+ * @returns {string} 完整端点。
2532
+ */
2533
+ function buildGenerationsEndpoint(apiBase) {
2534
+ const trimmed = str(apiBase, "").replace(/\/+$/, "");
2535
+ if (/\/images\/generations$/.test(trimmed)) return trimmed;
2536
+ if (/\/v1$/.test(trimmed)) return `${trimmed}/images/generations`;
2537
+ if (/\/v1\//.test(trimmed)) return trimmed.replace(/\/v1\/.*$/, "/v1/images/generations");
2538
+ return `${trimmed}/v1/images/generations`;
2539
+ }
2540
+ /**
2541
+ * 拼轮询端点。task_id 上游是 UUID(实测失败态出现过短 id),一律走 path。
2542
+ * @param {string} apiBase - 配置里的 apiBase。
2543
+ * @param {string} taskId - 任务 id。
2544
+ * @returns {string} 完整端点。
2545
+ */
2546
+ function buildTasksEndpoint(apiBase, taskId) {
2547
+ return `${buildGenerationsEndpoint(apiBase).replace(/\/images\/generations$/, "")}/tasks/${encodeURIComponent(taskId)}`;
2548
+ }
2549
+ /**
2550
+ * 一个响应体到底是不是「终态」——读不到就当还没好,继续等。
2551
+ *
2552
+ * 与 `isTerminalStatus` 的分工:那个管状态语义,这个管「响应本身能不能读」。
2553
+ * 上游偶发返回非 JSON(CDN 错误页)时,当作暂时性抖动继续等,而不是判失败——
2554
+ * 实测过一个 task 在正常轮询下始终 SUCCEED,没有理由把一次解析失败升级成终局。
2555
+ * @param {unknown} data - 已解析的 JSON。
2556
+ * @returns {boolean} 是否终态。
2557
+ */
2558
+ function isTerminalPayload(data) {
2559
+ return isTerminalStatus(readTaskStatus(data));
2560
+ }
2561
+ /**
2562
+ * 真正跑一次出图:提交 + 轮询到终态。
2563
+ *
2564
+ * 两段都必须带各自必需的头:提交带 `X-ModelScope-Async-Mode`(实测可省但官方示例
2565
+ * 都带,带上无害),轮询带 `X-ModelScope-Task-Type`(**必需**,漏了必假失败)。
2566
+ * @param {object} options - 调用参数。
2567
+ * @param {DrawFetch} options.fetchImpl - 注入的 fetch。
2568
+ * @param {string} options.apiBase - apiBase。
2569
+ * @param {string} options.apiKey - 访问令牌(绝不出现在返回文本里)。
2570
+ * @param {string} options.model - 模型 id。
2571
+ * @param {string} options.prompt - 提示词。
2572
+ * @param {string|string[]} [options.imageUrl] - 输入图(图生图 / 多图编辑)。
2573
+ * @param {string} [options.size] - `"宽x高"`。
2574
+ * @param {number} [options.steps] - 采样步数。
2575
+ * @param {number} [options.guidance] - 引导系数。
2576
+ * @param {number} [options.seed] - 种子。
2577
+ * @param {string} [options.negativePrompt] - 负向提示词。
2578
+ * @param {number} [options.pollDeadlineMs] - 轮询截止。
2579
+ * @param {number} [options.pollIntervalMs] - 轮询间隔(测试可缩短)。
2580
+ * @returns {Promise<DrawOutcome>} 出图结果。
2581
+ * @throws {Error} 提交失败、任务 FAILED、或超时——消息已过 `describeDrawFailure`。
2582
+ */
2583
+ async function runDrawOnce({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl, size, steps, guidance, seed, negativePrompt, pollDeadlineMs = DRAW_POLL_DEADLINE_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS }) {
2584
+ const headers = {
2585
+ Authorization: `Bearer ${apiKey}`,
2586
+ "Content-Type": "application/json"
2587
+ };
2588
+ const body = buildDrawBody({
2589
+ model,
2590
+ prompt,
2591
+ ...imageUrl !== void 0 ? { imageUrl } : {},
2592
+ ...size !== void 0 ? { size } : {},
2593
+ ...steps !== void 0 ? { steps } : {},
2594
+ ...guidance !== void 0 ? { guidance } : {},
2595
+ ...seed !== void 0 ? { seed } : {},
2596
+ ...negativePrompt !== void 0 ? { negativePrompt } : {}
2597
+ });
2598
+ let submitted;
2599
+ try {
2600
+ submitted = await fetchImpl(buildGenerationsEndpoint(apiBase), {
2601
+ method: "POST",
2602
+ headers: {
2603
+ ...headers,
2604
+ "X-ModelScope-Async-Mode": "true"
2605
+ },
2606
+ body: JSON.stringify(body)
2607
+ });
2608
+ } catch (error) {
2609
+ throw new Error(describeDrawFailure({ cause: `提交请求失败:${error instanceof Error ? error.message : String(error)}` }));
2610
+ }
2611
+ const submitText = await safeText(submitted);
2612
+ if (!submitted.ok) throw new Error(describeDrawFailure({
2613
+ status: submitted.status,
2614
+ bodyText: submitText
2615
+ }));
2616
+ const taskId = readTaskId(await safeJson(submitted, submitText));
2617
+ if (taskId === null) throw new Error(describeDrawFailure({
2618
+ bodyText: submitText,
2619
+ cause: "提交响应里没有 task_id"
2620
+ }));
2621
+ const deadline = Date.now() + Math.max(1e3, Math.floor(pollDeadlineMs));
2622
+ const interval = Math.max(1, Math.floor(pollIntervalMs));
2623
+ let polls = 0;
2624
+ let lastStatus;
2625
+ for (;;) {
2626
+ if (Date.now() >= deadline) throw new Error(describeDrawFailure({ cause: `超过截止时间(${Math.round(pollDeadlineMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${taskId})**可能仍在生成**——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关。稍后可重试;注意超时后原任务的结果不保证还能取回` }));
2627
+ await sleep(interval);
2628
+ polls++;
2629
+ let polled;
2630
+ try {
2631
+ polled = await fetchImpl(buildTasksEndpoint(apiBase, taskId), {
2632
+ method: "GET",
2633
+ headers: {
2634
+ ...headers,
2635
+ [TASK_TYPE_HEADER]: TASK_TYPE_IMAGE_GENERATION
2636
+ }
2637
+ });
2638
+ } catch {
2639
+ continue;
2640
+ }
2641
+ const pollText = await safeText(polled);
2642
+ if (!polled.ok) throw new Error(describeDrawFailure({
2643
+ ...polled.status !== void 0 ? { status: polled.status } : {},
2644
+ ...pollText !== "" ? { bodyText: pollText } : {},
2645
+ ...lastStatus !== void 0 ? { taskStatus: lastStatus } : {}
2646
+ }));
2647
+ const data = await safeJson(polled, pollText);
2648
+ const status = readTaskStatus(data);
2649
+ if (status !== null) lastStatus = status;
2650
+ if (!isTerminalPayload(data)) continue;
2651
+ if (status === "FAILED") throw new Error(describeDrawFailure({
2652
+ taskStatus: "FAILED",
2653
+ bodyText: pollText
2654
+ }));
2655
+ const images = readOutputImages(data);
2656
+ if (images.length === 0) throw new Error(describeDrawFailure({
2657
+ taskStatus: "SUCCEED",
2658
+ bodyText: pollText,
2659
+ cause: "任务报 SUCCEED 但没有可用图片"
2660
+ }));
2661
+ return {
2662
+ model,
2663
+ images,
2664
+ taskId,
2665
+ polls
2666
+ };
2667
+ }
2668
+ }
2669
+ /**
2670
+ * 从目录里挑一个出图模型。
2671
+ *
2672
+ * 优先级(高 → 低):
2673
+ * 1. **工具显式请求**——即使不在目录里也放行(手工覆盖,上游会自己报错);
2674
+ * 2. **首选模型**(面板保存的模型偏好,或配置 `drawModelId`)——用户点了谁就是
2675
+ * 谁,同样不校验在不在目录(用户可能先配好、等模型上线)。此前这个偏好只在
2676
+ * `pickDrawModel` 返回 null 时才生效,目录非空时等于死配置——面板显示的
2677
+ * preferredModel 与实际挑中的模型会说两套话;
2678
+ * 3. 目录里**平台声明**为 `text-to-image` 的条目(`textToImageModelIds`,将来目录
2679
+ * 补上真文生图模型时自动优先生效);
2680
+ * 4. **已知能纯文生图**的模型(`KNOWN_T2I_MODEL_ID`)——今天目录的坑位,见该
2681
+ * 常量的注释:真实目录里它是第二个,照旧取「第一个」会挑中纯文生图必挂的
2682
+ * `MusePublic/…`;
2683
+ * 5. 目录里第一个出图模型(`isImageGenModel` 的严格方向:只认明确声明,缺字段
2684
+ * 不算)。
2685
+ *
2686
+ * 目录为空且既无显式请求也无首选模型 → `null`,由调用方给出可操作的错误,
2687
+ * 而不是发一个注定失败的请求。
2688
+ * @param {unknown} entries - 归一目录条目。
2689
+ * @param {string} [requested] - 工具调用里显式给的 model。
2690
+ * @param {string} [preferred] - 首选模型 id(面板偏好或配置 `drawModelId`)。
2691
+ * @returns {string|null} 选中的 id。
2692
+ */
2693
+ function pickDrawModel(entries, requested, preferred) {
2694
+ const want = str(requested, "");
2695
+ if (want !== "") return want;
2696
+ const preferredId = str(preferred, "");
2697
+ if (preferredId !== "") return preferredId;
2698
+ const ids = imageGenModelIds(entries);
2699
+ if (ids.length === 0) return null;
2700
+ const t2i = textToImageModelIds(entries);
2701
+ if (t2i.length > 0) return t2i[0] ?? null;
2702
+ if (ids.includes("Qwen/Qwen-Image-Edit")) return KNOWN_T2I_MODEL_ID;
2703
+ return ids[0] ?? null;
2704
+ }
2705
+ /**
2706
+ * 目录里可用于出图的模型 id(工具结果里回显,方便 agent 与用户看到实际候选)。
2707
+ * @param {unknown} entries - 归一目录条目。
2708
+ * @returns {string[]} id 列表。
2709
+ */
2710
+ function drawModelIds(entries) {
2711
+ return imageGenModelIds(entries);
2712
+ }
2713
+ /**
2714
+ * 装配 agent 工具定义。
2715
+ *
2716
+ * 工具只做「参数归一 → 跑一次 → 组装结果文本」,所有判定都委托给 `draw.ts` 的纯函数。
2717
+ * @param {object} options - 装配参数。
2718
+ * @param {(definition: object) => unknown} options.defineTool - peer 的工厂。
2719
+ * @param {DrawFetch} options.fetchImpl - 注入的 fetch。
2720
+ * @param {() => Promise<string>} options.resolveApiKey - 每次调用现读令牌。
2721
+ * @param {() => unknown|Promise<unknown>} [options.getEntries] - 目录条目读取器。
2722
+ * @param {object} [options.settings] - `{ apiBase, drawModelId, drawTimeoutMs }`。
2723
+ * @param {ReturnType<typeof createDrawCooldown>} [options.cooldown] - 冷却门。
2724
+ * @param {() => boolean} [options.isDisposed] - 卸载闸。
2725
+ * @returns {unknown} 已定义的 agent 工具。
2726
+ */
2727
+ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, settings, getPreferredModelId, cooldown = createDrawCooldown(DRAW_FAILURE_COOLDOWN_MS), isDisposed = () => false }) {
2728
+ const timeoutMs = Math.max(3e4, Math.floor(Number(settings?.drawTimeoutMs) > 0 ? Number(settings?.drawTimeoutMs) : DRAW_TIMEOUT_MS));
2729
+ const apiBase = str(settings?.apiBase, "https://api-inference.modelscope.cn/v1");
2730
+ return defineTool({
2731
+ name: DRAW_TOOL_NAME,
2732
+ description: "Generate or edit an image with the ModelScope (魔搭) API-Inference token. Text-to-image by default; pass `imageUrl` to edit an existing image (image-to-image), or an array of URLs for multi-image editing. Omit `model` to use the catalogue's image model. This call is ASYNCHRONOUS upstream: the model queues first, so latency varies from ~1 minute to 15+ minutes depending on the free pool; it polls until the task reaches a terminal state. Available image model ids are reported in the result.",
2733
+ parameters: {
2734
+ prompt: {
2735
+ type: "string",
2736
+ required: true,
2737
+ description: "Image prompt; describe the target image (for edits, describe the intended change)"
2738
+ },
2739
+ model: {
2740
+ type: "string",
2741
+ description: "ModelScope image model id; defaults to the catalogue's image model"
2742
+ },
2743
+ imageUrl: {
2744
+ type: "string",
2745
+ 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."
2746
+ },
2747
+ size: {
2748
+ type: "string",
2749
+ description: "Image size as WIDTHxHEIGHT, e.g. 1024x1024 (64-aligned)"
2750
+ },
2751
+ steps: {
2752
+ type: "number",
2753
+ description: "Sampling steps (platform range 1-100)"
2754
+ },
2755
+ guidance: {
2756
+ type: "number",
2757
+ description: "Prompt guidance scale (platform range 1.5-20)"
2758
+ },
2759
+ seed: {
2760
+ type: "number",
2761
+ description: "Random seed"
2762
+ },
2763
+ negativePrompt: {
2764
+ type: "string",
2765
+ description: "Negative prompt"
2766
+ }
2767
+ },
2768
+ output: {
2769
+ schema: {
2770
+ type: "object",
2771
+ additionalProperties: false,
2772
+ properties: {
2773
+ source: { type: "string" },
2774
+ model: { type: "string" },
2775
+ url: { type: "string" },
2776
+ prompt: { type: "string" },
2777
+ hint: { type: "string" }
2778
+ }
2779
+ },
2780
+ render: (_args, result) => [{
2781
+ type: "text",
2782
+ text: result?.hint || "图片已生成"
2783
+ }]
2784
+ },
2785
+ timeoutMs: timeoutMs + 1e4,
2786
+ async execute(params) {
2787
+ if (isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
2788
+ const prompt = str(params?.prompt, "");
2789
+ if (prompt === "") throw new Error("prompt is required");
2790
+ const remaining = cooldown.remainingMs();
2791
+ if (remaining > 0) throw new Error(`draw cooldown: 上一次出图失败,${Math.ceil(remaining / 1e3)}s 后再试`);
2792
+ const apiKey = await resolveApiKey();
2793
+ if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("MODELSCOPE_API_KEY 未配置:在面板粘贴 ms- 访问令牌,或设置该环境变量");
2794
+ let picked = [];
2795
+ try {
2796
+ picked = await getEntries?.() ?? [];
2797
+ } catch {
2798
+ picked = [];
2799
+ }
2800
+ const entries = Array.isArray(picked) ? picked : [];
2801
+ const configuredPreferred = str(settings?.drawModelId, "");
2802
+ const panelPreferred = typeof getPreferredModelId === "function" ? await getPreferredModelId().catch(() => null) : null;
2803
+ const preferred = typeof panelPreferred === "string" && panelPreferred.trim() !== "" ? panelPreferred : configuredPreferred;
2804
+ const model = pickDrawModel(entries, str(params?.model, ""), preferred);
2805
+ if (model === null) throw new Error("目录里没有出图模型(image-to-image / text-to-image 条目为空):确认令牌已配置、面板已至少轮询一次,且套餐含该模型");
2806
+ const rawImages = splitImageUrls(params?.imageUrl);
2807
+ const images = [];
2808
+ for (const raw of rawImages) {
2809
+ const normalized = normalizeImageUrlInput(raw);
2810
+ if (normalized === null) throw new Error(`imageUrl 不可用:${raw.slice(0, 80)} —— 只接受公网 http(s) 链接或 data:image/...;base64 内联数据(本地文件上游取不到,请先上传)`);
2811
+ images.push(normalized);
2812
+ }
2813
+ try {
2814
+ const size = str(params?.size, "");
2815
+ const steps = toPositiveInt(params?.steps);
2816
+ const guidance = toPositiveNumber(params?.guidance);
2817
+ const seed = toNonNegativeInt(params?.seed);
2818
+ const negativePrompt = str(params?.negativePrompt, "");
2819
+ const outcome = await runDrawOnce({
2820
+ fetchImpl,
2821
+ apiBase,
2822
+ apiKey,
2823
+ model,
2824
+ prompt,
2825
+ ...images.length > 0 ? { imageUrl: images } : {},
2826
+ ...size !== "" ? { size } : {},
2827
+ ...steps !== void 0 ? { steps } : {},
2828
+ ...guidance !== void 0 ? { guidance } : {},
2829
+ ...seed !== void 0 ? { seed } : {},
2830
+ ...negativePrompt !== "" ? { negativePrompt } : {},
2831
+ pollDeadlineMs: timeoutMs
2832
+ });
2833
+ const available = drawModelIds(entries);
2834
+ const urls = outcome.images.join("\n");
2835
+ return {
2836
+ source: "modelscope",
2837
+ model: outcome.model,
2838
+ url: outcome.images[0] ?? "",
2839
+ prompt,
2840
+ hint: `图片已生成(上游异步任务,${outcome.polls} 次轮询,task ${outcome.taskId})\n模型: ${outcome.model}\n${urls}` + (available.length > 0 ? `\n可用出图模型: ${available.join(", ")}` : "")
2841
+ };
2842
+ } catch (error) {
2843
+ cooldown.fail();
2844
+ throw error;
2845
+ }
2846
+ }
2847
+ });
2848
+ }
2849
+ /**
2850
+ * 把工具参数里的输入图拆成列表。
2851
+ *
2852
+ * 官方支持多图(数组形状),而 agent 工具的参数类型是标量,所以约定**用换行分隔**
2853
+ * 多张。同时容忍调用方直接传数组(内部调用与测试会这么用)。
2854
+ * @param {unknown} value - 原始参数。
2855
+ * @returns {string[]} 拆好的 URL(未归一,调用方逐项过闸)。
2856
+ */
2857
+ function splitImageUrls(value) {
2858
+ if (Array.isArray(value)) return value.map((v) => String(v)).filter((v) => v.trim() !== "");
2859
+ const raw = str(value, "");
2860
+ if (raw === "") return [];
2861
+ return raw.split(/\r?\n/).map((v) => v.trim()).filter((v) => v !== "");
2862
+ }
2863
+ /** 读正整数,非法返回 undefined(不下发无意义参数)。 */
2864
+ function toPositiveInt(value) {
2865
+ const n = Number(value);
2866
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : void 0;
2867
+ }
2868
+ /** 读非负整数(seed 可为 0)。 */
2869
+ function toNonNegativeInt(value) {
2870
+ const n = Number(value);
2871
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : void 0;
2872
+ }
2873
+ /** 读正浮点(guidance 可为小数)。 */
2874
+ function toPositiveNumber(value) {
2875
+ const n = Number(value);
2876
+ return Number.isFinite(n) && n > 0 ? n : void 0;
2877
+ }
2878
+ /** 读 body 文本,读不到给空串(错误分诊不能因为读 body 失败而失败)。 */
2879
+ async function safeText(response) {
2880
+ try {
2881
+ return await response.text();
2882
+ } catch {
2883
+ return "";
2884
+ }
2885
+ }
2886
+ /** 解析 JSON;读不到就返回 `null`,由调用方按「非终态/无 id」处理。 */
2887
+ async function safeJson(response, text) {
2888
+ try {
2889
+ return await response.json();
2890
+ } catch {
2891
+ try {
2892
+ return text === "" ? null : JSON.parse(text);
2893
+ } catch {
2894
+ return null;
2895
+ }
2896
+ }
2897
+ }
2898
+ /** 可注入的 sleep(测试可覆写;这里只提供默认实现)。 */
2899
+ function sleep(ms) {
2900
+ return new Promise((resolve) => setTimeout(resolve, ms));
2901
+ }
2902
+
2903
+ //#endregion
2904
+ //#region src/host/draw-tool-state.ts
2905
+ /** 当前缺席原因(`null` = 未登记 / 已清掉)。 */
2906
+ let note = null;
2907
+ /**
2908
+ * 记下缺席原因(`null` 清空)。
2909
+ * @param {DrawToolAbsentReason|null} reason - 原因码。
2910
+ * @returns {void}
2911
+ */
2912
+ function setDrawToolNote(reason) {
2913
+ note = reason;
2914
+ }
2915
+ /**
2916
+ * 读当前缺席原因。
2917
+ * @returns {DrawToolAbsentReason|null} 原因码。
2918
+ */
2919
+ function drawToolNote() {
2920
+ return note;
2921
+ }
2922
+ /**
2923
+ * 清空(卸载时调用:下一个 Host 生命周期从干净的「未登记」开始)。
2924
+ * @returns {void}
2925
+ */
2926
+ function resetDrawToolNote() {
2927
+ note = null;
2928
+ }
2929
+
2930
+ //#endregion
2931
+ //#region src/host/switch-precedence.ts
2932
+ /**
2933
+ * 所有 opt-in 开关共用的唯一裁决:「面板保存值 vs 配置默认值」。
2934
+ *
2935
+ * 规则只有一条:**面板保存值赢,否则配置默认值说了算**。来源标签(`panel` /
2936
+ * `config`)随答案一起返回,面板才能说出是谁在管。规则收在一处,是为了让开关
2937
+ * 再也无法各自发明一套优先级方言;`test/switch-precedence.test.mjs` 钉死这个
2938
+ * 方言,照抄形状的新调用方会被它标成异类。
2939
+ *
2940
+ * 不服务「纯面板拥有、无配置默认值」的开关——那种没有可裁决的配置默认值,
2941
+ * 调用方直接读即可。peer-free,纯函数。
2942
+ *
2943
+ * @module dsh-connect-modelscope-token-plan/switch-precedence
2944
+ */
2945
+ /**
2946
+ * Resolve the effective boolean switch: a panel-saved value always wins,
2947
+ * otherwise the config default rules.
2948
+ * @param {boolean|null} panel - the panel-saved value (`null` = never saved).
2949
+ * @param {boolean} config - the patch-declared default.
2950
+ * @returns {boolean} the effective switch.
2951
+ */
2952
+ function resolveSwitchEnabled(panel, config) {
2953
+ return (panel ?? config) === true;
2954
+ }
2955
+ /**
2956
+ * Resolve the effective string preference (e.g. a draw-model id): a
2957
+ * panel-saved value wins, otherwise the config default rules.
2958
+ * @param {string|null} panel - the panel-saved value (`null` = never saved).
2959
+ * @param {string|undefined} config - the patch-declared default.
2960
+ * @returns {string|undefined} the effective preference.
2961
+ */
2962
+ function resolveSwitchValue(panel, config) {
2963
+ return panel ?? config;
2964
+ }
2965
+ /**
2966
+ * Where the effective value came from — the panel when it saved one, the
2967
+ * config otherwise. Rides with every resolved answer so the panel can name
2968
+ * the side in charge.
2969
+ * @param {boolean|string|null} panel - the panel-saved value.
2970
+ * @returns {"panel"|"config"}
2971
+ */
2972
+ function switchSource(panel) {
2973
+ return panel === null ? "config" : "panel";
2974
+ }
2975
+
2976
+ //#endregion
2977
+ //#region src/host/draw-register.ts
2978
+ /**
2979
+ * 出图工具的挂载副作用:读开关 → 解析目录 → 注册 agent 工具。
2980
+ *
2981
+ * 与 `provider-publish.ts` 的分工:那个管「魔搭作为 chat provider 的注册」,本文件
2982
+ * 管「给 agent 一个出图工具」。两者是**独立的开关**——回答的是不同问题,合并没有
2983
+ * 好处(用户可能想用工具出图但不想把魔搭塞进模型选择器,反之亦然)。
2984
+ *
2985
+ * 承重约束:**peer 的 tools registry 没有 unregister**。所以
2986
+ * - 注册只在挂载时尝试**一次**,不在任何轮询/路由里重复调用;
2987
+ * - 开关在挂载后翻转,要到下次 Host 启动才生效(面板文案必须说这句,否则用户
2988
+ * 会以为开关坏了);
2989
+ * - 目录变化**不需要**重注册:工具在**调用时**才读目录(`getEntries` 是个函数),
2990
+ * 所以一次目录刷新会自然被下一次出图看到。
2991
+ *
2992
+ * 失败一律降级为「工具缺席」,绝不炸掉插件:tools 服务缺失、peer 加载失败、
2993
+ * registry 拒收——三种都由 `draw-tool-state.ts` 记下原因供面板显示。本文件不吞
2994
+ * 错误(那会让「工具为什么不在」无从诊断),只把它们翻译成可展示的原因码。
2995
+ *
2996
+ * peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule` 惰性注入,
2997
+ * 因此本文件与 `llm-adapter.ts` 一样**不能被离线测试导入**,也没有测试覆盖
2998
+ * (docs/ROADMAP.md Backlog 记着这条缺口)。
2999
+ *
3000
+ * @module dsh-connect-modelscope-token-plan/draw-register
3001
+ */
3002
+ /**
3003
+ * 解析一个可选服务,允许它稍后才注册。
3004
+ *
3005
+ * 承 sensenova 同名函数的纪律:**重试的是读,不是注册**——工具不能注销,重复注册
3006
+ * 比缺席更糟。这里只做有限次立即读取,不做长退避(服务要么已注册,要么这次挂载
3007
+ * 就没有)。
3008
+ * @param {object} ctx - Host 上下文。
3009
+ * @param {string} service - 服务名。
3010
+ * @param {() => boolean} isDisposed - 卸载闸。
3011
+ * @returns {Promise<object|null>} 服务实例或 `null`。
3012
+ */
3013
+ async function resolveService(ctx, service, isDisposed) {
3014
+ for (let attempt = 0; attempt < 3; attempt++) {
3015
+ if (isDisposed()) return null;
3016
+ const found = ctx.get?.(service);
3017
+ if (found !== null && found !== void 0) return found;
3018
+ if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 50));
3019
+ }
3020
+ return null;
3021
+ }
3022
+ /**
3023
+ * 读一次开关,决定是否注册出图工具。
3024
+ * @param {object} ctx - Host 上下文。
3025
+ * @param {object} wiring - `{ settings, configError, tokenStore, inference, providerStore, publisher, logger }`。
3026
+ * @param {object} deps - 测试缝(`loadToolsModule`、`drawFetch`)。
3027
+ * @returns {Promise<void>}
3028
+ */
3029
+ async function registerDrawTool(ctx, wiring, deps = {}) {
3030
+ const { settings, configError, tokenStore, inference, drawStore, publisher, logger } = wiring;
3031
+ const loggerFor = logger ?? ctx.logger;
3032
+ setDrawToolNote(null);
3033
+ try {
3034
+ if (configError !== null) return;
3035
+ const panelValue = drawStore ? await drawStore.enabled().catch(() => null) : null;
3036
+ if (resolveSwitchEnabled(panelValue, settings.drawEnabled === true) !== true) return;
3037
+ const isDisposed = () => publisher?.isDisposed?.() === true;
3038
+ const tools = await resolveService(ctx, "tools", isDisposed);
3039
+ if (tools === null || typeof tools.register !== "function") {
3040
+ setDrawToolNote("no-tools-service");
3041
+ return;
3042
+ }
3043
+ let defineTool = null;
3044
+ try {
3045
+ const mod = await Promise.resolve(deps.loadToolsModule ? deps.loadToolsModule() : import("@deepseek-ai/dsh-tools"));
3046
+ defineTool = mod?.defineTool ?? mod?.default?.defineTool ?? null;
3047
+ } catch (error) {
3048
+ setDrawToolNote("peer-load-failed");
3049
+ loggerFor?.warn?.(`${name}: draw tool peer module failed to load (${redactSecrets$1(errMsg(error))}) — the tool is absent`);
3050
+ return;
3051
+ }
3052
+ if (typeof defineTool !== "function") {
3053
+ setDrawToolNote("peer-load-failed");
3054
+ return;
3055
+ }
3056
+ const drawFetch = deps.drawFetch ?? ((url, options) => fetch(url, options));
3057
+ tools.register(defineDrawTool({
3058
+ defineTool,
3059
+ fetchImpl: drawFetch,
3060
+ resolveApiKey: async () => (await tokenStore.resolve()).value,
3061
+ getEntries: async () => {
3062
+ return (await inference.fetchModels().catch(() => null))?.entries ?? [];
3063
+ },
3064
+ getPreferredModelId: async () => {
3065
+ const panelValue = drawStore ? await drawStore.modelId().catch(() => null) : null;
3066
+ return resolveSwitchValue(panelValue, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
3067
+ },
3068
+ settings: {
3069
+ apiBase: settings.apiBase,
3070
+ drawModelId: settings.drawModelId,
3071
+ drawTimeoutMs: settings.drawTimeoutMs
3072
+ },
3073
+ isDisposed
3074
+ }));
3075
+ } catch (error) {
3076
+ setDrawToolNote("registry-refused");
3077
+ loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets$1(errMsg(error))}) — the tool is absent`);
3078
+ }
3079
+ }
3080
+
3081
+ //#endregion
3082
+ //#region src/host/publish-core.ts
3083
+ /**
3084
+ * The shared bones of a provider publisher — the parts that MUST NOT differ
3085
+ * between upstreams, factored out so they cannot drift.
3086
+ *
3087
+ * The publisher (`provider-publish.ts` for the ModelScope provider) runs the
3088
+ * same control plane: a publish queue, a `disposed` gate, and a single-point
3089
+ * pair registration that doubles as the rollback path. Those three are
3090
+ * load-bearing and pinned by tests — and the rollback is the worst place to
3091
+ * discover a divergence, because it only runs once something has already failed.
3092
+ * When they were written twice, keeping them in sync relied on comments in one
3093
+ * file pointing at the other; here there is one copy.
3094
+ *
3095
+ * What is deliberately NOT shared: the publish GATE and the state shape. The
3096
+ * ModelScope side decides from "switch on?" plus a persisted catalog and an
3097
+ * allow-list, and restores `entries`/`enabledIds`/quota ids on a rollback;
3098
+ * another provider's publisher decides from "switch on?" plus "is there a
3099
+ * credential?" and restores its roster. That difference is real domain
3100
+ * difference, and folding it into one parameterized state machine would make a
3101
+ * publish unreadable — every reader would have to read the configuration to
3102
+ * know what one does.
3103
+ *
3104
+ * Peer-free: touches no runtime peer.
3105
+ *
3106
+ * @module dsh-connect-modelscope-token-plan/publish-core
1749
3107
  */
1750
3108
  /** The event a successful (re)registration emits so readers refresh. */
1751
3109
  const ADAPTERS_UPDATED_EVENT = "llm/adapters-updated";
@@ -1857,6 +3215,15 @@ function registerProviderPair(llm, built, target, { providerId, displayName }) {
1857
3215
  *
1858
3216
  * The module is loaded once and the factory is read off it once, so a Host
1859
3217
  * whose peers resolve slowly pays that cost one time, not per publish.
3218
+ *
3219
+ * Only the SUCCESS is memoized. The memoized value used to be the promise
3220
+ * itself, which pinned a rejection in the slot for the life of the process:
3221
+ * after one failed `import()` every later publish rethrew the same error and
3222
+ * the provider could never register again — even after the operator fixed the
3223
+ * missing peer, which is exactly the remedy this file's own
3224
+ * `describeBuildFailure` prints for `ERR_MODULE_NOT_FOUND`. A recoverable
3225
+ * install problem cost a Host restart. Clearing the slot on failure keeps
3226
+ * "loaded once" for the happy path and gives the next publish a real retry.
1860
3227
  * @param {() => Promise<object>} loadModule - resolves the adapter module.
1861
3228
  * @param {string} exportName - the factory export to read off the module.
1862
3229
  * @returns {() => Promise<Function>} the memoized factory resolver.
@@ -1864,7 +3231,10 @@ function registerProviderPair(llm, built, target, { providerId, displayName }) {
1864
3231
  function createAdapterFactoryResolver(loadModule, exportName) {
1865
3232
  let adapterFactoryPromise;
1866
3233
  return async () => {
1867
- if (adapterFactoryPromise === void 0) adapterFactoryPromise = Promise.resolve(loadModule()).then((mod) => mod?.[exportName]);
3234
+ if (adapterFactoryPromise === void 0) adapterFactoryPromise = Promise.resolve(loadModule()).then((mod) => mod?.[exportName]).catch((error) => {
3235
+ adapterFactoryPromise = void 0;
3236
+ throw error;
3237
+ });
1868
3238
  return adapterFactoryPromise;
1869
3239
  };
1870
3240
  }
@@ -1895,7 +3265,7 @@ function describeBuildFailure(error) {
1895
3265
  const annotated = error;
1896
3266
  const why = errMsg(error);
1897
3267
  return {
1898
- note: redactSecrets(why),
3268
+ note: redactSecrets$1(why),
1899
3269
  hint: annotated?.code === "ERR_MODULE_NOT_FOUND" ? " — the llm peer packages ship with the Host; install this plugin where they resolve (or link them into its own node_modules)" : "",
1900
3270
  error
1901
3271
  };
@@ -1917,6 +3287,13 @@ function warnBuildFailure(logger, label, described) {
1917
3287
  * fact about the Host — not about the provider — so it is one copy: a Host
1918
3288
  * with no `llm` service gets no registration and a stated reason, never a
1919
3289
  * half-built one.
3290
+ *
3291
+ * The absence routes through {@link unregister}, not a hand-rolled
3292
+ * `release(); registered = false`: `state.built` must go down with it. A
3293
+ * stale `built` survives into the NEXT publish as its rollback target, so a
3294
+ * later failed publish would re-register an adapter whose release has already
3295
+ * been called — a rollback path running against a dead pair, which is the
3296
+ * worst possible moment to discover the divergence.
1920
3297
  * @param {object} job
1921
3298
  * @param {object} job.state - the publisher state.
1922
3299
  * @param {(service: string) => object|null} job.getLlm - the service resolver.
@@ -1927,9 +3304,11 @@ function resolveRegistrationService({ state, getLlm, release }) {
1927
3304
  const llm = getLlm("llm");
1928
3305
  state.llmAvailable = llm !== null && typeof llm.registerAdapter === "function";
1929
3306
  if (!state.llmAvailable) {
1930
- release();
1931
- state.registered = false;
1932
- state.error = NO_LLM_SERVICE_ERROR;
3307
+ unregister({
3308
+ state,
3309
+ release,
3310
+ error: NO_LLM_SERVICE_ERROR
3311
+ });
1933
3312
  return null;
1934
3313
  }
1935
3314
  return llm;
@@ -1992,16 +3371,14 @@ function swapRegistration({ llm, built, previousBuilt, state, release, registerP
1992
3371
  release();
1993
3372
  state.built = null;
1994
3373
  onRollback?.();
1995
- 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;
3374
+ state.error = redactSecrets$1(errMsg(error));
3375
+ restorePreviousPair({
3376
+ llm,
3377
+ previousBuilt,
3378
+ state,
3379
+ registerPair,
3380
+ release
3381
+ });
2005
3382
  return {
2006
3383
  ok: false,
2007
3384
  error
@@ -2014,6 +3391,30 @@ function swapRegistration({ llm, built, previousBuilt, state, release, registerP
2014
3391
  return { ok: true };
2015
3392
  }
2016
3393
  /**
3394
+ * 注册失败后恢复先前在服务的 provider 对(如果有)。
3395
+ *
3396
+ * 从 {@link swapRegistration} 提取,把 catch 块里的嵌套从 4 层降到 2 层——
3397
+ * 原实现里 `registerPair(llm, previousBuilt, ...)` 住在 try 内、try 住在
3398
+ * catch 内,读起来要数四层缩进才能确认它在做什么。行为完全不变:先摘掉
3399
+ * 新对(`release()` 已在调用方做过),再尝试挂回旧对;旧对也注册不上就
3400
+ * 诚实地标 `registered: false`。
3401
+ */
3402
+ function restorePreviousPair({ llm, previousBuilt, state, registerPair, release }) {
3403
+ if (previousBuilt === null) {
3404
+ state.registered = false;
3405
+ return;
3406
+ }
3407
+ try {
3408
+ registerPair(llm, previousBuilt, state);
3409
+ state.built = previousBuilt;
3410
+ state.registered = true;
3411
+ } catch {
3412
+ release();
3413
+ state.built = null;
3414
+ state.registered = false;
3415
+ }
3416
+ }
3417
+ /**
2017
3418
  * Emit the adapter-update event, tolerating a Host that has no `emit` and a
2018
3419
  * Host whose `emit` refuses.
2019
3420
  *
@@ -2086,7 +3487,7 @@ function createProviderPublisher(deps = {}) {
2086
3487
  const { settings, panelSwitch, loadAdapterModule, getLlm, resolveApiKey, usage, emit, logger } = deps;
2087
3488
  const effectiveSettings = settings ?? {};
2088
3489
  const effectivePanelSwitch = panelSwitch ?? (async () => null);
2089
- const effectiveLoadAdapterModule = loadAdapterModule ?? (() => import("./llm-adapter-DnaNNORe.js"));
3490
+ const effectiveLoadAdapterModule = loadAdapterModule ?? (() => import("./llm-adapter.js"));
2090
3491
  const effectiveGetLlm = getLlm ?? (() => null);
2091
3492
  const effectiveResolveApiKey = resolveApiKey ?? (async () => "");
2092
3493
  const effectiveLogger = logger ?? { warn: () => {} };
@@ -2134,6 +3535,15 @@ function createProviderPublisher(deps = {}) {
2134
3535
  displayName: LLM_DISPLAY_NAME
2135
3536
  });
2136
3537
  /**
3538
+ * 空转守卫的公共判据:目录 offer 与额度耗尽集合是否都没变。
3539
+ *
3540
+ * 两处空转守卫(开关关着 / 开关开着)共用这个签名比对——公式与轮询同源
3541
+ * (由 {@link syncSignaturesAfterPublish} 内部调用导出的 {@link catalogSignature}
3542
+ * 与 {@link quotaSignatureOf}),不会漂移。提取成函数避免两处各写一遍。
3543
+ * @returns {boolean} true = offer 没变,可以跳过这次 publish。
3544
+ */
3545
+ const offerUnchanged = () => state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds);
3546
+ /**
2137
3547
  * 为一个目录/允许清单快照(重)构建并注册 provider。
2138
3548
  *
2139
3549
  * 重建并重注册而非原地改:`PiAiAdapter` 内部 memoize profiles 快照,只有新的注册
@@ -2158,7 +3568,7 @@ function createProviderPublisher(deps = {}) {
2158
3568
  state.unavailableIds = Array.isArray(unavailableModelIds) ? unavailableModelIds : [];
2159
3569
  const panelValue = await effectivePanelSwitch().catch(() => null);
2160
3570
  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 {
3571
+ if (state.registered === false && state.error === null && offerUnchanged()) return {
2162
3572
  ok: true,
2163
3573
  skipped: true
2164
3574
  };
@@ -2202,7 +3612,7 @@ function createProviderPublisher(deps = {}) {
2202
3612
  error: described.error
2203
3613
  };
2204
3614
  }
2205
- if (state.registered === true && state.error === null && state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds)) return {
3615
+ if (state.registered === true && state.error === null && offerUnchanged()) return {
2206
3616
  ok: true,
2207
3617
  skipped: true
2208
3618
  };
@@ -2210,6 +3620,7 @@ function createProviderPublisher(deps = {}) {
2210
3620
  ok: false,
2211
3621
  skipped: true
2212
3622
  };
3623
+ const requestedSignature = catalogSignature(state.entries, state.enabledIds);
2213
3624
  const result = swapRegistration({
2214
3625
  llm,
2215
3626
  built,
@@ -2225,6 +3636,7 @@ function createProviderPublisher(deps = {}) {
2225
3636
  }
2226
3637
  });
2227
3638
  syncSignaturesAfterPublish(state);
3639
+ if (result.ok === false && state.registered === true && state.signature === requestedSignature) state.error = null;
2228
3640
  return result;
2229
3641
  };
2230
3642
  /**
@@ -2305,7 +3717,9 @@ function syncSignaturesAfterPublish(state) {
2305
3717
  //#region src/host/routes/paths.ts
2306
3718
  /**
2307
3719
  * 路由字面量。浏览器 bundle(src/client/const.ts)无法从 host-config 导入,
2308
- * 两边各写一份字面量——由 test/config.test.mjs 钉住相等,改名时先红再改。
3720
+ * 两边各写一份字面量——由 test/panel.test.mjs 的 §6/§6b 钉住两端解析出的完整
3721
+ * 路径集合双向相等,改名时先红再改。(原注释指向 test/config.test.mjs,但那套
3722
+ * 钉的是 CONFIG_DEFAULTS ↔ cordis.patch.yml,不含路由字面量。)
2309
3723
  * @module dsh-connect-modelscope-token-plan/routes/paths
2310
3724
  */
2311
3725
  const SNAPSHOT_PATH = "/api/dsh-connect-modelscope-token-plan/snapshot";
@@ -2314,6 +3728,7 @@ const TOKEN_PATH = "/api/dsh-connect-modelscope-token-plan/token";
2314
3728
  const TOKEN_FORGET_PATH = "/api/dsh-connect-modelscope-token-plan/token/forget";
2315
3729
  const PROBE_PATH = "/api/dsh-connect-modelscope-token-plan/probe";
2316
3730
  const PROVIDER_PATH = "/api/dsh-connect-modelscope-token-plan/provider";
3731
+ const DRAW_PATH = "/api/dsh-connect-modelscope-token-plan/draw";
2317
3732
 
2318
3733
  //#endregion
2319
3734
  //#region src/host/snapshot-aggregate.ts
@@ -2352,7 +3767,7 @@ async function soft(promise) {
2352
3767
  const code = error.code;
2353
3768
  return {
2354
3769
  ok: false,
2355
- error: redactSecrets(errMsg(error)),
3770
+ error: redactSecrets$1(errMsg(error)),
2356
3771
  code: typeof code === "string" ? code : CODE.INTERNAL_ERROR
2357
3772
  };
2358
3773
  }
@@ -2372,43 +3787,47 @@ function providerDegraded(error) {
2372
3787
  enabledCount: 0,
2373
3788
  allowed: "all",
2374
3789
  enabledIds: [],
3790
+ contextWindow: null,
2375
3791
  roster: []
2376
3792
  };
2377
3793
  }
2378
3794
  /** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
2379
3795
  async function buildSnapshotBody(wiring) {
2380
- const { settings, tokenStore, usageStore, inference, providerStore, publisher } = wiring;
2381
- const [tokenState, daily, perModel, trend, events] = await Promise.all([
3796
+ const { settings, tokenStore, usageStore, inference, providerStore, drawStore, publisher } = wiring;
3797
+ const [tokenState, daily, perModel, trend, events, providerNote] = await Promise.all([
2382
3798
  soft(tokenStore.state()),
2383
3799
  soft(usageStore.daily()),
2384
3800
  soft(usageStore.perModelToday()),
2385
3801
  soft(usageStore.trend(settings.trendDays)),
2386
- soft(usageStore.events())
3802
+ soft(usageStore.events()),
3803
+ soft(providerStore.versionNote())
2387
3804
  ]);
2388
3805
  const shapeWarnings = [];
2389
3806
  const usageAnomaly = usageStore.anomaly();
2390
3807
  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());
3808
+ for (const [label, path] of [
3809
+ ["token", tokenState],
3810
+ ["daily", daily],
3811
+ ["per-model", perModel],
3812
+ ["trend", trend],
3813
+ ["events", events]
3814
+ ]) if (!path.ok) shapeWarnings.push(`${label} source failed (${path.code}): ${path.error}`);
3815
+ const tokenKnownAbsent = tokenState.ok && tokenState.value.present === false;
3816
+ const contextOverride = await providerStore.contextWindow().catch(() => null);
3817
+ const [balance, models] = await Promise.all([soft(inference.fetchBalance()), soft(inference.fetchModels(contextOverride))]);
2401
3818
  const modelIds = models.ok ? models.value.ids : [];
2402
3819
  const modelEntries = models.ok ? models.value.entries : [];
2403
3820
  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}`);
3821
+ if (!tokenKnownAbsent && !balance.ok && balance.code !== "network_error") shapeWarnings.push(`balance: ${balance.error}`);
3822
+ if (providerNote.ok && providerNote.value !== null) shapeWarnings.push(providerNote.value);
2405
3823
  const provider = await soft((async () => {
2406
3824
  const panel = await providerStore.enabled().catch(() => null);
2407
3825
  const enabled = resolveSwitchEnabled(panel, settings.registerProvider);
2408
3826
  const source = switchSource(panel);
2409
3827
  const roster = rosterWithAvailability(modelEntries, []);
2410
3828
  const stored = await providerStore.enabledIds().catch(() => null);
2411
- const enabledIds = Array.isArray(stored) ? stored : Array.isArray(publisher.state.enabledIds) ? publisher.state.enabledIds : [];
3829
+ const enabledIds = resolveEnabledIds(stored, publisher.state.enabledIds);
3830
+ const contextWindow = await providerStore.contextWindow().catch(() => null);
2412
3831
  const allowed = resolveAllowedList(enabledIds);
2413
3832
  const allowSet = new Set(enabledIds);
2414
3833
  const enabledCount = allowed === "all" ? roster.length : allowed === "list" ? roster.filter((row) => allowSet.has(row.id)).length : 0;
@@ -2422,10 +3841,27 @@ async function buildSnapshotBody(wiring) {
2422
3841
  enabledCount,
2423
3842
  allowed,
2424
3843
  enabledIds,
3844
+ contextWindow,
2425
3845
  roster
2426
3846
  };
2427
3847
  })());
2428
- const usedLocal = daily.ok ? daily.value.calls : 0;
3848
+ const draw = await soft((async () => {
3849
+ const panel = await drawStore.enabled().catch(() => null);
3850
+ const panelModel = await drawStore.modelId().catch(() => null);
3851
+ const candidateIds = imageGenModelIds(modelEntries);
3852
+ return {
3853
+ enabled: resolveSwitchEnabled(panel, settings.drawEnabled),
3854
+ source: switchSource(panel),
3855
+ candidateIds,
3856
+ candidateCount: candidateIds.length,
3857
+ preferredModel: resolveSwitchValue(panelModel, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null,
3858
+ preferredSource: switchSource(panelModel),
3859
+ absentReason: drawToolNote(),
3860
+ mountPending: resolveSwitchEnabled(panel, settings.drawEnabled) === true && drawToolNote() !== null,
3861
+ toolName: "modelscope_draw_image"
3862
+ };
3863
+ })());
3864
+ const usedLocal = daily.ok ? daily.value.calls : null;
2429
3865
  return {
2430
3866
  ok: true,
2431
3867
  name,
@@ -2433,12 +3869,16 @@ async function buildSnapshotBody(wiring) {
2433
3869
  now: (/* @__PURE__ */ new Date()).toISOString(),
2434
3870
  pollSeconds: settings.pollSeconds,
2435
3871
  cacheSeconds: settings.cacheSeconds,
2436
- token: tokenState.ok ? tokenState.value : {
3872
+ token: tokenState.ok ? {
3873
+ ...tokenState.value,
3874
+ readError: null
3875
+ } : {
2437
3876
  present: false,
2438
3877
  source: "none",
2439
3878
  valid: null,
2440
3879
  checkedAt: null,
2441
- ephemeral: true
3880
+ ephemeral: true,
3881
+ readError: tokenState.error
2442
3882
  },
2443
3883
  balance: balance.ok ? {
2444
3884
  available: balance.value.available,
@@ -2451,7 +3891,7 @@ async function buildSnapshotBody(wiring) {
2451
3891
  total: null,
2452
3892
  frozen: null,
2453
3893
  fetchedAt: null,
2454
- error: tokenPresent ? balance.error : null
3894
+ error: tokenKnownAbsent ? null : balance.error
2455
3895
  },
2456
3896
  quota: {
2457
3897
  daily: { usedLocal },
@@ -2470,6 +3910,7 @@ async function buildSnapshotBody(wiring) {
2470
3910
  error: models.ok ? null : models.error
2471
3911
  },
2472
3912
  provider: provider.ok ? provider.value : providerDegraded(provider.error),
3913
+ ...draw.ok ? { draw: draw.value } : {},
2473
3914
  shapeWarnings,
2474
3915
  quotaError: null
2475
3916
  };
@@ -2567,9 +4008,25 @@ function refuseMethod(response) {
2567
4008
  * 日志或响应)。redactSecrets 幂等,双重应用无害。
2568
4009
  */
2569
4010
  function redactedError(error) {
2570
- const text = redactSecrets(errMsg(error));
4011
+ const text = redactSecrets$1(errMsg(error));
2571
4012
  return text.trim() === "" ? "request failed" : text;
2572
4013
  }
4014
+ /**
4015
+ * 写盘/操作失败的统一响应:HTTP 恒 200、成败看 body(与快照路由同一语义,
4016
+ * 一次写失败不许顺着渲染树炸穿)。**用户显式操作的失败必须上屏**,所以
4017
+ * 调用方不吞、这里只负责脱敏后的标准形状。provider 开关/清单/reset 与
4018
+ * token save/forget 五处 catch 曾各写一遍这个 writeJson——集中成一份,
4019
+ * 措辞与形状不会漂移。
4020
+ * @param {object} response - webServer 的响应对象。
4021
+ * @param {unknown} error - catch 到的任意值(未必是 Error)。
4022
+ * @returns {void}
4023
+ */
4024
+ function respondError(response, error) {
4025
+ writeJson(response, 200, {
4026
+ ok: false,
4027
+ error: redactedError(error)
4028
+ }, { "cache-control": "no-store" });
4029
+ }
2573
4030
  /** 读并校验 JSON body,失败时写 400 并返回 null(调用方的停手信号)。 */
2574
4031
  async function readJsonBodyOr400(request, response, limit = MAX_JSON_BODY_BYTES) {
2575
4032
  const body = await readJsonBody(request, limit);
@@ -2702,7 +4159,14 @@ function registerModelsRoute(ctx, wiring) {
2702
4159
  * 简化(SPIKE.md §结论 3)。
2703
4160
  * @module dsh-connect-modelscope-token-plan/routes/token
2704
4161
  */
2705
- /** 注册令牌路由(POST /token 保存,POST /token/forget 忘掉)。 */
4162
+ /**
4163
+ * 注册令牌路由(POST /token 保存,POST /token/forget 忘掉)。
4164
+ *
4165
+ * 返回类型是本族唯一的一个**数组**:token 注册两条路由(save + forget),其余
4166
+ * 路由族各只注册一条、返回单个 off()。标注在**这里**而不是在 routes.ts 里对
4167
+ * 调用结果做 `as Array<() => void>` 断言——断言放在消费端等于把「这两条返回的是
4168
+ * 注销回调而不是 handler」这个约定藏起来,标注在产地才是声明。
4169
+ */
2706
4170
  function registerTokenRoute(ctx, wiring) {
2707
4171
  const { settings, tokenStore } = wiring;
2708
4172
  const save = withOrigin(async (request, response) => {
@@ -2727,10 +4191,7 @@ function registerTokenRoute(ctx, wiring) {
2727
4191
  ...await tokenStore.state()
2728
4192
  }, { "cache-control": "no-store" });
2729
4193
  } catch (error) {
2730
- writeJson(response, 200, {
2731
- ok: false,
2732
- error: redactedError(error)
2733
- }, { "cache-control": "no-store" });
4194
+ respondError(response, error);
2734
4195
  }
2735
4196
  }, settings.allowedHosts);
2736
4197
  const forget = withOrigin(async (request, response) => {
@@ -2745,10 +4206,7 @@ function registerTokenRoute(ctx, wiring) {
2745
4206
  ...await tokenStore.state()
2746
4207
  }, { "cache-control": "no-store" });
2747
4208
  } catch (error) {
2748
- writeJson(response, 200, {
2749
- ok: false,
2750
- error: redactedError(error)
2751
- }, { "cache-control": "no-store" });
4209
+ respondError(response, error);
2752
4210
  }
2753
4211
  }, settings.allowedHosts);
2754
4212
  return [ctx.webServer.register({
@@ -2807,13 +4265,15 @@ function registerProbeRoute(ctx, wiring) {
2807
4265
  }
2808
4266
  try {
2809
4267
  const result = await inference.probe({ modelId });
4268
+ wiring.tokenStore.noteValidity(true);
2810
4269
  writeJson(response, 200, {
2811
4270
  ...result,
2812
- tokenState: await wiring.tokenStore.state()
4271
+ tokenState: await wiring.tokenStore.state().catch(() => null)
2813
4272
  }, { "cache-control": "no-store" });
2814
4273
  } catch (error) {
2815
4274
  const code = error.code;
2816
4275
  const message = redactedError(error);
4276
+ if (code === CODE.AUTH_ERROR) wiring.tokenStore.noteValidity(false);
2817
4277
  const eventKind = code === CODE.QUOTA_EXCEEDED ? "quota" : code === CODE.RATE_LIMITED ? "rate_limit" : "error";
2818
4278
  await usageStore.recordEvent({
2819
4279
  modelId,
@@ -2837,13 +4297,15 @@ function registerProbeRoute(ctx, wiring) {
2837
4297
  //#endregion
2838
4298
  //#region src/host/routes/provider.ts
2839
4299
  /**
2840
- * provider 注册路由:开关读/写、允许清单保存、回退默认。
4300
+ * provider 注册路由:开关读/写、允许清单保存、上下文窗口覆盖、回退默认。
2841
4301
  *
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 默认。
4302
+ * 四件事(§9):
4303
+ * - GET `PROVIDER_PATH` 读当前开关 + 目录 + 允许清单 + 注册状态(无副作用);
4304
+ * - POST `PROVIDER_PATH` body `{enabled?: boolean}` → 保存面板开关并重注册;
4305
+ * - POST `PROVIDER_PATH/roster` body `{enabledIds?: string[]}` → 保存允许清单并重注册;
4306
+ * - POST `PROVIDER_PATH/context` body `{contextWindow: number|null}` → 保存全局上下文
4307
+ * 窗口覆盖并重注册(null = 跟随目录);
4308
+ * - POST `PROVIDER_PATH/reset` 忘掉面板开关、允许清单与窗口覆盖,回到 patch 默认。
2847
4309
  *
2848
4310
  * `withOrigin` 围栏与 `refuseMethod` 照现有路由;写开关/清单失败**必须传播**
2849
4311
  * (`ok:false` + error),不静默吞——这是用户的显式操作,面板必须看见它没生效。
@@ -2853,9 +4315,16 @@ function registerProbeRoute(ctx, wiring) {
2853
4315
  */
2854
4316
  /**
2855
4317
  * 读目录条目,读不到按空目录降级(GET 不能因目录失败把整个面板状态打掉)。
4318
+ *
4319
+ * 读目录前先取**当前窗口覆盖**传给 `fetchModels`:条目在归一前打上覆盖标记,
4320
+ * descriptor / roster / catalogSignature 都从这份条目派生。每个写路由(开关、
4321
+ * 清单、窗口、reset)都经这里取条目再 publish,所以任何一次重注册都带着最新
4322
+ * 覆盖——否则先存窗口、再存清单会把未打标的目录重新注册上去,覆盖静默失效到
4323
+ * 下一轮轮询。
2856
4324
  */
2857
- async function readEntries(inference) {
2858
- const catalog = await inference.fetchModels().catch((error) => {
4325
+ async function readEntries$1(inference, providerStore) {
4326
+ const contextWindow = await optional(providerStore?.contextWindow?.(), null);
4327
+ const catalog = await inference.fetchModels(contextWindow).catch((error) => {
2859
4328
  return {
2860
4329
  entries: [],
2861
4330
  __error: error
@@ -2865,11 +4334,12 @@ async function readEntries(inference) {
2865
4334
  }
2866
4335
  /**
2867
4336
  * 读「当前」允许清单:优先持久化的,否则 publisher 最近 publish 的那份。
4337
+ *
4338
+ * 判据是**共用**的 resolveEnabledIds(provider-store)——本文件与面板聚合、
4339
+ * index.ts 的轮询必须对「空清单」给同一个答案,否则三个界面各说一套。
2868
4340
  */
2869
4341
  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 : [];
4342
+ return resolveEnabledIds(await optional(store.enabledIds?.(), null), published);
2873
4343
  }
2874
4344
  /**
2875
4345
  * `allowed` 的分类:委托给 `llm-models.ts` 的单一口径
@@ -2902,7 +4372,8 @@ function registerProviderRoute(ctx, wiring) {
2902
4372
  const snapshot = async (extra = {}) => {
2903
4373
  const panelSwitch = await optional(providerStore.enabled());
2904
4374
  const enabledIds = await readEnabledIds(providerStore, publisher.state.enabledIds);
2905
- const entries = await readEntries(inference);
4375
+ const contextWindow = await optional(providerStore.contextWindow?.(), null);
4376
+ const entries = await readEntries$1(inference, providerStore);
2906
4377
  const roster = rosterWithAvailability(entries, []);
2907
4378
  const allowed = allowedOf(enabledIds);
2908
4379
  const modelCount = roster.length;
@@ -2918,6 +4389,7 @@ function registerProviderRoute(ctx, wiring) {
2918
4389
  enabledCount: allowed === "all" ? modelCount : allowed === "none" ? 0 : roster.filter((row) => allowSet.has(row.id)).length,
2919
4390
  allowed,
2920
4391
  enabledIds,
4392
+ contextWindow,
2921
4393
  roster,
2922
4394
  ...extra
2923
4395
  };
@@ -2947,13 +4419,10 @@ function registerProviderRoute(ctx, wiring) {
2947
4419
  }
2948
4420
  try {
2949
4421
  await providerStore.save(body.enabled);
2950
- const entries = await readEntries(inference);
4422
+ const entries = await readEntries$1(inference, providerStore);
2951
4423
  await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
2952
4424
  } catch (error) {
2953
- writeJson(response, 200, {
2954
- ok: false,
2955
- error: redactedError(error)
2956
- }, { "cache-control": "no-store" });
4425
+ respondError(response, error);
2957
4426
  return;
2958
4427
  }
2959
4428
  writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
@@ -2979,15 +4448,40 @@ function registerProviderRoute(ctx, wiring) {
2979
4448
  const ids = body.enabledIds.filter((id) => typeof id === "string" && id !== "");
2980
4449
  try {
2981
4450
  await providerStore.saveEnabledIds?.(ids);
2982
- const entries = await readEntries(inference);
4451
+ const entries = await readEntries$1(inference, providerStore);
2983
4452
  await publisher.publish(entries, ids, []);
2984
4453
  } catch (error) {
2985
- writeJson(response, 200, {
4454
+ respondError(response, error);
4455
+ return;
4456
+ }
4457
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
4458
+ }, settings.allowedHosts)
4459
+ }));
4460
+ offs.push(ctx.webServer.register({
4461
+ kind: "exact",
4462
+ path: `${PROVIDER_PATH}/context`,
4463
+ handler: withOrigin(async (request, response) => {
4464
+ if ((request.method === void 0 ? "POST" : request.method) !== "POST") {
4465
+ refuseMethod(response);
4466
+ return;
4467
+ }
4468
+ const body = await readJsonBodyOr400(request, response);
4469
+ if (body === null) return;
4470
+ if (body.contextWindow !== null && (typeof body.contextWindow !== "number" || !Number.isFinite(body.contextWindow) || body.contextWindow <= 0)) {
4471
+ writeJson(response, 400, {
2986
4472
  ok: false,
2987
- error: redactedError(error)
4473
+ error: "expected { contextWindow: number | null }"
2988
4474
  }, { "cache-control": "no-store" });
2989
4475
  return;
2990
4476
  }
4477
+ try {
4478
+ await providerStore.saveContextWindow?.(body.contextWindow);
4479
+ const entries = await readEntries$1(inference, providerStore);
4480
+ await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
4481
+ } catch (error) {
4482
+ respondError(response, error);
4483
+ return;
4484
+ }
2991
4485
  writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
2992
4486
  }, settings.allowedHosts)
2993
4487
  }));
@@ -3006,15 +4500,182 @@ function registerProviderRoute(ctx, wiring) {
3006
4500
  try {
3007
4501
  await providerStore.forget();
3008
4502
  await providerStore.saveEnabledIds?.([]);
3009
- const entries = await readEntries(inference);
4503
+ const entries = await readEntries$1(inference, providerStore);
3010
4504
  await publisher.publish(entries, [], []);
3011
4505
  } catch (error) {
3012
- writeJson(response, 200, {
4506
+ respondError(response, error);
4507
+ return;
4508
+ }
4509
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
4510
+ }, settings.allowedHosts)
4511
+ }));
4512
+ return () => {
4513
+ for (const off of offs) try {
4514
+ off();
4515
+ } catch (error) {
4516
+ logger?.warn?.(`provider route unregister failed: ${redactedError(error)}`);
4517
+ }
4518
+ };
4519
+ }
4520
+
4521
+ //#endregion
4522
+ //#region src/host/routes/draw.ts
4523
+ /**
4524
+ * 出图工具路由:开关读/写、模型偏好保存、回到默认。
4525
+ *
4526
+ * 三条路径:
4527
+ * - GET `DRAW_PATH` 读开关 + 出图候选模型 + 工具挂载状态(无副作用);
4528
+ * - POST `DRAW_PATH` body `{enabled?: boolean, modelId?: string|null}` → 保存;
4529
+ * - POST `DRAW_PATH/reset` 忘掉面板值,回到 patch 默认。
4530
+ *
4531
+ * **与 provider 路由刻意分开**:provider 管「魔搭作为 chat provider」,本路由管
4532
+ * 「给 agent 一个出图工具」。两者的开关、状态文件、候选集合都不同,合并会让
4533
+ * 「关掉出图」波及 provider 的注册时序(详见 draw-store.ts 的头注)。
4534
+ *
4535
+ * 一处**与 provider 路由不同**的地方必须说清:provider 开关翻转会**立即**重注册
4536
+ * (publish 是活的重建),而出图工具的开关翻转**要等下次 Host 启动**——peer 的
4537
+ * tools registry 没有 unregister,工具一旦注册就摘不下来。所以本路由保存成功后
4538
+ * 不改挂载状态,只在响应里给出 `mountPending`,让面板能说清这件事,而不是让用户
4539
+ * 以为开关坏了。
4540
+ *
4541
+ * peer-free:不 import 任何 Host peer。
4542
+ * @module dsh-connect-modelscope-token-plan/routes/draw
4543
+ */
4544
+ /**
4545
+ * 读目录条目,读不到按空目录降级。
4546
+ *
4547
+ * 与 provider 路由同一条纪律:GET 不能因为一次目录失败就把整个面板状态打掉——
4548
+ * 出图候选为空只影响「能不能挑模型」,不影响开关的显示。
4549
+ * @param {object} inference - inference client。
4550
+ * @returns {Promise<unknown[]>} 归一目录条目。
4551
+ */
4552
+ async function readEntries(inference) {
4553
+ const catalog = await inference.fetchModels().catch(() => ({ entries: [] }));
4554
+ return Array.isArray(catalog?.entries) ? catalog.entries : [];
4555
+ }
4556
+ /**
4557
+ * 注册出图路由(两条路径)。
4558
+ * @param ctx - host 根上下文(只用 `ctx.webServer`)。
4559
+ * @param {DrawRouteWiring} wiring - 本路由读到的子集。
4560
+ * @returns {Function} 注销回调。
4561
+ */
4562
+ function registerDrawRoute(ctx, wiring) {
4563
+ const { settings, drawStore, inference, logger } = wiring;
4564
+ /**
4565
+ * 组装响应。GET 与写成功后的响应共用,两个方向不可能对「当前状态」说两套话。
4566
+ * @param {object} [extra] - 追加/覆盖字段。
4567
+ * @returns {Promise<object>} 响应体。
4568
+ */
4569
+ const snapshot = async (extra = {}) => {
4570
+ const panelSwitch = await optional(drawStore.enabled());
4571
+ const panelModelId = await optional(drawStore.modelId());
4572
+ const entries = await readEntries(inference);
4573
+ const candidateIds = imageGenModelIds(entries);
4574
+ const effectiveModelId = resolveSwitchValue(panelModelId, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
4575
+ return {
4576
+ ok: true,
4577
+ enabled: resolveSwitchEnabled(panelSwitch, settings.drawEnabled),
4578
+ source: switchSource(panelSwitch),
4579
+ candidateIds,
4580
+ candidateCount: candidateIds.length,
4581
+ preferredModel: effectiveModelId,
4582
+ preferredSource: switchSource(panelModelId),
4583
+ absentReason: drawToolNote(),
4584
+ /**
4585
+ * 开关的保存**不会**改变本次挂载的工具。
4586
+ *
4587
+ * 工具无法注销(peer 的 tools registry 没有 unregister),所以开关翻转只在
4588
+ * 下次 Host 启动时生效。面板据此显示一句说明——否则用户会以为开关坏了。
4589
+ * 判据:开关为开且本次挂载尚未注册成功(或缺席原因未知)。
4590
+ */
4591
+ mountPending: resolveSwitchEnabled(panelSwitch, settings.drawEnabled) === true && drawToolNote() !== null,
4592
+ toolName: "modelscope_draw_image",
4593
+ ...extra
4594
+ };
4595
+ };
4596
+ const offs = [];
4597
+ offs.push(ctx.webServer.register({
4598
+ kind: "exact",
4599
+ path: DRAW_PATH,
4600
+ handler: withOrigin(async (request, response) => {
4601
+ const method = request.method === void 0 ? "GET" : request.method;
4602
+ if (method === "GET") {
4603
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
4604
+ return;
4605
+ }
4606
+ if (method !== "POST") {
4607
+ refuseMethod(response);
4608
+ return;
4609
+ }
4610
+ const body = await readJsonBodyOr400(request, response);
4611
+ if (body === null) return;
4612
+ const hasEnabled = typeof body.enabled === "boolean";
4613
+ const hasModel = body.modelId === null || typeof body.modelId === "string";
4614
+ if (!hasEnabled && !hasModel) {
4615
+ writeJson(response, 400, {
3013
4616
  ok: false,
3014
- error: redactedError(error)
4617
+ error: "expected { enabled?: boolean, modelId?: string|null }"
4618
+ }, { "cache-control": "no-store" });
4619
+ return;
4620
+ }
4621
+ if (body.enabled !== void 0 && !hasEnabled) {
4622
+ writeJson(response, 400, {
4623
+ ok: false,
4624
+ error: "enabled must be a boolean"
3015
4625
  }, { "cache-control": "no-store" });
3016
4626
  return;
3017
4627
  }
4628
+ if (body.modelId !== void 0 && !hasModel) {
4629
+ writeJson(response, 400, {
4630
+ ok: false,
4631
+ error: "modelId must be a string or null"
4632
+ }, { "cache-control": "no-store" });
4633
+ return;
4634
+ }
4635
+ try {
4636
+ if (hasEnabled) {
4637
+ const refusal = await drawStore.saveEnabled(body.enabled);
4638
+ if (refusal !== null) {
4639
+ respondError(response, new Error(refusal));
4640
+ return;
4641
+ }
4642
+ }
4643
+ if (body.modelId !== void 0) {
4644
+ const refusal = await drawStore.saveModelId(body.modelId);
4645
+ if (refusal !== null) {
4646
+ respondError(response, new Error(refusal));
4647
+ return;
4648
+ }
4649
+ }
4650
+ } catch (error) {
4651
+ respondError(response, error);
4652
+ return;
4653
+ }
4654
+ writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
4655
+ }, settings.allowedHosts)
4656
+ }));
4657
+ offs.push(ctx.webServer.register({
4658
+ kind: "exact",
4659
+ path: `${DRAW_PATH}/reset`,
4660
+ handler: withOrigin(async (request, response) => {
4661
+ if ((request.method === void 0 ? "POST" : request.method) !== "POST") {
4662
+ refuseMethod(response);
4663
+ return;
4664
+ }
4665
+ await readJsonBody(request, MAX_JSON_BODY_BYTES).catch(() => ({
4666
+ ok: false,
4667
+ error: "ignored"
4668
+ }));
4669
+ try {
4670
+ const refusal = await drawStore.reset();
4671
+ if (refusal !== null) {
4672
+ respondError(response, new Error(refusal));
4673
+ return;
4674
+ }
4675
+ } catch (error) {
4676
+ respondError(response, error);
4677
+ return;
4678
+ }
3018
4679
  writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
3019
4680
  }, settings.allowedHosts)
3020
4681
  }));
@@ -3022,7 +4683,7 @@ function registerProviderRoute(ctx, wiring) {
3022
4683
  for (const off of offs) try {
3023
4684
  off();
3024
4685
  } catch (error) {
3025
- logger?.warn?.(`provider route unregister failed: ${redactedError(error)}`);
4686
+ logger?.warn?.(`draw route unregister failed: ${redactedError(error)}`);
3026
4687
  }
3027
4688
  };
3028
4689
  }
@@ -3042,7 +4703,8 @@ function registerRoutes(ctx, wiring) {
3042
4703
  registerModelsRoute(ctx, wiring),
3043
4704
  ...registerTokenRoute(ctx, wiring),
3044
4705
  registerProbeRoute(ctx, wiring),
3045
- registerProviderRoute(ctx, wiring)
4706
+ registerProviderRoute(ctx, wiring),
4707
+ registerDrawRoute(ctx, wiring)
3046
4708
  ];
3047
4709
  }
3048
4710
 
@@ -3051,8 +4713,12 @@ function registerRoutes(ctx, wiring) {
3051
4713
  /**
3052
4714
  * dsh-connect-modelscope-token-plan — Host 半边(thin router)。
3053
4715
  *
3054
- * 读魔搭 API-Inference 的**本地用量**(上游没有余额接口,见 docs/SPIKE.md)
3055
- * 并经一条只读 /api 路由喂给 Client 面板。重活都在专注的兄弟模块里,本文件
4716
+ * 读魔搭 API-Inference 的**本地用量**,并把它与官方魔粒余额一起经一条只读 /api
4717
+ * 路由喂给 Client 面板。口径分工:推理响应**不携带**额度头(SPIKE.md §结论 1),但
4718
+ * 官方另有魔粒余额端点(`GET {siteBase}/openapi/v1/magicubes/balance`,SPIKE.md
4719
+ * 文首后记,2026-10-04 实测可用),面板头条读的是它;本文件下的兄弟模块只负责本地
4720
+ * 计数那一半(按模型分布 / 本地趋势 / 429 事件流),回答官方余额答不了的问题。
4721
+ * 重活都在专注的兄弟模块里,本文件
3056
4722
  * 只保留 Cordis 入口(name/inject/apply)、wiring 装配与卸载副作用:
3057
4723
  *
3058
4724
  * - `host-config.ts` 配置契约 + isAdmitted 信任围栏
@@ -3072,11 +4738,16 @@ function registerRoutes(ctx, wiring) {
3072
4738
  * @param ctx - Host 根上下文。
3073
4739
  * @param config - 行的原始 patch 配置(无 schema,未校验;坏配置以
3074
4740
  * configError 经快照呈现,而不是在挂载时炸掉整台插件)。
3075
- * @param deps - 测试缝(fetchImpl)。真实 Loader 不传。
4741
+ * @param deps - 测试缝(`HostDeps`:fetchImpl / getLlm / emit / credentials / env
4742
+ * 与出图那两条)。真实 Loader 不传;**每个缝不传时都必须落回 ctx 或全局默认**,
4743
+ * 产品行为零漂移。声明与接线由 `test/deps-wiring.test.mjs` 双向扫描守着。
3076
4744
  */
3077
4745
  function apply(ctx, config = {}, deps = {}) {
3078
4746
  const { settings, configError } = resolveSettings(config);
3079
- const tokenStore = createTokenStore({ credentials: () => ctx.get("credentials") ?? null });
4747
+ const tokenStore = createTokenStore({
4748
+ credentials: deps.credentials !== void 0 ? deps.credentials : () => ctx.get("credentials") ?? null,
4749
+ env: deps.env
4750
+ });
3080
4751
  const profile = profileSegment(ctx);
3081
4752
  const usageStore = createFileUsageStore({
3082
4753
  name,
@@ -3095,10 +4766,14 @@ function apply(ctx, config = {}, deps = {}) {
3095
4766
  profile,
3096
4767
  logger: ctx.logger
3097
4768
  });
4769
+ const drawStore = createFileDrawStore({
4770
+ profile,
4771
+ logger: ctx.logger
4772
+ });
3098
4773
  const publisher = createProviderPublisher({
3099
4774
  settings,
3100
4775
  panelSwitch: () => providerStore.enabled(),
3101
- getLlm: (service) => ctx.get?.(service) ?? null,
4776
+ getLlm: deps.getLlm ?? ((service) => ctx.get?.(service) ?? null),
3102
4777
  resolveApiKey: async () => (await tokenStore.resolve()).value,
3103
4778
  usage: {
3104
4779
  recordCall: (input) => usageStore.recordCall({
@@ -3107,24 +4782,52 @@ function apply(ctx, config = {}, deps = {}) {
3107
4782
  }).catch(() => {}),
3108
4783
  recordEvent: (input) => usageStore.recordEvent(input).catch(() => {})
3109
4784
  },
3110
- emit: (event) => ctx.emit?.(event),
4785
+ emit: deps.emit ?? ((event) => ctx.emit?.(event)),
4786
+ ...deps.loadAdapterModule ? { loadAdapterModule: deps.loadAdapterModule } : {},
3111
4787
  logger: ctx.logger
3112
4788
  });
3113
4789
  /**
3114
- * 一次目录轮询:拉目录 + 读落盘允许清单,再 publish 当前 offer。
4790
+ * 当前允许清单:落盘优先,读不到回退内存最近一次 publish 的清单。
4791
+ *
4792
+ * 判据与另外两个消费者(`snapshot-aggregate.ts` 的面板块、`routes/provider.ts`
4793
+ * 的 GET)**共用** {@link resolveEnabledIds}——空清单是「不过滤,提供全部模型」,
4794
+ * 那是 provider-store 的契约,不是「没有答案」。本函数曾经自己写成
4795
+ * `stored.length > 0` 才算数,于是**可读的空清单**(操作者按 versionNote 的建议
4796
+ * 删掉 provider.json,或清单被保存为空)会让面板说 all、轮询却继续按内存里的旧
4797
+ * 清单注册:面板与 Host 对「正在提供什么」各说一套。只有读**抛错**(`.catch`
4798
+ * → null)才回退内存。
4799
+ * @returns {Promise<string[]>}
4800
+ */
4801
+ const currentEnabledIds = async () => resolveEnabledIds(await providerStore.enabledIds().catch(() => null), publisher.state.enabledIds);
4802
+ /**
4803
+ * 一次目录轮询:拉目录,再 publish 当前 offer。
4804
+ *
4805
+ * **窗口覆盖必须参与**:catalogSignature 含窗口值,轮询若用未打标条目 publish,
4806
+ * 用户保存覆盖后的每次轮询都会因「签名不同」把注册摘下重挂(覆盖每 pollSeconds
4807
+ * 静默回退一次、适配器每轮重建)。所以 fetch **前**先读落盘覆盖传给 fetchModels,
4808
+ * 与 `currentEnabledIds` 同一模式。
3115
4809
  *
3116
- * 允许清单必须走 `providerStore.enabledIds()`——清单是**落盘**的(provider-store
3117
- * 按 profile 分段持久化),重启后内存里的 `publisher.state.enabledIds` 早已清空,
3118
- * 若从它读,用户勾选的清单会随重启静默丢失(见 fix A)。
4810
+ * fetch **后**再读一次:目录拉取可能耗时数百毫秒,期间用户可能保存了新覆盖——
4811
+ * 保存路由已经用新值 publish 过,此时用旧标条目 publish 反而把注册**倒退**一个
4812
+ * 周期;这种窗口内跳过本轮即可(下一轮轮询自然带新值)。清单沿用 fetch 后读的
4813
+ * 老顺序(捕获 fetch 期间的编辑),两条时序理由同源。
3119
4814
  * @returns {Promise<void>}
3120
4815
  */
3121
4816
  const runPoll = async () => {
4817
+ const contextWindow = await providerStore.contextWindow().catch(() => null);
4818
+ let catalog = null;
3122
4819
  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 {}
4820
+ catalog = await inference.fetchModels(contextWindow);
4821
+ } catch (error) {
4822
+ ctx.logger?.warn?.(`${name}: catalog fetch failed (${redactSecrets$1(errMsg(error))}) — the next poll retries; the panel keeps its degraded models block`);
4823
+ return;
4824
+ }
4825
+ if (await providerStore.contextWindow().catch(() => null) !== contextWindow) return;
4826
+ try {
4827
+ await publisher.publish(catalog.entries, await currentEnabledIds(), []);
4828
+ } catch (error) {
4829
+ ctx.logger?.warn?.(`${name}: publish failed (${redactSecrets$1(errMsg(error))}) — the catalog is current, only the provider registration did not land`);
4830
+ }
3128
4831
  };
3129
4832
  const wiring = {
3130
4833
  settings,
@@ -3133,23 +4836,26 @@ function apply(ctx, config = {}, deps = {}) {
3133
4836
  usageStore,
3134
4837
  inference,
3135
4838
  providerStore,
4839
+ drawStore,
3136
4840
  publisher,
3137
4841
  logger: ctx.logger
3138
4842
  };
3139
4843
  const offs = registerRoutes(ctx, wiring);
3140
4844
  ctx.effect(() => {
3141
4845
  runPoll();
4846
+ registerDrawTool(ctx, wiring, deps);
3142
4847
  const timer = setInterval(() => {
3143
4848
  runPoll();
3144
4849
  }, settings.pollSeconds * 1e3);
3145
4850
  return () => {
3146
4851
  clearInterval(timer);
4852
+ resetDrawToolNote();
3147
4853
  publisher.dispose();
3148
4854
  publisher.release();
3149
4855
  for (const off of offs) try {
3150
4856
  off?.();
3151
4857
  } catch (error) {
3152
- ctx.logger?.warn?.(`${name}: route unregister failed: ${redactSecrets(errMsg(error))}`);
4858
+ ctx.logger?.warn?.(`${name}: route unregister failed: ${redactSecrets$1(errMsg(error))}`);
3153
4859
  }
3154
4860
  };
3155
4861
  }, `${name}: routes`);