dsh-router-core 0.3.11 → 0.3.13
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 +32 -3
- package/docs/ext.md +224 -0
- package/docs/pool-sticky-block.md +257 -0
- package/lib/client-registry.js +267 -95
- package/lib/client.js +267 -95
- package/lib/index.js +416 -13
- package/lib/types/ext/contract.d.ts +95 -0
- package/lib/types/ext/store.d.ts +16 -0
- package/lib/types/llm/adapter.d.ts +9 -0
- package/lib/types/router/account-pool.d.ts +58 -2
- package/lib/types/router/prefix-affinity.d.ts +6 -0
- package/lib/types/router/usage-store.d.ts +8 -0
- package/lib/types/shared.d.ts +18 -0
- package/package.json +5 -5
- package/src/client/ExtTab.tsx +143 -0
- package/src/client/RouterView.tsx +10 -2
- package/src/client/SupplierDetail.tsx +1 -1
- package/src/client/router.css +19 -0
- package/src/ext/contract.ts +115 -0
- package/src/ext/store.test.ts +144 -0
- package/src/ext/store.ts +125 -0
- package/src/index.ts +71 -0
- package/src/llm/adapter.ts +22 -5
- package/src/router/account-pool.test.ts +244 -1
- package/src/router/account-pool.ts +180 -4
- package/src/router/index.ts +25 -2
- package/src/router/prefix-affinity.ts +80 -0
- package/src/router/usage-store.ts +8 -0
- package/src/shared.ts +20 -0
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { basename, dirname, join, resolve } from "node:path";
|
|
2
2
|
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
4
|
import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, attributionHeaders } from "@deepseek-ai/dsh-llm";
|
|
4
|
-
import { randomBytes } from "node:crypto";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
7
7
|
import { homedir } from "node:os";
|
|
@@ -9,6 +9,84 @@ import { homedir } from "node:os";
|
|
|
9
9
|
/** Same-origin API base the client panel fetches (host half serves it). */
|
|
10
10
|
const ROUTER_API_BASE = "/router/api";
|
|
11
11
|
//#endregion
|
|
12
|
+
//#region src/router/prefix-affinity.ts
|
|
13
|
+
/**
|
|
14
|
+
* 前缀指纹 —— 把一次请求的**消息前缀**压成一个短字符串,用于「同一个会话
|
|
15
|
+
* 粘在同一个账号上」。
|
|
16
|
+
*
|
|
17
|
+
* ## 为什么要这个
|
|
18
|
+
*
|
|
19
|
+
* 上游 prompt cache 是 **per-account × per-conversation** 的命名空间。块轮询
|
|
20
|
+
* 保证的是「单会话内连续驻留」,但多个会话被轮到同一个号时,各自十几万的大
|
|
21
|
+
* 前缀会在该号的缓存里**互相驱逐**(实测 3 会话并发 → 命中 3.8%,单会话独占
|
|
22
|
+
* → 82.5%,见 docs/pool-sticky-block.md §2.1)。
|
|
23
|
+
*
|
|
24
|
+
* 破法是让每个会话固定落到一个号:前缀指纹 → 选号。指纹相同的请求(= 同一
|
|
25
|
+
* 会话的连续请求,前缀逐轮增长但开头不变)落在同一个号,各自有独立的缓存
|
|
26
|
+
* 空间。
|
|
27
|
+
*
|
|
28
|
+
* ## 为什么按「前几条消息」而不是整个 body
|
|
29
|
+
*
|
|
30
|
+
* - 整个 body 每次都不一样(会话越跑越长)→ 指纹漂移 → 同一会话换号,正好
|
|
31
|
+
* 是要避免的事。
|
|
32
|
+
* - 请求体前 N 字节也不行:system prompt 动辄几 KB,同源会话(同一个 agent)
|
|
33
|
+
* 在这 N 字节里完全一样,多个会话会撞到同一个号。
|
|
34
|
+
* - 条数取 **2**(system + 首条用户消息)是刻意的:这两个槽位在会话整个
|
|
35
|
+
* 生命周期里**永不变化**(新消息只往后追加),所以从第 1 个请求起指纹
|
|
36
|
+
* 就稳定。取 3 条会在第一条 assistant 回复落进槽位 2 时变一次指纹 →
|
|
37
|
+
* 会话开局多付一次全量重算。而「system + 首条消息逐字相同」的会话,
|
|
38
|
+
* 缓存前缀本来就相同,共享一个号的缓存是收益不是冲突。
|
|
39
|
+
*
|
|
40
|
+
* 天花板:若某个客户端的前 3 条消息在所有会话里都逐字相同(无状态短请求),
|
|
41
|
+
* 指纹会退化成一个值 → 全部压到一个号。此时退化行为 = fallback,可接受;
|
|
42
|
+
* 真要区分得等 DSH 透传会话 id(不为此改契约)。
|
|
43
|
+
*/
|
|
44
|
+
/** 取前几条消息:system + 首条用户消息。会话里这两个槽位永不变化 → 指纹从第 1 个请求起稳定。 */
|
|
45
|
+
const FP_MSGS = 2;
|
|
46
|
+
/** 每条消息取前多少字符。缓存前缀的区分度集中在开头,全量哈希只增成本。 */
|
|
47
|
+
const FP_CHARS = 512;
|
|
48
|
+
/**
|
|
49
|
+
* 消息 content 可能是字符串,也可能是多模态分片数组
|
|
50
|
+
* (`[{ type: 'text', text: '...' }]`)。只取文本部分:图片是 base64,
|
|
51
|
+
* 既大又不参与前缀匹配的语义(同一张图每轮都是同一串)。
|
|
52
|
+
*/
|
|
53
|
+
function textOf(content) {
|
|
54
|
+
if (typeof content === "string") return content;
|
|
55
|
+
if (!Array.isArray(content)) return "";
|
|
56
|
+
let s = "";
|
|
57
|
+
for (const part of content) {
|
|
58
|
+
if (typeof part === "string") {
|
|
59
|
+
s += part;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (part !== null && typeof part === "object") {
|
|
63
|
+
const t = part.text;
|
|
64
|
+
if (typeof t === "string") s += t;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return s;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 算请求的前缀指纹。
|
|
71
|
+
* @param messages OpenAI 请求体的 `messages` 字段(未校验,来自外部输入)
|
|
72
|
+
* @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
|
|
73
|
+
*/
|
|
74
|
+
function prefixFingerprint(messages) {
|
|
75
|
+
if (!Array.isArray(messages) || messages.length === 0) return "";
|
|
76
|
+
let buf = "";
|
|
77
|
+
const n = Math.min(messages.length, FP_MSGS);
|
|
78
|
+
for (let i = 0; i < n; i++) {
|
|
79
|
+
const m = messages[i];
|
|
80
|
+
if (m === null || typeof m !== "object") continue;
|
|
81
|
+
const { role, content } = m;
|
|
82
|
+
const text = textOf(content);
|
|
83
|
+
if (typeof role !== "string" && text === "") continue;
|
|
84
|
+
buf += `${typeof role === "string" ? role : ""}\u0000${text.slice(0, FP_CHARS)}\u0001`;
|
|
85
|
+
}
|
|
86
|
+
if (buf === "") return "";
|
|
87
|
+
return createHash("sha1").update(buf).digest("base64url").slice(0, 12);
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
12
90
|
//#region src/router/account-pool.ts
|
|
13
91
|
const MINUTE = 6e4;
|
|
14
92
|
/**
|
|
@@ -77,6 +155,22 @@ const TRANSIENT_COOLDOWN_MS = 3e4;
|
|
|
77
155
|
const BACKOFF_BASE_MS = 2e3;
|
|
78
156
|
const BACKOFF_MAX_MS = 5 * MINUTE;
|
|
79
157
|
const BACKOFF_MAX_LEVEL = 15;
|
|
158
|
+
/** 块轮询:命中率达标就放行(`留守 = 计数<N OR 命中率<M`)。见 stickyBlock 说明。 */
|
|
159
|
+
const BLOCK_HIT_RATIO = .85;
|
|
160
|
+
/** 块轮询:最短驻留。新接到这个号的最少连续服务次数(防冷启动误切)。 */
|
|
161
|
+
const BLOCK_MIN = 16;
|
|
162
|
+
/** 块轮询:绝对兜底。轮次过多(max)无论命中率如何都让位,防死守 + 保均衡下限。 */
|
|
163
|
+
const BLOCK_MAX = 48;
|
|
164
|
+
/** 块轮询:判定「缓存无效」后的跳过时长。 */
|
|
165
|
+
const BLOCK_DEMOTE_MS = 10 * MINUTE;
|
|
166
|
+
/**
|
|
167
|
+
* 前缀亲和:指纹 → 号 的绑定记忆上限。
|
|
168
|
+
*
|
|
169
|
+
* 会话指纹是无限集合(每个新会话一个),不封顶就是内存泄漏。超过了就丢掉
|
|
170
|
+
* **最久没用**的那条:活跃会话的指纹每请求都在刷新,不会被误删。
|
|
171
|
+
* 单号池(<= 几十个会话)绰绰有余。
|
|
172
|
+
*/
|
|
173
|
+
const AFFINITY_MAX = 512;
|
|
80
174
|
/** 指数退避:等级 1 = BACKOFF_BASE_MS,每级翻倍,封顶 BACKOFF_MAX_MS。 */
|
|
81
175
|
function backoffMs(level) {
|
|
82
176
|
return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, level - 1), BACKOFF_MAX_MS);
|
|
@@ -91,6 +185,14 @@ var AccountPool = class {
|
|
|
91
185
|
/** uid → 连接级禁用 / 手动暂停。 */
|
|
92
186
|
byUid = /* @__PURE__ */ new Map();
|
|
93
187
|
rrCursor = 0;
|
|
188
|
+
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
189
|
+
blocks = /* @__PURE__ */ new Map();
|
|
190
|
+
/** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
|
|
191
|
+
samples = /* @__PURE__ */ new Map();
|
|
192
|
+
/** (supplier, model, uid) → 缓存无效降权。 */
|
|
193
|
+
demoted = /* @__PURE__ */ new Map();
|
|
194
|
+
/** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
|
|
195
|
+
affinity = /* @__PURE__ */ new Map();
|
|
94
196
|
constructor(supplierId = "") {
|
|
95
197
|
this.supplierId = supplierId;
|
|
96
198
|
}
|
|
@@ -156,22 +258,118 @@ var AccountPool = class {
|
|
|
156
258
|
}
|
|
157
259
|
/**
|
|
158
260
|
* 按策略为「某个模型」选一个健康账号。
|
|
261
|
+
*
|
|
262
|
+
* round-robin = **前缀亲和定号 + 块统计只用来判定缓存无效**:
|
|
263
|
+
*
|
|
264
|
+
* 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
|
|
265
|
+
* 这是第一优先:同一个会话的连续请求**永远落回同一个号**,前缀在该号的
|
|
266
|
+
* 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
|
|
267
|
+
* 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
|
|
268
|
+
* 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
|
|
269
|
+
* 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
|
|
270
|
+
* 3. 块统计不再驱动轮转(亲和已经保证驻留,再轮转只会把会话踢出已热前缀),
|
|
271
|
+
* 只保留 `BLOCK_MAX` 次不达标 → 降权该号(缓存无效,可恢复)。
|
|
272
|
+
*
|
|
273
|
+
* 亲和失效(拿不到 messages / 指纹算不出)时**回退到块轮询**,行为与之前一致。
|
|
274
|
+
* 设计见 docs/pool-sticky-block.md §8。
|
|
275
|
+
*
|
|
159
276
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
160
277
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
161
|
-
* @param strategy fallback 取第一个健康 / round-robin
|
|
278
|
+
* @param strategy fallback 取第一个健康 / round-robin 前缀亲和
|
|
162
279
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
280
|
+
* @param messages 请求体的 messages(可选;给了才能算前缀指纹)
|
|
163
281
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
164
282
|
*/
|
|
165
|
-
pick(accounts, poolOrder, strategy, modelId) {
|
|
283
|
+
pick(accounts, poolOrder, strategy, modelId, messages) {
|
|
166
284
|
const now = Date.now();
|
|
167
285
|
const byUid = new Map(accounts.map((a) => [a.uid, a]));
|
|
168
286
|
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));
|
|
169
287
|
if (healthy.length === 0) return void 0;
|
|
170
288
|
if (strategy !== "round-robin") return healthy[0];
|
|
171
|
-
const
|
|
172
|
-
|
|
289
|
+
const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now));
|
|
290
|
+
const pool = usable.length > 0 ? usable : healthy;
|
|
291
|
+
const fp = messages === void 0 ? "" : prefixFingerprint(messages);
|
|
292
|
+
if (fp !== "" && pool.includes(this.affinity.get(this.blockKey(modelId))?.get(fp) ?? "")) return this.affinity.get(this.blockKey(modelId)).get(fp);
|
|
293
|
+
const block = this.blocks.get(this.blockKey(modelId));
|
|
294
|
+
if (fp === "" && block !== void 0 && pool.includes(block) && this.blockKeeps(modelId, block)) return block;
|
|
295
|
+
const uid = pool[(block === void 0 ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)) % pool.length];
|
|
296
|
+
this.rrCursor = (pool.indexOf(uid) + 1) % pool.length;
|
|
297
|
+
this.blocks.set(this.blockKey(modelId), uid);
|
|
298
|
+
if (fp !== "") this.bind(modelId, fp, uid);
|
|
299
|
+
else this.samples.delete(this.key(modelId, uid));
|
|
173
300
|
return uid;
|
|
174
301
|
}
|
|
302
|
+
/** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
|
|
303
|
+
bind(modelId, fp, uid) {
|
|
304
|
+
const k = this.blockKey(modelId);
|
|
305
|
+
let m = this.affinity.get(k);
|
|
306
|
+
if (m === void 0) {
|
|
307
|
+
m = /* @__PURE__ */ new Map();
|
|
308
|
+
this.affinity.set(k, m);
|
|
309
|
+
}
|
|
310
|
+
m.delete(fp);
|
|
311
|
+
m.set(fp, uid);
|
|
312
|
+
if (m.size > AFFINITY_MAX) {
|
|
313
|
+
const oldest = m.keys().next().value;
|
|
314
|
+
if (oldest !== void 0) m.delete(oldest);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
|
|
318
|
+
isDemoted(uid, modelId, now) {
|
|
319
|
+
const e = this.demoted.get(this.key(modelId, uid));
|
|
320
|
+
return e !== void 0 && e.until > now;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* 驻留是否还应留在当前号上(**只用于无亲和的回退路径**)。
|
|
324
|
+
*
|
|
325
|
+
* OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
|
|
326
|
+
* 单一账号内部:一个号先被轮到(冷启动/热),然后
|
|
327
|
+
* - 命中率**爬上去**(>= M,缓存热了)→ 让位,轮到下一个号(均衡)
|
|
328
|
+
* - 命中率**没上去**(冷启动/短块)→ 留守,继续热(不急着跳)
|
|
329
|
+
* - 轮次**实在太多**(计数 >= N_max)→ 也换(防死守 + 保均衡下限)
|
|
330
|
+
*
|
|
331
|
+
* 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
|
|
332
|
+
* 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
|
|
333
|
+
*/
|
|
334
|
+
blockKeeps(modelId, uid) {
|
|
335
|
+
const s = this.samples.get(this.key(modelId, uid));
|
|
336
|
+
const served = s?.served ?? 0;
|
|
337
|
+
if (served >= BLOCK_MAX) return false;
|
|
338
|
+
const ratio = served === 0 ? 0 : (s?.hits ?? 0) / served;
|
|
339
|
+
return served < BLOCK_MIN || ratio < BLOCK_HIT_RATIO;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
|
|
343
|
+
*
|
|
344
|
+
* 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
|
|
345
|
+
* 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
|
|
346
|
+
* 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
|
|
347
|
+
* @param uid 实际服务的号
|
|
348
|
+
* @param modelId 模型(块粒度)
|
|
349
|
+
* @param cachedTokens 上游报的命中 token 数;0 = 全量重算
|
|
350
|
+
*/
|
|
351
|
+
noteCache(uid, modelId, cachedTokens) {
|
|
352
|
+
const k = this.key(modelId, uid);
|
|
353
|
+
const s = this.samples.get(k) ?? {
|
|
354
|
+
served: 0,
|
|
355
|
+
hits: 0
|
|
356
|
+
};
|
|
357
|
+
s.served += 1;
|
|
358
|
+
if (cachedTokens > 0) s.hits += 1;
|
|
359
|
+
this.samples.set(k, s);
|
|
360
|
+
if (s.served >= BLOCK_MAX && s.hits / s.served < BLOCK_HIT_RATIO) {
|
|
361
|
+
this.demoted.set(k, { until: Date.now() + BLOCK_DEMOTE_MS });
|
|
362
|
+
this.samples.delete(k);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/** 故障切走时作废当前驻留:换号后前缀归属变了,驻留计数不能沿用。 */
|
|
366
|
+
dropBlock(modelId) {
|
|
367
|
+
this.blocks.delete(this.blockKey(modelId));
|
|
368
|
+
}
|
|
369
|
+
/** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
|
|
370
|
+
blockKey(modelId) {
|
|
371
|
+
return `${this.supplierId}${SEP}${modelId}`;
|
|
372
|
+
}
|
|
175
373
|
/** 记录一次失败:按状态处置(冷却/禁用/退避),落在「(模型, 连接)」这个冷却单元上。 */
|
|
176
374
|
noteFailure(uid, modelId, state, message) {
|
|
177
375
|
const rule = RULES[state];
|
|
@@ -1156,6 +1354,17 @@ const TRANSIENT_SETTLE_MS = 2e3;
|
|
|
1156
1354
|
function isTransient(state) {
|
|
1157
1355
|
return state === "transport" || state === "unavailable";
|
|
1158
1356
|
}
|
|
1357
|
+
/**
|
|
1358
|
+
* 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
|
|
1359
|
+
* undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
|
|
1360
|
+
*/
|
|
1361
|
+
function reqMessages(rawBody) {
|
|
1362
|
+
try {
|
|
1363
|
+
return JSON.parse(rawBody).messages;
|
|
1364
|
+
} catch {
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1159
1368
|
/** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
|
|
1160
1369
|
function sinkRes() {
|
|
1161
1370
|
let status = 200;
|
|
@@ -1867,6 +2076,7 @@ var Router = class {
|
|
|
1867
2076
|
ok,
|
|
1868
2077
|
durationMs: Date.now() - probe.startedAt,
|
|
1869
2078
|
ttfbMs: probe.ttfbMs,
|
|
2079
|
+
...ok && probe.uid !== "" ? { uid: probe.uid } : {},
|
|
1870
2080
|
...error === void 0 ? {} : { error }
|
|
1871
2081
|
}, usage);
|
|
1872
2082
|
};
|
|
@@ -1993,9 +2203,10 @@ var Router = class {
|
|
|
1993
2203
|
}
|
|
1994
2204
|
return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0);
|
|
1995
2205
|
}
|
|
2206
|
+
const messages = reqMessages(req.rawBody);
|
|
1996
2207
|
const tried = /* @__PURE__ */ new Set();
|
|
1997
2208
|
for (;;) {
|
|
1998
|
-
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model);
|
|
2209
|
+
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model, messages);
|
|
1999
2210
|
if (uid === void 0) return false;
|
|
2000
2211
|
tried.add(uid);
|
|
2001
2212
|
const r = await s.chatOnce(uid, req.lv ?? "auto", req);
|
|
@@ -2005,6 +2216,7 @@ var Router = class {
|
|
|
2005
2216
|
return false;
|
|
2006
2217
|
}
|
|
2007
2218
|
pool.noteFailure(uid, req.model, r.state, r.message);
|
|
2219
|
+
pool.dropBlock(req.model);
|
|
2008
2220
|
if (trace !== void 0) {
|
|
2009
2221
|
trace.attempts += 1;
|
|
2010
2222
|
trace.lastState = r.state;
|
|
@@ -2019,7 +2231,10 @@ var Router = class {
|
|
|
2019
2231
|
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
|
|
2020
2232
|
probe.uid = uid;
|
|
2021
2233
|
}
|
|
2022
|
-
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0))
|
|
2234
|
+
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) {
|
|
2235
|
+
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0);
|
|
2236
|
+
return true;
|
|
2237
|
+
}
|
|
2023
2238
|
pool.noteFailure(uid, req.model, "transport", "stream failed before first byte");
|
|
2024
2239
|
if (trace !== void 0) {
|
|
2025
2240
|
trace.attempts += 1;
|
|
@@ -2088,11 +2303,16 @@ var Router = class {
|
|
|
2088
2303
|
/**
|
|
2089
2304
|
* 把 DSH 消息序列化成 openai 兼容 wire 消息(纯文本 + tool)。
|
|
2090
2305
|
*
|
|
2091
|
-
* `system` 必须由调用方从 `options.system` 传进来:
|
|
2092
|
-
* 放在 **options.system 这个独立槽位**,不放进 messages
|
|
2093
|
-
*
|
|
2094
|
-
*
|
|
2095
|
-
*
|
|
2306
|
+
* `system` 必须由调用方从 `options.system` 传进来:one-shot 调用方把系统提示词
|
|
2307
|
+
* 放在 **options.system 这个独立槽位**,不放进 messages。不读这个槽位,
|
|
2308
|
+
* one-shot 调用方(如 compaction / session-title)的模型每一轮都拿不到身份/
|
|
2309
|
+
* 规则/工具用法约束,且**不会报错**,只是行为悄悄降级。
|
|
2310
|
+
*
|
|
2311
|
+
* 0.1.5 起 loop-built 请求反过来:`options.system` 为 undefined,系统提示词作为
|
|
2312
|
+
* **messages 里的 system-role 消息**下发(dsh-agent-loop 的 SystemProjection,
|
|
2313
|
+
* 可出现在任意位置以支持提示词热更新)。两条路径都必须支持 —— 与官方
|
|
2314
|
+
* dsh-llm-deepseek 一致(serializeRequest 前置 options.system,再按位置序列化
|
|
2315
|
+
* messages 里的 system 消息)。
|
|
2096
2316
|
*/
|
|
2097
2317
|
function wireMessages(options, system) {
|
|
2098
2318
|
const out = [];
|
|
@@ -2420,7 +2640,7 @@ const ROUTER_REASONING_EFFORTS = [
|
|
|
2420
2640
|
name: "Max"
|
|
2421
2641
|
}
|
|
2422
2642
|
];
|
|
2423
|
-
var RouterAdapter = class extends LlmAdapter {
|
|
2643
|
+
var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
2424
2644
|
baseURL;
|
|
2425
2645
|
source;
|
|
2426
2646
|
constructor(baseURL, source) {
|
|
@@ -2449,12 +2669,22 @@ var RouterAdapter = class extends LlmAdapter {
|
|
|
2449
2669
|
}
|
|
2450
2670
|
return out;
|
|
2451
2671
|
}
|
|
2672
|
+
/**
|
|
2673
|
+
* 只收文本。`resolveModel` 声明 `inputModalities: ['text']` 是**必须**的:
|
|
2674
|
+
* 0.1.5 的 runtime 只在 `inputModalities` 存在且不含 `image` 时才把 image 块
|
|
2675
|
+
* 投影成占位文本(见 dsh-llm adapterStream 的 projectImagesForTextModel)。
|
|
2676
|
+
* 不声明 = "unknown" = 不投影,而 wireMessages 只读 text 块 → 图片**静默
|
|
2677
|
+
* 丢失**,模型看到一条没有图片的用户消息却毫无提示。声明后由 runtime 统一
|
|
2678
|
+
* 投影,我们仍只处理文本。
|
|
2679
|
+
*/
|
|
2680
|
+
static INPUT_MODALITIES = ["text"];
|
|
2452
2681
|
async resolveModel(_provider, model) {
|
|
2453
2682
|
const found = (await this.source.comboModels()).find((m) => m.id === model || m.name === model);
|
|
2454
2683
|
return {
|
|
2455
2684
|
provider: "router",
|
|
2456
2685
|
id: model,
|
|
2457
2686
|
name: found?.name ?? found?.id ?? model,
|
|
2687
|
+
inputModalities: RouterAdapter.INPUT_MODALITIES,
|
|
2458
2688
|
...found?.contextWindow !== void 0 ? { context: { contextWindow: found.contextWindow } } : {},
|
|
2459
2689
|
reasoning: {
|
|
2460
2690
|
efforts: ROUTER_REASONING_EFFORTS,
|
|
@@ -3328,6 +3558,116 @@ function dshHome() {
|
|
|
3328
3558
|
return resolve(value);
|
|
3329
3559
|
}
|
|
3330
3560
|
//#endregion
|
|
3561
|
+
//#region src/ext/store.ts
|
|
3562
|
+
/**
|
|
3563
|
+
* 扩展存储 —— **开关状态与插件数据都归核心持久化**,插件不自己 file IO。
|
|
3564
|
+
*
|
|
3565
|
+
* 落盘 `<dataDir>/ext.json`,按扩展器 id 存:
|
|
3566
|
+
* `{ "rtk": { "enabled": true, "data": { ...插件自己的东西... } } }`
|
|
3567
|
+
*
|
|
3568
|
+
* 为什么归核心:跟 `supplier-config.json` 一个道理——开关是用户对**核心面板**的操作,
|
|
3569
|
+
* 不该由扩展插件各自决定存哪、怎么存(否则每个插件长一份互相不一致的实现,核心也无
|
|
3570
|
+
* 从统一裁决「能不能开」)。插件变薄:只报自己是否可用(`ready`)+ 做事。
|
|
3571
|
+
*
|
|
3572
|
+
* `data` 槽是给插件的通用抽屉(2026-09 加):插件不再为了存一点状态而自己开数据目录,
|
|
3573
|
+
* 统一经 `router.extStore` service 读写,落盘位置仍由核心锚定(不跟 cwd 跑)。
|
|
3574
|
+
*
|
|
3575
|
+
* 写法与核心其他存储一致:先写 `.tmp` 再 `rename` 原子覆盖(避免写一半崩掉留坏文件),
|
|
3576
|
+
* 权限 600;持久化失败不阻断。
|
|
3577
|
+
*/
|
|
3578
|
+
/** 扩展开关 + 插件数据存储。 */
|
|
3579
|
+
var ExtStore = class {
|
|
3580
|
+
fp = "";
|
|
3581
|
+
byId = {};
|
|
3582
|
+
constructor(stateFile) {
|
|
3583
|
+
this.fp = stateFile ? join(dirname(stateFile), "ext.json") : "";
|
|
3584
|
+
this.load();
|
|
3585
|
+
this.migrateLegacy();
|
|
3586
|
+
}
|
|
3587
|
+
migrateLegacy() {
|
|
3588
|
+
if (this.fp === "") return;
|
|
3589
|
+
if (Object.keys(this.byId).length > 0) return;
|
|
3590
|
+
const legacy = join(dirname(this.fp), "enhance.json");
|
|
3591
|
+
let enabled = false;
|
|
3592
|
+
try {
|
|
3593
|
+
enabled = JSON.parse(readFileSync(legacy, "utf8")).enabled === true;
|
|
3594
|
+
} catch {
|
|
3595
|
+
return;
|
|
3596
|
+
}
|
|
3597
|
+
if (!enabled) return;
|
|
3598
|
+
this.byId.rtk = {
|
|
3599
|
+
enabled: true,
|
|
3600
|
+
data: void 0
|
|
3601
|
+
};
|
|
3602
|
+
this.save();
|
|
3603
|
+
}
|
|
3604
|
+
load() {
|
|
3605
|
+
if (this.fp === "") return;
|
|
3606
|
+
let raw;
|
|
3607
|
+
try {
|
|
3608
|
+
raw = readFileSync(this.fp, "utf8");
|
|
3609
|
+
} catch {
|
|
3610
|
+
return;
|
|
3611
|
+
}
|
|
3612
|
+
try {
|
|
3613
|
+
const f = JSON.parse(raw);
|
|
3614
|
+
for (const [id, v] of Object.entries(f)) {
|
|
3615
|
+
if (id === "" || v === null || typeof v !== "object") continue;
|
|
3616
|
+
if (typeof v?.enabled !== "boolean") continue;
|
|
3617
|
+
this.byId[id] = {
|
|
3618
|
+
enabled: v.enabled,
|
|
3619
|
+
data: v.data
|
|
3620
|
+
};
|
|
3621
|
+
}
|
|
3622
|
+
} catch {}
|
|
3623
|
+
}
|
|
3624
|
+
save() {
|
|
3625
|
+
if (this.fp === "") return;
|
|
3626
|
+
try {
|
|
3627
|
+
const dir = dirname(this.fp);
|
|
3628
|
+
if (dir !== "" && dir !== ".") mkdirSync(dir, { recursive: true });
|
|
3629
|
+
const out = {};
|
|
3630
|
+
for (const [id, e] of Object.entries(this.byId)) out[id] = e.data === void 0 ? { enabled: e.enabled } : {
|
|
3631
|
+
enabled: e.enabled,
|
|
3632
|
+
data: e.data
|
|
3633
|
+
};
|
|
3634
|
+
const raw = JSON.stringify(out, null, 2);
|
|
3635
|
+
const tmp = this.fp + ".tmp";
|
|
3636
|
+
writeFileSync(tmp, raw, { mode: 384 });
|
|
3637
|
+
renameSync(tmp, this.fp);
|
|
3638
|
+
} catch {}
|
|
3639
|
+
}
|
|
3640
|
+
/** 是否开启(未记录过 = 默认关)。 */
|
|
3641
|
+
isEnabled(id) {
|
|
3642
|
+
return this.byId[id]?.enabled === true;
|
|
3643
|
+
}
|
|
3644
|
+
setEnabled(id, enabled) {
|
|
3645
|
+
if (id === "") return;
|
|
3646
|
+
const cur = this.byId[id];
|
|
3647
|
+
if (cur?.enabled === enabled) return;
|
|
3648
|
+
this.byId[id] = {
|
|
3649
|
+
enabled,
|
|
3650
|
+
data: cur?.data
|
|
3651
|
+
};
|
|
3652
|
+
this.save();
|
|
3653
|
+
}
|
|
3654
|
+
/** 读插件自己存的数据块(没存过 = undefined)。 */
|
|
3655
|
+
readData(id) {
|
|
3656
|
+
if (id === "") return void 0;
|
|
3657
|
+
return this.byId[id]?.data;
|
|
3658
|
+
}
|
|
3659
|
+
/** 写插件自己的数据块(覆盖;与 enabled 同一文件落盘)。 */
|
|
3660
|
+
writeData(id, value) {
|
|
3661
|
+
if (id === "") return;
|
|
3662
|
+
const cur = this.byId[id];
|
|
3663
|
+
this.byId[id] = {
|
|
3664
|
+
enabled: cur?.enabled === true,
|
|
3665
|
+
data: value
|
|
3666
|
+
};
|
|
3667
|
+
this.save();
|
|
3668
|
+
}
|
|
3669
|
+
};
|
|
3670
|
+
//#endregion
|
|
3331
3671
|
//#region src/index.ts
|
|
3332
3672
|
/**
|
|
3333
3673
|
* Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
|
|
@@ -3401,6 +3741,10 @@ function apply(rawContext) {
|
|
|
3401
3741
|
log(`supplier unregistered: ${id}`);
|
|
3402
3742
|
};
|
|
3403
3743
|
ctx.provide("router.suppliers", {});
|
|
3744
|
+
const exts = {};
|
|
3745
|
+
ctx.provide("router.ext", exts);
|
|
3746
|
+
const extStore = new ExtStore(stateFile);
|
|
3747
|
+
ctx.provide("router.extStore", extStore);
|
|
3404
3748
|
const registerLoaded = (loaded) => {
|
|
3405
3749
|
loadedSuppliers.push(loaded);
|
|
3406
3750
|
router.add(loaded.supplier);
|
|
@@ -3703,6 +4047,62 @@ function apply(rawContext) {
|
|
|
3703
4047
|
requireApiKey: keys.requireApiKey
|
|
3704
4048
|
});
|
|
3705
4049
|
});
|
|
4050
|
+
route(`${ROUTER_API_BASE}/ext`, async (req, res) => {
|
|
4051
|
+
if (req.method === "PATCH") {
|
|
4052
|
+
let body;
|
|
4053
|
+
try {
|
|
4054
|
+
body = JSON.parse(await readBody(req, 65536));
|
|
4055
|
+
} catch {
|
|
4056
|
+
writeJson(res, 400, {
|
|
4057
|
+
ok: false,
|
|
4058
|
+
error: "invalid JSON body"
|
|
4059
|
+
});
|
|
4060
|
+
return;
|
|
4061
|
+
}
|
|
4062
|
+
const ext = typeof body.id === "string" ? exts[body.id] : void 0;
|
|
4063
|
+
if (!ext) {
|
|
4064
|
+
writeJson(res, 404, {
|
|
4065
|
+
ok: false,
|
|
4066
|
+
error: "extension not found"
|
|
4067
|
+
});
|
|
4068
|
+
return;
|
|
4069
|
+
}
|
|
4070
|
+
if (typeof body.enabled !== "boolean") {
|
|
4071
|
+
writeJson(res, 400, {
|
|
4072
|
+
ok: false,
|
|
4073
|
+
error: "enabled must be a boolean"
|
|
4074
|
+
});
|
|
4075
|
+
return;
|
|
4076
|
+
}
|
|
4077
|
+
if (body.enabled && typeof ext.getState === "function") {
|
|
4078
|
+
const st = ext.getState();
|
|
4079
|
+
if (st?.ready !== true) {
|
|
4080
|
+
writeJson(res, 409, {
|
|
4081
|
+
ok: false,
|
|
4082
|
+
error: st?.detail ?? "extension not ready"
|
|
4083
|
+
});
|
|
4084
|
+
return;
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4087
|
+
extStore.setEnabled(ext.id, body.enabled);
|
|
4088
|
+
}
|
|
4089
|
+
writeJson(res, 200, {
|
|
4090
|
+
ok: true,
|
|
4091
|
+
enhancers: Object.values(exts).map((raw) => {
|
|
4092
|
+
const e = raw;
|
|
4093
|
+
if (!e || typeof e.id !== "string" || e.id === "") return void 0;
|
|
4094
|
+
const st = typeof e.getState === "function" ? e.getState() : { ready: false };
|
|
4095
|
+
return {
|
|
4096
|
+
id: e.id,
|
|
4097
|
+
name: e.name ?? e.id,
|
|
4098
|
+
...e.description !== void 0 ? { description: e.description } : {},
|
|
4099
|
+
enabled: extStore.isEnabled(e.id),
|
|
4100
|
+
ready: st?.ready === true,
|
|
4101
|
+
...st?.detail !== void 0 ? { detail: st.detail } : {}
|
|
4102
|
+
};
|
|
4103
|
+
}).filter((e) => e !== void 0)
|
|
4104
|
+
});
|
|
4105
|
+
});
|
|
3706
4106
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
3707
4107
|
const periodOf = (raw) => {
|
|
3708
4108
|
return raw === "24h" || raw === "7d" || raw === "30d" ? raw : "today";
|
|
@@ -3803,6 +4203,9 @@ function apply(rawContext) {
|
|
|
3803
4203
|
});
|
|
3804
4204
|
});
|
|
3805
4205
|
ctx.effect(() => () => {
|
|
4206
|
+
for (const raw of Object.values(exts)) try {
|
|
4207
|
+
raw?.dispose?.();
|
|
4208
|
+
} catch {}
|
|
3806
4209
|
for (const dispose of disposers.splice(0)) dispose();
|
|
3807
4210
|
router.dispose();
|
|
3808
4211
|
}, "dsh-router: teardown");
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展 (Ext) 通用契约 —— dsh-router 的「扩展插件」扩展点(不是供应商)。
|
|
3
|
+
*
|
|
4
|
+
* 分工(2026-09 重构:核心只做管理,不做拦截):
|
|
5
|
+
* - **dsh-router 核心 = 管理面**:持有 `router.ext` 注册表(发现)、渲染面板与
|
|
6
|
+
* `/router/api/ext`(展示)、代存开关与插件数据(`router.extStore` →
|
|
7
|
+
* `<dataDir>/ext.json`)。核心**不挂 `tools/execute`、不裁决、不改写**。
|
|
8
|
+
* - **扩展插件 = 执行面**:自己挂监听、自己问 `isEnabled`、自己按 ready 裁决、
|
|
9
|
+
* 自己短路执行。怎么改一条命令是插件的**私事**,不在本契约里。
|
|
10
|
+
*
|
|
11
|
+
* 为什么把拦截从核心挪走:
|
|
12
|
+
* `tools/execute` 是任何插件都能自己挂的 around-dispatch waterfall(插件 ctx 不在
|
|
13
|
+
* agent scope 下就能收到全部派发)。核心代挂只在一个消费者(rtk)时是纯亏损:为留
|
|
14
|
+
* 40 行委派逻辑,付了共享表 + inject 广播 + 契约两处同步的代价。核心归位成管理面
|
|
15
|
+
* 后,扩展插件与 dsh-router 只在**注册表 + 存储**两处耦合。
|
|
16
|
+
*
|
|
17
|
+
* ponytail: 天花板 —— 核心代挂原本顺带解决「多个扩展插件各自挂监听会互相踩 / 顺序
|
|
18
|
+
* 不可控」。这个保护现在没了;目前只有 rtk 一家消费,无所谓。**第二家 ext 出现时
|
|
19
|
+
* 要重新收敛顺序**(升级路径:核心暴露一个按顺序委派的共享工具方法,而不是收回拦截)。
|
|
20
|
+
*
|
|
21
|
+
* 契约要点:
|
|
22
|
+
* - 扩展器只有**声明**(id / name / description)+ `getState()`;核心拿这些渲染面板。
|
|
23
|
+
* - `getState()` 只报**运行时事实**(ready 是否可用、detail 说明),不报开关。
|
|
24
|
+
* - **开关与数据归核心持久化**(`router.extStore` → `<dataDir>/ext.json`),插件不自己
|
|
25
|
+
* file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
|
|
26
|
+
*/
|
|
27
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
28
|
+
/** 扩展器唯一 id(注册键)。 */
|
|
29
|
+
export type ExtId = string;
|
|
30
|
+
/**
|
|
31
|
+
* 扩展器当前状态(**运行时事实**,由插件现算,不持久化)。
|
|
32
|
+
*
|
|
33
|
+
* 注意:`enabled` 不在这里 —— 开关状态归核心持久化(`<dataDir>/ext.json`),
|
|
34
|
+
* 插件只负责做事 + 报自己是否可用。核心把两者合并后再给面板用。
|
|
35
|
+
*/
|
|
36
|
+
export interface ExtState {
|
|
37
|
+
/** 运行时是否就绪(如 RTK 二进制是否可用);false 时即使开启也不生效。 */
|
|
38
|
+
ready: boolean;
|
|
39
|
+
/** 不就绪时的一句话说明(如「本机未装 rtk」),面板红字显示。 */
|
|
40
|
+
detail?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 注册到 `router.ext` 表的扩展器实现。核心(面板/API)只认这几个成员,
|
|
44
|
+
* 插件可以携带更多内部状态。
|
|
45
|
+
*/
|
|
46
|
+
export interface RouterExt {
|
|
47
|
+
/** 扩展器 id,也是 `router.ext` 表里的注册键。 */
|
|
48
|
+
readonly id: ExtId;
|
|
49
|
+
/** 面板显示名(如「RTK」)。 */
|
|
50
|
+
readonly name: string;
|
|
51
|
+
/** 面板副标题说明。 */
|
|
52
|
+
readonly description?: string;
|
|
53
|
+
/** 当前运行时状态(ready + 不就绪时的说明)。 */
|
|
54
|
+
getState(): ExtState;
|
|
55
|
+
/** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
|
|
56
|
+
dispose?(): void;
|
|
57
|
+
}
|
|
58
|
+
/** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
|
|
59
|
+
export interface ExtInfo {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
/** 核心持久化的开关(默认关)。 */
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
/** 插件报的运行时就绪状态。 */
|
|
66
|
+
ready: boolean;
|
|
67
|
+
/** 不就绪时的说明(面板红字)。 */
|
|
68
|
+
detail?: string;
|
|
69
|
+
}
|
|
70
|
+
/** `router.ext` 共享聚合表:`{ [extId]: RouterExt }`(同一 live 对象可追加)。 */
|
|
71
|
+
export interface RouterExtService {
|
|
72
|
+
[extId: string]: RouterExt;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 扩展的存储面(核心 provide,插件 inject)。
|
|
76
|
+
*
|
|
77
|
+
* 插件**不自己 file IO**:落盘位置由核心用 `dataDirOf(ctx.baseUrl)` 锚定(不跟
|
|
78
|
+
* process.cwd() 跑),读写都经核心,同一文件、同一次原子写。
|
|
79
|
+
*
|
|
80
|
+
* 落盘形状:`<dataDir>/ext.json` = `{ "<id>": { "enabled": boolean, "data": unknown } }`
|
|
81
|
+
*/
|
|
82
|
+
export interface ExtStoreService {
|
|
83
|
+
/** 是否开启(未记录过 = 默认关)。 */
|
|
84
|
+
isEnabled(id: string): boolean;
|
|
85
|
+
/** 置开关(面板/API 调)。 */
|
|
86
|
+
setEnabled(id: string, enabled: boolean): void;
|
|
87
|
+
/** 读插件自己的数据块(没有 = undefined)。 */
|
|
88
|
+
readData<T = unknown>(id: string): T | undefined;
|
|
89
|
+
/** 写插件自己的数据块(覆盖;与 enabled 同文件原子落盘)。 */
|
|
90
|
+
writeData(id: string, value: unknown): void;
|
|
91
|
+
}
|
|
92
|
+
/** 读取当前 router.ext 表(同一 live 对象,可追加)——扩展插件用它注册自己。 */
|
|
93
|
+
export declare function currentExts(ctx: Context): RouterExtService | undefined;
|
|
94
|
+
/** 读取扩展存储服务——扩展插件用它读开关、读写自己存的数据。 */
|
|
95
|
+
export declare function currentExtStore(ctx: Context): ExtStoreService | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** 扩展开关 + 插件数据存储。 */
|
|
2
|
+
export declare class ExtStore {
|
|
3
|
+
private fp;
|
|
4
|
+
private byId;
|
|
5
|
+
constructor(stateFile: string);
|
|
6
|
+
private migrateLegacy;
|
|
7
|
+
private load;
|
|
8
|
+
private save;
|
|
9
|
+
/** 是否开启(未记录过 = 默认关)。 */
|
|
10
|
+
isEnabled(id: string): boolean;
|
|
11
|
+
setEnabled(id: string, enabled: boolean): void;
|
|
12
|
+
/** 读插件自己存的数据块(没存过 = undefined)。 */
|
|
13
|
+
readData<T = unknown>(id: string): T | undefined;
|
|
14
|
+
/** 写插件自己的数据块(覆盖;与 enabled 同一文件落盘)。 */
|
|
15
|
+
writeData(id: string, value: unknown): void;
|
|
16
|
+
}
|