dsh-router-core 0.3.21 → 0.4.1
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 +238 -63
- package/cordis.patch.yml +22 -0
- package/docs/ext.md +28 -5
- package/docs/pool-sticky-block.md +158 -0
- package/docs/suppliers.md +28 -5
- package/lib/client-registry.js +1300 -549
- package/lib/client.js +1300 -549
- package/lib/ext-test/index.js +47 -0
- package/lib/ext-test/locale/en.json +6 -0
- package/lib/ext-test/locale/zh.json +6 -0
- package/lib/ext-test/package.json +5 -0
- package/lib/http-state-C1vb3gW2.js +48 -0
- package/lib/index.js +1169 -254
- package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
- package/lib/suppliers/nvidia/locale/en.json +6 -0
- package/lib/suppliers/nvidia/locale/zh.json +6 -0
- package/lib/suppliers/nvidia/package.json +5 -0
- package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
- package/lib/suppliers/opencode/locale/en.json +6 -0
- package/lib/suppliers/opencode/locale/zh.json +6 -0
- package/lib/suppliers/opencode/package.json +5 -0
- package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
- package/lib/suppliers/openrouter/locale/en.json +6 -0
- package/lib/suppliers/openrouter/locale/zh.json +6 -0
- package/lib/suppliers/openrouter/package.json +5 -0
- package/lib/types/ext/contract.d.ts +9 -7
- package/lib/types/ext-test/index.d.ts +21 -0
- package/lib/types/ext-test/plugin.d.ts +23 -0
- package/lib/types/host-version.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/llm/adapter.d.ts +35 -4
- package/lib/types/router/account-pool.d.ts +53 -18
- package/lib/types/router/index.d.ts +65 -15
- 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 +25 -21
- package/lib/types/supplier-config.d.ts +15 -3
- package/lib/types/suppliers/contract.d.ts +37 -4
- package/lib/types/suppliers/http-state.d.ts +23 -0
- package/lib/types/suppliers/loader.d.ts +6 -4
- package/lib/types/suppliers/nvidia/index.d.ts +18 -0
- package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
- package/lib/types/suppliers/opencode/index.d.ts +18 -0
- package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
- package/lib/types/suppliers/openrouter/index.d.ts +18 -0
- package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
- package/lib/types/suppliers/probe.d.ts +160 -0
- package/lib/types/suppliers/registry.d.ts +3 -2
- package/package.json +50 -16
- package/src/client/CombosTab.tsx +8 -53
- package/src/client/ExtDetail.tsx +32 -196
- package/src/client/ExtTab.tsx +25 -42
- package/src/client/ExtTestPanel.tsx +392 -0
- package/src/client/LastHitDock.tsx +141 -0
- package/src/client/RouterComponentsSection.tsx +241 -0
- package/src/client/RouterView.tsx +12 -5
- package/src/client/StatsTab.tsx +8 -6
- package/src/client/SupplierDetail.tsx +2 -34
- package/src/client/comp-button-scope.test.ts +58 -0
- package/src/client/comp-panel-layout.test.ts +58 -0
- package/src/client/env.d.ts +38 -1
- package/src/client/ext-panels.test.ts +44 -0
- package/src/client/ext-panels.ts +49 -0
- package/src/client/index.tsx +86 -0
- package/src/client/last-hit-store.ts +89 -0
- package/src/client/model-choice.test.ts +41 -0
- package/src/client/model-choice.ts +37 -0
- package/src/client/model-hint-copy.test.ts +7 -2
- package/src/client/model-hint-copy.ts +2 -2
- package/src/client/router-components.test.ts +114 -0
- package/src/client/router-components.ts +104 -0
- package/src/client/router.css +2592 -1635
- package/src/client/supplier-visibility.test.ts +57 -0
- package/src/client/supplier-visibility.ts +63 -0
- package/src/ext/contract.ts +9 -7
- package/src/ext-test/ext-test.test.ts +60 -0
- package/src/ext-test/index.ts +35 -0
- package/src/ext-test/locale/en.json +6 -0
- package/src/ext-test/locale/zh.json +6 -0
- package/src/ext-test/plugin.ts +34 -0
- package/src/ext-test/row.json +5 -0
- package/src/host-version.test.ts +61 -0
- package/src/host-version.ts +101 -0
- package/src/index.ts +165 -68
- package/src/llm/adapter.test.ts +175 -4
- package/src/llm/adapter.ts +109 -16
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/disconnect-leak.test.ts +7 -12
- package/src/router/first-byte-budget.test.ts +160 -0
- package/src/router/index.ts +276 -62
- package/src/router/models-cache.test.ts +54 -9
- package/src/router/precise-dispatch.test.ts +6 -6
- package/src/router/prefix-affinity.ts +22 -1
- package/src/router/supplier-switch.test.ts +220 -0
- 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 +25 -24
- package/src/supplier-config.ts +33 -13
- package/src/suppliers/contract.ts +56 -4
- package/src/suppliers/loader.test.ts +38 -4
- package/src/suppliers/loader.ts +33 -18
- package/src/suppliers/nvidia/index.ts +27 -0
- package/src/suppliers/nvidia/locale/en.json +6 -0
- package/src/suppliers/nvidia/locale/zh.json +6 -0
- package/src/suppliers/nvidia/row.json +5 -0
- package/src/suppliers/opencode/index.ts +27 -0
- package/src/suppliers/opencode/locale/en.json +6 -0
- package/src/suppliers/opencode/locale/zh.json +6 -0
- package/src/suppliers/opencode/row.json +5 -0
- package/src/suppliers/openrouter/index.ts +27 -0
- package/src/suppliers/openrouter/locale/en.json +6 -0
- package/src/suppliers/openrouter/locale/zh.json +6 -0
- package/src/suppliers/openrouter/row.json +5 -0
- package/src/suppliers/probe.test.ts +266 -0
- package/src/suppliers/probe.ts +475 -0
- package/src/suppliers/registry.ts +103 -23
- package/src/types.d.ts +2 -1
package/src/index.ts
CHANGED
|
@@ -36,12 +36,13 @@ import { ROUTER_API_BASE, type RouterPeriod } from './shared.ts'
|
|
|
36
36
|
import { Router } from './router/index.ts'
|
|
37
37
|
import { RouterAdapter, type RouterAttachmentStore } from './llm/adapter.ts'
|
|
38
38
|
import { KeysStore } from './keys.ts'
|
|
39
|
-
import { loadSuppliers, wrapModule, type LoadedSupplier } from './suppliers/loader.ts'
|
|
39
|
+
import { loadSuppliers, supplierWins, wrapModule, type LoadedSupplier } from './suppliers/loader.ts'
|
|
40
40
|
import { supplierRoutes } from './suppliers/registry.ts'
|
|
41
|
-
import type { SupplierEnv, SupplierModule } from './suppliers/contract.ts'
|
|
41
|
+
import type { SupplierEnv, SupplierModule, SupplierRegistry } 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)
|
|
@@ -180,7 +217,7 @@ export function apply(rawContext: unknown): void {
|
|
|
180
217
|
disposers.push(ctx.webServer.register({ kind: 'exact', path, handler }))
|
|
181
218
|
}
|
|
182
219
|
|
|
183
|
-
// ----
|
|
220
|
+
// ---- 供应商注册表:行(内置)+ 用户目录 js + 外部插件供应商 ----
|
|
184
221
|
const loadedSuppliers: LoadedSupplier[] = []
|
|
185
222
|
/** 每个供应商的 webServer 路由注销函数(注销时单独清理)。 */
|
|
186
223
|
const supplierDisposers = new Map<string, Array<() => void>>()
|
|
@@ -224,23 +261,38 @@ export function apply(rawContext: unknown): void {
|
|
|
224
261
|
|
|
225
262
|
// 内置 + 用户 + 外部插件供应商(异步加载,完成后注册路由 + 加入路由器)
|
|
226
263
|
const registerLoaded = (loaded: LoadedSupplier): void => {
|
|
264
|
+
// 同 id 已有供应商:只有「级别更高」的那个能顶掉它(用户目录 js > 内置行 >
|
|
265
|
+
// 外部插件)。级别不高不低就直接丢,别把先到的那份卸了又装回来。
|
|
266
|
+
const existing = loadedSuppliers.find((l) => l.supplier.id === loaded.supplier.id)
|
|
267
|
+
if (existing !== undefined) {
|
|
268
|
+
if (!supplierWins(loaded.source, existing.source)) {
|
|
269
|
+
log(`supplier ${loaded.supplier.id} (${loaded.source}) skipped: ${existing.source} already registered`)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
log(`supplier ${loaded.supplier.id}: ${existing.source} replaced by ${loaded.source}`)
|
|
273
|
+
unregisterSupplier(loaded.supplier.id)
|
|
274
|
+
}
|
|
227
275
|
loadedSuppliers.push(loaded)
|
|
228
276
|
router.add(loaded.supplier)
|
|
229
277
|
registerSupplierRoutes(loaded)
|
|
230
278
|
}
|
|
231
279
|
/** 当前由 router.suppliers service 加载的供应商 id(外部插件卸载时全部注销)。 */
|
|
232
280
|
let externalSupplierIds: string[] = []
|
|
233
|
-
const loadExternal = (suppliers:
|
|
281
|
+
const loadExternal = (suppliers: SupplierRegistry): void => {
|
|
234
282
|
for (const [sid, factory] of Object.entries(suppliers)) {
|
|
235
283
|
if (externalSupplierIds.includes(sid)) continue // 已加载(internal/service + inject 可能重复触发)
|
|
236
284
|
try {
|
|
237
285
|
const module = factory({ dataDir, log, store, credentials })
|
|
238
|
-
|
|
286
|
+
// 工厂上的 source 标签决定 `/health` 怎么报它:内置行标 'builtin',独立
|
|
287
|
+
// 安装的供应商插件不标(= external)。面板按这个值分「内置 / 插件」两组,
|
|
288
|
+
// 标错就把随核心分发的供应商混进插件组了。
|
|
289
|
+
const kind = factory.source === 'builtin' ? 'builtin' : 'external'
|
|
290
|
+
const loaded = wrapModule(module, { dataDir, log, store, credentials }, `service router.suppliers.${sid}`, kind)
|
|
239
291
|
registerLoaded(loaded)
|
|
240
292
|
externalSupplierIds.push(loaded.supplier.id)
|
|
241
|
-
log(
|
|
293
|
+
log(`${kind} supplier loaded: ${sid}`)
|
|
242
294
|
} catch (err) {
|
|
243
|
-
ctx.logger.warn(`[dsh-router]
|
|
295
|
+
ctx.logger.warn(`[dsh-router] supplier ${sid} load failed: ${(err as Error).message}`)
|
|
244
296
|
}
|
|
245
297
|
}
|
|
246
298
|
}
|
|
@@ -268,7 +320,6 @@ export function apply(rawContext: unknown): void {
|
|
|
268
320
|
// 用户供应商目录也在 profile 里:跟着 baseUrl 走,别硬编码 `web`。
|
|
269
321
|
const userDir = join(profileDirOf(ctx.baseUrl), 'suppliers')
|
|
270
322
|
const { suppliers, errors } = await loadSuppliers({
|
|
271
|
-
builtinDir: join(import.meta.dirname, 'suppliers'), // 内置 js(opencode 等)
|
|
272
323
|
userDir,
|
|
273
324
|
dataDir,
|
|
274
325
|
store,
|
|
@@ -323,8 +374,17 @@ export function apply(rawContext: unknown): void {
|
|
|
323
374
|
const lv = typeof peek.reasoning_effort === 'string' && peek.reasoning_effort !== ''
|
|
324
375
|
? peek.reasoning_effort
|
|
325
376
|
: 'auto'
|
|
377
|
+
// 宿主会话身份(adapter 从 GenerateOptions.sessionId 转来的内部头);外部客户端没有。
|
|
378
|
+
const sessionRaw = req.headers['x-dsh-router-session']
|
|
379
|
+
const session = typeof sessionRaw === 'string' && sessionRaw !== '' ? sessionRaw : undefined
|
|
326
380
|
await router.chatCompletions(
|
|
327
|
-
{
|
|
381
|
+
{
|
|
382
|
+
rawBody: body,
|
|
383
|
+
stream: !!peek.stream,
|
|
384
|
+
model: typeof peek.model === 'string' ? peek.model : '',
|
|
385
|
+
lv,
|
|
386
|
+
...(session === undefined ? {} : { session }),
|
|
387
|
+
},
|
|
328
388
|
res,
|
|
329
389
|
)
|
|
330
390
|
}))
|
|
@@ -335,6 +395,10 @@ export function apply(rawContext: unknown): void {
|
|
|
335
395
|
const { suppliers } = router.status()
|
|
336
396
|
writeJson(res, 200, {
|
|
337
397
|
ok: true,
|
|
398
|
+
// 宿主版本 + 徽章支持位:客户端据此决定是否挂「最近命中」控件
|
|
399
|
+
// (composer.dock 在 0.1.5 渲染位置不对,只有 >= 0.1.7 才挂)。
|
|
400
|
+
hostVersion: hostVersion ?? '',
|
|
401
|
+
lastHitDock: lastHitDock,
|
|
338
402
|
suppliers: suppliers.map((s) => {
|
|
339
403
|
const loaded = loadedSuppliers.find((l) => l.supplier.id === s.id)
|
|
340
404
|
return {
|
|
@@ -344,6 +408,8 @@ export function apply(rawContext: unknown): void {
|
|
|
344
408
|
apiKeyHint: loaded?.supplier.apiKeyHint,
|
|
345
409
|
capabilities: loaded ? [...loaded.capabilities] : [],
|
|
346
410
|
source: loaded?.source ?? 'external',
|
|
411
|
+
// 开关状态:关掉的供应商**仍在列表里**(关掉后要能再开),只是不参与路由。
|
|
412
|
+
enabled: s.enabled,
|
|
347
413
|
}
|
|
348
414
|
}),
|
|
349
415
|
})
|
|
@@ -374,6 +440,38 @@ export function apply(rawContext: unknown): void {
|
|
|
374
440
|
writeJson(res, 200, { ok: true })
|
|
375
441
|
})
|
|
376
442
|
|
|
443
|
+
route(`${ROUTER_API_BASE}/last-hit`, (req, res) => {
|
|
444
|
+
// 最近一次被服务的请求:给输入框旁的小指示器用。
|
|
445
|
+
// `?session=` 给定时只在该会话内找(当前会话视角);不给则退回全局最近。
|
|
446
|
+
// 附上连接显示别名与积分 —— 前端只读这一个端点,不打 status(重)。
|
|
447
|
+
const session = new URL(req.url ?? '/', 'http://localhost').searchParams.get('session') ?? undefined
|
|
448
|
+
const last = router.usage.lastHit(session)
|
|
449
|
+
if (last === undefined) {
|
|
450
|
+
writeJson(res, 200, { ok: true, hit: null })
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
const { suppliers } = router.status()
|
|
454
|
+
const owner = suppliers.find((s) => s.id === last.supplier)
|
|
455
|
+
const acct = owner?.accounts.find((a) => a.uid === last.uid)
|
|
456
|
+
// 连接名:**优先直接查 accountNames**(面板里配的显示别名),不依赖
|
|
457
|
+
// status 列表里有没有这个号 —— 号未上报/未加载时 status 里就没有它,
|
|
458
|
+
// 只靠 acct 会回落成 uid(显示成 cb-12 而不是 k8661)。
|
|
459
|
+
const named = last.uid === undefined || last.uid === '' ? undefined : store.getAccountName(last.supplier, last.uid)
|
|
460
|
+
writeJson(res, 200, {
|
|
461
|
+
ok: true,
|
|
462
|
+
hit: {
|
|
463
|
+
supplier: last.supplier,
|
|
464
|
+
model: last.model,
|
|
465
|
+
requested: last.requested,
|
|
466
|
+
uid: last.uid,
|
|
467
|
+
account: named ?? acct?.nickname ?? (last.uid === undefined || last.uid === '' ? undefined : last.uid),
|
|
468
|
+
credits: acct?.credits,
|
|
469
|
+
ok: last.ok,
|
|
470
|
+
ts: last.ts,
|
|
471
|
+
},
|
|
472
|
+
})
|
|
473
|
+
})
|
|
474
|
+
|
|
377
475
|
route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
|
|
378
476
|
try {
|
|
379
477
|
const models = await router.listModels()
|
|
@@ -392,26 +490,26 @@ export function apply(rawContext: unknown): void {
|
|
|
392
490
|
})
|
|
393
491
|
|
|
394
492
|
route(`${ROUTER_API_BASE}/combos/create`, async (req, res) => {
|
|
395
|
-
let body: { name?: string;
|
|
493
|
+
let body: { name?: string; models?: string[] }
|
|
396
494
|
try {
|
|
397
|
-
body = JSON.parse(await readBody(req, 64 << 10)) as { name?: string;
|
|
495
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { name?: string; models?: string[] }
|
|
398
496
|
} catch {
|
|
399
497
|
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
400
498
|
return
|
|
401
499
|
}
|
|
402
|
-
const result = router.createCombo(body.name ?? '', body.
|
|
500
|
+
const result = router.createCombo(body.name ?? '', body.models ?? [])
|
|
403
501
|
writeJson(res, result.ok ? 200 : 400, result)
|
|
404
502
|
})
|
|
405
503
|
|
|
406
504
|
route(`${ROUTER_API_BASE}/combos/update`, async (req, res) => {
|
|
407
|
-
let body: { id?: string; name?: string;
|
|
505
|
+
let body: { id?: string; name?: string; models?: string[] }
|
|
408
506
|
try {
|
|
409
|
-
body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string; name?: string;
|
|
507
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { id?: string; name?: string; models?: string[] }
|
|
410
508
|
} catch {
|
|
411
509
|
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
412
510
|
return
|
|
413
511
|
}
|
|
414
|
-
const result = router.updateCombo(body.id ?? '', body.name ?? '', body.
|
|
512
|
+
const result = router.updateCombo(body.id ?? '', body.name ?? '', body.models ?? [])
|
|
415
513
|
writeJson(res, result.ok ? 200 : 400, result)
|
|
416
514
|
})
|
|
417
515
|
|
|
@@ -528,6 +626,8 @@ export function apply(rawContext: unknown): void {
|
|
|
528
626
|
...(e.icon !== undefined ? { icon: e.icon } : {}),
|
|
529
627
|
enabled: extStore.isEnabled(e.id),
|
|
530
628
|
ready: st?.ready === true,
|
|
629
|
+
// 随核心分发的扩展带这个标记(插件页自绘节据此不重复列它:它已经有原生行)
|
|
630
|
+
...(raw.source === 'builtin' ? { source: 'builtin' as const } : {}),
|
|
531
631
|
...(st?.detail !== undefined ? { detail: st.detail } : {}),
|
|
532
632
|
}
|
|
533
633
|
})
|
|
@@ -535,34 +635,6 @@ export function apply(rawContext: unknown): void {
|
|
|
535
635
|
writeJson(res, 200, { ok: true, enhancers: list })
|
|
536
636
|
})
|
|
537
637
|
|
|
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
638
|
// ---- 概览看板:用量统计 ----
|
|
567
639
|
|
|
568
640
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
@@ -590,7 +662,14 @@ export function apply(rawContext: unknown): void {
|
|
|
590
662
|
bySupplier: s.bySupplier,
|
|
591
663
|
byModel: s.byModel,
|
|
592
664
|
byRequested: s.byRequested,
|
|
593
|
-
|
|
665
|
+
// 「最近请求」的「连接」列要显示连接名(uid → 显示别名)。后端一次解析,
|
|
666
|
+
// 前端不必再拉 /status。无 uid(失败/未服务)→ 返回空串,前端显示「—」。
|
|
667
|
+
recent: router.usage.recentList(20).map((r) => ({
|
|
668
|
+
...r,
|
|
669
|
+
connection: r.uid === undefined || r.uid === ''
|
|
670
|
+
? ''
|
|
671
|
+
: store.getAccountName(r.supplier, r.uid) ?? r.uid,
|
|
672
|
+
})),
|
|
594
673
|
})
|
|
595
674
|
})
|
|
596
675
|
|
|
@@ -610,42 +689,55 @@ export function apply(rawContext: unknown): void {
|
|
|
610
689
|
|
|
611
690
|
// ---- 设置-模型:Router 提供方(固定卡片,插件注册,不可删)+ 模型目录(= 组合) ----
|
|
612
691
|
|
|
692
|
+
// settings ns = 配置树行 id(cordis.patch.yml insert 的 id)—— 0.1.7 设置镜像按
|
|
693
|
+
// entry.options.id 收录;loader 未挂 entry 时回落到同一默认值,两版一个 ns。
|
|
694
|
+
const settingsNs = ctx.fiber?.entry?.options?.id ?? 'dsh-router'
|
|
695
|
+
|
|
613
696
|
// llm 缺失时(如测试环境)跳过,不影响 /v1 核心。
|
|
614
697
|
if (ctx.llm !== undefined) {
|
|
615
|
-
const LLM_NS = 'llm-dsh-router'
|
|
616
698
|
try {
|
|
617
699
|
const providerReg = ctx.llm.registerConfigurableProviders([
|
|
618
700
|
{
|
|
619
701
|
provider: 'router',
|
|
620
702
|
displayName: 'Router',
|
|
621
|
-
settingsNs
|
|
703
|
+
settingsNs,
|
|
622
704
|
settingsPath: [],
|
|
623
705
|
},
|
|
624
706
|
])
|
|
625
707
|
disposers.push(providerReg)
|
|
626
|
-
disposers.push(ctx.llm.registerModelDiscovery(
|
|
708
|
+
disposers.push(ctx.llm.registerModelDiscovery(settingsNs, async () => {
|
|
627
709
|
const combos = await router.combos()
|
|
628
710
|
return combos.map((c) => ({ id: c.name }))
|
|
629
711
|
}))
|
|
630
712
|
// Router 的配置(组合/密钥/签到)存在 core 自己的 state.json,走插件自己的
|
|
631
|
-
// 「路由系统」面板;设置-模型
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
713
|
+
// 「路由系统」面板;设置-模型 这边必须让 settingsNs 在设置镜像里能解析出来
|
|
714
|
+
// (见 dsh-client-ui-settings-models 的 configurable 过滤),否则卡片不出现。
|
|
715
|
+
// 按宿主版本分流(不再靠 installSection 能力检测):
|
|
716
|
+
// 0.1.7+:镜像行来自顶部导出的 volatile Config(ns = 行 id),无需注册;
|
|
717
|
+
// 0.1.5 :镜像行来自 settings.installSection。
|
|
635
718
|
// 空 schema = 卡片是入口/占位(该布局下不可提交),真正的配置在路由系统面板。
|
|
636
719
|
const routerSettingsSchema = Schema.object({})
|
|
637
|
-
ctx.
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
720
|
+
if (isHost017Plus(ctx.baseUrl)) {
|
|
721
|
+
log(`settings section driven by exported Config (${settingsNs})`)
|
|
722
|
+
} else {
|
|
723
|
+
ctx.inject(['settings'], (sctx: unknown) => {
|
|
724
|
+
const settings = (sctx as { settings?: SettingsServiceFace }).settings
|
|
725
|
+
if (settings === undefined) {
|
|
726
|
+
log('settings service absent — skip Router settings-section registration')
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
if (settings.installSection === undefined) {
|
|
730
|
+
// 版本说该走 installSection 但它不在(异常宿主):只记日志,不抛。
|
|
731
|
+
log(`settings.installSection absent despite 0.1.5 host (${settingsNs})`)
|
|
732
|
+
return
|
|
733
|
+
}
|
|
734
|
+
settings.installSection(rawContext as CordisContext, settingsNs, routerSettingsSchema, {}, {
|
|
735
|
+
setSource: () => {},
|
|
736
|
+
onChange: () => {},
|
|
737
|
+
})
|
|
738
|
+
log(`Router settings section registered (${settingsNs})`)
|
|
646
739
|
})
|
|
647
|
-
|
|
648
|
-
})
|
|
740
|
+
}
|
|
649
741
|
// adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
|
|
650
742
|
// 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
|
|
651
743
|
// 图片序列化需要读附件字节:把 ctx.attachments 传给 adapter(dsh-attachment
|
|
@@ -655,7 +747,12 @@ export function apply(rawContext: unknown): void {
|
|
|
655
747
|
const w = router.comboContextWindow(c)
|
|
656
748
|
return { id: c.name, ...(w !== undefined ? { contextWindow: w } : {}) }
|
|
657
749
|
}),
|
|
658
|
-
},
|
|
750
|
+
},
|
|
751
|
+
() => ctx.get('attachments') as RouterAttachmentStore | undefined,
|
|
752
|
+
undefined,
|
|
753
|
+
// 第五个参数 = 降级诊断出口:图片读不到会降级成占位文本,原因必须出口
|
|
754
|
+
// (曾经那条路径是空 catch,issue #8 的回归因此静默了两个内测版本)。
|
|
755
|
+
(msg) => ctx.logger.warn(`[dsh-router] ${msg}`))))
|
|
659
756
|
log('llm provider (Router) + discovery + adapter registered ok')
|
|
660
757
|
// 预热模型缓存:`comboContextWindow` 只读缓存、不打上游,缓存空着就
|
|
661
758
|
// 报不出窗口。这里后台填一次,让第一次 resolveModel 就有值。
|
|
@@ -708,7 +805,7 @@ export function apply(rawContext: unknown): void {
|
|
|
708
805
|
directory,
|
|
709
806
|
registeredProviders,
|
|
710
807
|
settingsNamespaces,
|
|
711
|
-
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(
|
|
808
|
+
routerNamespaceRegistered: Array.isArray(settingsNamespaces) && settingsNamespaces.includes(settingsNs),
|
|
712
809
|
})
|
|
713
810
|
})
|
|
714
811
|
|
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
|
*
|
|
@@ -609,13 +683,48 @@ test('resolveModel 透传组合的 contextWindow(自动压缩才能算阈值
|
|
|
609
683
|
|
|
610
684
|
/* ---------------- 图片序列化:图片必须真正到上游(不静默丢图) ---------------- */
|
|
611
685
|
|
|
612
|
-
/**
|
|
686
|
+
/**
|
|
687
|
+
* 假附件 store:**按宿主的方式校验请求目标**,返回指定 mediaType + bytes 的请求版本。
|
|
688
|
+
*
|
|
689
|
+
* 为什么必须校验:宿主 0.1.6 起把请求目标从 `ImageRequestPolicy`(maxPixels +
|
|
690
|
+
* maxBytes)换成 `ImageRequestTarget`(width + height + maxBytes),字段不匹配就在
|
|
691
|
+
* `validateTarget()` 抛错。之前这个假 store **两个参数都不看**(`async () => ({...})`),
|
|
692
|
+
* 于是插件侧传错字段没有任何测试会发现 —— 生产里图片静默降级成占位文本,跨两个
|
|
693
|
+
* 内测版本没人察觉(issue #8)。现在假 store 照宿主那样校验,字段写错会当场红。
|
|
694
|
+
*/
|
|
613
695
|
function fakeAttachments(mediaType = 'image/png', data = new TextEncoder().encode('<png-bytes>')): {
|
|
614
|
-
readImageRequest: (
|
|
696
|
+
readImageRequest: (
|
|
697
|
+
ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
|
|
698
|
+
target: { width: number; height: number; maxBytes: number },
|
|
699
|
+
) => Promise<{ mediaType: string; data: Uint8Array }>
|
|
700
|
+
/** 最近一次收到的请求目标,供断言检查。 */
|
|
701
|
+
lastTarget?: { width: number; height: number; maxBytes: number }
|
|
615
702
|
} {
|
|
616
|
-
|
|
617
|
-
|
|
703
|
+
// 用闭包记录而不是 `this`:对象字面量里的箭头函数拿不到宿主对象。
|
|
704
|
+
// 显式标注类型,否则 `store.lastTarget = …` 会被推断出的窄类型挡下。
|
|
705
|
+
const store: {
|
|
706
|
+
readImageRequest: (
|
|
707
|
+
ref: { attachmentId: string; mediaType: string; bytes: number; width: number; height: number },
|
|
708
|
+
target: { width: number; height: number; maxBytes: number },
|
|
709
|
+
) => Promise<{ mediaType: string; data: Uint8Array }>
|
|
710
|
+
lastTarget?: { width: number; height: number; maxBytes: number }
|
|
711
|
+
} = {
|
|
712
|
+
readImageRequest: async (_ref: unknown, target: { width: number; height: number; maxBytes: number }) => {
|
|
713
|
+
// 与宿主 validateTarget **逐字同规则**:它查的是 width/height/maxBytes 这三个
|
|
714
|
+
// 键**存在且为正整数**。注意不能写成「遍历传进来的键校验」——那样漏掉整个
|
|
715
|
+
// 键(0.1.5 的 maxPixels)反而能过,闸门就成了摆设(实测:这么写时把实现改回
|
|
716
|
+
// maxPixels,45 条测试全绿)。
|
|
717
|
+
for (const key of ['width', 'height', 'maxBytes']) {
|
|
718
|
+
const value = (target as unknown as Record<string, unknown>)[key]
|
|
719
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
720
|
+
throw new Error(`Image request ${key} must be a positive integer.`)
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
store.lastTarget = target
|
|
724
|
+
return { mediaType, data }
|
|
725
|
+
},
|
|
618
726
|
}
|
|
727
|
+
return store
|
|
619
728
|
}
|
|
620
729
|
|
|
621
730
|
/** 用给定 attachments 跑一次 stream(),返回上游收到的 body。 */
|
|
@@ -636,6 +745,68 @@ async function captureBodyWith(options: Record<string, unknown>, attachments?: u
|
|
|
636
745
|
return captured
|
|
637
746
|
}
|
|
638
747
|
|
|
748
|
+
/* ---------------- 请求目标:形状与像素预算(issue #8 的回归闸门) ---------------- */
|
|
749
|
+
|
|
750
|
+
test('目标:请求目标是 0.1.6+ 的 target 形状(width/height/maxBytes),没有 0.1.5 的 maxPixels', async () => {
|
|
751
|
+
const attachments = fakeAttachments()
|
|
752
|
+
await captureBodyWith({
|
|
753
|
+
messages: [{ role: 'user', content: [
|
|
754
|
+
{ type: 'image', attachment: { attachmentId: 'sha256:aa', mediaType: 'image/png', bytes: 11, width: 1000, height: 338 } },
|
|
755
|
+
] }],
|
|
756
|
+
}, attachments)
|
|
757
|
+
const target = attachments.lastTarget
|
|
758
|
+
assert.ok(target !== undefined, '必须真的调过 readImageRequest')
|
|
759
|
+
assert.deepEqual(Object.keys(target).sort(), ['height', 'maxBytes', 'width'],
|
|
760
|
+
'键就是宿主 validateTarget 查的那三个;多一个少一个都会被宿主拒')
|
|
761
|
+
assert.equal('maxPixels' in (target as unknown as Record<string, unknown>), false,
|
|
762
|
+
'0.1.5 时代的 maxPixels 不再被宿主接受,带上它等于没传 width/height')
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
test('目标:大图按 64e4 像素预算等比投影(不放大;小图原样)', async () => {
|
|
766
|
+
const big = fakeAttachments()
|
|
767
|
+
await captureBodyWith({
|
|
768
|
+
messages: [{ role: 'user', content: [
|
|
769
|
+
{ type: 'image', attachment: { attachmentId: 'sha256:bb', mediaType: 'image/png', bytes: 11, width: 4000, height: 3000 } },
|
|
770
|
+
] }],
|
|
771
|
+
}, big)
|
|
772
|
+
const t = big.lastTarget
|
|
773
|
+
assert.ok(t !== undefined)
|
|
774
|
+
// 12MP 投影到 ~64e4 像素内,且不超过预算
|
|
775
|
+
assert.ok(t.width * t.height <= 64e4, `投影后仍在预算内,实际 ${t.width}x${t.height}`)
|
|
776
|
+
assert.ok(t.width < 4000 && t.height < 3000, '大图要缩')
|
|
777
|
+
const small = fakeAttachments()
|
|
778
|
+
await captureBodyWith({
|
|
779
|
+
messages: [{ role: 'user', content: [
|
|
780
|
+
{ type: 'image', attachment: { attachmentId: 'sha256:cc', mediaType: 'image/png', bytes: 11, width: 4, height: 2 } },
|
|
781
|
+
] }],
|
|
782
|
+
}, small)
|
|
783
|
+
assert.deepEqual(small.lastTarget, { width: 4, height: 2, maxBytes: 1024 * 1024 }, '预算内的小图不放大')
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
test('降级留痕:读图失败会写一条 log,不再静默(issue #8 为什么能静默两个版本)', async () => {
|
|
787
|
+
const lines: string[] = []
|
|
788
|
+
const boom = {
|
|
789
|
+
readImageRequest: async () => { throw new Error('Image request width must be a positive integer.') },
|
|
790
|
+
}
|
|
791
|
+
const adapter = new RouterAdapter(
|
|
792
|
+
'http://x',
|
|
793
|
+
{ comboModels: async () => [] },
|
|
794
|
+
() => boom as never,
|
|
795
|
+
undefined,
|
|
796
|
+
(msg) => lines.push(msg),
|
|
797
|
+
)
|
|
798
|
+
globalThis.fetch = (async () => new Response('data: [DONE]\n\n', { status: 200 })) as typeof fetch
|
|
799
|
+
for await (const _c of adapter.stream({
|
|
800
|
+
model: 'm',
|
|
801
|
+
messages: [{ role: 'user', content: [
|
|
802
|
+
{ type: 'image', attachment: { attachmentId: 'sha256:dd', mediaType: 'image/png', bytes: 11, width: 4, height: 2 } },
|
|
803
|
+
] }],
|
|
804
|
+
signal: AbortSignal.timeout(3000),
|
|
805
|
+
} as never)) { void _c }
|
|
806
|
+
assert.equal(lines.length, 1, '降级必须留一条痕,否则问题又会是「静默」')
|
|
807
|
+
assert.match(lines[0] ?? '', /width must be a positive integer/, 'log 要带上真实原因')
|
|
808
|
+
})
|
|
809
|
+
|
|
639
810
|
test('wire:图片块被序列化成 OpenAI image_url base64 part(不再静默丢图)', async () => {
|
|
640
811
|
const ref = {
|
|
641
812
|
attachmentId: 'sha256:abcdef1234567890',
|