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,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 协议接线的**接线事实**判据 —— 守「四种协议真的挂上去了」这件事。
|
|
3
|
+
*
|
|
4
|
+
* ════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 为什么需要这一组(而不是只测编解码器)
|
|
6
|
+
* ════════════════════════════════════════════════════════════════════
|
|
7
|
+
* 编解码器全绿、端点却是 404/405 —— 这是 2026-10-21 真实发生的状态:
|
|
8
|
+
* - `/v1beta/models/` 的 prefix 带了尾斜杠,宿主匹配规则是
|
|
9
|
+
* `pathname.startsWith(prefix + '/')`,于是永远比不上 → GET 404 / POST 405;
|
|
10
|
+
* - `chatCompletions` 里造了 `out`(sink 包装)却没用它,所有写入仍走 `res`
|
|
11
|
+
* → 非 chat 协议拿到的仍是 OpenAI 形态。
|
|
12
|
+
* 两条都在**单元测试全绿**的前提下发生。所以这里测的是接线本身:
|
|
13
|
+
* 路径前缀、以及「写入方到底写到谁」。
|
|
14
|
+
*/
|
|
15
|
+
import test from 'node:test'
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
import { readFileSync } from 'node:fs'
|
|
18
|
+
import { join, dirname } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { geminiModelFromPath, geminiStreamFromPath } from './entry.ts'
|
|
21
|
+
import { StreamState, translateStream } from './stream.ts'
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
const ROOT = join(HERE, '..', '..')
|
|
25
|
+
|
|
26
|
+
function source(rel: string): string {
|
|
27
|
+
return readFileSync(join(ROOT, rel), 'utf8')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* ------------------------------------------------------------------ */
|
|
31
|
+
/* 1. Gemini 的 prefix 不能带尾斜杠 */
|
|
32
|
+
/* ------------------------------------------------------------------ */
|
|
33
|
+
|
|
34
|
+
test('★ Gemini 路由注册的 prefix 不带尾斜杠(否则宿主永不匹配 → 404/405)', () => {
|
|
35
|
+
// 判据直接读源码:这是**接线**的正确性,不是某个函数的正确性。
|
|
36
|
+
// 为什么要钉这一条:宿主 `Table.match()` 的规则是
|
|
37
|
+
// `pathname === prefix || pathname.startsWith(prefix + '/')`
|
|
38
|
+
// 带尾斜杠时它去比 `'/v1beta/models//'`,实际请求永远比不过。404/405 的
|
|
39
|
+
// 症状离根因太远(看起来像「端点没实现」),必须在这里拦住。
|
|
40
|
+
const src = source('src/index.ts')
|
|
41
|
+
const m = /kind:\s*'prefix',\s*\n\s*path:\s*'([^']+)'/.exec(src)
|
|
42
|
+
assert.ok(m !== null, '没找到 prefix 路由注册 —— 路由被删了?')
|
|
43
|
+
const path = m[1]!
|
|
44
|
+
assert.ok(
|
|
45
|
+
!path.endsWith('/'),
|
|
46
|
+
`prefix 尾斜杠会让宿主匹配成 '${path}/',实际请求永不命中(2026-10-21 实测 404/405)`,
|
|
47
|
+
)
|
|
48
|
+
assert.equal(path, '/v1beta/models')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('Gemini 路径解析:模型名从 URL 取,streamGenerateContent 也能认', () => {
|
|
52
|
+
assert.equal(geminiModelFromPath('/v1beta/models/gemini-2.5-pro:generateContent'), 'gemini-2.5-pro')
|
|
53
|
+
assert.equal(geminiModelFromPath('/v1beta/models/x:streamGenerateContent'), 'x')
|
|
54
|
+
// 带 query(Gemini 的 ?key= 形态)也能取
|
|
55
|
+
assert.equal(geminiModelFromPath('/v1beta/models/m:generateContent?key=abc'), 'm')
|
|
56
|
+
// 命名空间带斜杠的模型 id
|
|
57
|
+
assert.equal(geminiModelFromPath('/v1beta/models/publishers/google/models/gemini:generateContent'), 'publishers/google/models/gemini')
|
|
58
|
+
// 不认识的路径返回 undefined(不能瞎猜一个模型名)
|
|
59
|
+
assert.equal(geminiModelFromPath('/v1/models'), undefined)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
/* ------------------------------------------------------------------ */
|
|
63
|
+
/* 2. 非 chat 协议的响应必须真的经过 sink */
|
|
64
|
+
/* ------------------------------------------------------------------ */
|
|
65
|
+
|
|
66
|
+
test('★ chatCompletions 内部一律写 out(sink 包装),不许再写 res', () => {
|
|
67
|
+
// 这条守的是「协议翻译是否真的接上」:造了 `out` 却仍写 `res` = 翻译层
|
|
68
|
+
// 形同虚设,非 chat 客户端会收到 OpenAI 形态(2026-10-21 实测状态)。
|
|
69
|
+
const src = source('src/router/index.ts')
|
|
70
|
+
const start = src.indexOf('async chatCompletions(')
|
|
71
|
+
assert.ok(start > 0, '找不到 chatCompletions')
|
|
72
|
+
const end = src.indexOf('/** 调指定供应商。', start)
|
|
73
|
+
assert.ok(end > start, '找不到 chatCompletions 的结尾')
|
|
74
|
+
const body = src.slice(start, end)
|
|
75
|
+
|
|
76
|
+
// 允许出现 res 的地方:签名行 + out 的定义行
|
|
77
|
+
const allowed = [
|
|
78
|
+
'async chatCompletions(req: ChatRequest, res: ServerResponse, sink?: ResponseSink): Promise<void> {',
|
|
79
|
+
'const out = sink === undefined ? res : sinkRes(sink)',
|
|
80
|
+
]
|
|
81
|
+
let scanned = body
|
|
82
|
+
for (const a of allowed) scanned = scanned.replace(a, '')
|
|
83
|
+
|
|
84
|
+
const hits = [...scanned.matchAll(/\bres\b/g)]
|
|
85
|
+
assert.equal(
|
|
86
|
+
hits.length,
|
|
87
|
+
0,
|
|
88
|
+
`chatCompletions 方法体内还有 ${hits.length} 处直接使用 res —— `
|
|
89
|
+
+ '响应绕过了 sink,非 chat 协议会收到 OpenAI 形态',
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('非 chat 入口确实把 sink 传给 chatCompletions(不是只 parse 不接出站)', () => {
|
|
94
|
+
const src = source('src/index.ts')
|
|
95
|
+
// enterProtocol 里必须:造 sink 并作为第三个参数传进去
|
|
96
|
+
const m = /const sink = outboundSink\(protocol, parsed\.model, res\)/.exec(src)
|
|
97
|
+
assert.ok(m !== null, '没造 outboundSink')
|
|
98
|
+
const call = /await router\.chatCompletions\(\s*\{[\s\S]{0,400}?\},\s*res,\s*sink,\s*\)/.exec(src)
|
|
99
|
+
assert.ok(call !== null, 'sink 没传给 chatCompletions —— 出站翻译不会生效')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/* ------------------------------------------------------------------ */
|
|
103
|
+
/* 3. 四条入站路由都注册了 */
|
|
104
|
+
/* ------------------------------------------------------------------ */
|
|
105
|
+
|
|
106
|
+
test('★ 四种协议的入站端点全部注册(少一条就是那种协议不可用)', () => {
|
|
107
|
+
const src = source('src/index.ts')
|
|
108
|
+
// 只认「route(...) 调用」与 prefix register,避免注释里提到也算命中
|
|
109
|
+
const routes = [
|
|
110
|
+
{ name: 'chat', pattern: /route\('\/v1\/chat\/completions'/ },
|
|
111
|
+
{ name: 'anthropic', pattern: /route\('\/v1\/messages'/ },
|
|
112
|
+
{ name: 'responses', pattern: /route\('\/v1\/responses'/ },
|
|
113
|
+
{ name: 'gemini', pattern: /path: '\/v1beta\/models'/ },
|
|
114
|
+
]
|
|
115
|
+
for (const r of routes) {
|
|
116
|
+
assert.ok(r.pattern.test(src), `${r.name} 的入站路由没注册`)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('count_tokens 明确 501(不编 token 数)', () => {
|
|
121
|
+
const src = source('src/index.ts')
|
|
122
|
+
assert.ok(src.includes("route('/v1/messages/count_tokens'"), 'count_tokens 路由没注册')
|
|
123
|
+
assert.ok(/writeJson\(res,\s*501/.test(src), 'count_tokens 应当 501,不该假装能数')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
/* ------------------------------------------------------------------ */
|
|
127
|
+
/* 4. 供应商 wire 的读取点存在 */
|
|
128
|
+
/* ------------------------------------------------------------------ */
|
|
129
|
+
|
|
130
|
+
test('★ 供应商声明非 chat wire 时,chatOnce 调用被包翻译', () => {
|
|
131
|
+
const src = source('src/router/index.ts')
|
|
132
|
+
const start = src.indexOf('private async chatWithSupplier(')
|
|
133
|
+
// 下一个方法(testModel)的文档注释起点作结束标记
|
|
134
|
+
const end = src.indexOf('测试某供应商的某模型是否可用。', start)
|
|
135
|
+
assert.ok(start > 0 && end > start, '找不到 chatWithSupplier')
|
|
136
|
+
const body = src.slice(start, end)
|
|
137
|
+
// chatWithSupplier 必须用包过的 chatOnce,而不是裸 s.chatOnce
|
|
138
|
+
assert.ok(body.includes('const chatOnce = this.wireOf(s)'), 'chatWithSupplier 没取 wire 包装')
|
|
139
|
+
assert.ok(!/await s\.chatOnce\(/.test(body), 'chatWithSupplier 里还在裸调 s.chatOnce,翻译被绕过')
|
|
140
|
+
|
|
141
|
+
// wireChatOnce 本体:读 s.wire、非 chat 时包 translateStream
|
|
142
|
+
const ws = src.indexOf('function wireChatOnce(')
|
|
143
|
+
const we = src.indexOf('/** 从上游响应体提取错误信息', ws)
|
|
144
|
+
assert.ok(ws > 0 && we > ws, '找不到 wireChatOnce')
|
|
145
|
+
const impl = src.slice(ws, we)
|
|
146
|
+
assert.ok(/s\.wire \?\? 'chat'/.test(impl), 'wireChatOnce 没读供应商的 wire 声明')
|
|
147
|
+
assert.ok(impl.includes('translateStream(r.stream'), 'wireChatOnce 没给上游流套协议翻译')
|
|
148
|
+
assert.ok(impl.includes('codec.fromChat('), 'wireChatOnce 没翻请求体')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('★ Gemini 的流式意图来自 URL 路径(body 里没有 stream 字段)', () => {
|
|
152
|
+
// 事故(2026-10-21 在 3080 实测):`:streamGenerateContent` 请求被当成
|
|
153
|
+
// 非流式,客户端拿到一整个 JSON 却按 SSE 解析 ⇒ 空响应。
|
|
154
|
+
assert.equal(geminiStreamFromPath('/v1beta/models/m:streamGenerateContent'), true)
|
|
155
|
+
assert.equal(geminiStreamFromPath('/v1beta/models/m:streamGenerateContent?alt=sse'), true)
|
|
156
|
+
assert.equal(geminiStreamFromPath('/v1beta/models/m:generateContent'), false)
|
|
157
|
+
// 路径没表态 → undefined(交给 body 的 stream 决定)
|
|
158
|
+
assert.equal(geminiStreamFromPath('/v1/messages'), undefined)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('★ 入口确实把 streamFromPath 传进 parseInbound(否则上面那条解析等于没接)', () => {
|
|
162
|
+
const src = source('src/index.ts')
|
|
163
|
+
assert.ok(
|
|
164
|
+
/streamFromPath: protocol === 'gemini' \? geminiStreamFromPath\(req\.url \?\? ''\)/.test(src),
|
|
165
|
+
'Gemini 的路径流式意图没接进 parseInbound',
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
/* ------------------------------------------------------------------ */
|
|
170
|
+
/* 4. 流式收尾:用量必须进收尾帧,且不许重复 message_delta */
|
|
171
|
+
/* ------------------------------------------------------------------ */
|
|
172
|
+
|
|
173
|
+
test('★ Anthropic 流式收尾:message_delta 只发一次,且带真实 output_tokens', async () => {
|
|
174
|
+
// 事故(2026-10-21 在 3080 实测):finish 与 usage 各发一帧 message_delta,
|
|
175
|
+
// 第一帧是 stop_reason + output_tokens:0(占位)、第二帧才是真数。
|
|
176
|
+
// Anthropic 客户端取第一个 ⇒ 用量永远读成 0。
|
|
177
|
+
const upstream = new ReadableStream<Uint8Array>({
|
|
178
|
+
start(ctrl) {
|
|
179
|
+
ctrl.enqueue(new TextEncoder().encode(
|
|
180
|
+
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n'
|
|
181
|
+
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":7}}\n\n'
|
|
182
|
+
+ 'data: [DONE]\n\n',
|
|
183
|
+
))
|
|
184
|
+
ctrl.close()
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
// 直接驱动 StreamState:translateStream 会自己建 state,这里要拿到它的收尾帧
|
|
188
|
+
const state = new StreamState('chat', 'anthropic', 'm')
|
|
189
|
+
const text = await new Response(translateStream(upstream, state)).text()
|
|
190
|
+
|
|
191
|
+
const deltas = text.split('\n\n').filter((f) => f.includes('event: message_delta'))
|
|
192
|
+
assert.equal(deltas.length, 1, `message_delta 应当只发一次,实际 ${deltas.length} 次`)
|
|
193
|
+
|
|
194
|
+
const payload = JSON.parse(deltas[0]!.split('\ndata: ')[1]!) as {
|
|
195
|
+
delta: { stop_reason?: string }
|
|
196
|
+
usage: { output_tokens?: number }
|
|
197
|
+
}
|
|
198
|
+
assert.equal(payload.delta.stop_reason, 'end_turn')
|
|
199
|
+
assert.equal(payload.usage.output_tokens, 7, '收尾帧的 output_tokens 必须是真数,不是占位 0')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('★ Gemini 流式:functionCall 必须排在 finishReason 之前', async () => {
|
|
203
|
+
// 事故(2026-10-22 在 3080 实测):Gemini 的 functionCall 攒到流末才发,
|
|
204
|
+
// 而 finishReason 由 encodeFrame(finish) 提前发出 —— 客户端看到 finishReason
|
|
205
|
+
// 就收尾这一轮,随后到达的工具调用被丢掉。
|
|
206
|
+
const state = new StreamState('chat', 'gemini', 'm')
|
|
207
|
+
const upstream = new ReadableStream<Uint8Array>({
|
|
208
|
+
start(ctrl) {
|
|
209
|
+
ctrl.enqueue(new TextEncoder().encode(
|
|
210
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"get_weather","arguments":""}}]}}]}\n\n'
|
|
211
|
+
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"city\\":\\"Beijing\\"}"}}]}}]}\n\n'
|
|
212
|
+
+ 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n'
|
|
213
|
+
+ 'data: [DONE]\n\n',
|
|
214
|
+
))
|
|
215
|
+
ctrl.close()
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
const text = await new Response(translateStream(upstream, state)).text()
|
|
219
|
+
const callAt = text.indexOf('functionCall')
|
|
220
|
+
const finishAt = text.indexOf('finishReason')
|
|
221
|
+
assert.ok(callAt >= 0, 'functionCall 丢了')
|
|
222
|
+
assert.ok(finishAt >= 0, 'finishReason 丢了')
|
|
223
|
+
assert.ok(callAt < finishAt, `functionCall(idx ${callAt}) 必须排在 finishReason(idx ${finishAt}) 之前`)
|
|
224
|
+
assert.ok(text.includes('Beijing'), '工具参数没攒完整')
|
|
225
|
+
})
|
|
@@ -660,3 +660,38 @@ test('【隔离】不同供应商的同名模型,各自独立冷却', () => {
|
|
|
660
660
|
// B 供应商同名模型不受影响
|
|
661
661
|
assert.equal(pb.pick(list, [], 'deepseek-v4-flash'), 'acc', 'B 池同名模型照常可用')
|
|
662
662
|
})
|
|
663
|
+
|
|
664
|
+
// ============ 限流冷却对齐上游声明的重置时刻(resetAt) ============
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* 2026-10-15 事故:6004 文案自带「将在 … 重置」(小时级),但退避上限 5min →
|
|
668
|
+
* 到点回来再撞,11 个号轮流被打冷。锁住:带 resetAt 就冷到那个点;
|
|
669
|
+
* 过期/未带的 resetAt 退回指数退避(行为与改动前一致)。
|
|
670
|
+
*/
|
|
671
|
+
test('【resetAt】限流冷却到上游声明的重置时刻,而不是 5min 退避上限', () => {
|
|
672
|
+
const p = pool()
|
|
673
|
+
const list = accs(['a'])
|
|
674
|
+
const resetAt = Date.now() + 3 * 3600_000 // 3 小时后重置
|
|
675
|
+
p.noteFailure('a', 'm1', 'rate_limit', 'codebuddy 6004: 您的使用量已超出频率限制,将在 … 重置', resetAt)
|
|
676
|
+
const row = p.decorate(list, 'm1')[0]
|
|
677
|
+
assert.equal(row?.cooling, true)
|
|
678
|
+
assert.equal(row?.until === undefined ? 0 : new Date(row.until).getTime(), resetAt, '冷到上游重置点')
|
|
679
|
+
assert.equal(p.pick(list, [], 'm1'), undefined, '重置前不该再被选中')
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
test('【resetAt】过期的重置时刻退回指数退避;不带 resetAt 行为不变', () => {
|
|
683
|
+
const p = pool()
|
|
684
|
+
const list = accs(['a'])
|
|
685
|
+
// 上游报的重置时刻已过 → 退避(首档 2s 量级),不是「不冷」也不是冷很久
|
|
686
|
+
p.noteFailure('a', 'm1', 'rate_limit', '429', Date.now() - 1000)
|
|
687
|
+
const row = p.decorate(list, 'm1')[0]
|
|
688
|
+
const until = row?.until === undefined ? 0 : new Date(row.until).getTime()
|
|
689
|
+
assert.ok(until > Date.now(), '仍要冷')
|
|
690
|
+
assert.ok(until - Date.now() <= 60_000, '退回短退避,而不是按过期时刻冷')
|
|
691
|
+
// 不带 resetAt(其它供应商/文案没解析出):同样短退避
|
|
692
|
+
const p2 = pool()
|
|
693
|
+
p2.noteFailure('b', 'm1', 'rate_limit', '429')
|
|
694
|
+
const row2 = p2.decorate(accs(['b']), 'm1')[0]
|
|
695
|
+
const until2 = row2?.until === undefined ? 0 : new Date(row2.until).getTime()
|
|
696
|
+
assert.ok(until2 > Date.now() && until2 - Date.now() <= 60_000)
|
|
697
|
+
})
|
|
@@ -54,7 +54,9 @@ const SESSION_DEAD_COOLDOWN_MS = 30 * MINUTE
|
|
|
54
54
|
*
|
|
55
55
|
* 冷却策略对齐 9router 的 accountFallback / errorConfig:
|
|
56
56
|
* - `rate_limit` 用**指数退避**(9router 的 `backoff: true`):偶发限流只
|
|
57
|
-
* 短冷,持续限流越退越久,比固定 1
|
|
57
|
+
* 短冷,持续限流越退越久,比固定 1 分钟两头不讨好要准。上游自己声明了
|
|
58
|
+
* **重置时刻**(resetAt,如 6004「将在 … 重置」)时冷到该点——频率配额是
|
|
59
|
+
* 小时级的,退避上限(5min)只会到点回来再撞。
|
|
58
60
|
* - 未知/瞬时错误用**瞬时短冷却**(9router 默认分支 TRANSIENT_COOLDOWN_MS):
|
|
59
61
|
* 每次失败都冻结,坏号不会留在池里被下一个请求再选中。
|
|
60
62
|
*
|
|
@@ -495,8 +497,13 @@ export class AccountPool {
|
|
|
495
497
|
return `${this.supplierId}${SEP}${modelId}`
|
|
496
498
|
}
|
|
497
499
|
|
|
498
|
-
/**
|
|
499
|
-
|
|
500
|
+
/**
|
|
501
|
+
* 记录一次失败:按状态处置(冷却/退避),落在「(模型, 连接)」这个冷却单元上。
|
|
502
|
+
* `resetAt` = 上游声明的重置时刻(epoch ms):限流退避优先冷到该点——
|
|
503
|
+
* 频率配额是小时级的,5min 上限的指数退避只会到点回来再撞 6004,
|
|
504
|
+
* 把号轮流打冷(2026-10-15 的 11 号全灭事故)。
|
|
505
|
+
*/
|
|
506
|
+
noteFailure(uid: string, modelId: string, state: AccountState, message: string, resetAt?: number): void {
|
|
500
507
|
const rule = RULES[state]
|
|
501
508
|
if (rule.cooldown === 0) return // no_such_model 等:不冷不记
|
|
502
509
|
// session_dead 是连接级冷却(登录态问题,跨该号所有模型),到期自愈——
|
|
@@ -514,7 +521,11 @@ export class AccountPool {
|
|
|
514
521
|
if (rule.cooldown === 'backoff') {
|
|
515
522
|
// 连续被限流 → 等级递进冷却;成功(noteSuccess)会清零等级
|
|
516
523
|
e.backoffLevel = Math.min(e.backoffLevel + 1, BACKOFF_MAX_LEVEL)
|
|
517
|
-
|
|
524
|
+
const backoffUntil = Date.now() + backoffMs(e.backoffLevel)
|
|
525
|
+
// 上游自己报了重置时刻就冷到那个点(且不低于已有的更长冷却);过期/
|
|
526
|
+
// 未报的 resetAt 退回指数退避。
|
|
527
|
+
const until = resetAt !== undefined && resetAt > Date.now() ? resetAt : backoffUntil
|
|
528
|
+
e.until = Math.max(e.until, until)
|
|
518
529
|
return
|
|
519
530
|
}
|
|
520
531
|
if (rule.cooldown === 'transient') {
|
package/src/router/index.ts
CHANGED
|
@@ -13,6 +13,9 @@ import { AccountPool } from './account-pool.ts'
|
|
|
13
13
|
import { SupplierConfigStore } from '../supplier-config.ts'
|
|
14
14
|
import { UsageStore } from './usage-store.ts'
|
|
15
15
|
import { tapStreamUsage, usageFromJsonBody, withEstimates, type UsageTokens } from './usage-tokens.ts'
|
|
16
|
+
import { codecOf } from '../protocol/index.ts'
|
|
17
|
+
import { StreamState, translateStream } from '../protocol/stream.ts'
|
|
18
|
+
import type { JsonObject } from '../protocol/types.ts'
|
|
16
19
|
|
|
17
20
|
/** 一次 chat 的追踪信息(记日志用,回答「到底用的哪个模型哪个号」)。 */
|
|
18
21
|
interface ChatTrace {
|
|
@@ -191,29 +194,62 @@ function reqMessages(rawBody: string): unknown {
|
|
|
191
194
|
}
|
|
192
195
|
|
|
193
196
|
/** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
+
/**
|
|
198
|
+
* 响应收集器:把 Router 本来要写给 `res` 的东西收进来,让调用方翻译后自己写。
|
|
199
|
+
*
|
|
200
|
+
* 只给**非 chat 协议**用(`/v1/messages`、`/v1/responses`、Gemini):Router 只会
|
|
201
|
+
* 写 OpenAI 形态,而客户端要的是别的协议,中间必须插一层翻译。插入点在
|
|
202
|
+
* `chatCompletions` 的入口 —— 那里是唯一能同时看到「响应还没开始写」与
|
|
203
|
+
* 「协议是什么」的地方。
|
|
204
|
+
*
|
|
205
|
+
* 三条路径都要接住,漏一条就是那个协议下的一种故障:
|
|
206
|
+
* - `head(status, headers)` —— 非流式的状态行(503 也走这里)
|
|
207
|
+
* - `write(bytes)` —— 流式的 SSE 字节(可能是多段)
|
|
208
|
+
* - `end()` —— 流结束
|
|
209
|
+
*
|
|
210
|
+
* 流式的 `writeHead` 是**延迟到第一个字节**才发生的(见 writeChatResult),
|
|
211
|
+
* 所以 `head` 在流式下可能一次都不来;调用方不能假设它来过。
|
|
212
|
+
*/
|
|
213
|
+
export interface ResponseSink {
|
|
214
|
+
/** 状态行 + 响应头(流式下可能不来;来了就一次)。 */
|
|
215
|
+
head?: (status: number, headers: Record<string, string>) => void
|
|
216
|
+
/** 一段响应体字节。 */
|
|
217
|
+
write: (bytes: Uint8Array) => void
|
|
218
|
+
/** 响应体结束。 */
|
|
219
|
+
end: () => void
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 把 ResponseSink 包成一个 Router 能写的 ServerResponse。 */
|
|
223
|
+
function sinkRes(sink: ResponseSink): ServerResponse {
|
|
197
224
|
const self = {
|
|
198
225
|
headersSent: false,
|
|
199
226
|
writableEnded: false,
|
|
200
|
-
|
|
201
|
-
|
|
227
|
+
destroyed: false,
|
|
228
|
+
writeHead: (code: number, headers?: Record<string, string>): unknown => {
|
|
229
|
+
sink.head?.(code, headers ?? {})
|
|
202
230
|
return self
|
|
203
231
|
},
|
|
204
232
|
write: (chunk?: unknown): boolean => {
|
|
205
|
-
if (chunk !== undefined)
|
|
233
|
+
if (chunk !== undefined) {
|
|
234
|
+
sink.write(typeof chunk === 'string' ? new TextEncoder().encode(chunk) : (chunk as Uint8Array))
|
|
235
|
+
}
|
|
206
236
|
return true
|
|
207
237
|
},
|
|
208
238
|
end: (chunk?: unknown): unknown => {
|
|
209
|
-
if (chunk !== undefined)
|
|
239
|
+
if (chunk !== undefined) self.write(chunk)
|
|
240
|
+
sink.end()
|
|
210
241
|
return self
|
|
211
242
|
},
|
|
212
243
|
flushHeaders: (): void => {},
|
|
213
|
-
|
|
214
|
-
|
|
244
|
+
once: (): unknown => self,
|
|
245
|
+
on: (): unknown => self,
|
|
246
|
+
removeListener: (): unknown => self,
|
|
247
|
+
destroy: (): void => { self.destroyed = true },
|
|
248
|
+
setHeader: (): unknown => self,
|
|
249
|
+
removeHeader: (): unknown => self,
|
|
250
|
+
writeableEnded: false,
|
|
215
251
|
}
|
|
216
|
-
return self as unknown as ServerResponse
|
|
252
|
+
return self as unknown as ServerResponse
|
|
217
253
|
}
|
|
218
254
|
|
|
219
255
|
/**
|
|
@@ -594,6 +630,56 @@ async function writeChatResult(
|
|
|
594
630
|
/** SSE 终止帧(断流兜底补发用)。 */
|
|
595
631
|
const DONE_FRAME = new TextEncoder().encode('data: [DONE]\n\n')
|
|
596
632
|
|
|
633
|
+
/**
|
|
634
|
+
* 取该供应商的 `chatOnce`,按它声明的 wire 包一层协议翻译。
|
|
635
|
+
*
|
|
636
|
+
* ════════════════════════════════════════════════════════════════════
|
|
637
|
+
* 为什么翻译放在核心、而不是让插件自己做
|
|
638
|
+
* ════════════════════════════════════════════════════════════════════
|
|
639
|
+
* 插件自己翻译意味着「每个只会说 Anthropic 的供应商各写一份 Anthropic→OpenAI
|
|
640
|
+
* 转换」,与账号池/冷却表/积分落盘是同一个错误:N 份互相不一致的实现,核心
|
|
641
|
+
* 也就无从判断该不该换号。放在核心,插件契约不变(仍只吐 OpenAI SSE),
|
|
642
|
+
* 换号/冷却/用量全部照旧生效。
|
|
643
|
+
*
|
|
644
|
+
* 翻译**只碰请求体与响应流**,不碰 `chatOnce` 的返回值语义:失败仍然返回
|
|
645
|
+
* `{ok:false, state, message}`,核心的冷却/回退逻辑一行不改。
|
|
646
|
+
*
|
|
647
|
+
* 非流式同理:上游给 JSON 时,先把它翻成规范 JSON 再交给核心(`writeChatResult`
|
|
648
|
+
* 的 `!('stream' in r)` 分支),客户端要流式而上游只给 JSON 时由现有的
|
|
649
|
+
* `aggregateSSE` 兜底(那条路径本来就要把 JSON 折成流)。
|
|
650
|
+
*/
|
|
651
|
+
function wireChatOnce(s: Supplier): (uid: string, lv: string, req: ChatRequest) => Promise<ChatOnceResult> {
|
|
652
|
+
const wire = s.wire ?? 'chat'
|
|
653
|
+
// 恒等快通道:今天所有供应商都是 chat,别为一个 no-op 造闭包 + 包流
|
|
654
|
+
if (wire === 'chat') return (uid, lv, req) => s.chatOnce(uid, lv, req)
|
|
655
|
+
const codec = codecOf(wire)
|
|
656
|
+
return async (uid, lv, req) => {
|
|
657
|
+
// 请求体:规范 → 上游协议。解析失败(坏 JSON)时原样交给插件 ——
|
|
658
|
+
// 插件的报错比在这里猜一个形状更接近真相。
|
|
659
|
+
let outReq = req
|
|
660
|
+
let body: JsonObject | undefined
|
|
661
|
+
try {
|
|
662
|
+
body = JSON.parse(req.rawBody) as JsonObject
|
|
663
|
+
} catch {
|
|
664
|
+
body = undefined
|
|
665
|
+
}
|
|
666
|
+
if (body !== undefined) {
|
|
667
|
+
outReq = {
|
|
668
|
+
...req,
|
|
669
|
+
rawBody: JSON.stringify(codec.fromChat(body, { model: req.model, stream: req.stream })),
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const r = await s.chatOnce(uid, lv, outReq)
|
|
673
|
+
if (!r.ok || !('stream' in r)) return r
|
|
674
|
+
// 响应流:上游协议 → OpenAI SSE。
|
|
675
|
+
//
|
|
676
|
+
// 首字节预算**不在这里套**:`writeChatResult` 已经给上游流套了
|
|
677
|
+
// `withFirstByteDeadline`,而那时流还是 OpenAI 形态(本函数返回的已经是
|
|
678
|
+
// 翻好的)。在翻好的流上再套一次会把两个预算叠起来,且翻译本身不该有超时。
|
|
679
|
+
return { ok: true, stream: translateStream(r.stream, new StreamState(wire, 'chat')) }
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
597
683
|
/** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
|
|
598
684
|
function extractUpstreamError(body: string): string {
|
|
599
685
|
try {
|
|
@@ -854,6 +940,16 @@ export class Router {
|
|
|
854
940
|
return out
|
|
855
941
|
}
|
|
856
942
|
|
|
943
|
+
/**
|
|
944
|
+
* 该供应商的 `chatOnce`,已按它声明的 wire 包好协议翻译。
|
|
945
|
+
*
|
|
946
|
+
* 每次调用都现取(而不是构造时缓存):供应商会被开关/热重载替换,
|
|
947
|
+
* 缓存会把旧实例的翻译绑在新实例上。
|
|
948
|
+
*/
|
|
949
|
+
private wireOf(s: Supplier): (uid: string, lv: string, req: ChatRequest) => Promise<ChatOnceResult> {
|
|
950
|
+
return wireChatOnce(s)
|
|
951
|
+
}
|
|
952
|
+
|
|
857
953
|
/** 组合列表(面板用):用户自定义组合。 */
|
|
858
954
|
async combos(): Promise<Combo[]> {
|
|
859
955
|
return [...this.customCombos]
|
|
@@ -1078,7 +1174,17 @@ export class Router {
|
|
|
1078
1174
|
* - model 命中组合名 → 在组合模型里按策略选一个,交给供应商;失败按回退顺序尝试剩余模型。
|
|
1079
1175
|
* - 否则 → 依次尝试供应商,直到某个返回 true(已写响应)或全部返回 false。
|
|
1080
1176
|
*/
|
|
1081
|
-
|
|
1177
|
+
/**
|
|
1178
|
+
* 规范请求入口。
|
|
1179
|
+
*
|
|
1180
|
+
* @param res 真实的客户端响应。协议非 chat 时**不直接用它**,而是经
|
|
1181
|
+
* `sink` 先收集再翻译(见 `protocol/entry.ts`)—— 因为 Router 只知道
|
|
1182
|
+
* 怎么写 OpenAI 形态,而客户端说的可能是 Anthropic/Gemini。
|
|
1183
|
+
* @param sink 可选的响应收集器:给了就把「写给 res 的东西」改成写给 sink,
|
|
1184
|
+
* 由调用方翻译后自己写真正的 res。只给非 chat 协议用。
|
|
1185
|
+
*/
|
|
1186
|
+
async chatCompletions(req: ChatRequest, res: ServerResponse, sink?: ResponseSink): Promise<void> {
|
|
1187
|
+
const out = sink === undefined ? res : sinkRes(sink)
|
|
1082
1188
|
// 每个客户端请求一条用量记录(组合回退会试多个供应商,但只落一次账)
|
|
1083
1189
|
const probe: UsageProbe = {
|
|
1084
1190
|
requested: req.model,
|
|
@@ -1122,7 +1228,7 @@ export class Router {
|
|
|
1122
1228
|
// 只剩 0ms(否则组合越多越没法用),但同一条腿内的多个号共享一份
|
|
1123
1229
|
// (停滞是上游级的,按号各等一份会 11×60s)。
|
|
1124
1230
|
const served = await this.chatWithModel(
|
|
1125
|
-
req,
|
|
1231
|
+
req, out, model, probe, trace, Date.now() + this.firstByteBudgetMs,
|
|
1126
1232
|
)
|
|
1127
1233
|
if (served) {
|
|
1128
1234
|
settle(true)
|
|
@@ -1135,7 +1241,7 @@ export class Router {
|
|
|
1135
1241
|
}
|
|
1136
1242
|
}
|
|
1137
1243
|
settle(false, 'combo: all models unavailable')
|
|
1138
|
-
writeJson(
|
|
1244
|
+
writeJson(out, 503, openAIError('no_healthy_supplier', `combo ${JSON.stringify(req.model)}: all models unavailable`))
|
|
1139
1245
|
return
|
|
1140
1246
|
}
|
|
1141
1247
|
// 直接调用:模型全名 = alias/model。用别名反查供应商,精准调用。
|
|
@@ -1146,22 +1252,22 @@ export class Router {
|
|
|
1146
1252
|
const alias = slash > 0 ? req.model.slice(0, slash) : ''
|
|
1147
1253
|
const target = alias === '' ? undefined : this.supplierByAlias(alias)
|
|
1148
1254
|
if (target !== undefined) {
|
|
1149
|
-
const served = await this.chatWithTarget(target, req,
|
|
1255
|
+
const served = await this.chatWithTarget(target, req, out, probe)
|
|
1150
1256
|
if (served) {
|
|
1151
1257
|
settle(true)
|
|
1152
1258
|
return
|
|
1153
1259
|
}
|
|
1154
1260
|
settle(false, `supplier ${target.id}: all accounts unavailable`)
|
|
1155
|
-
writeJson(
|
|
1261
|
+
writeJson(out, 503, openAIError('no_healthy_supplier', `supplier ${JSON.stringify(target.id)}: all accounts unavailable`))
|
|
1156
1262
|
return
|
|
1157
1263
|
}
|
|
1158
|
-
const served = await this.chatWithModel(req,
|
|
1264
|
+
const served = await this.chatWithModel(req, out, req.model, probe)
|
|
1159
1265
|
if (served) {
|
|
1160
1266
|
settle(true)
|
|
1161
1267
|
return
|
|
1162
1268
|
}
|
|
1163
1269
|
settle(false, 'all suppliers unavailable')
|
|
1164
|
-
writeJson(
|
|
1270
|
+
writeJson(out, 503, openAIError('no_healthy_supplier', 'all suppliers unavailable'))
|
|
1165
1271
|
}
|
|
1166
1272
|
|
|
1167
1273
|
/** 调指定供应商。
|
|
@@ -1274,9 +1380,13 @@ export class Router {
|
|
|
1274
1380
|
): Promise<boolean> {
|
|
1275
1381
|
const pool = s.pool
|
|
1276
1382
|
const cfg = this.store.get(s.id)
|
|
1277
|
-
//
|
|
1383
|
+
// 上游协议适配:供应商声明了非 chat 的 wire 时,在这里包一层翻译 ——
|
|
1384
|
+
// 请求体翻成它的协议、它的响应流翻回 OpenAI SSE。契约不变(插件仍只
|
|
1385
|
+
// 看见 OpenAI 形态),翻译只此一处,四个入口(组合腿/直调/测试/探测)
|
|
1386
|
+
// 全部受益。wire = 'chat'(缺省,今天所有供应商)时零成本返回原函数。
|
|
1387
|
+
const chatOnce = this.wireOf(s)
|
|
1278
1388
|
if (s.accounts !== undefined && s.accounts().length === 0) {
|
|
1279
|
-
const r = await
|
|
1389
|
+
const r = await chatOnce('', req.lv ?? 'auto', req)
|
|
1280
1390
|
if (!r.ok) {
|
|
1281
1391
|
// 模型不属于本供应商、或请求本身被上游拒收:换供应商,不记账
|
|
1282
1392
|
if (r.state === 'no_such_model' || r.state === 'bad_request') return false
|
|
@@ -1342,7 +1452,7 @@ export class Router {
|
|
|
1342
1452
|
}
|
|
1343
1453
|
pickWhy = pool.lastWhy
|
|
1344
1454
|
tried.add(uid)
|
|
1345
|
-
const r = await
|
|
1455
|
+
const r = await chatOnce(uid, req.lv ?? 'auto', req)
|
|
1346
1456
|
if (!r.ok) {
|
|
1347
1457
|
// 模型不属于本供应商、或请求本身被上游拒收:整个供应商都跳过,
|
|
1348
1458
|
// 换号重试没有意义,也不能记在账号头上(否则无关账号会被冷掉)。
|
|
@@ -1355,7 +1465,7 @@ export class Router {
|
|
|
1355
1465
|
}
|
|
1356
1466
|
return false
|
|
1357
1467
|
}
|
|
1358
|
-
pool.noteFailure(uid, req.model, r.state, r.message)
|
|
1468
|
+
pool.noteFailure(uid, req.model, r.state, r.message, r.resetAt)
|
|
1359
1469
|
// 故障切走:下次选号会换号,旧块的驻留计数不再适用(前缀归属变了)
|
|
1360
1470
|
pool.dropBlock(req.model)
|
|
1361
1471
|
if (trace !== undefined) {
|
|
@@ -1406,7 +1516,16 @@ export class Router {
|
|
|
1406
1516
|
async testModel(supplierId: string, model: string, onlyUid?: string): Promise<{ ok: boolean; error?: string }> {
|
|
1407
1517
|
const s = this.active.find((x) => x.id === supplierId)
|
|
1408
1518
|
if (s === undefined) return { ok: false, error: `unknown supplier ${JSON.stringify(supplierId)}` }
|
|
1409
|
-
|
|
1519
|
+
// 测试 sink:只收集状态与 body,不写给任何真实 res(testModel 的响应丢弃)
|
|
1520
|
+
let sinkStatus = 200
|
|
1521
|
+
let sinkBody = ''
|
|
1522
|
+
const sink = sinkRes({
|
|
1523
|
+
head: (code) => { sinkStatus = code },
|
|
1524
|
+
write: (bytes) => { sinkBody += new TextDecoder().decode(bytes) },
|
|
1525
|
+
end: () => {},
|
|
1526
|
+
})
|
|
1527
|
+
const statusOf = (): number => sinkStatus
|
|
1528
|
+
const bodyOf = (): string => sinkBody
|
|
1410
1529
|
const req: ChatRequest = {
|
|
1411
1530
|
model,
|
|
1412
1531
|
stream: false,
|
|
@@ -1420,8 +1539,8 @@ export class Router {
|
|
|
1420
1539
|
return { ok: false, error: (err as Error).message }
|
|
1421
1540
|
}
|
|
1422
1541
|
// 供应商可能「服务了但写出错误响应」(如 opencode:唯一能处理该模型的供应商,错误自己上报)
|
|
1423
|
-
if (served &&
|
|
1424
|
-
const fromSink = served &&
|
|
1542
|
+
if (served && statusOf() < 400) return { ok: true }
|
|
1543
|
+
const fromSink = served && statusOf() >= 400 ? extractUpstreamError(bodyOf()) : ''
|
|
1425
1544
|
// 失败原因优先从上游响应体取,其次用 chatOnce 返回的 message(trace.lastError)。
|
|
1426
1545
|
// 以前这里问插件的 lastError()——那是「最后一次失败」的可变全局,既可能串到
|
|
1427
1546
|
// 别的账号/别的请求,也有插件压根没实现(codebuddy 就漏了,于是永远拿不到兜底)。
|
package/src/router/types.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { AccountState, ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts'
|
|
7
7
|
import type { AccountPool } from './account-pool.ts'
|
|
8
|
+
import type { ProtocolId } from '../protocol/types.ts'
|
|
8
9
|
|
|
9
10
|
/** 面板展示的账号状态(脱敏)。 */
|
|
10
11
|
export interface SupplierAccount {
|
|
@@ -61,11 +62,24 @@ export interface ChatRequest {
|
|
|
61
62
|
/** 推理等级(reasoning effort),供应商各自决定怎么映射到上游。缺省 = 'auto'。 */
|
|
62
63
|
lv?: string
|
|
63
64
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
65
|
+
* 调用方身份(归因键)。来源两种:
|
|
66
|
+
* 1. 宿主会话身份(dsh-llm `GenerateOptions.sessionId`,adapter 转成内部头
|
|
67
|
+
* `x-dsh-router-session`)——形式 `session-<uuid>`;
|
|
68
|
+
* 2. 外部 OpenAI 兼容客户端的 `user` 字段(Zed/IDE 等多客户端场景)——形式
|
|
69
|
+
* `ext:<user>`,前缀标明「这不是宿主会话」。
|
|
70
|
+
* 用途:把用量记录**按调用方归因**(「最近命中」徽章按当前会话显示;出事时
|
|
71
|
+
* 能反查是哪个客户端在打)。缺省 = 匿名外部客户端。
|
|
67
72
|
*/
|
|
68
73
|
session?: string
|
|
74
|
+
/**
|
|
75
|
+
* 客户端说的协议(入站)。
|
|
76
|
+
*
|
|
77
|
+
* 缺省 `'chat'`(今天所有入口的现状)。四种协议(chat / anthropic /
|
|
78
|
+
* responses / gemini)在入站路由上就被翻成规范形态,所以**核心与供应商
|
|
79
|
+
* 只认 chat**;这个字段存在的理由是让用量记录与日志能说出「客户端原本
|
|
80
|
+
* 说的哪种协议」,排查「Claude Code 打过来为什么不通」时省一次猜。
|
|
81
|
+
*/
|
|
82
|
+
protocol?: ProtocolId
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
/**
|
|
@@ -93,6 +107,15 @@ export interface Supplier {
|
|
|
93
107
|
accounts(): SupplierAccountNow[]
|
|
94
108
|
/** 对单个账号调一次上游。不遍历账号、不管冷却、不写 res。 */
|
|
95
109
|
chatOnce(uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult>
|
|
110
|
+
/**
|
|
111
|
+
* 上游说哪种协议(缺省 `'chat'`)。
|
|
112
|
+
*
|
|
113
|
+
* 声明成别种时,核心在 `chatOnce` 前后各做一次翻译:请求体翻成该协议、
|
|
114
|
+
* 响应流翻回 OpenAI SSE。于是**插件实现里永远只看见 OpenAI 形态**,
|
|
115
|
+
* 契约不变 —— 一个只会说 Anthropic 的上游可以接一个只写了几行 fetch 的
|
|
116
|
+
* 插件,路由/选号/冷却/用量全都照旧。
|
|
117
|
+
*/
|
|
118
|
+
readonly wire?: ProtocolId
|
|
96
119
|
/** 删除链接(清理凭证;内部能力,js 契约不要求实现)。 */
|
|
97
120
|
removeLink?(uid: string): Promise<boolean>
|
|
98
121
|
dispose(): void
|
|
@@ -55,8 +55,10 @@ export interface UsageRecord {
|
|
|
55
55
|
/** 失败原因(ok=false 时)。 */
|
|
56
56
|
error?: string
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
58
|
+
* 调用方身份(有则记):宿主会话 id(`session-<uuid>`)或外部 OpenAI 客户端
|
|
59
|
+
* 标签(`ext/…`,见 shared.ts 的 clientIdentityOf)。用于「最近命中」按当前
|
|
60
|
+
* 会话归因(徽章要显示当前会话的命中,而不是全局最近),出事时也能反查
|
|
61
|
+
* 是哪个客户端在打。
|
|
60
62
|
*/
|
|
61
63
|
session?: string
|
|
62
64
|
}
|