dsh-router-core 0.4.5 → 0.4.7
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 +152 -12
- package/docs/ext.md +1 -1
- package/docs/protocols.md +198 -0
- package/docs/suppliers.md +2 -2
- package/lib/client-registry.js +146 -106
- package/lib/client.js +146 -106
- package/lib/ext-prompt/index.js +182 -17
- package/lib/index.js +2764 -63
- package/lib/suppliers/opencode/index.js +58 -30
- package/lib/types/ext/contract.d.ts +20 -1
- package/lib/types/ext-prompt/content.d.ts +7 -1
- package/lib/types/ext-prompt/mount.d.ts +12 -1
- package/lib/types/ext-prompt/plugin.d.ts +10 -0
- package/lib/types/ext-prompt/render.d.ts +14 -3
- package/lib/types/ext-prompt/sections.d.ts +95 -0
- package/lib/types/protocol/anthropic.d.ts +35 -0
- package/lib/types/protocol/chat.d.ts +25 -0
- package/lib/types/protocol/entry.d.ts +137 -0
- package/lib/types/protocol/gemini.d.ts +57 -0
- package/lib/types/protocol/index.d.ts +13 -0
- package/lib/types/protocol/responses.d.ts +41 -0
- package/lib/types/protocol/sse.d.ts +41 -0
- package/lib/types/protocol/stream.d.ts +92 -0
- package/lib/types/protocol/types.d.ts +224 -0
- package/lib/types/router/account-pool.d.ts +7 -2
- package/lib/types/router/index.d.ts +42 -1
- package/lib/types/router/types.d.ts +26 -3
- package/lib/types/router/usage-store.d.ts +4 -2
- package/lib/types/shared.d.ts +24 -0
- package/lib/types/suppliers/contract.d.ts +15 -1
- package/package.json +6 -6
- package/src/client/ExtDetail.tsx +25 -5
- package/src/client/group-controls.test.ts +53 -0
- package/src/client/group-controls.ts +43 -0
- package/src/client/router.css +20 -1
- package/src/ext/contract.ts +20 -1
- package/src/ext-prompt/assembly.test.ts +63 -0
- package/src/ext-prompt/content.ts +8 -10
- package/src/ext-prompt/ext-prompt.test.ts +38 -28
- package/src/ext-prompt/index.ts +50 -2
- package/src/ext-prompt/mount.test.ts +18 -1
- package/src/ext-prompt/mount.ts +22 -7
- package/src/ext-prompt/plugin.ts +64 -1
- package/src/ext-prompt/render.test.ts +1 -1
- package/src/ext-prompt/render.ts +17 -6
- package/src/ext-prompt/section-controls.test.ts +148 -0
- package/src/ext-prompt/section-filter.test.ts +140 -0
- package/src/ext-prompt/sections.test.ts +123 -0
- package/src/ext-prompt/sections.ts +193 -0
- package/src/ext-route.test.ts +16 -0
- package/src/index.ts +117 -6
- package/src/llm/adapter.test.ts +54 -0
- package/src/llm/adapter.ts +24 -2
- package/src/protocol/anthropic.ts +662 -0
- package/src/protocol/chat.ts +272 -0
- package/src/protocol/entry.ts +440 -0
- package/src/protocol/gemini.ts +548 -0
- package/src/protocol/index.ts +31 -0
- package/src/protocol/protocol.test.ts +728 -0
- package/src/protocol/responses.ts +551 -0
- package/src/protocol/sse.ts +94 -0
- package/src/protocol/stream.ts +195 -0
- package/src/protocol/types.ts +249 -0
- package/src/protocol/wire-e2e.test.ts +208 -0
- package/src/protocol/wiring.test.ts +225 -0
- package/src/router/account-pool.test.ts +35 -0
- package/src/router/account-pool.ts +15 -4
- package/src/router/index.ts +143 -24
- package/src/router/types.ts +26 -3
- package/src/router/usage-store.ts +4 -2
- package/src/shared.test.ts +35 -0
- package/src/shared.ts +33 -0
- package/src/suppliers/contract.ts +15 -2
- package/src/suppliers/opencode/plugin.test.ts +79 -25
- package/src/suppliers/opencode/plugin.ts +60 -29
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Chat Completions 协议 —— **规范形态本身**,所以编解码是恒等变换。
|
|
3
|
+
*
|
|
4
|
+
* 这个文件承担两件别处不做的事:
|
|
5
|
+
*
|
|
6
|
+
* 1. **请求侧恒等**:`toChat`/`fromChat` 原样返回。供应商契约
|
|
7
|
+
* (`suppliers/contract.ts` 的 `chatOnce`)吃的就是 Chat Completions 请求体,
|
|
8
|
+
* 多一层拷贝只会让「哪一层改的」变难查。
|
|
9
|
+
*
|
|
10
|
+
* 2. **响应侧的规范事件翻译**:OpenAI SSE → `ChatEvent`,以及反向。这是**所有
|
|
11
|
+
* 协议共用的脊柱** —— Anthropic/Gemini 的 `decodeFrame` 拿到自己的帧后,
|
|
12
|
+
* 也要靠这里的逻辑把内容映射成同一组事件,才能让「Anthropic 客户端问、
|
|
13
|
+
* Gemini 供应商答」这条路只有两个模块参与,而不是 12 条路径。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ 这里的映射规则与 `llm/adapter.ts` 的 `translateSse` 是**同一套语义的两种
|
|
16
|
+
* 落点**:那边翻译成 DSH 的 `StreamChunk`(给宿主 agent loop 用),这边翻译
|
|
17
|
+
* 成 `ChatEvent`(给协议矩阵用)。两处都留着,因为消费方不同、且
|
|
18
|
+
* `translateSse` 里那些踩过坑的处理(残缺 tool_call 收尾、空串 name 剥除)
|
|
19
|
+
* 只对 DSH 有意义;但**改任何一处规则都必须看另一处**,否则同一个上游在
|
|
20
|
+
* 两个入口表现不一致。
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
type ChatEvent,
|
|
24
|
+
type CodecContext,
|
|
25
|
+
type FinishReason,
|
|
26
|
+
type JsonObject,
|
|
27
|
+
type ProtocolCodec,
|
|
28
|
+
arr,
|
|
29
|
+
finishOf,
|
|
30
|
+
num,
|
|
31
|
+
obj,
|
|
32
|
+
str,
|
|
33
|
+
} from './types.ts'
|
|
34
|
+
import { dataFrame } from './sse.ts'
|
|
35
|
+
|
|
36
|
+
/* ------------------------------------------------------------------ */
|
|
37
|
+
/* 请求侧:恒等 */
|
|
38
|
+
/* ------------------------------------------------------------------ */
|
|
39
|
+
|
|
40
|
+
export const chatCodec: ProtocolCodec = {
|
|
41
|
+
id: 'chat',
|
|
42
|
+
toChat: (body) => body,
|
|
43
|
+
fromChat: (body) => body,
|
|
44
|
+
|
|
45
|
+
/* ---------------- 响应侧:OpenAI SSE → ChatEvent ---------------- */
|
|
46
|
+
|
|
47
|
+
decodeFrame(frame) {
|
|
48
|
+
let chunk: JsonObject
|
|
49
|
+
try {
|
|
50
|
+
chunk = JSON.parse(frame) as JsonObject
|
|
51
|
+
} catch {
|
|
52
|
+
return [] // 坏帧跳过(半截 JSON 是上游常态)
|
|
53
|
+
}
|
|
54
|
+
return chatBodyEvents(chunk)
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 非流式响应体 → 规范事件。
|
|
59
|
+
*
|
|
60
|
+
* 复用同一个 `chatBodyEvents`:一个非流式 body 就是一帧(内容在
|
|
61
|
+
* `choices[0].message` 里、`finish_reason` 已给、`usage` 在外层)。单独实现
|
|
62
|
+
* 一份只会让「流式与非流式行为不一致」有地方可长。
|
|
63
|
+
*/
|
|
64
|
+
decodeJson(body) {
|
|
65
|
+
return chatBodyEvents(body)
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/* ---------------- 响应侧:ChatEvent → OpenAI SSE ---------------- */
|
|
69
|
+
|
|
70
|
+
encodeFrame(event) {
|
|
71
|
+
switch (event.type) {
|
|
72
|
+
case 'text':
|
|
73
|
+
return [dataFrame(JSON.stringify(choiceDelta({ content: event.text })))]
|
|
74
|
+
case 'reasoning':
|
|
75
|
+
return [dataFrame(JSON.stringify(choiceDelta({ reasoning_content: event.text })))]
|
|
76
|
+
case 'tool-start':
|
|
77
|
+
return [
|
|
78
|
+
dataFrame(
|
|
79
|
+
JSON.stringify(
|
|
80
|
+
choiceDelta({
|
|
81
|
+
tool_calls: [{
|
|
82
|
+
index: event.index,
|
|
83
|
+
...(event.id === undefined ? {} : { id: event.id }),
|
|
84
|
+
type: 'function',
|
|
85
|
+
function: { name: event.name ?? '', arguments: '' },
|
|
86
|
+
}],
|
|
87
|
+
}),
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
]
|
|
91
|
+
case 'tool-args':
|
|
92
|
+
return [
|
|
93
|
+
dataFrame(
|
|
94
|
+
JSON.stringify(
|
|
95
|
+
choiceDelta({
|
|
96
|
+
tool_calls: [{ index: event.index, function: { arguments: event.delta } }],
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
]
|
|
101
|
+
case 'usage':
|
|
102
|
+
// 只在有非零值时发:全 0 的 usage 帧会让客户端把「上游没给」当成「给了 0」
|
|
103
|
+
if (event.promptTokens === 0 && event.completionTokens === 0 && event.cachedTokens === 0) return []
|
|
104
|
+
return [
|
|
105
|
+
dataFrame(
|
|
106
|
+
JSON.stringify({
|
|
107
|
+
choices: [],
|
|
108
|
+
usage: {
|
|
109
|
+
prompt_tokens: event.promptTokens,
|
|
110
|
+
completion_tokens: event.completionTokens,
|
|
111
|
+
total_tokens: event.promptTokens + event.completionTokens,
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
]
|
|
116
|
+
case 'error':
|
|
117
|
+
return [dataFrame(JSON.stringify({ error: { message: event.message, type: 'api_error' } }))]
|
|
118
|
+
case 'finish':
|
|
119
|
+
return [
|
|
120
|
+
dataFrame(JSON.stringify(choiceDelta({}, finishToOpenAi(event.reason)))),
|
|
121
|
+
'data: [DONE]\n\n',
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
startFrames: () => [],
|
|
127
|
+
endFrames: () => [],
|
|
128
|
+
|
|
129
|
+
/** 规范事件 → 非流式 body。 */
|
|
130
|
+
encodeJson(events, ctx) {
|
|
131
|
+
let content = ''
|
|
132
|
+
let reasoning = ''
|
|
133
|
+
let finish: FinishReason = 'stop'
|
|
134
|
+
let usage: Extract<ChatEvent, { type: 'usage' }> | undefined
|
|
135
|
+
const calls = new Map<number, { id?: string; name: string; args: string }>()
|
|
136
|
+
for (const ev of events) {
|
|
137
|
+
switch (ev.type) {
|
|
138
|
+
case 'text': content += ev.text; break
|
|
139
|
+
case 'reasoning': reasoning += ev.text; break
|
|
140
|
+
case 'tool-start':
|
|
141
|
+
calls.set(ev.index, { ...(ev.id === undefined ? {} : { id: ev.id }), name: ev.name ?? '', args: '' })
|
|
142
|
+
break
|
|
143
|
+
case 'tool-args': {
|
|
144
|
+
const c = calls.get(ev.index) ?? { name: '', args: '' }
|
|
145
|
+
c.args += ev.delta
|
|
146
|
+
calls.set(ev.index, c)
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
case 'finish': finish = ev.reason; break
|
|
150
|
+
case 'usage': usage = ev; break
|
|
151
|
+
case 'error': break
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const message: JsonObject = { role: 'assistant', content }
|
|
155
|
+
if (reasoning !== '') message.reasoning_content = reasoning
|
|
156
|
+
const toolCalls = [...calls.entries()]
|
|
157
|
+
.sort((a, b) => a[0] - b[0])
|
|
158
|
+
.filter(([, c]) => c.name !== '')
|
|
159
|
+
.map(([i, c]) => ({ id: c.id ?? `call_${i}`, type: 'function', function: { name: c.name, arguments: c.args } }))
|
|
160
|
+
if (toolCalls.length > 0) message.tool_calls = toolCalls
|
|
161
|
+
const body: JsonObject = {
|
|
162
|
+
id: `chatcmpl-${Date.now().toString(36)}`,
|
|
163
|
+
object: 'chat.completion',
|
|
164
|
+
created: Math.floor(Date.now() / 1000),
|
|
165
|
+
model: ctx.model,
|
|
166
|
+
choices: [{ index: 0, message, finish_reason: finishToOpenAi(finish) }],
|
|
167
|
+
}
|
|
168
|
+
if (usage !== undefined) {
|
|
169
|
+
body.usage = {
|
|
170
|
+
prompt_tokens: usage.promptTokens,
|
|
171
|
+
completion_tokens: usage.completionTokens,
|
|
172
|
+
total_tokens: usage.promptTokens + usage.completionTokens,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return body
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** OpenAI 的一帧 `choices[0].delta`。 */
|
|
180
|
+
function choiceDelta(delta: JsonObject, finish?: string): JsonObject {
|
|
181
|
+
return {
|
|
182
|
+
id: 'chatcmpl-stream',
|
|
183
|
+
object: 'chat.completion.chunk',
|
|
184
|
+
choices: [{ index: 0, delta, finish_reason: finish ?? null }],
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 一个 OpenAI 响应体(流式帧或非流式 body,两者形状一致)→ 规范事件。
|
|
190
|
+
*
|
|
191
|
+
* 流式与非流式共用:非流式 body 就是「一帧」(`choices[0].message` 带全量
|
|
192
|
+
* 内容、`finish_reason` 已给、`usage` 在外层)。分开写两份只会让两边行为
|
|
193
|
+
* 漂移 —— 而漂移的表现是「同一个上游在流式下工具名对、非流式下为空」。
|
|
194
|
+
*/
|
|
195
|
+
function chatBodyEvents(chunk: JsonObject): ChatEvent[] {
|
|
196
|
+
const out: ChatEvent[] = []
|
|
197
|
+
const usage = decodeUsage(chunk)
|
|
198
|
+
if (usage !== undefined) out.push(usage)
|
|
199
|
+
const err = obj(chunk.error)
|
|
200
|
+
if (err !== undefined) {
|
|
201
|
+
out.push({ type: 'error', message: str(err.message) ?? 'upstream error' })
|
|
202
|
+
return out
|
|
203
|
+
}
|
|
204
|
+
const ch = obj(arr(chunk.choices)?.[0])
|
|
205
|
+
if (ch === undefined) return out
|
|
206
|
+
// 内容在 delta(流式)或 message(非流式)里
|
|
207
|
+
const delta = obj(ch.delta) ?? obj(ch.message)
|
|
208
|
+
if (delta !== undefined) {
|
|
209
|
+
const text = str(delta.content)
|
|
210
|
+
if (text !== undefined) out.push({ type: 'text', text })
|
|
211
|
+
// reasoning 有两种拼写:reasoning_content(国产系)/ reasoning(OpenAI o 系)
|
|
212
|
+
const reasoning = str(delta.reasoning_content) ?? str(obj(delta.reasoning)?.text)
|
|
213
|
+
if (reasoning !== undefined) out.push({ type: 'reasoning', text: reasoning })
|
|
214
|
+
for (const ev of decodeToolCalls(delta.tool_calls)) out.push(ev)
|
|
215
|
+
}
|
|
216
|
+
const finish = ch.finish_reason
|
|
217
|
+
if (finish !== undefined && finish !== null) {
|
|
218
|
+
out.push({ type: 'finish', reason: finishOf(finish) })
|
|
219
|
+
}
|
|
220
|
+
return out
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function finishToOpenAi(reason: FinishReason): string {
|
|
224
|
+
switch (reason) {
|
|
225
|
+
case 'length': return 'length'
|
|
226
|
+
case 'tool-calls': return 'tool_calls'
|
|
227
|
+
case 'content-filter': return 'content_filter'
|
|
228
|
+
case 'error': return 'stop'
|
|
229
|
+
default: return 'stop'
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** OpenAI SSE 帧 → 工具调用增量。 */
|
|
234
|
+
function decodeToolCalls(v: unknown): ChatEvent[] {
|
|
235
|
+
const list = arr(v)
|
|
236
|
+
if (list === undefined) return []
|
|
237
|
+
const out: ChatEvent[] = []
|
|
238
|
+
for (const item of list) {
|
|
239
|
+
const tc = obj(item)
|
|
240
|
+
if (tc === undefined) continue
|
|
241
|
+
const index = num(tc.index) ?? 0
|
|
242
|
+
const fn = obj(tc.function)
|
|
243
|
+
const name = fn === undefined ? undefined : str(fn.name)
|
|
244
|
+
const id = str(tc.id)
|
|
245
|
+
// 只在**有新信息**时发 tool-start:后续增量帧的名字是空串(或 null),
|
|
246
|
+
// 照发会把已建立的名字冲掉(`llm/adapter.ts` 里同一个坑)
|
|
247
|
+
if (name !== undefined || id !== undefined) {
|
|
248
|
+
out.push({ type: 'tool-start', index, ...(id === undefined ? {} : { id }), ...(name === undefined ? {} : { name }) })
|
|
249
|
+
}
|
|
250
|
+
const args = fn === undefined ? undefined : str(fn.arguments)
|
|
251
|
+
if (args !== undefined && args !== '') out.push({ type: 'tool-args', index, delta: args })
|
|
252
|
+
}
|
|
253
|
+
return out
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 从一帧 OpenAI 响应里取用量。
|
|
258
|
+
*
|
|
259
|
+
* 口径与 `router/usage-tokens.ts` 的 `normalizeUsage` 一致:`prompt_tokens`
|
|
260
|
+
* **含**缓存(OpenAI 系如此),`cachedTokens` 是它的子集。
|
|
261
|
+
*/
|
|
262
|
+
function decodeUsage(chunk: JsonObject): ChatEvent | undefined {
|
|
263
|
+
const u = obj(chunk.usage)
|
|
264
|
+
if (u === undefined) return undefined
|
|
265
|
+
const prompt = num(u.prompt_tokens) ?? num(u.input_tokens) ?? 0
|
|
266
|
+
const completion = num(u.completion_tokens) ?? num(u.output_tokens) ?? 0
|
|
267
|
+
const cached = num(u.cached_tokens) ?? num(obj(u.prompt_tokens_details)?.cached_tokens) ?? 0
|
|
268
|
+
return { type: 'usage', promptTokens: prompt, completionTokens: completion, cachedTokens: cached }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 收尾原因 → 规范形态(在各协议模块里共用,从这再导出省一次 import)。 */
|
|
272
|
+
export { finishOf }
|