dsh-connect-modelscope-token-plan 0.1.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 +198 -0
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/assets/icon-preview.png +0 -0
- package/assets/tab-account-and-usage.png +0 -0
- package/assets/tab-model-list.png +0 -0
- package/client.js +1828 -0
- package/cordis.patch.yml +53 -0
- package/icon.svg +30 -0
- package/lib/host-config-DBCiZOBW.js +900 -0
- package/lib/index.js +3181 -0
- package/lib/llm-adapter-CpcQ3gC5.js +557 -0
- package/locale/en.json +5 -0
- package/locale/zh.json +6 -0
- package/package.json +107 -0
- package/screenshots.json +4 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,3181 @@
|
|
|
1
|
+
import { C as redactSecrets, S as pluginError, T as verbatim, a as resolveSettings, b as obj, c as LLM_PROVIDER_ID, d as normalizeEntry, f as resolveAllowedList, g as looksLikeRateLimit, h as hasHardQuotaWordingIn, i as name, n as inject, p as rosterWithAvailability, r as isAdmitted, s as LLM_DISPLAY_NAME, t as PLUGIN_VERSION, u as isVisionModel, v as degrade, w as str, x as optional, y as errMsg } from "./host-config-DBCiZOBW.js";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
|
|
7
|
+
//#region src/host/ms-auth.ts
|
|
8
|
+
/**
|
|
9
|
+
* 魔搭访问令牌(`ms-…`)的存取——本插件唯一的凭据模块。
|
|
10
|
+
*
|
|
11
|
+
* 与姊妹插件的最大差异:**没有登录流**。没有 OIDC、没有密码、没有 refresh
|
|
12
|
+
* ——令牌是用户在魔搭个人中心(siteBase → 访问令牌)生成的一把静态钥匙,
|
|
13
|
+
* 过期/吊销只能由用户换新的。所以 sensenova 的整套 token-store(grant 续期、
|
|
14
|
+
* 节流、重登)在这里缩成一个「凭据引用的 resolve/save/forget」。
|
|
15
|
+
*
|
|
16
|
+
* 存储形态:凭据服务里的 **引用**(owner-only `~/.dsh/.credentials.yaml`),
|
|
17
|
+
* 引用名沿用本机已有的 `MODELSCOPE_API_KEY`——一个已存在的引用值,装上面板
|
|
18
|
+
* 即亮,零配置。kind 保持服务承认的 `api-key` 形(绝不发明私有 kind:那会让
|
|
19
|
+
* 凭据文件对整个 Host 不可解析,Host 直接起不来)。`process.env` 的
|
|
20
|
+
* `MODELSCOPE_API_KEY` 是回退。
|
|
21
|
+
*
|
|
22
|
+
* 读取优先级,每次 resolve 都重走一遍:凭据服务(面板保存的值必须立即生效)
|
|
23
|
+
* → 进程内存(无凭据服务的 Host)→ 环境。
|
|
24
|
+
*
|
|
25
|
+
* 令牌有效性检查(零额度探针:缺 `messages` 的请求预期 401 先于 400)属于
|
|
26
|
+
* probe 路由的职责,本模块只管存取与状态;SPIKE.md §结论 5 记录了该探针
|
|
27
|
+
* 尚待真机验证。
|
|
28
|
+
*
|
|
29
|
+
* @module dsh-connect-modelscope-token-plan/ms-auth
|
|
30
|
+
*/
|
|
31
|
+
/** 引用名:沿用本机凭据库里已存在的记录(README「三条事实」第 3 条)。 */
|
|
32
|
+
const TOKEN_REF = "MODELSCOPE_API_KEY";
|
|
33
|
+
/**
|
|
34
|
+
* 令牌形状:`ms-` + 至少 16 位十六进制,允许连字符分段(UUID 那种)。
|
|
35
|
+
*
|
|
36
|
+
* 存在的理由见 {@link createTokenStore} 的 `save`:脱敏闸是正则,遮不住它不
|
|
37
|
+
* 认识的形状,所以「什么形状能进来」必须在入口就钉住,而不是指望出口拦得住。
|
|
38
|
+
*
|
|
39
|
+
* **下限必须与 `util.ts#redactSecrets` 的 `ms-` 闸一致**(都是 16 位):比它宽则
|
|
40
|
+
* 出现「进得来但遮不住」的缝,比它窄则无谓地拒掉合法令牌。两处同改——
|
|
41
|
+
* `test/credentials.test.mjs` 钉住这条一致性。
|
|
42
|
+
*/
|
|
43
|
+
const TOKEN_SHAPE = /^ms-[0-9a-f-]{16,}$/i;
|
|
44
|
+
/** 这个值长得像魔搭访问令牌吗?(只判形状,不判真伪——真伪要问上游。) */
|
|
45
|
+
function isPlausibleToken(value) {
|
|
46
|
+
return typeof value === "string" && TOKEN_SHAPE.test(value.trim());
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 构造令牌 store。
|
|
50
|
+
* @param {object} [options] - wiring。
|
|
51
|
+
* @param {object|Function|null} [options.credentials] - `ctx.credentials` 服务
|
|
52
|
+
* 或其 resolver;**每次使用时解析**(服务可能在本插件挂载之后才注册,
|
|
53
|
+
* 一次性查找会把错误的 "ephemeral" 断言冻进后续每个轮询)。
|
|
54
|
+
* @param {object} [options.env] - 环境源;默认 `process.env`。
|
|
55
|
+
*/
|
|
56
|
+
function createTokenStore({ credentials = null, env = process.env } = {}) {
|
|
57
|
+
/** 无凭据服务的 Host 的内存兜底。 */
|
|
58
|
+
const memory = /* @__PURE__ */ new Map();
|
|
59
|
+
const resolveService = () => {
|
|
60
|
+
return (typeof credentials === "function" ? credentials() : credentials) ?? null;
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
/**
|
|
64
|
+
* 把面板输入的令牌存成引用;原样存(不 trim),空白值视为没输入。
|
|
65
|
+
*
|
|
66
|
+
* **形状校验**:只接受 `ms-` + 至少 16 位十六进制(可含连字符分段)。这不是
|
|
67
|
+
* 洁癖——`redactSecrets` 的脱敏闸是**正则**,只能遮它认识的形状;一个
|
|
68
|
+
* `ms-short-abc` 这样的短值它遮不住,而任何非空字符串此前都能被存下来并作为
|
|
69
|
+
* `Authorization: Bearer …` 发出(非标准形状的令牌是可达状态)。上游一旦把
|
|
70
|
+
* 它回显进错误消息(`inference-client` 的几处都把上游 body 拼进错误文本),
|
|
71
|
+
* 凭据就进日志与面板响应——撞红线 1。
|
|
72
|
+
*
|
|
73
|
+
* 收紧入口同时也修了 UX:粘错令牌时立刻得到一句明确的话,而不是等一次
|
|
74
|
+
* 真机请求换来一个语焉不详的 401。
|
|
75
|
+
*/
|
|
76
|
+
async save(token) {
|
|
77
|
+
const value = verbatim(token, "");
|
|
78
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error("a ModelScope token is required");
|
|
79
|
+
if (!isPlausibleToken(value.trim())) throw new Error("that does not look like a ModelScope access token (expected ms- followed by hex digits)");
|
|
80
|
+
const service = resolveService();
|
|
81
|
+
if (service !== null && typeof service.set === "function") {
|
|
82
|
+
await service.set(TOKEN_REF, value);
|
|
83
|
+
memory.set(TOKEN_REF, value);
|
|
84
|
+
} else memory.set(TOKEN_REF, value);
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* 忘掉面板保存的令牌。env 回退**不动**——清面板引用不能删操作者的 .env。
|
|
88
|
+
*
|
|
89
|
+
* 失败**向上传播**(由路由脱敏后呈报),不静默:凭据文件只读(EACCES)或
|
|
90
|
+
* 服务实现里 unset 抛错时,吞掉错误会让路由回 `ok: true` + `source: credentials`,
|
|
91
|
+
* 而令牌**仍在 `~/.dsh/.credentials.yaml` 里并且仍被 resolve() 读回来**。
|
|
92
|
+
* 「忘掉令牌」是一个安全动作,谎报成功比失败更糟——用户以为凭据已删干净。
|
|
93
|
+
* 内存副本无论如何都先清掉(它才是本进程真正持有的那份)。
|
|
94
|
+
*/
|
|
95
|
+
async forget() {
|
|
96
|
+
memory.delete(TOKEN_REF);
|
|
97
|
+
const service = resolveService();
|
|
98
|
+
if (service !== null && typeof service.unset === "function") await service.unset(TOKEN_REF);
|
|
99
|
+
},
|
|
100
|
+
/**
|
|
101
|
+
* 解析当前令牌与来源。返回类型显式注解:这是快照 TokenStatus.source
|
|
102
|
+
* 联合类型的上游,靠推断会把字面量联合拓宽成 string。
|
|
103
|
+
* @returns {Promise<{value: string, source: ("credentials"|"memory"|"env"|null)}>}
|
|
104
|
+
*/
|
|
105
|
+
async resolve() {
|
|
106
|
+
try {
|
|
107
|
+
const service = resolveService();
|
|
108
|
+
if (service !== null && typeof service.resolve === "function") {
|
|
109
|
+
const resolved = await service.resolve(TOKEN_REF).catch(() => void 0);
|
|
110
|
+
const value = verbatim(resolved?.value, "");
|
|
111
|
+
if (typeof value === "string" && value.trim() !== "") return {
|
|
112
|
+
value,
|
|
113
|
+
source: "credentials"
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
} catch {}
|
|
117
|
+
const held = memory.get(TOKEN_REF);
|
|
118
|
+
if (typeof held === "string" && held.trim() !== "") return {
|
|
119
|
+
value: held,
|
|
120
|
+
source: "memory"
|
|
121
|
+
};
|
|
122
|
+
const fromEnv = verbatim(env[TOKEN_REF], "");
|
|
123
|
+
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return {
|
|
124
|
+
value: fromEnv,
|
|
125
|
+
source: "env"
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
value: "",
|
|
129
|
+
source: null
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
/**
|
|
133
|
+
* 无秘密的状态描述(快照与面板消费)。
|
|
134
|
+
* `valid` 恒为 null:本模块不做有效性断言(零额度探针在 probe 路由,
|
|
135
|
+
* 尚待真机验证后接入——SPIKE.md §结论 5)。
|
|
136
|
+
*
|
|
137
|
+
* `ephemeral` 答的是「**手里这个值**重启会不会丢」,不是「有没有凭据服务」
|
|
138
|
+
* ——wire 的定义是前者。曾经只按「服务缺席」置位,于是两种都说谎:没有服务
|
|
139
|
+
* 时若令牌来自 env(重启不丢)被标 ephemeral;服务在场时面板保存的值被标非
|
|
140
|
+
* ephemeral(那是碰巧对,不是判出来的)。按来源答:
|
|
141
|
+
* `memory` 才真的只在本次进程存活期间存在。
|
|
142
|
+
*/
|
|
143
|
+
async state() {
|
|
144
|
+
const { source } = await this.resolve();
|
|
145
|
+
return {
|
|
146
|
+
present: source !== null,
|
|
147
|
+
source: source === null ? "none" : source,
|
|
148
|
+
valid: null,
|
|
149
|
+
checkedAt: null,
|
|
150
|
+
ephemeral: source === "memory"
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/host/state-store.ts
|
|
158
|
+
/**
|
|
159
|
+
* 状态文件公共原语 —— 把本插件的各 store(usage-store)从
|
|
160
|
+
* 各自手写的同一段"版本载荷 + temp 文件 + rename 原子 + 0600 + 损坏即忽略"
|
|
161
|
+
* 收敛到这里(家规:peer-free、离线可测)。。
|
|
162
|
+
*
|
|
163
|
+
* 第二步收敛的是**读缓存**:provider / draw 早有 1s TTL,而 catalog 完全没有
|
|
164
|
+
* (进程内永不失效)——同一个「两个进程共享一个 state 目录」的问题修了两个、
|
|
165
|
+
* 漏了第三个。现在统一走 {@link createStateReadCache},一个 TTL 三个调用方。
|
|
166
|
+
*
|
|
167
|
+
* peer-free 与四个 store 同纪律:不 import 任何 Host peer,纯 `node:fs`,
|
|
168
|
+
* 离线可测(store.test.mjs 直接注入 dir 构造即可)。
|
|
169
|
+
*
|
|
170
|
+
* 行为约定(与本插件 store 的约定逐一对齐):
|
|
171
|
+
* - 目录:`$DSH_HOME/state/<name>`——与 Host 自己的目录并列,而不是在
|
|
172
|
+
* `logs/`(trace 轮转会按日志清扫,状态文件不能跟着被扫走)。
|
|
173
|
+
* - 写:临时文件(0600,owner-only)→ `rename` 原子落位。**失败抛错**,
|
|
174
|
+
* 是否吞错是各 store 的语义(usage-store 面对只读 Home 选择吞、
|
|
175
|
+
* provider 面板开关交给调用方的错误路径),原语不做决定。
|
|
176
|
+
* - 临时名:进程 + 时间戳 + 随机 UUID 后缀。固定临时名会让两个 Host 进程的
|
|
177
|
+
* 写落到同一路径、互相 `rename` 掉对方写了一半的文件;同一进程同一毫秒的
|
|
178
|
+
* 两次异步写也会撞名(`writeFile` 截断覆盖后一次 `rename` 静默丢写),
|
|
179
|
+
* 随机后缀让每个 temp 路径唯一,`rename` 原子性借此成立(见 `temporaryOf`)。
|
|
180
|
+
* 此前的写法曾只有进程 + 时间戳,同毫秒并发写会静默丢一条。
|
|
181
|
+
* - 读:缺失、不可读、非 JSON 一律返回 `null`——"损坏即忽略"的方向。是否
|
|
182
|
+
* 缓存、缓存多久由 {@link createStateReadCache} 决定,不是每个 store 各自的
|
|
183
|
+
* 即兴实现。
|
|
184
|
+
*
|
|
185
|
+
* @module dsh-connect-modelscope-token-plan/state-store
|
|
186
|
+
*/
|
|
187
|
+
/**
|
|
188
|
+
* The DSH home: `$DSH_HOME` when the operator exported one, else `~/.dsh`.
|
|
189
|
+
* @returns {string} the home directory.
|
|
190
|
+
*/
|
|
191
|
+
function dshHome() {
|
|
192
|
+
return str(process.env.DSH_HOME, join(homedir(), ".dsh"));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Where this plugin keeps state: `$DSH_HOME/state/<name>`.
|
|
196
|
+
* @param {string} name - the plugin's own state directory name
|
|
197
|
+
* (`host-config.ts`'s `name`).
|
|
198
|
+
* @returns {string} the directory.
|
|
199
|
+
*/
|
|
200
|
+
function stateDir(name) {
|
|
201
|
+
return join(dshHome(), "state", name);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 单个 profile 名的形态约束。它会直接成为磁盘路径的一段,所以这里按
|
|
205
|
+
* **外部输入**处理,而不是信任 Host 给的值。
|
|
206
|
+
*
|
|
207
|
+
* 规则与它的用途一一对应:
|
|
208
|
+
* - 字符集限制(`[A-Za-z0-9._-]`)——排除路径分隔符与任何 traversal 形状;
|
|
209
|
+
* - 不以点开头——顺带排掉 `.` 与 `..` 这两个唯一能让单段路径逃逸的名字;
|
|
210
|
+
* - 长度上限——防超长目录名(Windows 路径上限、以及某些文件系统的 NAME_MAX)。
|
|
211
|
+
*
|
|
212
|
+
* 为什么不用白名单枚举已知 profile 名:集合是开放的(用户可以任意新建
|
|
213
|
+
* profile,本插件不该认识它们),白名单会把新 profile 错判成"拿不到名字"。
|
|
214
|
+
*/
|
|
215
|
+
const PROFILE_SEGMENT_MAX = 64;
|
|
216
|
+
const PROFILE_SEGMENT_RE = /^(?!\.)[A-Za-z0-9._-]+$/;
|
|
217
|
+
/**
|
|
218
|
+
* Is this string safe to use as ONE path segment?
|
|
219
|
+
* @param {unknown} value - candidate profile name.
|
|
220
|
+
* @returns {boolean} true when it survives {@link PROFILE_SEGMENT_RE}.
|
|
221
|
+
*/
|
|
222
|
+
function isProfileSegment(value) {
|
|
223
|
+
if (typeof value !== "string") return false;
|
|
224
|
+
const name = value.trim();
|
|
225
|
+
if (name === "" || name.length > PROFILE_SEGMENT_MAX) return false;
|
|
226
|
+
return PROFILE_SEGMENT_RE.test(name);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* 当前这台 Host 跑在哪个 profile 下,取不到就返回 `null`。
|
|
230
|
+
*
|
|
231
|
+
* **怎么读它**:`ctx.get(name)` —— Cordis 自己的 "read a service without the
|
|
232
|
+
* inject requirement" 入口,未提供时安静返回 `undefined`。注意**别用属性访问**
|
|
233
|
+
* 去探:`ctx.profileContext` 会在服务缺失时**抛错**(`cannot get property
|
|
234
|
+
* "profileContext" without inject`,cordis `lib/index.js:676`)——这是本插件
|
|
235
|
+
* 实测踩到的,不是推测。`readOptionalService` 把两个入口都包了,属性访问只作为
|
|
236
|
+
* 测试桩的兜底留在最后。
|
|
237
|
+
*
|
|
238
|
+
* **为什么不用 `inject` 声明它**:`inject` 里的是**硬依赖**(`lib/index.js:688`
|
|
239
|
+
* 的报错文案就叫 "cannot get required service"),缺了 Cordis 根本不加载本插件。
|
|
240
|
+
* 而 `profileContext` 在官方 runtime 里是**可选**的(`@linxin666/
|
|
241
|
+
* dsh-client-ui-plugin-manager` 明确处理了"host 隐藏了它"的情形,
|
|
242
|
+
* `dsh-better-sidebar` 同理)。把它变成硬依赖,会让那些主机上整个插件消失
|
|
243
|
+
* (面板、额度、provider 全挂),代价远大于收益。
|
|
244
|
+
*
|
|
245
|
+
* **为什么不读 `DSH_PROFILE`**:在那个 runtime 里它是 OUTPUT 而非输入——由
|
|
246
|
+
* `runProfile()` 派生给子进程(`dsh-shell-env` 做的事),"no runtime module
|
|
247
|
+
* reads it to choose a profile"。手设或陈旧的值会把状态写进一个"这台 Host
|
|
248
|
+
* 根本不读"的 profile。
|
|
249
|
+
*
|
|
250
|
+
* 取到 = 调用方据此分段;取不到 = **退回当前的全局路径**,行为零漂移。
|
|
251
|
+
*
|
|
252
|
+
* @param {object} [ctx] - the Cordis context the Host handed `apply()`.
|
|
253
|
+
* @returns {string|null} the profile name, or `null` when unavailable/unsafe.
|
|
254
|
+
*/
|
|
255
|
+
function profileSegment(ctx) {
|
|
256
|
+
if (ctx === null || typeof ctx !== "object") return null;
|
|
257
|
+
const raw = readOptionalService(ctx, "profileContext");
|
|
258
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
259
|
+
const name = raw.name;
|
|
260
|
+
return isProfileSegment(name) ? String(name).trim() : null;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* 读一个**可选**服务,三种入口依次尝试。
|
|
264
|
+
*
|
|
265
|
+
* 1. `ctx.get(name)` —— Cordis 的官方无 inject 读法(`ReflectService.get`),也是
|
|
266
|
+
* `startSideEffects` 读可选 `settings` 服务用的同一入口。首选。
|
|
267
|
+
* 2. `ctx.reflect.get(name, false)` —— 底层等价物,宿主未把 mixin 挂出来时用。
|
|
268
|
+
* 3. `ctx[name]` 直接取属性 —— 手写测试桩的形状。**留在最后**:在真 Cordis 上
|
|
269
|
+
* 访问一个未声明且未提供的服务会抛(`... without inject`),必须包着 try。
|
|
270
|
+
*
|
|
271
|
+
* 三者都拿不到就是"这台 Host 没有这个服务",调用方据此降级;这里永不抛错,
|
|
272
|
+
* 因为一个探测不到的可选服务不该让插件挂掉。
|
|
273
|
+
* @param {object} ctx - the Cordis context.
|
|
274
|
+
* @param {string} name - the service name.
|
|
275
|
+
* @returns {unknown} the service value, or `undefined`.
|
|
276
|
+
*/
|
|
277
|
+
function readOptionalService(ctx, name) {
|
|
278
|
+
if (typeof ctx.get === "function") try {
|
|
279
|
+
return ctx.get(name);
|
|
280
|
+
} catch {}
|
|
281
|
+
const reflect = ctx.reflect;
|
|
282
|
+
if (reflect && typeof reflect.get === "function") try {
|
|
283
|
+
return reflect.get(name, false);
|
|
284
|
+
} catch {}
|
|
285
|
+
try {
|
|
286
|
+
return ctx[name];
|
|
287
|
+
} catch {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Per-profile state directory: `$DSH_HOME/state/<profile>/<name>`.
|
|
293
|
+
*
|
|
294
|
+
* Which states use this and which keep {@link stateDir} is a deliberate split,
|
|
295
|
+
* not an inconsistency. Briefly: the three switch-shaped
|
|
296
|
+
* states (catalog / provider / draw) answer "what does THIS profile want", so
|
|
297
|
+
* two profiles must not overwrite each other; the throttle answers "how long
|
|
298
|
+
* did the upstream tell US to wait" and the credentials grant answers "who are
|
|
299
|
+
* you", both of which are per-machine and are INTENDED to cross profiles.
|
|
300
|
+
*
|
|
301
|
+
* `profile` being `null` degrades to the shared directory, so every old host,
|
|
302
|
+
* every test and every in-process construction behaves exactly as before.
|
|
303
|
+
* @param {string} name - the plugin's own state directory name.
|
|
304
|
+
* @param {string|null} [profile] - the profile name; `null` means shared.
|
|
305
|
+
* @returns {string} the directory.
|
|
306
|
+
*/
|
|
307
|
+
function profileStateDir(name, profile) {
|
|
308
|
+
return profile ? join(dshHome(), "state", profile, name) : stateDir(name);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Make the state directory exist (owner-only), created on demand.
|
|
312
|
+
*
|
|
313
|
+
* A read-only Home throws — callers wrap this in their own policy (the
|
|
314
|
+
* throttle/catalog writers swallow it, the provider switch does not).
|
|
315
|
+
* @param {string} dir - the state directory.
|
|
316
|
+
* @returns {Promise<void>}
|
|
317
|
+
*/
|
|
318
|
+
async function ensureStateDir(dir) {
|
|
319
|
+
await mkdir(dir, {
|
|
320
|
+
recursive: true,
|
|
321
|
+
mode: 448
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* A unique temporary path per write.
|
|
326
|
+
*
|
|
327
|
+
* Two Host processes can share one state directory, so a fixed temp name would
|
|
328
|
+
* let both writes land on the same path and each `rename` could move the
|
|
329
|
+
* other's half-written file. A process-plus-clock suffix keeps concurrent
|
|
330
|
+
* writers off each other; the RANDOM suffix then makes two writes from the
|
|
331
|
+
* SAME process inside one millisecond distinct too — clock+pid alone collides
|
|
332
|
+
* when two async state writes land in the same tick, and the second `writeFile`
|
|
333
|
+
* truncates the first's half-written temp before its `rename`, silently losing
|
|
334
|
+
* one write. The rename itself stays atomic per path.
|
|
335
|
+
* @param {string} dir - the state directory.
|
|
336
|
+
* @param {string} base - the final file name, e.g. `"throttle.json"`.
|
|
337
|
+
* @param {() => number} [now] - clock source; injected by the tests.
|
|
338
|
+
* @returns {string} `dir/<base>.<pid>.<now>.<uuid>.tmp`.
|
|
339
|
+
*/
|
|
340
|
+
function temporaryOf(dir, base, now = Date.now) {
|
|
341
|
+
return join(dir, `${base}.${process.pid}.${now()}.${randomUUID()}.tmp`);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Write one state file atomically: a 0600 temporary file, then a rename.
|
|
345
|
+
*
|
|
346
|
+
* The payload string is written with a trailing newline, exactly as every
|
|
347
|
+
* store wrote before this module existed. Failures PROPAGATE — the callers
|
|
348
|
+
* decide whether a read-only Home breaks their flow.
|
|
349
|
+
* @param {string} file - the final file path.
|
|
350
|
+
* @param {string} payload - the serialized body (JSON text).
|
|
351
|
+
* @param {{temporary: string}} options - the temp path to write first.
|
|
352
|
+
* @returns {Promise<void>}
|
|
353
|
+
*/
|
|
354
|
+
/**
|
|
355
|
+
* 写一个状态文件:一个 0600 临时文件,然后 rename。
|
|
356
|
+
*
|
|
357
|
+
* 载荷串以换行结尾,与本模块存在前每个 store 的写法一致。失败**向上传播** ——
|
|
358
|
+
* 调用方自己决定只读 Home 要不要吞(throttle/catalog 吞,provider 开关不吞)。
|
|
359
|
+
*
|
|
360
|
+
* ## rename 失败时的临时文件清理
|
|
361
|
+
*
|
|
362
|
+
* `rename` 会失败,而最常见的原因是 Windows 上目标文件正被另一个进程/编辑器
|
|
363
|
+
* 占着(EACCES/EPERM)。临时文件此前**留在原地**:名字是
|
|
364
|
+
* `<base>.<pid>.<ts>.<uuid>.tmp`,每次失败多一个,于是
|
|
365
|
+
* `state/<profile>/<name>/` 慢慢堆满垃圾,而没有任何代码会去清它们(全仓原本
|
|
366
|
+
* 无任何 unlink)。现在失败时删掉自己的临时文件再重抛——清理失败不掩盖原始错误
|
|
367
|
+
* (那才是调用方要处理的),所以只 `catch {}`。
|
|
368
|
+
*
|
|
369
|
+
* @param {string} file - 最终文件路径。
|
|
370
|
+
* @param {string} payload - 序列化后的正文(JSON 文本)。
|
|
371
|
+
* @param {{temporary: string}} options - 先写哪个临时路径。
|
|
372
|
+
* @returns {Promise<void>}
|
|
373
|
+
*/
|
|
374
|
+
async function writeStateFile(file, payload, { temporary }) {
|
|
375
|
+
await writeFile(temporary, `${payload}\n`, {
|
|
376
|
+
encoding: "utf8",
|
|
377
|
+
mode: 384
|
|
378
|
+
});
|
|
379
|
+
const handle = await open(temporary, "r+");
|
|
380
|
+
try {
|
|
381
|
+
await handle.sync();
|
|
382
|
+
} finally {
|
|
383
|
+
await handle.close();
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
await rename(temporary, file);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
try {
|
|
389
|
+
await unlink(temporary);
|
|
390
|
+
} catch {}
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* How long a parsed state file may be reused without going back to disk.
|
|
396
|
+
*
|
|
397
|
+
* Two Host processes share one state directory, so this is
|
|
398
|
+
* the upper bound on "how stale this process's view can be" — long enough to
|
|
399
|
+
* keep one poll self-consistent, short enough that a change made anywhere else
|
|
400
|
+
* is picked up on the next tick rather than after a restart.
|
|
401
|
+
*/
|
|
402
|
+
const STATE_READ_TTL_MS = 1e3;
|
|
403
|
+
/**
|
|
404
|
+
* 状态文件的短生命周期读缓存 —— 把 catalog / provider / draw 三个 store
|
|
405
|
+
* 各自手写的「近期读过就不再读盘」收敛到这里(两个 Host 进程共享同一个状态
|
|
406
|
+
* 目录,缓存期就是「另一个进程的写入多久可见」的上界)。
|
|
407
|
+
*
|
|
408
|
+
* 为什么要有 TTL 而不是不缓存:每次轮询都重读一遍小 JSON 本身不贵,但快照
|
|
409
|
+
* 聚合在一次请求内会多次问同一个 store(目录条目、允许清单、开关),缓存让
|
|
410
|
+
* 一次请求内的答案自洽。为什么 TTL 必须短:超过了就是「另一个 profile 改了
|
|
411
|
+
* 允许清单,本机要重启才看得见」——这正是 catalog-store 早前的形态(无 TTL,
|
|
412
|
+
* 进程内永不失效),而现在三者共用一份 `ttlMs`。
|
|
413
|
+
*
|
|
414
|
+
* `null` 也是一个合法的缓存值("文件不存在/损坏,读作无记录"),所以"从未
|
|
415
|
+
* 读过"用 `undefined` 表示,两者不可混。
|
|
416
|
+
*
|
|
417
|
+
* peer-free,与其余原语同纪律(不 import Host peer、离线可测)。时钟与 TTL
|
|
418
|
+
* 都可注入,便于测试把缓存推进过期。
|
|
419
|
+
*
|
|
420
|
+
* `inheritFrom` 是一次性迁移缝:按 profile 分段后,本 profile 的新文件
|
|
421
|
+
* 一开始并不存在,而旧版把值放在**所有 profile 共享**的目录里。给了它以后,
|
|
422
|
+
* 读穿透发现自己的记录缺失时会去旧路径取一次、回填、再返回——**只尝试一次**
|
|
423
|
+
* (`adopted` 标志),所以它不会变成每个 TTL 周期都多读一个文件。
|
|
424
|
+
*
|
|
425
|
+
* 为什么让缓存原语承担这件事,而不是在外面先跑一遍迁移脚本:迁移就有了时序,
|
|
426
|
+
* 而"先迁移、再 seed"在 `apply()` 的同步构造里排不出确定顺序。挂在读穿透上
|
|
427
|
+
* 则天然正确——任何读到"空"的地方都会自动拿到旧值,且与并发进程无关(读到
|
|
428
|
+
* 同一份旧值、写同一份结果)。
|
|
429
|
+
*
|
|
430
|
+
* @template T
|
|
431
|
+
* @param {() => Promise<T|null>} readThrough - 真正的读盘 + 解析;返回 `null` 表示无可用记录。
|
|
432
|
+
* @param {object} [options]
|
|
433
|
+
* @param {number} [options.ttlMs] - 缓存有效期,默认 {@link STATE_READ_TTL_MS}。
|
|
434
|
+
* @param {() => number} [options.now] - 时钟源;测试注入。
|
|
435
|
+
* @param {{read: () => Promise<T|null>, write: (value: T) => Promise<void>}|null} [options.inheritFrom]
|
|
436
|
+
* - 旧版共享布局(`read`)与把它回填到本 profile(`write`);`null` = 不迁移。
|
|
437
|
+
* @returns {{read: () => Promise<T|null>, remember: (value: T|null) => void}}
|
|
438
|
+
*/
|
|
439
|
+
function createStateReadCache(readThrough, options = {}) {
|
|
440
|
+
const { ttlMs = STATE_READ_TTL_MS, now = Date.now, inheritFrom = null } = options;
|
|
441
|
+
let cached = void 0;
|
|
442
|
+
let cachedAt = 0;
|
|
443
|
+
/** Whether the one-shot legacy adoption has already been attempted. */
|
|
444
|
+
let adopted = false;
|
|
445
|
+
/**
|
|
446
|
+
* 读穿透:自己的记录优先;缺失且还有旧布局可继承时,取一次旧值并回填。
|
|
447
|
+
* @returns {Promise<T|null>}
|
|
448
|
+
*/
|
|
449
|
+
const load = async () => {
|
|
450
|
+
const own = await readThrough();
|
|
451
|
+
if (own !== null || inheritFrom === null || adopted) return own;
|
|
452
|
+
adopted = true;
|
|
453
|
+
const inherited = await inheritFrom.read();
|
|
454
|
+
if (inherited === null) return null;
|
|
455
|
+
try {
|
|
456
|
+
await inheritFrom.write(inherited);
|
|
457
|
+
} catch {}
|
|
458
|
+
return inherited;
|
|
459
|
+
};
|
|
460
|
+
return {
|
|
461
|
+
/**
|
|
462
|
+
* 读值:TTL 内返回缓存,过期则穿透到 `load()`。
|
|
463
|
+
* @returns {Promise<T|null>}
|
|
464
|
+
*/
|
|
465
|
+
async read() {
|
|
466
|
+
if (cached !== void 0 && now() - cachedAt < ttlMs) return cached;
|
|
467
|
+
cached = await load();
|
|
468
|
+
cachedAt = now();
|
|
469
|
+
return cached;
|
|
470
|
+
},
|
|
471
|
+
/**
|
|
472
|
+
* 写路径用:把刚写入的值直接放进缓存,省掉下一次读盘,并保证自己的写入
|
|
473
|
+
* 立刻对自己可见(不必等 TTL)。语义与 `read()` 一致,只是来源可信。
|
|
474
|
+
* @param {T|null} value - 刚写入并解析后的值。
|
|
475
|
+
* @returns {void}
|
|
476
|
+
*/
|
|
477
|
+
remember(value) {
|
|
478
|
+
cached = value;
|
|
479
|
+
cachedAt = now();
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Read a state file as JSON, or `null` when it is absent, unreadable, or not
|
|
485
|
+
* JSON. Anything unrecognised reads as "nothing stored" — the safe direction
|
|
486
|
+
* for every consumer (one extra attempt / one re-fetch / the config default
|
|
487
|
+
* rules again), never a crash.
|
|
488
|
+
* @param {string} file - the file path.
|
|
489
|
+
* @returns {Promise<unknown>} the parsed value, or `null`.
|
|
490
|
+
*/
|
|
491
|
+
async function readStateJson(file) {
|
|
492
|
+
try {
|
|
493
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
494
|
+
} catch {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Read the persisted `version` field of one state file, or `null` when it is
|
|
500
|
+
* absent, unreadable, non-JSON, or carries no numeric `version`.
|
|
501
|
+
*
|
|
502
|
+
* This is the ADR-006 write-side guard's probe: a writer must know what it is
|
|
503
|
+
* about to overwrite. "No version" is the same as "no record" (nothing to
|
|
504
|
+
* protect), while a NUMERIC version this build does not recognise means the
|
|
505
|
+
* file was written by a NEWER build and must not be clobbered.
|
|
506
|
+
*
|
|
507
|
+
* Kept apart from `readStateJson` on purpose: the guard needs the version even
|
|
508
|
+
* when the rest of the payload is unparseable, and it must not depend on any
|
|
509
|
+
* store's parse semantics.
|
|
510
|
+
* @param {string} file - the state file path.
|
|
511
|
+
* @returns {Promise<number|null>} the persisted version, or `null`.
|
|
512
|
+
*/
|
|
513
|
+
async function readStateVersion(file) {
|
|
514
|
+
const raw = await readStateJson(file);
|
|
515
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
516
|
+
const version = raw.version;
|
|
517
|
+
return typeof version === "number" ? version : null;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Whether a writer may proceed given the version currently on disk.
|
|
521
|
+
*
|
|
522
|
+
* `null` (no file / no version) is always safe to write. A numeric version is
|
|
523
|
+
* safe only when this build declares it known — anything else is a NEWER build
|
|
524
|
+
* that this build cannot read, so the write must be refused.
|
|
525
|
+
* @param {number|null} version - the on-disk version, from {@link readStateVersion}.
|
|
526
|
+
* @param {readonly number[]} known - the versions this build understands.
|
|
527
|
+
* @returns {boolean} true when writing may proceed.
|
|
528
|
+
*/
|
|
529
|
+
function isKnownStateVersion(version, known) {
|
|
530
|
+
return version === null || known.includes(version);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/host/codes.ts
|
|
535
|
+
/**
|
|
536
|
+
* 错误分类的单一真源(对齐姊妹插件的 codes.ts 纪律)。
|
|
537
|
+
*
|
|
538
|
+
* 魔搭的分类现实(SPIKE.md):上游不带任何额度头,额度耗尽与每分钟限频都只
|
|
539
|
+
* 会以 429 + 文案出现,所以 429 的分诊是**按 body 文案**做的保守二分——
|
|
540
|
+
* QUOTA_EXCEEDED 快速失败不重试,RATE_LIMITED 交给调用方退避。分不清时归
|
|
541
|
+
* RATE_LIMITED(退避是两方向里更安全的那个)。
|
|
542
|
+
*
|
|
543
|
+
* @module dsh-connect-modelscope-token-plan/codes
|
|
544
|
+
*/
|
|
545
|
+
/** 面板按它分流的稳定码表。 */
|
|
546
|
+
const CODE = Object.freeze({
|
|
547
|
+
CONFIG_ERROR: "config_error",
|
|
548
|
+
NETWORK_ERROR: "network_error",
|
|
549
|
+
TIMEOUT_ERROR: "timeout_error",
|
|
550
|
+
AUTH_ERROR: "auth_error",
|
|
551
|
+
RATE_LIMITED: "rate_limited",
|
|
552
|
+
QUOTA_EXCEEDED: "quota_exceeded",
|
|
553
|
+
UPSTREAM_ERROR: "upstream_error",
|
|
554
|
+
/**
|
|
555
|
+
* 插件自身抛错(聚合器、状态层、路由之外的意外)。
|
|
556
|
+
*
|
|
557
|
+
* 曾经只作为 `soft()` / `failureCode()` 的兜底字符串出现在快照里,没进这张
|
|
558
|
+
* 表——于是 Client 的 `GUIDANCE_BY_CODE` 也没法映射它(那份映射被测试钉住
|
|
559
|
+
* 「每个键必须在 CODE 里」),最需要人看的内部错误反而没有引导文案。
|
|
560
|
+
* 正式收进码表,两端从此同一份真源。
|
|
561
|
+
*/
|
|
562
|
+
INTERNAL_ERROR: "internal_error"
|
|
563
|
+
});
|
|
564
|
+
/**
|
|
565
|
+
* 把上游 HTTP 状态归类成稳定码。
|
|
566
|
+
*
|
|
567
|
+
* 401/403 → AUTH_ERROR(换令牌能修,绝不自动重试——魔搭没有锁号问题,
|
|
568
|
+
* 但重试一个坏令牌只会刷屏);429 由调用方先走 {@link classifyRateLimit}
|
|
569
|
+
* 分诊,这里只兜底;5xx → UPSTREAM_ERROR;其余 → NETWORK_ERROR。
|
|
570
|
+
*/
|
|
571
|
+
function classifyStatus(status) {
|
|
572
|
+
if (status === 401 || status === 403) return CODE.AUTH_ERROR;
|
|
573
|
+
if (status === 429) return CODE.RATE_LIMITED;
|
|
574
|
+
if (status >= 500) return CODE.UPSTREAM_ERROR;
|
|
575
|
+
if (status >= 400) return CODE.UPSTREAM_ERROR;
|
|
576
|
+
return CODE.NETWORK_ERROR;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* 429 的文案分诊:quota(当日/单模型额度耗尽,重试无意义)vs rate_limit
|
|
580
|
+
* (每分钟限频,退避有用)。
|
|
581
|
+
*
|
|
582
|
+
* 判据**复用 `llm-error-fix.ts` 的 {@link looksLikeRateLimit}**(限频信号 +
|
|
583
|
+
* 硬额度措辞的二元区分),不再在这里维护第二份词表——两个词表必然漂移,而漂移
|
|
584
|
+
* 的后果是插件内部两层互相抵消。
|
|
585
|
+
*
|
|
586
|
+
* 为什么曾经判错:这张表原来把 `"limit reached"` / `"daily"` / `"次数"` 归进
|
|
587
|
+
* quota 桶,于是
|
|
588
|
+
*
|
|
589
|
+
* - `"Rate limit reached, please retry after 60 seconds"` → quota(应为 rate_limit)
|
|
590
|
+
* - `"429 rate limit reached for rpm"` → quota(应为 rate_limit)
|
|
591
|
+
* - `"请求过于频繁,请稍后重试"` → quota("次数" 命中)
|
|
592
|
+
*
|
|
593
|
+
* 后果很具体:`inference-client` 据此选 `QUOTA_EXCEEDED`,而 `llm-retry.ts`刻意
|
|
594
|
+
* 把 QUOTA 排除在可重试之外 —— 瞬时限频被当成耗尽、快失败且不重试;面板事件流
|
|
595
|
+
* 还会把 rpm 限频显示成「额度耗尽」,与 `llm-error-fix.ts` 那套「429 不是耗尽」
|
|
596
|
+
* 的纠正层在同一条数据上自相矛盾。
|
|
597
|
+
*
|
|
598
|
+
* 语义顺序:**限频信号优先,硬额度措辞兜底**。`looksLikeRateLimit` 正是这个
|
|
599
|
+
* 二元判定(有限频信号且无硬额度措辞),所以它说 true → rate_limit;说 false
|
|
600
|
+
* 时需要区分「有硬额度措辞的真耗尽」与「完全读不懂」,前者 quota、后者按更安全
|
|
601
|
+
* 的默认归 rate_limit。
|
|
602
|
+
*/
|
|
603
|
+
function classifyRateLimit(message) {
|
|
604
|
+
if (typeof message !== "string" || message.length === 0) return "rate_limit";
|
|
605
|
+
if (looksLikeRateLimit(message)) return "rate_limit";
|
|
606
|
+
return hasHardQuotaWording(message) ? "quota" : "rate_limit";
|
|
607
|
+
}
|
|
608
|
+
/** 文本是否含硬额度措辞(真耗尽,不纠正)——直接问 llm-error-fix 的那张表。 */
|
|
609
|
+
function hasHardQuotaWording(message) {
|
|
610
|
+
return hasHardQuotaWordingIn(message);
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* 解析 Retry-After(秒或 HTTP 日期)与平台文案里写明的等待时长。
|
|
614
|
+
* 上游声明多长就等多长,不截断也不放大;解析不出返回 null。
|
|
615
|
+
*/
|
|
616
|
+
function parseRetryAfterMs(headers, message) {
|
|
617
|
+
const raw = headers?.get?.("retry-after") ?? null;
|
|
618
|
+
if (typeof raw === "string" && raw.trim() !== "") {
|
|
619
|
+
const seconds = Number(raw.trim());
|
|
620
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1e3);
|
|
621
|
+
const date = Date.parse(raw);
|
|
622
|
+
if (!Number.isNaN(date)) return Math.max(0, date - Date.now());
|
|
623
|
+
}
|
|
624
|
+
const cn = String(message).match(/(\d+)\s*(秒|分钟|小时|天)/);
|
|
625
|
+
if (cn) {
|
|
626
|
+
const unit = cn[2] ?? "秒";
|
|
627
|
+
const value = Number(cn[1]);
|
|
628
|
+
const multiplier = unit === "分钟" ? 60 : unit === "小时" ? 3600 : unit === "天" ? 86400 : 1;
|
|
629
|
+
if (Number.isFinite(value) && value > 0) return value * multiplier * 1e3;
|
|
630
|
+
}
|
|
631
|
+
const en = String(message).match(/(\d+)\s*(seconds?|secs?|minutes?|mins?|hours?|hrs?|days?)\b/i);
|
|
632
|
+
if (en) {
|
|
633
|
+
const value = Number(en[1]);
|
|
634
|
+
const unit = (en[2] ?? "seconds").toLowerCase();
|
|
635
|
+
const multiplier = unit.startsWith("min") ? 60 : unit.startsWith("h") ? 3600 : unit.startsWith("d") ? 86400 : 1;
|
|
636
|
+
if (Number.isFinite(value) && value > 0) return value * multiplier * 1e3;
|
|
637
|
+
}
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/host/usage-store.ts
|
|
643
|
+
/**
|
|
644
|
+
* 本地用量存储——面板「额度」tab 的数据真源。
|
|
645
|
+
*
|
|
646
|
+
* 为什么存在:魔搭没有余额查询接口(SPIKE.md §结论 1),面板的「剩余」=
|
|
647
|
+
* 配置常数 − 本地计数。计数只可能来自**经本插件**的调用(probe 现在记、
|
|
648
|
+
* provider 注册以后也记同一入口),所以面板文案必须永远带着「本地推算,
|
|
649
|
+
* 仅统计经本插件的调用」。
|
|
650
|
+
*
|
|
651
|
+
* 持久化纪律(与姊妹插件同款):`$DSH_HOME/state/[<profile>/]<name>/usage.json`
|
|
652
|
+
* —— 按 profile 分段(它回答的是「这个 profile 的调用吃了多少额度」,与
|
|
653
|
+
* catalog/provider 开关同侧;没有共享跨 profile 的理由)。原子写(0600 temp
|
|
654
|
+
* + rename)、损坏即忽略、版本守卫拒覆写未知版本。进程内 1s 读缓存让一次
|
|
655
|
+
* 快照聚合的多次问询自洽。
|
|
656
|
+
*
|
|
657
|
+
* 语义基线(wire.ts):
|
|
658
|
+
* - 天桶缺失 = 该日本插件没有记录到调用 = 真实的 0,不是数据丢失;
|
|
659
|
+
* - models 的 `tokens` 可为 null(上游没给 usage 时),绝不把 null 写成 0;
|
|
660
|
+
* - 事件流有界(maxEvents), Newest-first。
|
|
661
|
+
*
|
|
662
|
+
* peer-free:纯 node:fs,离线可测(注入 dir 即可,见 test/usage-store.test.mjs)。
|
|
663
|
+
*
|
|
664
|
+
* @module dsh-connect-modelscope-token-plan/usage-store
|
|
665
|
+
*/
|
|
666
|
+
/** 本地日期键( quota 按天重置,以 Host 本地时区为准)。 */
|
|
667
|
+
function localDateKey(now) {
|
|
668
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
669
|
+
}
|
|
670
|
+
const STATE_VERSION = 1;
|
|
671
|
+
const KNOWN_VERSIONS = [STATE_VERSION];
|
|
672
|
+
/** 解析并校验磁盘载荷;形状不对读作 null(损坏即忽略),并标记异常。 */
|
|
673
|
+
function parsePayload(raw) {
|
|
674
|
+
if (raw === null) return {
|
|
675
|
+
payload: null,
|
|
676
|
+
anomaly: null
|
|
677
|
+
};
|
|
678
|
+
if (typeof raw !== "object" || Array.isArray(raw)) return {
|
|
679
|
+
payload: null,
|
|
680
|
+
anomaly: "usage-state: payload is not an object"
|
|
681
|
+
};
|
|
682
|
+
const source = raw;
|
|
683
|
+
const version = source.version;
|
|
684
|
+
if (typeof version !== "number" || !KNOWN_VERSIONS.includes(version)) return {
|
|
685
|
+
payload: null,
|
|
686
|
+
anomaly: `usage-state: unknown version ${String(version)}`
|
|
687
|
+
};
|
|
688
|
+
const days = {};
|
|
689
|
+
const rawDays = source.days;
|
|
690
|
+
if (rawDays !== void 0 && rawDays !== null) {
|
|
691
|
+
if (typeof rawDays !== "object" || Array.isArray(rawDays)) return {
|
|
692
|
+
payload: null,
|
|
693
|
+
anomaly: "usage-state: days is not an object"
|
|
694
|
+
};
|
|
695
|
+
for (const [key, value] of Object.entries(rawDays)) if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
696
|
+
const bucket = value;
|
|
697
|
+
days[key] = {
|
|
698
|
+
calls: typeof bucket.calls === "number" && Number.isFinite(bucket.calls) ? bucket.calls : 0,
|
|
699
|
+
tokens: typeof bucket.tokens === "number" && Number.isFinite(bucket.tokens) ? bucket.tokens : 0
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const models = {};
|
|
704
|
+
const rawModels = source.models;
|
|
705
|
+
if (rawModels !== void 0 && rawModels !== null) {
|
|
706
|
+
if (typeof rawModels !== "object" || Array.isArray(rawModels)) return {
|
|
707
|
+
payload: null,
|
|
708
|
+
anomaly: "usage-state: models is not an object"
|
|
709
|
+
};
|
|
710
|
+
for (const [key, value] of Object.entries(rawModels)) if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
711
|
+
const counter = value;
|
|
712
|
+
models[key] = {
|
|
713
|
+
calls: typeof counter.calls === "number" && Number.isFinite(counter.calls) ? counter.calls : 0,
|
|
714
|
+
tokens: typeof counter.tokens === "number" && Number.isFinite(counter.tokens) ? counter.tokens : null,
|
|
715
|
+
lastAt: typeof counter.lastAt === "string" ? counter.lastAt : null,
|
|
716
|
+
day: typeof counter.day === "string" ? counter.day : "",
|
|
717
|
+
dayCalls: typeof counter.dayCalls === "number" && Number.isFinite(counter.dayCalls) ? counter.dayCalls : 0,
|
|
718
|
+
dayTokens: typeof counter.dayTokens === "number" && Number.isFinite(counter.dayTokens) ? counter.dayTokens : 0
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const events = Array.isArray(source.events) ? source.events.filter((entry) => Boolean(entry) && typeof entry === "object" && typeof entry.at === "string" && typeof entry.modelId === "string" && typeof entry.kind === "string" && typeof entry.message === "string") : [];
|
|
723
|
+
return {
|
|
724
|
+
payload: {
|
|
725
|
+
version: STATE_VERSION,
|
|
726
|
+
days,
|
|
727
|
+
models,
|
|
728
|
+
events
|
|
729
|
+
},
|
|
730
|
+
anomaly: null
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* 构造文件用量 store。
|
|
735
|
+
* @param {object} options
|
|
736
|
+
* @param {string} options.name - 插件 slug(状态目录名,来自 host-config)。
|
|
737
|
+
* @param {string|null} [options.profile] - profile 名;null = 共享目录。
|
|
738
|
+
* @param {number} [options.trendDays] - 天桶保留数(超出修剪;从配置注入,
|
|
739
|
+
* 让「配置改小」在下次写入时生效,而不是等一个固定的内部常数)。
|
|
740
|
+
* @param {number} [options.maxEvents] - 事件流上限。
|
|
741
|
+
* @param {() => Date} [options.now] - 时钟源;测试注入。
|
|
742
|
+
* @param {{warn?: (message: string) => void}} [options.logger] - ctx.logger。
|
|
743
|
+
*/
|
|
744
|
+
function createFileUsageStore({ name, profile = null, trendDays = 14, maxEvents = 50, now = () => /* @__PURE__ */ new Date(), logger }) {
|
|
745
|
+
const dir = profileStateDir(name, profile);
|
|
746
|
+
const file = join(dir, "usage.json");
|
|
747
|
+
/** 最近一次解析异常(损坏即忽略的痕迹),快照转 shapeWarnings。 */
|
|
748
|
+
let anomaly = null;
|
|
749
|
+
/** 只读闸:磁盘版本比本构建新时置位——读照常、写拒绝,绝不 clobber。 */
|
|
750
|
+
let readOnly = false;
|
|
751
|
+
/** 最近一次确认过的内存态;读路径拿它,写路径以它为基线。 */
|
|
752
|
+
let current = {
|
|
753
|
+
version: STATE_VERSION,
|
|
754
|
+
days: {},
|
|
755
|
+
models: {},
|
|
756
|
+
events: []
|
|
757
|
+
};
|
|
758
|
+
let loaded = false;
|
|
759
|
+
/** 写串行化:读-改-写必须排队,两个并发 recordCall 不能互相覆盖。 */
|
|
760
|
+
let writeChain = Promise.resolve();
|
|
761
|
+
/**
|
|
762
|
+
* 磁盘版本闸 —— 读路径与写路径**各自**调用,不共享副作用。
|
|
763
|
+
*
|
|
764
|
+
* 为什么必须两边都直接调(而不是让读路径置位、写路径顺带检查):这个函数
|
|
765
|
+
* 早先只在 `readThrough` 里被调用,而写路径的守卫写在 `enqueue` 开头——那时
|
|
766
|
+
* `readOnly` 还没被置位(置位发生在随后的 `cache.read()` 内部),于是**守卫永远
|
|
767
|
+
* 比真正的探测晚一步**,旧构建会用一个空载荷把新构建的 usage.json 降级覆写
|
|
768
|
+
* (实测:version 2 → 1,110 次历史归零,而日志还在说 "recording disabled to
|
|
769
|
+
* avoid clobbering"——日志与磁盘状态互相撒谎)。
|
|
770
|
+
*
|
|
771
|
+
* 现在写路径在 mutate 之前**自己**读一次盘,判据与执行之间不再隔着一个 await。
|
|
772
|
+
*
|
|
773
|
+
* @returns `true` = 磁盘版本比本构建新(写必须拒绝);`false` = 可以写。
|
|
774
|
+
*/
|
|
775
|
+
const diskVersionBlocksWrites = async () => {
|
|
776
|
+
const onDisk = await readStateVersion(file);
|
|
777
|
+
if (onDisk !== null && !isKnownStateVersion(onDisk, KNOWN_VERSIONS)) {
|
|
778
|
+
const note = `usage-state: on-disk version ${onDisk} is newer than this build knows`;
|
|
779
|
+
if (!readOnly) logger?.warn?.(`${name}: ${note} — recording disabled to avoid clobbering`);
|
|
780
|
+
anomaly = note;
|
|
781
|
+
readOnly = true;
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
if (readOnly) {
|
|
785
|
+
logger?.warn?.(`${name}: usage-state: version guard lifted (on-disk version ${onDisk ?? "none"}) — recording resumed`);
|
|
786
|
+
readOnly = false;
|
|
787
|
+
}
|
|
788
|
+
return false;
|
|
789
|
+
};
|
|
790
|
+
/** 读盘(缓存穿透);返回 null = 无记录或损坏。 */
|
|
791
|
+
const readThrough = async () => {
|
|
792
|
+
if (await diskVersionBlocksWrites()) return null;
|
|
793
|
+
const parsed = parsePayload(await readStateJson(file));
|
|
794
|
+
anomaly = parsed.anomaly;
|
|
795
|
+
if (parsed.anomaly !== null) logger?.warn?.(`${name}: ${parsed.anomaly} — starting from empty`);
|
|
796
|
+
return parsed.payload;
|
|
797
|
+
};
|
|
798
|
+
const cache = createStateReadCache(readThrough, { now: () => Date.now() });
|
|
799
|
+
/** 把内存态写盘(原子),失败不抛——计数是尽力而为的观测,不是事务。 */
|
|
800
|
+
const persist = async (payload) => {
|
|
801
|
+
await ensureStateDir(dir);
|
|
802
|
+
await writeStateFile(file, JSON.stringify(payload), { temporary: temporaryOf(dir, "usage.json") });
|
|
803
|
+
cache.remember(payload);
|
|
804
|
+
};
|
|
805
|
+
/** 排队执行一次读-改-写;只读闸置位时整个变更是 no-op。 */
|
|
806
|
+
const enqueue = (mutate) => {
|
|
807
|
+
const run = writeChain.then(async () => {
|
|
808
|
+
if (await diskVersionBlocksWrites()) return;
|
|
809
|
+
const payload = (loaded ? current : await cache.read() ?? null) ?? {
|
|
810
|
+
version: STATE_VERSION,
|
|
811
|
+
days: {},
|
|
812
|
+
models: {},
|
|
813
|
+
events: []
|
|
814
|
+
};
|
|
815
|
+
mutate(payload);
|
|
816
|
+
current = payload;
|
|
817
|
+
loaded = true;
|
|
818
|
+
await persist(payload);
|
|
819
|
+
});
|
|
820
|
+
writeChain = run.catch(() => {});
|
|
821
|
+
return run;
|
|
822
|
+
};
|
|
823
|
+
/** 修剪:只留最近 trendDays 个天桶(按 dateKey 排序,今天不删)。 */
|
|
824
|
+
const pruneDays = (payload, todayKey) => {
|
|
825
|
+
const keep = new Set(Object.keys(payload.days).sort().slice(-Math.max(1, trendDays)));
|
|
826
|
+
keep.add(todayKey);
|
|
827
|
+
for (const key of Object.keys(payload.days)) if (!keep.has(key)) delete payload.days[key];
|
|
828
|
+
};
|
|
829
|
+
return {
|
|
830
|
+
/** 记一次调用(今天 +1;模型计数跨日清零;tokens 可为 null)。 */
|
|
831
|
+
async recordCall({ modelId, tokens = null, at = now() }) {
|
|
832
|
+
if (typeof modelId !== "string" || modelId.trim() === "") throw pluginError(CODE.CONFIG_ERROR, "recordCall: modelId is required");
|
|
833
|
+
const dayKey = localDateKey(at);
|
|
834
|
+
await enqueue((payload) => {
|
|
835
|
+
const day = payload.days[dayKey] ?? {
|
|
836
|
+
calls: 0,
|
|
837
|
+
tokens: 0
|
|
838
|
+
};
|
|
839
|
+
day.calls += 1;
|
|
840
|
+
if (typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) day.tokens += tokens;
|
|
841
|
+
payload.days[dayKey] = day;
|
|
842
|
+
const counter = payload.models[modelId] ?? {
|
|
843
|
+
calls: 0,
|
|
844
|
+
tokens: null,
|
|
845
|
+
lastAt: null,
|
|
846
|
+
day: dayKey,
|
|
847
|
+
dayCalls: 0,
|
|
848
|
+
dayTokens: 0
|
|
849
|
+
};
|
|
850
|
+
if (counter.day !== dayKey) {
|
|
851
|
+
counter.day = dayKey;
|
|
852
|
+
counter.dayCalls = 0;
|
|
853
|
+
counter.dayTokens = 0;
|
|
854
|
+
}
|
|
855
|
+
counter.calls += 1;
|
|
856
|
+
counter.dayCalls += 1;
|
|
857
|
+
if (typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0) {
|
|
858
|
+
counter.tokens = (counter.tokens ?? 0) + tokens;
|
|
859
|
+
counter.dayTokens += tokens;
|
|
860
|
+
}
|
|
861
|
+
counter.lastAt = at.toISOString();
|
|
862
|
+
payload.models[modelId] = counter;
|
|
863
|
+
pruneDays(payload, dayKey);
|
|
864
|
+
});
|
|
865
|
+
},
|
|
866
|
+
/** 记一条事件(429/错误),Newest-first,截断到 maxEvents。 */
|
|
867
|
+
async recordEvent({ modelId, kind, message, at = now() }) {
|
|
868
|
+
await enqueue((payload) => {
|
|
869
|
+
payload.events = [{
|
|
870
|
+
at: at.toISOString(),
|
|
871
|
+
modelId: String(modelId),
|
|
872
|
+
kind,
|
|
873
|
+
message: String(message).slice(0, 500)
|
|
874
|
+
}, ...payload.events].slice(0, maxEvents);
|
|
875
|
+
});
|
|
876
|
+
},
|
|
877
|
+
/** 今天(本地时区)的账户级计数;没有任何记录时是 0,不是 null。 */
|
|
878
|
+
async daily() {
|
|
879
|
+
const day = (await cache.read())?.days[localDateKey(now())] ?? null;
|
|
880
|
+
return day === null ? {
|
|
881
|
+
dateKey: localDateKey(now()),
|
|
882
|
+
calls: 0,
|
|
883
|
+
tokens: 0
|
|
884
|
+
} : {
|
|
885
|
+
...day,
|
|
886
|
+
dateKey: localDateKey(now())
|
|
887
|
+
};
|
|
888
|
+
},
|
|
889
|
+
/** 今天的单模型计数(只含有记录的模型,dayCalls/dayTokens 口径,均跨日清零)。 */
|
|
890
|
+
async perModelToday() {
|
|
891
|
+
const payload = await cache.read();
|
|
892
|
+
const todayKey = localDateKey(now());
|
|
893
|
+
const out = [];
|
|
894
|
+
for (const [modelId, counter] of Object.entries(payload?.models ?? {})) {
|
|
895
|
+
if (counter.day !== todayKey) continue;
|
|
896
|
+
out.push({
|
|
897
|
+
modelId,
|
|
898
|
+
calls: counter.dayCalls,
|
|
899
|
+
tokens: counter.dayTokens,
|
|
900
|
+
lastAt: counter.lastAt
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
out.sort((a, b) => b.calls - a.calls);
|
|
904
|
+
return out;
|
|
905
|
+
},
|
|
906
|
+
/** 最近 N 天趋势(含今天;缺桶 = 该日 0 次,真实零不是数据丢失)。 */
|
|
907
|
+
async trend(days = trendDays) {
|
|
908
|
+
const payload = await cache.read();
|
|
909
|
+
const out = [];
|
|
910
|
+
const cursor = now();
|
|
911
|
+
for (let offset = Number(days) - 1; offset >= 0; offset -= 1) {
|
|
912
|
+
const date = new Date(cursor);
|
|
913
|
+
date.setDate(date.getDate() - offset);
|
|
914
|
+
const key = localDateKey(date);
|
|
915
|
+
const bucket = payload?.days[key];
|
|
916
|
+
out.push({
|
|
917
|
+
dateKey: key,
|
|
918
|
+
calls: bucket?.calls ?? 0,
|
|
919
|
+
tokens: bucket?.tokens ?? 0
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
return out;
|
|
923
|
+
},
|
|
924
|
+
/** 事件流(Newest-first,已截断)。 */
|
|
925
|
+
async events() {
|
|
926
|
+
return [...(await cache.read())?.events ?? []];
|
|
927
|
+
},
|
|
928
|
+
/** 最近一次解析异常(无异常为 null)——快照转 shapeWarnings。 */
|
|
929
|
+
anomaly() {
|
|
930
|
+
return anomaly;
|
|
931
|
+
},
|
|
932
|
+
/** 无秘密的状态(doctor 用)。 */
|
|
933
|
+
async state() {
|
|
934
|
+
const payload = await cache.read();
|
|
935
|
+
return {
|
|
936
|
+
file: profile === null ? join(stateDir(name), "usage.json") : file,
|
|
937
|
+
hasRecord: payload !== null,
|
|
938
|
+
days: Object.keys(payload?.days ?? {}).length,
|
|
939
|
+
events: payload?.events.length ?? 0,
|
|
940
|
+
readOnly
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
//#endregion
|
|
947
|
+
//#region src/host/coalesced-fetch.ts
|
|
948
|
+
/**
|
|
949
|
+
* One coalescing read-through cache: a TTL cache plus ONE in-flight promise
|
|
950
|
+
* per key.
|
|
951
|
+
*
|
|
952
|
+
* Two properties every caller needs, and that are easy to get wrong when each
|
|
953
|
+
* fetch re-implements them by hand (as `console-client.ts` once did twice):
|
|
954
|
+
*
|
|
955
|
+
* 1. **Single flight** — N concurrent readers of one key share ONE upstream
|
|
956
|
+
* call. Without it, every open panel (or tab) polling at once issues its
|
|
957
|
+
* own request, which is how a Host walks into the platform's own rate
|
|
958
|
+
* limiter.
|
|
959
|
+
* 2. **Read-through TTL** — a fresh answer is served from memory; a stale one
|
|
960
|
+
* is refetched.
|
|
961
|
+
*
|
|
962
|
+
* A REJECTED producer is shared but never cached: a failure is not an answer,
|
|
963
|
+
* and caching one would pin an error on the panel for a whole TTL after a
|
|
964
|
+
* single transient hiccup. Sharing the rejection is still correct — the callers
|
|
965
|
+
* asked for the same thing at the same time and get the same outcome.
|
|
966
|
+
*
|
|
967
|
+
* The maps are injectable so a caller can share one cache across modules (the
|
|
968
|
+
* console route's `cache`/`inflight` pair is created in `index.ts` and cleared
|
|
969
|
+
* wholesale when the account or key changes).
|
|
970
|
+
*
|
|
971
|
+
* @module dsh-connect-modelscope-token-plan/coalesced-fetch
|
|
972
|
+
*/
|
|
973
|
+
/**
|
|
974
|
+
* The longest TTL any caller may use; entries older than this are swept so the
|
|
975
|
+
* map cannot grow without bound when keys are per-credential and credentials
|
|
976
|
+
* rotate.
|
|
977
|
+
*/
|
|
978
|
+
const MAX_CACHE_AGE_MS = 36e5;
|
|
979
|
+
/**
|
|
980
|
+
* The generation state for one shared cache map.
|
|
981
|
+
*
|
|
982
|
+
* Keyed by the cache MAP rather than held per instance, and that is load
|
|
983
|
+
* bearing: a caller may construct a fresh `createCoalescedFetch` on every
|
|
984
|
+
* request while injecting one long-lived shared map (which is exactly what
|
|
985
|
+
* `console-client.ts` does, over the `cache`/`inflight` pair created in
|
|
986
|
+
* `index.ts`). Per-instance counters would reset to 0 on every call, so a
|
|
987
|
+
* `clear()` bump would be invisible to the very next read and a pre-clear
|
|
988
|
+
* flight's answer would be served to the account that just signed in.
|
|
989
|
+
* Hanging the counters off the map makes every instance over that map share
|
|
990
|
+
* one generation state, so the guard holds no matter how the caller builds it.
|
|
991
|
+
*/
|
|
992
|
+
const GENERATIONS = /* @__PURE__ */ new WeakMap();
|
|
993
|
+
/** The (created-on-demand) generation state belonging to one cache map. */
|
|
994
|
+
function generationsOf(cache) {
|
|
995
|
+
let state = GENERATIONS.get(cache);
|
|
996
|
+
if (state === void 0) {
|
|
997
|
+
state = {
|
|
998
|
+
global: 0,
|
|
999
|
+
keys: /* @__PURE__ */ new Map()
|
|
1000
|
+
};
|
|
1001
|
+
GENERATIONS.set(cache, state);
|
|
1002
|
+
}
|
|
1003
|
+
return state;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Drop one key's cached answer, or the whole cache, bumping generations.
|
|
1007
|
+
*
|
|
1008
|
+
* Exported separately from the instance so a caller that only owns the raw
|
|
1009
|
+
* maps — the account and api-key routes, which must invalidate the console
|
|
1010
|
+
* cache the moment the credential changes — can invalidate WITHOUT having to
|
|
1011
|
+
* construct an instance first. It shares `generationsOf`, so an instance's own
|
|
1012
|
+
* `clear()` and this function are the same operation.
|
|
1013
|
+
* @param {Map<string, unknown>} cache - the cache map to drop from.
|
|
1014
|
+
* @param {Map<string, Promise<unknown>>} inflight - its in-flight companion.
|
|
1015
|
+
* @param {string} [key] - the key to drop; omit to drop everything.
|
|
1016
|
+
* @returns {void}
|
|
1017
|
+
*/
|
|
1018
|
+
function clearCoalescedFetch(cache, inflight, key) {
|
|
1019
|
+
const gens = generationsOf(cache);
|
|
1020
|
+
if (key === void 0) {
|
|
1021
|
+
gens.global += 1;
|
|
1022
|
+
cache.clear();
|
|
1023
|
+
inflight.clear();
|
|
1024
|
+
gens.keys.clear();
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
gens.keys.set(key, (gens.keys.get(key) ?? gens.global) + 1);
|
|
1028
|
+
cache.delete(key);
|
|
1029
|
+
inflight.delete(key);
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* A coalescing read-through cache.
|
|
1033
|
+
* @param {object} [options]
|
|
1034
|
+
* @param {Map<string, {body: unknown, at: number, gen: number}>} [options.cache] -
|
|
1035
|
+
* an existing cache map to share; a fresh one is created when omitted.
|
|
1036
|
+
* @param {Map<string, Promise<unknown>>} [options.inflight] - an existing
|
|
1037
|
+
* in-flight map to share.
|
|
1038
|
+
* @param {number} [options.maxAgeMs] - the sweep ceiling.
|
|
1039
|
+
* @returns {{
|
|
1040
|
+
* read: (key: string, producer: () => Promise<unknown>, ttlMs: number) => Promise<unknown>,
|
|
1041
|
+
* clear: (key?: string) => void
|
|
1042
|
+
* }}
|
|
1043
|
+
*/
|
|
1044
|
+
function createCoalescedFetch(options = {}) {
|
|
1045
|
+
const { cache = /* @__PURE__ */ new Map(), inflight = /* @__PURE__ */ new Map(), maxAgeMs = MAX_CACHE_AGE_MS } = options;
|
|
1046
|
+
const gens = generationsOf(cache);
|
|
1047
|
+
const genOf = (key) => gens.keys.get(key) ?? gens.global;
|
|
1048
|
+
/** Drop entries past the sweep ceiling; called after every write. */
|
|
1049
|
+
const sweep = () => {
|
|
1050
|
+
const nowMs = Date.now();
|
|
1051
|
+
for (const [key, entry] of cache) if (nowMs - entry.at > maxAgeMs) cache.delete(key);
|
|
1052
|
+
};
|
|
1053
|
+
/**
|
|
1054
|
+
* Read one key through the cache, coalescing concurrent misses.
|
|
1055
|
+
* @param {string} key - the cache key: a URL, or a key that also carries a
|
|
1056
|
+
* credential fingerprint when the answer depends on which token is in play.
|
|
1057
|
+
* @param {() => Promise<unknown>} producer - what to call on a miss.
|
|
1058
|
+
* @param {number} ttlMs - how long an answer stays fresh (`0` = never
|
|
1059
|
+
* reuse; the single-flight sharing still applies).
|
|
1060
|
+
* @returns {Promise<unknown>} the value.
|
|
1061
|
+
*/
|
|
1062
|
+
const read = async (key, producer, ttlMs) => {
|
|
1063
|
+
const cached = cache.get(key);
|
|
1064
|
+
if (cached !== void 0 && cached.gen === genOf(key) && Date.now() - cached.at < ttlMs) return cached.body;
|
|
1065
|
+
const pending = inflight.get(key);
|
|
1066
|
+
if (pending !== void 0) return pending;
|
|
1067
|
+
const born = genOf(key);
|
|
1068
|
+
const flight = (async () => {
|
|
1069
|
+
const body = await producer();
|
|
1070
|
+
cache.set(key, {
|
|
1071
|
+
body,
|
|
1072
|
+
at: Date.now(),
|
|
1073
|
+
gen: born
|
|
1074
|
+
});
|
|
1075
|
+
sweep();
|
|
1076
|
+
return body;
|
|
1077
|
+
})().finally(() => {
|
|
1078
|
+
if (inflight.get(key) === flight) inflight.delete(key);
|
|
1079
|
+
});
|
|
1080
|
+
inflight.set(key, flight);
|
|
1081
|
+
return flight;
|
|
1082
|
+
};
|
|
1083
|
+
/**
|
|
1084
|
+
* Drop one key's cached answer, or the whole cache.
|
|
1085
|
+
*
|
|
1086
|
+
* The drop bumps the key's (or the global) generation AND evicts the
|
|
1087
|
+
* in-flight map entry, so the NEXT read cannot join a pre-clear flight nor be
|
|
1088
|
+
* served a pre-clear cache entry — the previous identity's answer is gone for
|
|
1089
|
+
* good even when its producer was still in flight when the change happened.
|
|
1090
|
+
* In-flight calls themselves are NOT cancelled: a reader that already took a
|
|
1091
|
+
* flight's promise still settles with what it asked for; only NEW reads miss.
|
|
1092
|
+
* @param {string} [key] - the key to drop; omit to drop everything.
|
|
1093
|
+
* @returns {void}
|
|
1094
|
+
*/
|
|
1095
|
+
const clear = (key) => clearCoalescedFetch(cache, inflight, key);
|
|
1096
|
+
return {
|
|
1097
|
+
read,
|
|
1098
|
+
clear
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
//#endregion
|
|
1103
|
+
//#region src/host/inference-client.ts
|
|
1104
|
+
/**
|
|
1105
|
+
* 魔搭 API-Inference 的 HTTP 出口:模型目录(零额度)与 probe 调用。
|
|
1106
|
+
*
|
|
1107
|
+
* 两条纪律来自 SPIKE.md:
|
|
1108
|
+
* - **上游不带额度头**——所以本模块只解析 status / body / Retry-After,
|
|
1109
|
+
* 不假装能从响应里读到余额;
|
|
1110
|
+
* - **models 免认证可读**——目录轮询走无凭据 GET,零额度成本,也因此
|
|
1111
|
+
* 令牌变化不需要失效目录缓存(一个省掉整类失效 bug 的简化)。
|
|
1112
|
+
* (注释纪律:块注释里写以斜杠开头的路径必须用反引号包住,否则
|
|
1113
|
+
* 「两个星号加斜杠」会把注释提前关掉——本次 typecheck 亲手踩的。)
|
|
1114
|
+
*
|
|
1115
|
+
* 429 分诊按 body 文案(codes.ts classifyRateLimit):quota → 快速失败,
|
|
1116
|
+
* rate_limit → 带 retryAfterMs 的退避。401/403 → AUTH_ERROR(换令牌能修,
|
|
1117
|
+
* 调用方绝不自动重试)。所有错误经 pluginError 带稳定 code,消息过
|
|
1118
|
+
* redactSecrets。
|
|
1119
|
+
*
|
|
1120
|
+
* @module dsh-connect-modelscope-token-plan/inference-client
|
|
1121
|
+
*/
|
|
1122
|
+
/**
|
|
1123
|
+
* 有界并发 map:对 items 逐个跑 fn(异步),同时最多 limit 个在飞。
|
|
1124
|
+
* 用于目录加载时并行拉取各模型详情端点,避免一次性打爆主站。
|
|
1125
|
+
*/
|
|
1126
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
1127
|
+
const results = new Array(items.length);
|
|
1128
|
+
let cursor = 0;
|
|
1129
|
+
async function worker() {
|
|
1130
|
+
while (cursor < items.length) {
|
|
1131
|
+
const i = cursor++;
|
|
1132
|
+
const item = items[i];
|
|
1133
|
+
if (item === void 0) continue;
|
|
1134
|
+
results[i] = await fn(item);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
const n = Math.max(1, Math.min(limit, items.length));
|
|
1138
|
+
await Promise.all(Array.from({ length: n }, () => worker()));
|
|
1139
|
+
return results;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* 构造推理 client。
|
|
1143
|
+
* @param {object} options
|
|
1144
|
+
* @param {ResolvedSettings} options.settings - resolveSettings 的产物。
|
|
1145
|
+
* @param {object} options.tokenStore - ms-auth 的 store(resolve() 取令牌)。
|
|
1146
|
+
* @param {HostDeps} [options.deps] - 测试缝(fetchImpl)。
|
|
1147
|
+
* @param {{warn?: (message: string) => void}} [options.logger] - ctx.logger。
|
|
1148
|
+
*/
|
|
1149
|
+
function createInferenceClient({ settings, tokenStore, deps = {}, logger }) {
|
|
1150
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
1151
|
+
const { read } = createCoalescedFetch();
|
|
1152
|
+
/** 带超时的一次 fetch(AbortController;abort → TIMEOUT_ERROR)。 */
|
|
1153
|
+
const fetchWithTimeout = async (url, init) => {
|
|
1154
|
+
const controller = new AbortController();
|
|
1155
|
+
const timer = setTimeout(() => controller.abort(), settings.inferenceTimeoutMs);
|
|
1156
|
+
try {
|
|
1157
|
+
return await doFetch(url, {
|
|
1158
|
+
...init,
|
|
1159
|
+
signal: controller.signal
|
|
1160
|
+
});
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
if (error instanceof Error && error.name === "AbortError") throw pluginError(CODE.TIMEOUT_ERROR, "modelscope request timed out");
|
|
1163
|
+
throw pluginError(CODE.NETWORK_ERROR, redactSecrets(errMsg(error)));
|
|
1164
|
+
} finally {
|
|
1165
|
+
clearTimeout(timer);
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
/**
|
|
1169
|
+
* 丢弃一个我们不读的响应体。
|
|
1170
|
+
*
|
|
1171
|
+
* undici 里未消费的 body 会**占住连接**直到 GC。目录刷新会对 35 个模型并发打
|
|
1172
|
+
* 详情端点(`Promise.all` 分批),其中任何一条走「!ok」或「形状不符」分支,
|
|
1173
|
+
* 连接就一直挂着——短时间内连接池被这些挂起的响应吃满,后面的请求开始排队。
|
|
1174
|
+
*
|
|
1175
|
+
* `cancel()` 是显式的弃权:告诉 undici「这个 body 我不要了」,它会立刻释放
|
|
1176
|
+
* 连接而不是等GC。用 `void` 前缀是因为这是best-effort——取消失败不影响我们
|
|
1177
|
+
* 已经决定返回 `[]` 的结论。
|
|
1178
|
+
*/
|
|
1179
|
+
const discardBody = (response) => {
|
|
1180
|
+
try {
|
|
1181
|
+
response.body?.cancel();
|
|
1182
|
+
} catch {}
|
|
1183
|
+
};
|
|
1184
|
+
/** 读响应 body 里的 OpenAI 错误文案(形状漂移时返回空串,不抛)。 */
|
|
1185
|
+
const readErrorMessage = async (response) => {
|
|
1186
|
+
try {
|
|
1187
|
+
const body = await response.json();
|
|
1188
|
+
const message = body?.error?.message ?? body?.message;
|
|
1189
|
+
return typeof message === "string" ? message : "";
|
|
1190
|
+
} catch {
|
|
1191
|
+
discardBody(response);
|
|
1192
|
+
return "";
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
/**
|
|
1196
|
+
* 单个模型的详情端点任务标签(modelscope.cn/api/v1/models/{id})。
|
|
1197
|
+
* 免认证 GET,零推理额度(实测匿名 200)。返回 `Data.Tasks[].Name`(+
|
|
1198
|
+
* `widgets[].task` 兜底)去重后的标签数组;任意失败 / 非预期形状返回空数组,
|
|
1199
|
+
* 调用方据此回退策展清单 + 名字启发。
|
|
1200
|
+
*/
|
|
1201
|
+
const fetchTaskTags = async (id) => {
|
|
1202
|
+
const detailUrl = settings.siteBase + "/api/v1/models/" + encodeURI(id);
|
|
1203
|
+
const response = await fetchWithTimeout(detailUrl, {
|
|
1204
|
+
method: "GET",
|
|
1205
|
+
headers: { accept: "application/json" }
|
|
1206
|
+
});
|
|
1207
|
+
if (!response.ok) {
|
|
1208
|
+
discardBody(response);
|
|
1209
|
+
return [];
|
|
1210
|
+
}
|
|
1211
|
+
const json = await response.json().catch(() => null);
|
|
1212
|
+
if (json === null || typeof json !== "object" || json.Code !== 200) return [];
|
|
1213
|
+
const data = json.Data ?? {};
|
|
1214
|
+
const tasks = [];
|
|
1215
|
+
for (const t of Array.isArray(data.Tasks) ? data.Tasks : []) {
|
|
1216
|
+
const name = str(t?.Name, "");
|
|
1217
|
+
if (name !== "") tasks.push(name);
|
|
1218
|
+
}
|
|
1219
|
+
for (const w of Array.isArray(data.widgets) ? data.widgets : []) {
|
|
1220
|
+
const task = str(w?.task, "");
|
|
1221
|
+
if (task !== "") tasks.push(task);
|
|
1222
|
+
}
|
|
1223
|
+
return Array.from(new Set(tasks));
|
|
1224
|
+
};
|
|
1225
|
+
return {
|
|
1226
|
+
/**
|
|
1227
|
+
* 模型目录:GET apiBase/models,免认证、零额度,coalesced 缓存
|
|
1228
|
+
* cacheSeconds。返回归一后的目录条目(entries,M4 用来建 descriptor)与
|
|
1229
|
+
* 派生的 id 列表(ids,旧消费方继续可用)——两条路线读同一份条目,不会漂移;
|
|
1230
|
+
* 形状漂移抛 UPSTREAM_ERROR 并带 detail。
|
|
1231
|
+
*/
|
|
1232
|
+
async fetchModels() {
|
|
1233
|
+
const url = settings.apiBase + "/models";
|
|
1234
|
+
return await read("models", async () => {
|
|
1235
|
+
const response = await fetchWithTimeout(url, { method: "GET" });
|
|
1236
|
+
if (!response.ok) {
|
|
1237
|
+
const message = await readErrorMessage(response);
|
|
1238
|
+
const code = classifyStatus(response.status);
|
|
1239
|
+
throw pluginError(code, redactSecrets("model catalog failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
|
|
1240
|
+
}
|
|
1241
|
+
const json = await response.json().catch(() => null);
|
|
1242
|
+
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:[...]})");
|
|
1243
|
+
const raws = json.data;
|
|
1244
|
+
const ids = raws.map((r) => str(r.id, "")).filter((id) => id !== "");
|
|
1245
|
+
const tagArrays = await mapWithConcurrency(ids, 6, (id) => fetchTaskTags(id).catch(() => []));
|
|
1246
|
+
const tagsById = /* @__PURE__ */ new Map();
|
|
1247
|
+
ids.forEach((id, i) => tagsById.set(id, tagArrays[i] ?? []));
|
|
1248
|
+
const entries = raws.map((raw) => {
|
|
1249
|
+
const id = str(raw.id, "");
|
|
1250
|
+
const tags = tagsById.get(id) ?? [];
|
|
1251
|
+
return normalizeEntry({
|
|
1252
|
+
...raw,
|
|
1253
|
+
tasks: tags.length > 0 ? tags : void 0
|
|
1254
|
+
});
|
|
1255
|
+
}).filter((entry) => entry.id !== "");
|
|
1256
|
+
return {
|
|
1257
|
+
entries,
|
|
1258
|
+
ids: entries.map((entry) => entry.id),
|
|
1259
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1260
|
+
};
|
|
1261
|
+
}, settings.cacheSeconds * 1e3);
|
|
1262
|
+
},
|
|
1263
|
+
/**
|
|
1264
|
+
* 官方「魔粒」余额:GET siteBase/openapi/v1/magicubes/balance(Bearer
|
|
1265
|
+
* 令牌;2026-10-04 实测可用,匿名 401,SPIKE.md §魔粒)。coalesced 缓存
|
|
1266
|
+
* cacheSeconds,缓存键带令牌指纹——换令牌 ≤1 个 TTL 内必然读到新身份。
|
|
1267
|
+
*/
|
|
1268
|
+
async fetchBalance() {
|
|
1269
|
+
const { value: token } = await tokenStore.resolve();
|
|
1270
|
+
if (token === "") throw pluginError(CODE.AUTH_ERROR, "no ModelScope token configured — set MODELSCOPE_API_KEY or save one in the panel");
|
|
1271
|
+
const url = settings.siteBase + "/openapi/v1/magicubes/balance";
|
|
1272
|
+
const key = "balance#" + token.slice(-6);
|
|
1273
|
+
return await read(key, async () => {
|
|
1274
|
+
const response = await fetchWithTimeout(url, {
|
|
1275
|
+
method: "GET",
|
|
1276
|
+
headers: {
|
|
1277
|
+
authorization: "Bearer " + token,
|
|
1278
|
+
accept: "application/json"
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
if (!response.ok) {
|
|
1282
|
+
const message = await readErrorMessage(response);
|
|
1283
|
+
const code = classifyStatus(response.status);
|
|
1284
|
+
throw pluginError(code, redactSecrets("magicube balance failed with HTTP " + String(response.status) + (message === "" ? "" : ": " + message)));
|
|
1285
|
+
}
|
|
1286
|
+
const json = await response.json().catch(() => null);
|
|
1287
|
+
const data = json !== null && typeof json === "object" ? json.data : null;
|
|
1288
|
+
if (json === null || typeof json !== "object" || json.success !== true || data === null || typeof data !== "object") throw pluginError(CODE.UPSTREAM_ERROR, "magicube balance shape drifted (expected {success:true, data:{total_balance,available_balance,frozen_amount}})");
|
|
1289
|
+
const numOr = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1290
|
+
const row = data;
|
|
1291
|
+
return {
|
|
1292
|
+
available: numOr(row.available_balance),
|
|
1293
|
+
total: numOr(row.total_balance),
|
|
1294
|
+
frozen: numOr(row.frozen_amount),
|
|
1295
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1296
|
+
};
|
|
1297
|
+
}, settings.cacheSeconds * 1e3);
|
|
1298
|
+
},
|
|
1299
|
+
/**
|
|
1300
|
+
* 一次 probe 调用。
|
|
1301
|
+
*
|
|
1302
|
+
* - usage:真调用(1 个 max_tokens 的 ping),**消耗 1 次免费额度**,
|
|
1303
|
+
* 换回 usage 计数——面板「测试一次调用」与计数演示的来源;
|
|
1304
|
+
* - validity:故意缺 messages 的请求,预期 401(令牌坏)先于 400
|
|
1305
|
+
* (请求坏)返回,**零额度**鉴权探针(语义待真机回填,SPIKE.md)。
|
|
1306
|
+
*
|
|
1307
|
+
* 失败抛 pluginError;调用方(probe 路由)负责把它记进事件流。
|
|
1308
|
+
*/
|
|
1309
|
+
async probe({ modelId, kind }) {
|
|
1310
|
+
if (typeof modelId !== "string" || modelId.trim() === "") throw pluginError(CODE.CONFIG_ERROR, "probe: modelId is required");
|
|
1311
|
+
const { value: token } = await tokenStore.resolve();
|
|
1312
|
+
if (token === "") throw pluginError(CODE.AUTH_ERROR, "no ModelScope token configured — set MODELSCOPE_API_KEY or save one in the panel");
|
|
1313
|
+
const startedAt = Date.now();
|
|
1314
|
+
const payload = {
|
|
1315
|
+
model: modelId,
|
|
1316
|
+
stream: false
|
|
1317
|
+
};
|
|
1318
|
+
if (kind === "usage") {
|
|
1319
|
+
payload.messages = [{
|
|
1320
|
+
role: "user",
|
|
1321
|
+
content: "ping"
|
|
1322
|
+
}];
|
|
1323
|
+
payload.max_tokens = 1;
|
|
1324
|
+
}
|
|
1325
|
+
const response = await fetchWithTimeout(settings.apiBase + "/chat/completions", {
|
|
1326
|
+
method: "POST",
|
|
1327
|
+
headers: {
|
|
1328
|
+
"content-type": "application/json",
|
|
1329
|
+
authorization: "Bearer " + token
|
|
1330
|
+
},
|
|
1331
|
+
body: JSON.stringify(payload)
|
|
1332
|
+
});
|
|
1333
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1334
|
+
if (kind === "validity") {
|
|
1335
|
+
if (response.status === 401 || response.status === 403) {
|
|
1336
|
+
const message = await readErrorMessage(response);
|
|
1337
|
+
throw pluginError(CODE.AUTH_ERROR, redactSecrets(message === "" ? "token rejected (HTTP 401/403)" : message));
|
|
1338
|
+
}
|
|
1339
|
+
if (response.status === 429) {
|
|
1340
|
+
const message = await readErrorMessage(response);
|
|
1341
|
+
const retryAfterMs = parseRetryAfterMs(response.headers, message);
|
|
1342
|
+
throw pluginError(CODE.RATE_LIMITED, redactSecrets(message === "" ? "modelscope returned 429 (validity probe)" : message), retryAfterMs === null ? {} : { retryAfterMs });
|
|
1343
|
+
}
|
|
1344
|
+
if (response.status >= 500) throw pluginError(CODE.UPSTREAM_ERROR, "modelscope returned HTTP " + String(response.status) + " (validity probe)");
|
|
1345
|
+
return {
|
|
1346
|
+
ok: true,
|
|
1347
|
+
kind,
|
|
1348
|
+
status: response.status,
|
|
1349
|
+
modelId,
|
|
1350
|
+
usage: null,
|
|
1351
|
+
elapsedMs
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
if (!response.ok) {
|
|
1355
|
+
const message = await readErrorMessage(response);
|
|
1356
|
+
if (response.status === 429) {
|
|
1357
|
+
const retryAfterMs = parseRetryAfterMs(response.headers, message);
|
|
1358
|
+
const triage = classifyRateLimit(message);
|
|
1359
|
+
const extra = retryAfterMs !== null && retryAfterMs !== void 0 ? { retryAfterMs } : {};
|
|
1360
|
+
throw pluginError(triage === "quota" ? CODE.QUOTA_EXCEEDED : CODE.RATE_LIMITED, redactSecrets(message === "" ? "modelscope returned 429 (probe on " + modelId + ")" : message), extra);
|
|
1361
|
+
}
|
|
1362
|
+
throw pluginError(classifyStatus(response.status), redactSecrets(message === "" ? "modelscope returned HTTP " + String(response.status) : message));
|
|
1363
|
+
}
|
|
1364
|
+
const json = await response.json().catch(() => null);
|
|
1365
|
+
const usage = json === null ? null : json.usage;
|
|
1366
|
+
const positive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
|
1367
|
+
return {
|
|
1368
|
+
ok: true,
|
|
1369
|
+
kind,
|
|
1370
|
+
status: response.status,
|
|
1371
|
+
modelId,
|
|
1372
|
+
usage: usage === void 0 || usage === null ? null : {
|
|
1373
|
+
promptTokens: positive(usage.prompt_tokens) ?? 0,
|
|
1374
|
+
completionTokens: positive(usage.completion_tokens) ?? 0,
|
|
1375
|
+
totalTokens: positive(usage.total_tokens) ?? 0
|
|
1376
|
+
},
|
|
1377
|
+
elapsedMs
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
//#endregion
|
|
1384
|
+
//#region src/host/provider-store.ts
|
|
1385
|
+
/**
|
|
1386
|
+
* The provider-registration switch AND its model allow-list — this plugin's OWN
|
|
1387
|
+
* state file, never the Host's configuration.
|
|
1388
|
+
*
|
|
1389
|
+
* Why a file at all: `registerProvider` in `cordis.patch.yml` is a DEPLOYMENT
|
|
1390
|
+
* default the operator edits with a reload, but the panel needs a live switch
|
|
1391
|
+
* that takes effect on the next request. The switch state therefore lives in
|
|
1392
|
+
* `$DSH_HOME/state/<plugin>/provider.json`, exactly like the plugin's other
|
|
1393
|
+
* state stores (`usage-store.ts`): operational state, not an operator decision
|
|
1394
|
+
* baked into the patch layer.
|
|
1395
|
+
*
|
|
1396
|
+
* The allow-list rides in the SAME file, not a second one: both fields answer
|
|
1397
|
+
* "what does THIS profile want the provider to offer", so they must be read and
|
|
1398
|
+
* written as one payload — two files (or two caches over one file) would let a
|
|
1399
|
+
* switch flip clobber a list save that landed a tick earlier.
|
|
1400
|
+
*
|
|
1401
|
+
* Precedence at read time:
|
|
1402
|
+
*
|
|
1403
|
+
* 1. a value SAVED FROM THE PANEL (enabled: true|false, enabledIds: [...])
|
|
1404
|
+
* always wins;
|
|
1405
|
+
* 2. no saved value (never touched, or the file was unreadable) falls back
|
|
1406
|
+
* to the patch's `registerProvider` for the switch, and to "no filter"
|
|
1407
|
+
* (empty list) for the allow-list.
|
|
1408
|
+
*
|
|
1409
|
+
* Integrity follows `state-store.ts`: a versioned payload, a temp file plus an
|
|
1410
|
+
* atomic rename (two Host processes can share the directory), owner-only
|
|
1411
|
+
* modes, and "anything unrecognised reads as not set" — a corrupted or
|
|
1412
|
+
* downgraded file costs one re-toggle, never a crash.
|
|
1413
|
+
*
|
|
1414
|
+
* The allow-list's empty-vs-absent distinction is the one this module guards:
|
|
1415
|
+
* an EMPTY list means "no filter, offer every model", while `HIDE_ALL_MODELS`
|
|
1416
|
+
* (`llm-models.ts`) is the caller-side sentinel for "offer nothing". Neither
|
|
1417
|
+
* spelling is stored here as `null` — absence of a saved list reads as "no
|
|
1418
|
+
* filter", which is the only safe default for a fresh install.
|
|
1419
|
+
*
|
|
1420
|
+
* @module dsh-connect-modelscope-token-plan/provider-store
|
|
1421
|
+
*/
|
|
1422
|
+
/** Shape version, bumped when the persisted form changes incompatibly. */
|
|
1423
|
+
const PROVIDER_VERSION = 1;
|
|
1424
|
+
/**
|
|
1425
|
+
* Every persisted shape THIS build can read: the current version plus any
|
|
1426
|
+
* historical ones. Bumping {@link PROVIDER_VERSION} means adding the new number
|
|
1427
|
+
* here too — otherwise this build would refuse its own newest files.
|
|
1428
|
+
*
|
|
1429
|
+
* This is the ADR-006 write-side guard's whitelist: an on-disk version not in
|
|
1430
|
+
* this list was written by a NEWER build, and must not be clobbered (see
|
|
1431
|
+
* {@link writePayload}).
|
|
1432
|
+
*/
|
|
1433
|
+
const KNOWN_PROVIDER_VERSIONS = [1];
|
|
1434
|
+
/**
|
|
1435
|
+
* The directory this plugin's state lives in — per-profile when the Host names
|
|
1436
|
+
* one, shared otherwise. Unlike the THROTTLE, which is deliberately shared
|
|
1437
|
+
* across profiles, this answers "does THIS profile want the provider registered"
|
|
1438
|
+
* and must not be overwritten by the other profile's Host.
|
|
1439
|
+
* @param {string|null} [profile] - the profile name; `null` means shared.
|
|
1440
|
+
* @returns {string} the directory.
|
|
1441
|
+
*/
|
|
1442
|
+
function providerDir(profile) {
|
|
1443
|
+
return profileStateDir(name, profile);
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Normalize an on/off switch: only booleans are real answers.
|
|
1447
|
+
* @param {unknown} raw - the persisted or posted value.
|
|
1448
|
+
* @returns {boolean|null} `true`/`false`, or `null` when nothing usable.
|
|
1449
|
+
*/
|
|
1450
|
+
function normalizeEnabled(raw) {
|
|
1451
|
+
return typeof raw === "boolean" ? raw : null;
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Normalize a persisted allow-list: an array of non-empty strings, de-duplicated
|
|
1455
|
+
* and order-preserved. Anything else reads as "no saved list".
|
|
1456
|
+
*
|
|
1457
|
+
* The de-dup matters: the route publishes this list straight into
|
|
1458
|
+
* `filterByEnabled`, and a list carrying one id twice would still filter
|
|
1459
|
+
* correctly, but the snapshot echoes `enabledCount` off it — counting a
|
|
1460
|
+
* duplicate twice is the kind of drift that takes a while to notice.
|
|
1461
|
+
* @param {unknown} raw - the persisted or posted value.
|
|
1462
|
+
* @returns {string[]|null} the list, or `null` when nothing usable.
|
|
1463
|
+
*/
|
|
1464
|
+
function normalizeEnabledIds(raw) {
|
|
1465
|
+
if (!Array.isArray(raw)) return null;
|
|
1466
|
+
const out = [];
|
|
1467
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1468
|
+
for (const entry of raw) {
|
|
1469
|
+
const id = typeof entry === "string" ? entry.trim() : "";
|
|
1470
|
+
if (id === "" || seen.has(id)) continue;
|
|
1471
|
+
seen.add(id);
|
|
1472
|
+
out.push(id);
|
|
1473
|
+
}
|
|
1474
|
+
return out;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* The file-backed provider switch + allow-list.
|
|
1478
|
+
* @param {object} [options]
|
|
1479
|
+
* @param {string} [options.dir] - override the state directory (tests).
|
|
1480
|
+
* @param {string|null} [options.profile] - the profile name; see {@link providerDir}.
|
|
1481
|
+
* @param {number} [options.ttlMs] - how long a parsed payload may be reused
|
|
1482
|
+
* before disk is consulted again; defaults to {@link STATE_READ_TTL_MS}.
|
|
1483
|
+
* @returns {object} the store.
|
|
1484
|
+
*/
|
|
1485
|
+
function createFileProviderStore(options = {}) {
|
|
1486
|
+
const { dir, profile = null, ttlMs = STATE_READ_TTL_MS, logger } = options;
|
|
1487
|
+
const stateDir$1 = dir ?? providerDir(profile);
|
|
1488
|
+
const filePath = join(stateDir$1, "provider.json");
|
|
1489
|
+
/**
|
|
1490
|
+
* Write one payload atomically to this file.
|
|
1491
|
+
*
|
|
1492
|
+
* The single writer for every caller (save / saveEnabledIds / forget / the
|
|
1493
|
+
* legacy adoption path) — several copies of this is exactly the drift this
|
|
1494
|
+
* module keeps getting bitten by.
|
|
1495
|
+
*
|
|
1496
|
+
* ADR-006 write-side guard: never overwrite a state file this build cannot
|
|
1497
|
+
* read. An unknown NUMERIC version means a NEWER build wrote it; clobbering
|
|
1498
|
+
* it destroys data we cannot even see. So the write is refused with a
|
|
1499
|
+
* `degrade` signal, not a silent no-op — swallow the failure, not the reason.
|
|
1500
|
+
* @param {object} body - the JSON body to persist.
|
|
1501
|
+
* @returns {Promise<string|null>} the refusal reason when the write was
|
|
1502
|
+
* refused (already `degrade`-logged), or `null` when it landed — the caller
|
|
1503
|
+
* decides whether to surface the refusal to a user.
|
|
1504
|
+
*/
|
|
1505
|
+
const writePayload = async (body) => {
|
|
1506
|
+
const existing = await readStateVersion(filePath);
|
|
1507
|
+
if (!isKnownStateVersion(existing, KNOWN_PROVIDER_VERSIONS)) {
|
|
1508
|
+
const reason = `provider: refusing to overwrite provider.json holding version ${existing} (this build knows ${KNOWN_PROVIDER_VERSIONS.join("/")})`;
|
|
1509
|
+
degrade(reason, null, logger, null);
|
|
1510
|
+
return reason;
|
|
1511
|
+
}
|
|
1512
|
+
const temporary = temporaryOf(stateDir$1, "provider.json");
|
|
1513
|
+
await ensureStateDir(stateDir$1);
|
|
1514
|
+
await writeStateFile(filePath, JSON.stringify(body, null, 2), { temporary });
|
|
1515
|
+
return null;
|
|
1516
|
+
};
|
|
1517
|
+
const legacyFile = dir === void 0 && profile ? join(stateDir(name), "provider.json") : null;
|
|
1518
|
+
/** Read ONE payload off the disk, shape-checked. */
|
|
1519
|
+
const parsePayload = async () => {
|
|
1520
|
+
const raw = await readStateJson(filePath);
|
|
1521
|
+
const source = obj(raw);
|
|
1522
|
+
if (source.version !== 1) return {
|
|
1523
|
+
enabled: null,
|
|
1524
|
+
enabledIds: null
|
|
1525
|
+
};
|
|
1526
|
+
return {
|
|
1527
|
+
enabled: normalizeEnabled(source.enabled),
|
|
1528
|
+
enabledIds: normalizeEnabledIds(source.enabledIds)
|
|
1529
|
+
};
|
|
1530
|
+
};
|
|
1531
|
+
/**
|
|
1532
|
+
* Read one payload, then hand it to `write` — the read-modify-write that keeps
|
|
1533
|
+
* the switch and the list from clobbering each other.
|
|
1534
|
+
*
|
|
1535
|
+
* Three-state keys, and they are SYMMETRIC between `enabled` and `enabledIds`:
|
|
1536
|
+
* a key the caller omits (`undefined`) keeps whatever is on disk, an explicit
|
|
1537
|
+
* `null` clears it back to "not set" (the ABSENCE of the key — that is how
|
|
1538
|
+
* {@link forget} expresses "fall back to the config default"), and any other
|
|
1539
|
+
* value is stored as given. An earlier `mergeEnabled` flag applied the
|
|
1540
|
+
* "keep what is on disk" rule to `enabled` unconditionally, which silently
|
|
1541
|
+
* discarded the very value a switch save was asked to store.
|
|
1542
|
+
* @param {object} patch - `{ enabled?, enabledIds? }` to persist.
|
|
1543
|
+
* @returns {Promise<string|null>} the write refusal reason, or `null`.
|
|
1544
|
+
*/
|
|
1545
|
+
/**
|
|
1546
|
+
* 读-改-写**整段排队**:两个并发patch 不能互相覆盖。
|
|
1547
|
+
*
|
|
1548
|
+
* 这个 store 以前完全没有写串行化(对比 `usage-store.ts` 的 `writeChain`),于是
|
|
1549
|
+
* 面板开关POST 与 roster POST —— 两个独立请求,外加 `index.ts` 轮询同时在调
|
|
1550
|
+
* `enabledIds()` —— 会各自读到同一个 `current`,后写的赢。实测:先存清单,再并发
|
|
1551
|
+
* `save(true)` + `saveEnabledIds([...])`,其中一次保存被**整个丢掉**(用户勾了
|
|
1552
|
+
* 模型又开了开关,两次点击只活下来一次)。
|
|
1553
|
+
*
|
|
1554
|
+
* 文件头说「两个字段必须作为一个载荷读写,否则开关会覆盖清单」——设计意图是对的,
|
|
1555
|
+
* 但那只防住了「**不同字段**互相覆盖」;缺了串行化,**同字段**与「两次都基于同一
|
|
1556
|
+
* 快照」的互相覆盖照样发生。链的形状与 `usage-store` 一致,且必须把
|
|
1557
|
+
* `writePayload` 的版本拒绝语义一起搬进临界区(否则拒绝判断会落在锁外)。
|
|
1558
|
+
*
|
|
1559
|
+
* 链吞掉自己的失败继续走:一次只读 Home 上的写失败不该毒化后续所有保存。
|
|
1560
|
+
*/
|
|
1561
|
+
let writeChain = Promise.resolve(null);
|
|
1562
|
+
const patchPayload = (patch) => {
|
|
1563
|
+
const run = writeChain.then(async () => {
|
|
1564
|
+
const current = await parsePayload();
|
|
1565
|
+
/** `undefined` keeps the stored key; `null` clears it (key absent). */
|
|
1566
|
+
const body = { version: 1 };
|
|
1567
|
+
const enabled = patch.enabled === void 0 ? current.enabled : patch.enabled;
|
|
1568
|
+
if (enabled !== null) body.enabled = enabled;
|
|
1569
|
+
const enabledIds = patch.enabledIds === void 0 ? current.enabledIds : patch.enabledIds;
|
|
1570
|
+
if (enabledIds !== null) body.enabledIds = enabledIds;
|
|
1571
|
+
body.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1572
|
+
return writePayload(body);
|
|
1573
|
+
});
|
|
1574
|
+
writeChain = run.catch(() => null);
|
|
1575
|
+
return run;
|
|
1576
|
+
};
|
|
1577
|
+
const cache = createStateReadCache(async () => {
|
|
1578
|
+
const own = await parsePayload();
|
|
1579
|
+
if (own.enabled !== null || own.enabledIds !== null || legacyFile === null) return own;
|
|
1580
|
+
const legacy = await parsePayloadFrom(legacyFile);
|
|
1581
|
+
if (legacy.enabled === null && legacy.enabledIds === null) return own;
|
|
1582
|
+
const inherited = {
|
|
1583
|
+
version: 1,
|
|
1584
|
+
...legacy.enabled === null ? {} : { enabled: legacy.enabled },
|
|
1585
|
+
...legacy.enabledIds === null ? {} : { enabledIds: legacy.enabledIds },
|
|
1586
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1587
|
+
};
|
|
1588
|
+
await writeChain.then(() => writePayload(inherited).then(() => void 0, () => void 0), () => writePayload(inherited).then(() => void 0, () => void 0));
|
|
1589
|
+
return legacy;
|
|
1590
|
+
}, { ttlMs });
|
|
1591
|
+
const read = () => cache.read();
|
|
1592
|
+
/**
|
|
1593
|
+
* The payload the callers actually read. The short-TTL cache's `read()` is
|
|
1594
|
+
* typed `Promise<T | null>` because it doubles as "never read" signalling for
|
|
1595
|
+
* the §23 legacy adoption; a provider payload is always an object, so the
|
|
1596
|
+
* `null` branch collapses here, in one place, rather than at every caller.
|
|
1597
|
+
* @returns {Promise<ProviderPayload>} the payload; `{enabled:null,enabledIds:null}` when nothing is stored.
|
|
1598
|
+
*/
|
|
1599
|
+
const readPayload = async () => await read() ?? {
|
|
1600
|
+
enabled: null,
|
|
1601
|
+
enabledIds: null
|
|
1602
|
+
};
|
|
1603
|
+
/** Read one payload off an explicit path (the legacy shared file). */
|
|
1604
|
+
const parsePayloadFrom = async (file) => {
|
|
1605
|
+
const raw = await readStateJson(file);
|
|
1606
|
+
const source = obj(raw);
|
|
1607
|
+
if (source.version !== 1) return {
|
|
1608
|
+
enabled: null,
|
|
1609
|
+
enabledIds: null
|
|
1610
|
+
};
|
|
1611
|
+
return {
|
|
1612
|
+
enabled: normalizeEnabled(source.enabled),
|
|
1613
|
+
enabledIds: normalizeEnabledIds(source.enabledIds)
|
|
1614
|
+
};
|
|
1615
|
+
};
|
|
1616
|
+
return {
|
|
1617
|
+
/**
|
|
1618
|
+
* The saved switch value.
|
|
1619
|
+
* @returns {Promise<boolean|null>} `null` = not set, fall back to config.
|
|
1620
|
+
*/
|
|
1621
|
+
async enabled() {
|
|
1622
|
+
return (await readPayload()).enabled;
|
|
1623
|
+
},
|
|
1624
|
+
/**
|
|
1625
|
+
* The saved model allow-list.
|
|
1626
|
+
* @returns {Promise<string[]>} the saved ids; an EMPTY list means "no
|
|
1627
|
+
* filter, offer every model" (also the answer for a never-saved list).
|
|
1628
|
+
*/
|
|
1629
|
+
async enabledIds() {
|
|
1630
|
+
return (await readPayload()).enabledIds ?? [];
|
|
1631
|
+
},
|
|
1632
|
+
/**
|
|
1633
|
+
* Whether the panel has ever saved a value here.
|
|
1634
|
+
* @returns {Promise<boolean>}
|
|
1635
|
+
*/
|
|
1636
|
+
async isSet() {
|
|
1637
|
+
const payload = await readPayload();
|
|
1638
|
+
return payload.enabled !== null || payload.enabledIds !== null;
|
|
1639
|
+
},
|
|
1640
|
+
/**
|
|
1641
|
+
* Persist a switch value. The write is atomic (temp file + rename) so a
|
|
1642
|
+
* concurrent reader never sees a partial payload, and it PRESERVES the
|
|
1643
|
+
* saved allow-list.
|
|
1644
|
+
* @param {boolean} value - the new switch state.
|
|
1645
|
+
* @returns {Promise<void>}
|
|
1646
|
+
*/
|
|
1647
|
+
async save(value) {
|
|
1648
|
+
const enabled = normalizeEnabled(value);
|
|
1649
|
+
if (enabled === null) throw new TypeError("provider switch expects a boolean");
|
|
1650
|
+
const refusal = await patchPayload({ enabled });
|
|
1651
|
+
if (refusal !== null) throw new Error(refusal);
|
|
1652
|
+
cache.remember({
|
|
1653
|
+
enabled,
|
|
1654
|
+
enabledIds: (await readPayload()).enabledIds ?? []
|
|
1655
|
+
});
|
|
1656
|
+
},
|
|
1657
|
+
/**
|
|
1658
|
+
* Persist the model allow-list, preserving the saved switch.
|
|
1659
|
+
* @param {string[]} ids - the ids to offer (empty = offer all).
|
|
1660
|
+
* @returns {Promise<void>}
|
|
1661
|
+
*/
|
|
1662
|
+
async saveEnabledIds(ids) {
|
|
1663
|
+
const list = normalizeEnabledIds(ids);
|
|
1664
|
+
if (list === null) throw new TypeError("provider allow-list expects an array of strings");
|
|
1665
|
+
const refusal = await patchPayload({ enabledIds: list });
|
|
1666
|
+
if (refusal !== null) throw new Error(refusal);
|
|
1667
|
+
cache.remember({
|
|
1668
|
+
enabled: (await readPayload()).enabled ?? null,
|
|
1669
|
+
enabledIds: list
|
|
1670
|
+
});
|
|
1671
|
+
},
|
|
1672
|
+
/**
|
|
1673
|
+
* Forget the panel-saved values: the config default and "no filter" rule
|
|
1674
|
+
* again.
|
|
1675
|
+
* @returns {Promise<void>}
|
|
1676
|
+
*/
|
|
1677
|
+
async forget() {
|
|
1678
|
+
const refusal = await patchPayload({
|
|
1679
|
+
enabled: null,
|
|
1680
|
+
enabledIds: null
|
|
1681
|
+
});
|
|
1682
|
+
if (refusal !== null) throw new Error(refusal);
|
|
1683
|
+
cache.remember({
|
|
1684
|
+
enabled: null,
|
|
1685
|
+
enabledIds: null
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
//#endregion
|
|
1692
|
+
//#region src/host/switch-precedence.ts
|
|
1693
|
+
/**
|
|
1694
|
+
* The single adjudicator for "panel-saved value vs config default" across
|
|
1695
|
+
* every opt-in switch.
|
|
1696
|
+
*
|
|
1697
|
+
* Historically the rule was hand-copied at three call sites (the provider
|
|
1698
|
+
* route, the models route, the draw route) in two look-alike dialects:
|
|
1699
|
+
*
|
|
1700
|
+
* - the boolean switch: `(panel ?? config) === true` plus
|
|
1701
|
+
* `panel === null ? "config" : "panel"`;
|
|
1702
|
+
* - the model preference: `panel ?? config` plus the same source probe.
|
|
1703
|
+
*
|
|
1704
|
+
* A fourth dialect existed where no config default exists at all (a switch that
|
|
1705
|
+
* is purely panel-owned — a profile without a saved value falls to `off` with no
|
|
1706
|
+
* fallback), which is exactly the shape this module does NOT serve: that one has
|
|
1707
|
+
* no config default to adjudicate against, so it is the caller's plain read.
|
|
1708
|
+
*
|
|
1709
|
+
* This module is peer-free and deliberately tiny: the whole point is that a
|
|
1710
|
+
* switch can never again invent its own precedence dialect, and the source
|
|
1711
|
+
* label (`panel` / `config`) always rides with the answer so the panel can
|
|
1712
|
+
* say which side is in charge. `test/switch-precedence.test.mjs` pins the
|
|
1713
|
+
* dialect so a new caller copying the shape is the odd one out.
|
|
1714
|
+
*
|
|
1715
|
+
* 注:本模块自带的 `test/switch-precedence.test.mjs` 钉死纯函数行为;部分旧注释里
|
|
1716
|
+
* 提到的 `test/peer-contract.test.mjs`(钉死真 peer 的 classifyPiAiError /
|
|
1717
|
+
* resolveRetryPolicy 协议)依赖未 vendored 进本仓库的运行时 peer,不在 `npm test`
|
|
1718
|
+
* 离线门禁内。
|
|
1719
|
+
*
|
|
1720
|
+
* @module dsh-connect-modelscope-token-plan/switch-precedence
|
|
1721
|
+
*/
|
|
1722
|
+
/**
|
|
1723
|
+
* Resolve the effective boolean switch: a panel-saved value always wins,
|
|
1724
|
+
* otherwise the config default rules.
|
|
1725
|
+
* @param {boolean|null} panel - the panel-saved value (`null` = never saved).
|
|
1726
|
+
* @param {boolean} config - the patch-declared default.
|
|
1727
|
+
* @returns {boolean} the effective switch.
|
|
1728
|
+
*/
|
|
1729
|
+
function resolveSwitchEnabled(panel, config) {
|
|
1730
|
+
return (panel ?? config) === true;
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Where the effective value came from — the panel when it saved one, the
|
|
1734
|
+
* config otherwise. Rides with every resolved answer so the panel can name
|
|
1735
|
+
* the side in charge.
|
|
1736
|
+
* @param {boolean|string|null} panel - the panel-saved value.
|
|
1737
|
+
* @returns {"panel"|"config"}
|
|
1738
|
+
*/
|
|
1739
|
+
function switchSource(panel) {
|
|
1740
|
+
return panel === null ? "config" : "panel";
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
//#endregion
|
|
1744
|
+
//#region src/host/publish-core.ts
|
|
1745
|
+
/**
|
|
1746
|
+
* The shared bones of a provider publisher — the parts that MUST NOT differ
|
|
1747
|
+
* between upstreams, factored out so they cannot drift.
|
|
1748
|
+
*
|
|
1749
|
+
* The publisher (`provider-publish.ts` for the ModelScope provider) runs the
|
|
1750
|
+
* same control plane: a publish queue, a `disposed` gate, and a single-point
|
|
1751
|
+
* pair registration that doubles as the rollback path. Those three are
|
|
1752
|
+
* load-bearing and pinned by tests — and the rollback is the worst place to
|
|
1753
|
+
* discover a divergence, because it only runs once something has already failed.
|
|
1754
|
+
* When they were written twice, keeping them in sync relied on comments in one
|
|
1755
|
+
* file pointing at the other; here there is one copy.
|
|
1756
|
+
*
|
|
1757
|
+
* What is deliberately NOT shared: the publish GATE and the state shape. The
|
|
1758
|
+
* ModelScope side decides from "switch on?" plus a persisted catalog and an
|
|
1759
|
+
* allow-list, and restores `entries`/`enabledIds`/quota ids on a rollback;
|
|
1760
|
+
* another provider's publisher decides from "switch on?" plus "is there a
|
|
1761
|
+
* credential?" and restores its roster. That difference is real domain
|
|
1762
|
+
* difference, and folding it into one parameterized state machine would make a
|
|
1763
|
+
* publish unreadable — every reader would have to read the configuration to
|
|
1764
|
+
* know what one does.
|
|
1765
|
+
*
|
|
1766
|
+
* Peer-free: touches no runtime peer.
|
|
1767
|
+
*
|
|
1768
|
+
* @module dsh-connect-modelscope-token-plan/publish-core
|
|
1769
|
+
*/
|
|
1770
|
+
/** The event a successful (re)registration emits so readers refresh. */
|
|
1771
|
+
const ADAPTERS_UPDATED_EVENT = "llm/adapters-updated";
|
|
1772
|
+
/** The error a publisher reports when the Host exposes no `llm` service. */
|
|
1773
|
+
const NO_LLM_SERVICE_ERROR = "the Host exposes no llm registration service";
|
|
1774
|
+
/** The shape check message both publishers raise on a bad factory result. */
|
|
1775
|
+
const BAD_FACTORY_SHAPE_ERROR = "the adapter factory did not return { adapter, providerIds }";
|
|
1776
|
+
/**
|
|
1777
|
+
* A publish queue: every publish runs after all the ones in flight.
|
|
1778
|
+
*
|
|
1779
|
+
* Concurrent publishes are not hypothetical — a mount seed can still be
|
|
1780
|
+
* mid-flight when the first panel poll publishes the catalog it just fetched,
|
|
1781
|
+
* and a switch flip or a logout can land on top of either. Two publishes
|
|
1782
|
+
* interleaving means the SLOWER one wins: it releases the pair the faster one
|
|
1783
|
+
* registered and then registers its own, so the Host serves a stale (possibly
|
|
1784
|
+
* empty) set while the snapshot reports the fresh one.
|
|
1785
|
+
*
|
|
1786
|
+
* The chain is the same shape `token-store.ts` uses for `getToken`: no lock
|
|
1787
|
+
* object, and a rejected link never poisons the ones behind it.
|
|
1788
|
+
* @returns {{
|
|
1789
|
+
* enqueue: (task: () => Promise<unknown>) => Promise<unknown>,
|
|
1790
|
+
* isDisposed: () => boolean,
|
|
1791
|
+
* dispose: () => void
|
|
1792
|
+
* }}
|
|
1793
|
+
*/
|
|
1794
|
+
function createPublishQueue() {
|
|
1795
|
+
let publishChain = Promise.resolve();
|
|
1796
|
+
let disposed = false;
|
|
1797
|
+
return {
|
|
1798
|
+
/**
|
|
1799
|
+
* Queue `task` behind everything in flight and resolve with its result.
|
|
1800
|
+
* @param {() => Promise<unknown>} task - the publish to run, in turn.
|
|
1801
|
+
* @returns {Promise<unknown>} the task's own settled value.
|
|
1802
|
+
*/
|
|
1803
|
+
enqueue(task) {
|
|
1804
|
+
const queued = publishChain.then(task, task);
|
|
1805
|
+
publishChain = queued.then(() => void 0, () => void 0);
|
|
1806
|
+
return queued;
|
|
1807
|
+
},
|
|
1808
|
+
/**
|
|
1809
|
+
* Whether the publisher has been disposed. A publish that arrives after
|
|
1810
|
+
* dispose registers a provider into a Host that has already withdrawn the
|
|
1811
|
+
* plugin: no owner, no release, nothing on screen.
|
|
1812
|
+
* @returns {boolean}
|
|
1813
|
+
*/
|
|
1814
|
+
isDisposed: () => disposed,
|
|
1815
|
+
/** Mark the publisher disposed; every later publish becomes a no-op. */
|
|
1816
|
+
dispose() {
|
|
1817
|
+
disposed = true;
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
/**
|
|
1822
|
+
* Build the releaser for one publisher's `state`.
|
|
1823
|
+
*
|
|
1824
|
+
* Releases are idempotent in the Host, and a release that throws must not
|
|
1825
|
+
* abort the one behind it — during shutdown or a rollback the service may
|
|
1826
|
+
* already be gone.
|
|
1827
|
+
* @param {object} state - the publisher state holding the release functions.
|
|
1828
|
+
* @returns {() => void} release, safe to call any number of times.
|
|
1829
|
+
*/
|
|
1830
|
+
function createPairReleaser(state) {
|
|
1831
|
+
return () => {
|
|
1832
|
+
const releaseFn = (fn) => {
|
|
1833
|
+
try {
|
|
1834
|
+
fn?.();
|
|
1835
|
+
} catch {}
|
|
1836
|
+
};
|
|
1837
|
+
releaseFn(state.releaseAdapter);
|
|
1838
|
+
releaseFn(state.releaseDirectory);
|
|
1839
|
+
state.releaseAdapter = null;
|
|
1840
|
+
state.releaseDirectory = null;
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Hand one built adapter to the `llm` service and record its release
|
|
1845
|
+
* functions onto `target`.
|
|
1846
|
+
*
|
|
1847
|
+
* Defined ONCE because the publish path and the rollback path both register a
|
|
1848
|
+
* pair, and two copies drift: a change to the directory row made in one place
|
|
1849
|
+
* and not the other leaves the ROLLBACK registering a provider the publish
|
|
1850
|
+
* path would never have built — and a rollback only runs once something has
|
|
1851
|
+
* already gone wrong, which is the worst possible moment to find out.
|
|
1852
|
+
*
|
|
1853
|
+
* The releases are written straight onto `target` rather than returned: if the
|
|
1854
|
+
* directory call throws AFTER the adapter was registered, the adapter's
|
|
1855
|
+
* release must still be reachable, or `release()` cannot undo it and the
|
|
1856
|
+
* adapter outlives the plugin.
|
|
1857
|
+
* @param {object} llm - the registration service.
|
|
1858
|
+
* @param {{providerIds: string[], adapter: unknown}} built - what to register.
|
|
1859
|
+
* @param {object} target - where the release functions are recorded.
|
|
1860
|
+
* @param {object} identity - the provider's row on the models settings page.
|
|
1861
|
+
* @param {string} identity.providerId
|
|
1862
|
+
* @param {string} identity.displayName
|
|
1863
|
+
* @returns {void}
|
|
1864
|
+
*/
|
|
1865
|
+
function registerProviderPair(llm, built, target, { providerId, displayName }) {
|
|
1866
|
+
target.releaseAdapter = llm.registerAdapter(built.providerIds, built.adapter);
|
|
1867
|
+
target.releaseDirectory = typeof llm.registerConfigurableProviders === "function" ? llm.registerConfigurableProviders([{
|
|
1868
|
+
provider: providerId,
|
|
1869
|
+
displayName,
|
|
1870
|
+
settingsNs: name,
|
|
1871
|
+
settingsPath: [],
|
|
1872
|
+
declared: false
|
|
1873
|
+
}]) : null;
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Memoize the peer-dependent adapter factory for one publisher.
|
|
1877
|
+
*
|
|
1878
|
+
* The module is loaded once and the factory is read off it once, so a Host
|
|
1879
|
+
* whose peers resolve slowly pays that cost one time, not per publish.
|
|
1880
|
+
* @param {() => Promise<object>} loadModule - resolves the adapter module.
|
|
1881
|
+
* @param {string} exportName - the factory export to read off the module.
|
|
1882
|
+
* @returns {() => Promise<Function>} the memoized factory resolver.
|
|
1883
|
+
*/
|
|
1884
|
+
function createAdapterFactoryResolver(loadModule, exportName) {
|
|
1885
|
+
let adapterFactoryPromise;
|
|
1886
|
+
return async () => {
|
|
1887
|
+
if (adapterFactoryPromise === void 0) adapterFactoryPromise = Promise.resolve(loadModule()).then((mod) => mod?.[exportName]);
|
|
1888
|
+
return adapterFactoryPromise;
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Verify a built adapter is really one before it is registered Host-wide.
|
|
1893
|
+
*
|
|
1894
|
+
* An adapter is registered Host-wide, so a factory that returns anything else
|
|
1895
|
+
* must fail here rather than publish a provider that cannot serve a request.
|
|
1896
|
+
* @param {unknown} built - what the factory returned.
|
|
1897
|
+
* @returns {boolean} whether it is `{ adapter, providerIds }`.
|
|
1898
|
+
*/
|
|
1899
|
+
function isBuiltAdapter(built) {
|
|
1900
|
+
return built !== null && typeof built === "object" && Array.isArray(built.providerIds) && built.adapter !== void 0;
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* Turn a build failure into a secret-free note plus a hint.
|
|
1904
|
+
*
|
|
1905
|
+
* A credential never reaches the panel or a log. The failure a reader cannot
|
|
1906
|
+
* diagnose from the message alone: the llm peer packages ship INSIDE the Host,
|
|
1907
|
+
* so a plugin directory the Host's node_modules cannot be reached from — a dev
|
|
1908
|
+
* checkout symlinked into the profile, say — has no way to import them. Say
|
|
1909
|
+
* so, with the remedy, because the panel can only report "provider absent".
|
|
1910
|
+
* @param {unknown} error - the thrown value (may not be an Error at all).
|
|
1911
|
+
* @returns {{note: string, hint: string, error: unknown}} the redacted message,
|
|
1912
|
+
* the optional remedy suffix, and the original value for re-raising.
|
|
1913
|
+
*/
|
|
1914
|
+
function describeBuildFailure(error) {
|
|
1915
|
+
const annotated = error;
|
|
1916
|
+
const why = errMsg(error);
|
|
1917
|
+
return {
|
|
1918
|
+
note: redactSecrets(why),
|
|
1919
|
+
hint: annotated?.code === "ERR_MODULE_NOT_FOUND" ? " — the llm peer packages ship with the Host; install this plugin where they resolve (or link them into its own node_modules)" : "",
|
|
1920
|
+
error
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Log one build failure the way both publishers log it.
|
|
1925
|
+
* @param {object} [logger] - `ctx.logger`.
|
|
1926
|
+
* @param {string} label - the upstream's name for the message ("ModelScope").
|
|
1927
|
+
* @param {{note: string, hint: string}} described - from `describeBuildFailure`.
|
|
1928
|
+
* @returns {void}
|
|
1929
|
+
*/
|
|
1930
|
+
function warnBuildFailure(logger, label, described) {
|
|
1931
|
+
logger?.warn?.(`${name}: cannot build the ${label} adapter: ${described.note}${described.hint}`);
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* Resolve the `llm` registration service, or bail out with the pair released.
|
|
1935
|
+
*
|
|
1936
|
+
* Both upstreams answer the same question the same way, and the answer is a
|
|
1937
|
+
* fact about the Host — not about the provider — so it is one copy: a Host
|
|
1938
|
+
* with no `llm` service gets no registration and a stated reason, never a
|
|
1939
|
+
* half-built one.
|
|
1940
|
+
* @param {object} job
|
|
1941
|
+
* @param {object} job.state - the publisher state.
|
|
1942
|
+
* @param {(service: string) => object|null} job.getLlm - the service resolver.
|
|
1943
|
+
* @param {() => void} job.release - the publisher's releaser.
|
|
1944
|
+
* @returns {object|null} the service, or null when it cannot register.
|
|
1945
|
+
*/
|
|
1946
|
+
function resolveRegistrationService({ state, getLlm, release }) {
|
|
1947
|
+
const llm = getLlm("llm");
|
|
1948
|
+
state.llmAvailable = llm !== null && typeof llm.registerAdapter === "function";
|
|
1949
|
+
if (!state.llmAvailable) {
|
|
1950
|
+
release();
|
|
1951
|
+
state.registered = false;
|
|
1952
|
+
state.error = NO_LLM_SERVICE_ERROR;
|
|
1953
|
+
return null;
|
|
1954
|
+
}
|
|
1955
|
+
return llm;
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Take the registration down and record why it is gone.
|
|
1959
|
+
*
|
|
1960
|
+
* `state.built` is cleared along with it, and that matters: a stale `built`
|
|
1961
|
+
* survives into the NEXT publish as its rollback target, so a later failed
|
|
1962
|
+
* publish would re-register an adapter whose release has already been called.
|
|
1963
|
+
* @param {object} job
|
|
1964
|
+
* @param {object} job.state - the publisher state.
|
|
1965
|
+
* @param {() => void} job.release - the publisher's releaser.
|
|
1966
|
+
* @param {string|null} [job.error] - why nothing is registered; null when the
|
|
1967
|
+
* absence is the wanted state (switch off).
|
|
1968
|
+
* @returns {{ok: boolean, skipped: boolean}} the publish outcome.
|
|
1969
|
+
*/
|
|
1970
|
+
function unregister({ state, release, error = null }) {
|
|
1971
|
+
release();
|
|
1972
|
+
state.registered = false;
|
|
1973
|
+
state.built = null;
|
|
1974
|
+
state.error = error;
|
|
1975
|
+
return {
|
|
1976
|
+
ok: true,
|
|
1977
|
+
skipped: true
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Swap the registered pair: take down the old one, register the new one, and
|
|
1982
|
+
* restore the OLD one if the new registration throws.
|
|
1983
|
+
*
|
|
1984
|
+
* This is the other half of the single-point `registerPair` discipline, and the
|
|
1985
|
+
* reason it is shared: the rollback is the path that only runs once something
|
|
1986
|
+
* has already gone wrong. A registration that fails AFTER the old pair was
|
|
1987
|
+
* released must put the previous one back, or a bad publish takes down models
|
|
1988
|
+
* that were already serving. Written twice, the copies drift; written once, a fix
|
|
1989
|
+
* to the rollback reaches every publisher.
|
|
1990
|
+
*
|
|
1991
|
+
* @param {object} job
|
|
1992
|
+
* @param {object} job.llm - the registration service.
|
|
1993
|
+
* @param {{providerIds: string[], adapter: unknown}} job.built - the new pair.
|
|
1994
|
+
* @param {unknown} job.previousBuilt - the pair that was serving, or null.
|
|
1995
|
+
* @param {object} job.state - the publisher state to record onto.
|
|
1996
|
+
* @param {() => void} job.release - the publisher's releaser.
|
|
1997
|
+
* @param {(llm: object, built: object, target: object) => void} job.registerPair -
|
|
1998
|
+
* the single-point registrar.
|
|
1999
|
+
* @param {(event: string) => void | undefined} job.emit - the Host's raw
|
|
2000
|
+
* `ctx.emit`, if it has one. Optional so the absent case is handled once, in
|
|
2001
|
+
* {@link emitAdaptersUpdated}, rather than by every call site inventing a
|
|
2002
|
+
* no-op stand-in before it gets there.
|
|
2003
|
+
* @param {() => void} [job.onRollback] - restore the domain snapshot fields
|
|
2004
|
+
* (`entries`/`enabledIds`, or the roster) to what is really serving.
|
|
2005
|
+
* @returns {{ok: boolean, error?: unknown}} the publish outcome.
|
|
2006
|
+
*/
|
|
2007
|
+
function swapRegistration({ llm, built, previousBuilt, state, release, registerPair, emit, onRollback }) {
|
|
2008
|
+
release();
|
|
2009
|
+
try {
|
|
2010
|
+
registerPair(llm, built, state);
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
release();
|
|
2013
|
+
state.built = null;
|
|
2014
|
+
onRollback?.();
|
|
2015
|
+
state.error = redactSecrets(errMsg(error));
|
|
2016
|
+
if (previousBuilt !== null) try {
|
|
2017
|
+
registerPair(llm, previousBuilt, state);
|
|
2018
|
+
state.built = previousBuilt;
|
|
2019
|
+
state.registered = true;
|
|
2020
|
+
} catch {
|
|
2021
|
+
state.built = null;
|
|
2022
|
+
state.registered = false;
|
|
2023
|
+
}
|
|
2024
|
+
else state.registered = false;
|
|
2025
|
+
return {
|
|
2026
|
+
ok: false,
|
|
2027
|
+
error
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
state.built = built;
|
|
2031
|
+
state.registered = true;
|
|
2032
|
+
state.error = null;
|
|
2033
|
+
emitAdaptersUpdated(emit);
|
|
2034
|
+
return { ok: true };
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Emit the adapter-update event, tolerating a Host that has no `emit` and a
|
|
2038
|
+
* Host whose `emit` refuses.
|
|
2039
|
+
*
|
|
2040
|
+
* This is the ONE place the emit path is defended, and it absorbs both halves
|
|
2041
|
+
* of what used to be three layers. The assembly point (`index.ts`) used to wrap
|
|
2042
|
+
* `ctx.emit` in its own `try { ctx.emit?.(event) } catch {}`, and each
|
|
2043
|
+
* publisher then defaulted a missing `emit` to a no-op
|
|
2044
|
+
* (`emit ?? (() => {})`) before handing it here — where a third `try/catch`
|
|
2045
|
+
* waited. The middle layer could never do anything: the layer above it had
|
|
2046
|
+
* already swallowed every throw, so the no-op fallback was unreachable except
|
|
2047
|
+
* for a literal `undefined`, which the outer `?.` had already covered too.
|
|
2048
|
+
*
|
|
2049
|
+
* @param {((event: string) => void) | undefined} emit - the Host's raw
|
|
2050
|
+
* `ctx.emit`, or `undefined` on a Host that has none.
|
|
2051
|
+
* @returns {void}
|
|
2052
|
+
*/
|
|
2053
|
+
function emitAdaptersUpdated(emit) {
|
|
2054
|
+
try {
|
|
2055
|
+
emit?.(ADAPTERS_UPDATED_EVENT);
|
|
2056
|
+
} catch {}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
//#endregion
|
|
2060
|
+
//#region src/host/provider-publish.ts
|
|
2061
|
+
/**
|
|
2062
|
+
* 魔搭 provider 的**发布状态机**——接入 DSH 的 control-plane 半边。
|
|
2063
|
+
*
|
|
2064
|
+
* 三条承重语义,每条都被一条必须继续绿的测试钉住:
|
|
2065
|
+
* - publish 队列把每一次 publish 排到所有在途 publish 之后,慢 publish 不能被
|
|
2066
|
+
* 快 publish 覆盖(队列语义见 `publish-core.ts` 的 `createPublishQueue`);
|
|
2067
|
+
* - `disposed` 闸拦住 dispose 之后才到的 publish,不让它注册进一个已经撤下本
|
|
2068
|
+
* 插件的 Host;
|
|
2069
|
+
* - 单点 `registerPair`(带 factory-await + shape 检查)被 publish 与 rollback
|
|
2070
|
+
* 两条路共用;失败时**恢复旧对**,坏 publish 不会把已在服务的模型也拉下来。
|
|
2071
|
+
*
|
|
2072
|
+
* 与姊妹插件的差异**只有**:工厂导出名 `"createModelScopeAdapter"`、`registerPair`
|
|
2073
|
+
* 用的 `LLM_PROVIDER_ID`/`LLM_DISPLAY_NAME`、`warnBuildFailure` 的 label
|
|
2074
|
+
* `"ModelScope"`。上面的四条语义原样保留——它们是 `publish-core.ts` 的骨头。
|
|
2075
|
+
*
|
|
2076
|
+
* peer-free:不 import 任何运行时 peer。适配器工厂由调用方注入
|
|
2077
|
+
* (`loadAdapterModule`,默认 `import("./llm-adapter.ts")`),所以离线套件可以换
|
|
2078
|
+
* 一个假工厂而不碰 Host 的 node_modules。
|
|
2079
|
+
*
|
|
2080
|
+
* 跨切面行为指路:目录变化 / 开关翻转 / 清单保存 → 重注册 = `index.ts` 轮询
|
|
2081
|
+
* publish + provider 路由 handler。
|
|
2082
|
+
*
|
|
2083
|
+
* @module dsh-connect-modelscope-token-plan/provider-publish
|
|
2084
|
+
*/
|
|
2085
|
+
/**
|
|
2086
|
+
* provider 发布器。
|
|
2087
|
+
*
|
|
2088
|
+
* 持有活注册状态(`state`);publish 队列、`disposed` 闸与单点 `registerPair` 是
|
|
2089
|
+
* `publish-core.ts` 共享的骨头。调用方从挂载 seed、目录轮询、provider 开关、roster
|
|
2090
|
+
* 保存来驱动 `publish`,从 `ctx.effect` 的 teardown 调 `dispose`。
|
|
2091
|
+
*
|
|
2092
|
+
* `getLlm` 解析器是**函数**不是快照:`llm` 服务可能在本插件挂载后才与 Host 注册
|
|
2093
|
+
* ——与 `credentials` 的「解析器而非快照」同一套。
|
|
2094
|
+
*
|
|
2095
|
+
* @param {ProviderPublisherDeps} [deps]
|
|
2096
|
+
* @returns {{
|
|
2097
|
+
* state: ProviderPublisherState,
|
|
2098
|
+
* publish: (entries: object[], enabledIds: string[],
|
|
2099
|
+
* unavailableModelIds?: string[]) => Promise<object>,
|
|
2100
|
+
* release: () => void,
|
|
2101
|
+
* dispose: () => void,
|
|
2102
|
+
* isDisposed: () => boolean
|
|
2103
|
+
* }}
|
|
2104
|
+
*/
|
|
2105
|
+
function createProviderPublisher(deps = {}) {
|
|
2106
|
+
const { settings, panelSwitch, loadAdapterModule, getLlm, resolveApiKey, usage, emit, logger } = deps;
|
|
2107
|
+
const effectiveSettings = settings ?? {};
|
|
2108
|
+
const effectivePanelSwitch = panelSwitch ?? (async () => null);
|
|
2109
|
+
const effectiveLoadAdapterModule = loadAdapterModule ?? (() => import("./llm-adapter-CpcQ3gC5.js"));
|
|
2110
|
+
const effectiveGetLlm = getLlm ?? (() => null);
|
|
2111
|
+
const effectiveResolveApiKey = resolveApiKey ?? (async () => "");
|
|
2112
|
+
const effectiveLogger = logger ?? { warn: () => {} };
|
|
2113
|
+
/**
|
|
2114
|
+
* 活注册状态。一个普通对象,调用方以 `state` 读它(快照的 provider 块从上面取
|
|
2115
|
+
* `registered`/`error`)。release 函数住在这里而不是作为返回值,是因为在注册
|
|
2116
|
+
* 已完成后才抛的注册也必须能被拿到——否则适配器会比插件活得久(见
|
|
2117
|
+
* `registerPair`)。
|
|
2118
|
+
*/
|
|
2119
|
+
const state = {
|
|
2120
|
+
entries: [],
|
|
2121
|
+
enabledIds: [],
|
|
2122
|
+
unavailableIds: [],
|
|
2123
|
+
signature: "",
|
|
2124
|
+
quotaSignature: "",
|
|
2125
|
+
/** 是否有应答 `registerAdapter` 的 `llm` 服务。 */
|
|
2126
|
+
llmAvailable: false,
|
|
2127
|
+
/** 我们的 provider 对当前是否已无错注册。 */
|
|
2128
|
+
registered: false,
|
|
2129
|
+
/** 最近一次注册错误,经快照脱敏呈现。 */
|
|
2130
|
+
error: null,
|
|
2131
|
+
releaseAdapter: null,
|
|
2132
|
+
releaseDirectory: null,
|
|
2133
|
+
/** 当前 release 函数所属于的已构建适配器。 */
|
|
2134
|
+
built: null
|
|
2135
|
+
};
|
|
2136
|
+
/** publish 队列与 `disposed` 闸——三条承重语义里的前两条,都在 `publish-core.ts`。 */
|
|
2137
|
+
const queue = createPublishQueue();
|
|
2138
|
+
/** 解析 peer-dependent 适配器工厂,只解析一次并 memoize。 */
|
|
2139
|
+
const resolveAdapterFactory = createAdapterFactoryResolver(effectiveLoadAdapterModule, "createModelScopeAdapter");
|
|
2140
|
+
/** 释放已注册的 provider 对。释放函数在 Host 里是幂等的。 */
|
|
2141
|
+
const release = createPairReleaser(state);
|
|
2142
|
+
/**
|
|
2143
|
+
* 把一个构建好的适配器交给 llm 服务,并把它的 release 函数记到 `target` 上。
|
|
2144
|
+
*
|
|
2145
|
+
* 函数体是共享的 `registerProviderPair`——所有 publisher、两条路径共用一份;
|
|
2146
|
+
* 这个包装只补上「这行是给哪个 provider 的」。
|
|
2147
|
+
* @param {object} llm - 注册服务。
|
|
2148
|
+
* @param {{providerIds: string[], adapter: unknown}} built - 要注册的东西。
|
|
2149
|
+
* @param {object} target - release 函数要记到哪(`state`)。
|
|
2150
|
+
* @returns {void}
|
|
2151
|
+
*/
|
|
2152
|
+
const registerPair = (llm, built, target) => registerProviderPair(llm, built, target, {
|
|
2153
|
+
providerId: LLM_PROVIDER_ID,
|
|
2154
|
+
displayName: LLM_DISPLAY_NAME
|
|
2155
|
+
});
|
|
2156
|
+
/**
|
|
2157
|
+
* 为一个目录/允许清单快照(重)构建并注册 provider。
|
|
2158
|
+
*
|
|
2159
|
+
* 重建并重注册而非原地改:`PiAiAdapter` 内部 memoize profiles 快照,只有新的注册
|
|
2160
|
+
* 才能改变提供的模型列表。注册失败时**恢复上一对**,所以坏 publish 永远不能把
|
|
2161
|
+
* 已在服务的模型拉下来。
|
|
2162
|
+
* @param {object[]} entries - 归一目录条目。
|
|
2163
|
+
* @param {string[]} enabledIds - 允许清单(空 = 全部)。
|
|
2164
|
+
* @param {string[]} [unavailableModelIds] - 要从 picker 的 offer 里丢掉的额度耗尽 id。
|
|
2165
|
+
* @returns {Promise<{ok: boolean, skipped?: boolean, error?: unknown}>}
|
|
2166
|
+
*/
|
|
2167
|
+
const publishProviderOnce = async (entries, enabledIds, unavailableModelIds = []) => {
|
|
2168
|
+
if (queue.isDisposed()) return {
|
|
2169
|
+
ok: false,
|
|
2170
|
+
skipped: true
|
|
2171
|
+
};
|
|
2172
|
+
const previousBuilt = state.built;
|
|
2173
|
+
const previousEntries = state.entries;
|
|
2174
|
+
const previousEnabledIds = state.enabledIds;
|
|
2175
|
+
const previousUnavailable = state.unavailableIds;
|
|
2176
|
+
state.entries = Array.isArray(entries) ? entries : [];
|
|
2177
|
+
state.enabledIds = Array.isArray(enabledIds) ? enabledIds : [];
|
|
2178
|
+
state.unavailableIds = Array.isArray(unavailableModelIds) ? unavailableModelIds : [];
|
|
2179
|
+
const panelValue = await effectivePanelSwitch().catch(() => null);
|
|
2180
|
+
if (!resolveSwitchEnabled(panelValue, effectiveSettings.registerProvider === true)) {
|
|
2181
|
+
if (state.registered === false && state.error === null && state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds)) return {
|
|
2182
|
+
ok: true,
|
|
2183
|
+
skipped: true
|
|
2184
|
+
};
|
|
2185
|
+
const result = unregister({
|
|
2186
|
+
state,
|
|
2187
|
+
release
|
|
2188
|
+
});
|
|
2189
|
+
syncSignaturesAfterPublish(state);
|
|
2190
|
+
return result;
|
|
2191
|
+
}
|
|
2192
|
+
const llm = resolveRegistrationService({
|
|
2193
|
+
state,
|
|
2194
|
+
getLlm: effectiveGetLlm,
|
|
2195
|
+
release
|
|
2196
|
+
});
|
|
2197
|
+
if (llm === null) return {
|
|
2198
|
+
ok: false,
|
|
2199
|
+
error: state.error
|
|
2200
|
+
};
|
|
2201
|
+
let createModelScopeAdapter;
|
|
2202
|
+
let built;
|
|
2203
|
+
try {
|
|
2204
|
+
createModelScopeAdapter = await resolveAdapterFactory();
|
|
2205
|
+
if (createModelScopeAdapter === void 0) throw new Error(BAD_FACTORY_SHAPE_ERROR);
|
|
2206
|
+
built = await createModelScopeAdapter({
|
|
2207
|
+
entries: state.entries,
|
|
2208
|
+
enabledIds: state.enabledIds,
|
|
2209
|
+
baseUrl: effectiveSettings.apiBase,
|
|
2210
|
+
resolveApiKey: effectiveResolveApiKey,
|
|
2211
|
+
get: effectiveGetLlm,
|
|
2212
|
+
unavailableModelIds: state.unavailableIds,
|
|
2213
|
+
...usage !== void 0 ? { usage } : {}
|
|
2214
|
+
});
|
|
2215
|
+
if (!isBuiltAdapter(built)) throw new Error(BAD_FACTORY_SHAPE_ERROR);
|
|
2216
|
+
} catch (e) {
|
|
2217
|
+
const described = describeBuildFailure(e);
|
|
2218
|
+
state.error = described.note;
|
|
2219
|
+
warnBuildFailure(effectiveLogger, "ModelScope", described);
|
|
2220
|
+
return {
|
|
2221
|
+
ok: false,
|
|
2222
|
+
error: described.error
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
if (state.registered === true && state.error === null && state.signature === catalogSignature(state.entries, state.enabledIds) && state.quotaSignature === quotaSignatureOf(state.unavailableIds)) return {
|
|
2226
|
+
ok: true,
|
|
2227
|
+
skipped: true
|
|
2228
|
+
};
|
|
2229
|
+
if (queue.isDisposed()) return {
|
|
2230
|
+
ok: false,
|
|
2231
|
+
skipped: true
|
|
2232
|
+
};
|
|
2233
|
+
const result = swapRegistration({
|
|
2234
|
+
llm,
|
|
2235
|
+
built,
|
|
2236
|
+
previousBuilt,
|
|
2237
|
+
state,
|
|
2238
|
+
release,
|
|
2239
|
+
registerPair,
|
|
2240
|
+
emit,
|
|
2241
|
+
onRollback: () => {
|
|
2242
|
+
state.entries = previousEntries;
|
|
2243
|
+
state.enabledIds = previousEnabledIds;
|
|
2244
|
+
state.unavailableIds = previousUnavailable;
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
syncSignaturesAfterPublish(state);
|
|
2248
|
+
return result;
|
|
2249
|
+
};
|
|
2250
|
+
/**
|
|
2251
|
+
* publish,排到所有其他在途 publish 之后。
|
|
2252
|
+
*
|
|
2253
|
+
* 包装的存在是为了没有任何调用方要记得队列:挂载 seed、目录轮询、provider 开关、
|
|
2254
|
+
* roster 保存都走同一条临界区,它们任何一个与另一个竞争就是上面的 bug。
|
|
2255
|
+
* @param {object[]} entries - 归一目录条目。
|
|
2256
|
+
* @param {string[]} enabledIds - 允许清单(空 = 全部)。
|
|
2257
|
+
* @param {string[]} [unavailableModelIds] - 额度耗尽的模型 id。
|
|
2258
|
+
* @returns {Promise<{ok: boolean, skipped?: boolean, error?: unknown}>}
|
|
2259
|
+
*/
|
|
2260
|
+
const publish = (entries, enabledIds, unavailableModelIds = []) => queue.enqueue(() => publishProviderOnce(entries, enabledIds, unavailableModelIds));
|
|
2261
|
+
/**
|
|
2262
|
+
* 标记 publisher 已 dispose:之后(或在途的)任何 publish 都变成 no-op,不能注册
|
|
2263
|
+
* 进一个已撤下的 Host。
|
|
2264
|
+
*/
|
|
2265
|
+
const dispose = () => queue.dispose();
|
|
2266
|
+
return {
|
|
2267
|
+
state,
|
|
2268
|
+
publish,
|
|
2269
|
+
release,
|
|
2270
|
+
dispose,
|
|
2271
|
+
isDisposed: () => queue.isDisposed()
|
|
2272
|
+
};
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* 一份 provider 注册会提供的模型集合的廉价签名。
|
|
2276
|
+
*
|
|
2277
|
+
* 它只回答「重建会不会改变什么」:目录顺序里的模型 id,每个带上 descriptor 用的
|
|
2278
|
+
* 同一个 vision 判定(id 没变但模态翻了也必须重建),加上允许清单。目录条目里其他
|
|
2279
|
+
* 东西变了不影响注册的 offer。
|
|
2280
|
+
* @param {object[]} entries - 归一目录条目。
|
|
2281
|
+
* @param {string[]} enabledIds - 允许清单(空 = 全部)。
|
|
2282
|
+
* @returns {string}
|
|
2283
|
+
*/
|
|
2284
|
+
function catalogSignature(entries, enabledIds) {
|
|
2285
|
+
return `${(Array.isArray(entries) ? entries : []).map((entry) => {
|
|
2286
|
+
const e = entry;
|
|
2287
|
+
const id = str(e?.id, "");
|
|
2288
|
+
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 : ""}`;
|
|
2289
|
+
}).join(",")}|${(Array.isArray(enabledIds) ? enabledIds : []).join(",")}`;
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* 额度不可用集合的签名:轮询的「耗尽集合变没变」信号。
|
|
2293
|
+
*
|
|
2294
|
+
* 导出是为了让轮询路径与 {@link syncSignaturesAfterPublish} 无法漂移——它们以前把
|
|
2295
|
+
* 这个公式各写一遍(`[...ids].sort().join(",")`),正是那种一对会被碰一下就走样的
|
|
2296
|
+
* 组合,失败方式是静默的:两边不再看到对方的 publish,注册要么每次轮询都 churn,
|
|
2297
|
+
* 要么再也不重建。构造上与顺序无关(排序过),所以两个相等的集合永远产出同一个
|
|
2298
|
+
* 字符串。
|
|
2299
|
+
* @param {string[]} unavailableIds - 耗尽的模型 id。
|
|
2300
|
+
* @returns {string}
|
|
2301
|
+
*/
|
|
2302
|
+
function quotaSignatureOf(unavailableIds) {
|
|
2303
|
+
return [...unavailableIds].sort().join(",");
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* 按 publisher 刚发布出去的 offer 重算 state 的 `signature` 与 `quotaSignature`。
|
|
2307
|
+
*
|
|
2308
|
+
* 在每次绕开轮询变更检测的**直接** publish 之后调用:那两个字段是轮询的「offer 变没
|
|
2309
|
+
* 变」信号,所以它们必须跟着实际提供的东西走,否则下一次轮询会无谓地 churn(重建)
|
|
2310
|
+
* 注册。这里的公式与轮询用的**相同**——它们就是被导出的函数,不是抄一份——所以
|
|
2311
|
+
* 两者不会漂移。只在成功的 publish 之后有意义:失败的 publish 上,调用方的 rollback
|
|
2312
|
+
* 已经恢复了之前的字段。
|
|
2313
|
+
* @param {{entries?: unknown, enabledIds?: unknown, unavailableIds?: unknown,
|
|
2314
|
+
* signature: string, quotaSignature: string}} state - `publisher.state`。
|
|
2315
|
+
*/
|
|
2316
|
+
function syncSignaturesAfterPublish(state) {
|
|
2317
|
+
const entries = Array.isArray(state.entries) ? state.entries : [];
|
|
2318
|
+
const enabledIds = Array.isArray(state.enabledIds) ? state.enabledIds : [];
|
|
2319
|
+
const unavailable = Array.isArray(state.unavailableIds) ? state.unavailableIds : [];
|
|
2320
|
+
state.signature = catalogSignature(entries, enabledIds);
|
|
2321
|
+
state.quotaSignature = quotaSignatureOf(unavailable);
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
//#endregion
|
|
2325
|
+
//#region src/host/routes/paths.ts
|
|
2326
|
+
/**
|
|
2327
|
+
* 路由字面量。浏览器 bundle(src/client/const.ts)无法从 host-config 导入,
|
|
2328
|
+
* 两边各写一份字面量——由 test/config.test.mjs 钉住相等,改名时先红再改。
|
|
2329
|
+
* @module dsh-connect-modelscope-token-plan/routes/paths
|
|
2330
|
+
*/
|
|
2331
|
+
const SNAPSHOT_PATH = "/api/dsh-connect-modelscope-token-plan/snapshot";
|
|
2332
|
+
const MODELS_PATH = "/api/dsh-connect-modelscope-token-plan/models";
|
|
2333
|
+
const TOKEN_PATH = "/api/dsh-connect-modelscope-token-plan/token";
|
|
2334
|
+
const TOKEN_FORGET_PATH = "/api/dsh-connect-modelscope-token-plan/token/forget";
|
|
2335
|
+
const PROBE_PATH = "/api/dsh-connect-modelscope-token-plan/probe";
|
|
2336
|
+
const PROVIDER_PATH = "/api/dsh-connect-modelscope-token-plan/provider";
|
|
2337
|
+
|
|
2338
|
+
//#endregion
|
|
2339
|
+
//#region src/host/snapshot-aggregate.ts
|
|
2340
|
+
/**
|
|
2341
|
+
* 快照聚合:把 token 状态、本地用量、模型目录三路数据软失败地拼成
|
|
2342
|
+
* `Snapshot`(src/shared/wire.ts 的契约)。
|
|
2343
|
+
*
|
|
2344
|
+
* `soft()` 家规:任何一路失败都不许白屏——部分失败呈现为该路的降级形状
|
|
2345
|
+
* (models.available=false / shapeWarnings 一条),而不是整条快照 ok:false。
|
|
2346
|
+
* 快照路由只在**配置坏**(configError)或聚合器自身抛错时才回 ok:false。
|
|
2347
|
+
*
|
|
2348
|
+
* 严格区分 null 与 0(wire.ts 语义基线):本地计数器没记录 = 真实 0;模型
|
|
2349
|
+
* 目录读不到 = available:false + error,绝不伪装成「0 个模型」。
|
|
2350
|
+
*
|
|
2351
|
+
* @module dsh-connect-modelscope-token-plan/snapshot-aggregate
|
|
2352
|
+
*/
|
|
2353
|
+
/**
|
|
2354
|
+
* 软失败包装:成功给 value,失败给 {error, code}——从不 reject。
|
|
2355
|
+
*
|
|
2356
|
+
* **`error` 在这里脱敏,这是本模块唯一的安全收口。** 这个字符串会一路流进
|
|
2357
|
+
* `shapeWarnings`、`balance.error`、`models.error`(面板可见)与快照路由的响应体,
|
|
2358
|
+
* 所以「下游每个 promise 都已自行脱敏」这个假设一旦有任何一个遵守者,令牌就直达
|
|
2359
|
+
* 面板。契约不该靠下游的自觉:**收口处自己脱敏**,下游脱敏是纵深防御而不是前提。
|
|
2360
|
+
*
|
|
2361
|
+
* 代价是重复脱敏(上游多已脱敏过一次)——`redactSecrets` 是幂等的(已替换成
|
|
2362
|
+
* `ms-[REDACTED]` 的内容再过一次仍是它),这点开销换掉「凭据可能外泄」这个尾部
|
|
2363
|
+
* 风险划算。
|
|
2364
|
+
*/
|
|
2365
|
+
async function soft(promise) {
|
|
2366
|
+
try {
|
|
2367
|
+
return {
|
|
2368
|
+
ok: true,
|
|
2369
|
+
value: await promise
|
|
2370
|
+
};
|
|
2371
|
+
} catch (error) {
|
|
2372
|
+
const code = error.code;
|
|
2373
|
+
return {
|
|
2374
|
+
ok: false,
|
|
2375
|
+
error: redactSecrets(errMsg(error)),
|
|
2376
|
+
code: typeof code === "string" ? code : CODE.INTERNAL_ERROR
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* Provider 状态块降级形状:读不到开关/注册状态/目录时,面板仍然得到一个完整
|
|
2382
|
+
* 可渲染的块(开关关、roster 空、error 说明原因),绝不让整条快照失败。
|
|
2383
|
+
*/
|
|
2384
|
+
function providerDegraded(error) {
|
|
2385
|
+
return {
|
|
2386
|
+
enabled: false,
|
|
2387
|
+
source: "config",
|
|
2388
|
+
llmAvailable: false,
|
|
2389
|
+
registered: false,
|
|
2390
|
+
error,
|
|
2391
|
+
modelCount: 0,
|
|
2392
|
+
enabledCount: 0,
|
|
2393
|
+
allowed: "all",
|
|
2394
|
+
enabledIds: [],
|
|
2395
|
+
roster: []
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
/** 聚合快照 body。wiring 子集见 routes/snapshot.ts 的 Pick。 */
|
|
2399
|
+
async function buildSnapshotBody(wiring) {
|
|
2400
|
+
const { settings, tokenStore, usageStore, inference, providerStore, publisher } = wiring;
|
|
2401
|
+
const [tokenState, daily, perModel, trend, events] = await Promise.all([
|
|
2402
|
+
soft(tokenStore.state()),
|
|
2403
|
+
soft(usageStore.daily()),
|
|
2404
|
+
soft(usageStore.perModelToday()),
|
|
2405
|
+
soft(usageStore.trend(settings.trendDays)),
|
|
2406
|
+
soft(usageStore.events())
|
|
2407
|
+
]);
|
|
2408
|
+
const shapeWarnings = [];
|
|
2409
|
+
const usageAnomaly = usageStore.anomaly();
|
|
2410
|
+
if (usageAnomaly !== null) shapeWarnings.push(usageAnomaly);
|
|
2411
|
+
for (const path of [
|
|
2412
|
+
tokenState,
|
|
2413
|
+
daily,
|
|
2414
|
+
perModel,
|
|
2415
|
+
trend,
|
|
2416
|
+
events
|
|
2417
|
+
]) if (!path.ok) shapeWarnings.push(`usage-source failed (${path.code}): ${path.error}`);
|
|
2418
|
+
const tokenPresent = tokenState.ok ? tokenState.value.present : false;
|
|
2419
|
+
const balance = await soft(inference.fetchBalance());
|
|
2420
|
+
const models = await soft(inference.fetchModels());
|
|
2421
|
+
const modelIds = models.ok ? models.value.ids : [];
|
|
2422
|
+
const modelEntries = models.ok ? models.value.entries : [];
|
|
2423
|
+
if (!models.ok && models.code !== "network_error") shapeWarnings.push(`models: ${models.error}`);
|
|
2424
|
+
if (tokenPresent && !balance.ok && balance.code !== "network_error") shapeWarnings.push(`balance: ${balance.error}`);
|
|
2425
|
+
const provider = await soft((async () => {
|
|
2426
|
+
const panel = await providerStore.enabled().catch(() => null);
|
|
2427
|
+
const enabled = resolveSwitchEnabled(panel, settings.registerProvider);
|
|
2428
|
+
const source = switchSource(panel);
|
|
2429
|
+
const roster = rosterWithAvailability(modelEntries, []);
|
|
2430
|
+
const stored = await providerStore.enabledIds().catch(() => null);
|
|
2431
|
+
const enabledIds = Array.isArray(stored) ? stored : Array.isArray(publisher.state.enabledIds) ? publisher.state.enabledIds : [];
|
|
2432
|
+
const allowed = resolveAllowedList(enabledIds);
|
|
2433
|
+
const allowSet = new Set(enabledIds);
|
|
2434
|
+
const enabledCount = allowed === "all" ? roster.length : allowed === "list" ? roster.filter((row) => allowSet.has(row.id)).length : 0;
|
|
2435
|
+
return {
|
|
2436
|
+
enabled,
|
|
2437
|
+
source,
|
|
2438
|
+
llmAvailable: publisher.state.llmAvailable === true,
|
|
2439
|
+
registered: publisher.state.registered === true,
|
|
2440
|
+
error: typeof publisher.state.error === "string" ? publisher.state.error : null,
|
|
2441
|
+
modelCount: roster.length,
|
|
2442
|
+
enabledCount,
|
|
2443
|
+
allowed,
|
|
2444
|
+
enabledIds,
|
|
2445
|
+
roster
|
|
2446
|
+
};
|
|
2447
|
+
})());
|
|
2448
|
+
const usedLocal = daily.ok ? daily.value.calls : 0;
|
|
2449
|
+
return {
|
|
2450
|
+
ok: true,
|
|
2451
|
+
name,
|
|
2452
|
+
version: PLUGIN_VERSION,
|
|
2453
|
+
now: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2454
|
+
pollSeconds: settings.pollSeconds,
|
|
2455
|
+
cacheSeconds: settings.cacheSeconds,
|
|
2456
|
+
token: tokenState.ok ? tokenState.value : {
|
|
2457
|
+
present: false,
|
|
2458
|
+
source: "none",
|
|
2459
|
+
valid: null,
|
|
2460
|
+
checkedAt: null,
|
|
2461
|
+
ephemeral: true
|
|
2462
|
+
},
|
|
2463
|
+
balance: balance.ok ? {
|
|
2464
|
+
available: balance.value.available,
|
|
2465
|
+
total: balance.value.total,
|
|
2466
|
+
frozen: balance.value.frozen,
|
|
2467
|
+
fetchedAt: balance.value.fetchedAt,
|
|
2468
|
+
error: null
|
|
2469
|
+
} : {
|
|
2470
|
+
available: null,
|
|
2471
|
+
total: null,
|
|
2472
|
+
frozen: null,
|
|
2473
|
+
fetchedAt: null,
|
|
2474
|
+
error: tokenPresent ? balance.error : null
|
|
2475
|
+
},
|
|
2476
|
+
quota: {
|
|
2477
|
+
daily: { usedLocal },
|
|
2478
|
+
perModel: perModel.ok ? perModel.value : [],
|
|
2479
|
+
countingNote: "local-counting"
|
|
2480
|
+
},
|
|
2481
|
+
events: events.ok ? events.value : [],
|
|
2482
|
+
trend: {
|
|
2483
|
+
days: settings.trendDays,
|
|
2484
|
+
buckets: trend.ok ? trend.value : []
|
|
2485
|
+
},
|
|
2486
|
+
models: {
|
|
2487
|
+
available: models.ok,
|
|
2488
|
+
count: modelIds.length,
|
|
2489
|
+
sample: modelIds.slice(0, 20),
|
|
2490
|
+
error: models.ok ? null : models.error
|
|
2491
|
+
},
|
|
2492
|
+
provider: provider.ok ? provider.value : providerDegraded(provider.error),
|
|
2493
|
+
shapeWarnings,
|
|
2494
|
+
quotaError: null
|
|
2495
|
+
};
|
|
2496
|
+
}
|
|
2497
|
+
/** 聚合器自身抛错时的失败码(快照路由的 ok:false 分支用)。 */
|
|
2498
|
+
function failureCode(error) {
|
|
2499
|
+
const code = error.code;
|
|
2500
|
+
return typeof code === "string" ? code : CODE.INTERNAL_ERROR;
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
//#endregion
|
|
2504
|
+
//#region src/host/routes/http.ts
|
|
2505
|
+
/**
|
|
2506
|
+
* 路由族共享的 HTTP 原语:JSON 形状、有界 body 读取、标准拒绝与信任围栏。
|
|
2507
|
+
* 与姊妹插件 routes/http.ts 受控复制;每个拒绝的措辞只此一份,路由无法
|
|
2508
|
+
* 漂移出自己的 403/405。不 import 任何 Host peer。
|
|
2509
|
+
* @module dsh-connect-modelscope-token-plan/routes/http
|
|
2510
|
+
*/
|
|
2511
|
+
/** JSON 路由的族默认响应头。 */
|
|
2512
|
+
const JSON_HEADERS = {
|
|
2513
|
+
"content-type": "application/json; charset=utf-8",
|
|
2514
|
+
"referrer-policy": "no-referrer"
|
|
2515
|
+
};
|
|
2516
|
+
/** 写一个带族头的 JSON 响应。 */
|
|
2517
|
+
function writeJson(res, status, body, headers = {}) {
|
|
2518
|
+
const payload = JSON.stringify(body);
|
|
2519
|
+
res.writeHead(status, {
|
|
2520
|
+
...JSON_HEADERS,
|
|
2521
|
+
...headers
|
|
2522
|
+
});
|
|
2523
|
+
res.end(payload);
|
|
2524
|
+
}
|
|
2525
|
+
/** 提交体的字节上限: hostile 页面不能对着路由流一个无限 body。 */
|
|
2526
|
+
const MAX_JSON_BODY_BYTES = 4096;
|
|
2527
|
+
/**
|
|
2528
|
+
* 读一个小的 JSON 请求体,超限即拒。刻意无趣:不协商 content-type、不流式,
|
|
2529
|
+
* 有界收集 + 解析。
|
|
2530
|
+
*/
|
|
2531
|
+
async function readJsonBody(request, limit = MAX_JSON_BODY_BYTES) {
|
|
2532
|
+
const chunks = [];
|
|
2533
|
+
let received = 0;
|
|
2534
|
+
try {
|
|
2535
|
+
for await (const chunk of request) {
|
|
2536
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
2537
|
+
received += buffer.byteLength;
|
|
2538
|
+
if (received > limit) return {
|
|
2539
|
+
ok: false,
|
|
2540
|
+
error: "request body is too large"
|
|
2541
|
+
};
|
|
2542
|
+
chunks.push(buffer);
|
|
2543
|
+
}
|
|
2544
|
+
} catch {
|
|
2545
|
+
return {
|
|
2546
|
+
ok: false,
|
|
2547
|
+
error: "could not read the request body"
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
if (chunks.length === 0) return {
|
|
2551
|
+
ok: false,
|
|
2552
|
+
error: "a JSON body is required"
|
|
2553
|
+
};
|
|
2554
|
+
try {
|
|
2555
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2556
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {
|
|
2557
|
+
ok: false,
|
|
2558
|
+
error: "the body must be a JSON object"
|
|
2559
|
+
};
|
|
2560
|
+
return {
|
|
2561
|
+
ok: true,
|
|
2562
|
+
value: parsed
|
|
2563
|
+
};
|
|
2564
|
+
} catch {
|
|
2565
|
+
return {
|
|
2566
|
+
ok: false,
|
|
2567
|
+
error: "the body is not valid JSON"
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
/** 信任围栏拒绝时的一律 403(面板认这个形状)。 */
|
|
2572
|
+
function refuseOrigin(response) {
|
|
2573
|
+
writeJson(response, 403, {
|
|
2574
|
+
ok: false,
|
|
2575
|
+
error: "forbidden: origin mismatch"
|
|
2576
|
+
});
|
|
2577
|
+
}
|
|
2578
|
+
/** 方法不允许时的一律 405。方法拒绝不是新答案,不带 cache-control。 */
|
|
2579
|
+
function refuseMethod(response) {
|
|
2580
|
+
writeJson(response, 405, {
|
|
2581
|
+
ok: false,
|
|
2582
|
+
error: "method not allowed"
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
/**
|
|
2586
|
+
* 把 catch 到的错误脱敏后再进响应的 error 字段(家规红线 1:凭据永不进
|
|
2587
|
+
* 日志或响应)。redactSecrets 幂等,双重应用无害。
|
|
2588
|
+
*/
|
|
2589
|
+
function redactedError(error) {
|
|
2590
|
+
const text = redactSecrets(errMsg(error));
|
|
2591
|
+
return text.trim() === "" ? "request failed" : text;
|
|
2592
|
+
}
|
|
2593
|
+
/** 读并校验 JSON body,失败时写 400 并返回 null(调用方的停手信号)。 */
|
|
2594
|
+
async function readJsonBodyOr400(request, response, limit = MAX_JSON_BODY_BYTES) {
|
|
2595
|
+
const body = await readJsonBody(request, limit);
|
|
2596
|
+
if (!body.ok) {
|
|
2597
|
+
writeJson(response, 400, {
|
|
2598
|
+
ok: false,
|
|
2599
|
+
error: body.error
|
|
2600
|
+
}, { "cache-control": "no-store" });
|
|
2601
|
+
return null;
|
|
2602
|
+
}
|
|
2603
|
+
return body.value;
|
|
2604
|
+
}
|
|
2605
|
+
/**
|
|
2606
|
+
* 给每个路由包上同一道信任围栏(isAdmitted),403 措辞只此一份。
|
|
2607
|
+
* 被包的 handler 不得再重复围栏——那只是第二道死守卫。
|
|
2608
|
+
*/
|
|
2609
|
+
function withOrigin(handler, allowedHosts) {
|
|
2610
|
+
return async (request, response) => {
|
|
2611
|
+
if (!isAdmitted(request, allowedHosts)) {
|
|
2612
|
+
refuseOrigin(response);
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
return handler(request, response);
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
//#endregion
|
|
2620
|
+
//#region src/host/routes/snapshot.ts
|
|
2621
|
+
/**
|
|
2622
|
+
* 快照路由——Client 面板轮询的唯一只读路由。
|
|
2623
|
+
*
|
|
2624
|
+
* HTTP 恒 200,成败看 body(wire.ts 的 Snapshot | SnapshotFailure):一次
|
|
2625
|
+
* 读取失败不许顺着渲染树炸穿成整页失效,错误是**数据**(ok:false + code)
|
|
2626
|
+
* 不是异常。configError 在这里短路——面板把它映射成顶部一行,让操作者看到
|
|
2627
|
+
* 配错的是什么,而不是一个莫名其妙的网络失败。
|
|
2628
|
+
*
|
|
2629
|
+
* @module dsh-connect-modelscope-token-plan/routes/snapshot
|
|
2630
|
+
*/
|
|
2631
|
+
/**
|
|
2632
|
+
* 注册快照路由。wiring 子集:settings / configError / tokenStore /
|
|
2633
|
+
* usageStore / inference / providerStore / publisher / logger。
|
|
2634
|
+
* @returns {Function} off() 注销回调。
|
|
2635
|
+
*/
|
|
2636
|
+
function registerSnapshotRoute(ctx, wiring) {
|
|
2637
|
+
const { settings, configError } = wiring;
|
|
2638
|
+
return ctx.webServer.register({
|
|
2639
|
+
kind: "exact",
|
|
2640
|
+
path: SNAPSHOT_PATH,
|
|
2641
|
+
handler: withOrigin(async (request, response) => {
|
|
2642
|
+
if (request.method !== void 0 && request.method !== "GET" && request.method !== "HEAD") {
|
|
2643
|
+
refuseMethod(response);
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
if (configError !== null) {
|
|
2647
|
+
writeJson(response, 200, {
|
|
2648
|
+
ok: false,
|
|
2649
|
+
code: CODE.CONFIG_ERROR,
|
|
2650
|
+
error: configError
|
|
2651
|
+
}, { "cache-control": "no-store" });
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
try {
|
|
2655
|
+
const body = await buildSnapshotBody(wiring);
|
|
2656
|
+
writeJson(response, 200, body, { "cache-control": "no-store" });
|
|
2657
|
+
} catch (error) {
|
|
2658
|
+
writeJson(response, 200, {
|
|
2659
|
+
ok: false,
|
|
2660
|
+
error: redactedError(error),
|
|
2661
|
+
code: failureCode(error)
|
|
2662
|
+
}, { "cache-control": "no-store" });
|
|
2663
|
+
}
|
|
2664
|
+
}, settings.allowedHosts)
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
//#endregion
|
|
2669
|
+
//#region src/host/routes/models.ts
|
|
2670
|
+
/**
|
|
2671
|
+
* 模型目录路由:`GET /models` —— 免认证的 `/v1/models` 读穿透(缓存与单飞
|
|
2672
|
+
* 在 inference-client 里),零额度成本(SPIKE.md §结论 3)。只读。
|
|
2673
|
+
* @module dsh-connect-modelscope-token-plan/routes/models
|
|
2674
|
+
*/
|
|
2675
|
+
/** 注册模型目录路由。wiring 子集:settings / inference。 */
|
|
2676
|
+
function registerModelsRoute(ctx, wiring) {
|
|
2677
|
+
const { settings, inference } = wiring;
|
|
2678
|
+
return ctx.webServer.register({
|
|
2679
|
+
kind: "exact",
|
|
2680
|
+
path: MODELS_PATH,
|
|
2681
|
+
handler: withOrigin(async (request, response) => {
|
|
2682
|
+
if (request.method !== void 0 && request.method !== "GET" && request.method !== "HEAD") {
|
|
2683
|
+
refuseMethod(response);
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
try {
|
|
2687
|
+
const catalog = await inference.fetchModels();
|
|
2688
|
+
writeJson(response, 200, {
|
|
2689
|
+
ok: true,
|
|
2690
|
+
models: catalog.ids.map((id) => ({ id })),
|
|
2691
|
+
entries: catalog.entries.map((entry) => ({
|
|
2692
|
+
id: entry.id,
|
|
2693
|
+
name: entry.name,
|
|
2694
|
+
vision: entry.vision,
|
|
2695
|
+
contextWindow: entry.contextWindow,
|
|
2696
|
+
maxOutputLength: entry.maxOutputLength
|
|
2697
|
+
})),
|
|
2698
|
+
count: catalog.ids.length,
|
|
2699
|
+
fetchedAt: catalog.fetchedAt,
|
|
2700
|
+
cacheSeconds: settings.cacheSeconds
|
|
2701
|
+
}, { "cache-control": "no-store" });
|
|
2702
|
+
} catch (error) {
|
|
2703
|
+
const code = error.code;
|
|
2704
|
+
writeJson(response, 200, {
|
|
2705
|
+
ok: false,
|
|
2706
|
+
code: typeof code === "string" ? code : CODE.UPSTREAM_ERROR,
|
|
2707
|
+
error: redactedError(error)
|
|
2708
|
+
}, { "cache-control": "no-store" });
|
|
2709
|
+
}
|
|
2710
|
+
}, settings.allowedHosts)
|
|
2711
|
+
});
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
//#endregion
|
|
2715
|
+
//#region src/host/routes/token.ts
|
|
2716
|
+
/**
|
|
2717
|
+
* 令牌路由:面板「接入」tab 保存 / 忘掉访问令牌。
|
|
2718
|
+
*
|
|
2719
|
+
* 存的是凭据引用(ms-auth TOKEN_REF),不是新 kind。保存/忘掉后**不需要**
|
|
2720
|
+
* 失效任何缓存:模型目录免认证、probe 每次现 resolve 令牌——本插件没有
|
|
2721
|
+
* 「凭据换手后缓存还在替旧身份说话」的形态,这是免认证目录带来的结构性
|
|
2722
|
+
* 简化(SPIKE.md §结论 3)。
|
|
2723
|
+
* @module dsh-connect-modelscope-token-plan/routes/token
|
|
2724
|
+
*/
|
|
2725
|
+
/** 注册令牌路由(POST /token 保存,POST /token/forget 忘掉)。 */
|
|
2726
|
+
function registerTokenRoute(ctx, wiring) {
|
|
2727
|
+
const { settings, tokenStore } = wiring;
|
|
2728
|
+
const save = withOrigin(async (request, response) => {
|
|
2729
|
+
if (request.method !== "POST") {
|
|
2730
|
+
refuseMethod(response);
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
const body = await readJsonBodyOr400(request, response);
|
|
2734
|
+
if (body === null) return;
|
|
2735
|
+
const token = typeof body.token === "string" ? body.token : "";
|
|
2736
|
+
if (token.trim() === "") {
|
|
2737
|
+
writeJson(response, 400, {
|
|
2738
|
+
ok: false,
|
|
2739
|
+
error: "a ModelScope token (ms-…) is required"
|
|
2740
|
+
}, { "cache-control": "no-store" });
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2743
|
+
try {
|
|
2744
|
+
await tokenStore.save(token);
|
|
2745
|
+
writeJson(response, 200, {
|
|
2746
|
+
ok: true,
|
|
2747
|
+
...await tokenStore.state()
|
|
2748
|
+
}, { "cache-control": "no-store" });
|
|
2749
|
+
} catch (error) {
|
|
2750
|
+
writeJson(response, 200, {
|
|
2751
|
+
ok: false,
|
|
2752
|
+
error: redactedError(error)
|
|
2753
|
+
}, { "cache-control": "no-store" });
|
|
2754
|
+
}
|
|
2755
|
+
}, settings.allowedHosts);
|
|
2756
|
+
const forget = withOrigin(async (request, response) => {
|
|
2757
|
+
if (request.method !== "POST") {
|
|
2758
|
+
refuseMethod(response);
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
try {
|
|
2762
|
+
await tokenStore.forget();
|
|
2763
|
+
writeJson(response, 200, {
|
|
2764
|
+
ok: true,
|
|
2765
|
+
...await tokenStore.state()
|
|
2766
|
+
}, { "cache-control": "no-store" });
|
|
2767
|
+
} catch (error) {
|
|
2768
|
+
writeJson(response, 200, {
|
|
2769
|
+
ok: false,
|
|
2770
|
+
error: redactedError(error)
|
|
2771
|
+
}, { "cache-control": "no-store" });
|
|
2772
|
+
}
|
|
2773
|
+
}, settings.allowedHosts);
|
|
2774
|
+
return [ctx.webServer.register({
|
|
2775
|
+
kind: "exact",
|
|
2776
|
+
path: TOKEN_PATH,
|
|
2777
|
+
handler: save
|
|
2778
|
+
}), ctx.webServer.register({
|
|
2779
|
+
kind: "exact",
|
|
2780
|
+
path: TOKEN_FORGET_PATH,
|
|
2781
|
+
handler: forget
|
|
2782
|
+
})];
|
|
2783
|
+
}
|
|
2784
|
+
|
|
2785
|
+
//#endregion
|
|
2786
|
+
//#region src/host/routes/probe.ts
|
|
2787
|
+
/**
|
|
2788
|
+
* probe 路由:面板「接入」tab 的「测试一次调用」,也是本地计数器的第一个
|
|
2789
|
+
* 数据源。
|
|
2790
|
+
*
|
|
2791
|
+
* 两种形态(SPIKE.md §结论 5):
|
|
2792
|
+
* - `kind:"usage"` —— 真调用(max_tokens=1),**消耗 1 次免费额度**;成功后
|
|
2793
|
+
* recordCall 把这次调用记进当天天桶与单模型计数(tokens 取 usage)。
|
|
2794
|
+
* - `kind:"validity"` —— 故意缺 messages 的请求,预期 401 先于 400,零额度
|
|
2795
|
+
* 鉴权探针(语义待真机回填)。只记事件,不记调用。
|
|
2796
|
+
*
|
|
2797
|
+
* 失败照常记账:429 分诊成 quota/rate_limit 事件(带 Retry-After),401 记
|
|
2798
|
+
* auth 事件——事件流是面板「额度」tab 的第三块。错误消息先脱敏(红线 1)。
|
|
2799
|
+
* @module dsh-connect-modelscope-token-plan/routes/probe
|
|
2800
|
+
*/
|
|
2801
|
+
/** 注册 probe 路由。wiring 子集:settings / tokenStore / usageStore / inference / logger。 */
|
|
2802
|
+
function registerProbeRoute(ctx, wiring) {
|
|
2803
|
+
const { settings, usageStore, inference, logger } = wiring;
|
|
2804
|
+
return ctx.webServer.register({
|
|
2805
|
+
kind: "exact",
|
|
2806
|
+
path: PROBE_PATH,
|
|
2807
|
+
handler: withOrigin(async (request, response) => {
|
|
2808
|
+
if (request.method !== "POST") {
|
|
2809
|
+
refuseMethod(response);
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
const body = await readJsonBodyOr400(request, response);
|
|
2813
|
+
if (body === null) return;
|
|
2814
|
+
const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
|
|
2815
|
+
const kind = body.kind === "validity" ? "validity" : "usage";
|
|
2816
|
+
if (modelId === "") {
|
|
2817
|
+
writeJson(response, 400, {
|
|
2818
|
+
ok: false,
|
|
2819
|
+
error: "modelId is required"
|
|
2820
|
+
}, { "cache-control": "no-store" });
|
|
2821
|
+
return;
|
|
2822
|
+
}
|
|
2823
|
+
try {
|
|
2824
|
+
const result = await inference.probe({
|
|
2825
|
+
modelId,
|
|
2826
|
+
kind
|
|
2827
|
+
});
|
|
2828
|
+
if (kind === "usage") await usageStore.recordCall({
|
|
2829
|
+
modelId,
|
|
2830
|
+
tokens: result.usage === null ? null : result.usage.totalTokens
|
|
2831
|
+
});
|
|
2832
|
+
writeJson(response, 200, {
|
|
2833
|
+
...result,
|
|
2834
|
+
tokenState: await wiring.tokenStore.state()
|
|
2835
|
+
}, { "cache-control": "no-store" });
|
|
2836
|
+
} catch (error) {
|
|
2837
|
+
const code = error.code;
|
|
2838
|
+
const message = redactedError(error);
|
|
2839
|
+
const eventKind = code === CODE.QUOTA_EXCEEDED ? "quota" : code === CODE.RATE_LIMITED ? "rate_limit" : "error";
|
|
2840
|
+
await usageStore.recordEvent({
|
|
2841
|
+
modelId,
|
|
2842
|
+
kind: eventKind,
|
|
2843
|
+
message
|
|
2844
|
+
}).catch(() => {});
|
|
2845
|
+
if (eventKind !== "error") logger?.warn?.(`probe ${modelId}: ${eventKind}: ${message}`);
|
|
2846
|
+
const retryAfterMs = error.retryAfterMs;
|
|
2847
|
+
writeJson(response, 200, {
|
|
2848
|
+
ok: false,
|
|
2849
|
+
code: typeof code === "string" ? code : CODE.UPSTREAM_ERROR,
|
|
2850
|
+
error: message,
|
|
2851
|
+
...retryAfterMs !== void 0 ? { retryAfterMs } : {},
|
|
2852
|
+
tokenState: await wiring.tokenStore.state().catch(() => null)
|
|
2853
|
+
}, { "cache-control": "no-store" });
|
|
2854
|
+
}
|
|
2855
|
+
}, settings.allowedHosts)
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
//#endregion
|
|
2860
|
+
//#region src/host/routes/provider.ts
|
|
2861
|
+
/**
|
|
2862
|
+
* provider 注册路由:开关读/写、允许清单保存、回退默认。
|
|
2863
|
+
*
|
|
2864
|
+
* 三件事(§9):
|
|
2865
|
+
* - GET `PROVIDER_PATH` 读当前开关 + 目录 + 允许清单 + 注册状态(无副作用);
|
|
2866
|
+
* - POST `PROVIDER_PATH` body `{enabled?: boolean}` → 保存面板开关并重注册;
|
|
2867
|
+
* - POST `PROVIDER_PATH/roster` body `{enabledIds?: string[]}` → 保存允许清单并重注册;
|
|
2868
|
+
* - POST `PROVIDER_PATH/reset` 忘掉面板开关与允许清单,回到 patch 默认。
|
|
2869
|
+
*
|
|
2870
|
+
* `withOrigin` 围栏与 `refuseMethod` 照现有路由;写开关/清单失败**必须传播**
|
|
2871
|
+
* (`ok:false` + error),不静默吞——这是用户的显式操作,面板必须看见它没生效。
|
|
2872
|
+
*
|
|
2873
|
+
* peer-free:不 import 任何 Host peer(`@deepseek-ai/*` / `@earendil-works/*`)。
|
|
2874
|
+
* @module dsh-connect-modelscope-token-plan/routes/provider
|
|
2875
|
+
*/
|
|
2876
|
+
/**
|
|
2877
|
+
* 读目录条目,读不到按空目录降级(GET 不能因目录失败把整个面板状态打掉)。
|
|
2878
|
+
*/
|
|
2879
|
+
async function readEntries(inference) {
|
|
2880
|
+
const catalog = await inference.fetchModels().catch((error) => {
|
|
2881
|
+
return {
|
|
2882
|
+
entries: [],
|
|
2883
|
+
__error: error
|
|
2884
|
+
};
|
|
2885
|
+
});
|
|
2886
|
+
return Array.isArray(catalog?.entries) ? catalog.entries : [];
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* 读「当前」允许清单:优先持久化的,否则 publisher 最近 publish 的那份。
|
|
2890
|
+
*/
|
|
2891
|
+
async function readEnabledIds(store, published) {
|
|
2892
|
+
const stored = await optional(store.enabledIds?.(), null);
|
|
2893
|
+
if (Array.isArray(stored)) return stored;
|
|
2894
|
+
return Array.isArray(published) ? published : [];
|
|
2895
|
+
}
|
|
2896
|
+
/**
|
|
2897
|
+
* `allowed` 的分类:委托给 `llm-models.ts` 的单一口径
|
|
2898
|
+
* {@link resolveAllowedList}(fix D:快照与 provider 路由以前各写一套分类,会漂移)。
|
|
2899
|
+
*
|
|
2900
|
+
* 旧版 `allowedOf` 只认「恰好哨兵」为 `"none"`,`["__hide_all__","other"]` 会被错判成
|
|
2901
|
+
* `"list"`;统一到 `resolveAllowedList` 后,含哨兵即 `"none"`,与 `filterByEnabled` /
|
|
2902
|
+
* `isModelEnabled` 口径一致。
|
|
2903
|
+
* @param {string[]} enabledIds - 允许清单。
|
|
2904
|
+
* @returns {"all"|"none"|"list"}
|
|
2905
|
+
*/
|
|
2906
|
+
function allowedOf(enabledIds) {
|
|
2907
|
+
return resolveAllowedList(enabledIds);
|
|
2908
|
+
}
|
|
2909
|
+
/**
|
|
2910
|
+
* 注册 provider 路由(三条路径)。wiring 子集:settings / providerStore /
|
|
2911
|
+
* publisher / inference / logger。
|
|
2912
|
+
* @param ctx - host 根上下文(只用 `ctx.webServer`)。
|
|
2913
|
+
* @param {ProviderRouteWiring} wiring - 本路由读到的子集。
|
|
2914
|
+
* @returns {Function} 依次调用三个 `off()` 的注销回调。
|
|
2915
|
+
*/
|
|
2916
|
+
function registerProviderRoute(ctx, wiring) {
|
|
2917
|
+
const { settings, providerStore, publisher, inference, logger } = wiring;
|
|
2918
|
+
/**
|
|
2919
|
+
* 组装一份 §9 响应形状。GET 与成功后的 POST 共用,所以两个方向不可能对「当前
|
|
2920
|
+
* 状态是什么」说两套话。
|
|
2921
|
+
* @param {object} [extra] - 调用方要追加/覆盖的字段。
|
|
2922
|
+
* @returns {Promise<object>} 响应体。
|
|
2923
|
+
*/
|
|
2924
|
+
const snapshot = async (extra = {}) => {
|
|
2925
|
+
const panelSwitch = await optional(providerStore.enabled());
|
|
2926
|
+
const enabledIds = await readEnabledIds(providerStore, publisher.state.enabledIds);
|
|
2927
|
+
const entries = await readEntries(inference);
|
|
2928
|
+
const roster = rosterWithAvailability(entries, []);
|
|
2929
|
+
const allowed = allowedOf(enabledIds);
|
|
2930
|
+
const modelCount = roster.length;
|
|
2931
|
+
const allowSet = new Set(enabledIds);
|
|
2932
|
+
return {
|
|
2933
|
+
ok: true,
|
|
2934
|
+
enabled: resolveSwitchEnabled(panelSwitch, settings.registerProvider),
|
|
2935
|
+
source: switchSource(panelSwitch),
|
|
2936
|
+
llmAvailable: publisher.state.llmAvailable === true,
|
|
2937
|
+
registered: publisher.state.registered === true,
|
|
2938
|
+
error: typeof publisher.state.error === "string" ? publisher.state.error : null,
|
|
2939
|
+
modelCount,
|
|
2940
|
+
enabledCount: allowed === "all" ? modelCount : allowed === "none" ? 0 : roster.filter((row) => allowSet.has(row.id)).length,
|
|
2941
|
+
allowed,
|
|
2942
|
+
enabledIds,
|
|
2943
|
+
roster,
|
|
2944
|
+
...extra
|
|
2945
|
+
};
|
|
2946
|
+
};
|
|
2947
|
+
const offs = [];
|
|
2948
|
+
offs.push(ctx.webServer.register({
|
|
2949
|
+
kind: "exact",
|
|
2950
|
+
path: PROVIDER_PATH,
|
|
2951
|
+
handler: withOrigin(async (request, response) => {
|
|
2952
|
+
const method = request.method === void 0 ? "GET" : request.method;
|
|
2953
|
+
if (method === "GET") {
|
|
2954
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
if (method !== "POST") {
|
|
2958
|
+
refuseMethod(response);
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
const body = await readJsonBodyOr400(request, response);
|
|
2962
|
+
if (body === null) return;
|
|
2963
|
+
if (typeof body.enabled !== "boolean") {
|
|
2964
|
+
writeJson(response, 400, {
|
|
2965
|
+
ok: false,
|
|
2966
|
+
error: "expected { enabled: boolean }"
|
|
2967
|
+
}, { "cache-control": "no-store" });
|
|
2968
|
+
return;
|
|
2969
|
+
}
|
|
2970
|
+
try {
|
|
2971
|
+
await providerStore.save(body.enabled);
|
|
2972
|
+
const entries = await readEntries(inference);
|
|
2973
|
+
await publisher.publish(entries, await readEnabledIds(providerStore, publisher.state.enabledIds), []);
|
|
2974
|
+
} catch (error) {
|
|
2975
|
+
writeJson(response, 200, {
|
|
2976
|
+
ok: false,
|
|
2977
|
+
error: redactedError(error)
|
|
2978
|
+
}, { "cache-control": "no-store" });
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
2982
|
+
}, settings.allowedHosts)
|
|
2983
|
+
}));
|
|
2984
|
+
offs.push(ctx.webServer.register({
|
|
2985
|
+
kind: "exact",
|
|
2986
|
+
path: `${PROVIDER_PATH}/roster`,
|
|
2987
|
+
handler: withOrigin(async (request, response) => {
|
|
2988
|
+
if ((request.method === void 0 ? "POST" : request.method) !== "POST") {
|
|
2989
|
+
refuseMethod(response);
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2992
|
+
const body = await readJsonBodyOr400(request, response);
|
|
2993
|
+
if (body === null) return;
|
|
2994
|
+
if (!Array.isArray(body.enabledIds)) {
|
|
2995
|
+
writeJson(response, 400, {
|
|
2996
|
+
ok: false,
|
|
2997
|
+
error: "expected { enabledIds: string[] }"
|
|
2998
|
+
}, { "cache-control": "no-store" });
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
const ids = body.enabledIds.filter((id) => typeof id === "string" && id !== "");
|
|
3002
|
+
try {
|
|
3003
|
+
await providerStore.saveEnabledIds?.(ids);
|
|
3004
|
+
const entries = await readEntries(inference);
|
|
3005
|
+
await publisher.publish(entries, ids, []);
|
|
3006
|
+
} catch (error) {
|
|
3007
|
+
writeJson(response, 200, {
|
|
3008
|
+
ok: false,
|
|
3009
|
+
error: redactedError(error)
|
|
3010
|
+
}, { "cache-control": "no-store" });
|
|
3011
|
+
return;
|
|
3012
|
+
}
|
|
3013
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
3014
|
+
}, settings.allowedHosts)
|
|
3015
|
+
}));
|
|
3016
|
+
offs.push(ctx.webServer.register({
|
|
3017
|
+
kind: "exact",
|
|
3018
|
+
path: `${PROVIDER_PATH}/reset`,
|
|
3019
|
+
handler: withOrigin(async (request, response) => {
|
|
3020
|
+
if ((request.method === void 0 ? "POST" : request.method) !== "POST") {
|
|
3021
|
+
refuseMethod(response);
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
await readJsonBody(request, MAX_JSON_BODY_BYTES).catch(() => ({
|
|
3025
|
+
ok: false,
|
|
3026
|
+
error: "ignored"
|
|
3027
|
+
}));
|
|
3028
|
+
try {
|
|
3029
|
+
await providerStore.forget();
|
|
3030
|
+
await providerStore.saveEnabledIds?.([]);
|
|
3031
|
+
const entries = await readEntries(inference);
|
|
3032
|
+
await publisher.publish(entries, [], []);
|
|
3033
|
+
} catch (error) {
|
|
3034
|
+
writeJson(response, 200, {
|
|
3035
|
+
ok: false,
|
|
3036
|
+
error: redactedError(error)
|
|
3037
|
+
}, { "cache-control": "no-store" });
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
writeJson(response, 200, await snapshot(), { "cache-control": "no-store" });
|
|
3041
|
+
}, settings.allowedHosts)
|
|
3042
|
+
}));
|
|
3043
|
+
return () => {
|
|
3044
|
+
for (const off of offs) try {
|
|
3045
|
+
off();
|
|
3046
|
+
} catch (error) {
|
|
3047
|
+
logger?.warn?.(`provider route unregister failed: ${redactedError(error)}`);
|
|
3048
|
+
}
|
|
3049
|
+
};
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
//#endregion
|
|
3053
|
+
//#region src/host/routes.ts
|
|
3054
|
+
/**
|
|
3055
|
+
* 路由族的注册门面。`apply()`(index.ts)是唯一挂载缝:装配 wiring、交给
|
|
3056
|
+
* registerRoutes;每个路由一个模块、闭包自己的 wiring 子集,绝不直接 import
|
|
3057
|
+
* 服务。注册顺序即注销顺序(teardown 按此跑)。
|
|
3058
|
+
* @module dsh-connect-modelscope-token-plan/routes
|
|
3059
|
+
*/
|
|
3060
|
+
/** 注册全部路由,返回 off() 注销回调(注册序)。M4 起含 provider 路由。 */
|
|
3061
|
+
function registerRoutes(ctx, wiring) {
|
|
3062
|
+
return [
|
|
3063
|
+
registerSnapshotRoute(ctx, wiring),
|
|
3064
|
+
registerModelsRoute(ctx, wiring),
|
|
3065
|
+
...registerTokenRoute(ctx, wiring),
|
|
3066
|
+
registerProbeRoute(ctx, wiring),
|
|
3067
|
+
registerProviderRoute(ctx, wiring)
|
|
3068
|
+
];
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
//#endregion
|
|
3072
|
+
//#region src/host/index.ts
|
|
3073
|
+
/**
|
|
3074
|
+
* dsh-connect-modelscope-token-plan — Host 半边(thin router)。
|
|
3075
|
+
*
|
|
3076
|
+
* 读魔搭 API-Inference 的**本地用量**(上游没有余额接口,见 docs/SPIKE.md)
|
|
3077
|
+
* 并经一条只读 /api 路由喂给 Client 面板。重活都在专注的兄弟模块里,本文件
|
|
3078
|
+
* 只保留 Cordis 入口(name/inject/apply)、wiring 装配与卸载副作用:
|
|
3079
|
+
*
|
|
3080
|
+
* - `host-config.ts` 配置契约 + isAdmitted 信任围栏
|
|
3081
|
+
* - `routes.ts` 四条路由的门面(snapshot/models/token/probe)
|
|
3082
|
+
* - `ms-auth.ts` 访问令牌存取(静态钥匙,无登录流)
|
|
3083
|
+
* - `inference-client.ts` 模型目录(零额度)+ probe 调用(带分诊)
|
|
3084
|
+
* - `usage-store.ts` 本地计数:天桶/单模型/事件流(原子写、版本守卫)
|
|
3085
|
+
* - `snapshot-aggregate.ts` 快照聚合(软失败)
|
|
3086
|
+
* - `state-store.ts` 状态文件原子原语
|
|
3087
|
+
* - `codes.ts` 错误分类单一真源
|
|
3088
|
+
* - `usage-observer.ts` 真实对话 → 本地计数/事件(观察流出口,见该文件头)
|
|
3089
|
+
*
|
|
3090
|
+
* @module dsh-connect-modelscope-token-plan
|
|
3091
|
+
*/
|
|
3092
|
+
/**
|
|
3093
|
+
* Host 入口:装配 wiring、注册路由、挂卸载副作用。
|
|
3094
|
+
* @param ctx - Host 根上下文。
|
|
3095
|
+
* @param config - 行的原始 patch 配置(无 schema,未校验;坏配置以
|
|
3096
|
+
* configError 经快照呈现,而不是在挂载时炸掉整台插件)。
|
|
3097
|
+
* @param deps - 测试缝(fetchImpl)。真实 Loader 不传。
|
|
3098
|
+
*/
|
|
3099
|
+
function apply(ctx, config = {}, deps = {}) {
|
|
3100
|
+
const { settings, configError } = resolveSettings(config);
|
|
3101
|
+
const tokenStore = createTokenStore({ credentials: () => ctx.get("credentials") ?? null });
|
|
3102
|
+
const profile = profileSegment(ctx);
|
|
3103
|
+
const usageStore = createFileUsageStore({
|
|
3104
|
+
name,
|
|
3105
|
+
profile,
|
|
3106
|
+
trendDays: settings.trendDays,
|
|
3107
|
+
maxEvents: settings.maxEvents,
|
|
3108
|
+
logger: ctx.logger
|
|
3109
|
+
});
|
|
3110
|
+
const inference = createInferenceClient({
|
|
3111
|
+
settings,
|
|
3112
|
+
tokenStore,
|
|
3113
|
+
deps,
|
|
3114
|
+
logger: ctx.logger
|
|
3115
|
+
});
|
|
3116
|
+
const providerStore = createFileProviderStore({
|
|
3117
|
+
profile,
|
|
3118
|
+
logger: ctx.logger
|
|
3119
|
+
});
|
|
3120
|
+
const publisher = createProviderPublisher({
|
|
3121
|
+
settings,
|
|
3122
|
+
panelSwitch: () => providerStore.enabled(),
|
|
3123
|
+
getLlm: (service) => ctx.get?.(service) ?? null,
|
|
3124
|
+
resolveApiKey: async () => (await tokenStore.resolve()).value,
|
|
3125
|
+
usage: {
|
|
3126
|
+
recordCall: (input) => usageStore.recordCall({
|
|
3127
|
+
modelId: input.modelId,
|
|
3128
|
+
tokens: input.tokens
|
|
3129
|
+
}).catch(() => {}),
|
|
3130
|
+
recordEvent: (input) => usageStore.recordEvent(input).catch(() => {})
|
|
3131
|
+
},
|
|
3132
|
+
emit: (event) => ctx.emit?.(event),
|
|
3133
|
+
logger: ctx.logger
|
|
3134
|
+
});
|
|
3135
|
+
/**
|
|
3136
|
+
* 一次目录轮询:拉目录 + 读落盘允许清单,再 publish 当前 offer。
|
|
3137
|
+
*
|
|
3138
|
+
* 允许清单必须走 `providerStore.enabledIds()`——清单是**落盘**的(provider-store
|
|
3139
|
+
* 按 profile 分段持久化),重启后内存里的 `publisher.state.enabledIds` 早已清空,
|
|
3140
|
+
* 若从它读,用户勾选的清单会随重启静默丢失(见 fix A)。
|
|
3141
|
+
* @returns {Promise<void>}
|
|
3142
|
+
*/
|
|
3143
|
+
const runPoll = async () => {
|
|
3144
|
+
try {
|
|
3145
|
+
const catalog = await inference.fetchModels();
|
|
3146
|
+
const stored = await providerStore.enabledIds().catch(() => null);
|
|
3147
|
+
const enabledIds = Array.isArray(stored) && stored.length > 0 ? stored : Array.isArray(publisher.state.enabledIds) ? publisher.state.enabledIds : [];
|
|
3148
|
+
await publisher.publish(catalog.entries, enabledIds, []);
|
|
3149
|
+
} catch {}
|
|
3150
|
+
};
|
|
3151
|
+
const wiring = {
|
|
3152
|
+
settings,
|
|
3153
|
+
configError,
|
|
3154
|
+
tokenStore,
|
|
3155
|
+
usageStore,
|
|
3156
|
+
inference,
|
|
3157
|
+
providerStore,
|
|
3158
|
+
publisher,
|
|
3159
|
+
logger: ctx.logger
|
|
3160
|
+
};
|
|
3161
|
+
const offs = registerRoutes(ctx, wiring);
|
|
3162
|
+
ctx.effect(() => {
|
|
3163
|
+
runPoll();
|
|
3164
|
+
const timer = setInterval(() => {
|
|
3165
|
+
runPoll();
|
|
3166
|
+
}, settings.pollSeconds * 1e3);
|
|
3167
|
+
return () => {
|
|
3168
|
+
clearInterval(timer);
|
|
3169
|
+
publisher.dispose();
|
|
3170
|
+
publisher.release();
|
|
3171
|
+
for (const off of offs) try {
|
|
3172
|
+
off?.();
|
|
3173
|
+
} catch (error) {
|
|
3174
|
+
ctx.logger?.warn?.(`${name}: route unregister failed: ${redactSecrets(errMsg(error))}`);
|
|
3175
|
+
}
|
|
3176
|
+
};
|
|
3177
|
+
}, `${name}: routes`);
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
//#endregion
|
|
3181
|
+
export { apply, inject, name, resolveSettings };
|