dsh-connect-modelscope-token-plan 0.4.0 → 0.6.1
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 +235 -178
- package/README.md +20 -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 +770 -165
- package/cordis.patch.yml +45 -9
- package/lib/host-config.js +288 -21
- package/lib/index.js +916 -232
- package/lib/llm-adapter.js +45 -19
- package/package.json +115 -115
- package/screenshots.json +2 -1
- package/assets/icon-preview.png +0 -0
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as
|
|
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
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";
|
|
@@ -145,7 +145,7 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
145
145
|
async save(token) {
|
|
146
146
|
const value = verbatim(token, "");
|
|
147
147
|
if (typeof value !== "string" || value.trim() === "") throw new Error("a ModelScope token is required");
|
|
148
|
-
if (!isPlausibleToken(value.trim())) throw new Error("that does not look like a ModelScope access token (expected ms- followed by 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");
|
|
149
149
|
const service = resolveService();
|
|
150
150
|
if (service !== null && typeof service.set === "function") {
|
|
151
151
|
await service.set(TOKEN_REF, value);
|
|
@@ -160,13 +160,18 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
160
160
|
* 服务实现里 unset 抛错时,吞掉错误会让路由回 `ok: true` + `source: credentials`,
|
|
161
161
|
* 而令牌**仍在 `~/.dsh/.credentials.yaml` 里并且仍被 resolve() 读回来**。
|
|
162
162
|
* 「忘掉令牌」是一个安全动作,谎报成功比失败更糟——用户以为凭据已删干净。
|
|
163
|
-
*
|
|
163
|
+
*
|
|
164
|
+
* **先 unset 成功、再删内存**:反过来的顺序有一个竞态——unset 抛错时内存
|
|
165
|
+
* 已删,下次 resolve() 会走 env 回退读回那个本该被删掉的令牌(如果 env
|
|
166
|
+
* 里也有 `MODELSCOPE_API_KEY`)。用户以为凭据已删干净,但它「复活」了。
|
|
167
|
+
* 现在 unset 失败时内存保留,resolve() 会读到内存里的旧值并报错(因为
|
|
168
|
+
* `source` 不是 `null`,但用户认为已经删了)——至少不会静默回退到 env。
|
|
164
169
|
*/
|
|
165
170
|
async forget() {
|
|
166
|
-
memory.delete(TOKEN_REF);
|
|
167
|
-
lastCheck = null;
|
|
168
171
|
const service = resolveService();
|
|
169
172
|
if (service !== null && typeof service.unset === "function") await service.unset(TOKEN_REF);
|
|
173
|
+
memory.delete(TOKEN_REF);
|
|
174
|
+
lastCheck = null;
|
|
170
175
|
},
|
|
171
176
|
/**
|
|
172
177
|
* 记录一次**零额度探针**的结论(probe 路由是唯一调用方)。
|
|
@@ -256,6 +261,10 @@ function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
|
256
261
|
* 现在三件套各有一份公共实现:串行化是{@link createStateWriteQueue}、写守卫是
|
|
257
262
|
* {@link guardStateWrite}、读侧说明是{@link stateVersionNote};provider /
|
|
258
263
|
* usage / draw 三个 store 都是调用方,不再各写一遍。
|
|
264
|
+
* 4. 第四步删掉了{@link createStateReadCache} 的 `inheritFrom`——一个从未被
|
|
265
|
+
* 任何 store 使用、且写回路径绕过写队列的迁移缝(死代码 + 有害形状 +
|
|
266
|
+
* 文档误导三合一,裁定见该函数的说明);同时把{@link writeStateFile} 的
|
|
267
|
+
* 临时文件清理从「rename 失败」一条路扩大到**任何**失败路径。
|
|
259
268
|
*
|
|
260
269
|
* @module dsh-connect-modelscope-token-plan/state-store
|
|
261
270
|
*/
|
|
@@ -431,14 +440,17 @@ function temporaryOf(dir, base, now = Date.now) {
|
|
|
431
440
|
* 载荷串以换行结尾,与本模块存在前每个 store 的写法一致。失败**向上传播** ——
|
|
432
441
|
* 调用方自己决定只读 Home 要不要吞(throttle/catalog 吞,provider 开关不吞)。
|
|
433
442
|
*
|
|
434
|
-
* ##
|
|
443
|
+
* ## 任何一步失败都要清掉临时文件
|
|
435
444
|
*
|
|
436
445
|
* `rename` 会失败,而最常见的原因是 Windows 上目标文件正被另一个进程/编辑器
|
|
437
446
|
* 占着(EACCES/EPERM)。临时文件此前**留在原地**:名字是
|
|
438
447
|
* `<base>.<pid>.<ts>.<uuid>.tmp`,每次失败多一个,于是
|
|
439
448
|
* `state/<profile>/<name>/` 慢慢堆满垃圾,而没有任何代码会去清它们(全仓原本
|
|
440
|
-
* 无任何 unlink
|
|
441
|
-
*
|
|
449
|
+
* 无任何 unlink)。第一次修只补了 rename 失败那一条路;`writeFile` 本身失败
|
|
450
|
+
* (磁盘满)、`open` 失败、`handle.sync()` 失败(EIO)留下的半截临时文件照样
|
|
451
|
+
* 堆——2026-10-18 改为:**try 覆盖到 rename 成功为止,catch 里删掉自己的临时
|
|
452
|
+
* 文件再重抛**。清理失败不掩盖原始错误(那才是调用方要处理的),所以只
|
|
453
|
+
* `catch {}`。
|
|
442
454
|
*
|
|
443
455
|
* @param {string} file - 最终文件路径。
|
|
444
456
|
* @param {string} payload - 序列化后的正文(JSON 文本)。
|
|
@@ -446,17 +458,17 @@ function temporaryOf(dir, base, now = Date.now) {
|
|
|
446
458
|
* @returns {Promise<void>}
|
|
447
459
|
*/
|
|
448
460
|
async function writeStateFile(file, payload, { temporary }) {
|
|
449
|
-
await writeFile(temporary, `${payload}\n`, {
|
|
450
|
-
encoding: "utf8",
|
|
451
|
-
mode: 384
|
|
452
|
-
});
|
|
453
|
-
const handle = await open(temporary, "r+");
|
|
454
|
-
try {
|
|
455
|
-
await handle.sync();
|
|
456
|
-
} finally {
|
|
457
|
-
await handle.close();
|
|
458
|
-
}
|
|
459
461
|
try {
|
|
462
|
+
await writeFile(temporary, `${payload}\n`, {
|
|
463
|
+
encoding: "utf8",
|
|
464
|
+
mode: 384
|
|
465
|
+
});
|
|
466
|
+
const handle = await open(temporary, "r+");
|
|
467
|
+
try {
|
|
468
|
+
await handle.sync();
|
|
469
|
+
} finally {
|
|
470
|
+
await handle.close();
|
|
471
|
+
}
|
|
460
472
|
await rename(temporary, file);
|
|
461
473
|
} catch (error) {
|
|
462
474
|
try {
|
|
@@ -491,54 +503,40 @@ const STATE_READ_TTL_MS = 1e3;
|
|
|
491
503
|
* peer-free,与其余原语同纪律(不 import Host peer、离线可测)。时钟与 TTL
|
|
492
504
|
* 都可注入,便于测试把缓存推进过期。
|
|
493
505
|
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
506
|
+
* ## 这个原语为什么不负责 legacy 继承
|
|
507
|
+
*
|
|
508
|
+
* 一次性迁移缝(profile 分段前写在**共享**目录里的状态 → 本 profile 目录)曾经
|
|
509
|
+
* 作为 `inheritFrom` 选项住在这里,2026-10-18 删除,三个原因叠在一起:
|
|
510
|
+
* - **死代码**:四个 store 无一带它(`grep -rn inheritFrom src/` 只命中本文件
|
|
511
|
+
* 自己的声明;实际继承是 provider-store 在读穿透闭包里手写的);
|
|
512
|
+
* - **有害形状**:它的写回是**读路径内直接触发的写**,不经本 store 的写队列
|
|
513
|
+
* ——那正是 provider-store 注释里记着「咬过三次」的竞态形状:继承写与并发
|
|
514
|
+
* 的用户保存竞争,而继承只发生一次,被盖掉的那次保存是**永久**丢失的;
|
|
515
|
+
* - **文档误导**:本文件与 provider-store 的注释都把继承描述成走这个缝,
|
|
516
|
+
* 实为两处手写,读者会以为它是共享基础设施。
|
|
517
|
+
* 现在 provider / usage-prefs 各自在读穿透闭包里手写继承,并把回填排进**自己
|
|
518
|
+
* 的写队列**(裁定在各自头注)。新 store 要继承时照那个形状写,**不要**复活
|
|
519
|
+
* 这个参数。
|
|
503
520
|
*
|
|
504
521
|
* @template T
|
|
505
522
|
* @param {() => Promise<T|null>} readThrough - 真正的读盘 + 解析;返回 `null` 表示无可用记录。
|
|
506
523
|
* @param {object} [options]
|
|
507
524
|
* @param {number} [options.ttlMs] - 缓存有效期,默认 {@link STATE_READ_TTL_MS}。
|
|
508
525
|
* @param {() => number} [options.now] - 时钟源;测试注入。
|
|
509
|
-
* @param {{read: () => Promise<T|null>, write: (value: T) => Promise<void>}|null} [options.inheritFrom]
|
|
510
|
-
* - 旧版共享布局(`read`)与把它回填到本 profile(`write`);`null` = 不迁移。
|
|
511
526
|
* @returns {{read: () => Promise<T|null>, remember: (value: T|null) => void}}
|
|
512
527
|
*/
|
|
513
528
|
function createStateReadCache(readThrough, options = {}) {
|
|
514
|
-
const { ttlMs = STATE_READ_TTL_MS, now = Date.now
|
|
529
|
+
const { ttlMs = STATE_READ_TTL_MS, now = Date.now } = options;
|
|
515
530
|
let cached = void 0;
|
|
516
531
|
let cachedAt = 0;
|
|
517
|
-
/** Whether the one-shot legacy adoption has already been attempted. */
|
|
518
|
-
let adopted = false;
|
|
519
|
-
/**
|
|
520
|
-
* 读穿透:自己的记录优先;缺失且还有旧布局可继承时,取一次旧值并回填。
|
|
521
|
-
* @returns {Promise<T|null>}
|
|
522
|
-
*/
|
|
523
|
-
const load = async () => {
|
|
524
|
-
const own = await readThrough();
|
|
525
|
-
if (own !== null || inheritFrom === null || adopted) return own;
|
|
526
|
-
adopted = true;
|
|
527
|
-
const inherited = await inheritFrom.read();
|
|
528
|
-
if (inherited === null) return null;
|
|
529
|
-
try {
|
|
530
|
-
await inheritFrom.write(inherited);
|
|
531
|
-
} catch {}
|
|
532
|
-
return inherited;
|
|
533
|
-
};
|
|
534
532
|
return {
|
|
535
533
|
/**
|
|
536
|
-
* 读值:TTL 内返回缓存,过期则穿透到 `
|
|
534
|
+
* 读值:TTL 内返回缓存,过期则穿透到 `readThrough()`。
|
|
537
535
|
* @returns {Promise<T|null>}
|
|
538
536
|
*/
|
|
539
537
|
async read() {
|
|
540
538
|
if (cached !== void 0 && now() - cachedAt < ttlMs) return cached;
|
|
541
|
-
cached = await
|
|
539
|
+
cached = await readThrough();
|
|
542
540
|
cachedAt = now();
|
|
543
541
|
return cached;
|
|
544
542
|
},
|
|
@@ -878,6 +876,14 @@ function parseRetryAfterMs(headers, message) {
|
|
|
878
876
|
* + rename)、损坏即忽略、版本守卫拒覆写未知版本。进程内 1s 读缓存让一次
|
|
879
877
|
* 快照聚合的多次问询自洽。
|
|
880
878
|
*
|
|
879
|
+
* 旧共享布局**不继承**(2026-10-18 补记的裱定):profile 分段前写在共享目录里的
|
|
880
|
+
* usage.json 不做一次性迁移——它回答「这个 profile 的调用吃了多少额度」,而无
|
|
881
|
+
* profile 时期(宿主不提供 profileContext)的账不属于任何一个 profile,整段搬
|
|
882
|
+
* 过来是把那段账算成这个 profile 的。宿主升级后开始提供该服务时,旧文件原地
|
|
883
|
+
* 保留,由 `npm run doctor` 以 shared (legacy) 作用域报出——面板不搬历史,但操作者看得见
|
|
884
|
+
* 它(诊断诚实性,见 doctor.ts)。(usage-prefs 的单值偏好反而继承,裱定见
|
|
885
|
+
* usage-prefs-store.ts 头注)。
|
|
886
|
+
*
|
|
881
887
|
* 语义基线(wire.ts):
|
|
882
888
|
* - 天桶缺失 = 该日本插件没有记录到调用 = 真实的 0,不是数据丢失;
|
|
883
889
|
* - models 的 `tokens` 可为 null(上游没给 usage 时),绝不把 null 写成 0;
|
|
@@ -972,13 +978,14 @@ function parsePayload(raw) {
|
|
|
972
978
|
* @param {object} options
|
|
973
979
|
* @param {string} options.name - 插件 slug(状态目录名,来自 host-config)。
|
|
974
980
|
* @param {string|null} [options.profile] - profile 名;null = 共享目录。
|
|
975
|
-
* @param {number} [options.
|
|
976
|
-
*
|
|
981
|
+
* @param {number} [options.retentionDays] - 天桶**保留**数(超出即修剪;从配置注入,
|
|
982
|
+
* 让「配置改小」在下次写入时生效,而不是等一个固定的内部常数)。与「显示多少天」
|
|
983
|
+
* 是两件事——显示窗口由调用方传给 `trend(days)`,保留窗口恒 ≥ 它(host-config 夹)。
|
|
977
984
|
* @param {number} [options.maxEvents] - 事件流上限。
|
|
978
985
|
* @param {() => Date} [options.now] - 时钟源;测试注入。
|
|
979
986
|
* @param {{warn?: (message: string) => void}} [options.logger] - ctx.logger。
|
|
980
987
|
*/
|
|
981
|
-
function createFileUsageStore({ name, profile = null,
|
|
988
|
+
function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEvents = 50, now = () => /* @__PURE__ */ new Date(), logger }) {
|
|
982
989
|
const dir = profileStateDir(name, profile);
|
|
983
990
|
const file = join(dir, "usage.json");
|
|
984
991
|
/** 最近一次解析异常(损坏即忽略的痕迹),快照转 shapeWarnings。 */
|
|
@@ -1050,7 +1057,19 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
1050
1057
|
await writeStateFile(file, JSON.stringify(payload), { temporary: temporaryOf(dir, "usage.json") });
|
|
1051
1058
|
cache.remember(payload);
|
|
1052
1059
|
};
|
|
1053
|
-
/**
|
|
1060
|
+
/**
|
|
1061
|
+
* 排队执行一次读-改-写;版本冲突时整个变更是 no-op。
|
|
1062
|
+
*
|
|
1063
|
+
* 闸在**临界区内部**由 diskVersionBlocksWrites 现读磁盘判定,不读内存里的
|
|
1064
|
+
* readOnly——那个变量只用于日志去重与「闸已抬起」通知,删掉它不会让任何一次
|
|
1065
|
+
* 写入被放行或拦住(2026-10-18 变异实证:删掉复位块后「删文件后写入必须恢复」
|
|
1066
|
+
* 与「新计数落盘」两条断言仍然全绿)。
|
|
1067
|
+
*
|
|
1068
|
+
* 注释曾写「只读闸置位时整个变更是 no-op」——把判定归给内存闩锁。a61db2f(10-04,
|
|
1069
|
+
* 首次提交)起就是这么写的,那时磁盘闸还没长出来,那句话是真的;403bfbc(10-05)
|
|
1070
|
+
* 把守卫改成临界区内现读磁盘之后它就过期了,一直没被发现——因为没有任何断言
|
|
1071
|
+
* 读它,它只是一句注释。
|
|
1072
|
+
*/
|
|
1054
1073
|
const enqueue = (mutate) => writeQueue.enqueue(async () => {
|
|
1055
1074
|
if (await diskVersionBlocksWrites()) return;
|
|
1056
1075
|
const payload = await readThrough() ?? (loaded ? current : null) ?? {
|
|
@@ -1064,9 +1083,9 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
1064
1083
|
loaded = true;
|
|
1065
1084
|
await persist(payload);
|
|
1066
1085
|
});
|
|
1067
|
-
/** 修剪:只留最近
|
|
1086
|
+
/** 修剪:只留最近 retentionDays 个天桶(按 dateKey 排序,今天不删)。 */
|
|
1068
1087
|
const pruneDays = (payload, todayKey) => {
|
|
1069
|
-
const keep = new Set(Object.keys(payload.days).sort().slice(-Math.max(1,
|
|
1088
|
+
const keep = new Set(Object.keys(payload.days).sort().slice(-Math.max(1, retentionDays)));
|
|
1070
1089
|
keep.add(todayKey);
|
|
1071
1090
|
for (const key of Object.keys(payload.days)) if (!keep.has(key)) delete payload.days[key];
|
|
1072
1091
|
};
|
|
@@ -1161,12 +1180,29 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
1161
1180
|
dateKey
|
|
1162
1181
|
};
|
|
1163
1182
|
},
|
|
1164
|
-
/**
|
|
1183
|
+
/**
|
|
1184
|
+
* 今天的单模型计数(只含有记录的模型,dayCalls/dayTokens 口径,均跨日清零)。
|
|
1185
|
+
*
|
|
1186
|
+
* 三种答案,与 {@link daily} / {@link trend} 同一条红线(wire.ts:读不到 ≠ 0):
|
|
1187
|
+
* - 数组 —— 今天的单模型记录(只含有记录的模型;空数组 = 今天确实没有记录,
|
|
1188
|
+
* 是**真实零**);
|
|
1189
|
+
* - `null` —— **读不到**:载荷读不出来(损坏 / 形状不对 / 更新构建写的版本 /
|
|
1190
|
+
* 存在却不可读)。面板渲染「—」,不是空排行。
|
|
1191
|
+
*
|
|
1192
|
+
* 这是 2026-10-18 补上的最后一块拼图:daily() 三态在 2026-10-16、trend() 在
|
|
1193
|
+
* 本轮都补上了,perModelToday() 一直停在 `payload?.models ?? {}`——同一个坏文件
|
|
1194
|
+
* 在头条画「—」、在单模型排行却画「还没有本地调用记录。」,同一份坏数据在
|
|
1195
|
+
* 同一个卡片里给出两种答案。判据与 daily() 逐字一致:文件不存在才是真实的零,
|
|
1196
|
+
* 文件在却读成空是信息丢失。
|
|
1197
|
+
*
|
|
1198
|
+
* @returns {Array<{modelId: string, calls: number, tokens: number|null, lastAt: string|null}> | null}
|
|
1199
|
+
*/
|
|
1165
1200
|
async perModelToday() {
|
|
1166
1201
|
const payload = await cache.read();
|
|
1202
|
+
if (payload === null) return await pathExists(file) ? null : [];
|
|
1167
1203
|
const todayKey = localDateKey(now());
|
|
1168
1204
|
const out = [];
|
|
1169
|
-
for (const [modelId, counter] of Object.entries(payload
|
|
1205
|
+
for (const [modelId, counter] of Object.entries(payload.models ?? {})) {
|
|
1170
1206
|
if (counter.day !== todayKey) continue;
|
|
1171
1207
|
out.push({
|
|
1172
1208
|
modelId,
|
|
@@ -1178,16 +1214,37 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
1178
1214
|
out.sort((a, b) => b.calls - a.calls);
|
|
1179
1215
|
return out;
|
|
1180
1216
|
},
|
|
1181
|
-
/**
|
|
1182
|
-
|
|
1217
|
+
/**
|
|
1218
|
+
* 最近 N 天趋势(含今天)。
|
|
1219
|
+
*
|
|
1220
|
+
* 三种答案,与 {@link daily} 同一条红线(wire.ts:读不到 ≠ 0):
|
|
1221
|
+
* - `TrendBucket[]` —— 逐日桶,缺桶 = 该日 0 次(**真实零**:那天确实没有
|
|
1222
|
+
* 记录,是已知答案);
|
|
1223
|
+
* - `null` —— **读不到**:载荷读不出来(损坏 / 形状不对 / 更新构建写的
|
|
1224
|
+
* 版本 / 存在却不可读)。面板渲染「—」,不是一片零柱。
|
|
1225
|
+
*
|
|
1226
|
+
* 判据与 daily() 逐字一致:**文件不存在**才是真实的零(这台 Host 从未经本
|
|
1227
|
+
* 插件调用过),**文件在却读成空**是信息丢失。缺桶补 0 与整份读空补 0 是两件
|
|
1228
|
+
* 完全不同的事,前者是数据,后者是伪造。
|
|
1229
|
+
*
|
|
1230
|
+
* 这是 2026-10-18 补上的一块拼图:daily() 早在 2026-10-16 就有了三态,
|
|
1231
|
+
* trend() 一直只做了一半——`payload === null` 时照样逐日 push `{calls: 0}`,
|
|
1232
|
+
* 于是「usage.json 被写坏」在今日计数上画「—」、在趋势图上却画 14 根结实的
|
|
1233
|
+
* 零柱,同一份坏数据在两个区块给出两种答案。shapeWarnings 里那条 anomaly
|
|
1234
|
+
* 确实会出现,但它说「usage.json 有问题」,不说「你看到的这条趋势线是假的」。
|
|
1235
|
+
*
|
|
1236
|
+
* @returns {TrendBucket[] | null}
|
|
1237
|
+
*/
|
|
1238
|
+
async trend(days = retentionDays) {
|
|
1183
1239
|
const payload = await cache.read();
|
|
1240
|
+
if (payload === null) return await pathExists(file) ? null : [];
|
|
1184
1241
|
const out = [];
|
|
1185
1242
|
const cursor = now();
|
|
1186
1243
|
for (let offset = Number(days) - 1; offset >= 0; offset -= 1) {
|
|
1187
1244
|
const date = new Date(cursor);
|
|
1188
1245
|
date.setDate(date.getDate() - offset);
|
|
1189
1246
|
const key = localDateKey(date);
|
|
1190
|
-
const bucket = payload
|
|
1247
|
+
const bucket = payload.days[key];
|
|
1191
1248
|
out.push({
|
|
1192
1249
|
dateKey: key,
|
|
1193
1250
|
calls: bucket?.calls ?? 0,
|
|
@@ -1196,27 +1253,82 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
1196
1253
|
}
|
|
1197
1254
|
return out;
|
|
1198
1255
|
},
|
|
1199
|
-
/**
|
|
1256
|
+
/**
|
|
1257
|
+
* 事件流(Newest-first,已截断)。
|
|
1258
|
+
*
|
|
1259
|
+
* 三种答案,与 {@link daily} / {@link trend} 同一条红线(wire.ts:读不到 ≠ 0):
|
|
1260
|
+
* - 数组 —— 读到的事件(空数组 = 确实没有事件,是**真实零**);
|
|
1261
|
+
* - `null` —— **读不到**:载荷读不出来(损坏 / 形状不对 / 更新构建写的版本 /
|
|
1262
|
+
* 存在却不可读)。面板渲染「—」,不是「暂无事件」。
|
|
1263
|
+
*
|
|
1264
|
+
* 与 perModelToday() 同一块拼图(2026-10-18):`payload?.events ?? []` 把
|
|
1265
|
+
* 「读不到」和「确实没有」合成同一种答案,坏文件在事件卡上假装岁月静好。
|
|
1266
|
+
*
|
|
1267
|
+
* @returns {QuotaEvent[] | null}
|
|
1268
|
+
*/
|
|
1200
1269
|
async events() {
|
|
1201
|
-
|
|
1270
|
+
const payload = await cache.read();
|
|
1271
|
+
if (payload === null) return await pathExists(file) ? null : [];
|
|
1272
|
+
return [...payload.events ?? []];
|
|
1202
1273
|
},
|
|
1203
1274
|
/** 最近一次解析异常(无异常为 null)——快照转 shapeWarnings。 */
|
|
1204
1275
|
anomaly() {
|
|
1205
1276
|
return anomaly;
|
|
1206
|
-
},
|
|
1207
|
-
/** 无秘密的状态(doctor 用)。 */
|
|
1208
|
-
async state() {
|
|
1209
|
-
const payload = await cache.read();
|
|
1210
|
-
return {
|
|
1211
|
-
file: profile === null ? join(stateDir(name), "usage.json") : file,
|
|
1212
|
-
hasRecord: payload !== null,
|
|
1213
|
-
days: Object.keys(payload?.days ?? {}).length,
|
|
1214
|
-
events: payload?.events.length ?? 0,
|
|
1215
|
-
readOnly
|
|
1216
|
-
};
|
|
1217
1277
|
}
|
|
1218
1278
|
};
|
|
1219
1279
|
}
|
|
1280
|
+
/**
|
|
1281
|
+
* 从 429/错误事件流推断「哪些模型当前额度耗尽」——**纯函数**,判定规则的唯一真源。
|
|
1282
|
+
*
|
|
1283
|
+
* 为什么需要它:官方只给**账户级**魔粒余额(`/openapi/v1/magicubes/balance`),
|
|
1284
|
+
* 没有 per-model 额度端点;而「哪个模型刚才在限频」恰恰是面板独家能答的问题。
|
|
1285
|
+
* 证据只有两类:事件流(`kind` 已按 llm-error-fix **重分类后**的口径,见
|
|
1286
|
+
* `usage-observer.ts` 头注)与单模型今日计数,本函数只用前者——计数分不清
|
|
1287
|
+
* 成功与失败(`recordCall` 按流计次、含失败),拿它当证据会猜。
|
|
1288
|
+
*
|
|
1289
|
+
* 判定规则(三条,全部可机械复现):
|
|
1290
|
+
* 1. **只有 `kind:"quota"` 算证据**。`rate_limit` 是瞬时限频(peer 会退避
|
|
1291
|
+
* 重试,请求打得出去),`error` 是未知——把任何 429 都当耗尽是 0.2.0
|
|
1292
|
+
* 已修掉的误判(QUOTA 快速失败 vs RATE_LIMIT 退避,两条路不同)。
|
|
1293
|
+
* 2. **只在推断窗口内有效**:事件 `at` 落在 `[now - windowMinutes, +∞)`。
|
|
1294
|
+
* 窗口外的旧证据不算——免费额度按天重置,昨天的耗尽不该关掉今天的模型。
|
|
1295
|
+
* 3. **不推断恢复**:成功的流不发事件,所以「模型已恢复」在这份数据里
|
|
1296
|
+
* **不可观测**。已恢复的模型最多灰显满一个窗口——方向是保守的(宁可少
|
|
1297
|
+
* 提供一会,不把注定 429 的模型放进选择器),且面板灰显带着原因,
|
|
1298
|
+
* 不是静默消失。
|
|
1299
|
+
*
|
|
1300
|
+
* 总量语义:`events` 是环形缓冲(`maxEvents` 截断、Newest-first),所以「窗口
|
|
1301
|
+
* 内没有证据」可能是**真没有**、也可能是**证据被截掉了**——后者只发生在
|
|
1302
|
+
* 极端刷屏(50 条以内)时。**方向是假阴性,不是保守**:证据被挤掉 → 集合收缩
|
|
1303
|
+
* → 一个仍耗尽的模型可能提前回灰、被重新放进选择器。以前这里写的是「保守收敛」,
|
|
1304
|
+
* 把危险方向说成了安全方向(下一轮会话会据此放松,所以改掉)。它之所以还能接受,
|
|
1305
|
+
* 是因为失败面很小且自限:窗口最长 1440min、事件天然会滑出,`reconcile` 每轮
|
|
1306
|
+
* 现算,下一个 quota 事件会立刻把它重新灰掉。时间戳解析不出的事件跳过,不猜。
|
|
1307
|
+
*
|
|
1308
|
+
* @param {readonly QuotaEvent[]|null|undefined} events - 事件流(任意顺序)。
|
|
1309
|
+
* @param {Date|number} [now] - 时钟源(测试注入)。
|
|
1310
|
+
* @param {number} [windowMinutes] - 推断窗口(分钟)。非正/非有限时返回空集
|
|
1311
|
+
* ——总量防御:host-config 已把配置夹到 [5, 1440],走到这里就是编程错误,
|
|
1312
|
+
* 「没有证据」比按坏窗口算出一个莫名的集合诚实。
|
|
1313
|
+
* @returns {string[]} 当前应视为额度耗尽的模型 id(去重、排序,稳定快照)。
|
|
1314
|
+
*/
|
|
1315
|
+
function exhaustedModelIds(events, now = Date.now(), windowMinutes = 60) {
|
|
1316
|
+
if (!Number.isFinite(windowMinutes) || windowMinutes <= 0) return [];
|
|
1317
|
+
const list = Array.isArray(events) ? events : [];
|
|
1318
|
+
const nowMs = now instanceof Date ? now.getTime() : Number(now);
|
|
1319
|
+
if (!Number.isFinite(nowMs)) return [];
|
|
1320
|
+
const since = nowMs - windowMinutes * 6e4;
|
|
1321
|
+
const out = /* @__PURE__ */ new Set();
|
|
1322
|
+
for (const event of list) {
|
|
1323
|
+
if (event === null || typeof event !== "object") continue;
|
|
1324
|
+
if (event.kind !== "quota") continue;
|
|
1325
|
+
const at = Date.parse(String(event.at));
|
|
1326
|
+
if (!Number.isFinite(at) || at < since) continue;
|
|
1327
|
+
const modelId = typeof event.modelId === "string" ? event.modelId : "";
|
|
1328
|
+
if (modelId !== "") out.add(modelId);
|
|
1329
|
+
}
|
|
1330
|
+
return [...out].sort();
|
|
1331
|
+
}
|
|
1220
1332
|
|
|
1221
1333
|
//#endregion
|
|
1222
1334
|
//#region src/host/coalesced-fetch.ts
|
|
@@ -1407,6 +1519,39 @@ function createCoalescedFetch(options = {}) {
|
|
|
1407
1519
|
* 目录声明过窗口的条目不进打标,策展表永不推翻平台声明。
|
|
1408
1520
|
* 4. 生图/语音模型(Qwen-Image-Edit、ACE-Step 等)无对话上下文,不收。
|
|
1409
1521
|
*/
|
|
1522
|
+
/**
|
|
1523
|
+
* 本表最后一次与 models.dev / 真机目录核对的日期(ISO 日期,UTC)。
|
|
1524
|
+
*
|
|
1525
|
+
* 存在的理由:策展表是**静态知识**,而目录会上下架。维护纪律要求「目录下架的条目
|
|
1526
|
+
* 应随手删除」,但没有任何机制会自动知道下架发生了——没有这个日期,表会悄悄腐烂,
|
|
1527
|
+
* 而使用者无从判断一个值是三个月前还是三年前核的。快照侧据此在表过期时给一条
|
|
1528
|
+
* shapeWarning(见 {@link curatedCatalogStale})。
|
|
1529
|
+
*/
|
|
1530
|
+
const CURATED_AS_OF = "2026-10-16";
|
|
1531
|
+
/**
|
|
1532
|
+
* 策展表距上次核对已经过去多少天;日期解析不了返回 `null`。
|
|
1533
|
+
* @param {Date} [now] - 时钟源(测试注入)。
|
|
1534
|
+
* @returns {number|null} 天数(向下取整),或 null。
|
|
1535
|
+
*/
|
|
1536
|
+
function curatedCatalogAgeDays(now = /* @__PURE__ */ new Date()) {
|
|
1537
|
+
const asOf = Date.parse("2026-10-16T00:00:00Z");
|
|
1538
|
+
if (Number.isNaN(asOf)) return null;
|
|
1539
|
+
const delta = now.getTime() - asOf;
|
|
1540
|
+
return delta < 0 ? 0 : Math.floor(delta / 864e5);
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* 策展表是否该被复核了:距 {@link CURATED_AS_OF} 超过 `maxAgeDays` 天。
|
|
1544
|
+
*
|
|
1545
|
+
* 只回答「该提醒了吗」,不回答「表里的值错了」——过期的表往往仍然正确,只是
|
|
1546
|
+
* 不再有核对背书。快照把它转成一条 shapeWarning,而不是拒绝使用表里的值。
|
|
1547
|
+
* @param {Date} [now] - 时钟源(测试注入)。
|
|
1548
|
+
* @param {number} [maxAgeDays] - 容忍的天数,默认 180。
|
|
1549
|
+
* @returns {boolean}
|
|
1550
|
+
*/
|
|
1551
|
+
function curatedCatalogStale(now = /* @__PURE__ */ new Date(), maxAgeDays = 180) {
|
|
1552
|
+
const age = curatedCatalogAgeDays(now);
|
|
1553
|
+
return age !== null && age > maxAgeDays;
|
|
1554
|
+
}
|
|
1410
1555
|
/** id → 标称上下文窗口(tokens)。键必须与魔搭目录 id 逐字一致。 */
|
|
1411
1556
|
const CURATED_CONTEXT_WINDOWS = {
|
|
1412
1557
|
"deepseek-ai/DeepSeek-V4-Flash-0731": 1048576,
|
|
@@ -1493,6 +1638,25 @@ async function mapWithConcurrency(items, limit, fn) {
|
|
|
1493
1638
|
function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
1494
1639
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
1495
1640
|
const { read } = createCoalescedFetch();
|
|
1641
|
+
/**
|
|
1642
|
+
* 详情端点任务标签的**独立**缓存与 TTL——与目录/余额的 `read` 刻意分开。
|
|
1643
|
+
*
|
|
1644
|
+
* 标签回答「这个模型吃不吃图」,是出厂能力,几乎不变;目录/余额回答「现在有
|
|
1645
|
+
* 什么、还剩多少」——两者需要的陈旧度差着量级。它们曾共用 `cacheSeconds`
|
|
1646
|
+
* (默认 60 秒),而面板与对话框胶囊按 `pollSeconds`(默认 30s)轮询快照、
|
|
1647
|
+
* 快照每次都调 `fetchModels`:穿透时对目录里每个模型各打一次
|
|
1648
|
+
* `siteBase/api/v1/models/{id}`。当前目录 ~37 条 = **每轮 38 个 HTTP 请求、
|
|
1649
|
+
* 稳定每分钟一轮**(约 5 万/天),只为判一个布尔值,打的还是 modelscope.cn
|
|
1650
|
+
* 网站上一个非公开、非契约的接口。那不叫新鲜,叫白打。
|
|
1651
|
+
*
|
|
1652
|
+
* `maxAgeMs` 必须一起抬:coalesced-fetch 的清扫上限默认 1 小时(那是「任何
|
|
1653
|
+
* 调用方的最长 TTL」),不抬的话 6 小时的 TTL 会被清扫提前作废。键数等于目录
|
|
1654
|
+
* 里的模型数(约 40 条),无界增长不成立。
|
|
1655
|
+
*/
|
|
1656
|
+
const TAGS_TTL_MS = 216e5;
|
|
1657
|
+
const { read: readTags } = createCoalescedFetch({ maxAgeMs: TAGS_TTL_MS });
|
|
1658
|
+
/** 详情端点的并发:长 TTL 之后扇出已经很稀,没必要一上来 6 路齐发去撞主站。 */
|
|
1659
|
+
const TAG_FETCH_CONCURRENCY = 3;
|
|
1496
1660
|
/** 带超时的一次 fetch(AbortController;abort → TIMEOUT_ERROR)。 */
|
|
1497
1661
|
const fetchWithTimeout = async (url, init) => {
|
|
1498
1662
|
const controller = new AbortController();
|
|
@@ -1512,13 +1676,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1512
1676
|
/**
|
|
1513
1677
|
* 丢弃一个我们不读的响应体。
|
|
1514
1678
|
*
|
|
1515
|
-
* undici 里未消费的 body 会**占住连接**直到 GC
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1679
|
+
* undici 里未消费的 body 会**占住连接**直到 GC。标签扇出会对目录里 30–40 个
|
|
1680
|
+
* 模型并发打详情端点(`mapWithConcurrency` 分批),其中任何一条走「!ok」或
|
|
1681
|
+
* 「形状不符」分支,连接就一直挂着——短时间内连接池被这些挂起的响应吃满,
|
|
1682
|
+
* 后面的请求开始排队。
|
|
1518
1683
|
*
|
|
1519
1684
|
* `cancel()` 是显式的弃权:告诉 undici「这个 body 我不要了」,它会立刻释放
|
|
1520
1685
|
* 连接而不是等GC。用 `void` 前缀是因为这是best-effort——取消失败不影响我们
|
|
1521
|
-
*
|
|
1686
|
+
* 已经决定的结果。
|
|
1522
1687
|
*/
|
|
1523
1688
|
const discardBody = (response) => {
|
|
1524
1689
|
try {
|
|
@@ -1539,8 +1704,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1539
1704
|
/**
|
|
1540
1705
|
* 单个模型的详情端点任务标签(modelscope.cn/api/v1/models/{id})。
|
|
1541
1706
|
* 免认证 GET,零推理额度(实测匿名 200)。返回 `Data.Tasks[].Name`(+
|
|
1542
|
-
* `widgets[].task`
|
|
1543
|
-
*
|
|
1707
|
+
* `widgets[].task` 兜底)去重后的标签数组。
|
|
1708
|
+
*
|
|
1709
|
+
* **失败一律抛,绝不返回 `[]`**:本函数的结果会被 {@link TAGS_TTL_MS} 缓存
|
|
1710
|
+
* 六小时,而 `[]` 是一个**结论**(「这个模型没有任务标签」)。把 429 / 5xx /
|
|
1711
|
+
* 形状漂移降级成 `[]` 再缓存,等于让一次网络抖动把某个模型的视觉标签钉死半天
|
|
1712
|
+
* ——正是「读不到 ≠ 没有」的反面。coalesced-fetch 不缓存 rejection(失败被当
|
|
1713
|
+
* 「不是答案」共享,但不落盘),所以抛出去的那一轮不会被记住;调用方 catch 成
|
|
1714
|
+
* `[]`,只影响这一轮,下一轮照常重试。
|
|
1544
1715
|
*/
|
|
1545
1716
|
const fetchTaskTags = async (id) => {
|
|
1546
1717
|
const detailUrl = settings.siteBase + "/api/v1/models/" + encodeURI(id);
|
|
@@ -1550,10 +1721,16 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1550
1721
|
});
|
|
1551
1722
|
if (!response.ok) {
|
|
1552
1723
|
discardBody(response);
|
|
1553
|
-
|
|
1724
|
+
throw new Error("model detail failed with HTTP " + String(response.status));
|
|
1554
1725
|
}
|
|
1555
|
-
|
|
1556
|
-
|
|
1726
|
+
let json;
|
|
1727
|
+
try {
|
|
1728
|
+
json = await response.json();
|
|
1729
|
+
} catch {
|
|
1730
|
+
discardBody(response);
|
|
1731
|
+
throw new Error("model detail body was not JSON");
|
|
1732
|
+
}
|
|
1733
|
+
if (json === null || typeof json !== "object" || json.Code !== 200) throw new Error("model detail shape drifted (expected {Code:200, Data:{Tasks:[...]}})");
|
|
1557
1734
|
const data = json.Data ?? {};
|
|
1558
1735
|
const tasks = [];
|
|
1559
1736
|
for (const t of Array.isArray(data.Tasks) ? data.Tasks : []) {
|
|
@@ -1594,7 +1771,9 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1594
1771
|
*/
|
|
1595
1772
|
async fetchModels(contextOverride) {
|
|
1596
1773
|
const url = settings.apiBase + "/models";
|
|
1597
|
-
|
|
1774
|
+
const cacheKey = contextOverride != null ? `models:override:${contextOverride}` : "models:catalog";
|
|
1775
|
+
const cacheTtl = contextOverride != null ? Math.min(settings.cacheSeconds * 1e3, 5e3) : settings.cacheSeconds * 1e3;
|
|
1776
|
+
return await read(cacheKey, async () => {
|
|
1598
1777
|
const response = await fetchWithTimeout(url, { method: "GET" });
|
|
1599
1778
|
if (!response.ok) {
|
|
1600
1779
|
const message = await readErrorMessage(response);
|
|
@@ -1605,7 +1784,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1605
1784
|
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:[...]})");
|
|
1606
1785
|
const raws = json.data;
|
|
1607
1786
|
const ids = raws.map((r) => str(r.id, "")).filter((id) => id !== "");
|
|
1608
|
-
const tagArrays = await mapWithConcurrency(ids,
|
|
1787
|
+
const tagArrays = await mapWithConcurrency(ids, TAG_FETCH_CONCURRENCY, async (id) => {
|
|
1788
|
+
try {
|
|
1789
|
+
const cached = await readTags(`model-tags:${id}`, () => fetchTaskTags(id), TAGS_TTL_MS);
|
|
1790
|
+
return Array.isArray(cached) ? cached : [];
|
|
1791
|
+
} catch {
|
|
1792
|
+
return [];
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1609
1795
|
const tagsById = /* @__PURE__ */ new Map();
|
|
1610
1796
|
ids.forEach((id, i) => tagsById.set(id, tagArrays[i] ?? []));
|
|
1611
1797
|
const entries = raws.map((raw) => {
|
|
@@ -1645,7 +1831,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1645
1831
|
ids: entries.map((entry) => entry.id),
|
|
1646
1832
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1647
1833
|
};
|
|
1648
|
-
},
|
|
1834
|
+
}, cacheTtl);
|
|
1649
1835
|
},
|
|
1650
1836
|
/**
|
|
1651
1837
|
* 官方「魔粒」余额:GET siteBase/openapi/v1/magicubes/balance(Bearer
|
|
@@ -1773,6 +1959,16 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1773
1959
|
* filter", which is the only safe default for a fresh install. A foreign file
|
|
1774
1960
|
* reads the same way, which is precisely why `versionNote()` exists.
|
|
1775
1961
|
*
|
|
1962
|
+
* Legacy adoption (ruling 2026-10-18): pre-§23 machines kept this state in
|
|
1963
|
+
* the SHARED directory; a profile-scoped store inherits it ONCE, when its own
|
|
1964
|
+
* file is missing. The seam lives in the read-through closure below, NOT in a
|
|
1965
|
+
* cache option, and the write-back rides this store's write chain; an explicit
|
|
1966
|
+
* `dir` (the tests) never inherits, and an explicit `forget()` is never
|
|
1967
|
+
* resurrected. The mirror rulings for the other three state stores live in
|
|
1968
|
+
* their own file headers: usage-prefs inherits the same way, usage / draw do
|
|
1969
|
+
* NOT adopt — the old shared files stay put, and `npm run doctor` reports them
|
|
1970
|
+
* as a `shared (legacy)` scope instead.
|
|
1971
|
+
*
|
|
1776
1972
|
* @module dsh-connect-modelscope-token-plan/provider-store
|
|
1777
1973
|
*/
|
|
1778
1974
|
/**
|
|
@@ -1780,7 +1976,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1780
1976
|
* collapses here, so the three call sites that produce it stay in step instead
|
|
1781
1977
|
* of each spelling out the same three `null`s.
|
|
1782
1978
|
*/
|
|
1783
|
-
const EMPTY_PAYLOAD$
|
|
1979
|
+
const EMPTY_PAYLOAD$2 = {
|
|
1784
1980
|
enabled: null,
|
|
1785
1981
|
enabledIds: null,
|
|
1786
1982
|
contextWindow: null,
|
|
@@ -1914,8 +2110,8 @@ function resolveEnabledIds(stored, remembered) {
|
|
|
1914
2110
|
*/
|
|
1915
2111
|
function createFileProviderStore(options = {}) {
|
|
1916
2112
|
const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
|
|
1917
|
-
const stateDir$
|
|
1918
|
-
const filePath = join(stateDir$
|
|
2113
|
+
const stateDir$2 = dir ?? providerDir(profile);
|
|
2114
|
+
const filePath = join(stateDir$2, "provider.json");
|
|
1919
2115
|
/**
|
|
1920
2116
|
* Write one payload atomically to this file.
|
|
1921
2117
|
*
|
|
@@ -1941,8 +2137,8 @@ function createFileProviderStore(options = {}) {
|
|
|
1941
2137
|
logger
|
|
1942
2138
|
});
|
|
1943
2139
|
if (refusal !== null) return refusal;
|
|
1944
|
-
await ensureStateDir(stateDir$
|
|
1945
|
-
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$
|
|
2140
|
+
await ensureStateDir(stateDir$2);
|
|
2141
|
+
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$2, "provider.json") });
|
|
1946
2142
|
return null;
|
|
1947
2143
|
};
|
|
1948
2144
|
const legacyFile = dir === void 0 && profile ? join(stateDir(name), "provider.json") : null;
|
|
@@ -2009,7 +2205,7 @@ function createFileProviderStore(options = {}) {
|
|
|
2009
2205
|
* `null` branch collapses here, in one place, rather than at every caller.
|
|
2010
2206
|
* @returns {Promise<ProviderPayload>} the payload; the empty payload when nothing is stored.
|
|
2011
2207
|
*/
|
|
2012
|
-
const readPayload = async () => await read() ?? EMPTY_PAYLOAD$
|
|
2208
|
+
const readPayload = async () => await read() ?? EMPTY_PAYLOAD$2;
|
|
2013
2209
|
/**
|
|
2014
2210
|
* Read one payload, then hand it to {@link writePayload} — the read-modify-
|
|
2015
2211
|
* write that keeps the switch and the list from clobbering each other.
|
|
@@ -2215,8 +2411,14 @@ function createFileProviderStore(options = {}) {
|
|
|
2215
2411
|
* 值被忽略」呈现成「从未保存过」。
|
|
2216
2412
|
*
|
|
2217
2413
|
* 与 provider-store 的一处**有意差异**:没有 legacy(pre-profile 共享目录)继承。
|
|
2218
|
-
* draw.json 随出图工具一起诞生,晚于按 profile
|
|
2219
|
-
*
|
|
2414
|
+
* draw.json 随出图工具一起诞生,晚于按 profile 分段(git 史上成立),且宿主一直提供
|
|
2415
|
+
* profileContext 的机器上不存在共享布局里的旧 draw.json;provider.json 则需要
|
|
2416
|
+
* (§23 之前它一直在共享目录)。
|
|
2417
|
+
* 前提修正(2026-10-18):「市面上不存在」只对宿主一直提供 profileContext 的机器成立。
|
|
2418
|
+
* 宿主不提供时 profile 恒为 null,draw.json 今至仍写共享目录;宿主升级后提供该服务
|
|
2419
|
+
* 时,同一个迁移窗口在 draw.json 上一模一样——那种窗口里旧 draw.json 不继承不搬,
|
|
2420
|
+
* doctor 以 shared (legacy) 作用域报出,出图开关与模型偏好回 patch 默认(与 usage.json 同侧:
|
|
2421
|
+
* 诊断可见 > 静默搬运)。
|
|
2220
2422
|
*
|
|
2221
2423
|
* doctor 覆盖:`describeDrawPayload` 给只读速览,`doctor.ts` 据此把 draw.json
|
|
2222
2424
|
* 的「损坏 / 未知版本」报进症状表——写侧守卫挡覆盖,doctor 报静默降级。
|
|
@@ -2227,7 +2429,7 @@ function createFileProviderStore(options = {}) {
|
|
|
2227
2429
|
const DRAW_STORE_VERSION = 1;
|
|
2228
2430
|
/** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
|
|
2229
2431
|
const KNOWN_DRAW_VERSIONS = Object.freeze([1]);
|
|
2230
|
-
const EMPTY_PAYLOAD = {
|
|
2432
|
+
const EMPTY_PAYLOAD$1 = {
|
|
2231
2433
|
enabled: null,
|
|
2232
2434
|
modelId: null,
|
|
2233
2435
|
version: null
|
|
@@ -2326,7 +2528,7 @@ function createFileDrawStore(options = {}) {
|
|
|
2326
2528
|
* 读当前 payload。
|
|
2327
2529
|
* @returns {Promise<DrawPayload>}
|
|
2328
2530
|
*/
|
|
2329
|
-
const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
|
|
2531
|
+
const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$1;
|
|
2330
2532
|
/**
|
|
2331
2533
|
* 读-改-写一份补丁,**整段排队**。
|
|
2332
2534
|
*
|
|
@@ -2413,6 +2615,285 @@ function createFileDrawStore(options = {}) {
|
|
|
2413
2615
|
};
|
|
2414
2616
|
}
|
|
2415
2617
|
|
|
2618
|
+
//#endregion
|
|
2619
|
+
//#region src/host/switch-precedence.ts
|
|
2620
|
+
/**
|
|
2621
|
+
* 所有 opt-in 开关共用的唯一裁决:「面板保存值 vs 配置默认值」。
|
|
2622
|
+
*
|
|
2623
|
+
* 规则只有一条:**面板保存值赢,否则配置默认值说了算**。来源标签(`panel` /
|
|
2624
|
+
* `config`)随答案一起返回,面板才能说出是谁在管。规则收在一处,是为了让开关
|
|
2625
|
+
* 再也无法各自发明一套优先级方言;`test/switch-precedence.test.mjs` 钉死这个
|
|
2626
|
+
* 方言,照抄形状的新调用方会被它标成异类。
|
|
2627
|
+
*
|
|
2628
|
+
* 不服务「纯面板拥有、无配置默认值」的开关——那种没有可裁决的配置默认值,
|
|
2629
|
+
* 调用方直接读即可。peer-free,纯函数。
|
|
2630
|
+
*
|
|
2631
|
+
* @module dsh-connect-modelscope-token-plan/switch-precedence
|
|
2632
|
+
*/
|
|
2633
|
+
/**
|
|
2634
|
+
* Resolve the effective boolean switch: a panel-saved value always wins,
|
|
2635
|
+
* otherwise the config default rules.
|
|
2636
|
+
* @param {boolean|null} panel - the panel-saved value (`null` = never saved).
|
|
2637
|
+
* @param {boolean} config - the patch-declared default.
|
|
2638
|
+
* @returns {boolean} the effective switch.
|
|
2639
|
+
*/
|
|
2640
|
+
function resolveSwitchEnabled(panel, config) {
|
|
2641
|
+
return (panel ?? config) === true;
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Resolve the effective string preference (e.g. a draw-model id): a
|
|
2645
|
+
* panel-saved value wins, otherwise the config default rules.
|
|
2646
|
+
* @param {string|null} panel - the panel-saved value (`null` = never saved).
|
|
2647
|
+
* @param {string|undefined} config - the patch-declared default.
|
|
2648
|
+
* @returns {string|undefined} the effective preference.
|
|
2649
|
+
*/
|
|
2650
|
+
function resolveSwitchValue(panel, config) {
|
|
2651
|
+
return panel ?? config;
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Resolve the effective numeric preference (e.g. how many days the trend chart
|
|
2655
|
+
* shows): the same `panel ?? config` dialect as the other two shapes.
|
|
2656
|
+
*
|
|
2657
|
+
* Range clamping is deliberately NOT done here — the bound (a retention
|
|
2658
|
+
* window) belongs to the caller that knows it (`host-config` / the snapshot
|
|
2659
|
+
* aggregate), not to the precedence rule.
|
|
2660
|
+
* @param {number|null} panel - the panel-saved value (`null` = never saved).
|
|
2661
|
+
* @param {number} config - the patch-declared default.
|
|
2662
|
+
* @returns {number} the effective preference.
|
|
2663
|
+
*/
|
|
2664
|
+
function resolveSwitchNumber(panel, config) {
|
|
2665
|
+
return panel ?? config;
|
|
2666
|
+
}
|
|
2667
|
+
/**
|
|
2668
|
+
* Where the effective value came from — the panel when it saved one, the
|
|
2669
|
+
* config otherwise. Rides with every resolved answer so the panel can name
|
|
2670
|
+
* the side in charge.
|
|
2671
|
+
* @param {boolean|string|number|null} panel - the panel-saved value.
|
|
2672
|
+
* @returns {"panel"|"config"}
|
|
2673
|
+
*/
|
|
2674
|
+
function switchSource(panel) {
|
|
2675
|
+
return panel === null ? "config" : "panel";
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
//#endregion
|
|
2679
|
+
//#region src/host/usage-prefs-store.ts
|
|
2680
|
+
/**
|
|
2681
|
+
* 用量面板的**显示偏好**——目前只有一个字段:趋势图的显示窗口(画多少天)。
|
|
2682
|
+
*
|
|
2683
|
+
* ## 为什么单独一个文件
|
|
2684
|
+
*
|
|
2685
|
+
* - `usage.json` 是**数据**(天桶 / 单模型 / 事件),写路径是观测热路径。把一个
|
|
2686
|
+
* 显示偏好混进去,会让「偏好读不到」与「计数文件损坏」共用一条降级语义;更要命
|
|
2687
|
+
* 的是版本漂移——旧构建重写那份 payload 时会**静默丢掉**它不认识的键。
|
|
2688
|
+
* - `provider.json` 是 provider 注册的 desired state(开关 / 允许清单 / 窗口覆盖),
|
|
2689
|
+
* 与「趋势图画多少天」无关;合成一份 payload 会让两边的写入时序互相牵连。
|
|
2690
|
+
*
|
|
2691
|
+
* 落点与 provider / draw 同一套分段规则:`$DSH_HOME/state/<profile>/<plugin>/usage-prefs.json`
|
|
2692
|
+
* (无 profile 时落共享目录)。分段的意思是「这个 profile 想看多长」,两个 profile
|
|
2693
|
+
* 不互相覆盖。
|
|
2694
|
+
*
|
|
2695
|
+
* legacy 继承(2026-10-18 裁定):pre-profile 共享目录里的 usage-prefs.json 在
|
|
2696
|
+
* 本 profile 自己的文件**不在场**时一次性继承——与 provider-store 同款手写缝
|
|
2697
|
+
*(读路径内触发、回填排进**本 store 的写队列**、显式清除不复活)。为什么这个
|
|
2698
|
+
* 搬而 usage.json 不搬:单值显示偏好回填无害,而历史计数是「本 profile 的账」,
|
|
2699
|
+
* 搬过来是把无 profile 时期的账算成这个 profile 的(usage-store.ts 头注的裁定)。
|
|
2700
|
+
*
|
|
2701
|
+
* 优先级与所有 opt-in 开关同一份裁决(`switch-precedence.ts`):面板保存值赢,
|
|
2702
|
+
* 否则 `cordis.patch.yml` 的 `trendDays` 说了算;两者都会在 host-config 里被夹进
|
|
2703
|
+
* 保留窗口之内(`retentionDays`)——**画得比留得多**会把已剪的日子画成 0。
|
|
2704
|
+
*
|
|
2705
|
+
* 完整性三件套与 provider / usage / draw **同源**(全部上收在 `state-store.ts`,
|
|
2706
|
+
* 本文件只做调用方,论述见 `draw-store.ts` 头注,不在此重复):写串行化
|
|
2707
|
+
* ({@link createStateWriteQueue})、写守卫({@link guardStateWrite},ADR-006)、
|
|
2708
|
+
* 读侧说明({@link stateVersionNote})。
|
|
2709
|
+
*
|
|
2710
|
+
* doctor 覆盖:{@link describeUsagePrefsPayload} 给只读速览,`doctor.ts` 据此把
|
|
2711
|
+
* usage-prefs.json 的「损坏 / 未知版本」报进症状表。
|
|
2712
|
+
*
|
|
2713
|
+
* @module dsh-connect-modelscope-token-plan/usage-prefs-store
|
|
2714
|
+
*/
|
|
2715
|
+
/** 本 store 落盘 payload 的版本。 */
|
|
2716
|
+
const USAGE_PREFS_VERSION = 1;
|
|
2717
|
+
/** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
|
|
2718
|
+
const KNOWN_USAGE_PREFS_VERSIONS = Object.freeze([1]);
|
|
2719
|
+
const EMPTY_PAYLOAD = {
|
|
2720
|
+
windowDays: null,
|
|
2721
|
+
version: null
|
|
2722
|
+
};
|
|
2723
|
+
/**
|
|
2724
|
+
* 本 build 读不读得懂这个版本。
|
|
2725
|
+
*
|
|
2726
|
+
* 与 provider / draw 的 `isKnown*Version` 同形:**白名单**判据,不与
|
|
2727
|
+
* {@link USAGE_PREFS_VERSION} 比较——升级后旧号仍要可读。读侧、报告侧
|
|
2728
|
+
* (versionNote / describeUsagePrefsPayload)问的都是这一个函数,三者不可能就
|
|
2729
|
+
* 「什么是未知版本」说两套话。写侧问的是 `state-store` 的 `isKnownStateVersion`
|
|
2730
|
+
* (它额外放行 `null` = 没有文件,那是唯一该写的情形)。
|
|
2731
|
+
* @param {unknown} version - 盘上读到的值(可能根本不是数字)。
|
|
2732
|
+
* @returns {boolean}
|
|
2733
|
+
*/
|
|
2734
|
+
function isKnownUsagePrefsVersion(version) {
|
|
2735
|
+
return typeof version === "number" && KNOWN_USAGE_PREFS_VERSIONS.includes(version);
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* 归一一个保存的显示窗口:正整数(向下取整),或 `null`(= 跟随配置默认)。
|
|
2739
|
+
*
|
|
2740
|
+
* **不在这里夹上界**:上界是保留窗口(`retentionDays`),那是 host-config 的事,
|
|
2741
|
+
* 此模块不该知道它。传进来的越界值由调用方(host-config / 快照聚合 / 路由)夹。
|
|
2742
|
+
* @param {unknown} value - 原始值。
|
|
2743
|
+
* @returns {number|null} 归一后的天数或 `null`。
|
|
2744
|
+
*/
|
|
2745
|
+
function normalizeWindowDays(value) {
|
|
2746
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
2747
|
+
const days = Math.floor(value);
|
|
2748
|
+
return days > 0 ? days : null;
|
|
2749
|
+
}
|
|
2750
|
+
/** 本 store 的目录(与 provider / draw 同一套分段规则)。 */
|
|
2751
|
+
function usagePrefsDir(profile, dir) {
|
|
2752
|
+
if (typeof dir === "string" && dir !== "") return dir;
|
|
2753
|
+
return profile ? profileStateDir(name, profile) : stateDir(name);
|
|
2754
|
+
}
|
|
2755
|
+
/**
|
|
2756
|
+
* 显示窗口的**唯一**裁决 + 夹取:面板保存值优先(沿用全仓唯一方言
|
|
2757
|
+
* {@link resolveSwitchNumber}),否则配置默认;随后落进 `[1, maxDays]`。
|
|
2758
|
+
*
|
|
2759
|
+
* 夹取放在这里而不是 store 里,是因为 store 不该知道上界;放在**本模块**而不是
|
|
2760
|
+
* 两个调用方各写一遍,是因为「面板值 vs 默认」和「夹到保留窗口」加起来只有一条
|
|
2761
|
+
* 正确算法——路由保存时与快照聚合渲染时各写一份,就会出现「面板显示 90 天、
|
|
2762
|
+
* 图里只画了 14 天」这类两处不一致。
|
|
2763
|
+
* @param {number|null} panel - 面板保存值(`null` = 未保存)。
|
|
2764
|
+
* @param {number} fallback - 配置默认(已由 host-config 夹过)。
|
|
2765
|
+
* @param {number} maxDays - 上界(保留窗口,`retentionDays`)。
|
|
2766
|
+
* @returns {number} 生效天数。
|
|
2767
|
+
*/
|
|
2768
|
+
function resolveWindowDays(panel, fallback, maxDays) {
|
|
2769
|
+
const want = resolveSwitchNumber(panel, fallback);
|
|
2770
|
+
return Math.min(Math.max(1, Math.floor(want)), Math.max(1, Math.floor(maxDays)));
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* 文件支撑的用量显示偏好。
|
|
2774
|
+
* @param {StoreOptions} [options] - `{ dir, profile, ttlMs, logger }`。
|
|
2775
|
+
* @returns {object} store 句柄。
|
|
2776
|
+
*/
|
|
2777
|
+
function createFileUsagePrefsStore(options = {}) {
|
|
2778
|
+
const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
|
|
2779
|
+
const stateDir$1 = usagePrefsDir(profile, dir);
|
|
2780
|
+
const filePath = join(stateDir$1, "usage-prefs.json");
|
|
2781
|
+
/**
|
|
2782
|
+
* 写一份 payload 并落盘。
|
|
2783
|
+
*
|
|
2784
|
+
* ADR-006 写守卫({@link guardStateWrite})先问盘:版本不认或文件读不出 →
|
|
2785
|
+
* 返回拒绝理由(已 degrade 记录),不落笔。守卫与随后的写入在同一个临界区里
|
|
2786
|
+
* (由 patchPayload 的链保证),判据与执行之间不隔任何 await。
|
|
2787
|
+
* @param {object} body - 要持久化的 JSON 体。
|
|
2788
|
+
* @returns {Promise<string|null>} 拒绝原因,或 `null` = 已写入。
|
|
2789
|
+
*/
|
|
2790
|
+
const writePayload = async (body) => {
|
|
2791
|
+
const refusal = await guardStateWrite(filePath, KNOWN_USAGE_PREFS_VERSIONS, {
|
|
2792
|
+
label: "usage-prefs-state",
|
|
2793
|
+
logger
|
|
2794
|
+
});
|
|
2795
|
+
if (refusal !== null) return refusal;
|
|
2796
|
+
await ensureStateDir(stateDir$1);
|
|
2797
|
+
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary: temporaryOf(stateDir$1, "usage-prefs.json") });
|
|
2798
|
+
return null;
|
|
2799
|
+
};
|
|
2800
|
+
/** 写串行化:读-改-写必须排队(详见{@link createStateWriteQueue})。 */
|
|
2801
|
+
const writeQueue = createStateWriteQueue();
|
|
2802
|
+
/**
|
|
2803
|
+
* 解析一份路径上的 payload;认不出的版本读作「没设过」但**记住**版本号——读侧
|
|
2804
|
+
* 宽容、versionNote 负责把宽容说出口(写侧守卫已挡住覆盖,两半合起来才是
|
|
2805
|
+
* ADR-006)。
|
|
2806
|
+
*/
|
|
2807
|
+
const parseAt = async (path) => {
|
|
2808
|
+
const source = obj(await readStateJson(path));
|
|
2809
|
+
const version = typeof source.version === "number" ? source.version : null;
|
|
2810
|
+
if (!isKnownUsagePrefsVersion(version)) return {
|
|
2811
|
+
windowDays: null,
|
|
2812
|
+
version
|
|
2813
|
+
};
|
|
2814
|
+
return {
|
|
2815
|
+
windowDays: normalizeWindowDays(source.windowDays),
|
|
2816
|
+
version
|
|
2817
|
+
};
|
|
2818
|
+
};
|
|
2819
|
+
/** 读本 profile 自己的文件。 */
|
|
2820
|
+
const parsePayload = async () => parseAt(filePath);
|
|
2821
|
+
const legacyFile = dir === void 0 && profile ? join(stateDir(name), "usage-prefs.json") : null;
|
|
2822
|
+
const cache = createStateReadCache(async () => {
|
|
2823
|
+
const own = await parsePayload();
|
|
2824
|
+
if (own.windowDays !== null || legacyFile === null || own.version !== null) return own;
|
|
2825
|
+
const legacy = await parseAt(legacyFile);
|
|
2826
|
+
if (legacy.windowDays === null) return own;
|
|
2827
|
+
await writeQueue.enqueue(() => writePayload({
|
|
2828
|
+
version: 1,
|
|
2829
|
+
windowDays: legacy.windowDays,
|
|
2830
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2831
|
+
}).then(() => void 0, () => void 0));
|
|
2832
|
+
return legacy;
|
|
2833
|
+
}, { ttlMs });
|
|
2834
|
+
/**
|
|
2835
|
+
* 读当前 payload。
|
|
2836
|
+
* @returns {Promise<UsagePrefsPayload>}
|
|
2837
|
+
*/
|
|
2838
|
+
const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
|
|
2839
|
+
/**
|
|
2840
|
+
* 读-改-写,**整段排队**。
|
|
2841
|
+
*
|
|
2842
|
+
* 基线每次从磁盘重读(`parsePayload` 直读,不走 TTL 缓存):两个 Host 进程共享
|
|
2843
|
+
* 一个状态目录,而缓存里可能是写入前的值(provider §8d 的回归用例在 draw 上
|
|
2844
|
+
* 同样成立,见 draw-store.ts)。写成功即 `cache.remember`。
|
|
2845
|
+
*
|
|
2846
|
+
* 失败语义与 provider / draw 对齐:拒绝原因与 IO 失败都**向上抛**(save 抛、
|
|
2847
|
+
* 路由 catch 后 `respondError` 呈报),链自己吞掉失败继续走后面的保存。
|
|
2848
|
+
*
|
|
2849
|
+
* @param {number|null} windowDays - 新的显示窗口;`null` = 清除(回到配置默认)。
|
|
2850
|
+
* @returns {Promise<void>}
|
|
2851
|
+
*/
|
|
2852
|
+
const save = (windowDays) => writeQueue.enqueue(async () => {
|
|
2853
|
+
const body = { version: 1 };
|
|
2854
|
+
if (windowDays !== null) body.windowDays = windowDays;
|
|
2855
|
+
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2856
|
+
const refusal = await writePayload(body);
|
|
2857
|
+
if (refusal !== null) throw new Error(refusal);
|
|
2858
|
+
cache.remember({
|
|
2859
|
+
windowDays,
|
|
2860
|
+
version: 1
|
|
2861
|
+
});
|
|
2862
|
+
});
|
|
2863
|
+
return {
|
|
2864
|
+
/** 面板保存的显示窗口天数;`null` = 跟随配置默认。 */
|
|
2865
|
+
async windowDays() {
|
|
2866
|
+
return (await readPayload()).windowDays;
|
|
2867
|
+
},
|
|
2868
|
+
/**
|
|
2869
|
+
* 盘上带着本构建读不懂的版本时的一句说明,否则 `null`。
|
|
2870
|
+
*
|
|
2871
|
+
* 读侧宽容(未知版本读作「没设过」)的另一半:快照聚合把它转进
|
|
2872
|
+
* `shapeWarnings`,doctor 报同一份文件。没有它,「保存的显示窗口被忽略」在
|
|
2873
|
+
* 面板上就是「从未保存过」——而回退方向是配置默认。
|
|
2874
|
+
* @returns {Promise<string|null>}
|
|
2875
|
+
*/
|
|
2876
|
+
async versionNote() {
|
|
2877
|
+
const version = (await readPayload()).version;
|
|
2878
|
+
if (version === null || isKnownUsagePrefsVersion(version)) return null;
|
|
2879
|
+
return stateVersionNote({
|
|
2880
|
+
label: "usage-prefs-state",
|
|
2881
|
+
file: filePath,
|
|
2882
|
+
version,
|
|
2883
|
+
known: KNOWN_USAGE_PREFS_VERSIONS,
|
|
2884
|
+
consequence: "the saved trend window is being answered as \"not set\", so the chart falls back to the config default."
|
|
2885
|
+
});
|
|
2886
|
+
},
|
|
2887
|
+
/** 保存显示窗口(`null` = 清掉,回到配置默认)。 */
|
|
2888
|
+
async saveWindowDays(windowDays) {
|
|
2889
|
+
if (windowDays !== null && !(typeof windowDays === "number" && Number.isFinite(windowDays) && Math.floor(windowDays) > 0)) throw new TypeError("trend window expects a positive integer or null");
|
|
2890
|
+
await save(windowDays === null ? null : Math.floor(windowDays));
|
|
2891
|
+
},
|
|
2892
|
+
/** 落盘路径(诊断与测试用)。 */
|
|
2893
|
+
filePath
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2416
2897
|
//#endregion
|
|
2417
2898
|
//#region src/host/draw.ts
|
|
2418
2899
|
/**
|
|
@@ -2787,19 +3268,6 @@ function createDrawJobs(options = {}) {
|
|
|
2787
3268
|
*/
|
|
2788
3269
|
/** agent 工具名。带插件前缀,避免与 `dsh-draw-router` 的 `draw_image` 撞名。 */
|
|
2789
3270
|
const DRAW_TOOL_NAME = "modelscope_draw_image";
|
|
2790
|
-
/**
|
|
2791
|
-
* 已知能**纯文生图**的目录内模型 id(2026-10-16 真机复核)。
|
|
2792
|
-
*
|
|
2793
|
-
* 为什么需要手工策展:api-inference 目录里至今**没有**平台声明的 `text-to-image`
|
|
2794
|
-
* 条目,只有 2 条 `image-to-image`——而两者行为**不同**:
|
|
2795
|
-
* `Qwen/Qwen-Image-Edit` 不带 `image_url` 纯文生图可用(本轮实测:提交成功、两次
|
|
2796
|
-
* 15min 排队超时后于 9.7min 出图);`MusePublic/Qwen-Image-Edit` 纯文生图被上游
|
|
2797
|
-
* 直接拒绝(提交阶段 HTTP 4xx:`image_url is required for model: …`)。
|
|
2798
|
-
* 能力枚举把两者都归为 `image-to-image`,区分不了这个差异,所以这里用实测知识兜底:
|
|
2799
|
-
* 目录里没有 `text-to-image` 可挑时,默认偏向这个已知能用的 id,而不是「目录第一个」
|
|
2800
|
-
* (真实目录顺序 `MusePublic/…` 在前,照旧逻辑纯文生图必挂)。
|
|
2801
|
-
*/
|
|
2802
|
-
const KNOWN_T2I_MODEL_ID = "Qwen/Qwen-Image-Edit";
|
|
2803
3271
|
/** 一次失败的冷却窗:出图慢,失败后立刻重试既易撞限流又白等。 */
|
|
2804
3272
|
const DRAW_FAILURE_COOLDOWN_MS = 6e4;
|
|
2805
3273
|
/**
|
|
@@ -2906,7 +3374,7 @@ async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, image
|
|
|
2906
3374
|
* @param {number} [options.pollIntervalMs] - 轮询间隔(测试可缩短)。
|
|
2907
3375
|
* @returns {Promise<DrawAwait>} 三种结局之一。
|
|
2908
3376
|
*/
|
|
2909
|
-
async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS }) {
|
|
3377
|
+
async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS, signal }) {
|
|
2910
3378
|
const headers = {
|
|
2911
3379
|
Authorization: `Bearer ${apiKey}`,
|
|
2912
3380
|
"Content-Type": "application/json"
|
|
@@ -2920,7 +3388,14 @@ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW
|
|
|
2920
3388
|
kind: "pending",
|
|
2921
3389
|
polls
|
|
2922
3390
|
};
|
|
2923
|
-
|
|
3391
|
+
try {
|
|
3392
|
+
await sleep(interval, signal);
|
|
3393
|
+
} catch {
|
|
3394
|
+
return {
|
|
3395
|
+
kind: "pending",
|
|
3396
|
+
polls
|
|
3397
|
+
};
|
|
3398
|
+
}
|
|
2924
3399
|
polls++;
|
|
2925
3400
|
let polled;
|
|
2926
3401
|
try {
|
|
@@ -2995,16 +3470,7 @@ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW
|
|
|
2995
3470
|
* @returns {string|null} 选中的 id。
|
|
2996
3471
|
*/
|
|
2997
3472
|
function pickDrawModel(entries, requested, preferred) {
|
|
2998
|
-
|
|
2999
|
-
if (want !== "") return want;
|
|
3000
|
-
const preferredId = str(preferred, "");
|
|
3001
|
-
if (preferredId !== "") return preferredId;
|
|
3002
|
-
const ids = imageGenModelIds(entries);
|
|
3003
|
-
if (ids.length === 0) return null;
|
|
3004
|
-
const t2i = textToImageModelIds(entries);
|
|
3005
|
-
if (t2i.length > 0) return t2i[0] ?? null;
|
|
3006
|
-
if (ids.includes("Qwen/Qwen-Image-Edit")) return KNOWN_T2I_MODEL_ID;
|
|
3007
|
-
return ids[0] ?? null;
|
|
3473
|
+
return resolveDrawModelChoice(entries, requested, preferred).id;
|
|
3008
3474
|
}
|
|
3009
3475
|
/**
|
|
3010
3476
|
* 目录里可用于出图的模型 id(工具结果里回显,方便 agent 与用户看到实际候选)。
|
|
@@ -3061,6 +3527,10 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
3061
3527
|
*/
|
|
3062
3528
|
const waitMs = Math.min(timeoutMs, Math.max(1, Math.floor(Number(settings?.drawWaitMs) > 0 ? Number(settings?.drawWaitMs) : DRAW_WAIT_BUDGET_MS)));
|
|
3063
3529
|
const apiBase = str(settings?.apiBase, "https://api-inference.modelscope.cn/v1");
|
|
3530
|
+
const abortController = new AbortController();
|
|
3531
|
+
const abortIfDisposed = () => {
|
|
3532
|
+
if (isDisposed()) abortController.abort();
|
|
3533
|
+
};
|
|
3064
3534
|
/**
|
|
3065
3535
|
* 等一个作业,把 {@link awaitDrawTask} 的三种结局翻译成工具结果或错误。
|
|
3066
3536
|
*
|
|
@@ -3073,13 +3543,15 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
3073
3543
|
* @throws {Error} 真失败(上游 FAILED / 轮询 HTTP 失败)或越过总放弃线。
|
|
3074
3544
|
*/
|
|
3075
3545
|
const waitAndReport = async (apiKey, job, available) => {
|
|
3546
|
+
abortIfDisposed();
|
|
3076
3547
|
const awaited = await awaitDrawTask({
|
|
3077
3548
|
fetchImpl,
|
|
3078
3549
|
apiBase,
|
|
3079
3550
|
apiKey,
|
|
3080
3551
|
taskId: job.taskId,
|
|
3081
3552
|
waitMs,
|
|
3082
|
-
pollIntervalMs
|
|
3553
|
+
pollIntervalMs,
|
|
3554
|
+
signal: abortController.signal
|
|
3083
3555
|
});
|
|
3084
3556
|
if (awaited.kind === "failed") {
|
|
3085
3557
|
jobs.drop(job.jobId);
|
|
@@ -3101,7 +3573,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
3101
3573
|
};
|
|
3102
3574
|
}
|
|
3103
3575
|
const elapsedMs = Date.now() - job.submittedAt;
|
|
3104
|
-
if (elapsedMs >= timeoutMs) throw new Error(describeDrawFailure({ cause: `超过总预算(${Math.round(timeoutMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${job.taskId}
|
|
3576
|
+
if (elapsedMs >= timeoutMs) throw new Error(describeDrawFailure({ cause: `超过总预算(${Math.round(timeoutMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${job.taskId})可能仍在生成——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关;注意放弃后原任务的结果不保证还能取回` }));
|
|
3105
3577
|
return {
|
|
3106
3578
|
source: "modelscope",
|
|
3107
3579
|
status: "pending",
|
|
@@ -3114,7 +3586,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
3114
3586
|
};
|
|
3115
3587
|
return defineTool({
|
|
3116
3588
|
name: DRAW_TOOL_NAME,
|
|
3117
|
-
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
|
|
3589
|
+
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.",
|
|
3118
3590
|
parameters: {
|
|
3119
3591
|
prompt: {
|
|
3120
3592
|
type: "string",
|
|
@@ -3175,7 +3647,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
3175
3647
|
},
|
|
3176
3648
|
timeoutMs: waitMs + 1e4,
|
|
3177
3649
|
async execute(params) {
|
|
3178
|
-
if (isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
|
|
3650
|
+
if (abortController.signal.aborted || isDisposed()) throw new Error("modelscope draw tool is no longer mounted");
|
|
3179
3651
|
const apiKey = await resolveApiKey();
|
|
3180
3652
|
if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("MODELSCOPE_API_KEY 未配置:在面板粘贴 ms- 访问令牌,或设置该环境变量");
|
|
3181
3653
|
const resumeId = str(params?.jobId, "");
|
|
@@ -3293,9 +3765,29 @@ async function safeJson(response, text) {
|
|
|
3293
3765
|
}
|
|
3294
3766
|
}
|
|
3295
3767
|
}
|
|
3296
|
-
/**
|
|
3297
|
-
|
|
3298
|
-
|
|
3768
|
+
/**
|
|
3769
|
+
* 可注入的 sleep(测试可覆写;这里只提供默认实现)。
|
|
3770
|
+
*
|
|
3771
|
+
* 支持 `AbortSignal`:插件 dispose 时可以立即中断等待,而不是等 sleep 自然结束。
|
|
3772
|
+
* 默认 15s 的轮询间隔意味着用户点「停止」后最多要等 15s 才真正停——abort 让
|
|
3773
|
+
* 停止信号立即生效。
|
|
3774
|
+
*/
|
|
3775
|
+
function sleep(ms, signal) {
|
|
3776
|
+
return new Promise((resolve, reject) => {
|
|
3777
|
+
if (signal?.aborted) {
|
|
3778
|
+
reject(/* @__PURE__ */ new Error("sleep aborted"));
|
|
3779
|
+
return;
|
|
3780
|
+
}
|
|
3781
|
+
const timer = setTimeout(() => {
|
|
3782
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3783
|
+
resolve();
|
|
3784
|
+
}, ms);
|
|
3785
|
+
const onAbort = () => {
|
|
3786
|
+
clearTimeout(timer);
|
|
3787
|
+
reject(/* @__PURE__ */ new Error("sleep aborted"));
|
|
3788
|
+
};
|
|
3789
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
3790
|
+
});
|
|
3299
3791
|
}
|
|
3300
3792
|
|
|
3301
3793
|
//#endregion
|
|
@@ -3303,6 +3795,14 @@ function sleep(ms) {
|
|
|
3303
3795
|
/** 当前缺席原因(`null` = 未登记 / 已清掉)。 */
|
|
3304
3796
|
let note = null;
|
|
3305
3797
|
/**
|
|
3798
|
+
* 本次挂载是否真的把工具注册上了。
|
|
3799
|
+
*
|
|
3800
|
+
* 与 `note` 的区别见文件头:`note` 是「注册尝试过且失败的原因」,这一位是「注册
|
|
3801
|
+
* 成功与否」。开关在挂载后才翻开的场景,两者都是「没有缺席原因 + 没注册上」,
|
|
3802
|
+
* 只有这一位能把它认出来。
|
|
3803
|
+
*/
|
|
3804
|
+
let mounted = false;
|
|
3805
|
+
/**
|
|
3306
3806
|
* 记下缺席原因(`null` 清空)。
|
|
3307
3807
|
* @param {DrawToolAbsentReason|null} reason - 原因码。
|
|
3308
3808
|
* @returns {void}
|
|
@@ -3318,57 +3818,28 @@ function drawToolNote() {
|
|
|
3318
3818
|
return note;
|
|
3319
3819
|
}
|
|
3320
3820
|
/**
|
|
3321
|
-
*
|
|
3821
|
+
* 记下「本次挂载已注册上」。只有注册路径走完才置 true;缺席与「开关没开」都是
|
|
3822
|
+
* false。
|
|
3823
|
+
* @param {boolean} value - 是否注册成功。
|
|
3322
3824
|
* @returns {void}
|
|
3323
3825
|
*/
|
|
3324
|
-
function
|
|
3325
|
-
|
|
3326
|
-
}
|
|
3327
|
-
|
|
3328
|
-
//#endregion
|
|
3329
|
-
//#region src/host/switch-precedence.ts
|
|
3330
|
-
/**
|
|
3331
|
-
* 所有 opt-in 开关共用的唯一裁决:「面板保存值 vs 配置默认值」。
|
|
3332
|
-
*
|
|
3333
|
-
* 规则只有一条:**面板保存值赢,否则配置默认值说了算**。来源标签(`panel` /
|
|
3334
|
-
* `config`)随答案一起返回,面板才能说出是谁在管。规则收在一处,是为了让开关
|
|
3335
|
-
* 再也无法各自发明一套优先级方言;`test/switch-precedence.test.mjs` 钉死这个
|
|
3336
|
-
* 方言,照抄形状的新调用方会被它标成异类。
|
|
3337
|
-
*
|
|
3338
|
-
* 不服务「纯面板拥有、无配置默认值」的开关——那种没有可裁决的配置默认值,
|
|
3339
|
-
* 调用方直接读即可。peer-free,纯函数。
|
|
3340
|
-
*
|
|
3341
|
-
* @module dsh-connect-modelscope-token-plan/switch-precedence
|
|
3342
|
-
*/
|
|
3343
|
-
/**
|
|
3344
|
-
* Resolve the effective boolean switch: a panel-saved value always wins,
|
|
3345
|
-
* otherwise the config default rules.
|
|
3346
|
-
* @param {boolean|null} panel - the panel-saved value (`null` = never saved).
|
|
3347
|
-
* @param {boolean} config - the patch-declared default.
|
|
3348
|
-
* @returns {boolean} the effective switch.
|
|
3349
|
-
*/
|
|
3350
|
-
function resolveSwitchEnabled(panel, config) {
|
|
3351
|
-
return (panel ?? config) === true;
|
|
3826
|
+
function setDrawToolMounted(value) {
|
|
3827
|
+
mounted = value === true;
|
|
3352
3828
|
}
|
|
3353
3829
|
/**
|
|
3354
|
-
*
|
|
3355
|
-
*
|
|
3356
|
-
* @param {string|null} panel - the panel-saved value (`null` = never saved).
|
|
3357
|
-
* @param {string|undefined} config - the patch-declared default.
|
|
3358
|
-
* @returns {string|undefined} the effective preference.
|
|
3830
|
+
* 读「本次挂载是否注册上了」。
|
|
3831
|
+
* @returns {boolean} true = 注册成功。
|
|
3359
3832
|
*/
|
|
3360
|
-
function
|
|
3361
|
-
return
|
|
3833
|
+
function drawToolMounted() {
|
|
3834
|
+
return mounted;
|
|
3362
3835
|
}
|
|
3363
3836
|
/**
|
|
3364
|
-
*
|
|
3365
|
-
*
|
|
3366
|
-
* the side in charge.
|
|
3367
|
-
* @param {boolean|string|null} panel - the panel-saved value.
|
|
3368
|
-
* @returns {"panel"|"config"}
|
|
3837
|
+
* 清空(卸载时调用:下一个 Host 生命周期从干净的「未登记」开始)。
|
|
3838
|
+
* @returns {void}
|
|
3369
3839
|
*/
|
|
3370
|
-
function
|
|
3371
|
-
|
|
3840
|
+
function resetDrawToolNote() {
|
|
3841
|
+
note = null;
|
|
3842
|
+
mounted = false;
|
|
3372
3843
|
}
|
|
3373
3844
|
|
|
3374
3845
|
//#endregion
|
|
@@ -3393,11 +3864,14 @@ function switchSource(panel) {
|
|
|
3393
3864
|
*
|
|
3394
3865
|
* peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule` 惰性注入。
|
|
3395
3866
|
*
|
|
3396
|
-
*
|
|
3397
|
-
*
|
|
3398
|
-
*
|
|
3399
|
-
*
|
|
3400
|
-
*
|
|
3867
|
+
* **测试覆盖**:`test/draw-register.test.mjs`(2026-10-17 补上,此前这里是零引用)
|
|
3868
|
+
* 用 `deps.loadToolsModule` 这条现成的缝注入伪 `defineTool`,让真装配链原样跑起来
|
|
3869
|
+
* ——**不需要** peer-stub-loader。教训是:本文件此前把「peer-dependent」直接读成
|
|
3870
|
+
* 「测不了」,而缝早就留好了;同处境的 `llm-adapter.ts` / `draw-runtime.ts` 用
|
|
3871
|
+
* `test/helpers/peer-stub-loader.mjs` 证明过另一条路(那条 loader 是给**没有缝**
|
|
3872
|
+
* 的直连 import 用的)。`tools/dev/mutate-spec.json` 里有本文件的一组变异,用来
|
|
3873
|
+
* 保证那套测试真的会红——第一版测试里 `mount` 助手自己清了缺席原因,于是「过期
|
|
3874
|
+
* 原因会不会被清掉」那条断言是测试自己满足的,是变异测试把它抓出来的。
|
|
3401
3875
|
*
|
|
3402
3876
|
* @module dsh-connect-modelscope-token-plan/draw-register
|
|
3403
3877
|
*/
|
|
@@ -3432,6 +3906,7 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
|
|
|
3432
3906
|
const { settings, configError, tokenStore, inference, drawStore, publisher, logger } = wiring;
|
|
3433
3907
|
const loggerFor = logger ?? ctx.logger;
|
|
3434
3908
|
setDrawToolNote(null);
|
|
3909
|
+
setDrawToolMounted(false);
|
|
3435
3910
|
try {
|
|
3436
3911
|
if (configError !== null) return;
|
|
3437
3912
|
const panelValue = drawStore ? await drawStore.enabled().catch(() => null) : null;
|
|
@@ -3475,6 +3950,7 @@ async function registerDrawTool(ctx, wiring, deps = {}) {
|
|
|
3475
3950
|
},
|
|
3476
3951
|
isDisposed
|
|
3477
3952
|
}));
|
|
3953
|
+
setDrawToolMounted(true);
|
|
3478
3954
|
} catch (error) {
|
|
3479
3955
|
setDrawToolNote("registry-refused");
|
|
3480
3956
|
loggerFor?.warn?.(`${name}: draw tool registration refused (${redactSecrets(errMsg(error))}) — the tool is absent`);
|
|
@@ -4059,10 +4535,12 @@ function createProviderPublisher(deps = {}) {
|
|
|
4059
4535
|
entries: state.entries,
|
|
4060
4536
|
enabledIds: state.enabledIds,
|
|
4061
4537
|
baseUrl: effectiveSettings.apiBase,
|
|
4538
|
+
maxTokensOverrides: effectiveSettings.maxTokensOverrides ?? {},
|
|
4062
4539
|
resolveApiKey: effectiveResolveApiKey,
|
|
4063
4540
|
get: effectiveGetLlm,
|
|
4064
4541
|
unavailableModelIds: state.unavailableIds,
|
|
4065
|
-
...usage !== void 0 ? { usage } : {}
|
|
4542
|
+
...usage !== void 0 ? { usage } : {},
|
|
4543
|
+
...usage !== void 0 ? { onError: (error) => effectiveLogger?.warn?.(`usage record failed: ${typeof error === "object" && error !== null && "message" in error ? String(error.message) : String(error)}`) } : {}
|
|
4066
4544
|
});
|
|
4067
4545
|
if (!isBuiltAdapter(built)) throw new Error(BAD_FACTORY_SHAPE_ERROR);
|
|
4068
4546
|
} catch (e) {
|
|
@@ -4191,6 +4669,10 @@ function createProviderPublisher(deps = {}) {
|
|
|
4191
4669
|
* 它只回答「重建会不会改变什么」:目录顺序里的模型 id,每个带上 descriptor 用的
|
|
4192
4670
|
* 同一个 vision 判定(id 没变但模态翻了也必须重建),加上允许清单。目录条目里其他
|
|
4193
4671
|
* 东西变了不影响注册的 offer。
|
|
4672
|
+
*
|
|
4673
|
+
* **不含展示名 `name`**:模型展示名变化不影响注册的 offer(descriptor 里的 `name`
|
|
4674
|
+
* 只是显示用的),重新注册是浪费——期间在途请求可能被打断。展示名变化不改变
|
|
4675
|
+
* 模型的能力、窗口或输出上限,所以不参与签名。
|
|
4194
4676
|
* @param {object[]} entries - 归一目录条目。
|
|
4195
4677
|
* @param {string[]} enabledIds - 允许清单(空 = 全部)。
|
|
4196
4678
|
* @returns {string}
|
|
@@ -4198,8 +4680,7 @@ function createProviderPublisher(deps = {}) {
|
|
|
4198
4680
|
function catalogSignature(entries, enabledIds) {
|
|
4199
4681
|
return `${(Array.isArray(entries) ? entries : []).map((entry) => {
|
|
4200
4682
|
const e = entry;
|
|
4201
|
-
|
|
4202
|
-
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 : ""}`;
|
|
4683
|
+
return `${str(e?.id, "")}:v${isVisionModel(e) ? 1 : 0}:c${typeof e?.contextWindow === "number" ? e.contextWindow : ""}:o${typeof e?.maxOutputLength === "number" ? e.maxOutputLength : ""}`;
|
|
4203
4684
|
}).join(",")}|${(Array.isArray(enabledIds) ? enabledIds : []).join(",")}`;
|
|
4204
4685
|
}
|
|
4205
4686
|
/**
|
|
@@ -4254,7 +4735,8 @@ function syncSignaturesAfterPublish(state) {
|
|
|
4254
4735
|
* - **desired state**(操作者想要什么)= `providerStore` 里的开关 + 允许
|
|
4255
4736
|
* 清单 + 窗口覆盖。它只有一个家,轮询和路由都只读它、不各写一份;
|
|
4256
4737
|
* - **reconcile 一遍** = 读 desired → 带窗口覆盖拉目录 → 复查 desired
|
|
4257
|
-
* (fetch 期间被改就重跑,不等下一轮)→
|
|
4738
|
+
* (fetch 期间被改就重跑,不等下一轮)→ 算这一轮的额度耗尽集合(事件流 +
|
|
4739
|
+
* 纯函数 `exhaustedModelIds`)→ 把「当前目录 + 当前清单 + 耗尽集合」交给
|
|
4258
4740
|
* publisher(它自己重读开关,single source 不变);
|
|
4259
4741
|
* - **触发方全部降级为「标记 dirty」**:挂载、轮询定时器、四条写路由。
|
|
4260
4742
|
* 谁都不用记得「别人是不是也在 publish」——队列性质由本模块的串行链保证。
|
|
@@ -4283,17 +4765,37 @@ function syncSignaturesAfterPublish(state) {
|
|
|
4283
4765
|
* @returns {{reconcile: () => Promise<void>}} 触发一次收敛。
|
|
4284
4766
|
*/
|
|
4285
4767
|
function createProviderReconciler(deps) {
|
|
4286
|
-
const { providerStore, inference, publisher, logger } = deps;
|
|
4768
|
+
const { settings, providerStore, inference, publisher, usageEvents, logger } = deps;
|
|
4287
4769
|
/**
|
|
4288
|
-
*
|
|
4289
|
-
*
|
|
4770
|
+
* 把一次 store 读取收成「值或 null」——**同步抛与异步拒收都降级**。
|
|
4771
|
+
*
|
|
4772
|
+
* store 是外部契约:当前三个 store 方法都是 `async`(同步抛不可达),但这
|
|
4773
|
+
* 不是可依赖的保证——病态实现、未来改签名、测试替身都可能同步抛。同步抛
|
|
4774
|
+
* 曾在 `Promise.all([a(), b(), c()])` 的**数组构造处**逃逸,把整个 executor
|
|
4775
|
+
* 变成 rejection(2026-10-18 实测的链毒化入口,见 {@link reconcile} 的防
|
|
4776
|
+
* 毒化注释)。一次读失败按「未设置」降级是本文件既有纪律,同步抛与拒收
|
|
4777
|
+
* 不该得到不同待遇。
|
|
4778
|
+
* @template T
|
|
4779
|
+
* @param {() => T | Promise<T>} read - 一次 store 读取。
|
|
4780
|
+
* @returns {Promise<T | null>} 读到的值,或 null(读不到)。
|
|
4781
|
+
*/
|
|
4782
|
+
const settle = (read) => {
|
|
4783
|
+
try {
|
|
4784
|
+
return Promise.resolve(read()).catch(() => null);
|
|
4785
|
+
} catch {
|
|
4786
|
+
return Promise.resolve(null);
|
|
4787
|
+
}
|
|
4788
|
+
};
|
|
4789
|
+
/**
|
|
4790
|
+
* 读一次 desired。三路都降级为「未设置」:读不到按未设置处理,与开关/清单/
|
|
4791
|
+
* 覆盖各自的读侧纪律一致——一次读失败不该把收敛打掉。
|
|
4290
4792
|
* @returns {Promise<Desired>}
|
|
4291
4793
|
*/
|
|
4292
4794
|
const readDesired = async () => {
|
|
4293
4795
|
const [enabled, enabledIds, contextWindow] = await Promise.all([
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4796
|
+
settle(() => providerStore.enabled()),
|
|
4797
|
+
settle(() => providerStore.enabledIds()),
|
|
4798
|
+
settle(() => providerStore.contextWindow())
|
|
4297
4799
|
]);
|
|
4298
4800
|
return {
|
|
4299
4801
|
enabled,
|
|
@@ -4308,6 +4810,26 @@ function createProviderReconciler(deps) {
|
|
|
4308
4810
|
desired.contextWindow
|
|
4309
4811
|
]);
|
|
4310
4812
|
/**
|
|
4813
|
+
* 读这一轮要发布的「额度耗尽集合」。
|
|
4814
|
+
*
|
|
4815
|
+
* 事件读失败**不抛**(收敛不能因为一条观测路径坏了就停):降级为空集并留一条
|
|
4816
|
+
* warn——与目录 fetch 失败同纪律(原因不丢、下一轮重试)。方向要说清:空集
|
|
4817
|
+
* 意味着「没有证据」,picker 会提供那些模型;这是把可观测失败换算成「暂时
|
|
4818
|
+
* 没有证据」的取舍,比让整个收敛链崩掉诚实。
|
|
4819
|
+
* @returns {Promise<string[]>}
|
|
4820
|
+
*/
|
|
4821
|
+
const readUnavailable = async () => {
|
|
4822
|
+
if (usageEvents === void 0) return [];
|
|
4823
|
+
let events;
|
|
4824
|
+
try {
|
|
4825
|
+
events = await usageEvents();
|
|
4826
|
+
} catch (error) {
|
|
4827
|
+
logger?.warn?.(`${name}: usage events read failed (${redactSecrets(errMsg(error))}) — no quota-exhaustion evidence this pass`);
|
|
4828
|
+
return [];
|
|
4829
|
+
}
|
|
4830
|
+
return exhaustedModelIds(events, /* @__PURE__ */ new Date(), settings.exhaustionWindowMinutes);
|
|
4831
|
+
};
|
|
4832
|
+
/**
|
|
4311
4833
|
* 收敛一遍:读 desired → 拉目录 → 复查 desired → publish。
|
|
4312
4834
|
* @returns {Promise<void>}
|
|
4313
4835
|
*/
|
|
@@ -4327,8 +4849,9 @@ function createProviderReconciler(deps) {
|
|
|
4327
4849
|
}
|
|
4328
4850
|
const entries = Array.isArray(catalog?.entries) ? catalog.entries : [];
|
|
4329
4851
|
const enabledIds = resolveEnabledIds(after.enabledIds, publisher.state.enabledIds);
|
|
4852
|
+
const unavailable = await readUnavailable();
|
|
4330
4853
|
try {
|
|
4331
|
-
await publisher.publish(entries, enabledIds,
|
|
4854
|
+
await publisher.publish(entries, enabledIds, unavailable);
|
|
4332
4855
|
} catch (error) {
|
|
4333
4856
|
logger?.warn?.(`${name}: publish failed (${redactSecrets(errMsg(error))}) — the catalog is current, only the provider registration did not land`);
|
|
4334
4857
|
}
|
|
@@ -4344,14 +4867,16 @@ function createProviderReconciler(deps) {
|
|
|
4344
4867
|
*/
|
|
4345
4868
|
const reconcile = () => {
|
|
4346
4869
|
dirty = true;
|
|
4347
|
-
|
|
4870
|
+
const result = chain.then(async () => {
|
|
4348
4871
|
let guard = 0;
|
|
4349
4872
|
while (dirty) {
|
|
4350
4873
|
dirty = false;
|
|
4351
4874
|
await runOnce();
|
|
4352
|
-
if (++guard >
|
|
4875
|
+
if (++guard > 20) break;
|
|
4353
4876
|
}
|
|
4354
4877
|
});
|
|
4878
|
+
chain = result.then(() => void 0, () => void 0);
|
|
4879
|
+
return result;
|
|
4355
4880
|
};
|
|
4356
4881
|
return { reconcile };
|
|
4357
4882
|
}
|
|
@@ -4372,6 +4897,7 @@ const TOKEN_FORGET_PATH = "/api/dsh-connect-modelscope-token-plan/token/forget";
|
|
|
4372
4897
|
const PROBE_PATH = "/api/dsh-connect-modelscope-token-plan/probe";
|
|
4373
4898
|
const PROVIDER_PATH = "/api/dsh-connect-modelscope-token-plan/provider";
|
|
4374
4899
|
const DRAW_PATH = "/api/dsh-connect-modelscope-token-plan/draw";
|
|
4900
|
+
const TREND_WINDOW_PATH = "/api/dsh-connect-modelscope-token-plan/trend/window";
|
|
4375
4901
|
|
|
4376
4902
|
//#endregion
|
|
4377
4903
|
//#region src/host/snapshot-aggregate.ts
|
|
@@ -4417,12 +4943,14 @@ async function soft(promise) {
|
|
|
4417
4943
|
}
|
|
4418
4944
|
/** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
|
|
4419
4945
|
async function buildSnapshotBody(wiring) {
|
|
4420
|
-
const { settings, tokenStore, usageStore, inference, providerStore, drawStore, publisher } = wiring;
|
|
4946
|
+
const { settings, tokenStore, usageStore, inference, providerStore, drawStore, usagePrefsStore, publisher } = wiring;
|
|
4947
|
+
const panelWindow = await usagePrefsStore.windowDays().catch(() => null);
|
|
4948
|
+
const windowDays = resolveWindowDays(panelWindow, settings.trendDays, settings.retentionDays);
|
|
4421
4949
|
const [tokenState, daily, perModel, trend, events, providerNote] = await Promise.all([
|
|
4422
4950
|
soft(tokenStore.state()),
|
|
4423
4951
|
soft(usageStore.daily()),
|
|
4424
4952
|
soft(usageStore.perModelToday()),
|
|
4425
|
-
soft(usageStore.trend(
|
|
4953
|
+
soft(usageStore.trend(windowDays)),
|
|
4426
4954
|
soft(usageStore.events()),
|
|
4427
4955
|
soft(providerStore.versionNote())
|
|
4428
4956
|
]);
|
|
@@ -4446,11 +4974,17 @@ async function buildSnapshotBody(wiring) {
|
|
|
4446
4974
|
if (providerNote.ok && providerNote.value !== null) shapeWarnings.push(providerNote.value);
|
|
4447
4975
|
const drawNote = await soft((async () => drawStore.versionNote())());
|
|
4448
4976
|
if (drawNote.ok && drawNote.value !== null) shapeWarnings.push(drawNote.value);
|
|
4977
|
+
const windowNote = await soft((async () => usagePrefsStore.versionNote())());
|
|
4978
|
+
if (windowNote.ok && windowNote.value !== null) shapeWarnings.push(windowNote.value);
|
|
4979
|
+
if (curatedCatalogStale()) {
|
|
4980
|
+
const age = curatedCatalogAgeDays();
|
|
4981
|
+
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");
|
|
4982
|
+
}
|
|
4449
4983
|
const provider = await soft((async () => {
|
|
4450
4984
|
const panel = await providerStore.enabled().catch(() => null);
|
|
4451
4985
|
const enabled = resolveSwitchEnabled(panel, settings.registerProvider);
|
|
4452
4986
|
const source = switchSource(panel);
|
|
4453
|
-
const roster = rosterWithAvailability(modelEntries, []);
|
|
4987
|
+
const roster = rosterWithAvailability(modelEntries, publisher.state.unavailableIds ?? [], settings.maxTokensOverrides);
|
|
4454
4988
|
const stored = await providerStore.enabledIds().catch(() => null);
|
|
4455
4989
|
const enabledIds = resolveEnabledIds(stored, publisher.state.enabledIds);
|
|
4456
4990
|
const allowedList = parseAllowedList(enabledIds);
|
|
@@ -4473,16 +5007,22 @@ async function buildSnapshotBody(wiring) {
|
|
|
4473
5007
|
const draw = await soft((async () => {
|
|
4474
5008
|
const panel = await drawStore.enabled().catch(() => null);
|
|
4475
5009
|
const panelModel = await drawStore.modelId().catch(() => null);
|
|
4476
|
-
const
|
|
5010
|
+
const candidates = drawCandidates(modelEntries);
|
|
5011
|
+
const preferred = resolveSwitchValue(panelModel, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
|
|
5012
|
+
const choice = resolveDrawModelChoice(modelEntries, void 0, preferred ?? void 0);
|
|
4477
5013
|
return {
|
|
4478
5014
|
enabled: resolveSwitchEnabled(panel, settings.drawEnabled),
|
|
4479
5015
|
source: switchSource(panel),
|
|
4480
|
-
candidateIds,
|
|
4481
|
-
candidateCount:
|
|
4482
|
-
|
|
5016
|
+
candidateIds: candidates.map((row) => row.id),
|
|
5017
|
+
candidateCount: candidates.length,
|
|
5018
|
+
candidates,
|
|
5019
|
+
preferredModel: preferred,
|
|
4483
5020
|
preferredSource: switchSource(panelModel),
|
|
5021
|
+
effectiveModel: choice.id,
|
|
5022
|
+
effectiveSource: choice.source,
|
|
4484
5023
|
absentReason: drawToolNote(),
|
|
4485
|
-
mountPending: resolveSwitchEnabled(panel, settings.drawEnabled) === true &&
|
|
5024
|
+
mountPending: resolveSwitchEnabled(panel, settings.drawEnabled) === true && drawToolMounted() !== true,
|
|
5025
|
+
preferredInCatalog: preferred === null ? null : models.ok ? candidates.some((row) => row.id === preferred) : null,
|
|
4486
5026
|
toolName: "modelscope_draw_image"
|
|
4487
5027
|
};
|
|
4488
5028
|
})());
|
|
@@ -4522,13 +5062,15 @@ async function buildSnapshotBody(wiring) {
|
|
|
4522
5062
|
},
|
|
4523
5063
|
quota: {
|
|
4524
5064
|
daily: { usedLocal },
|
|
4525
|
-
perModel: perModel.ok ? perModel.value :
|
|
5065
|
+
perModel: perModel.ok ? perModel.value : null,
|
|
4526
5066
|
countingNote: "local-counting"
|
|
4527
5067
|
},
|
|
4528
|
-
events: events.ok ? events.value :
|
|
5068
|
+
events: events.ok ? events.value : null,
|
|
4529
5069
|
trend: {
|
|
4530
|
-
days:
|
|
4531
|
-
|
|
5070
|
+
days: windowDays,
|
|
5071
|
+
windowDays: panelWindow,
|
|
5072
|
+
maxDays: settings.retentionDays,
|
|
5073
|
+
buckets: trend.ok ? trend.value : null
|
|
4532
5074
|
},
|
|
4533
5075
|
models: {
|
|
4534
5076
|
available: models.ok,
|
|
@@ -4585,7 +5127,7 @@ async function readJsonBody(request, limit = MAX_JSON_BODY_BYTES) {
|
|
|
4585
5127
|
received += buffer.byteLength;
|
|
4586
5128
|
if (received > limit) return {
|
|
4587
5129
|
ok: false,
|
|
4588
|
-
error:
|
|
5130
|
+
error: `request body is too large (max ${limit} bytes)`
|
|
4589
5131
|
};
|
|
4590
5132
|
chunks.push(buffer);
|
|
4591
5133
|
}
|
|
@@ -4941,22 +5483,22 @@ function registerProbeRoute(ctx, wiring) {
|
|
|
4941
5483
|
* @module dsh-connect-modelscope-token-plan/routes/provider
|
|
4942
5484
|
*/
|
|
4943
5485
|
/**
|
|
4944
|
-
*
|
|
5486
|
+
* 读目录条目,读不到**抛错**(让 snapshot() 走 respondError 返回 ok:false)。
|
|
4945
5487
|
*
|
|
4946
5488
|
* 读目录前先取**当前窗口覆盖**传给 `fetchModels`:条目在归一前打上覆盖标记,
|
|
4947
5489
|
* descriptor / roster / catalogSignature 都从这份条目派生。每个写路由(开关、
|
|
4948
5490
|
* 清单、窗口、reset)都经这里取条目再 publish,所以任何一次重注册都带着最新
|
|
4949
5491
|
* 覆盖——否则先存窗口、再存清单会把未打标的目录重新注册上去,覆盖静默失效到
|
|
4950
5492
|
* 下一轮轮询。
|
|
5493
|
+
*
|
|
5494
|
+
* **为什么抛错而不是降级成空目录**:空 roster 与「真的没有模型」在面板上长得
|
|
5495
|
+
* 一模一样(modelCount=0),而真相是「目录拉不到」——这与 snapshot-aggregate
|
|
5496
|
+
* 的 `models.available=false` 语义矛盾(后者明确区分「0 个模型」与「读不到」)。
|
|
5497
|
+
* 抛错让 GET 返回 ok:false + error,面板能显示「目录拉不到」而不是「0 个模型」。
|
|
4951
5498
|
*/
|
|
4952
5499
|
async function readEntries$1(inference, providerStore) {
|
|
4953
5500
|
const contextWindow = await optional(providerStore?.contextWindow?.(), null);
|
|
4954
|
-
const catalog = await inference.fetchModels(contextWindow)
|
|
4955
|
-
return {
|
|
4956
|
-
entries: [],
|
|
4957
|
-
__error: error
|
|
4958
|
-
};
|
|
4959
|
-
});
|
|
5501
|
+
const catalog = await inference.fetchModels(contextWindow);
|
|
4960
5502
|
return Array.isArray(catalog?.entries) ? catalog.entries : [];
|
|
4961
5503
|
}
|
|
4962
5504
|
/**
|
|
@@ -4987,15 +5529,28 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
4987
5529
|
/**
|
|
4988
5530
|
* 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
|
|
4989
5531
|
* 状态是什么」说两套话。
|
|
5532
|
+
*
|
|
5533
|
+
* **目录读不到怎么办**:GET 是权威读路径,读不到就让它失败(handler 转
|
|
5534
|
+
* `respondError`,面板看见「目录拉不到」而不是「0 个模型」)。POST 的响应只是
|
|
5535
|
+
* 保存后的回读,而此时**保存已经成功**——不能把一次成功的写报成失败,所以
|
|
5536
|
+
* `tolerateCatalogFailure` 让它降级为空 roster 并带上 `catalogError` 说明原因。
|
|
4990
5537
|
* @param {object} [extra] - 调用方要追加/覆盖的字段。
|
|
5538
|
+
* @param {object} [options] - `{ tolerateCatalogFailure }`:写成功后的回读宽容模式。
|
|
4991
5539
|
* @returns {Promise<object>} 响应体。
|
|
4992
5540
|
*/
|
|
4993
|
-
const snapshot = async (extra = {}) => {
|
|
5541
|
+
const snapshot = async (extra = {}, options = {}) => {
|
|
4994
5542
|
const panelSwitch = await optional(providerStore.enabled());
|
|
4995
5543
|
const enabledIds = await readEnabledIds(providerStore, publisher.state.enabledIds);
|
|
4996
5544
|
const contextWindow = await optional(providerStore.contextWindow?.(), null);
|
|
4997
|
-
|
|
4998
|
-
|
|
5545
|
+
let entries = [];
|
|
5546
|
+
let catalogError = null;
|
|
5547
|
+
try {
|
|
5548
|
+
entries = await readEntries$1(inference, providerStore);
|
|
5549
|
+
} catch (error) {
|
|
5550
|
+
if (options.tolerateCatalogFailure !== true) throw error;
|
|
5551
|
+
catalogError = redactedError(error);
|
|
5552
|
+
}
|
|
5553
|
+
const roster = rosterWithAvailability(entries, publisher.state.unavailableIds ?? [], settings.maxTokensOverrides);
|
|
4999
5554
|
const allowedList = parseAllowedList(enabledIds);
|
|
5000
5555
|
const modelCount = roster.length;
|
|
5001
5556
|
return {
|
|
@@ -5011,6 +5566,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5011
5566
|
enabledIds,
|
|
5012
5567
|
contextWindow,
|
|
5013
5568
|
roster,
|
|
5569
|
+
...catalogError !== null ? { catalogError } : {},
|
|
5014
5570
|
...extra
|
|
5015
5571
|
};
|
|
5016
5572
|
};
|
|
@@ -5021,7 +5577,11 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5021
5577
|
handler: withOrigin(async (request, response) => {
|
|
5022
5578
|
const method = request.method === void 0 ? "GET" : request.method;
|
|
5023
5579
|
if (method === "GET") {
|
|
5024
|
-
|
|
5580
|
+
try {
|
|
5581
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5582
|
+
} catch (error) {
|
|
5583
|
+
respondError(response, error);
|
|
5584
|
+
}
|
|
5025
5585
|
return;
|
|
5026
5586
|
}
|
|
5027
5587
|
if (method !== "POST") {
|
|
@@ -5044,7 +5604,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5044
5604
|
respondError(response, error);
|
|
5045
5605
|
return;
|
|
5046
5606
|
}
|
|
5047
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5607
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
5048
5608
|
}, settings.allowedHosts)
|
|
5049
5609
|
}));
|
|
5050
5610
|
offs.push(ctx.webServer.register({
|
|
@@ -5072,7 +5632,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5072
5632
|
respondError(response, error);
|
|
5073
5633
|
return;
|
|
5074
5634
|
}
|
|
5075
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5635
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
5076
5636
|
}, settings.allowedHosts)
|
|
5077
5637
|
}));
|
|
5078
5638
|
offs.push(ctx.webServer.register({
|
|
@@ -5099,7 +5659,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5099
5659
|
respondError(response, error);
|
|
5100
5660
|
return;
|
|
5101
5661
|
}
|
|
5102
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5662
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
5103
5663
|
}, settings.allowedHosts)
|
|
5104
5664
|
}));
|
|
5105
5665
|
offs.push(ctx.webServer.register({
|
|
@@ -5122,7 +5682,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5122
5682
|
respondError(response, error);
|
|
5123
5683
|
return;
|
|
5124
5684
|
}
|
|
5125
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5685
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
5126
5686
|
}, settings.allowedHosts)
|
|
5127
5687
|
}));
|
|
5128
5688
|
return () => {
|
|
@@ -5158,16 +5718,31 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5158
5718
|
* @module dsh-connect-modelscope-token-plan/routes/draw
|
|
5159
5719
|
*/
|
|
5160
5720
|
/**
|
|
5161
|
-
*
|
|
5721
|
+
* 读目录条目。
|
|
5162
5722
|
*
|
|
5163
5723
|
* 与 provider 路由同一条纪律:GET 不能因为一次目录失败就把整个面板状态打掉——
|
|
5164
5724
|
* 出图候选为空只影响「能不能挑模型」,不影响开关的显示。
|
|
5725
|
+
*
|
|
5726
|
+
* 但「空目录」和「目录读不到」必须分开(红线 3):后者要能回答「首选模型还在
|
|
5727
|
+
* 不在」时说「不知道」而不是「不在」。所以这里返回 `{ ok, entries }`,调用方
|
|
5728
|
+
* 按 ok 决定是断言还是弃权。
|
|
5729
|
+
*
|
|
5165
5730
|
* @param {object} inference - inference client。
|
|
5166
|
-
* @returns {Promise<unknown[]>}
|
|
5731
|
+
* @returns {Promise<{ok: boolean, entries: unknown[]}>} 目录读取结果与归一条目。
|
|
5167
5732
|
*/
|
|
5168
5733
|
async function readEntries(inference) {
|
|
5169
|
-
|
|
5170
|
-
|
|
5734
|
+
try {
|
|
5735
|
+
const catalog = await inference.fetchModels();
|
|
5736
|
+
return {
|
|
5737
|
+
ok: true,
|
|
5738
|
+
entries: Array.isArray(catalog?.entries) ? catalog.entries : []
|
|
5739
|
+
};
|
|
5740
|
+
} catch {
|
|
5741
|
+
return {
|
|
5742
|
+
ok: false,
|
|
5743
|
+
entries: []
|
|
5744
|
+
};
|
|
5745
|
+
}
|
|
5171
5746
|
}
|
|
5172
5747
|
/**
|
|
5173
5748
|
* 注册出图路由(两条路径)。
|
|
@@ -5185,26 +5760,35 @@ function registerDrawRoute(ctx, wiring) {
|
|
|
5185
5760
|
const snapshot = async (extra = {}) => {
|
|
5186
5761
|
const panelSwitch = await optional(drawStore.enabled());
|
|
5187
5762
|
const panelModelId = await optional(drawStore.modelId());
|
|
5188
|
-
const
|
|
5189
|
-
const
|
|
5190
|
-
const
|
|
5763
|
+
const catalog = await readEntries(inference);
|
|
5764
|
+
const candidates = drawCandidates(catalog.entries);
|
|
5765
|
+
const preferredModelId = resolveSwitchValue(panelModelId, settings.drawModelId === "" ? void 0 : settings.drawModelId) ?? null;
|
|
5766
|
+
const choice = resolveDrawModelChoice(catalog.entries, void 0, preferredModelId ?? void 0);
|
|
5191
5767
|
return {
|
|
5192
5768
|
ok: true,
|
|
5193
5769
|
enabled: resolveSwitchEnabled(panelSwitch, settings.drawEnabled),
|
|
5194
5770
|
source: switchSource(panelSwitch),
|
|
5195
|
-
candidateIds,
|
|
5196
|
-
candidateCount:
|
|
5197
|
-
|
|
5771
|
+
candidateIds: candidates.map((row) => row.id),
|
|
5772
|
+
candidateCount: candidates.length,
|
|
5773
|
+
candidates,
|
|
5774
|
+
preferredModel: preferredModelId,
|
|
5198
5775
|
preferredSource: switchSource(panelModelId),
|
|
5776
|
+
effectiveModel: choice.id,
|
|
5777
|
+
effectiveSource: choice.source,
|
|
5199
5778
|
absentReason: drawToolNote(),
|
|
5200
5779
|
/**
|
|
5201
5780
|
* 开关的保存**不会**改变本次挂载的工具。
|
|
5202
5781
|
*
|
|
5203
5782
|
* 工具无法注销(peer 的 tools registry 没有 unregister),所以开关翻转只在
|
|
5204
5783
|
* 下次 Host 启动时生效。面板据此显示一句说明——否则用户会以为开关坏了。
|
|
5205
|
-
*
|
|
5784
|
+
* 判据:开关为开且**本次挂载没注册上**。
|
|
5785
|
+
*
|
|
5786
|
+
* 注意判据不是「有缺席原因」:缺席原因非 null 表示注册尝试过且失败,而挂载
|
|
5787
|
+
* 那一刻开关是关的(用户之后才翻开)时 absentReason 为 null、却同样没注册
|
|
5788
|
+
* 上——后者才是最需要这句提示的场景。见 `draw-tool-state.ts` 文件头。
|
|
5206
5789
|
*/
|
|
5207
|
-
mountPending: resolveSwitchEnabled(panelSwitch, settings.drawEnabled) === true &&
|
|
5790
|
+
mountPending: resolveSwitchEnabled(panelSwitch, settings.drawEnabled) === true && drawToolMounted() !== true,
|
|
5791
|
+
preferredInCatalog: preferredModelId === null ? null : catalog.ok ? candidates.some((row) => row.id === preferredModelId) : null,
|
|
5208
5792
|
toolName: "modelscope_draw_image",
|
|
5209
5793
|
...extra
|
|
5210
5794
|
};
|
|
@@ -5288,6 +5872,95 @@ function registerDrawRoute(ctx, wiring) {
|
|
|
5288
5872
|
};
|
|
5289
5873
|
}
|
|
5290
5874
|
|
|
5875
|
+
//#endregion
|
|
5876
|
+
//#region src/host/routes/usage-prefs.ts
|
|
5877
|
+
/**
|
|
5878
|
+
* 趋势图**显示窗口**路由:读当前窗口(GET)与保存(POST)。
|
|
5879
|
+
*
|
|
5880
|
+
* 一条路径两件事:
|
|
5881
|
+
* - GET `TREND_WINDOW_PATH` 读面板覆盖 + 生效天数 + 上界(无副作用);
|
|
5882
|
+
* - POST `TREND_WINDOW_PATH` body `{ windowDays: number|null }` → 保存
|
|
5883
|
+
* (`null` = 清除覆盖,回到 `trendDays` 配置默认)。
|
|
5884
|
+
*
|
|
5885
|
+
* **与 provider / draw 路由不同,本路由保存后不触发任何收敛**:显示窗口只影响
|
|
5886
|
+
* 快照里 `trend` 那一段,面板保存成功后自己调一次 `load()` 就当场回显;没有
|
|
5887
|
+
* 需要重建的注册态。
|
|
5888
|
+
*
|
|
5889
|
+
* 越界值**夹取而不是拒绝**:上界是保留窗口(`settings.retentionDays`),而
|
|
5890
|
+
* host-config 对配置里的同一个值也是夹——两处若一个夹一个拒,同一个越界值会因
|
|
5891
|
+
* 来源不同得到两个下场。类型给错(字符串 / 缺字段)仍然按 400 拒:那是形状错,
|
|
5892
|
+
* 不是范围错。
|
|
5893
|
+
*
|
|
5894
|
+
* peer-free:不 import 任何 Host peer。
|
|
5895
|
+
* @module dsh-connect-modelscope-token-plan/routes/usage-prefs
|
|
5896
|
+
*/
|
|
5897
|
+
/**
|
|
5898
|
+
* 注册趋势窗口路由。
|
|
5899
|
+
* @param ctx - host 根上下文(只用 `ctx.webServer`)。
|
|
5900
|
+
* @param {UsagePrefsRouteWiring} wiring - 本路由读到的子集。
|
|
5901
|
+
* @returns {Function} 注销回调。
|
|
5902
|
+
*/
|
|
5903
|
+
function registerUsagePrefsRoute(ctx, wiring) {
|
|
5904
|
+
const { settings, usagePrefsStore, logger } = wiring;
|
|
5905
|
+
/**
|
|
5906
|
+
* 组装响应。GET 与写成功后的响应共用,两个方向不可能对「当前窗口」说两套话。
|
|
5907
|
+
* @param {object} [extra] - 追加/覆盖字段。
|
|
5908
|
+
* @returns {Promise<object>} 响应体。
|
|
5909
|
+
*/
|
|
5910
|
+
const snapshot = async (extra = {}) => {
|
|
5911
|
+
const panel = await optional(usagePrefsStore.windowDays());
|
|
5912
|
+
return {
|
|
5913
|
+
ok: true,
|
|
5914
|
+
windowDays: panel,
|
|
5915
|
+
days: resolveWindowDays(panel, settings.trendDays, settings.retentionDays),
|
|
5916
|
+
maxDays: settings.retentionDays,
|
|
5917
|
+
source: switchSource(panel),
|
|
5918
|
+
...extra
|
|
5919
|
+
};
|
|
5920
|
+
};
|
|
5921
|
+
const off = ctx.webServer.register({
|
|
5922
|
+
kind: "exact",
|
|
5923
|
+
path: TREND_WINDOW_PATH,
|
|
5924
|
+
handler: withOrigin(async (request, response) => {
|
|
5925
|
+
const method = request.method === void 0 ? "GET" : request.method;
|
|
5926
|
+
if (method === "GET") {
|
|
5927
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5928
|
+
return;
|
|
5929
|
+
}
|
|
5930
|
+
if (method !== "POST") {
|
|
5931
|
+
refuseMethod(response);
|
|
5932
|
+
return;
|
|
5933
|
+
}
|
|
5934
|
+
const body = await readJsonBodyOr400(request, response);
|
|
5935
|
+
if (body === null) return;
|
|
5936
|
+
const isNull = body.windowDays === null;
|
|
5937
|
+
const isNumber = typeof body.windowDays === "number" && Number.isFinite(body.windowDays);
|
|
5938
|
+
if (!isNull && !isNumber) {
|
|
5939
|
+
writeJson(response, 400, {
|
|
5940
|
+
ok: false,
|
|
5941
|
+
error: "expected { windowDays: number | null }"
|
|
5942
|
+
}, { "cache-control": "no-store" });
|
|
5943
|
+
return;
|
|
5944
|
+
}
|
|
5945
|
+
const saved = isNull ? null : Math.min(Math.max(1, Math.floor(body.windowDays)), settings.retentionDays);
|
|
5946
|
+
try {
|
|
5947
|
+
await usagePrefsStore.saveWindowDays(saved);
|
|
5948
|
+
} catch (error) {
|
|
5949
|
+
respondError(response, error);
|
|
5950
|
+
return;
|
|
5951
|
+
}
|
|
5952
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5953
|
+
}, settings.allowedHosts)
|
|
5954
|
+
});
|
|
5955
|
+
return () => {
|
|
5956
|
+
try {
|
|
5957
|
+
off();
|
|
5958
|
+
} catch (error) {
|
|
5959
|
+
logger?.warn?.(`trend window route unregister failed: ${redactedError(error)}`);
|
|
5960
|
+
}
|
|
5961
|
+
};
|
|
5962
|
+
}
|
|
5963
|
+
|
|
5291
5964
|
//#endregion
|
|
5292
5965
|
//#region src/host/routes.ts
|
|
5293
5966
|
/**
|
|
@@ -5304,7 +5977,8 @@ function registerRoutes(ctx, wiring) {
|
|
|
5304
5977
|
...registerTokenRoute(ctx, wiring),
|
|
5305
5978
|
registerProbeRoute(ctx, wiring),
|
|
5306
5979
|
registerProviderRoute(ctx, wiring),
|
|
5307
|
-
registerDrawRoute(ctx, wiring)
|
|
5980
|
+
registerDrawRoute(ctx, wiring),
|
|
5981
|
+
registerUsagePrefsRoute(ctx, wiring)
|
|
5308
5982
|
];
|
|
5309
5983
|
}
|
|
5310
5984
|
|
|
@@ -5352,7 +6026,7 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
5352
6026
|
const usageStore = createFileUsageStore({
|
|
5353
6027
|
name,
|
|
5354
6028
|
profile,
|
|
5355
|
-
|
|
6029
|
+
retentionDays: settings.retentionDays,
|
|
5356
6030
|
maxEvents: settings.maxEvents,
|
|
5357
6031
|
logger: ctx.logger
|
|
5358
6032
|
});
|
|
@@ -5370,6 +6044,10 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
5370
6044
|
profile,
|
|
5371
6045
|
logger: ctx.logger
|
|
5372
6046
|
});
|
|
6047
|
+
const usagePrefsStore = createFileUsagePrefsStore({
|
|
6048
|
+
profile,
|
|
6049
|
+
logger: ctx.logger
|
|
6050
|
+
});
|
|
5373
6051
|
const publisher = createProviderPublisher({
|
|
5374
6052
|
settings,
|
|
5375
6053
|
panelSwitch: () => providerStore.enabled(),
|
|
@@ -5391,6 +6069,7 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
5391
6069
|
providerStore,
|
|
5392
6070
|
inference,
|
|
5393
6071
|
publisher,
|
|
6072
|
+
usageEvents: () => usageStore.events(),
|
|
5394
6073
|
logger: ctx.logger
|
|
5395
6074
|
});
|
|
5396
6075
|
const wiring = {
|
|
@@ -5401,16 +6080,21 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
5401
6080
|
inference,
|
|
5402
6081
|
providerStore,
|
|
5403
6082
|
drawStore,
|
|
6083
|
+
usagePrefsStore,
|
|
5404
6084
|
publisher,
|
|
5405
6085
|
reconcile,
|
|
5406
6086
|
logger: ctx.logger
|
|
5407
6087
|
};
|
|
5408
6088
|
const offs = registerRoutes(ctx, wiring);
|
|
5409
6089
|
ctx.effect(() => {
|
|
5410
|
-
reconcile()
|
|
6090
|
+
reconcile().catch((error) => {
|
|
6091
|
+
ctx.logger?.warn?.(`${name}: mount reconcile failed (${redactSecrets(errMsg(error))})`);
|
|
6092
|
+
});
|
|
5411
6093
|
registerDrawTool(ctx, wiring, deps);
|
|
5412
6094
|
const timer = setInterval(() => {
|
|
5413
|
-
reconcile()
|
|
6095
|
+
reconcile().catch((error) => {
|
|
6096
|
+
ctx.logger?.warn?.(`${name}: scheduled reconcile failed (${redactSecrets(errMsg(error))})`);
|
|
6097
|
+
});
|
|
5414
6098
|
}, settings.pollSeconds * 1e3);
|
|
5415
6099
|
return () => {
|
|
5416
6100
|
clearInterval(timer);
|