dsh-router-core 0.3.13 → 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/lib/client-registry.js +55 -0
- package/lib/client.js +55 -0
- package/lib/index.js +139 -22
- package/lib/types/llm/adapter.d.ts +34 -8
- package/package.json +5 -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 +130 -17
package/lib/client-registry.js
CHANGED
|
@@ -4115,6 +4115,60 @@ window.__ModuleLoader__.load({
|
|
|
4115
4115
|
};
|
|
4116
4116
|
}
|
|
4117
4117
|
//#endregion
|
|
4118
|
+
//#region src/client/model-hint-copy.ts
|
|
4119
|
+
/**
|
|
4120
|
+
* 设置-模型 → Router 卡片:把宿主那句走不通的提示换成正确说法。
|
|
4121
|
+
*
|
|
4122
|
+
* 为什么不能直接改源头:Router 的卡片走宿主的 "unknown" 布局(宿主只认得
|
|
4123
|
+
* llm-deepseek / llm-pi-ai 两套表单),那句
|
|
4124
|
+
* 「其余字段在 settings.yaml 中,请直接编辑对应段。 (llm-dsh-router)」
|
|
4125
|
+
* 属于宿主 `settings.models` 的 locale 字典,而字典注册对同一个 (ns, locale)
|
|
4126
|
+
* 只允许一个占位者(重复注册直接抛),插件覆写不了,契约也没给可覆写字段。
|
|
4127
|
+
* 所以沿用本插件既有的认领手法(同 settings-nav-icon.ts):按文案指纹认出宿主
|
|
4128
|
+
* 渲染的那一段,就地改写。
|
|
4129
|
+
*
|
|
4130
|
+
* 只写 `nodeValue`、不增删节点(节点归 React,摘掉会让它的引用落空)。React 重渲染
|
|
4131
|
+
* 覆盖回来时观察器会再改一次;已是目标文案就早退,不会自激。
|
|
4132
|
+
*
|
|
4133
|
+
* 升级路径:宿主若给该提示长出可覆写字段(或 Router 有了专属布局),删掉本文件。
|
|
4134
|
+
*/
|
|
4135
|
+
/** 指纹:语言无关(不受中英文切换影响),且只出现在 Router 卡片上。 */
|
|
4136
|
+
var FINGERPRINT = "(llm-dsh-router)";
|
|
4137
|
+
/** 正确说法:对齐 README「组合即模型」。 */
|
|
4138
|
+
var ROUTER_MODEL_HINT_COPY = "组合即模型 · 建好的组合会自动出现在 DSH 模型目录中,选中组合名即可直接使用。";
|
|
4139
|
+
/**
|
|
4140
|
+
* 这段文本要不要改写。
|
|
4141
|
+
* @param text 段落当前文本
|
|
4142
|
+
* @returns 需要改写时给出目标文案,否则 undefined
|
|
4143
|
+
*/
|
|
4144
|
+
function routerHintRewrite(text) {
|
|
4145
|
+
if (!text.includes(FINGERPRINT)) return void 0;
|
|
4146
|
+
if (text === "组合即模型 · 建好的组合会自动出现在 DSH 模型目录中,选中组合名即可直接使用。") return void 0;
|
|
4147
|
+
return ROUTER_MODEL_HINT_COPY;
|
|
4148
|
+
}
|
|
4149
|
+
/**
|
|
4150
|
+
* 持续把 Router 卡片里的宿主提示改写成 {@link ROUTER_MODEL_HINT_COPY}。
|
|
4151
|
+
* @returns 清理函数:断开观察
|
|
4152
|
+
*/
|
|
4153
|
+
function rewriteRouterModelHint() {
|
|
4154
|
+
const sync = () => {
|
|
4155
|
+
for (const el of document.querySelectorAll("p[class*=\"advancedHint\"]")) {
|
|
4156
|
+
const next = routerHintRewrite(el.textContent ?? "");
|
|
4157
|
+
const node = el.firstChild;
|
|
4158
|
+
if (next === void 0 || node?.nodeType !== Node.TEXT_NODE) continue;
|
|
4159
|
+
node.nodeValue = next;
|
|
4160
|
+
}
|
|
4161
|
+
};
|
|
4162
|
+
sync();
|
|
4163
|
+
const observer = new MutationObserver(sync);
|
|
4164
|
+
observer.observe(document.body, {
|
|
4165
|
+
childList: true,
|
|
4166
|
+
subtree: true,
|
|
4167
|
+
characterData: true
|
|
4168
|
+
});
|
|
4169
|
+
return () => observer.disconnect();
|
|
4170
|
+
}
|
|
4171
|
+
//#endregion
|
|
4118
4172
|
//#region src/client/index.tsx
|
|
4119
4173
|
/**
|
|
4120
4174
|
* Client half of dsh-router.
|
|
@@ -4157,6 +4211,7 @@ window.__ModuleLoader__.load({
|
|
|
4157
4211
|
}
|
|
4158
4212
|
ctx.effect(() => disposeSettings, "dsh-router: settings section");
|
|
4159
4213
|
ctx.effect(() => registerSettingsNavIcon(SECTION_LABEL), "dsh-router: settings nav icon");
|
|
4214
|
+
ctx.effect(() => rewriteRouterModelHint(), "dsh-router: models provider hint copy");
|
|
4160
4215
|
}
|
|
4161
4216
|
//#endregion
|
|
4162
4217
|
exports.apply = apply;
|
package/lib/client.js
CHANGED
|
@@ -4115,6 +4115,60 @@ window.__ModuleLoader__.load({
|
|
|
4115
4115
|
};
|
|
4116
4116
|
}
|
|
4117
4117
|
//#endregion
|
|
4118
|
+
//#region src/client/model-hint-copy.ts
|
|
4119
|
+
/**
|
|
4120
|
+
* 设置-模型 → Router 卡片:把宿主那句走不通的提示换成正确说法。
|
|
4121
|
+
*
|
|
4122
|
+
* 为什么不能直接改源头:Router 的卡片走宿主的 "unknown" 布局(宿主只认得
|
|
4123
|
+
* llm-deepseek / llm-pi-ai 两套表单),那句
|
|
4124
|
+
* 「其余字段在 settings.yaml 中,请直接编辑对应段。 (llm-dsh-router)」
|
|
4125
|
+
* 属于宿主 `settings.models` 的 locale 字典,而字典注册对同一个 (ns, locale)
|
|
4126
|
+
* 只允许一个占位者(重复注册直接抛),插件覆写不了,契约也没给可覆写字段。
|
|
4127
|
+
* 所以沿用本插件既有的认领手法(同 settings-nav-icon.ts):按文案指纹认出宿主
|
|
4128
|
+
* 渲染的那一段,就地改写。
|
|
4129
|
+
*
|
|
4130
|
+
* 只写 `nodeValue`、不增删节点(节点归 React,摘掉会让它的引用落空)。React 重渲染
|
|
4131
|
+
* 覆盖回来时观察器会再改一次;已是目标文案就早退,不会自激。
|
|
4132
|
+
*
|
|
4133
|
+
* 升级路径:宿主若给该提示长出可覆写字段(或 Router 有了专属布局),删掉本文件。
|
|
4134
|
+
*/
|
|
4135
|
+
/** 指纹:语言无关(不受中英文切换影响),且只出现在 Router 卡片上。 */
|
|
4136
|
+
var FINGERPRINT = "(llm-dsh-router)";
|
|
4137
|
+
/** 正确说法:对齐 README「组合即模型」。 */
|
|
4138
|
+
var ROUTER_MODEL_HINT_COPY = "组合即模型 · 建好的组合会自动出现在 DSH 模型目录中,选中组合名即可直接使用。";
|
|
4139
|
+
/**
|
|
4140
|
+
* 这段文本要不要改写。
|
|
4141
|
+
* @param text 段落当前文本
|
|
4142
|
+
* @returns 需要改写时给出目标文案,否则 undefined
|
|
4143
|
+
*/
|
|
4144
|
+
function routerHintRewrite(text) {
|
|
4145
|
+
if (!text.includes(FINGERPRINT)) return void 0;
|
|
4146
|
+
if (text === "组合即模型 · 建好的组合会自动出现在 DSH 模型目录中,选中组合名即可直接使用。") return void 0;
|
|
4147
|
+
return ROUTER_MODEL_HINT_COPY;
|
|
4148
|
+
}
|
|
4149
|
+
/**
|
|
4150
|
+
* 持续把 Router 卡片里的宿主提示改写成 {@link ROUTER_MODEL_HINT_COPY}。
|
|
4151
|
+
* @returns 清理函数:断开观察
|
|
4152
|
+
*/
|
|
4153
|
+
function rewriteRouterModelHint() {
|
|
4154
|
+
const sync = () => {
|
|
4155
|
+
for (const el of document.querySelectorAll("p[class*=\"advancedHint\"]")) {
|
|
4156
|
+
const next = routerHintRewrite(el.textContent ?? "");
|
|
4157
|
+
const node = el.firstChild;
|
|
4158
|
+
if (next === void 0 || node?.nodeType !== Node.TEXT_NODE) continue;
|
|
4159
|
+
node.nodeValue = next;
|
|
4160
|
+
}
|
|
4161
|
+
};
|
|
4162
|
+
sync();
|
|
4163
|
+
const observer = new MutationObserver(sync);
|
|
4164
|
+
observer.observe(document.body, {
|
|
4165
|
+
childList: true,
|
|
4166
|
+
subtree: true,
|
|
4167
|
+
characterData: true
|
|
4168
|
+
});
|
|
4169
|
+
return () => observer.disconnect();
|
|
4170
|
+
}
|
|
4171
|
+
//#endregion
|
|
4118
4172
|
//#region src/client/index.tsx
|
|
4119
4173
|
/**
|
|
4120
4174
|
* Client half of dsh-router.
|
|
@@ -4157,6 +4211,7 @@ window.__ModuleLoader__.load({
|
|
|
4157
4211
|
}
|
|
4158
4212
|
ctx.effect(() => disposeSettings, "dsh-router: settings section");
|
|
4159
4213
|
ctx.effect(() => registerSettingsNavIcon(SECTION_LABEL), "dsh-router: settings nav icon");
|
|
4214
|
+
ctx.effect(() => rewriteRouterModelHint(), "dsh-router: models provider hint copy");
|
|
4160
4215
|
}
|
|
4161
4216
|
//#endregion
|
|
4162
4217
|
exports.apply = apply;
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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";
|
|
3
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
5
|
import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, attributionHeaders } from "@deepseek-ai/dsh-llm";
|
|
@@ -2298,10 +2299,14 @@ var Router = class {
|
|
|
2298
2299
|
* 模型目录 = 组合(自动带出,不可改)。对话转发到本插件 /v1/chat/completions,
|
|
2299
2300
|
* 由现有路由按组合策略命中供应商模型。
|
|
2300
2301
|
*
|
|
2301
|
-
*
|
|
2302
|
+
* 图文流:adapter 声明图片 modal(`inputModalities` 含 `image`),把 user 消息里的
|
|
2303
|
+
* `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
|
|
2304
|
+
* 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
|
|
2305
|
+
* 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
|
|
2306
|
+
* 图片字节从 `ctx.attachments` 读取(读不到的按稳定占位文本降级,绝不静默丢图)。
|
|
2302
2307
|
*/
|
|
2303
2308
|
/**
|
|
2304
|
-
* 把 DSH 消息序列化成 openai 兼容 wire
|
|
2309
|
+
* 把 DSH 消息序列化成 openai 兼容 wire 消息(文本 + 图片 + tool)。
|
|
2305
2310
|
*
|
|
2306
2311
|
* `system` 必须由调用方从 `options.system` 传进来:one-shot 调用方把系统提示词
|
|
2307
2312
|
* 放在 **options.system 这个独立槽位**,不放进 messages。不读这个槽位,
|
|
@@ -2313,8 +2318,12 @@ var Router = class {
|
|
|
2313
2318
|
* 可出现在任意位置以支持提示词热更新)。两条路径都必须支持 —— 与官方
|
|
2314
2319
|
* dsh-llm-deepseek 一致(serializeRequest 前置 options.system,再按位置序列化
|
|
2315
2320
|
* messages 里的 system 消息)。
|
|
2321
|
+
*
|
|
2322
|
+
* 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
|
|
2323
|
+
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
2324
|
+
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
2316
2325
|
*/
|
|
2317
|
-
function wireMessages(options, system) {
|
|
2326
|
+
async function wireMessages(options, system, attachments) {
|
|
2318
2327
|
const out = [];
|
|
2319
2328
|
if (typeof system === "string" && system !== "") out.push({
|
|
2320
2329
|
role: "system",
|
|
@@ -2347,12 +2356,24 @@ function wireMessages(options, system) {
|
|
|
2347
2356
|
});
|
|
2348
2357
|
continue;
|
|
2349
2358
|
}
|
|
2350
|
-
const text = flattenText(message.content);
|
|
2351
2359
|
const toolResults = message.content.filter((b) => b.type === "tool-result");
|
|
2352
|
-
if (
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
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
|
+
}
|
|
2356
2377
|
for (const result of toolResults) out.push({
|
|
2357
2378
|
role: "tool",
|
|
2358
2379
|
tool_call_id: result.toolCallId,
|
|
@@ -2361,9 +2382,73 @@ function wireMessages(options, system) {
|
|
|
2361
2382
|
}
|
|
2362
2383
|
return out;
|
|
2363
2384
|
}
|
|
2364
|
-
/**
|
|
2365
|
-
|
|
2366
|
-
|
|
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);
|
|
2367
2452
|
const body = {
|
|
2368
2453
|
model: options.model,
|
|
2369
2454
|
messages,
|
|
@@ -2410,6 +2495,14 @@ function applyReasoning(body, effort) {
|
|
|
2410
2495
|
function flattenText(blocks) {
|
|
2411
2496
|
return blocks.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
2412
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
|
+
}
|
|
2413
2506
|
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
2414
2507
|
/**
|
|
2415
2508
|
* 把上游 SSE 的 `data:` 载荷流翻译成 DSH 的 StreamChunk 事件流。
|
|
@@ -2643,10 +2736,12 @@ const ROUTER_REASONING_EFFORTS = [
|
|
|
2643
2736
|
var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
2644
2737
|
baseURL;
|
|
2645
2738
|
source;
|
|
2646
|
-
|
|
2739
|
+
resolveAttachments;
|
|
2740
|
+
constructor(baseURL, source, resolveAttachments) {
|
|
2647
2741
|
super();
|
|
2648
2742
|
this.baseURL = baseURL;
|
|
2649
2743
|
this.source = source;
|
|
2744
|
+
this.resolveAttachments = resolveAttachments ?? (() => void 0);
|
|
2650
2745
|
}
|
|
2651
2746
|
providerInfo(provider) {
|
|
2652
2747
|
return {
|
|
@@ -2670,14 +2765,14 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
|
2670
2765
|
return out;
|
|
2671
2766
|
}
|
|
2672
2767
|
/**
|
|
2673
|
-
*
|
|
2674
|
-
*
|
|
2675
|
-
*
|
|
2676
|
-
*
|
|
2677
|
-
*
|
|
2678
|
-
*
|
|
2768
|
+
* 声明图文:`inputModalities` 必须同时含 `text` 与 `image`。
|
|
2769
|
+
* - 含 `image`:runtime/subagent 的模态门禁放行(不再抛
|
|
2770
|
+
* `MODEL_DOES_NOT_SUPPORT_IMAGES` = 界面「当前模型不支持图片」),
|
|
2771
|
+
* `projectImagesForTextModel` 也不会把图片块投影成占位文本。
|
|
2772
|
+
* - 组合背后是异构供应商,**统一声明图片能力**;真正能否看图取决于命中
|
|
2773
|
+
* 的某个上游模型(供应商各模型 capability 不同,网关端不能一刀切拦截)。
|
|
2679
2774
|
*/
|
|
2680
|
-
static INPUT_MODALITIES = ["text"];
|
|
2775
|
+
static INPUT_MODALITIES = ["text", "image"];
|
|
2681
2776
|
async resolveModel(_provider, model) {
|
|
2682
2777
|
const found = (await this.source.comboModels()).find((m) => m.id === model || m.name === model);
|
|
2683
2778
|
return {
|
|
@@ -2693,7 +2788,7 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
|
2693
2788
|
};
|
|
2694
2789
|
}
|
|
2695
2790
|
async *stream(options) {
|
|
2696
|
-
const body = wireRequest(options);
|
|
2791
|
+
const body = await wireRequest(options, this.resolveAttachments());
|
|
2697
2792
|
const controller = new AbortController();
|
|
2698
2793
|
const onAbort = () => controller.abort();
|
|
2699
2794
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -4161,13 +4256,26 @@ function apply(rawContext) {
|
|
|
4161
4256
|
disposers.push(ctx.llm.registerModelDiscovery(LLM_NS, async () => {
|
|
4162
4257
|
return (await router.combos()).map((c) => ({ id: c.name }));
|
|
4163
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
|
+
});
|
|
4164
4272
|
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
|
|
4165
4273
|
const w = router.comboContextWindow(c);
|
|
4166
4274
|
return {
|
|
4167
4275
|
id: c.name,
|
|
4168
4276
|
...w !== void 0 ? { contextWindow: w } : {}
|
|
4169
4277
|
};
|
|
4170
|
-
}) })));
|
|
4278
|
+
}) }, () => ctx.get("attachments"))));
|
|
4171
4279
|
log("llm provider (Router) + discovery + adapter registered ok");
|
|
4172
4280
|
(async () => {
|
|
4173
4281
|
for (const c of await router.combos()) for (const ref of c.models) {
|
|
@@ -4195,11 +4303,20 @@ function apply(rawContext) {
|
|
|
4195
4303
|
} catch (err) {
|
|
4196
4304
|
registeredProviders = `error: ${err.message}`;
|
|
4197
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
|
+
}
|
|
4198
4313
|
writeJson(res, 200, {
|
|
4199
4314
|
ok: true,
|
|
4200
4315
|
llmAvailable: ctx.llm !== void 0,
|
|
4201
4316
|
directory,
|
|
4202
|
-
registeredProviders
|
|
4317
|
+
registeredProviders,
|
|
4318
|
+
settingsNamespaces,
|
|
4319
|
+
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes("llm-dsh-router")
|
|
4203
4320
|
});
|
|
4204
4321
|
});
|
|
4205
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,19 +57,20 @@ 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[]>;
|
|
41
67
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
68
|
+
* 声明图文:`inputModalities` 必须同时含 `text` 与 `image`。
|
|
69
|
+
* - 含 `image`:runtime/subagent 的模态门禁放行(不再抛
|
|
70
|
+
* `MODEL_DOES_NOT_SUPPORT_IMAGES` = 界面「当前模型不支持图片」),
|
|
71
|
+
* `projectImagesForTextModel` 也不会把图片块投影成占位文本。
|
|
72
|
+
* - 组合背后是异构供应商,**统一声明图片能力**;真正能否看图取决于命中
|
|
73
|
+
* 的某个上游模型(供应商各模型 capability 不同,网关端不能一刀切拦截)。
|
|
48
74
|
*/
|
|
49
75
|
private static readonly INPUT_MODALITIES;
|
|
50
76
|
resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-router-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14",
|
|
4
4
|
"description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -64,11 +64,15 @@
|
|
|
64
64
|
"prepublishOnly": "npm run build && node scripts/check-publish.mjs --pack"
|
|
65
65
|
},
|
|
66
66
|
"license": "MIT",
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
69
|
+
},
|
|
67
70
|
"peerDependencies": {
|
|
68
71
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
69
72
|
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
|
|
70
73
|
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
|
|
71
74
|
"@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-settings": "^0.1.5-rc.2",
|
|
72
76
|
"cordis": "^4.0.0-rc.7",
|
|
73
77
|
"react": "^18.2.0",
|
|
74
78
|
"react-dom": "^18.2.0"
|
package/src/client/index.tsx
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { mountRouterWorkspace } from './workspace-mount.tsx'
|
|
12
12
|
import { RouterSettingsSection } from './settings-section.tsx'
|
|
13
13
|
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
|
|
14
|
+
import { rewriteRouterModelHint } from './model-hint-copy.ts'
|
|
14
15
|
import './router.css'
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -65,4 +66,6 @@ export function apply(ctx: Ctx): void {
|
|
|
65
66
|
ctx.effect(() => disposeSettings, 'dsh-router: settings section')
|
|
66
67
|
// 换掉宿主的默认齿轮(契约没有 icon 字段,只能注册后认领自己的行)
|
|
67
68
|
ctx.effect(() => registerSettingsNavIcon(SECTION_LABEL), 'dsh-router: settings nav icon')
|
|
69
|
+
// 设置-模型 里 Router 卡片的那句死路提示 → 正确说法(同样只能认领后就地改写)
|
|
70
|
+
ctx.effect(() => rewriteRouterModelHint(), 'dsh-router: models provider hint copy')
|
|
68
71
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 改写判据测试 —— 锁定「识别宿主提示」与「不自激」两条。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有:这个改写是按文案指纹认领宿主 DOM(见 model-hint-copy.ts)。
|
|
5
|
+
* 判据写错有两种坏法:漏认(提示原样留着)和自激(改完又被自己判定为需要改,
|
|
6
|
+
* 观察器反复触发)。两条都是纯函数可测的。
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { routerHintRewrite, ROUTER_MODEL_HINT_COPY } from './model-hint-copy.ts'
|
|
11
|
+
|
|
12
|
+
test('认出宿主提示(中/英两种文案 + 命名空间指纹)', () => {
|
|
13
|
+
const zh = '其余字段在 settings.yaml 中,请直接编辑对应段。 (llm-dsh-router)'
|
|
14
|
+
const en = 'Other fields live in settings.yaml; edit that section directly. (llm-dsh-router)'
|
|
15
|
+
assert.equal(routerHintRewrite(zh), ROUTER_MODEL_HINT_COPY)
|
|
16
|
+
assert.equal(routerHintRewrite(en), ROUTER_MODEL_HINT_COPY)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('别的 provider 的同类提示不动(指纹只属于 Router)', () => {
|
|
20
|
+
const deepseek = '其余字段在 settings.yaml 中,请直接编辑对应段。 (llm-deepseek)'
|
|
21
|
+
assert.equal(routerHintRewrite(deepseek), undefined)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('已是目标文案时早退 —— 观察器不自激', () => {
|
|
25
|
+
assert.equal(routerHintRewrite(ROUTER_MODEL_HINT_COPY), undefined)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('无关段落一律不动', () => {
|
|
29
|
+
assert.equal(routerHintRewrite(''), undefined)
|
|
30
|
+
assert.equal(routerHintRewrite('配置 DeepSeek 官方模型,即可开始使用。'), undefined)
|
|
31
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设置-模型 → Router 卡片:把宿主那句走不通的提示换成正确说法。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不能直接改源头:Router 的卡片走宿主的 "unknown" 布局(宿主只认得
|
|
5
|
+
* llm-deepseek / llm-pi-ai 两套表单),那句
|
|
6
|
+
* 「其余字段在 settings.yaml 中,请直接编辑对应段。 (llm-dsh-router)」
|
|
7
|
+
* 属于宿主 `settings.models` 的 locale 字典,而字典注册对同一个 (ns, locale)
|
|
8
|
+
* 只允许一个占位者(重复注册直接抛),插件覆写不了,契约也没给可覆写字段。
|
|
9
|
+
* 所以沿用本插件既有的认领手法(同 settings-nav-icon.ts):按文案指纹认出宿主
|
|
10
|
+
* 渲染的那一段,就地改写。
|
|
11
|
+
*
|
|
12
|
+
* 只写 `nodeValue`、不增删节点(节点归 React,摘掉会让它的引用落空)。React 重渲染
|
|
13
|
+
* 覆盖回来时观察器会再改一次;已是目标文案就早退,不会自激。
|
|
14
|
+
*
|
|
15
|
+
* 升级路径:宿主若给该提示长出可覆写字段(或 Router 有了专属布局),删掉本文件。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 指纹:语言无关(不受中英文切换影响),且只出现在 Router 卡片上。 */
|
|
19
|
+
const FINGERPRINT = '(llm-dsh-router)'
|
|
20
|
+
|
|
21
|
+
/** 正确说法:对齐 README「组合即模型」。 */
|
|
22
|
+
export const ROUTER_MODEL_HINT_COPY = '组合即模型 · 建好的组合会自动出现在 DSH 模型目录中,选中组合名即可直接使用。'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 这段文本要不要改写。
|
|
26
|
+
* @param text 段落当前文本
|
|
27
|
+
* @returns 需要改写时给出目标文案,否则 undefined
|
|
28
|
+
*/
|
|
29
|
+
export function routerHintRewrite(text: string): string | undefined {
|
|
30
|
+
if (!text.includes(FINGERPRINT)) return undefined
|
|
31
|
+
if (text === ROUTER_MODEL_HINT_COPY) return undefined // 已改过,早退防自激
|
|
32
|
+
return ROUTER_MODEL_HINT_COPY
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 持续把 Router 卡片里的宿主提示改写成 {@link ROUTER_MODEL_HINT_COPY}。
|
|
37
|
+
* @returns 清理函数:断开观察
|
|
38
|
+
*/
|
|
39
|
+
export function rewriteRouterModelHint(): () => void {
|
|
40
|
+
const sync = (): void => {
|
|
41
|
+
for (const el of document.querySelectorAll<HTMLElement>('p[class*="advancedHint"]')) {
|
|
42
|
+
const next = routerHintRewrite(el.textContent ?? '')
|
|
43
|
+
// 宿主把整句渲染成一个文本节点(单个模板串)——直接整段替换
|
|
44
|
+
const node = el.firstChild
|
|
45
|
+
if (next === undefined || node?.nodeType !== Node.TEXT_NODE) continue
|
|
46
|
+
node.nodeValue = next
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
sync()
|
|
51
|
+
// 设置面板按需挂载、React 重渲染都会覆盖回去,子树变动时重新认领
|
|
52
|
+
const observer = new MutationObserver(sync)
|
|
53
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
54
|
+
return () => observer.disconnect()
|
|
55
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -31,9 +31,10 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
33
33
|
import { join } from 'node:path'
|
|
34
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
34
35
|
import { ROUTER_API_BASE, type RouterPeriod } from './shared.ts'
|
|
35
36
|
import { Router } from './router/index.ts'
|
|
36
|
-
import { RouterAdapter } from './llm/adapter.ts'
|
|
37
|
+
import { RouterAdapter, type RouterAttachmentStore } from './llm/adapter.ts'
|
|
37
38
|
import { KeysStore } from './keys.ts'
|
|
38
39
|
import { loadSuppliers, wrapModule, type LoadedSupplier } from './suppliers/loader.ts'
|
|
39
40
|
import { supplierRoutes } from './suppliers/registry.ts'
|
|
@@ -94,6 +95,29 @@ interface Llm {
|
|
|
94
95
|
registerAdapter: (providers: readonly string[], adapter: unknown) => () => void
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/**
|
|
99
|
+
* 设置服务(`ctx.settings`,dsh-settings)的最小切面:本插件只用 `installSection`
|
|
100
|
+
* 注册一个命名空间,让设置-模型页能列出 Router provider 卡片。dsh-settings 是
|
|
101
|
+
* 宿主注入的 peer service,这里不深绑它的类型,只声明用到的这一个方法。
|
|
102
|
+
*/
|
|
103
|
+
interface SettingsServiceFace {
|
|
104
|
+
installSection: (
|
|
105
|
+
owner: CordisContext,
|
|
106
|
+
ns: string,
|
|
107
|
+
schema: unknown,
|
|
108
|
+
entry: unknown,
|
|
109
|
+
hooks: { setSource: (current: () => unknown) => void; onChange: () => void },
|
|
110
|
+
) => void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* cordis Context 的最小切面。`installSection(owner)` 只会对 owner 调
|
|
115
|
+
* `ctx.effect(...)` 并在卸载时读 `fiber.state`,所以这里只声明 `effect`。
|
|
116
|
+
*/
|
|
117
|
+
interface CordisContext {
|
|
118
|
+
effect: (fn: () => () => void, label?: string) => unknown
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
98
122
|
const payload = JSON.stringify(body)
|
|
99
123
|
res.writeHead(status, {
|
|
@@ -574,14 +598,35 @@ export function apply(rawContext: unknown): void {
|
|
|
574
598
|
const combos = await router.combos()
|
|
575
599
|
return combos.map((c) => ({ id: c.name }))
|
|
576
600
|
}))
|
|
601
|
+
// Router 的配置(组合/密钥/签到)存在 core 自己的 state.json,走插件自己的
|
|
602
|
+
// 「路由系统」面板;设置-模型 这边**仍必须注册 `llm-dsh-router` 命名空间**。
|
|
603
|
+
// 原因:设置-模型 的 provider 列表只渲染 settingsNs 能在设置镜像里解析出来的
|
|
604
|
+
// 行(见 dsh-client-ui-settings-models 的 configurable 过滤)。只调
|
|
605
|
+
// registerConfigurableProviders 而不注册 section,Router 卡片**不会出现**。
|
|
606
|
+
// 空 schema = 卡片是入口/占位(该布局下不可提交),真正的配置在路由系统面板。
|
|
607
|
+
const routerSettingsSchema = Schema.object({})
|
|
608
|
+
ctx.inject(['settings'], (sctx: unknown) => {
|
|
609
|
+
const settings = (sctx as { settings?: SettingsServiceFace }).settings
|
|
610
|
+
if (settings === undefined) {
|
|
611
|
+
log('settings service absent — skip Router settings-section registration')
|
|
612
|
+
return
|
|
613
|
+
}
|
|
614
|
+
settings.installSection(rawContext as CordisContext, LLM_NS, routerSettingsSchema, {}, {
|
|
615
|
+
setSource: () => {},
|
|
616
|
+
onChange: () => {},
|
|
617
|
+
})
|
|
618
|
+
log(`Router settings section registered (${LLM_NS})`)
|
|
619
|
+
})
|
|
577
620
|
// adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
|
|
578
621
|
// 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
|
|
622
|
+
// 图片序列化需要读附件字节:把 ctx.attachments 传给 adapter(dsh-attachment
|
|
623
|
+
// 是宿主注入的 service,插件不直接 import 它,只依赖 duck-typed 切面)。
|
|
579
624
|
disposers.push(ctx.llm.registerAdapter(['router'], new RouterAdapter('http://localhost:3080/v1', {
|
|
580
625
|
comboModels: async () => (await router.combos()).map((c) => {
|
|
581
626
|
const w = router.comboContextWindow(c)
|
|
582
627
|
return { id: c.name, ...(w !== undefined ? { contextWindow: w } : {}) }
|
|
583
628
|
}),
|
|
584
|
-
})))
|
|
629
|
+
}, () => ctx.get('attachments') as RouterAttachmentStore | undefined)))
|
|
585
630
|
log('llm provider (Router) + discovery + adapter registered ok')
|
|
586
631
|
// 预热模型缓存:`comboContextWindow` 只读缓存、不打上游,缓存空着就
|
|
587
632
|
// 报不出窗口。这里后台填一次,让第一次 resolveModel 就有值。
|
|
@@ -616,11 +661,25 @@ export function apply(rawContext: unknown): void {
|
|
|
616
661
|
} catch (err) {
|
|
617
662
|
registeredProviders = `error: ${(err as Error).message}`
|
|
618
663
|
}
|
|
664
|
+
// 设置命名空间是否注册成功 —— 卡片不出现的**唯一**嫌疑点:provider 在目录里
|
|
665
|
+
// (上面的 directory)却没注册 section 时,设置-模型 的 configurable 过滤会
|
|
666
|
+
// 把该行整条丢掉,界面上什么都不显示、也没有任何报错。
|
|
667
|
+
let settingsNamespaces: unknown = 'n/a'
|
|
668
|
+
try {
|
|
669
|
+
const settings = ctx.get('settings') as { describe?: (opts?: unknown) => Array<{ ns: string }> } | undefined
|
|
670
|
+
settingsNamespaces = settings?.describe === undefined
|
|
671
|
+
? 'no settings service'
|
|
672
|
+
: settings.describe({ redactSecrets: true }).map((d) => d.ns)
|
|
673
|
+
} catch (err) {
|
|
674
|
+
settingsNamespaces = `error: ${(err as Error).message}`
|
|
675
|
+
}
|
|
619
676
|
writeJson(res, 200, {
|
|
620
677
|
ok: true,
|
|
621
678
|
llmAvailable: ctx.llm !== undefined,
|
|
622
679
|
directory,
|
|
623
680
|
registeredProviders,
|
|
681
|
+
settingsNamespaces,
|
|
682
|
+
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes('llm-dsh-router'),
|
|
624
683
|
})
|
|
625
684
|
})
|
|
626
685
|
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -519,6 +519,9 @@ test('模型目录:resolveModel 对每个组合声明推理等级(off/low/hi
|
|
|
519
519
|
assert.equal(a.provider, 'router')
|
|
520
520
|
assert.equal(a.id, 'c-a')
|
|
521
521
|
assert.equal(a.name, '组合A')
|
|
522
|
+
// 图文模态:必须声明含 image,否则 runtime/subagent 的模态门禁会抛
|
|
523
|
+
// `MODEL_DOES_NOT_SUPPORT_IMAGES`(界面「当前模型不支持图片」)。
|
|
524
|
+
assert.deepEqual(a.inputModalities, ['text', 'image'], 'adapter 必须声明 image 模态,否则图片被门禁拦截')
|
|
522
525
|
assert.ok(a.reasoning, '组合应声明推理等级,DSH 才允许显式 effort')
|
|
523
526
|
assert.deepEqual(a.reasoning!.efforts.map((e) => e.id), ['off', 'low', 'high', 'max'])
|
|
524
527
|
assert.equal(a.reasoning!.defaultEffort, 'high', '默认 High:调用方不指定时物化为 high')
|
|
@@ -543,3 +546,82 @@ test('resolveModel 透传组合的 contextWindow(自动压缩才能算阈值
|
|
|
543
546
|
const b = await adapter.resolveModel('router', 'c-b')
|
|
544
547
|
assert.equal(b.context, undefined, '没拿到就不声明 context,绝不填一个假窗口')
|
|
545
548
|
})
|
|
549
|
+
|
|
550
|
+
/* ---------------- 图片序列化:图片必须真正到上游(不静默丢图) ---------------- */
|
|
551
|
+
|
|
552
|
+
/** 假附件 store:返回指定 mediaType + bytes 的请求版本。 */
|
|
553
|
+
function fakeAttachments(mediaType = 'image/png', data = new TextEncoder().encode('<png-bytes>')): {
|
|
554
|
+
readImageRequest: () => Promise<{ mediaType: string; data: Uint8Array }>
|
|
555
|
+
} {
|
|
556
|
+
return {
|
|
557
|
+
readImageRequest: async () => ({ mediaType, data }),
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** 用给定 attachments 跑一次 stream(),返回上游收到的 body。 */
|
|
562
|
+
async function captureBodyWith(options: Record<string, unknown>, attachments?: unknown): Promise<Record<string, unknown>> {
|
|
563
|
+
let captured: Record<string, unknown> = {}
|
|
564
|
+
globalThis.fetch = (async (_url: string, init?: { body?: string }) => {
|
|
565
|
+
captured = JSON.parse(init?.body ?? '{}') as Record<string, unknown>
|
|
566
|
+
return new Response('data: [DONE]\n\n', { status: 200 })
|
|
567
|
+
}) as typeof fetch
|
|
568
|
+
const adapter = new RouterAdapter(
|
|
569
|
+
'http://x',
|
|
570
|
+
{ comboModels: async () => [] },
|
|
571
|
+
() => attachments as never,
|
|
572
|
+
)
|
|
573
|
+
for await (const _c of adapter.stream({ model: 'm', messages: [], signal: AbortSignal.timeout(3000), ...options } as never)) {
|
|
574
|
+
void _c
|
|
575
|
+
}
|
|
576
|
+
return captured
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
test('wire:图片块被序列化成 OpenAI image_url base64 part(不再静默丢图)', async () => {
|
|
580
|
+
const ref = {
|
|
581
|
+
attachmentId: 'sha256:abcdef1234567890',
|
|
582
|
+
mediaType: 'image/png',
|
|
583
|
+
bytes: 11,
|
|
584
|
+
width: 4,
|
|
585
|
+
height: 2,
|
|
586
|
+
}
|
|
587
|
+
const body = await captureBodyWith({
|
|
588
|
+
messages: [{ role: 'user', content: [
|
|
589
|
+
{ type: 'text', text: '看这张图' },
|
|
590
|
+
{ type: 'image', attachment: ref },
|
|
591
|
+
] }],
|
|
592
|
+
}, fakeAttachments())
|
|
593
|
+
const user = (body.messages as Array<{ role: string; content: unknown }>).find((m) => m.role === 'user')
|
|
594
|
+
const parts = (user?.content ?? []) as Array<{ type: string; text?: string; image_url?: { url: string } }>
|
|
595
|
+
assert.equal(parts.length, 2, 'text + image 两个 part')
|
|
596
|
+
assert.equal(parts[0]?.type, 'text')
|
|
597
|
+
assert.equal(parts[0]?.text, '看这张图')
|
|
598
|
+
assert.equal(parts[1]?.type, 'image_url', '图片必须是 image_url part,不是占位文本')
|
|
599
|
+
const b64 = btoa(String.fromCharCode(...new TextEncoder().encode('<png-bytes>')))
|
|
600
|
+
assert.equal(parts[1]?.image_url?.url, `data:image/png;base64,${b64}`, 'data URI 必须是 base64 编码的真实字节')
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
test('wire:纯文本 user 消息仍是紧凑字符串(不回归 wire 形式)', async () => {
|
|
604
|
+
const body = await captureBodyWith({
|
|
605
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
606
|
+
}, fakeAttachments())
|
|
607
|
+
const user = (body.messages as Array<{ role: string; content: unknown }>).find((m) => m.role === 'user')
|
|
608
|
+
assert.equal(user?.content, 'hi', '无图时必须保持字符串 content')
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
test('wire:无 attachments 时图片回退占位文本,绝不静默丢图', async () => {
|
|
612
|
+
const ref = {
|
|
613
|
+
attachmentId: 'sha256:abcdef1234567890',
|
|
614
|
+
mediaType: 'image/png',
|
|
615
|
+
bytes: 11,
|
|
616
|
+
width: 4,
|
|
617
|
+
height: 2,
|
|
618
|
+
}
|
|
619
|
+
const body = await captureBodyWith({
|
|
620
|
+
messages: [{ role: 'user', content: [{ type: 'image', attachment: ref }] }],
|
|
621
|
+
}, undefined)
|
|
622
|
+
const user = (body.messages as Array<{ role: string; content: unknown }>).find((m) => m.role === 'user')
|
|
623
|
+
const parts = (user?.content ?? []) as Array<{ type: string; text?: string }>
|
|
624
|
+
assert.equal(parts.length, 1)
|
|
625
|
+
assert.equal(parts[0]?.type, 'text')
|
|
626
|
+
assert.ok((parts[0]?.text ?? '').includes('image omitted'), '读不到附件时必须给占位文本,图片不能无声消失')
|
|
627
|
+
})
|
package/src/llm/adapter.ts
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
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 {
|
|
10
14
|
EMPTY_RESPONSE_CODE,
|
|
@@ -22,6 +26,29 @@ import {
|
|
|
22
26
|
} from '@deepseek-ai/dsh-llm'
|
|
23
27
|
import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../router/usage-tokens.ts'
|
|
24
28
|
|
|
29
|
+
/**
|
|
30
|
+
* dsh-attachment 的最小本地切面:adapter 只依赖「读一张图片的请求版本」
|
|
31
|
+
* 这一行为,不引入对 dsh-attachment 包编译期/运行期的依赖(它是宿主注入的
|
|
32
|
+
* devDependency)。本地声明与原包 `AttachmentStore.readImageRequest` 形状一致,
|
|
33
|
+
* 宿主注入真实 store 时天然满足。
|
|
34
|
+
*/
|
|
35
|
+
export interface RouterAttachmentStore {
|
|
36
|
+
readImageRequest(
|
|
37
|
+
ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
|
|
38
|
+
policy: { maxPixels: number; maxBytes: number },
|
|
39
|
+
signal?: AbortSignal,
|
|
40
|
+
): Promise<{ mediaType: string; data: Uint8Array }>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 一张 image 块的 attachment ref 的最小形状(value 类型,够用即可)。 */
|
|
44
|
+
interface RouterImgLike {
|
|
45
|
+
attachmentId: string
|
|
46
|
+
mediaType: string
|
|
47
|
+
bytes: number
|
|
48
|
+
width: number
|
|
49
|
+
height: number
|
|
50
|
+
}
|
|
51
|
+
|
|
25
52
|
/** 模型目录来源:组合。 */
|
|
26
53
|
export interface RouterAdapterSource {
|
|
27
54
|
/**
|
|
@@ -34,7 +61,7 @@ export interface RouterAdapterSource {
|
|
|
34
61
|
}
|
|
35
62
|
|
|
36
63
|
/**
|
|
37
|
-
* 把 DSH 消息序列化成 openai 兼容 wire
|
|
64
|
+
* 把 DSH 消息序列化成 openai 兼容 wire 消息(文本 + 图片 + tool)。
|
|
38
65
|
*
|
|
39
66
|
* `system` 必须由调用方从 `options.system` 传进来:one-shot 调用方把系统提示词
|
|
40
67
|
* 放在 **options.system 这个独立槽位**,不放进 messages。不读这个槽位,
|
|
@@ -46,8 +73,12 @@ export interface RouterAdapterSource {
|
|
|
46
73
|
* 可出现在任意位置以支持提示词热更新)。两条路径都必须支持 —— 与官方
|
|
47
74
|
* dsh-llm-deepseek 一致(serializeRequest 前置 options.system,再按位置序列化
|
|
48
75
|
* messages 里的 system 消息)。
|
|
76
|
+
*
|
|
77
|
+
* 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
|
|
78
|
+
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
79
|
+
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
49
80
|
*/
|
|
50
|
-
function wireMessages(options: GenerateOptions, system
|
|
81
|
+
async function wireMessages(options: GenerateOptions, system: string | undefined, attachments: RouterAttachmentStore | undefined): Promise<Array<Record<string, unknown>>> {
|
|
51
82
|
const out: Array<Record<string, unknown>> = []
|
|
52
83
|
if (typeof system === 'string' && system !== '') {
|
|
53
84
|
out.push({ role: 'system', content: system })
|
|
@@ -76,9 +107,16 @@ function wireMessages(options: GenerateOptions, system?: string): Array<Record<s
|
|
|
76
107
|
continue
|
|
77
108
|
}
|
|
78
109
|
// user / tool-result
|
|
79
|
-
const text = flattenText(message.content)
|
|
80
110
|
const toolResults = message.content.filter((b) => b.type === 'tool-result')
|
|
81
|
-
|
|
111
|
+
// 有图 → content 数组;纯文本 → 保持紧凑字符串 wire 形式不回归。
|
|
112
|
+
if (hasImageBlock(message.content)) {
|
|
113
|
+
const parts = await contentParts(message.content, attachments, options.signal)
|
|
114
|
+
if (parts.length > 0) out.push({ role: 'user', content: parts })
|
|
115
|
+
else if (toolResults.length === 0) out.push({ role: 'user', content: '' })
|
|
116
|
+
} else {
|
|
117
|
+
const text = flattenText(message.content)
|
|
118
|
+
if (text.length > 0 || toolResults.length === 0) out.push({ role: 'user', content: text })
|
|
119
|
+
}
|
|
82
120
|
for (const result of toolResults) {
|
|
83
121
|
// 空结果就发空串,不要替换成 '(no output)' 之类的字面量 —— 模型会
|
|
84
122
|
// 以为工具真的打印了那句话(9router 也是补 content: "")。
|
|
@@ -88,9 +126,73 @@ function wireMessages(options: GenerateOptions, system?: string): Array<Record<s
|
|
|
88
126
|
return out
|
|
89
127
|
}
|
|
90
128
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
129
|
+
/**
|
|
130
|
+
* 一块 DSH user/tool-result 内容序列化成 OpenAI content parts(text + image_url)。
|
|
131
|
+
* 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
|
|
132
|
+
* attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
|
|
133
|
+
*/
|
|
134
|
+
async function contentParts(
|
|
135
|
+
blocks: ReadonlyArray<{ type: string }>,
|
|
136
|
+
attachments: RouterAttachmentStore | undefined,
|
|
137
|
+
signal: AbortSignal | undefined,
|
|
138
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
139
|
+
const parts: Array<Record<string, unknown>> = []
|
|
140
|
+
for (const block of blocks) {
|
|
141
|
+
if (block.type === 'text') {
|
|
142
|
+
const text = (block as { text?: string }).text ?? ''
|
|
143
|
+
if (text.length > 0) parts.push({ type: 'text', text })
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if (block.type === 'image') {
|
|
147
|
+
const ref = (block as unknown as { attachment: RouterImgLike }).attachment
|
|
148
|
+
parts.push(...(await imageParts(ref, attachments, signal)))
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if (block.type === 'tool-result') {
|
|
152
|
+
parts.push(...(await contentParts((block as unknown as { content: ReadonlyArray<{ type: string }> }).content, attachments, signal)))
|
|
153
|
+
}
|
|
154
|
+
// 其它块(reasoning/tool-call)不属于 user content,忽略
|
|
155
|
+
}
|
|
156
|
+
return parts
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 一张图片 → 一个 OpenAI image_url part(或读不到附件时的占位文本)。 */
|
|
160
|
+
async function imageParts(
|
|
161
|
+
ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
|
|
162
|
+
attachments: RouterAttachmentStore | undefined,
|
|
163
|
+
signal: AbortSignal | undefined,
|
|
164
|
+
): Promise<Array<Record<string, unknown>>> {
|
|
165
|
+
if (attachments === undefined) return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
|
|
166
|
+
try {
|
|
167
|
+
const img = await attachments.readImageRequest(
|
|
168
|
+
{ attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height },
|
|
169
|
+
{ maxPixels: 64e4, maxBytes: 1024 * 1024 },
|
|
170
|
+
signal,
|
|
171
|
+
)
|
|
172
|
+
return [{ type: 'image_url', image_url: { url: `data:${img.mediaType};base64,${bytesToBase64(img.data)}` } }]
|
|
173
|
+
} catch {
|
|
174
|
+
return [{ type: 'text', text: imagePlaceholder(ref.attachmentId) }]
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Uint8Array → base64(browser + node 双环境安全,不依赖 Buffer 全局)。 */
|
|
179
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
180
|
+
let bin = ''
|
|
181
|
+
const chunk = 0x8000
|
|
182
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
183
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + chunk))
|
|
184
|
+
}
|
|
185
|
+
return btoa(bin)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 读不到图片字节时的稳定占位文本。 */
|
|
189
|
+
function imagePlaceholder(attachmentId: string): string {
|
|
190
|
+
return `[image omitted because the attached bytes could not be read; attachment ${attachmentId}]`
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 组装 wire 请求体。图片序列化需要读 attachments,故为异步。 */
|
|
194
|
+
async function wireRequest(options: GenerateOptions, attachments: RouterAttachmentStore | undefined): Promise<Record<string, unknown>> {
|
|
195
|
+
const messages = await wireMessages(options, options.system, attachments)
|
|
94
196
|
const body: Record<string, unknown> = {
|
|
95
197
|
model: options.model,
|
|
96
198
|
messages,
|
|
@@ -136,6 +238,15 @@ function flattenText(blocks: readonly { type: string; text?: string }[]): string
|
|
|
136
238
|
return blocks.filter((b) => b.type === 'text').map((b) => b.text ?? '').join('')
|
|
137
239
|
}
|
|
138
240
|
|
|
241
|
+
/** 内容块里(含递归进 tool-result)是否存在 image 块。决定走数组 content 还是字符串。 */
|
|
242
|
+
function hasImageBlock(blocks: readonly { type: string }[]): boolean {
|
|
243
|
+
for (const b of blocks) {
|
|
244
|
+
if (b.type === 'image') return true
|
|
245
|
+
if (b.type === 'tool-result' && hasImageBlock((b as unknown as { content: ReadonlyArray<{ type: string }> }).content)) return true
|
|
246
|
+
}
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
|
|
139
250
|
interface OpenBlock {
|
|
140
251
|
index: number
|
|
141
252
|
kind: 'text' | 'reasoning' | 'tool-call'
|
|
@@ -379,11 +490,13 @@ const ROUTER_REASONING_EFFORTS: readonly LlmReasoningEffortInfo[] = [
|
|
|
379
490
|
export class RouterAdapter extends LlmAdapter {
|
|
380
491
|
private readonly baseURL: string
|
|
381
492
|
private readonly source: RouterAdapterSource
|
|
493
|
+
private readonly resolveAttachments: () => RouterAttachmentStore | undefined
|
|
382
494
|
|
|
383
|
-
constructor(baseURL: string, source: RouterAdapterSource) {
|
|
495
|
+
constructor(baseURL: string, source: RouterAdapterSource, resolveAttachments?: () => RouterAttachmentStore | undefined) {
|
|
384
496
|
super()
|
|
385
497
|
this.baseURL = baseURL
|
|
386
498
|
this.source = source
|
|
499
|
+
this.resolveAttachments = resolveAttachments ?? (() => undefined)
|
|
387
500
|
}
|
|
388
501
|
|
|
389
502
|
providerInfo(provider: string): { id: string; name: string } {
|
|
@@ -403,14 +516,14 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
403
516
|
}
|
|
404
517
|
|
|
405
518
|
/**
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
519
|
+
* 声明图文:`inputModalities` 必须同时含 `text` 与 `image`。
|
|
520
|
+
* - 含 `image`:runtime/subagent 的模态门禁放行(不再抛
|
|
521
|
+
* `MODEL_DOES_NOT_SUPPORT_IMAGES` = 界面「当前模型不支持图片」),
|
|
522
|
+
* `projectImagesForTextModel` 也不会把图片块投影成占位文本。
|
|
523
|
+
* - 组合背后是异构供应商,**统一声明图片能力**;真正能否看图取决于命中
|
|
524
|
+
* 的某个上游模型(供应商各模型 capability 不同,网关端不能一刀切拦截)。
|
|
412
525
|
*/
|
|
413
|
-
private static readonly INPUT_MODALITIES: readonly ModelModality[] = ['text']
|
|
526
|
+
private static readonly INPUT_MODALITIES: readonly ModelModality[] = ['text', 'image']
|
|
414
527
|
|
|
415
528
|
async resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
416
529
|
const combos = await this.source.comboModels()
|
|
@@ -430,7 +543,7 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
430
543
|
}
|
|
431
544
|
|
|
432
545
|
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
433
|
-
const body = wireRequest(options)
|
|
546
|
+
const body = await wireRequest(options, this.resolveAttachments())
|
|
434
547
|
const controller = new AbortController()
|
|
435
548
|
const onAbort = (): void => controller.abort()
|
|
436
549
|
options.signal?.addEventListener('abort', onAbort, { once: true })
|