dsh-router-core 0.3.21 → 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 +383 -170
- 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 +74 -0
- package/src/llm/adapter.ts +33 -0
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/index.ts +22 -28
- 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
|
* 模型列表缓存有效期。
|
|
@@ -1753,9 +1849,7 @@ var Router = class {
|
|
|
1753
1849
|
suppliers = [];
|
|
1754
1850
|
combosFp = "";
|
|
1755
1851
|
customCombos = [];
|
|
1756
|
-
/**
|
|
1757
|
-
rrCursors = /* @__PURE__ */ new Map();
|
|
1758
|
-
/** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
|
|
1852
|
+
/** 通用供应商配置(连接池顺序、模型启用、别名)。 */
|
|
1759
1853
|
store;
|
|
1760
1854
|
/** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
|
|
1761
1855
|
modelsCache = /* @__PURE__ */ new Map();
|
|
@@ -1801,7 +1895,6 @@ var Router = class {
|
|
|
1801
1895
|
return {
|
|
1802
1896
|
id: c.id,
|
|
1803
1897
|
name: typeof c.name === "string" ? c.name : c.id,
|
|
1804
|
-
strategy: c.strategy === "round-robin" ? "round-robin" : "fallback",
|
|
1805
1898
|
models
|
|
1806
1899
|
};
|
|
1807
1900
|
}).filter((c) => c.models.length > 0);
|
|
@@ -1998,9 +2091,6 @@ var Router = class {
|
|
|
1998
2091
|
validModels(models) {
|
|
1999
2092
|
return Array.isArray(models) && models.length > 0 && models.every((m) => typeof m === "string" && m !== "");
|
|
2000
2093
|
}
|
|
2001
|
-
validStrategy(strategy) {
|
|
2002
|
-
return strategy === "fallback" || strategy === "round-robin";
|
|
2003
|
-
}
|
|
2004
2094
|
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
2005
2095
|
* 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
|
|
2006
2096
|
* (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
|
|
@@ -2021,8 +2111,8 @@ var Router = class {
|
|
|
2021
2111
|
return s === void 0 ? modelId : `${s.id},${modelId}`;
|
|
2022
2112
|
});
|
|
2023
2113
|
}
|
|
2024
|
-
/** 创建组合(name 唯一,非 default
|
|
2025
|
-
createCombo(name,
|
|
2114
|
+
/** 创建组合(name 唯一,非 default)。选模型是降级链(按序尝试),无策略。 */
|
|
2115
|
+
createCombo(name, models) {
|
|
2026
2116
|
const clean = name.trim();
|
|
2027
2117
|
if (clean === "" || clean === "default") return {
|
|
2028
2118
|
ok: false,
|
|
@@ -2040,14 +2130,9 @@ var Router = class {
|
|
|
2040
2130
|
ok: false,
|
|
2041
2131
|
error: "至少需要一个模型"
|
|
2042
2132
|
};
|
|
2043
|
-
if (!this.validStrategy(strategy)) return {
|
|
2044
|
-
ok: false,
|
|
2045
|
-
error: "策略无效"
|
|
2046
|
-
};
|
|
2047
2133
|
const combo = {
|
|
2048
2134
|
id: clean,
|
|
2049
2135
|
name: clean,
|
|
2050
|
-
strategy,
|
|
2051
2136
|
models: this.normalizeModelIds(models)
|
|
2052
2137
|
};
|
|
2053
2138
|
this.customCombos.push(combo);
|
|
@@ -2058,7 +2143,7 @@ var Router = class {
|
|
|
2058
2143
|
};
|
|
2059
2144
|
}
|
|
2060
2145
|
/** 更新组合(按 id)。 */
|
|
2061
|
-
updateCombo(id, name,
|
|
2146
|
+
updateCombo(id, name, models) {
|
|
2062
2147
|
const target = this.customCombos.find((c) => c.id === id);
|
|
2063
2148
|
if (!target) return {
|
|
2064
2149
|
ok: false,
|
|
@@ -2081,12 +2166,7 @@ var Router = class {
|
|
|
2081
2166
|
ok: false,
|
|
2082
2167
|
error: "至少需要一个模型"
|
|
2083
2168
|
};
|
|
2084
|
-
if (!this.validStrategy(strategy)) return {
|
|
2085
|
-
ok: false,
|
|
2086
|
-
error: "策略无效"
|
|
2087
|
-
};
|
|
2088
2169
|
target.name = clean;
|
|
2089
|
-
target.strategy = strategy;
|
|
2090
2170
|
target.models = this.normalizeModelIds(models);
|
|
2091
2171
|
this.saveCombos();
|
|
2092
2172
|
return { ok: true };
|
|
@@ -2143,15 +2223,14 @@ var Router = class {
|
|
|
2143
2223
|
durationMs: Date.now() - probe.startedAt,
|
|
2144
2224
|
ttfbMs: probe.ttfbMs,
|
|
2145
2225
|
...ok && probe.uid !== "" ? { uid: probe.uid } : {},
|
|
2226
|
+
...req.session === void 0 ? {} : { session: req.session },
|
|
2146
2227
|
...error === void 0 ? {} : { error }
|
|
2147
2228
|
}, usage);
|
|
2148
2229
|
};
|
|
2149
2230
|
const combo = this.comboByName(req.model);
|
|
2150
2231
|
if (combo) {
|
|
2151
|
-
const start = combo.strategy === "round-robin" ? (this.rrCursors.get(combo.id) ?? 0) % combo.models.length : 0;
|
|
2152
|
-
if (combo.strategy === "round-robin") this.rrCursors.set(combo.id, (this.rrCursors.get(combo.id) ?? 0) + 1);
|
|
2153
2232
|
for (let i = 0; i < combo.models.length; i++) {
|
|
2154
|
-
const model = combo.models[
|
|
2233
|
+
const model = combo.models[i];
|
|
2155
2234
|
if (model === void 0) continue;
|
|
2156
2235
|
const trace = { attempts: 0 };
|
|
2157
2236
|
if (await this.chatWithModel(req, res, model, probe, trace)) {
|
|
@@ -2234,7 +2313,7 @@ var Router = class {
|
|
|
2234
2313
|
outTrace.lastError = trace.lastError;
|
|
2235
2314
|
outTrace.lastState = trace.lastState;
|
|
2236
2315
|
}
|
|
2237
|
-
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);
|
|
2238
2317
|
return served;
|
|
2239
2318
|
}
|
|
2240
2319
|
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
@@ -2271,9 +2350,11 @@ var Router = class {
|
|
|
2271
2350
|
}
|
|
2272
2351
|
const messages = reqMessages(req.rawBody);
|
|
2273
2352
|
const tried = /* @__PURE__ */ new Set();
|
|
2353
|
+
let pickWhy = "";
|
|
2274
2354
|
for (;;) {
|
|
2275
|
-
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);
|
|
2276
2356
|
if (uid === void 0) return false;
|
|
2357
|
+
pickWhy = pool.lastWhy;
|
|
2277
2358
|
tried.add(uid);
|
|
2278
2359
|
const r = await s.chatOnce(uid, req.lv ?? "auto", req);
|
|
2279
2360
|
if (!r.ok) {
|
|
@@ -2294,14 +2375,17 @@ var Router = class {
|
|
|
2294
2375
|
continue;
|
|
2295
2376
|
}
|
|
2296
2377
|
pool.noteSuccess(uid, req.model);
|
|
2297
|
-
if (trace !== void 0)
|
|
2378
|
+
if (trace !== void 0) {
|
|
2379
|
+
trace.uid = uid;
|
|
2380
|
+
trace.pickWhy = pickWhy;
|
|
2381
|
+
}
|
|
2298
2382
|
if (probe !== void 0) {
|
|
2299
2383
|
probe.supplier = s.id;
|
|
2300
2384
|
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
|
|
2301
2385
|
probe.uid = uid;
|
|
2302
2386
|
}
|
|
2303
2387
|
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) {
|
|
2304
|
-
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0);
|
|
2388
|
+
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session);
|
|
2305
2389
|
return true;
|
|
2306
2390
|
}
|
|
2307
2391
|
pool.noteFailure(uid, req.model, "transport", "stream failed before first byte");
|
|
@@ -2390,6 +2474,10 @@ var Router = class {
|
|
|
2390
2474
|
* 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
|
|
2391
2475
|
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
2392
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' 分支。
|
|
2393
2481
|
*/
|
|
2394
2482
|
/** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
|
|
2395
2483
|
const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
|
|
@@ -2461,6 +2549,19 @@ async function wireMessages(options, system, attachments) {
|
|
|
2461
2549
|
});
|
|
2462
2550
|
continue;
|
|
2463
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
|
+
}
|
|
2464
2565
|
const toolResults = message.content.filter((b) => b.type === "tool-result");
|
|
2465
2566
|
const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal);
|
|
2466
2567
|
if (ownParts.length > 0 || toolResults.length === 0) {
|
|
@@ -2913,7 +3014,8 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
|
2913
3014
|
headers: {
|
|
2914
3015
|
...attributionHeaders(),
|
|
2915
3016
|
"content-type": "application/json",
|
|
2916
|
-
accept: "text/event-stream"
|
|
3017
|
+
accept: "text/event-stream",
|
|
3018
|
+
...options.sessionId === void 0 ? {} : { "x-dsh-router-session": String(options.sessionId) }
|
|
2917
3019
|
},
|
|
2918
3020
|
body: JSON.stringify(body),
|
|
2919
3021
|
signal: controller.signal
|
|
@@ -3512,29 +3614,6 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
3512
3614
|
writeJson$1(res, 200, { ok: true });
|
|
3513
3615
|
}
|
|
3514
3616
|
});
|
|
3515
|
-
routes.push({
|
|
3516
|
-
kind: "exact",
|
|
3517
|
-
path: `${p}/pool/strategy`,
|
|
3518
|
-
handler: async (req, res) => {
|
|
3519
|
-
if (req.method === "GET") {
|
|
3520
|
-
writeJson$1(res, 200, {
|
|
3521
|
-
ok: true,
|
|
3522
|
-
strategy: store.get(s.id).poolStrategy
|
|
3523
|
-
});
|
|
3524
|
-
return;
|
|
3525
|
-
}
|
|
3526
|
-
const body = JSON.parse(await readBody$1(req));
|
|
3527
|
-
if (body.strategy !== "fallback" && body.strategy !== "round-robin") {
|
|
3528
|
-
writeJson$1(res, 400, {
|
|
3529
|
-
ok: false,
|
|
3530
|
-
error: "策略无效"
|
|
3531
|
-
});
|
|
3532
|
-
return;
|
|
3533
|
-
}
|
|
3534
|
-
store.setPoolStrategy(s.id, body.strategy);
|
|
3535
|
-
writeJson$1(res, 200, { ok: true });
|
|
3536
|
-
}
|
|
3537
|
-
});
|
|
3538
3617
|
routes.push({
|
|
3539
3618
|
kind: "exact",
|
|
3540
3619
|
path: `${p}/models/fetch`,
|
|
@@ -3795,6 +3874,98 @@ function dshHome() {
|
|
|
3795
3874
|
return resolve(value);
|
|
3796
3875
|
}
|
|
3797
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
|
|
3798
3969
|
//#region src/ext/store.ts
|
|
3799
3970
|
/**
|
|
3800
3971
|
* 扩展存储 —— **开关状态与插件数据都归核心持久化**,插件不自己 file IO。
|
|
@@ -3913,6 +4084,31 @@ var ExtStore = class {
|
|
|
3913
4084
|
const name = "dsh-router-core";
|
|
3914
4085
|
/** Services required before mounting: the webserver (routes) + llm (设置-模型). */
|
|
3915
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;
|
|
3916
4112
|
function writeJson(res, status, body) {
|
|
3917
4113
|
const payload = JSON.stringify(body);
|
|
3918
4114
|
res.writeHead(status, {
|
|
@@ -3945,6 +4141,10 @@ function apply(rawContext) {
|
|
|
3945
4141
|
const dataDir = dataDirOf(ctx.baseUrl);
|
|
3946
4142
|
const stateFile = join(dataDir, "state.json");
|
|
3947
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"})`);
|
|
3948
4148
|
const store = new SupplierConfigStore(stateFile);
|
|
3949
4149
|
const credentials = new CredentialStore(join(dataDir, "auths"));
|
|
3950
4150
|
const router = new Router(stateFile, store, log);
|
|
@@ -4088,17 +4288,22 @@ function apply(rawContext) {
|
|
|
4088
4288
|
return;
|
|
4089
4289
|
}
|
|
4090
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;
|
|
4091
4293
|
await router.chatCompletions({
|
|
4092
4294
|
rawBody: body,
|
|
4093
4295
|
stream: !!peek.stream,
|
|
4094
4296
|
model: typeof peek.model === "string" ? peek.model : "",
|
|
4095
|
-
lv
|
|
4297
|
+
lv,
|
|
4298
|
+
...session === void 0 ? {} : { session }
|
|
4096
4299
|
}, res);
|
|
4097
4300
|
}));
|
|
4098
4301
|
route(`${ROUTER_API_BASE}/health`, (_req, res) => {
|
|
4099
4302
|
const { suppliers } = router.status();
|
|
4100
4303
|
writeJson(res, 200, {
|
|
4101
4304
|
ok: true,
|
|
4305
|
+
hostVersion: hostVersion ?? "",
|
|
4306
|
+
lastHitDock,
|
|
4102
4307
|
suppliers: suppliers.map((s) => {
|
|
4103
4308
|
const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id);
|
|
4104
4309
|
return {
|
|
@@ -4143,6 +4348,33 @@ function apply(rawContext) {
|
|
|
4143
4348
|
store.setAccountName(body.supplier, body.uid, typeof body.name === "string" ? body.name : "");
|
|
4144
4349
|
writeJson(res, 200, { ok: true });
|
|
4145
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
|
+
});
|
|
4146
4378
|
route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
|
|
4147
4379
|
try {
|
|
4148
4380
|
writeJson(res, 200, {
|
|
@@ -4176,7 +4408,7 @@ function apply(rawContext) {
|
|
|
4176
4408
|
});
|
|
4177
4409
|
return;
|
|
4178
4410
|
}
|
|
4179
|
-
const result = router.createCombo(body.name ?? "", body.
|
|
4411
|
+
const result = router.createCombo(body.name ?? "", body.models ?? []);
|
|
4180
4412
|
writeJson(res, result.ok ? 200 : 400, result);
|
|
4181
4413
|
});
|
|
4182
4414
|
route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
|
|
@@ -4190,7 +4422,7 @@ function apply(rawContext) {
|
|
|
4190
4422
|
});
|
|
4191
4423
|
return;
|
|
4192
4424
|
}
|
|
4193
|
-
const result = router.updateCombo(body.id ?? "", body.name ?? "", body.
|
|
4425
|
+
const result = router.updateCombo(body.id ?? "", body.name ?? "", body.models ?? []);
|
|
4194
4426
|
writeJson(res, result.ok ? 200 : 400, result);
|
|
4195
4427
|
});
|
|
4196
4428
|
route(`${ROUTER_API_BASE}/combos/remove`, async (req, res) => {
|
|
@@ -4341,32 +4573,6 @@ function apply(rawContext) {
|
|
|
4341
4573
|
}).filter((e) => e !== void 0)
|
|
4342
4574
|
});
|
|
4343
4575
|
});
|
|
4344
|
-
const extDispatch = ctx.webServer.register({
|
|
4345
|
-
kind: "prefix",
|
|
4346
|
-
path: `${ROUTER_API_BASE}/ext/`,
|
|
4347
|
-
handler: async (req, res) => {
|
|
4348
|
-
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
4349
|
-
const mount = `${ROUTER_API_BASE}/ext/`;
|
|
4350
|
-
const rest = pathname.startsWith(mount) ? pathname.slice(mount.length) : "";
|
|
4351
|
-
const slash = rest.indexOf("/");
|
|
4352
|
-
const extId = slash < 0 ? rest : rest.slice(0, slash);
|
|
4353
|
-
const subpath = slash < 0 ? "" : rest.slice(slash + 1);
|
|
4354
|
-
if (extId === "") {
|
|
4355
|
-
writeJson(res, 404, {
|
|
4356
|
-
ok: false,
|
|
4357
|
-
error: "extension not found"
|
|
4358
|
-
});
|
|
4359
|
-
return;
|
|
4360
|
-
}
|
|
4361
|
-
const ext = exts[extId];
|
|
4362
|
-
if (ext !== void 0 && typeof ext.api === "function" && await ext.api(subpath, req, res)) return;
|
|
4363
|
-
writeJson(res, 404, {
|
|
4364
|
-
ok: false,
|
|
4365
|
-
error: "extension api not found"
|
|
4366
|
-
});
|
|
4367
|
-
}
|
|
4368
|
-
});
|
|
4369
|
-
disposers.push(extDispatch);
|
|
4370
4576
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
4371
4577
|
const periodOf = (raw) => {
|
|
4372
4578
|
return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";
|
|
@@ -4391,7 +4597,10 @@ function apply(rawContext) {
|
|
|
4391
4597
|
bySupplier: s.bySupplier,
|
|
4392
4598
|
byModel: s.byModel,
|
|
4393
4599
|
byRequested: s.byRequested,
|
|
4394
|
-
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
|
+
}))
|
|
4395
4604
|
});
|
|
4396
4605
|
});
|
|
4397
4606
|
route(`${ROUTER_API_BASE}/stats/chart`, (req, res) => {
|
|
@@ -4412,53 +4621,57 @@ function apply(rawContext) {
|
|
|
4412
4621
|
router.usage.clear();
|
|
4413
4622
|
writeJson(res, 200, { ok: true });
|
|
4414
4623
|
});
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
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: () => {}
|
|
4440
4651
|
});
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
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");
|
|
4462
4675
|
route(`${ROUTER_API_BASE}/debug/llm`, async (_req, res) => {
|
|
4463
4676
|
let directory = "n/a";
|
|
4464
4677
|
let registeredProviders = "n/a";
|
|
@@ -4485,7 +4698,7 @@ function apply(rawContext) {
|
|
|
4485
4698
|
directory,
|
|
4486
4699
|
registeredProviders,
|
|
4487
4700
|
settingsNamespaces,
|
|
4488
|
-
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(
|
|
4701
|
+
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(settingsNs)
|
|
4489
4702
|
});
|
|
4490
4703
|
});
|
|
4491
4704
|
ctx.effect(() => () => {
|
|
@@ -4497,4 +4710,4 @@ function apply(rawContext) {
|
|
|
4497
4710
|
}, "dsh-router: teardown");
|
|
4498
4711
|
}
|
|
4499
4712
|
//#endregion
|
|
4500
|
-
export { apply, inject, name };
|
|
4713
|
+
export { Config, apply, inject, name };
|