dsh-connect-modelscope-token-plan 0.5.0 → 0.6.2

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
@@ -261,6 +261,10 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
261
261
  * 现在三件套各有一份公共实现:串行化是{@link createStateWriteQueue}、写守卫是
262
262
  * {@link guardStateWrite}、读侧说明是{@link stateVersionNote};provider /
263
263
  * usage / draw 三个 store 都是调用方,不再各写一遍。
264
+ * 4. 第四步删掉了{@link createStateReadCache} 的 `inheritFrom`——一个从未被
265
+ * 任何 store 使用、且写回路径绕过写队列的迁移缝(死代码 + 有害形状 +
266
+ * 文档误导三合一,裁定见该函数的说明);同时把{@link writeStateFile} 的
267
+ * 临时文件清理从「rename 失败」一条路扩大到**任何**失败路径。
264
268
  *
265
269
  * @module dsh-connect-modelscope-token-plan/state-store
266
270
  */
@@ -436,14 +440,17 @@ function temporaryOf(dir, base, now = Date.now) {
436
440
  * 载荷串以换行结尾,与本模块存在前每个 store 的写法一致。失败**向上传播** ——
437
441
  * 调用方自己决定只读 Home 要不要吞(throttle/catalog 吞,provider 开关不吞)。
438
442
  *
439
- * ## rename 失败时的临时文件清理
443
+ * ## 任何一步失败都要清掉临时文件
440
444
  *
441
445
  * `rename` 会失败,而最常见的原因是 Windows 上目标文件正被另一个进程/编辑器
442
446
  * 占着(EACCES/EPERM)。临时文件此前**留在原地**:名字是
443
447
  * `<base>.<pid>.<ts>.<uuid>.tmp`,每次失败多一个,于是
444
448
  * `state/<profile>/<name>/` 慢慢堆满垃圾,而没有任何代码会去清它们(全仓原本
445
- * 无任何 unlink)。现在失败时删掉自己的临时文件再重抛——清理失败不掩盖原始错误
446
- * (那才是调用方要处理的),所以只 `catch {}`。
449
+ * 无任何 unlink)。第一次修只补了 rename 失败那一条路;`writeFile` 本身失败
450
+ * (磁盘满)、`open` 失败、`handle.sync()` 失败(EIO)留下的半截临时文件照样
451
+ * 堆——2026-10-18 改为:**try 覆盖到 rename 成功为止,catch 里删掉自己的临时
452
+ * 文件再重抛**。清理失败不掩盖原始错误(那才是调用方要处理的),所以只
453
+ * `catch {}`。
447
454
  *
448
455
  * @param {string} file - 最终文件路径。
449
456
  * @param {string} payload - 序列化后的正文(JSON 文本)。
