dsh-router-core 0.3.20 → 0.4.0
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 +77 -34
- package/docs/pool-sticky-block.md +158 -0
- package/lib/client-registry.js +351 -407
- package/lib/client.js +351 -407
- package/lib/index.js +398 -176
- package/lib/types/ext/contract.d.ts +0 -7
- package/lib/types/host-version.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/router/account-pool.d.ts +53 -18
- package/lib/types/router/index.d.ts +4 -7
- package/lib/types/router/prefix-affinity.d.ts +18 -1
- package/lib/types/router/types.d.ts +7 -4
- package/lib/types/router/usage-store.d.ts +12 -0
- package/lib/types/shared.d.ts +13 -21
- package/lib/types/supplier-config.d.ts +0 -3
- package/lib/types/suppliers/contract.d.ts +2 -2
- package/lib/types/suppliers/registry.d.ts +0 -1
- package/package.json +12 -13
- package/src/client/CombosTab.tsx +8 -53
- package/src/client/ExtDetail.tsx +15 -178
- package/src/client/LastHitDock.tsx +141 -0
- package/src/client/StatsTab.tsx +8 -6
- package/src/client/SupplierDetail.tsx +2 -34
- package/src/client/index.tsx +45 -0
- package/src/client/last-hit-store.ts +89 -0
- package/src/client/model-hint-copy.test.ts +7 -2
- package/src/client/model-hint-copy.ts +2 -2
- package/src/client/router.css +2106 -1630
- package/src/ext/contract.ts +0 -7
- package/src/host-version.test.ts +61 -0
- package/src/host-version.ts +101 -0
- package/src/index.ts +133 -59
- package/src/llm/adapter.test.ts +128 -1
- package/src/llm/adapter.ts +48 -3
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/disconnect-leak.test.ts +50 -0
- package/src/router/index.ts +38 -31
- package/src/router/models-cache.test.ts +9 -9
- package/src/router/precise-dispatch.test.ts +6 -6
- package/src/router/prefix-affinity.ts +22 -1
- package/src/router/types.ts +7 -5
- package/src/router/usage-store.ts +16 -0
- package/src/router/usage.test.ts +1 -1
- package/src/shared.ts +13 -24
- package/src/supplier-config.ts +2 -13
- package/src/suppliers/contract.ts +2 -2
- package/src/suppliers/loader.test.ts +2 -2
- package/src/suppliers/registry.ts +1 -20
- package/src/types.d.ts +2 -1
package/src/ext/contract.ts
CHANGED
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
* file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
|
|
26
26
|
*/
|
|
27
27
|
import type { Context } from '@deepseek-ai/cordis'
|
|
28
|
-
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
29
28
|
|
|
30
29
|
/** 扩展器唯一 id(注册键)。 */
|
|
31
30
|
export type ExtId = string
|
|
@@ -58,12 +57,6 @@ export interface RouterExt {
|
|
|
58
57
|
readonly icon?: string
|
|
59
58
|
/** 当前运行时状态(ready + 不就绪时的说明)。 */
|
|
60
59
|
getState(): ExtState
|
|
61
|
-
/**
|
|
62
|
-
* 通用扩展 API 点(可选):核心把 `/router/api/ext/{id}/{subpath}` 兜底路由派发进来,
|
|
63
|
-
* 由扩展器自己裁决/处理(如 watch 的 `processes`/`kill`)。返回 true 表示已处理;
|
|
64
|
-
* false / 未实现 → 核心回 404。这样扩展的执行面逻辑进插件,核心只加一条通用转发。
|
|
65
|
-
*/
|
|
66
|
-
api?(subpath: string, req: IncomingMessage, res: ServerResponse): boolean | Promise<boolean>
|
|
67
60
|
/** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
|
|
68
61
|
dispose?(): void
|
|
69
62
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主版本探测测试 —— 锁死「最近命中徽章仅在 >= 0.1.7 出现」这条判定。
|
|
3
|
+
*
|
|
4
|
+
* 起因:`conversation.composer.dock` 座位在 0.1.5 也在,但渲染在 InputBar 之后
|
|
5
|
+
* 的独立块(位置不对);只有 0.1.7 才放进底部操作行。座位名相同没法靠契约区分,
|
|
6
|
+
* 只能按宿主版本号判定——本测试就是这条判据的唯一守卫。
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { pathToFileURL } from 'node:url'
|
|
14
|
+
import { detectHostVersion, isHost017Plus, supportsLastHitDock } from './host-version.ts'
|
|
15
|
+
|
|
16
|
+
/** 造一个「装了指定版本 dsh」的临时 profile,返回它的 file: URL。 */
|
|
17
|
+
function profileWithDsh(version: string | null): string {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), 'dshr-hostver-'))
|
|
19
|
+
if (version !== null) {
|
|
20
|
+
const pkgDir = join(dir, 'node_modules', '@deepseek-ai', 'dsh')
|
|
21
|
+
mkdirSync(pkgDir, { recursive: true })
|
|
22
|
+
writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name: 'dsh', version }))
|
|
23
|
+
}
|
|
24
|
+
return pathToFileURL(dir).href + '/'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('读得到版本:detectHostVersion 返回 profile 里装的 dsh 版本', () => {
|
|
28
|
+
assert.equal(detectHostVersion(profileWithDsh('0.1.5-rc.3')), '0.1.5-rc.3')
|
|
29
|
+
assert.equal(detectHostVersion(profileWithDsh('0.1.7-alpha.2')), '0.1.7-alpha.2')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('支持位:>= 0.1.7 才为真(预发布标识忽略,按前导数字比)', () => {
|
|
33
|
+
assert.equal(supportsLastHitDock(profileWithDsh('0.1.7-alpha.2')), true)
|
|
34
|
+
assert.equal(supportsLastHitDock(profileWithDsh('0.1.7')), true)
|
|
35
|
+
assert.equal(supportsLastHitDock(profileWithDsh('0.2.0')), true)
|
|
36
|
+
assert.equal(supportsLastHitDock(profileWithDsh('1.0.0')), true)
|
|
37
|
+
// 低于 0.1.7 → 不挂(0.1.5 位置不对,宁可不显示)
|
|
38
|
+
assert.equal(supportsLastHitDock(profileWithDsh('0.1.5-rc.3')), false)
|
|
39
|
+
assert.equal(supportsLastHitDock(profileWithDsh('0.1.6-alpha.2')), false)
|
|
40
|
+
assert.equal(supportsLastHitDock(profileWithDsh('0.1.4')), false)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('isHost017Plus 与徽章支持位同源(三处兼容分叉共用一个判据)', () => {
|
|
44
|
+
for (const v of ['0.1.7-alpha.2', '0.1.7', '0.2.0', '1.0.0']) {
|
|
45
|
+
assert.equal(isHost017Plus(profileWithDsh(v)), true, v)
|
|
46
|
+
assert.equal(supportsLastHitDock(profileWithDsh(v)), true, v)
|
|
47
|
+
}
|
|
48
|
+
for (const v of ['0.1.5-rc.3', '0.1.6-alpha.2', '0.1.4']) {
|
|
49
|
+
assert.equal(isHost017Plus(profileWithDsh(v)), false, v)
|
|
50
|
+
assert.equal(supportsLastHitDock(profileWithDsh(v)), false, v)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('读不到版本 → 一律当不支持(不抛、宁可不显示)', () => {
|
|
55
|
+
assert.equal(detectHostVersion(profileWithDsh(null)), undefined)
|
|
56
|
+
assert.equal(supportsLastHitDock(profileWithDsh(null)), false)
|
|
57
|
+
// 垃圾版本号
|
|
58
|
+
assert.equal(supportsLastHitDock(profileWithDsh('not-a-version')), false)
|
|
59
|
+
// baseUrl 缺失(冷启动/单测)→ 回落 ~/.dsh/profiles/web,不抛
|
|
60
|
+
assert.doesNotThrow(() => supportsLastHitDock(undefined))
|
|
61
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主 DSH 版本探测 —— **0.1.5 / 0.1.7 兼容分叉的唯一判据来源**。
|
|
3
|
+
*
|
|
4
|
+
* 原先三处兼容都靠「能力检测」(看方法/属性在不在),语义上是同一个问题
|
|
5
|
+
* ——「这是 0.1.7 还是更老」——却各检各的、还容易误判(插件自己 node_modules
|
|
6
|
+
* 里的依赖版本与宿主实际加载的版本并不一致:实测插件解析到 schemastery 3.18.4,
|
|
7
|
+
* profile 里却是 3.18.3、全局是 3.18.2)。所以统一改成**读宿主版本号**判定:
|
|
8
|
+
*
|
|
9
|
+
* 1. 「最近命中」徽章:composer.dock 在 0.1.5 渲染位置不对 → >= 0.1.7 才挂。
|
|
10
|
+
* 2. 设置命名空间:0.1.5 用 `settings.installSection` 注册;0.1.7 改由导出的
|
|
11
|
+
* Config 驱动(无此方法)→ 按版本走对应注册路径。
|
|
12
|
+
* 3. adapter 的 tool-result 消息形态:0.1.5/0.1.6 = user 里的 'tool-result'
|
|
13
|
+
* 块;0.1.7+ = 独立 role:'tool' 消息。
|
|
14
|
+
*
|
|
15
|
+
* 读法:宿主把 profile 目录挂在 `ctx.baseUrl`(见 data-dir.ts)。本插件跑在宿主
|
|
16
|
+
* 进程内,`require.resolve('@deepseek-ai/dsh/package.json', { paths: [profile] })`
|
|
17
|
+
* 会沿 profile 的 node_modules 向上解析到全局安装根(实测命中 bun 全局目录)。
|
|
18
|
+
* 拿不到就回落直接拼路径,再不行就返回 undefined(按「不支持/老版本」处理,不抛)。
|
|
19
|
+
*
|
|
20
|
+
* 注意:**只能在 `apply(ctx)` 里用**(那时才有 baseUrl)。模块顶层(如 Config 导出)
|
|
21
|
+
* 解析到的是插件自己的依赖,不能代表宿主——那处保留能力检测,见 index.ts 注释。
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { createRequire } from 'node:module'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import { profileDirOf } from './data-dir.ts'
|
|
27
|
+
|
|
28
|
+
/** 各兼容分叉的版本门槛。 */
|
|
29
|
+
/** composer.dock 徽章 / 独立 role:'tool' 消息形态:0.1.7 起。 */
|
|
30
|
+
const V0_1_7: readonly number[] = [0, 1, 7]
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 解析并比较 semver(只取前导数字段,预发布标识忽略)。
|
|
34
|
+
* `0.1.7-alpha.2` → [0,1,7],`0.1.5-rc.3` → [0,1,5]。
|
|
35
|
+
* 返回负数/0/正数,语义同 `<`/`==`/`>`。
|
|
36
|
+
*/
|
|
37
|
+
function compareVersion(a: readonly number[], b: readonly number[]): number {
|
|
38
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
39
|
+
const x = a[i] ?? 0
|
|
40
|
+
const y = b[i] ?? 0
|
|
41
|
+
if (x !== y) return x - y
|
|
42
|
+
}
|
|
43
|
+
return 0
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `0.1.7-alpha.2` → [0,1,7];解析失败返回 null。 */
|
|
47
|
+
function parseVersion(raw: string): number[] | null {
|
|
48
|
+
const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(raw.trim())
|
|
49
|
+
if (m === null) return null
|
|
50
|
+
return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 探测 profile 里安装的 DSH 宿主版本。
|
|
55
|
+
* @param baseUrl 宿主注入的 profile 锚点(`ctx.baseUrl`)
|
|
56
|
+
* @returns 形如 `0.1.7-alpha.2`;读不到返回 undefined(调用方按「不支持」处理)
|
|
57
|
+
*/
|
|
58
|
+
export function detectHostVersion(baseUrl?: string): string | undefined {
|
|
59
|
+
const profile = profileDirOf(baseUrl)
|
|
60
|
+
// 先走模块解析(能沿 node_modules 向上找到全局安装根),再回落直接拼路径。
|
|
61
|
+
const candidates: string[] = []
|
|
62
|
+
try {
|
|
63
|
+
candidates.push(createRequire(join(profile, 'noop.js')).resolve('@deepseek-ai/dsh/package.json'))
|
|
64
|
+
} catch {
|
|
65
|
+
// 解析不到就走下面的直接路径
|
|
66
|
+
}
|
|
67
|
+
candidates.push(join(profile, 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))
|
|
68
|
+
for (const file of candidates) {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }
|
|
71
|
+
if (typeof parsed.version === 'string' && parsed.version !== '') return parsed.version
|
|
72
|
+
} catch {
|
|
73
|
+
// 换下一个候选
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 宿主版本是否 >= 门槛。读不到版本 → false(按老版本处理)。 */
|
|
80
|
+
function atLeast(baseUrl: string | undefined, min: readonly number[]): boolean {
|
|
81
|
+
const v = detectHostVersion(baseUrl)
|
|
82
|
+
if (v === undefined) return false
|
|
83
|
+
const parsed = parseVersion(v)
|
|
84
|
+
if (parsed === null) return false
|
|
85
|
+
return compareVersion(parsed, min) >= 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 宿主是否 >= 0.1.7。三处兼容分叉共用这一个判据:
|
|
90
|
+
* - 「最近命中」徽章(composer.dock 位置正确)
|
|
91
|
+
* - 设置命名空间(Config 驱动而非 installSection)
|
|
92
|
+
* - adapter 的独立 role:'tool' 消息形态
|
|
93
|
+
*/
|
|
94
|
+
export function isHost017Plus(baseUrl?: string): boolean {
|
|
95
|
+
return atLeast(baseUrl, V0_1_7)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 「最近命中」徽章是否可用(= 宿主 >= 0.1.7)。 */
|
|
99
|
+
export function supportsLastHitDock(baseUrl?: string): boolean {
|
|
100
|
+
return isHost017Plus(baseUrl)
|
|
101
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,7 @@ import type { SupplierEnv, SupplierModule } from './suppliers/contract.ts'
|
|
|
42
42
|
import { SupplierConfigStore } from './supplier-config.ts'
|
|
43
43
|
import { CredentialStore } from './credential-store.ts'
|
|
44
44
|
import { dataDirOf, profileDirOf } from './data-dir.ts'
|
|
45
|
+
import { detectHostVersion, isHost017Plus, supportsLastHitDock } from './host-version.ts'
|
|
45
46
|
import { ExtStore } from './ext/store.ts'
|
|
46
47
|
import type { ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
|
|
47
48
|
|
|
@@ -54,6 +55,34 @@ export const name = 'dsh-router-core'
|
|
|
54
55
|
/** Services required before mounting: the webserver (routes) + llm (设置-模型). */
|
|
55
56
|
export const inject = ['webServer', 'llm']
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* `Config` 导出:让 Router 卡片进 0.1.7 的设置镜像。
|
|
60
|
+
*
|
|
61
|
+
* **这处刻意保留能力检测,不用宿主版本号** —— 与另两处兼容点(installSection /
|
|
62
|
+
* adapter 消息形态)不同。原因(已实测):这是**模块顶层导出**,loader 一 import
|
|
63
|
+
* 就求值;而 schemastery 是本插件的**普通依赖**(`^3.18.4`,见 package.json),
|
|
64
|
+
* 解析到的是**插件自己 node_modules 里那一份**,跟宿主实际加载的版本无关
|
|
65
|
+
* (实测:插件解析到 3.18.4,而 profile 里是 3.18.3、全局是 3.18.2)。既然这里
|
|
66
|
+
* 拿不到宿主版本,就只剩「这份 Schema 实例到底有没有 `.volatile`」这个真问题 ——
|
|
67
|
+
* 能力检测恰好就是**对**的判据。
|
|
68
|
+
*
|
|
69
|
+
* 定位说明(非兼容分叉):`.volatile()` 是 schemastery 3.18.4 才有的原型方法;
|
|
70
|
+
* 老版本上直写 `Schema.object({}).volatile()` 会 `.volatile` 为 undefined →
|
|
71
|
+
* 抛 TypeError → **整个插件加载失败**(不只是卡片,是 dsh-router 完全挂掉)。
|
|
72
|
+
* - 有 volatile:走 volatile,空 object 不被 volatileForm 过滤 → 卡片进镜像;
|
|
73
|
+
* - 无 volatile:回落普通空 schema,不抛;此时宿主必是 0.1.5,本就靠
|
|
74
|
+
* installSection 出卡片(见下方按版本分流的注册),这个 Config 无副作用。
|
|
75
|
+
*/
|
|
76
|
+
const emptySchema = Schema.object({})
|
|
77
|
+
const hasVolatile = typeof (emptySchema as { volatile?: unknown }).volatile === 'function'
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 0.1.7 的设置镜像只认「插件导出的 Config + 配置树行 id」这条收录链,且空 object
|
|
81
|
+
* 会被 volatileForm 过滤掉 —— 顶层 `.volatile()` 才能让 Router 卡片出现在设置-模型。
|
|
82
|
+
* Router 的真配置在自己的 state.json(路由系统面板),这里故意不暴露任何字段。
|
|
83
|
+
*/
|
|
84
|
+
export const Config = hasVolatile ? emptySchema.volatile() : emptySchema
|
|
85
|
+
|
|
57
86
|
/** Minimal shape of the webServer service face used here. */
|
|
58
87
|
interface WebServerRoute {
|
|
59
88
|
kind: 'prefix' | 'exact'
|
|
@@ -96,12 +125,12 @@ interface Llm {
|
|
|
96
125
|
}
|
|
97
126
|
|
|
98
127
|
/**
|
|
99
|
-
* 设置服务(`ctx.settings`,dsh-settings
|
|
100
|
-
*
|
|
101
|
-
*
|
|
128
|
+
* 设置服务(`ctx.settings`,dsh-settings)的最小切面。0.1.5 用 `installSection`
|
|
129
|
+
* 注册命名空间;0.1.7 已移除该方法、改由插件导出的 Config 驱动 —— 故声明为可选,
|
|
130
|
+
* 调用处做能力检测(不是版本分支)。dsh-settings 是宿主注入的 peer service,不深绑类型。
|
|
102
131
|
*/
|
|
103
132
|
interface SettingsServiceFace {
|
|
104
|
-
installSection
|
|
133
|
+
installSection?: (
|
|
105
134
|
owner: CordisContext,
|
|
106
135
|
ns: string,
|
|
107
136
|
schema: unknown,
|
|
@@ -156,6 +185,8 @@ export function apply(rawContext: unknown): void {
|
|
|
156
185
|
logger: { info: (message: string) => void; warn: (message: string) => void }
|
|
157
186
|
effect: (fn: () => () => void, label?: string) => void
|
|
158
187
|
inject: (services: string[], callback: (sctx: unknown) => void) => void
|
|
188
|
+
/** cordis-plugin-loader 挂在 fiber 上的配置树锚点(tests 等环境可能没有)。 */
|
|
189
|
+
fiber?: { entry?: { options?: { id?: string } } }
|
|
159
190
|
/** 配置树锚点 = profile 目录(宿主注入),用来把数据钉在 profile 里。 */
|
|
160
191
|
baseUrl?: string
|
|
161
192
|
}
|
|
@@ -166,6 +197,12 @@ export function apply(rawContext: unknown): void {
|
|
|
166
197
|
const dataDir = dataDirOf(ctx.baseUrl)
|
|
167
198
|
const stateFile = join(dataDir, 'state.json')
|
|
168
199
|
log(`data dir: ${dataDir}`)
|
|
200
|
+
// 宿主版本探测:三处 0.1.5/0.1.7 兼容分叉共用这一个判据(徽章 / 设置命名空间 /
|
|
201
|
+
// adapter 的 tool-result 消息形态)。探测失败按老版本处理,不影响其它功能。
|
|
202
|
+
const hostVersion = detectHostVersion(ctx.baseUrl)
|
|
203
|
+
const host017Plus = isHost017Plus(ctx.baseUrl)
|
|
204
|
+
const lastHitDock = supportsLastHitDock(ctx.baseUrl)
|
|
205
|
+
log(`host version: ${hostVersion ?? '未知'} (>=0.1.7: ${host017Plus ? 'yes' : 'no'}, last-hit dock: ${lastHitDock ? 'on' : 'off'})`)
|
|
169
206
|
const store = new SupplierConfigStore(stateFile)
|
|
170
207
|
const credentials = new CredentialStore(join(dataDir, 'auths'))
|
|
171
208
|
const router = new Router(stateFile, store, log)
|
|
@@ -323,8 +360,17 @@ export function apply(rawContext: unknown): void {
|
|
|
323
360
|
const lv = typeof peek.reasoning_effort === 'string' && peek.reasoning_effort !== ''
|
|
324
361
|
? peek.reasoning_effort
|
|
325
362
|
: 'auto'
|
|
363
|
+
// 宿主会话身份(adapter 从 GenerateOptions.sessionId 转来的内部头);外部客户端没有。
|
|
364
|
+
const sessionRaw = req.headers['x-dsh-router-session']
|
|
365
|
+
const session = typeof sessionRaw === 'string' && sessionRaw !== '' ? sessionRaw : undefined
|
|
326
366
|
await router.chatCompletions(
|
|
327
|
-
{
|
|
367
|
+
{
|
|
368
|
+
rawBody: body,
|
|
369
|
+
stream: !!peek.stream,
|
|
370
|
+
model: typeof peek.model === 'string' ? peek.model : '',
|
|
371
|
+
lv,
|
|
372
|
+
...(session === undefined ? {} : { session }),
|
|
373
|
+
},
|
|
328
374
|
res,
|
|
329
375
|
)
|
|
330
376
|
}))
|
|
@@ -335,6 +381,10 @@ export function apply(rawContext: unknown): void {
|
|
|
335
381
|
const { suppliers } = router.status()
|
|
336
382
|
writeJson(res, 200, {
|
|
337
383
|
ok: true,
|
|
384
|
+
// 宿主版本 + 徽章支持位:客户端据此决定是否挂「最近命中」控件
|
|
385
|
+
// (composer.dock 在 0.1.5 渲染位置不对,只有 >= 0.1.7 才挂)。
|
|
386
|
+
hostVersion: hostVersion ?? '',
|
|
387
|
+
lastHitDock: lastHitDock,
|
|
338
388
|
suppliers: suppliers.map((s) => {
|
|
339
389
|
const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id)
|
|
340
390
|
return {
|
|
@@ -374,6 +424,38 @@ export function apply(rawContext: unknown): void {
|
|
|
374
424
|
writeJson(res, 200, { ok: true })
|
|
375
425
|
})
|
|
376
426
|
|
|
427
|
+
route(`${ROUTER_API_BASE}/last-hit`, (req, res) => {
|
|
428
|
+
// 最近一次被服务的请求:给输入框旁的小指示器用。
|
|
429
|
+
// `?session=` 给定时只在该会话内找(当前会话视角);不给则退回全局最近。
|
|
430
|
+
// 附上连接显示别名与积分 —— 前端只读这一个端点,不打 status(重)。
|
|
431
|
+
const session = new URL(req.url ?? '/', 'http://localhost').searchParams.get('session') ?? undefined
|
|
432
|
+
const last = router.usage.lastHit(session)
|
|
433
|
+
if (last === undefined) {
|
|
434
|
+
writeJson(res, 200, { ok: true, hit: null })
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
const { suppliers } = router.status()
|
|
438
|
+
const owner = suppliers.find((s) => s.id === last.supplier)
|
|
439
|
+
const acct = owner?.accounts.find((a) => a.uid === last.uid)
|
|
440
|
+
// 连接名:**优先直接查 accountNames**(面板里配的显示别名),不依赖
|
|
441
|
+
// status 列表里有没有这个号 —— 号未上报/未加载时 status 里就没有它,
|
|
442
|
+
// 只靠 acct 会回落成 uid(显示成 cb-12 而不是 k8661)。
|
|
443
|
+
const named = last.uid === undefined || last.uid === '' ? undefined : store.getAccountName(last.supplier, last.uid)
|
|
444
|
+
writeJson(res, 200, {
|
|
445
|
+
ok: true,
|
|
446
|
+
hit: {
|
|
447
|
+
supplier: last.supplier,
|
|
448
|
+
model: last.model,
|
|
449
|
+
requested: last.requested,
|
|
450
|
+
uid: last.uid,
|
|
451
|
+
account: named ?? acct?.nickname ?? (last.uid === undefined || last.uid === '' ? undefined : last.uid),
|
|
452
|
+
credits: acct?.credits,
|
|
453
|
+
ok: last.ok,
|
|
454
|
+
ts: last.ts,
|
|
455
|
+
},
|
|
456
|
+
})
|
|
457
|
+
})
|
|
458
|
+
|
|
377
459
|
route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
|
|
378
460
|
try {
|
|
379
461
|
const models = await router.listModels()
|
|
@@ -392,26 +474,26 @@ export function apply(rawContext: unknown): void {
|
|
|
392
474
|
})
|
|
393
475
|
|
|
394
476
|
route(`${ROUTER_API_BASE}/combos/create`, async (req, res) => {
|
|
395
|
-
let body: { name?: string;
|
|
477
|
+
let body: { name?: string; models?: string[] }
|
|
396
478
|
try {
|
|
397
|
-
body = JSON.parse(await readBody(req, 64 << 10)) as { name?: string;
|
|
479
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { name?: string; models?: string[] }
|
|
398
480
|
} catch {
|
|
399
481
|
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
400
482
|
return
|
|
401
483
|
}
|
|
402
|
-
const result = router.createCombo(body.name ?? '', body.
|
|
484
|
+
const result = router.createCombo(body.name ?? '', body.models ?? [])
|
|
403
485
|
writeJson(res, result.ok ? 200 : 400, result)
|
|
404
486
|
})
|
|
405
487
|
|
|
406
488
|
route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
|
|
407
|
-
let body: { id?: string; name?: string;
|
|
489
|
+
let body: { id?: string; name?: string; models?: string[] }
|
|
408
490
|
try {
|
|
409
|
-
body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string; name?: string;
|
|
491
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string; name?: string; models?: string[] }
|
|
410
492
|
} catch {
|
|
411
493
|
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
412
494
|
return
|
|
413
495
|
}
|
|
414
|
-
const result = router.updateCombo(body.id ?? '', body.name ?? '', body.
|
|
496
|
+
const result = router.updateCombo(body.id ?? '', body.name ?? '', body.models ?? [])
|
|
415
497
|
writeJson(res, result.ok ? 200 : 400, result)
|
|
416
498
|
})
|
|
417
499
|
|
|
@@ -535,34 +617,6 @@ export function apply(rawContext: unknown): void {
|
|
|
535
617
|
writeJson(res, 200, { ok: true, enhancers: list })
|
|
536
618
|
})
|
|
537
619
|
|
|
538
|
-
// ---- 扩展通用 API 转发:/router/api/ext/{id}/{subpath} → 该扩展器的 api() ----
|
|
539
|
-
// watch 这类自定义扩展用它暴露自己的面板接口(如 processes)。分开的 exact 路由
|
|
540
|
-
// `/ext`(上方)管开关列表;这里必须是 **prefix** 路由兜子路径(`route()` 是 exact,
|
|
541
|
-
// 匹配不了 `/ext/watch/processes`——用 exact 会造成 404 空 body → 前端 res.json() 崩)。
|
|
542
|
-
// 核心不知道子路径长什么样——是否处理、怎么处理全由扩展器自己裁决(执行面),核心只转发。
|
|
543
|
-
const extDispatch = ctx.webServer.register({
|
|
544
|
-
kind: 'prefix',
|
|
545
|
-
path: `${ROUTER_API_BASE}/ext/`,
|
|
546
|
-
handler: async (req, res) => {
|
|
547
|
-
const pathname = new URL(req.url ?? '/', 'http://localhost').pathname
|
|
548
|
-
const mount = `${ROUTER_API_BASE}/ext/`
|
|
549
|
-
const rest = pathname.startsWith(mount) ? pathname.slice(mount.length) : ''
|
|
550
|
-
const slash = rest.indexOf('/')
|
|
551
|
-
const extId = slash < 0 ? rest : rest.slice(0, slash)
|
|
552
|
-
const subpath = slash < 0 ? '' : rest.slice(slash + 1)
|
|
553
|
-
if (extId === '') {
|
|
554
|
-
writeJson(res, 404, { ok: false, error: 'extension not found' })
|
|
555
|
-
return
|
|
556
|
-
}
|
|
557
|
-
const ext = (exts[extId] as RouterExt | undefined)
|
|
558
|
-
if (ext !== undefined && typeof ext.api === 'function' && (await ext.api(subpath, req, res))) {
|
|
559
|
-
return // 扩展器已处理并写出响应
|
|
560
|
-
}
|
|
561
|
-
writeJson(res, 404, { ok: false, error: 'extension api not found' })
|
|
562
|
-
},
|
|
563
|
-
})
|
|
564
|
-
disposers.push(extDispatch)
|
|
565
|
-
|
|
566
620
|
// ---- 概览看板:用量统计 ----
|
|
567
621
|
|
|
568
622
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
@@ -590,7 +644,14 @@ export function apply(rawContext: unknown): void {
|
|
|
590
644
|
bySupplier: s.bySupplier,
|
|
591
645
|
byModel: s.byModel,
|
|
592
646
|
byRequested: s.byRequested,
|
|
593
|
-
|
|
647
|
+
// 「最近请求」的「连接」列要显示连接名(uid → 显示别名)。后端一次解析,
|
|
648
|
+
// 前端不必再拉 /status。无 uid(失败/未服务)→ 返回空串,前端显示「—」。
|
|
649
|
+
recent: router.usage.recentList(20).map((r) => ({
|
|
650
|
+
...r,
|
|
651
|
+
connection: r.uid === undefined || r.uid === ''
|
|
652
|
+
? ''
|
|
653
|
+
: store.getAccountName(r.supplier, r.uid) ?? r.uid,
|
|
654
|
+
})),
|
|
594
655
|
})
|
|
595
656
|
})
|
|
596
657
|
|
|
@@ -610,42 +671,55 @@ export function apply(rawContext: unknown): void {
|
|
|
610
671
|
|
|
611
672
|
// ---- 设置-模型:Router 提供方(固定卡片,插件注册,不可删)+ 模型目录(= 组合) ----
|
|
612
673
|
|
|
674
|
+
// settings ns = 配置树行 id(cordis.patch.yml insert 的 id)—— 0.1.7 设置镜像按
|
|
675
|
+
// entry.options.id 收录;loader 未挂 entry 时回落到同一默认值,两版一个 ns。
|
|
676
|
+
const settingsNs = ctx.fiber?.entry?.options?.id ?? 'dsh-router'
|
|
677
|
+
|
|
613
678
|
// llm 缺失时(如测试环境)跳过,不影响 /v1 核心。
|
|
614
679
|
if (ctx.llm !== undefined) {
|
|
615
|
-
const LLM_NS = 'llm-dsh-router'
|
|
616
680
|
try {
|
|
617
681
|
const providerReg = ctx.llm.registerConfigurableProviders([
|
|
618
682
|
{
|
|
619
683
|
provider: 'router',
|
|
620
684
|
displayName: 'Router',
|
|
621
|
-
settingsNs
|
|
685
|
+
settingsNs,
|
|
622
686
|
settingsPath: [],
|
|
623
687
|
},
|
|
624
688
|
])
|
|
625
689
|
disposers.push(providerReg)
|
|
626
|
-
disposers.push(ctx.llm.registerModelDiscovery(
|
|
690
|
+
disposers.push(ctx.llm.registerModelDiscovery(settingsNs, async () => {
|
|
627
691
|
const combos = await router.combos()
|
|
628
692
|
return combos.map((c) => ({ id: c.name }))
|
|
629
693
|
}))
|
|
630
694
|
// Router 的配置(组合/密钥/签到)存在 core 自己的 state.json,走插件自己的
|
|
631
|
-
// 「路由系统」面板;设置-模型
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
695
|
+
// 「路由系统」面板;设置-模型 这边必须让 settingsNs 在设置镜像里能解析出来
|
|
696
|
+
// (见 dsh-client-ui-settings-models 的 configurable 过滤),否则卡片不出现。
|
|
697
|
+
// 按宿主版本分流(不再靠 installSection 能力检测):
|
|
698
|
+
// 0.1.7+:镜像行来自顶部导出的 volatile Config(ns = 行 id),无需注册;
|
|
699
|
+
// 0.1.5 :镜像行来自 settings.installSection。
|
|
635
700
|
// 空 schema = 卡片是入口/占位(该布局下不可提交),真正的配置在路由系统面板。
|
|
636
701
|
const routerSettingsSchema = Schema.object({})
|
|
637
|
-
ctx.
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
702
|
+
if (isHost017Plus(ctx.baseUrl)) {
|
|
703
|
+
log(`settings section driven by exported Config (${settingsNs})`)
|
|
704
|
+
} else {
|
|
705
|
+
ctx.inject(['settings'], (sctx: unknown) => {
|
|
706
|
+
const settings = (sctx as { settings?: SettingsServiceFace }).settings
|
|
707
|
+
if (settings === undefined) {
|
|
708
|
+
log('settings service absent — skip Router settings-section registration')
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
if (settings.installSection === undefined) {
|
|
712
|
+
// 版本说该走 installSection 但它不在(异常宿主):只记日志,不抛。
|
|
713
|
+
log(`settings.installSection absent despite 0.1.5 host (${settingsNs})`)
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
settings.installSection(rawContext as CordisContext, settingsNs, routerSettingsSchema, {}, {
|
|
717
|
+
setSource: () => {},
|
|
718
|
+
onChange: () => {},
|
|
719
|
+
})
|
|
720
|
+
log(`Router settings section registered (${settingsNs})`)
|
|
646
721
|
})
|
|
647
|
-
|
|
648
|
-
})
|
|
722
|
+
}
|
|
649
723
|
// adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
|
|
650
724
|
// 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
|
|
651
725
|
// 图片序列化需要读附件字节:把 ctx.attachments 传给 adapter(dsh-attachment
|
|
@@ -708,7 +782,7 @@ export function apply(rawContext: unknown): void {
|
|
|
708
782
|
directory,
|
|
709
783
|
registeredProviders,
|
|
710
784
|
settingsNamespaces,
|
|
711
|
-
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(
|
|
785
|
+
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(settingsNs),
|
|
712
786
|
})
|
|
713
787
|
})
|
|
714
788
|
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { test } from 'node:test'
|
|
13
13
|
import assert from 'node:assert/strict'
|
|
14
|
-
import { EMPTY_RESPONSE_CODE } from '@deepseek-ai/dsh-llm'
|
|
14
|
+
import { AssistantStreamAccumulator, EMPTY_RESPONSE_CODE } from '@deepseek-ai/dsh-llm'
|
|
15
15
|
import { toTokenUsage } from '../router/usage-tokens.ts'
|
|
16
16
|
import { RouterAdapter, translateSse } from './adapter.ts'
|
|
17
17
|
|
|
@@ -191,6 +191,59 @@ async function toolCallsOf(...payloads: string[]): Promise<Array<{ name?: string
|
|
|
191
191
|
return out
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
test('工具调用:后续 delta 把 id/name 重发成 null 时,不能冲掉首帧也不许发 null', async () => {
|
|
195
|
+
// 真实事故(2026-09-22,OpenCode/Zen):上游首帧给 id+name,后续分片帧把
|
|
196
|
+
// 它们**显式重发成 null**(不是省略、不是空串)。用对象构造、交给 frame() 序列化,
|
|
197
|
+
// 免得在字符串字面量里跟引号打架。
|
|
198
|
+
const tcDelta = (tc: unknown): string => frame({ choices: [{ delta: { tool_calls: [tc] } }] })
|
|
199
|
+
const payloads = [
|
|
200
|
+
tcDelta({ index: 0, id: 'call_abc', type: 'function', function: { name: 'bash', arguments: '' } }),
|
|
201
|
+
tcDelta({ index: 0, id: null, type: 'function', function: { name: null, arguments: '{' } }),
|
|
202
|
+
tcDelta({ index: 0, id: null, type: 'function', function: { name: null, arguments: '"command": "ls"}' } }),
|
|
203
|
+
frame({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] }),
|
|
204
|
+
'[DONE]',
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
// 旧判据是 `!== undefined`,null 通不过它却被当成真值:既把 "bash" 冲成 null,
|
|
208
|
+
// 又把 `name: null` 发进 StreamChunk。而真实运行路径
|
|
209
|
+
// (dsh-llm 的 AssistantStreamAccumulator.push)判据是
|
|
210
|
+
// `Object.hasOwn(chunk,'name') && typeof chunk.name !== 'string'` → 抛
|
|
211
|
+
// `TypeError: tool-call-delta name must be a string`(用户看到的
|
|
212
|
+
// 「本轮运行失败 tool-call-delta name must be a string」)。
|
|
213
|
+
//
|
|
214
|
+
// 用**真实的那个累加器**验收,而不是自己复述规则:它就是运行时会抛的那一段。
|
|
215
|
+
const acc = new AssistantStreamAccumulator()
|
|
216
|
+
let n = 0
|
|
217
|
+
for await (const chunk of translateSse(payloads)) {
|
|
218
|
+
acc.push({ time: n++, chunk }) // 这里就是线上抛错的那一步
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 逐条再按原判据过一遍,失败时能直接指向违规的那条 delta
|
|
222
|
+
for await (const chunk of translateSse(payloads)) {
|
|
223
|
+
if (chunk.type !== 'tool-call-delta') continue
|
|
224
|
+
if (Object.hasOwn(chunk, 'name')) assert.equal(typeof chunk.name, 'string', 'name 一旦出现就必须是字符串')
|
|
225
|
+
assert.equal(typeof chunk.id, 'string', 'id 必须是字符串')
|
|
226
|
+
assert.equal(typeof chunk.argumentsDelta, 'string', 'argumentsDelta 必须是字符串')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 首帧给的 name/id 必须在后续 null 帧里**保持**
|
|
230
|
+
const deltas: Array<Record<string, unknown>> = []
|
|
231
|
+
for await (const chunk of translateSse(payloads)) {
|
|
232
|
+
if (chunk.type === 'tool-call-delta') deltas.push(chunk as unknown as Record<string, unknown>)
|
|
233
|
+
}
|
|
234
|
+
assert.ok(deltas.length >= 3, '三个分片帧都该产出 delta')
|
|
235
|
+
for (const d of deltas) {
|
|
236
|
+
assert.equal(d.name, 'bash', 'name 被后续 null 帧冲掉了(工具名会变成空)')
|
|
237
|
+
assert.equal(d.id, 'call_abc', 'id 被后续 null 帧冲掉了')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 收尾后的完整调用也要正确
|
|
241
|
+
const calls = await toolCallsOf(...payloads)
|
|
242
|
+
assert.equal(calls.length, 1)
|
|
243
|
+
assert.equal(calls[0]?.name, 'bash', '工具名必须保住')
|
|
244
|
+
assert.equal(calls[0]?.arguments, '{"command": "ls"}')
|
|
245
|
+
})
|
|
246
|
+
|
|
194
247
|
test('工具调用:参数分片发完才收尾,完整 JSON 原样保留', async () => {
|
|
195
248
|
const calls = await toolCallsOf(
|
|
196
249
|
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'bash', arguments: '' } }] } }] }),
|
|
@@ -426,6 +479,80 @@ test('wire:空工具结果发空串,不伪造 (no output) 字面量', async
|
|
|
426
479
|
assert.equal(tool?.content, '', '空结果就该是空串')
|
|
427
480
|
})
|
|
428
481
|
|
|
482
|
+
// 0.1.7+ 消息模型:tool 结果是**独立 role:"tool" 消息**、toolCallId 挂消息级,
|
|
483
|
+
// ContentBlockMap 已无 'tool-result' 块。必须原样发 role:"tool",否则工具结果被
|
|
484
|
+
// 序列化成 role:"user"、夹在 assistant(tool_calls) 与 tool 之间,上游判定失配 → 400
|
|
485
|
+
// code=11148。下面几条锁死新分支;老的 'tool-result' 块路径(≤0.1.6)仍有各自的用例。
|
|
486
|
+
test('wire:0.1.7+ 独立 role:"tool" 消息序列化为 role:"tool",正确带 tool_call_id', async () => {
|
|
487
|
+
const body = await captureBody({
|
|
488
|
+
messages: [
|
|
489
|
+
{ role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
|
|
490
|
+
{ role: 'tool', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] },
|
|
491
|
+
],
|
|
492
|
+
})
|
|
493
|
+
const messages = body.messages as Array<{ role: string; tool_call_id?: string; content: unknown }>
|
|
494
|
+
const tool = messages.find((m) => m.role === 'tool')
|
|
495
|
+
assert.ok(tool, '0.1.7 tool 消息必须原样发 role:"tool"')
|
|
496
|
+
assert.equal(tool.tool_call_id, 'c1', 'tool_call_id 取自消息级 toolCallId')
|
|
497
|
+
assert.equal(tool.content, 'out', '纯文本重组为字符串')
|
|
498
|
+
assert.equal(messages.filter((m) => m.role === 'user').length, 0, '不容许被误序列化成 user 消息')
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
test('wire:0.1.7+ 独立 role:"tool" 消息不带图时不在其后插 user(图)', async () => {
|
|
502
|
+
// 图片才攒批补 user(图);纯文本 tool 后面绝不能冒出 user,配对接续必须完整。
|
|
503
|
+
const body = await captureBody({
|
|
504
|
+
messages: [
|
|
505
|
+
{ role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'a', name: 'bash', arguments: '{}' }] },
|
|
506
|
+
{ role: 'tool', toolCallId: 'a', content: [{ type: 'text', text: 'x' }] },
|
|
507
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'done' }] },
|
|
508
|
+
],
|
|
509
|
+
})
|
|
510
|
+
const messages = body.messages as Array<{ role: string }>
|
|
511
|
+
// 没有任何位置允许 user 夹在 tool 与下一 assistant 之间。
|
|
512
|
+
for (let i = 1; i < messages.length; i++) {
|
|
513
|
+
const m = messages[i]
|
|
514
|
+
assert.ok(m, `第 ${i} 条缺失`)
|
|
515
|
+
assert.notEqual(m.role, 'user', `第 ${i} 条不应该是 user(tool 配对接续被插断)`)
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
test('wire:0.1.7+ 空 tool 结果发空串(不伪造字面量)', async () => {
|
|
520
|
+
const body = await captureBody({
|
|
521
|
+
messages: [{ role: 'tool', toolCallId: 'c2', content: [] }],
|
|
522
|
+
})
|
|
523
|
+
const tool = (body.messages as Array<{ role: string; content: string }>).find((m) => m.role === 'tool')
|
|
524
|
+
assert.equal(tool?.content, '', '空结果该是空串')
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* tool 结果的两种形态**都必须序列化成 role:'tool'**,且分派只看消息形状、与宿主版本无关。
|
|
529
|
+
*
|
|
530
|
+
* 背景(实测):同一宿主内两种形态都合法 —— 0.1.7 loop-built 走新形态
|
|
531
|
+
* (独立 role:'tool' 消息),而老会话历史经 session-format 迁移/重放时仍是旧形态
|
|
532
|
+
* (user 消息里的 'tool-result' 块)。所以 adapter 不能按版本号判形态对错,
|
|
533
|
+
* 只能按形状分派;这条锁死两种形状在**同一份 adapter**下产物一致。
|
|
534
|
+
*/
|
|
535
|
+
test('wire:tool-result 新/旧两种形态都序列化为 role:"tool"(按形状分派,与版本无关)', async () => {
|
|
536
|
+
const newShapeMsgs = [
|
|
537
|
+
{ role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
|
|
538
|
+
{ role: 'tool', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] },
|
|
539
|
+
]
|
|
540
|
+
const oldShapeMsgs = [
|
|
541
|
+
{ role: 'assistant', content: [{ type: 'text', text: '' }, { type: 'tool-call', id: 'c1', name: 'bash', arguments: '{}' }] },
|
|
542
|
+
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'out' }] }] },
|
|
543
|
+
]
|
|
544
|
+
// 新形态:原样发 role:'tool'
|
|
545
|
+
const n = await captureBody({ messages: newShapeMsgs })
|
|
546
|
+
const nt = (n.messages as Array<{ role: string; tool_call_id?: string }>).find((m) => m.role === 'tool')
|
|
547
|
+
assert.ok(nt, '新形态应发 role:"tool"')
|
|
548
|
+
assert.equal(nt.tool_call_id, 'c1')
|
|
549
|
+
// 旧形态:由 'tool-result' 块转出 role:'tool'(同样带 tool_call_id)
|
|
550
|
+
const o = await captureBody({ messages: oldShapeMsgs })
|
|
551
|
+
const ot = (o.messages as Array<{ role: string; tool_call_id?: string }>).find((m) => m.role === 'tool')
|
|
552
|
+
assert.ok(ot, '旧形态也应转出 role:"tool"')
|
|
553
|
+
assert.equal(ot.tool_call_id, 'c1')
|
|
554
|
+
})
|
|
555
|
+
|
|
429
556
|
/**
|
|
430
557
|
* 为什么要有这一条:只不发 `block-end` **并不能**拦住残缺调用。
|
|
431
558
|
*
|