dsh-router-core 0.3.21 → 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 +383 -170
- 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 +74 -0
- package/src/llm/adapter.ts +33 -0
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/index.ts +22 -28
- 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
|
@@ -479,6 +479,80 @@ test('wire:空工具结果发空串,不伪造 (no output) 字面量', async
|
|
|
479
479
|
assert.equal(tool?.content, '', '空结果就该是空串')
|
|
480
480
|
})
|
|
481
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
|
+
|
|
482
556
|
/**
|
|
483
557
|
* 为什么要有这一条:只不发 `block-end` **并不能**拦住残缺调用。
|
|
484
558
|
*
|
package/src/llm/adapter.ts
CHANGED
|
@@ -77,6 +77,10 @@ export interface RouterAdapterSource {
|
|
|
77
77
|
* 图片:`image` 块被序列化成 OpenAI `content` 数组里的 `image_url` part(data URI
|
|
78
78
|
* base64)。图片字节经 `resolveAttachments` 读取;读不到时回退为稳定的占位文本,
|
|
79
79
|
* **绝不静默丢图**(丢图 = 模型看到一条没有图的消息却毫无提示)。
|
|
80
|
+
*
|
|
81
|
+
* 工具结果双模型兼容:0.1.5/0.1.6 里 tool result 是 user 消息里的 'tool-result' 块
|
|
82
|
+
* (旧路径);0.1.7+ 改成了独立 role:'tool' 消息、toolCallId 挂消息级(新分支)。
|
|
83
|
+
* 两者在 wire 上统一序列化为 role:'tool',产物一致。见主循环里的 role==='tool' 分支。
|
|
80
84
|
*/
|
|
81
85
|
/** tool-result 里图片挂到 user 消息时用的说明文字(与官方适配器一致)。 */
|
|
82
86
|
const TOOL_RESULT_IMAGE_TEXT = 'Tool result images'
|
|
@@ -139,6 +143,32 @@ async function wireMessages(options: GenerateOptions, system: string | undefined
|
|
|
139
143
|
})
|
|
140
144
|
continue
|
|
141
145
|
}
|
|
146
|
+
// tool 结果的消息形态:**同一宿主内两种形态都合法**,按消息实际形状分派。
|
|
147
|
+
// 新形态:独立 role:'tool' 消息、toolCallId 挂消息级(0.1.7 loop-built);
|
|
148
|
+
// 旧形态:user 消息里的 'tool-result' 块(0.1.5/0.1.6,以及 0.1.7 下经
|
|
149
|
+
// session-format 迁移/重放的老会话历史——实测 0.1.7 仍会出现)。
|
|
150
|
+
// 所以**不能**用宿主版本号判断形态对错(0.1.7-alpha.2 也是 0.1.7,同样会出现
|
|
151
|
+
// 旧形态)——形状才是事实,按形状分派这一份 adapter 就同时吃两版。
|
|
152
|
+
const isToolRoleMessage = (message.role as string) === 'tool'
|
|
153
|
+
if (isToolRoleMessage) {
|
|
154
|
+
// tool 消息必须紧跟 assistant(tool_calls):中间插进任何 user 消息,上游都会判定
|
|
155
|
+
// tool_call 与 tool_result 失配 → codebuddy 直接 400 网关码 11148(bad_request
|
|
156
|
+
// 按核心设计不罚账号,面板上看不到异常,易误判成额度/风控)。
|
|
157
|
+
// 图片不能放进 tool 消息(实测模型会把两张不同图认成同一张),也不能插在两条 tool
|
|
158
|
+
// 之间(同样 400),所以先攒进 pending,等整串 tool 发完再补一条 user(图)。
|
|
159
|
+
const toolCallId = (message as { toolCallId?: unknown }).toolCallId
|
|
160
|
+
const parts = await contentParts(message.content, attachments, options.signal)
|
|
161
|
+
const imageParts = parts.filter((p) => p.type !== 'text')
|
|
162
|
+
const text = parts
|
|
163
|
+
.filter((p) => p.type === 'text')
|
|
164
|
+
.map((p) => (p as { text?: string }).text ?? '')
|
|
165
|
+
.join('')
|
|
166
|
+
// 空结果就发空串:不要替换成 '(no output)' 之类字面量——模型会以为工具真的打印了
|
|
167
|
+
// 那句话。上游接受 content:""(0.1.7 探测矩阵第 6 项确认)。
|
|
168
|
+
out.push({ role: 'tool', tool_call_id: String(toolCallId ?? ''), content: text })
|
|
169
|
+
pendingToolImages.push(...imageParts)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
142
172
|
// user / tool-result
|
|
143
173
|
const toolResults = message.content.filter((b) => b.type === 'tool-result')
|
|
144
174
|
// 这条消息自己的(非 tool-result 的)user 部分:文本 + 图片。
|
|
@@ -632,6 +662,9 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
632
662
|
...attributionHeaders(),
|
|
633
663
|
'content-type': 'application/json',
|
|
634
664
|
accept: 'text/event-stream',
|
|
665
|
+
// 宿主盖的会话身份转成内部头:核心据此把用量按会话归因,
|
|
666
|
+
// 供「最近命中」徽章按当前会话显示。宿主没给就不带(外部调用)。
|
|
667
|
+
...options.sessionId === undefined ? {} : { 'x-dsh-router-session': String(options.sessionId) },
|
|
635
668
|
},
|
|
636
669
|
body: JSON.stringify(body),
|
|
637
670
|
signal: controller.signal,
|