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/CHANGELOG.md +74 -0
- package/README.md +71 -64
- package/assets/modelscope-usage-capsule.png +0 -0
- package/client.js +50 -40
- package/lib/host-config.js +22 -10
- package/lib/index.js +299 -128
- package/lib/llm-adapter.js +1 -1
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
- package/screenshots.json +2 -1
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
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 (
|
|
148
|
-
if (!isPlausibleToken(value
|
|
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
|
|
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
|
|
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)
|
|
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`
|
|
2921
|
-
*
|
|
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
|
-
*
|
|
3128
|
-
*
|
|
3129
|
-
*
|
|
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
|
|
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} ——
|
|
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")
|
|
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
|
-
*
|
|
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)
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
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
|
-
|
|
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
|
-
* `
|
|
3526
|
-
*
|
|
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
|
|
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 +
|
|
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 :
|
|
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
|
|
6007
|
-
*
|
|
6008
|
-
*
|
|
6009
|
-
*
|
|
6010
|
-
*
|
|
6011
|
-
* 文首后记,2026-10-04
|
|
6012
|
-
*
|
|
6013
|
-
*
|
|
6014
|
-
*
|
|
6015
|
-
*
|
|
6016
|
-
*
|
|
6017
|
-
*
|
|
6018
|
-
*
|
|
6019
|
-
* - `
|
|
6020
|
-
* - `
|
|
6021
|
-
* - `
|
|
6022
|
-
*
|
|
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
|
-
*
|
|
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 {
|