dsh-connect-modelscope-token-plan 0.3.0 → 0.5.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 +116 -74
- package/README.md +23 -12
- package/assets/tab-account-and-usage.png +0 -0
- package/assets/tab-model-draw.png +0 -0
- package/assets/tab-model-list.png +0 -0
- package/client.js +778 -182
- package/cordis.patch.yml +13 -4
- package/lib/host-config.js +271 -47
- package/lib/index.js +1575 -567
- package/lib/llm-adapter.js +38 -18
- package/package.json +2 -2
- package/screenshots.json +2 -1
- package/assets/icon-preview.png +0 -0
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { C as errMsg, D as redactSecrets, E as pluginError, O as str, S as degrade, T as optional, _ as parseAllowedList, a as resolveSettings, b as looksLikeRateLimit, c as LLM_PROVIDER_ID, d as imageGenModelIds, f as isVisionModel, g as enabledCountOf, h as rosterWithAvailability, i as name, k as verbatim, m as resolveDrawModelChoice, n as inject, p as normalizeEntry, r as isAdmitted, s as LLM_DISPLAY_NAME, t as PLUGIN_VERSION, u as drawCandidates, w as obj, y as hasHardQuotaWordingIn } from "./host-config.js";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
3
|
import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { homedir } from "node:os";
|
|
@@ -90,6 +90,44 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
90
90
|
const resolveService = () => {
|
|
91
91
|
return (typeof credentials === "function" ? credentials() : credentials) ?? null;
|
|
92
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* 解析当前令牌与来源(实现体)。
|
|
95
|
+
*
|
|
96
|
+
* 提成局部函数而不是只做返回对象上的方法:`state()` 也要走同一条路,而方法里
|
|
97
|
+
* 调 `this.resolve()` 是一个**解构即崩**的 footgun——`const { state } =
|
|
98
|
+
* createTokenStore(...)` 之后 `this` 是 undefined,调用直接 TypeError。当前
|
|
99
|
+
* 调用点都规规矩矩用方法调用所以没炸,但那是运气,不是设计。返回类型显式
|
|
100
|
+
* 注解:这是快照 TokenStatus.source 联合类型的上游,靠推断会把字面量联合
|
|
101
|
+
* 拓宽成 string。
|
|
102
|
+
* @returns {Promise<{value: string, source: ("credentials"|"memory"|"env"|null)}>}
|
|
103
|
+
*/
|
|
104
|
+
const resolveToken = async () => {
|
|
105
|
+
try {
|
|
106
|
+
const service = resolveService();
|
|
107
|
+
if (service !== null && typeof service.resolve === "function") {
|
|
108
|
+
const resolved = await service.resolve(TOKEN_REF).catch(() => void 0);
|
|
109
|
+
const value = verbatim(resolved?.value, "");
|
|
110
|
+
if (typeof value === "string" && value.trim() !== "") return {
|
|
111
|
+
value,
|
|
112
|
+
source: "credentials"
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
const held = memory.get(TOKEN_REF);
|
|
117
|
+
if (typeof held === "string" && held.trim() !== "") return {
|
|
118
|
+
value: held,
|
|
119
|
+
source: "memory"
|
|
120
|
+
};
|
|
121
|
+
const fromEnv = verbatim(env[TOKEN_REF], "");
|
|
122
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return {
|
|
123
|
+
value: fromEnv,
|
|
124
|
+
source: "env"
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
value: "",
|
|
128
|
+
source: null
|
|
129
|
+
};
|
|
130
|
+
};
|
|
93
131
|
return {
|
|
94
132
|
/**
|
|
95
133
|
* 把面板输入的令牌存成引用;原样存(不 trim),空白值视为没输入。
|
|
@@ -107,7 +145,7 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
107
145
|
async save(token) {
|
|
108
146
|
const value = verbatim(token, "");
|
|
109
147
|
if (typeof value !== "string" || value.trim() === "") throw new Error("a ModelScope token is required");
|
|
110
|
-
if (!isPlausibleToken(value.trim())) throw new Error("that does not look like a ModelScope access token (expected ms- followed by hex digits)");
|
|
148
|
+
if (!isPlausibleToken(value.trim())) throw new Error("that does not look like a ModelScope access token (expected ms- followed by at least 16 hex digits); if you are sure the token is correct, this plugin's shape check may be out of date");
|
|
111
149
|
const service = resolveService();
|
|
112
150
|
if (service !== null && typeof service.set === "function") {
|
|
113
151
|
await service.set(TOKEN_REF, value);
|
|
@@ -122,13 +160,18 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
122
160
|
* 服务实现里 unset 抛错时,吞掉错误会让路由回 `ok: true` + `source: credentials`,
|
|
123
161
|
* 而令牌**仍在 `~/.dsh/.credentials.yaml` 里并且仍被 resolve() 读回来**。
|
|
124
162
|
* 「忘掉令牌」是一个安全动作,谎报成功比失败更糟——用户以为凭据已删干净。
|
|
125
|
-
*
|
|
163
|
+
*
|
|
164
|
+
* **先 unset 成功、再删内存**:反过来的顺序有一个竞态——unset 抛错时内存
|
|
165
|
+
* 已删,下次 resolve() 会走 env 回退读回那个本该被删掉的令牌(如果 env
|
|
166
|
+
* 里也有 `MODELSCOPE_API_KEY`)。用户以为凭据已删干净,但它「复活」了。
|
|
167
|
+
* 现在 unset 失败时内存保留,resolve() 会读到内存里的旧值并报错(因为
|
|
168
|
+
* `source` 不是 `null`,但用户认为已经删了)——至少不会静默回退到 env。
|
|
126
169
|
*/
|
|
127
170
|
async forget() {
|
|
128
|
-
memory.delete(TOKEN_REF);
|
|
129
|
-
lastCheck = null;
|
|
130
171
|
const service = resolveService();
|
|
131
172
|
if (service !== null && typeof service.unset === "function") await service.unset(TOKEN_REF);
|
|
173
|
+
memory.delete(TOKEN_REF);
|
|
174
|
+
lastCheck = null;
|
|
132
175
|
},
|
|
133
176
|
/**
|
|
134
177
|
* 记录一次**零额度探针**的结论(probe 路由是唯一调用方)。
|
|
@@ -146,36 +189,11 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
146
189
|
};
|
|
147
190
|
},
|
|
148
191
|
/**
|
|
149
|
-
*
|
|
150
|
-
* 联合类型的上游,靠推断会把字面量联合拓宽成 string。
|
|
192
|
+
* 解析当前令牌与来源。
|
|
151
193
|
* @returns {Promise<{value: string, source: ("credentials"|"memory"|"env"|null)}>}
|
|
152
194
|
*/
|
|
153
195
|
async resolve() {
|
|
154
|
-
|
|
155
|
-
const service = resolveService();
|
|
156
|
-
if (service !== null && typeof service.resolve === "function") {
|
|
157
|
-
const resolved = await service.resolve(TOKEN_REF).catch(() => void 0);
|
|
158
|
-
const value = verbatim(resolved?.value, "");
|
|
159
|
-
if (typeof value === "string" && value.trim() !== "") return {
|
|
160
|
-
value,
|
|
161
|
-
source: "credentials"
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
} catch {}
|
|
165
|
-
const held = memory.get(TOKEN_REF);
|
|
166
|
-
if (typeof held === "string" && held.trim() !== "") return {
|
|
167
|
-
value: held,
|
|
168
|
-
source: "memory"
|
|
169
|
-
};
|
|
170
|
-
const fromEnv = verbatim(env[TOKEN_REF], "");
|
|
171
|
-
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return {
|
|
172
|
-
value: fromEnv,
|
|
173
|
-
source: "env"
|
|
174
|
-
};
|
|
175
|
-
return {
|
|
176
|
-
value: "",
|
|
177
|
-
source: null
|
|
178
|
-
};
|
|
196
|
+
return resolveToken();
|
|
179
197
|
},
|
|
180
198
|
/**
|
|
181
199
|
* 无秘密的状态描述(快照与面板消费)。
|
|
@@ -192,7 +210,7 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
192
210
|
* `memory` 才真的只在本次进程存活期间存在。
|
|
193
211
|
*/
|
|
194
212
|
async state() {
|
|
195
|
-
const { source } = await
|
|
213
|
+
const { source } = await resolveToken();
|
|
196
214
|
return {
|
|
197
215
|
present: source !== null,
|
|
198
216
|
source: source === null ? "none" : source,
|
|
@@ -207,9 +225,11 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
207
225
|
//#endregion
|
|
208
226
|
//#region src/host/state-store.ts
|
|
209
227
|
/**
|
|
210
|
-
* 状态文件公共原语。**TL;DR
|
|
211
|
-
* 状态目录)、`profileSegment`(按 profile
|
|
212
|
-
*
|
|
228
|
+
* 状态文件公共原语。**TL;DR**:本文件只做四件事——`dshHome`/`stateDir`(定位
|
|
229
|
+
* 状态目录)、`profileSegment`(按 profile 分段路径)、{@link createStateReadCache}
|
|
230
|
+
* (统一读缓存)、以及写侧三件套({@link createStateWriteQueue} 写串行化 /
|
|
231
|
+
* {@link guardStateWrite} ADR-006 写守卫 / {@link stateVersionNote} 读侧版本说明)。
|
|
232
|
+
* 其余一切遵守下方「行为约定」。
|
|
213
233
|
*
|
|
214
234
|
* 把本插件各 store(usage-store / provider / draw)各自手写的同一段
|
|
215
235
|
* "版本载荷 + temp 文件 + rename 原子 + 0600 + 损坏即忽略" 收敛到这里。
|
|
@@ -234,6 +254,13 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
234
254
|
* 2. 第二步收敛的是**读缓存**:provider / draw 早有 1s TTL,而 catalog 完全
|
|
235
255
|
* 没有(进程内永不失效)——同一个「两个进程共享一个 state 目录」的问题修了
|
|
236
256
|
* 两个、漏了第三个。现在统一走 {@link createStateReadCache},一个 TTL 三个调用方。
|
|
257
|
+
* 3. 第三步收敛的是**写侧三件套**。这三个 store 曾各自手写:provider 有
|
|
258
|
+
* writeChain、usage 有 writeChain、draw **两个都没有**(并发保存丢一次、
|
|
259
|
+
* 磁盘 version 比本构建新时降级覆写、读侧静默读作「没设过」——2026-10-17
|
|
260
|
+
* 实测复现,正是 provider-store 注释里记着「咬过三次」的那类漂移的第四条)。
|
|
261
|
+
* 现在三件套各有一份公共实现:串行化是{@link createStateWriteQueue}、写守卫是
|
|
262
|
+
* {@link guardStateWrite}、读侧说明是{@link stateVersionNote};provider /
|
|
263
|
+
* usage / draw 三个 store 都是调用方,不再各写一遍。
|
|
237
264
|
*
|
|
238
265
|
* @module dsh-connect-modelscope-token-plan/state-store
|
|
239
266
|
*/
|
|
@@ -605,11 +632,113 @@ async function readStateVersion(file) {
|
|
|
605
632
|
function isKnownStateVersion(version, known) {
|
|
606
633
|
return version === null || known.includes(version);
|
|
607
634
|
}
|
|
635
|
+
/**
|
|
636
|
+
* 写串行化队列——store 的「读-改-写必须排队」的那把链。
|
|
637
|
+
*
|
|
638
|
+
* 为什么这不是某个 store 的私事:三个 store(provider / usage / draw)各写过
|
|
639
|
+
* 一份一模一样的 `let writeChain = Promise.resolve(...)`,而 draw 曾经**两份都
|
|
640
|
+
* 没有**——并发 `saveEnabled(true)` + `saveModelId(...)` 各自读到同一个 current,
|
|
641
|
+
* 后写的赢,前一次保存被整个丢掉(2026-10-17 实测:落盘只剩其中一个字段)。
|
|
642
|
+
* provider-store 的注释记着这类漂移「咬过三次」,draw 是第四条。抽到这里,
|
|
643
|
+
* 第四个 store 起只需调用,没有「再手写一遍」这个选项。
|
|
644
|
+
*
|
|
645
|
+
* 语义(与 provider / usage 此前的私有实现逐字一致):
|
|
646
|
+
* - `task` 在上一个任务落幕后才起动,临界区内重新读盘(基线不来自 TTL 缓存);
|
|
647
|
+
* - **调用方拿到的是 `run` 本身**——拒绝照旧传播(provider 的 `save()` 要抛给
|
|
648
|
+
* 路由、usage 的 `recordCall()` 也要抛),因为吞掉失败是调用方的语义决定;
|
|
649
|
+
* - 链自己吞掉失败继续走:一次只读 Home 上的写失败不该毒化后续所有保存。
|
|
650
|
+
*
|
|
651
|
+
* peer-free,与其余原语同纪律。
|
|
652
|
+
* @returns {{
|
|
653
|
+
* enqueue: <T>(task: () => Promise<T>) => Promise<T>
|
|
654
|
+
* }} 排队执行一次任务。
|
|
655
|
+
*/
|
|
656
|
+
function createStateWriteQueue() {
|
|
657
|
+
let chain = Promise.resolve();
|
|
658
|
+
return { enqueue(task) {
|
|
659
|
+
const run = chain.then(task);
|
|
660
|
+
chain = run.then(() => void 0, () => void 0);
|
|
661
|
+
return run;
|
|
662
|
+
} };
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* ADR-006 写侧守卫:本构建读不懂的版本,不许覆盖。
|
|
666
|
+
*
|
|
667
|
+
* 磁盘上出现一个**本构建不认识的数字版本**,只可能是更新的构建写的;用一个旧
|
|
668
|
+
* 载荷盖掉它,销毁的是我们根本看不见的数据(实测过一次 version 2 → 1 的降级
|
|
669
|
+
* 覆写,110 次历史归零)。所以写入前先问盘,三种答案:
|
|
670
|
+
* - `null`(没有文件 / 没有版本 / 损坏)——没有什么可保护,写是恢复手段,放行;
|
|
671
|
+
* - 已知版本——放行;
|
|
672
|
+
* - 未知数字版本,或文件**在场却读不出来**(EACCES/EBUSY/EIO)——拒写。
|
|
673
|
+
* 后者由 {@link readStateVersion} 抛出来(它只把 ENOENT 读作「没有文件」),
|
|
674
|
+
* 这里接住并拒写:「读不到」不等于「没有东西要保护」。
|
|
675
|
+
*
|
|
676
|
+
* 拒写返回一句**已 degrade 记录**的原因(调用方决定要不要再抛给用户——provider
|
|
677
|
+
* 的 save 抛、draw 的 saveEnabled 抛、usage 的 recordCall 静默 no-op),而不是
|
|
678
|
+
* 静默 no-op: swallow the failure, not the reason。
|
|
679
|
+
*
|
|
680
|
+
* @param {string} file - 目标状态文件路径(消息里只取 basename)。
|
|
681
|
+
* @param {readonly number[]} known - 本构建读得懂的版本集合。
|
|
682
|
+
* @param {{label: string, logger?: {warn?: (m: string) => void}|undefined}} options -
|
|
683
|
+
* `label` 进拒绝消息的前缀(如 `"provider"` / `"draw-state"`)。`logger` 的
|
|
684
|
+
* `| undefined` 不是冗余:tsconfig 开了 `exactOptionalPropertyTypes`,而各 store
|
|
685
|
+
* 解构出来的 `logger` 本身就带着 `undefined`(属性可选),不写这一半编译不过。
|
|
686
|
+
* @returns {Promise<string|null>} 拒绝原因(已记日志),放行时为 `null`。
|
|
687
|
+
*/
|
|
688
|
+
async function guardStateWrite(file, known, options) {
|
|
689
|
+
const base = basename(file);
|
|
690
|
+
let existing;
|
|
691
|
+
try {
|
|
692
|
+
existing = await readStateVersion(file);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
const reason = `${options.label}: refusing to overwrite ${base} (cannot read it: ${redactSecrets(errMsg(error))})`;
|
|
695
|
+
degrade(reason, null, options.logger, null);
|
|
696
|
+
return reason;
|
|
697
|
+
}
|
|
698
|
+
if (!isKnownStateVersion(existing, known)) {
|
|
699
|
+
const reason = `${options.label}: refusing to overwrite ${base} holding version ${existing} (this build knows ${known.join("/")})`;
|
|
700
|
+
degrade(reason, null, options.logger, null);
|
|
701
|
+
return reason;
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* 读侧版本说明:磁盘上那份文件带着本构建读不懂的版本时,给操作者的一句话。
|
|
707
|
+
*
|
|
708
|
+
* 为什么读侧宽容还要说话:写侧守卫挡住了覆盖,读侧按设计把未知版本**宽容地**
|
|
709
|
+
* 读作「没设过」——于是「保存的值被忽略」在面板上呈现成「从未保存过」。对
|
|
710
|
+
* provider 的允许清单,这两者读出的方向是危险的(空清单 = 不过滤 = 提供全部
|
|
711
|
+
* 模型);对 draw 的开关,回退方向是 patch 默认(通常 = 关)。闸与说明是同一条
|
|
712
|
+
* 承诺的两半,缺了哪半都静默。
|
|
713
|
+
*
|
|
714
|
+
* `consequence` 由调用方给:每个 store 知道自己「读作没设过」意味着什么,公共
|
|
715
|
+
* 原语只负责把文件名、版本号、已知版本集合与下一步动作说全。
|
|
716
|
+
*
|
|
717
|
+
* @param {object} options
|
|
718
|
+
* @param {string} options.label - 消息前缀(如 `"provider-state"`)。
|
|
719
|
+
* @param {string} options.file - 状态文件路径(消息里只取 basename)。
|
|
720
|
+
* @param {number} options.version - 盘上的版本号(调用方已判过是未知的)。
|
|
721
|
+
* @param {readonly number[]} options.known - 本构建读得懂的版本集合。
|
|
722
|
+
* @param {string} options.consequence - 「读作没设过」对这个 store 意味着什么。
|
|
723
|
+
* @returns {string} 人读的一句警告。
|
|
724
|
+
*/
|
|
725
|
+
function stateVersionNote(options) {
|
|
726
|
+
return `${options.label}: ${basename(options.file)} holds version ${options.version}, which this build cannot read (this build knows ${options.known.join("/")}); ${options.consequence} Upgrade this build to see the saved values, or delete the file to reset them.`;
|
|
727
|
+
}
|
|
608
728
|
|
|
609
729
|
//#endregion
|
|
610
|
-
//#region src/
|
|
730
|
+
//#region src/shared/codes.ts
|
|
611
731
|
/**
|
|
612
|
-
*
|
|
732
|
+
* 错误分类码表的**单一真源**——Host 与 Client 同读这一份。
|
|
733
|
+
*
|
|
734
|
+
* 为什么从 `host/codes.ts` 搬来:这张表曾经在 Client 侧有一份「刻意副本」
|
|
735
|
+
* (`client/snapshot.ts` 的 `CLIENT_CODE`),理由是「浏览器无法 import Host
|
|
736
|
+
* 模块」。那个理由已经过期——`shared/wire.ts` 的运行时值(如
|
|
737
|
+
* `SNAPSHOT_REQUIRED_KEYS`)早就在双端 import,浏览器 bundle 拿得到 shared/
|
|
738
|
+
* 下的任何模块。两份表必然漂移,而漂移的后果是最需要人看的内部错误没有引导
|
|
739
|
+
* 文案(INTERNAL_ERROR 当初就是这么漏的)。码表住 shared,Host 的分类函数
|
|
740
|
+
* (`classifyStatus` / `classifyRateLimit` / `parseRetryAfterMs`)留在
|
|
741
|
+
* `host/codes.ts`——那些读上游响应,是 Host 独有的行为。
|
|
613
742
|
*
|
|
614
743
|
* 魔搭的分类现实(SPIKE.md):上游不带任何额度头,额度耗尽与每分钟限频都只
|
|
615
744
|
* 会以 429 + 文案出现,所以 429 的分诊是**按 body 文案**做的保守二分——
|
|
@@ -619,8 +748,8 @@ function isKnownStateVersion(version, known) {
|
|
|
619
748
|
* @module dsh-connect-modelscope-token-plan/codes
|
|
620
749
|
*/
|
|
621
750
|
/**
|
|
622
|
-
* 面板按它分流的稳定码表 —— Client 的 `GUIDANCE_BY_CODE`
|
|
623
|
-
*
|
|
751
|
+
* 面板按它分流的稳定码表 —— Client 的 `GUIDANCE_BY_CODE` 以这份表的键为契约
|
|
752
|
+
* (且以 `CodeValue` 为**类型**契约:缺键是编译错误,不再只靠测试红)。
|
|
624
753
|
*/
|
|
625
754
|
const CODE = Object.freeze({
|
|
626
755
|
CONFIG_ERROR: "config_error",
|
|
@@ -643,12 +772,26 @@ const CODE = Object.freeze({
|
|
|
643
772
|
* Client 自产码:Host 响应无法解读成快照(非 JSON / 缺 ok 字段)时,
|
|
644
773
|
* 面板用它走引导文案,而不是把裸英文串("unexpected payload")上屏。
|
|
645
774
|
*
|
|
646
|
-
* 本表是 `GUIDANCE_BY_CODE`
|
|
647
|
-
* CODE 里」、§3c 钉「每个码必须有引导」)——新增 client 码必须同时进
|
|
775
|
+
* 本表是 `GUIDANCE_BY_CODE` 的键集真源——新增 client 码必须同时进
|
|
648
776
|
* 这张表,两侧才同步。
|
|
649
777
|
*/
|
|
650
778
|
PAYLOAD_ERROR: "payload_error"
|
|
651
779
|
});
|
|
780
|
+
|
|
781
|
+
//#endregion
|
|
782
|
+
//#region src/host/codes.ts
|
|
783
|
+
/**
|
|
784
|
+
* Host 侧的 429 分诊分类函数。**码表本身已迁至 `shared/codes.ts`**(双端同读,
|
|
785
|
+
* 见那里的搬迁理由);本文件只剩「读上游响应」的 Host 独有行为,并把 CODE
|
|
786
|
+
* 再导出一次,让 Host 各模块的 `from "./codes.ts"` 导入路径不变。
|
|
787
|
+
*
|
|
788
|
+
* 魔搭的分类现实(SPIKE.md):上游不带任何额度头,额度耗尽与每分钟限频都只
|
|
789
|
+
* 会以 429 + 文案出现,所以 429 的分诊是**按 body 文案**做的保守二分——
|
|
790
|
+
* QUOTA_EXCEEDED 快速失败不重试,RATE_LIMITED 交给调用方退避。分不清时归
|
|
791
|
+
* RATE_LIMITED(退避是两方向里更安全的那个)。
|
|
792
|
+
*
|
|
793
|
+
* @module dsh-connect-modelscope-token-plan/codes
|
|
794
|
+
*/
|
|
652
795
|
/**
|
|
653
796
|
* 把上游 HTTP 状态归类成稳定码。
|
|
654
797
|
*
|
|
@@ -690,11 +833,7 @@ function classifyStatus(status) {
|
|
|
690
833
|
function classifyRateLimit(message) {
|
|
691
834
|
if (typeof message !== "string" || message.length === 0) return "rate_limit";
|
|
692
835
|
if (looksLikeRateLimit(message)) return "rate_limit";
|
|
693
|
-
return
|
|
694
|
-
}
|
|
695
|
-
/** 文本是否含硬额度措辞(真耗尽,不纠正)——直接问 llm-error-fix 的那张表。 */
|
|
696
|
-
function hasHardQuotaWording(message) {
|
|
697
|
-
return hasHardQuotaWordingIn(message);
|
|
836
|
+
return hasHardQuotaWordingIn(message) ? "quota" : "rate_limit";
|
|
698
837
|
}
|
|
699
838
|
/**
|
|
700
839
|
* 解析 Retry-After(秒或 HTTP 日期)与平台文案里写明的等待时长。
|
|
@@ -838,13 +977,14 @@ function parsePayload(raw) {
|
|
|
838
977
|
* @param {object} options
|
|
839
978
|
* @param {string} options.name - 插件 slug(状态目录名,来自 host-config)。
|
|
840
979
|
* @param {string|null} [options.profile] - profile 名;null = 共享目录。
|
|
841
|
-
* @param {number} [options.
|
|
842
|
-
*
|
|
980
|
+
* @param {number} [options.retentionDays] - 天桶**保留**数(超出即修剪;从配置注入,
|
|
981
|
+
* 让「配置改小」在下次写入时生效,而不是等一个固定的内部常数)。与「显示多少天」
|
|
982
|
+
* 是两件事——显示窗口由调用方传给 `trend(days)`,保留窗口恒 ≥ 它(host-config 夹)。
|
|
843
983
|
* @param {number} [options.maxEvents] - 事件流上限。
|
|
844
984
|
* @param {() => Date} [options.now] - 时钟源;测试注入。
|
|
845
985
|
* @param {{warn?: (message: string) => void}} [options.logger] - ctx.logger。
|
|
846
986
|
*/
|
|
847
|
-
function createFileUsageStore({ name, profile = null,
|
|
987
|
+
function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEvents = 50, now = () => /* @__PURE__ */ new Date(), logger }) {
|
|
848
988
|
const dir = profileStateDir(name, profile);
|
|
849
989
|
const file = join(dir, "usage.json");
|
|
850
990
|
/** 最近一次解析异常(损坏即忽略的痕迹),快照转 shapeWarnings。 */
|
|
@@ -859,8 +999,10 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
859
999
|
events: []
|
|
860
1000
|
};
|
|
861
1001
|
let loaded = false;
|
|
862
|
-
/** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。
|
|
863
|
-
|
|
1002
|
+
/** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。
|
|
1003
|
+
队列实现在 state-store({@link createStateWriteQueue})——provider / draw
|
|
1004
|
+
同款,三份私有副本正是 draw-store 曾经三件全缺的入口。 */
|
|
1005
|
+
const writeQueue = createStateWriteQueue();
|
|
864
1006
|
/**
|
|
865
1007
|
* 磁盘版本闸 —— 读路径与写路径**各自**调用,不共享副作用。
|
|
866
1008
|
*
|
|
@@ -880,7 +1022,7 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
880
1022
|
try {
|
|
881
1023
|
onDisk = await readStateVersion(file);
|
|
882
1024
|
} catch (error) {
|
|
883
|
-
const note = `usage-state: usage.json exists but cannot be read (${redactSecrets
|
|
1025
|
+
const note = `usage-state: usage.json exists but cannot be read (${redactSecrets(errMsg(error))})`;
|
|
884
1026
|
if (!readOnly) logger?.warn?.(`${name}: ${note} — recording disabled to avoid clobbering`);
|
|
885
1027
|
anomaly = note;
|
|
886
1028
|
readOnly = true;
|
|
@@ -915,26 +1057,22 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
915
1057
|
cache.remember(payload);
|
|
916
1058
|
};
|
|
917
1059
|
/** 排队执行一次读-改-写;只读闸置位时整个变更是 no-op。 */
|
|
918
|
-
const enqueue = (mutate) => {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
writeChain = run.catch(() => {});
|
|
933
|
-
return run;
|
|
934
|
-
};
|
|
935
|
-
/** 修剪:只留最近 trendDays 个天桶(按 dateKey 排序,今天不删)。 */
|
|
1060
|
+
const enqueue = (mutate) => writeQueue.enqueue(async () => {
|
|
1061
|
+
if (await diskVersionBlocksWrites()) return;
|
|
1062
|
+
const payload = await readThrough() ?? (loaded ? current : null) ?? {
|
|
1063
|
+
version: STATE_VERSION,
|
|
1064
|
+
days: {},
|
|
1065
|
+
models: {},
|
|
1066
|
+
events: []
|
|
1067
|
+
};
|
|
1068
|
+
mutate(payload);
|
|
1069
|
+
current = payload;
|
|
1070
|
+
loaded = true;
|
|
1071
|
+
await persist(payload);
|
|
1072
|
+
});
|
|
1073
|
+
/** 修剪:只留最近 retentionDays 个天桶(按 dateKey 排序,今天不删)。 */
|
|
936
1074
|
const pruneDays = (payload, todayKey) => {
|
|
937
|
-
const keep = new Set(Object.keys(payload.days).sort().slice(-Math.max(1,
|
|
1075
|
+
const keep = new Set(Object.keys(payload.days).sort().slice(-Math.max(1, retentionDays)));
|
|
938
1076
|
keep.add(todayKey);
|
|
939
1077
|
for (const key of Object.keys(payload.days)) if (!keep.has(key)) delete payload.days[key];
|
|
940
1078
|
};
|
|
@@ -1047,7 +1185,7 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
1047
1185
|
return out;
|
|
1048
1186
|
},
|
|
1049
1187
|
/** 最近 N 天趋势(含今天;缺桶 = 该日 0 次,真实零不是数据丢失)。 */
|
|
1050
|
-
async trend(days =
|
|
1188
|
+
async trend(days = retentionDays) {
|
|
1051
1189
|
const payload = await cache.read();
|
|
1052
1190
|
const out = [];
|
|
1053
1191
|
const cursor = now();
|
|
@@ -1275,6 +1413,39 @@ function createCoalescedFetch(options = {}) {
|
|
|
1275
1413
|
* 目录声明过窗口的条目不进打标,策展表永不推翻平台声明。
|
|
1276
1414
|
* 4. 生图/语音模型(Qwen-Image-Edit、ACE-Step 等)无对话上下文,不收。
|
|
1277
1415
|
*/
|
|
1416
|
+
/**
|
|
1417
|
+
* 本表最后一次与 models.dev / 真机目录核对的日期(ISO 日期,UTC)。
|
|
1418
|
+
*
|
|
1419
|
+
* 存在的理由:策展表是**静态知识**,而目录会上下架。维护纪律要求「目录下架的条目
|
|
1420
|
+
* 应随手删除」,但没有任何机制会自动知道下架发生了——没有这个日期,表会悄悄腐烂,
|
|
1421
|
+
* 而使用者无从判断一个值是三个月前还是三年前核的。快照侧据此在表过期时给一条
|
|
1422
|
+
* shapeWarning(见 {@link curatedCatalogStale})。
|
|
1423
|
+
*/
|
|
1424
|
+
const CURATED_AS_OF = "2026-10-16";
|
|
1425
|
+
/**
|
|
1426
|
+
* 策展表距上次核对已经过去多少天;日期解析不了返回 `null`。
|
|
1427
|
+
* @param {Date} [now] - 时钟源(测试注入)。
|
|
1428
|
+
* @returns {number|null} 天数(向下取整),或 null。
|
|
1429
|
+
*/
|
|
1430
|
+
function curatedCatalogAgeDays(now = /* @__PURE__ */ new Date()) {
|
|
1431
|
+
const asOf = Date.parse("2026-10-16T00:00:00Z");
|
|
1432
|
+
if (Number.isNaN(asOf)) return null;
|
|
1433
|
+
const delta = now.getTime() - asOf;
|
|
1434
|
+
return delta < 0 ? 0 : Math.floor(delta / 864e5);
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* 策展表是否该被复核了:距 {@link CURATED_AS_OF} 超过 `maxAgeDays` 天。
|
|
1438
|
+
*
|
|
1439
|
+
* 只回答「该提醒了吗」,不回答「表里的值错了」——过期的表往往仍然正确,只是
|
|
1440
|
+
* 不再有核对背书。快照把它转成一条 shapeWarning,而不是拒绝使用表里的值。
|
|
1441
|
+
* @param {Date} [now] - 时钟源(测试注入)。
|
|
1442
|
+
* @param {number} [maxAgeDays] - 容忍的天数,默认 180。
|
|
1443
|
+
* @returns {boolean}
|
|
1444
|
+
*/
|
|
1445
|
+
function curatedCatalogStale(now = /* @__PURE__ */ new Date(), maxAgeDays = 180) {
|
|
1446
|
+
const age = curatedCatalogAgeDays(now);
|
|
1447
|
+
return age !== null && age > maxAgeDays;
|
|
1448
|
+
}
|
|
1278
1449
|
/** id → 标称上下文窗口(tokens)。键必须与魔搭目录 id 逐字一致。 */
|
|
1279
1450
|
const CURATED_CONTEXT_WINDOWS = {
|
|
1280
1451
|
"deepseek-ai/DeepSeek-V4-Flash-0731": 1048576,
|
|
@@ -1361,6 +1532,25 @@ async function mapWithConcurrency(items, limit, fn) {
|
|
|
1361
1532
|
function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
1362
1533
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
1363
1534
|
const { read } = createCoalescedFetch();
|
|
1535
|
+
/**
|
|
1536
|
+
* 详情端点任务标签的**独立**缓存与 TTL——与目录/余额的 `read` 刻意分开。
|
|
1537
|
+
*
|
|
1538
|
+
* 标签回答「这个模型吃不吃图」,是出厂能力,几乎不变;目录/余额回答「现在有
|
|
1539
|
+
* 什么、还剩多少」——两者需要的陈旧度差着量级。它们曾共用 `cacheSeconds`
|
|
1540
|
+
* (默认 60 秒),而面板与对话框胶囊按 `pollSeconds`(默认 30s)轮询快照、
|
|
1541
|
+
* 快照每次都调 `fetchModels`:穿透时对目录里每个模型各打一次
|
|
1542
|
+
* `siteBase/api/v1/models/{id}`。当前目录 ~37 条 = **每轮 38 个 HTTP 请求、
|
|
1543
|
+
* 稳定每分钟一轮**(约 5 万/天),只为判一个布尔值,打的还是 modelscope.cn
|
|
1544
|
+
* 网站上一个非公开、非契约的接口。那不叫新鲜,叫白打。
|
|
1545
|
+
*
|
|
1546
|
+
* `maxAgeMs` 必须一起抬:coalesced-fetch 的清扫上限默认 1 小时(那是「任何
|
|
1547
|
+
* 调用方的最长 TTL」),不抬的话 6 小时的 TTL 会被清扫提前作废。键数等于目录
|
|
1548
|
+
* 里的模型数(约 40 条),无界增长不成立。
|
|
1549
|
+
*/
|
|
1550
|
+
const TAGS_TTL_MS = 216e5;
|
|
1551
|
+
const { read: readTags } = createCoalescedFetch({ maxAgeMs: TAGS_TTL_MS });
|
|
1552
|
+
/** 详情端点的并发:长 TTL 之后扇出已经很稀,没必要一上来 6 路齐发去撞主站。 */
|
|
1553
|
+
const TAG_FETCH_CONCURRENCY = 3;
|
|
1364
1554
|
/** 带超时的一次 fetch(AbortController;abort → TIMEOUT_ERROR)。 */
|
|
1365
1555
|
const fetchWithTimeout = async (url, init) => {
|
|
1366
1556
|
const controller = new AbortController();
|
|
@@ -1372,7 +1562,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1372
1562
|
});
|
|
1373
1563
|
} catch (error) {
|
|
1374
1564
|
if (error instanceof Error && error.name === "AbortError") throw pluginError(CODE.TIMEOUT_ERROR, "modelscope request timed out");
|
|
1375
|
-
throw pluginError(CODE.NETWORK_ERROR, redactSecrets
|
|
1565
|
+
throw pluginError(CODE.NETWORK_ERROR, redactSecrets(errMsg(error)));
|
|
1376
1566
|
} finally {
|
|
1377
1567
|
clearTimeout(timer);
|
|
1378
1568
|
}
|
|
@@ -1380,13 +1570,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1380
1570
|
/**
|
|
1381
1571
|
* 丢弃一个我们不读的响应体。
|
|
1382
1572
|
*
|
|
1383
|
-
* undici 里未消费的 body 会**占住连接**直到 GC
|
|
1384
|
-
*
|
|
1385
|
-
*
|
|
1573
|
+
* undici 里未消费的 body 会**占住连接**直到 GC。标签扇出会对目录里 30–40 个
|
|
1574
|
+
* 模型并发打详情端点(`mapWithConcurrency` 分批),其中任何一条走「!ok」或
|
|
1575
|
+
* 「形状不符」分支,连接就一直挂着——短时间内连接池被这些挂起的响应吃满,
|
|
1576
|
+
* 后面的请求开始排队。
|
|
1386
1577
|
*
|
|
1387
1578
|
* `cancel()` 是显式的弃权:告诉 undici「这个 body 我不要了」,它会立刻释放
|
|
1388
1579
|
* 连接而不是等GC。用 `void` 前缀是因为这是best-effort——取消失败不影响我们
|
|
1389
|
-
*
|
|
1580
|
+
* 已经决定的结果。
|
|
1390
1581
|
*/
|
|
1391
1582
|
const discardBody = (response) => {
|
|
1392
1583
|
try {
|
|
@@ -1407,8 +1598,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1407
1598
|
/**
|
|
1408
1599
|
* 单个模型的详情端点任务标签(modelscope.cn/api/v1/models/{id})。
|
|
1409
1600
|
* 免认证 GET,零推理额度(实测匿名 200)。返回 `Data.Tasks[].Name`(+
|
|
1410
|
-
* `widgets[].task`
|
|
1411
|
-
*
|
|
1601
|
+
* `widgets[].task` 兜底)去重后的标签数组。
|
|
1602
|
+
*
|
|
1603
|
+
* **失败一律抛,绝不返回 `[]`**:本函数的结果会被 {@link TAGS_TTL_MS} 缓存
|
|
1604
|
+
* 六小时,而 `[]` 是一个**结论**(「这个模型没有任务标签」)。把 429 / 5xx /
|
|
1605
|
+
* 形状漂移降级成 `[]` 再缓存,等于让一次网络抖动把某个模型的视觉标签钉死半天
|
|
1606
|
+
* ——正是「读不到 ≠ 没有」的反面。coalesced-fetch 不缓存 rejection(失败被当
|
|
1607
|
+
* 「不是答案」共享,但不落盘),所以抛出去的那一轮不会被记住;调用方 catch 成
|
|
1608
|
+
* `[]`,只影响这一轮,下一轮照常重试。
|
|
1412
1609
|
*/
|
|
1413
1610
|
const fetchTaskTags = async (id) => {
|
|
1414
1611
|
const detailUrl = settings.siteBase + "/api/v1/models/" + encodeURI(id);
|
|
@@ -1418,10 +1615,16 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1418
1615
|
});
|
|
1419
1616
|
if (!response.ok) {
|
|
1420
1617
|
discardBody(response);
|
|
1421
|
-
|
|
1618
|
+
throw new Error("model detail failed with HTTP " + String(response.status));
|
|
1619
|
+
}
|
|
1620
|
+
let json;
|
|
1621
|
+
try {
|
|
1622
|
+
json = await response.json();
|
|
1623
|
+
} catch {
|
|
1624
|
+
discardBody(response);
|
|
1625
|
+
throw new Error("model detail body was not JSON");
|
|
1422
1626
|
}
|
|
1423
|
-
|
|
1424
|
-
if (json === null || typeof json !== "object" || json.Code !== 200) return [];
|
|
1627
|
+
if (json === null || typeof json !== "object" || json.Code !== 200) throw new Error("model detail shape drifted (expected {Code:200, Data:{Tasks:[...]}})");
|
|
1425
1628
|
const data = json.Data ?? {};
|
|
1426
1629
|
const tasks = [];
|
|
1427
1630
|
for (const t of Array.isArray(data.Tasks) ? data.Tasks : []) {
|
|
@@ -1462,18 +1665,27 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1462
1665
|
*/
|
|
1463
1666
|
async fetchModels(contextOverride) {
|
|
1464
1667
|
const url = settings.apiBase + "/models";
|
|
1465
|
-
|
|
1668
|
+
const cacheKey = contextOverride != null ? `models:override:${contextOverride}` : "models:catalog";
|
|
1669
|
+
const cacheTtl = contextOverride != null ? Math.min(settings.cacheSeconds * 1e3, 5e3) : settings.cacheSeconds * 1e3;
|
|
1670
|
+
return await read(cacheKey, async () => {
|
|
1466
1671
|
const response = await fetchWithTimeout(url, { method: "GET" });
|
|
1467
1672
|
if (!response.ok) {
|
|
1468
1673
|
const message = await readErrorMessage(response);
|
|
1469
1674
|
const code = classifyStatus(response.status);
|
|
1470
|
-
throw pluginError(code, redactSecrets
|
|
1675
|
+
throw pluginError(code, redactSecrets("model catalog failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
|
|
1471
1676
|
}
|
|
1472
1677
|
const json = await response.json().catch(() => null);
|
|
1473
1678
|
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:[...]})");
|
|
1474
1679
|
const raws = json.data;
|
|
1475
1680
|
const ids = raws.map((r) => str(r.id, "")).filter((id) => id !== "");
|
|
1476
|
-
const tagArrays = await mapWithConcurrency(ids,
|
|
1681
|
+
const tagArrays = await mapWithConcurrency(ids, TAG_FETCH_CONCURRENCY, async (id) => {
|
|
1682
|
+
try {
|
|
1683
|
+
const cached = await readTags(`model-tags:${id}`, () => fetchTaskTags(id), TAGS_TTL_MS);
|
|
1684
|
+
return Array.isArray(cached) ? cached : [];
|
|
1685
|
+
} catch {
|
|
1686
|
+
return [];
|
|
1687
|
+
}
|
|
1688
|
+
});
|
|
1477
1689
|
const tagsById = /* @__PURE__ */ new Map();
|
|
1478
1690
|
ids.forEach((id, i) => tagsById.set(id, tagArrays[i] ?? []));
|
|
1479
1691
|
const entries = raws.map((raw) => {
|
|
@@ -1513,7 +1725,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1513
1725
|
ids: entries.map((entry) => entry.id),
|
|
1514
1726
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1515
1727
|
};
|
|
1516
|
-
},
|
|
1728
|
+
}, cacheTtl);
|
|
1517
1729
|
},
|
|
1518
1730
|
/**
|
|
1519
1731
|
* 官方「魔粒」余额:GET siteBase/openapi/v1/magicubes/balance(Bearer
|
|
@@ -1536,7 +1748,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1536
1748
|
if (!response.ok) {
|
|
1537
1749
|
const message = await readErrorMessage(response);
|
|
1538
1750
|
const code = classifyStatus(response.status);
|
|
1539
|
-
throw pluginError(code, redactSecrets
|
|
1751
|
+
throw pluginError(code, redactSecrets("magicube balance failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
|
|
1540
1752
|
}
|
|
1541
1753
|
const json = await response.json().catch(() => null);
|
|
1542
1754
|
const data = json !== null && typeof json === "object" ? json.data : null;
|
|
@@ -1578,14 +1790,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1578
1790
|
const elapsedMs = Date.now() - startedAt;
|
|
1579
1791
|
if (response.status === 401 || response.status === 403) {
|
|
1580
1792
|
const message = await readErrorMessage(response);
|
|
1581
|
-
throw pluginError(CODE.AUTH_ERROR, redactSecrets
|
|
1793
|
+
throw pluginError(CODE.AUTH_ERROR, redactSecrets(message === "" ? "token rejected (HTTP 401/403)" : message));
|
|
1582
1794
|
}
|
|
1583
1795
|
if (response.status === 429) {
|
|
1584
1796
|
const message = await readErrorMessage(response);
|
|
1585
1797
|
const retryAfterMs = parseRetryAfterMs(response.headers, message);
|
|
1586
1798
|
const triage = classifyRateLimit(message);
|
|
1587
1799
|
const extra = retryAfterMs !== null && retryAfterMs !== void 0 ? { retryAfterMs } : {};
|
|
1588
|
-
throw pluginError(triage === "quota" ? CODE.QUOTA_EXCEEDED : CODE.RATE_LIMITED, redactSecrets
|
|
1800
|
+
throw pluginError(triage === "quota" ? CODE.QUOTA_EXCEEDED : CODE.RATE_LIMITED, redactSecrets(message === "" ? "modelscope returned 429 (probe on " + modelId + ")" : message), extra);
|
|
1589
1801
|
}
|
|
1590
1802
|
if (response.status >= 500) throw pluginError(CODE.UPSTREAM_ERROR, "modelscope returned HTTP " + String(response.status) + " (validity probe)");
|
|
1591
1803
|
response.body?.cancel();
|
|
@@ -1648,7 +1860,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1648
1860
|
* collapses here, so the three call sites that produce it stay in step instead
|
|
1649
1861
|
* of each spelling out the same three `null`s.
|
|
1650
1862
|
*/
|
|
1651
|
-
const EMPTY_PAYLOAD$
|
|
1863
|
+
const EMPTY_PAYLOAD$2 = {
|
|
1652
1864
|
enabled: null,
|
|
1653
1865
|
enabledIds: null,
|
|
1654
1866
|
contextWindow: null,
|
|
@@ -1795,28 +2007,22 @@ function createFileProviderStore(options = {}) {
|
|
|
1795
2007
|
* read. An unknown NUMERIC version means a NEWER build wrote it; clobbering
|
|
1796
2008
|
* it destroys data we cannot even see. So the write is refused with a
|
|
1797
2009
|
* `degrade` signal, not a silent no-op — swallow the failure, not the reason.
|
|
2010
|
+
* The guard itself lives in `state-store.ts` ({@link guardStateWrite}) — it is
|
|
2011
|
+
* the same promise draw-store makes, and two copies of "what may be
|
|
2012
|
+
* overwritten" is one copy too many.
|
|
1798
2013
|
* @param {object} body - the JSON body to persist.
|
|
1799
2014
|
* @returns {Promise<string|null>} the refusal reason when the write was
|
|
1800
2015
|
* refused (already `degrade`-logged), or `null` when it landed — the caller
|
|
1801
2016
|
* decides whether to surface the refusal to a user.
|
|
1802
2017
|
*/
|
|
1803
2018
|
const writePayload = async (body) => {
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
degrade(reason, null, logger, null);
|
|
1810
|
-
return reason;
|
|
1811
|
-
}
|
|
1812
|
-
if (!isKnownStateVersion(existing, KNOWN_PROVIDER_VERSIONS)) {
|
|
1813
|
-
const reason = `provider: refusing to overwrite provider.json holding version ${existing} (this build knows ${KNOWN_PROVIDER_VERSIONS.join("/")})`;
|
|
1814
|
-
degrade(reason, null, logger, null);
|
|
1815
|
-
return reason;
|
|
1816
|
-
}
|
|
1817
|
-
const temporary = temporaryOf(stateDir$1, "provider.json");
|
|
2019
|
+
const refusal = await guardStateWrite(filePath, KNOWN_PROVIDER_VERSIONS, {
|
|
2020
|
+
label: "provider",
|
|
2021
|
+
logger
|
|
2022
|
+
});
|
|
2023
|
+
if (refusal !== null) return refusal;
|
|
1818
2024
|
await ensureStateDir(stateDir$1);
|
|
1819
|
-
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary });
|
|
2025
|
+
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$1, "provider.json") });
|
|
1820
2026
|
return null;
|
|
1821
2027
|
};
|
|
1822
2028
|
const legacyFile = dir === void 0 && profile ? join(stateDir(name), "provider.json") : null;
|
|
@@ -1859,7 +2065,7 @@ function createFileProviderStore(options = {}) {
|
|
|
1859
2065
|
const parsePayload = async () => parseAt(filePath);
|
|
1860
2066
|
/** Read one payload off an explicit path (the legacy shared file). */
|
|
1861
2067
|
const parsePayloadFrom = async (file) => parseAt(file);
|
|
1862
|
-
|
|
2068
|
+
const writeQueue = createStateWriteQueue();
|
|
1863
2069
|
const cache = createStateReadCache(async () => {
|
|
1864
2070
|
const own = await parsePayload();
|
|
1865
2071
|
if (own.enabled !== null || own.enabledIds !== null || legacyFile === null) return own;
|
|
@@ -1872,7 +2078,7 @@ function createFileProviderStore(options = {}) {
|
|
|
1872
2078
|
...legacy.enabledIds === null ? {} : { enabledIds: legacy.enabledIds },
|
|
1873
2079
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1874
2080
|
};
|
|
1875
|
-
await
|
|
2081
|
+
await writeQueue.enqueue(() => writePayload(inherited).then(() => void 0, () => void 0));
|
|
1876
2082
|
return legacy;
|
|
1877
2083
|
}, { ttlMs });
|
|
1878
2084
|
const read = () => cache.read();
|
|
@@ -1883,7 +2089,7 @@ function createFileProviderStore(options = {}) {
|
|
|
1883
2089
|
* `null` branch collapses here, in one place, rather than at every caller.
|
|
1884
2090
|
* @returns {Promise<ProviderPayload>} the payload; the empty payload when nothing is stored.
|
|
1885
2091
|
*/
|
|
1886
|
-
const readPayload = async () => await read() ?? EMPTY_PAYLOAD$
|
|
2092
|
+
const readPayload = async () => await read() ?? EMPTY_PAYLOAD$2;
|
|
1887
2093
|
/**
|
|
1888
2094
|
* Read one payload, then hand it to {@link writePayload} — the read-modify-
|
|
1889
2095
|
* write that keeps the switch and the list from clobbering each other.
|
|
@@ -1918,7 +2124,7 @@ function createFileProviderStore(options = {}) {
|
|
|
1918
2124
|
* @returns {Promise<string|null>} the write refusal reason, or `null`.
|
|
1919
2125
|
*/
|
|
1920
2126
|
const patchPayload = (patch) => {
|
|
1921
|
-
|
|
2127
|
+
return writeQueue.enqueue(async () => {
|
|
1922
2128
|
const current = await parsePayload();
|
|
1923
2129
|
/** `undefined` keeps the stored key; `null` clears it (key absent). */
|
|
1924
2130
|
const body = { version: 1 };
|
|
@@ -1938,8 +2144,6 @@ function createFileProviderStore(options = {}) {
|
|
|
1938
2144
|
});
|
|
1939
2145
|
return refusal;
|
|
1940
2146
|
});
|
|
1941
|
-
writeChain = run.catch(() => null);
|
|
1942
|
-
return run;
|
|
1943
2147
|
};
|
|
1944
2148
|
return {
|
|
1945
2149
|
/**
|
|
@@ -1994,7 +2198,13 @@ function createFileProviderStore(options = {}) {
|
|
|
1994
2198
|
async versionNote() {
|
|
1995
2199
|
const version = (await readPayload()).version;
|
|
1996
2200
|
if (version === null || isKnownProviderVersion(version)) return null;
|
|
1997
|
-
return
|
|
2201
|
+
return stateVersionNote({
|
|
2202
|
+
label: "provider-state",
|
|
2203
|
+
file: filePath,
|
|
2204
|
+
version,
|
|
2205
|
+
known: KNOWN_PROVIDER_VERSIONS,
|
|
2206
|
+
consequence: "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."
|
|
2207
|
+
});
|
|
1998
2208
|
},
|
|
1999
2209
|
/**
|
|
2000
2210
|
* Persist a switch value. The write is atomic (temp file + rename) so a
|
|
@@ -2069,8 +2279,27 @@ function createFileProviderStore(options = {}) {
|
|
|
2069
2279
|
* 优先级与所有 opt-in 开关同一份裁决(`switch-precedence.ts`):面板保存值赢,
|
|
2070
2280
|
* 否则 `cordis.patch.yml` 的 `drawEnabled` 默认值说了算。
|
|
2071
2281
|
*
|
|
2072
|
-
*
|
|
2073
|
-
*
|
|
2282
|
+
* 完整性三件套(与 provider / usage 同源,全部上收在 `state-store.ts`,本文件只做
|
|
2283
|
+
* 调用方——2026-10-17 收敛:此前这里**三件全缺**,并发 `saveEnabled` +
|
|
2284
|
+
* `saveModelId` 会丢一次保存、磁盘 version 比本构建新时降级覆写、读侧静默读作
|
|
2285
|
+
* 「没设过」,正是 provider-store 注释里记着「咬过三次」的那类漂移的第四条):
|
|
2286
|
+
* - **写串行化**({@link createStateWriteQueue}):读-改-写整体排队,临界区内
|
|
2287
|
+
* 从磁盘重读基线(不用 TTL 缓存—— provider §8d 的回归用例),链吞自己的
|
|
2288
|
+
* 失败继续走;
|
|
2289
|
+
* - **写守卫**({@link guardStateWrite},ADR-006):盘上版本本构建读不懂时
|
|
2290
|
+
* 拒写并给出理由,绝不降级覆写;「文件在场却读不出来」同样拒写(读不到 ≠
|
|
2291
|
+
* 没有东西要保护);
|
|
2292
|
+
* - **读侧说明**({@link stateVersionNote}):未知版本宽容读作「没设过」,但
|
|
2293
|
+
* {@link createFileDrawStore} 的 `versionNote()` 把「保存的开关与模型偏好
|
|
2294
|
+
* 被忽略」说出口,快照聚合把它转进 `shapeWarnings`——不说,面板就把「保存的
|
|
2295
|
+
* 值被忽略」呈现成「从未保存过」。
|
|
2296
|
+
*
|
|
2297
|
+
* 与 provider-store 的一处**有意差异**:没有 legacy(pre-profile 共享目录)继承。
|
|
2298
|
+
* draw.json 随出图工具一起诞生,晚于按 profile 分段,市面上不存在共享布局里的
|
|
2299
|
+
* 旧 draw.json;provider.json 则需要(§23 之前它一直在共享目录)。
|
|
2300
|
+
*
|
|
2301
|
+
* doctor 覆盖:`describeDrawPayload` 给只读速览,`doctor.ts` 据此把 draw.json
|
|
2302
|
+
* 的「损坏 / 未知版本」报进症状表——写侧守卫挡覆盖,doctor 报静默降级。
|
|
2074
2303
|
*
|
|
2075
2304
|
* @module dsh-connect-modelscope-token-plan/draw-store
|
|
2076
2305
|
*/
|
|
@@ -2078,14 +2307,24 @@ function createFileProviderStore(options = {}) {
|
|
|
2078
2307
|
const DRAW_STORE_VERSION = 1;
|
|
2079
2308
|
/** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
|
|
2080
2309
|
const KNOWN_DRAW_VERSIONS = Object.freeze([1]);
|
|
2081
|
-
const EMPTY_PAYLOAD = {
|
|
2310
|
+
const EMPTY_PAYLOAD$1 = {
|
|
2082
2311
|
enabled: null,
|
|
2083
2312
|
modelId: null,
|
|
2084
2313
|
version: null
|
|
2085
2314
|
};
|
|
2086
|
-
/**
|
|
2315
|
+
/**
|
|
2316
|
+
* 本 build 读不读得懂这个版本。
|
|
2317
|
+
*
|
|
2318
|
+
* 与 provider-store 的 {@link isKnownProviderVersion} 同形:**白名单**判据,
|
|
2319
|
+
* 不与 {@link DRAW_STORE_VERSION} 比较——升级后旧号仍要可读。读侧(parseAt)、
|
|
2320
|
+
* 报告侧(versionNote / describeDrawPayload)问的都是这一个函数,三者不可能就
|
|
2321
|
+
* 「什么是未知版本」说两套话。写侧问的是 `state-store` 的
|
|
2322
|
+
* `isKnownStateVersion`(它额外放行 `null` = 没有文件,那是唯一该写的情形)。
|
|
2323
|
+
* @param {unknown} version - 盘上读到的值(可能根本不是数字)。
|
|
2324
|
+
* @returns {boolean}
|
|
2325
|
+
*/
|
|
2087
2326
|
function isKnownDrawVersion(version) {
|
|
2088
|
-
return
|
|
2327
|
+
return typeof version === "number" && KNOWN_DRAW_VERSIONS.includes(version);
|
|
2089
2328
|
}
|
|
2090
2329
|
/**
|
|
2091
2330
|
* 归一一个保存的模型偏好:非空字符串,或 `null`(= 自动挑——具体挑谁由
|
|
@@ -2123,7 +2362,31 @@ function createFileDrawStore(options = {}) {
|
|
|
2123
2362
|
const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
|
|
2124
2363
|
const stateDir = drawStoreDir(profile, dir);
|
|
2125
2364
|
const filePath = join(stateDir, "draw.json");
|
|
2126
|
-
/**
|
|
2365
|
+
/**
|
|
2366
|
+
* 写一份补丁并落盘(内部用;对调用方暴露的是 saveEnabled / saveModelId / reset)。
|
|
2367
|
+
*
|
|
2368
|
+
* ADR-006 写守卫({@link guardStateWrite})先问盘:版本不认或文件读不出 →
|
|
2369
|
+
* 返回拒绝理由(已 degrade 记录),不落笔。守卫与随后的写入在同一个临界区里
|
|
2370
|
+
* (由 patchPayload 的链保证),判据与执行之间不隔任何 await。
|
|
2371
|
+
* @param {object} body - 要持久化的 JSON 体。
|
|
2372
|
+
* @returns {Promise<string|null>} 拒绝原因,或 `null` = 已写入。
|
|
2373
|
+
*/
|
|
2374
|
+
const writePayload = async (body) => {
|
|
2375
|
+
const refusal = await guardStateWrite(filePath, KNOWN_DRAW_VERSIONS, {
|
|
2376
|
+
label: "draw-state",
|
|
2377
|
+
logger
|
|
2378
|
+
});
|
|
2379
|
+
if (refusal !== null) return refusal;
|
|
2380
|
+
await ensureStateDir(stateDir);
|
|
2381
|
+
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "draw.json") });
|
|
2382
|
+
return null;
|
|
2383
|
+
};
|
|
2384
|
+
/** 写串行化:读-改-写必须排队(详见{@link createStateWriteQueue}与文件头)。 */
|
|
2385
|
+
const writeQueue = createStateWriteQueue();
|
|
2386
|
+
/**
|
|
2387
|
+
* 解析一份 payload;认不出的版本读作「没设过」但**记住**版本号——读侧宽容、
|
|
2388
|
+
* versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是 ADR-006)。
|
|
2389
|
+
*/
|
|
2127
2390
|
const parsePayload = async () => {
|
|
2128
2391
|
const source = obj(await readStateJson(filePath));
|
|
2129
2392
|
const version = typeof source.version === "number" ? source.version : null;
|
|
@@ -2143,60 +2406,84 @@ function createFileDrawStore(options = {}) {
|
|
|
2143
2406
|
* 读当前 payload。
|
|
2144
2407
|
* @returns {Promise<DrawPayload>}
|
|
2145
2408
|
*/
|
|
2146
|
-
const
|
|
2409
|
+
const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$1;
|
|
2147
2410
|
/**
|
|
2148
|
-
*
|
|
2149
|
-
*
|
|
2150
|
-
*
|
|
2411
|
+
* 读-改-写一份补丁,**整段排队**。
|
|
2412
|
+
*
|
|
2413
|
+
* 基线每次从磁盘重读(`parsePayload` 直读,不走 TTL 缓存):两个 Host 进程
|
|
2414
|
+
* 共享一个状态目录,而缓存里可能是写入前的值——provider §8d 的回归用例
|
|
2415
|
+
* (两个写入者共享一个目录,回读把较新的缓存顶成旧值)在 draw 上同样成立。
|
|
2416
|
+
* 写成功即 `cache.remember`,调用方不必各自回读一遍。
|
|
2417
|
+
*
|
|
2418
|
+
* 失败语义与 provider-store 对齐:拒绝原因与 IO 失败都**向上抛**(save 抛、
|
|
2419
|
+
* 路由 catch 后 `respondError` 呈报),链自己吞掉失败继续走后面的保存。
|
|
2420
|
+
*
|
|
2421
|
+
* @param {{enabled?: boolean|null, modelId?: string|null}} patch - 要改的字段;
|
|
2422
|
+
* `undefined` = 保留盘上的值,`null` = 清除(回到「没设过」)。
|
|
2423
|
+
* @returns {Promise<void>}
|
|
2151
2424
|
*/
|
|
2152
|
-
const patchPayload = async (
|
|
2153
|
-
const
|
|
2154
|
-
const enabled = patch.enabled === void 0 ?
|
|
2155
|
-
const modelId = patch.modelId === void 0 ?
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
return null;
|
|
2169
|
-
} catch (error) {
|
|
2170
|
-
return degrade("draw store write failed", error, logger, "draw store write failed");
|
|
2171
|
-
}
|
|
2172
|
-
};
|
|
2425
|
+
const patchPayload = (patch) => writeQueue.enqueue(async () => {
|
|
2426
|
+
const current = await parsePayload();
|
|
2427
|
+
const enabled = patch.enabled === void 0 ? current.enabled : patch.enabled;
|
|
2428
|
+
const modelId = patch.modelId === void 0 ? current.modelId : patch.modelId;
|
|
2429
|
+
const body = { version: 1 };
|
|
2430
|
+
if (enabled !== null) body.enabled = enabled;
|
|
2431
|
+
if (modelId !== null) body.modelId = modelId;
|
|
2432
|
+
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2433
|
+
const refusal = await writePayload(body);
|
|
2434
|
+
if (refusal !== null) throw new Error(refusal);
|
|
2435
|
+
cache.remember({
|
|
2436
|
+
enabled,
|
|
2437
|
+
modelId,
|
|
2438
|
+
version: 1
|
|
2439
|
+
});
|
|
2440
|
+
});
|
|
2173
2441
|
return {
|
|
2174
2442
|
/** 面板保存的出图开关;`null` = 回退 patch 默认值。 */
|
|
2175
2443
|
async enabled() {
|
|
2176
|
-
return (await
|
|
2444
|
+
return (await readPayload()).enabled;
|
|
2177
2445
|
},
|
|
2178
2446
|
/** 面板保存的模型偏好;`null` = 自动。 */
|
|
2179
2447
|
async modelId() {
|
|
2180
|
-
return (await
|
|
2448
|
+
return (await readPayload()).modelId;
|
|
2181
2449
|
},
|
|
2182
2450
|
/** 这个 profile 是否被显式配置过。 */
|
|
2183
2451
|
async isConfigured() {
|
|
2184
|
-
const payload = await
|
|
2452
|
+
const payload = await readPayload();
|
|
2185
2453
|
return payload.enabled !== null || payload.modelId !== null;
|
|
2186
2454
|
},
|
|
2455
|
+
/**
|
|
2456
|
+
* 盘上带着本构建读不懂的版本时的一句说明,否则 `null`。
|
|
2457
|
+
*
|
|
2458
|
+
* 读侧宽容(未知版本读作「没设过」)的另一半:快照聚合把它转进
|
|
2459
|
+
* `shapeWarnings`,doctor 报同一份文件。没有它,「保存的开关与模型偏好
|
|
2460
|
+
* 被忽略」在面板上就是「从未保存过」——而回退方向是 patch 默认(通常=关)。
|
|
2461
|
+
* @returns {Promise<string|null>}
|
|
2462
|
+
*/
|
|
2463
|
+
async versionNote() {
|
|
2464
|
+
const version = (await readPayload()).version;
|
|
2465
|
+
if (version === null || isKnownDrawVersion(version)) return null;
|
|
2466
|
+
return stateVersionNote({
|
|
2467
|
+
label: "draw-state",
|
|
2468
|
+
file: filePath,
|
|
2469
|
+
version,
|
|
2470
|
+
known: KNOWN_DRAW_VERSIONS,
|
|
2471
|
+
consequence: "the saved draw switch and model preference are being answered as \"not set\", so the switch falls back to the config default and the model back to automatic selection."
|
|
2472
|
+
});
|
|
2473
|
+
},
|
|
2187
2474
|
/** 保存开关。 */
|
|
2188
2475
|
async saveEnabled(enabled) {
|
|
2189
2476
|
if (typeof enabled !== "boolean") throw new TypeError("draw switch expects a boolean");
|
|
2190
|
-
|
|
2477
|
+
await patchPayload({ enabled });
|
|
2191
2478
|
},
|
|
2192
2479
|
/** 保存模型偏好(`null` = 清掉,回到自动)。 */
|
|
2193
2480
|
async saveModelId(modelId) {
|
|
2194
2481
|
if (modelId !== null && (typeof modelId !== "string" || modelId.trim() === "")) throw new TypeError("draw model expects a non-empty string or null");
|
|
2195
|
-
|
|
2482
|
+
await patchPayload({ modelId });
|
|
2196
2483
|
},
|
|
2197
2484
|
/** 清掉两个字段(回到「没配过」)。 */
|
|
2198
2485
|
async reset() {
|
|
2199
|
-
|
|
2486
|
+
await patchPayload({
|
|
2200
2487
|
enabled: null,
|
|
2201
2488
|
modelId: null
|
|
2202
2489
|
});
|
|
@@ -2207,92 +2494,368 @@ function createFileDrawStore(options = {}) {
|
|
|
2207
2494
|
}
|
|
2208
2495
|
|
|
2209
2496
|
//#endregion
|
|
2210
|
-
//#region src/host/
|
|
2497
|
+
//#region src/host/switch-precedence.ts
|
|
2211
2498
|
/**
|
|
2212
|
-
*
|
|
2213
|
-
*
|
|
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` 的真机实测,非文档转述。
|
|
2219
|
-
*
|
|
2220
|
-
* 本模块**只做纯函数**:endpoint 构造、请求体构造、响应解析、状态判定、失败分诊。
|
|
2221
|
-
* 它**不发起任何请求、不碰凭据、不注册工具**——那半边(fetch 注入、Host 工具注册)
|
|
2222
|
-
* 与 `llm-adapter.ts` 一样 peer-dependent,测试覆盖同样是零(docs/ROADMAP.md Backlog)。
|
|
2223
|
-
* 刻意 peer-free:纯函数部分能进离线 `npm test` 门禁,`test/draw.test.mjs` 钉住形状。
|
|
2499
|
+
* 所有 opt-in 开关共用的唯一裁决:「面板保存值 vs 配置默认值」。
|
|
2224
2500
|
*
|
|
2225
|
-
*
|
|
2501
|
+
* 规则只有一条:**面板保存值赢,否则配置默认值说了算**。来源标签(`panel` /
|
|
2502
|
+
* `config`)随答案一起返回,面板才能说出是谁在管。规则收在一处,是为了让开关
|
|
2503
|
+
* 再也无法各自发明一套优先级方言;`test/switch-precedence.test.mjs` 钉死这个
|
|
2504
|
+
* 方言,照抄形状的新调用方会被它标成异类。
|
|
2226
2505
|
*
|
|
2227
|
-
*
|
|
2228
|
-
*
|
|
2229
|
-
* 缺这个头的实现会在第一次轮询就误判失败并放弃一张已经能出的图。
|
|
2230
|
-
* 2. **提交响应的 `task_status` 不可信**:实测每次提交都回 `SUCCEED`,包括最终确实
|
|
2231
|
-
* `FAILED` 的那次。所以提交只认 `task_id`,**成功与否只看轮询终态**。
|
|
2232
|
-
* 3. **`FAILED` 没有可分诊的形状**:`errors.code` 实测为 `0`、`errors.message` 为
|
|
2233
|
-
* 空串,没有顶层 `message`。任何「从错误里读出原因」的承诺都是编造——本模块的
|
|
2234
|
-
* `describeDrawFailure` 因此**明确承认原因不可知**,只报可确定的形态差异。
|
|
2506
|
+
* 不服务「纯面板拥有、无配置默认值」的开关——那种没有可裁决的配置默认值,
|
|
2507
|
+
* 调用方直接读即可。peer-free,纯函数。
|
|
2235
2508
|
*
|
|
2236
|
-
* @module dsh-connect-modelscope-token-plan/
|
|
2509
|
+
* @module dsh-connect-modelscope-token-plan/switch-precedence
|
|
2237
2510
|
*/
|
|
2238
2511
|
/**
|
|
2239
|
-
*
|
|
2240
|
-
*
|
|
2241
|
-
*
|
|
2242
|
-
*
|
|
2243
|
-
*
|
|
2512
|
+
* Resolve the effective boolean switch: a panel-saved value always wins,
|
|
2513
|
+
* otherwise the config default rules.
|
|
2514
|
+
* @param {boolean|null} panel - the panel-saved value (`null` = never saved).
|
|
2515
|
+
* @param {boolean} config - the patch-declared default.
|
|
2516
|
+
* @returns {boolean} the effective switch.
|
|
2244
2517
|
*/
|
|
2245
|
-
|
|
2518
|
+
function resolveSwitchEnabled(panel, config) {
|
|
2519
|
+
return (panel ?? config) === true;
|
|
2520
|
+
}
|
|
2246
2521
|
/**
|
|
2247
|
-
*
|
|
2248
|
-
*
|
|
2249
|
-
*
|
|
2250
|
-
*
|
|
2251
|
-
*
|
|
2252
|
-
* ⚠️ 早先曾写「更密的轮询拖慢任务」,**该归因已推翻**——后续对照显示同一模型、
|
|
2253
|
-
* 同样 15s 轮询的耗时是 61s → 261s → 537s 单调恶化,是**上游排队时长在波动**。
|
|
2254
|
-
* 那次构成「证据」的 5s vs 30s 对照,控制变量没做,不足以支撑因果。
|
|
2255
|
-
* 详见 docs/SPIKE-image-gen.md §坑 4(含撤回记录)。
|
|
2522
|
+
* Resolve the effective string preference (e.g. a draw-model id): a
|
|
2523
|
+
* panel-saved value wins, otherwise the config default rules.
|
|
2524
|
+
* @param {string|null} panel - the panel-saved value (`null` = never saved).
|
|
2525
|
+
* @param {string|undefined} config - the patch-declared default.
|
|
2526
|
+
* @returns {string|undefined} the effective preference.
|
|
2256
2527
|
*/
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2528
|
+
function resolveSwitchValue(panel, config) {
|
|
2529
|
+
return panel ?? config;
|
|
2530
|
+
}
|
|
2260
2531
|
/**
|
|
2261
|
-
*
|
|
2532
|
+
* Resolve the effective numeric preference (e.g. how many days the trend chart
|
|
2533
|
+
* shows): the same `panel ?? config` dialect as the other two shapes.
|
|
2262
2534
|
*
|
|
2263
|
-
*
|
|
2264
|
-
*
|
|
2535
|
+
* Range clamping is deliberately NOT done here — the bound (a retention
|
|
2536
|
+
* window) belongs to the caller that knows it (`host-config` / the snapshot
|
|
2537
|
+
* aggregate), not to the precedence rule.
|
|
2538
|
+
* @param {number|null} panel - the panel-saved value (`null` = never saved).
|
|
2539
|
+
* @param {number} config - the patch-declared default.
|
|
2540
|
+
* @returns {number} the effective preference.
|
|
2265
2541
|
*/
|
|
2266
|
-
|
|
2267
|
-
|
|
2542
|
+
function resolveSwitchNumber(panel, config) {
|
|
2543
|
+
return panel ?? config;
|
|
2544
|
+
}
|
|
2268
2545
|
/**
|
|
2269
|
-
*
|
|
2270
|
-
*
|
|
2546
|
+
* Where the effective value came from — the panel when it saved one, the
|
|
2547
|
+
* config otherwise. Rides with every resolved answer so the panel can name
|
|
2548
|
+
* the side in charge.
|
|
2549
|
+
* @param {boolean|string|number|null} panel - the panel-saved value.
|
|
2550
|
+
* @returns {"panel"|"config"}
|
|
2271
2551
|
*/
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2552
|
+
function switchSource(panel) {
|
|
2553
|
+
return panel === null ? "config" : "panel";
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
//#endregion
|
|
2557
|
+
//#region src/host/usage-prefs-store.ts
|
|
2276
2558
|
/**
|
|
2277
|
-
*
|
|
2559
|
+
* 用量面板的**显示偏好**——目前只有一个字段:趋势图的显示窗口(画多少天)。
|
|
2278
2560
|
*
|
|
2279
|
-
*
|
|
2280
|
-
*
|
|
2281
|
-
*
|
|
2282
|
-
*
|
|
2561
|
+
* ## 为什么单独一个文件
|
|
2562
|
+
*
|
|
2563
|
+
* - `usage.json` 是**数据**(天桶 / 单模型 / 事件),写路径是观测热路径。把一个
|
|
2564
|
+
* 显示偏好混进去,会让「偏好读不到」与「计数文件损坏」共用一条降级语义;更要命
|
|
2565
|
+
* 的是版本漂移——旧构建重写那份 payload 时会**静默丢掉**它不认识的键。
|
|
2566
|
+
* - `provider.json` 是 provider 注册的 desired state(开关 / 允许清单 / 窗口覆盖),
|
|
2567
|
+
* 与「趋势图画多少天」无关;合成一份 payload 会让两边的写入时序互相牵连。
|
|
2568
|
+
*
|
|
2569
|
+
* 落点与 provider / draw 同一套分段规则:`$DSH_HOME/state/<profile>/<plugin>/usage-prefs.json`
|
|
2570
|
+
* (无 profile 时落共享目录)。分段的意思是「这个 profile 想看多长」,两个 profile
|
|
2571
|
+
* 不互相覆盖。
|
|
2572
|
+
*
|
|
2573
|
+
* 优先级与所有 opt-in 开关同一份裁决(`switch-precedence.ts`):面板保存值赢,
|
|
2574
|
+
* 否则 `cordis.patch.yml` 的 `trendDays` 说了算;两者都会在 host-config 里被夹进
|
|
2575
|
+
* 保留窗口之内(`retentionDays`)——**画得比留得多**会把已剪的日子画成 0。
|
|
2576
|
+
*
|
|
2577
|
+
* 完整性三件套与 provider / usage / draw **同源**(全部上收在 `state-store.ts`,
|
|
2578
|
+
* 本文件只做调用方,论述见 `draw-store.ts` 头注,不在此重复):写串行化
|
|
2579
|
+
* ({@link createStateWriteQueue})、写守卫({@link guardStateWrite},ADR-006)、
|
|
2580
|
+
* 读侧说明({@link stateVersionNote})。
|
|
2581
|
+
*
|
|
2582
|
+
* doctor 覆盖:{@link describeUsagePrefsPayload} 给只读速览,`doctor.ts` 据此把
|
|
2583
|
+
* usage-prefs.json 的「损坏 / 未知版本」报进症状表。
|
|
2584
|
+
*
|
|
2585
|
+
* @module dsh-connect-modelscope-token-plan/usage-prefs-store
|
|
2283
2586
|
*/
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
}
|
|
2587
|
+
/** 本 store 落盘 payload 的版本。 */
|
|
2588
|
+
const USAGE_PREFS_VERSION = 1;
|
|
2589
|
+
/** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
|
|
2590
|
+
const KNOWN_USAGE_PREFS_VERSIONS = Object.freeze([1]);
|
|
2591
|
+
const EMPTY_PAYLOAD = {
|
|
2592
|
+
windowDays: null,
|
|
2593
|
+
version: null
|
|
2594
|
+
};
|
|
2292
2595
|
/**
|
|
2293
|
-
*
|
|
2596
|
+
* 本 build 读不读得懂这个版本。
|
|
2294
2597
|
*
|
|
2295
|
-
*
|
|
2598
|
+
* 与 provider / draw 的 `isKnown*Version` 同形:**白名单**判据,不与
|
|
2599
|
+
* {@link USAGE_PREFS_VERSION} 比较——升级后旧号仍要可读。读侧、报告侧
|
|
2600
|
+
* (versionNote / describeUsagePrefsPayload)问的都是这一个函数,三者不可能就
|
|
2601
|
+
* 「什么是未知版本」说两套话。写侧问的是 `state-store` 的 `isKnownStateVersion`
|
|
2602
|
+
* (它额外放行 `null` = 没有文件,那是唯一该写的情形)。
|
|
2603
|
+
* @param {unknown} version - 盘上读到的值(可能根本不是数字)。
|
|
2604
|
+
* @returns {boolean}
|
|
2605
|
+
*/
|
|
2606
|
+
function isKnownUsagePrefsVersion(version) {
|
|
2607
|
+
return typeof version === "number" && KNOWN_USAGE_PREFS_VERSIONS.includes(version);
|
|
2608
|
+
}
|
|
2609
|
+
/**
|
|
2610
|
+
* 归一一个保存的显示窗口:正整数(向下取整),或 `null`(= 跟随配置默认)。
|
|
2611
|
+
*
|
|
2612
|
+
* **不在这里夹上界**:上界是保留窗口(`retentionDays`),那是 host-config 的事,
|
|
2613
|
+
* 此模块不该知道它。传进来的越界值由调用方(host-config / 快照聚合 / 路由)夹。
|
|
2614
|
+
* @param {unknown} value - 原始值。
|
|
2615
|
+
* @returns {number|null} 归一后的天数或 `null`。
|
|
2616
|
+
*/
|
|
2617
|
+
function normalizeWindowDays(value) {
|
|
2618
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
2619
|
+
const days = Math.floor(value);
|
|
2620
|
+
return days > 0 ? days : null;
|
|
2621
|
+
}
|
|
2622
|
+
/** 本 store 的目录(与 provider / draw 同一套分段规则)。 */
|
|
2623
|
+
function usagePrefsDir(profile, dir) {
|
|
2624
|
+
if (typeof dir === "string" && dir !== "") return dir;
|
|
2625
|
+
return profile ? profileStateDir(name, profile) : stateDir(name);
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* 显示窗口的**唯一**裁决 + 夹取:面板保存值优先(沿用全仓唯一方言
|
|
2629
|
+
* {@link resolveSwitchNumber}),否则配置默认;随后落进 `[1, maxDays]`。
|
|
2630
|
+
*
|
|
2631
|
+
* 夹取放在这里而不是 store 里,是因为 store 不该知道上界;放在**本模块**而不是
|
|
2632
|
+
* 两个调用方各写一遍,是因为「面板值 vs 默认」和「夹到保留窗口」加起来只有一条
|
|
2633
|
+
* 正确算法——路由保存时与快照聚合渲染时各写一份,就会出现「面板显示 90 天、
|
|
2634
|
+
* 图里只画了 14 天」这类两处不一致。
|
|
2635
|
+
* @param {number|null} panel - 面板保存值(`null` = 未保存)。
|
|
2636
|
+
* @param {number} fallback - 配置默认(已由 host-config 夹过)。
|
|
2637
|
+
* @param {number} maxDays - 上界(保留窗口,`retentionDays`)。
|
|
2638
|
+
* @returns {number} 生效天数。
|
|
2639
|
+
*/
|
|
2640
|
+
function resolveWindowDays(panel, fallback, maxDays) {
|
|
2641
|
+
const want = resolveSwitchNumber(panel, fallback);
|
|
2642
|
+
return Math.min(Math.max(1, Math.floor(want)), Math.max(1, Math.floor(maxDays)));
|
|
2643
|
+
}
|
|
2644
|
+
/**
|
|
2645
|
+
* 文件支撑的用量显示偏好。
|
|
2646
|
+
* @param {StoreOptions} [options] - `{ dir, profile, ttlMs, logger }`。
|
|
2647
|
+
* @returns {object} store 句柄。
|
|
2648
|
+
*/
|
|
2649
|
+
function createFileUsagePrefsStore(options = {}) {
|
|
2650
|
+
const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
|
|
2651
|
+
const stateDir = usagePrefsDir(profile, dir);
|
|
2652
|
+
const filePath = join(stateDir, "usage-prefs.json");
|
|
2653
|
+
/**
|
|
2654
|
+
* 写一份 payload 并落盘。
|
|
2655
|
+
*
|
|
2656
|
+
* ADR-006 写守卫({@link guardStateWrite})先问盘:版本不认或文件读不出 →
|
|
2657
|
+
* 返回拒绝理由(已 degrade 记录),不落笔。守卫与随后的写入在同一个临界区里
|
|
2658
|
+
* (由 patchPayload 的链保证),判据与执行之间不隔任何 await。
|
|
2659
|
+
* @param {object} body - 要持久化的 JSON 体。
|
|
2660
|
+
* @returns {Promise<string|null>} 拒绝原因,或 `null` = 已写入。
|
|
2661
|
+
*/
|
|
2662
|
+
const writePayload = async (body) => {
|
|
2663
|
+
const refusal = await guardStateWrite(filePath, KNOWN_USAGE_PREFS_VERSIONS, {
|
|
2664
|
+
label: "usage-prefs-state",
|
|
2665
|
+
logger
|
|
2666
|
+
});
|
|
2667
|
+
if (refusal !== null) return refusal;
|
|
2668
|
+
await ensureStateDir(stateDir);
|
|
2669
|
+
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir, "usage-prefs.json") });
|
|
2670
|
+
return null;
|
|
2671
|
+
};
|
|
2672
|
+
/** 写串行化:读-改-写必须排队(详见{@link createStateWriteQueue})。 */
|
|
2673
|
+
const writeQueue = createStateWriteQueue();
|
|
2674
|
+
/**
|
|
2675
|
+
* 解析一份 payload;认不出的版本读作「没设过」但**记住**版本号——读侧宽容、
|
|
2676
|
+
* versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是 ADR-006)。
|
|
2677
|
+
*/
|
|
2678
|
+
const parsePayload = async () => {
|
|
2679
|
+
const source = obj(await readStateJson(filePath));
|
|
2680
|
+
const version = typeof source.version === "number" ? source.version : null;
|
|
2681
|
+
if (!isKnownUsagePrefsVersion(version)) return {
|
|
2682
|
+
windowDays: null,
|
|
2683
|
+
version
|
|
2684
|
+
};
|
|
2685
|
+
return {
|
|
2686
|
+
windowDays: normalizeWindowDays(source.windowDays),
|
|
2687
|
+
version
|
|
2688
|
+
};
|
|
2689
|
+
};
|
|
2690
|
+
const cache = createStateReadCache(parsePayload, { ttlMs });
|
|
2691
|
+
/**
|
|
2692
|
+
* 读当前 payload。
|
|
2693
|
+
* @returns {Promise<UsagePrefsPayload>}
|
|
2694
|
+
*/
|
|
2695
|
+
const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
|
|
2696
|
+
/**
|
|
2697
|
+
* 读-改-写,**整段排队**。
|
|
2698
|
+
*
|
|
2699
|
+
* 基线每次从磁盘重读(`parsePayload` 直读,不走 TTL 缓存):两个 Host 进程共享
|
|
2700
|
+
* 一个状态目录,而缓存里可能是写入前的值(provider §8d 的回归用例在 draw 上
|
|
2701
|
+
* 同样成立,见 draw-store.ts)。写成功即 `cache.remember`。
|
|
2702
|
+
*
|
|
2703
|
+
* 失败语义与 provider / draw 对齐:拒绝原因与 IO 失败都**向上抛**(save 抛、
|
|
2704
|
+
* 路由 catch 后 `respondError` 呈报),链自己吞掉失败继续走后面的保存。
|
|
2705
|
+
*
|
|
2706
|
+
* @param {number|null} windowDays - 新的显示窗口;`null` = 清除(回到配置默认)。
|
|
2707
|
+
* @returns {Promise<void>}
|
|
2708
|
+
*/
|
|
2709
|
+
const save = (windowDays) => writeQueue.enqueue(async () => {
|
|
2710
|
+
const body = { version: 1 };
|
|
2711
|
+
if (windowDays !== null) body.windowDays = windowDays;
|
|
2712
|
+
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2713
|
+
const refusal = await writePayload(body);
|
|
2714
|
+
if (refusal !== null) throw new Error(refusal);
|
|
2715
|
+
cache.remember({
|
|
2716
|
+
windowDays,
|
|
2717
|
+
version: 1
|
|
2718
|
+
});
|
|
2719
|
+
});
|
|
2720
|
+
return {
|
|
2721
|
+
/** 面板保存的显示窗口天数;`null` = 跟随配置默认。 */
|
|
2722
|
+
async windowDays() {
|
|
2723
|
+
return (await readPayload()).windowDays;
|
|
2724
|
+
},
|
|
2725
|
+
/**
|
|
2726
|
+
* 盘上带着本构建读不懂的版本时的一句说明,否则 `null`。
|
|
2727
|
+
*
|
|
2728
|
+
* 读侧宽容(未知版本读作「没设过」)的另一半:快照聚合把它转进
|
|
2729
|
+
* `shapeWarnings`,doctor 报同一份文件。没有它,「保存的显示窗口被忽略」在
|
|
2730
|
+
* 面板上就是「从未保存过」——而回退方向是配置默认。
|
|
2731
|
+
* @returns {Promise<string|null>}
|
|
2732
|
+
*/
|
|
2733
|
+
async versionNote() {
|
|
2734
|
+
const version = (await readPayload()).version;
|
|
2735
|
+
if (version === null || isKnownUsagePrefsVersion(version)) return null;
|
|
2736
|
+
return stateVersionNote({
|
|
2737
|
+
label: "usage-prefs-state",
|
|
2738
|
+
file: filePath,
|
|
2739
|
+
version,
|
|
2740
|
+
known: KNOWN_USAGE_PREFS_VERSIONS,
|
|
2741
|
+
consequence: "the saved trend window is being answered as \"not set\", so the chart falls back to the config default."
|
|
2742
|
+
});
|
|
2743
|
+
},
|
|
2744
|
+
/** 保存显示窗口(`null` = 清掉,回到配置默认)。 */
|
|
2745
|
+
async saveWindowDays(windowDays) {
|
|
2746
|
+
if (windowDays !== null && !(typeof windowDays === "number" && Number.isFinite(windowDays) && Math.floor(windowDays) > 0)) throw new TypeError("trend window expects a positive integer or null");
|
|
2747
|
+
await save(windowDays === null ? null : Math.floor(windowDays));
|
|
2748
|
+
},
|
|
2749
|
+
/** 落盘路径(诊断与测试用)。 */
|
|
2750
|
+
filePath
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
//#endregion
|
|
2755
|
+
//#region src/host/draw.ts
|
|
2756
|
+
/**
|
|
2757
|
+
* 魔搭出图(`/v1/images/generations`)的**纯逻辑半边**——协议形状的解析与判定。
|
|
2758
|
+
*
|
|
2759
|
+
* 为什么是这个形状:魔搭的生图是**异步两段式**(提交得 `task_id`,轮询才出图),
|
|
2760
|
+
* 与姊妹插件 `dsh-connect-sensenova-token-plan/src/host/draw.ts` 的**同步单发**
|
|
2761
|
+
* (`drawOnce` 一次 fetch 即 `parseDrawResponse` 取 `data[0].url`)形状不兼容,
|
|
2762
|
+
* **不能照抄**。可复用的只有外壳(endpoint 拼接、冷却门、失败文案),核心必须新写。
|
|
2763
|
+
* 协议依据全部来自 `docs/SPIKE-image-gen.md` 的真机实测,非文档转述。
|
|
2764
|
+
*
|
|
2765
|
+
* 本模块**只做纯函数**:endpoint 构造、请求体构造、响应解析、状态判定、失败分诊。
|
|
2766
|
+
* 它**不发起任何请求、不碰凭据、不注册工具**——那半边(fetch 注入、Host 工具注册)
|
|
2767
|
+
* 与 `llm-adapter.ts` 一样 peer-dependent,测试覆盖同样是零(docs/ROADMAP.md Backlog)。
|
|
2768
|
+
* 刻意 peer-free:纯函数部分能进离线 `npm test` 门禁,`test/draw.test.mjs` 钉住形状。
|
|
2769
|
+
*
|
|
2770
|
+
* 三条承重结论(全部实测,推翻了几条广泛流传的错误写法):
|
|
2771
|
+
*
|
|
2772
|
+
* 1. **轮询必须带 `X-ModelScope-Task-Type: image_generation`**,否则上游一律返回
|
|
2773
|
+
* `task not found` + `task_status: FAILED`——**那是假失败**,任务实际正常。
|
|
2774
|
+
* 缺这个头的实现会在第一次轮询就误判失败并放弃一张已经能出的图。
|
|
2775
|
+
* 2. **提交响应的 `task_status` 不可信**:实测每次提交都回 `SUCCEED`,包括最终确实
|
|
2776
|
+
* `FAILED` 的那次。所以提交只认 `task_id`,**成功与否只看轮询终态**。
|
|
2777
|
+
* 3. **`FAILED` 没有可分诊的形状**:`errors.code` 实测为 `0`、`errors.message` 为
|
|
2778
|
+
* 空串,没有顶层 `message`。任何「从错误里读出原因」的承诺都是编造——本模块的
|
|
2779
|
+
* `describeDrawFailure` 因此**明确承认原因不可知**,只报可确定的形态差异。
|
|
2780
|
+
*
|
|
2781
|
+
* 关于两个小工具(`str` / `redactSecrets`):它们曾以**本地私有副本**的形式活在
|
|
2782
|
+
* 本文件里,理由是「避免为 3 行代码引 util 的依赖面」。那条理由不成立——util.ts
|
|
2783
|
+
* 自己只有 type-only import(peer-free),同包的 `draw-runtime.ts` 早就 import 它;
|
|
2784
|
+
* 而私有副本已经漂移:脱敏正则少了 `sk-` 与密码键值对两组、`ms-` 的 charset 与
|
|
2785
|
+
* util 版不一致(`[A-Za-z0-9-]` vs `[0-9a-f-]`)——脱敏闸漏一条就是凭据外泄
|
|
2786
|
+
* (红线 1)。现在一律用 util 的单一副本。
|
|
2787
|
+
*
|
|
2788
|
+
* @module dsh-connect-modelscope-token-plan/draw
|
|
2789
|
+
*/
|
|
2790
|
+
/**
|
|
2791
|
+
* 出图请求的截止时间(墙钟毫秒)。
|
|
2792
|
+
*
|
|
2793
|
+
* **15 分钟**:实测同一模型的耗时在 61s–537s 之间大幅波动(免费池排队),
|
|
2794
|
+
* 且**超时放弃不等于任务死亡**——上游任务会继续跑完(记录之后还会被回收,
|
|
2795
|
+
* 见 docs/SPIKE-image-gen.md §坑 5)。宁可多等,不要扔掉一张已在生成的图。
|
|
2796
|
+
*/
|
|
2797
|
+
const DRAW_TIMEOUT_MS = 9e5;
|
|
2798
|
+
/**
|
|
2799
|
+
* 两次轮询之间的间隔(毫秒)。
|
|
2800
|
+
*
|
|
2801
|
+
* 取 15s 的**真正理由**是「够用且不浪费」:实测最慢一次 537s,15s 间隔意味着
|
|
2802
|
+
* 最多几十次查询,代价可忽略;而更密(社区示例的 5s)没有观测收益。
|
|
2803
|
+
*
|
|
2804
|
+
* ⚠️ 早先曾写「更密的轮询拖慢任务」,**该归因已推翻**——后续对照显示同一模型、
|
|
2805
|
+
* 同样 15s 轮询的耗时是 61s → 261s → 537s 单调恶化,是**上游排队时长在波动**。
|
|
2806
|
+
* 那次构成「证据」的 5s vs 30s 对照,控制变量没做,不足以支撑因果。
|
|
2807
|
+
* 详见 docs/SPIKE-image-gen.md §坑 4(含撤回记录)。
|
|
2808
|
+
*/
|
|
2809
|
+
const DRAW_POLL_INTERVAL_MS = 15e3;
|
|
2810
|
+
/**
|
|
2811
|
+
* 一次**工具调用**里愿意等的时间(毫秒)。
|
|
2812
|
+
*
|
|
2813
|
+
* 出图工具是给 agent 用的,一次调用阻塞 = 一整轮对话被占住。15 分钟的总预算
|
|
2814
|
+
* ({@link DRAW_TIMEOUT_MS})是「这张图值不值得继续等」的上限,不是「一次调用
|
|
2815
|
+
* 该不该堵住 agent」的上限——两者同一个数,就是把上游排队的代价转嫁成对话的
|
|
2816
|
+
* 停顿。
|
|
2817
|
+
*
|
|
2818
|
+
* 取 90s:实测耗时 61s–537s 大幅波动,90s 能一次拿完最快那一档(61s),而
|
|
2819
|
+
* 更慢的本来就必须分段查(见 `draw-runtime.ts` 的两段式工具)。再往上加只是
|
|
2820
|
+
* 把「等得久」换成「卡得久」,并不改变「慢的图要分几次查」这个事实。
|
|
2821
|
+
*/
|
|
2822
|
+
const DRAW_WAIT_BUDGET_MS = 9e4;
|
|
2823
|
+
/**
|
|
2824
|
+
* 轮询必须携带的头名与取值。
|
|
2825
|
+
*
|
|
2826
|
+
* 漏掉它 → 上游返回 `task not found` + `FAILED`,而任务其实在正常跑。这是本协议
|
|
2827
|
+
* 最贵的单点知识,也是第一份外部资料缺失的那一块。
|
|
2828
|
+
*/
|
|
2829
|
+
const TASK_TYPE_HEADER = "X-ModelScope-Task-Type";
|
|
2830
|
+
const TASK_TYPE_IMAGE_GENERATION = "image_generation";
|
|
2831
|
+
/**
|
|
2832
|
+
* 一次出图请求由几个 HTTP 请求组成:一次提交 + N 次轮询。
|
|
2833
|
+
* 只作为文档性常量,供工具描述与测试引用。
|
|
2834
|
+
*/
|
|
2835
|
+
const DRAW_PHASE = Object.freeze({
|
|
2836
|
+
submit: "submit",
|
|
2837
|
+
poll: "poll"
|
|
2838
|
+
});
|
|
2839
|
+
/**
|
|
2840
|
+
* 读一个响应的 `task_status`,取不到返回 `null`。
|
|
2841
|
+
*
|
|
2842
|
+
* 刻意**不猜**:字段缺失或类型不对都返回 `null`,由 `isTerminalStatus` 把它当作
|
|
2843
|
+
* 「非终态」继续等——这样一次形状漂移最多变成多轮轮询,不会变成误报失败。
|
|
2844
|
+
* @param {unknown} data - 已解析的 JSON。
|
|
2845
|
+
* @returns {DrawTaskStatus|null} 规范化状态,取不到为 `null`。
|
|
2846
|
+
*/
|
|
2847
|
+
function readTaskStatus(data) {
|
|
2848
|
+
if (data === null || typeof data !== "object") return null;
|
|
2849
|
+
const raw = data.task_status;
|
|
2850
|
+
if (typeof raw !== "string") return null;
|
|
2851
|
+
const upper = raw.trim().toUpperCase();
|
|
2852
|
+
if (upper === "PENDING" || upper === "RUNNING" || upper === "SUCCEED" || upper === "FAILED") return upper;
|
|
2853
|
+
return null;
|
|
2854
|
+
}
|
|
2855
|
+
/**
|
|
2856
|
+
* 一个状态是不是终态。
|
|
2857
|
+
*
|
|
2858
|
+
* **只有** `SUCCEED` / `FAILED` 是终态;`null`(读不到)与一切未知值都**不是**。
|
|
2296
2859
|
* 「未知值继续等」是刻意的:把上游新增的状态误判成失败会丢掉一张已经能出的图,
|
|
2297
2860
|
* 而误判成进行中最多多等几轮(受 `DRAW_POLL_DEADLINE_MS` 兜底)。
|
|
2298
2861
|
* @param {DrawTaskStatus|null} status - 规范化状态。
|
|
@@ -2361,17 +2924,17 @@ function readOutputImages(data) {
|
|
|
2361
2924
|
*/
|
|
2362
2925
|
function buildDrawBody(options) {
|
|
2363
2926
|
const body = {
|
|
2364
|
-
model:
|
|
2365
|
-
prompt:
|
|
2927
|
+
model: str(options.model, ""),
|
|
2928
|
+
prompt: str(options.prompt, "")
|
|
2366
2929
|
};
|
|
2367
2930
|
const imageUrl = normalizeImageUrlList(options.imageUrl);
|
|
2368
2931
|
if (imageUrl.length > 0) body.image_url = imageUrl;
|
|
2369
|
-
const size =
|
|
2932
|
+
const size = str(options.size, "");
|
|
2370
2933
|
if (size !== "") body.size = size;
|
|
2371
2934
|
if (Number.isFinite(options.steps)) body.steps = options.steps;
|
|
2372
2935
|
if (Number.isFinite(options.guidance)) body.guidance = options.guidance;
|
|
2373
2936
|
if (Number.isFinite(options.seed)) body.seed = options.seed;
|
|
2374
|
-
const negativePrompt =
|
|
2937
|
+
const negativePrompt = str(options.negativePrompt, "");
|
|
2375
2938
|
if (negativePrompt !== "") body.negative_prompt = negativePrompt;
|
|
2376
2939
|
const loras = options.loras;
|
|
2377
2940
|
if (typeof loras === "string" && loras.trim() !== "") body.loras = loras.trim();
|
|
@@ -2438,9 +3001,9 @@ function describeDrawFailure(failure) {
|
|
|
2438
3001
|
if (status === 404) return `出图失败:HTTP 404 —— 端点或模型不存在(检查 apiBase 与模型 id)${tail}`;
|
|
2439
3002
|
if (status === 429) return `出图失败:HTTP 429 —— 上游限流,请稍后重试${tail}`;
|
|
2440
3003
|
if (status >= 500) return `出图失败:HTTP ${status} —— 上游服务异常,稍后重试${tail}`;
|
|
2441
|
-
const cause =
|
|
3004
|
+
const cause = str(failure.cause, "");
|
|
2442
3005
|
if (cause !== "") return `出图失败:${cause}${tail}`;
|
|
2443
|
-
const taskStatus =
|
|
3006
|
+
const taskStatus = str(failure.taskStatus, "");
|
|
2444
3007
|
if (taskStatus === "FAILED") return "出图失败:上游任务 FAILED,但未给出失败原因(实测 errors.code=0、message 为空),换模型或稍后重试";
|
|
2445
3008
|
if (taskStatus !== "") return `出图失败:任务停在 ${taskStatus}(非终态),已超过截止时间`;
|
|
2446
3009
|
return `出图失败:上游未给出可诊断的失败信息${tail}`;
|
|
@@ -2471,13 +3034,61 @@ function createDrawCooldown(cooldownMs) {
|
|
|
2471
3034
|
}
|
|
2472
3035
|
};
|
|
2473
3036
|
}
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
3037
|
+
|
|
3038
|
+
//#endregion
|
|
3039
|
+
//#region src/host/draw-jobs.ts
|
|
3040
|
+
/**
|
|
3041
|
+
* 建一张出图作业表。
|
|
3042
|
+
* @param {DrawJobsOptions} [options] - 容量上限。
|
|
3043
|
+
* @returns {DrawJobs} 作业表。
|
|
3044
|
+
*/
|
|
3045
|
+
function createDrawJobs(options = {}) {
|
|
3046
|
+
const maxJobs = Math.max(1, Math.floor(options.maxJobs ?? 64));
|
|
3047
|
+
/** @type {Map<string, DrawJob>} 插入顺序即淘汰顺序(Map 保序)。 */
|
|
3048
|
+
const jobs = /* @__PURE__ */ new Map();
|
|
3049
|
+
const evictIfNeeded = () => {
|
|
3050
|
+
while (jobs.size > maxJobs) {
|
|
3051
|
+
const oldest = jobs.keys().next();
|
|
3052
|
+
if (oldest.done === true) return;
|
|
3053
|
+
jobs.delete(oldest.value);
|
|
3054
|
+
}
|
|
3055
|
+
};
|
|
3056
|
+
return {
|
|
3057
|
+
create({ taskId, model, prompt, now }) {
|
|
3058
|
+
const jobId = taskId;
|
|
3059
|
+
const job = {
|
|
3060
|
+
jobId,
|
|
3061
|
+
taskId,
|
|
3062
|
+
model,
|
|
3063
|
+
prompt,
|
|
3064
|
+
submittedAt: typeof now === "number" && Number.isFinite(now) ? now : Date.now(),
|
|
3065
|
+
polls: 0
|
|
3066
|
+
};
|
|
3067
|
+
const previous = jobs.get(jobId);
|
|
3068
|
+
if (previous !== void 0) job.polls = previous.polls;
|
|
3069
|
+
jobs.set(jobId, job);
|
|
3070
|
+
evictIfNeeded();
|
|
3071
|
+
return { ...job };
|
|
3072
|
+
},
|
|
3073
|
+
get(jobId) {
|
|
3074
|
+
const found = jobs.get(jobId);
|
|
3075
|
+
return found === void 0 ? null : { ...found };
|
|
3076
|
+
},
|
|
3077
|
+
notePolls(jobId, polls) {
|
|
3078
|
+
const found = jobs.get(jobId);
|
|
3079
|
+
if (found === void 0) return;
|
|
3080
|
+
found.polls += Math.max(0, Math.floor(polls));
|
|
3081
|
+
},
|
|
3082
|
+
drop(jobId) {
|
|
3083
|
+
jobs.delete(jobId);
|
|
3084
|
+
},
|
|
3085
|
+
dispose() {
|
|
3086
|
+
jobs.clear();
|
|
3087
|
+
},
|
|
3088
|
+
size() {
|
|
3089
|
+
return jobs.size;
|
|
3090
|
+
}
|
|
3091
|
+
};
|
|
2481
3092
|
}
|
|
2482
3093
|
|
|
2483
3094
|
//#endregion
|
|
@@ -2488,7 +3099,14 @@ function redactSecrets(text) {
|
|
|
2488
3099
|
* 与 `llm-adapter.ts` 同一个处境——import Host 发行的 peer(`@deepseek-ai/dsh-tools`
|
|
2489
3100
|
* 的 `defineTool` 由调用方惰性注入),**不能由离线单测套件导入**。纯逻辑全部住在
|
|
2490
3101
|
* `draw.ts`(已由 `test/draw.test.mjs` 覆盖 39 组),本文件只做「接线 + I/O」:
|
|
2491
|
-
* 轮询循环、fetch
|
|
3102
|
+
* 轮询循环、fetch、工具定义。
|
|
3103
|
+
*
|
|
3104
|
+
* **覆盖边界**(2026-10-16 复核):`runDrawOnce` / `pickDrawModel` / `drawModelIds` /
|
|
3105
|
+
* `splitImageUrls` / `buildGenerationsEndpoint` / `buildTasksEndpoint` /
|
|
3106
|
+
* `DRAW_TOOL_NAME` 由 `test/draw-runtime.test.mjs` 钉住;**没被覆盖的是
|
|
3107
|
+
* `defineDrawTool` 落到 peer registry 的那一段**(真 `defineTool` 形状与注册结果)
|
|
3108
|
+
* ——只改那一段不会让任何门禁变红。本文件此前整段自称「无覆盖」,该陈述在补上
|
|
3109
|
+
* 这个套件之后已经过时。
|
|
2492
3110
|
*
|
|
2493
3111
|
* 与姊妹插件的关键差异(形状不兼容,不能照抄):
|
|
2494
3112
|
* `sensenova` 的出图是**同步单发**——一次 `fetch` 拿 `data[0].url` 就结束。魔搭是
|
|
@@ -2507,19 +3125,6 @@ function redactSecrets(text) {
|
|
|
2507
3125
|
*/
|
|
2508
3126
|
/** agent 工具名。带插件前缀,避免与 `dsh-draw-router` 的 `draw_image` 撞名。 */
|
|
2509
3127
|
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
3128
|
/** 一次失败的冷却窗:出图慢,失败后立刻重试既易撞限流又白等。 */
|
|
2524
3129
|
const DRAW_FAILURE_COOLDOWN_MS = 6e4;
|
|
2525
3130
|
/**
|
|
@@ -2559,28 +3164,16 @@ function isTerminalPayload(data) {
|
|
|
2559
3164
|
return isTerminalStatus(readTaskStatus(data));
|
|
2560
3165
|
}
|
|
2561
3166
|
/**
|
|
2562
|
-
*
|
|
3167
|
+
* **第一段**:提交一次出图,只要 `task_id`。
|
|
2563
3168
|
*
|
|
2564
|
-
*
|
|
2565
|
-
*
|
|
2566
|
-
*
|
|
2567
|
-
* @param {
|
|
2568
|
-
* @
|
|
2569
|
-
* @
|
|
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`。
|
|
3169
|
+
* 提交必须带 `X-ModelScope-Async-Mode`(实测可省但官方示例都带,带上无害)。
|
|
3170
|
+
* 响应里的 `task_status` **一律忽略**——实测恒为 `SUCCEED`,连最终 `FAILED` 的那次
|
|
3171
|
+
* 也是,所以成功与否只看轮询终态(这条由 `readTaskId` 钉在纯逻辑层)。
|
|
3172
|
+
* @param {DrawSubmitInput} input - 提交参数。
|
|
3173
|
+
* @returns {Promise<{taskId: string}>} 上游任务 id(= 续查用的 jobId)。
|
|
3174
|
+
* @throws {Error} 提交失败——消息已过 `describeDrawFailure`。
|
|
2582
3175
|
*/
|
|
2583
|
-
async function
|
|
3176
|
+
async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl, size, steps, guidance, seed, negativePrompt }) {
|
|
2584
3177
|
const headers = {
|
|
2585
3178
|
Authorization: `Bearer ${apiKey}`,
|
|
2586
3179
|
"Content-Type": "application/json"
|
|
@@ -2618,13 +3211,48 @@ async function runDrawOnce({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl
|
|
|
2618
3211
|
bodyText: submitText,
|
|
2619
3212
|
cause: "提交响应里没有 task_id"
|
|
2620
3213
|
}));
|
|
2621
|
-
|
|
3214
|
+
return { taskId };
|
|
3215
|
+
}
|
|
3216
|
+
/**
|
|
3217
|
+
* **第二段**:在给定预算内轮询一个任务,直到终态或预算耗尽。
|
|
3218
|
+
*
|
|
3219
|
+
* 与 `runDrawOnce` 的差别只有一处:预算耗尽时**返回 `pending` 而不是抛错**——
|
|
3220
|
+
* 抛错是「这件事做不成了」,`pending` 是「还没成,可以再查」。同一段循环因此
|
|
3221
|
+
* 同时服务于两种调用形状(一次性跑完 / 分段续查),不写两份。
|
|
3222
|
+
*
|
|
3223
|
+
* 轮询带 `X-ModelScope-Task-Type`(**必需**,漏了必假失败);传输层抖动与非
|
|
3224
|
+
* JSON 响应体都**不升级为终局**,继续等下一轮。
|
|
3225
|
+
* @param {object} options - 等待参数。
|
|
3226
|
+
* @param {DrawFetch} options.fetchImpl - 注入的 fetch。
|
|
3227
|
+
* @param {string} options.apiBase - apiBase。
|
|
3228
|
+
* @param {string} options.apiKey - 访问令牌。
|
|
3229
|
+
* @param {string} options.taskId - 上游任务 id。
|
|
3230
|
+
* @param {number} [options.waitMs] - 本次调用的等待预算。
|
|
3231
|
+
* @param {number} [options.pollIntervalMs] - 轮询间隔(测试可缩短)。
|
|
3232
|
+
* @returns {Promise<DrawAwait>} 三种结局之一。
|
|
3233
|
+
*/
|
|
3234
|
+
async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS, signal }) {
|
|
3235
|
+
const headers = {
|
|
3236
|
+
Authorization: `Bearer ${apiKey}`,
|
|
3237
|
+
"Content-Type": "application/json"
|
|
3238
|
+
};
|
|
3239
|
+
const deadline = Date.now() + Math.max(1, Math.floor(waitMs));
|
|
2622
3240
|
const interval = Math.max(1, Math.floor(pollIntervalMs));
|
|
2623
3241
|
let polls = 0;
|
|
2624
3242
|
let lastStatus;
|
|
2625
3243
|
for (;;) {
|
|
2626
|
-
if (Date.now() >= deadline)
|
|
2627
|
-
|
|
3244
|
+
if (Date.now() >= deadline) return {
|
|
3245
|
+
kind: "pending",
|
|
3246
|
+
polls
|
|
3247
|
+
};
|
|
3248
|
+
try {
|
|
3249
|
+
await sleep(interval, signal);
|
|
3250
|
+
} catch {
|
|
3251
|
+
return {
|
|
3252
|
+
kind: "pending",
|
|
3253
|
+
polls
|
|
3254
|
+
};
|
|
3255
|
+
}
|
|
2628
3256
|
polls++;
|
|
2629
3257
|
let polled;
|
|
2630
3258
|
try {
|
|
@@ -2639,29 +3267,37 @@ async function runDrawOnce({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl
|
|
|
2639
3267
|
continue;
|
|
2640
3268
|
}
|
|
2641
3269
|
const pollText = await safeText(polled);
|
|
2642
|
-
if (!polled.ok)
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
3270
|
+
if (!polled.ok) return {
|
|
3271
|
+
kind: "failed",
|
|
3272
|
+
message: describeDrawFailure({
|
|
3273
|
+
...polled.status !== void 0 ? { status: polled.status } : {},
|
|
3274
|
+
...pollText !== "" ? { bodyText: pollText } : {},
|
|
3275
|
+
...lastStatus !== void 0 ? { taskStatus: lastStatus } : {}
|
|
3276
|
+
})
|
|
3277
|
+
};
|
|
2647
3278
|
const data = await safeJson(polled, pollText);
|
|
2648
3279
|
const status = readTaskStatus(data);
|
|
2649
3280
|
if (status !== null) lastStatus = status;
|
|
2650
3281
|
if (!isTerminalPayload(data)) continue;
|
|
2651
|
-
if (status === "FAILED")
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
3282
|
+
if (status === "FAILED") return {
|
|
3283
|
+
kind: "failed",
|
|
3284
|
+
message: describeDrawFailure({
|
|
3285
|
+
taskStatus: "FAILED",
|
|
3286
|
+
bodyText: pollText
|
|
3287
|
+
})
|
|
3288
|
+
};
|
|
2655
3289
|
const images = readOutputImages(data);
|
|
2656
|
-
if (images.length === 0)
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
3290
|
+
if (images.length === 0) return {
|
|
3291
|
+
kind: "failed",
|
|
3292
|
+
message: describeDrawFailure({
|
|
3293
|
+
taskStatus: "SUCCEED",
|
|
3294
|
+
bodyText: pollText,
|
|
3295
|
+
cause: "任务报 SUCCEED 但没有可用图片"
|
|
3296
|
+
})
|
|
3297
|
+
};
|
|
2661
3298
|
return {
|
|
2662
|
-
|
|
3299
|
+
kind: "done",
|
|
2663
3300
|
images,
|
|
2664
|
-
taskId,
|
|
2665
3301
|
polls
|
|
2666
3302
|
};
|
|
2667
3303
|
}
|
|
@@ -2691,16 +3327,7 @@ async function runDrawOnce({ fetchImpl, apiBase, apiKey, model, prompt, imageUrl
|
|
|
2691
3327
|
* @returns {string|null} 选中的 id。
|
|
2692
3328
|
*/
|
|
2693
3329
|
function pickDrawModel(entries, requested, preferred) {
|
|
2694
|
-
|
|
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;
|
|
3330
|
+
return resolveDrawModelChoice(entries, requested, preferred).id;
|
|
2704
3331
|
}
|
|
2705
3332
|
/**
|
|
2706
3333
|
* 目录里可用于出图的模型 id(工具结果里回显,方便 agent 与用户看到实际候选)。
|
|
@@ -2711,30 +3338,121 @@ function drawModelIds(entries) {
|
|
|
2711
3338
|
return imageGenModelIds(entries);
|
|
2712
3339
|
}
|
|
2713
3340
|
/**
|
|
2714
|
-
* 装配 agent
|
|
3341
|
+
* 装配 agent 工具定义——**两段式**,一次调用不再阻塞整轮对话。
|
|
3342
|
+
*
|
|
3343
|
+
* ## 为什么是两段式
|
|
2715
3344
|
*
|
|
2716
|
-
*
|
|
3345
|
+
* 上游出图要排队,实测 61s–537s(免费池)。旧形状是「一次调用 await 到终态」,
|
|
3346
|
+
* 于是 `drawTimeoutMs`(15 分钟)既是「这张图值不值得等」的上限,也成了「一轮
|
|
3347
|
+
* 对话被卡多久」的上限——把上游排队的代价直接转嫁成对话停顿。
|
|
3348
|
+
*
|
|
3349
|
+
* 现在一次调用只做两件事之一:
|
|
3350
|
+
* - **不带 `jobId`** → 提交任务,然后只在 `waitMs`(默认 {@link DRAW_WAIT_BUDGET_MS})
|
|
3351
|
+
* 内等;等到就返回 `done`,没等到就返回 `pending` + `jobId`;
|
|
3352
|
+
* - **带 `jobId`** → 只查那个任务,同样最多等 `waitMs`。
|
|
3353
|
+
*
|
|
3354
|
+
* `drawTimeoutMs` 因此回到它本来的语义:**从提交算起的总放弃线**(跨调用累计),
|
|
3355
|
+
* 越过它才报「放弃」。
|
|
3356
|
+
*
|
|
3357
|
+
* ## 两个刻意的判定
|
|
3358
|
+
*
|
|
3359
|
+
* - `pending` **不是失败**:不抛错、不进冷却门。它只表示「这次调用愿意等的时间里
|
|
3360
|
+
* 没等到」,进冷却就会把续查这条路堵死——那正是分段查询的目的所在。
|
|
3361
|
+
* - 只有**真失败**(提交失败 / 上游 `FAILED` / 越过总放弃线)才 `cooldown.fail()`。
|
|
3362
|
+
*
|
|
3363
|
+
* 工具仍只做「参数归一 → 跑一次 → 组装结果文本」,所有判定都委托给 `draw.ts` 的纯函数。
|
|
2717
3364
|
* @param {object} options - 装配参数。
|
|
2718
3365
|
* @param {(definition: object) => unknown} options.defineTool - peer 的工厂。
|
|
2719
3366
|
* @param {DrawFetch} options.fetchImpl - 注入的 fetch。
|
|
2720
3367
|
* @param {() => Promise<string>} options.resolveApiKey - 每次调用现读令牌。
|
|
2721
3368
|
* @param {() => unknown|Promise<unknown>} [options.getEntries] - 目录条目读取器。
|
|
2722
|
-
* @param {object} [options.settings] - `{ apiBase, drawModelId, drawTimeoutMs }`。
|
|
3369
|
+
* @param {object} [options.settings] - `{ apiBase, drawModelId, drawTimeoutMs, drawWaitMs }`。
|
|
2723
3370
|
* @param {ReturnType<typeof createDrawCooldown>} [options.cooldown] - 冷却门。
|
|
3371
|
+
* @param {DrawJobs} [options.jobs] - 作业表(可注入,便于测试)。
|
|
2724
3372
|
* @param {() => boolean} [options.isDisposed] - 卸载闸。
|
|
2725
3373
|
* @returns {unknown} 已定义的 agent 工具。
|
|
2726
3374
|
*/
|
|
2727
|
-
function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, settings, getPreferredModelId, cooldown = createDrawCooldown(DRAW_FAILURE_COOLDOWN_MS), isDisposed = () => false }) {
|
|
3375
|
+
function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, settings, getPreferredModelId, cooldown = createDrawCooldown(DRAW_FAILURE_COOLDOWN_MS), jobs = createDrawJobs(), pollIntervalMs = DRAW_POLL_INTERVAL_MS, isDisposed = () => false }) {
|
|
3376
|
+
/** 总放弃线:从提交算起,跨多次调用累计。 */
|
|
2728
3377
|
const timeoutMs = Math.max(3e4, Math.floor(Number(settings?.drawTimeoutMs) > 0 ? Number(settings?.drawTimeoutMs) : DRAW_TIMEOUT_MS));
|
|
3378
|
+
/**
|
|
3379
|
+
* 单次调用愿意等的时间。
|
|
3380
|
+
*
|
|
3381
|
+
* 只夹**上界**(不该超过总放弃线,超过它的等待没有意义);下界交给
|
|
3382
|
+
* `host-config` 的 `drawWaitMs` clamp——同一个数在两处各夹一次,上限/下限就会
|
|
3383
|
+
* 各说一套话(`drawTimeoutMs` 的 clamp 早已是本仓库的教训)。
|
|
3384
|
+
*/
|
|
3385
|
+
const waitMs = Math.min(timeoutMs, Math.max(1, Math.floor(Number(settings?.drawWaitMs) > 0 ? Number(settings?.drawWaitMs) : DRAW_WAIT_BUDGET_MS)));
|
|
2729
3386
|
const apiBase = str(settings?.apiBase, "https://api-inference.modelscope.cn/v1");
|
|
3387
|
+
const abortController = new AbortController();
|
|
3388
|
+
const abortIfDisposed = () => {
|
|
3389
|
+
if (isDisposed()) abortController.abort();
|
|
3390
|
+
};
|
|
3391
|
+
/**
|
|
3392
|
+
* 等一个作业,把 {@link awaitDrawTask} 的三种结局翻译成工具结果或错误。
|
|
3393
|
+
*
|
|
3394
|
+
* 两段共用这一段——「刚提交的」与「续查的」在等待这件事上没有区别,唯一的区别
|
|
3395
|
+
* 是作业表里那条记录从哪来。
|
|
3396
|
+
* @param {string} apiKey - 访问令牌。
|
|
3397
|
+
* @param {DrawJob} job - 作业(含提交时刻与累计轮询数)。
|
|
3398
|
+
* @param {string[]} available - 目录里的出图模型(续查时为空,不必再拉目录)。
|
|
3399
|
+
* @returns {Promise<DrawToolResult>} 工具结果。
|
|
3400
|
+
* @throws {Error} 真失败(上游 FAILED / 轮询 HTTP 失败)或越过总放弃线。
|
|
3401
|
+
*/
|
|
3402
|
+
const waitAndReport = async (apiKey, job, available) => {
|
|
3403
|
+
abortIfDisposed();
|
|
3404
|
+
const awaited = await awaitDrawTask({
|
|
3405
|
+
fetchImpl,
|
|
3406
|
+
apiBase,
|
|
3407
|
+
apiKey,
|
|
3408
|
+
taskId: job.taskId,
|
|
3409
|
+
waitMs,
|
|
3410
|
+
pollIntervalMs,
|
|
3411
|
+
signal: abortController.signal
|
|
3412
|
+
});
|
|
3413
|
+
if (awaited.kind === "failed") {
|
|
3414
|
+
jobs.drop(job.jobId);
|
|
3415
|
+
throw new Error(awaited.message);
|
|
3416
|
+
}
|
|
3417
|
+
jobs.notePolls(job.jobId, awaited.polls);
|
|
3418
|
+
const polls = job.polls + awaited.polls;
|
|
3419
|
+
if (awaited.kind === "done") {
|
|
3420
|
+
jobs.drop(job.jobId);
|
|
3421
|
+
const urls = awaited.images.join("\n");
|
|
3422
|
+
return {
|
|
3423
|
+
source: "modelscope",
|
|
3424
|
+
status: "done",
|
|
3425
|
+
jobId: job.jobId,
|
|
3426
|
+
model: job.model,
|
|
3427
|
+
url: awaited.images[0] ?? "",
|
|
3428
|
+
prompt: job.prompt,
|
|
3429
|
+
hint: `图片已生成(上游异步任务,${polls} 次轮询,task ${job.taskId})\n模型: ${job.model}\n${urls}` + (available.length > 0 ? `\n可用出图模型: ${available.join(", ")}` : "")
|
|
3430
|
+
};
|
|
3431
|
+
}
|
|
3432
|
+
const elapsedMs = Date.now() - job.submittedAt;
|
|
3433
|
+
if (elapsedMs >= timeoutMs) throw new Error(describeDrawFailure({ cause: `超过总预算(${Math.round(timeoutMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${job.taskId})可能仍在生成——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关;注意放弃后原任务的结果不保证还能取回` }));
|
|
3434
|
+
return {
|
|
3435
|
+
source: "modelscope",
|
|
3436
|
+
status: "pending",
|
|
3437
|
+
jobId: job.jobId,
|
|
3438
|
+
model: job.model,
|
|
3439
|
+
url: "",
|
|
3440
|
+
prompt: job.prompt,
|
|
3441
|
+
hint: `仍在生成中(已等 ${Math.round(elapsedMs / 1e3)}s,${polls} 次轮询,task ${job.taskId}):上游排队通常 1–15 分钟。稍后带 jobId ${job.jobId} 再调用本工具即可继续查询(只查询、不会重复出图)`
|
|
3442
|
+
};
|
|
3443
|
+
};
|
|
2730
3444
|
return defineTool({
|
|
2731
3445
|
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
|
|
3446
|
+
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 several newline-separated URLs for multi-image editing. Omit `model` to use the catalogue's image model. TWO-PHASE: upstream generation queues, so latency varies from ~1 minute to 15+ minutes. Call without `jobId` to submit and wait briefly; if the result is `status: pending`, call again with the returned `jobId` to keep polling. A pending result is NOT a failure — the image is still being generated. Available image model ids are reported in the result.",
|
|
2733
3447
|
parameters: {
|
|
2734
3448
|
prompt: {
|
|
2735
3449
|
type: "string",
|
|
2736
3450
|
required: true,
|
|
2737
|
-
description: "Image prompt; describe the target image (for edits, describe the intended change)"
|
|
3451
|
+
description: "Image prompt; describe the target image (for edits, describe the intended change). Ignored when resuming with `jobId`."
|
|
3452
|
+
},
|
|
3453
|
+
jobId: {
|
|
3454
|
+
type: "string",
|
|
3455
|
+
description: "Resume an earlier task: pass the `jobId` returned by a previous call to keep polling it (no new image is submitted)"
|
|
2738
3456
|
},
|
|
2739
3457
|
model: {
|
|
2740
3458
|
type: "string",
|
|
@@ -2771,6 +3489,8 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
2771
3489
|
additionalProperties: false,
|
|
2772
3490
|
properties: {
|
|
2773
3491
|
source: { type: "string" },
|
|
3492
|
+
status: { type: "string" },
|
|
3493
|
+
jobId: { type: "string" },
|
|
2774
3494
|
model: { type: "string" },
|
|
2775
3495
|
url: { type: "string" },
|
|
2776
3496
|
prompt: { type: "string" },
|
|
@@ -2782,15 +3502,26 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
2782
3502
|
text: result?.hint || "图片已生成"
|
|
2783
3503
|
}]
|
|
2784
3504
|
},
|
|
2785
|
-
timeoutMs:
|
|
3505
|
+
timeoutMs: waitMs + 1e4,
|
|
2786
3506
|
async execute(params) {
|
|
2787
|
-
if (isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
|
|
3507
|
+
if (abortController.signal.aborted || isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
|
|
3508
|
+
const apiKey = await resolveApiKey();
|
|
3509
|
+
if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("MODELSCOPE_API_KEY 未配置:在面板粘贴 ms- 访问令牌,或设置该环境变量");
|
|
3510
|
+
const resumeId = str(params?.jobId, "");
|
|
3511
|
+
if (resumeId !== "") {
|
|
3512
|
+
const job = jobs.get(resumeId);
|
|
3513
|
+
if (job === null) throw new Error(`jobId ${resumeId} 不在本次会话的作业表里(插件重启过,或该 id 不是本插件发出的):原任务可能仍在生成,但它的结果不保证还能取回(上游终态记录会被回收),请重新出图`);
|
|
3514
|
+
try {
|
|
3515
|
+
return await waitAndReport(apiKey, job, []);
|
|
3516
|
+
} catch (error) {
|
|
3517
|
+
cooldown.fail();
|
|
3518
|
+
throw error;
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
2788
3521
|
const prompt = str(params?.prompt, "");
|
|
2789
3522
|
if (prompt === "") throw new Error("prompt is required");
|
|
2790
3523
|
const remaining = cooldown.remainingMs();
|
|
2791
3524
|
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
3525
|
let picked = [];
|
|
2795
3526
|
try {
|
|
2796
3527
|
picked = await getEntries?.() ?? [];
|
|
@@ -2816,7 +3547,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
2816
3547
|
const guidance = toPositiveNumber(params?.guidance);
|
|
2817
3548
|
const seed = toNonNegativeInt(params?.seed);
|
|
2818
3549
|
const negativePrompt = str(params?.negativePrompt, "");
|
|
2819
|
-
const
|
|
3550
|
+
const { taskId } = await submitDrawTask({
|
|
2820
3551
|
fetchImpl,
|
|
2821
3552
|
apiBase,
|
|
2822
3553
|
apiKey,
|
|
@@ -2827,18 +3558,14 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
2827
3558
|
...steps !== void 0 ? { steps } : {},
|
|
2828
3559
|
...guidance !== void 0 ? { guidance } : {},
|
|
2829
3560
|
...seed !== void 0 ? { seed } : {},
|
|
2830
|
-
...negativePrompt !== "" ? { negativePrompt } : {}
|
|
2831
|
-
pollDeadlineMs: timeoutMs
|
|
3561
|
+
...negativePrompt !== "" ? { negativePrompt } : {}
|
|
2832
3562
|
});
|
|
2833
|
-
const
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
prompt,
|
|
2840
|
-
hint: `图片已生成(上游异步任务,${outcome.polls} 次轮询,task ${outcome.taskId})\n模型: ${outcome.model}\n${urls}` + (available.length > 0 ? `\n可用出图模型: ${available.join(", ")}` : "")
|
|
2841
|
-
};
|
|
3563
|
+
const job = jobs.create({
|
|
3564
|
+
taskId,
|
|
3565
|
+
model,
|
|
3566
|
+
prompt
|
|
3567
|
+
});
|
|
3568
|
+
return await waitAndReport(apiKey, job, drawModelIds(entries));
|
|
2842
3569
|
} catch (error) {
|
|
2843
3570
|
cooldown.fail();
|
|
2844
3571
|
throw error;
|
|
@@ -2895,82 +3622,56 @@ async function safeJson(response, text) {
|
|
|
2895
3622
|
}
|
|
2896
3623
|
}
|
|
2897
3624
|
}
|
|
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
3625
|
/**
|
|
2908
|
-
*
|
|
2909
|
-
*
|
|
2910
|
-
*
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
note = reason;
|
|
2914
|
-
}
|
|
2915
|
-
/**
|
|
2916
|
-
* 读当前缺席原因。
|
|
2917
|
-
* @returns {DrawToolAbsentReason|null} 原因码。
|
|
2918
|
-
*/
|
|
2919
|
-
function drawToolNote() {
|
|
2920
|
-
return note;
|
|
2921
|
-
}
|
|
2922
|
-
/**
|
|
2923
|
-
* 清空(卸载时调用:下一个 Host 生命周期从干净的「未登记」开始)。
|
|
2924
|
-
* @returns {void}
|
|
3626
|
+
* 可注入的 sleep(测试可覆写;这里只提供默认实现)。
|
|
3627
|
+
*
|
|
3628
|
+
* 支持 `AbortSignal`:插件 dispose 时可以立即中断等待,而不是等 sleep 自然结束。
|
|
3629
|
+
* 默认 15s 的轮询间隔意味着用户点「停止」后最多要等 15s 才真正停——abort 让
|
|
3630
|
+
* 停止信号立即生效。
|
|
2925
3631
|
*/
|
|
2926
|
-
function
|
|
2927
|
-
|
|
3632
|
+
function sleep(ms, signal) {
|
|
3633
|
+
return new Promise((resolve, reject) => {
|
|
3634
|
+
if (signal?.aborted) {
|
|
3635
|
+
reject(/* @__PURE__ */ new Error("sleep aborted"));
|
|
3636
|
+
return;
|
|
3637
|
+
}
|
|
3638
|
+
const timer = setTimeout(() => {
|
|
3639
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3640
|
+
resolve();
|
|
3641
|
+
}, ms);
|
|
3642
|
+
const onAbort = () => {
|
|
3643
|
+
clearTimeout(timer);
|
|
3644
|
+
reject(/* @__PURE__ */ new Error("sleep aborted"));
|
|
3645
|
+
};
|
|
3646
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3647
|
+
});
|
|
2928
3648
|
}
|
|
2929
3649
|
|
|
2930
3650
|
//#endregion
|
|
2931
|
-
//#region src/host/
|
|
2932
|
-
/**
|
|
2933
|
-
|
|
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
|
-
*/
|
|
3651
|
+
//#region src/host/draw-tool-state.ts
|
|
3652
|
+
/** 当前缺席原因(`null` = 未登记 / 已清掉)。 */
|
|
3653
|
+
let note = null;
|
|
2945
3654
|
/**
|
|
2946
|
-
*
|
|
2947
|
-
*
|
|
2948
|
-
* @
|
|
2949
|
-
* @param {boolean} config - the patch-declared default.
|
|
2950
|
-
* @returns {boolean} the effective switch.
|
|
3655
|
+
* 记下缺席原因(`null` 清空)。
|
|
3656
|
+
* @param {DrawToolAbsentReason|null} reason - 原因码。
|
|
3657
|
+
* @returns {void}
|
|
2951
3658
|
*/
|
|
2952
|
-
function
|
|
2953
|
-
|
|
3659
|
+
function setDrawToolNote(reason) {
|
|
3660
|
+
note = reason;
|
|
2954
3661
|
}
|
|
2955
3662
|
/**
|
|
2956
|
-
*
|
|
2957
|
-
*
|
|
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.
|
|
3663
|
+
* 读当前缺席原因。
|
|
3664
|
+
* @returns {DrawToolAbsentReason|null} 原因码。
|
|
2961
3665
|
*/
|
|
2962
|
-
function
|
|
2963
|
-
return
|
|
3666
|
+
function drawToolNote() {
|
|
3667
|
+
return note;
|
|
2964
3668
|
}
|
|
2965
3669
|
/**
|
|
2966
|
-
*
|
|
2967
|
-
*
|
|
2968
|
-
* the side in charge.
|
|
2969
|
-
* @param {boolean|string|null} panel - the panel-saved value.
|
|
2970
|
-
* @returns {"panel"|"config"}
|
|
3670
|
+
* 清空(卸载时调用:下一个 Host 生命周期从干净的「未登记」开始)。
|
|
3671
|
+
* @returns {void}
|
|
2971
3672
|
*/
|
|
2972
|
-
function
|
|
2973
|
-
|
|
3673
|
+
function resetDrawToolNote() {
|
|
3674
|
+
note = null;
|
|
2974
3675
|
}
|
|
2975
3676
|
|
|
2976
3677
|
//#endregion
|
|
@@ -2993,9 +3694,16 @@ function switchSource(panel) {
|
|
|
2993
3694
|
* registry 拒收——三种都由 `draw-tool-state.ts` 记下原因供面板显示。本文件不吞
|
|
2994
3695
|
* 错误(那会让「工具为什么不在」无从诊断),只把它们翻译成可展示的原因码。
|
|
2995
3696
|
*
|
|
2996
|
-
* peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule`
|
|
2997
|
-
*
|
|
2998
|
-
*
|
|
3697
|
+
* peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule` 惰性注入。
|
|
3698
|
+
*
|
|
3699
|
+
* **测试覆盖**:`test/draw-register.test.mjs`(2026-10-17 补上,此前这里是零引用)
|
|
3700
|
+
* 用 `deps.loadToolsModule` 这条现成的缝注入伪 `defineTool`,让真装配链原样跑起来
|
|
3701
|
+
* ——**不需要** peer-stub-loader。教训是:本文件此前把「peer-dependent」直接读成
|
|
3702
|
+
* 「测不了」,而缝早就留好了;同处境的 `llm-adapter.ts` / `draw-runtime.ts` 用
|
|
3703
|
+
* `test/helpers/peer-stub-loader.mjs` 证明过另一条路(那条 loader 是给**没有缝**
|
|
3704
|
+
* 的直连 import 用的)。`tools/dev/mutate-spec.json` 里有本文件的一组变异,用来
|
|
3705
|
+
* 保证那套测试真的会红——第一版测试里 `mount` 助手自己清了缺席原因,于是「过期
|
|
3706
|
+
* 原因会不会被清掉」那条断言是测试自己满足的,是变异测试把它抓出来的。
|
|
2999
3707
|
*
|
|
3000
3708
|
* @module dsh-connect-modelscope-token-plan/draw-register
|
|
3001
3709
|
*/
|
|
@@ -3046,7 +3754,7 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
|
|
|
3046
3754
|
defineTool = mod?.defineTool ?? mod?.default?.defineTool ?? null;
|
|
3047
3755
|
} catch (error) {
|
|
3048
3756
|
setDrawToolNote("peer-load-failed");
|
|
3049
|
-
loggerFor?.warn?.(`${name}: draw tool peer module failed to load (${redactSecrets
|
|
3757
|
+
loggerFor?.warn?.(`${name}: draw tool peer module failed to load (${redactSecrets(errMsg(error))}) — the tool is absent`);
|
|
3050
3758
|
return;
|
|
3051
3759
|
}
|
|
3052
3760
|
if (typeof defineTool !== "function") {
|
|
@@ -3068,13 +3776,14 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
|
|
|
3068
3776
|
settings: {
|
|
3069
3777
|
apiBase: settings.apiBase,
|
|
3070
3778
|
drawModelId: settings.drawModelId,
|
|
3071
|
-
drawTimeoutMs: settings.drawTimeoutMs
|
|
3779
|
+
drawTimeoutMs: settings.drawTimeoutMs,
|
|
3780
|
+
drawWaitMs: settings.drawWaitMs
|
|
3072
3781
|
},
|
|
3073
3782
|
isDisposed
|
|
3074
3783
|
}));
|
|
3075
3784
|
} catch (error) {
|
|
3076
3785
|
setDrawToolNote("registry-refused");
|
|
3077
|
-
loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets
|
|
3786
|
+
loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets(errMsg(error))}) — the tool is absent`);
|
|
3078
3787
|
}
|
|
3079
3788
|
}
|
|
3080
3789
|
|
|
@@ -3265,7 +3974,7 @@ function describeBuildFailure(error) {
|
|
|
3265
3974
|
const annotated = error;
|
|
3266
3975
|
const why = errMsg(error);
|
|
3267
3976
|
return {
|
|
3268
|
-
note: redactSecrets
|
|
3977
|
+
note: redactSecrets(why),
|
|
3269
3978
|
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)" : "",
|
|
3270
3979
|
error
|
|
3271
3980
|
};
|
|
@@ -3371,7 +4080,7 @@ function swapRegistration({ llm, built, previousBuilt, state, release, registerP
|
|
|
3371
4080
|
release();
|
|
3372
4081
|
state.built = null;
|
|
3373
4082
|
onRollback?.();
|
|
3374
|
-
state.error = redactSecrets
|
|
4083
|
+
state.error = redactSecrets(errMsg(error));
|
|
3375
4084
|
restorePreviousPair({
|
|
3376
4085
|
llm,
|
|
3377
4086
|
previousBuilt,
|
|
@@ -3450,9 +4159,20 @@ function emitAdaptersUpdated(emit) {
|
|
|
3450
4159
|
* - 单点 `registerPair`(带 factory-await + shape 检查)被 publish 与 rollback
|
|
3451
4160
|
* 两条路共用;失败时**恢复旧对**,坏 publish 不会把已在服务的模型也拉下来。
|
|
3452
4161
|
*
|
|
3453
|
-
*
|
|
3454
|
-
*
|
|
3455
|
-
*
|
|
4162
|
+
* 结构性纪律(本文件新立的):一次 publish 拆成 **gather(可挂起)**与
|
|
4163
|
+
* **commit(不可挂起)**两半,commit 是**非 async 函数**——「disposed 复查与
|
|
4164
|
+
* registerAdapter 之间没有 await」由此前的注释论证升级为语言级保证。相位
|
|
4165
|
+
* (idle/serving/failing/broken/disposed)从契约字段推导,不双写。
|
|
4166
|
+
*
|
|
4167
|
+
* 与 sensenova 版的关系:**结构同源、代码不等价**——去注释后代码行重叠 77%
|
|
4168
|
+
* (2026-10-16 实测;复核:`node tools/dev/cross-repo-drift.mjs`)。所以差异**不止**
|
|
4169
|
+
* 三个标签:除工厂导出名 `"createModelScopeAdapter"`、`registerPair` 用的
|
|
4170
|
+
* `LLM_PROVIDER_ID`/`LLM_DISPLAY_NAME`、`warnBuildFailure` 的 label `"ModelScope"`
|
|
4171
|
+
* 之外,vision 判定这里取自 `llm-models.ts`(sensenova 侧走 `parsers.ts` 的
|
|
4172
|
+
* `identifyVisionModel`),usage 观察层的接线位置也不同。
|
|
4173
|
+
*
|
|
4174
|
+
* 可以跨仓库**对着读**的是三条承重语义(队列 / disposed 闸 / 单点 registerPair,
|
|
4175
|
+
* 它们是 `publish-core.ts` 的骨头,三处 91% 同源);具体代码不可照搬。
|
|
3456
4176
|
*
|
|
3457
4177
|
* peer-free:不 import 任何运行时 peer。适配器工厂由调用方注入
|
|
3458
4178
|
* (`loadAdapterModule`,默认 `import("./llm-adapter.ts")`),所以离线套件可以换
|
|
@@ -3464,6 +4184,29 @@ function emitAdaptersUpdated(emit) {
|
|
|
3464
4184
|
* @module dsh-connect-modelscope-token-plan/provider-publish
|
|
3465
4185
|
*/
|
|
3466
4186
|
/**
|
|
4187
|
+
* 从 publisher 的契约状态**推导**相位——单源,不双写。
|
|
4188
|
+
*
|
|
4189
|
+
* `registered` / `error` 是 `publish-core.ts` 的共享契约字段(`swapRegistration`
|
|
4190
|
+
* 与 `unregister` 直接写它们)。并行维护一个 `phase` 字段就是双写,双写必然
|
|
4191
|
+
* 漂移且方向静默;推导让相位永远不可能与那两个字段矛盾。
|
|
4192
|
+
*
|
|
4193
|
+
* 相位与两道空转守卫的对应(旧版注释里的条件,现在是有名字的状态):
|
|
4194
|
+
* - `idle` = 没注册且无错:开关关着的稳态 → 关侧空转守卫可跳过;
|
|
4195
|
+
* - `serving` = 已注册且无错:offer 已 live → 开侧空转守卫可跳过;
|
|
4196
|
+
* - `failing` = 已注册但有错:回滚到了旧对、但新 offer 没落地 → **必须继续
|
|
4197
|
+
* 重试**(跳过就是静默丢弃用户刚保存的清单);
|
|
4198
|
+
* - `broken` = 没注册且有错:上次发布失败且什么都没在服务 → 继续重试自愈;
|
|
4199
|
+
* - `disposed` = 插件已 dispose:任何 publish 都是 no-op。
|
|
4200
|
+
* @param {{registered: boolean, error: string|null}} state - publisher 状态。
|
|
4201
|
+
* @param {boolean} disposed - publish 队列的 disposed 闸状态。
|
|
4202
|
+
* @returns {PublisherPhase} 相位。
|
|
4203
|
+
*/
|
|
4204
|
+
function publisherPhaseOf(state, disposed) {
|
|
4205
|
+
if (disposed) return "disposed";
|
|
4206
|
+
if (state.registered) return state.error === null ? "serving" : "failing";
|
|
4207
|
+
return state.error === null ? "idle" : "broken";
|
|
4208
|
+
}
|
|
4209
|
+
/**
|
|
3467
4210
|
* provider 发布器。
|
|
3468
4211
|
*
|
|
3469
4212
|
* 持有活注册状态(`state`);publish 队列、`disposed` 闸与单点 `registerPair` 是
|
|
@@ -3544,20 +4287,37 @@ function createProviderPublisher(deps = {}) {
|
|
|
3544
4287
|
*/
|
|
3545
4288
|
const offerUnchanged = () => state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds);
|
|
3546
4289
|
/**
|
|
3547
|
-
*
|
|
4290
|
+
* 发布相位——两道空转守卫的判断条件从此有名字(推导见 {@link publisherPhaseOf})。
|
|
4291
|
+
* @param {boolean} disposed - 当前是否已 dispose。
|
|
4292
|
+
* @returns {PublisherPhase}
|
|
4293
|
+
*/
|
|
4294
|
+
const phaseOf = (disposed) => publisherPhaseOf(state, disposed);
|
|
4295
|
+
/**
|
|
4296
|
+
* gather 阶段:一次 publish 的**可挂起**半边。
|
|
3548
4297
|
*
|
|
3549
|
-
*
|
|
3550
|
-
*
|
|
3551
|
-
*
|
|
3552
|
-
*
|
|
3553
|
-
*
|
|
3554
|
-
*
|
|
3555
|
-
*
|
|
4298
|
+
* 读面板开关、解析 llm 服务、解析适配器工厂、构建适配器——四个 await 全住
|
|
4299
|
+
* 在这里。产出两种计划:`done`(跳过 / 注销 / 构建失败,都已执行完)或
|
|
4300
|
+
* `commit`(适配器构建好了,只剩同步提交)。
|
|
4301
|
+
*
|
|
4302
|
+
* 领域字段(entries / enabledIds / unavailableIds)在查询开关**之前**写入,
|
|
4303
|
+
* 开关关分支刻意不回滚它们——这个不对称是承重的,不是疏忽:`routes/provider.ts`
|
|
4304
|
+
* 与 roster 保存会在开关重新打开时正好从这些字段重新 publish,所以用户的
|
|
4305
|
+
* 允许清单编辑与最后一次拉到的目录**必须**熬过关闭期。把 `previous*` 还回去
|
|
4306
|
+
* 会静默丢掉两者——开关关闭期间做的保存,会在开关重新打开时消失。
|
|
4307
|
+
* `swapRegistration` 的 `onRollback` 会还它们,但只对注册真的抛了的路径——
|
|
4308
|
+
* 那种失败必须让先前在服务的对继续服务,这是与「开关关着」不同的承诺。
|
|
4309
|
+
* @param {unknown} entries - 归一目录条目。
|
|
4310
|
+
* @param {unknown} enabledIds - 允许清单(空 = 全部)。
|
|
4311
|
+
* @param {string[]} unavailableModelIds - 额度耗尽的模型 id。
|
|
4312
|
+
* @returns {Promise<PublishPlan>} 执行计划。
|
|
3556
4313
|
*/
|
|
3557
|
-
const
|
|
4314
|
+
const gatherPublish = async (entries, enabledIds, unavailableModelIds) => {
|
|
3558
4315
|
if (queue.isDisposed()) return {
|
|
3559
|
-
|
|
3560
|
-
|
|
4316
|
+
kind: "done",
|
|
4317
|
+
result: {
|
|
4318
|
+
ok: false,
|
|
4319
|
+
skipped: true
|
|
4320
|
+
}
|
|
3561
4321
|
};
|
|
3562
4322
|
const previousBuilt = state.built;
|
|
3563
4323
|
const previousEntries = state.entries;
|
|
@@ -3568,16 +4328,22 @@ function createProviderPublisher(deps = {}) {
|
|
|
3568
4328
|
state.unavailableIds = Array.isArray(unavailableModelIds) ? unavailableModelIds : [];
|
|
3569
4329
|
const panelValue = await effectivePanelSwitch().catch(() => null);
|
|
3570
4330
|
if (!resolveSwitchEnabled(panelValue, effectiveSettings.registerProvider === true)) {
|
|
3571
|
-
if (
|
|
3572
|
-
|
|
3573
|
-
|
|
4331
|
+
if (phaseOf(false) === "idle" && offerUnchanged()) return {
|
|
4332
|
+
kind: "done",
|
|
4333
|
+
result: {
|
|
4334
|
+
ok: true,
|
|
4335
|
+
skipped: true
|
|
4336
|
+
}
|
|
3574
4337
|
};
|
|
3575
4338
|
const result = unregister({
|
|
3576
4339
|
state,
|
|
3577
4340
|
release
|
|
3578
4341
|
});
|
|
3579
4342
|
syncSignaturesAfterPublish(state);
|
|
3580
|
-
return
|
|
4343
|
+
return {
|
|
4344
|
+
kind: "done",
|
|
4345
|
+
result
|
|
4346
|
+
};
|
|
3581
4347
|
}
|
|
3582
4348
|
const llm = resolveRegistrationService({
|
|
3583
4349
|
state,
|
|
@@ -3585,13 +4351,15 @@ function createProviderPublisher(deps = {}) {
|
|
|
3585
4351
|
release
|
|
3586
4352
|
});
|
|
3587
4353
|
if (llm === null) return {
|
|
3588
|
-
|
|
3589
|
-
|
|
4354
|
+
kind: "done",
|
|
4355
|
+
result: {
|
|
4356
|
+
ok: false,
|
|
4357
|
+
error: state.error
|
|
4358
|
+
}
|
|
3590
4359
|
};
|
|
3591
|
-
let createModelScopeAdapter;
|
|
3592
4360
|
let built;
|
|
3593
4361
|
try {
|
|
3594
|
-
createModelScopeAdapter = await resolveAdapterFactory();
|
|
4362
|
+
const createModelScopeAdapter = await resolveAdapterFactory();
|
|
3595
4363
|
if (createModelScopeAdapter === void 0) throw new Error(BAD_FACTORY_SHAPE_ERROR);
|
|
3596
4364
|
built = await createModelScopeAdapter({
|
|
3597
4365
|
entries: state.entries,
|
|
@@ -3600,7 +4368,8 @@ function createProviderPublisher(deps = {}) {
|
|
|
3600
4368
|
resolveApiKey: effectiveResolveApiKey,
|
|
3601
4369
|
get: effectiveGetLlm,
|
|
3602
4370
|
unavailableModelIds: state.unavailableIds,
|
|
3603
|
-
...usage !== void 0 ? { usage } : {}
|
|
4371
|
+
...usage !== void 0 ? { usage } : {},
|
|
4372
|
+
...usage !== void 0 ? { onError: (error) => effectiveLogger?.warn?.(`usage record failed: ${typeof error === "object" && error !== null && "message" in error ? String(error.message) : String(error)}`) } : {}
|
|
3604
4373
|
});
|
|
3605
4374
|
if (!isBuiltAdapter(built)) throw new Error(BAD_FACTORY_SHAPE_ERROR);
|
|
3606
4375
|
} catch (e) {
|
|
@@ -3608,31 +4377,75 @@ function createProviderPublisher(deps = {}) {
|
|
|
3608
4377
|
state.error = described.note;
|
|
3609
4378
|
warnBuildFailure(effectiveLogger, "ModelScope", described);
|
|
3610
4379
|
return {
|
|
3611
|
-
|
|
3612
|
-
|
|
4380
|
+
kind: "done",
|
|
4381
|
+
result: {
|
|
4382
|
+
ok: false,
|
|
4383
|
+
error: described.error
|
|
4384
|
+
}
|
|
3613
4385
|
};
|
|
3614
4386
|
}
|
|
3615
|
-
if (
|
|
3616
|
-
|
|
3617
|
-
|
|
4387
|
+
if (phaseOf(false) === "serving" && offerUnchanged()) return {
|
|
4388
|
+
kind: "done",
|
|
4389
|
+
result: {
|
|
4390
|
+
ok: true,
|
|
4391
|
+
skipped: true
|
|
4392
|
+
}
|
|
4393
|
+
};
|
|
4394
|
+
return {
|
|
4395
|
+
kind: "commit",
|
|
4396
|
+
llm,
|
|
4397
|
+
built,
|
|
4398
|
+
previousBuilt,
|
|
4399
|
+
previousEntries,
|
|
4400
|
+
previousEnabledIds,
|
|
4401
|
+
previousUnavailable
|
|
3618
4402
|
};
|
|
4403
|
+
};
|
|
4404
|
+
/**
|
|
4405
|
+
* commit 阶段:一次 publish 的**不可挂起**半边。
|
|
4406
|
+
*
|
|
4407
|
+
* **这个函数刻意不是 `async`**——语言禁止在非 async 函数里写 `await`,所以
|
|
4408
|
+
* 从下面的 disposed 复查到 `swapRegistration` 内部的 `registerAdapter` 之间
|
|
4409
|
+
* **不可能**插入挂起点。旧版靠一句「这个位置严密:从这里到 registerAdapter
|
|
4410
|
+
* 之间没有任何 await」的注释论证这件事;现在由函数签名本身保证——想在这里
|
|
4411
|
+
* 加 await,编译期就过不去。临界区的正确性由结构承载,不由注释承载。
|
|
4412
|
+
*
|
|
4413
|
+
* 为什么这道复查必须有(gather 入口那次只覆盖到第一个 await,而注册发生在
|
|
4414
|
+
* **最后一次** await 之后):teardown 落在这段窗口里时,入口闸门不会重跑,
|
|
4415
|
+
* 于是 registerAdapter 把适配器注册进一个**已经把本插件撤下的 Host**,而
|
|
4416
|
+
* teardown 早就跑完、release 永不调用——泄漏的对持有 tokenStore 与整个
|
|
4417
|
+
* Cordis ctx 的闭包,形成僵尸 provider:picker 里那个 provider 还在、还能
|
|
4418
|
+
* 路由,而面板路由已注销,用户没有界面能关掉它。
|
|
4419
|
+
*
|
|
4420
|
+
* 为什么 peer 的 fiber 兜底救不了:`registerAdapter` 用的是 **llm 服务自己的**
|
|
4421
|
+
* `ctx.effect`(peer 源码里 `.bind(this)` 到 llm 服务),插件卸载不会 dispose
|
|
4422
|
+
* 它,宿主注册表永久持有该 adapter。
|
|
4423
|
+
*
|
|
4424
|
+
* 若 teardown 发生在本次检查**之后**,它执行 release() 时能看到新写入的
|
|
4425
|
+
* releaseAdapter 并摘掉——两种时序都正确。检查刻意留在本文件(publisher)而
|
|
4426
|
+
* **不**放进 `swapRegistration`:那是 publish-core 的共享骨头,不该知道
|
|
4427
|
+
* disposed 的概念。
|
|
4428
|
+
* @param {Extract<PublishPlan, {kind: "commit"}>} plan - gather 产出的提交计划。
|
|
4429
|
+
* @returns {{ok: boolean, skipped?: boolean, error?: unknown}}
|
|
4430
|
+
*/
|
|
4431
|
+
const commitPublish = (plan) => {
|
|
3619
4432
|
if (queue.isDisposed()) return {
|
|
3620
4433
|
ok: false,
|
|
3621
4434
|
skipped: true
|
|
3622
4435
|
};
|
|
3623
4436
|
const requestedSignature = catalogSignature(state.entries, state.enabledIds);
|
|
3624
4437
|
const result = swapRegistration({
|
|
3625
|
-
llm,
|
|
3626
|
-
built,
|
|
3627
|
-
previousBuilt,
|
|
4438
|
+
llm: plan.llm,
|
|
4439
|
+
built: plan.built,
|
|
4440
|
+
previousBuilt: plan.previousBuilt,
|
|
3628
4441
|
state,
|
|
3629
4442
|
release,
|
|
3630
4443
|
registerPair,
|
|
3631
4444
|
emit,
|
|
3632
4445
|
onRollback: () => {
|
|
3633
|
-
state.entries = previousEntries;
|
|
3634
|
-
state.enabledIds = previousEnabledIds;
|
|
3635
|
-
state.unavailableIds = previousUnavailable;
|
|
4446
|
+
state.entries = plan.previousEntries;
|
|
4447
|
+
state.enabledIds = plan.previousEnabledIds;
|
|
4448
|
+
state.unavailableIds = plan.previousUnavailable;
|
|
3636
4449
|
}
|
|
3637
4450
|
});
|
|
3638
4451
|
syncSignaturesAfterPublish(state);
|
|
@@ -3640,6 +4453,22 @@ function createProviderPublisher(deps = {}) {
|
|
|
3640
4453
|
return result;
|
|
3641
4454
|
};
|
|
3642
4455
|
/**
|
|
4456
|
+
* 为一个目录/允许清单快照(重)构建并注册 provider。
|
|
4457
|
+
*
|
|
4458
|
+
* 形状 = gather(可挂起)+ commit(不可挂起):全部 await 住在前者,后者是
|
|
4459
|
+
* 非 async 函数。重建并重注册而非原地改:`PiAiAdapter` 内部 memoize profiles
|
|
4460
|
+
* 快照,只有新的注册才能改变提供的模型列表。注册失败时**恢复上一对**,所以
|
|
4461
|
+
* 坏 publish 永远不能把已在服务的模型拉下来。
|
|
4462
|
+
* @param {object[]} entries - 归一目录条目。
|
|
4463
|
+
* @param {string[]} enabledIds - 允许清单(空 = 全部)。
|
|
4464
|
+
* @param {string[]} [unavailableModelIds] - 要从 picker 的 offer 里丢掉的额度耗尽 id。
|
|
4465
|
+
* @returns {Promise<{ok: boolean, skipped?: boolean, error?: unknown}>}
|
|
4466
|
+
*/
|
|
4467
|
+
const publishProviderOnce = async (entries, enabledIds, unavailableModelIds = []) => {
|
|
4468
|
+
const plan = await gatherPublish(entries, enabledIds, unavailableModelIds);
|
|
4469
|
+
return plan.kind === "done" ? plan.result : commitPublish(plan);
|
|
4470
|
+
};
|
|
4471
|
+
/**
|
|
3643
4472
|
* publish,排到所有其他在途 publish 之后。
|
|
3644
4473
|
*
|
|
3645
4474
|
* 包装的存在是为了没有任何调用方要记得队列:挂载 seed、目录轮询、provider 开关、
|
|
@@ -3669,6 +4498,10 @@ function createProviderPublisher(deps = {}) {
|
|
|
3669
4498
|
* 它只回答「重建会不会改变什么」:目录顺序里的模型 id,每个带上 descriptor 用的
|
|
3670
4499
|
* 同一个 vision 判定(id 没变但模态翻了也必须重建),加上允许清单。目录条目里其他
|
|
3671
4500
|
* 东西变了不影响注册的 offer。
|
|
4501
|
+
*
|
|
4502
|
+
* **不含展示名 `name`**:模型展示名变化不影响注册的 offer(descriptor 里的 `name`
|
|
4503
|
+
* 只是显示用的),重新注册是浪费——期间在途请求可能被打断。展示名变化不改变
|
|
4504
|
+
* 模型的能力、窗口或输出上限,所以不参与签名。
|
|
3672
4505
|
* @param {object[]} entries - 归一目录条目。
|
|
3673
4506
|
* @param {string[]} enabledIds - 允许清单(空 = 全部)。
|
|
3674
4507
|
* @returns {string}
|
|
@@ -3676,8 +4509,7 @@ function createProviderPublisher(deps = {}) {
|
|
|
3676
4509
|
function catalogSignature(entries, enabledIds) {
|
|
3677
4510
|
return `${(Array.isArray(entries) ? entries : []).map((entry) => {
|
|
3678
4511
|
const e = entry;
|
|
3679
|
-
|
|
3680
|
-
return `${id}:v${isVisionModel(e) ? 1 : 0}:n${str(e?.name, id)}:c${typeof e?.contextWindow === "number" ? e.contextWindow : ""}:o${typeof e?.maxOutputLength === "number" ? e.maxOutputLength : ""}`;
|
|
4512
|
+
return `${str(e?.id, "")}:v${isVisionModel(e) ? 1 : 0}:c${typeof e?.contextWindow === "number" ? e.contextWindow : ""}:o${typeof e?.maxOutputLength === "number" ? e.maxOutputLength : ""}`;
|
|
3681
4513
|
}).join(",")}|${(Array.isArray(enabledIds) ? enabledIds : []).join(",")}`;
|
|
3682
4514
|
}
|
|
3683
4515
|
/**
|
|
@@ -3713,6 +4545,127 @@ function syncSignaturesAfterPublish(state) {
|
|
|
3713
4545
|
state.quotaSignature = quotaSignatureOf(unavailable);
|
|
3714
4546
|
}
|
|
3715
4547
|
|
|
4548
|
+
//#endregion
|
|
4549
|
+
//#region src/host/provider-reconcile.ts
|
|
4550
|
+
/**
|
|
4551
|
+
* provider 注册的 **desired-state reconciler**——把两个各自为政的驱动收编成
|
|
4552
|
+
* 一个控制器。
|
|
4553
|
+
*
|
|
4554
|
+
* ## 它取代什么
|
|
4555
|
+
*
|
|
4556
|
+
* 改之前,往同一个注册态里写的有两个 driver:挂载 seed + 每 `pollSeconds`
|
|
4557
|
+
* 一次的目录轮询(`index.ts` 的 `runPoll`),以及四条 provider 路由的
|
|
4558
|
+
* 「保存后立即 publish」。两者没有共同的状态词汇表,于是 `runPoll` 不得不把
|
|
4559
|
+
* 窗口覆盖**读两遍**(fetch 前一次、fetch 后一次),「期间被改就跳过本轮」——
|
|
4560
|
+
* 那是没有 desired-state 控制器的典型补丁:丢一次更新、再等一个轮询周期。
|
|
4561
|
+
*
|
|
4562
|
+
* ## 模型
|
|
4563
|
+
*
|
|
4564
|
+
* - **desired state**(操作者想要什么)= `providerStore` 里的开关 + 允许
|
|
4565
|
+
* 清单 + 窗口覆盖。它只有一个家,轮询和路由都只读它、不各写一份;
|
|
4566
|
+
* - **reconcile 一遍** = 读 desired → 带窗口覆盖拉目录 → 复查 desired
|
|
4567
|
+
* (fetch 期间被改就重跑,不等下一轮)→ 把「当前目录 + 当前清单」交给
|
|
4568
|
+
* publisher(它自己重读开关,single source 不变);
|
|
4569
|
+
* - **触发方全部降级为「标记 dirty」**:挂载、轮询定时器、四条写路由。
|
|
4570
|
+
* 谁都不用记得「别人是不是也在 publish」——队列性质由本模块的串行链保证。
|
|
4571
|
+
*
|
|
4572
|
+
* ## 两个刻意的行为改变(相对旧 runPoll)
|
|
4573
|
+
*
|
|
4574
|
+
* 1. **fetch 失败不再发布空目录**。旧 `readEntries` 把 fetch 失败降级成
|
|
4575
|
+
* `entries: []` 再 publish——一次瞬时的目录抖动会把已服务的模型全部撤下
|
|
4576
|
+
* (破坏,不是重试)。这里失败只记原因、保留当前 offer,下一轮自然重试;
|
|
4577
|
+
* 轮询侧本来就该如此(旧 runPoll 正是这么做的),路由侧现在与它一致。
|
|
4578
|
+
* 2. **fetch 期间 desired 被改 → 重跑,不是跳过**。旧代码「跳过本轮、等
|
|
4579
|
+
* pollSeconds」;这里原地收敛,保存路由的 await 返回时新值一定已落地。
|
|
4580
|
+
*
|
|
4581
|
+
* peer-free:不 import 任何 Host peer。
|
|
4582
|
+
*
|
|
4583
|
+
* @module dsh-connect-modelscope-token-plan/provider-reconcile
|
|
4584
|
+
*/
|
|
4585
|
+
/**
|
|
4586
|
+
* 创建 provider reconciler。
|
|
4587
|
+
*
|
|
4588
|
+
* 串行链 + dirty 标志:每次触发(轮询/路由/挂载)都排在链尾,链上每一环先看
|
|
4589
|
+
* dirty 再决定要不要真跑——在途那一轮会替后来者消费 dirty,所以 N 次连续触发
|
|
4590
|
+
* 最多收敛成两遍 reconcile,不会排 N 遍。调用方 await 返回的 promise,即
|
|
4591
|
+
* 「我的触发已被消费」。
|
|
4592
|
+
* @param {ProviderReconcilerDeps} deps - wiring 子集。
|
|
4593
|
+
* @returns {{reconcile: () => Promise<void>}} 触发一次收敛。
|
|
4594
|
+
*/
|
|
4595
|
+
function createProviderReconciler(deps) {
|
|
4596
|
+
const { providerStore, inference, publisher, logger } = deps;
|
|
4597
|
+
/**
|
|
4598
|
+
* 读一次 desired。三路都 `.catch(() => null)`:读不到按「未设置」降级,
|
|
4599
|
+
* 与开关/清单/覆盖各自的读侧纪律一致——一次读失败不该把收敛打掉。
|
|
4600
|
+
* @returns {Promise<Desired>}
|
|
4601
|
+
*/
|
|
4602
|
+
const readDesired = async () => {
|
|
4603
|
+
const [enabled, enabledIds, contextWindow] = await Promise.all([
|
|
4604
|
+
providerStore.enabled().catch(() => null),
|
|
4605
|
+
providerStore.enabledIds().catch(() => null),
|
|
4606
|
+
providerStore.contextWindow().catch(() => null)
|
|
4607
|
+
]);
|
|
4608
|
+
return {
|
|
4609
|
+
enabled,
|
|
4610
|
+
enabledIds,
|
|
4611
|
+
contextWindow
|
|
4612
|
+
};
|
|
4613
|
+
};
|
|
4614
|
+
/** desired 的稳定键:用于「fetch 期间被改了吗」的比对。 */
|
|
4615
|
+
const desiredKey = (desired) => JSON.stringify([
|
|
4616
|
+
desired.enabled,
|
|
4617
|
+
desired.enabledIds,
|
|
4618
|
+
desired.contextWindow
|
|
4619
|
+
]);
|
|
4620
|
+
/**
|
|
4621
|
+
* 收敛一遍:读 desired → 拉目录 → 复查 desired → publish。
|
|
4622
|
+
* @returns {Promise<void>}
|
|
4623
|
+
*/
|
|
4624
|
+
const runOnce = async () => {
|
|
4625
|
+
const before = await readDesired();
|
|
4626
|
+
let catalog;
|
|
4627
|
+
try {
|
|
4628
|
+
catalog = await inference.fetchModels(before.contextWindow);
|
|
4629
|
+
} catch (error) {
|
|
4630
|
+
logger?.warn?.(`${name}: catalog fetch failed (${redactSecrets(errMsg(error))}) — the current offer stays registered; the next pass retries`);
|
|
4631
|
+
return;
|
|
4632
|
+
}
|
|
4633
|
+
const after = await readDesired();
|
|
4634
|
+
if (desiredKey(after) !== desiredKey(before)) {
|
|
4635
|
+
dirty = true;
|
|
4636
|
+
return;
|
|
4637
|
+
}
|
|
4638
|
+
const entries = Array.isArray(catalog?.entries) ? catalog.entries : [];
|
|
4639
|
+
const enabledIds = resolveEnabledIds(after.enabledIds, publisher.state.enabledIds);
|
|
4640
|
+
try {
|
|
4641
|
+
await publisher.publish(entries, enabledIds, []);
|
|
4642
|
+
} catch (error) {
|
|
4643
|
+
logger?.warn?.(`${name}: publish failed (${redactSecrets(errMsg(error))}) — the catalog is current, only the provider registration did not land`);
|
|
4644
|
+
}
|
|
4645
|
+
};
|
|
4646
|
+
/** 串行链:每次触发排到链尾;在途那一轮替后来者消费 dirty。 */
|
|
4647
|
+
let chain = Promise.resolve();
|
|
4648
|
+
/** 有新触发待消费。闭包状态,不暴露——触发方只看得见 {@link reconcile}。 */
|
|
4649
|
+
let dirty = false;
|
|
4650
|
+
/**
|
|
4651
|
+
* 触发一次收敛。幂等且可重入:在途时只置 dirty,由在途那一轮替本调用消费
|
|
4652
|
+
* (N 次连续触发最多收敛成两遍 reconcile,不会排 N 遍)。
|
|
4653
|
+
* @returns {Promise<void>} 本触发已被消费(注册态已反映 desired)后 resolve。
|
|
4654
|
+
*/
|
|
4655
|
+
const reconcile = () => {
|
|
4656
|
+
dirty = true;
|
|
4657
|
+
return chain = chain.then(async () => {
|
|
4658
|
+
let guard = 0;
|
|
4659
|
+
while (dirty) {
|
|
4660
|
+
dirty = false;
|
|
4661
|
+
await runOnce();
|
|
4662
|
+
if (++guard > 20) break;
|
|
4663
|
+
}
|
|
4664
|
+
});
|
|
4665
|
+
};
|
|
4666
|
+
return { reconcile };
|
|
4667
|
+
}
|
|
4668
|
+
|
|
3716
4669
|
//#endregion
|
|
3717
4670
|
//#region src/host/routes/paths.ts
|
|
3718
4671
|
/**
|
|
@@ -3729,6 +4682,7 @@ const TOKEN_FORGET_PATH = "/api/dsh-connect-modelscope-token-plan/token/forget";
|
|
|
3729
4682
|
const PROBE_PATH = "/api/dsh-connect-modelscope-token-plan/probe";
|
|
3730
4683
|
const PROVIDER_PATH = "/api/dsh-connect-modelscope-token-plan/provider";
|
|
3731
4684
|
const DRAW_PATH = "/api/dsh-connect-modelscope-token-plan/draw";
|
|
4685
|
+
const TREND_WINDOW_PATH = "/api/dsh-connect-modelscope-token-plan/trend/window";
|
|
3732
4686
|
|
|
3733
4687
|
//#endregion
|
|
3734
4688
|
//#region src/host/snapshot-aggregate.ts
|
|
@@ -3767,38 +4721,21 @@ async function soft(promise) {
|
|
|
3767
4721
|
const code = error.code;
|
|
3768
4722
|
return {
|
|
3769
4723
|
ok: false,
|
|
3770
|
-
error: redactSecrets
|
|
4724
|
+
error: redactSecrets(errMsg(error)),
|
|
3771
4725
|
code: typeof code === "string" ? code : CODE.INTERNAL_ERROR
|
|
3772
4726
|
};
|
|
3773
4727
|
}
|
|
3774
4728
|
}
|
|
3775
|
-
/**
|
|
3776
|
-
* Provider 状态块降级形状:读不到开关/注册状态/目录时,面板仍然得到一个完整
|
|
3777
|
-
* 可渲染的块(开关关、roster 空、error 说明原因),绝不让整条快照失败。
|
|
3778
|
-
*/
|
|
3779
|
-
function providerDegraded(error) {
|
|
3780
|
-
return {
|
|
3781
|
-
enabled: false,
|
|
3782
|
-
source: "config",
|
|
3783
|
-
llmAvailable: false,
|
|
3784
|
-
registered: false,
|
|
3785
|
-
error,
|
|
3786
|
-
modelCount: 0,
|
|
3787
|
-
enabledCount: 0,
|
|
3788
|
-
allowed: "all",
|
|
3789
|
-
enabledIds: [],
|
|
3790
|
-
contextWindow: null,
|
|
3791
|
-
roster: []
|
|
3792
|
-
};
|
|
3793
|
-
}
|
|
3794
4729
|
/** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
|
|
3795
4730
|
async function buildSnapshotBody(wiring) {
|
|
3796
|
-
const { settings, tokenStore, usageStore, inference, providerStore, drawStore, publisher } = wiring;
|
|
4731
|
+
const { settings, tokenStore, usageStore, inference, providerStore, drawStore, usagePrefsStore, publisher } = wiring;
|
|
4732
|
+
const panelWindow = await usagePrefsStore.windowDays().catch(() => null);
|
|
4733
|
+
const windowDays = resolveWindowDays(panelWindow, settings.trendDays, settings.retentionDays);
|
|
3797
4734
|
const [tokenState, daily, perModel, trend, events, providerNote] = await Promise.all([
|
|
3798
4735
|
soft(tokenStore.state()),
|
|
3799
4736
|
soft(usageStore.daily()),
|
|
3800
4737
|
soft(usageStore.perModelToday()),
|
|
3801
|
-
soft(usageStore.trend(
|
|
4738
|
+
soft(usageStore.trend(windowDays)),
|
|
3802
4739
|
soft(usageStore.events()),
|
|
3803
4740
|
soft(providerStore.versionNote())
|
|
3804
4741
|
]);
|
|
@@ -3820,6 +4757,14 @@ async function buildSnapshotBody(wiring) {
|
|
|
3820
4757
|
if (!models.ok && models.code !== "network_error") shapeWarnings.push(`models: ${models.error}`);
|
|
3821
4758
|
if (!tokenKnownAbsent && !balance.ok && balance.code !== "network_error") shapeWarnings.push(`balance: ${balance.error}`);
|
|
3822
4759
|
if (providerNote.ok && providerNote.value !== null) shapeWarnings.push(providerNote.value);
|
|
4760
|
+
const drawNote = await soft((async () => drawStore.versionNote())());
|
|
4761
|
+
if (drawNote.ok && drawNote.value !== null) shapeWarnings.push(drawNote.value);
|
|
4762
|
+
const windowNote = await soft((async () => usagePrefsStore.versionNote())());
|
|
4763
|
+
if (windowNote.ok && windowNote.value !== null) shapeWarnings.push(windowNote.value);
|
|
4764
|
+
if (curatedCatalogStale()) {
|
|
4765
|
+
const age = curatedCatalogAgeDays();
|
|
4766
|
+
shapeWarnings.push(`context-catalog: curated windows were last verified on ${CURATED_AS_OF}` + (age === null ? "" : ` (${age} days ago)`) + " — re-check them against the current catalogue");
|
|
4767
|
+
}
|
|
3823
4768
|
const provider = await soft((async () => {
|
|
3824
4769
|
const panel = await providerStore.enabled().catch(() => null);
|
|
3825
4770
|
const enabled = resolveSwitchEnabled(panel, settings.registerProvider);
|
|
@@ -3827,10 +4772,9 @@ async function buildSnapshotBody(wiring) {
|
|
|
3827
4772
|
const roster = rosterWithAvailability(modelEntries, []);
|
|
3828
4773
|
const stored = await providerStore.enabledIds().catch(() => null);
|
|
3829
4774
|
const enabledIds = resolveEnabledIds(stored, publisher.state.enabledIds);
|
|
4775
|
+
const allowedList = parseAllowedList(enabledIds);
|
|
4776
|
+
const enabledCount = enabledCountOf(allowedList, roster.map((row) => row.id));
|
|
3830
4777
|
const contextWindow = await providerStore.contextWindow().catch(() => null);
|
|
3831
|
-
const allowed = resolveAllowedList(enabledIds);
|
|
3832
|
-
const allowSet = new Set(enabledIds);
|
|
3833
|
-
const enabledCount = allowed === "all" ? roster.length : allowed === "list" ? roster.filter((row) => allowSet.has(row.id)).length : 0;
|
|
3834
4778
|
return {
|
|
3835
4779
|
enabled,
|
|
3836
4780
|
source,
|
|
@@ -3839,7 +4783,7 @@ async function buildSnapshotBody(wiring) {
|
|
|
3839
4783
|
error: typeof publisher.state.error === "string" ? publisher.state.error : null,
|
|
3840
4784
|
modelCount: roster.length,
|
|
3841
4785
|
enabledCount,
|
|
3842
|
-
allowed,
|
|
4786
|
+
allowed: allowedList.mode,
|
|
3843
4787
|
enabledIds,
|
|
3844
4788
|
contextWindow,
|
|
3845
4789
|
roster
|
|
@@ -3848,19 +4792,26 @@ async function buildSnapshotBody(wiring) {
|
|
|
3848
4792
|
const draw = await soft((async () => {
|
|
3849
4793
|
const panel = await drawStore.enabled().catch(() => null);
|
|
3850
4794
|
const panelModel = await drawStore.modelId().catch(() => null);
|
|
3851
|
-
const
|
|
4795
|
+
const candidates = drawCandidates(modelEntries);
|
|
4796
|
+
const preferred = resolveSwitchValue(panelModel, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
|
|
4797
|
+
const choice = resolveDrawModelChoice(modelEntries, void 0, preferred ?? void 0);
|
|
3852
4798
|
return {
|
|
3853
4799
|
enabled: resolveSwitchEnabled(panel, settings.drawEnabled),
|
|
3854
4800
|
source: switchSource(panel),
|
|
3855
|
-
candidateIds,
|
|
3856
|
-
candidateCount:
|
|
3857
|
-
|
|
4801
|
+
candidateIds: candidates.map((row) => row.id),
|
|
4802
|
+
candidateCount: candidates.length,
|
|
4803
|
+
candidates,
|
|
4804
|
+
preferredModel: preferred,
|
|
3858
4805
|
preferredSource: switchSource(panelModel),
|
|
4806
|
+
effectiveModel: choice.id,
|
|
4807
|
+
effectiveSource: choice.source,
|
|
3859
4808
|
absentReason: drawToolNote(),
|
|
3860
4809
|
mountPending: resolveSwitchEnabled(panel, settings.drawEnabled) === true && drawToolNote() !== null,
|
|
3861
4810
|
toolName: "modelscope_draw_image"
|
|
3862
4811
|
};
|
|
3863
4812
|
})());
|
|
4813
|
+
if (!provider.ok) shapeWarnings.push(`provider source failed (${provider.code}): ${provider.error}`);
|
|
4814
|
+
if (!draw.ok) shapeWarnings.push(`draw source failed (${draw.code}): ${draw.error}`);
|
|
3864
4815
|
const usedLocal = daily.ok ? daily.value.calls : null;
|
|
3865
4816
|
return {
|
|
3866
4817
|
ok: true,
|
|
@@ -3900,7 +4851,9 @@ async function buildSnapshotBody(wiring) {
|
|
|
3900
4851
|
},
|
|
3901
4852
|
events: events.ok ? events.value : [],
|
|
3902
4853
|
trend: {
|
|
3903
|
-
days:
|
|
4854
|
+
days: windowDays,
|
|
4855
|
+
windowDays: panelWindow,
|
|
4856
|
+
maxDays: settings.retentionDays,
|
|
3904
4857
|
buckets: trend.ok ? trend.value : []
|
|
3905
4858
|
},
|
|
3906
4859
|
models: {
|
|
@@ -3909,7 +4862,7 @@ async function buildSnapshotBody(wiring) {
|
|
|
3909
4862
|
sample: modelIds.slice(0, 20),
|
|
3910
4863
|
error: models.ok ? null : models.error
|
|
3911
4864
|
},
|
|
3912
|
-
provider
|
|
4865
|
+
...provider.ok ? { provider: provider.value } : {},
|
|
3913
4866
|
...draw.ok ? { draw: draw.value } : {},
|
|
3914
4867
|
shapeWarnings,
|
|
3915
4868
|
quotaError: null
|
|
@@ -3958,7 +4911,7 @@ async function readJsonBody(request, limit = MAX_JSON_BODY_BYTES) {
|
|
|
3958
4911
|
received += buffer.byteLength;
|
|
3959
4912
|
if (received > limit) return {
|
|
3960
4913
|
ok: false,
|
|
3961
|
-
error:
|
|
4914
|
+
error: `request body is too large (max ${limit} bytes)`
|
|
3962
4915
|
};
|
|
3963
4916
|
chunks.push(buffer);
|
|
3964
4917
|
}
|
|
@@ -4008,7 +4961,7 @@ function refuseMethod(response) {
|
|
|
4008
4961
|
* 日志或响应)。redactSecrets 幂等,双重应用无害。
|
|
4009
4962
|
*/
|
|
4010
4963
|
function redactedError(error) {
|
|
4011
|
-
const text = redactSecrets
|
|
4964
|
+
const text = redactSecrets(errMsg(error));
|
|
4012
4965
|
return text.trim() === "" ? "request failed" : text;
|
|
4013
4966
|
}
|
|
4014
4967
|
/**
|
|
@@ -4314,22 +5267,22 @@ function registerProbeRoute(ctx, wiring) {
|
|
|
4314
5267
|
* @module dsh-connect-modelscope-token-plan/routes/provider
|
|
4315
5268
|
*/
|
|
4316
5269
|
/**
|
|
4317
|
-
*
|
|
5270
|
+
* 读目录条目,读不到**抛错**(让 snapshot() 走 respondError 返回 ok:false)。
|
|
4318
5271
|
*
|
|
4319
5272
|
* 读目录前先取**当前窗口覆盖**传给 `fetchModels`:条目在归一前打上覆盖标记,
|
|
4320
5273
|
* descriptor / roster / catalogSignature 都从这份条目派生。每个写路由(开关、
|
|
4321
5274
|
* 清单、窗口、reset)都经这里取条目再 publish,所以任何一次重注册都带着最新
|
|
4322
5275
|
* 覆盖——否则先存窗口、再存清单会把未打标的目录重新注册上去,覆盖静默失效到
|
|
4323
5276
|
* 下一轮轮询。
|
|
5277
|
+
*
|
|
5278
|
+
* **为什么抛错而不是降级成空目录**:空 roster 与「真的没有模型」在面板上长得
|
|
5279
|
+
* 一模一样(modelCount=0),而真相是「目录拉不到」——这与 snapshot-aggregate
|
|
5280
|
+
* 的 `models.available=false` 语义矛盾(后者明确区分「0 个模型」与「读不到」)。
|
|
5281
|
+
* 抛错让 GET 返回 ok:false + error,面板能显示「目录拉不到」而不是「0 个模型」。
|
|
4324
5282
|
*/
|
|
4325
5283
|
async function readEntries$1(inference, providerStore) {
|
|
4326
5284
|
const contextWindow = await optional(providerStore?.contextWindow?.(), null);
|
|
4327
|
-
const catalog = await inference.fetchModels(contextWindow)
|
|
4328
|
-
return {
|
|
4329
|
-
entries: [],
|
|
4330
|
-
__error: error
|
|
4331
|
-
};
|
|
4332
|
-
});
|
|
5285
|
+
const catalog = await inference.fetchModels(contextWindow);
|
|
4333
5286
|
return Array.isArray(catalog?.entries) ? catalog.entries : [];
|
|
4334
5287
|
}
|
|
4335
5288
|
/**
|
|
@@ -4342,18 +5295,12 @@ async function readEnabledIds(store, published) {
|
|
|
4342
5295
|
return resolveEnabledIds(await optional(store.enabledIds?.(), null), published);
|
|
4343
5296
|
}
|
|
4344
5297
|
/**
|
|
4345
|
-
* `allowed`
|
|
4346
|
-
* {@link
|
|
4347
|
-
*
|
|
4348
|
-
*
|
|
4349
|
-
* `"
|
|
4350
|
-
* `isModelEnabled` 口径一致。
|
|
4351
|
-
* @param {string[]} enabledIds - 允许清单。
|
|
4352
|
-
* @returns {"all"|"none"|"list"}
|
|
5298
|
+
* `allowed` 的分类与 `enabledCount` 的计数:都从 `shared/allow-list.ts` 的
|
|
5299
|
+
* {@link parseAllowedList} 联合上取(fix D:快照与 provider 路由以前各写一套
|
|
5300
|
+
* 分类,会漂移;旧版 `allowedOf` 只认「恰好哨兵」为 `"none"`,
|
|
5301
|
+
* `["__hide_all__","other"]` 会被错判成 `"list"`——统一到联合后,含哨兵即
|
|
5302
|
+
* `"none"`,与 `filterByEnabled` / `isModelEnabled` 同一份解释)。
|
|
4353
5303
|
*/
|
|
4354
|
-
function allowedOf(enabledIds) {
|
|
4355
|
-
return resolveAllowedList(enabledIds);
|
|
4356
|
-
}
|
|
4357
5304
|
/**
|
|
4358
5305
|
* 注册 provider 路由(三条路径)。wiring 子集:settings / providerStore /
|
|
4359
5306
|
* publisher / inference / logger。
|
|
@@ -4362,22 +5309,34 @@ function allowedOf(enabledIds) {
|
|
|
4362
5309
|
* @returns {Function} 依次调用三个 `off()` 的注销回调。
|
|
4363
5310
|
*/
|
|
4364
5311
|
function registerProviderRoute(ctx, wiring) {
|
|
4365
|
-
const { settings, providerStore, publisher, inference, logger } = wiring;
|
|
5312
|
+
const { settings, providerStore, publisher, reconcile, inference, logger } = wiring;
|
|
4366
5313
|
/**
|
|
4367
5314
|
* 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
|
|
4368
5315
|
* 状态是什么」说两套话。
|
|
5316
|
+
*
|
|
5317
|
+
* **目录读不到怎么办**:GET 是权威读路径,读不到就让它失败(handler 转
|
|
5318
|
+
* `respondError`,面板看见「目录拉不到」而不是「0 个模型」)。POST 的响应只是
|
|
5319
|
+
* 保存后的回读,而此时**保存已经成功**——不能把一次成功的写报成失败,所以
|
|
5320
|
+
* `tolerateCatalogFailure` 让它降级为空 roster 并带上 `catalogError` 说明原因。
|
|
4369
5321
|
* @param {object} [extra] - 调用方要追加/覆盖的字段。
|
|
5322
|
+
* @param {object} [options] - `{ tolerateCatalogFailure }`:写成功后的回读宽容模式。
|
|
4370
5323
|
* @returns {Promise<object>} 响应体。
|
|
4371
5324
|
*/
|
|
4372
|
-
const snapshot = async (extra = {}) => {
|
|
5325
|
+
const snapshot = async (extra = {}, options = {}) => {
|
|
4373
5326
|
const panelSwitch = await optional(providerStore.enabled());
|
|
4374
5327
|
const enabledIds = await readEnabledIds(providerStore, publisher.state.enabledIds);
|
|
4375
5328
|
const contextWindow = await optional(providerStore.contextWindow?.(), null);
|
|
4376
|
-
|
|
5329
|
+
let entries = [];
|
|
5330
|
+
let catalogError = null;
|
|
5331
|
+
try {
|
|
5332
|
+
entries = await readEntries$1(inference, providerStore);
|
|
5333
|
+
} catch (error) {
|
|
5334
|
+
if (options.tolerateCatalogFailure !== true) throw error;
|
|
5335
|
+
catalogError = redactedError(error);
|
|
5336
|
+
}
|
|
4377
5337
|
const roster = rosterWithAvailability(entries, []);
|
|
4378
|
-
const
|
|
5338
|
+
const allowedList = parseAllowedList(enabledIds);
|
|
4379
5339
|
const modelCount = roster.length;
|
|
4380
|
-
const allowSet = new Set(enabledIds);
|
|
4381
5340
|
return {
|
|
4382
5341
|
ok: true,
|
|
4383
5342
|
enabled: resolveSwitchEnabled(panelSwitch, settings.registerProvider),
|
|
@@ -4386,11 +5345,12 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
4386
5345
|
registered: publisher.state.registered === true,
|
|
4387
5346
|
error: typeof publisher.state.error === "string" ? publisher.state.error : null,
|
|
4388
5347
|
modelCount,
|
|
4389
|
-
enabledCount:
|
|
4390
|
-
allowed,
|
|
5348
|
+
enabledCount: enabledCountOf(allowedList, roster.map((row) => row.id)),
|
|
5349
|
+
allowed: allowedList.mode,
|
|
4391
5350
|
enabledIds,
|
|
4392
5351
|
contextWindow,
|
|
4393
5352
|
roster,
|
|
5353
|
+
...catalogError !== null ? { catalogError } : {},
|
|
4394
5354
|
...extra
|
|
4395
5355
|
};
|
|
4396
5356
|
};
|
|
@@ -4401,7 +5361,11 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
4401
5361
|
handler: withOrigin(async (request, response) => {
|
|
4402
5362
|
const method = request.method === void 0 ? "GET" : request.method;
|
|
4403
5363
|
if (method === "GET") {
|
|
4404
|
-
|
|
5364
|
+
try {
|
|
5365
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5366
|
+
} catch (error) {
|
|
5367
|
+
respondError(response, error);
|
|
5368
|
+
}
|
|
4405
5369
|
return;
|
|
4406
5370
|
}
|
|
4407
5371
|
if (method !== "POST") {
|
|
@@ -4419,13 +5383,12 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
4419
5383
|
}
|
|
4420
5384
|
try {
|
|
4421
5385
|
await providerStore.save(body.enabled);
|
|
4422
|
-
|
|
4423
|
-
await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
|
|
5386
|
+
await reconcile();
|
|
4424
5387
|
} catch (error) {
|
|
4425
5388
|
respondError(response, error);
|
|
4426
5389
|
return;
|
|
4427
5390
|
}
|
|
4428
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5391
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
4429
5392
|
}, settings.allowedHosts)
|
|
4430
5393
|
}));
|
|
4431
5394
|
offs.push(ctx.webServer.register({
|
|
@@ -4448,13 +5411,12 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
4448
5411
|
const ids = body.enabledIds.filter((id) => typeof id === "string" && id !== "");
|
|
4449
5412
|
try {
|
|
4450
5413
|
await providerStore.saveEnabledIds?.(ids);
|
|
4451
|
-
|
|
4452
|
-
await publisher.publish(entries, ids, []);
|
|
5414
|
+
await reconcile();
|
|
4453
5415
|
} catch (error) {
|
|
4454
5416
|
respondError(response, error);
|
|
4455
5417
|
return;
|
|
4456
5418
|
}
|
|
4457
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5419
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
4458
5420
|
}, settings.allowedHosts)
|
|
4459
5421
|
}));
|
|
4460
5422
|
offs.push(ctx.webServer.register({
|
|
@@ -4476,13 +5438,12 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
4476
5438
|
}
|
|
4477
5439
|
try {
|
|
4478
5440
|
await providerStore.saveContextWindow?.(body.contextWindow);
|
|
4479
|
-
|
|
4480
|
-
await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
|
|
5441
|
+
await reconcile();
|
|
4481
5442
|
} catch (error) {
|
|
4482
5443
|
respondError(response, error);
|
|
4483
5444
|
return;
|
|
4484
5445
|
}
|
|
4485
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5446
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
4486
5447
|
}, settings.allowedHosts)
|
|
4487
5448
|
}));
|
|
4488
5449
|
offs.push(ctx.webServer.register({
|
|
@@ -4500,13 +5461,12 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
4500
5461
|
try {
|
|
4501
5462
|
await providerStore.forget();
|
|
4502
5463
|
await providerStore.saveEnabledIds?.([]);
|
|
4503
|
-
|
|
4504
|
-
await publisher.publish(entries, [], []);
|
|
5464
|
+
await reconcile();
|
|
4505
5465
|
} catch (error) {
|
|
4506
5466
|
respondError(response, error);
|
|
4507
5467
|
return;
|
|
4508
5468
|
}
|
|
4509
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5469
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
4510
5470
|
}, settings.allowedHosts)
|
|
4511
5471
|
}));
|
|
4512
5472
|
return () => {
|
|
@@ -4570,16 +5530,20 @@ function registerDrawRoute(ctx, wiring) {
|
|
|
4570
5530
|
const panelSwitch = await optional(drawStore.enabled());
|
|
4571
5531
|
const panelModelId = await optional(drawStore.modelId());
|
|
4572
5532
|
const entries = await readEntries(inference);
|
|
4573
|
-
const
|
|
4574
|
-
const
|
|
5533
|
+
const candidates = drawCandidates(entries);
|
|
5534
|
+
const preferredModelId = resolveSwitchValue(panelModelId, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
|
|
5535
|
+
const choice = resolveDrawModelChoice(entries, void 0, preferredModelId ?? void 0);
|
|
4575
5536
|
return {
|
|
4576
5537
|
ok: true,
|
|
4577
5538
|
enabled: resolveSwitchEnabled(panelSwitch, settings.drawEnabled),
|
|
4578
5539
|
source: switchSource(panelSwitch),
|
|
4579
|
-
candidateIds,
|
|
4580
|
-
candidateCount:
|
|
4581
|
-
|
|
5540
|
+
candidateIds: candidates.map((row) => row.id),
|
|
5541
|
+
candidateCount: candidates.length,
|
|
5542
|
+
candidates,
|
|
5543
|
+
preferredModel: preferredModelId,
|
|
4582
5544
|
preferredSource: switchSource(panelModelId),
|
|
5545
|
+
effectiveModel: choice.id,
|
|
5546
|
+
effectiveSource: choice.source,
|
|
4583
5547
|
absentReason: drawToolNote(),
|
|
4584
5548
|
/**
|
|
4585
5549
|
* 开关的保存**不会**改变本次挂载的工具。
|
|
@@ -4633,20 +5597,8 @@ function registerDrawRoute(ctx, wiring) {
|
|
|
4633
5597
|
return;
|
|
4634
5598
|
}
|
|
4635
5599
|
try {
|
|
4636
|
-
if (hasEnabled)
|
|
4637
|
-
|
|
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
|
-
}
|
|
5600
|
+
if (hasEnabled) await drawStore.saveEnabled(body.enabled);
|
|
5601
|
+
if (body.modelId !== void 0) await drawStore.saveModelId(body.modelId);
|
|
4650
5602
|
} catch (error) {
|
|
4651
5603
|
respondError(response, error);
|
|
4652
5604
|
return;
|
|
@@ -4667,11 +5619,7 @@ function registerDrawRoute(ctx, wiring) {
|
|
|
4667
5619
|
error: "ignored"
|
|
4668
5620
|
}));
|
|
4669
5621
|
try {
|
|
4670
|
-
|
|
4671
|
-
if (refusal !== null) {
|
|
4672
|
-
respondError(response, new Error(refusal));
|
|
4673
|
-
return;
|
|
4674
|
-
}
|
|
5622
|
+
await drawStore.reset();
|
|
4675
5623
|
} catch (error) {
|
|
4676
5624
|
respondError(response, error);
|
|
4677
5625
|
return;
|
|
@@ -4688,6 +5636,95 @@ function registerDrawRoute(ctx, wiring) {
|
|
|
4688
5636
|
};
|
|
4689
5637
|
}
|
|
4690
5638
|
|
|
5639
|
+
//#endregion
|
|
5640
|
+
//#region src/host/routes/usage-prefs.ts
|
|
5641
|
+
/**
|
|
5642
|
+
* 趋势图**显示窗口**路由:读当前窗口(GET)与保存(POST)。
|
|
5643
|
+
*
|
|
5644
|
+
* 一条路径两件事:
|
|
5645
|
+
* - GET `TREND_WINDOW_PATH` 读面板覆盖 + 生效天数 + 上界(无副作用);
|
|
5646
|
+
* - POST `TREND_WINDOW_PATH` body `{ windowDays: number|null }` → 保存
|
|
5647
|
+
* (`null` = 清除覆盖,回到 `trendDays` 配置默认)。
|
|
5648
|
+
*
|
|
5649
|
+
* **与 provider / draw 路由不同,本路由保存后不触发任何收敛**:显示窗口只影响
|
|
5650
|
+
* 快照里 `trend` 那一段,面板保存成功后自己调一次 `load()` 就当场回显;没有
|
|
5651
|
+
* 需要重建的注册态。
|
|
5652
|
+
*
|
|
5653
|
+
* 越界值**夹取而不是拒绝**:上界是保留窗口(`settings.retentionDays`),而
|
|
5654
|
+
* host-config 对配置里的同一个值也是夹——两处若一个夹一个拒,同一个越界值会因
|
|
5655
|
+
* 来源不同得到两个下场。类型给错(字符串 / 缺字段)仍然按 400 拒:那是形状错,
|
|
5656
|
+
* 不是范围错。
|
|
5657
|
+
*
|
|
5658
|
+
* peer-free:不 import 任何 Host peer。
|
|
5659
|
+
* @module dsh-connect-modelscope-token-plan/routes/usage-prefs
|
|
5660
|
+
*/
|
|
5661
|
+
/**
|
|
5662
|
+
* 注册趋势窗口路由。
|
|
5663
|
+
* @param ctx - host 根上下文(只用 `ctx.webServer`)。
|
|
5664
|
+
* @param {UsagePrefsRouteWiring} wiring - 本路由读到的子集。
|
|
5665
|
+
* @returns {Function} 注销回调。
|
|
5666
|
+
*/
|
|
5667
|
+
function registerUsagePrefsRoute(ctx, wiring) {
|
|
5668
|
+
const { settings, usagePrefsStore, logger } = wiring;
|
|
5669
|
+
/**
|
|
5670
|
+
* 组装响应。GET 与写成功后的响应共用,两个方向不可能对「当前窗口」说两套话。
|
|
5671
|
+
* @param {object} [extra] - 追加/覆盖字段。
|
|
5672
|
+
* @returns {Promise<object>} 响应体。
|
|
5673
|
+
*/
|
|
5674
|
+
const snapshot = async (extra = {}) => {
|
|
5675
|
+
const panel = await optional(usagePrefsStore.windowDays());
|
|
5676
|
+
return {
|
|
5677
|
+
ok: true,
|
|
5678
|
+
windowDays: panel,
|
|
5679
|
+
days: resolveWindowDays(panel, settings.trendDays, settings.retentionDays),
|
|
5680
|
+
maxDays: settings.retentionDays,
|
|
5681
|
+
source: switchSource(panel),
|
|
5682
|
+
...extra
|
|
5683
|
+
};
|
|
5684
|
+
};
|
|
5685
|
+
const off = ctx.webServer.register({
|
|
5686
|
+
kind: "exact",
|
|
5687
|
+
path: TREND_WINDOW_PATH,
|
|
5688
|
+
handler: withOrigin(async (request, response) => {
|
|
5689
|
+
const method = request.method === void 0 ? "GET" : request.method;
|
|
5690
|
+
if (method === "GET") {
|
|
5691
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5692
|
+
return;
|
|
5693
|
+
}
|
|
5694
|
+
if (method !== "POST") {
|
|
5695
|
+
refuseMethod(response);
|
|
5696
|
+
return;
|
|
5697
|
+
}
|
|
5698
|
+
const body = await readJsonBodyOr400(request, response);
|
|
5699
|
+
if (body === null) return;
|
|
5700
|
+
const isNull = body.windowDays === null;
|
|
5701
|
+
const isNumber = typeof body.windowDays === "number" && Number.isFinite(body.windowDays);
|
|
5702
|
+
if (!isNull && !isNumber) {
|
|
5703
|
+
writeJson(response, 400, {
|
|
5704
|
+
ok: false,
|
|
5705
|
+
error: "expected { windowDays: number | null }"
|
|
5706
|
+
}, { "cache-control": "no-store" });
|
|
5707
|
+
return;
|
|
5708
|
+
}
|
|
5709
|
+
const saved = isNull ? null : Math.min(Math.max(1, Math.floor(body.windowDays)), settings.retentionDays);
|
|
5710
|
+
try {
|
|
5711
|
+
await usagePrefsStore.saveWindowDays(saved);
|
|
5712
|
+
} catch (error) {
|
|
5713
|
+
respondError(response, error);
|
|
5714
|
+
return;
|
|
5715
|
+
}
|
|
5716
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5717
|
+
}, settings.allowedHosts)
|
|
5718
|
+
});
|
|
5719
|
+
return () => {
|
|
5720
|
+
try {
|
|
5721
|
+
off();
|
|
5722
|
+
} catch (error) {
|
|
5723
|
+
logger?.warn?.(`trend window route unregister failed: ${redactedError(error)}`);
|
|
5724
|
+
}
|
|
5725
|
+
};
|
|
5726
|
+
}
|
|
5727
|
+
|
|
4691
5728
|
//#endregion
|
|
4692
5729
|
//#region src/host/routes.ts
|
|
4693
5730
|
/**
|
|
@@ -4704,7 +5741,8 @@ function registerRoutes(ctx, wiring) {
|
|
|
4704
5741
|
...registerTokenRoute(ctx, wiring),
|
|
4705
5742
|
registerProbeRoute(ctx, wiring),
|
|
4706
5743
|
registerProviderRoute(ctx, wiring),
|
|
4707
|
-
registerDrawRoute(ctx, wiring)
|
|
5744
|
+
registerDrawRoute(ctx, wiring),
|
|
5745
|
+
registerUsagePrefsRoute(ctx, wiring)
|
|
4708
5746
|
];
|
|
4709
5747
|
}
|
|
4710
5748
|
|
|
@@ -4752,7 +5790,7 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
4752
5790
|
const usageStore = createFileUsageStore({
|
|
4753
5791
|
name,
|
|
4754
5792
|
profile,
|
|
4755
|
-
|
|
5793
|
+
retentionDays: settings.retentionDays,
|
|
4756
5794
|
maxEvents: settings.maxEvents,
|
|
4757
5795
|
logger: ctx.logger
|
|
4758
5796
|
});
|
|
@@ -4770,6 +5808,10 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
4770
5808
|
profile,
|
|
4771
5809
|
logger: ctx.logger
|
|
4772
5810
|
});
|
|
5811
|
+
const usagePrefsStore = createFileUsagePrefsStore({
|
|
5812
|
+
profile,
|
|
5813
|
+
logger: ctx.logger
|
|
5814
|
+
});
|
|
4773
5815
|
const publisher = createProviderPublisher({
|
|
4774
5816
|
settings,
|
|
4775
5817
|
panelSwitch: () => providerStore.enabled(),
|
|
@@ -4786,49 +5828,13 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
4786
5828
|
...deps.loadAdapterModule ? { loadAdapterModule: deps.loadAdapterModule } : {},
|
|
4787
5829
|
logger: ctx.logger
|
|
4788
5830
|
});
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
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` 同一模式。
|
|
4809
|
-
*
|
|
4810
|
-
* fetch **后**再读一次:目录拉取可能耗时数百毫秒,期间用户可能保存了新覆盖——
|
|
4811
|
-
* 保存路由已经用新值 publish 过,此时用旧标条目 publish 反而把注册**倒退**一个
|
|
4812
|
-
* 周期;这种窗口内跳过本轮即可(下一轮轮询自然带新值)。清单沿用 fetch 后读的
|
|
4813
|
-
* 老顺序(捕获 fetch 期间的编辑),两条时序理由同源。
|
|
4814
|
-
* @returns {Promise<void>}
|
|
4815
|
-
*/
|
|
4816
|
-
const runPoll = async () => {
|
|
4817
|
-
const contextWindow = await providerStore.contextWindow().catch(() => null);
|
|
4818
|
-
let catalog = null;
|
|
4819
|
-
try {
|
|
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
|
-
}
|
|
4831
|
-
};
|
|
5831
|
+
const { reconcile } = createProviderReconciler({
|
|
5832
|
+
settings,
|
|
5833
|
+
providerStore,
|
|
5834
|
+
inference,
|
|
5835
|
+
publisher,
|
|
5836
|
+
logger: ctx.logger
|
|
5837
|
+
});
|
|
4832
5838
|
const wiring = {
|
|
4833
5839
|
settings,
|
|
4834
5840
|
configError,
|
|
@@ -4837,15 +5843,17 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
4837
5843
|
inference,
|
|
4838
5844
|
providerStore,
|
|
4839
5845
|
drawStore,
|
|
5846
|
+
usagePrefsStore,
|
|
4840
5847
|
publisher,
|
|
5848
|
+
reconcile,
|
|
4841
5849
|
logger: ctx.logger
|
|
4842
5850
|
};
|
|
4843
5851
|
const offs = registerRoutes(ctx, wiring);
|
|
4844
5852
|
ctx.effect(() => {
|
|
4845
|
-
|
|
5853
|
+
reconcile();
|
|
4846
5854
|
registerDrawTool(ctx, wiring, deps);
|
|
4847
5855
|
const timer = setInterval(() => {
|
|
4848
|
-
|
|
5856
|
+
reconcile();
|
|
4849
5857
|
}, settings.pollSeconds * 1e3);
|
|
4850
5858
|
return () => {
|
|
4851
5859
|
clearInterval(timer);
|
|
@@ -4855,7 +5863,7 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
4855
5863
|
for (const off of offs) try {
|
|
4856
5864
|
off?.();
|
|
4857
5865
|
} catch (error) {
|
|
4858
|
-
ctx.logger?.warn?.(`${name}: route unregister failed: ${redactSecrets
|
|
5866
|
+
ctx.logger?.warn?.(`${name}: route unregister failed: ${redactSecrets(errMsg(error))}`);
|
|
4859
5867
|
}
|
|
4860
5868
|
};
|
|
4861
5869
|
}, `${name}: routes`);
|