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