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,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token 用量提取 —— 口径照 9router 的 `open-sse/utils/usageTracking.js`。
|
|
3
|
+
*
|
|
4
|
+
* 三件事:
|
|
5
|
+
* 1. **归一化**:上游字段名各不相同(prompt_tokens / input_tokens /
|
|
6
|
+
* promptTokenCount …),统一到 OpenAI 口径。
|
|
7
|
+
* 2. **合并**:流式里 usage 常被拆在多个事件里(Claude 系:
|
|
8
|
+
* message_start 给输入+缓存,message_delta 给输出)。所以是
|
|
9
|
+
* **字段级 max 合并**,不是覆盖——照 9router 的 `mergeUsage`。
|
|
10
|
+
* 3. **回退估算**:上游压根不发 usage 时,按字符数/4 估算(照 9router 的
|
|
11
|
+
* `estimateInputTokens`/`estimateOutputTokens`),并**分别**标记输入/输出
|
|
12
|
+
* 哪个是估算的——9router 只有一个 `estimated` 布尔,这里拆开更准,
|
|
13
|
+
* 面板才知道该给哪个数打「~」。
|
|
14
|
+
*
|
|
15
|
+
* 天花板(刻意不做):
|
|
16
|
+
* - 估算就是估算,~4 字符/token 是跨 tokenizer 的粗略平均。
|
|
17
|
+
* 要准得上真 tokenizer,代价是引入依赖,不值。
|
|
18
|
+
* - 不注入 `stream_options.include_usage`:那要改写客户端的请求体,
|
|
19
|
+
* 个别上游不认这个字段。上游不发我们就估算,不动请求语义。
|
|
20
|
+
*/
|
|
21
|
+
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
|
22
|
+
|
|
23
|
+
/** 归一化后的用量(不含估算标记)。 */
|
|
24
|
+
export interface UsageTokens {
|
|
25
|
+
promptTokens: number
|
|
26
|
+
completionTokens: number
|
|
27
|
+
cachedTokens: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 最终用量:token 数 + 哪些字段是估算的。 */
|
|
31
|
+
export interface Usage extends UsageTokens {
|
|
32
|
+
/** 输入是估算的(上游没给)。 */
|
|
33
|
+
inputEstimated: boolean
|
|
34
|
+
/** 输出是估算的(上游没给)。 */
|
|
35
|
+
outputEstimated: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 一帧 SSE 里可能带 usage 的形状(上游各不相同,全是可选)。 */
|
|
39
|
+
interface RawUsage {
|
|
40
|
+
prompt_tokens?: number
|
|
41
|
+
completion_tokens?: number
|
|
42
|
+
cached_tokens?: number
|
|
43
|
+
input_tokens?: number
|
|
44
|
+
output_tokens?: number
|
|
45
|
+
cache_read_input_tokens?: number
|
|
46
|
+
promptTokenCount?: number
|
|
47
|
+
candidatesTokenCount?: number
|
|
48
|
+
cachedContentTokenCount?: number
|
|
49
|
+
prompt_tokens_details?: { cached_tokens?: number }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 取有限数,其余当 0(NaN 会毒穿整个累加:`Math.max(x, NaN) === NaN`)。 */
|
|
53
|
+
function num(v: unknown): number {
|
|
54
|
+
const n = Number(v)
|
|
55
|
+
return Number.isFinite(n) ? n : 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 从上游任意形态的 usage 对象提取归一用量。
|
|
60
|
+
*
|
|
61
|
+
* `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
|
|
62
|
+
* prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
|
|
63
|
+
* prompt + 单独的 cache_read,要折进来,否则总输入偏小。
|
|
64
|
+
* 判别式就是「有没有 cache_read_input_tokens」。
|
|
65
|
+
*
|
|
66
|
+
* @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeUsage(raw: unknown): UsageTokens | null {
|
|
69
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
70
|
+
const u = raw as RawUsage
|
|
71
|
+
|
|
72
|
+
const cached =
|
|
73
|
+
num(u.cached_tokens) +
|
|
74
|
+
num(u.cache_read_input_tokens) +
|
|
75
|
+
num(u.cachedContentTokenCount) +
|
|
76
|
+
num(u.prompt_tokens_details?.cached_tokens)
|
|
77
|
+
|
|
78
|
+
// promptTokenCount 是 Gemini 形态;input_tokens 是 Claude 形态
|
|
79
|
+
let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount)
|
|
80
|
+
if (u.cache_read_input_tokens !== undefined) prompt += num(u.cache_read_input_tokens)
|
|
81
|
+
|
|
82
|
+
const completion = num(u.completion_tokens) + num(u.output_tokens) + num(u.candidatesTokenCount)
|
|
83
|
+
|
|
84
|
+
if (prompt === 0 && completion === 0 && cached === 0) return null
|
|
85
|
+
return { promptTokens: prompt, completionTokens: completion, cachedTokens: cached }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 字段级 max 合并:后来的同名字段取大者(Claude 把 usage 拆在多个事件里)。 */
|
|
89
|
+
export function mergeUsage(prev: UsageTokens | null, next: UsageTokens | null): UsageTokens | null {
|
|
90
|
+
if (prev === null) return next
|
|
91
|
+
if (next === null) return prev
|
|
92
|
+
return {
|
|
93
|
+
promptTokens: Math.max(prev.promptTokens, next.promptTokens),
|
|
94
|
+
completionTokens: Math.max(prev.completionTokens, next.completionTokens),
|
|
95
|
+
cachedTokens: Math.max(prev.cachedTokens, next.cachedTokens),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 从流式上游的一帧 JSON 里取 usage。
|
|
101
|
+
* 覆盖 OpenAI(Chat/Responses)、Claude、Gemini 三种事件形状,照 9router
|
|
102
|
+
* `extractUsage` 的形态判断(dsh-router 的上游多是 OpenAI SSE,但供应商
|
|
103
|
+
* 插件可能有直通的原生流,多认几种不亏)。
|
|
104
|
+
*/
|
|
105
|
+
export function extractFrameUsage(chunk: unknown): UsageTokens | null {
|
|
106
|
+
if (chunk === null || typeof chunk !== 'object' || Array.isArray(chunk)) return null
|
|
107
|
+
const c = chunk as Record<string, unknown>
|
|
108
|
+
|
|
109
|
+
// Responses API:response.completed / response.done 带 response.usage
|
|
110
|
+
if (c.type === 'response.completed' || c.type === 'response.done') {
|
|
111
|
+
const r = c.response as { usage?: unknown } | undefined
|
|
112
|
+
if (r !== undefined) return normalizeUsage(r.usage)
|
|
113
|
+
}
|
|
114
|
+
// Claude:message_start 的 message.usage(输入+缓存)、message_delta 的 usage(输出)
|
|
115
|
+
if (c.type === 'message_start') {
|
|
116
|
+
const m = c.message as { usage?: unknown } | undefined
|
|
117
|
+
if (m !== undefined) return normalizeUsage(m.usage)
|
|
118
|
+
}
|
|
119
|
+
if (c.type === 'message_delta') return normalizeUsage(c.usage)
|
|
120
|
+
|
|
121
|
+
// OpenAI Chat:顶层 usage(多数供应商在最后一帧给)
|
|
122
|
+
if (c.usage !== undefined) return normalizeUsage(c.usage)
|
|
123
|
+
// Gemini:usageMetadata,可能被包在 response 里
|
|
124
|
+
if (c.usageMetadata !== undefined) return normalizeUsage(c.usageMetadata)
|
|
125
|
+
const wrapped = c.response as { usageMetadata?: unknown } | undefined
|
|
126
|
+
if (wrapped?.usageMetadata !== undefined) return normalizeUsage(wrapped.usageMetadata)
|
|
127
|
+
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 估算:~4 字符/token(照 9router 的 estimateInputTokens/estimateOutputTokens)。 */
|
|
132
|
+
export function estimateTokens(chars: number): number {
|
|
133
|
+
if (!Number.isFinite(chars) || chars <= 0) return 0
|
|
134
|
+
return Math.ceil(chars / 4)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 非流式响应体取 usage。
|
|
139
|
+
*
|
|
140
|
+
* 正常是纯 JSON;但个别供应商客户端要 JSON 却回 SSE(核心会聚合成一次响应,
|
|
141
|
+
* 见 `aggregateSSE`),所以顺手扫尾部几帧——只扫尾部,不多做。
|
|
142
|
+
*/
|
|
143
|
+
export function usageFromJsonBody(body: string): UsageTokens | null {
|
|
144
|
+
try {
|
|
145
|
+
return normalizeUsage((JSON.parse(body) as { usage?: unknown }).usage)
|
|
146
|
+
} catch {
|
|
147
|
+
// 不是 JSON → 按 SSE 尾部扫
|
|
148
|
+
}
|
|
149
|
+
let merged: UsageTokens | null = null
|
|
150
|
+
for (const frame of body.split('\n\n').slice(-3)) {
|
|
151
|
+
for (const line of frame.split('\n')) {
|
|
152
|
+
if (!line.startsWith('data:')) continue
|
|
153
|
+
const payload = line.slice(5).trim()
|
|
154
|
+
if (payload === '' || payload === '[DONE]') continue
|
|
155
|
+
let obj: unknown
|
|
156
|
+
try { obj = JSON.parse(payload) } catch { continue }
|
|
157
|
+
merged = mergeUsage(merged, extractFrameUsage(obj))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return merged
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 归一用量 → DSH 的 `TokenUsage`(**必须**走这一步再交给 dsh-llm)。
|
|
165
|
+
*
|
|
166
|
+
* 为什么不能把上游 usage 原样透传:dsh-llm 的 `TokenUsage` 有两条硬性约定,
|
|
167
|
+
* 上游的 OpenAI 形态两条都踩:
|
|
168
|
+
* 1. 字段名是 `inputTokens` / `outputTokens`,不是 `prompt_tokens`;
|
|
169
|
+
* 名字对不上 → 下游读 `usage.inputTokens` 得 `undefined` → 累加出 `NaN`。
|
|
170
|
+
* 2. **DISJOINT 口径**:`inputTokens` 只算**未缓存**输入,缓存单列
|
|
171
|
+
* `cacheReadTokens`/`cacheWriteTokens`(计费输入 = 三者之和)。
|
|
172
|
+
* OpenAI 的 `prompt_tokens` 是**含**缓存的总量,直接当 inputTokens
|
|
173
|
+
* 就是重复计费。
|
|
174
|
+
*
|
|
175
|
+
* 后果不只是「数字不好看」:token-meter 的投影 schema 是
|
|
176
|
+
* `z.number().int().nonnegative()`,`NaN` 一进去校验就抛,整条
|
|
177
|
+
* `session.history` RPC 失败 —— 表现为「历史加载失败:history unavailable
|
|
178
|
+
* for session ... expected number, received NaN」。所以这里是**写入侧**
|
|
179
|
+
* 的防线:宁可丢掉这一帧 usage,也不能往会话日志里写 NaN。
|
|
180
|
+
*
|
|
181
|
+
* 天花板(刻意不做):不区分 cacheRead/cacheWrite。上游多数只报读不报写,
|
|
182
|
+
* 拆不开的字段硬拆只会更错;哪天上游稳定给 `cache_creation_input_tokens`
|
|
183
|
+
* 再补 `cacheWriteTokens`(升级路径就在这)。
|
|
184
|
+
*
|
|
185
|
+
* @returns DSH 契约用量;三个数全为 0 时返回 null(= 这一帧没真数据)
|
|
186
|
+
*/
|
|
187
|
+
export function toTokenUsage(tokens: UsageTokens | null): TokenUsage | null {
|
|
188
|
+
if (tokens === null) return null
|
|
189
|
+
// promptTokens 含缓存(normalizeUsage 的口径),DISJOINT 要求减掉
|
|
190
|
+
const input = finiteInt(tokens.promptTokens - tokens.cachedTokens)
|
|
191
|
+
const output = finiteInt(tokens.completionTokens)
|
|
192
|
+
const cachedTokens = finiteInt(tokens.cachedTokens)
|
|
193
|
+
if (input === 0 && output === 0 && cachedTokens === 0) return null
|
|
194
|
+
return {
|
|
195
|
+
inputTokens: input,
|
|
196
|
+
outputTokens: output,
|
|
197
|
+
...(cachedTokens > 0 ? { cacheReadTokens: cachedTokens } : {}),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 取非负有限整数:NaN / Infinity / 负数一律归 0,小数取整。 */
|
|
202
|
+
function finiteInt(v: number): number {
|
|
203
|
+
if (!Number.isFinite(v)) return 0
|
|
204
|
+
return Math.max(0, Math.round(v))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 一帧 SSE 里的可见文本内容(用于输出字符数估算)。 */
|
|
208
|
+
function frameText(chunk: Record<string, unknown>): string {
|
|
209
|
+
const choices = chunk.choices as Array<Record<string, unknown>> | undefined
|
|
210
|
+
if (choices === undefined) return ''
|
|
211
|
+
let out = ''
|
|
212
|
+
for (const ch of choices) {
|
|
213
|
+
const delta = ch.delta as Record<string, unknown> | undefined
|
|
214
|
+
if (delta !== undefined) {
|
|
215
|
+
if (typeof delta.content === 'string') out += delta.content
|
|
216
|
+
if (typeof delta.reasoning_content === 'string') out += delta.reasoning_content
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
// 少数实现把内容直接放在 message 里(非 delta)
|
|
220
|
+
const msg = ch.message as Record<string, unknown> | undefined
|
|
221
|
+
if (msg !== undefined) {
|
|
222
|
+
if (typeof msg.content === 'string') out += msg.content
|
|
223
|
+
if (typeof msg.reasoning_content === 'string') out += msg.reasoning_content
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return out
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 流结束后汇总出的东西。 */
|
|
230
|
+
export interface StreamUsage {
|
|
231
|
+
usage: UsageTokens | null
|
|
232
|
+
/** 输出内容字符数(上游没给 completion_tokens 时用来估算)。 */
|
|
233
|
+
outputChars: number
|
|
234
|
+
/** 首字节延迟(ms);一个字节都没收到时为 0。 */
|
|
235
|
+
ttfbMs: number
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 边透传边统计:原流**原样**出去,统计在旁边做。
|
|
240
|
+
*
|
|
241
|
+
* 为什么不用 `source.tee()`:tee 的两条分支互相牵制,慢的一边会把 buffer 憋住;
|
|
242
|
+
* 而统计这条分支得读完整个流才出得了 usage,客户端那边却要尽快拿到首字节。
|
|
243
|
+
* 手动转发是把上游 chunk **即刻**交给下游,统计只攒几个数字,零缓冲、
|
|
244
|
+
* 首字节延迟不受影响。
|
|
245
|
+
*
|
|
246
|
+
* @param source 上游流(插件已转成 OpenAI SSE)
|
|
247
|
+
* @param startedAt 请求开始时间(`Date.now()`),TTFB 以此为基准
|
|
248
|
+
*/
|
|
249
|
+
export function tapStreamUsage(
|
|
250
|
+
source: ReadableStream<Uint8Array>,
|
|
251
|
+
startedAt: number,
|
|
252
|
+
): { stream: ReadableStream<Uint8Array>; done: () => Promise<StreamUsage> } {
|
|
253
|
+
const dec = new TextDecoder()
|
|
254
|
+
let buf = ''
|
|
255
|
+
let merged: UsageTokens | null = null
|
|
256
|
+
let outputChars = 0
|
|
257
|
+
let firstAt = 0
|
|
258
|
+
|
|
259
|
+
const parseFrame = (frame: string): void => {
|
|
260
|
+
for (const line of frame.split('\n')) {
|
|
261
|
+
if (!line.startsWith('data:')) continue
|
|
262
|
+
const payload = line.slice(5).trim()
|
|
263
|
+
if (payload === '' || payload === '[DONE]') continue
|
|
264
|
+
let obj: unknown
|
|
265
|
+
try { obj = JSON.parse(payload) } catch { continue }
|
|
266
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) continue
|
|
267
|
+
merged = mergeUsage(merged, extractFrameUsage(obj))
|
|
268
|
+
outputChars += frameText(obj as Record<string, unknown>).length
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
273
|
+
start: async (ctrl) => {
|
|
274
|
+
const reader = source.getReader()
|
|
275
|
+
try {
|
|
276
|
+
for (;;) {
|
|
277
|
+
const { done, value } = await reader.read()
|
|
278
|
+
if (done) break
|
|
279
|
+
if (firstAt === 0) firstAt = Date.now()
|
|
280
|
+
// 统计用 stream:true 解码(跨 chunk 边界的多字节字符不会被切断);
|
|
281
|
+
// 转发出去的是原始字节 value,解码与转发互不影响
|
|
282
|
+
buf += dec.decode(value as Uint8Array, { stream: true })
|
|
283
|
+
let sep: number
|
|
284
|
+
while ((sep = buf.indexOf('\n\n')) >= 0) {
|
|
285
|
+
const frame = buf.slice(0, sep + 2)
|
|
286
|
+
buf = buf.slice(sep + 2)
|
|
287
|
+
parseFrame(frame)
|
|
288
|
+
}
|
|
289
|
+
ctrl.enqueue(value as Uint8Array)
|
|
290
|
+
}
|
|
291
|
+
if (buf !== '') parseFrame(buf)
|
|
292
|
+
} finally {
|
|
293
|
+
reader.releaseLock()
|
|
294
|
+
}
|
|
295
|
+
ctrl.close()
|
|
296
|
+
},
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
stream,
|
|
301
|
+
done: async () => ({ usage: merged, outputChars, ttfbMs: firstAt === 0 ? 0 : firstAt - startedAt }),
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 补齐估算:上游没给的字段用字符数估,并打标记。
|
|
307
|
+
*
|
|
308
|
+
* `requestChars` = 请求体字符数(估输入),`outputChars` = 输出字符数(估输出)。
|
|
309
|
+
* 上游给了的字段**不覆盖**——估算只填空,不抢真数据。
|
|
310
|
+
*/
|
|
311
|
+
export function withEstimates(
|
|
312
|
+
usage: UsageTokens | null,
|
|
313
|
+
requestChars: number,
|
|
314
|
+
outputChars: number,
|
|
315
|
+
): Usage {
|
|
316
|
+
const prompt = usage?.promptTokens ?? 0
|
|
317
|
+
const completion = usage?.completionTokens ?? 0
|
|
318
|
+
const inputEstimated = prompt === 0
|
|
319
|
+
const outputEstimated = completion === 0
|
|
320
|
+
return {
|
|
321
|
+
promptTokens: inputEstimated ? estimateTokens(requestChars) : prompt,
|
|
322
|
+
completionTokens: outputEstimated ? estimateTokens(outputChars) : completion,
|
|
323
|
+
cachedTokens: usage?.cachedTokens ?? 0,
|
|
324
|
+
inputEstimated,
|
|
325
|
+
outputEstimated,
|
|
326
|
+
}
|
|
327
|
+
}
|