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
|
@@ -31,10 +31,14 @@ export declare class AccountPool {
|
|
|
31
31
|
/** uid → 连接级禁用 / 手动暂停。 */
|
|
32
32
|
private byUid;
|
|
33
33
|
private rrCursor;
|
|
34
|
-
/** (supplier, model) →
|
|
34
|
+
/** (supplier, model) → 当前驻留的号(只有**无亲和**的回退路径用它)。 */
|
|
35
35
|
private blocks;
|
|
36
|
+
/** (supplier, model, uid) → 该号的缓存质量样本(降权判据,跨驻留累计)。 */
|
|
37
|
+
private samples;
|
|
36
38
|
/** (supplier, model, uid) → 缓存无效降权。 */
|
|
37
39
|
private demoted;
|
|
40
|
+
/** (supplier, model) → Map<前缀指纹, uid>:前缀亲和的选号记忆(LRU 封顶)。 */
|
|
41
|
+
private affinity;
|
|
38
42
|
constructor(supplierId?: string);
|
|
39
43
|
private key;
|
|
40
44
|
private entry;
|
|
@@ -51,26 +55,34 @@ export declare class AccountPool {
|
|
|
51
55
|
/**
|
|
52
56
|
* 按策略为「某个模型」选一个健康账号。
|
|
53
57
|
*
|
|
54
|
-
* round-robin
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
58
|
+
* round-robin = **前缀亲和定号 + 块统计只用来判定缓存无效**:
|
|
59
|
+
*
|
|
60
|
+
* 1. 算请求的前缀指纹(messages 前几条),查绑定表 → 命中且该号健康 → 用它。
|
|
61
|
+
* 这是第一优先:同一个会话的连续请求**永远落回同一个号**,前缀在该号的
|
|
62
|
+
* 缓存里只写一份。块轮询治不了跨会话驱逐(多会话大前缀在同一号互踩,
|
|
63
|
+
* 实测 3 会话 3.8% vs 单会话 82.5%),亲和才是根治。
|
|
64
|
+
* 2. 新指纹(或绑定的号不健康)→ 按游标挑一个号并**记下绑定**,之后就固定。
|
|
65
|
+
* 新会话用游标分发,所以不同会话天然散到不同号上(均衡)。
|
|
66
|
+
* 3. 块统计不再驱动轮转(亲和已经保证驻留,再轮转只会把会话踢出已热前缀),
|
|
67
|
+
* 只保留 `BLOCK_MAX` 次不达标 → 降权该号(缓存无效,可恢复)。
|
|
68
|
+
*
|
|
69
|
+
* 亲和失效(拿不到 messages / 指纹算不出)时**回退到块轮询**,行为与之前一致。
|
|
70
|
+
* 设计见 docs/pool-sticky-block.md §8。
|
|
62
71
|
*
|
|
63
72
|
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
64
73
|
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
65
|
-
* @param strategy fallback 取第一个健康 / round-robin
|
|
74
|
+
* @param strategy fallback 取第一个健康 / round-robin 前缀亲和
|
|
66
75
|
* @param modelId 当前要路由的模型(决定查哪个 (model, uid) 冷却单元)
|
|
76
|
+
* @param messages 请求体的 messages(可选;给了才能算前缀指纹)
|
|
67
77
|
* @returns 选中的 uid;无健康账号返回 undefined
|
|
68
78
|
*/
|
|
69
|
-
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string): string | undefined;
|
|
79
|
+
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string, modelId: string, messages?: unknown): string | undefined;
|
|
80
|
+
/** 记下「前缀指纹 → 号」的绑定(LRU:超上限丢最久未用的)。 */
|
|
81
|
+
private bind;
|
|
70
82
|
/** 该号在本模型上是否被判定「缓存无效」(降权中)。 */
|
|
71
83
|
private isDemoted;
|
|
72
84
|
/**
|
|
73
|
-
*
|
|
85
|
+
* 驻留是否还应留在当前号上(**只用于无亲和的回退路径**)。
|
|
74
86
|
*
|
|
75
87
|
* OR 语义(用户拍板):`留守 = 计数 < N OR 命中率 < M`。
|
|
76
88
|
* 单一账号内部:一个号先被轮到(冷启动/热),然后
|
|
@@ -80,22 +92,20 @@ export declare class AccountPool {
|
|
|
80
92
|
*
|
|
81
93
|
* 关键:命中率是**留守**条件不是切号执行器(反接会正反馈死锁,
|
|
82
94
|
* 见文档 §3)。换号由「命中达标 OR 份额用够」触发,都不是「命中率低」。
|
|
83
|
-
*
|
|
84
|
-
* 每个号各自守着「这个会话在自己账号上的那段前缀」,轮流热;
|
|
85
|
-
* 命中率 = 块的命中占比。单会话下每个号块都从冷到热各付一次,
|
|
86
|
-
* 收益来自块内连续驻留(缺了它就跟每请求轮询一样)。
|
|
87
95
|
*/
|
|
88
96
|
private blockKeeps;
|
|
89
97
|
/**
|
|
90
|
-
*
|
|
98
|
+
* 记录一次成功请求的缓存命中情况(降权判据的反馈信号)。
|
|
91
99
|
*
|
|
92
|
-
*
|
|
100
|
+
* 只有**成功**请求才计入:失败没产生缓存,计入会污染命中率并导致错降权。
|
|
101
|
+
* 统计按 (model, uid) 记,**不管这个号是不是「当前驻留」**——亲和开启后
|
|
102
|
+
* 多个会话各绑各的号交错服务,按驻留记会漏掉大部分真实反馈。
|
|
93
103
|
* @param uid 实际服务的号
|
|
94
104
|
* @param modelId 模型(块粒度)
|
|
95
105
|
* @param cachedTokens 上游报的命中 token 数;0 = 全量重算
|
|
96
106
|
*/
|
|
97
107
|
noteCache(uid: string, modelId: string, cachedTokens: number): void;
|
|
98
|
-
/**
|
|
108
|
+
/** 故障切走时作废当前驻留:换号后前缀归属变了,驻留计数不能沿用。 */
|
|
99
109
|
dropBlock(modelId: string): void;
|
|
100
110
|
/** 块状态键:与冷却同粒度 (supplier, model),不含 uid。 */
|
|
101
111
|
private blockKey;
|
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
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.
|
|
71
|
-
"@deepseek-ai/dsh-llm": "^0.1.
|
|
72
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.5-rc.1",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.5-rc.1",
|
|
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"
|
|
@@ -85,7 +89,7 @@
|
|
|
85
89
|
}
|
|
86
90
|
},
|
|
87
91
|
"devDependencies": {
|
|
88
|
-
"@deepseek-ai/dsh-llm": "0.1.
|
|
92
|
+
"@deepseek-ai/dsh-llm": "0.1.5-rc.1",
|
|
89
93
|
"@types/node": "^26.4.1",
|
|
90
94
|
"@types/react": "~19.2.18",
|
|
91
95
|
"@types/react-dom": "~19.2.7",
|
|
@@ -908,7 +908,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
908
908
|
</>
|
|
909
909
|
<>
|
|
910
910
|
<label className="dshr-fieldLabel">连接池策略</label>
|
|
911
|
-
<p className="dshr-muted">回退 = 按连接池顺序取第一个健康链接;轮询 =
|
|
911
|
+
<p className="dshr-muted">回退 = 按连接池顺序取第一个健康链接;轮询 = 按会话前缀粘住同一链接(前缀亲和,多会话不打架),拿不到会话信息时退回块轮转。</p>
|
|
912
912
|
<select
|
|
913
913
|
className="dshr-select dshr-input"
|
|
914
914
|
value={poolStrategy}
|
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
|
+
})
|