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.
@@ -0,0 +1,900 @@
1
+ //#region src/host/util.ts
2
+ /** 读一个有限的正数,否则回退值。 */
3
+ function num(value, fallback) {
4
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
5
+ }
6
+ /** 读一个非空字符串,否则回退值。 */
7
+ function str(value, fallback) {
8
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : fallback;
9
+ }
10
+ /** 读一个普通对象,否则 `{}`。 */
11
+ function obj(value) {
12
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
13
+ }
14
+ /** catch 点统一的一行错误消息。 */
15
+ function errMsg(value) {
16
+ return value instanceof Error ? value.message : String(value);
17
+ }
18
+ /** 原样读字符串(凭据专用:trim 是用户看不见的改动)。 */
19
+ function verbatim(value, fallback) {
20
+ return typeof value === "string" ? value : fallback;
21
+ }
22
+ /**
23
+ * 凭据形状字符串的脱敏闸:任何可能进日志/错误消息/面板响应的文本都先过这里。
24
+ * 家规(AGENTS.md 红线 1):凭据永不进日志。魔搭访问令牌的裸值形态
25
+ * (`ms-` + UUID)与 `sk-` 推理键、Bearer/Basic 头、常见密钥键值对都在闸内。
26
+ */
27
+ function redactSecrets(text) {
28
+ return (typeof text === "string" ? text : "").replace(/(["']?[Aa]uthorization["']?\s*[:=]\s*["']?)(?!Bearer\s)[^"',;\s]+/g, "$1[REDACTED]").replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "$1 [REDACTED]").replace(/\bms-[0-9a-f-]{16,}\b/gi, "ms-[REDACTED]").replace(/\bsk-[A-Za-z0-9._-]{8,}/g, "sk-[REDACTED]").replace(/(["']?(?:password|access_token|refresh_token|api[_-]?key|token)["']?\s*:\s*["'])[^"']+(?=["'])/gi, "$1[REDACTED]").replace(/\b(password|access_token|refresh_token|api[_-]?key|token)\s*=\s*[^&;\s]+/gi, "$1=[REDACTED]");
29
+ }
30
+ /**
31
+ * 吞掉失败但留下痕迹。opt-in 模块按设计降级,但降级的理由必须落日志
32
+ * (warn 而非 debug——debug 在默认 Host 上被滤掉等于零日志)。错误消息先
33
+ * 脱敏。返回 fallback,两种静默 catch 形态都能用。
34
+ */
35
+ function degrade(reason, error, logger, fallback) {
36
+ const detail = error == null ? "" : redactSecrets(errMsg(error));
37
+ logger?.warn?.(detail === "" ? `degraded: ${reason}` : `degraded: ${reason}: ${detail}`);
38
+ return fallback;
39
+ }
40
+ /**
41
+ * Await 一个 OPTIONAL 的 store 调用:值缺失与 promise 拒绝都读作 fallback。
42
+ * 注意守卫要套在**值**上(`Promise.resolve(value).catch(...)`)而不是调用结果上
43
+ * ——`try { await value } catch` 漏掉「value 本身是 undefined」这条。
44
+ */
45
+ function optional(value, fallback = null) {
46
+ return Promise.resolve(value).catch(() => fallback);
47
+ }
48
+ /** 带 code 的 Error:本插件所有失败的唯一构造点。 */
49
+ function pluginError(code, message, extra = {}) {
50
+ const error = new Error(message);
51
+ error.code = code;
52
+ if (extra.retryAfterMs !== void 0) error.retryAfterMs = extra.retryAfterMs;
53
+ if (extra.detail !== void 0) error.detail = extra.detail;
54
+ return error;
55
+ }
56
+
57
+ //#endregion
58
+ //#region src/host/llm-error-fix.ts
59
+ /**
60
+ * 429 误判纠正层 —— host 侧对 peer 分类器的安全覆盖。
61
+ *
62
+ * 根因(对照 peer 源码 `@deepseek-ai/dsh-llm-pi-ai` 的 `classifyPiAiError`
63
+ * 与 `@deepseek-ai/dsh-llm` 的 `isQuotaExceededError`,二者均为**非行号锚**
64
+ * 的稳定符号;其判定顺序与措辞命中面由 `test/peer-contract.test.mjs` 在
65
+ * 真 peer 可达时钉死——该契约测试依赖未 vendored 进本仓库的运行时 peer,不在
66
+ * `npm test` 离线门禁内,本模块自身的纠正判定由 `test/error-fix.test.mjs` 钉死):
67
+ * `classifyPiAiError` **先**跑 `isQuotaExceededError`(命中面极宽:`out of
68
+ * ... budget`、`balance/credits exhausted` 等),**后**才跑纯限频分支
69
+ * (`\b429\b|rate.?limit`)。凡是魔搭 429 体里带上一两个 “budget/credits/
70
+ * limit” 字眼,就被前一行抢判成 `QUOTA`;于是 `llm-retry.ts` 的
71
+ * `retryableCodes()`(刻意排除 QUOTA)对这类 429 不重试,面板又把模型按
72
+ * `exhaustedModelIds` 静默下线,对用户呈现“额度耗尽”。
73
+ *
74
+ * 本模块在 host 侧把“看似限频却被误判为 QUOTA 的 429”纠正回 `RATE_LIMIT`,
75
+ * 让退避重试真正生效;真配额耗尽(明确余额/积分耗尽的硬额度措辞)保留
76
+ * `QUOTA`(那是共享 Token Plan 池的硬耗尽,重试只会延长冷却窗口,见
77
+ * ROADMAP §1 纪律)。
78
+ *
79
+ * 设计约束(对应 AGENTS.md 红线与并行纪律):
80
+ * - 不动 vendor peer:peer 不在此插件 git 内,也不可被改。
81
+ * - 零 peer 依赖:纯函数 + 稳定协议字符串;peer 缺席(干净 checkout)时
82
+ * 模块仍加载、测试仍跑,不破坏 `npm test` 离线门禁。
83
+ * - 只重写 `finish` chunk 的 `failure.code`,保留原始 `message` 以便排查,
84
+ * 不触碰任何正常数据流,幂等(已是 RATE_LIMIT / 非 QUOTA 原样放行)。
85
+ *
86
+ * 阅读顺序:本文件自上而下是「判据词表 → 两个纯判据 → 决策组合 → 流出口
87
+ * 改写」。对外契约在底部(`reclassifyStream` / `reclassifyFinish`),想找它
88
+ * 依赖了哪些判据,就从底部逆着依赖链往上读。
89
+ *
90
+ * @module dsh-connect-modelscope-token-plan/llm-error-fix
91
+ */
92
+ /**
93
+ * 与 peer 协议对齐的失败类(稳定字符串,不 import peer 也成立)。
94
+ * `QUOTA` 来自 `@deepseek-ai/dsh-llm` 的 `QUOTA_EXCEEDED_CODE`,`RATE_LIMIT`
95
+ * 来自 peer `classifyPiAiError` 的返回字面量。
96
+ */
97
+ const CODE = Object.freeze({
98
+ /** 共享 Token Plan 池耗尽(peer 不重试,本层默认也不纠正)。 */
99
+ QUOTA: "QUOTA",
100
+ /**
101
+ * 账户级配额耗尽。peer 的 `classifyPiAiError` 目前不产生此类(它只返回
102
+ * `QUOTA_EXCEEDED_CODE`),列在此处纯为协议对齐与防御;本层不纠正它。
103
+ */
104
+ ACCOUNT_QUOTA: "ACCOUNT_QUOTA",
105
+ /** 瞬时限频(peer retryPolicy 默认重试,本层把误判体拉回这里)。 */
106
+ RATE_LIMIT: "RATE_LIMIT"
107
+ });
108
+ /** 纯文本层面的限频信号(v1 启发用)。 */
109
+ const RATE_SIGNAL = [
110
+ /\b429\b/,
111
+ /rate[_\s-]?limit/i,
112
+ /too many requests?/i,
113
+ /requests?\s+(?:per|rate|freq)/i,
114
+ /\b(?:rpm|tpm)\b/,
115
+ /throttl/i,
116
+ /请求过于频繁|限流|频率/
117
+ ];
118
+ /**
119
+ * 硬额度措辞:命中即相信是配额耗尽,不纠正(避免把真耗尽也拉去重试)。
120
+ *
121
+ * 最后两条是被 `codes.ts#classifyRateLimit` 的回归用例逼出来的:那张表的旧词表
122
+ * 里有 `"daily"`,而这里没有,于是 `"You have reached your daily limit"`(真日额度
123
+ * 耗尽)被共用后的新判据读成限频。两处现在共用这一份,缺口不会再各漂各的。
124
+ */
125
+ const HARD_QUOTA_WORDING = [
126
+ /\b(?:balance|credits?)\s+(?:exhausted|depleted)\b/i,
127
+ /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i,
128
+ /额度\s*(?:已)?\s*(?:用尽|耗尽|不足)/,
129
+ /quota\s*(?:exceeded|exhausted|reached)/i,
130
+ /\b(?:daily|per[\s_-]?day|monthly|per[\s_-]?month)\b[^\n]{0,24}?\blimit\b/i,
131
+ /(?:每日|每月)\s*(?:调用)?\s*(?:上限|额度|配额)/
132
+ ];
133
+ /** 结构化 type 分支认的「速率上限」信号:rpm/tpm/每分钟/限流/频率。 */
134
+ const RATE_CAP_WORDING = /\brpm\b|\btpm\b|rate[_\s-]?limit|per[\s_-]?(?:min|minute|sec|second)|too many|限流|频率|请求过于频繁/i;
135
+ /** 从错误文本里回捞魔搭结构化 `type` 字段(如 `"type":"quota_exceeded_error"`)。 */
136
+ const STRUCTURED_TYPE = /"type"\s*:\s*"([^"]+)"/i;
137
+ /**
138
+ * 文本是否含硬额度措辞(真耗尽,不纠正)。
139
+ *
140
+ * 导出给 `codes.ts` 的 `classifyRateLimit`——那张表曾自己维护一份同组正则,
141
+ * 于是「限频被误判成耗尽」和「耗尽被误判成限频」两个方向的漂移都可能发生。
142
+ * 词表只有这一份,改它时 `test/error-fix.test.mjs` 与 `test/provider.test.mjs`
143
+ * 会一起红。
144
+ * @param message - 平台错误文本。
145
+ * @returns `true` 表示命中硬额度措辞。
146
+ */
147
+ function hasHardQuotaWordingIn(message) {
148
+ if (typeof message !== "string" || message.length === 0) return false;
149
+ return HARD_QUOTA_WORDING.some((re) => re.test(message));
150
+ }
151
+ /**
152
+ * 一个 429 体是否“更可能是限频而非真配额耗尽”——纯文本启发(v1)。
153
+ *
154
+ * 判据:消息里出现显式限频信号({@link RATE_SIGNAL} 任一),且**没有**命中
155
+ * 硬额度措辞({@link HARD_QUOTA_WORDING})。
156
+ *
157
+ * 关键区分:“out of **rate** budget” / “rate limit budget” 是限频体,不在硬
158
+ * 额度措辞里,所以仍判为限频——这正是误判的核心。
159
+ * @param {string} message - 平台错误文本(可能含状态码与 JSON)。
160
+ * @returns {boolean} true 表示应纠正为 RATE_LIMIT。
161
+ */
162
+ function looksLikeRateLimit(message) {
163
+ if (typeof message !== "string" || message.length === 0) return false;
164
+ const m = message.toLowerCase();
165
+ if (!RATE_SIGNAL.some((re) => re.test(m))) return false;
166
+ return !hasHardQuotaWordingIn(message);
167
+ }
168
+ /**
169
+ * 从错误文本里抽出魔搭结构化 `type` 字段。
170
+ *
171
+ * peer 把整条错误 JSON 拼进 `failure.message`,所以这里能从文本回捞结构信
172
+ * 号,而不依赖 peer 是否单独透传了 `type`。抓不到返回 null。
173
+ * @param {string} message
174
+ * @returns {string|null}
175
+ */
176
+ function extractStructuredType(message) {
177
+ if (typeof message !== "string" || message.length === 0) return null;
178
+ const match = STRUCTURED_TYPE.exec(message);
179
+ return match ? match[1] ?? null : null;
180
+ }
181
+ /**
182
+ * 一个被 peer 判为 QUOTA 的失败,是否其实是限频、应纠正为 RATE_LIMIT。
183
+ *
184
+ * 这是修正 v1(纯文本启发)漏判的核心:`{"message":"rpm exhausted",
185
+ * "type":"quota_exceeded_error","code":"8"}` 这种体——魔搭把**请求速率上限**
186
+ * 复用 `quota_exceeded_error` 这个名字,纯文本里没有 “rate limit” 字样,v1 的
187
+ * `looksLikeRateLimit` 既没命中限频信号也没命中硬额度,于是留在 QUOTA、不重试、
188
+ * 直接失败。本函数改读结构化 `type`,逐层收窄:
189
+ *
190
+ * 1. `type` 含 `rate_limit` → 本就是限频(peer 多数已判 RATE_LIMIT,这里是防御)。
191
+ * 2. `type` 含 `quota` 但仍带 {@link RATE_CAP_WORDING}(rpm/tpm/每分钟/限流…)
192
+ * → 是“被错命名为 quota 的速率上限”,纠正为 RATE_LIMIT。
193
+ * 3. `type` 含 `quota` 且无任何速率信号(token/credit/balance 真耗尽)→ 保留 QUOTA。
194
+ * 4. `type` 是其它非 quota 值 → 保守不纠正。
195
+ * 5. 无结构化 `type` → 退回 v1 的 {@link looksLikeRateLimit}。
196
+ *
197
+ * 不纠正真配额耗尽:那是共享 Token Plan 池的硬耗尽,重试只会延长冷却窗口
198
+ * (ROADMAP §1 纪律),所以宁可快失败。
199
+ * @param {{code?: string, message?: string}} failure
200
+ * @returns {boolean} true 表示应纠正为 RATE_LIMIT。
201
+ */
202
+ function shouldReclassifyQuotaToRate(failure) {
203
+ if (!failure || failure.code !== CODE.QUOTA) return false;
204
+ const message = typeof failure.message === "string" ? failure.message : "";
205
+ const type = extractStructuredType(message);
206
+ if (type !== null) {
207
+ const t = type.toLowerCase();
208
+ if (/rate[_\s-]?limit/.test(t)) return true;
209
+ if (/quota/.test(t)) return RATE_CAP_WORDING.test(message);
210
+ return false;
211
+ }
212
+ return looksLikeRateLimit(message);
213
+ }
214
+ /**
215
+ * 把单个流 chunk 里的失败码在误判时纠正。
216
+ *
217
+ * 非 finish、非 error 终止、非 QUOTA,或已判为限频的,一律原样返回(新对象
218
+ * 仅在确有纠正时创建,保持调用方对引用相等的预期)。
219
+ * @param {object} chunk - harness 流协议 chunk。
220
+ * @returns {object} 原 chunk 或 code 被纠正后的新 chunk。
221
+ */
222
+ function reclassifyFinish(chunk) {
223
+ if (!isErrorFinish(chunk)) return chunk;
224
+ const errorChunk = chunk;
225
+ const reason = errorChunk.reason;
226
+ const failure = reason?.failure;
227
+ if (failure === void 0 || !shouldReclassifyQuotaToRate(failure)) return chunk;
228
+ return {
229
+ ...errorChunk,
230
+ reason: {
231
+ ...reason,
232
+ failure: {
233
+ ...failure,
234
+ code: CODE.RATE_LIMIT
235
+ }
236
+ }
237
+ };
238
+ }
239
+ /** 是否为「带 failure 的 error 型 finish chunk」——纠正层唯一关心的形状。 */
240
+ function isErrorFinish(chunk) {
241
+ if (chunk === null || typeof chunk !== "object") return false;
242
+ const candidate = chunk;
243
+ return candidate.type === "finish" && candidate.reason !== null && typeof candidate.reason === "object" && candidate.reason.kind === "error" && candidate.reason.failure != null;
244
+ }
245
+ /**
246
+ * 包裹一个 adapter 的 async iterator 流:对每个 finish chunk 做重判。
247
+ * @param {AsyncIterableIterator<object>} source - 内层 adapter 的流。
248
+ * @returns {AsyncGenerator<object>} 纠正后的流。
249
+ */
250
+ async function* reclassifyStream(source) {
251
+ for await (const chunk of source) yield reclassifyFinish(chunk);
252
+ }
253
+
254
+ //#endregion
255
+ //#region src/host/llm-models.ts
256
+ /**
257
+ * 目录条目 → pi-ai model descriptor 的映射层——魔搭 provider 注册的**纯函数半边**。
258
+ *
259
+ * 本模块刻意不 import 任何 Host peer(`@earendil-works/pi-ai`、
260
+ * `@deepseek-ai/dsh-llm-pi-ai`):它只产普通对象,所以这些映射决策可以在一个
261
+ * 干净的 checkout 上离线测。peer 依赖的另一半(把这些 descriptor 交给
262
+ * `createProvider`)在 `llm-adapter.ts`。
263
+ *
264
+ * 这里携带的三条决策是承重的,不是装饰:
265
+ *
266
+ * 1. `compat.supportsDeveloperRole: false`。pi-ai 在缺省时会**自动探测**这个
267
+ * 标志,对非标准 provider 一律猜 `true`,于是每条请求都用 `developer`
268
+ * 角色——魔搭这个多模型代理不认识,会 400 或 403。qoder 路由证明必须
269
+ * 显式写 false。
270
+ * 2. `maxTokens` 只在目录**声明了**上限时声明。harness 对未声明的 maxTokens
271
+ * 用 `defaultMaxTokens ?? 32768` 兜底,所以「不声明」不是「没有上限」,而是
272
+ * 「平台没说」的诚实处理;别猜数字填。
273
+ * 3. `reasoning: false` + **不设** `thinkingLevelMap`。魔搭 API-Inference 是
274
+ * 多模型代理,是否吃 `reasoning_effort` 取决于背后那个具体模型,本插件
275
+ * 无法离线得知每个 id 的档位表(sensenova 那份是拿真令牌逐模型探测 200/400
276
+ * 才钉出来的)。「宁可不选,不可错发」:发错档位会整条请求 400,比不发
277
+ * 差得多。M4 之后的迭代(探测出档位表)再按模型开启。
278
+ *
279
+ * @module dsh-connect-modelscope-token-plan/llm-models
280
+ */
281
+ /**
282
+ * 本插件注册 provider 用的 id。
283
+ *
284
+ * **绝不能**是裸 `"modelscope"`:操作者可能已手写一条 `llm-pi-ai`
285
+ * (apiKeyEnv `MODELSCOPE_API_KEY`、base `https://api-inference.modelscope.cn/v1`),
286
+ * `registerAdapter` 对冲突 id 直接拒绝。自有 slug 形 id 不会撞。
287
+ */
288
+ const LLM_PROVIDER_ID = "modelscope-token-plan";
289
+ /** DSH 模型选择器里显示的 provider 名。 */
290
+ const LLM_DISPLAY_NAME = "ModelScope Token Plan";
291
+ /** pi-ai 展示的凭据名(静态令牌,形如 `ms-…`)。 */
292
+ const LLM_API_KEY_NAME = "ModelScope token";
293
+ /**
294
+ * 代表「什么都不提供」的 id。
295
+ *
296
+ * 空的允许清单已经表示「不过滤」,所以还得有第二种写法表示「过滤结果为空」:
297
+ * 一个永远不可能成为真实模型 id 的字符串,作为清单里唯一的项。`filterByEnabled`
298
+ * 按一个匹配不到任何东西的 id 过滤,这就是用户要的 offer。裸 `[]` 不能同时表示
299
+ * 「全部模型」与「没有模型」。
300
+ *
301
+ * provider 路由与快照用同一个字面量(浏览器 bundle 无法 import 本模块,所以
302
+ * `client.js` 里还有一份)——两侧改名必须一起改,否则面板的「全部隐藏」会
303
+ * 静默变成「全部提供」。
304
+ */
305
+ const HIDE_ALL_MODELS = "__hide_all__";
306
+ /**
307
+ * 每 token 单价对额度制路由不可知,四处一律报零。
308
+ *
309
+ * ⚠️ 零是**哨兵**,不是「免费」。魔搭 Token Plan 是次数/额度池计费,按 token
310
+ * 的美元单价在这条路上根本不存在——但模型照样烧额度。面板行显示 "$0.00" 说的是
311
+ * 「按 token 单价未知」,永远不是「这模型不花钱」。注释就贴在这组值旁边,免得
312
+ * 后来人「好心」改成真价,或更糟改成 `null`(pi-ai 会把它当未知成本行渲染,
313
+ * 弄坏选择器的成本算术)。
314
+ */
315
+ const NO_COST = Object.freeze({
316
+ input: 0,
317
+ output: 0,
318
+ cacheRead: 0,
319
+ cacheWrite: 0
320
+ });
321
+ /**
322
+ * 目录没声明可用窗口时的兜底窗口。
323
+ *
324
+ * pi-ai 的 options 构建器会对 `model.contextWindow` 做算术,所以 undefined 被
325
+ * 当 0 算而不是「未知」,会把 max-token 计算算坏;qoder 路由因此总是给正数。
326
+ * 128k 是保守的通用默认;目录真的声明了窗口时,那个值永远赢。
327
+ */
328
+ const FALLBACK_CONTEXT_WINDOW = 128e3;
329
+ /** 判 vision 时优先读的结构化模态字段(平台拼写 + 常见别称)。 */
330
+ const INPUT_MODALITY_KEYS = Object.freeze([
331
+ "input_modalities",
332
+ "modalities",
333
+ "inputTypes",
334
+ "modality",
335
+ "capabilities"
336
+ ]);
337
+ /**
338
+ * 无结构化字段时的名字启发。
339
+ *
340
+ * 多模态模型名里 `vl` 很常见,这条启发是社区惯例。漏判 = 模型收图时报
341
+ * `UNSUPPORTED_CONTENT`,面板能看见,不算静默失败(所以方向偏「松」)。
342
+ */
343
+ const VISION_NAME_PATTERN = /(vl|vision|qwen2?\.?\d*-?vl|glm.*v|internvl|llava|pixtral)/i;
344
+ /**
345
+ * 明确是图像**生成**模型的 id/名字才排除。
346
+ *
347
+ * 魔搭目录里这些是纯生图模型,打 `/chat/completions` 会 404。没标注的一律算
348
+ * chat——缺失字段不等于不能用。
349
+ */
350
+ const IMAGE_GEN_NAME_PATTERN = /(wanx|qwen-image|cogview|flux|stable-diffusion|sdxl|sd3|txt2img|text-to-image|draw|seedream|kolors|hunyuan-?image|imagen)/i;
351
+ /**
352
+ * 人工策展的「已知多模态(能吃图)模型」清单——详情端点**不可达时**的离线兜底。
353
+ *
354
+ * 为什么还需要:魔搭 `/v1/models` 实测每条只返回 `id`/`object`/`owned_by`/`created`,
355
+ * 零模态字段(不像 sensenova 有 `type` 字段可判),名字启发也会漏掉
356
+ * `DeepSeek-V4.1-Flash` 这类「名字不含 vl/vision 但能吃图」的模型。详见 `isVisionModel`:
357
+ * 首选详情端点 `Tasks[].Name` 精确判定(image-text-to-text 即真),此清单只在详情拉取
358
+ * 失败(网络 / 限频)时兜底,避免面板在离线时完全失去视觉标签。
359
+ *
360
+ * 这份清单是「我们已知」(模型卡 / 用户反馈),不是平台声明;与名字启发一样,**不假装
361
+ * 上游返回过**。真机若某模型吃图却不在清单,最坏只是收 `UNSUPPORTED_CONTENT`,不静默
362
+ * 失败。新增已确知的多模态模型时在此追加 id 即可。
363
+ *
364
+ * 已按详情端点 `Tasks[].Name == image-text-to-text` 逐条真机核实补全(2026-10-04,api-inference
365
+ * 当前 35 条目录里共 14 条吃图模型,下面就是全部)。这样即使详情端点整段拉不到,面板也能
366
+ * 保留准确的视觉标签,不用靠名字猜——`MiniMax-M3`/`Intern-S1`/`Qwen3.5-397B-A17B`/`Step-3.7-Flash`
367
+ * 这类名字不含 vl/vision 的模型,光靠 `VISION_NAME_PATTERN` 会漏。
368
+ */
369
+ const KNOWN_VISION_IDS = Object.freeze(/* @__PURE__ */ new Set([
370
+ "deepseek-ai/DeepSeek-V4.1-Flash",
371
+ "Qwen/Qwen3.8-Flash-Next",
372
+ "MiniMax/MiniMax-M3",
373
+ "OpenGVLab/InternVL3_5-241B-A28B",
374
+ "PaddlePaddle/ERNIE-4.5-VL-28B-A3B-PT",
375
+ "Qwen/Qwen3.5-122B-A10B",
376
+ "Qwen/Qwen3.5-27B",
377
+ "Qwen/Qwen3.5-35B-A3B",
378
+ "Qwen/Qwen3.5-397B-A17B",
379
+ "Qwen/Qwen3.8-27B",
380
+ "Shanghai_AI_Laboratory/Intern-S1",
381
+ "Shanghai_AI_Laboratory/Intern-S1-mini",
382
+ "Shanghai_AI_Laboratory/Intern-S2-Preview",
383
+ "stepfun-ai/Step-3.7-Flash"
384
+ ]));
385
+ /**
386
+ * 把原始 `/v1/models` data 条目变成归一目录条目。
387
+ *
388
+ * 只做投影与回退,不做判断:`id` 必取,`name` 回退 id,`vision`/`contextWindow`/
389
+ * `maxOutputLength` 由下面的判断函数产出。`raw` 保留整条原始对象,且原始字段
390
+ * 一并展开在条目上,所以 `context_length` 这类字段下游仍能直读——平台新增字段
391
+ * 不需要改解析器。
392
+ * @param {unknown} raw - `/v1/models` `data[]` 里的一条。
393
+ * @returns {CatalogEntry} 归一后的条目。
394
+ */
395
+ function normalizeEntry(raw) {
396
+ const source = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
397
+ const id = str(source.id, "");
398
+ return {
399
+ ...source,
400
+ id,
401
+ name: str(source.name, id),
402
+ capability: resolveModelCapability(source),
403
+ vision: isVisionModel(source),
404
+ contextWindow: contextWindowOf(source),
405
+ maxOutputLength: maxOutputLengthOf(source),
406
+ raw: source
407
+ };
408
+ }
409
+ /**
410
+ * 读一条目录条目首个模态字段,取不到返回 undefined。
411
+ *
412
+ * 魔搭 `/v1/models` 是否声明 `input_modalities` 需真机核实;这里把平台拼写与
413
+ * 常见别称一并列出,字符串/数组都能吃,平台换拼写不用改解析器。
414
+ * @param {Record<string, unknown>} source - 一条目录条目。
415
+ * @returns {string[]|undefined} 模态名列表,取不到为 undefined。
416
+ */
417
+ function inputModalitiesOf(source) {
418
+ for (const key of INPUT_MODALITY_KEYS) {
419
+ const value = source[key];
420
+ if (Array.isArray(value)) return value.map((modality) => String(modality));
421
+ if (typeof value === "string" && value !== "") return value.split(/[,|]/).map((modality) => modality.trim()).filter((m) => m !== "");
422
+ }
423
+ }
424
+ /**
425
+ * 读一条目录条目从详情端点带来的任务标签(image-text-to-text 等)。
426
+ *
427
+ * `fetchModels` 会对每个 id 并行请求 modelscope.cn/api/v1/models/{id},把响应的
428
+ * `Data.Tasks[].Name`(+ `widgets[].task` 兜底)收集后挂到 `entry.tasks`。取不到或
429
+ * 非数组返回 undefined,调用方据此回退策展清单 + 名字启发。
430
+ * @param {Record<string, unknown>} source - 一条目录条目。
431
+ * @returns {string[]|undefined} 任务标签列表,取不到为 undefined。
432
+ */
433
+ function tasksOf(source) {
434
+ const value = source.tasks;
435
+ if (Array.isArray(value)) {
436
+ const out = value.map((task) => String(task)).filter((task) => task !== "");
437
+ return out.length > 0 ? out : void 0;
438
+ }
439
+ }
440
+ /** 任务标签 → 能力。键为平台返回的小写任务名,词表见上方说明。 */
441
+ const TASK_TO_CAPABILITY = Object.freeze({
442
+ "text-generation": "text",
443
+ "text2text-generation": "text",
444
+ "image-text-to-text": "vision-input",
445
+ "visual-question-answering": "vision-input",
446
+ "image-to-text": "image-to-text",
447
+ "image-caption": "image-to-text",
448
+ "image-to-image": "image-to-image",
449
+ "text-to-image-synthesis": "text-to-image",
450
+ "text-to-video-synthesis": "text-to-video"
451
+ });
452
+ /** 能力优先级:图像类信号比「纯文本」具体,多个标签时取最具体的一个。 */
453
+ const CAPABILITY_RANK = Object.freeze({
454
+ unknown: 0,
455
+ text: 1,
456
+ "vision-input": 3,
457
+ "image-to-text": 3,
458
+ "image-to-image": 4,
459
+ "text-to-image": 5,
460
+ "text-to-video": 5
461
+ });
462
+ /** 从任务标签数组取能力;一个都不认识返回 undefined(调用方再走兜底)。 */
463
+ function capabilityFromTasks(tasks) {
464
+ let best;
465
+ let bestRank = -1;
466
+ for (const task of tasks) {
467
+ const capability = TASK_TO_CAPABILITY[task.trim().toLowerCase()];
468
+ if (capability === void 0) continue;
469
+ if (CAPABILITY_RANK[capability] > bestRank) {
470
+ best = capability;
471
+ bestRank = CAPABILITY_RANK[capability];
472
+ }
473
+ }
474
+ return best;
475
+ }
476
+ /**
477
+ * 解析一条目录条目的能力类型。
478
+ *
479
+ * 判定顺序(权威 → 兜底):结构化模态字段 → 详情端点任务标签 → 策展清单 → 名字启发。
480
+ * 前两级是平台声明,后两级是「我们已知 / 我们推断」,绝不混淆。
481
+ * @param {object} entry - 一条归一目录条目。
482
+ * @returns {ModelCapability} 能力类型。
483
+ */
484
+ function resolveModelCapability(entry) {
485
+ const source = entry ?? {};
486
+ const modalities = inputModalitiesOf(source);
487
+ if (modalities !== void 0) return modalities.some((modality) => /image|vision/i.test(modality)) ? "vision-input" : "text";
488
+ const tasks = tasksOf(source);
489
+ if (tasks !== void 0) {
490
+ const capability = capabilityFromTasks(tasks);
491
+ if (capability !== void 0) return capability;
492
+ return "unknown";
493
+ }
494
+ const id = str(source.id, "");
495
+ if (id !== "" && KNOWN_VISION_IDS.has(id)) return "vision-input";
496
+ const idOrName = `${id} ${str(entry?.name, "")}`;
497
+ if (VISION_NAME_PATTERN.test(idOrName)) return "vision-input";
498
+ return "unknown";
499
+ }
500
+ /**
501
+ * 一条目录条目能否吃图(vision)。
502
+ *
503
+ * 是 `resolveModelCapability` 的布尔投影:图进文出(`vision-input`)或图出文
504
+ * (`image-to-text`/caption)算视觉模型;图出图(`image-to-image`)、文生图
505
+ * (`text-to-image`)**不算**——它们不吃图,别混进 vision 开关。
506
+ * 漏判 = 模型收图时报 `UNSUPPORTED_CONTENT`,面板能看见,不算静默失败。
507
+ * @param {object} entry - 一条归一目录条目。
508
+ * @returns {boolean} 是否吃图。
509
+ */
510
+ function isVisionModel(entry) {
511
+ const capability = resolveModelCapability(entry);
512
+ return capability === "vision-input" || capability === "image-to-text";
513
+ }
514
+ /**
515
+ * 一条目录条目能否作为 chat 模型寻址。
516
+ *
517
+ * **宽松方向**。只有明确是图像**生成**模型的 id/名字(`IMAGE_GEN_NAME_PATTERN`)
518
+ * 或 `output_modalities` 只含 `image` 才排除:魔搭目录里这些是纯生图模型,打
519
+ * `/chat/completions` 会 404。没标注的一律算 chat——缺失字段不等于不能用。
520
+ * @param {object} entry - 一条归一目录条目。
521
+ * @returns {boolean} 是否可用作 chat 模型。
522
+ */
523
+ function isChatModel(entry) {
524
+ const out = (entry ?? {}).output_modalities;
525
+ if (Array.isArray(out) && out.length > 0) {
526
+ if (out.every((modality) => String(modality).toLowerCase() === "image")) return false;
527
+ }
528
+ const idOrName = `${str(entry?.id, "")} ${str(entry?.name, "")}`;
529
+ return !IMAGE_GEN_NAME_PATTERN.test(idOrName);
530
+ }
531
+ /**
532
+ * 读目录声明的上下文窗口,取不到用兜底。
533
+ *
534
+ * 命中一个正数就用;否则 `FALLBACK_CONTEXT_WINDOW`——pi-ai 对 undefined 的
535
+ * contextWindow 当 0 算,会坏掉 max-token 计算。与 `contextWindowOf` 同一份
536
+ * 拼写策略:只认已知拼写,缺字段回退,不猜数字。
537
+ * @param {object} entry - 一条归一目录条目。
538
+ * @returns {number} 声明的窗口,或兜底值。
539
+ */
540
+ function contextWindowOf(entry) {
541
+ for (const key of [
542
+ "context_length",
543
+ "context_window",
544
+ "contextWindow",
545
+ "max_context_tokens"
546
+ ]) {
547
+ const value = Math.floor(num(entry?.[key], 0));
548
+ if (value > 0) return value;
549
+ }
550
+ return FALLBACK_CONTEXT_WINDOW;
551
+ }
552
+ /**
553
+ * 读目录声明的单次输出上限,0 = 未声明。
554
+ *
555
+ * 这个数**只用于展示/投影**。descriptor 只在 >0 时声明 `maxTokens`(见模块头注
556
+ * 第 2 条),所以它从不变成请求参数——它说的是平台至多能吐多少,让用户明白为什么
557
+ * 长回答仍会以 `finish_reason: length` 停住。
558
+ * @param {object} entry - 一条归一目录条目。
559
+ * @returns {number} 声明的上限,未声明为 0。
560
+ */
561
+ function maxOutputLengthOf(entry) {
562
+ for (const key of [
563
+ "max_output_length",
564
+ "maxOutputLength",
565
+ "max_output_tokens"
566
+ ]) {
567
+ const value = Math.floor(num(entry?.[key], 0));
568
+ if (value > 0) return value;
569
+ }
570
+ return 0;
571
+ }
572
+ /**
573
+ * 把一条目录条目映射成适配器交给 pi-ai 的 model descriptor。
574
+ *
575
+ * vision 用的是与快照/roster 完全相同的判定(`isVisionModel`),所以模型选择器
576
+ * 不可能与面板的 vision 清单就「哪些模型能吃图」意见相反。
577
+ * @param {object} entry - 一条归一目录条目(必须带 `id`)。
578
+ * @param {object} [options] - 装配参数。
579
+ * @param {string} [options.providerId] - 该 descriptor 所属的 provider id。
580
+ * @param {string} [options.baseUrl] - OpenAI 兼容源站。
581
+ * @returns {object} pi-ai descriptor。
582
+ */
583
+ function toPiDescriptor(entry, options = {}) {
584
+ const { providerId = LLM_PROVIDER_ID, baseUrl } = options;
585
+ const id = str(entry?.id, "");
586
+ if (id === "") throw new Error("toPiDescriptor: catalog entry has no id");
587
+ const vision = isVisionModel(entry);
588
+ const maxOutputLength = maxOutputLengthOf(entry);
589
+ return {
590
+ id,
591
+ name: str(entry.name, id),
592
+ api: "openai-completions",
593
+ provider: providerId,
594
+ baseUrl,
595
+ input: vision ? ["text", "image"] : ["text"],
596
+ reasoning: false,
597
+ cost: { ...NO_COST },
598
+ contextWindow: contextWindowOf(entry),
599
+ ...maxOutputLength > 0 ? { maxTokens: maxOutputLength } : {},
600
+ compat: {
601
+ maxTokensField: "max_tokens",
602
+ supportsDeveloperRole: false
603
+ }
604
+ };
605
+ }
606
+ /**
607
+ * 允许清单的三态归一(§11 的 `allowed`)。
608
+ *
609
+ * 全插件只有这一处口径,快照(`snapshot-aggregate.ts`)与 provider 路由
610
+ * (`routes/provider.ts`)都调它,杜绝两处各自写一套分类导致口径漂移:
611
+ *
612
+ * - 空清单 → `"all"`(不过滤,提供全部模型);
613
+ * - 含哨兵 `HIDE_ALL_MODELS` → `"none"`(什么都不提供);
614
+ * - 其余非空清单 → `"list"`(严格白名单)。
615
+ *
616
+ * 空清单已经占用了「不过滤」,所以「什么都不提供」必须有第二种拼写——这正是
617
+ * `HIDE_ALL_MODELS` 存在的原因。注意「含哨兵但还塞了别的 id」仍按 `"none"` 处理
618
+ * (哨兵语义优先,与 `filterByEnabled` 的「匹配不到任何真实 id」完全一致)。
619
+ * @param {string[]} enabledIds - 允许清单。
620
+ * @returns {"all"|"none"|"list"}
621
+ */
622
+ function resolveAllowedList(enabledIds) {
623
+ if (!Array.isArray(enabledIds) || enabledIds.length === 0) return "all";
624
+ if (enabledIds.includes("__hide_all__")) return "none";
625
+ return "list";
626
+ }
627
+ /**
628
+ * 把目录缩到用户允许的模型子集。
629
+ *
630
+ * **空清单 = 不过滤**:新装没做过选择,仍应提供全部模型。一旦非空就是严格白
631
+ * 名单;名单里点名了本目录没有的 id 也无害——它只是匹配不到东西。
632
+ *
633
+ * `HIDE_ALL_MODELS` 哨兵靠「匹配不到任何真实 id」表达「什么都不提供」,因为空
634
+ * 清单已经被「不过滤」占用了。这里直接走 {@link resolveAllowedList} 的三态:
635
+ * 含哨兵的清单属 `"none"`,过滤结果必然是空(哨兵不是真实 id),与
636
+ * `isModelEnabled` 的口径一致(fix D)。
637
+ * @param {object[]} entries - 归一目录条目。
638
+ * @param {string[]} [enabledIds] - 允许清单;空/缺省关闭过滤。
639
+ * @returns {object[]} 仍被提供的条目,按目录顺序。
640
+ */
641
+ function filterByEnabled(entries, enabledIds) {
642
+ const list = Array.isArray(enabledIds) ? enabledIds : [];
643
+ if (list.length === 0) return Array.isArray(entries) ? entries : [];
644
+ const allow = new Set(list.map((id) => str(id, "")));
645
+ return (Array.isArray(entries) ? entries : []).filter((entry) => allow.has(str(entry?.id, "")));
646
+ }
647
+ /**
648
+ * 为一份目录构建整份 descriptor 列表。
649
+ *
650
+ * 没有 id 的条目丢弃(线上没法寻址),重复 id 保留末次出现,与 `rosterOf` 的
651
+ * 去重一致。`unavailableModelIds` 里的(额度耗尽的)模型丢弃——面板经
652
+ * `rosterWithAvailability` 仍显示它们,灰显加原因,但 picker 不提供注定 429 的
653
+ * 请求;两处口径不同是故意的,别统一。
654
+ * @param {object[]} entries - 归一目录条目。
655
+ * @param {object} options - `{ providerId, baseUrl, enabledIds, unavailableModelIds }`。
656
+ * @returns {object[]} pi-ai descriptor,按首见顺序。
657
+ */
658
+ function buildDescriptors(entries, options = {}) {
659
+ const { providerId = LLM_PROVIDER_ID, baseUrl, enabledIds = [], unavailableModelIds = [] } = options;
660
+ const filtered = filterByEnabled(entries, enabledIds).filter((entry) => isChatModel(entry));
661
+ const blocked = new Set(Array.isArray(unavailableModelIds) ? unavailableModelIds : []);
662
+ const seen = /* @__PURE__ */ new Map();
663
+ const out = [];
664
+ for (const entry of Array.isArray(filtered) ? filtered : []) {
665
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue;
666
+ const catalogEntry = entry;
667
+ const id = str(catalogEntry.id, "");
668
+ if (id === "") continue;
669
+ if (blocked.has(id)) continue;
670
+ if (!seen.has(id)) {
671
+ seen.set(id, out.length);
672
+ out.push(void 0);
673
+ }
674
+ out[seen.get(id)] = toPiDescriptor({
675
+ ...catalogEntry,
676
+ id
677
+ }, {
678
+ providerId,
679
+ ...baseUrl !== void 0 ? { baseUrl } : {}
680
+ });
681
+ }
682
+ return out;
683
+ }
684
+ /**
685
+ * 带额度可用性的面板 roster。
686
+ *
687
+ * 与 {@link rosterOf} 一样每 chat 模型 id 一行,但每行还带该模型额度池是否耗尽。
688
+ * 与 **picker**(`buildDescriptors` 直接丢弃耗尽模型,避免发注定 429 的请求)不同,
689
+ * 面板保留它们在清单里、灰显,让用户看得见「为什么模型从选择器里不见了」。
690
+ * @param {object[]} entries - 归一目录条目。
691
+ * @param {string[]} unavailableIds - 额度耗尽的模型 id;空数组 = 全部可用。
692
+ * @returns {{id: string, name: string, vision: boolean, capability: ModelCapability, available: boolean, quotaExhausted: boolean, contextWindow: number, maxOutputLength: number}[]}
693
+ */
694
+ function rosterWithAvailability(entries, unavailableIds) {
695
+ const blocked = new Set(Array.isArray(unavailableIds) ? unavailableIds : []);
696
+ const position = /* @__PURE__ */ new Map();
697
+ const out = [];
698
+ for (const entry of Array.isArray(entries) ? entries : []) {
699
+ if (!isChatModel(entry)) continue;
700
+ const id = str(entry?.id, "");
701
+ if (id === "") continue;
702
+ const row = {
703
+ id,
704
+ name: str(entry?.name, id),
705
+ vision: isVisionModel(entry),
706
+ capability: resolveModelCapability(entry),
707
+ available: !blocked.has(id),
708
+ quotaExhausted: blocked.has(id),
709
+ contextWindow: contextWindowOf(entry),
710
+ maxOutputLength: maxOutputLengthOf(entry)
711
+ };
712
+ if (position.has(id)) out[position.get(id)] = row;
713
+ else {
714
+ position.set(id, out.length);
715
+ out.push(row);
716
+ }
717
+ }
718
+ return out;
719
+ }
720
+
721
+ //#endregion
722
+ //#region src/host/host-config.ts
723
+ /**
724
+ * 配置默认值。`CONFIG_DEFAULTS` 与 `cordis.patch.yml` 由 test/config.test.mjs
725
+ * 双向钉住,代码与文档不会静默漂移。
726
+ *
727
+ * 这里**没有**额度常数(社区快照的「每日 N 次 / 单模型 N 次」):官方改用
728
+ * 「魔粒」计费后,面板头条是官方魔粒余额,次数口径的推算条已删除(见 README
729
+ * 「三条事实」第2 条),而本插件的独家数据是分布 / 趋势 / 429 事件流——没有
730
+ * 「上限」可与之相比。留着两个无人消费的常数只会在有人读配置时以为面板会拿它
731
+ * 算「剩余次数」。真要加阈值提醒时,那应该是读官方余额的差值,不是这两个数。
732
+ * @module dsh-connect-modelscope-token-plan/host-config
733
+ */
734
+ /**
735
+ * 本插件所有可寻址面的唯一 slug:/api 路由前缀、状态目录名、凭据记录命名
736
+ * 空间都从它派生。改名必须连着用户已存的数据一起搬,不是改文本。
737
+ * test/config.test.mjs 钉住 package.json#name 与 patch 行的 id/name 字面量。
738
+ */
739
+ const name = "dsh-connect-modelscope-token-plan";
740
+ /** Cordis 硬依赖:没有 webServer 本插件保持不活动。 */
741
+ const inject = ["webServer"];
742
+ /**
743
+ * 快照里的插件版本。单一来源本可以是 package.json,但 lib 打包把它内联进
744
+ * 产物反而引入第二份真源;这里用常量、由 test/config.test.mjs 钉住与
745
+ * package.json 一致。
746
+ */
747
+ const PLUGIN_VERSION = "0.1.0";
748
+ /**
749
+ * 默认值。刻意不含额度常数——理由见本文件头。
750
+ * 面板头条是官方魔粒余额,次数口径的推算已在面板收敛时删除,这里若留着
751
+ * dailyQuotaTotal 之类的社区快照,只会成为「看起来能算剩余次数」的假线索。
752
+ */
753
+ const CONFIG_DEFAULTS = Object.freeze({
754
+ /** OpenAI 兼容推理源站(模型目录 + probe + provider 直连)。 */
755
+ apiBase: "https://api-inference.modelscope.cn/v1",
756
+ /** 站点源站,只用于面板外链(访问令牌页 / 文档)。 */
757
+ siteBase: "https://modelscope.cn",
758
+ /** 本地趋势保留天数(usage-store 天桶数量;超出即修剪)。 */
759
+ trendDays: 14,
760
+ /** Host 侧对 /v1/models 的缓存秒数。 */
761
+ cacheSeconds: 60,
762
+ /** 面板轮询间隔——由 Host 下发、面板跟随,两端不各自假设。 */
763
+ pollSeconds: 30,
764
+ /** 单次魔搭请求超时(毫秒)。 */
765
+ inferenceTimeoutMs: 3e4,
766
+ /** 事件流保留条数(429/错误事件的环形上限)。 */
767
+ maxEvents: 50,
768
+ /** provider 注册的 patch 默认值(面板保存值优先于它)。 */
769
+ registerProvider: false,
770
+ /** Host 应答的默认主机名;操作者的列表是「只增不替」。 */
771
+ admittedHosts: [
772
+ "localhost",
773
+ "127.0.0.1",
774
+ "[::1]",
775
+ "::1"
776
+ ]
777
+ });
778
+ /** 把原始数值夹到有效整数(floor → 下限 → 上限;非正/非有限回退 def)。 */
779
+ function clampInt(raw, def, min, max = Infinity) {
780
+ return Math.min(max, Math.max(min, Math.floor(num(raw, def))));
781
+ }
782
+ /**
783
+ * 端点必须是合法的 http(s) 绝对地址;不是就在**挂载时**抛(resolveSettings
784
+ * 捕获后转成 configError 经快照呈现),而不是等到第一次轮询才变成莫名其妙的
785
+ * 网络失败。
786
+ */
787
+ function assertHttpUrl(value, field) {
788
+ let parsed;
789
+ try {
790
+ parsed = new URL(value);
791
+ } catch {
792
+ throw new Error(`${field} is not an absolute URL: ${JSON.stringify(value)}`);
793
+ }
794
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${field} must be an http(s) URL, got ${parsed.protocol}`);
795
+ }
796
+ /**
797
+ * 把 patch 行的原始配置解析成有效设置。
798
+ *
799
+ * 坏行不许从这里抛出去——apply 在挂载期跑,抛错等于整机插件消失。问题以
800
+ * `configError` 返回,经快照路由呈现为一个能自我解释的面板。
801
+ */
802
+ function resolveSettings(config) {
803
+ const source = obj(config);
804
+ try {
805
+ const apiBase = str(source.apiBase, CONFIG_DEFAULTS.apiBase).replace(/\/+$/, "");
806
+ const siteBase = str(source.siteBase, CONFIG_DEFAULTS.siteBase).replace(/\/+$/, "");
807
+ assertHttpUrl(apiBase, "apiBase");
808
+ assertHttpUrl(siteBase, "siteBase");
809
+ return {
810
+ settings: {
811
+ apiBase,
812
+ siteBase,
813
+ trendDays: clampInt(source.trendDays, CONFIG_DEFAULTS.trendDays, 1, 365),
814
+ cacheSeconds: clampInt(source.cacheSeconds, CONFIG_DEFAULTS.cacheSeconds, 5, 600),
815
+ pollSeconds: clampInt(source.pollSeconds, CONFIG_DEFAULTS.pollSeconds, 5, 3600),
816
+ inferenceTimeoutMs: clampInt(source.inferenceTimeoutMs, CONFIG_DEFAULTS.inferenceTimeoutMs, 1e3, 6e5),
817
+ maxEvents: clampInt(source.maxEvents, CONFIG_DEFAULTS.maxEvents, 1, 1e3),
818
+ registerProvider: source.registerProvider === true,
819
+ allowedHosts: resolveAllowedHosts(source)
820
+ },
821
+ configError: null
822
+ };
823
+ } catch (error) {
824
+ return {
825
+ settings: {
826
+ apiBase: CONFIG_DEFAULTS.apiBase,
827
+ siteBase: CONFIG_DEFAULTS.siteBase,
828
+ trendDays: CONFIG_DEFAULTS.trendDays,
829
+ cacheSeconds: CONFIG_DEFAULTS.cacheSeconds,
830
+ pollSeconds: CONFIG_DEFAULTS.pollSeconds,
831
+ inferenceTimeoutMs: CONFIG_DEFAULTS.inferenceTimeoutMs,
832
+ maxEvents: CONFIG_DEFAULTS.maxEvents,
833
+ registerProvider: false,
834
+ allowedHosts: new Set(CONFIG_DEFAULTS.admittedHosts)
835
+ },
836
+ configError: redactSecrets(errMsg(error))
837
+ };
838
+ }
839
+ }
840
+ /**
841
+ * 收集本 Host 应答的主机名。操作者列表 **加** 到默认集上,绝不替换。
842
+ */
843
+ function resolveAllowedHosts(source) {
844
+ const admitted = new Set(CONFIG_DEFAULTS.admittedHosts);
845
+ const extra = Array.isArray(source.allowedHosts) ? source.allowedHosts : [];
846
+ for (const entry of extra) {
847
+ const name = str(entry, "").trim().toLowerCase();
848
+ if (name !== "") admitted.add(name);
849
+ }
850
+ return admitted;
851
+ }
852
+ /**
853
+ * 从 Host 头里剥端口;非法形状返回空串(被 {@link isAdmitted} 拒绝)。
854
+ *
855
+ * IPv6 字面量必须**整体**匹配 `[...]` 或 `[...]:port`:方括号闭合之后只允许空
856
+ * 或 `:端口`,其余一律拒绝。曾经这里只「截断到第一个 `]` 就返回」,于是
857
+ * `Host: [::1]evil.com` 读成 `[::1]` 并**通过**围栏——白名单是精确集合,前缀
858
+ * 匹配等于把它放宽成「以白名单成员开头」。
859
+ */
860
+ function hostName(host) {
861
+ if (host.startsWith("[")) {
862
+ const end = host.indexOf("]");
863
+ if (end < 0) return "";
864
+ const rest = host.slice(end + 1);
865
+ if (rest !== "" && !/^:\d{1,5}$/.test(rest)) return "";
866
+ return host.slice(0, end + 1);
867
+ }
868
+ if (host.includes("]")) return "";
869
+ const colons = host.split(":");
870
+ if (colons.length > 2) {
871
+ const penultimate = colons[colons.length - 2] ?? "";
872
+ const last = colons[colons.length - 1] ?? "";
873
+ if (/^\d+$/.test(penultimate) && /^\d+$/.test(last)) return host.slice(0, host.lastIndexOf(":"));
874
+ return host;
875
+ }
876
+ return colons.length > 1 ? host.slice(0, host.lastIndexOf(":")) : host;
877
+ }
878
+ /**
879
+ * 路由的信任围栏。两攻击两事实:
880
+ * 1. DNS rebinding——攻击页把域名 rebind 到 127.0.0.1,此时 Origin 与 Host
881
+ * 互相一致,比较两者无意义;Host 头是浏览器唯一伪造不了的,对白名单查。
882
+ * 2. 跨站伪造——Host 本来就合法,暴露它的是 Origin。
883
+ * 所以:Host 必须在白名单内,且带 Origin 时必须与 Host 一致;无 Origin 的
884
+ * 普通同源 GET 放行。围栏的边界是**浏览器**不是本机——本机进程两者都能伪造。
885
+ */
886
+ function isAdmitted(request, allowedHosts) {
887
+ const host = str(request.headers?.host, "").toLowerCase();
888
+ if (host === "" || !allowedHosts.has(hostName(host))) return false;
889
+ const origin = request.headers?.origin;
890
+ if (typeof origin !== "string" || origin === "") return true;
891
+ if (origin === "null") return false;
892
+ try {
893
+ return new URL(origin).host === host;
894
+ } catch {
895
+ return false;
896
+ }
897
+ }
898
+
899
+ //#endregion
900
+ export { redactSecrets as C, pluginError as S, verbatim as T, reclassifyStream as _, resolveSettings as a, obj as b, LLM_PROVIDER_ID as c, normalizeEntry as d, resolveAllowedList as f, looksLikeRateLimit as g, hasHardQuotaWordingIn as h, name as i, buildDescriptors as l, CODE as m, inject as n, LLM_API_KEY_NAME as o, rosterWithAvailability as p, isAdmitted as r, LLM_DISPLAY_NAME as s, PLUGIN_VERSION as t, isVisionModel as u, degrade as v, str as w, optional as x, errMsg as y };