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

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,4 +1,4 @@
1
- import { C as errMsg, D as redactSecrets, E as pluginError, O as str, S as degrade, T as optional, _ as parseAllowedList, a as resolveSettings, b as looksLikeRateLimit, c as LLM_PROVIDER_ID, d as imageGenModelIds, f as isVisionModel, g as enabledCountOf, h as rosterWithAvailability, i as name, k as verbatim, m as resolveDrawModelChoice, n as inject, p as normalizeEntry, r as isAdmitted, s as LLM_DISPLAY_NAME, t as PLUGIN_VERSION, u as drawCandidates, w as obj, y as hasHardQuotaWordingIn } from "./host-config.js";
1
+ import { A as verbatim, C as degrade, D as pluginError, E as optional, O as redactSecrets, T as obj, _ as enabledCountOf, a as name, b as hasHardQuotaWordingIn, c as LLM_DISPLAY_NAME, d as drawCandidates, f as imageGenModelIds, g as rosterWithAvailability, h as resolveDrawModelChoice, i as isAdmitted, k as str, l as LLM_PROVIDER_ID, m as normalizeEntry, n as PLUGIN_VERSION, o as resolveSettings, p as isVisionModel, r as inject, t as DRAW_TIMEOUT_MAX_MS, v as parseAllowedList, w as errMsg, x as looksLikeRateLimit } from "./host-config.js";
2
2
  import { basename, join } from "node:path";
3
3
  import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { randomUUID } from "node:crypto";
@@ -130,7 +130,13 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
130
130
  };
