dsh-router-core 0.3.19 → 0.3.21
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 +42 -6
- package/lib/suppliers/opencode.js +103 -19
- package/lib/types/ext/contract.d.ts +11 -0
- 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 +54 -1
- package/src/llm/adapter.ts +15 -3
- package/src/router/disconnect-leak.test.ts +50 -0
- package/src/router/index.ts +16 -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/client/ExtTab.tsx
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { useEffect, useRef, useState } from 'react'
|
|
12
12
|
import { ROUTER_API_BASE, type RouterExtItem, type RouterExtResponse } from '../shared.ts'
|
|
13
|
+
import { ExtDetail } from './ExtDetail.tsx'
|
|
13
14
|
|
|
14
15
|
function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
|
|
15
16
|
return (
|
|
@@ -28,6 +29,7 @@ export function ExtTab(): JSX.Element {
|
|
|
28
29
|
const [items, setItems] = useState<RouterExtItem[]>([])
|
|
29
30
|
const [loaded, setLoaded] = useState(false)
|
|
30
31
|
const [error, setError] = useState('')
|
|
32
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
31
33
|
const mounted = useRef(true)
|
|
32
34
|
|
|
33
35
|
useEffect(() => {
|
|
@@ -79,6 +81,12 @@ export function ExtTab(): JSX.Element {
|
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
// 打开详情视图:用 live item(否则详情里的开关状态不会随列表更新)。
|
|
85
|
+
const selected = selectedId !== null ? items.find((i) => i.id === selectedId) ?? null : null
|
|
86
|
+
if (selected !== null) {
|
|
87
|
+
return <ExtDetail item={selected} onBack={() => setSelectedId(null)} onToggle={toggle} />
|
|
88
|
+
}
|
|
89
|
+
|
|
82
90
|
return (
|
|
83
91
|
<div className="dshr-tabBody">
|
|
84
92
|
{error !== '' && (
|
|
@@ -92,51 +100,43 @@ export function ExtTab(): JSX.Element {
|
|
|
92
100
|
<span className="dshr-keyEmptyIcon"><Icon d={I.puzzle} size={30} /></span>
|
|
93
101
|
<p className="dshr-keyEmptyTitle">暂无扩展</p>
|
|
94
102
|
<p className="dshr-keyEmptyDesc">
|
|
95
|
-
安装扩展插件(如 <code>dsh-router-ext-
|
|
103
|
+
安装扩展插件(如 <code>dsh-router-ext-watch</code>)后,这里会出现开关
|
|
96
104
|
</p>
|
|
97
105
|
</div>
|
|
98
106
|
) : (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
<
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
? '当前不可用,无法开启'
|
|
118
|
-
: item.enabled ? `关闭 ${item.name}` : `开启 ${item.name}`}
|
|
119
|
-
>
|
|
120
|
-
<span className="dshr-toggleKnob" />
|
|
121
|
-
</button>
|
|
122
|
-
</div>
|
|
107
|
+
<div className="dshr-supplierGrid">
|
|
108
|
+
{items.map(item => {
|
|
109
|
+
const notReady = item.ready === false
|
|
110
|
+
return (
|
|
111
|
+
<section key={item.id} className="dshr-supplierCard" onClick={() => setSelectedId(item.id)}>
|
|
112
|
+
<div className="dshr-supplierRow">
|
|
113
|
+
{item.icon !== undefined ? (
|
|
114
|
+
<img
|
|
115
|
+
className="dshr-supplierIcon dshr-extLogo"
|
|
116
|
+
src={item.icon}
|
|
117
|
+
alt=""
|
|
118
|
+
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
|
119
|
+
/>
|
|
120
|
+
) : (
|
|
121
|
+
<span className="dshr-supplierIcon"><Icon d={I.bolt} size={24} /></span>
|
|
122
|
+
)}
|
|
123
|
+
<div className="dshr-supplierName">{item.name}</div>
|
|
124
|
+
</div>
|
|
123
125
|
|
|
124
|
-
{/* 内容区:说明 + 未就绪红字(左对齐、与卡片图标对齐) */}
|
|
125
|
-
<div className="dshr-extBody">
|
|
126
|
-
{item.description !== undefined && item.description !== '' && (
|
|
127
|
-
<div className="dshr-muted">{item.description}</div>
|
|
128
|
-
)}
|
|
129
126
|
{notReady && (
|
|
130
|
-
<div className="dshr-
|
|
131
|
-
{item.detail !== undefined && item.detail !== ''
|
|
132
|
-
? item.detail
|
|
133
|
-
: '未就绪,无法开启'}
|
|
127
|
+
<div className="dshr-extCardError">
|
|
128
|
+
{item.detail !== undefined && item.detail !== '' ? item.detail : '未就绪'}
|
|
134
129
|
</div>
|
|
135
130
|
)}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
131
|
+
|
|
132
|
+
<div className="dshr-supplierMeta">
|
|
133
|
+
<span className="dshr-muted">{item.enabled ? '已启用' : '未启用'}</span>
|
|
134
|
+
<span className="dshr-chevron">›</span>
|
|
135
|
+
</div>
|
|
136
|
+
</section>
|
|
137
|
+
)
|
|
138
|
+
})}
|
|
139
|
+
</div>
|
|
140
140
|
)}
|
|
141
141
|
</div>
|
|
142
142
|
)
|
package/src/client/router.css
CHANGED
|
@@ -923,6 +923,34 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-
|
|
|
923
923
|
margin-top: 4px;
|
|
924
924
|
}
|
|
925
925
|
|
|
926
|
+
/* watch 进程行动作区(复制 PID / kill 命令,只读) */
|
|
927
|
+
.dshr-procOps {
|
|
928
|
+
display: inline-flex;
|
|
929
|
+
align-items: center;
|
|
930
|
+
justify-content: flex-end;
|
|
931
|
+
gap: 4px;
|
|
932
|
+
white-space: nowrap;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/* 扩展供应商式小卡片:未就绪红字(描述只在详情页,卡片不放) */
|
|
936
|
+
.dshr-extCardError {
|
|
937
|
+
color: var(--rs-danger);
|
|
938
|
+
font-size: 11px;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/* 扩展卡 logo(如 rtk 白底 logo):黑底 + 内边距,白色 logo 在黑底上显形 */
|
|
942
|
+
.dshr-extLogo {
|
|
943
|
+
background: #000;
|
|
944
|
+
padding: 3px;
|
|
945
|
+
box-sizing: border-box;
|
|
946
|
+
object-fit: contain;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/* 扩展详情头部右侧的开关(顶到标题行右边,同供应商详情的编辑按钮位) */
|
|
950
|
+
.dshr-extDetailToggle {
|
|
951
|
+
margin-left: auto;
|
|
952
|
+
}
|
|
953
|
+
|
|
926
954
|
/* 扩展卡片内容区:文字左对齐,左侧留白与标题行的图标位对齐 */
|
|
927
955
|
.dshr-extBody {
|
|
928
956
|
padding: 10px 14px 12px 48px;
|
package/src/ext/contract.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
* file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
|
|
26
26
|
*/
|
|
27
27
|
import type { Context } from '@deepseek-ai/cordis'
|
|
28
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
28
29
|
|
|
29
30
|
/** 扩展器唯一 id(注册键)。 */
|
|
30
31
|
export type ExtId = string
|
|
@@ -53,8 +54,16 @@ export interface RouterExt {
|
|
|
53
54
|
readonly name: string
|
|
54
55
|
/** 面板副标题说明。 */
|
|
55
56
|
readonly description?: string
|
|
57
|
+
/** 卡片/详情图标的 logo URL(可选;缺省用默认图标)。 */
|
|
58
|
+
readonly icon?: string
|
|
56
59
|
/** 当前运行时状态(ready + 不就绪时的说明)。 */
|
|
57
60
|
getState(): ExtState
|
|
61
|
+
/**
|
|
62
|
+
* 通用扩展 API 点(可选):核心把 `/router/api/ext/{id}/{subpath}` 兜底路由派发进来,
|
|
63
|
+
* 由扩展器自己裁决/处理(如 watch 的 `processes`/`kill`)。返回 true 表示已处理;
|
|
64
|
+
* false / 未实现 → 核心回 404。这样扩展的执行面逻辑进插件,核心只加一条通用转发。
|
|
65
|
+
*/
|
|
66
|
+
api?(subpath: string, req: IncomingMessage, res: ServerResponse): boolean | Promise<boolean>
|
|
58
67
|
/** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
|
|
59
68
|
dispose?(): void
|
|
60
69
|
}
|
|
@@ -64,6 +73,8 @@ export interface ExtInfo {
|
|
|
64
73
|
id: string
|
|
65
74
|
name: string
|
|
66
75
|
description?: string
|
|
76
|
+
/** 卡片/详情图标 logo URL(可选)。 */
|
|
77
|
+
icon?: string
|
|
67
78
|
/** 核心持久化的开关(默认关)。 */
|
|
68
79
|
enabled: boolean
|
|
69
80
|
/** 插件报的运行时就绪状态。 */
|
package/src/index.ts
CHANGED
|
@@ -525,6 +525,7 @@ export function apply(rawContext: unknown): void {
|
|
|
525
525
|
id: e.id,
|
|
526
526
|
name: e.name ?? e.id,
|
|
527
527
|
...(e.description !== undefined ? { description: e.description } : {}),
|
|
528
|
+
...(e.icon !== undefined ? { icon: e.icon } : {}),
|
|
528
529
|
enabled: extStore.isEnabled(e.id),
|
|
529
530
|
ready: st?.ready === true,
|
|
530
531
|
...(st?.detail !== undefined ? { detail: st.detail } : {}),
|
|
@@ -534,6 +535,34 @@ export function apply(rawContext: unknown): void {
|
|
|
534
535
|
writeJson(res, 200, { ok: true, enhancers: list })
|
|
535
536
|
})
|
|
536
537
|
|
|
538
|
+
// ---- 扩展通用 API 转发:/router/api/ext/{id}/{subpath} → 该扩展器的 api() ----
|
|
539
|
+
// watch 这类自定义扩展用它暴露自己的面板接口(如 processes)。分开的 exact 路由
|
|
540
|
+
// `/ext`(上方)管开关列表;这里必须是 **prefix** 路由兜子路径(`route()` 是 exact,
|
|
541
|
+
// 匹配不了 `/ext/watch/processes`——用 exact 会造成 404 空 body → 前端 res.json() 崩)。
|
|
542
|
+
// 核心不知道子路径长什么样——是否处理、怎么处理全由扩展器自己裁决(执行面),核心只转发。
|
|
543
|
+
const extDispatch = ctx.webServer.register({
|
|
544
|
+
kind: 'prefix',
|
|
545
|
+
path: `${ROUTER_API_BASE}/ext/`,
|
|
546
|
+
handler: async (req, res) => {
|
|
547
|
+
const pathname = new URL(req.url ?? '/', 'http://localhost').pathname
|
|
548
|
+
const mount = `${ROUTER_API_BASE}/ext/`
|
|
549
|
+
const rest = pathname.startsWith(mount) ? pathname.slice(mount.length) : ''
|
|
550
|
+
const slash = rest.indexOf('/')
|
|
551
|
+
const extId = slash < 0 ? rest : rest.slice(0, slash)
|
|
552
|
+
const subpath = slash < 0 ? '' : rest.slice(slash + 1)
|
|
553
|
+
if (extId === '') {
|
|
554
|
+
writeJson(res, 404, { ok: false, error: 'extension not found' })
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
const ext = (exts[extId] as RouterExt | undefined)
|
|
558
|
+
if (ext !== undefined && typeof ext.api === 'function' && (await ext.api(subpath, req, res))) {
|
|
559
|
+
return // 扩展器已处理并写出响应
|
|
560
|
+
}
|
|
561
|
+
writeJson(res, 404, { ok: false, error: 'extension api not found' })
|
|
562
|
+
},
|
|
563
|
+
})
|
|
564
|
+
disposers.push(extDispatch)
|
|
565
|
+
|
|
537
566
|
// ---- 概览看板:用量统计 ----
|
|
538
567
|
|
|
539
568
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { test } from 'node:test'
|
|
13
13
|
import assert from 'node:assert/strict'
|
|
14
|
-
import { EMPTY_RESPONSE_CODE } from '@deepseek-ai/dsh-llm'
|
|
14
|
+
import { AssistantStreamAccumulator, EMPTY_RESPONSE_CODE } from '@deepseek-ai/dsh-llm'
|
|
15
15
|
import { toTokenUsage } from '../router/usage-tokens.ts'
|
|
16
16
|
import { RouterAdapter, translateSse } from './adapter.ts'
|
|
17
17
|
|
|
@@ -191,6 +191,59 @@ async function toolCallsOf(...payloads: string[]): Promise<Array<{ name?: string
|
|
|
191
191
|
return out
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
test('工具调用:后续 delta 把 id/name 重发成 null 时,不能冲掉首帧也不许发 null', async () => {
|
|
195
|
+
// 真实事故(2026-09-22,OpenCode/Zen):上游首帧给 id+name,后续分片帧把
|
|
196
|
+
// 它们**显式重发成 null**(不是省略、不是空串)。用对象构造、交给 frame() 序列化,
|
|
197
|
+
// 免得在字符串字面量里跟引号打架。
|
|
198
|
+
const tcDelta = (tc: unknown): string => frame({ choices: [{ delta: { tool_calls: [tc] } }] })
|
|
199
|
+
const payloads = [
|
|
200
|
+
tcDelta({ index: 0, id: 'call_abc', type: 'function', function: { name: 'bash', arguments: '' } }),
|
|
201
|
+
tcDelta({ index: 0, id: null, type: 'function', function: { name: null, arguments: '{' } }),
|
|
202
|
+
tcDelta({ index: 0, id: null, type: 'function', function: { name: null, arguments: '"command": "ls"}' } }),
|
|
203
|
+
frame({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] }),
|
|
204
|
+
'[DONE]',
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
// 旧判据是 `!== undefined`,null 通不过它却被当成真值:既把 "bash" 冲成 null,
|
|
208
|
+
// 又把 `name: null` 发进 StreamChunk。而真实运行路径
|
|
209
|
+
// (dsh-llm 的 AssistantStreamAccumulator.push)判据是
|
|
210
|
+
// `Object.hasOwn(chunk,'name') && typeof chunk.name !== 'string'` → 抛
|
|
211
|
+
// `TypeError: tool-call-delta name must be a string`(用户看到的
|
|
212
|
+
// 「本轮运行失败 tool-call-delta name must be a string」)。
|
|
213
|
+
//
|
|
214
|
+
// 用**真实的那个累加器**验收,而不是自己复述规则:它就是运行时会抛的那一段。
|
|
215
|
+
const acc = new AssistantStreamAccumulator()
|
|
216
|
+
let n = 0
|
|
217
|
+
for await (const chunk of translateSse(payloads)) {
|
|
218
|
+
acc.push({ time: n++, chunk }) // 这里就是线上抛错的那一步
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 逐条再按原判据过一遍,失败时能直接指向违规的那条 delta
|
|
222
|
+
for await (const chunk of translateSse(payloads)) {
|
|
223
|
+
if (chunk.type !== 'tool-call-delta') continue
|
|
224
|
+
if (Object.hasOwn(chunk, 'name')) assert.equal(typeof chunk.name, 'string', 'name 一旦出现就必须是字符串')
|
|
225
|
+
assert.equal(typeof chunk.id, 'string', 'id 必须是字符串')
|
|
226
|
+
assert.equal(typeof chunk.argumentsDelta, 'string', 'argumentsDelta 必须是字符串')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 首帧给的 name/id 必须在后续 null 帧里**保持**
|
|
230
|
+
const deltas: Array<Record<string, unknown>> = []
|
|
231
|
+
for await (const chunk of translateSse(payloads)) {
|
|
232
|
+
if (chunk.type === 'tool-call-delta') deltas.push(chunk as unknown as Record<string, unknown>)
|
|
233
|
+
}
|
|
234
|
+
assert.ok(deltas.length >= 3, '三个分片帧都该产出 delta')
|
|
235
|
+
for (const d of deltas) {
|
|
236
|
+
assert.equal(d.name, 'bash', 'name 被后续 null 帧冲掉了(工具名会变成空)')
|
|
237
|
+
assert.equal(d.id, 'call_abc', 'id 被后续 null 帧冲掉了')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 收尾后的完整调用也要正确
|
|
241
|
+
const calls = await toolCallsOf(...payloads)
|
|
242
|
+
assert.equal(calls.length, 1)
|
|
243
|
+
assert.equal(calls[0]?.name, 'bash', '工具名必须保住')
|
|
244
|
+
assert.equal(calls[0]?.arguments, '{"command": "ls"}')
|
|
245
|
+
})
|
|
246
|
+
|
|
194
247
|
test('工具调用:参数分片发完才收尾,完整 JSON 原样保留', async () => {
|
|
195
248
|
const calls = await toolCallsOf(
|
|
196
249
|
frame({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'bash', arguments: '' } }] } }] }),
|
package/src/llm/adapter.ts
CHANGED
|
@@ -439,9 +439,21 @@ export async function* translateSse(payloads: AsyncIterable<string> | Iterable<s
|
|
|
439
439
|
toolBlocks.set(key, block)
|
|
440
440
|
yield { type: 'block-start', index: block.index, blockType: 'tool-call' }
|
|
441
441
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
442
|
+
// 身份字段只认**非空字符串**。上游(OpenCode/Zen)在后续 delta 里会把
|
|
443
|
+
// 首帧已给过的 id/name 显式重发成 `null`(不是省略、也不是空串)——
|
|
444
|
+
// 实测帧形状:
|
|
445
|
+
// {"index":0,"id":"call_abc","function":{"name":"bash","arguments":""}}
|
|
446
|
+
// {"index":0,"id":null, "function":{"name":null, "arguments":"{"}}
|
|
447
|
+
// 用 `!== undefined` 挡不住 null:它既会把首帧的 "bash"/"call_abc" **冲成
|
|
448
|
+
// null**(工具名丢失),又会把 `name: null` 原样发进 StreamChunk,而
|
|
449
|
+
// dsh-llm 的校验是 `typeof chunk.name !== 'string'`(Object.hasOwn 先判存在)
|
|
450
|
+
// → 抛 `TypeError: tool-call-delta name must be a string` →「本轮运行失败」。
|
|
451
|
+
// 判据与核心 aggregateSSE(非流式聚合路径)保持一致,两条路不能有分歧。
|
|
452
|
+
if (typeof call.id === 'string' && call.id !== '') block.callId = call.id
|
|
453
|
+
if (typeof call.function?.name === 'string' && call.function.name !== '') block.name = call.function.name
|
|
454
|
+
// arguments 同理只认字符串:`?? ''` 挡得住 null/undefined,但挡不住
|
|
455
|
+
// 上游偶发把参数发成对象/数字——那会撞 dsh-llm 的同一条校验。
|
|
456
|
+
const fragment = typeof call.function?.arguments === 'string' ? call.function.arguments : ''
|
|
445
457
|
block.text += fragment
|
|
446
458
|
yield {
|
|
447
459
|
type: 'tool-call-delta',
|
|
@@ -198,3 +198,53 @@ test('聚合失败:reader 必须被解绑(否则连接还活着时上层无
|
|
|
198
198
|
assert.equal(releaseLocked, true, '聚合失败后必须 releaseLock,否则流锁死、上层无法回收')
|
|
199
199
|
assert.equal(failing.locked, false, '流必须处于未锁定状态(可被 cancel / 已被回收)')
|
|
200
200
|
})
|
|
201
|
+
|
|
202
|
+
test('fixFrame:把后续 delta 里重发成 null 的 id/name 剥掉(否则外部 OpenAI 客户端会丢工具名)', async () => {
|
|
203
|
+
// 为什么这一层也得修(adapter 的修复保护不到它):`/v1/chat/completions` 是
|
|
204
|
+
// **对外**的 OpenAI 兼容端点,外部客户端(Cline/Cursor/其它工具)直接读这条流。
|
|
205
|
+
// 上游 OpenCode/Zen 在工具调用的后续分片帧里会把首帧已给过的 id/name 显式重发成
|
|
206
|
+
// null——外部客户端普遍按 `name !== undefined` 判「有没有新值」,于是把已经拿到的
|
|
207
|
+
// "bash" 冲成 null,表现为 `unknown tool ""`。fixFrame 是核心的**线上归一化层**,
|
|
208
|
+
// 职责就是抹掉这类不合规的空值(空串早就抹了,null 语义相同)。
|
|
209
|
+
const enc = new TextEncoder()
|
|
210
|
+
const sse =
|
|
211
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"bash","arguments":""}}]}}]}\n\n' +
|
|
212
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":null,"type":"function","function":{"name":null,"arguments":"{"}}]}}]}\n\n' +
|
|
213
|
+
'data: [DONE]\n\n'
|
|
214
|
+
const upstream = new ReadableStream<Uint8Array>({
|
|
215
|
+
start(ctrl) { ctrl.enqueue(enc.encode(sse)); ctrl.close() },
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const router = new Router('')
|
|
219
|
+
router.add(supplierWith(upstream) as never)
|
|
220
|
+
|
|
221
|
+
const server = http.createServer((req, res) => {
|
|
222
|
+
let body = ''
|
|
223
|
+
req.on('data', (c) => { body += c })
|
|
224
|
+
req.on('end', () => {
|
|
225
|
+
void router.chatCompletions(
|
|
226
|
+
{ model: 'sup,m1', stream: true, rawBody: body } as never,
|
|
227
|
+
res as never,
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
await new Promise<void>((r) => server.listen(0, '127.0.0.1', () => r()))
|
|
232
|
+
const port = (server.address() as { port: number }).port
|
|
233
|
+
try {
|
|
234
|
+
const resp = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'Content-Type': 'application/json' },
|
|
237
|
+
body: JSON.stringify({ model: 'sup,m1', messages: [{ role: 'user', content: 'hi' }], stream: true }),
|
|
238
|
+
})
|
|
239
|
+
const text = await resp.text()
|
|
240
|
+
// 第一帧给的 "bash"/"call_abc" 必须还在
|
|
241
|
+
assert.ok(text.includes('"name":"bash"'), `首帧的工具名丢了:${text.slice(0, 200)}`)
|
|
242
|
+
assert.ok(text.includes('"id":"call_abc"'), `首帧的 id 丢了:${text.slice(0, 200)}`)
|
|
243
|
+
// 后续帧的 null 必须被剥掉(不能出现在线上字节里)
|
|
244
|
+
assert.equal(/"name":null/.test(text), false, `null 的 name 泄漏到了线上:${text.slice(0, 200)}`)
|
|
245
|
+
assert.equal(/"id":null/.test(text), false, `null 的 id 泄漏到了线上:${text.slice(0, 200)}`)
|
|
246
|
+
} finally {
|
|
247
|
+
server.closeAllConnections?.()
|
|
248
|
+
server.close()
|
|
249
|
+
}
|
|
250
|
+
})
|
package/src/router/index.ts
CHANGED
|
@@ -296,7 +296,16 @@ function isDoneFrame(frame: string): boolean {
|
|
|
296
296
|
return /(^|\n)data:[ \t]*\[DONE\][ \t]*(\n|$)/.test(frame)
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
/**
|
|
299
|
+
/**
|
|
300
|
+
* 修一帧 SSE:剥掉**空的**(`''` 或 `null`)name/id,出任何问题都原样返回。
|
|
301
|
+
*
|
|
302
|
+
* 为什么 `null` 也算空:上游(OpenCode/Zen)在后续 delta 里会把首帧已给过的
|
|
303
|
+
* id/name 显式重发成 `null`——`{"id":null,"function":{"name":null,"arguments":"{"}}`。
|
|
304
|
+
* 下游判「有没有新值」用的是 `name !== undefined`(dsh 的 BlockAssembler 就是),
|
|
305
|
+
* null 过不了这个判断、却也不该覆盖首帧:客户端会把已拿到的 "bash" 冲成 null,
|
|
306
|
+
* 工具名丢失(表现为 `unknown tool ""`)。空串早就剥了,null 语义上同样是
|
|
307
|
+
* 「本帧没给新值」,一并剥掉。
|
|
308
|
+
*/
|
|
300
309
|
function fixFrame(frame: string): string {
|
|
301
310
|
// 绝大多数帧没有 tool_calls,先廉价字符串筛查,避免无谓的 JSON 解析
|
|
302
311
|
if (!frame.includes('tool_calls')) return frame
|
|
@@ -314,9 +323,13 @@ function fixFrame(frame: string): string {
|
|
|
314
323
|
const tcs = delta?.tool_calls as Array<Record<string, unknown>> | undefined
|
|
315
324
|
if (Array.isArray(tcs)) {
|
|
316
325
|
for (const tc of tcs) {
|
|
317
|
-
|
|
326
|
+
// 空串与 null 都是「本帧没给新值」,剥掉让下游沿用首帧那个
|
|
327
|
+
if (tc.id === '' || tc.id === null) { delete tc.id; changed = true }
|
|
318
328
|
const fn = tc.function as Record<string, unknown> | undefined
|
|
319
|
-
if (fn !== null && typeof fn === 'object' && fn.name === ''
|
|
329
|
+
if (fn !== null && typeof fn === 'object' && (fn.name === '' || fn.name === null)) {
|
|
330
|
+
delete fn.name
|
|
331
|
+
changed = true
|
|
332
|
+
}
|
|
320
333
|
}
|
|
321
334
|
}
|
|
322
335
|
return changed ? `data: ${JSON.stringify(obj)}` : line
|
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
|
+
})
|