dsh-router-core 0.3.13 → 0.3.15
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/docs/suppliers.md +8 -8
- package/lib/client-registry.js +57 -6
- package/lib/client.js +57 -6
- package/lib/index.js +158 -66
- package/lib/types/llm/adapter.d.ts +34 -8
- package/lib/types/router/account-pool.d.ts +11 -11
- package/lib/types/router/types.d.ts +0 -1
- package/lib/types/shared.d.ts +0 -1
- package/lib/types/suppliers/contract.d.ts +5 -5
- package/package.json +5 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +1 -2
- 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/src/router/account-pool.test.ts +11 -10
- package/src/router/account-pool.ts +46 -45
- package/src/router/models-cache.test.ts +0 -1
- package/src/router/types.ts +0 -1
- package/src/shared.ts +0 -1
- package/src/suppliers/contract.ts +5 -5
- package/src/suppliers/loader.ts +1 -1
- package/src/suppliers/registry.test.ts +3 -4
- package/src/suppliers/registry.ts +1 -1
|
@@ -62,7 +62,6 @@ function untilLabel(account: RouterAccount): string {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
function statusBadge(account: RouterAccount): { text: string; cls: string } {
|
|
65
|
-
if (account.disabled) return { text: '已禁用', cls: 'dshr-badge-danger' }
|
|
66
65
|
if (account.cooling) return { text: '冷却中', cls: 'dshr-badge-warn' }
|
|
67
66
|
return { text: '健康', cls: 'dshr-badge-ok' }
|
|
68
67
|
}
|
|
@@ -666,7 +665,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
666
665
|
<div className="dshr-linkList">
|
|
667
666
|
{orderedAccounts.map((account, index) => {
|
|
668
667
|
const badge = statusBadge(account)
|
|
669
|
-
const coolingText = account.cooling
|
|
668
|
+
const coolingText = account.cooling
|
|
670
669
|
? `${account.reason ?? ''}${untilLabel(account) !== '' ? ` · ${untilLabel(account)}` : ''}`
|
|
671
670
|
: ''
|
|
672
671
|
return (
|
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 })
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 账号池(核心策略)测试 ——
|
|
2
|
+
* 账号池(核心策略)测试 —— 选号/冷却/遍历回退。
|
|
3
3
|
*
|
|
4
4
|
* 关键语义(v2):
|
|
5
5
|
* - 冷却单元 = (supplierId, modelId, uid)。某连接在模型 A 上失败,只冷
|
|
6
6
|
* (A, 该连接),该连接调模型 B 不受影响。
|
|
7
|
-
* -
|
|
7
|
+
* - session_dead = 连接级(uid)可恢复冷却:该号所有模型都不可用,到期自愈。
|
|
8
|
+
* **没有永久禁用**——403 分不清凭证死活与风控误伤,误伤号不该被钉死。
|
|
8
9
|
* - no_such_model 不惩罚账号(模型不属于本供应商不是号的错)。
|
|
9
10
|
* 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
|
|
10
11
|
*/
|
|
@@ -327,16 +328,18 @@ test('【颗粒度】同一连接对不同模型的退避独立累加/清零', (
|
|
|
327
328
|
assert.equal(p.decorate(list, 'm1')[0]?.err_count, 0)
|
|
328
329
|
})
|
|
329
330
|
|
|
330
|
-
// ============
|
|
331
|
+
// ============ 连接级冷却(跨模型, session_dead) ============
|
|
331
332
|
|
|
332
|
-
test('【颗粒度】session_dead
|
|
333
|
+
test('【颗粒度】session_dead 是连接级冷却:跨该号所有模型,到期自愈', () => {
|
|
333
334
|
const p = pool()
|
|
334
335
|
const list = accs(['a'])
|
|
335
336
|
p.noteFailure('a', 'm1', 'session_dead', '凭证失效')
|
|
336
337
|
// 该号在 m1、m2 上都不可用(登录态问题,不是模型问题)
|
|
337
338
|
assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined)
|
|
338
339
|
assert.equal(p.pick(list, [], 'fallback', 'm2'), undefined)
|
|
339
|
-
|
|
340
|
+
const d = p.decorate(list)[0]
|
|
341
|
+
assert.equal(d?.cooling, true)
|
|
342
|
+
assert.ok(d?.until !== undefined, 'session_dead 冷却必须有 until(可恢复)')
|
|
340
343
|
})
|
|
341
344
|
|
|
342
345
|
test('处置:未知错误第一次就冷却(坏号不留在池里等下一次撞)', () => {
|
|
@@ -347,7 +350,7 @@ test('处置:未知错误第一次就冷却(坏号不留在池里等下一
|
|
|
347
350
|
assert.equal(p.pick(list.filter((x) => x.uid === 'a'), [], 'fallback', 'm1'), undefined)
|
|
348
351
|
})
|
|
349
352
|
|
|
350
|
-
test('decorate
|
|
353
|
+
test('decorate:把冷却叠加到插件报的「现在状态」上(聚合展示)', () => {
|
|
351
354
|
const p = pool()
|
|
352
355
|
const list: SupplierAccountNow[] = [
|
|
353
356
|
{ uid: 'ok', credits: 5, state: 'ok' },
|
|
@@ -359,10 +362,9 @@ test('decorate:把冷却/禁用叠加到插件报的「现在状态」上(
|
|
|
359
362
|
const out = p.decorate(list)
|
|
360
363
|
const by = new Map(out.map((a) => [a.uid, a]))
|
|
361
364
|
assert.equal(by.get('ok')?.cooling, false)
|
|
362
|
-
assert.equal(by.get('ok')?.disabled, false)
|
|
363
365
|
assert.equal(by.get('cool')?.cooling, true)
|
|
364
366
|
assert.equal(by.get('cool')?.until !== undefined, true)
|
|
365
|
-
assert.equal(by.get('dead')?.
|
|
367
|
+
assert.equal(by.get('dead')?.cooling, true, 'session_dead 连接级冷却也展示为冷却中')
|
|
366
368
|
// 插件报的字段要原样透出(不能被 decorate 吃掉)
|
|
367
369
|
assert.equal(by.get('ok')?.credits, 5)
|
|
368
370
|
})
|
|
@@ -375,7 +377,6 @@ test('no_such_model 不惩罚账号:不冷却、不计数', () => {
|
|
|
375
377
|
assert.equal(p.pick(list, [], 'fallback', 'm1'), 'a')
|
|
376
378
|
const out = p.decorate(list, 'm1')[0]
|
|
377
379
|
assert.equal(out?.cooling, false)
|
|
378
|
-
assert.equal(out?.disabled, false)
|
|
379
380
|
assert.equal(out?.err_count, 0)
|
|
380
381
|
})
|
|
381
382
|
|
|
@@ -389,7 +390,7 @@ test('状态表:各 AccountState 的处置符合预期', () => {
|
|
|
389
390
|
const by = new Map(p.decorate(list).map((a) => [a.uid, a]))
|
|
390
391
|
assert.equal(by.get('rate')?.cooling, true)
|
|
391
392
|
assert.equal(by.get('quota')?.cooling, true)
|
|
392
|
-
assert.equal(by.get('dead')?.
|
|
393
|
+
assert.equal(by.get('dead')?.cooling, true, 'session_dead = 连接级冷却')
|
|
393
394
|
assert.equal(by.get('unavail')?.cooling, true, '瞬时错误第一次就冷却')
|
|
394
395
|
assert.equal(p.pick(list, [], 'fallback', 'm1'), undefined, '四个号都不可服务')
|
|
395
396
|
})
|