dsh-connect-modelscope-token-plan 0.4.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 +30 -0
- package/README.md +18 -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 +632 -150
- package/cordis.patch.yml +9 -4
- package/lib/host-config.js +138 -6
- package/lib/index.js +586 -143
- package/lib/llm-adapter.js +38 -18
- package/package.json +1 -1
- 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 路由是唯一调用方)。
|
|
@@ -972,13 +977,14 @@ function parsePayload(raw) {
|
|
|
972
977
|
* @param {object} options
|
|
973
978
|
* @param {string} options.name - 插件 slug(状态目录名,来自 host-config)。
|
|
974
979
|
* @param {string|null} [options.profile] - profile 名;null = 共享目录。
|
|
975
|
-
* @param {number} [options.
|
|
976
|
-
*
|
|
980
|
+
* @param {number} [options.retentionDays] - 天桶**保留**数(超出即修剪;从配置注入,
|
|
981
|
+
* 让「配置改小」在下次写入时生效,而不是等一个固定的内部常数)。与「显示多少天」
|
|
982
|
+
* 是两件事——显示窗口由调用方传给 `trend(days)`,保留窗口恒 ≥ 它(host-config 夹)。
|
|
977
983
|
* @param {number} [options.maxEvents] - 事件流上限。
|
|
978
984
|
* @param {() => Date} [options.now] - 时钟源;测试注入。
|
|
979
985
|
* @param {{warn?: (message: string) => void}} [options.logger] - ctx.logger。
|
|
980
986
|
*/
|
|
981
|
-
function createFileUsageStore({ name, profile = null,
|
|
987
|
+
function createFileUsageStore({ name, profile = null, retentionDays = 90, maxEvents = 50, now = () => /* @__PURE__ */ new Date(), logger }) {
|
|
982
988
|
const dir = profileStateDir(name, profile);
|
|
983
989
|
const file = join(dir, "usage.json");
|
|
984
990
|
/** 最近一次解析异常(损坏即忽略的痕迹),快照转 shapeWarnings。 */
|
|
@@ -1064,9 +1070,9 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
1064
1070
|
loaded = true;
|
|
1065
1071
|
await persist(payload);
|
|
1066
1072
|
});
|
|
1067
|
-
/** 修剪:只留最近
|
|
1073
|
+
/** 修剪:只留最近 retentionDays 个天桶(按 dateKey 排序,今天不删)。 */
|
|
1068
1074
|
const pruneDays = (payload, todayKey) => {
|
|
1069
|
-
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)));
|
|
1070
1076
|
keep.add(todayKey);
|
|
1071
1077
|
for (const key of Object.keys(payload.days)) if (!keep.has(key)) delete payload.days[key];
|
|
1072
1078
|
};
|
|
@@ -1179,7 +1185,7 @@ function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents
|
|
|
1179
1185
|
return out;
|
|
1180
1186
|
},
|
|
1181
1187
|
/** 最近 N 天趋势(含今天;缺桶 = 该日 0 次,真实零不是数据丢失)。 */
|
|
1182
|
-
async trend(days =
|
|
1188
|
+
async trend(days = retentionDays) {
|
|
1183
1189
|
const payload = await cache.read();
|
|
1184
1190
|
const out = [];
|
|
1185
1191
|
const cursor = now();
|
|
@@ -1407,6 +1413,39 @@ function createCoalescedFetch(options = {}) {
|
|
|
1407
1413
|
* 目录声明过窗口的条目不进打标,策展表永不推翻平台声明。
|
|
1408
1414
|
* 4. 生图/语音模型(Qwen-Image-Edit、ACE-Step 等)无对话上下文,不收。
|
|
1409
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
|
+
}
|
|
1410
1449
|
/** id → 标称上下文窗口(tokens)。键必须与魔搭目录 id 逐字一致。 */
|
|
1411
1450
|
const CURATED_CONTEXT_WINDOWS = {
|
|
1412
1451
|
"deepseek-ai/DeepSeek-V4-Flash-0731": 1048576,
|
|
@@ -1493,6 +1532,25 @@ async function mapWithConcurrency(items, limit, fn) {
|
|
|
1493
1532
|
function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
1494
1533
|
const doFetch = deps.fetchImpl ?? fetch;
|
|
1495
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;
|
|
1496
1554
|
/** 带超时的一次 fetch(AbortController;abort → TIMEOUT_ERROR)。 */
|
|
1497
1555
|
const fetchWithTimeout = async (url, init) => {
|
|
1498
1556
|
const controller = new AbortController();
|
|
@@ -1512,13 +1570,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1512
1570
|
/**
|
|
1513
1571
|
* 丢弃一个我们不读的响应体。
|
|
1514
1572
|
*
|
|
1515
|
-
* undici 里未消费的 body 会**占住连接**直到 GC
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1573
|
+
* undici 里未消费的 body 会**占住连接**直到 GC。标签扇出会对目录里 30–40 个
|
|
1574
|
+
* 模型并发打详情端点(`mapWithConcurrency` 分批),其中任何一条走「!ok」或
|
|
1575
|
+
* 「形状不符」分支,连接就一直挂着——短时间内连接池被这些挂起的响应吃满,
|
|
1576
|
+
* 后面的请求开始排队。
|
|
1518
1577
|
*
|
|
1519
1578
|
* `cancel()` 是显式的弃权:告诉 undici「这个 body 我不要了」,它会立刻释放
|
|
1520
1579
|
* 连接而不是等GC。用 `void` 前缀是因为这是best-effort——取消失败不影响我们
|
|
1521
|
-
*
|
|
1580
|
+
* 已经决定的结果。
|
|
1522
1581
|
*/
|
|
1523
1582
|
const discardBody = (response) => {
|
|
1524
1583
|
try {
|
|
@@ -1539,8 +1598,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1539
1598
|
/**
|
|
1540
1599
|
* 单个模型的详情端点任务标签(modelscope.cn/api/v1/models/{id})。
|
|
1541
1600
|
* 免认证 GET,零推理额度(实测匿名 200)。返回 `Data.Tasks[].Name`(+
|
|
1542
|
-
* `widgets[].task`
|
|
1543
|
-
*
|
|
1601
|
+
* `widgets[].task` 兜底)去重后的标签数组。
|
|
1602
|
+
*
|
|
1603
|
+
* **失败一律抛,绝不返回 `[]`**:本函数的结果会被 {@link TAGS_TTL_MS} 缓存
|
|
1604
|
+
* 六小时,而 `[]` 是一个**结论**(「这个模型没有任务标签」)。把 429 / 5xx /
|
|
1605
|
+
* 形状漂移降级成 `[]` 再缓存,等于让一次网络抖动把某个模型的视觉标签钉死半天
|
|
1606
|
+
* ——正是「读不到 ≠ 没有」的反面。coalesced-fetch 不缓存 rejection(失败被当
|
|
1607
|
+
* 「不是答案」共享,但不落盘),所以抛出去的那一轮不会被记住;调用方 catch 成
|
|
1608
|
+
* `[]`,只影响这一轮,下一轮照常重试。
|
|
1544
1609
|
*/
|
|
1545
1610
|
const fetchTaskTags = async (id) => {
|
|
1546
1611
|
const detailUrl = settings.siteBase + "/api/v1/models/" + encodeURI(id);
|
|
@@ -1550,10 +1615,16 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1550
1615
|
});
|
|
1551
1616
|
if (!response.ok) {
|
|
1552
1617
|
discardBody(response);
|
|
1553
|
-
|
|
1618
|
+
throw new Error("model detail failed with HTTP " + String(response.status));
|
|
1554
1619
|
}
|
|
1555
|
-
|
|
1556
|
-
|
|
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");
|
|
1626
|
+
}
|
|
1627
|
+
if (json === null || typeof json !== "object" || json.Code !== 200) throw new Error("model detail shape drifted (expected {Code:200, Data:{Tasks:[...]}})");
|
|
1557
1628
|
const data = json.Data ?? {};
|
|
1558
1629
|
const tasks = [];
|
|
1559
1630
|
for (const t of Array.isArray(data.Tasks) ? data.Tasks : []) {
|
|
@@ -1594,7 +1665,9 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1594
1665
|
*/
|
|
1595
1666
|
async fetchModels(contextOverride) {
|
|
1596
1667
|
const url = settings.apiBase + "/models";
|
|
1597
|
-
|
|
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 () => {
|
|
1598
1671
|
const response = await fetchWithTimeout(url, { method: "GET" });
|
|
1599
1672
|
if (!response.ok) {
|
|
1600
1673
|
const message = await readErrorMessage(response);
|
|
@@ -1605,7 +1678,14 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1605
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:[...]})");
|
|
1606
1679
|
const raws = json.data;
|
|
1607
1680
|
const ids = raws.map((r) => str(r.id, "")).filter((id) => id !== "");
|
|
1608
|
-
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
|
+
});
|
|
1609
1689
|
const tagsById = /* @__PURE__ */ new Map();
|
|
1610
1690
|
ids.forEach((id, i) => tagsById.set(id, tagArrays[i] ?? []));
|
|
1611
1691
|
const entries = raws.map((raw) => {
|
|
@@ -1645,7 +1725,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1645
1725
|
ids: entries.map((entry) => entry.id),
|
|
1646
1726
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1647
1727
|
};
|
|
1648
|
-
},
|
|
1728
|
+
}, cacheTtl);
|
|
1649
1729
|
},
|
|
1650
1730
|
/**
|
|
1651
1731
|
* 官方「魔粒」余额:GET siteBase/openapi/v1/magicubes/balance(Bearer
|
|
@@ -1780,7 +1860,7 @@ function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
|
1780
1860
|
* collapses here, so the three call sites that produce it stay in step instead
|
|
1781
1861
|
* of each spelling out the same three `null`s.
|
|
1782
1862
|
*/
|
|
1783
|
-
const EMPTY_PAYLOAD$
|
|
1863
|
+
const EMPTY_PAYLOAD$2 = {
|
|
1784
1864
|
enabled: null,
|
|
1785
1865
|
enabledIds: null,
|
|
1786
1866
|
contextWindow: null,
|
|
@@ -2009,7 +2089,7 @@ function createFileProviderStore(options = {}) {
|
|
|
2009
2089
|
* `null` branch collapses here, in one place, rather than at every caller.
|
|
2010
2090
|
* @returns {Promise<ProviderPayload>} the payload; the empty payload when nothing is stored.
|
|
2011
2091
|
*/
|
|
2012
|
-
const readPayload = async () => await read() ?? EMPTY_PAYLOAD$
|
|
2092
|
+
const readPayload = async () => await read() ?? EMPTY_PAYLOAD$2;
|
|
2013
2093
|
/**
|
|
2014
2094
|
* Read one payload, then hand it to {@link writePayload} — the read-modify-
|
|
2015
2095
|
* write that keeps the switch and the list from clobbering each other.
|
|
@@ -2227,7 +2307,7 @@ function createFileProviderStore(options = {}) {
|
|
|
2227
2307
|
const DRAW_STORE_VERSION = 1;
|
|
2228
2308
|
/** 本 build 读得懂的版本集合(升级时把新号加进来,旧号保持可读)。 */
|
|
2229
2309
|
const KNOWN_DRAW_VERSIONS = Object.freeze([1]);
|
|
2230
|
-
const EMPTY_PAYLOAD = {
|
|
2310
|
+
const EMPTY_PAYLOAD$1 = {
|
|
2231
2311
|
enabled: null,
|
|
2232
2312
|
modelId: null,
|
|
2233
2313
|
version: null
|
|
@@ -2326,7 +2406,7 @@ function createFileDrawStore(options = {}) {
|
|
|
2326
2406
|
* 读当前 payload。
|
|
2327
2407
|
* @returns {Promise<DrawPayload>}
|
|
2328
2408
|
*/
|
|
2329
|
-
const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD;
|
|
2409
|
+
const readPayload = async () => await cache.read() ?? EMPTY_PAYLOAD$1;
|
|
2330
2410
|
/**
|
|
2331
2411
|
* 读-改-写一份补丁,**整段排队**。
|
|
2332
2412
|
*
|
|
@@ -2413,6 +2493,264 @@ function createFileDrawStore(options = {}) {
|
|
|
2413
2493
|
};
|
|
2414
2494
|
}
|
|
2415
2495
|
|
|
2496
|
+
//#endregion
|
|
2497
|
+
//#region src/host/switch-precedence.ts
|
|
2498
|
+
/**
|
|
2499
|
+
* 所有 opt-in 开关共用的唯一裁决:「面板保存值 vs 配置默认值」。
|
|
2500
|
+
*
|
|
2501
|
+
* 规则只有一条:**面板保存值赢,否则配置默认值说了算**。来源标签(`panel` /
|
|
2502
|
+
* `config`)随答案一起返回,面板才能说出是谁在管。规则收在一处,是为了让开关
|
|
2503
|
+
* 再也无法各自发明一套优先级方言;`test/switch-precedence.test.mjs` 钉死这个
|
|
2504
|
+
* 方言,照抄形状的新调用方会被它标成异类。
|
|
2505
|
+
*
|
|
2506
|
+
* 不服务「纯面板拥有、无配置默认值」的开关——那种没有可裁决的配置默认值,
|
|
2507
|
+
* 调用方直接读即可。peer-free,纯函数。
|
|
2508
|
+
*
|
|
2509
|
+
* @module dsh-connect-modelscope-token-plan/switch-precedence
|
|
2510
|
+
*/
|
|
2511
|
+
/**
|
|
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.
|
|
2517
|
+
*/
|
|
2518
|
+
function resolveSwitchEnabled(panel, config) {
|
|
2519
|
+
return (panel ?? config) === true;
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
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.
|
|
2527
|
+
*/
|
|
2528
|
+
function resolveSwitchValue(panel, config) {
|
|
2529
|
+
return panel ?? config;
|
|
2530
|
+
}
|
|
2531
|
+
/**
|
|
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.
|
|
2534
|
+
*
|
|
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.
|
|
2541
|
+
*/
|
|
2542
|
+
function resolveSwitchNumber(panel, config) {
|
|
2543
|
+
return panel ?? config;
|
|
2544
|
+
}
|
|
2545
|
+
/**
|
|
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"}
|
|
2551
|
+
*/
|
|
2552
|
+
function switchSource(panel) {
|
|
2553
|
+
return panel === null ? "config" : "panel";
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
//#endregion
|
|
2557
|
+
//#region src/host/usage-prefs-store.ts
|
|
2558
|
+
/**
|
|
2559
|
+
* 用量面板的**显示偏好**——目前只有一个字段:趋势图的显示窗口(画多少天)。
|
|
2560
|
+
*
|
|
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
|
|
2586
|
+
*/
|
|
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
|
+
};
|
|
2595
|
+
/**
|
|
2596
|
+
* 本 build 读不读得懂这个版本。
|
|
2597
|
+
*
|
|
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
|
+
|
|
2416
2754
|
//#endregion
|
|
2417
2755
|
//#region src/host/draw.ts
|
|
2418
2756
|
/**
|
|
@@ -2787,19 +3125,6 @@ function createDrawJobs(options = {}) {
|
|
|
2787
3125
|
*/
|
|
2788
3126
|
/** agent 工具名。带插件前缀,避免与 `dsh-draw-router` 的 `draw_image` 撞名。 */
|
|
2789
3127
|
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
3128
|
/** 一次失败的冷却窗:出图慢,失败后立刻重试既易撞限流又白等。 */
|
|
2804
3129
|
const DRAW_FAILURE_COOLDOWN_MS = 6e4;
|
|
2805
3130
|
/**
|
|
@@ -2906,7 +3231,7 @@ async function submitDrawTask({ fetchImpl, apiBase, apiKey, model, prompt, image
|
|
|
2906
3231
|
* @param {number} [options.pollIntervalMs] - 轮询间隔(测试可缩短)。
|
|
2907
3232
|
* @returns {Promise<DrawAwait>} 三种结局之一。
|
|
2908
3233
|
*/
|
|
2909
|
-
async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS }) {
|
|
3234
|
+
async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW_WAIT_BUDGET_MS, pollIntervalMs = DRAW_POLL_INTERVAL_MS, signal }) {
|
|
2910
3235
|
const headers = {
|
|
2911
3236
|
Authorization: `Bearer ${apiKey}`,
|
|
2912
3237
|
"Content-Type": "application/json"
|
|
@@ -2920,7 +3245,14 @@ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW
|
|
|
2920
3245
|
kind: "pending",
|
|
2921
3246
|
polls
|
|
2922
3247
|
};
|
|
2923
|
-
|
|
3248
|
+
try {
|
|
3249
|
+
await sleep(interval, signal);
|
|
3250
|
+
} catch {
|
|
3251
|
+
return {
|
|
3252
|
+
kind: "pending",
|
|
3253
|
+
polls
|
|
3254
|
+
};
|
|
3255
|
+
}
|
|
2924
3256
|
polls++;
|
|
2925
3257
|
let polled;
|
|
2926
3258
|
try {
|
|
@@ -2995,16 +3327,7 @@ async function awaitDrawTask({ fetchImpl, apiBase, apiKey, taskId, waitMs = DRAW
|
|
|
2995
3327
|
* @returns {string|null} 选中的 id。
|
|
2996
3328
|
*/
|
|
2997
3329
|
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;
|
|
3330
|
+
return resolveDrawModelChoice(entries, requested, preferred).id;
|
|
3008
3331
|
}
|
|
3009
3332
|
/**
|
|
3010
3333
|
* 目录里可用于出图的模型 id(工具结果里回显,方便 agent 与用户看到实际候选)。
|
|
@@ -3061,6 +3384,10 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
3061
3384
|
*/
|
|
3062
3385
|
const waitMs = Math.min(timeoutMs, Math.max(1, Math.floor(Number(settings?.drawWaitMs) > 0 ? Number(settings?.drawWaitMs) : DRAW_WAIT_BUDGET_MS)));
|
|
3063
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
|
+
};
|
|
3064
3391
|
/**
|
|
3065
3392
|
* 等一个作业,把 {@link awaitDrawTask} 的三种结局翻译成工具结果或错误。
|
|
3066
3393
|
*
|
|
@@ -3073,13 +3400,15 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
3073
3400
|
* @throws {Error} 真失败(上游 FAILED / 轮询 HTTP 失败)或越过总放弃线。
|
|
3074
3401
|
*/
|
|
3075
3402
|
const waitAndReport = async (apiKey, job, available) => {
|
|
3403
|
+
abortIfDisposed();
|
|
3076
3404
|
const awaited = await awaitDrawTask({
|
|
3077
3405
|
fetchImpl,
|
|
3078
3406
|
apiBase,
|
|
3079
3407
|
apiKey,
|
|
3080
3408
|
taskId: job.taskId,
|
|
3081
3409
|
waitMs,
|
|
3082
|
-
pollIntervalMs
|
|
3410
|
+
pollIntervalMs,
|
|
3411
|
+
signal: abortController.signal
|
|
3083
3412
|
});
|
|
3084
3413
|
if (awaited.kind === "failed") {
|
|
3085
3414
|
jobs.drop(job.jobId);
|
|
@@ -3101,7 +3430,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
3101
3430
|
};
|
|
3102
3431
|
}
|
|
3103
3432
|
const elapsedMs = Date.now() - job.submittedAt;
|
|
3104
|
-
if (elapsedMs >= timeoutMs) throw new Error(describeDrawFailure({ cause: `超过总预算(${Math.round(timeoutMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${job.taskId}
|
|
3433
|
+
if (elapsedMs >= timeoutMs) throw new Error(describeDrawFailure({ cause: `超过总预算(${Math.round(timeoutMs / 1e3)}s,${polls} 次轮询)仍未拿到结果。上游任务(${job.taskId})可能仍在生成——实测排队时长在 1–15 分钟间大幅波动,与请求本身无关;注意放弃后原任务的结果不保证还能取回` }));
|
|
3105
3434
|
return {
|
|
3106
3435
|
source: "modelscope",
|
|
3107
3436
|
status: "pending",
|
|
@@ -3114,7 +3443,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
3114
3443
|
};
|
|
3115
3444
|
return defineTool({
|
|
3116
3445
|
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
|
|
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.",
|
|
3118
3447
|
parameters: {
|
|
3119
3448
|
prompt: {
|
|
3120
3449
|
type: "string",
|
|
@@ -3175,7 +3504,7 @@ function defineDrawTool({ defineTool, fetchImpl, resolveApiKey, getEntries, sett
|
|
|
3175
3504
|
},
|
|
3176
3505
|
timeoutMs: waitMs + 1e4,
|
|
3177
3506
|
async execute(params) {
|
|
3178
|
-
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");
|
|
3179
3508
|
const apiKey = await resolveApiKey();
|
|
3180
3509
|
if (typeof apiKey !== "string" || apiKey.trim() === "") throw new Error("MODELSCOPE_API_KEY 未配置:在面板粘贴 ms- 访问令牌,或设置该环境变量");
|
|
3181
3510
|
const resumeId = str(params?.jobId, "");
|
|
@@ -3293,9 +3622,29 @@ async function safeJson(response, text) {
|
|
|
3293
3622
|
}
|
|
3294
3623
|
}
|
|
3295
3624
|
}
|
|
3296
|
-
/**
|
|
3297
|
-
|
|
3298
|
-
|
|
3625
|
+
/**
|
|
3626
|
+
* 可注入的 sleep(测试可覆写;这里只提供默认实现)。
|
|
3627
|
+
*
|
|
3628
|
+
* 支持 `AbortSignal`:插件 dispose 时可以立即中断等待,而不是等 sleep 自然结束。
|
|
3629
|
+
* 默认 15s 的轮询间隔意味着用户点「停止」后最多要等 15s 才真正停——abort 让
|
|
3630
|
+
* 停止信号立即生效。
|
|
3631
|
+
*/
|
|
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
|
+
});
|
|
3299
3648
|
}
|
|
3300
3649
|
|
|
3301
3650
|
//#endregion
|
|
@@ -3325,52 +3674,6 @@ function resetDrawToolNote() {
|
|
|
3325
3674
|
note = null;
|
|
3326
3675
|
}
|
|
3327
3676
|
|
|
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;
|
|
3352
|
-
}
|
|
3353
|
-
/**
|
|
3354
|
-
* Resolve the effective string preference (e.g. a draw-model id): a
|
|
3355
|
-
* panel-saved value wins, otherwise the config default rules.
|
|
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.
|
|
3359
|
-
*/
|
|
3360
|
-
function resolveSwitchValue(panel, config) {
|
|
3361
|
-
return panel ?? config;
|
|
3362
|
-
}
|
|
3363
|
-
/**
|
|
3364
|
-
* Where the effective value came from — the panel when it saved one, the
|
|
3365
|
-
* config otherwise. Rides with every resolved answer so the panel can name
|
|
3366
|
-
* the side in charge.
|
|
3367
|
-
* @param {boolean|string|null} panel - the panel-saved value.
|
|
3368
|
-
* @returns {"panel"|"config"}
|
|
3369
|
-
*/
|
|
3370
|
-
function switchSource(panel) {
|
|
3371
|
-
return panel === null ? "config" : "panel";
|
|
3372
|
-
}
|
|
3373
|
-
|
|
3374
3677
|
//#endregion
|
|
3375
3678
|
//#region src/host/draw-register.ts
|
|
3376
3679
|
/**
|
|
@@ -3393,11 +3696,14 @@ function switchSource(panel) {
|
|
|
3393
3696
|
*
|
|
3394
3697
|
* peer-dependent:`@deepseek-ai/dsh-tools` 由 `deps.loadToolsModule` 惰性注入。
|
|
3395
3698
|
*
|
|
3396
|
-
*
|
|
3397
|
-
*
|
|
3398
|
-
*
|
|
3399
|
-
*
|
|
3400
|
-
*
|
|
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
|
+
* 原因会不会被清掉」那条断言是测试自己满足的,是变异测试把它抓出来的。
|
|
3401
3707
|
*
|
|
3402
3708
|
* @module dsh-connect-modelscope-token-plan/draw-register
|
|
3403
3709
|
*/
|
|
@@ -4062,7 +4368,8 @@ function createProviderPublisher(deps = {}) {
|
|
|
4062
4368
|
resolveApiKey: effectiveResolveApiKey,
|
|
4063
4369
|
get: effectiveGetLlm,
|
|
4064
4370
|
unavailableModelIds: state.unavailableIds,
|
|
4065
|
-
...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)}`) } : {}
|
|
4066
4373
|
});
|
|
4067
4374
|
if (!isBuiltAdapter(built)) throw new Error(BAD_FACTORY_SHAPE_ERROR);
|
|
4068
4375
|
} catch (e) {
|
|
@@ -4191,6 +4498,10 @@ function createProviderPublisher(deps = {}) {
|
|
|
4191
4498
|
* 它只回答「重建会不会改变什么」:目录顺序里的模型 id,每个带上 descriptor 用的
|
|
4192
4499
|
* 同一个 vision 判定(id 没变但模态翻了也必须重建),加上允许清单。目录条目里其他
|
|
4193
4500
|
* 东西变了不影响注册的 offer。
|
|
4501
|
+
*
|
|
4502
|
+
* **不含展示名 `name`**:模型展示名变化不影响注册的 offer(descriptor 里的 `name`
|
|
4503
|
+
* 只是显示用的),重新注册是浪费——期间在途请求可能被打断。展示名变化不改变
|
|
4504
|
+
* 模型的能力、窗口或输出上限,所以不参与签名。
|
|
4194
4505
|
* @param {object[]} entries - 归一目录条目。
|
|
4195
4506
|
* @param {string[]} enabledIds - 允许清单(空 = 全部)。
|
|
4196
4507
|
* @returns {string}
|
|
@@ -4198,8 +4509,7 @@ function createProviderPublisher(deps = {}) {
|
|
|
4198
4509
|
function catalogSignature(entries, enabledIds) {
|
|
4199
4510
|
return `${(Array.isArray(entries) ? entries : []).map((entry) => {
|
|
4200
4511
|
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 : ""}`;
|
|
4512
|
+
return `${str(e?.id, "")}:v${isVisionModel(e) ? 1 : 0}:c${typeof e?.contextWindow === "number" ? e.contextWindow : ""}:o${typeof e?.maxOutputLength === "number" ? e.maxOutputLength : ""}`;
|
|
4203
4513
|
}).join(",")}|${(Array.isArray(enabledIds) ? enabledIds : []).join(",")}`;
|
|
4204
4514
|
}
|
|
4205
4515
|
/**
|
|
@@ -4349,7 +4659,7 @@ function createProviderReconciler(deps) {
|
|
|
4349
4659
|
while (dirty) {
|
|
4350
4660
|
dirty = false;
|
|
4351
4661
|
await runOnce();
|
|
4352
|
-
if (++guard >
|
|
4662
|
+
if (++guard > 20) break;
|
|
4353
4663
|
}
|
|
4354
4664
|
});
|
|
4355
4665
|
};
|
|
@@ -4372,6 +4682,7 @@ const TOKEN_FORGET_PATH = "/api/dsh-connect-modelscope-token-plan/token/forget";
|
|
|
4372
4682
|
const PROBE_PATH = "/api/dsh-connect-modelscope-token-plan/probe";
|
|
4373
4683
|
const PROVIDER_PATH = "/api/dsh-connect-modelscope-token-plan/provider";
|
|
4374
4684
|
const DRAW_PATH = "/api/dsh-connect-modelscope-token-plan/draw";
|
|
4685
|
+
const TREND_WINDOW_PATH = "/api/dsh-connect-modelscope-token-plan/trend/window";
|
|
4375
4686
|
|
|
4376
4687
|
//#endregion
|
|
4377
4688
|
//#region src/host/snapshot-aggregate.ts
|
|
@@ -4417,12 +4728,14 @@ async function soft(promise) {
|
|
|
4417
4728
|
}
|
|
4418
4729
|
/** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
|
|
4419
4730
|
async function buildSnapshotBody(wiring) {
|
|
4420
|
-
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);
|
|
4421
4734
|
const [tokenState, daily, perModel, trend, events, providerNote] = await Promise.all([
|
|
4422
4735
|
soft(tokenStore.state()),
|
|
4423
4736
|
soft(usageStore.daily()),
|
|
4424
4737
|
soft(usageStore.perModelToday()),
|
|
4425
|
-
soft(usageStore.trend(
|
|
4738
|
+
soft(usageStore.trend(windowDays)),
|
|
4426
4739
|
soft(usageStore.events()),
|
|
4427
4740
|
soft(providerStore.versionNote())
|
|
4428
4741
|
]);
|
|
@@ -4446,6 +4759,12 @@ async function buildSnapshotBody(wiring) {
|
|
|
4446
4759
|
if (providerNote.ok && providerNote.value !== null) shapeWarnings.push(providerNote.value);
|
|
4447
4760
|
const drawNote = await soft((async () => drawStore.versionNote())());
|
|
4448
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
|
+
}
|
|
4449
4768
|
const provider = await soft((async () => {
|
|
4450
4769
|
const panel = await providerStore.enabled().catch(() => null);
|
|
4451
4770
|
const enabled = resolveSwitchEnabled(panel, settings.registerProvider);
|
|
@@ -4473,14 +4792,19 @@ async function buildSnapshotBody(wiring) {
|
|
|
4473
4792
|
const draw = await soft((async () => {
|
|
4474
4793
|
const panel = await drawStore.enabled().catch(() => null);
|
|
4475
4794
|
const panelModel = await drawStore.modelId().catch(() => null);
|
|
4476
|
-
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);
|
|
4477
4798
|
return {
|
|
4478
4799
|
enabled: resolveSwitchEnabled(panel, settings.drawEnabled),
|
|
4479
4800
|
source: switchSource(panel),
|
|
4480
|
-
candidateIds,
|
|
4481
|
-
candidateCount:
|
|
4482
|
-
|
|
4801
|
+
candidateIds: candidates.map((row) => row.id),
|
|
4802
|
+
candidateCount: candidates.length,
|
|
4803
|
+
candidates,
|
|
4804
|
+
preferredModel: preferred,
|
|
4483
4805
|
preferredSource: switchSource(panelModel),
|
|
4806
|
+
effectiveModel: choice.id,
|
|
4807
|
+
effectiveSource: choice.source,
|
|
4484
4808
|
absentReason: drawToolNote(),
|
|
4485
4809
|
mountPending: resolveSwitchEnabled(panel, settings.drawEnabled) === true && drawToolNote() !== null,
|
|
4486
4810
|
toolName: "modelscope_draw_image"
|
|
@@ -4527,7 +4851,9 @@ async function buildSnapshotBody(wiring) {
|
|
|
4527
4851
|
},
|
|
4528
4852
|
events: events.ok ? events.value : [],
|
|
4529
4853
|
trend: {
|
|
4530
|
-
days:
|
|
4854
|
+
days: windowDays,
|
|
4855
|
+
windowDays: panelWindow,
|
|
4856
|
+
maxDays: settings.retentionDays,
|
|
4531
4857
|
buckets: trend.ok ? trend.value : []
|
|
4532
4858
|
},
|
|
4533
4859
|
models: {
|
|
@@ -4585,7 +4911,7 @@ async function readJsonBody(request, limit = MAX_JSON_BODY_BYTES) {
|
|
|
4585
4911
|
received += buffer.byteLength;
|
|
4586
4912
|
if (received > limit) return {
|
|
4587
4913
|
ok: false,
|
|
4588
|
-
error:
|
|
4914
|
+
error: `request body is too large (max ${limit} bytes)`
|
|
4589
4915
|
};
|
|
4590
4916
|
chunks.push(buffer);
|
|
4591
4917
|
}
|
|
@@ -4941,22 +5267,22 @@ function registerProbeRoute(ctx, wiring) {
|
|
|
4941
5267
|
* @module dsh-connect-modelscope-token-plan/routes/provider
|
|
4942
5268
|
*/
|
|
4943
5269
|
/**
|
|
4944
|
-
*
|
|
5270
|
+
* 读目录条目,读不到**抛错**(让 snapshot() 走 respondError 返回 ok:false)。
|
|
4945
5271
|
*
|
|
4946
5272
|
* 读目录前先取**当前窗口覆盖**传给 `fetchModels`:条目在归一前打上覆盖标记,
|
|
4947
5273
|
* descriptor / roster / catalogSignature 都从这份条目派生。每个写路由(开关、
|
|
4948
5274
|
* 清单、窗口、reset)都经这里取条目再 publish,所以任何一次重注册都带着最新
|
|
4949
5275
|
* 覆盖——否则先存窗口、再存清单会把未打标的目录重新注册上去,覆盖静默失效到
|
|
4950
5276
|
* 下一轮轮询。
|
|
5277
|
+
*
|
|
5278
|
+
* **为什么抛错而不是降级成空目录**:空 roster 与「真的没有模型」在面板上长得
|
|
5279
|
+
* 一模一样(modelCount=0),而真相是「目录拉不到」——这与 snapshot-aggregate
|
|
5280
|
+
* 的 `models.available=false` 语义矛盾(后者明确区分「0 个模型」与「读不到」)。
|
|
5281
|
+
* 抛错让 GET 返回 ok:false + error,面板能显示「目录拉不到」而不是「0 个模型」。
|
|
4951
5282
|
*/
|
|
4952
5283
|
async function readEntries$1(inference, providerStore) {
|
|
4953
5284
|
const contextWindow = await optional(providerStore?.contextWindow?.(), null);
|
|
4954
|
-
const catalog = await inference.fetchModels(contextWindow)
|
|
4955
|
-
return {
|
|
4956
|
-
entries: [],
|
|
4957
|
-
__error: error
|
|
4958
|
-
};
|
|
4959
|
-
});
|
|
5285
|
+
const catalog = await inference.fetchModels(contextWindow);
|
|
4960
5286
|
return Array.isArray(catalog?.entries) ? catalog.entries : [];
|
|
4961
5287
|
}
|
|
4962
5288
|
/**
|
|
@@ -4987,14 +5313,27 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
4987
5313
|
/**
|
|
4988
5314
|
* 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
|
|
4989
5315
|
* 状态是什么」说两套话。
|
|
5316
|
+
*
|
|
5317
|
+
* **目录读不到怎么办**:GET 是权威读路径,读不到就让它失败(handler 转
|
|
5318
|
+
* `respondError`,面板看见「目录拉不到」而不是「0 个模型」)。POST 的响应只是
|
|
5319
|
+
* 保存后的回读,而此时**保存已经成功**——不能把一次成功的写报成失败,所以
|
|
5320
|
+
* `tolerateCatalogFailure` 让它降级为空 roster 并带上 `catalogError` 说明原因。
|
|
4990
5321
|
* @param {object} [extra] - 调用方要追加/覆盖的字段。
|
|
5322
|
+
* @param {object} [options] - `{ tolerateCatalogFailure }`:写成功后的回读宽容模式。
|
|
4991
5323
|
* @returns {Promise<object>} 响应体。
|
|
4992
5324
|
*/
|
|
4993
|
-
const snapshot = async (extra = {}) => {
|
|
5325
|
+
const snapshot = async (extra = {}, options = {}) => {
|
|
4994
5326
|
const panelSwitch = await optional(providerStore.enabled());
|
|
4995
5327
|
const enabledIds = await readEnabledIds(providerStore, publisher.state.enabledIds);
|
|
4996
5328
|
const contextWindow = await optional(providerStore.contextWindow?.(), null);
|
|
4997
|
-
|
|
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
|
+
}
|
|
4998
5337
|
const roster = rosterWithAvailability(entries, []);
|
|
4999
5338
|
const allowedList = parseAllowedList(enabledIds);
|
|
5000
5339
|
const modelCount = roster.length;
|
|
@@ -5011,6 +5350,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5011
5350
|
enabledIds,
|
|
5012
5351
|
contextWindow,
|
|
5013
5352
|
roster,
|
|
5353
|
+
...catalogError !== null ? { catalogError } : {},
|
|
5014
5354
|
...extra
|
|
5015
5355
|
};
|
|
5016
5356
|
};
|
|
@@ -5021,7 +5361,11 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5021
5361
|
handler: withOrigin(async (request, response) => {
|
|
5022
5362
|
const method = request.method === void 0 ? "GET" : request.method;
|
|
5023
5363
|
if (method === "GET") {
|
|
5024
|
-
|
|
5364
|
+
try {
|
|
5365
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5366
|
+
} catch (error) {
|
|
5367
|
+
respondError(response, error);
|
|
5368
|
+
}
|
|
5025
5369
|
return;
|
|
5026
5370
|
}
|
|
5027
5371
|
if (method !== "POST") {
|
|
@@ -5044,7 +5388,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5044
5388
|
respondError(response, error);
|
|
5045
5389
|
return;
|
|
5046
5390
|
}
|
|
5047
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5391
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
5048
5392
|
}, settings.allowedHosts)
|
|
5049
5393
|
}));
|
|
5050
5394
|
offs.push(ctx.webServer.register({
|
|
@@ -5072,7 +5416,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5072
5416
|
respondError(response, error);
|
|
5073
5417
|
return;
|
|
5074
5418
|
}
|
|
5075
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5419
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
5076
5420
|
}, settings.allowedHosts)
|
|
5077
5421
|
}));
|
|
5078
5422
|
offs.push(ctx.webServer.register({
|
|
@@ -5099,7 +5443,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5099
5443
|
respondError(response, error);
|
|
5100
5444
|
return;
|
|
5101
5445
|
}
|
|
5102
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5446
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
5103
5447
|
}, settings.allowedHosts)
|
|
5104
5448
|
}));
|
|
5105
5449
|
offs.push(ctx.webServer.register({
|
|
@@ -5122,7 +5466,7 @@ function registerProviderRoute(ctx, wiring) {
|
|
|
5122
5466
|
respondError(response, error);
|
|
5123
5467
|
return;
|
|
5124
5468
|
}
|
|
5125
|
-
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
5469
|
+
writeJson(response, 200, await snapshot({}, { tolerateCatalogFailure: true }), { "cache-control": "no-store" });
|
|
5126
5470
|
}, settings.allowedHosts)
|
|
5127
5471
|
}));
|
|
5128
5472
|
return () => {
|
|
@@ -5186,16 +5530,20 @@ function registerDrawRoute(ctx, wiring) {
|
|
|
5186
5530
|
const panelSwitch = await optional(drawStore.enabled());
|
|
5187
5531
|
const panelModelId = await optional(drawStore.modelId());
|
|
5188
5532
|
const entries = await readEntries(inference);
|
|
5189
|
-
const
|
|
5190
|
-
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);
|
|
5191
5536
|
return {
|
|
5192
5537
|
ok: true,
|
|
5193
5538
|
enabled: resolveSwitchEnabled(panelSwitch, settings.drawEnabled),
|
|
5194
5539
|
source: switchSource(panelSwitch),
|
|
5195
|
-
candidateIds,
|
|
5196
|
-
candidateCount:
|
|
5197
|
-
|
|
5540
|
+
candidateIds: candidates.map((row) => row.id),
|
|
5541
|
+
candidateCount: candidates.length,
|
|
5542
|
+
candidates,
|
|
5543
|
+
preferredModel: preferredModelId,
|
|
5198
5544
|
preferredSource: switchSource(panelModelId),
|
|
5545
|
+
effectiveModel: choice.id,
|
|
5546
|
+
effectiveSource: choice.source,
|
|
5199
5547
|
absentReason: drawToolNote(),
|
|
5200
5548
|
/**
|
|
5201
5549
|
* 开关的保存**不会**改变本次挂载的工具。
|
|
@@ -5288,6 +5636,95 @@ function registerDrawRoute(ctx, wiring) {
|
|
|
5288
5636
|
};
|
|
5289
5637
|
}
|
|
5290
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
|
+
|
|
5291
5728
|
//#endregion
|
|
5292
5729
|
//#region src/host/routes.ts
|
|
5293
5730
|
/**
|
|
@@ -5304,7 +5741,8 @@ function registerRoutes(ctx, wiring) {
|
|
|
5304
5741
|
...registerTokenRoute(ctx, wiring),
|
|
5305
5742
|
registerProbeRoute(ctx, wiring),
|
|
5306
5743
|
registerProviderRoute(ctx, wiring),
|
|
5307
|
-
registerDrawRoute(ctx, wiring)
|
|
5744
|
+
registerDrawRoute(ctx, wiring),
|
|
5745
|
+
registerUsagePrefsRoute(ctx, wiring)
|
|
5308
5746
|
];
|
|
5309
5747
|
}
|
|
5310
5748
|
|
|
@@ -5352,7 +5790,7 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
5352
5790
|
const usageStore = createFileUsageStore({
|
|
5353
5791
|
name,
|
|
5354
5792
|
profile,
|
|
5355
|
-
|
|
5793
|
+
retentionDays: settings.retentionDays,
|
|
5356
5794
|
maxEvents: settings.maxEvents,
|
|
5357
5795
|
logger: ctx.logger
|
|
5358
5796
|
});
|
|
@@ -5370,6 +5808,10 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
5370
5808
|
profile,
|
|
5371
5809
|
logger: ctx.logger
|
|
5372
5810
|
});
|
|
5811
|
+
const usagePrefsStore = createFileUsagePrefsStore({
|
|
5812
|
+
profile,
|
|
5813
|
+
logger: ctx.logger
|
|
5814
|
+
});
|
|
5373
5815
|
const publisher = createProviderPublisher({
|
|
5374
5816
|
settings,
|
|
5375
5817
|
panelSwitch: () => providerStore.enabled(),
|
|
@@ -5401,6 +5843,7 @@ function apply(ctx, config = {}, deps = {}) {
|
|
|
5401
5843
|
inference,
|
|
5402
5844
|
providerStore,
|
|
5403
5845
|
drawStore,
|
|
5846
|
+
usagePrefsStore,
|
|
5404
5847
|
publisher,
|
|
5405
5848
|
reconcile,
|
|
5406
5849
|
logger: ctx.logger
|