131
131
  return {
132
132
  /**
133
- * 把面板输入的令牌存成引用;原样存(不 trim),空白值视为没输入。
133
+ * 把面板输入的令牌存成引用;**两端空白先 trim**,空白值视为没输入。
134
+ *
135
+ * **为什么 trim 而不是原样存**:校验用的是 `value.trim()`,落库却曾是原值——
136
+ * 校验与落库必须是**同一个值**,否则「通过校验的值」与「发出去的值」不是一个东西。
137
+ * 实测(2026-10-19):`save(" ms-3f2a… ")` 会把带着前后空格的串存进凭据服务,
138
+ * 而它拼出的请求头是 `Bearer ms-… `——用户粘进一个空格就换来一个语焉不详的
139
+ * 401,面板却显示「已保存」。只 trim 两端,令牌中间的形状(分段连字符)不动。
134
140
  *
135
141
  * **形状校验**:只接受 `ms-` + 至少 16 位十六进制(可含连字符分段)。这不是
136
142
  * 洁癖——`redactSecrets` 的脱敏闸是**正则**,只能遮它认识的形状;一个
@@ -143,9 +149,9 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
143
149
  * 真机请求换来一个语焉不详的 401。
144
150
  */
145
151
  async save(token) {
146
- const value = verbatim(token, "");
147
- if (typeof value !== "string" || value.trim() === "") throw new Error("a ModelScope token is required");
148
- if (!isPlausibleToken(value.trim())) throw new Error("that does not look like a ModelScope access token (expected ms- followed by at least 16 hex digits); if you are sure the token is correct, this plugin's shape check may be out of date");
152
+ const value = verbatim(token, "").trim();
153
+ if (value === "") throw new Error("a ModelScope token is required");
154
+ if (!isPlausibleToken(value)) throw new Error("that does not look like a ModelScope access token (expected ms- followed by at least 16 hex digits); if you are sure the token is correct, this plugin's shape check may be out of date");
149
155
  const service = resolveService();
150
156
  if (service !== null && typeof service.set === "function") {
151
157
  await service.set(TOKEN_REF, value);
@@ -424,17 +430,6 @@ function temporaryOf(dir, base, now = Date.now) {
424
430
  return join(dir, `${base}.${process.pid}.${now()}.${randomUUID()}.tmp`);
425
431
  }
426
432
  /**
427
- * Write one state file atomically: a 0600 temporary file, then a rename.
428
- *
429
- * The payload string is written with a trailing newline, exactly as every
430
- * store wrote before this module existed. Failures PROPAGATE — the callers
431
- * decide whether a read-only Home breaks their flow.
432
- * @param {string} file - the final file path.
433
- * @param {string} payload - the serialized body (JSON text).
434
- * @param {{temporary: string}} options - the temp path to write first.
435
- * @returns {Promise<void>}
436
- */
437
- /**
438
433
  * 写一个状态文件:一个 0600 临时文件,然后 rename。
439
434
  *
440
435
  * 载荷串以换行结尾,与本模块存在前每个 store 的写法一致。失败**向上传播** ——
@@ -1743,6 +1738,24 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1743
1738
  }
1744
1739
  return Array.from(new Set(tasks));
1745
1740
  };
1741
+ /**
1742
+ * 令牌的稳定指纹(FNV-1a,32 位 hex)——只用作余额缓存的键。
1743
+ *
1744
+ * **不用 `token.slice(-6)` 那种末位截断**:两个不同令牌末 6 位相同的概率在
1745
+ * 1/16^7 量级,虽低但非零,而代价是一个 TTL(cacheSeconds,默认 60s)内把
1746
+ * A 的身份的余额当 B 的返回——缓存键的正确性不该交给运气。指纹不需要保密、
1747
+ * 不进日志(脱敏正则的形状与它无关),完整的哈希零成本。
1748
+ * @param {string} token - 访问令牌。
1749
+ * @returns {string} 8 位 hex 指纹。
1750
+ */
1751
+ function tokenFingerprint(token) {
1752
+ let hash = 2166136261;
1753
+ for (let i = 0; i < token.length; i++) {
1754
+ hash ^= token.charCodeAt(i);
1755
+ hash = Math.imul(hash, 16777619);
1756
+ }
1757
+ return (hash >>> 0).toString(16).padStart(8, "0");
1758
+ }
1746
1759
  return {
1747
1760
  /**
1748
1761
  * 模型目录:GET apiBase/models,免认证、零额度,coalesced 缓存
@@ -1836,13 +1849,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1836
1849
  /**
1837
1850
  * 官方「魔粒」余额:GET siteBase/openapi/v1/magicubes/balance(Bearer
1838
1851
  * 令牌;2026-10-04 实测可用,匿名 401,SPIKE.md §魔粒)。coalesced 缓存
1839
- * cacheSeconds,缓存键带令牌指纹——换令牌 ≤1 个 TTL 内必然读到新身份。
1852
+ * cacheSeconds,缓存键带令牌指纹(FNV-1a 完整哈希,不是末位截断——
1853
+ * 换令牌 ≤1 个 TTL 内必然读到新身份,且不同令牌不会因同后缀串味)。
1840
1854
  */
1841
1855
  async fetchBalance() {
1842
1856
  const { value: token } = await tokenStore.resolve();
1843
1857
  if (token === "") throw pluginError(CODE.AUTH_ERROR, "no ModelScope token configured — set MODELSCOPE_API_KEY or save one in the panel");
1844
1858
  const url = settings.siteBase + "/openapi/v1/magicubes/balance";
1845
- const key = "balance#" + token.slice(-6);
1859
+ const key = "balance#" + tokenFingerprint(token);
1846
1860
  return await read(key, async () => {
1847
1861
  const response = await fetchWithTimeout(url, {
1848
1862
  method: "GET",
@@ -1905,7 +1919,10 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
1905
1919
  const extra = retryAfterMs !== null && retryAfterMs !== void 0 ? { retryAfterMs } : {};
1906
1920
  throw pluginError(triage === "quota" ? CODE.QUOTA_EXCEEDED : CODE.RATE_LIMITED, redactSecrets(message === "" ? "modelscope returned 429 (probe on " + modelId + ")" : message), extra);
1907
1921
  }
1908
- if (response.status >= 500) throw pluginError(CODE.UPSTREAM_ERROR, "modelscope returned HTTP " + String(response.status) + " (validity probe)");
1922
+ if (response.status >= 500) {
1923
+ discardBody(response);
1924
+ throw pluginError(CODE.UPSTREAM_ERROR, "modelscope returned HTTP " + String(response.status) + " (validity probe)");
1925
+ }
1909
1926
  response.body?.cancel();
1910
1927
  return {
1911
1928
  ok: true,
@@ -2615,6 +2632,76 @@ function createFileDrawStore(options = {}) {
2615
2632
  };
2616
2633
  }
2617
2634
 
2635
+ //#endregion
2636
+ //#region src/host/draw-jobs.ts
2637
+ /**
2638
+ * 建一张出图作业表。
2639
+ * @param {DrawJobsOptions} [options] - 容量与保护窗参数。
2640
+ * @returns {DrawJobs} 作业表。
2641
+ */
2642
+ function createDrawJobs(options = {}) {
2643
+ const maxJobs = Math.max(1, Math.floor(options.maxJobs ?? 64));
2644
+ const resumeWindowMs = Math.max(1, Math.floor(options.resumeWindowMs ?? 9e5));
2645
+ const hardMaxJobs = Math.max(maxJobs, Math.floor(options.hardMaxJobs ?? maxJobs * 2));
2646
+ /** @type {Map<string, DrawJob>} 插入顺序即淘汰顺序(Map 保序)。 */
2647
+ const jobs = /* @__PURE__ */ new Map();
2648
+ /**
2649
+ * 超容时的两级淘汰(判据见文件头「生命周期」):
2650
+ * 软上限只淘汰已过续查保护窗的(越过总放弃线,续查只换来「超过总预算」);
2651
+ * 硬上限保底——全是保护窗内的作业也绝不无界。
2652
+ * @param {number} now - 当前墙钟毫秒(`create` 的 `now` 缝传进来,测试可造旧作业)。
2653
+ */
2654
+ const evictIfNeeded = (now) => {
2655
+ if (jobs.size <= maxJobs) return;
2656
+ for (const [id, job] of jobs) {
2657
+ if (jobs.size <= maxJobs) return;
2658
+ if (now - job.submittedAt >= resumeWindowMs) jobs.delete(id);
2659
+ }
2660
+ while (jobs.size > hardMaxJobs) {
2661
+ const oldest = jobs.keys().next();
2662
+ if (oldest.done === true) return;
2663
+ jobs.delete(oldest.value);
2664
+ }
2665
+ };
2666
+ return {
2667
+ create({ taskId, model, prompt, now }) {
2668
+ const jobId = taskId;
2669
+ const at = typeof now === "number" && Number.isFinite(now) ? now : Date.now();
2670
+ const job = {
2671
+ jobId,
2672
+ taskId,
2673
+ model,
2674
+ prompt,
2675
+ submittedAt: at,
2676
+ polls: 0
2677
+ };
2678
+ const previous = jobs.get(jobId);
2679
+ if (previous !== void 0) job.polls = previous.polls;
2680
+ jobs.set(jobId, job);
2681
+ evictIfNeeded(at);
2682
+ return { ...job };
2683
+ },
2684
+ get(jobId) {
2685
+ const found = jobs.get(jobId);
2686
+ return found === void 0 ? null : { ...found };
2687
+ },
2688
+ notePolls(jobId, polls) {
2689
+ const found = jobs.get(jobId);
2690
+ if (found === void 0) return;
2691
+ found.polls += Math.max(0, Math.floor(polls));
2692
+ },
2693
+ drop(jobId) {
2694
+ jobs.delete(jobId);
2695
+ },
2696
+ dispose() {
2697
+ jobs.clear();
2698
+ },
2699
+ size() {
2700
+ return jobs.size;
2701
+ }
2702
+ };
2703
+ }
2704
+
2618
2705
  //#endregion
2619
2706
  //#region src/host/switch-precedence.ts
2620
2707
  /**
@@ -2917,9 +3004,11 @@ function createFileUsagePrefsStore(options = {}) {
2917
3004
  * 缺这个头的实现会在第一次轮询就误判失败并放弃一张已经能出的图。
2918
3005
  * 2. **提交响应的 `task_status` 不可信**:实测每次提交都回 `SUCCEED`,包括最终确实
2919
3006
  * `FAILED` 的那次。所以提交只认 `task_id`,**成功与否只看轮询终态**。
2920
- * 3. **`FAILED` 没有可分诊的形状**:`errors.code` 实测为 `0`、`errors.message` 为
2921
- * 空串,没有顶层 `message`。任何「从错误里读出原因」的承诺都是编造——本模块的
2922
- * `describeDrawFailure` 因此**明确承认原因不可知**,只报可确定的形态差异。
3007
+ * 3. **`FAILED` 常常没有可分诊的形状**:实测样本 `errors.code` 为 `0`、
3008
+ * `errors.message` 为空串、没有顶层 `message`。所以不能承诺「一定读得出原因」——
3009
+ * 本模块的 `describeDrawFailure` 只报**这个 body 里确实写着**的东西:读得到
3010
+ * `errors.message` 就报它,读不到就明说原因不可知(2026-10-19 修补:旧写法把
3011
+ * 「实测样本为空」当成了「永远为空」,非空原因会被一并报成「未给出原因」)。
2923
3012
  *
2924
3013
  * 关于两个小工具(`str` / `redactSecrets`):它们曾以**本地私有副本**的形式活在
2925
3014
  * 本文件里,理由是「避免为 3 行代码引 util 的依赖面」。那条理由不成立——util.ts
@@ -3120,13 +3209,31 @@ function normalizeImageUrlInput(value) {
3120
3209
  return null;
3121
3210
  }
3122
3211
  /**
3212
+ * 从上游 body 里取第一句**可引用的原因**:`errors.message`,退回顶层 `message`。
3213
+ *
3214
+ * 取的是**未截断**的原文——`describeDrawFailure` 的 tail 会截到 300 字符,截断后的
3215
+ * JSON 解析不出来。解析不出来(非 JSON / 字段为空 / 字段不是字符串)一律返回 `""`:
3216
+ * 这里**不猜**,没有原因就说没有原因。
3217
+ */
3218
+ function upstreamMessageOf(raw) {
3219
+ if (raw === "") return "";
3220
+ try {
3221
+ const parsed = JSON.parse(raw);
3222
+ for (const candidate of [parsed?.errors?.message, parsed?.message]) if (typeof candidate === "string" && candidate.trim() !== "") return candidate.trim();
3223
+ } catch {}
3224
+ return "";
3225
+ }
3226
+ /**
3123
3227
  * 把一次失败转成 agent 与面板都能读的一句话。
3124
3228
  *
3125
- * **关于魔搭 `FAILED` 的诚实边界**:实测唯一的失败样本是
3126
- * `{"errors":{"code":0,"message":""}, "task_status":"FAILED"}`——**原因不可知**。
3127
- * 所以这里只报「可确定的形态事实」(HTTP 码 / 轮询未出终态 / 明确的原因文本),
3128
- * **不从空错误里编造原因**,并在原因确实拿不到时明说「上游未给出原因」。这是本仓库
3129
- * README「诚实声明」的要求。
3229
+ * **关于魔搭 `FAILED` 的诚实边界**:实测见过的失败样本是
3230
+ * `{"errors":{"code":0,"message":""}, "task_status":"FAILED"}`——那一份**原因不可知**。
3231
+ * 但样本量是 1,不是恒定形态:上游也可能给出非空的 `errors.message`
3232
+ * (2026-10-19 锐评实测 `sensitive content detected`、
3233
+ * `image_url is required for model MusePublic/Qwen-Image-Edit` 这类原因会被旧写法
3234
+ * 一并报成「未给出失败原因」,且 body 被丢掉,2/2 误报)。所以判据是**逐个 body 判**:
3235
+ * 有原因就报原因,确实没有才明说「上游未给出原因」;任何情况下都不从空错误里编造
3236
+ * 原因,两种情况**都附脱敏后的 body**。这是本仓库 README「诚实声明」的要求。
3130
3237
  *
3131
3238
  * 凭据永不入消息:body 文本可能回显 `ms-` 令牌,过 `redactSecrets` 后再截断。
3132
3239
  * @param {object} failure - 失败信息。
@@ -3137,17 +3244,22 @@ function normalizeImageUrlInput(value) {
3137
3244
  * @returns {string} 一行说明。
3138
3245
  */
3139
3246
  function describeDrawFailure(failure) {
3140
- const text = redactSecrets(typeof failure.bodyText === "string" ? failure.bodyText : "").slice(0, 300);
3247
+ const raw = typeof failure.bodyText === "string" ? failure.bodyText : "";
3248
+ const text = redactSecrets(raw).slice(0, 300);
3141
3249
  const status = Number.isFinite(failure.status) ? Number(failure.status) : 0;
3142
3250
  const tail = text === "" ? "" : `; body: ${text}`;
3143
- if (status === 401 || status === 403) return `出图失败:HTTP ${status} —— 访问令牌无效、无权访问该模型,或账号未完成邮箱验证/实名(后者是实测遇到过的真实拦截)。请在面板确认令牌与账号状态${tail}`;
3251
+ if (status === 401 || status === 403) return `出图失败:HTTP ${status} —— ${/邮箱|实名|email[^\n]{0,40}verif|verif\w*[^\n]{0,40}email|complete\s+your\s+profile/i.test(text) ? "访问令牌无效、无权访问该模型,或账号未完成邮箱验证/实名(上游 body 提到了账号态)" : "访问令牌无效或无权访问该模型(上游未指明具体原因)"}。请在面板确认令牌与账号状态${tail}`;
3144
3252
  if (status === 404) return `出图失败:HTTP 404 —— 端点或模型不存在(检查 apiBase 与模型 id)${tail}`;
3145
3253
  if (status === 429) return `出图失败:HTTP 429 —— 上游限流,请稍后重试${tail}`;
3146
3254
  if (status >= 500) return `出图失败:HTTP ${status} —— 上游服务异常,稍后重试${tail}`;
3147
3255
  const cause = str(failure.cause, "");
3148
- if (cause !== "") return `出图失败:${cause}${tail}`;
3256
+ if (cause !== "") return `出图失败:${redactSecrets(cause)}${tail}`;
3149
3257
  const taskStatus = str(failure.taskStatus, "");
3150
- if (taskStatus === "FAILED") return "出图失败:上游任务 FAILED,但未给出失败原因(实测 errors.code=0、message 为空),换模型或稍后重试";
3258
+ if (taskStatus === "FAILED") {
3259
+ const upstream = upstreamMessageOf(raw);
3260
+ if (upstream !== "") return `出图失败:上游任务 FAILED —— ${redactSecrets(upstream)}${tail}`;
3261
+ return `出图失败:上游任务 FAILED,但未给出失败原因(上游 errors.message 为空),换模型或稍后重试${tail}`;
3262
+ }
3151
3263
  if (taskStatus !== "") return `出图失败:任务停在 ${taskStatus}(非终态),已超过截止时间`;
3152
3264
  return `出图失败:上游未给出可诊断的失败信息${tail}`;
3153
3265
  }
@@ -3178,62 +3290,6 @@ function createDrawCooldown(cooldownMs) {
3178
3290
  };
3179
3291
  }
3180
3292
 
3181
- //#endregion
3182
- //#region src/host/draw-jobs.ts
3183
- /**
3184
- * 建一张出图作业表。
3185
- * @param {DrawJobsOptions} [options] - 容量上限。
3186
- * @returns {DrawJobs} 作业表。
3187
- */
3188
- function createDrawJobs(options = {}) {
3189
- const maxJobs = Math.max(1, Math.floor(options.maxJobs ?? 64));
3190
- /** @type {Map<string, DrawJob>} 插入顺序即淘汰顺序(Map 保序)。 */
3191
- const jobs = /* @__PURE__ */ new Map();
3192
- const evictIfNeeded = () => {
3193
- while (jobs.size > maxJobs) {
3194
- const oldest = jobs.keys().next();
3195
- if (oldest.done === true) return;
3196
- jobs.delete(oldest.value);
3197
- }
3198
- };
3199
- return {
3200
- create({ taskId, model, prompt, now }) {
3201
- const jobId = taskId;
3202
- const job = {
3203
- jobId,
3204
- taskId,
3205
- model,
3206
- prompt,
3207
- submittedAt: typeof now === "number" && Number.isFinite(now) ? now : Date.now(),
3208
- polls: 0
3209
- };
3210
- const previous = jobs.get(jobId);
3211
- if (previous !== void 0) job.polls = previous.polls;
3212
- jobs.set(jobId, job);
3213
- evictIfNeeded();
3214
- return { ...job };
3215
- },
3216
- get(jobId) {
3217
- const found = jobs.get(jobId);
3218
- return found === void 0 ? null : { ...found };
3219
- },
3220
- notePolls(jobId, polls) {
3221
- const found = jobs.get(jobId);
3222
- if (found === void 0) return;
3223
- found.polls += Math.max(0, Math.floor(polls));
3224
- },
3225
- drop(jobId) {
3226
- jobs.delete(jobId);
3227
- },
3228
- dispose() {
3229
- jobs.clear();
3230
- },
3231
- size() {
3232
- return jobs.size;
3233
- }
3234
- };
3235
- }
3236
-
3237
3293
  //#endregion
3238
3294
  //#region src/host/draw-runtime.ts
3239
3295
  /**
@@ -3263,6 +3319,9 @@ function createDrawJobs(options = {}) {
3263
3319
  * 轮询只与更慢相关。
3264
3320
  * 3. 失败原因实测可能**不可知**(`errors.code:0` + 空 message)。`describeDrawFailure`
3265
3321
  * 因此明说「未给出原因」,本文件也**不从空错误里编造**。
3322
+ * 4. 轮询的 5xx / 429 / 408 是**瞬态**不是终局({@link isTransientPollStatus}):
3323
+ * 同一个物理故障不因表现为 TCP 抛错还是 5xx 响应就两种判法;只有 4xx 是
3324
+ * 终局证据。瞬态持续时由 deadline 收口成 pending,续查把手不丢。
3266
3325
  *
3267
3326
  * @module dsh-connect-modelscope-token-plan/draw-runtime
3268
3327
  */
@@ -3271,6 +3330,13 @@ const DRAW_TOOL_NAME = "modelscope_draw_image";
3271
3330
  /** 一次失败的冷却窗:出图慢,失败后立刻重试既易撞限流又白等。 */
3272
3331
  const DRAW_FAILURE_COOLDOWN_MS = 6e4;
3273
3332
  /**
3333
+ * 工具级硬护栏比单次等待预算多出的余量。
3334
+ *
3335
+ * 它保证**等待循环先返回**:护栏若与等待预算同值,正常路径会以 peer 的超时错误
3336
+ * 结束,而不是带 `jobId` 的 `pending`——那会把续查这条唯一的把手换成一个死胡同。
3337
+ */
3338
+ const DRAW_TOOL_TIMEOUT_MARGIN_MS = 1e4;
3339
+ /**
3274
3340
  * 把 OpenAI 兼容 base 拼成出图端点。
3275
3341
  *
3276
3342
  * `…/v1` → `…/v1/images/generations`;已带该后缀则原样;更深路径回卷到 `/v1`。
@@ -3363,8 +3429,10 @@ async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, image
3363
3429
  * 抛错是「这件事做不成了」,`pending` 是「还没成,可以再查」。同一段循环因此
3364
3430
  * 同时服务于两种调用形状(一次性跑完 / 分段续查),不写两份。
3365
3431
  *
3366
- * 轮询带 `X-ModelScope-Task-Type`(**必需**,漏了必假失败);传输层抖动与非
3367
- * JSON 响应体都**不升级为终局**,继续等下一轮。
3432
+ * 轮询带 `X-ModelScope-Task-Type`(**必需**,漏了必假失败)。三类情形**不升级
3433
+ * 为终局**,在本次预算内继续等下一轮:① 传输层抛错(ECONNRESET / DNS);
3434
+ * ② 非 JSON / 200 垃圾响应体(CDN 错误页);③ **瞬态 HTTP 状态码**(5xx /
3435
+ * 429 / 408,见 {@link isTransientPollStatus})。只有 4xx 是终局证据。
3368
3436
  * @param {object} options - 等待参数。
3369
3437
  * @param {DrawFetch} options.fetchImpl - 注入的 fetch。
3370
3438
  * @param {string} options.apiBase - apiBase。
@@ -3372,9 +3440,12 @@ async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, image
3372
3440
  * @param {string} options.taskId - 上游任务 id。
3373
3441
  * @param {number} [options.waitMs] - 本次调用的等待预算。
3374
3442
  * @param {number} [options.pollIntervalMs] - 轮询间隔(测试可缩短)。
3443
+ * @param {() => boolean} [options.shouldStop] - 每轮轮询前问一次「还要不要继续」
3444
+ * (撤下闸)。`signal` 只能表达**已经**中止,问不出「刚刚被撤下」——等待循环是
3445
+ * 本模块唯一的循环,撤下只有在这里能被周期性看见。
3375
3446
  * @returns {Promise<DrawAwait>} 三种结局之一。
3376
3447
  */
3377
- async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS, signal }) {
3448
+ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS, signal, shouldStop }) {
3378
3449
  const headers = {
3379
3450
  Authorization: `Bearer ${apiKey}`,
3380
3451
  "Content-Type": "application/json"
@@ -3388,6 +3459,10 @@ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW
3388
3459
  kind: "pending",
3389
3460
  polls
3390
3461
  };
3462
+ if (shouldStop?.() === true) return {
3463
+ kind: "pending",
3464
+ polls
3465
+ };
3391
3466
  try {
3392
3467
  await sleep(interval, signal);
3393
3468
  } catch {
@@ -3404,20 +3479,29 @@ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW
3404
3479
  headers: {
3405
3480
  ...headers,
3406
3481
  [TASK_TYPE_HEADER]: TASK_TYPE_IMAGE_GENERATION
3407
- }
3482
+ },
3483
+ ...signal !== void 0 ? { signal } : {}
3408
3484
  });
3409
3485
  } catch {
3486
+ if (signal?.aborted === true) return {
3487
+ kind: "pending",
3488
+ polls
3489
+ };
3410
3490
  continue;
3411
3491
  }
3412
3492
  const pollText = await safeText(polled);
3413
- if (!polled.ok) return {
3414
- kind: "failed",
3415
- message: describeDrawFailure({
3416
- ...polled.status !== void 0 ? { status: polled.status } : {},
3417
- ...pollText !== "" ? { bodyText: pollText } : {},
3418
- ...lastStatus !== void 0 ? { taskStatus: lastStatus } : {}
3419
- })
3420
- };
3493
+ if (!polled.ok) {
3494
+ if (isTransientPollStatus(polled.status)) continue;
3495
+ const previous = lastStatus !== void 0 ? `(上一轮读到 ${lastStatus})` : "";
3496
+ return {
3497
+ kind: "failed",
3498
+ message: describeDrawFailure({
3499
+ ...polled.status !== void 0 ? { status: polled.status } : {},
3500
+ ...pollText !== "" ? { bodyText: pollText } : {},
3501
+ cause: `轮询请求失败:HTTP ${polled.status ?? "未知"},本轮未读到任务状态${previous}`
3502
+ })
3503
+ };
3504
+ }
3421
3505
  const data = await safeJson(polled, pollText);
3422
3506
  const status = readTaskStatus(data);
3423
3507
  if (status !== null) lastStatus = status;
@@ -3516,21 +3600,34 @@ function drawModelIds(entries) {
3516
3600
  * @returns {unknown} 已定义的 agent 工具。
3517
3601
  */
3518
3602
  function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, settings, getPreferredModelId, cooldown = createDrawCooldown(DRAW_FAILURE_COOLDOWN_MS), jobs = createDrawJobs(), pollIntervalMs = DRAW_POLL_INTERVAL_MS, isDisposed = () => false }) {
3519
- /** 总放弃线:从提交算起,跨多次调用累计。 */
3520
- const timeoutMs = Math.max(3e4, Math.floor(Number(settings?.drawTimeoutMs) > 0 ? Number(settings?.drawTimeoutMs) : DRAW_TIMEOUT_MS));
3603
+ /**
3604
+ * 总放弃线:从提交算起,跨多次调用累计。
3605
+ *
3606
+ * 上下界与 `host-config.resolveSettings` 同源,这里再夹一次是为了让本函数的
3607
+ * 不变式**自足**:`waitMs + margin <= timeoutMs <= DRAW_TIMEOUT_MAX_MS`(见下面
3608
+ * `waitMs` 与工具定义里的 `timeoutMs`)。直接调用本函数(不经配置层)时也成立。
3609
+ */
3610
+ const timeoutMs = Math.min(DRAW_TIMEOUT_MAX_MS, Math.max(3e4, Math.floor(Number(settings?.drawTimeoutMs) > 0 ? Number(settings?.drawTimeoutMs) : DRAW_TIMEOUT_MS)));
3521
3611
  /**
3522
3612
  * 单次调用愿意等的时间。
3523
3613
  *
3524
- * 只夹**上界**(不该超过总放弃线,超过它的等待没有意义);下界交给
3525
- * `host-config` 的 `drawWaitMs` clamp——同一个数在两处各夹一次,上限/下限就会
3526
- * 各说一套话(`drawTimeoutMs` 的 clamp 早已是本仓库的教训)。
3614
+ * 上界是 `timeoutMs - margin`,**不是** `timeoutMs`:工具的硬护栏取
3615
+ * `waitMs + margin`,若允许两者相等,护栏就会越过 `DRAW_TIMEOUT_MAX_MS`
3616
+ * (配置允许 `drawWaitMs == drawTimeoutMs`,上限 1_800_000 → 护栏 1_810_000),
3617
+ * 且「等待循环先返回」从结构保证退化成「用户不会把两个值设成相等」的运气。
3618
+ * 下界交给 `host-config` 的 `drawWaitMs` clamp——同一个数在两处各夹一次,上限/
3619
+ * 下限就会各说一套话(`drawTimeoutMs` 的 clamp 早已是本仓库的教训)。
3527
3620
  */
3528
- const waitMs = Math.min(timeoutMs, Math.max(1, Math.floor(Number(settings?.drawWaitMs) > 0 ? Number(settings?.drawWaitMs) : DRAW_WAIT_BUDGET_MS)));
3621
+ const waitMs = Math.min(Math.max(1, timeoutMs - DRAW_TOOL_TIMEOUT_MARGIN_MS), Math.max(1, Math.floor(Number(settings?.drawWaitMs) > 0 ? Number(settings?.drawWaitMs) : DRAW_WAIT_BUDGET_MS)));
3529
3622
  const apiBase = str(settings?.apiBase, "https://api-inference.modelscope.cn/v1");
3530
3623
  const abortController = new AbortController();
3531
3624
  const abortIfDisposed = () => {
3532
3625
  if (isDisposed()) abortController.abort();
3533
3626
  };
3627
+ const shouldStop = () => {
3628
+ abortIfDisposed();
3629
+ return isDisposed();
3630
+ };
3534
3631
  /**
3535
3632
  * 等一个作业,把 {@link awaitDrawTask} 的三种结局翻译成工具结果或错误。
3536
3633
  *
@@ -3551,7 +3648,8 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
3551
3648
  taskId: job.taskId,
3552
3649
  waitMs,
3553
3650
  pollIntervalMs,
3554
- signal: abortController.signal
3651
+ signal: abortController.signal,
3652
+ shouldStop
3555
3653
  });
3556
3654
  if (awaited.kind === "failed") {
3557
3655
  jobs.drop(job.jobId);
@@ -3574,6 +3672,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
3574
3672
  }
3575
3673
  const elapsedMs = Date.now() - job.submittedAt;
3576
3674
  if (elapsedMs >= timeoutMs) throw new Error(describeDrawFailure({ cause: `超过总预算(${Math.round(timeoutMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${job.taskId})可能仍在生成——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关;注意放弃后原任务的结果不保证还能取回` }));
3675
+ const resumeHint = jobs.get(job.jobId) !== null ? `稍后带 jobId ${job.jobId} 再调用本工具即可继续查询(只查询、不会重复出图;作业表只在本次挂载内有效,重启 DSH 或重挂插件后要重新提交)` : "本次挂载已撤下(插件被卸载/重载),作业表已清空——这一轮之后**不能**再带 jobId 续查;上游任务可能仍在生成,但结果不保证还能取回,重新出图请去掉 jobId 再调用";
3577
3676
  return {
3578
3677
  source: "modelscope",
3579
3678
  status: "pending",
@@ -3581,7 +3680,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
3581
3680
  model: job.model,
3582
3681
  url: "",
3583
3682
  prompt: job.prompt,
3584
- hint: `仍在生成中(已等 ${Math.round(elapsedMs / 1e3)}s,${polls} 次轮询,task ${job.taskId}):上游排队通常 1–15 分钟。稍后带 jobId ${job.jobId} 再调用本工具即可继续查询(只查询、不会重复出图)`
3683
+ hint: `仍在生成中(已等 ${Math.round(elapsedMs / 1e3)}s,${polls} 次轮询,task ${job.taskId}):上游排队通常 1–15 分钟。${resumeHint}`
3585
3684
  };
3586
3685
  };
3587
3686
  return defineTool({
@@ -3645,7 +3744,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
3645
3744
  text: result?.hint || "图片已生成"
3646
3745
  }]
3647
3746
  },
3648
- timeoutMs: waitMs + 1e4,
3747
+ timeoutMs: waitMs + DRAW_TOOL_TIMEOUT_MARGIN_MS,
3649
3748
  async execute(params) {
3650
3749
  if (abortController.signal.aborted || isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
3651
3750
  const apiKey = await resolveApiKey();
@@ -3745,6 +3844,24 @@ function toPositiveNumber(value) {
3745
3844
  const n = Number(value);
3746
3845
  return Number.isFinite(n) && n > 0 ? n : void 0;
3747
3846
  }
3847
+ /**
3848
+ * 轮询响应里的**瞬态** HTTP 状态码:不判作业死刑,本次等待预算内继续等下一轮。
3849
+ *
3850
+ * 判据是「同一个物理故障不因表现形态而异」:传输层抛错(ECONNRESET / DNS
3851
+ * 解析失败)早就走的是「继续等下一轮」,而同一种 CDN 边缘抖动、上游短暂过载
3852
+ * 以 **5xx/429 响应**的形态出现时,旧写法直接判失败、作业出表、进 60s 冷却
3853
+ * ——agent 想续查只能重新提交一张图。上游排队窗口实测 1–15 分钟
3854
+ * (docs/SPIKE-image-gen.md),那个窗口内的边缘 5xx 是常态噪声,不是终局。
3855
+ *
3856
+ * 4xx 保持终局:401/403 = 令牌或权限真的没了,404/400 = 任务或端点真的没了
3857
+ * ——这些等下去也不会变。deadline 负责收口:瞬态一直持续时耗尽预算后返回
3858
+ * `pending`,两段式的续查把手(jobId)不丢。
3859
+ * @param {number} status - 轮询响应的 HTTP 状态码。
3860
+ * @returns {boolean} 是否按瞬态继续等。
3861
+ */
3862
+ function isTransientPollStatus(status) {
3863
+ return status === 408 || status === 429 || status >= 500;
3864
+ }
3748
3865
  /** 读 body 文本,读不到给空串(错误分诊不能因为读 body 失败而失败)。 */
3749
3866
  async function safeText(response) {
3750
3867
  try {
@@ -3899,7 +4016,7 @@ async function resolveService(ctx, service, isDisposed) {
3899
4016
  * 读一次开关,决定是否注册出图工具。
3900
4017
  * @param {object} ctx - Host 上下文。
3901
4018
  * @param {object} wiring - `{ settings, configError, tokenStore, inference, providerStore, publisher, logger }`。
3902
- * @param {object} deps - 测试缝(`loadToolsModule`、`drawFetch`)。
4019
+ * @param {object} deps - 测试缝(`loadToolsModule`、`drawFetch`、`pollIntervalMs`)。
3903
4020
  * @returns {Promise<void>}
3904
4021
  */
3905
4022
  async function registerDrawTool(ctx, wiring, deps = {}) {
@@ -3948,6 +4065,8 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
3948
4065
  drawTimeoutMs: settings.drawTimeoutMs,
3949
4066
  drawWaitMs: settings.drawWaitMs
3950
4067
  },
4068
+ ...wiring.drawJobs !== void 0 ? { jobs: wiring.drawJobs } : {},
4069
+ ...deps.pollIntervalMs !== void 0 ? { pollIntervalMs: deps.pollIntervalMs } : {},
3951
4070
  isDisposed
3952
4071
  }));
3953
4072
  setDrawToolMounted(true);
@@ -5008,12 +5127,13 @@ async function buildSnapshotBody(wiring) {
5008
5127
  const allowedList = parseAllowedList(enabledIds);
5009
5128
  const enabledCount = enabledCountOf(allowedList, roster.map((row) => row.id));
5010
5129
  const contextWindow = await providerStore.contextWindow().catch(() => null);
5130
+ const catalogNote = models.ok ? null : `模型目录读不到:${models.error}`;
5011
5131
  return {
5012
5132
  enabled,
5013
5133
  source,
5014
5134
  llmAvailable: publisher.state.llmAvailable === true,
5015
5135
  registered: publisher.state.registered === true,
5016
- error: typeof publisher.state.error === "string" ? publisher.state.error : null,
5136
+ error: typeof publisher.state.error === "string" ? publisher.state.error : catalogNote,
5017
5137
  modelCount: roster.length,
5018
5138
  enabledCount,
5019
5139
  allowed: allowedList.mode,
@@ -6003,25 +6123,73 @@ function registerRoutes(ctx, wiring) {
6003
6123
  //#endregion
6004
6124
  //#region src/host/index.ts
6005
6125
  /**
6006
- * dsh-connect-modelscope-token-plan — Host 半边(thin router)。
6007
- *
6008
- * 读魔搭 API-Inference 的**本地用量**,并把它与官方魔粒余额一起经一条只读 /api
6009
- * 路由喂给 Client 面板。口径分工:推理响应**不携带**额度头(SPIKE.md §结论 1),但
6010
- * 官方另有魔粒余额端点(`GET {siteBase}/openapi/v1/magicubes/balance`,SPIKE.md
6011
- * 文首后记,2026-10-04 实测可用),面板头条读的是它;本文件下的兄弟模块只负责本地
6012
- * 计数那一半(按模型分布 / 本地趋势 / 429 事件流),回答官方余额答不了的问题。
6013
- * 重活都在专注的兄弟模块里,本文件
6014
- * 只保留 Cordis 入口(name/inject/apply)、wiring 装配与卸载副作用:
6015
- *
6016
- * - `host-config.ts` 配置契约 + isAdmitted 信任围栏
6017
- * - `routes.ts` 四条路由的门面(snapshot/models/token/probe)
6018
- * - `ms-auth.ts` 访问令牌存取(静态钥匙,无登录流)
6019
- * - `inference-client.ts` 模型目录(零额度)+ probe 调用(带分诊)
6020
- * - `usage-store.ts` 本地计数:天桶/单模型/事件流(原子写、版本守卫)
6021
- * - `snapshot-aggregate.ts` 快照聚合(软失败)
6022
- * - `state-store.ts` 状态文件原子原语
6126
+ * dsh-connect-modelscope-token-plan — Host 半边(装配入口)。
6127
+ *
6128
+ * 本插件的定位是**额度 + provider + 出图 + 上下文窗口策展**的多功能集成(见
6129
+ * AGENTS.md「定位变更」),不再是「只做额度信息」的那一版。口径分工:推理响应
6130
+ * **不携带**额度头(SPIKE.md §结论 1),但官方另有魔粒余额端点
6131
+ * (`GET {siteBase}/openapi/v1/magicubes/balance`,SPIKE.md 文首后记,2026-10-04
6132
+ * 实测可用),面板头条读的是它;本文件下的兄弟模块只负责本地计数那一半(按模型
6133
+ * 分布 / 本地趋势 / 429 事件流),回答官方余额答不了的问题。
6134
+ *
6135
+ * 重活都在专注的兄弟模块里,本文件只保留 Cordis 入口(name/inject/apply)、
6136
+ * wiring 装配与卸载副作用。兄弟模块按域分组:
6137
+ *
6138
+ * **配置与凭据**
6139
+ * - `host-config.ts` 配置契约 + isAdmitted 信任围栏 + 夹取常量
6140
+ * - `switch-precedence.ts` 开关优先级:面板值 > patch 默认
6141
+ * - `ms-auth.ts` 访问令牌存取(静态钥匙,无登录流;唯一凭据出口)
6142
+ *
6143
+ * **路由(7 条,`routes.ts` 是门面,`routes/` 下各域一份)**
6144
+ * - `routes/snapshot.ts` 面板轮询的唯一只读路由
6145
+ * - `routes/models.ts` 模型目录
6146
+ * - `routes/token.ts` 令牌保存/忘掉/验证
6147
+ * - `routes/probe.ts` 零额度鉴权探针
6148
+ * - `routes/provider.ts` provider 开关/清单/窗口/重置
6149
+ * - `routes/draw.ts` 出图开关/模型/重置
6150
+ * - `routes/usage-prefs.ts` 趋势显示窗口
6151
+ * - `routes/http.ts` CORS / JSON / 错误脱敏的公共辅助
6152
+ * - `routes/paths.ts` 路由路径常量
6153
+ *
6154
+ * **数据层(`state-store.ts` 是共用原语)**
6155
+ * - `state-store.ts` 原子写/版本守卫/读缓存/写队列(四个 store 共用)
6156
+ * - `usage-store.ts` 本地计数:天桶/单模型/事件流
6157
+ * - `provider-store.ts` 开关 + 允许清单 + 窗口覆盖的持久化
6158
+ * - `draw-store.ts` 出图开关 + 模型偏好
6159
+ * - `usage-prefs-store.ts` 趋势显示窗口偏好 + `resolveWindowDays` 夹取
6160
+ * - `inference-client.ts` HTTP 出口:模型目录 + 魔粒余额 + 探针
6161
+ * - `coalesced-fetch.ts` 有界并发缓存(目录/余额/标签共用)
6162
+ * - `context-catalog.ts` 内置上下文窗口策展表(models.dev 交叉真值)
6163
+ *
6164
+ * **Provider 注册**
6165
+ * - `llm-models.ts` 目录 → descriptor 映射(peer-free)
6166
+ * - `llm-adapter.ts` / `llm-adapter-core.ts` 适配器装配(core 为三族共享)
6167
+ * - `provider-publish.ts` 发布状态机:队列/disposed 闸/回滚
6168
+ * - `provider-reconcile.ts` desired-state 收敛器(轮询 + 路由统一触发)
6169
+ * - `publish-core.ts` 三族共享的发布原语
6170
+ *
6171
+ * **观测与纠正**
6172
+ * - `usage-observer.ts` 真实对话 → 本地计数/事件(观察流出口)
6173
+ * - `llm-error-fix.ts` 429 误判纠正(QUOTA → RATE_LIMIT)
6174
+ * - `llm-retry.ts` 重试策略(按 code 决定退避)
6023
6175
  * - `codes.ts` 错误分类单一真源
6024
- * - `usage-observer.ts` 真实对话 → 本地计数/事件(观察流出口,见该文件头)
6176
+ *
6177
+ * **出图工具**
6178
+ * - `draw.ts` 协议纯逻辑半边(peer-free)
6179
+ * - `draw-register.ts` 工具注册(挂载时一次性,不可撤销)
6180
+ * - `draw-runtime.ts` 运行时(两段式:提交 → 轮询)
6181
+ * - `draw-jobs.ts` 作业表(本次挂载内有效)
6182
+ * - `draw-tool-state.ts` 缺席原因 + 本次挂载注册位(内存)
6183
+ *
6184
+ * **聚合与工具**
6185
+ * - `snapshot-aggregate.ts` 快照聚合(软失败,绝不白屏)
6186
+ * - `doctor.ts` 只读盘点工具
6187
+ * - `util.ts` 通用工具(脱敏/错误消息/类型守卫)
6188
+ * - `types.ts` Host 侧类型定义
6189
+ *
6190
+ * 完整的「文件 → 一句话职责 → 关键导出」表在 AGENTS.md 的代码地图里,由
6191
+ * test/docs.test.mjs §14 门禁守着(每个 src/*.ts 都必须登记)。本注释只做入口
6192
+ * 处的分组索引——**新增兄弟模块时两处都要改**,别让这里再落后一轮。
6025
6193
  *
6026
6194
  * @module dsh-connect-modelscope-token-plan
6027
6195
  */
@@ -6062,6 +6230,7 @@ function apply(ctx, config = {}, deps = {}) {
6062
6230
  profile,
6063
6231
  logger: ctx.logger
6064
6232
  });
6233
+ const drawJobs = createDrawJobs({ resumeWindowMs: settings.drawTimeoutMs });
6065
6234
  const usagePrefsStore = createFileUsagePrefsStore({
6066
6235
  profile,
6067
6236
  logger: ctx.logger
@@ -6098,6 +6267,7 @@ function apply(ctx, config = {}, deps = {}) {
6098
6267
  inference,
6099
6268
  providerStore,
6100
6269
  drawStore,
6270
+ drawJobs,
6101
6271
  usagePrefsStore,
6102
6272
  publisher,
6103
6273
  reconcile,
@@ -6117,6 +6287,7 @@ function apply(ctx, config = {}, deps = {}) {
6117
6287
  return () => {
6118
6288
  clearInterval(timer);
6119
6289
  resetDrawToolNote();
6290
+ drawJobs.dispose();
6120
6291
  publisher.dispose();
6121
6292
  publisher.release();
6122
6293
  for (const off of offs) try {