dsh-router-core 0.3.19 → 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 +27 -0
- 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/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/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
|
+
})
|