@@ -451,17 +458,17 @@ function temporaryOf(dir, base, now = Date.now) {
451
458
  * @returns {Promise<void>}
452
459
  */
453
460
  async function writeStateFile(file, payload, { temporary }) {
454
- await writeFile(temporary, `${payload}\n`, {
455
- encoding: "utf8",
456
- mode: 384
457
- });
458
- const handle = await open(temporary, "r+");
459
- try {
460
- await handle.sync();
461
- } finally {
462
- await handle.close();
463
- }
464
461
  try {
462
+ await writeFile(temporary, `${payload}\n`, {
463
+ encoding: "utf8",
464
+ mode: 384
465
+ });
466
+ const handle = await open(temporary, "r+");
467
+ try {
468
+ await handle.sync();
469
+ } finally {
470
+ await handle.close();
471
+ }
465
472
  await rename(temporary, file);
466
473
  } catch (error) {
467
474
  try {
@@ -496,54 +503,40 @@ const STATE_READ_TTL_MS = 1e3;
496
503
  * peer-free,与其余原语同纪律(不 import Host peer、离线可测)。时钟与 TTL
497
504
  * 都可注入,便于测试把缓存推进过期。
498
505
  *
499
- * `inheritFrom` 是一次性迁移缝:按 profile 分段后,本 profile 的新文件
500
- * 一开始并不存在,而旧版把值放在**所有 profile 共享**的目录里。给了它以后,
501
- * 读穿透发现自己的记录缺失时会去旧路径取一次、回填、再返回——**只尝试一次**
502
- * (`adopted` 标志),所以它不会变成每个 TTL 周期都多读一个文件。
503
- *
504
- * 为什么让缓存原语承担这件事,而不是在外面先跑一遍迁移脚本:迁移就有了时序,
505
- * 而"先迁移、再 seed"在 `apply()` 的同步构造里排不出确定顺序。挂在读穿透上
506
- * 则天然正确——任何读到"空"的地方都会自动拿到旧值,且与并发进程无关(读到
507
- * 同一份旧值、写同一份结果)。
506
+ * ## 这个原语为什么不负责 legacy 继承
507
+ *
508
+ * 一次性迁移缝(profile 分段前写在**共享**目录里的状态 → 本 profile 目录)曾经
509
+ * 作为 `inheritFrom` 选项住在这里,2026-10-18 删除,三个原因叠在一起:
510
+ * - **死代码**:四个 store 无一带它(`grep -rn inheritFrom src/` 只命中本文件
511
+ * 自己的声明;实际继承是 provider-store 在读穿透闭包里手写的);
512
+ * - **有害形状**:它的写回是**读路径内直接触发的写**,不经本 store 的写队列
513
+ * ——那正是 provider-store 注释里记着「咬过三次」的竞态形状:继承写与并发
514
+ * 的用户保存竞争,而继承只发生一次,被盖掉的那次保存是**永久**丢失的;
515
+ * - **文档误导**:本文件与 provider-store 的注释都把继承描述成走这个缝,
516
+ * 实为两处手写,读者会以为它是共享基础设施。
517
+ * 现在 provider / usage-prefs 各自在读穿透闭包里手写继承,并把回填排进**自己
518
+ * 的写队列**(裁定在各自头注)。新 store 要继承时照那个形状写,**不要**复活
519
+ * 这个参数。
508
520
  *
509
521
  * @template T
510
522
  * @param {() => Promise<T|null>} readThrough - 真正的读盘 + 解析;返回 `null` 表示无可用记录。
511
523
  * @param {object} [options]
512
524
  * @param {number} [options.ttlMs] - 缓存有效期,默认 {@link STATE_READ_TTL_MS}。
513
525
  * @param {() => number} [options.now] - 时钟源;测试注入。
514
- * @param {{read: () => Promise<T|null>, write: (value: T) => Promise<void>}|null} [options.inheritFrom]
515
- * - 旧版共享布局(`read`)与把它回填到本 profile(`write`);`null` = 不迁移。
516
526
  * @returns {{read: () => Promise<T|null>, remember: (value: T|null) => void}}
517
527
  */
518
528
  function createStateReadCache(readThrough, options = {}) {
519
- const { ttlMs = STATE_READ_TTL_MS, now = Date.now, inheritFrom = null } = options;
529
+ const { ttlMs = STATE_READ_TTL_MS, now = Date.now } = options;
520
530
  let cached = void 0;
521
531
  let cachedAt = 0;
522
- /** Whether the one-shot legacy adoption has already been attempted. */
523
- let adopted = false;
524
- /**
525
- * 读穿透:自己的记录优先;缺失且还有旧布局可继承时,取一次旧值并回填。
526
- * @returns {Promise<T|null>}
527
- */
528
- const load = async () => {
529
- const own = await readThrough();
530
- if (own !== null || inheritFrom === null || adopted) return own;
531
- adopted = true;
532
- const inherited = await inheritFrom.read();
533
- if (inherited === null) return null;
534
- try {
535
- await inheritFrom.write(inherited);
536
- } catch {}
537
- return inherited;
538
- };
539
532
  return {
540
533
  /**
541
- * 读值:TTL 内返回缓存,过期则穿透到 `load()`。
534
+ * 读值:TTL 内返回缓存,过期则穿透到 `readThrough()`。
542
535
  * @returns {Promise<T|null>}
543
536
  */
544
537
  async read() {
545
538
  if (cached !== void 0 && now() - cachedAt < ttlMs) return cached;
546
- cached = await load();
539
+ cached = await readThrough();
547
540
  cachedAt = now();
548
541
  return cached;
549
542
  },
@@ -883,6 +876,14 @@ function parseRetryAfterMs(headers, message) {
883
876
  * + rename)、损坏即忽略、版本守卫拒覆写未知版本。进程内 1s 读缓存让一次
884
877
  * 快照聚合的多次问询自洽。
885
878
  *
879
+ * 旧共享布局**不继承**(2026-10-18 补记的裱定):profile 分段前写在共享目录里的
880
+ * usage.json 不做一次性迁移——它回答「这个 profile 的调用吃了多少额度」,而无
881
+ * profile 时期(宿主不提供 profileContext)的账不属于任何一个 profile,整段搬
882
+ * 过来是把那段账算成这个 profile 的。宿主升级后开始提供该服务时,旧文件原地
883
+ * 保留,由 `npm run doctor` 以 shared (legacy) 作用域报出——面板不搬历史,但操作者看得见
884
+ * 它(诊断诚实性,见 doctor.ts)。(usage-prefs 的单值偏好反而继承,裱定见
885
+ * usage-prefs-store.ts 头注)。
886
+ *
886
887
  * 语义基线(wire.ts):
887
888
  * - 天桶缺失 = 该日本插件没有记录到调用 = 真实的 0,不是数据丢失;
888
889
  * - models 的 `tokens` 可为 null(上游没给 usage 时),绝不把 null 写成 0;
@@ -1056,7 +1057,19 @@ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEve
1056
1057
  await writeStateFile(file, JSON.stringify(payload), { temporary: temporaryOf(dir, "usage.json") });
1057
1058
  cache.remember(payload);
1058
1059
  };
1059
- /** 排队执行一次读-改-写;只读闸置位时整个变更是 no-op。 */
1060
+ /**
1061
+ * 排队执行一次读-改-写;版本冲突时整个变更是 no-op。
1062
+ *
1063
+ * 闸在**临界区内部**由 diskVersionBlocksWrites 现读磁盘判定,不读内存里的
1064
+ * readOnly——那个变量只用于日志去重与「闸已抬起」通知,删掉它不会让任何一次
1065
+ * 写入被放行或拦住(2026-10-18 变异实证:删掉复位块后「删文件后写入必须恢复」
1066
+ * 与「新计数落盘」两条断言仍然全绿)。
1067
+ *
1068
+ * 注释曾写「只读闸置位时整个变更是 no-op」——把判定归给内存闩锁。a61db2f(10-04,
1069
+ * 首次提交)起就是这么写的,那时磁盘闸还没长出来,那句话是真的;403bfbc(10-05)
1070
+ * 把守卫改成临界区内现读磁盘之后它就过期了,一直没被发现——因为没有任何断言
1071
+ * 读它,它只是一句注释。
1072
+ */
1060
1073
  const enqueue = (mutate) => writeQueue.enqueue(async () => {
1061
1074
  if (await diskVersionBlocksWrites()) return;
1062
1075
  const payload = await readThrough() ?? (loaded ? current : null) ?? {
@@ -1167,12 +1180,29 @@ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEve
1167
1180
  dateKey
1168
1181
  };
1169
1182
  },
1170
- /** 今天的单模型计数(只含有记录的模型,dayCalls/dayTokens 口径,均跨日清零)。 */
1183
+ /**
1184
+ * 今天的单模型计数(只含有记录的模型,dayCalls/dayTokens 口径,均跨日清零)。
1185
+ *
1186
+ * 三种答案,与 {@link daily} / {@link trend} 同一条红线(wire.ts:读不到 ≠ 0):
1187
+ * - 数组 —— 今天的单模型记录(只含有记录的模型;空数组 = 今天确实没有记录,
1188
+ * 是**真实零**);
1189
+ * - `null` —— **读不到**:载荷读不出来(损坏 / 形状不对 / 更新构建写的版本 /
1190
+ * 存在却不可读)。面板渲染「—」,不是空排行。
1191
+ *
1192
+ * 这是 2026-10-18 补上的最后一块拼图:daily() 三态在 2026-10-16、trend() 在
1193
+ * 本轮都补上了,perModelToday() 一直停在 `payload?.models ?? {}`——同一个坏文件
1194
+ * 在头条画「—」、在单模型排行却画「还没有本地调用记录。」,同一份坏数据在
1195
+ * 同一个卡片里给出两种答案。判据与 daily() 逐字一致:文件不存在才是真实的零,
1196
+ * 文件在却读成空是信息丢失。
1197
+ *
1198
+ * @returns {Array<{modelId: string, calls: number, tokens: number|null, lastAt: string|null}> | null}
1199
+ */
1171
1200
  async perModelToday() {
1172
1201
  const payload = await cache.read();
1202
+ if (payload === null) return await pathExists(file) ? null : [];
1173
1203
  const todayKey = localDateKey(now());
1174
1204
  const out = [];
1175
- for (const [modelId, counter] of Object.entries(payload?.models ?? {})) {
1205
+ for (const [modelId, counter] of Object.entries(payload.models ?? {})) {
1176
1206
  if (counter.day !== todayKey) continue;
1177
1207
  out.push({
1178
1208
  modelId,
@@ -1184,16 +1214,37 @@ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEve
1184
1214
  out.sort((a, b) => b.calls - a.calls);
1185
1215
  return out;
1186
1216
  },
1187
- /** 最近 N 天趋势(含今天;缺桶 = 该日 0 次,真实零不是数据丢失)。 */
1217
+ /**
1218
+ * 最近 N 天趋势(含今天)。
1219
+ *
1220
+ * 三种答案,与 {@link daily} 同一条红线(wire.ts:读不到 ≠ 0):
1221
+ * - `TrendBucket[]` —— 逐日桶,缺桶 = 该日 0 次(**真实零**:那天确实没有
1222
+ * 记录,是已知答案);
1223
+ * - `null` —— **读不到**:载荷读不出来(损坏 / 形状不对 / 更新构建写的
1224
+ * 版本 / 存在却不可读)。面板渲染「—」,不是一片零柱。
1225
+ *
1226
+ * 判据与 daily() 逐字一致:**文件不存在**才是真实的零(这台 Host 从未经本
1227
+ * 插件调用过),**文件在却读成空**是信息丢失。缺桶补 0 与整份读空补 0 是两件
1228
+ * 完全不同的事,前者是数据,后者是伪造。
1229
+ *
1230
+ * 这是 2026-10-18 补上的一块拼图:daily() 早在 2026-10-16 就有了三态,
1231
+ * trend() 一直只做了一半——`payload === null` 时照样逐日 push `{calls: 0}`,
1232
+ * 于是「usage.json 被写坏」在今日计数上画「—」、在趋势图上却画 14 根结实的
1233
+ * 零柱,同一份坏数据在两个区块给出两种答案。shapeWarnings 里那条 anomaly
1234
+ * 确实会出现,但它说「usage.json 有问题」,不说「你看到的这条趋势线是假的」。
1235
+ *
1236
+ * @returns {TrendBucket[] | null}
1237
+ */
1188
1238
  async trend(days = retentionDays) {
1189
1239
  const payload = await cache.read();
1240
+ if (payload === null) return await pathExists(file) ? null : [];
1190
1241
  const out = [];
1191
1242
  const cursor = now();
1192
1243
  for (let offset = Number(days) - 1; offset >= 0; offset -= 1) {
1193
1244
  const date = new Date(cursor);
1194
1245
  date.setDate(date.getDate() - offset);
1195
1246
  const key = localDateKey(date);
1196
- const bucket = payload?.days[key];
1247
+ const bucket = payload.days[key];
1197
1248
  out.push({
1198
1249
  dateKey: key,
1199
1250
  calls: bucket?.calls ?? 0,
@@ -1202,27 +1253,82 @@ function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEve
1202
1253
  }
1203
1254
  return out;
1204
1255
  },
1205
- /** 事件流(Newest-first,已截断)。 */
1256
+ /**
1257
+ * 事件流(Newest-first,已截断)。
1258
+ *
1259
+ * 三种答案,与 {@link daily} / {@link trend} 同一条红线(wire.ts:读不到 ≠ 0):
1260
+ * - 数组 —— 读到的事件(空数组 = 确实没有事件,是**真实零**);
1261
+ * - `null` —— **读不到**:载荷读不出来(损坏 / 形状不对 / 更新构建写的版本 /
1262
+ * 存在却不可读)。面板渲染「—」,不是「暂无事件」。
1263
+ *
1264
+ * 与 perModelToday() 同一块拼图(2026-10-18):`payload?.events ?? []` 把
1265
+ * 「读不到」和「确实没有」合成同一种答案,坏文件在事件卡上假装岁月静好。
1266
+ *
1267
+ * @returns {QuotaEvent[] | null}
1268
+ */
1206
1269
  async events() {
1207
- return [...(await cache.read())?.events ?? []];
1270
+ const payload = await cache.read();
1271
+ if (payload === null) return await pathExists(file) ? null : [];
1272
+ return [...payload.events ?? []];
1208
1273
  },
1209
1274
  /** 最近一次解析异常(无异常为 null)——快照转 shapeWarnings。 */
1210
1275
  anomaly() {
1211
1276
  return anomaly;
1212
- },
1213
- /** 无秘密的状态(doctor 用)。 */
1214
- async state() {
1215
- const payload = await cache.read();
1216
- return {
1217
- file: profile === null ? join(stateDir(name), "usage.json") : file,
1218
- hasRecord: payload !== null,
1219
- days: Object.keys(payload?.days ?? {}).length,
1220
- events: payload?.events.length ?? 0,
1221
- readOnly
1222
- };
1223
1277
  }
1224
1278
  };
1225
1279
  }
1280
+ /**
1281
+ * 从 429/错误事件流推断「哪些模型当前额度耗尽」——**纯函数**,判定规则的唯一真源。
1282
+ *
1283
+ * 为什么需要它:官方只给**账户级**魔粒余额(`/openapi/v1/magicubes/balance`),
1284
+ * 没有 per-model 额度端点;而「哪个模型刚才在限频」恰恰是面板独家能答的问题。
1285
+ * 证据只有两类:事件流(`kind` 已按 llm-error-fix **重分类后**的口径,见
1286
+ * `usage-observer.ts` 头注)与单模型今日计数,本函数只用前者——计数分不清
1287
+ * 成功与失败(`recordCall` 按流计次、含失败),拿它当证据会猜。
1288
+ *
1289
+ * 判定规则(三条,全部可机械复现):
1290
+ * 1. **只有 `kind:"quota"` 算证据**。`rate_limit` 是瞬时限频(peer 会退避
1291
+ * 重试,请求打得出去),`error` 是未知——把任何 429 都当耗尽是 0.2.0
1292
+ * 已修掉的误判(QUOTA 快速失败 vs RATE_LIMIT 退避,两条路不同)。
1293
+ * 2. **只在推断窗口内有效**:事件 `at` 落在 `[now - windowMinutes, +∞)`。
1294
+ * 窗口外的旧证据不算——免费额度按天重置,昨天的耗尽不该关掉今天的模型。
1295
+ * 3. **不推断恢复**:成功的流不发事件,所以「模型已恢复」在这份数据里
1296
+ * **不可观测**。已恢复的模型最多灰显满一个窗口——方向是保守的(宁可少
1297
+ * 提供一会,不把注定 429 的模型放进选择器),且面板灰显带着原因,
1298
+ * 不是静默消失。
1299
+ *
1300
+ * 总量语义:`events` 是环形缓冲(`maxEvents` 截断、Newest-first),所以「窗口
1301
+ * 内没有证据」可能是**真没有**、也可能是**证据被截掉了**——后者只发生在
1302
+ * 极端刷屏(50 条以内)时。**方向是假阴性,不是保守**:证据被挤掉 → 集合收缩
1303
+ * → 一个仍耗尽的模型可能提前回灰、被重新放进选择器。以前这里写的是「保守收敛」,
1304
+ * 把危险方向说成了安全方向(下一轮会话会据此放松,所以改掉)。它之所以还能接受,
1305
+ * 是因为失败面很小且自限:窗口最长 1440min、事件天然会滑出,`reconcile` 每轮
1306
+ * 现算,下一个 quota 事件会立刻把它重新灰掉。时间戳解析不出的事件跳过,不猜。
1307
+ *
1308
+ * @param {readonly QuotaEvent[]|null|undefined} events - 事件流(任意顺序)。
1309
+ * @param {Date|number} [now] - 时钟源(测试注入)。
1310
+ * @param {number} [windowMinutes] - 推断窗口(分钟)。非正/非有限时返回空集
1311
+ * ——总量防御:host-config 已把配置夹到 [5, 1440],走到这里就是编程错误,
1312
+ * 「没有证据」比按坏窗口算出一个莫名的集合诚实。
1313
+ * @returns {string[]} 当前应视为额度耗尽的模型 id(去重、排序,稳定快照)。
1314
+ */
1315
+ function exhaustedModelIds(events, now = Date.now(), windowMinutes = 60) {
1316
+ if (!Number.isFinite(windowMinutes) || windowMinutes <= 0) return [];
1317
+ const list = Array.isArray(events) ? events : [];
1318
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
1319
+ if (!Number.isFinite(nowMs)) return [];
1320
+ const since = nowMs - windowMinutes * 6e4;
1321
+ const out = /* @__PURE__ */ new Set();
1322
+ for (const event of list) {
1323
+ if (event === null || typeof event !== "object") continue;
1324
+ if (event.kind !== "quota") continue;
1325
+ const at = Date.parse(String(event.at));
1326
+ if (!Number.isFinite(at) || at < since) continue;
1327
+ const modelId = typeof event.modelId === "string" ? event.modelId : "";
1328
+ if (modelId !== "") out.add(modelId);
1329
+ }
1330
+ return [...out].sort();
1331
+ }
1226
1332
 
1227
1333
  //#endregion
1228
1334
  //#region src/host/coalesced-fetch.ts
@@ -1853,6 +1959,16 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1853
1959
  * filter", which is the only safe default for a fresh install. A foreign file
1854
1960
  * reads the same way, which is precisely why `versionNote()` exists.
1855
1961
  *
1962
+ * Legacy adoption (ruling 2026-10-18): pre-§23 machines kept this state in
1963
+ * the SHARED directory; a profile-scoped store inherits it ONCE, when its own
1964
+ * file is missing. The seam lives in the read-through closure below, NOT in a
1965
+ * cache option, and the write-back rides this store's write chain; an explicit
1966
+ * `dir` (the tests) never inherits, and an explicit `forget()` is never
1967
+ * resurrected. The mirror rulings for the other three state stores live in
1968
+ * their own file headers: usage-prefs inherits the same way, usage / draw do
1969
+ * NOT adopt — the old shared files stay put, and `npm run doctor` reports them
1970
+ * as a `shared (legacy)` scope instead.
1971
+ *
1856
1972
  * @module dsh-connect-modelscope-token-plan/provider-store
1857
1973
  */
1858
1974
  /**
@@ -1994,8 +2110,8 @@ function resolveEnabledIds(stored, remembered) {
1994
2110
  */
1995
2111
  function createFileProviderStore(options = {}) {
1996
2112
  const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
1997
- const stateDir$1 = dir ?? providerDir(profile);
1998
- const filePath = join(stateDir$1, "provider.json");
2113
+ const stateDir$2 = dir ?? providerDir(profile);
2114
+ const filePath = join(stateDir$2, "provider.json");
1999
2115
  /**
2000
2116
  * Write one payload atomically to this file.
2001
2117
  *
@@ -2021,8 +2137,8 @@ function createFileProviderStore(options = {}) {
2021
2137
  logger
2022
2138
  });
2023
2139
  if (refusal !== null) return refusal;
2024
- await ensureStateDir(stateDir$1);
2025
- await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$1, "provider.json") });
2140
+ await ensureStateDir(stateDir$2);
2141
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$2, "provider.json") });
2026
2142
  return null;
2027
2143
  };
2028
2144
  const legacyFile = dir === void 0 && profile ? join(stateDir(name), "provider.json") : null;
@@ -2295,8 +2411,14 @@ function createFileProviderStore(options = {}) {
2295
2411
  * 值被忽略」呈现成「从未保存过」。
2296
2412
  *
2297
2413
  * 与 provider-store 的一处**有意差异**:没有 legacy(pre-profile 共享目录)继承。
2298
- * draw.json 随出图工具一起诞生,晚于按 profile 分段,市面上不存在共享布局里的
2299
- * 旧 draw.json;provider.json 则需要(§23 之前它一直在共享目录)。
2414
+ * draw.json 随出图工具一起诞生,晚于按 profile 分段(git 史上成立),且宿主一直提供
2415
+ * profileContext 的机器上不存在共享布局里的旧 draw.json;provider.json 则需要
2416
+ * (§23 之前它一直在共享目录)。
2417
+ * 前提修正(2026-10-18):「市面上不存在」只对宿主一直提供 profileContext 的机器成立。
2418
+ * 宿主不提供时 profile 恒为 null,draw.json 今至仍写共享目录;宿主升级后提供该服务
2419
+ * 时,同一个迁移窗口在 draw.json 上一模一样——那种窗口里旧 draw.json 不继承不搬,
2420
+ * doctor 以 shared (legacy) 作用域报出,出图开关与模型偏好回 patch 默认(与 usage.json 同侧:
2421
+ * 诊断可见 > 静默搬运)。
2300
2422
  *
2301
2423
  * doctor 覆盖:`describeDrawPayload` 给只读速览,`doctor.ts` 据此把 draw.json
2302
2424
  * 的「损坏 / 未知版本」报进症状表——写侧守卫挡覆盖,doctor 报静默降级。
@@ -2570,6 +2692,12 @@ function switchSource(panel) {
2570
2692
  * (无 profile 时落共享目录)。分段的意思是「这个 profile 想看多长」,两个 profile
2571
2693
  * 不互相覆盖。
2572
2694
  *
2695
+ * legacy 继承(2026-10-18 裁定):pre-profile 共享目录里的 usage-prefs.json 在
2696
+ * 本 profile 自己的文件**不在场**时一次性继承——与 provider-store 同款手写缝
2697
+ *(读路径内触发、回填排进**本 store 的写队列**、显式清除不复活)。为什么这个
2698
+ * 搬而 usage.json 不搬:单值显示偏好回填无害,而历史计数是「本 profile 的账」,
2699
+ * 搬过来是把无 profile 时期的账算成这个 profile 的(usage-store.ts 头注的裁定)。
2700
+ *
2573
2701
  * 优先级与所有 opt-in 开关同一份裁决(`switch-precedence.ts`):面板保存值赢,
2574
2702
  * 否则 `cordis.patch.yml` 的 `trendDays` 说了算;两者都会在 host-config 里被夹进
2575
2703
  * 保留窗口之内(`retentionDays`)——**画得比留得多**会把已剪的日子画成 0。
@@ -2648,8 +2776,8 @@ function resolveWindowDays(panel, fallback, maxDays) {
2648
2776
  */
2649
2777
  function createFileUsagePrefsStore(options = {}) {
2650
2778
  const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
2651
- const stateDir = usagePrefsDir(profile, dir);
2652
- const filePath = join(stateDir, "usage-prefs.json");
2779
+ const stateDir$1 = usagePrefsDir(profile, dir);
2780
+ const filePath = join(stateDir$1, "usage-prefs.json");
2653
2781
  /**
2654
2782
  * 写一份 payload 并落盘。
2655
2783
  *
@@ -2665,18 +2793,19 @@ function createFileUsagePrefsStore(options = {}) {
2665
2793
  logger
2666
2794
  });
2667
2795
  if (refusal !== null) return refusal;
2668
- await ensureStateDir(stateDir);
2669
- await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "usage-prefs.json") });
2796
+ await ensureStateDir(stateDir$1);
2797
+ await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$1, "usage-prefs.json") });
2670
2798
  return null;
2671
2799
  };
2672
2800
  /** 写串行化:读-改-写必须排队(详见{@link createStateWriteQueue})。 */
2673
2801
  const writeQueue = createStateWriteQueue();
2674
2802
  /**
2675
- * 解析一份 payload;认不出的版本读作「没设过」但**记住**版本号——读侧宽容、
2676
- * versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是 ADR-006)。
2803
+ * 解析一份路径上的 payload;认不出的版本读作「没设过」但**记住**版本号——读侧
2804
+ * 宽容、versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是
2805
+ * ADR-006)。
2677
2806
  */
2678
- const parsePayload = async () => {
2679
- const source = obj(await readStateJson(filePath));
2807
+ const parseAt = async (path) => {
2808
+ const source = obj(await readStateJson(path));
2680
2809
  const version = typeof source.version === "number" ? source.version : null;
2681
2810
  if (!isKnownUsagePrefsVersion(version)) return {
2682
2811
  windowDays: null,
@@ -2687,7 +2816,21 @@ function createFileUsagePrefsStore(options = {}) {
2687
2816
  version
2688
2817
  };
2689
2818
  };
2690
- const cache = createStateReadCache(parsePayload, { ttlMs });
2819
+ /** 读本 profile 自己的文件。 */
2820
+ const parsePayload = async () => parseAt(filePath);
2821
+ const legacyFile = dir === void 0 && profile ? join(stateDir(name), "usage-prefs.json") : null;
2822
+ const cache = createStateReadCache(async () => {
2823
+ const own = await parsePayload();
2824
+ if (own.windowDays !== null || legacyFile === null || own.version !== null) return own;
2825
+ const legacy = await parseAt(legacyFile);
2826
+ if (legacy.windowDays === null) return own;
2827
+ await writeQueue.enqueue(() => writePayload({
2828
+ version: 1,
2829
+ windowDays: legacy.windowDays,
2830
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2831
+ }).then(() => void 0, () => void 0));
2832
+ return legacy;
2833
+ }, { ttlMs });
2691
2834
  /**
2692
2835
  * 读当前 payload。
2693
2836
  * @returns {Promise<UsagePrefsPayload>}
@@ -3652,6 +3795,14 @@ function sleep(ms, signal) {
3652
3795
  /** 当前缺席原因(`null` = 未登记 / 已清掉)。 */
3653
3796
  let note = null;
3654
3797
  /**
3798
+ * 本次挂载是否真的把工具注册上了。
3799
+ *
3800
+ * 与 `note` 的区别见文件头:`note` 是「注册尝试过且失败的原因」,这一位是「注册
3801
+ * 成功与否」。开关在挂载后才翻开的场景,两者都是「没有缺席原因 + 没注册上」,
3802
+ * 只有这一位能把它认出来。
3803
+ */
3804
+ let mounted = false;
3805
+ /**
3655
3806
  * 记下缺席原因(`null` 清空)。
3656
3807
  * @param {DrawToolAbsentReason|null} reason - 原因码。
3657
3808
  * @returns {void}
@@ -3667,11 +3818,28 @@ function drawToolNote() {
3667
3818
  return note;
3668
3819
  }
3669
3820
  /**
3821
+ * 记下「本次挂载已注册上」。只有注册路径走完才置 true;缺席与「开关没开」都是
3822
+ * false。
3823
+ * @param {boolean} value - 是否注册成功。
3824
+ * @returns {void}
3825
+ */
3826
+ function setDrawToolMounted(value) {
3827
+ mounted = value === true;
3828
+ }
3829
+ /**
3830
+ * 读「本次挂载是否注册上了」。
3831
+ * @returns {boolean} true = 注册成功。
3832
+ */
3833
+ function drawToolMounted() {
3834
+ return mounted;
3835
+ }
3836
+ /**
3670
3837
  * 清空(卸载时调用:下一个 Host 生命周期从干净的「未登记」开始)。
3671
3838
  * @returns {void}
3672
3839
  */
3673
3840
  function resetDrawToolNote() {
3674
3841
  note = null;
3842
+ mounted = false;
3675
3843
  }
3676
3844
 
3677
3845
  //#endregion
@@ -3738,6 +3906,7 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
3738
3906
  const { settings, configError, tokenStore, inference, drawStore, publisher, logger } = wiring;
3739
3907
  const loggerFor = logger ?? ctx.logger;
3740
3908
  setDrawToolNote(null);
3909
+ setDrawToolMounted(false);
3741
3910
  try {
3742
3911
  if (configError !== null) return;
3743
3912
  const panelValue = drawStore ? await drawStore.enabled().catch(() => null) : null;
@@ -3781,6 +3950,7 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
3781
3950
  },
3782
3951
  isDisposed
3783
3952
  }));
3953
+ setDrawToolMounted(true);
3784
3954
  } catch (error) {
3785
3955
  setDrawToolNote("registry-refused");
3786
3956
  loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets(errMsg(error))}) — the tool is absent`);
@@ -4365,6 +4535,7 @@ function createProviderPublisher(deps = {}) {
4365
4535
  entries: state.entries,
4366
4536
  enabledIds: state.enabledIds,
4367
4537
  baseUrl: effectiveSettings.apiBase,
4538
+ maxTokensOverrides: effectiveSettings.maxTokensOverrides ?? {},
4368
4539
  resolveApiKey: effectiveResolveApiKey,
4369
4540
  get: effectiveGetLlm,
4370
4541
  unavailableModelIds: state.unavailableIds,
@@ -4564,7 +4735,8 @@ function syncSignaturesAfterPublish(state) {
4564
4735
  * - **desired state**(操作者想要什么)= `providerStore` 里的开关 + 允许
4565
4736
  * 清单 + 窗口覆盖。它只有一个家,轮询和路由都只读它、不各写一份;
4566
4737
  * - **reconcile 一遍** = 读 desired → 带窗口覆盖拉目录 → 复查 desired
4567
- * (fetch 期间被改就重跑,不等下一轮)→ 把「当前目录 + 当前清单」交给
4738
+ * (fetch 期间被改就重跑,不等下一轮)→ 算这一轮的额度耗尽集合(事件流 +
4739
+ * 纯函数 `exhaustedModelIds`)→ 把「当前目录 + 当前清单 + 耗尽集合」交给
4568
4740
  * publisher(它自己重读开关,single source 不变);
4569
4741
  * - **触发方全部降级为「标记 dirty」**:挂载、轮询定时器、四条写路由。
4570
4742
  * 谁都不用记得「别人是不是也在 publish」——队列性质由本模块的串行链保证。
@@ -4590,20 +4762,40 @@ function syncSignaturesAfterPublish(state) {
4590
4762
  * 最多收敛成两遍 reconcile,不会排 N 遍。调用方 await 返回的 promise,即
4591
4763
  * 「我的触发已被消费」。
4592
4764
  * @param {ProviderReconcilerDeps} deps - wiring 子集。
4593
- * @returns {{reconcile: () => Promise<void>}} 触发一次收敛。
4765
+ * @returns {{reconcile: () => Promise<void>; health: () => {{ lastError: unknown }} }}
4594
4766
  */
4595
4767
  function createProviderReconciler(deps) {
4596
- const { providerStore, inference, publisher, logger } = deps;
4768
+ const { settings, providerStore, inference, publisher, usageEvents, logger } = deps;
4597
4769
  /**
4598
- * 读一次 desired。三路都 `.catch(() => null)`:读不到按「未设置」降级,
4599
- * 与开关/清单/覆盖各自的读侧纪律一致——一次读失败不该把收敛打掉。
4770
+ * 把一次 store 读取收成「值或 null」——**同步抛与异步拒收都降级**。
4771
+ *
4772
+ * store 是外部契约:当前三个 store 方法都是 `async`(同步抛不可达),但这
4773
+ * 不是可依赖的保证——病态实现、未来改签名、测试替身都可能同步抛。同步抛
4774
+ * 曾在 `Promise.all([a(), b(), c()])` 的**数组构造处**逃逸,把整个 executor
4775
+ * 变成 rejection(2026-10-18 实测的链毒化入口,见 {@link reconcile} 的防
4776
+ * 毒化注释)。一次读失败按「未设置」降级是本文件既有纪律,同步抛与拒收
4777
+ * 不该得到不同待遇。
4778
+ * @template T
4779
+ * @param {() => T | Promise<T>} read - 一次 store 读取。
4780
+ * @returns {Promise<T | null>} 读到的值,或 null(读不到)。
4781
+ */
4782
+ const settle = (read) => {
4783
+ try {
4784
+ return Promise.resolve(read()).catch(() => null);
4785
+ } catch {
4786
+ return Promise.resolve(null);
4787
+ }
4788
+ };
4789
+ /**
4790
+ * 读一次 desired。三路都降级为「未设置」:读不到按未设置处理,与开关/清单/
4791
+ * 覆盖各自的读侧纪律一致——一次读失败不该把收敛打掉。
4600
4792
  * @returns {Promise<Desired>}
4601
4793
  */
4602
4794
  const readDesired = async () => {
4603
4795
  const [enabled, enabledIds, contextWindow] = await Promise.all([
4604
- providerStore.enabled().catch(() => null),
4605
- providerStore.enabledIds().catch(() => null),
4606
- providerStore.contextWindow().catch(() => null)
4796
+ settle(() => providerStore.enabled()),
4797
+ settle(() => providerStore.enabledIds()),
4798
+ settle(() => providerStore.contextWindow())
4607
4799
  ]);
4608
4800
  return {
4609
4801
  enabled,
@@ -4618,6 +4810,26 @@ function createProviderReconciler(deps) {
4618
4810
  desired.contextWindow
4619
4811
  ]);
4620
4812
  /**
4813
+ * 读这一轮要发布的「额度耗尽集合」。
4814
+ *
4815
+ * 事件读失败**不抛**(收敛不能因为一条观测路径坏了就停):降级为空集并留一条
4816
+ * warn——与目录 fetch 失败同纪律(原因不丢、下一轮重试)。方向要说清:空集
4817
+ * 意味着「没有证据」,picker 会提供那些模型;这是把可观测失败换算成「暂时
4818
+ * 没有证据」的取舍,比让整个收敛链崩掉诚实。
4819
+ * @returns {Promise<string[]>}
4820
+ */
4821
+ const readUnavailable = async () => {
4822
+ if (usageEvents === void 0) return [];
4823
+ let events;
4824
+ try {
4825
+ events = await usageEvents();
4826
+ } catch (error) {
4827
+ logger?.warn?.(`${name}: usage events read failed (${redactSecrets(errMsg(error))}) — no quota-exhaustion evidence this pass`);
4828
+ return [];
4829
+ }
4830
+ return exhaustedModelIds(events, /* @__PURE__ */ new Date(), settings.exhaustionWindowMinutes);
4831
+ };
4832
+ /**
4621
4833
  * 收敛一遍:读 desired → 拉目录 → 复查 desired → publish。
4622
4834
  * @returns {Promise<void>}
4623
4835
  */
@@ -4637,8 +4849,9 @@ function createProviderReconciler(deps) {
4637
4849
  }
4638
4850
  const entries = Array.isArray(catalog?.entries) ? catalog.entries : [];
4639
4851
  const enabledIds = resolveEnabledIds(after.enabledIds, publisher.state.enabledIds);
4852
+ const unavailable = await readUnavailable();
4640
4853
  try {
4641
- await publisher.publish(entries, enabledIds, []);
4854
+ await publisher.publish(entries, enabledIds, unavailable);
4642
4855
  } catch (error) {
4643
4856
  logger?.warn?.(`${name}: publish failed (${redactSecrets(errMsg(error))}) — the catalog is current, only the provider registration did not land`);
4644
4857
  }
@@ -4648,13 +4861,21 @@ function createProviderReconciler(deps) {
4648
4861
  /** 有新触发待消费。闭包状态,不暴露——触发方只看得见 {@link reconcile}。 */
4649
4862
  let dirty = false;
4650
4863
  /**
4864
+ * 最近一次「未被 runOnce 自行记档」的意外反抗(executor 层面漏网的抛,如
4865
+ * `publisher.state` 的 getter 同步抛),下一遍成功即清空。路由侧 await 拿到
4866
+ * `result` 会自行 respondError;但**轮询侧 `void reconcile()` 会把它丢掉**——
4867
+ * 这份 lastError + 一条 warn 是「这一遍没收敛成」对操作员/测试唯一可观测的
4868
+ * 记档(否则链的防毒化会把它连同结果一起吞成 0 痕迹,见 {@link reconcile} 注释)。
4869
+ */
4870
+ let lastError = null;
4871
+ /**
4651
4872
  * 触发一次收敛。幂等且可重入:在途时只置 dirty,由在途那一轮替本调用消费
4652
4873
  * (N 次连续触发最多收敛成两遍 reconcile,不会排 N 遍)。
4653
4874
  * @returns {Promise<void>} 本触发已被消费(注册态已反映 desired)后 resolve。
4654
4875
  */
4655
4876
  const reconcile = () => {
4656
4877
  dirty = true;
4657
- return chain = chain.then(async () => {
4878
+ const result = chain.then(async () => {
4658
4879
  let guard = 0;
4659
4880
  while (dirty) {
4660
4881
  dirty = false;
@@ -4662,8 +4883,20 @@ function createProviderReconciler(deps) {
4662
4883
  if (++guard > 20) break;
4663
4884
  }
4664
4885
  });
4886
+ result.then(() => {
4887
+ lastError = null;
4888
+ }, (error) => {
4889
+ lastError = error;
4890
+ logger?.warn?.(`${name}: reconcile pass failed (${redactSecrets(errMsg(error))}); state will be reconciled on the next trigger`);
4891
+ });
4892
+ chain = result.then(() => void 0, () => void 0);
4893
+ return result;
4894
+ };
4895
+ return {
4896
+ reconcile,
4897
+ /** 最近一次未被记档的意外反抗(成功即清空);null = 当前健康。 */
4898
+ health: () => ({ lastError })
4665
4899
  };
4666
- return { reconcile };
4667
4900
  }
4668
4901
 
4669
4902
  //#endregion
@@ -4769,7 +5002,7 @@ async function buildSnapshotBody(wiring) {
4769
5002
  const panel = await providerStore.enabled().catch(() => null);
4770
5003
  const enabled = resolveSwitchEnabled(panel, settings.registerProvider);
4771
5004
  const source = switchSource(panel);
4772
- const roster = rosterWithAvailability(modelEntries, []);
5005
+ const roster = rosterWithAvailability(modelEntries, publisher.state.unavailableIds ?? [], settings.maxTokensOverrides);
4773
5006
  const stored = await providerStore.enabledIds().catch(() => null);
4774
5007
  const enabledIds = resolveEnabledIds(stored, publisher.state.enabledIds);
4775
5008
  const allowedList = parseAllowedList(enabledIds);
@@ -4806,7 +5039,8 @@ async function buildSnapshotBody(wiring) {
4806
5039
  effectiveModel: choice.id,
4807
5040
  effectiveSource: choice.source,
4808
5041
  absentReason: drawToolNote(),
4809
- mountPending: resolveSwitchEnabled(panel, settings.drawEnabled) === true && drawToolNote() !== null,
5042
+ mountPending: resolveSwitchEnabled(panel, settings.drawEnabled) === true && drawToolMounted() !== true,
5043
+ preferredInCatalog: preferred === null ? null : models.ok ? candidates.some((row) => row.id === preferred) : null,
4810
5044
  toolName: "modelscope_draw_image"
4811
5045
  };
4812
5046
  })());
@@ -4846,15 +5080,15 @@ async function buildSnapshotBody(wiring) {
4846
5080
  },
4847
5081
  quota: {
4848
5082
  daily: { usedLocal },
4849
- perModel: perModel.ok ? perModel.value : [],
5083
+ perModel: perModel.ok ? perModel.value : null,
4850
5084
  countingNote: "local-counting"
4851
5085
  },
4852
- events: events.ok ? events.value : [],
5086
+ events: events.ok ? events.value : null,
4853
5087
  trend: {
4854
5088
  days: windowDays,
4855
5089
  windowDays: panelWindow,
4856
5090
  maxDays: settings.retentionDays,
4857
- buckets: trend.ok ? trend.value : []
5091
+ buckets: trend.ok ? trend.value : null
4858
5092
  },
4859
5093
  models: {
4860
5094
  available: models.ok,
@@ -5334,7 +5568,7 @@ function registerProviderRoute(ctx, wiring) {
5334
5568
  if (options.tolerateCatalogFailure !== true) throw error;
5335
5569
  catalogError = redactedError(error);
5336
5570
  }
5337
- const roster = rosterWithAvailability(entries, []);
5571
+ const roster = rosterWithAvailability(entries, publisher.state.unavailableIds ?? [], settings.maxTokensOverrides);
5338
5572
  const allowedList = parseAllowedList(enabledIds);
5339
5573
  const modelCount = roster.length;
5340
5574
  return {
@@ -5502,16 +5736,31 @@ function registerProviderRoute(ctx, wiring) {
5502
5736
  * @module dsh-connect-modelscope-token-plan/routes/draw
5503
5737
  */
5504
5738
  /**
5505
- * 读目录条目,读不到按空目录降级。
5739
+ * 读目录条目。
5506
5740
  *
5507
5741
  * 与 provider 路由同一条纪律:GET 不能因为一次目录失败就把整个面板状态打掉——
5508
5742
  * 出图候选为空只影响「能不能挑模型」,不影响开关的显示。
5743
+ *
5744
+ * 但「空目录」和「目录读不到」必须分开(红线 3):后者要能回答「首选模型还在
5745
+ * 不在」时说「不知道」而不是「不在」。所以这里返回 `{ ok, entries }`,调用方
5746
+ * 按 ok 决定是断言还是弃权。
5747
+ *
5509
5748
  * @param {object} inference - inference client。
5510
- * @returns {Promise<unknown[]>} 归一目录条目。
5749
+ * @returns {Promise<{ok: boolean, entries: unknown[]}>} 目录读取结果与归一条目。
5511
5750
  */
5512
5751
  async function readEntries(inference) {
5513
- const catalog = await inference.fetchModels().catch(() => ({ entries: [] }));
5514
- return Array.isArray(catalog?.entries) ? catalog.entries : [];
5752
+ try {
5753
+ const catalog = await inference.fetchModels();
5754
+ return {
5755
+ ok: true,
5756
+ entries: Array.isArray(catalog?.entries) ? catalog.entries : []
5757
+ };
5758
+ } catch {
5759
+ return {
5760
+ ok: false,
5761
+ entries: []
5762
+ };
5763
+ }
5515
5764
  }
5516
5765
  /**
5517
5766
  * 注册出图路由(两条路径)。
@@ -5529,10 +5778,10 @@ function registerDrawRoute(ctx, wiring) {
5529
5778
  const snapshot = async (extra = {}) => {
5530
5779
  const panelSwitch = await optional(drawStore.enabled());
5531
5780
  const panelModelId = await optional(drawStore.modelId());
5532
- const entries = await readEntries(inference);
5533
- const candidates = drawCandidates(entries);
5781
+ const catalog = await readEntries(inference);
5782
+ const candidates = drawCandidates(catalog.entries);
5534
5783
  const preferredModelId = resolveSwitchValue(panelModelId, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
5535
- const choice = resolveDrawModelChoice(entries, void 0, preferredModelId ?? void 0);
5784
+ const choice = resolveDrawModelChoice(catalog.entries, void 0, preferredModelId ?? void 0);
5536
5785
  return {
5537
5786
  ok: true,
5538
5787
  enabled: resolveSwitchEnabled(panelSwitch, settings.drawEnabled),
@@ -5550,9 +5799,14 @@ function registerDrawRoute(ctx, wiring) {
5550
5799
  *
5551
5800
  * 工具无法注销(peer 的 tools registry 没有 unregister),所以开关翻转只在
5552
5801
  * 下次 Host 启动时生效。面板据此显示一句说明——否则用户会以为开关坏了。
5553
- * 判据:开关为开且本次挂载尚未注册成功(或缺席原因未知)。
5802
+ * 判据:开关为开且**本次挂载没注册上**。
5803
+ *
5804
+ * 注意判据不是「有缺席原因」:缺席原因非 null 表示注册尝试过且失败,而挂载
5805
+ * 那一刻开关是关的(用户之后才翻开)时 absentReason 为 null、却同样没注册
5806
+ * 上——后者才是最需要这句提示的场景。见 `draw-tool-state.ts` 文件头。
5554
5807
  */
5555
- mountPending: resolveSwitchEnabled(panelSwitch, settings.drawEnabled) === true && drawToolNote() !== null,
5808
+ mountPending: resolveSwitchEnabled(panelSwitch, settings.drawEnabled) === true && drawToolMounted() !== true,
5809
+ preferredInCatalog: preferredModelId === null ? null : catalog.ok ? candidates.some((row) => row.id === preferredModelId) : null,
5556
5810
  toolName: "modelscope_draw_image",
5557
5811
  ...extra
5558
5812
  };
@@ -5833,6 +6087,7 @@ function apply(ctx, config = {}, deps = {}) {
5833
6087
  providerStore,
5834
6088
  inference,
5835
6089
  publisher,
6090
+ usageEvents: () => usageStore.events(),
5836
6091
  logger: ctx.logger
5837
6092
  });
5838
6093
  const wiring = {
@@ -5850,10 +6105,14 @@ function apply(ctx, config = {}, deps = {}) {
5850
6105
  };
5851
6106
  const offs = registerRoutes(ctx, wiring);
5852
6107
  ctx.effect(() => {
5853
- reconcile();
6108
+ reconcile().catch((error) => {
6109
+ ctx.logger?.warn?.(`${name}: mount reconcile failed (${redactSecrets(errMsg(error))})`);
6110
+ });
5854
6111
  registerDrawTool(ctx, wiring, deps);
5855
6112
  const timer = setInterval(() => {
5856
- reconcile();
6113
+ reconcile().catch((error) => {
6114
+ ctx.logger?.warn?.(`${name}: scheduled reconcile failed (${redactSecrets(errMsg(error))})`);
6115
+ });
5857
6116
  }, settings.pollSeconds * 1e3);
5858
6117
  return () => {
5859
6118
  clearInterval(timer);