dsh-router-core 0.3.21 → 0.4.1
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 +238 -63
- package/cordis.patch.yml +22 -0
- package/docs/ext.md +28 -5
- package/docs/pool-sticky-block.md +158 -0
- package/docs/suppliers.md +28 -5
- package/lib/client-registry.js +1300 -549
- package/lib/client.js +1300 -549
- package/lib/ext-test/index.js +47 -0
- package/lib/ext-test/locale/en.json +6 -0
- package/lib/ext-test/locale/zh.json +6 -0
- package/lib/ext-test/package.json +5 -0
- package/lib/http-state-C1vb3gW2.js +48 -0
- package/lib/index.js +1169 -254
- package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
- package/lib/suppliers/nvidia/locale/en.json +6 -0
- package/lib/suppliers/nvidia/locale/zh.json +6 -0
- package/lib/suppliers/nvidia/package.json +5 -0
- package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
- package/lib/suppliers/opencode/locale/en.json +6 -0
- package/lib/suppliers/opencode/locale/zh.json +6 -0
- package/lib/suppliers/opencode/package.json +5 -0
- package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
- package/lib/suppliers/openrouter/locale/en.json +6 -0
- package/lib/suppliers/openrouter/locale/zh.json +6 -0
- package/lib/suppliers/openrouter/package.json +5 -0
- package/lib/types/ext/contract.d.ts +9 -7
- package/lib/types/ext-test/index.d.ts +21 -0
- package/lib/types/ext-test/plugin.d.ts +23 -0
- package/lib/types/host-version.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/llm/adapter.d.ts +35 -4
- package/lib/types/router/account-pool.d.ts +53 -18
- package/lib/types/router/index.d.ts +65 -15
- package/lib/types/router/prefix-affinity.d.ts +18 -1
- package/lib/types/router/types.d.ts +7 -4
- package/lib/types/router/usage-store.d.ts +12 -0
- package/lib/types/shared.d.ts +25 -21
- package/lib/types/supplier-config.d.ts +15 -3
- package/lib/types/suppliers/contract.d.ts +37 -4
- package/lib/types/suppliers/http-state.d.ts +23 -0
- package/lib/types/suppliers/loader.d.ts +6 -4
- package/lib/types/suppliers/nvidia/index.d.ts +18 -0
- package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
- package/lib/types/suppliers/opencode/index.d.ts +18 -0
- package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
- package/lib/types/suppliers/openrouter/index.d.ts +18 -0
- package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
- package/lib/types/suppliers/probe.d.ts +160 -0
- package/lib/types/suppliers/registry.d.ts +3 -2
- package/package.json +50 -16
- package/src/client/CombosTab.tsx +8 -53
- package/src/client/ExtDetail.tsx +32 -196
- package/src/client/ExtTab.tsx +25 -42
- package/src/client/ExtTestPanel.tsx +392 -0
- package/src/client/LastHitDock.tsx +141 -0
- package/src/client/RouterComponentsSection.tsx +241 -0
- package/src/client/RouterView.tsx +12 -5
- package/src/client/StatsTab.tsx +8 -6
- package/src/client/SupplierDetail.tsx +2 -34
- package/src/client/comp-button-scope.test.ts +58 -0
- package/src/client/comp-panel-layout.test.ts +58 -0
- package/src/client/env.d.ts +38 -1
- package/src/client/ext-panels.test.ts +44 -0
- package/src/client/ext-panels.ts +49 -0
- package/src/client/index.tsx +86 -0
- package/src/client/last-hit-store.ts +89 -0
- package/src/client/model-choice.test.ts +41 -0
- package/src/client/model-choice.ts +37 -0
- package/src/client/model-hint-copy.test.ts +7 -2
- package/src/client/model-hint-copy.ts +2 -2
- package/src/client/router-components.test.ts +114 -0
- package/src/client/router-components.ts +104 -0
- package/src/client/router.css +2592 -1635
- package/src/client/supplier-visibility.test.ts +57 -0
- package/src/client/supplier-visibility.ts +63 -0
- package/src/ext/contract.ts +9 -7
- package/src/ext-test/ext-test.test.ts +60 -0
- package/src/ext-test/index.ts +35 -0
- package/src/ext-test/locale/en.json +6 -0
- package/src/ext-test/locale/zh.json +6 -0
- package/src/ext-test/plugin.ts +34 -0
- package/src/ext-test/row.json +5 -0
- package/src/host-version.test.ts +61 -0
- package/src/host-version.ts +101 -0
- package/src/index.ts +165 -68
- package/src/llm/adapter.test.ts +175 -4
- package/src/llm/adapter.ts +109 -16
- package/src/router/account-pool.test.ts +189 -83
- package/src/router/account-pool.ts +120 -31
- package/src/router/disconnect-leak.test.ts +7 -12
- package/src/router/first-byte-budget.test.ts +160 -0
- package/src/router/index.ts +276 -62
- package/src/router/models-cache.test.ts +54 -9
- package/src/router/precise-dispatch.test.ts +6 -6
- package/src/router/prefix-affinity.ts +22 -1
- package/src/router/supplier-switch.test.ts +220 -0
- package/src/router/types.ts +7 -5
- package/src/router/usage-store.ts +16 -0
- package/src/router/usage.test.ts +1 -1
- package/src/shared.ts +25 -24
- package/src/supplier-config.ts +33 -13
- package/src/suppliers/contract.ts +56 -4
- package/src/suppliers/loader.test.ts +38 -4
- package/src/suppliers/loader.ts +33 -18
- package/src/suppliers/nvidia/index.ts +27 -0
- package/src/suppliers/nvidia/locale/en.json +6 -0
- package/src/suppliers/nvidia/locale/zh.json +6 -0
- package/src/suppliers/nvidia/row.json +5 -0
- package/src/suppliers/opencode/index.ts +27 -0
- package/src/suppliers/opencode/locale/en.json +6 -0
- package/src/suppliers/opencode/locale/zh.json +6 -0
- package/src/suppliers/opencode/row.json +5 -0
- package/src/suppliers/openrouter/index.ts +27 -0
- package/src/suppliers/openrouter/locale/en.json +6 -0
- package/src/suppliers/openrouter/locale/zh.json +6 -0
- package/src/suppliers/openrouter/row.json +5 -0
- package/src/suppliers/probe.test.ts +266 -0
- package/src/suppliers/probe.ts +475 -0
- package/src/suppliers/registry.ts +103 -23
- package/src/types.d.ts +2 -1
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 供应商插件的**契约体检**:逐个成员报告「实现了吗 / 实跑通了吗 / 为什么没跑」。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不直接在客户端遍历:契约成员活在**宿主进程**里的模块实例上
|
|
5
|
+
* (`wrapModule` 把它挂在 `supplier.__module`),客户端只看得见 `/health` 摘要里
|
|
6
|
+
* 那 5 个能力名 —— 那是 `DIFF_CAPS`,不是契约全貌。所以探测必须在核心里做。
|
|
7
|
+
*
|
|
8
|
+
* ## 最重要的约束:绝不代替用户做有副作用的事
|
|
9
|
+
*
|
|
10
|
+
* 「把所有功能跑一遍」听起来很爽,但契约里有一批成员**不能自动调**:
|
|
11
|
+
* `dispose` 调了就是卸载这个供应商;`addApiKey` 写凭证;`removeLink` 删凭证;
|
|
12
|
+
* `generateLoginUrl` 很可能直接触发设备码/OAuth 流。
|
|
13
|
+
* 那些**不出现在报告里**(见 `PROBE_EXCLUDED_MEMBERS`)—— 体检是给眼睛看的,
|
|
14
|
+
* 占一行只为说「没验」,是用篇幅淹掉真问题。
|
|
15
|
+
* 剩下的成员一律实跑(需要真实账号/额度的用连接池里的 token 真跑一次)。
|
|
16
|
+
*
|
|
17
|
+
* `tests/probe.test.ts` 里有一条判据专门锁这件事:被标记为 `skip` 的成员,
|
|
18
|
+
* 探针一个都不会去调(用会记账的假模块验)。
|
|
19
|
+
*
|
|
20
|
+
* ## 清单要跟契约一起长
|
|
21
|
+
*
|
|
22
|
+
* 契约加了成员而这里没加 = 体检漏检。所以 `probe.test.ts` 直接解析
|
|
23
|
+
* `contract.ts` 源码里的 `SupplierModule` 成员名,断言清单全覆盖 —— 编译期类型
|
|
24
|
+
* 抓不到这种「两份清单」,只能这样钉。
|
|
25
|
+
*/
|
|
26
|
+
import type { SupplierModule } from './contract.ts'
|
|
27
|
+
import type { LoadedSupplier } from './loader.ts'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 体检只有**一档,全自动**。
|
|
31
|
+
*
|
|
32
|
+
* 前提由使用者保证:连接池里已经有一个可用的 token。所以「每个功能到底通不通」这个问题
|
|
33
|
+
* 是可以被回答的 —— 于是能跑的就都跑掉:
|
|
34
|
+
* - 没有副作用的成员直接实跑;
|
|
35
|
+
* - 有副作用但可以用**无害探针输入**跑通代码路径的也实跑(`removeLink` 传不存在的
|
|
36
|
+
* uid、`completeLogin` 传无效回调 URL、`generateLoginUrl` 直接调);
|
|
37
|
+
* - 需要真实账号/真实额度的也实跑(`chatOnce` 走 `router.testModel` 真发一次、
|
|
38
|
+
* `checkinNow` 对连接池里的真实连接签到);
|
|
39
|
+
* - 会破坏体检前提的(`dispose` 卸载自己、`addApiKey` 写凭证…)**不进清单** ——
|
|
40
|
+
* 见 `PROBE_EXCLUDED_MEMBERS`,逐个写了不测的理由。
|
|
41
|
+
*
|
|
42
|
+
* 报告的每个成员都标 `executed`:体检的价值全在「哪些是验过的、哪些只是看了一眼」。
|
|
43
|
+
*/
|
|
44
|
+
export type ProbeExecuted = 'ran' | 'no'
|
|
45
|
+
|
|
46
|
+
/** 这个成员是不是有副作用的(`skip` = 需要特别处理:要么探针实跑,要么只报存在性)。 */
|
|
47
|
+
export type ProbeKind = 'safe' | 'skip'
|
|
48
|
+
|
|
49
|
+
/** 一个契约成员的体检条目。 */
|
|
50
|
+
export interface ProbeMember {
|
|
51
|
+
/** 契约里的成员名(与 `SupplierModule` 逐字一致)。 */
|
|
52
|
+
key: string
|
|
53
|
+
/** 面板显示名。 */
|
|
54
|
+
label: string
|
|
55
|
+
/** 必填成员 / 可选(差异化)成员。 */
|
|
56
|
+
required: boolean
|
|
57
|
+
probe: ProbeKind
|
|
58
|
+
/** 不自动执行的原因(`probe: 'skip'` 时必填,面板要显示给用户看)。 */
|
|
59
|
+
skipReason?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 结果里一条成员的最终状态。
|
|
64
|
+
*
|
|
65
|
+
* `unverified` 与 `fail` 必须分开:**没条件验**(没有 token、没有已启用模型、连接池
|
|
66
|
+
* 为空)不是插件的毛病。压成 fail 会让「用户还没放 token」被出厂结论判成不合格 ——
|
|
67
|
+
* 体检最忌讳把「没测到」说成「测出问题」。
|
|
68
|
+
*/
|
|
69
|
+
export type ProbeState = 'ok' | 'fail' | 'absent' | 'unverified'
|
|
70
|
+
|
|
71
|
+
export interface ProbeResult {
|
|
72
|
+
key: string
|
|
73
|
+
label: string
|
|
74
|
+
required: boolean
|
|
75
|
+
present: boolean
|
|
76
|
+
state: ProbeState
|
|
77
|
+
/** 到底跑没跑。`no` 时 `detail` 写清为什么。 */
|
|
78
|
+
executed: ProbeExecuted
|
|
79
|
+
/** 摘要/失败原因;`executed: 'no'` 时这里是「为什么没跑」。 */
|
|
80
|
+
detail?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 体检的外部输入:模型启用状态 + 跑一次真实 `chatOnce` 的入口。 */
|
|
84
|
+
export interface ProbeInput {
|
|
85
|
+
/** 该供应商当前的模型启用状态(调用方用 `router.modelsOf` 取,走核心缓存)。 */
|
|
86
|
+
models?: ReadonlyArray<{ id: string; enabled: boolean }>
|
|
87
|
+
/**
|
|
88
|
+
* 跑一次真实调用(`router.testModel`:账号遍历 + 冷却 + 首字节预算都生效)。
|
|
89
|
+
* 调用方决定用哪个模型 / 哪个连接。取不到模型时可以不提供 —— 报告里会写明
|
|
90
|
+
* 「无法验证」而不是假装通过。
|
|
91
|
+
*/
|
|
92
|
+
runChatOnce?: (model: string) => Promise<{ ok: boolean; detail: string }>
|
|
93
|
+
/**
|
|
94
|
+
* **真跑一遍「全部禁用 → 全部启用」并还原**。
|
|
95
|
+
*
|
|
96
|
+
* 这不是「只报可用性」—— 它就是体检里能测出「插件在 listModels 里过滤已禁用模型」
|
|
97
|
+
* 越权的那条路径:核心把全部模型标成停用,再拉一次模型列表,**被藏起来的就露馅**
|
|
98
|
+
* (插件以为该藏,核心却必须还能看见它们,否则面板的「已禁用」列表会空、用户再也
|
|
99
|
+
* 看不到自己禁用过什么)。
|
|
100
|
+
*
|
|
101
|
+
* 由调用方实现,**必须自己还原**(体检不该在用户配置上留痕)。
|
|
102
|
+
*/
|
|
103
|
+
runBulkToggleRoundTrip?: () => Promise<{ ok: boolean; detail: string }>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* **核心侧**能力:不属于插件契约(插件不实现它们,是核心代劳),所以不在上面的
|
|
110
|
+
* 成员表里 —— 但体检要看它们的状态。模型启用/禁用就是这一类。
|
|
111
|
+
*/
|
|
112
|
+
export interface ProbeCoreReport {
|
|
113
|
+
models: {
|
|
114
|
+
/** 模型总数;`listModels` 失败时为 null(= 拿不到,不代表 0 个)。 */
|
|
115
|
+
total: number | null
|
|
116
|
+
enabled: number
|
|
117
|
+
disabled: number
|
|
118
|
+
/** 拿不到总数时的原因。 */
|
|
119
|
+
note?: string
|
|
120
|
+
}
|
|
121
|
+
/** 核心代劳的操作:**只报可用性,一律不自动执行**(改了就是替用户改配置)。 */
|
|
122
|
+
/**
|
|
123
|
+
* 核心代劳的能力区。**「全部启用 / 全部禁用」是实跑的**(并还原):它既是面板上
|
|
124
|
+
* 真实存在的操作,也是体检唯一能验出「插件私自过滤已禁用模型」的办法。
|
|
125
|
+
*/
|
|
126
|
+
operations: Array<{ key: string; label: string; ran: boolean; ok?: boolean; detail?: string }>
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ProbeReport {
|
|
130
|
+
supplier: string
|
|
131
|
+
name: string
|
|
132
|
+
/** 出厂结论:`fail` = 必填成员缺失/实跑失败;`warn` = 可选能力缺或没实跑;`pass` = 全绿。 */
|
|
133
|
+
members: ProbeResult[]
|
|
134
|
+
core: ProbeCoreReport
|
|
135
|
+
summary: {
|
|
136
|
+
total: number
|
|
137
|
+
implemented: number
|
|
138
|
+
ok: number
|
|
139
|
+
fail: number
|
|
140
|
+
absent: number
|
|
141
|
+
/** 实现了但没验(任何输入都会毁掉体检前提)。 */
|
|
142
|
+
unverified: number
|
|
143
|
+
/** 实际实跑过的成员数(`executed !== 'no'`)。 */
|
|
144
|
+
ran: number
|
|
145
|
+
/** 核心区「全部启用/全部禁用」实跑的结果(抓插件越权的那条路)。 */
|
|
146
|
+
bulk: 'ok' | 'fail' | 'unverified'
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 契约成员清单(体检的唯一事实来源)。
|
|
152
|
+
*
|
|
153
|
+
* `required: false` = 差异化能力,按存在性暴露端点/UI;`probe: 'skip'` 的理由都写在这里,
|
|
154
|
+
* 面板会把 `skipReason` 原样显示 —— 用户能知道「有这个能力,只是体检不替你调」。
|
|
155
|
+
*/
|
|
156
|
+
export const SUPPLIER_CONTRACT_MEMBERS: readonly ProbeMember[] = [
|
|
157
|
+
{ key: 'id', label: '供应商 id', required: true, probe: 'safe' },
|
|
158
|
+
{ key: 'name', label: '显示名', required: true, probe: 'safe' },
|
|
159
|
+
{ key: 'priority', label: '优先级', required: false, probe: 'safe' },
|
|
160
|
+
{ key: 'icon', label: '图标', required: false, probe: 'safe' },
|
|
161
|
+
{ key: 'apiKeyHint', label: 'API key 提示文案', required: false, probe: 'safe' },
|
|
162
|
+
|
|
163
|
+
{ key: 'status', label: '账号状态', required: true, probe: 'safe' },
|
|
164
|
+
{ key: 'listModels', label: '模型列表', required: true, probe: 'safe' },
|
|
165
|
+
{
|
|
166
|
+
key: 'chatOnce',
|
|
167
|
+
label: '调用上游',
|
|
168
|
+
required: true,
|
|
169
|
+
probe: 'skip',
|
|
170
|
+
skipReason: '会真的发一次请求(消耗额度)。用上面的「跑一次访问测试」单独测,或在供应商详情里测模型。',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
key: 'checkinNow',
|
|
174
|
+
label: '签到',
|
|
175
|
+
required: false,
|
|
176
|
+
probe: 'skip',
|
|
177
|
+
skipReason: '会替这个连接真的去签到。',
|
|
178
|
+
},
|
|
179
|
+
] as const
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* **故意不列进体检清单的成员**(不是「不跑」,是**不在报告里出现**):
|
|
183
|
+
*
|
|
184
|
+
* - `completeLogin`:需要一个真实回调 URL 才跑得通,体检拿不到 —— 跑了也只是验
|
|
185
|
+
* 「无效 URL 会不会抛错」,那不是功能是否可用。
|
|
186
|
+
* - `addApiKey`:需要一个真实 API key,自动造一个只会往凭证库写垃圾。
|
|
187
|
+
* - `removeLink`:用不存在的 uid 去试,它当然返回 false —— 那是**正常响应**,
|
|
188
|
+
* 判成失败是体检自己错判功能有问题(踩过)。真要验它只能删一个真连接。
|
|
189
|
+
* - `pollLogin`:只读一个布尔标记,验它通不通没有意义。
|
|
190
|
+
* - `generateLoginUrl`:可能直接触发设备码 / OAuth 登录流,会在上游留下待处理的会话。
|
|
191
|
+
*
|
|
192
|
+
* 保留在清单里、逐条占行、只为说「没验」,是在用篇幅淹掉真问题。
|
|
193
|
+
* 但**契约里加了新成员,体检要能报出来** —— `probe.test.ts` 有一条判据解析
|
|
194
|
+
* `contract.ts` 断言「除了这份有意排除的名单之外必须全覆盖」。
|
|
195
|
+
*/
|
|
196
|
+
export const PROBE_EXCLUDED_MEMBERS: Record<string, string> = {
|
|
197
|
+
dispose: '调用它就是把这个供应商卸载掉 —— 体检没法在自己身上验自己',
|
|
198
|
+
completeLogin: '需要一个真实回调 URL',
|
|
199
|
+
addApiKey: '需要一个真实 API key',
|
|
200
|
+
removeLink: '只能用真连接验(试删不存在的连接必然返回 false,那是正常响应)',
|
|
201
|
+
pollLogin: '只读一个布尔标记,验它通不通没有意义',
|
|
202
|
+
generateLoginUrl: '可能触发设备码 / OAuth 流,会在上游留下待处理会话',
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 读原始模块实例(`wrapModule` 挂在 wrapper 上)。 */
|
|
206
|
+
function rawModule(loaded: LoadedSupplier): Record<string, unknown> {
|
|
207
|
+
return (loaded.supplier as unknown as { __module?: Record<string, unknown> }).__module ?? {}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 读出账号状态列表(只读,不改任何东西)。 */
|
|
211
|
+
function accountStates(status: unknown): Array<{ uid: string; state: string; cooling: boolean }> {
|
|
212
|
+
if (typeof status !== 'object' || status === null) return []
|
|
213
|
+
const accounts = (status as { accounts?: unknown }).accounts
|
|
214
|
+
if (!Array.isArray(accounts)) return []
|
|
215
|
+
return accounts.flatMap((a) => {
|
|
216
|
+
const uid = (a as { uid?: unknown })?.uid
|
|
217
|
+
if (typeof uid !== 'string') return []
|
|
218
|
+
const state = typeof (a as { state?: unknown }).state === 'string' ? (a as { state: string }).state : 'ok'
|
|
219
|
+
return [{ uid, state, cooling: (a as { cooling?: unknown }).cooling === true }]
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 第一个连接 uid(`checkinNow` 之类要真实连接时用)。 */
|
|
224
|
+
function firstAccountUid(m: SupplierModule): string | undefined {
|
|
225
|
+
return accountStates(m.status())[0]?.uid
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 账号状态分布:`正常 2、冷却 1`、`session_dead 1` 这样。 */
|
|
229
|
+
function accountTally(accounts: Array<{ uid: string; state: string; cooling: boolean }>): string {
|
|
230
|
+
const counts = new Map<string, number>()
|
|
231
|
+
for (const a of accounts) {
|
|
232
|
+
const key = a.cooling ? '冷却' : a.state === 'ok' ? '正常' : a.state
|
|
233
|
+
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
234
|
+
}
|
|
235
|
+
return [...counts].map(([k, n]) => `${k} ${n}`).join('、')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 摘要:把返回值压成一行**对排查有用**的文本。
|
|
240
|
+
*
|
|
241
|
+
* 原先只说「返回 number」「返回 data:image/svg+xml,%3Csvg…」——等于没说话:
|
|
242
|
+
* 体检的意义就是让人一眼看出「priority 是几」「图标给了没有」。
|
|
243
|
+
*/
|
|
244
|
+
function describe(value: unknown): string {
|
|
245
|
+
if (value === undefined) return '未提供(可选)'
|
|
246
|
+
if (value === null) return '返回 null'
|
|
247
|
+
if (Array.isArray(value)) return `${value.length} 项`
|
|
248
|
+
if (typeof value === 'string') {
|
|
249
|
+
// 内联图标是一整段 data URI,截断出来全是 %3C…,不如说清「给了、是什么格式」
|
|
250
|
+
if (value.startsWith('data:')) return `已提供(内联 ${value.slice(5, value.indexOf('/') || 12)})`
|
|
251
|
+
if (value === '') return '返回空串'
|
|
252
|
+
return value.length > 48 ? `返回 ${value.slice(0, 48)}…` : `返回 ${value}`
|
|
253
|
+
}
|
|
254
|
+
if (typeof value === 'number' || typeof value === 'boolean') return `返回 ${value}`
|
|
255
|
+
if (typeof value === 'object') {
|
|
256
|
+
const o = value as Record<string, unknown>
|
|
257
|
+
if (typeof o.ok === 'boolean') return o.ok === false ? `ok:false ${String(o.error ?? o.message ?? '')}`.trim() : 'ok:true'
|
|
258
|
+
return Object.keys(o).slice(0, 4).join(', ') || '空对象'
|
|
259
|
+
}
|
|
260
|
+
return `返回 ${typeof value}`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 异步取一个值并压成摘要(同步抛错也要接住)。 */
|
|
264
|
+
async function probeValue(call: () => unknown): Promise<{ ok: boolean; detail: string }> {
|
|
265
|
+
try {
|
|
266
|
+
const value = await call()
|
|
267
|
+
return { ok: true, detail: describe(value) }
|
|
268
|
+
} catch (err) {
|
|
269
|
+
return { ok: false, detail: (err as Error).message }
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
*(这里原本有一份「永不实跑」名单:`dispose` / `addApiKey`。
|
|
275
|
+
* 两者都已移出体检清单,名单随之作废 —— 留着空机制比删掉更糟:
|
|
276
|
+
* 它看起来像个可配置的开关,其实没有任何成员会命中。
|
|
277
|
+
* 「为什么要移」见 PROBE_EXCLUDED_MEMBERS。)
|
|
278
|
+
*/
|
|
279
|
+
|
|
280
|
+
/** 深度档用来「不碰真实状态就跑通代码路径」的探针输入。 */
|
|
281
|
+
const PROBE_UID = '__probe_no_such_uid__'
|
|
282
|
+
const PROBE_CALLBACK = 'https://invalid.example/__probe__'
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 体检一个已装载的供应商。
|
|
286
|
+
*
|
|
287
|
+
* 只跑 `probe: 'safe'` 的成员;`skip` 的只报存在性。绝不代替用户调有副作用的成员。
|
|
288
|
+
*
|
|
289
|
+
* @param models - 该供应商当前的模型启用状态(由调用方用 `router.modelsOf` 取,
|
|
290
|
+
* 走核心缓存不额外打上游)。取不到就传 undefined —— 报告里会如实写「拿不到」,
|
|
291
|
+
* 不能拿 0 冒充「一个模型都没有」。
|
|
292
|
+
*/
|
|
293
|
+
export async function probeSupplier(loaded: LoadedSupplier, input: ProbeInput = {}): Promise<ProbeReport> {
|
|
294
|
+
const m = rawModule(loaded)
|
|
295
|
+
const members: ProbeResult[] = []
|
|
296
|
+
for (const member of SUPPLIER_CONTRACT_MEMBERS) {
|
|
297
|
+
const present = member.key in m ? m[member.key] !== undefined : false
|
|
298
|
+
// 清单里的成员一律实跑 —— 会毁掉体检前提的那些(dispose 等)早已移出清单
|
|
299
|
+
const base: Omit<ProbeResult, 'state' | 'detail'> = {
|
|
300
|
+
key: member.key, label: member.label, required: member.required, present,
|
|
301
|
+
executed: present ? 'ran' : 'no',
|
|
302
|
+
}
|
|
303
|
+
if (!present) {
|
|
304
|
+
members.push({
|
|
305
|
+
...base, state: member.required ? 'fail' : 'absent',
|
|
306
|
+
...(member.required ? { detail: '必填成员缺失,插件不完整' } : {}),
|
|
307
|
+
})
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
const outcome = member.probe === 'safe'
|
|
311
|
+
? await runSafeProbe(member.key, m as unknown as SupplierModule, input)
|
|
312
|
+
: await runProbedMember(member.key, m as unknown as SupplierModule, input)
|
|
313
|
+
// 「无法验证」归 unverified(warn 语义),别压成 fail
|
|
314
|
+
members.push({
|
|
315
|
+
...base,
|
|
316
|
+
state: outcome.ok ? 'ok' : outcome.detail.startsWith('无法验证') ? 'unverified' : 'fail',
|
|
317
|
+
detail: outcome.detail,
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
const bulkResult = await runBulkRoundTrip(input)
|
|
321
|
+
const models = input.models
|
|
322
|
+
const summary = {
|
|
323
|
+
total: members.length,
|
|
324
|
+
implemented: members.filter((x) => x.present).length,
|
|
325
|
+
ok: members.filter((x) => x.state === 'ok').length,
|
|
326
|
+
fail: members.filter((x) => x.state === 'fail').length,
|
|
327
|
+
absent: members.filter((x) => x.state === 'absent').length,
|
|
328
|
+
unverified: members.filter((x) => x.state === 'unverified').length,
|
|
329
|
+
}
|
|
330
|
+
const listModels = members.find((x) => x.key === 'listModels')
|
|
331
|
+
return {
|
|
332
|
+
supplier: loaded.supplier.id,
|
|
333
|
+
name: loaded.supplier.name,
|
|
334
|
+
members,
|
|
335
|
+
core: await coreReport(input, listModels?.state === 'ok', bulkResult),
|
|
336
|
+
summary: {
|
|
337
|
+
...summary,
|
|
338
|
+
ran: members.filter((x) => x.executed === 'ran' && x.present).length,
|
|
339
|
+
// 核心区「全部启用/禁用」是实跑的,它失败必须能被数字看见 —— 否则那条最该抓的
|
|
340
|
+
// 越权(插件在 listModels 里藏了已禁用的模型)就只藏在 detail 里。
|
|
341
|
+
bulk: bulkResult.ran ? (bulkResult.ok === true ? 'ok' : 'fail') : 'unverified',
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 实跑「有副作用但可以安全试」的成员:要么用**无害探针输入**跑通代码路径,要么用
|
|
348
|
+
* **连接池里的真实连接**真跑一次(前提由使用者保证:池里已放好可用 token)。
|
|
349
|
+
*/
|
|
350
|
+
async function runProbedMember(key: string, m: SupplierModule, input: ProbeInput): Promise<{ ok: boolean; detail: string }> {
|
|
351
|
+
try {
|
|
352
|
+
switch (key) {
|
|
353
|
+
case 'chatOnce': {
|
|
354
|
+
if (input.runChatOnce === undefined) return { ok: false, detail: '无法验证:没有发起真实调用的入口' }
|
|
355
|
+
const model = input.models?.find((x) => x.enabled)?.id ?? input.models?.[0]?.id
|
|
356
|
+
if (model === undefined) return { ok: false, detail: '无法验证:没有已启用的模型(先启用至少一个)' }
|
|
357
|
+
const r = await input.runChatOnce(model)
|
|
358
|
+
return { ok: r.ok, detail: `真实调用 ${model}:${r.ok ? '通了' : `失败(${r.detail})`}` }
|
|
359
|
+
}
|
|
360
|
+
case 'checkinNow': {
|
|
361
|
+
const uid = firstAccountUid(m)
|
|
362
|
+
if (uid === undefined) return { ok: false, detail: '无法验证:连接池里没有可用连接(先加一个 token)' }
|
|
363
|
+
const v = await m.checkinNow?.(uid)
|
|
364
|
+
return { ok: v?.ok !== false, detail: `对连接 ${uid} 真实签到:${describe(v)}` }
|
|
365
|
+
}
|
|
366
|
+
case 'generateLoginUrl': {
|
|
367
|
+
const v = await m.generateLoginUrl?.()
|
|
368
|
+
return { ok: true, detail: describe(v) }
|
|
369
|
+
}
|
|
370
|
+
case 'completeLogin': {
|
|
371
|
+
// 成功时返回 {uid,nickname},失败时抛错 —— 所以「没抛错」就算通过
|
|
372
|
+
const v = await m.completeLogin?.(PROBE_CALLBACK)
|
|
373
|
+
return { ok: true, detail: `无效回调实跑:${describe(v)}(没抛错即通过)` }
|
|
374
|
+
}
|
|
375
|
+
case 'removeLink': {
|
|
376
|
+
const v = await m.removeLink?.(PROBE_UID)
|
|
377
|
+
return { ok: v !== false, detail: `不存在的连接实跑:${describe(v)}` }
|
|
378
|
+
}
|
|
379
|
+
default:
|
|
380
|
+
return { ok: true, detail: '未纳入自动实跑' }
|
|
381
|
+
}
|
|
382
|
+
} catch (err) {
|
|
383
|
+
return { ok: false, detail: `实跑抛错(这本身就是一条问题):${(err as Error).message}` }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 核心代劳的能力区:模型启用/禁用状态 + 那几个改配置的操作(只报可用性)。 */
|
|
388
|
+
/** 核心区:模型启用状态 + 「全部启用/禁用」实跑结果。 */
|
|
389
|
+
async function coreReport(
|
|
390
|
+
input: ProbeInput,
|
|
391
|
+
modelsProbed: boolean,
|
|
392
|
+
bulk: { ran: boolean; ok?: boolean; detail: string },
|
|
393
|
+
): Promise<ProbeCoreReport> {
|
|
394
|
+
const models = input.models
|
|
395
|
+
const enabled = models?.filter((m) => m.enabled).length
|
|
396
|
+
const disabled = models === undefined ? undefined : models.length - (enabled ?? 0)
|
|
397
|
+
return {
|
|
398
|
+
models: {
|
|
399
|
+
total: models?.length ?? null,
|
|
400
|
+
enabled: enabled ?? 0,
|
|
401
|
+
disabled: disabled ?? 0,
|
|
402
|
+
...(models === undefined
|
|
403
|
+
? { note: modelsProbed ? '拿不到模型列表' : 'listModels 不可用,无法读启用状态' }
|
|
404
|
+
: {}),
|
|
405
|
+
},
|
|
406
|
+
operations: [
|
|
407
|
+
{
|
|
408
|
+
key: 'models.bulk',
|
|
409
|
+
label: '模型全部启用 / 全部禁用',
|
|
410
|
+
ran: bulk.ran,
|
|
411
|
+
...(bulk.ok === undefined ? {} : { ok: bulk.ok }),
|
|
412
|
+
detail: bulk.detail,
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* 实跑「全部禁用 → 看列表 → 全部启用」。
|
|
420
|
+
*
|
|
421
|
+
* 违规的插件会在这里露馅:它把已禁用的模型从 `listModels` 里过滤掉了,于是全部禁用
|
|
422
|
+
* 之后核心**看不到任何一个模型** —— 用户点「全部启用」也就再也点不回来了
|
|
423
|
+
* (路由里没有可用的模型 id 可传)。
|
|
424
|
+
*/
|
|
425
|
+
async function runBulkRoundTrip(input: ProbeInput): Promise<{ ran: boolean; ok?: boolean; detail: string }> {
|
|
426
|
+
if (input.runBulkToggleRoundTrip === undefined) {
|
|
427
|
+
return { ran: false, detail: '没有模型可禁用' }
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
const r = await input.runBulkToggleRoundTrip()
|
|
431
|
+
return { ran: true, ok: r.ok, detail: r.detail }
|
|
432
|
+
} catch (err) {
|
|
433
|
+
return { ran: true, ok: false, detail: `实跑抛错:${(err as Error).message}` }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 无副作用成员的实跑。**每个 case 都必须没有副作用** —— 新增成员时照此办理。 */
|
|
438
|
+
async function runSafeProbe(key: string, m: SupplierModule, input: ProbeInput): Promise<{ ok: boolean; detail: string }> {
|
|
439
|
+
switch (key) {
|
|
440
|
+
case 'id':
|
|
441
|
+
case 'name':
|
|
442
|
+
case 'priority':
|
|
443
|
+
case 'icon':
|
|
444
|
+
case 'apiKeyHint': {
|
|
445
|
+
// 纯字段:存在即通过,取值只为了在面板上回显
|
|
446
|
+
const value = (m as unknown as Record<string, unknown>)[key]
|
|
447
|
+
if (value === undefined) return { ok: true, detail: '未提供(可选)' }
|
|
448
|
+
return { ok: true, detail: describe(value) }
|
|
449
|
+
}
|
|
450
|
+
case 'status': {
|
|
451
|
+
// 这是体检里最有信息量的一条:账号有几个、分别什么状态(冷却 / 失效 / 正常)。
|
|
452
|
+
// 报「≥1」等于没报 —— 链接全过期正是要靠它看出来。
|
|
453
|
+
const r = await probeValue(() => m.status())
|
|
454
|
+
if (!r.ok) return r
|
|
455
|
+
const accounts = accountStates(m.status())
|
|
456
|
+
const summary = accounts.length === 0
|
|
457
|
+
? '无账号(无账号直连型供应商)'
|
|
458
|
+
: `${accounts.length} 个账号:${accountTally(accounts)}`
|
|
459
|
+
return { ok: true, detail: summary }
|
|
460
|
+
}
|
|
461
|
+
case 'listModels': {
|
|
462
|
+
// 「插件是否在 listModels 里过滤已禁用的模型」不在这里查 —— 那要看「全部禁用」
|
|
463
|
+
// 之后列表还剩多少,由核心区的 runBulkToggleRoundTrip 验(见下)。
|
|
464
|
+
// 在这里查会有个致命前提:得先有用户禁用过模型才查得出来,而出厂体检面对的
|
|
465
|
+
// 恰恰是「用户还没用过的新插件」。
|
|
466
|
+
return await probeValue(() => m.listModels())
|
|
467
|
+
}
|
|
468
|
+
case 'pollLogin': {
|
|
469
|
+
return await probeValue(() => m.pollLogin?.())
|
|
470
|
+
}
|
|
471
|
+
default:
|
|
472
|
+
// 清单里出现了没写 case 的成员:当成「不探」,别蒙混过关
|
|
473
|
+
return { ok: true, detail: '未纳入自动探测' }
|
|
474
|
+
}
|
|
475
|
+
}
|
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 通用能力(dsh-router 核心,所有供应商自动可用,js 无需实现):
|
|
5
5
|
* GET /suppliers/:id/models models + alias(listModels 合并启用状态)
|
|
6
|
+
* POST /suppliers/:id/probe 契约体检:逐成员报「实现/可用/不执行」
|
|
7
|
+
* PATCH /suppliers/:id/enabled {enabled} —— 供应商开关(关掉不参与路由)
|
|
6
8
|
* POST /suppliers/:id/models/toggle {id, enabled}
|
|
7
9
|
* POST /suppliers/:id/models/add {id}
|
|
8
10
|
* POST /suppliers/:id/models/remove {id}
|
|
9
11
|
* POST /suppliers/:id/models/bulk {enabled}
|
|
10
12
|
* POST /suppliers/:id/alias {alias}
|
|
11
13
|
* GET /suppliers/:id/pool/order + POST {uids}
|
|
12
|
-
* GET /suppliers/:id/pool/strategy + POST {strategy}
|
|
13
14
|
*
|
|
14
15
|
* 差异化能力(供应商 js 实现,按存在性注册;未实现返回 404):
|
|
15
16
|
* POST /suppliers/:id/models/fetch 拉取上游模型
|
|
16
|
-
* POST /suppliers/:id/models/test {id}
|
|
17
|
+
* POST /suppliers/:id/models/test {id, uid?}(uid = 只测这个连接)
|
|
17
18
|
* POST /suppliers/:id/login 生成登录链接
|
|
18
19
|
* POST /suppliers/:id/login/callback {callbackUrl}
|
|
19
20
|
* POST /suppliers/:id/links/remove {uid}
|
|
@@ -25,6 +26,7 @@ import type { Router } from '../router/index.ts'
|
|
|
25
26
|
import type { ModelWithEnabled, SupplierStatus } from '../router/types.ts'
|
|
26
27
|
import type { SupplierConfigStore } from '../supplier-config.ts'
|
|
27
28
|
import type { LoadedSupplier } from './loader.ts'
|
|
29
|
+
import { probeSupplier } from './probe.ts'
|
|
28
30
|
|
|
29
31
|
/** webServer 路由形状(与 index.ts 的 WebServerRoute 一致)。 */
|
|
30
32
|
export interface WebServerRoute {
|
|
@@ -113,6 +115,100 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
113
115
|
},
|
|
114
116
|
})
|
|
115
117
|
|
|
118
|
+
// ---- 通用: 供应商开关 ----
|
|
119
|
+
// 与扩展开关同构(用户对核心面板的操作,归核心持久化)。关掉 = 核心不把它接进
|
|
120
|
+
// 路由:`Router` 的活跃集合里没有它,请求/模型列表/组合都看不到,而它自己仍留在
|
|
121
|
+
// `/health` 与面板里 —— 否则关掉之后就找不回来,开关也失去了对象。
|
|
122
|
+
routes.push({
|
|
123
|
+
kind: 'exact',
|
|
124
|
+
path: `${p}/enabled`,
|
|
125
|
+
handler: async (req, res) => {
|
|
126
|
+
let body: { enabled?: unknown }
|
|
127
|
+
try {
|
|
128
|
+
body = JSON.parse(await readBody(req)) as { enabled?: unknown }
|
|
129
|
+
} catch {
|
|
130
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if (typeof body.enabled !== 'boolean') {
|
|
134
|
+
writeJson(res, 400, { ok: false, error: 'enabled must be a boolean' })
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
store.setEnabled(s.id, body.enabled)
|
|
138
|
+
router.invalidateModels(s.id)
|
|
139
|
+
writeJson(res, 200, { ok: true, id: s.id, enabled: router.isEnabled(s.id) })
|
|
140
|
+
},
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// ---- 契约体检(只跑只读成员;有副作用的只报存在性)----
|
|
144
|
+
routes.push({
|
|
145
|
+
kind: 'exact',
|
|
146
|
+
path: `${p}/probe`,
|
|
147
|
+
handler: async (req, res) => {
|
|
148
|
+
if (req.method !== 'POST') {
|
|
149
|
+
writeJson(res, 405, { ok: false, error: 'probe requires POST' })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
// body 可空;只用来选模型/连接(不选就用第一个可用的)
|
|
154
|
+
let pick: { model?: string; uid?: string } = {}
|
|
155
|
+
const raw = (await readBody(req)).trim()
|
|
156
|
+
if (raw !== '') {
|
|
157
|
+
const body = JSON.parse(raw) as { model?: unknown; uid?: unknown }
|
|
158
|
+
pick = {
|
|
159
|
+
...(typeof body.model === 'string' ? { model: body.model } : {}),
|
|
160
|
+
...(typeof body.uid === 'string' ? { uid: body.uid } : {}),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// 模型启用状态走 router.modelsOf(核心缓存,不额外打上游)
|
|
164
|
+
const models = await router.modelsOf(s.id).catch(() => undefined)
|
|
165
|
+
const report = await probeSupplier(loaded, {
|
|
166
|
+
models,
|
|
167
|
+
// **实跑「全部禁用 → 看列表 → 全部启用」,验完还原。**
|
|
168
|
+
//
|
|
169
|
+
// 这是体检里唯一能抓出「插件在 listModels 里过滤已禁用模型」越权的路径:
|
|
170
|
+
// 违规插件把已禁用的藏起来,于是全部禁用之后核心一个模型都看不到,
|
|
171
|
+
// 用户点「全部启用」也就再也点不回来(没有 id 可传)。
|
|
172
|
+
// 还原写在 finally —— 体检不该在用户配置上留痕,哪怕中间抛了。
|
|
173
|
+
runBulkToggleRoundTrip: async () => {
|
|
174
|
+
const original = [...store.get(s.id).disabled]
|
|
175
|
+
const before = await router.modelsOf(s.id)
|
|
176
|
+
const ids = before.map((mm) => mm.id)
|
|
177
|
+
if (ids.length === 0) return { ok: true, detail: '该供应商没有模型,跳过' }
|
|
178
|
+
try {
|
|
179
|
+
store.setAllModelsEnabled(s.id, false, ids)
|
|
180
|
+
router.invalidateModels(s.id)
|
|
181
|
+
const after = await router.modelsOf(s.id, true)
|
|
182
|
+
const hidden = after.filter((mm) => !mm.enabled).length
|
|
183
|
+
const missing = ids.length - after.length
|
|
184
|
+
if (hidden !== ids.length || missing !== 0) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
// 一句话说完:现象 + 归因。细说留给源码与提交记录,面板上不占地方。
|
|
188
|
+
detail: `全部禁用后只剩 ${after.length}/${ids.length} 个 —— 插件在 listModels 里藏了已禁用的模型(启用状态由核心合并)`,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return { ok: true, detail: `全部禁用后 ${after.length} 个都在,再启用可恢复` }
|
|
192
|
+
} finally {
|
|
193
|
+
store.setAllModelsEnabled(s.id, true, ids)
|
|
194
|
+
for (const id of original) store.setAllModelsEnabled(s.id, false, [id])
|
|
195
|
+
router.invalidateModels(s.id)
|
|
196
|
+
await router.modelsOf(s.id, true)
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
// chatOnce 真跑一次:账号遍历 / 冷却 / 首字节预算都走真实路径
|
|
200
|
+
runChatOnce: async (model) => {
|
|
201
|
+
const r = await router.testModel(s.id, model, pick.uid)
|
|
202
|
+
return { ok: r.ok, detail: r.ok ? '通了' : (r.error ?? '上游没给原因') }
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
writeJson(res, 200, { ok: true, report })
|
|
206
|
+
} catch (err) {
|
|
207
|
+
writeJson(res, 500, { ok: false, error: (err as Error).message })
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
116
212
|
// ---- 通用: alias ----
|
|
117
213
|
routes.push({
|
|
118
214
|
kind: 'exact',
|
|
@@ -205,7 +301,7 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
205
301
|
},
|
|
206
302
|
})
|
|
207
303
|
|
|
208
|
-
// ---- 通用: pool/order
|
|
304
|
+
// ---- 通用: pool/order ----
|
|
209
305
|
routes.push({
|
|
210
306
|
kind: 'exact',
|
|
211
307
|
path: `${p}/pool/order`,
|
|
@@ -224,24 +320,6 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
224
320
|
},
|
|
225
321
|
})
|
|
226
322
|
|
|
227
|
-
routes.push({
|
|
228
|
-
kind: 'exact',
|
|
229
|
-
path: `${p}/pool/strategy`,
|
|
230
|
-
handler: async (req, res) => {
|
|
231
|
-
if (req.method === 'GET') {
|
|
232
|
-
writeJson(res, 200, { ok: true, strategy: store.get(s.id).poolStrategy })
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
const body = JSON.parse(await readBody(req)) as { strategy?: string }
|
|
236
|
-
if (body.strategy !== 'fallback' && body.strategy !== 'round-robin') {
|
|
237
|
-
writeJson(res, 400, { ok: false, error: '策略无效' })
|
|
238
|
-
return
|
|
239
|
-
}
|
|
240
|
-
store.setPoolStrategy(s.id, body.strategy)
|
|
241
|
-
writeJson(res, 200, { ok: true })
|
|
242
|
-
},
|
|
243
|
-
})
|
|
244
|
-
|
|
245
323
|
// ---- 通用 UI: 获取模型(= 强制刷新 + 核心缓存更新) ----
|
|
246
324
|
routes.push({
|
|
247
325
|
kind: 'exact',
|
|
@@ -257,8 +335,10 @@ export function supplierRoutes(base: string, loaded: LoadedSupplier, store: Supp
|
|
|
257
335
|
kind: 'exact',
|
|
258
336
|
path: `${p}/models/test`,
|
|
259
337
|
handler: async (req, res) => {
|
|
260
|
-
const body = JSON.parse(await readBody(req)) as { id?: string }
|
|
261
|
-
|
|
338
|
+
const body = JSON.parse(await readBody(req)) as { id?: string; uid?: string }
|
|
339
|
+
// uid 缺省/空串 = 走账号池(与面板「测试」按钮同一条路径);给了就只测它。
|
|
340
|
+
const onlyUid = typeof body.uid === 'string' && body.uid !== '' ? body.uid : undefined
|
|
341
|
+
const result = await router.testModel(s.id, body.id ?? '', onlyUid)
|
|
262
342
|
writeJson(res, result.ok ? 200 : 400, result)
|
|
263
343
|
},
|
|
264
344
|
})
|
package/src/types.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ declare module '@deepseek-ai/dsh-settings' {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
declare module '@deepseek-ai/schemastery' {
|
|
25
|
-
interface Schema<T> {
|
|
25
|
+
export interface Schema<T> {
|
|
26
26
|
default(value: T): Schema<T>
|
|
27
27
|
optional(): Schema<T | undefined>
|
|
28
28
|
required(): Schema<T>
|
|
@@ -30,6 +30,7 @@ declare module '@deepseek-ai/schemastery' {
|
|
|
30
30
|
min(n: number): Schema<T>
|
|
31
31
|
max(n: number): Schema<T>
|
|
32
32
|
role(name: string): Schema<T>
|
|
33
|
+
volatile(): Schema<T>
|
|
33
34
|
}
|
|
34
35
|
interface Z {
|
|
35
36
|
string(): Schema<string>
|