dsh-router-core 0.3.20 → 0.4.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/README.md +77 -34
- package/docs/pool-sticky-block.md +158 -0
- package/lib/client-registry.js +351 -407
- package/lib/client.js +351 -407
- package/lib/index.js +398 -176
- package/lib/types/ext/contract.d.ts +0 -7
- package/lib/types/host-version.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/router/account-pool.d.ts +53 -18
- package/lib/types/router/index.d.ts +4 -7
- package/lib/types/router/prefix-affinity.d.ts +18 -1
- package/lib/types/router/types.d.ts +7 -4
- package/lib/types/router/usage-store.d.ts +12 -0
- package/lib/types/shared.d.ts +13 -21
- package/lib/types/supplier-config.d.ts +0 -3
- package/lib/types/suppliers/contract.d.ts +2 -2
- package/lib/types/suppliers/registry.d.ts +0 -1
- package/package.json +12 -13
- package/src/client/CombosTab.tsx +8 -53
- package/src/client/ExtDetail.tsx +15 -178
- package/src/client/LastHitDock.tsx +141 -0
- package/src/client/StatsTab.tsx +8 -6
- package/src/client/SupplierDetail.tsx +2 -34
- package/src/client/index.tsx +45 -0
- package/src/client/last-hit-store.ts +89 -0
- package/src/client/model-hint-copy.test.ts +7 -2
- package/src/client/model-hint-copy.ts +2 -2
- package/src/client/router.css +2106 -1630
- package/src/ext/contract.ts +0 -7
- package/src/host-version.test.ts +61 -0
- package/src/host-version.ts +101 -0
- package/src/index.ts +133 -59
- package/src/llm/adapter.test.ts +128 -1
- package/src/llm/adapter.ts +48 -3
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/disconnect-leak.test.ts +50 -0
- package/src/router/index.ts +38 -31
- package/src/router/models-cache.test.ts +9 -9
- package/src/router/precise-dispatch.test.ts +6 -6
- package/src/router/prefix-affinity.ts +22 -1
- package/src/router/types.ts +7 -5
- package/src/router/usage-store.ts +16 -0
- package/src/router/usage.test.ts +1 -1
- package/src/shared.ts +13 -24
- package/src/supplier-config.ts +2 -13
- package/src/suppliers/contract.ts +2 -2
- package/src/suppliers/loader.test.ts +2 -2
- package/src/suppliers/registry.ts +1 -20
- package/src/types.d.ts +2 -1
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
2
3
|
import Schema from "@deepseek-ai/schemastery";
|
|
3
4
|
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -68,7 +69,27 @@ function textOf(content) {
|
|
|
68
69
|
return s;
|
|
69
70
|
}
|
|
70
71
|
/**
|
|
71
|
-
*
|
|
72
|
+
* 算请求的亲和指纹(选号主键)。
|
|
73
|
+
*
|
|
74
|
+
* **身份优先,内容兜底**:
|
|
75
|
+
* 1. 宿主给了会话身份(`session`,来自 dsh-llm 的 `sessionId`)→ 直接用它。
|
|
76
|
+
* 精确、零撞车 —— 同一会话永不漂移,不同会话(含 team 各成员)天然隔离。
|
|
77
|
+
* 2. 没有身份(外部 OpenAI 客户端)→ 回落 {@link prefixFingerprint}。
|
|
78
|
+
*
|
|
79
|
+
* 为什么优先身份而不是内容指纹:DSH 的 system prompt 是同一个 agent preset
|
|
80
|
+
* 生成的几 KB 文本,内容指纹只取前 512 字符 → 各会话高度雷同 → 撞车导致
|
|
81
|
+
* 绑定互相覆盖、同会话漂移(team 多成员时实测过)。
|
|
82
|
+
*
|
|
83
|
+
* @param session 宿主会话身份;无则 undefined
|
|
84
|
+
* @param messages OpenAI 请求体的 `messages`(无身份时才用)
|
|
85
|
+
* @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
|
|
86
|
+
*/
|
|
87
|
+
function sessionFingerprint(session, messages) {
|
|
88
|
+
if (session !== void 0 && session !== "") return `sid:${session}`;
|
|
89
|
+
return prefixFingerprint(messages);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 算请求的前缀指纹(内容兜底)。
|
|
72
93
|
* @param messages OpenAI 请求体的 `messages` 字段(未校验,来自外部输入)
|
|
73
94
|
* @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
|
|
74
95
|
*/
|
|
@@ -184,9 +205,9 @@ var AccountPool = class {
|
|
|
184
205
|
rrCursor = 0;
|
|
185
206
|
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
186
207
|
blocks = /* @__PURE__ */ new Map();
|
|
187
|
-
/** (supplier, model, uid) →
|
|
208
|
+
/** (supplier, model, uid[, session]) → 该号在某会话下的缓存质量样本。 */
|
|
188
209
|
samples = /* @__PURE__ */ new Map();
|
|
189
|
-
/**
|
|
210
|
+
/** 同上键 → 缓存无效降权。 */
|
|
190
211
|
demoted = /* @__PURE__ */ new Map();
|
|
191
212
|
/** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
|
|
192
213
|
affinity = /* @__PURE__ */ new Map();
|
|
@@ -196,6 +217,22 @@ var AccountPool = class {
|
|
|
196
217
|
key(model, uid) {
|
|
197
218
|
return `${this.supplierId}${SEP}${model}${SEP}${uid}`;
|
|
198
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* 缓存质量样本/降权的键。
|
|
222
|
+
*
|
|
223
|
+
* **按会话隔离**(team 场景的关键):同一个号被多个会话共用时,各会话的
|
|
224
|
+
* 前缀会互相驱逐,把它们混在一起算命中率,会把「多会话互踩」误判成
|
|
225
|
+
* 「这个号缓存坏了」→ 错误降权 → 只剩一个号 → 那个号又被打低 → 降权串。
|
|
226
|
+
* 所以有会话身份时按 (model, uid, session) 记,降权只反映**该会话自己**
|
|
227
|
+
* 在这个号上的真实缓存表现。
|
|
228
|
+
*
|
|
229
|
+
* 无会话身份(外部 OpenAI 客户端,走无指纹回退路径)→ 保持 (model, uid),
|
|
230
|
+
* 与块轮询的原语义一致(那时本就是单会话视角)。
|
|
231
|
+
*/
|
|
232
|
+
sampleKey(model, uid, session) {
|
|
233
|
+
const base = this.key(model, uid);
|
|
234
|
+
return session === void 0 || session === "" ? base : `${base}${SEP}${session}`;
|
|
235
|
+
}
|
|
199
236
|
entry(model, uid) {
|
|
200
237
|
const k = this.key(model, uid);
|
|
201
238
|
let e = this.cooldowns.get(k);
|
|
@@ -238,48 +275,98 @@ var AccountPool = class {
|
|
|
238
275
|
return c.until <= now;
|
|
239
276
|
}
|
|
240
277
|
/**
|
|
241
|
-
*
|
|
278
|
+
* 为「某个模型」选一个健康账号 —— **会话亲和选号,无策略旋钮**。
|
|
242
279
|
*
|
|
243
|
-
* round-robin
|
|
280
|
+
* 旧的两策略(fallback 取第一个 / round-robin 轮转)都删了:前者让所有
|
|
281
|
+
* 会话挤同一个号(team/多会话必互踩),后者的"轮转"与亲和"同会话永不换号"
|
|
282
|
+
* 直接冲突。正确模型只有一条:
|
|
244
283
|
*
|
|
245
284
|
* 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
|
|
246
|
-
*
|
|
247
|
-
* 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
|
|
248
|
-
* 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
|
|
285
|
+
* 同一个会话的连续请求**永远落回同一个号**,前缀在该号缓存里只写一份。
|
|
249
286
|
* 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
|
|
250
287
|
* 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
|
|
251
|
-
* 3.
|
|
252
|
-
*
|
|
288
|
+
* 3. 指纹算不出(拿不到 messages)→ **块轮询兜底**(§2 旧行为)。这不是
|
|
289
|
+
* "策略",是无会话身份时的替身。
|
|
290
|
+
* 4. 块统计不驱动轮转(亲和已保证驻留),只保留 `BLOCK_MAX` 次不达标 →
|
|
291
|
+
* 降权该号(缓存无效,可恢复)。
|
|
253
292
|
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
293
|
+
* 天花板:号数 < 并发会话数时必然有会话共用号 → 互踩,无法用选号手法消除
|
|
294
|
+
* (缓存空间是账号侧物理限制)。见 docs/pool-sticky-block.md §10。
|
|
256
295
|
*
|
|
257
296
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
258
297
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
259
|
-
* @param strategy fallback 取第一个健康 / round-robin 前缀亲和
|
|
260
298
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
261
|
-
* @param messages 请求体的 messages
|
|
299
|
+
* @param messages 请求体的 messages(算亲和指纹的内容兜底)
|
|
300
|
+
* @param session 宿主会话身份;给了就以它为主键(精确、零撞车)
|
|
262
301
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
263
302
|
*/
|
|
264
|
-
pick(accounts, poolOrder,
|
|
303
|
+
pick(accounts, poolOrder, modelId, messages, session) {
|
|
265
304
|
const now = Date.now();
|
|
266
305
|
const byUid = new Map(accounts.map((a) => [a.uid, a]));
|
|
267
306
|
const healthy = [...poolOrder.filter((uid) => byUid.has(uid)), ...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid))].filter((uid) => this.healthy(uid, modelId, now));
|
|
268
|
-
if (healthy.length === 0)
|
|
269
|
-
|
|
270
|
-
|
|
307
|
+
if (healthy.length === 0) {
|
|
308
|
+
this.lastWhy = "全池无健康号(都在冷却)";
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now, session));
|
|
271
312
|
const pool = usable.length > 0 ? usable : healthy;
|
|
272
|
-
const fp =
|
|
273
|
-
|
|
313
|
+
const fp = sessionFingerprint(session, messages);
|
|
314
|
+
const bound = fp === "" ? void 0 : this.affinity.get(this.blockKey(modelId))?.get(fp);
|
|
315
|
+
if (bound !== void 0 && pool.includes(bound)) {
|
|
316
|
+
this.lastWhy = `亲和命中 ${bound}(会话/前缀已绑定)`;
|
|
317
|
+
return bound;
|
|
318
|
+
}
|
|
319
|
+
const why = [];
|
|
320
|
+
if (fp === "") why.push("无指纹(拿不到 messages/会话)");
|
|
321
|
+
else if (bound === void 0) why.push("该指纹无绑定(会话首次出现)");
|
|
322
|
+
else if (!healthy.includes(bound)) {
|
|
323
|
+
const c = this.cooldowns.get(this.key(modelId, bound));
|
|
324
|
+
why.push(`原绑定 ${bound} 正在冷却${c?.reason ? `(${c.reason})` : ""}`);
|
|
325
|
+
} else if (!usable.includes(bound)) why.push(`原绑定 ${bound} 被降权(本会话样本不达标)`);
|
|
326
|
+
else why.push(`原绑定 ${bound} 不在当前健康池`);
|
|
274
327
|
const block = this.blocks.get(this.blockKey(modelId));
|
|
275
|
-
if (fp === "" && block !== void 0 && pool.includes(block) && this.blockKeeps(modelId, block))
|
|
276
|
-
|
|
328
|
+
if (fp === "" && block !== void 0 && pool.includes(block) && this.blockKeeps(modelId, block)) {
|
|
329
|
+
this.lastWhy = `块驻留 ${block}(无指纹路径)`;
|
|
330
|
+
return block;
|
|
331
|
+
}
|
|
332
|
+
const uid = fp !== "" ? this.allocSession(pool, modelId, fp) : pool[(block === void 0 ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)) % pool.length];
|
|
277
333
|
this.rrCursor = (pool.indexOf(uid) + 1) % pool.length;
|
|
278
334
|
this.blocks.set(this.blockKey(modelId), uid);
|
|
279
|
-
if (fp !== "")
|
|
280
|
-
|
|
335
|
+
if (fp !== "") {
|
|
336
|
+
this.bind(modelId, fp, uid);
|
|
337
|
+
const free = this.freeUids(pool, modelId, fp);
|
|
338
|
+
why.push(free.length === 0 ? `号已铺满,按游标复用 ${uid}` : `铺开到空闲号 ${uid}`);
|
|
339
|
+
} else {
|
|
340
|
+
this.samples.delete(this.key(modelId, uid));
|
|
341
|
+
why.push(`块推进到 ${uid}`);
|
|
342
|
+
}
|
|
343
|
+
this.lastWhy = why.join(";");
|
|
281
344
|
return uid;
|
|
282
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* 新会话分配:优先返回**未被其它会话占用**的号(按游标推进),
|
|
348
|
+
* 空闲号用尽才回绕到已占用的号(此时按游标复用,语义等同旧行为)。
|
|
349
|
+
*
|
|
350
|
+
* 「占用」= 该 model 的绑定表里,某个 **其它** 指纹当前指向该 uid。
|
|
351
|
+
* 同一会话自己的旧绑定不算占用(它已经在上面的亲和分支处理过,走到这里
|
|
352
|
+
* 说明原绑定不可用,允许它改绑到自己的其它号或空闲号)。
|
|
353
|
+
*/
|
|
354
|
+
allocSession(pool, modelId, fp) {
|
|
355
|
+
const free = this.freeUids(pool, modelId, fp);
|
|
356
|
+
const candidates = free.length > 0 ? free : pool;
|
|
357
|
+
return candidates[this.rrCursor % candidates.length];
|
|
358
|
+
}
|
|
359
|
+
/** 当前 model 下、本指纹之外没有任何其它会话绑定的号(按 pool 原顺序)。 */
|
|
360
|
+
freeUids(pool, modelId, fp) {
|
|
361
|
+
const taken = /* @__PURE__ */ new Set();
|
|
362
|
+
const m = this.affinity.get(this.blockKey(modelId));
|
|
363
|
+
if (m !== void 0) {
|
|
364
|
+
for (const [k, v] of m) if (k !== fp) taken.add(v);
|
|
365
|
+
}
|
|
366
|
+
return pool.filter((uid) => !taken.has(uid));
|
|
367
|
+
}
|
|
368
|
+
/** 上一次 pick 的选号理由(诊断用:解释"为什么没粘住原号")。 */
|
|
369
|
+
lastWhy = "";
|
|
283
370
|
/** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
|
|
284
371
|
bind(modelId, fp, uid) {
|
|
285
372
|
const k = this.blockKey(modelId);
|
|
@@ -295,9 +382,14 @@ var AccountPool = class {
|
|
|
295
382
|
if (oldest !== void 0) m.delete(oldest);
|
|
296
383
|
}
|
|
297
384
|
}
|
|
298
|
-
/**
|
|
299
|
-
|
|
300
|
-
|
|
385
|
+
/**
|
|
386
|
+
* 该号在**本会话**(有身份时)本模型上是否被判定「缓存无效」(降权中)。
|
|
387
|
+
*
|
|
388
|
+
* 判据按会话隔离(见 sampleKey):降权只惩罚「这个会话在这个号上真的
|
|
389
|
+
* 反复不命中」,不会因别的会话共用同号互踩而被牵连。
|
|
390
|
+
*/
|
|
391
|
+
isDemoted(uid, modelId, now, session) {
|
|
392
|
+
const e = this.demoted.get(this.sampleKey(modelId, uid, session));
|
|
301
393
|
return e !== void 0 && e.until > now;
|
|
302
394
|
}
|
|
303
395
|
/**
|
|
@@ -323,14 +415,16 @@ var AccountPool = class {
|
|
|
323
415
|
* 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
|
|
324
416
|
*
|
|
325
417
|
* 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
|
|
326
|
-
* 统计按 (model, uid)
|
|
327
|
-
*
|
|
418
|
+
* 统计按 (model, uid[, session]) 记,**不管这个号是不是「当前驻留」**——
|
|
419
|
+
* 亲和开启后多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
|
|
420
|
+
* 按会话隔离(见 sampleKey)则保证「多会话共用同号互踩」不会误伤降权。
|
|
328
421
|
* @param uid 实际服务的号
|
|
329
422
|
* @param modelId 模型(块粒度)
|
|
330
423
|
* @param cachedTokens 上游报的命中 token 数;0 = 全量重算
|
|
424
|
+
* @param session 宿主会话身份;有则按会话隔离统计
|
|
331
425
|
*/
|
|
332
|
-
noteCache(uid, modelId, cachedTokens) {
|
|
333
|
-
const k = this.
|
|
426
|
+
noteCache(uid, modelId, cachedTokens, session) {
|
|
427
|
+
const k = this.sampleKey(modelId, uid, session);
|
|
334
428
|
const s = this.samples.get(k) ?? {
|
|
335
429
|
served: 0,
|
|
336
430
|
hits: 0
|
|
@@ -471,7 +565,6 @@ var SupplierConfigStore = class {
|
|
|
471
565
|
disabled: [],
|
|
472
566
|
custom: [],
|
|
473
567
|
poolOrder: [],
|
|
474
|
-
poolStrategy: "fallback",
|
|
475
568
|
credits: {},
|
|
476
569
|
accountNames: {}
|
|
477
570
|
};
|
|
@@ -520,11 +613,6 @@ var SupplierConfigStore = class {
|
|
|
520
613
|
this.get(supplierId).poolOrder = [...new Set(uids)];
|
|
521
614
|
this.saveLocked();
|
|
522
615
|
}
|
|
523
|
-
setPoolStrategy(supplierId, strategy) {
|
|
524
|
-
if (strategy !== "fallback" && strategy !== "round-robin") return;
|
|
525
|
-
this.get(supplierId).poolStrategy = strategy;
|
|
526
|
-
this.saveLocked();
|
|
527
|
-
}
|
|
528
616
|
/** 读某连接的显示别名;没设或为空返回 `undefined`(用供应商原始昵称)。 */
|
|
529
617
|
getAccountName(supplierId, uid) {
|
|
530
618
|
const n = this.get(supplierId).accountNames[uid];
|
|
@@ -582,7 +670,6 @@ var SupplierConfigStore = class {
|
|
|
582
670
|
disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === "string") : [],
|
|
583
671
|
custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === "string") : [],
|
|
584
672
|
poolOrder: Array.isArray(c.poolOrder) ? c.poolOrder.filter((u) => typeof u === "string") : [],
|
|
585
|
-
poolStrategy: c.poolStrategy === "round-robin" ? "round-robin" : "fallback",
|
|
586
673
|
credits: readCredits(c.credits),
|
|
587
674
|
accountNames: readNames(c.accountNames)
|
|
588
675
|
});
|
|
@@ -595,7 +682,6 @@ var SupplierConfigStore = class {
|
|
|
595
682
|
disabled: [...cfg.disabled],
|
|
596
683
|
custom: [...cfg.custom],
|
|
597
684
|
poolOrder: [...cfg.poolOrder],
|
|
598
|
-
poolStrategy: cfg.poolStrategy,
|
|
599
685
|
credits: { ...cfg.credits },
|
|
600
686
|
accountNames: { ...cfg.accountNames }
|
|
601
687
|
};
|
|
@@ -889,6 +975,16 @@ var UsageStore = class UsageStore {
|
|
|
889
975
|
recentList(limit = 20) {
|
|
890
976
|
return this.recent.slice(0, limit);
|
|
891
977
|
}
|
|
978
|
+
/**
|
|
979
|
+
* 最近一次命中。给了 `session` 就只在该会话的记录里找(「最近命中」徽章
|
|
980
|
+
* 要显示**当前会话**的命中,team 多会话下全局最近会张冠李戴);
|
|
981
|
+
* 不给则退回全局最近一条。
|
|
982
|
+
* @param session 宿主会话身份;undefined = 全局
|
|
983
|
+
*/
|
|
984
|
+
lastHit(session) {
|
|
985
|
+
if (session === void 0) return this.recent[0];
|
|
986
|
+
return this.recent.find((r) => r.session === session);
|
|
987
|
+
}
|
|
892
988
|
clear() {
|
|
893
989
|
this.days.clear();
|
|
894
990
|
this.recent = [];
|
|
@@ -1302,8 +1398,8 @@ function withEstimates(usage, requestChars, outputChars) {
|
|
|
1302
1398
|
//#region src/router/index.ts
|
|
1303
1399
|
/**
|
|
1304
1400
|
* dsh-router 路由器本体:供应商注册表 + OpenAI 兼容 /v1/* 处理。
|
|
1305
|
-
* 仿 9router:组合 = 一组模型,请求 model
|
|
1306
|
-
*
|
|
1401
|
+
* 仿 9router:组合 = 一组模型,请求 model 命中组合名时,作为降级链
|
|
1402
|
+
* 按序尝试选中一个模型路由(无策略旋钮,见 docs/pool-sticky-block.md §10)。
|
|
1307
1403
|
*/
|
|
1308
1404
|
/**
|
|
1309
1405
|
* 模型列表缓存有效期。
|
|
@@ -1573,7 +1669,16 @@ function normalizeSSEStream(stream) {
|
|
|
1573
1669
|
function isDoneFrame(frame) {
|
|
1574
1670
|
return /(^|\n)data:[ \t]*\[DONE\][ \t]*(\n|$)/.test(frame);
|
|
1575
1671
|
}
|
|
1576
|
-
/**
|
|
1672
|
+
/**
|
|
1673
|
+
* 修一帧 SSE:剥掉**空的**(`''` 或 `null`)name/id,出任何问题都原样返回。
|
|
1674
|
+
*
|
|
1675
|
+
* 为什么 `null` 也算空:上游(OpenCode/Zen)在后续 delta 里会把首帧已给过的
|
|
1676
|
+
* id/name 显式重发成 `null`——`{"id":null,"function":{"name":null,"arguments":"{"}}`。
|
|
1677
|
+
* 下游判「有没有新值」用的是 `name !== undefined`(dsh 的 BlockAssembler 就是),
|
|
1678
|
+
* null 过不了这个判断、却也不该覆盖首帧:客户端会把已拿到的 "bash" 冲成 null,
|
|
1679
|
+
* 工具名丢失(表现为 `unknown tool ""`)。空串早就剥了,null 语义上同样是
|
|
1680
|
+
* 「本帧没给新值」,一并剥掉。
|
|
1681
|
+
*/
|
|
1577
1682
|
function fixFrame(frame) {
|
|
1578
1683
|
if (!frame.includes("tool_calls")) return frame;
|
|
1579
1684
|
return frame.split("\n").map((line) => {
|
|
@@ -1589,12 +1694,12 @@ function fixFrame(frame) {
|
|
|
1589
1694
|
let changed = false;
|
|
1590
1695
|
const tcs = (obj.choices?.[0]?.delta)?.tool_calls;
|
|
1591
1696
|
if (Array.isArray(tcs)) for (const tc of tcs) {
|
|
1592
|
-
if (tc.id === "") {
|
|
1697
|
+
if (tc.id === "" || tc.id === null) {
|
|
1593
1698
|
delete tc.id;
|
|
1594
1699
|
changed = true;
|
|
1595
1700
|
}
|
|
1596
1701
|
const fn = tc.function;
|
|
1597
|
-
if (fn !== null && typeof fn === "object" && fn.name === "") {
|
|
1702
|
+
if (fn !== null && typeof fn === "object" && (fn.name === "" || fn.name === null)) {
|
|
1598
1703
|
delete fn.name;
|
|
1599
1704
|
changed = true;
|
|
1600
1705
|
}
|
|
@@ -1744,9 +1849,7 @@ var Router = class {
|
|
|
1744
1849
|
suppliers = [];
|
|
1745
1850
|
combosFp = "";
|
|
1746
1851
|
customCombos = [];
|
|
1747
|
-
/**
|
|
1748
|
-
rrCursors = /* @__PURE__ */ new Map();
|
|
1749
|
-
/** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
|
|
1852
|
+
/** 通用供应商配置(连接池顺序、模型启用、别名)。 */
|
|
1750
1853
|
store;
|
|
1751
1854
|
/** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
|
|
1752
1855
|
modelsCache = /* @__PURE__ */ new Map();
|
|
@@ -1792,7 +1895,6 @@ var Router = class {
|
|
|
1792
1895
|
return {
|
|
1793
1896
|
id: c.id,
|
|
1794
1897
|
name: typeof c.name === "string" ? c.name : c.id,
|
|
1795
|
-
strategy: c.strategy === "round-robin" ? "round-robin" : "fallback",
|
|
1796
1898
|
models
|
|
1797
1899
|
};
|
|
1798
1900
|
}).filter((c) => c.models.length > 0);
|
|
@@ -1989,9 +2091,6 @@ var Router = class {
|
|
|
1989
2091
|
validModels(models) {
|
|
1990
2092
|
return Array.isArray(models) && models.length > 0 && models.every((m) => typeof m === "string" && m !== "");
|
|
1991
2093
|
}
|
|
1992
|
-
validStrategy(strategy) {
|
|
1993
|
-
return strategy === "fallback" || strategy === "round-robin";
|
|
1994
|
-
}
|
|
1995
2094
|
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
1996
2095
|
* 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
|
|
1997
2096
|
* (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
|
|
@@ -2012,8 +2111,8 @@ var Router = class {
|
|
|
2012
2111
|
return s === void 0 ? modelId : `${s.id},${modelId}`;
|
|
2013
2112
|
});
|
|
2014
2113
|
}
|
|
2015
|
-
/** 创建组合(name 唯一,非 default
|
|
2016
|
-
createCombo(name,
|
|
2114
|
+
/** 创建组合(name 唯一,非 default)。选模型是降级链(按序尝试),无策略。 */
|
|
2115
|
+
createCombo(name, models) {
|
|
2017
2116
|
const clean = name.trim();
|
|
2018
2117
|
if (clean === "" || clean === "default") return {
|
|
2019
2118
|
ok: false,
|
|
@@ -2031,14 +2130,9 @@ var Router = class {
|
|
|
2031
2130
|
ok: false,
|
|
2032
2131
|
error: "至少需要一个模型"
|
|
2033
2132
|
};
|
|
2034
|
-
if (!this.validStrategy(strategy)) return {
|
|
2035
|
-
ok: false,
|
|
2036
|
-
error: "策略无效"
|
|
2037
|
-
};
|
|
2038
2133
|
const combo = {
|
|
2039
2134
|
id: clean,
|
|
2040
2135
|
name: clean,
|
|
2041
|
-
strategy,
|
|
2042
2136
|
models: this.normalizeModelIds(models)
|
|
2043
2137
|
};
|
|
2044
2138
|
this.customCombos.push(combo);
|
|
@@ -2049,7 +2143,7 @@ var Router = class {
|
|
|
2049
2143
|
};
|
|
2050
2144
|
}
|
|
2051
2145
|
/** 更新组合(按 id)。 */
|
|
2052
|
-
updateCombo(id, name,
|
|
2146
|
+
updateCombo(id, name, models) {
|
|
2053
2147
|
const target = this.customCombos.find((c) => c.id === id);
|
|
2054
2148
|
if (!target) return {
|
|
2055
2149
|
ok: false,
|
|
@@ -2072,12 +2166,7 @@ var Router = class {
|
|
|
2072
2166
|
ok: false,
|
|
2073
2167
|
error: "至少需要一个模型"
|
|
2074
2168
|
};
|
|
2075
|
-
if (!this.validStrategy(strategy)) return {
|
|
2076
|
-
ok: false,
|
|
2077
|
-
error: "策略无效"
|
|
2078
|
-
};
|
|
2079
2169
|
target.name = clean;
|
|
2080
|
-
target.strategy = strategy;
|
|
2081
2170
|
target.models = this.normalizeModelIds(models);
|
|
2082
2171
|
this.saveCombos();
|
|
2083
2172
|
return { ok: true };
|
|
@@ -2134,15 +2223,14 @@ var Router = class {
|
|
|
2134
2223
|
durationMs: Date.now() - probe.startedAt,
|
|
2135
2224
|
ttfbMs: probe.ttfbMs,
|
|
2136
2225
|
...ok && probe.uid !== "" ? { uid: probe.uid } : {},
|
|
2226
|
+
...req.session === void 0 ? {} : { session: req.session },
|
|
2137
2227
|
...error === void 0 ? {} : { error }
|
|
2138
2228
|
}, usage);
|
|
2139
2229
|
};
|
|
2140
2230
|
const combo = this.comboByName(req.model);
|
|
2141
2231
|
if (combo) {
|
|
2142
|
-
const start = combo.strategy === "round-robin" ? (this.rrCursors.get(combo.id) ?? 0) % combo.models.length : 0;
|
|
2143
|
-
if (combo.strategy === "round-robin") this.rrCursors.set(combo.id, (this.rrCursors.get(combo.id) ?? 0) + 1);
|
|
2144
2232
|
for (let i = 0; i < combo.models.length; i++) {
|
|
2145
|
-
const model = combo.models[
|
|
2233
|
+
const model = combo.models[i];
|
|
2146
2234
|
if (model === void 0) continue;
|
|
2147
2235
|
const trace = { attempts: 0 };
|
|
2148
2236
|
if (await this.chatWithModel(req, res, model, probe, trace)) {
|
|
@@ -2225,7 +2313,7 @@ var Router = class {
|
|
|
2225
2313
|
outTrace.lastError = trace.lastError;
|
|
2226
2314
|
outTrace.lastState = trace.lastState;
|
|
2227
2315
|
}
|
|
2228
|
-
this.logChat(req.model, served ? `${s.id}/${modelId} (${trace.uid ?? "?"}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ""}` : `${s.id}/${modelId} 失败 ${trace.lastError ?? "no account"}`, Date.now() - t0);
|
|
2316
|
+
this.logChat(req.model, served ? `${s.id}/${modelId} (${trace.uid ?? "?"}) ok${trace.attempts > 0 ? ` 重试${trace.attempts}次` : ""}${trace.pickWhy !== void 0 ? ` [选号: ${trace.pickWhy}]` : ""}` : `${s.id}/${modelId} 失败 ${trace.lastError ?? "no account"}`, Date.now() - t0);
|
|
2229
2317
|
return served;
|
|
2230
2318
|
}
|
|
2231
2319
|
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
@@ -2262,9 +2350,11 @@ var Router = class {
|
|
|
2262
2350
|
}
|
|
2263
2351
|
const messages = reqMessages(req.rawBody);
|
|
2264
2352
|
const tried = /* @__PURE__ */ new Set();
|
|
2353
|
+
let pickWhy = "";
|
|
2265
2354
|
for (;;) {
|
|
2266
|
-
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder,
|
|
2355
|
+
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session);
|
|
2267
2356
|
if (uid === void 0) return false;
|
|
2357
|
+
pickWhy = pool.lastWhy;
|
|
2268
2358
|
tried.add(uid);
|
|
2269
2359
|
const r = await s.chatOnce(uid, req.lv ?? "auto", req);
|
|
2270
2360
|
if (!r.ok) {
|
|
@@ -2285,14 +2375,17 @@ var Router = class {
|
|
|
2285
2375
|
continue;
|
|
2286
2376
|
}
|
|
2287
2377
|
pool.noteSuccess(uid, req.model);
|
|
2288
|
-
if (trace !== void 0)
|
|
2378
|
+
if (trace !== void 0) {
|
|
2379
|
+
trace.uid = uid;
|
|
2380
|
+
trace.pickWhy = pickWhy;
|
|
2381
|
+
}
|
|
2289
2382
|
if (probe !== void 0) {
|
|
2290
2383
|
probe.supplier = s.id;
|
|
2291
2384
|
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
|
|
2292
2385
|
probe.uid = uid;
|
|
2293
2386
|
}
|
|
2294
2387
|
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) {
|
|
2295
|
-
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0);
|
|
2388
|
+
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session);
|
|
2296
2389
|
return true;
|
|
2297
2390
|
}
|
|
2298
2391
|
pool.noteFailure(uid, req.model, "transport", "stream failed before first byte");
|
|
@@ -2381,6 +2474,10 @@ var Router = class {
|
|
|
2381
2474
|
* 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
|
|
2382
2475
|
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
2383
2476
|
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
2477
|
+
*
|
|
2478
|
+
* 工具结果双模型兼容:0.1.5/0.1.6 里 tool result 是 user 消息里的 'tool-result' 块
|
|
2479
|
+
* (旧路径);0.1.7+ 改成了独立 role:'tool' 消息、toolCallId 挂消息级(新分支)。
|
|
2480
|
+
* 两者在 wire 上统一序列化为 role:'tool',产物一致。见主循环里的 role==='tool' 分支。
|
|
2384
2481
|
*/
|
|
2385
2482
|
/** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
|
|
2386
2483
|
const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
|
|
@@ -2452,6 +2549,19 @@ async function wireMessages(options, system, attachments) {
|
|
|
2452
2549
|
});
|
|
2453
2550
|
continue;
|
|
2454
2551
|
}
|
|
2552
|
+
if (message.role === "tool") {
|
|
2553
|
+
const toolCallId = message.toolCallId;
|
|
2554
|
+
const parts = await contentParts(message.content, attachments, options.signal);
|
|
2555
|
+
const imageParts = parts.filter((p) => p.type !== "text");
|
|
2556
|
+
const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
|
|
2557
|
+
out.push({
|
|
2558
|
+
role: "tool",
|
|
2559
|
+
tool_call_id: String(toolCallId ?? ""),
|
|
2560
|
+
content: text
|
|
2561
|
+
});
|
|
2562
|
+
pendingToolImages.push(...imageParts);
|
|
2563
|
+
continue;
|
|
2564
|
+
}
|
|
2455
2565
|
const toolResults = message.content.filter((b) => b.type === "tool-result");
|
|
2456
2566
|
const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal);
|
|
2457
2567
|
if (ownParts.length > 0 || toolResults.length === 0) {
|
|
@@ -2733,9 +2843,9 @@ async function* translateSse(payloads) {
|
|
|
2733
2843
|
blockType: "tool-call"
|
|
2734
2844
|
};
|
|
2735
2845
|
}
|
|
2736
|
-
if (call.id !==
|
|
2737
|
-
if (call.function?.name !==
|
|
2738
|
-
const fragment = call.function?.arguments
|
|
2846
|
+
if (typeof call.id === "string" && call.id !== "") block.callId = call.id;
|
|
2847
|
+
if (typeof call.function?.name === "string" && call.function.name !== "") block.name = call.function.name;
|
|
2848
|
+
const fragment = typeof call.function?.arguments === "string" ? call.function.arguments : "";
|
|
2739
2849
|
block.text += fragment;
|
|
2740
2850
|
yield {
|
|
2741
2851
|
type: "tool-call-delta",
|
|
@@ -2904,7 +3014,8 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
|
2904
3014
|
headers: {
|
|
2905
3015
|
...attributionHeaders(),
|
|
2906
3016
|
"content-type": "application/json",
|
|
2907
|
-
accept: "text/event-stream"
|
|
3017
|
+
accept: "text/event-stream",
|
|
3018
|
+
...options.sessionId === void 0 ? {} : { "x-dsh-router-session": String(options.sessionId) }
|
|
2908
3019
|
},
|
|
2909
3020
|
body: JSON.stringify(body),
|
|
2910
3021
|
signal: controller.signal
|
|
@@ -3503,29 +3614,6 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
3503
3614
|
writeJson$1(res, 200, { ok: true });
|
|
3504
3615
|
}
|
|
3505
3616
|
});
|
|
3506
|
-
routes.push({
|
|
3507
|
-
kind: "exact",
|
|
3508
|
-
path: `${p}/pool/strategy`,
|
|
3509
|
-
handler: async (req, res) => {
|
|
3510
|
-
if (req.method === "GET") {
|
|
3511
|
-
writeJson$1(res, 200, {
|
|
3512
|
-
ok: true,
|
|
3513
|
-
strategy: store.get(s.id).poolStrategy
|
|
3514
|
-
});
|
|
3515
|
-
return;
|
|
3516
|
-
}
|
|
3517
|
-
const body = JSON.parse(await readBody$1(req));
|
|
3518
|
-
if (body.strategy !== "fallback" && body.strategy !== "round-robin") {
|
|
3519
|
-
writeJson$1(res, 400, {
|
|
3520
|
-
ok: false,
|
|
3521
|
-
error: "策略无效"
|
|
3522
|
-
});
|
|
3523
|
-
return;
|
|
3524
|
-
}
|
|
3525
|
-
store.setPoolStrategy(s.id, body.strategy);
|
|
3526
|
-
writeJson$1(res, 200, { ok: true });
|
|
3527
|
-
}
|
|
3528
|
-
});
|
|
3529
3617
|
routes.push({
|
|
3530
3618
|
kind: "exact",
|
|
3531
3619
|
path: `${p}/models/fetch`,
|
|
@@ -3786,6 +3874,98 @@ function dshHome() {
|
|
|
3786
3874
|
return resolve(value);
|
|
3787
3875
|
}
|
|
3788
3876
|
//#endregion
|
|
3877
|
+
//#region src/host-version.ts
|
|
3878
|
+
/**
|
|
3879
|
+
* 宿主 DSH 版本探测 —— **0.1.5 / 0.1.7 兼容分叉的唯一判据来源**。
|
|
3880
|
+
*
|
|
3881
|
+
* 原先三处兼容都靠「能力检测」(看方法/属性在不在),语义上是同一个问题
|
|
3882
|
+
* ——「这是 0.1.7 还是更老」——却各检各的、还容易误判(插件自己 node_modules
|
|
3883
|
+
* 里的依赖版本与宿主实际加载的版本并不一致:实测插件解析到 schemastery 3.18.4,
|
|
3884
|
+
* profile 里却是 3.18.3、全局是 3.18.2)。所以统一改成**读宿主版本号**判定:
|
|
3885
|
+
*
|
|
3886
|
+
* 1. 「最近命中」徽章:composer.dock 在 0.1.5 渲染位置不对 → >= 0.1.7 才挂。
|
|
3887
|
+
* 2. 设置命名空间:0.1.5 用 `settings.installSection` 注册;0.1.7 改由导出的
|
|
3888
|
+
* Config 驱动(无此方法)→ 按版本走对应注册路径。
|
|
3889
|
+
* 3. adapter 的 tool-result 消息形态:0.1.5/0.1.6 = user 里的 'tool-result'
|
|
3890
|
+
* 块;0.1.7+ = 独立 role:'tool' 消息。
|
|
3891
|
+
*
|
|
3892
|
+
* 读法:宿主把 profile 目录挂在 `ctx.baseUrl`(见 data-dir.ts)。本插件跑在宿主
|
|
3893
|
+
* 进程内,`require.resolve('@deepseek-ai/dsh/package.json', { paths: [profile] })`
|
|
3894
|
+
* 会沿 profile 的 node_modules 向上解析到全局安装根(实测命中 bun 全局目录)。
|
|
3895
|
+
* 拿不到就回落直接拼路径,再不行就返回 undefined(按「不支持/老版本」处理,不抛)。
|
|
3896
|
+
*
|
|
3897
|
+
* 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl)。模块顶层(如 Config 导出)
|
|
3898
|
+
* 解析到的是插件自己的依赖,不能代表宿主——那处保留能力检测,见 index.ts 注释。
|
|
3899
|
+
*/
|
|
3900
|
+
/** 各兼容分叉的版本门槛。 */
|
|
3901
|
+
/** composer.dock 徽章 / 独立 role:'tool' 消息形态:0.1.7 起。 */
|
|
3902
|
+
const V0_1_7 = [
|
|
3903
|
+
0,
|
|
3904
|
+
1,
|
|
3905
|
+
7
|
|
3906
|
+
];
|
|
3907
|
+
/**
|
|
3908
|
+
* 解析并比较 semver(只取前导数字段,预发布标识忽略)。
|
|
3909
|
+
* `0.1.7-alpha.2` → [0,1,7],`0.1.5-rc.3` → [0,1,5]。
|
|
3910
|
+
* 返回负数/0/正数,语义同 `<`/`==`/`>`。
|
|
3911
|
+
*/
|
|
3912
|
+
function compareVersion(a, b) {
|
|
3913
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
3914
|
+
const x = a[i] ?? 0;
|
|
3915
|
+
const y = b[i] ?? 0;
|
|
3916
|
+
if (x !== y) return x - y;
|
|
3917
|
+
}
|
|
3918
|
+
return 0;
|
|
3919
|
+
}
|
|
3920
|
+
/** `0.1.7-alpha.2` → [0,1,7];解析失败返回 null。 */
|
|
3921
|
+
function parseVersion(raw) {
|
|
3922
|
+
const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(raw.trim());
|
|
3923
|
+
if (m === null) return null;
|
|
3924
|
+
return [
|
|
3925
|
+
Number(m[1]),
|
|
3926
|
+
Number(m[2] ?? 0),
|
|
3927
|
+
Number(m[3] ?? 0)
|
|
3928
|
+
];
|
|
3929
|
+
}
|
|
3930
|
+
/**
|
|
3931
|
+
* 探测 profile 里安装的 DSH 宿主版本。
|
|
3932
|
+
* @param baseUrl 宿主注入的 profile 锚点(`ctx.baseUrl`)
|
|
3933
|
+
* @returns 形如 `0.1.7-alpha.2`;读不到返回 undefined(调用方按「不支持」处理)
|
|
3934
|
+
*/
|
|
3935
|
+
function detectHostVersion(baseUrl) {
|
|
3936
|
+
const profile = profileDirOf(baseUrl);
|
|
3937
|
+
const candidates = [];
|
|
3938
|
+
try {
|
|
3939
|
+
candidates.push(createRequire(join(profile, "noop.js")).resolve("@deepseek-ai/dsh/package.json"));
|
|
3940
|
+
} catch {}
|
|
3941
|
+
candidates.push(join(profile, "node_modules", "@deepseek-ai", "dsh", "package.json"));
|
|
3942
|
+
for (const file of candidates) try {
|
|
3943
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
3944
|
+
if (typeof parsed.version === "string" && parsed.version !== "") return parsed.version;
|
|
3945
|
+
} catch {}
|
|
3946
|
+
}
|
|
3947
|
+
/** 宿主版本是否 >= 门槛。读不到版本 → false(按老版本处理)。 */
|
|
3948
|
+
function atLeast(baseUrl, min) {
|
|
3949
|
+
const v = detectHostVersion(baseUrl);
|
|
3950
|
+
if (v === void 0) return false;
|
|
3951
|
+
const parsed = parseVersion(v);
|
|
3952
|
+
if (parsed === null) return false;
|
|
3953
|
+
return compareVersion(parsed, min) >= 0;
|
|
3954
|
+
}
|
|
3955
|
+
/**
|
|
3956
|
+
* 宿主是否 >= 0.1.7。三处兼容分叉共用这一个判据:
|
|
3957
|
+
* - 「最近命中」徽章(composer.dock 位置正确)
|
|
3958
|
+
* - 设置命名空间(Config 驱动而非 installSection)
|
|
3959
|
+
* - adapter 的独立 role:'tool' 消息形态
|
|
3960
|
+
*/
|
|
3961
|
+
function isHost017Plus(baseUrl) {
|
|
3962
|
+
return atLeast(baseUrl, V0_1_7);
|
|
3963
|
+
}
|
|
3964
|
+
/** 「最近命中」徽章是否可用(= 宿主 >= 0.1.7)。 */
|
|
3965
|
+
function supportsLastHitDock(baseUrl) {
|
|
3966
|
+
return isHost017Plus(baseUrl);
|
|
3967
|
+
}
|
|
3968
|
+
//#endregion
|
|
3789
3969
|
//#region src/ext/store.ts
|
|
3790
3970
|
/**
|
|
3791
3971
|
* 扩展存储 —— **开关状态与插件数据都归核心持久化**,插件不自己 file IO。
|
|
@@ -3904,6 +4084,31 @@ var ExtStore = class {
|
|
|
3904
4084
|
const name = "dsh-router-core";
|
|
3905
4085
|
/** Services required before mounting: the webserver (routes) + llm (设置-模型). */
|
|
3906
4086
|
const inject = ["webServer", "llm"];
|
|
4087
|
+
/**
|
|
4088
|
+
* `Config` 导出:让 Router 卡片进 0.1.7 的设置镜像。
|
|
4089
|
+
*
|
|
4090
|
+
* **这处刻意保留能力检测,不用宿主版本号** —— 与另两处兼容点(installSection /
|
|
4091
|
+
* adapter 消息形态)不同。原因(已实测):这是**模块顶层导出**,loader 一 import
|
|
4092
|
+
* 就求值;而 schemastery 是本插件的**普通依赖**(`^3.18.4`,见 package.json),
|
|
4093
|
+
* 解析到的是**插件自己 node_modules 里那一份**,跟宿主实际加载的版本无关
|
|
4094
|
+
* (实测:插件解析到 3.18.4,而 profile 里是 3.18.3、全局是 3.18.2)。既然这里
|
|
4095
|
+
* 拿不到宿主版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 ——
|
|
4096
|
+
* 能力检测恰好就是**对**的判据。
|
|
4097
|
+
*
|
|
4098
|
+
* 定位说明(非兼容分叉):`.volatile()` 是 schemastery 3.18.4 才有的原型方法;
|
|
4099
|
+
* 老版本上直写 `Schema.object({}).volatile()` 会 `.volatile` 为 undefined →
|
|
4100
|
+
* 抛 TypeError → **整个插件加载失败**(不只是卡片,是 dsh-router 完全挂掉)。
|
|
4101
|
+
* - 有 volatile:走 volatile,空 object 不被 volatileForm 过滤 → 卡片进镜像;
|
|
4102
|
+
* - 无 volatile:回落普通空 schema,不抛;此时宿主必是 0.1.5,本就靠
|
|
4103
|
+
* installSection 出卡片(见下方按版本分流的注册),这个 Config 无副作用。
|
|
4104
|
+
*/
|
|
4105
|
+
const emptySchema = Schema.object({});
|
|
4106
|
+
/**
|
|
4107
|
+
* 0.1.7 的设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
|
|
4108
|
+
* 会被 volatileForm 过滤掉 —— 顶层 `.volatile()` 才能让 Router 卡片出现在设置-模型。
|
|
4109
|
+
* Router 的真配置在自己的 state.json(路由系统面板),这里故意不暴露任何字段。
|
|
4110
|
+
*/
|
|
4111
|
+
const Config = typeof emptySchema.volatile === "function" ? emptySchema.volatile() : emptySchema;
|
|
3907
4112
|
function writeJson(res, status, body) {
|
|
3908
4113
|
const payload = JSON.stringify(body);
|
|
3909
4114
|
res.writeHead(status, {
|
|
@@ -3936,6 +4141,10 @@ function apply(rawContext) {
|
|
|
3936
4141
|
const dataDir = dataDirOf(ctx.baseUrl);
|
|
3937
4142
|
const stateFile = join(dataDir, "state.json");
|
|
3938
4143
|
log(`data dir: ${dataDir}`);
|
|
4144
|
+
const hostVersion = detectHostVersion(ctx.baseUrl);
|
|
4145
|
+
const host017Plus = isHost017Plus(ctx.baseUrl);
|
|
4146
|
+
const lastHitDock = supportsLastHitDock(ctx.baseUrl);
|
|
4147
|
+
log(`host version: ${hostVersion ?? "未知"} (>=0.1.7: ${host017Plus ? "yes" : "no"}, last-hit dock: ${lastHitDock ? "on" : "off"})`);
|
|
3939
4148
|
const store = new SupplierConfigStore(stateFile);
|
|
3940
4149
|
const credentials = new CredentialStore(join(dataDir, "auths"));
|
|
3941
4150
|
const router = new Router(stateFile, store, log);
|
|
@@ -4079,17 +4288,22 @@ function apply(rawContext) {
|
|
|
4079
4288
|
return;
|
|
4080
4289
|
}
|
|
4081
4290
|
const lv = typeof peek.reasoning_effort === "string" && peek.reasoning_effort !== "" ? peek.reasoning_effort : "auto";
|
|
4291
|
+
const sessionRaw = req.headers["x-dsh-router-session"];
|
|
4292
|
+
const session = typeof sessionRaw === "string" && sessionRaw !== "" ? sessionRaw : void 0;
|
|
4082
4293
|
await router.chatCompletions({
|
|
4083
4294
|
rawBody: body,
|
|
4084
4295
|
stream: !!peek.stream,
|
|
4085
4296
|
model: typeof peek.model === "string" ? peek.model : "",
|
|
4086
|
-
lv
|
|
4297
|
+
lv,
|
|
4298
|
+
...session === void 0 ? {} : { session }
|
|
4087
4299
|
}, res);
|
|
4088
4300
|
}));
|
|
4089
4301
|
route(`${ROUTER_API_BASE}/health`, (_req, res) => {
|
|
4090
4302
|
const { suppliers } = router.status();
|
|
4091
4303
|
writeJson(res, 200, {
|
|
4092
4304
|
ok: true,
|
|
4305
|
+
hostVersion: hostVersion ?? "",
|
|
4306
|
+
lastHitDock,
|
|
4093
4307
|
suppliers: suppliers.map((s) => {
|
|
4094
4308
|
const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id);
|
|
4095
4309
|
return {
|
|
@@ -4134,6 +4348,33 @@ function apply(rawContext) {
|
|
|
4134
4348
|
store.setAccountName(body.supplier, body.uid, typeof body.name === "string" ? body.name : "");
|
|
4135
4349
|
writeJson(res, 200, { ok: true });
|
|
4136
4350
|
});
|
|
4351
|
+
route(`${ROUTER_API_BASE}/last-hit`, (req, res) => {
|
|
4352
|
+
const session = new URL(req.url ?? "/", "http://localhost").searchParams.get("session") ?? void 0;
|
|
4353
|
+
const last = router.usage.lastHit(session);
|
|
4354
|
+
if (last === void 0) {
|
|
4355
|
+
writeJson(res, 200, {
|
|
4356
|
+
ok: true,
|
|
4357
|
+
hit: null
|
|
4358
|
+
});
|
|
4359
|
+
return;
|
|
4360
|
+
}
|
|
4361
|
+
const { suppliers } = router.status();
|
|
4362
|
+
const acct = suppliers.find((s) => s.id === last.supplier)?.accounts.find((a) => a.uid === last.uid);
|
|
4363
|
+
const named = last.uid === void 0 || last.uid === "" ? void 0 : store.getAccountName(last.supplier, last.uid);
|
|
4364
|
+
writeJson(res, 200, {
|
|
4365
|
+
ok: true,
|
|
4366
|
+
hit: {
|
|
4367
|
+
supplier: last.supplier,
|
|
4368
|
+
model: last.model,
|
|
4369
|
+
requested: last.requested,
|
|
4370
|
+
uid: last.uid,
|
|
4371
|
+
account: named ?? acct?.nickname ?? (last.uid === void 0 || last.uid === "" ? void 0 : last.uid),
|
|
4372
|
+
credits: acct?.credits,
|
|
4373
|
+
ok: last.ok,
|
|
4374
|
+
ts: last.ts
|
|
4375
|
+
}
|
|
4376
|
+
});
|
|
4377
|
+
});
|
|
4137
4378
|
route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
|
|
4138
4379
|
try {
|
|
4139
4380
|
writeJson(res, 200, {
|
|
@@ -4167,7 +4408,7 @@ function apply(rawContext) {
|
|
|
4167
4408
|
});
|
|
4168
4409
|
return;
|
|
4169
4410
|
}
|
|
4170
|
-
const result = router.createCombo(body.name ?? "", body.
|
|
4411
|
+
const result = router.createCombo(body.name ?? "", body.models ?? []);
|
|
4171
4412
|
writeJson(res, result.ok ? 200 : 400, result);
|
|
4172
4413
|
});
|
|
4173
4414
|
route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
|
|
@@ -4181,7 +4422,7 @@ function apply(rawContext) {
|
|
|
4181
4422
|
});
|
|
4182
4423
|
return;
|
|
4183
4424
|
}
|
|
4184
|
-
const result = router.updateCombo(body.id ?? "", body.name ?? "", body.
|
|
4425
|
+
const result = router.updateCombo(body.id ?? "", body.name ?? "", body.models ?? []);
|
|
4185
4426
|
writeJson(res, result.ok ? 200 : 400, result);
|
|
4186
4427
|
});
|
|
4187
4428
|
route(`${ROUTER_API_BASE}/combos/remove`, async (req, res) => {
|
|
@@ -4332,32 +4573,6 @@ function apply(rawContext) {
|
|
|
4332
4573
|
}).filter((e) => e !== void 0)
|
|
4333
4574
|
});
|
|
4334
4575
|
});
|
|
4335
|
-
const extDispatch = ctx.webServer.register({
|
|
4336
|
-
kind: "prefix",
|
|
4337
|
-
path: `${ROUTER_API_BASE}/ext/`,
|
|
4338
|
-
handler: async (req, res) => {
|
|
4339
|
-
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
4340
|
-
const mount = `${ROUTER_API_BASE}/ext/`;
|
|
4341
|
-
const rest = pathname.startsWith(mount) ? pathname.slice(mount.length) : "";
|
|
4342
|
-
const slash = rest.indexOf("/");
|
|
4343
|
-
const extId = slash < 0 ? rest : rest.slice(0, slash);
|
|
4344
|
-
const subpath = slash < 0 ? "" : rest.slice(slash + 1);
|
|
4345
|
-
if (extId === "") {
|
|
4346
|
-
writeJson(res, 404, {
|
|
4347
|
-
ok: false,
|
|
4348
|
-
error: "extension not found"
|
|
4349
|
-
});
|
|
4350
|
-
return;
|
|
4351
|
-
}
|
|
4352
|
-
const ext = exts[extId];
|
|
4353
|
-
if (ext !== void 0 && typeof ext.api === "function" && await ext.api(subpath, req, res)) return;
|
|
4354
|
-
writeJson(res, 404, {
|
|
4355
|
-
ok: false,
|
|
4356
|
-
error: "extension api not found"
|
|
4357
|
-
});
|
|
4358
|
-
}
|
|
4359
|
-
});
|
|
4360
|
-
disposers.push(extDispatch);
|
|
4361
4576
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
4362
4577
|
const periodOf = (raw) => {
|
|
4363
4578
|
return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";
|
|
@@ -4382,7 +4597,10 @@ function apply(rawContext) {
|
|
|
4382
4597
|
bySupplier: s.bySupplier,
|
|
4383
4598
|
byModel: s.byModel,
|
|
4384
4599
|
byRequested: s.byRequested,
|
|
4385
|
-
recent: router.usage.recentList(20)
|
|
4600
|
+
recent: router.usage.recentList(20).map((r) => ({
|
|
4601
|
+
...r,
|
|
4602
|
+
connection: r.uid === void 0 || r.uid === "" ? "" : store.getAccountName(r.supplier, r.uid) ?? r.uid
|
|
4603
|
+
}))
|
|
4386
4604
|
});
|
|
4387
4605
|
});
|
|
4388
4606
|
route(`${ROUTER_API_BASE}/stats/chart`, (req, res) => {
|
|
@@ -4403,53 +4621,57 @@ function apply(rawContext) {
|
|
|
4403
4621
|
router.usage.clear();
|
|
4404
4622
|
writeJson(res, 200, { ok: true });
|
|
4405
4623
|
});
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4624
|
+
const settingsNs = ctx.fiber?.entry?.options?.id ?? "dsh-router";
|
|
4625
|
+
if (ctx.llm !== void 0) try {
|
|
4626
|
+
const providerReg = ctx.llm.registerConfigurableProviders([{
|
|
4627
|
+
provider: "router",
|
|
4628
|
+
displayName: "Router",
|
|
4629
|
+
settingsNs,
|
|
4630
|
+
settingsPath: []
|
|
4631
|
+
}]);
|
|
4632
|
+
disposers.push(providerReg);
|
|
4633
|
+
disposers.push(ctx.llm.registerModelDiscovery(settingsNs, async () => {
|
|
4634
|
+
return (await router.combos()).map((c) => ({ id: c.name }));
|
|
4635
|
+
}));
|
|
4636
|
+
const routerSettingsSchema = Schema.object({});
|
|
4637
|
+
if (isHost017Plus(ctx.baseUrl)) log(`settings section driven by exported Config (${settingsNs})`);
|
|
4638
|
+
else ctx.inject(["settings"], (sctx) => {
|
|
4639
|
+
const settings = sctx.settings;
|
|
4640
|
+
if (settings === void 0) {
|
|
4641
|
+
log("settings service absent — skip Router settings-section registration");
|
|
4642
|
+
return;
|
|
4643
|
+
}
|
|
4644
|
+
if (settings.installSection === void 0) {
|
|
4645
|
+
log(`settings.installSection absent despite 0.1.5 host (${settingsNs})`);
|
|
4646
|
+
return;
|
|
4647
|
+
}
|
|
4648
|
+
settings.installSection(rawContext, settingsNs, routerSettingsSchema, {}, {
|
|
4649
|
+
setSource: () => {},
|
|
4650
|
+
onChange: () => {}
|
|
4431
4651
|
});
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4652
|
+
log(`Router settings section registered (${settingsNs})`);
|
|
4653
|
+
});
|
|
4654
|
+
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
|
|
4655
|
+
const w = router.comboContextWindow(c);
|
|
4656
|
+
return {
|
|
4657
|
+
id: c.name,
|
|
4658
|
+
...w !== void 0 ? { contextWindow: w } : {}
|
|
4659
|
+
};
|
|
4660
|
+
}) }, () => ctx.get("attachments"))));
|
|
4661
|
+
log("llm provider (Router) + discovery + adapter registered ok");
|
|
4662
|
+
(async () => {
|
|
4663
|
+
for (const c of await router.combos()) for (const ref of c.models) {
|
|
4664
|
+
const at = ref.indexOf(",");
|
|
4665
|
+
if (at < 0) continue;
|
|
4666
|
+
try {
|
|
4667
|
+
await router.modelsOf(ref.slice(0, at));
|
|
4668
|
+
} catch {}
|
|
4669
|
+
}
|
|
4670
|
+
})().catch(() => {});
|
|
4671
|
+
} catch (err) {
|
|
4672
|
+
ctx.logger.warn(`[dsh-router] llm registration failed: ${err.message}`);
|
|
4673
|
+
}
|
|
4674
|
+
else log("llm service NOT available — skip 设置-模型 registration");
|
|
4453
4675
|
route(`${ROUTER_API_BASE}/debug/llm`, async (_req, res) => {
|
|
4454
4676
|
let directory = "n/a";
|
|
4455
4677
|
let registeredProviders = "n/a";
|
|
@@ -4476,7 +4698,7 @@ function apply(rawContext) {
|
|
|
4476
4698
|
directory,
|
|
4477
4699
|
registeredProviders,
|
|
4478
4700
|
settingsNamespaces,
|
|
4479
|
-
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(
|
|
4701
|
+
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(settingsNs)
|
|
4480
4702
|
});
|
|
4481
4703
|
});
|
|
4482
4704
|
ctx.effect(() => () => {
|
|
@@ -4488,4 +4710,4 @@ function apply(rawContext) {
|
|
|
4488
4710
|
}, "dsh-router: teardown");
|
|
4489
4711
|
}
|
|
4490
4712
|
//#endregion
|
|
4491
|
-
export { apply, inject, name };
|
|
4713
|
+
export { Config, apply, inject, name };
|