dsh-router-core 0.3.12 → 0.3.14
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 +2 -2
- package/docs/pool-sticky-block.md +51 -0
- package/lib/client-registry.js +56 -1
- package/lib/client.js +56 -1
- package/lib/index.js +320 -59
- package/lib/types/llm/adapter.d.ts +37 -2
- package/lib/types/router/account-pool.d.ts +29 -19
- package/lib/types/router/prefix-affinity.d.ts +6 -0
- package/package.json +9 -5
- package/src/client/SupplierDetail.tsx +1 -1
- package/src/client/index.tsx +3 -0
- package/src/client/model-hint-copy.test.ts +31 -0
- package/src/client/model-hint-copy.ts +55 -0
- package/src/index.ts +61 -2
- package/src/llm/adapter.test.ts +82 -0
- package/src/llm/adapter.ts +145 -15
- package/src/router/account-pool.test.ts +126 -0
- package/src/router/account-pool.ts +103 -39
- package/src/router/index.ts +16 -1
- package/src/router/prefix-affinity.ts +80 -0
package/lib/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { basename, dirname, join, resolve } from "node:path";
|
|
2
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
2
3
|
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
5
|
import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, attributionHeaders } from "@deepseek-ai/dsh-llm";
|
|
4
|
-
import { randomBytes } from "node:crypto";
|
|
5
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
7
|
import { DatabaseSync } from "node:sqlite";
|
|
7
8
|
import { homedir } from "node:os";
|
|
@@ -9,6 +10,84 @@ import { homedir } from "node:os";
|
|
|
9
10
|
/** Same-origin API base the client panel fetches (host half serves it). */
|
|
10
11
|
const ROUTER_API_BASE = "/router/api";
|
|
11
12
|
//#endregion
|
|
13
|
+
//#region src/router/prefix-affinity.ts
|
|
14
|
+
/**
|
|
15
|
+
* 前缀指纹 —— 把一次请求的**消息前缀**压成一个短字符串,用于「同一个会话
|
|
16
|
+
* 粘在同一个账号上」。
|
|
17
|
+
*
|
|
18
|
+
* ## 为什么要这个
|
|
19
|
+
*
|
|
20
|
+
* 上游 prompt cache 是 **per-account × per-conversation** 的命名空间。块轮询
|
|
21
|
+
* 保证的是「单会话内连续驻留」,但多个会话被轮到同一个号时,各自十几万的大
|
|
22
|
+
* 前缀会在该号的缓存里**互相驱逐**(实测 3 会话并发 → 命中 3.8%,单会话独占
|
|
23
|
+
* → 82.5%,见 docs/pool-sticky-block.md §2.1)。
|
|
24
|
+
*
|
|
25
|
+
* 破法是让每个会话固定落到一个号:前缀指纹 → 选号。指纹相同的请求(= 同一
|
|
26
|
+
* 会话的连续请求,前缀逐轮增长但开头不变)落在同一个号,各自有独立的缓存
|
|
27
|
+
* 空间。
|
|
28
|
+
*
|
|
29
|
+
* ## 为什么按「前几条消息」而不是整个 body
|
|
30
|
+
*
|
|
31
|
+
* - 整个 body 每次都不一样(会话越跑越长)→ 指纹漂移 → 同一会话换号,正好
|
|
32
|
+
* 是要避免的事。
|
|
33
|
+
* - 请求体前 N 字节也不行:system prompt 动辄几 KB,同源会话(同一个 agent)
|
|
34
|
+
* 在这 N 字节里完全一样,多个会话会撞到同一个号。
|
|
35
|
+
* - 条数取 **2**(system + 首条用户消息)是刻意的:这两个槽位在会话整个
|
|
36
|
+
* 生命周期里**永不变化**(新消息只往后追加),所以从第 1 个请求起指纹
|
|
37
|
+
* 就稳定。取 3 条会在第一条 assistant 回复落进槽位 2 时变一次指纹 →
|
|
38
|
+
* 会话开局多付一次全量重算。而「system + 首条消息逐字相同」的会话,
|
|
39
|
+
* 缓存前缀本来就相同,共享一个号的缓存是收益不是冲突。
|
|
40
|
+
*
|
|
41
|
+
* 天花板:若某个客户端的前 3 条消息在所有会话里都逐字相同(无状态短请求),
|
|
42
|
+
* 指纹会退化成一个值 → 全部压到一个号。此时退化行为 = fallback,可接受;
|
|
43
|
+
* 真要区分得等 DSH 透传会话 id(不为此改契约)。
|
|
44
|
+
*/
|
|
45
|
+
/** 取前几条消息:system + 首条用户消息。会话里这两个槽位永不变化 → 指纹从第 1 个请求起稳定。 */
|
|
46
|
+
const FP_MSGS = 2;
|
|
47
|
+
/** 每条消息取前多少字符。缓存前缀的区分度集中在开头,全量哈希只增成本。 */
|
|
48
|
+
const FP_CHARS = 512;
|
|
49
|
+
/**
|
|
50
|
+
* 消息 content 可能是字符串,也可能是多模态分片数组
|
|
51
|
+
* (`[{ type: 'text', text: '...' }]`)。只取文本部分:图片是 base64,
|
|
52
|
+
* 既大又不参与前缀匹配的语义(同一张图每轮都是同一串)。
|
|
53
|
+
*/
|
|
54
|
+
function textOf(content) {
|
|
55
|
+
if (typeof content === "string") return content;
|
|
56
|
+
if (!Array.isArray(content)) return "";
|
|
57
|
+
let s = "";
|
|
58
|
+
for (const part of content) {
|
|
59
|
+
if (typeof part === "string") {
|
|
60
|
+
s += part;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (part !== null && typeof part === "object") {
|
|
64
|
+
const t = part.text;
|
|
65
|
+
if (typeof t === "string") s += t;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return s;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 算请求的前缀指纹。
|
|
72
|
+
* @param messages OpenAI 请求体的 `messages` 字段(未校验,来自外部输入)
|
|
73
|
+
* @returns 指纹字符串;**空串 = 算不出来(无亲和,回退到块轮询)**
|
|
74
|
+
*/
|
|
75
|
+
function prefixFingerprint(messages) {
|
|
76
|
+
if (!Array.isArray(messages) || messages.length === 0) return "";
|
|
77
|
+
let buf = "";
|
|
78
|
+
const n = Math.min(messages.length, FP_MSGS);
|
|
79
|
+
for (let i = 0; i < n; i++) {
|
|
80
|
+
const m = messages[i];
|
|
81
|
+
if (m === null || typeof m !== "object") continue;
|
|
82
|
+
const { role, content } = m;
|
|
83
|
+
const text = textOf(content);
|
|
84
|
+
if (typeof role !== "string" && text === "") continue;
|
|
85
|
+
buf += `${typeof role === "string" ? role : ""}\u0000${text.slice(0, FP_CHARS)}\u0001`;
|
|
86
|
+
}
|
|
87
|
+
if (buf === "") return "";
|
|
88
|
+
return createHash("sha1").update(buf).digest("base64url").slice(0, 12);
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
12
91
|
//#region src/router/account-pool.ts
|
|
13
92
|
const MINUTE = 6e4;
|
|
14
93
|
/**
|
|
@@ -85,6 +164,14 @@ const BLOCK_MIN = 16;
|
|
|
85
164
|
const BLOCK_MAX = 48;
|
|
86
165
|
/** 块轮询:判定「缓存无效」后的跳过时长。 */
|
|
87
166
|
const BLOCK_DEMOTE_MS = 10 * MINUTE;
|
|
167
|
+
/**
|
|
168
|
+
* 前缀亲和:指纹 → 号 的绑定记忆上限。
|
|
169
|
+
*
|
|
170
|
+
* 会话指纹是无限集合(每个新会话一个),不封顶就是内存泄漏。超过了就丢掉
|
|
171
|
+
* **最久没用**的那条:活跃会话的指纹每请求都在刷新,不会被误删。
|
|
172
|
+
* 单号池(<= 几十个会话)绰绰有余。
|
|
173
|
+
*/
|
|
174
|
+
const AFFINITY_MAX = 512;
|
|
88
175
|
/** 指数退避:等级 1 = BACKOFF_BASE_MS,每级翻倍,封顶 BACKOFF_MAX_MS。 */
|
|
89
176
|
function backoffMs(level) {
|
|
90
177
|
return Math.min(BACKOFF_BASE_MS * 2 ** Math.max(0, level - 1), BACKOFF_MAX_MS);
|
|
@@ -99,10 +186,14 @@ var AccountPool = class {
|
|
|
99
186
|
/** uid → 连接级禁用 / 手动暂停。 */
|
|
100
187
|
byUid = /* @__PURE__ */ new Map();
|
|
101
188
|
rrCursor = 0;
|
|
102
|
-
/** (supplier, model) →
|
|
189
|
+
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
103
190
|
blocks = /* @__PURE__ */ new Map();
|
|
191
|
+
/** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
|
|
192
|
+
samples = /* @__PURE__ */ new Map();
|
|
104
193
|
/** (supplier, model, uid) → 缓存无效降权。 */
|
|
105
194
|
demoted = /* @__PURE__ */ new Map();
|
|
195
|
+
/** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
|
|
196
|
+
affinity = /* @__PURE__ */ new Map();
|
|
106
197
|
constructor(supplierId = "") {
|
|
107
198
|
this.supplierId = supplierId;
|
|
108
199
|
}
|
|
@@ -169,22 +260,28 @@ var AccountPool = class {
|
|
|
169
260
|
/**
|
|
170
261
|
* 按策略为「某个模型」选一个健康账号。
|
|
171
262
|
*
|
|
172
|
-
* round-robin
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
263
|
+
* round-robin = **前缀亲和定号 + 块统计只用来判定缓存无效**:
|
|
264
|
+
*
|
|
265
|
+
* 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
|
|
266
|
+
* 这是第一优先:同一个会话的连续请求**永远落回同一个号**,前缀在该号的
|
|
267
|
+
* 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
|
|
268
|
+
* 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
|
|
269
|
+
* 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
|
|
270
|
+
* 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
|
|
271
|
+
* 3. 块统计不再驱动轮转(亲和已经保证驻留,再轮转只会把会话踢出已热前缀),
|
|
272
|
+
* 只保留 `BLOCK_MAX` 次不达标 → 降权该号(缓存无效,可恢复)。
|
|
273
|
+
*
|
|
274
|
+
* 亲和失效(拿不到 messages / 指纹算不出)时**回退到块轮询**,行为与之前一致。
|
|
275
|
+
* 设计见 docs/pool-sticky-block.md §8。
|
|
180
276
|
*
|
|
181
277
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
182
278
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
183
|
-
* @param strategy fallback 取第一个健康 / round-robin
|
|
279
|
+
* @param strategy fallback 取第一个健康 / round-robin 前缀亲和
|
|
184
280
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
281
|
+
* @param messages 请求体的 messages(可选;给了才能算前缀指纹)
|
|
185
282
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
186
283
|
*/
|
|
187
|
-
pick(accounts, poolOrder, strategy, modelId) {
|
|
284
|
+
pick(accounts, poolOrder, strategy, modelId, messages) {
|
|
188
285
|
const now = Date.now();
|
|
189
286
|
const byUid = new Map(accounts.map((a) => [a.uid, a]));
|
|
190
287
|
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));
|
|
@@ -192,24 +289,39 @@ var AccountPool = class {
|
|
|
192
289
|
if (strategy !== "round-robin") return healthy[0];
|
|
193
290
|
const usable = healthy.filter((uid) => !this.isDemoted(uid, modelId, now));
|
|
194
291
|
const pool = usable.length > 0 ? usable : healthy;
|
|
292
|
+
const fp = messages === void 0 ? "" : prefixFingerprint(messages);
|
|
293
|
+
if (fp !== "" && pool.includes(this.affinity.get(this.blockKey(modelId))?.get(fp) ?? "")) return this.affinity.get(this.blockKey(modelId)).get(fp);
|
|
195
294
|
const block = this.blocks.get(this.blockKey(modelId));
|
|
196
|
-
if (block !== void 0 && pool.includes(block
|
|
197
|
-
const uid = pool[(block === void 0 ? this.rrCursor : Math.max(0, pool.indexOf(block
|
|
295
|
+
if (fp === "" && block !== void 0 && pool.includes(block) && this.blockKeeps(modelId, block)) return block;
|
|
296
|
+
const uid = pool[(block === void 0 ? this.rrCursor : Math.max(0, pool.indexOf(block) + 1)) % pool.length];
|
|
198
297
|
this.rrCursor = (pool.indexOf(uid) + 1) % pool.length;
|
|
199
|
-
this.blocks.set(this.blockKey(modelId),
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
hits: 0
|
|
203
|
-
});
|
|
298
|
+
this.blocks.set(this.blockKey(modelId), uid);
|
|
299
|
+
if (fp !== "") this.bind(modelId, fp, uid);
|
|
300
|
+
else this.samples.delete(this.key(modelId, uid));
|
|
204
301
|
return uid;
|
|
205
302
|
}
|
|
303
|
+
/** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
|
|
304
|
+
bind(modelId, fp, uid) {
|
|
305
|
+
const k = this.blockKey(modelId);
|
|
306
|
+
let m = this.affinity.get(k);
|
|
307
|
+
if (m === void 0) {
|
|
308
|
+
m = /* @__PURE__ */ new Map();
|
|
309
|
+
this.affinity.set(k, m);
|
|
310
|
+
}
|
|
311
|
+
m.delete(fp);
|
|
312
|
+
m.set(fp, uid);
|
|
313
|
+
if (m.size > AFFINITY_MAX) {
|
|
314
|
+
const oldest = m.keys().next().value;
|
|
315
|
+
if (oldest !== void 0) m.delete(oldest);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
206
318
|
/** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
|
|
207
319
|
isDemoted(uid, modelId, now) {
|
|
208
320
|
const e = this.demoted.get(this.key(modelId, uid));
|
|
209
321
|
return e !== void 0 && e.until > now;
|
|
210
322
|
}
|
|
211
323
|
/**
|
|
212
|
-
*
|
|
324
|
+
* 驻留是否还应留在当前号上(**只用于无亲和的回退路径**)。
|
|
213
325
|
*
|
|
214
326
|
* OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
|
|
215
327
|
* 单一账号内部:一个号先被轮到(冷启动/热),然后
|
|
@@ -219,33 +331,39 @@ var AccountPool = class {
|
|
|
219
331
|
*
|
|
220
332
|
* 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
|
|
221
333
|
* 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
|
|
222
|
-
*
|
|
223
|
-
* 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
|
|
224
|
-
* 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
|
|
225
|
-
* 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
|
|
226
334
|
*/
|
|
227
|
-
blockKeeps(
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
335
|
+
blockKeeps(modelId, uid) {
|
|
336
|
+
const s = this.samples.get(this.key(modelId, uid));
|
|
337
|
+
const served = s?.served ?? 0;
|
|
338
|
+
if (served >= BLOCK_MAX) return false;
|
|
339
|
+
const ratio = served === 0 ? 0 : (s?.hits ?? 0) / served;
|
|
340
|
+
return served < BLOCK_MIN || ratio < BLOCK_HIT_RATIO;
|
|
231
341
|
}
|
|
232
342
|
/**
|
|
233
|
-
*
|
|
343
|
+
* 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
|
|
234
344
|
*
|
|
235
|
-
*
|
|
345
|
+
* 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
|
|
346
|
+
* 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
|
|
347
|
+
* 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
|
|
236
348
|
* @param uid 实际服务的号
|
|
237
349
|
* @param modelId 模型(块粒度)
|
|
238
350
|
* @param cachedTokens 上游报的命中 token 数;0 = 全量重算
|
|
239
351
|
*/
|
|
240
352
|
noteCache(uid, modelId, cachedTokens) {
|
|
241
|
-
const k = this.
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
353
|
+
const k = this.key(modelId, uid);
|
|
354
|
+
const s = this.samples.get(k) ?? {
|
|
355
|
+
served: 0,
|
|
356
|
+
hits: 0
|
|
357
|
+
};
|
|
358
|
+
s.served += 1;
|
|
359
|
+
if (cachedTokens > 0) s.hits += 1;
|
|
360
|
+
this.samples.set(k, s);
|
|
361
|
+
if (s.served >= BLOCK_MAX && s.hits / s.served < BLOCK_HIT_RATIO) {
|
|
362
|
+
this.demoted.set(k, { until: Date.now() + BLOCK_DEMOTE_MS });
|
|
363
|
+
this.samples.delete(k);
|
|
364
|
+
}
|
|
247
365
|
}
|
|
248
|
-
/**
|
|
366
|
+
/** 故障切走时作废当前驻留:换号后前缀归属变了,驻留计数不能沿用。 */
|
|
249
367
|
dropBlock(modelId) {
|
|
250
368
|
this.blocks.delete(this.blockKey(modelId));
|
|
251
369
|
}
|
|
@@ -1237,6 +1355,17 @@ const TRANSIENT_SETTLE_MS = 2e3;
|
|
|
1237
1355
|
function isTransient(state) {
|
|
1238
1356
|
return state === "transport" || state === "unavailable";
|
|
1239
1357
|
}
|
|
1358
|
+
/**
|
|
1359
|
+
* 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
|
|
1360
|
+
* undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
|
|
1361
|
+
*/
|
|
1362
|
+
function reqMessages(rawBody) {
|
|
1363
|
+
try {
|
|
1364
|
+
return JSON.parse(rawBody).messages;
|
|
1365
|
+
} catch {
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1240
1369
|
/** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
|
|
1241
1370
|
function sinkRes() {
|
|
1242
1371
|
let status = 200;
|
|
@@ -2075,9 +2204,10 @@ var Router = class {
|
|
|
2075
2204
|
}
|
|
2076
2205
|
return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0);
|
|
2077
2206
|
}
|
|
2207
|
+
const messages = reqMessages(req.rawBody);
|
|
2078
2208
|
const tried = /* @__PURE__ */ new Set();
|
|
2079
2209
|
for (;;) {
|
|
2080
|
-
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model);
|
|
2210
|
+
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model, messages);
|
|
2081
2211
|
if (uid === void 0) return false;
|
|
2082
2212
|
tried.add(uid);
|
|
2083
2213
|
const r = await s.chatOnce(uid, req.lv ?? "auto", req);
|
|
@@ -2169,18 +2299,31 @@ var Router = class {
|
|
|
2169
2299
|
* 模型目录 = 组合(自动带出,不可改)。对话转发到本插件 /v1/chat/completions,
|
|
2170
2300
|
* 由现有路由按组合策略命中供应商模型。
|
|
2171
2301
|
*
|
|
2172
|
-
*
|
|
2302
|
+
* 图文流:adapter 声明图片 modal(`inputModalities` 含 `image`),把 user 消息里的
|
|
2303
|
+
* `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
|
|
2304
|
+
* 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
|
|
2305
|
+
* 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
|
|
2306
|
+
* 图片字节从 `ctx.attachments` 读取(读不到的按稳定占位文本降级,绝不静默丢图)。
|
|
2173
2307
|
*/
|
|
2174
2308
|
/**
|
|
2175
|
-
* 把 DSH 消息序列化成 openai 兼容 wire
|
|
2309
|
+
* 把 DSH 消息序列化成 openai 兼容 wire 消息(文本 + 图片 + tool)。
|
|
2176
2310
|
*
|
|
2177
|
-
* `system` 必须由调用方从 `options.system` 传进来:
|
|
2178
|
-
* 放在 **options.system 这个独立槽位**,不放进 messages
|
|
2179
|
-
*
|
|
2180
|
-
*
|
|
2181
|
-
*
|
|
2311
|
+
* `system` 必须由调用方从 `options.system` 传进来:one-shot 调用方把系统提示词
|
|
2312
|
+
* 放在 **options.system 这个独立槽位**,不放进 messages。不读这个槽位,
|
|
2313
|
+
* one-shot 调用方(如 compaction / session-title)的模型每一轮都拿不到身份/
|
|
2314
|
+
* 规则/工具用法约束,且**不会报错**,只是行为悄悄降级。
|
|
2315
|
+
*
|
|
2316
|
+
* 0.1.5 起 loop-built 请求反过来:`options.system` 为 undefined,系统提示词作为
|
|
2317
|
+
* **messages 里的 system-role 消息**下发(dsh-agent-loop 的 SystemProjection,
|
|
2318
|
+
* 可出现在任意位置以支持提示词热更新)。两条路径都必须支持 —— 与官方
|
|
2319
|
+
* dsh-llm-deepseek 一致(serializeRequest 前置 options.system,再按位置序列化
|
|
2320
|
+
* messages 里的 system 消息)。
|
|
2321
|
+
*
|
|
2322
|
+
* 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
|
|
2323
|
+
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
2324
|
+
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
2182
2325
|
*/
|
|
2183
|
-
function wireMessages(options, system) {
|
|
2326
|
+
async function wireMessages(options, system, attachments) {
|
|
2184
2327
|
const out = [];
|
|
2185
2328
|
if (typeof system === "string" && system !== "") out.push({
|
|
2186
2329
|
role: "system",
|
|
@@ -2213,12 +2356,24 @@ function wireMessages(options, system) {
|
|
|
2213
2356
|
});
|
|
2214
2357
|
continue;
|
|
2215
2358
|
}
|
|
2216
|
-
const text = flattenText(message.content);
|
|
2217
2359
|
const toolResults = message.content.filter((b) => b.type === "tool-result");
|
|
2218
|
-
if (
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2360
|
+
if (hasImageBlock(message.content)) {
|
|
2361
|
+
const parts = await contentParts(message.content, attachments, options.signal);
|
|
2362
|
+
if (parts.length > 0) out.push({
|
|
2363
|
+
role: "user",
|
|
2364
|
+
content: parts
|
|
2365
|
+
});
|
|
2366
|
+
else if (toolResults.length === 0) out.push({
|
|
2367
|
+
role: "user",
|
|
2368
|
+
content: ""
|
|
2369
|
+
});
|
|
2370
|
+
} else {
|
|
2371
|
+
const text = flattenText(message.content);
|
|
2372
|
+
if (text.length > 0 || toolResults.length === 0) out.push({
|
|
2373
|
+
role: "user",
|
|
2374
|
+
content: text
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2222
2377
|
for (const result of toolResults) out.push({
|
|
2223
2378
|
role: "tool",
|
|
2224
2379
|
tool_call_id: result.toolCallId,
|
|
@@ -2227,9 +2382,73 @@ function wireMessages(options, system) {
|
|
|
2227
2382
|
}
|
|
2228
2383
|
return out;
|
|
2229
2384
|
}
|
|
2230
|
-
/**
|
|
2231
|
-
|
|
2232
|
-
|
|
2385
|
+
/**
|
|
2386
|
+
* 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
|
|
2387
|
+
* 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
|
|
2388
|
+
* attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
|
|
2389
|
+
*/
|
|
2390
|
+
async function contentParts(blocks, attachments, signal) {
|
|
2391
|
+
const parts = [];
|
|
2392
|
+
for (const block of blocks) {
|
|
2393
|
+
if (block.type === "text") {
|
|
2394
|
+
const text = block.text ?? "";
|
|
2395
|
+
if (text.length > 0) parts.push({
|
|
2396
|
+
type: "text",
|
|
2397
|
+
text
|
|
2398
|
+
});
|
|
2399
|
+
continue;
|
|
2400
|
+
}
|
|
2401
|
+
if (block.type === "image") {
|
|
2402
|
+
const ref = block.attachment;
|
|
2403
|
+
parts.push(...await imageParts(ref, attachments, signal));
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
if (block.type === "tool-result") parts.push(...await contentParts(block.content, attachments, signal));
|
|
2407
|
+
}
|
|
2408
|
+
return parts;
|
|
2409
|
+
}
|
|
2410
|
+
/** 一张图片 → 一个 OpenAI image_url part(或读不到附件时的占位文本)。 */
|
|
2411
|
+
async function imageParts(ref, attachments, signal) {
|
|
2412
|
+
if (attachments === void 0) return [{
|
|
2413
|
+
type: "text",
|
|
2414
|
+
text: imagePlaceholder(ref.attachmentId)
|
|
2415
|
+
}];
|
|
2416
|
+
try {
|
|
2417
|
+
const img = await attachments.readImageRequest({
|
|
2418
|
+
attachmentId: ref.attachmentId,
|
|
2419
|
+
mediaType: ref.mediaType,
|
|
2420
|
+
bytes: ref.bytes,
|
|
2421
|
+
width: ref.width,
|
|
2422
|
+
height: ref.height
|
|
2423
|
+
}, {
|
|
2424
|
+
maxPixels: 64e4,
|
|
2425
|
+
maxBytes: 1048576
|
|
2426
|
+
}, signal);
|
|
2427
|
+
return [{
|
|
2428
|
+
type: "image_url",
|
|
2429
|
+
image_url: { url: `data:${img.mediaType};base64,${bytesToBase64(img.data)}` }
|
|
2430
|
+
}];
|
|
2431
|
+
} catch {
|
|
2432
|
+
return [{
|
|
2433
|
+
type: "text",
|
|
2434
|
+
text: imagePlaceholder(ref.attachmentId)
|
|
2435
|
+
}];
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
/** Uint8Array → base64(browser + node 双环境安全,不依赖 Buffer 全局)。 */
|
|
2439
|
+
function bytesToBase64(bytes) {
|
|
2440
|
+
let bin = "";
|
|
2441
|
+
const chunk = 32768;
|
|
2442
|
+
for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
2443
|
+
return btoa(bin);
|
|
2444
|
+
}
|
|
2445
|
+
/** 读不到图片字节时的稳定占位文本。 */
|
|
2446
|
+
function imagePlaceholder(attachmentId) {
|
|
2447
|
+
return `[image omitted because the attached bytes could not be read; attachment ${attachmentId}]`;
|
|
2448
|
+
}
|
|
2449
|
+
/** 组装 wire 请求体。图片序列化需要读 attachments,故为异步。 */
|
|
2450
|
+
async function wireRequest(options, attachments) {
|
|
2451
|
+
const messages = await wireMessages(options, options.system, attachments);
|
|
2233
2452
|
const body = {
|
|
2234
2453
|
model: options.model,
|
|
2235
2454
|
messages,
|
|
@@ -2276,6 +2495,14 @@ function applyReasoning(body, effort) {
|
|
|
2276
2495
|
function flattenText(blocks) {
|
|
2277
2496
|
return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
2278
2497
|
}
|
|
2498
|
+
/** 内容块里(含递归进 tool-result)是否存在 image 块。决定走数组 content 还是字符串。 */
|
|
2499
|
+
function hasImageBlock(blocks) {
|
|
2500
|
+
for (const b of blocks) {
|
|
2501
|
+
if (b.type === "image") return true;
|
|
2502
|
+
if (b.type === "tool-result" && hasImageBlock(b.content)) return true;
|
|
2503
|
+
}
|
|
2504
|
+
return false;
|
|
2505
|
+
}
|
|
2279
2506
|
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
2280
2507
|
/**
|
|
2281
2508
|
* 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
|
|
@@ -2506,13 +2733,15 @@ const ROUTER_REASONING_EFFORTS = [
|
|
|
2506
2733
|
name: "Max"
|
|
2507
2734
|
}
|
|
2508
2735
|
];
|
|
2509
|
-
var RouterAdapter = class extends LlmAdapter {
|
|
2736
|
+
var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
2510
2737
|
baseURL;
|
|
2511
2738
|
source;
|
|
2512
|
-
|
|
2739
|
+
resolveAttachments;
|
|
2740
|
+
constructor(baseURL, source, resolveAttachments) {
|
|
2513
2741
|
super();
|
|
2514
2742
|
this.baseURL = baseURL;
|
|
2515
2743
|
this.source = source;
|
|
2744
|
+
this.resolveAttachments = resolveAttachments ?? (() => void 0);
|
|
2516
2745
|
}
|
|
2517
2746
|
providerInfo(provider) {
|
|
2518
2747
|
return {
|
|
@@ -2535,12 +2764,22 @@ var RouterAdapter = class extends LlmAdapter {
|
|
|
2535
2764
|
}
|
|
2536
2765
|
return out;
|
|
2537
2766
|
}
|
|
2767
|
+
/**
|
|
2768
|
+
* 声明图文:`inputModalities` 必须同时含 `text` 与 `image`。
|
|
2769
|
+
* - 含 `image`:runtime/subagent 的模态门禁放行(不再抛
|
|
2770
|
+
* `MODEL_DOES_NOT_SUPPORT_IMAGES` = 界面「当前模型不支持图片」),
|
|
2771
|
+
* `projectImagesForTextModel` 也不会把图片块投影成占位文本。
|
|
2772
|
+
* - 组合背后是异构供应商,**统一声明图片能力**;真正能否看图取决于命中
|
|
2773
|
+
* 的某个上游模型(供应商各模型 capability 不同,网关端不能一刀切拦截)。
|
|
2774
|
+
*/
|
|
2775
|
+
static INPUT_MODALITIES = ["text", "image"];
|
|
2538
2776
|
async resolveModel(_provider, model) {
|
|
2539
2777
|
const found = (await this.source.comboModels()).find((m) => m.id === model || m.name === model);
|
|
2540
2778
|
return {
|
|
2541
2779
|
provider: "router",
|
|
2542
2780
|
id: model,
|
|
2543
2781
|
name: found?.name ?? found?.id ?? model,
|
|
2782
|
+
inputModalities: RouterAdapter.INPUT_MODALITIES,
|
|
2544
2783
|
...found?.contextWindow !== void 0 ? { context: { contextWindow: found.contextWindow } } : {},
|
|
2545
2784
|
reasoning: {
|
|
2546
2785
|
efforts: ROUTER_REASONING_EFFORTS,
|
|
@@ -2549,7 +2788,7 @@ var RouterAdapter = class extends LlmAdapter {
|
|
|
2549
2788
|
};
|
|
2550
2789
|
}
|
|
2551
2790
|
async *stream(options) {
|
|
2552
|
-
const body = wireRequest(options);
|
|
2791
|
+
const body = await wireRequest(options, this.resolveAttachments());
|
|
2553
2792
|
const controller = new AbortController();
|
|
2554
2793
|
const onAbort = () => controller.abort();
|
|
2555
2794
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -4017,13 +4256,26 @@ function apply(rawContext) {
|
|
|
4017
4256
|
disposers.push(ctx.llm.registerModelDiscovery(LLM_NS, async () => {
|
|
4018
4257
|
return (await router.combos()).map((c) => ({ id: c.name }));
|
|
4019
4258
|
}));
|
|
4259
|
+
const routerSettingsSchema = Schema.object({});
|
|
4260
|
+
ctx.inject(["settings"], (sctx) => {
|
|
4261
|
+
const settings = sctx.settings;
|
|
4262
|
+
if (settings === void 0) {
|
|
4263
|
+
log("settings service absent — skip Router settings-section registration");
|
|
4264
|
+
return;
|
|
4265
|
+
}
|
|
4266
|
+
settings.installSection(rawContext, LLM_NS, routerSettingsSchema, {}, {
|
|
4267
|
+
setSource: () => {},
|
|
4268
|
+
onChange: () => {}
|
|
4269
|
+
});
|
|
4270
|
+
log(`Router settings section registered (${LLM_NS})`);
|
|
4271
|
+
});
|
|
4020
4272
|
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
|
|
4021
4273
|
const w = router.comboContextWindow(c);
|
|
4022
4274
|
return {
|
|
4023
4275
|
id: c.name,
|
|
4024
4276
|
...w !== void 0 ? { contextWindow: w } : {}
|
|
4025
4277
|
};
|
|
4026
|
-
}) })));
|
|
4278
|
+
}) }, () => ctx.get("attachments"))));
|
|
4027
4279
|
log("llm provider (Router) + discovery + adapter registered ok");
|
|
4028
4280
|
(async () => {
|
|
4029
4281
|
for (const c of await router.combos()) for (const ref of c.models) {
|
|
@@ -4051,11 +4303,20 @@ function apply(rawContext) {
|
|
|
4051
4303
|
} catch (err) {
|
|
4052
4304
|
registeredProviders = `error: ${err.message}`;
|
|
4053
4305
|
}
|
|
4306
|
+
let settingsNamespaces = "n/a";
|
|
4307
|
+
try {
|
|
4308
|
+
const settings = ctx.get("settings");
|
|
4309
|
+
settingsNamespaces = settings?.describe === void 0 ? "no settings service" : settings.describe({ redactSecrets: true }).map((d) => d.ns);
|
|
4310
|
+
} catch (err) {
|
|
4311
|
+
settingsNamespaces = `error: ${err.message}`;
|
|
4312
|
+
}
|
|
4054
4313
|
writeJson(res, 200, {
|
|
4055
4314
|
ok: true,
|
|
4056
4315
|
llmAvailable: ctx.llm !== void 0,
|
|
4057
4316
|
directory,
|
|
4058
|
-
registeredProviders
|
|
4317
|
+
registeredProviders,
|
|
4318
|
+
settingsNamespaces,
|
|
4319
|
+
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes("llm-dsh-router")
|
|
4059
4320
|
});
|
|
4060
4321
|
});
|
|
4061
4322
|
ctx.effect(() => () => {
|
|
@@ -4,9 +4,34 @@
|
|
|
4
4
|
* 模型目录 = 组合(自动带出,不可改)。对话转发到本插件 /v1/chat/completions,
|
|
5
5
|
* 由现有路由按组合策略命中供应商模型。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* 图文流:adapter 声明图片 modal(`inputModalities` 含 `image`),把 user 消息里的
|
|
8
|
+
* `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
|
|
9
|
+
* 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
|
|
10
|
+
* 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
|
|
11
|
+
* 图片字节从 `ctx.attachments` 读取(读不到的按稳定占位文本降级,绝不静默丢图)。
|
|
8
12
|
*/
|
|
9
13
|
import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
14
|
+
/**
|
|
15
|
+
* dsh-attachment 的最小本地切面:adapter 只依赖「读一张图片的请求版本」
|
|
16
|
+
* 这一行为,不引入对 dsh-attachment 包编译期/运行期的依赖(它是宿主注入的
|
|
17
|
+
* devDependency)。本地声明与原包 `AttachmentStore.readImageRequest` 形状一致,
|
|
18
|
+
* 宿主注入真实 store 时天然满足。
|
|
19
|
+
*/
|
|
20
|
+
export interface RouterAttachmentStore {
|
|
21
|
+
readImageRequest(ref: {
|
|
22
|
+
attachmentId: string;
|
|
23
|
+
mediaType: string;
|
|
24
|
+
bytes: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
}, policy: {
|
|
28
|
+
maxPixels: number;
|
|
29
|
+
maxBytes: number;
|
|
30
|
+
}, signal?: AbortSignal): Promise<{
|
|
31
|
+
mediaType: string;
|
|
32
|
+
data: Uint8Array;
|
|
33
|
+
}>;
|
|
34
|
+
}
|
|
10
35
|
/** 模型目录来源:组合。 */
|
|
11
36
|
export interface RouterAdapterSource {
|
|
12
37
|
/**
|
|
@@ -32,12 +57,22 @@ export declare function translateSse(payloads: AsyncIterable<string> | Iterable<
|
|
|
32
57
|
export declare class RouterAdapter extends LlmAdapter {
|
|
33
58
|
private readonly baseURL;
|
|
34
59
|
private readonly source;
|
|
35
|
-
|
|
60
|
+
private readonly resolveAttachments;
|
|
61
|
+
constructor(baseURL: string, source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined);
|
|
36
62
|
providerInfo(provider: string): {
|
|
37
63
|
id: string;
|
|
38
64
|
name: string;
|
|
39
65
|
};
|
|
40
66
|
listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
|
|
67
|
+
/**
|
|
68
|
+
* 声明图文:`inputModalities` 必须同时含 `text` 与 `image`。
|
|
69
|
+
* - 含 `image`:runtime/subagent 的模态门禁放行(不再抛
|
|
70
|
+
* `MODEL_DOES_NOT_SUPPORT_IMAGES` = 界面「当前模型不支持图片」),
|
|
71
|
+
* `projectImagesForTextModel` 也不会把图片块投影成占位文本。
|
|
72
|
+
* - 组合背后是异构供应商,**统一声明图片能力**;真正能否看图取决于命中
|
|
73
|
+
* 的某个上游模型(供应商各模型 capability 不同,网关端不能一刀切拦截)。
|
|
74
|
+
*/
|
|
75
|
+
private static readonly INPUT_MODALITIES;
|
|
41
76
|
resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
42
77
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
43
78
|
}
|