dsh-router-core 0.2.0 → 0.3.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 +40 -11
- package/docs/screenshot.png +0 -0
- package/docs/suppliers.md +302 -0
- package/lib/client-registry.js +1119 -213
- package/lib/client.js +1119 -213
- package/lib/index.js +1066 -50
- package/lib/types/llm/adapter.d.ts +2 -0
- package/lib/types/router/index.d.ts +34 -5
- package/lib/types/router/usage-store.d.ts +93 -0
- package/lib/types/router/usage-tokens.d.ts +118 -0
- package/lib/types/shared.d.ts +86 -1
- package/lib/types/supplier-config.d.ts +15 -1
- package/package.json +21 -4
- package/src/client/CheckinCard.tsx +180 -0
- package/src/client/CombosTab.tsx +28 -18
- package/src/client/RouterView.tsx +44 -27
- package/src/client/StatsTab.tsx +531 -0
- package/src/client/index.tsx +61 -11
- package/src/client/router.css +688 -35
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/settings-section.tsx +21 -0
- package/src/client/workspace-mount.tsx +2 -9
- package/src/index.ts +51 -3
- package/src/llm/adapter.test.ts +108 -0
- package/src/llm/adapter.ts +18 -6
- package/src/router/index.ts +461 -49
- package/src/router/models-cache.test.ts +269 -0
- package/src/router/precise-dispatch.test.ts +198 -0
- package/src/router/usage-store.ts +499 -0
- package/src/router/usage-tokens.ts +327 -0
- package/src/router/usage.test.ts +558 -0
- package/src/shared.ts +90 -1
- package/src/supplier-config.ts +30 -2
- package/src/suppliers/loader.ts +2 -1
- package/src/suppliers/nvidia/plugin.ts +2 -3
- package/src/suppliers/opencode/plugin.ts +2 -3
- package/src/suppliers/openrouter/plugin.ts +2 -3
- package/src/suppliers/registry.ts +7 -2
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 给设置导航里「路由」这一行换掉宿主的默认齿轮。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这个:DSH 0.1.x 的 `settings.section` 注册**只认 id / order / label
|
|
5
|
+
* 三个字段**,图标是宿主内部按「内置 id 白名单」选的,插件传不了。所以外部
|
|
6
|
+
* section 一律拿到通用齿轮(跟「通用设置」「远程访问」同一个)。
|
|
7
|
+
* 在契约长出 icon 字段之前,只能注册后认领自己的那一行。
|
|
8
|
+
*
|
|
9
|
+
* 做法跟 dsh-better-sidebar 的 settings-nav-icon.ts 一致:给自己的行打一个
|
|
10
|
+
* 属性标记,再由 CSS 用 mask 渲染图标(见 router.css 的
|
|
11
|
+
* `[data-dsh-router-settings-nav]`)。用 mask + currentColor 是为了跟随
|
|
12
|
+
* 宿主导航的 hover/active 配色,也保持 16px 的图标节奏。
|
|
13
|
+
*
|
|
14
|
+
* 匹配方式:按行的**可见文案**认领(label 可能本地化),且只认 `nav button`,
|
|
15
|
+
* 碰到同名的别人家 section 不会误伤(我们同时校验它确实在设置面板里)。
|
|
16
|
+
* 卸载时断开观察并摘掉标记,HMR / 插件热卸载安全。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 打在设置导航按钮上的标记属性。 */
|
|
20
|
+
export const SETTINGS_NAV_MARKER = 'data-dsh-router-settings-nav'
|
|
21
|
+
|
|
22
|
+
/** 设置面板的导航按钮选择器(宿主结构:dialog 里的 nav)。 */
|
|
23
|
+
const NAV_BUTTON_SELECTOR = '[role="dialog"] nav button'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 持续把标记打在文案等于 `label` 的那一行的身上。
|
|
27
|
+
* @param label 当前本地化的 section 标题(跟注册时的 label 一致)
|
|
28
|
+
* @returns 清理函数:断开观察 + 摘掉所有本插件打的标记
|
|
29
|
+
*/
|
|
30
|
+
export function registerSettingsNavIcon(label: string): () => void {
|
|
31
|
+
let disposed = false
|
|
32
|
+
const wanted = label.trim()
|
|
33
|
+
|
|
34
|
+
const sync = (): void => {
|
|
35
|
+
if (disposed || wanted === '') return
|
|
36
|
+
for (const button of document.querySelectorAll<HTMLElement>(NAV_BUTTON_SELECTOR)) {
|
|
37
|
+
const matches = button.textContent?.trim() === wanted
|
|
38
|
+
if (matches) button.setAttribute(SETTINGS_NAV_MARKER, '')
|
|
39
|
+
else button.removeAttribute(SETTINGS_NAV_MARKER)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
sync()
|
|
44
|
+
// 设置面板是按需挂载的:body 子树变动时重新认领
|
|
45
|
+
const observer = new MutationObserver(sync)
|
|
46
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
disposed = true
|
|
50
|
+
observer.disconnect()
|
|
51
|
+
for (const el of document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`)) {
|
|
52
|
+
el.removeAttribute(SETTINGS_NAV_MARKER)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「设置 → 路由」页 —— 走 DSH 官方 `settings.section` 座位。
|
|
3
|
+
*
|
|
4
|
+
* 容器几何(浏览器实测,不是估的)决定了这里的写法:
|
|
5
|
+
* - 内容宽 564px(面板 612 − 左右 padding 24)
|
|
6
|
+
* - 外壳 `.VOzbGW_options` 是 `overflow-y:auto` 且高度确定,所以**根节点
|
|
7
|
+
* 限高**(`height:100%`)+ 内容区自己滚,才能做到「tab 条固定、只有
|
|
8
|
+
* 内容区滚」。不限高就会退化成整页滚,tab 条跟着滚上去
|
|
9
|
+
* - 根节点外面套了一层 `display: contents`(宿主 slot renderer 给的),
|
|
10
|
+
* 但实测 `height:100%` 能穿过它解析到外壳高度
|
|
11
|
+
* - 横向会溢出:老布局是按整个中心栏宽做的,卡片 grid 必须按 564px 排
|
|
12
|
+
*
|
|
13
|
+
* 组件本身无状态、无 props:数据全在 RouterView 里自己 fetch。
|
|
14
|
+
*/
|
|
15
|
+
import { RouterView } from './RouterView.tsx'
|
|
16
|
+
|
|
17
|
+
/** 设置页入口组件。 */
|
|
18
|
+
export function RouterSettingsSection(): JSX.Element {
|
|
19
|
+
// 不传 onBack:设置面板自带关闭,不需要「返回会话」
|
|
20
|
+
return <RouterView />
|
|
21
|
+
}
|
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
* other sidebar panels (taskboard / ssh / mnemon) through the shared
|
|
6
6
|
* `dsh-panel-activate` event.
|
|
7
7
|
*/
|
|
8
|
-
import { useEffect } from 'react'
|
|
9
8
|
import { createRoot, type Root } from 'react-dom/client'
|
|
10
|
-
import { ROUTER_ACTIVE_ATTR
|
|
9
|
+
import { ROUTER_ACTIVE_ATTR } from '../shared.ts'
|
|
11
10
|
import { RouterView } from './RouterView.tsx'
|
|
12
11
|
import { mountRouterSidebarEntry } from './sidebar-entry.ts'
|
|
13
12
|
import { RouterWorkspaceController } from './workspace-controller.ts'
|
|
@@ -24,13 +23,7 @@ function conversationColumn(): HTMLElement | undefined {
|
|
|
24
23
|
|
|
25
24
|
function mountPanel(controller: RouterWorkspaceController): () => void {
|
|
26
25
|
function Panel(): JSX.Element {
|
|
27
|
-
|
|
28
|
-
// Keep the panel title in the browser tab for orientation, like the
|
|
29
|
-
// official panels do.
|
|
30
|
-
const original = document.title
|
|
31
|
-
document.title = ROUTER_TITLE
|
|
32
|
-
return () => { document.title = original }
|
|
33
|
-
}, [])
|
|
26
|
+
// 不改 document.title:这是宿主页面(会话)的标题,插件不该动它。
|
|
34
27
|
return <RouterView onBack={() => controller.close()} />
|
|
35
28
|
}
|
|
36
29
|
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* PATCH /router/api/keys/:id toggle key {isActive}
|
|
19
19
|
* GET /router/api/settings { requireApiKey }
|
|
20
20
|
* PATCH /router/api/settings { requireApiKey }
|
|
21
|
+
* GET /router/api/stats usage overview (period=today|24h|7d|30d)
|
|
22
|
+
* GET /router/api/stats/chart usage bar chart buckets
|
|
23
|
+
* POST /router/api/stats/clear reset all usage stats
|
|
21
24
|
* POST /router/api/suppliers/:id/login generate login URL
|
|
22
25
|
* POST /router/api/suppliers/:id/login/callback {callbackUrl} → add account
|
|
23
26
|
* GET /router/api/suppliers/:id/models models with enabled state
|
|
@@ -28,7 +31,7 @@
|
|
|
28
31
|
*/
|
|
29
32
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
30
33
|
import { join, dirname } from 'node:path'
|
|
31
|
-
import { ROUTER_API_BASE } from './shared.ts'
|
|
34
|
+
import { ROUTER_API_BASE, type RouterPeriod } from './shared.ts'
|
|
32
35
|
import { Router } from './router/index.ts'
|
|
33
36
|
import { RouterAdapter } from './llm/adapter.ts'
|
|
34
37
|
import { KeysStore } from './keys.ts'
|
|
@@ -128,7 +131,7 @@ export function apply(rawContext: unknown): void {
|
|
|
128
131
|
const stateFile = process.env.TW2A_STATE_FILE ?? 'data/state.json'
|
|
129
132
|
const store = new SupplierConfigStore(stateFile)
|
|
130
133
|
const credentials = new CredentialStore(process.env.TW2A_AUTH_DIR ?? join(dirname(stateFile), 'auths'))
|
|
131
|
-
const router = new Router(stateFile, store)
|
|
134
|
+
const router = new Router(stateFile, store, log)
|
|
132
135
|
|
|
133
136
|
const keys = new KeysStore(stateFile)
|
|
134
137
|
|
|
@@ -405,6 +408,51 @@ export function apply(rawContext: unknown): void {
|
|
|
405
408
|
writeJson(res, 200, { ok: true, requireApiKey: keys.requireApiKey })
|
|
406
409
|
})
|
|
407
410
|
|
|
411
|
+
// ---- 概览看板:用量统计 ----
|
|
412
|
+
|
|
413
|
+
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
414
|
+
const periodOf = (raw: string | null): RouterPeriod => {
|
|
415
|
+
return raw === '24h' || raw === '7d' || raw === '30d' ? raw : 'today'
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
route(`${ROUTER_API_BASE}/stats`, (req, res) => {
|
|
419
|
+
const period = periodOf(new URL(req.url ?? '/', 'http://localhost').searchParams.get('period'))
|
|
420
|
+
const s = router.usage.stats(period)
|
|
421
|
+
writeJson(res, 200, {
|
|
422
|
+
ok: true,
|
|
423
|
+
period,
|
|
424
|
+
requests: s.requests,
|
|
425
|
+
okCount: s.ok,
|
|
426
|
+
failed: s.failed,
|
|
427
|
+
promptTokens: s.promptTokens,
|
|
428
|
+
completionTokens: s.completionTokens,
|
|
429
|
+
cachedTokens: s.cachedTokens,
|
|
430
|
+
avgDurationMs: s.avgDurationMs,
|
|
431
|
+
avgTtfbMs: s.avgTtfbMs,
|
|
432
|
+
estimatedInputs: s.estimatedInputs,
|
|
433
|
+
estimatedOutputs: s.estimatedOutputs,
|
|
434
|
+
lifetime: s.lifetime,
|
|
435
|
+
bySupplier: s.bySupplier,
|
|
436
|
+
byModel: s.byModel,
|
|
437
|
+
byRequested: s.byRequested,
|
|
438
|
+
recent: router.usage.recentList(20),
|
|
439
|
+
})
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
route(`${ROUTER_API_BASE}/stats/chart`, (req, res) => {
|
|
443
|
+
const period = periodOf(new URL(req.url ?? '/', 'http://localhost').searchParams.get('period'))
|
|
444
|
+
writeJson(res, 200, { ok: true, chart: router.usage.chart(period) })
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
route(`${ROUTER_API_BASE}/stats/clear`, async (req, res) => {
|
|
448
|
+
if (req.method !== 'POST') {
|
|
449
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
router.usage.clear()
|
|
453
|
+
writeJson(res, 200, { ok: true })
|
|
454
|
+
})
|
|
455
|
+
|
|
408
456
|
// ---- 设置-模型:Router 提供方(固定卡片,插件注册,不可删)+ 模型目录(= 组合) ----
|
|
409
457
|
|
|
410
458
|
// llm 缺失时(如测试环境)跳过,不影响 /v1 核心。
|
|
@@ -461,7 +509,7 @@ export function apply(rawContext: unknown): void {
|
|
|
461
509
|
ctx.effect(
|
|
462
510
|
() => () => {
|
|
463
511
|
for (const dispose of disposers.splice(0)) dispose()
|
|
464
|
-
router.dispose()
|
|
512
|
+
router.dispose() // 内部会 flush 用量统计(防抖中的计数不能丢)
|
|
465
513
|
},
|
|
466
514
|
'dsh-router: teardown',
|
|
467
515
|
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RouterAdapter 的 usage 契约测试。
|
|
3
|
+
*
|
|
4
|
+
* 这些用例存在的原因:adapter 曾把上游 OpenAI 形态的 usage 原样透传给
|
|
5
|
+
* dsh-llm,导致会话日志里的 usage 全是 `prompt_tokens`(没有 `inputTokens`)。
|
|
6
|
+
* 下游 token-meter 读 `usage.inputTokens` 得 `undefined`,累加出 `NaN`,
|
|
7
|
+
* 投影 schema(`z.number().int().nonnegative()`)校验一抛,整条
|
|
8
|
+
* `session.history` RPC 失败 —— 用户看到的就是「历史加载失败」。
|
|
9
|
+
*
|
|
10
|
+
* 所以这里锁死两条:字段名对、口径对(DISJOINT)。
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { toTokenUsage } from '../router/usage-tokens.ts'
|
|
15
|
+
import { translateSse } from './adapter.ts'
|
|
16
|
+
|
|
17
|
+
/** 跑一遍 SSE 载荷序列,返回产出的 usage chunk(没有则 undefined)。 */
|
|
18
|
+
async function usageOf(...payloads: string[]): Promise<Record<string, unknown> | undefined> {
|
|
19
|
+
for await (const chunk of translateSse(payloads)) {
|
|
20
|
+
if (chunk.type === 'usage') return chunk.usage as unknown as Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 一帧 SSE 的 data 载荷(translateSse 吃的是已剥掉 `data:` 前缀的载荷)。 */
|
|
26
|
+
const frame = (obj: unknown): string => JSON.stringify(obj)
|
|
27
|
+
|
|
28
|
+
/* ---------------- 契约转换 ---------------- */
|
|
29
|
+
|
|
30
|
+
test('转换:OpenAI 形态的 prompt_tokens → inputTokens,且扣掉缓存(DISJOINT)', () => {
|
|
31
|
+
// prompt_tokens 是含缓存的总量;DISJOINT 要求 inputTokens 只算未缓存部分
|
|
32
|
+
const u = toTokenUsage({ promptTokens: 1000, completionTokens: 50, cachedTokens: 300 })
|
|
33
|
+
assert.deepEqual(u, { inputTokens: 700, outputTokens: 50, cacheReadTokens: 300 })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('转换:无缓存时不带 cacheReadTokens 字段(不写无意义的 0)', () => {
|
|
37
|
+
const u = toTokenUsage({ promptTokens: 100, completionTokens: 20, cachedTokens: 0 })
|
|
38
|
+
assert.deepEqual(u, { inputTokens: 100, outputTokens: 20 })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('转换:全 0 返回 null(这一帧没真数据,整个省略 usage)', () => {
|
|
42
|
+
assert.equal(toTokenUsage({ promptTokens: 0, completionTokens: 0, cachedTokens: 0 }), null)
|
|
43
|
+
assert.equal(toTokenUsage(null), null)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('转换:NaN / Infinity / 负数一律归零 —— 绝不把 NaN 写进会话日志', () => {
|
|
47
|
+
const u = toTokenUsage({
|
|
48
|
+
promptTokens: Number.NaN,
|
|
49
|
+
completionTokens: Number.POSITIVE_INFINITY,
|
|
50
|
+
cachedTokens: -5,
|
|
51
|
+
})
|
|
52
|
+
assert.equal(u, null) // 全归零 → 视为无数据
|
|
53
|
+
const partial = toTokenUsage({ promptTokens: Number.NaN, completionTokens: 42, cachedTokens: 0 })
|
|
54
|
+
assert.deepEqual(partial, { inputTokens: 0, outputTokens: 42 })
|
|
55
|
+
for (const v of Object.values(partial ?? {})) assert.ok(Number.isFinite(v), `出现非有限数: ${v}`)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('转换:缓存比总输入还大时 inputTokens 不为负(上游口径打架也要自洽)', () => {
|
|
59
|
+
const u = toTokenUsage({ promptTokens: 100, completionTokens: 1, cachedTokens: 500 })
|
|
60
|
+
assert.equal(u?.inputTokens, 0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/* ---------------- adapter 出口 ---------------- */
|
|
64
|
+
|
|
65
|
+
test('出口:流式 SSE 的 usage 必须是 DSH 契约,不是上游原样', async () => {
|
|
66
|
+
const usage = await usageOf(
|
|
67
|
+
frame({ choices: [{ delta: { content: 'hi' } }] }),
|
|
68
|
+
frame({
|
|
69
|
+
choices: [{ delta: {}, finish_reason: 'stop' }],
|
|
70
|
+
usage: { prompt_tokens: 236655, completion_tokens: 548, prompt_tokens_details: { cached_tokens: 1024 } },
|
|
71
|
+
}),
|
|
72
|
+
'[DONE]',
|
|
73
|
+
)
|
|
74
|
+
assert.ok(usage !== undefined, '应当产出 usage chunk')
|
|
75
|
+
// 字段名对
|
|
76
|
+
assert.equal(usage.prompt_tokens, undefined, '不能把上游字段原样透传')
|
|
77
|
+
assert.equal(usage.inputTokens, 235631) // 236655 - 1024
|
|
78
|
+
assert.equal(usage.outputTokens, 548)
|
|
79
|
+
assert.equal(usage.cacheReadTokens, 1024)
|
|
80
|
+
// 每个字段都是有限非负整数(投影 schema 的硬要求)
|
|
81
|
+
for (const [k, v] of Object.entries(usage)) {
|
|
82
|
+
assert.ok(Number.isInteger(v) && (v as number) >= 0, `${k} 非法: ${v}`)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('出口:上游把 usage 拆在多帧时字段级合并(不是被后一帧覆盖)', async () => {
|
|
87
|
+
// Claude 系:message_start 给输入,message_delta 只给输出
|
|
88
|
+
const usage = await usageOf(
|
|
89
|
+
frame({ choices: [{ delta: { content: 'x' } }], usage: { input_tokens: 100, cache_read_input_tokens: 40 } }),
|
|
90
|
+
frame({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { output_tokens: 55 } }),
|
|
91
|
+
'[DONE]',
|
|
92
|
+
)
|
|
93
|
+
assert.equal(usage?.inputTokens, 100, '后一帧没有输入,不能把前一帧的输入冲掉')
|
|
94
|
+
assert.equal(usage?.outputTokens, 55)
|
|
95
|
+
assert.equal(usage?.cacheReadTokens, 40)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('出口:上游发全 0 或垃圾 usage 时省略整个 usage chunk', async () => {
|
|
99
|
+
const zero = frame({ choices: [{ delta: { content: 'x' } }], usage: { prompt_tokens: 0, completion_tokens: 0 } })
|
|
100
|
+
assert.equal(await usageOf(zero, '[DONE]'), undefined)
|
|
101
|
+
const junk = frame({ choices: [{ delta: { content: 'x' } }], usage: { prompt_tokens: 'abc', completion_tokens: null } })
|
|
102
|
+
assert.equal(await usageOf(junk, '[DONE]'), undefined)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('出口:上游压根不发 usage 时不硬凑 usage chunk', async () => {
|
|
106
|
+
const sse = frame({ choices: [{ delta: { content: 'x' }, finish_reason: 'stop' }] })
|
|
107
|
+
assert.equal(await usageOf(sse, '[DONE]'), undefined)
|
|
108
|
+
})
|
package/src/llm/adapter.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type LlmModelInfo,
|
|
16
16
|
type StreamChunk,
|
|
17
17
|
} from '@deepseek-ai/dsh-llm'
|
|
18
|
+
import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../router/usage-tokens.ts'
|
|
18
19
|
|
|
19
20
|
/** 模型目录来源:组合。 */
|
|
20
21
|
export interface RouterAdapterSource {
|
|
@@ -107,15 +108,16 @@ interface OpenBlock {
|
|
|
107
108
|
name?: string
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
/** openai SSE payload → DSH StreamChunk
|
|
111
|
-
async function*
|
|
111
|
+
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
112
|
+
export async function* translateSse(payloads: Iterable<string>): AsyncGenerator<StreamChunk> {
|
|
112
113
|
let nextIndex = 0
|
|
113
114
|
let textBlock: OpenBlock | undefined
|
|
114
115
|
let reasoningBlock: OpenBlock | undefined
|
|
115
116
|
const toolBlocks = new Map<number, OpenBlock>()
|
|
116
117
|
const order: OpenBlock[] = []
|
|
117
118
|
let pendingFinish: { kind: string; failure?: unknown } | undefined
|
|
118
|
-
|
|
119
|
+
/** 攒上游各帧的 usage(归一形态),[DONE] 时统一转成 DSH 契约。 */
|
|
120
|
+
let accumulated: UsageTokens | null = null
|
|
119
121
|
|
|
120
122
|
const open = (kind: OpenBlock['kind']): OpenBlock => {
|
|
121
123
|
const block: OpenBlock = { index: nextIndex++, kind, text: '' }
|
|
@@ -128,7 +130,15 @@ async function* translate(payloads: Iterable<string>): AsyncGenerator<StreamChun
|
|
|
128
130
|
for (const block of order) {
|
|
129
131
|
yield { type: 'block-end', index: block.index, block: closeBlock(block) }
|
|
130
132
|
}
|
|
131
|
-
if (
|
|
133
|
+
if (accumulated !== null) {
|
|
134
|
+
// 转成 DSH 的 TokenUsage:**必须**转,不能把上游的 OpenAI 形态
|
|
135
|
+
// (prompt_tokens,且含缓存)原样透传。字段名和 DISJOINT 口径都
|
|
136
|
+
// 对不上,下游 token-meter 读到 undefined 会累加出 NaN,投影
|
|
137
|
+
// schema 校验一抛就是整条 session.history 失败(「历史加载失败」)。
|
|
138
|
+
// 转换结果为 null(三个数全 0)则整个省略 usage。见 toTokenUsage。
|
|
139
|
+
const usage = toTokenUsage(accumulated)
|
|
140
|
+
if (usage !== null) yield { type: 'usage', usage }
|
|
141
|
+
}
|
|
132
142
|
const reason = pendingFinish ?? { kind: 'stop' }
|
|
133
143
|
yield {
|
|
134
144
|
type: 'finish',
|
|
@@ -188,7 +198,9 @@ async function* translate(payloads: Iterable<string>): AsyncGenerator<StreamChun
|
|
|
188
198
|
}
|
|
189
199
|
if (typeof choice.finish_reason === 'string') pendingFinish = mapFinishReason(choice.finish_reason)
|
|
190
200
|
}
|
|
191
|
-
|
|
201
|
+
// 上游可能把 usage 拆在多个帧里(Claude 系:先给输入、后给输出),
|
|
202
|
+
// 所以是字段级 max 合并,不是覆盖。攒着,[DONE] 时统一转契约。
|
|
203
|
+
if (chunk.usage !== undefined) accumulated = mergeUsage(accumulated, normalizeUsage(chunk.usage))
|
|
192
204
|
}
|
|
193
205
|
throw new LlmError('SSE payload stream ended without [DONE]', 'STREAM_CLOSED')
|
|
194
206
|
}
|
|
@@ -268,6 +280,6 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
268
280
|
throw new LlmError(`dsh-router /v1 returned ${resp.status}: ${detail.slice(0, 200)}`, 'TRANSPORT')
|
|
269
281
|
}
|
|
270
282
|
const text = resp.body === null ? '' : await resp.text()
|
|
271
|
-
yield*
|
|
283
|
+
yield* translateSse(parseSseText(text))
|
|
272
284
|
}
|
|
273
285
|
}
|