dsh-router-core 0.3.18 → 0.3.20
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 +1 -1
- package/lib/client-registry.js +449 -125
- package/lib/client.js +449 -125
- package/lib/index.js +243 -102
- package/lib/suppliers/opencode.js +103 -19
- package/lib/types/ext/contract.d.ts +11 -0
- package/lib/types/llm/adapter.d.ts +7 -1
- package/lib/types/shared.d.ts +19 -0
- package/package.json +1 -1
- package/src/client/ExtDetail.tsx +257 -0
- package/src/client/ExtTab.tsx +38 -38
- package/src/client/router.css +28 -0
- package/src/ext/contract.ts +11 -0
- package/src/index.ts +29 -0
- package/src/llm/adapter.test.ts +82 -0
- package/src/llm/adapter.ts +66 -9
- package/src/router/disconnect-leak.test.ts +200 -0
- package/src/router/index.ts +65 -9
- package/src/router/usage-tokens.ts +13 -3
- package/src/shared.ts +21 -0
- package/src/suppliers/opencode/plugin.test.ts +176 -0
- package/src/suppliers/opencode/plugin.ts +103 -21
package/src/router/index.ts
CHANGED
|
@@ -140,6 +140,7 @@ async function aggregateSSE(stream: ReadableStream<Uint8Array>): Promise<string>
|
|
|
140
140
|
|
|
141
141
|
const reader = stream.getReader()
|
|
142
142
|
let buf = ''
|
|
143
|
+
try {
|
|
143
144
|
for (;;) {
|
|
144
145
|
const { done, value } = await reader.read()
|
|
145
146
|
if (done) break
|
|
@@ -189,6 +190,12 @@ async function aggregateSSE(stream: ReadableStream<Uint8Array>): Promise<string>
|
|
|
189
190
|
}
|
|
190
191
|
}
|
|
191
192
|
}
|
|
193
|
+
} finally {
|
|
194
|
+
// 无论正常读完还是中途抛错,都要**解绑** reader:调用方(writeChatResult)
|
|
195
|
+
// 在失败路径里要对同一流 cancel(),而流被锁住时 cancel 会抛
|
|
196
|
+
// `Invalid state: ReadableStream is locked` —— 那样这次的连接就漏了。
|
|
197
|
+
try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
|
|
198
|
+
}
|
|
192
199
|
|
|
193
200
|
const toolCalls = [...calls.entries()]
|
|
194
201
|
.sort((a, b) => a[0] - b[0])
|
|
@@ -242,9 +249,11 @@ function normalizeSSEStream(stream: ReadableStream<Uint8Array>): {
|
|
|
242
249
|
const dec = new TextDecoder()
|
|
243
250
|
let buf = ''
|
|
244
251
|
let sawDone = false
|
|
252
|
+
/** 上游 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
|
|
253
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
245
254
|
const out = new ReadableStream<Uint8Array>({
|
|
246
255
|
start: async (ctrl) => {
|
|
247
|
-
|
|
256
|
+
reader = stream.getReader()
|
|
248
257
|
try {
|
|
249
258
|
for (;;) {
|
|
250
259
|
const { done, value } = await reader.read()
|
|
@@ -263,11 +272,21 @@ function normalizeSSEStream(stream: ReadableStream<Uint8Array>): {
|
|
|
263
272
|
if (isDoneFrame(buf)) sawDone = true
|
|
264
273
|
ctrl.enqueue(enc.encode(fixFrame(buf)))
|
|
265
274
|
}
|
|
275
|
+
} catch {
|
|
276
|
+
// 消费方取消(客户端断开)会让挂起的 read 抛错/返回 done —— 静默收尾
|
|
266
277
|
} finally {
|
|
267
|
-
reader.releaseLock()
|
|
278
|
+
try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
|
|
268
279
|
}
|
|
269
|
-
ctrl.close()
|
|
280
|
+
try { ctrl.close() } catch { /* 已取消 */ }
|
|
270
281
|
},
|
|
282
|
+
/**
|
|
283
|
+
* **取消必须往下传**:ReadableStream 的取消不会自动穿过 wrapper。
|
|
284
|
+
* 不实现这个分支,客户端断开的取消就停在这里,上游 fetch body 永不 cancel
|
|
285
|
+
* ——那条 TCP 连接和 in-flight 请求原样挂着(长跑一天就会累积到耗尽)。
|
|
286
|
+
* 用 reader.cancel() 而不是 stream.cancel():流已被 getReader() 锁住,
|
|
287
|
+
* 直接 cancel 会抛 `Invalid state: ReadableStream is locked`。
|
|
288
|
+
*/
|
|
289
|
+
cancel: (reason) => reader?.cancel(reason),
|
|
271
290
|
})
|
|
272
291
|
return { stream: out, sawDone: () => sawDone }
|
|
273
292
|
}
|
|
@@ -338,7 +357,12 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
338
357
|
try {
|
|
339
358
|
body = await aggregateSSE(r.stream)
|
|
340
359
|
} catch {
|
|
341
|
-
|
|
360
|
+
// 聚合失败 = 一个字节都没写,调用方可以换号重试。
|
|
361
|
+
// **但底层流必须 cancel**:aggregateSSE 内部 getReader() 消费到一半抛错时,
|
|
362
|
+
// reader 只是 releaseLock(),传输层毫无察觉 —— 那条上游连接会一直挂着。
|
|
363
|
+
// 不 cancel 就退化成「每次聚合失败漏一条连接」(issue #6 的路径 1)。
|
|
364
|
+
await r.stream.cancel().catch(() => {})
|
|
365
|
+
return false
|
|
342
366
|
}
|
|
343
367
|
if (probe !== undefined) probe.tokens = usageFromJsonBody(body)
|
|
344
368
|
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
@@ -351,6 +375,18 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
351
375
|
let wroteAny = false
|
|
352
376
|
/** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
|
|
353
377
|
let dead = false
|
|
378
|
+
/**
|
|
379
|
+
* 唤醒「等 drain 的挂起 write」。客户端断开时必须调它:那时 'drain' 永不到来,
|
|
380
|
+
* 不唤醒就永远卡在 write 里,pipeTo 不 settle,finally 不执行,
|
|
381
|
+
* **上游流也就永不 cancel**(= 每次客户端放弃漏一条连接)。
|
|
382
|
+
*/
|
|
383
|
+
let releaseWait: (() => void) | undefined
|
|
384
|
+
/**
|
|
385
|
+
* 中止 pipeTo 的信号源(客户端断开时 abort)。
|
|
386
|
+
* 单靠 `dead` 标志只能让**下一次** write 立刻返回,唤醒不了**已经挂起**的那次;
|
|
387
|
+
* abort 才能让 pipeTo 立刻收尾并走它的取消路径(进而 cancel 上游)。
|
|
388
|
+
*/
|
|
389
|
+
const abort = new AbortController()
|
|
354
390
|
const writeChunk = (chunk: Uint8Array): Promise<void> =>
|
|
355
391
|
new Promise<void>((resolve) => {
|
|
356
392
|
if (dead) { resolve(); return }
|
|
@@ -370,25 +406,44 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
370
406
|
if (res.write(chunk)) resolve()
|
|
371
407
|
else {
|
|
372
408
|
// 背压:等 drain。但连接可能在这期间断掉 —— 那时 'drain' 永不到来,
|
|
373
|
-
// 得靠
|
|
409
|
+
// 得靠 release() 兜住(客户端断开时唤醒),否则这次请求会永远挂着。
|
|
374
410
|
const onDrain = (): void => { res.removeListener('error', onDrain); resolve() }
|
|
375
411
|
res.once('drain', onDrain)
|
|
412
|
+
/** 让这个挂起的 write 立刻收尾(客户端断开时调用)。 */
|
|
413
|
+
releaseWait = () => { res.removeListener('drain', onDrain); resolve() }
|
|
376
414
|
}
|
|
377
415
|
})
|
|
378
416
|
|
|
379
|
-
//
|
|
417
|
+
// 客户端断开 / 流中途出错都要停手,否则连接悬挂
|
|
380
418
|
const onError = (): void => {
|
|
381
419
|
dead = true
|
|
382
420
|
res.destroy()
|
|
383
421
|
}
|
|
384
422
|
res.once('error', onError)
|
|
423
|
+
/**
|
|
424
|
+
* **客户端断开必须被当成取消信号**:只监听 'error' 是不够的 —— 对端关闭
|
|
425
|
+
* (pi-ai 的流空闲超时、用户取消、消费方提前退出)触发的是 'close',
|
|
426
|
+
* 不是 'error'。此时若不收手,pipeTo 会一直等着 drain(永不到来),
|
|
427
|
+
* finally 永不执行,上游 fetch body **永不 cancel** → 那条 TCP 连接和
|
|
428
|
+
* in-flight 请求原样挂着,长跑累积到耗尽("用久了就报 connection error")。
|
|
429
|
+
*/
|
|
430
|
+
const onClose = (): void => {
|
|
431
|
+
if (res.writableEnded) return // 正常写完的 close,不是取消
|
|
432
|
+
dead = true
|
|
433
|
+
releaseWait?.()
|
|
434
|
+
abort.abort()
|
|
435
|
+
}
|
|
436
|
+
res.once('close', onClose)
|
|
385
437
|
const norm = normalizeSSEStream(tapped?.stream ?? r.stream)
|
|
386
438
|
try {
|
|
387
|
-
|
|
439
|
+
// signal 让 abort 时 pipeTo 立刻收尾(而不是等 drain);其收尾路径会
|
|
440
|
+
// cancel 源流,顺着 wrapper 的 cancel 一路传到上游 fetch body。
|
|
441
|
+
await norm.stream.pipeTo(new WritableStream<Uint8Array>({ write: writeChunk }), { signal: abort.signal })
|
|
388
442
|
} catch {
|
|
389
|
-
//
|
|
443
|
+
// 断流/取消:一个字节都没写的话,调用方还能换号重试——交给上层决定
|
|
390
444
|
} finally {
|
|
391
445
|
res.removeListener('error', onError)
|
|
446
|
+
res.removeListener('close', onClose)
|
|
392
447
|
// 断流也要给客户端一个终止帧。客户端(dsh-llm adapter)严格等
|
|
393
448
|
// `[DONE]`,缺了就整轮判失败(`SSE payload stream ended without
|
|
394
449
|
// [DONE]`),已经流式吐出去的内容全部作废。上游自己发了就不重复发
|
|
@@ -396,10 +451,11 @@ async function writeChatResult(res: ServerResponse, r: ChatOnceResult, wantsStre
|
|
|
396
451
|
//
|
|
397
452
|
// 只在**已提交响应**时补:一个字节都没写 = 还能换号重试,补了就等于
|
|
398
453
|
// 把这次失败坐实成一个空响应,组合回退就没了。
|
|
454
|
+
// dead(客户端已走)时也不补:对端没了,写了也没人收。
|
|
399
455
|
if (wroteAny && !norm.sawDone() && !dead && !res.writableEnded && !res.destroyed) {
|
|
400
456
|
await writeChunk(DONE_FRAME)
|
|
401
457
|
}
|
|
402
|
-
if (wroteAny) res.end()
|
|
458
|
+
if (wroteAny && !dead) res.end()
|
|
403
459
|
// 流走完才出得了 usage(上游多在最后一帧才发 usage)
|
|
404
460
|
if (probe !== undefined && tapped !== undefined) {
|
|
405
461
|
const got = await tapped.done()
|
|
@@ -280,9 +280,11 @@ export function tapStreamUsage(
|
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/** 源 reader:cancel 时要靠它把取消传下去(见下方 cancel 分支)。 */
|
|
284
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
283
285
|
const stream = new ReadableStream<Uint8Array>({
|
|
284
286
|
start: async (ctrl) => {
|
|
285
|
-
|
|
287
|
+
reader = source.getReader()
|
|
286
288
|
try {
|
|
287
289
|
for (;;) {
|
|
288
290
|
const { done, value } = await reader.read()
|
|
@@ -300,11 +302,19 @@ export function tapStreamUsage(
|
|
|
300
302
|
ctrl.enqueue(value as Uint8Array)
|
|
301
303
|
}
|
|
302
304
|
if (buf !== '') parseFrame(buf)
|
|
305
|
+
} catch {
|
|
306
|
+
// 消费方取消会让挂起的 read 收尾 —— 静默退出
|
|
303
307
|
} finally {
|
|
304
|
-
reader.releaseLock()
|
|
308
|
+
try { reader.releaseLock() } catch { /* 有待定 read 时会抛,忽略 */ }
|
|
305
309
|
}
|
|
306
|
-
ctrl.close()
|
|
310
|
+
try { ctrl.close() } catch { /* 已取消 */ }
|
|
307
311
|
},
|
|
312
|
+
/**
|
|
313
|
+
* 取消要往下传(同 normalizeSSEStream 的理由):不传 → 客户端断开时
|
|
314
|
+
* 上游 fetch body 永不 cancel,连接与 in-flight 请求一直挂着。
|
|
315
|
+
* 必须用 reader.cancel():流已被 getReader() 锁住,stream.cancel() 会抛。
|
|
316
|
+
*/
|
|
317
|
+
cancel: (reason) => reader?.cancel(reason),
|
|
308
318
|
})
|
|
309
319
|
|
|
310
320
|
return {
|
package/src/shared.ts
CHANGED
|
@@ -143,6 +143,8 @@ export interface RouterExtItem {
|
|
|
143
143
|
id: string
|
|
144
144
|
name: string
|
|
145
145
|
description?: string
|
|
146
|
+
/** 卡片/详情图标 logo URL(可选)。 */
|
|
147
|
+
icon?: string
|
|
146
148
|
/** 是否启用改写(面板开关)。 */
|
|
147
149
|
enabled?: boolean
|
|
148
150
|
/** 运行时是否就绪(如 RTK 二进制是否可用)。 */
|
|
@@ -158,6 +160,25 @@ export interface RouterExtResponse {
|
|
|
158
160
|
enhancers?: RouterExtItem[]
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
/** 一行 watch 进程(`/router/api/ext/watch/processes`)。 */
|
|
164
|
+
export interface RouterWatchProcess {
|
|
165
|
+
pid: number
|
|
166
|
+
ppid: number
|
|
167
|
+
/** %cpu 快照 */ cpu: number
|
|
168
|
+
/** %mem 快照 */ mem: number
|
|
169
|
+
command: string
|
|
170
|
+
/** 命中的已记录 agent 命令。 */
|
|
171
|
+
match: string
|
|
172
|
+
recordedAt: number
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `/router/api/ext/{id}/processes` response。 */
|
|
176
|
+
export interface RouterWatchResponse {
|
|
177
|
+
ok: boolean
|
|
178
|
+
error?: string
|
|
179
|
+
processes?: RouterWatchProcess[]
|
|
180
|
+
}
|
|
181
|
+
|
|
161
182
|
/** 供应商模型(含启用状态)。 */
|
|
162
183
|
export interface RouterSupplierModel {
|
|
163
184
|
id: string
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode(Zen 免费档)握手契约测试 —— 锁住 2026-09-22 实测的那组硬门。
|
|
3
|
+
*
|
|
4
|
+
* 为什么值得固化:这四条**都不是可选优化,缺一即 403**
|
|
5
|
+
* `FreeTierError: OpenCode's free tier can only be used from within OpenCode`。
|
|
6
|
+
* 它们全是「看不出来」的那种约定——代码删掉一行 headers、或把 UA 改回
|
|
7
|
+
* `opencode`,单测本该立刻变红,否则下次只能在真机对话里发现整个供应商死了。
|
|
8
|
+
*
|
|
9
|
+
* 实测依据(2026-09-22,逐项消融验证):
|
|
10
|
+
* - UA 必须带版本且 ≥1.18:裸 `opencode`→403;`opencode/1.17.x`→426;1.18.0 起 200
|
|
11
|
+
* - 必须同时有 `x-opencode-session`/`x-opencode-request`,且形状是
|
|
12
|
+
* `ses_<12hex><14base62>` / `msg_<同形状>`——格式错也 403
|
|
13
|
+
* - tools 里必须**同时**有 bash 和 read,只给一个也 403
|
|
14
|
+
* - 非流式(`stream:false`)即使三样齐全也 403 → 必须恒发 stream:true
|
|
15
|
+
*
|
|
16
|
+
* 用 node --test 跑(Node 原生剥 TS 类型):
|
|
17
|
+
* node --test src/suppliers/opencode/plugin.test.ts
|
|
18
|
+
*/
|
|
19
|
+
import test from 'node:test'
|
|
20
|
+
import assert from 'node:assert/strict'
|
|
21
|
+
import factory from './plugin.ts'
|
|
22
|
+
import { SupplierConfigStore } from '../../supplier-config.ts'
|
|
23
|
+
import type { CredentialStore } from '../../credential-store.ts'
|
|
24
|
+
import type { SupplierEnv, SupplierModule } from '../contract.ts'
|
|
25
|
+
import type { ChatRequest } from '../../router/types.ts'
|
|
26
|
+
|
|
27
|
+
/** 上游认的 session/request id 形状:前缀 + 12 位小写 hex + 14 位 base62。 */
|
|
28
|
+
const SES_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/
|
|
29
|
+
const MSG_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/
|
|
30
|
+
|
|
31
|
+
interface Captured {
|
|
32
|
+
url: string
|
|
33
|
+
headers: Record<string, string>
|
|
34
|
+
body: Record<string, unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 打桩 globalThis.fetch,接住一次请求(上游返回空 SSE 流即可)。 */
|
|
38
|
+
function stubFetch(): { captured: Captured[]; restore: () => void } {
|
|
39
|
+
const captured: Captured[] = []
|
|
40
|
+
const real = globalThis.fetch
|
|
41
|
+
globalThis.fetch = (async (url: string, init?: { headers?: Record<string, string>; body?: string }) => {
|
|
42
|
+
captured.push({
|
|
43
|
+
url: String(url),
|
|
44
|
+
headers: init?.headers ?? {},
|
|
45
|
+
body: init?.body !== undefined ? (JSON.parse(init.body) as Record<string, unknown>) : {},
|
|
46
|
+
})
|
|
47
|
+
return new Response(new ReadableStream<Uint8Array>({ start: (c) => c.close() }), { status: 200 })
|
|
48
|
+
}) as unknown as typeof fetch
|
|
49
|
+
return { captured, restore: () => { globalThis.fetch = real } }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 造一个最小 env(store 空文件路径 = 纯内存,credentials 用不着的空壳)。 */
|
|
53
|
+
function makePlugin(): SupplierModule {
|
|
54
|
+
const store = new SupplierConfigStore('')
|
|
55
|
+
const env: SupplierEnv = {
|
|
56
|
+
dataDir: '/tmp',
|
|
57
|
+
log: () => {},
|
|
58
|
+
store,
|
|
59
|
+
credentials: {} as CredentialStore,
|
|
60
|
+
}
|
|
61
|
+
return factory(env)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function request(over: Partial<ChatRequest> = {}): ChatRequest {
|
|
65
|
+
return {
|
|
66
|
+
model: 'big-pickle',
|
|
67
|
+
stream: true,
|
|
68
|
+
rawBody: JSON.stringify({ model: 'big-pickle', messages: [{ role: 'user', content: 'hi' }] }),
|
|
69
|
+
...over,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test('chatOnce 必须带版本化 UA + 合法形状的 session/request 头', async () => {
|
|
74
|
+
const stub = stubFetch()
|
|
75
|
+
try {
|
|
76
|
+
const r = await makePlugin().chatOnce('', 'auto', request())
|
|
77
|
+
assert.equal(r.ok, true, '握手齐全时应当成功')
|
|
78
|
+
const h = stub.captured[0]!.headers
|
|
79
|
+
|
|
80
|
+
// UA 必须带版本号(裸 'opencode' 会被上游按非 CLI 拒掉)
|
|
81
|
+
assert.match(h['User-Agent'] ?? '', /^opencode\/\d+\.\d+/, 'UA 必须带 opencode/ 版本号')
|
|
82
|
+
|
|
83
|
+
// session/request 形状不对上游是**拒绝**而不是忽略 —— 形状必须严格
|
|
84
|
+
assert.match(h['x-opencode-session'] ?? '', SES_RE, 'x-opencode-session 形状不对会被 403')
|
|
85
|
+
assert.match(h['x-opencode-request'] ?? '', MSG_RE, 'x-opencode-request 形状不对会被 403')
|
|
86
|
+
} finally {
|
|
87
|
+
stub.restore()
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('请求体恒为流式,且 tools 同时含 bash 与 read', async () => {
|
|
92
|
+
const stub = stubFetch()
|
|
93
|
+
try {
|
|
94
|
+
// 调用方明确要非流式 —— 上游免费档对非流式一律 403,插件必须翻成流式
|
|
95
|
+
await makePlugin().chatOnce('', 'auto', request({ stream: false }))
|
|
96
|
+
const body = stub.captured[0]!.body
|
|
97
|
+
assert.equal(body.stream, true, '免费档只支持流式,非流式必须翻成 stream:true')
|
|
98
|
+
|
|
99
|
+
const names = (body.tools as Array<{ function?: { name?: string } }>).map((t) => t.function?.name)
|
|
100
|
+
assert.ok(names.includes('bash'), 'tools 缺 bash 会被 403')
|
|
101
|
+
assert.ok(names.includes('read'), 'tools 缺 read 会被 403')
|
|
102
|
+
} finally {
|
|
103
|
+
stub.restore()
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('调用方自带的同名工具不被占位工具覆盖(bash/read 只补缺)', async () => {
|
|
108
|
+
const stub = stubFetch()
|
|
109
|
+
try {
|
|
110
|
+
const realBash = {
|
|
111
|
+
type: 'function',
|
|
112
|
+
function: { name: 'bash', description: 'REAL BASH', parameters: { type: 'object', properties: { command: { type: 'string' } } } },
|
|
113
|
+
}
|
|
114
|
+
await makePlugin().chatOnce('', 'auto', request({
|
|
115
|
+
rawBody: JSON.stringify({ model: 'big-pickle', messages: [], stream: true, tools: [realBash] }),
|
|
116
|
+
}))
|
|
117
|
+
const tools = stub.captured[0]!.body.tools as Array<{ function: { name: string; description?: string } }>
|
|
118
|
+
|
|
119
|
+
const bash = tools.filter((t) => t.function.name === 'bash')
|
|
120
|
+
assert.equal(bash.length, 1, 'bash 不能被注入成两条(否则真实工具签名被挤掉)')
|
|
121
|
+
assert.equal(bash[0]!.function.description, 'REAL BASH', '调用方自己的定义必须原样保留')
|
|
122
|
+
|
|
123
|
+
// read 缺失 → 补上;bash 已有 → 不重复
|
|
124
|
+
assert.ok(tools.some((t) => t.function.name === 'read'), '缺的 read 要补上')
|
|
125
|
+
} finally {
|
|
126
|
+
stub.restore()
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("effort='off' 必须删字段而不是下发(上游没有这个值,会 400)", async () => {
|
|
131
|
+
const stub = stubFetch()
|
|
132
|
+
try {
|
|
133
|
+
// 关键:rawBody 里**本来就带** reasoning_effort/reasoning_summary。
|
|
134
|
+
// 不种这两个字段的话,「删」与「什么都不做」结果一样,闸门形同虚设
|
|
135
|
+
// (实测过:把 else-if 的 'off' 分支去掉,测试照样全绿)。
|
|
136
|
+
await makePlugin().chatOnce('', 'off', request({
|
|
137
|
+
rawBody: JSON.stringify({
|
|
138
|
+
model: 'big-pickle',
|
|
139
|
+
messages: [],
|
|
140
|
+
stream: true,
|
|
141
|
+
reasoning_effort: 'high',
|
|
142
|
+
reasoning_summary: 'auto',
|
|
143
|
+
}),
|
|
144
|
+
}))
|
|
145
|
+
const body = stub.captured[0]!.body
|
|
146
|
+
assert.equal('reasoning_effort' in body, false, "off 要删字段:上游收到 'off' 直接 400 invalid_request_error")
|
|
147
|
+
assert.equal('reasoning_summary' in body, false, 'reasoning_summary 一并删掉')
|
|
148
|
+
} finally {
|
|
149
|
+
stub.restore()
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test("effort='auto' 不动调用方自己的 reasoning_effort(不误删)", async () => {
|
|
154
|
+
const stub = stubFetch()
|
|
155
|
+
try {
|
|
156
|
+
await makePlugin().chatOnce('', 'auto', request({
|
|
157
|
+
rawBody: JSON.stringify({ model: 'big-pickle', messages: [], stream: true, reasoning_effort: 'high' }),
|
|
158
|
+
}))
|
|
159
|
+
const body = stub.captured[0]!.body
|
|
160
|
+
assert.equal(body.reasoning_effort, 'high', 'auto = 不显式下发,调用方原值必须留着')
|
|
161
|
+
} finally {
|
|
162
|
+
stub.restore()
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('非 free 模型报 no_such_model(不记账、不冷号)', async () => {
|
|
167
|
+
const stub = stubFetch()
|
|
168
|
+
try {
|
|
169
|
+
const r = await makePlugin().chatOnce('', 'auto', request({ model: 'gpt-5.5' }))
|
|
170
|
+
assert.equal(r.ok, false)
|
|
171
|
+
assert.equal(r.ok === false ? r.state : '', 'no_such_model', '模型不属于本供应商 → 核心换供应商而不是冷号')
|
|
172
|
+
assert.equal(stub.captured.length, 0, '不属于自己的模型不该打上游')
|
|
173
|
+
} finally {
|
|
174
|
+
stub.restore()
|
|
175
|
+
}
|
|
176
|
+
})
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* opencode 供应商插件 —— 参考 9Router(open-sse) 的 opencode free 实现。
|
|
3
3
|
*
|
|
4
|
-
* 上游:https://opencode.ai/zen/v1(OpenAI
|
|
5
|
-
* - chat: POST /zen/v1/chat/completions
|
|
4
|
+
* 上游:https://opencode.ai/zen/v1(OpenAI 兼容,免费档)
|
|
5
|
+
* - chat: POST /zen/v1/chat/completions(**只能流式**)
|
|
6
6
|
* - models: GET /zen/v1/models(透传,只取免费模型)
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* 「免费」不等于「无鉴权」:Zen 免费档仍要求**模拟真 CLI 的握手**,否则一律
|
|
9
|
+
* 403 `FreeTierError: OpenCode's free tier can only be used from within OpenCode`
|
|
10
|
+
* (2026-09-22 实测)。三个条件缺一即 403,见下方 `UA` / `sessionId` / `decoyTools`。
|
|
11
|
+
* 免费档也没有账号可填 —— 所以无凭证/连接池/签到,面板只显示模型卡片
|
|
12
|
+
* (连接池、签到由能力检测自动隐藏)。
|
|
10
13
|
*/
|
|
14
|
+
import { randomBytes } from 'node:crypto'
|
|
11
15
|
import type { ChatRequest, ModelInfo } from '../../router/types.ts'
|
|
12
16
|
import type { ChatOnceResult, SupplierEnv, SupplierModule, SupplierStatusNow } from '../contract.ts'
|
|
13
17
|
import { stateFromHttpStatus } from '../http-state.ts'
|
|
14
18
|
|
|
15
19
|
|
|
16
20
|
export const id = 'opencode'
|
|
17
|
-
export const name = 'OpenCode
|
|
21
|
+
export const name = 'OpenCode'
|
|
18
22
|
export const priority = 0
|
|
19
23
|
/** 面板图标。 */
|
|
20
24
|
export const icon = 'data:image/png;base64,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'
|
|
@@ -22,11 +26,86 @@ export const icon = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAA
|
|
|
22
26
|
const BASE = 'https://opencode.ai/zen/v1'
|
|
23
27
|
const MODELS_URL = `${BASE}/models`
|
|
24
28
|
const CHAT_URL = `${BASE}/chat/completions`
|
|
25
|
-
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* User-Agent 必须带**版本号**且 ≥ 1.18。
|
|
32
|
+
*
|
|
33
|
+
* 裸 `opencode` → 403 FreeTierError;`opencode/1.17.x` → **426 UpgradeRequired**
|
|
34
|
+
* (上游明确要求升级客户端);`opencode/1.18.0` 起通过。别去掉版本号,
|
|
35
|
+
* 也别写死成能过的最高版本——取一个已实测通过的稳定值即可。
|
|
36
|
+
*/
|
|
37
|
+
const UA = 'opencode/1.18.31'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 免费档按 session 记账(每次生成一个新的即可——见 `chatOnce` 的实测说明),
|
|
41
|
+
* `x-opencode-session` / `x-opencode-request` **必须**是上游认的形状,格式不对
|
|
42
|
+
* 同样 403(不是忽略,是拒绝):
|
|
43
|
+
* ses_<12 位小写 hex><14 位 base62>、msg_<同形状>
|
|
44
|
+
*/
|
|
45
|
+
function newSessionId(prefix: 'ses_' | 'msg_'): string {
|
|
46
|
+
const B62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
|
47
|
+
const bytes = randomBytes(20)
|
|
48
|
+
let time = ''
|
|
49
|
+
for (let i = 0; i < 6; i++) time += bytes[i]!.toString(16).padStart(2, '0')
|
|
50
|
+
let rand = ''
|
|
51
|
+
for (let i = 6; i < 20; i++) rand += B62[bytes[i]! % 62] ?? ''
|
|
52
|
+
return `${prefix}${time}${rand}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 每次请求一对新的 session/request id。
|
|
57
|
+
*
|
|
58
|
+
* 不做会话池的实测依据(2026-09-22):每次新 session 与固定 session 各打 40 次
|
|
59
|
+
* **全部 200**,且带大前缀时上游缓存命中数完全相同(都 2560 cached)——
|
|
60
|
+
* 9router 的 `stableSessionId` 在这里没有可测收益,属未经验证的复杂度。
|
|
61
|
+
*/
|
|
62
|
+
function sessionHeaders(): Record<string, string> {
|
|
63
|
+
return {
|
|
64
|
+
'x-opencode-session': newSessionId('ses_'),
|
|
65
|
+
'x-opencode-request': newSessionId('msg_'),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 免费档要求 tools 里**同时**存在 `bash` 和 `read`,否则 403。
|
|
71
|
+
*
|
|
72
|
+
* 为什么是「注入」而不是「覆盖」:上游只是**校验这两个名字在不在**,真正的
|
|
73
|
+
* 工具照常工作(实测真实工具在前、占位在后,混合下发 200)。所以调用方已经
|
|
74
|
+
* 带了同名工具时保持原样不动,缺哪个补哪个——绝不覆盖调用方自己的定义,
|
|
75
|
+
* 否则会把真实工具签名糊掉。
|
|
76
|
+
*/
|
|
77
|
+
const DECOY_TOOLS: ReadonlyArray<{ name: string; description: string }> = [
|
|
78
|
+
{ name: 'bash', description: 'This tool is currently unavailable and must not be used.' },
|
|
79
|
+
{ name: 'read', description: 'This tool is currently unavailable and must not be used.' },
|
|
80
|
+
]
|
|
26
81
|
|
|
27
82
|
/** 免费模型识别:id 以 -free 结尾,或已知不带后缀的免费模型。 */
|
|
28
83
|
const KNOWN_FREE = new Set(['big-pickle'])
|
|
29
84
|
|
|
85
|
+
/**
|
|
86
|
+
* 补上上游免费档要求的占位工具(幂等:已存在同名工具就跳过)。
|
|
87
|
+
*
|
|
88
|
+
* 只认 `function.name`(OpenAI chat 形态)——Zen 免费档是 chat/completions,
|
|
89
|
+
* 不走 responses 的扁平 `name` 形态。
|
|
90
|
+
*/
|
|
91
|
+
function ensureDecoyTools(obj: Record<string, unknown>): void {
|
|
92
|
+
const tools = Array.isArray(obj.tools) ? (obj.tools as Array<Record<string, unknown>>) : []
|
|
93
|
+
const names = new Set<string>()
|
|
94
|
+
for (const t of tools) {
|
|
95
|
+
const fn = t?.function as Record<string, unknown> | undefined
|
|
96
|
+
const n = typeof fn?.name === 'string' ? fn.name : typeof t?.name === 'string' ? t.name : ''
|
|
97
|
+
if (n !== '') names.add(n)
|
|
98
|
+
}
|
|
99
|
+
for (const d of DECOY_TOOLS) {
|
|
100
|
+
if (names.has(d.name)) continue
|
|
101
|
+
tools.push({
|
|
102
|
+
type: 'function',
|
|
103
|
+
function: { name: d.name, description: d.description, parameters: { type: 'object', properties: {} } },
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
obj.tools = tools
|
|
107
|
+
}
|
|
108
|
+
|
|
30
109
|
export default function factory(env: SupplierEnv): SupplierModule {
|
|
31
110
|
// 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
|
|
32
111
|
let modelsCache: ModelInfo[] | undefined
|
|
@@ -34,6 +113,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
34
113
|
|
|
35
114
|
async function refreshModels(): Promise<ModelInfo[]> {
|
|
36
115
|
try {
|
|
116
|
+
// /models 无握手要求(实测裸 curl 也 200),不必带 session 头
|
|
37
117
|
const resp = await fetch(MODELS_URL, {
|
|
38
118
|
headers: { 'User-Agent': UA, 'x-opencode-client': 'desktop' },
|
|
39
119
|
signal: AbortSignal.timeout(15000),
|
|
@@ -89,11 +169,20 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
89
169
|
const msg = `unknown free model ${JSON.stringify(req.model)}`
|
|
90
170
|
return { ok: false, state: 'no_such_model', message: msg }
|
|
91
171
|
}
|
|
92
|
-
//
|
|
172
|
+
// 模型名规范化 + 握手补齐。解析失败也要继续发(宁可不改,不能不发):
|
|
173
|
+
// 上游只认 JSON,坏 body 由它按 400 拒,那正是我们要如实上报的状态。
|
|
93
174
|
let body = req.rawBody
|
|
94
175
|
try {
|
|
95
176
|
const obj = JSON.parse(body) as Record<string, unknown>
|
|
96
177
|
obj.model = stripAlias(String(obj.model ?? req.model), currentAlias())
|
|
178
|
+
// 免费档**只支持流式**:`stream:false` 即使握手齐全也 403(实测)。
|
|
179
|
+
// 客户端要非流式时代理层会聚合 SSE,所以这里恒为 true。
|
|
180
|
+
obj.stream = true
|
|
181
|
+
// 上游要求 tools 里同时有 bash/read,否则 403(缺哪个补哪个)
|
|
182
|
+
ensureDecoyTools(obj)
|
|
183
|
+
// effort 白名单:'off'/'none' 必须**删字段**(上游没有这两个值,
|
|
184
|
+
// 传 'off' 直接 400 invalid_request_error);low/high/max 实测可过。
|
|
185
|
+
// 'auto' 保持调用方原样不动(可能带了自己的 reasoning_effort)。
|
|
97
186
|
if (lv !== 'auto' && lv !== '' && lv !== 'none' && lv !== 'off') obj.reasoning_effort = lv
|
|
98
187
|
else if (lv === 'none' || lv === 'off') {
|
|
99
188
|
delete obj.reasoning_effort
|
|
@@ -110,9 +199,11 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
110
199
|
method: 'POST',
|
|
111
200
|
headers: {
|
|
112
201
|
'Content-Type': 'application/json',
|
|
202
|
+
// 免费档固定字面量 'public';实测缺这行也 200,但保留以免上游收紧
|
|
113
203
|
Authorization: 'Bearer public',
|
|
114
204
|
'User-Agent': UA,
|
|
115
205
|
'x-opencode-client': 'desktop',
|
|
206
|
+
...sessionHeaders(),
|
|
116
207
|
},
|
|
117
208
|
body,
|
|
118
209
|
signal: AbortSignal.timeout(120000),
|
|
@@ -129,22 +220,13 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
129
220
|
return { ok: false, state: stateFromHttpStatus(upstream.status), message: msg }
|
|
130
221
|
}
|
|
131
222
|
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return { ok: false, state: 'transport', message: msg }
|
|
137
|
-
}
|
|
138
|
-
return { ok: true, stream: upstream.body }
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// 非流式:透传 JSON(空 body 视为上游异常)
|
|
142
|
-
const text = await upstream.text().catch(() => '')
|
|
143
|
-
if (text === '') {
|
|
144
|
-
const msg = 'opencode upstream: empty body'
|
|
223
|
+
// 上游只有流式(上面已强制 stream:true),所以这里恒回流。
|
|
224
|
+
// 客户端要非流式时由核心的 aggregateSSE 聚合——协议错配归核心吸收。
|
|
225
|
+
if (!upstream.body) {
|
|
226
|
+
const msg = 'opencode upstream: empty stream body'
|
|
145
227
|
return { ok: false, state: 'transport', message: msg }
|
|
146
228
|
}
|
|
147
|
-
return { ok: true,
|
|
229
|
+
return { ok: true, stream: upstream.body }
|
|
148
230
|
},
|
|
149
231
|
dispose: (): void => {
|
|
150
232
|
modelsCache = undefined
|