@dsh-plugins/dsh-llm-hub 0.7.0
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/CHANGELOG.md +374 -0
- package/LICENSE +21 -0
- package/README.en.md +266 -0
- package/README.md +260 -0
- package/cordis.patch.yml +55 -0
- package/lib/client.js +862 -0
- package/lib/harness.js +436 -0
- package/lib/index.js +1481 -0
- package/package.json +70 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-llm-hub —— Host(Node)半。
|
|
3
|
+
*
|
|
4
|
+
* 给 DSH 的 **DeepSeek 官方直连路由**(`deepseek-official`)补两件官方适配器
|
|
5
|
+
* 自己没做的事:
|
|
6
|
+
*
|
|
7
|
+
* 1. **模型发现**:`ctx.llm.registerModelDiscovery('llm-deepseek', ...)`,
|
|
8
|
+
* 让 Models 页的「获取可用模型」按钮对官方直连生效。
|
|
9
|
+
* 2. **余额查询**:`GET /api/dsh-llm-hub/balance`,转发 DeepSeek 的
|
|
10
|
+
* `GET /user/balance`,供 client 半渲染余额卡。
|
|
11
|
+
* 3. **pi-ai 旁路补缺**:`/api/dsh-llm-hub/pi-ai/{status,probe,catalog}`,按
|
|
12
|
+
* `llm-pi-ai` 段里各 provider 的 baseURL/apiKeyEnv 拉网关模型目录。官方
|
|
13
|
+
* `dsh-llm-pi-ai` 自己占用了 discovery 坑(DUPLICATE_DISCOVERY 抢不了),
|
|
14
|
+
* 且其 LISTABLE_PROTOCOLS 不含 anthropic-messages —— modelgo 这类网关的
|
|
15
|
+
* 官方「获取可用模型」天然失效,这里不改官方行、只旁路补上。
|
|
16
|
+
*
|
|
17
|
+
* ## 为什么占得住
|
|
18
|
+
*
|
|
19
|
+
* `registerModelDiscovery` 按 settings 命名空间索引,**每个命名空间只允许一个
|
|
20
|
+
* 注册**(第二次抛 `DUPLICATE_DISCOVERY`)。而 `@deepseek-ai/dsh-llm-deepseek`
|
|
21
|
+
* 从未注册过发现 —— `0.1.2-rc.1` 与 `0.1.5-rc.2` 两版实测 `discover` 均零命中。
|
|
22
|
+
* 所以 `llm-deepseek` 这个槽是空的,本插件占上即可,**不改动 DSH 任何文件**。
|
|
23
|
+
*
|
|
24
|
+
* ## 连接事实
|
|
25
|
+
*
|
|
26
|
+
* baseURL 与 apiKey 的解析顺序与适配器自身一致,且**每次调用惰性读取**设置段:
|
|
27
|
+
* 插件 apply 时该段可能尚未注册(启动竞态),而适配器本身也是按请求重新解析的 ——
|
|
28
|
+
* 惰性读才能让两边看到同一份文档修订。
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-llm-hub/host
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** 官方直连适配器拥有的 settings 命名空间。 */
|
|
34
|
+
import { readFileSync } from 'node:fs'
|
|
35
|
+
import { fileURLToPath } from 'node:url'
|
|
36
|
+
import { applyHarness, harnessReport } from './harness.js'
|
|
37
|
+
|
|
38
|
+
const NS = 'llm-deepseek'
|
|
39
|
+
/** 适配器自身的公网端点默认值。 */
|
|
40
|
+
const DEFAULT_BASE_URL = 'https://api.deepseek.com'
|
|
41
|
+
/** 适配器自身的凭据引用默认值。 */
|
|
42
|
+
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
|
|
43
|
+
/** 调用方给的端点不得让我们无上限缓冲。 */
|
|
44
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
|
45
|
+
/** client 半读取余额的路由。 */
|
|
46
|
+
const BALANCE_PATH = '/api/dsh-llm-hub/balance'
|
|
47
|
+
/** 官方 pi-ai 适配器拥有的 settings 命名空间;这里只旁路读取,绝不注册 discovery。 */
|
|
48
|
+
const PIAI_NS = 'llm-pi-ai'
|
|
49
|
+
/** pi-ai 旁路三路由:静态状态 / 网关探测 / 目录明细。 */
|
|
50
|
+
const PIAI_STATUS_PATH = '/api/dsh-llm-hub/pi-ai/status'
|
|
51
|
+
const PIAI_PROBE_PATH = '/api/dsh-llm-hub/pi-ai/probe'
|
|
52
|
+
const PIAI_CATALOG_PATH = '/api/dsh-llm-hub/pi-ai/catalog'
|
|
53
|
+
const PIAI_BALANCE_PATH = '/api/dsh-llm-hub/pi-ai/balance'
|
|
54
|
+
const PIAI_MODELS_PATH = '/api/dsh-llm-hub/pi-ai/models'
|
|
55
|
+
const META_PATH = '/api/dsh-llm-hub/meta'
|
|
56
|
+
/** harness 探测自检路由;宿主 info 日志不落盘,靠它从外部确认注册结果。 */
|
|
57
|
+
const HARNESS_PATH = '/api/dsh-llm-hub/harness'
|
|
58
|
+
/** 模型下拉可用性:读缓存 / 强制全量重探。 */
|
|
59
|
+
const AVAILABILITY_PATH = '/api/dsh-llm-hub/availability'
|
|
60
|
+
const AVAILABILITY_RECHECK_PATH = '/api/dsh-llm-hub/availability/recheck'
|
|
61
|
+
/** 探针结论的保鲜期;过期后下次读取顺手在后台重探,不阻塞下拉。 */
|
|
62
|
+
const AVAILABILITY_TTL_MS = 5 * 60 * 1000
|
|
63
|
+
/** 一轮探测最长允许在途的时间;超过就允许再开一轮(超时兜底的兜底)。 */
|
|
64
|
+
const STUCK_PROBE_MS = 60 * 1000
|
|
65
|
+
/** 运行期失败标记的保鲜期:充值/续期后不必等手动刷新。 */
|
|
66
|
+
const RUNTIME_TTL_MS = 10 * 60 * 1000
|
|
67
|
+
/**
|
|
68
|
+
* 外部请求的超时上界。
|
|
69
|
+
*
|
|
70
|
+
* `fetch` 默认**没有**超时:网关接了 TCP 却不回包(或回一半就停)会让这一轮探测永远不结束,
|
|
71
|
+
* 于是 `probing` 一直 true、判定再也不刷新 —— 表现为「面板停在旧结论上,下拉跟着旧结论走」。
|
|
72
|
+
* 2026-09-16 实测踩到过一次。
|
|
73
|
+
*/
|
|
74
|
+
const REQUEST_TIMEOUT_MS = 6000
|
|
75
|
+
/**
|
|
76
|
+
* 给一次外部请求加超时,并保留调用方自己的取消。
|
|
77
|
+
* @param signal - 调用方取消信号(可选)。
|
|
78
|
+
* @returns 与外层取消、超时二者取先到的信号。
|
|
79
|
+
*/
|
|
80
|
+
function timeoutSignal(signal) {
|
|
81
|
+
if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return signal
|
|
82
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
83
|
+
if (signal === undefined) return timeout
|
|
84
|
+
if (typeof AbortSignal.any !== 'function') return timeout
|
|
85
|
+
return AbortSignal.any([signal, timeout])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 官方直连适配器的 provider id(见 @deepseek-ai/dsh-llm-deepseek 的 PROVIDER)。 */
|
|
89
|
+
const DEEPSEEK_PROVIDER = 'deepseek-official'
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 取候选里第一个可用的去空白字符串。
|
|
93
|
+
* @param candidates - 按优先级排列的值。
|
|
94
|
+
* @returns 去空白后的值,全不可用时为 `undefined`。
|
|
95
|
+
*/
|
|
96
|
+
function label(...candidates) {
|
|
97
|
+
for (const candidate of candidates) {
|
|
98
|
+
if (typeof candidate !== 'string') continue
|
|
99
|
+
const trimmed = candidate.trim()
|
|
100
|
+
if (trimmed.length > 0) return trimmed
|
|
101
|
+
}
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 取候选里第一个可用的正整数。
|
|
107
|
+
* @param candidates - 原始容量字段,按优先级排列。
|
|
108
|
+
* @returns 向下取整后的容量,全不可用时为 `undefined`。
|
|
109
|
+
*/
|
|
110
|
+
function capacity(...candidates) {
|
|
111
|
+
for (const candidate of candidates) {
|
|
112
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) return Math.floor(candidate)
|
|
113
|
+
}
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 取候选里第一个可用的**非负**数值。
|
|
119
|
+
*
|
|
120
|
+
* 与 {@link capacity} 分开是必要的:上下文长度 / 最大 token 的 0 是「没填」,
|
|
121
|
+
* 而百分比字段的 0 是**实义** —— 「剩余 0%」就是「用尽」。2026-09-16 实测:
|
|
122
|
+
* 共用 capacity 时 MiniMax 的 `current_interval_remaining_percent: 0` 被当成缺字段
|
|
123
|
+
* 丢掉,于是「余量为零」的账号照样留在模型下拉里。
|
|
124
|
+
* @param candidates - 原始百分比字段,按优先级排列。
|
|
125
|
+
* @returns 第一个非负有限数,全不可用时为 `undefined`。
|
|
126
|
+
*/
|
|
127
|
+
function percentage(...candidates) {
|
|
128
|
+
for (const candidate of candidates) {
|
|
129
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate >= 0) return candidate
|
|
130
|
+
}
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 把一份 OpenAI 风格的模型列表映射成发现候选。
|
|
136
|
+
* @param body - 已解码的响应体。
|
|
137
|
+
* @returns 按端点顺序排列的候选,跳过没有可用 id 的条目。
|
|
138
|
+
* @throws {Error} 当载荷没有 `data` 数组时。
|
|
139
|
+
*/
|
|
140
|
+
function readListing(body) {
|
|
141
|
+
const data = body?.data
|
|
142
|
+
if (!Array.isArray(data)) throw new Error('the model listing has no "data" array')
|
|
143
|
+
const models = []
|
|
144
|
+
for (const raw of data) {
|
|
145
|
+
const id = label(raw?.id)
|
|
146
|
+
if (id === undefined) continue
|
|
147
|
+
const modelName = label(raw?.name, raw?.display_name)
|
|
148
|
+
const contextWindow = capacity(raw?.context_window, raw?.context_length)
|
|
149
|
+
const maxTokens = capacity(raw?.max_output_tokens, raw?.max_tokens)
|
|
150
|
+
models.push({
|
|
151
|
+
id,
|
|
152
|
+
...(modelName === undefined ? {} : { name: modelName }),
|
|
153
|
+
...(contextWindow === undefined ? {} : { contextWindow }),
|
|
154
|
+
...(maxTokens === undefined ? {} : { maxTokens })
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
return models
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 读取响应体,超出上限则拒绝。先看声明长度(诚实的服务器免于传输),
|
|
162
|
+
* 真正生效的是累计字节数。
|
|
163
|
+
* @param response - 流式响应。
|
|
164
|
+
* @param url - 用于拒绝信息的 URL。
|
|
165
|
+
* @returns 解码后的正文。
|
|
166
|
+
* @throws {Error} 当正文超过 {@link MAX_RESPONSE_BYTES} 时。
|
|
167
|
+
*/
|
|
168
|
+
async function readBounded(response, url) {
|
|
169
|
+
const oversized = () => new Error(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`)
|
|
170
|
+
const declared = Number(response.headers.get('content-length') ?? NaN)
|
|
171
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
172
|
+
await response.body?.cancel()
|
|
173
|
+
throw oversized()
|
|
174
|
+
}
|
|
175
|
+
if (response.body === null || response.body === undefined) return ''
|
|
176
|
+
const reader = response.body.getReader()
|
|
177
|
+
const chunks = []
|
|
178
|
+
let total = 0
|
|
179
|
+
try {
|
|
180
|
+
for (;;) {
|
|
181
|
+
const { done, value } = await reader.read()
|
|
182
|
+
if (done) break
|
|
183
|
+
total += value.byteLength
|
|
184
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
185
|
+
await reader.cancel()
|
|
186
|
+
throw oversized()
|
|
187
|
+
}
|
|
188
|
+
chunks.push(value)
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
reader.releaseLock?.()
|
|
192
|
+
}
|
|
193
|
+
const merged = new Uint8Array(total)
|
|
194
|
+
let offset = 0
|
|
195
|
+
for (const chunk of chunks) {
|
|
196
|
+
merged.set(chunk, offset)
|
|
197
|
+
offset += chunk.byteLength
|
|
198
|
+
}
|
|
199
|
+
return new TextDecoder().decode(merged)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 写一帧 JSON 响应(禁缓存)。
|
|
204
|
+
* @param response - 出站响应。
|
|
205
|
+
* @param status - HTTP 状态码。
|
|
206
|
+
* @param payload - 可序列化载荷。
|
|
207
|
+
*/
|
|
208
|
+
function sendJson(response, status, payload) {
|
|
209
|
+
response.writeHead(status, {
|
|
210
|
+
'content-type': 'application/json; charset=utf-8',
|
|
211
|
+
'cache-control': 'no-store'
|
|
212
|
+
})
|
|
213
|
+
response.end(JSON.stringify(payload))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const name = 'dsh-llm-hub'
|
|
217
|
+
/** 发现注册表在 llm 服务上;余额路由在 webServer 上。两者都是硬依赖。 */
|
|
218
|
+
export const inject = ['llm', 'webServer']
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 注册模型发现与余额路由。
|
|
222
|
+
* @param ctx - 插件上下文。
|
|
223
|
+
*/
|
|
224
|
+
export function apply(ctx) {
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 当前已解析的 `llm-deepseek` 设置段。
|
|
228
|
+
*
|
|
229
|
+
* 每次调用惰性重读:apply 时该段可能尚未注册,而适配器本身也按请求重解析
|
|
230
|
+
* 连接事实 —— 惰性读才能让两边看同一份修订。
|
|
231
|
+
* @returns 已解析的设置段;不可用时为 `undefined`。
|
|
232
|
+
*/
|
|
233
|
+
const sectionOf = () => {
|
|
234
|
+
const settings = ctx.get('settings')
|
|
235
|
+
if (settings === undefined) return undefined
|
|
236
|
+
try {
|
|
237
|
+
const value = settings.get(NS)
|
|
238
|
+
return value !== null && typeof value === 'object' ? value : undefined
|
|
239
|
+
} catch {
|
|
240
|
+
// 命名空间尚未注册时会抛 —— 那是正常启动竞态,不是失败。
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 巡检 settings 段里 `models: []` 的 provider,去对应网关拉一份目录写回。
|
|
247
|
+
* 启动时跑一次,避免 settings.yaml 里留 `models: []` 这个事实空头;下次 reload / 重启
|
|
248
|
+
* 时 pi-ai 自己的 onChange 会按 settings 重新注册 model 目录。
|
|
249
|
+
* 不覆写用户显式填的 models(length > 0 视为已指定);fetch 失败 best-effort 跳过。
|
|
250
|
+
* @param source - settings 段 llm-pi-ai 下的整段内容(settings.get(PIAI_NS) 的返回值)。
|
|
251
|
+
* @returns 要写回的 { providerId: profileWithModels },空对象表示没东西要写。
|
|
252
|
+
*/
|
|
253
|
+
const populateEmptyProviderModels = async (source) => {
|
|
254
|
+
if (source === undefined || source.providers === undefined) return {}
|
|
255
|
+
const updates = {}
|
|
256
|
+
for (const [provider, profile] of Object.entries(source.providers)) {
|
|
257
|
+
if (!Array.isArray(profile.models) || profile.models.length !== 0) continue
|
|
258
|
+
if (profile.baseURL === undefined || profile.baseURL === '' || profile.apiKeyEnv === undefined) continue
|
|
259
|
+
try {
|
|
260
|
+
const { baseURL, apiKey } = await connectionFacts({
|
|
261
|
+
provider,
|
|
262
|
+
baseURL: profile.baseURL,
|
|
263
|
+
apiKeyEnv: profile.apiKeyEnv
|
|
264
|
+
})
|
|
265
|
+
const url = `${baseURL}/models`
|
|
266
|
+
const headers = { accept: "application/json" }
|
|
267
|
+
if (apiKey !== undefined) headers.authorization = `Bearer ${apiKey}`
|
|
268
|
+
const response = await fetch(url, { method: 'GET', headers, signal: timeoutSignal() })
|
|
269
|
+
if (!response.ok) continue
|
|
270
|
+
const body = await readBounded(response, url)
|
|
271
|
+
const models = readListing(JSON.parse(body))
|
|
272
|
+
if (models.length > 0) {
|
|
273
|
+
updates[provider] = { ...profile, models }
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
/* best-effort */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return updates
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 解析本次调用要用的连接事实。
|
|
284
|
+
* @param request - 可选的一次性覆盖(发现草稿会带 baseURL / apiKey)。
|
|
285
|
+
* @returns baseURL、凭据引用与可用密钥。
|
|
286
|
+
*/
|
|
287
|
+
const connectionFacts = async (request = {}) => {
|
|
288
|
+
const section = sectionOf() ?? {}
|
|
289
|
+
const baseURL = (label(
|
|
290
|
+
request.baseURL,
|
|
291
|
+
section.baseURL,
|
|
292
|
+
process.env.DEEPSEEK_BASE_URL,
|
|
293
|
+
DEFAULT_BASE_URL
|
|
294
|
+
) ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
295
|
+
const ref = label(request.apiKeyEnv, section.apiKeyEnv) ?? DEFAULT_API_KEY_ENV
|
|
296
|
+
const explicit = label(request.apiKey)
|
|
297
|
+
let apiKey = explicit
|
|
298
|
+
if (apiKey === undefined) {
|
|
299
|
+
const credentials = ctx.get('credentials')
|
|
300
|
+
if (credentials !== undefined) {
|
|
301
|
+
const hit = await credentials.resolve(ref)
|
|
302
|
+
apiKey = label(hit?.value)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (apiKey === undefined) apiKey = label(process.env[ref])
|
|
306
|
+
return { baseURL, ref, apiKey }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* GET 一个 DeepSeek 端点并解析 JSON。
|
|
311
|
+
* @param path - 端点路径,如 `/models`。
|
|
312
|
+
* @param request - 可选的连接事实覆盖。
|
|
313
|
+
* @param signal - 调用方取消。
|
|
314
|
+
* @returns 解析后的 JSON。
|
|
315
|
+
*/
|
|
316
|
+
const getJson = async (path, request, signal) => {
|
|
317
|
+
const { baseURL, apiKey } = await connectionFacts(request)
|
|
318
|
+
const url = `${baseURL}${path}`
|
|
319
|
+
const headers = { accept: 'application/json' }
|
|
320
|
+
if (apiKey !== undefined) headers.authorization = `Bearer ${apiKey}`
|
|
321
|
+
let response
|
|
322
|
+
try {
|
|
323
|
+
response = await fetch(url, {
|
|
324
|
+
method: 'GET',
|
|
325
|
+
headers,
|
|
326
|
+
signal: timeoutSignal(signal)
|
|
327
|
+
})
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (signal?.aborted) throw new Error('the request was cancelled')
|
|
330
|
+
throw new Error(`could not reach ${url}: ${error instanceof Error ? error.message : String(error)}`)
|
|
331
|
+
}
|
|
332
|
+
if (!response.ok) {
|
|
333
|
+
const hint = response.status === 401 || response.status === 403 ? '; check the API key' : ''
|
|
334
|
+
throw new Error(`${url} answered ${response.status}${hint}`)
|
|
335
|
+
}
|
|
336
|
+
const text = await readBounded(response, url)
|
|
337
|
+
try {
|
|
338
|
+
return JSON.parse(text)
|
|
339
|
+
} catch {
|
|
340
|
+
throw new Error(`${url} did not answer with JSON`)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
//#region 1) 模型发现
|
|
345
|
+
ctx.llm.registerModelDiscovery(NS, async (request, signal) => readListing(await getJson('/models', request, signal)))
|
|
346
|
+
//#endregion
|
|
347
|
+
|
|
348
|
+
//#region 2) 余额路由
|
|
349
|
+
/**
|
|
350
|
+
* 把上游余额载荷整理成 client 半要的形状。
|
|
351
|
+
*
|
|
352
|
+
* 上游是 `{ is_available, balance_infos: [{ currency, total_balance,
|
|
353
|
+
* granted_balance, topped_up_balance }] }`,金额是**字符串**(避免浮点误差),
|
|
354
|
+
* 所以这里原样保留字符串,只在必要时解析。
|
|
355
|
+
* @param body - 上游载荷。
|
|
356
|
+
* @returns 精简后的载荷。
|
|
357
|
+
*/
|
|
358
|
+
const shapeBalance = (body) => {
|
|
359
|
+
const infos = Array.isArray(body?.balance_infos) ? body.balance_infos : []
|
|
360
|
+
return {
|
|
361
|
+
isAvailable: body?.is_available === true,
|
|
362
|
+
balances: infos
|
|
363
|
+
.filter((info) => info !== null && typeof info === 'object')
|
|
364
|
+
.map((info) => ({
|
|
365
|
+
currency: label(info.currency) ?? '',
|
|
366
|
+
total: label(info.total_balance) ?? '0',
|
|
367
|
+
granted: label(info.granted_balance) ?? '0',
|
|
368
|
+
toppedUp: label(info.topped_up_balance) ?? '0'
|
|
369
|
+
}))
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 只允许同源浏览器读取。余额是账户信息,即使服务绑在 loopback 也不该
|
|
375
|
+
* 被跨站页面读走。
|
|
376
|
+
* @param request - 进来的请求。
|
|
377
|
+
* @returns 是否放行。
|
|
378
|
+
*/
|
|
379
|
+
const sameOrigin = (request) => {
|
|
380
|
+
const site = request.headers['sec-fetch-site']
|
|
381
|
+
if (typeof site === 'string') return site === 'same-origin' || site === 'none'
|
|
382
|
+
return true
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
ctx.effect(() => ctx.webServer.register({
|
|
386
|
+
kind: 'exact',
|
|
387
|
+
path: BALANCE_PATH,
|
|
388
|
+
handler: async (request, response) => {
|
|
389
|
+
const send = (status, payload) => {
|
|
390
|
+
response.writeHead(status, {
|
|
391
|
+
'content-type': 'application/json; charset=utf-8',
|
|
392
|
+
'cache-control': 'no-store'
|
|
393
|
+
})
|
|
394
|
+
response.end(JSON.stringify(payload))
|
|
395
|
+
}
|
|
396
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
397
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
398
|
+
response.end()
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
if (!sameOrigin(request)) {
|
|
402
|
+
send(403, { ok: false, error: 'cross-origin balance reads are refused' })
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const payload = shapeBalance(await getJson('/user/balance'))
|
|
407
|
+
if (request.method === 'HEAD') {
|
|
408
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
409
|
+
response.end()
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
send(200, { ok: true, ...payload })
|
|
413
|
+
} catch (error) {
|
|
414
|
+
send(502, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}), 'dsh-llm-hub: balance route')
|
|
418
|
+
//#endregion
|
|
419
|
+
|
|
420
|
+
//#region 3) pi-ai 旁路补缺
|
|
421
|
+
// 官方 dsh-llm-pi-ai 占着自己的 discovery 坑,且 LISTABLE_PROTOCOLS 只认
|
|
422
|
+
// openai-completions / openai-responses:modelgo(anthropic-messages)与
|
|
423
|
+
// 无 baseURL 的 zai-coding-cn 官方发现天然失效。这里不注册 discovery,
|
|
424
|
+
// 只提供 status / probe / catalog 三条旁路路由,全部只读、可逆、同源受限。
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 枚举 `llm-pi-ai` 段里的全部 provider。
|
|
428
|
+
*
|
|
429
|
+
* 判定目标必须从**设置段**来,而不是 `ctx.llm.listProviders()` —— 后者正是本插件
|
|
430
|
+
* 过滤器的输出,拿它当目标会变成「隐藏即永久」。
|
|
431
|
+
* @returns [providerId, profile] 列表;段不可读时为空数组。
|
|
432
|
+
*/
|
|
433
|
+
const piaiProviders = () => {
|
|
434
|
+
const settings = ctx.get('settings')
|
|
435
|
+
if (settings === undefined) return []
|
|
436
|
+
try {
|
|
437
|
+
const section = settings.get(PIAI_NS)
|
|
438
|
+
const providers = section !== null && typeof section === 'object' ? section.providers : undefined
|
|
439
|
+
if (providers === null || typeof providers !== 'object') return []
|
|
440
|
+
return Object.entries(providers).filter(([, profile]) => profile !== null && typeof profile === 'object')
|
|
441
|
+
} catch {
|
|
442
|
+
// 命名空间尚未注册(启动竞态)——安静降级
|
|
443
|
+
return []
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 惰性读取 `llm-pi-ai` 段里某个 provider 的 profile。
|
|
449
|
+
* @param provider - 段内 providers 键名,如 `modelgo`。
|
|
450
|
+
* @returns 该 provider 的原始 profile;不可用时为 `undefined`。
|
|
451
|
+
*/
|
|
452
|
+
const piaiProfileOf = (provider) => {
|
|
453
|
+
if (typeof provider !== 'string' || provider.length === 0) return undefined
|
|
454
|
+
const settings = ctx.get('settings')
|
|
455
|
+
if (settings === undefined) return undefined
|
|
456
|
+
try {
|
|
457
|
+
const section = settings.get(PIAI_NS)
|
|
458
|
+
const providers = section !== null && typeof section === 'object' ? section.providers : undefined
|
|
459
|
+
const profile = providers !== null && typeof providers === 'object' ? providers[provider] : undefined
|
|
460
|
+
return profile !== null && typeof profile === 'object' ? profile : undefined
|
|
461
|
+
} catch {
|
|
462
|
+
// 命名空间尚未注册(官方 pi-ai 适配器未就绪的启动竞态)——安静降级。
|
|
463
|
+
return undefined
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 解析某 provider 本次调用的连接事实:baseURL 与可用密钥。
|
|
469
|
+
* 与 deepseek 侧一致:先凭据服务,再进程环境,全程惰性。
|
|
470
|
+
* @param profile - 段内原始 profile。
|
|
471
|
+
* @param request - 调用方一次性覆盖(apiKey / baseURL)。
|
|
472
|
+
* @returns baseURL(去尾斜杠)、凭据引用与可用密钥。
|
|
473
|
+
*/
|
|
474
|
+
const piaiConnection = async (profile, request = {}) => {
|
|
475
|
+
const baseURL = label(request.baseURL, profile?.baseURL)?.replace(/\/+$/, '')
|
|
476
|
+
const ref = label(profile?.apiKeyEnv)
|
|
477
|
+
let apiKey = label(request.apiKey)
|
|
478
|
+
if (apiKey === undefined && ref !== undefined) {
|
|
479
|
+
const credentials = ctx.get('credentials')
|
|
480
|
+
if (credentials !== undefined) {
|
|
481
|
+
const hit = await credentials.resolve(ref).catch(() => undefined)
|
|
482
|
+
apiKey = label(hit?.value)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (apiKey === undefined && ref !== undefined) apiKey = label(process.env[ref])
|
|
486
|
+
return { baseURL, ref, apiKey }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 已知服务商的官方 baseURL —— settings 里没填时用它兜底。
|
|
491
|
+
*
|
|
492
|
+
* 2026-09-15 owner 指出的矛盾:智谱的余额能查通(余额适配器自带 hostFor,
|
|
493
|
+
* 硬编码了 open.bigmodel.cn),探测却说「未配置 baseURL,无法探测」——
|
|
494
|
+
* 同一个 provider,一条路知道它在哪、另一条说不知道。地址是公开且固定的,
|
|
495
|
+
* 没理由因为用户没在设置里抄一遍就拒绝探测。
|
|
496
|
+
*
|
|
497
|
+
* 只收地址公开且固定的服务商;自建网关(ModelGo 等)不进这张表,
|
|
498
|
+
* 它们的地址因人而异,猜不得。
|
|
499
|
+
*/
|
|
500
|
+
const KNOWN_GATEWAY_BASE = {
|
|
501
|
+
'zai-coding-cn': 'https://open.bigmodel.cn/api/paas/v4',
|
|
502
|
+
zhipu: 'https://open.bigmodel.cn/api/paas/v4',
|
|
503
|
+
'zai-coding': 'https://api.z.ai/api/paas/v4',
|
|
504
|
+
minimax: 'https://api.minimaxi.com/v1',
|
|
505
|
+
moonshot: 'https://api.moonshot.cn/v1',
|
|
506
|
+
stepfun: 'https://api.stepfun.com/v1'
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* GET 网关模型目录,`/v1/models` 与 `/models` 依 baseURL 形态自动回退
|
|
511
|
+
* (modelgo 实测 `/models` 404、`/v1/models` 200;以 `/v1` 结尾的 base 只试
|
|
512
|
+
* `/models`),并测量到首个可用响应的耗时。
|
|
513
|
+
* @param profile - 段内原始 profile。
|
|
514
|
+
* @param request - 调用方一次性覆盖。
|
|
515
|
+
* @returns 统一结果:`ok=true` 带 models,`ok=false` 带 code 与 error。
|
|
516
|
+
*/
|
|
517
|
+
const piaiListing = async (profile, request = {}, provider) => {
|
|
518
|
+
const conn = await piaiConnection(profile, request)
|
|
519
|
+
const { ref, apiKey } = conn
|
|
520
|
+
const baseURL = conn.baseURL ?? KNOWN_GATEWAY_BASE[String(provider ?? '')]
|
|
521
|
+
if (baseURL === undefined) {
|
|
522
|
+
return { ok: false, code: 'NO_BASE_URL', ref, error: '该 provider 没有配置 baseURL,无法访问网关目录' }
|
|
523
|
+
}
|
|
524
|
+
const candidates = baseURL.endsWith('/v1') ? [`${baseURL}/models`] : [`${baseURL}/v1/models`, `${baseURL}/models`]
|
|
525
|
+
const startedAt = Date.now()
|
|
526
|
+
let lastError
|
|
527
|
+
for (const url of candidates) {
|
|
528
|
+
try {
|
|
529
|
+
const response = await fetch(url, {
|
|
530
|
+
method: 'GET',
|
|
531
|
+
headers: { accept: 'application/json', ...(apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` }) },
|
|
532
|
+
signal: timeoutSignal()
|
|
533
|
+
})
|
|
534
|
+
if (response.status === 404) {
|
|
535
|
+
lastError = `${url} answered 404`
|
|
536
|
+
continue
|
|
537
|
+
}
|
|
538
|
+
if (!response.ok) {
|
|
539
|
+
const hint = response.status === 401 || response.status === 403 ? '; check the API key' : ''
|
|
540
|
+
return { ok: false, code: 'HTTP_ERROR', status: response.status, latencyMs: Date.now() - startedAt, ref, error: `${url} answered ${response.status}${hint}` }
|
|
541
|
+
}
|
|
542
|
+
const text = await readBounded(response, url)
|
|
543
|
+
let body
|
|
544
|
+
try {
|
|
545
|
+
body = JSON.parse(text)
|
|
546
|
+
} catch {
|
|
547
|
+
return { ok: false, code: 'BAD_JSON', latencyMs: Date.now() - startedAt, ref, error: `${url} did not answer with JSON` }
|
|
548
|
+
}
|
|
549
|
+
let models
|
|
550
|
+
try {
|
|
551
|
+
models = readListing(body)
|
|
552
|
+
} catch (error) {
|
|
553
|
+
return { ok: false, code: 'BAD_PAYLOAD', latencyMs: Date.now() - startedAt, ref, error: error instanceof Error ? error.message : String(error) }
|
|
554
|
+
}
|
|
555
|
+
return { ok: true, url, latencyMs: Date.now() - startedAt, ref, models }
|
|
556
|
+
} catch (error) {
|
|
557
|
+
lastError = error instanceof Error ? error.message : String(error)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return { ok: false, code: 'UNREACHABLE', latencyMs: Date.now() - startedAt, ref, error: lastError }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 把 `llm-pi-ai` 段里某 provider 的静态事实整理给 client 半。
|
|
565
|
+
* @param provider - 段内 providers 键名。
|
|
566
|
+
* @returns 含已配模型数与 keyConfigured 的载荷。
|
|
567
|
+
*/
|
|
568
|
+
const piaiStatus = async (provider) => {
|
|
569
|
+
const profile = piaiProfileOf(provider)
|
|
570
|
+
if (profile === undefined) return { ok: false, error: `llm-pi-ai 段里没有 "${provider ?? ''}" 这个 provider` }
|
|
571
|
+
const { baseURL, ref, apiKey } = await piaiConnection(profile)
|
|
572
|
+
const models = Array.isArray(profile.models) ? profile.models : []
|
|
573
|
+
return {
|
|
574
|
+
ok: true,
|
|
575
|
+
provider,
|
|
576
|
+
displayName: label(profile.displayName) ?? provider,
|
|
577
|
+
api: label(profile.api),
|
|
578
|
+
// baseURL 也走已知网关兜底 —— 否则会出现「余额查得通、探测说没地址」这种
|
|
579
|
+
// 自相矛盾的显示(2026-09-15 owner 指出)。source 让前端能区分是人填的
|
|
580
|
+
// 还是我们兜的,必要时可以提示。
|
|
581
|
+
baseURL: baseURL ?? KNOWN_GATEWAY_BASE[String(provider ?? '')],
|
|
582
|
+
baseURLSource: baseURL !== undefined ? 'settings' : (KNOWN_GATEWAY_BASE[String(provider ?? '')] !== undefined ? 'known' : 'none'),
|
|
583
|
+
apiKeyEnv: ref,
|
|
584
|
+
keyConfigured: apiKey !== undefined,
|
|
585
|
+
modelCount: models.length,
|
|
586
|
+
// 已配置的 id 列表:前端拉到网关目录后要据此把「已经在用的」默认勾上,
|
|
587
|
+
// 光有数量做不到这件事。
|
|
588
|
+
modelIds: models.map((m) => (m !== null && typeof m === 'object' ? m.id : m)).filter((id) => typeof id === 'string'),
|
|
589
|
+
balanceAdapter: balanceAdapterFor(provider, profile) ?? null
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 余额/配额适配器表 —— 只收各 provider **公开文档化或实测可用**的开放端点,
|
|
595
|
+
* 鉴权形态与响应字段均按 2026-09 实测核对:
|
|
596
|
+
*
|
|
597
|
+
* - minimax `GET /v1/token_plan/remains`(Bearer)→ `model_remains[]` 百分比余量
|
|
598
|
+
* - zhipu `GET {host}/api/monitor/usage/quota/limit`(裸 key)→ `data.limits[]`,
|
|
599
|
+
* 国内 coding 套餐 host 是 open.bigmodel.cn,国际站是 api.z.ai;
|
|
600
|
+
* 未购套餐时返回 `{success:false, msg:'当前用户不存在coding plan'}` —— 如实透传
|
|
601
|
+
* - moonshot `GET /v1/users/me/balance`(Bearer)→ `data.total_balance` 现金
|
|
602
|
+
* - stepfun `GET /v1/accounts`(Bearer)→ `balance / total_cash_balance / total_voucher_balance`
|
|
603
|
+
*
|
|
604
|
+
* ModelGo 等自建网关没有开放计费路由(`/v1/dashboard/billing/*` 实测 404),
|
|
605
|
+
* 匹配不到适配器时如实报 `supported:false`,绝不猜端点。
|
|
606
|
+
*/
|
|
607
|
+
const BALANCE_ADAPTERS = {
|
|
608
|
+
minimax: {
|
|
609
|
+
kind: 'plan',
|
|
610
|
+
meaning: 'remain',
|
|
611
|
+
defaultBase: 'https://api.minimaxi.com',
|
|
612
|
+
path: (base) => `${base.endsWith('/v1') ? base : `${base}/v1`}/token_plan/remains`,
|
|
613
|
+
auth: 'bearer',
|
|
614
|
+
shape: (body) => {
|
|
615
|
+
const rows = Array.isArray(body?.model_remains) ? body.model_remains : []
|
|
616
|
+
const items = []
|
|
617
|
+
for (const row of rows) {
|
|
618
|
+
if (row === null || typeof row !== 'object') continue
|
|
619
|
+
const percent = percentage(row.current_interval_remaining_percent)
|
|
620
|
+
if (percent === undefined) continue
|
|
621
|
+
items.push({
|
|
622
|
+
label: label(row.model_name) ?? 'plan',
|
|
623
|
+
percent,
|
|
624
|
+
weeklyPercent: percentage(row.current_weekly_remaining_percent)
|
|
625
|
+
})
|
|
626
|
+
}
|
|
627
|
+
return { items }
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
zhipu: {
|
|
631
|
+
kind: 'quota',
|
|
632
|
+
meaning: 'used',
|
|
633
|
+
hostFor: (provider) => (provider.includes('cn') || provider.includes('bigmodel') ? 'https://open.bigmodel.cn' : 'https://api.z.ai'),
|
|
634
|
+
path: (base) => `${base}/api/monitor/usage/quota/limit`,
|
|
635
|
+
auth: 'raw',
|
|
636
|
+
shape: (body) => {
|
|
637
|
+
if (body?.success === false || (typeof body?.code === 'number' && body.code !== 200)) {
|
|
638
|
+
return { unavailable: label(body?.msg) ?? 'quota unavailable' }
|
|
639
|
+
}
|
|
640
|
+
const limits = Array.isArray(body?.data?.limits) ? body.data.limits : []
|
|
641
|
+
const items = []
|
|
642
|
+
for (const lim of limits) {
|
|
643
|
+
if (lim === null || typeof lim !== 'object') continue
|
|
644
|
+
const tier = lim.type === 'TOKENS_LIMIT' && lim.unit === 3 ? '5h'
|
|
645
|
+
: lim.type === 'TOKENS_LIMIT' && lim.unit === 6 ? 'week'
|
|
646
|
+
: lim.type === 'TIME_LIMIT' ? 'tools'
|
|
647
|
+
: undefined
|
|
648
|
+
if (tier === undefined) continue
|
|
649
|
+
const percent = percentage(lim.percentage)
|
|
650
|
+
if (percent === undefined) continue
|
|
651
|
+
items.push({ label: tier, percent })
|
|
652
|
+
}
|
|
653
|
+
return { level: label(body?.data?.level), items }
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
moonshot: {
|
|
657
|
+
kind: 'cash',
|
|
658
|
+
defaultBase: 'https://api.moonshot.cn',
|
|
659
|
+
path: (base) => `${base}/v1/users/me/balance`,
|
|
660
|
+
auth: 'bearer',
|
|
661
|
+
shape: (body) => {
|
|
662
|
+
const total = label(body?.data?.total_balance) ?? label(body?.data?.available_balance)
|
|
663
|
+
if (total === undefined) return { unavailable: 'no balance field in answer' }
|
|
664
|
+
return { items: [{ label: 'balance', amount: total, currency: 'CNY' }] }
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
stepfun: {
|
|
668
|
+
kind: 'cash',
|
|
669
|
+
defaultBase: 'https://api.stepfun.com',
|
|
670
|
+
path: (base) => `${base}/v1/accounts`,
|
|
671
|
+
auth: 'bearer',
|
|
672
|
+
shape: (body) => {
|
|
673
|
+
const total = label(body?.balance)
|
|
674
|
+
if (total === undefined) return { unavailable: 'no balance field in answer' }
|
|
675
|
+
return { items: [{ label: 'balance', amount: total, currency: 'CNY', voucher: label(body?.total_voucher_balance) }] }
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* 按 provider id 与 baseURL 猜测适用的余额适配器。
|
|
682
|
+
* @param provider - 段内 providers 键名。
|
|
683
|
+
* @param profile - 段内原始 profile。
|
|
684
|
+
* @returns 适配器键名;无匹配时为 `undefined`。
|
|
685
|
+
*/
|
|
686
|
+
const balanceAdapterFor = (provider, profile) => {
|
|
687
|
+
const haystack = `${provider} ${label(profile?.baseURL) ?? ''}`.toLowerCase()
|
|
688
|
+
if (haystack.includes('minimax')) return 'minimax'
|
|
689
|
+
if (haystack.includes('zhipu') || haystack.includes('zai') || haystack.includes('bigmodel') || haystack.includes('glm')) return 'zhipu'
|
|
690
|
+
if (haystack.includes('moonshot') || haystack.includes('kimi')) return 'moonshot'
|
|
691
|
+
if (haystack.includes('step')) return 'stepfun'
|
|
692
|
+
return undefined
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* 查询某 provider 的余额/配额。统一信封:`{ ok, kind, available, items?, reason? }`;
|
|
697
|
+
* 匹配不到适配器给 `{ ok, supported:false }`,无 key / 上游失败都折叠成 `available:false`
|
|
698
|
+
* 加人话 reason,绝不把异常直接抛给浏览器。
|
|
699
|
+
* @param provider - 段内 providers 键名。
|
|
700
|
+
* @returns 余额信封载荷。
|
|
701
|
+
*/
|
|
702
|
+
const piaiBalance = async (provider) => {
|
|
703
|
+
const profile = piaiProfileOf(provider)
|
|
704
|
+
if (profile === undefined) return { ok: false, error: `llm-pi-ai 段里没有 "${provider ?? ''}" 这个 provider` }
|
|
705
|
+
const adapterName = balanceAdapterFor(provider, profile)
|
|
706
|
+
if (adapterName === undefined) return { ok: true, provider, supported: false }
|
|
707
|
+
const adapter = BALANCE_ADAPTERS[adapterName]
|
|
708
|
+
const { ref, apiKey } = await piaiConnection(profile)
|
|
709
|
+
if (apiKey === undefined) {
|
|
710
|
+
return { ok: true, provider, kind: adapter.kind, available: false, reason: '还没填 API key' }
|
|
711
|
+
}
|
|
712
|
+
const host = adapter.hostFor !== undefined ? adapter.hostFor(provider) : (label(profile?.baseURL)?.replace(/\/+$/, '') ?? adapter.defaultBase)
|
|
713
|
+
// 设置页是给用人看的,不是给写代码的人看的 —— reason 一律是人话,技术细节走 detail。
|
|
714
|
+
// 2026-09-15 owner 指出:智谱没配 baseURL 时,卡片上直接印出
|
|
715
|
+
// 「could not reach /api/monitor/usage/quota/limit: Failed to parse URL from ...」,
|
|
716
|
+
// 那是 fetch 的原始异常,对着它读的人既不知道发生了什么,也不知道该做什么。
|
|
717
|
+
const fail = (reason, detail) => ({ ok: true, provider, kind: adapter.kind, available: false, reason, detail })
|
|
718
|
+
if (host === undefined || host === null || !/^https?:\/\//i.test(String(host))) {
|
|
719
|
+
// 最常见的一种:没填服务地址,URL 拼出来是相对路径,fetch 直接抛解析错误。
|
|
720
|
+
// 与其让它抛,不如提前说清楚缺什么。
|
|
721
|
+
return fail('未填写服务地址,查不了余额')
|
|
722
|
+
}
|
|
723
|
+
const url = adapter.path(host)
|
|
724
|
+
// 拼完再验一次:zhipu 的 path 曾经写成 () => '/api/...',把 base 参数整个丢掉,
|
|
725
|
+
// 于是 hostFor 明明给对了域名,fetch 拿到的仍是相对路径。适配器是一张表,
|
|
726
|
+
// 表里任何一行写错都不该让用户看见一句 Failed to parse URL。
|
|
727
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
728
|
+
return fail('这家服务商的余额接口暂时用不了', `adapter ${adapterName} produced a non-absolute url: ${url}`)
|
|
729
|
+
}
|
|
730
|
+
const headers = { accept: 'application/json' }
|
|
731
|
+
headers.authorization = adapter.auth === 'raw' ? apiKey : `Bearer ${apiKey}`
|
|
732
|
+
let response
|
|
733
|
+
try {
|
|
734
|
+
response = await fetch(url, { method: 'GET', headers, signal: timeoutSignal() })
|
|
735
|
+
} catch (error) {
|
|
736
|
+
return fail('连不上服务商', `${url}: ${error instanceof Error ? error.message : String(error)}`)
|
|
737
|
+
}
|
|
738
|
+
if (!response.ok) {
|
|
739
|
+
const hint = response.status === 401 || response.status === 403 ? 'API key 可能无效或没有查询余额的权限'
|
|
740
|
+
: response.status === 404 ? '这家服务商没有提供余额查询接口'
|
|
741
|
+
: '服务商暂时没有返回余额'
|
|
742
|
+
return fail(hint, `${url} answered ${response.status}`)
|
|
743
|
+
}
|
|
744
|
+
const text = await readBounded(response, url)
|
|
745
|
+
let body
|
|
746
|
+
try {
|
|
747
|
+
body = JSON.parse(text)
|
|
748
|
+
} catch {
|
|
749
|
+
return fail('服务商返回的内容看不懂', `${url} did not answer with JSON`)
|
|
750
|
+
}
|
|
751
|
+
const shaped = adapter.shape(body)
|
|
752
|
+
if (shaped.unavailable !== undefined) {
|
|
753
|
+
return { ok: true, provider, kind: adapter.kind, available: false, reason: shaped.unavailable }
|
|
754
|
+
}
|
|
755
|
+
return { ok: true, provider, kind: adapter.kind, adapter: adapterName, meaning: adapter.meaning, level: shaped.level, items: shaped.items ?? [] }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* 三条旁路路由共用的注册体:只放行 GET/HEAD,只放行同源。
|
|
760
|
+
* @param path - 精确路由路径。
|
|
761
|
+
* @param respond - 由 query.provider 算出载荷的异步函数。
|
|
762
|
+
* @param tag - ctx.effect 的注册标签。
|
|
763
|
+
*/
|
|
764
|
+
const registerPiaiRoute = (path, respond, tag) => ctx.effect(() => ctx.webServer.register({
|
|
765
|
+
kind: 'exact',
|
|
766
|
+
path,
|
|
767
|
+
handler: async (request, response) => {
|
|
768
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
769
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
770
|
+
response.end()
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
if (!sameOrigin(request)) {
|
|
774
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
|
|
775
|
+
return
|
|
776
|
+
}
|
|
777
|
+
if (request.method === 'HEAD') {
|
|
778
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
779
|
+
response.end()
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
const raw = String(request.url ?? '')
|
|
784
|
+
const qIndex = raw.indexOf('?')
|
|
785
|
+
const provider = new URLSearchParams(qIndex === -1 ? '' : raw.slice(qIndex + 1)).get('provider')
|
|
786
|
+
const payload = await respond(provider)
|
|
787
|
+
sendJson(response, 200, payload)
|
|
788
|
+
} catch (error) {
|
|
789
|
+
sendJson(response, 502, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}), tag)
|
|
793
|
+
|
|
794
|
+
registerPiaiRoute(PIAI_STATUS_PATH, piaiStatus, 'dsh-llm-hub: pi-ai status route')
|
|
795
|
+
registerPiaiRoute(PIAI_PROBE_PATH, async (provider) => {
|
|
796
|
+
const status = await piaiStatus(provider)
|
|
797
|
+
if (!status.ok) return status
|
|
798
|
+
const listing = await piaiListing(piaiProfileOf(provider), {}, provider)
|
|
799
|
+
return listing.ok
|
|
800
|
+
? { ok: true, provider, reachable: true, latencyMs: listing.latencyMs, remoteCount: listing.models.length, sample: listing.models.slice(0, 5).map((model) => model.id) }
|
|
801
|
+
: { ok: true, provider, reachable: false, code: listing.code, latencyMs: listing.latencyMs, error: listing.error }
|
|
802
|
+
}, 'dsh-llm-hub: pi-ai probe route')
|
|
803
|
+
registerPiaiRoute(PIAI_CATALOG_PATH, async (provider) => {
|
|
804
|
+
const listing = await piaiListing(piaiProfileOf(provider), {}, provider)
|
|
805
|
+
return listing.ok ? { ok: true, provider, latencyMs: listing.latencyMs, models: listing.models } : listing
|
|
806
|
+
}, 'dsh-llm-hub: pi-ai catalog route')
|
|
807
|
+
/**
|
|
808
|
+
* 插件自己的元信息 —— 版本、仓库、反馈入口。
|
|
809
|
+
*
|
|
810
|
+
* 2026-09-15 owner:「人家写的插件都有(GitHub / 问题反馈 / 检查更新),你也加上,
|
|
811
|
+
* 不然别人没有反馈,没法闭环」。版本号从 package.json 读,不在前端硬编码 ——
|
|
812
|
+
* 硬编码的版本每次发版都要记得改一次,而忘记改的那次没人会发现。
|
|
813
|
+
*/
|
|
814
|
+
// 本包是 ESM(package.json type: module),没有 require —— 用 import.meta.url
|
|
815
|
+
// 定位自己再读同级的 package.json。第一版写了 require('../package.json'),
|
|
816
|
+
// 装上去直接 `require is not defined`,而这种错只在运行时暴露。
|
|
817
|
+
const pluginMeta = () => {
|
|
818
|
+
try {
|
|
819
|
+
const here = fileURLToPath(new URL('../package.json', import.meta.url))
|
|
820
|
+
const pkg = JSON.parse(readFileSync(here, 'utf8'))
|
|
821
|
+
return { ok: true, name: pkg.name, version: pkg.version, homepage: pkg.homepage, issues: pkg.bugs?.url }
|
|
822
|
+
} catch (error) {
|
|
823
|
+
return { ok: false, error: '读不到插件元信息', detail: error instanceof Error ? error.message : String(error) }
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
ctx.effect(() => ctx.webServer.register({
|
|
827
|
+
kind: 'exact',
|
|
828
|
+
path: META_PATH,
|
|
829
|
+
handler: async (request, response) => {
|
|
830
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
831
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
832
|
+
response.end()
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
if (!sameOrigin(request)) {
|
|
836
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
sendJson(response, 200, pluginMeta())
|
|
840
|
+
}
|
|
841
|
+
}), 'dsh-llm-hub: meta route')
|
|
842
|
+
|
|
843
|
+
registerPiaiRoute(PIAI_BALANCE_PATH, piaiBalance, 'dsh-llm-hub: pi-ai balance route')
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* 把勾选的模型 id 写回 `llm-pi-ai.providers.<id>.models`。
|
|
847
|
+
*
|
|
848
|
+
* 这是本插件唯一的写操作 —— 拉到网关目录却只能"复制全部 id"再手工粘回配置文件,
|
|
849
|
+
* 等于把最后一公里留给了人。2026-09-15 owner:「模型页面里没地方显示当前网关
|
|
850
|
+
* 支持的模型,无法自己选择」。
|
|
851
|
+
*
|
|
852
|
+
* 写入纪律:
|
|
853
|
+
* - 只接受 POST + 同源(与读路由同一套 sameOrigin)
|
|
854
|
+
* - 只动 models 一个字段,profile 其余部分原样保留 —— 不替换整段
|
|
855
|
+
* - 已配置的模型保留其 name/contextWindow 等既有字段,新加的只写 id 与 name
|
|
856
|
+
*/
|
|
857
|
+
ctx.effect(() => ctx.webServer.register({
|
|
858
|
+
kind: 'exact',
|
|
859
|
+
path: PIAI_MODELS_PATH,
|
|
860
|
+
handler: async (request, response) => {
|
|
861
|
+
if (request.method !== 'POST') {
|
|
862
|
+
response.writeHead(405, { allow: 'POST' })
|
|
863
|
+
response.end()
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
if (!sameOrigin(request)) {
|
|
867
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin writes are refused' })
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
let payload
|
|
871
|
+
try {
|
|
872
|
+
const chunks = []
|
|
873
|
+
for await (const chunk of request) chunks.push(chunk)
|
|
874
|
+
payload = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
875
|
+
} catch {
|
|
876
|
+
sendJson(response, 400, { ok: false, error: '请求内容不是合法 JSON' })
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
const provider = label(payload?.provider)
|
|
880
|
+
const ids = Array.isArray(payload?.ids) ? payload.ids.filter((id) => typeof id === 'string' && id.length > 0) : undefined
|
|
881
|
+
if (provider === undefined || ids === undefined) {
|
|
882
|
+
sendJson(response, 400, { ok: false, error: '缺少 provider 或 ids' })
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
const settings = ctx.get('settings')
|
|
886
|
+
if (settings === undefined) {
|
|
887
|
+
sendJson(response, 500, { ok: false, error: '设置服务不可用' })
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
const profile = piaiProfileOf(provider)
|
|
891
|
+
if (profile === undefined) {
|
|
892
|
+
sendJson(response, 404, { ok: false, error: `llm-pi-ai 段里没有 "${provider}" 这个 provider` })
|
|
893
|
+
return
|
|
894
|
+
}
|
|
895
|
+
// 保留既有模型的完整定义(contextWindow / maxTokens / input 等都是人填的,
|
|
896
|
+
// 不能因为一次勾选就抹平);新增的只有 id。
|
|
897
|
+
const existing = Array.isArray(profile.models) ? profile.models : []
|
|
898
|
+
const byId = new Map(existing.filter((m) => m !== null && typeof m === 'object' && typeof m.id === 'string').map((m) => [m.id, m]))
|
|
899
|
+
const models = ids.map((id) => byId.get(id) ?? { id, name: id })
|
|
900
|
+
try {
|
|
901
|
+
await settings.update(PIAI_NS, { providers: { [provider]: { ...profile, models } } })
|
|
902
|
+
} catch (error) {
|
|
903
|
+
sendJson(response, 500, { ok: false, error: '写入设置失败', detail: error instanceof Error ? error.message : String(error) })
|
|
904
|
+
return
|
|
905
|
+
}
|
|
906
|
+
sendJson(response, 200, { ok: true, provider, count: models.length })
|
|
907
|
+
}
|
|
908
|
+
}), 'dsh-llm-hub: pi-ai models write route')
|
|
909
|
+
//#endregion
|
|
910
|
+
|
|
911
|
+
//#region 4) 模型可用性(composer 下拉门禁)
|
|
912
|
+
// 目标:composer 的模型下拉只列「当前确实能用」的分组。
|
|
913
|
+
//
|
|
914
|
+
// 判定纪律(2026-09-16 owner:「我默认我的下拉里都是可用的」):
|
|
915
|
+
// · **只认确凿证据**:凭据解析不到、网关明确 401/403/402、余额确凿为 0、
|
|
916
|
+
// 配额确凿用尽、最近一次真实请求因鉴权/欠费失败。
|
|
917
|
+
// · **拿不准一律放行**(fail-open):探测超时、404、协议不支持目录端点、
|
|
918
|
+
// 余额接口查不通 —— 全都记 `unknown` 而不隐藏。误藏一个能用的模型,
|
|
919
|
+
// 比多显示一个不能用的更糟:前者让人以为插件坏了。
|
|
920
|
+
// · 服务端只管判定;「从下拉里摘掉」在 client 半做(目录无法在 host 侧过滤)。
|
|
921
|
+
|
|
922
|
+
/** provider -> 探针判定(凭据 / 探测 / 余额)。 */
|
|
923
|
+
const baseVerdicts = new Map()
|
|
924
|
+
/** provider -> 最近一次运行期失败判定(真实请求的鉴权/欠费错误)。 */
|
|
925
|
+
const runtimeMarks = new Map()
|
|
926
|
+
/** provider -> 最近一次真实请求成功的时刻。 */
|
|
927
|
+
const successAt = new Map()
|
|
928
|
+
/** 全量重探的去重句柄。 */
|
|
929
|
+
let probeAll = null
|
|
930
|
+
/** 探针代际:手动重探会 +1,让在途的旧探针彻底作废。 */
|
|
931
|
+
let probeGeneration = 0
|
|
932
|
+
/** 本轮流探的起始时刻;配合 REQUEST_TIMEOUT_MS 兜住「理论上不会发生」的卡死。 */
|
|
933
|
+
let probeStartedAt = 0
|
|
934
|
+
/** 上次全量重探完成时刻;0 表示还没成功过。 */
|
|
935
|
+
let lastProbeAt = 0
|
|
936
|
+
|
|
937
|
+
const describeError = (error) => (error instanceof Error ? error.message : String(error))
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* 官方直连(deepseek-official)的判定:凭据 → 目录探测 → 余额。
|
|
941
|
+
* @returns 判定结果(不含 provider/displayName)。
|
|
942
|
+
*/
|
|
943
|
+
const probeDeepseek = async () => {
|
|
944
|
+
const { baseURL, ref, apiKey } = await connectionFacts()
|
|
945
|
+
if (apiKey === undefined) {
|
|
946
|
+
return { state: 'unavailable', code: 'CREDENTIAL_MISSING', source: 'credential', reason: `没配 API key(${ref} 解析不到),请求会直接失败` }
|
|
947
|
+
}
|
|
948
|
+
const url = `${baseURL}/models`
|
|
949
|
+
let response
|
|
950
|
+
try {
|
|
951
|
+
response = await fetch(url, { method: 'GET', headers: { accept: 'application/json', authorization: `Bearer ${apiKey}` }, signal: timeoutSignal() })
|
|
952
|
+
} catch (error) {
|
|
953
|
+
return { state: 'unknown', code: 'UNREACHABLE', source: 'probe', reason: `探测不到 ${baseURL}:${describeError(error)}` }
|
|
954
|
+
}
|
|
955
|
+
if (response.status === 401 || response.status === 403) {
|
|
956
|
+
return { state: 'unavailable', code: 'AUTH_REJECTED', source: 'probe', reason: `API key 被拒(HTTP ${response.status})` }
|
|
957
|
+
}
|
|
958
|
+
if (response.status === 402) {
|
|
959
|
+
return { state: 'unavailable', code: 'PAYMENT_REQUIRED', source: 'probe', reason: '账户欠费(HTTP 402)' }
|
|
960
|
+
}
|
|
961
|
+
if (!response.ok && response.status !== 404) {
|
|
962
|
+
return { state: 'unknown', code: 'HTTP_ERROR', source: 'probe', reason: `${url} 返回 HTTP ${response.status}` }
|
|
963
|
+
}
|
|
964
|
+
// 余额:官方直连有公开的 /user/balance,is_available=false 就是「调不了」。
|
|
965
|
+
// 查不通不构成隐藏理由 —— 那只说明余额接口这次没通。
|
|
966
|
+
try {
|
|
967
|
+
const body = shapeBalance(await getJson('/user/balance'))
|
|
968
|
+
if (body.isAvailable !== true) {
|
|
969
|
+
return { state: 'unavailable', code: 'ACCOUNT_UNAVAILABLE', source: 'balance', reason: '账户不可用(/user/balance 报 is_available=false)' }
|
|
970
|
+
}
|
|
971
|
+
const total = Number(body.balances[0]?.total ?? NaN)
|
|
972
|
+
if (Number.isFinite(total) && total <= 0) {
|
|
973
|
+
return { state: 'unavailable', code: 'BALANCE_EMPTY', source: 'balance', reason: '账户余额为 0' }
|
|
974
|
+
}
|
|
975
|
+
} catch {
|
|
976
|
+
// 静默:余额查不通 ≠ 模型不可用
|
|
977
|
+
}
|
|
978
|
+
return { state: 'available', code: 'OK', source: 'probe', reason: '' }
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* 从余额/配额信封里判断「确凿用尽」。
|
|
983
|
+
* 只认能算出来的数字,不认识的字句(如「当前用户不存在 coding plan」)一律不算。
|
|
984
|
+
* @param provider - 段内 providers 键名。
|
|
985
|
+
* @returns 用尽的判定;无法确凿判定时为 `undefined`。
|
|
986
|
+
*/
|
|
987
|
+
const exhaustedFromBalance = async (provider) => {
|
|
988
|
+
let envelope
|
|
989
|
+
try {
|
|
990
|
+
envelope = await piaiBalance(provider)
|
|
991
|
+
} catch {
|
|
992
|
+
return undefined
|
|
993
|
+
}
|
|
994
|
+
if (envelope === null || typeof envelope !== 'object' || envelope.ok !== true || envelope.supported === false) return undefined
|
|
995
|
+
// available=false 只表示「这次余额查询没成功」;原因千差万别(没 key、查不通、
|
|
996
|
+
// 没有套餐),除开 key 缺失(已由凭据那步覆盖)都不构成隐藏理由。
|
|
997
|
+
if (envelope.available === false) return undefined
|
|
998
|
+
const items = Array.isArray(envelope.items) ? envelope.items : []
|
|
999
|
+
if (envelope.kind === 'cash') {
|
|
1000
|
+
const amount = items[0]?.amount
|
|
1001
|
+
if (typeof amount !== 'string' || amount.trim() === '') return undefined
|
|
1002
|
+
const value = Number(amount)
|
|
1003
|
+
if (Number.isFinite(value) && value <= 0) {
|
|
1004
|
+
return { state: 'unavailable', code: 'BALANCE_EMPTY', source: 'balance', reason: `账户余额为 0(${items[0]?.currency ?? ''})` }
|
|
1005
|
+
}
|
|
1006
|
+
return undefined
|
|
1007
|
+
}
|
|
1008
|
+
const used = envelope.meaning === 'used'
|
|
1009
|
+
for (const item of items) {
|
|
1010
|
+
// `meaning: 'remain'` 的适配器还可能带一个周维度的余量:任一为 0 都表示
|
|
1011
|
+
// 现在发不出请求(MiniMax 的 5h 窗口用尽但周余量还在,或反过来)。
|
|
1012
|
+
const fields = used ? [item?.percent] : [item?.percent, item?.weeklyPercent]
|
|
1013
|
+
for (const percent of fields) {
|
|
1014
|
+
if (typeof percent !== 'number') continue
|
|
1015
|
+
if ((used && percent >= 100) || (!used && percent <= 0)) {
|
|
1016
|
+
return { state: 'unavailable', code: 'QUOTA_EXHAUSTED', source: 'balance', reason: `${label(item?.label) ?? '配额'}已用尽` }
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return undefined
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* pi-ai 路由的判定:凭据 → 网关探测 → 余额/配额。
|
|
1025
|
+
* @param provider - 段内 providers 键名。
|
|
1026
|
+
* @returns 判定结果(不含 provider/displayName)。
|
|
1027
|
+
*/
|
|
1028
|
+
const probePiai = async (provider) => {
|
|
1029
|
+
const profile = piaiProfileOf(provider)
|
|
1030
|
+
if (profile === undefined) return { state: 'unknown', code: 'NOT_CONFIGURED', source: 'probe', reason: '设置里没有这个 provider' }
|
|
1031
|
+
const { ref, apiKey } = await piaiConnection(profile)
|
|
1032
|
+
// ref 未配置 = 官方 pi-ai 眼里「刻意不鉴权」的路由,不能按缺 key 处理。
|
|
1033
|
+
if (ref !== undefined && apiKey === undefined) {
|
|
1034
|
+
return { state: 'unavailable', code: 'CREDENTIAL_MISSING', source: 'credential', reason: `apiKeyEnv 指向的 ${ref} 解析不到,请求会直接失败` }
|
|
1035
|
+
}
|
|
1036
|
+
const listing = await piaiListing(profile, {}, provider)
|
|
1037
|
+
if (!listing.ok && listing.code === 'HTTP_ERROR') {
|
|
1038
|
+
if (listing.status === 401 || listing.status === 403) {
|
|
1039
|
+
return { state: 'unavailable', code: 'AUTH_REJECTED', source: 'probe', reason: `网关拒绝了这个 key(HTTP ${listing.status})` }
|
|
1040
|
+
}
|
|
1041
|
+
if (listing.status === 402) {
|
|
1042
|
+
return { state: 'unavailable', code: 'PAYMENT_REQUIRED', source: 'probe', reason: '网关要求付费(HTTP 402)' }
|
|
1043
|
+
}
|
|
1044
|
+
return { state: 'unknown', code: 'HTTP_ERROR', source: 'probe', reason: `网关返回 HTTP ${listing.status}` }
|
|
1045
|
+
}
|
|
1046
|
+
const exhausted = await exhaustedFromBalance(provider)
|
|
1047
|
+
if (exhausted !== undefined) return exhausted
|
|
1048
|
+
if (!listing.ok) {
|
|
1049
|
+
// 404 / 连不上 / 协议不支持目录端点 —— 都不足以断言模型不可用。
|
|
1050
|
+
return { state: 'unknown', code: listing.code ?? 'PROBE_UNKNOWN', source: 'probe', reason: listing.error ?? '网关探测无结论' }
|
|
1051
|
+
}
|
|
1052
|
+
return { state: 'available', code: 'OK', source: 'probe', reason: '' }
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* 判定目标 —— **全部来自设置段**,与 `ctx.llm.listProviders()` 解耦。
|
|
1057
|
+
*
|
|
1058
|
+
* 为什么不用路由表:`listProviders()` 就是本插件过滤器的输出,拿它当目标,
|
|
1059
|
+
* 被隐藏的 provider 就再也不会进入下一轮探测 —— 「隐藏即永久」。2026-09-16 实测:
|
|
1060
|
+
* minimax 被隐藏之后,之后每一轮探测的目标里都没有它,`checkedAt` 一路前进、
|
|
1061
|
+
* 它的判定却永远停在旧值,「充值/换 key 后恢复」于是永远不成立。
|
|
1062
|
+
* 设置段没有这个问题:它只反映「人配了什么」,与可用性判定互不影响。
|
|
1063
|
+
* @returns 目标清单(id / ns / displayName)。
|
|
1064
|
+
*/
|
|
1065
|
+
const availabilityTargets = () => {
|
|
1066
|
+
const targets = []
|
|
1067
|
+
for (const [id, profile] of piaiProviders()) {
|
|
1068
|
+
targets.push({ id, ns: PIAI_NS, displayName: label(profile.displayName) ?? id })
|
|
1069
|
+
}
|
|
1070
|
+
// 官方直连:llm-deepseek 段存在就说明适配器挂上了(段由适配器自己注册)。
|
|
1071
|
+
const deepseek = sectionOf()
|
|
1072
|
+
if (deepseek !== undefined) {
|
|
1073
|
+
targets.push({ id: DEEPSEEK_PROVIDER, ns: NS, displayName: label(deepseek.displayName) ?? 'DeepSeek 官方直连' })
|
|
1074
|
+
}
|
|
1075
|
+
return targets
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* 全量重探。
|
|
1080
|
+
*
|
|
1081
|
+
* 去重:并发调用(例如下拉连开两次)共享同一个 promise。
|
|
1082
|
+
* 强制(`force`,手动重探用):作废在途探针并重开一轮 —— 否则「清空 → 等在途旧探针
|
|
1083
|
+
* → 旧探针只写回它还没走完的那部分」会留下空洞。2026-09-16 实测:凭据类判定不走网络、
|
|
1084
|
+
* 瞬间完成,于是它可能在清空**之前**就写完了,recheck 之后反而找不到 modelgo。
|
|
1085
|
+
* 代际令牌让被取代的旧探针彻底闭嘴,不再写回任何结论。
|
|
1086
|
+
* @param force - 是否作废在途探针并强制重开一轮。
|
|
1087
|
+
* @returns 本轮(或复用的那轮)的 promise。
|
|
1088
|
+
*/
|
|
1089
|
+
const refreshAvailability = async (force = false) => {
|
|
1090
|
+
if (!force && probeAll !== null) return probeAll
|
|
1091
|
+
const generation = ++probeGeneration
|
|
1092
|
+
probeStartedAt = Date.now()
|
|
1093
|
+
const run = (async () => {
|
|
1094
|
+
const targets = availabilityTargets()
|
|
1095
|
+
const before = hiddenProviders()
|
|
1096
|
+
await Promise.all(targets.map(async (target) => {
|
|
1097
|
+
let outcome
|
|
1098
|
+
try {
|
|
1099
|
+
outcome = target.ns === NS ? await probeDeepseek() : await probePiai(target.id)
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
outcome = { state: 'unknown', code: 'PROBE_FAILED', source: 'probe', reason: describeError(error) }
|
|
1102
|
+
}
|
|
1103
|
+
// 已经被新一轮取代:这份结论过期了,丢掉。
|
|
1104
|
+
if (generation !== probeGeneration) return
|
|
1105
|
+
baseVerdicts.set(target.id, { provider: target.id, displayName: target.displayName, ns: target.ns, checkedAt: Date.now(), ...outcome })
|
|
1106
|
+
}))
|
|
1107
|
+
if (generation !== probeGeneration) return
|
|
1108
|
+
// 清掉真正从路由表里消失的 provider。判据必须是**原始路由表**,不是本轮的
|
|
1109
|
+
// targets:targets 还要求设置段里能读到 profile,而那是会瞬时缺失的
|
|
1110
|
+
// (2026-09-16 实测:某一轮只剩 deepseek+zai 两个目标,清理就把 minimax/modelgo
|
|
1111
|
+
// 的判定一起删了 —— 面板显示「已隐藏 0」,下拉里它们又冒出来)。
|
|
1112
|
+
// 目标来自设置段,所以「不在 targets 里」就等于「人把它删了」,清掉判定是对的。
|
|
1113
|
+
if (targets.length > 0) {
|
|
1114
|
+
const live = new Set(targets.map((target) => target.id))
|
|
1115
|
+
for (const id of [...baseVerdicts.keys()]) if (!live.has(id)) baseVerdicts.delete(id)
|
|
1116
|
+
}
|
|
1117
|
+
// 一个目标都没找到(启动竞态:设置段还没注册)不算探过 —— 否则 5 分钟内不再重试。
|
|
1118
|
+
if (targets.length > 0) lastProbeAt = Date.now()
|
|
1119
|
+
announceHiddenChange(before)
|
|
1120
|
+
})()
|
|
1121
|
+
probeAll = run.finally(() => {
|
|
1122
|
+
if (generation === probeGeneration) probeAll = null
|
|
1123
|
+
})
|
|
1124
|
+
return probeAll
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** 缓存过期就在后台补一次,绝不阻塞当前读取。 */
|
|
1128
|
+
const ensureAvailabilityFresh = () => {
|
|
1129
|
+
if (Date.now() - lastProbeAt <= AVAILABILITY_TTL_MS) return
|
|
1130
|
+
// 有一轮卡了很久(超时没生效、或将来改坏了),不能因此永远不再探。
|
|
1131
|
+
const stuck = probeAll !== null && Date.now() - probeStartedAt > STUCK_PROBE_MS
|
|
1132
|
+
refreshAvailability(stuck).catch(() => {})
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** 把真实请求的失败/成功折成运行期标记。 */
|
|
1136
|
+
const classifyFailure = (failure) => {
|
|
1137
|
+
const code = typeof failure?.code === 'string' ? failure.code : ''
|
|
1138
|
+
const status = typeof failure?.status === 'number' ? failure.status : undefined
|
|
1139
|
+
// MISSING_CREDENTIAL 也归这一类:pi-ai 解析不到 apiKeyEnv 时抛的就是它,
|
|
1140
|
+
// 与「key 无效」对使用者的意义一样 —— 这条路由现在调不了。
|
|
1141
|
+
if (code === 'INVALID_CREDENTIAL' || code === 'MISSING_CREDENTIAL' || status === 401 || status === 403) {
|
|
1142
|
+
return { code: 'AUTH_REJECTED', reason: '最近一次请求被拒(API key 缺失、无效或已过期)' }
|
|
1143
|
+
}
|
|
1144
|
+
if (code === 'QUOTA_EXCEEDED' || status === 402) {
|
|
1145
|
+
return { code: 'QUOTA_EXHAUSTED', reason: '最近一次请求因余额/配额耗尽失败' }
|
|
1146
|
+
}
|
|
1147
|
+
return undefined
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** 汇总:运行期标记优先于探针判定,真实成功又覆盖运行期失败。 */
|
|
1151
|
+
const currentAvailability = () => {
|
|
1152
|
+
const now = Date.now()
|
|
1153
|
+
const merged = new Map()
|
|
1154
|
+
for (const [id, verdict] of baseVerdicts) merged.set(id, verdict)
|
|
1155
|
+
for (const [id, mark] of [...runtimeMarks]) {
|
|
1156
|
+
if (now - mark.at >= RUNTIME_TTL_MS) {
|
|
1157
|
+
runtimeMarks.delete(id)
|
|
1158
|
+
continue
|
|
1159
|
+
}
|
|
1160
|
+
if ((successAt.get(id) ?? 0) > mark.at) continue
|
|
1161
|
+
const base = merged.get(id)
|
|
1162
|
+
merged.set(id, {
|
|
1163
|
+
provider: id,
|
|
1164
|
+
displayName: base?.displayName ?? id,
|
|
1165
|
+
ns: base?.ns ?? PIAI_NS,
|
|
1166
|
+
state: 'unavailable',
|
|
1167
|
+
code: mark.code,
|
|
1168
|
+
reason: mark.reason,
|
|
1169
|
+
source: 'runtime',
|
|
1170
|
+
checkedAt: mark.at
|
|
1171
|
+
})
|
|
1172
|
+
}
|
|
1173
|
+
for (const [id, at] of successAt) {
|
|
1174
|
+
const base = merged.get(id)
|
|
1175
|
+
if (base === undefined || base.state === 'available') continue
|
|
1176
|
+
if (now - at >= RUNTIME_TTL_MS || at <= base.checkedAt) continue
|
|
1177
|
+
merged.set(id, { ...base, state: 'available', code: 'RECOVERED', reason: '', source: 'runtime', checkedAt: at })
|
|
1178
|
+
}
|
|
1179
|
+
return [...merged.values()].sort((left, right) => left.provider.localeCompare(right.provider))
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** 当前该从下拉里摘掉的 provider 集合。 */
|
|
1183
|
+
const hiddenProviders = () => {
|
|
1184
|
+
const hidden = new Set()
|
|
1185
|
+
for (const verdict of currentAvailability()) {
|
|
1186
|
+
if (verdict.state === 'unavailable') hidden.add(verdict.provider)
|
|
1187
|
+
}
|
|
1188
|
+
return hidden
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const sameHidden = (left, right) => left.size === right.size && [...left].every((id) => right.has(id))
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* 隐藏集合变了就通知客户端重拉模型目录。
|
|
1195
|
+
*
|
|
1196
|
+
* 客户端的目录是**缓存**的(`ModelCatalogDirectory.load()` 在 `ready` 时直接返回旧值),
|
|
1197
|
+
* 只在 `llm/adapters-updated` / `settings/document-updated` / `credentials/reference-updated`
|
|
1198
|
+
* 三个 host 事件上刷新。我们的过滤改变了 `llm.listProviders()` 的结果,却没有事件可说 ——
|
|
1199
|
+
* 不补这一下,下拉要等下一次无关刷新才跟上(2026-09-16 实测:判 MiniMax 不可用后,
|
|
1200
|
+
* 设置页面板已更新,下拉里它还在)。语义上这正是该事件声明的意思:
|
|
1201
|
+
* 「provider 拓扑变了,消费者重读 `listProviders()`」。
|
|
1202
|
+
* @param before - 变更前的隐藏集合。
|
|
1203
|
+
*/
|
|
1204
|
+
const announceHiddenChange = (before) => {
|
|
1205
|
+
const after = hiddenProviders()
|
|
1206
|
+
if (sameHidden(before, after)) return
|
|
1207
|
+
try {
|
|
1208
|
+
ctx.emit('llm/adapters-updated')
|
|
1209
|
+
} catch {
|
|
1210
|
+
// 通知失败不影响判定本身
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** client 半要的载荷:全量判定 + 需要摘掉的 id 列表。 */
|
|
1215
|
+
const availabilityPayload = () => {
|
|
1216
|
+
const providers = currentAvailability()
|
|
1217
|
+
return {
|
|
1218
|
+
ok: true,
|
|
1219
|
+
checkedAt: lastProbeAt,
|
|
1220
|
+
probing: probeAll !== null,
|
|
1221
|
+
providers,
|
|
1222
|
+
hidden: providers.filter((verdict) => verdict.state === 'unavailable').map((verdict) => verdict.provider)
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// 真实请求的失败与成功 —— 这是「没有余额了」最可靠的信号:目录探测常常照样 200,
|
|
1227
|
+
// 只有真发一次才知道。观察者只读,必须原样 next() 把控制权交回去。
|
|
1228
|
+
ctx.on('agent/request-error', (payload, next) => {
|
|
1229
|
+
try {
|
|
1230
|
+
const classified = classifyFailure(payload?.failure)
|
|
1231
|
+
if (classified !== undefined && typeof payload?.provider === 'string') {
|
|
1232
|
+
const before = hiddenProviders()
|
|
1233
|
+
runtimeMarks.set(payload.provider, { ...classified, at: Date.now() })
|
|
1234
|
+
announceHiddenChange(before)
|
|
1235
|
+
}
|
|
1236
|
+
} catch {
|
|
1237
|
+
// 观察者绝不影响请求本身
|
|
1238
|
+
}
|
|
1239
|
+
return next()
|
|
1240
|
+
}, { global: true })
|
|
1241
|
+
|
|
1242
|
+
ctx.on('llm/stream', (options, next) => {
|
|
1243
|
+
const provider = typeof options?.provider === 'string' ? options.provider : undefined
|
|
1244
|
+
const inner = next()
|
|
1245
|
+
if (provider === undefined) return inner
|
|
1246
|
+
return (async function* observeProviderOutcome() {
|
|
1247
|
+
for await (const chunk of inner) {
|
|
1248
|
+
if (chunk?.type === 'finish') {
|
|
1249
|
+
try {
|
|
1250
|
+
const kind = chunk.reason?.kind
|
|
1251
|
+
const before = hiddenProviders()
|
|
1252
|
+
if (kind === 'error' || kind === 'aborted') {
|
|
1253
|
+
const classified = classifyFailure(chunk.reason?.failure)
|
|
1254
|
+
if (classified !== undefined) runtimeMarks.set(provider, { ...classified, at: Date.now() })
|
|
1255
|
+
} else {
|
|
1256
|
+
successAt.set(provider, Date.now())
|
|
1257
|
+
runtimeMarks.delete(provider)
|
|
1258
|
+
}
|
|
1259
|
+
announceHiddenChange(before)
|
|
1260
|
+
} catch {
|
|
1261
|
+
// 记不上就算了,不能因为记账把流弄坏
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
yield chunk
|
|
1265
|
+
}
|
|
1266
|
+
})()
|
|
1267
|
+
}, { global: true })
|
|
1268
|
+
|
|
1269
|
+
// 配置/凭据一变,旧判定立刻作废(下次读取即重探)。
|
|
1270
|
+
//
|
|
1271
|
+
// `settings/document-updated` 是**必须**的那一条:改 `apiKeyEnv` / `baseURL` 这类
|
|
1272
|
+
// 不出现在 provider 目录事实里的字段时,`llm/adapters-updated` 根本不会发 ——
|
|
1273
|
+
// 2026-09-16 实测:把坏 key 改回好 key,判定一直停在「未配 Key」,直到 5 分钟 TTL 过期,
|
|
1274
|
+
// 也就是「充值了却不恢复」。这也正是 owner 明确要的那条路径。
|
|
1275
|
+
const invalidateAvailability = () => {
|
|
1276
|
+
lastProbeAt = 0
|
|
1277
|
+
}
|
|
1278
|
+
ctx.on('settings/document-updated', invalidateAvailability, { global: true })
|
|
1279
|
+
ctx.on('credentials/reference-updated', invalidateAvailability, { global: true })
|
|
1280
|
+
ctx.on('credentials/record-updated', invalidateAvailability, { global: true })
|
|
1281
|
+
// ⚠️ 刻意**不**监听 `llm/adapters-updated`:那个事件正是我们自己用来通知客户端重拉目录的
|
|
1282
|
+
// (见 announceHiddenChange)。监听它 = 自己的广播把自己的判定缓存打回未探状态,
|
|
1283
|
+
// 每次隐藏集合变化都会白白多探一轮。provider 增删本来就会改设置文档,
|
|
1284
|
+
// `settings/document-updated` 已经覆盖那种情况。
|
|
1285
|
+
|
|
1286
|
+
ctx.effect(() => {
|
|
1287
|
+
const timer = setTimeout(() => {
|
|
1288
|
+
refreshAvailability().catch(() => {})
|
|
1289
|
+
}, 1500)
|
|
1290
|
+
return () => clearTimeout(timer)
|
|
1291
|
+
}, 'dsh-llm-hub: initial availability probe')
|
|
1292
|
+
|
|
1293
|
+
// 把不可用的 provider 从 llm 服务的路由表里摘掉。
|
|
1294
|
+
//
|
|
1295
|
+
// 这是**唯一**能一次影响所有消费方的缝:composer 模型下拉、`/model` 弹窗、
|
|
1296
|
+
// 子代理选择器、ACP 全都读 `ctx.llm.listProviders()`,在 host 侧过滤一次,
|
|
1297
|
+
// 四处自然一致 —— 而客户端是过滤不了的。
|
|
1298
|
+
//
|
|
1299
|
+
// 为什么不去客户端包:`ctx.modelDirectories` 是 cordis 的 inject 追踪代理,
|
|
1300
|
+
// 读出来的方法被包成 traceable proxy,我们的 fiber 缺 `remote.session` 注入,
|
|
1301
|
+
// 一调用就 `cannot get property "remote.session" without inject`;2026-09-16 实测
|
|
1302
|
+
// 包装它会把官方模型座位整个打崩(座位从 composer 里消失)。host 侧这里
|
|
1303
|
+
// `ctx.get('llm')` 拿到的是**真实例**,补丁是纯函数式包装、只做减法、随插件卸载还原。
|
|
1304
|
+
//
|
|
1305
|
+
// 设置页不受影响:它读的是 configurable-provider 目录(`listConfigurableProviders`),
|
|
1306
|
+
// 所以被隐藏的分组在 设置 → 模型 里照常看得见、也照常能编辑。
|
|
1307
|
+
const installProviderFilter = () => {
|
|
1308
|
+
const service = ctx.get('llm')
|
|
1309
|
+
if (service === null || service === undefined || typeof service.listProviders !== 'function') {
|
|
1310
|
+
ctx.logger?.warn?.('dsh-llm-hub: 取不到 llm 服务,模型可用性过滤未安装')
|
|
1311
|
+
return () => {}
|
|
1312
|
+
}
|
|
1313
|
+
const original = service.listProviders
|
|
1314
|
+
const filtered = function (...args) {
|
|
1315
|
+
const providers = original.apply(this, args)
|
|
1316
|
+
if (!Array.isArray(providers)) return providers
|
|
1317
|
+
const hidden = hiddenProviders()
|
|
1318
|
+
if (hidden.size === 0) return providers
|
|
1319
|
+
return providers.filter((provider) => !hidden.has(provider?.id))
|
|
1320
|
+
}
|
|
1321
|
+
// ⚠️ 不要用 `service.listProviders !== filtered` 校验「写进去了没有」:
|
|
1322
|
+
// cordis 的服务访问返回 traceable 代理,每次读方法都是一个新的包装对象,永远不相等。
|
|
1323
|
+
// 上一版就是被这个假阴性坑的 —— 它把自己判成「赋值未生效」,顺手清掉了探针用的
|
|
1324
|
+
// 未过滤表,于是探针改读**过滤后**的路由表,隐藏的 provider 再也不会被重探
|
|
1325
|
+
// (2026-09-16 实测:minimax 被隐藏后再也没恢复)。改看真实例上的属性描述符。
|
|
1326
|
+
let installed = false
|
|
1327
|
+
try {
|
|
1328
|
+
service.listProviders = filtered
|
|
1329
|
+
const descriptor = Object.getOwnPropertyDescriptor(service, 'listProviders')
|
|
1330
|
+
installed = descriptor === undefined || descriptor.value === filtered
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
ctx.logger?.warn?.(`dsh-llm-hub: 包不住 llm.listProviders,模型可用性过滤未安装:${describeError(error)}`)
|
|
1333
|
+
return () => {}
|
|
1334
|
+
}
|
|
1335
|
+
if (!installed) {
|
|
1336
|
+
ctx.logger?.warn?.('dsh-llm-hub: llm.listProviders 赋值未生效,模型可用性过滤未安装')
|
|
1337
|
+
return () => {}
|
|
1338
|
+
}
|
|
1339
|
+
return () => {
|
|
1340
|
+
try {
|
|
1341
|
+
service.listProviders = original
|
|
1342
|
+
} catch {
|
|
1343
|
+
// 还原失败:进程退出即消失,不做补救
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
ctx.effect(() => installProviderFilter(), 'dsh-llm-hub: hide unavailable providers from llm.listProviders')
|
|
1348
|
+
|
|
1349
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1350
|
+
kind: 'exact',
|
|
1351
|
+
path: AVAILABILITY_PATH,
|
|
1352
|
+
handler: async (request, response) => {
|
|
1353
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
1354
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
1355
|
+
response.end()
|
|
1356
|
+
return
|
|
1357
|
+
}
|
|
1358
|
+
if (!sameOrigin(request)) {
|
|
1359
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
|
|
1360
|
+
return
|
|
1361
|
+
}
|
|
1362
|
+
ensureAvailabilityFresh()
|
|
1363
|
+
if (request.method === 'HEAD') {
|
|
1364
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
1365
|
+
response.end()
|
|
1366
|
+
return
|
|
1367
|
+
}
|
|
1368
|
+
sendJson(response, 200, availabilityPayload())
|
|
1369
|
+
}
|
|
1370
|
+
}), 'dsh-llm-hub: availability route')
|
|
1371
|
+
|
|
1372
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1373
|
+
kind: 'exact',
|
|
1374
|
+
path: AVAILABILITY_RECHECK_PATH,
|
|
1375
|
+
handler: async (request, response) => {
|
|
1376
|
+
if (request.method !== 'POST') {
|
|
1377
|
+
response.writeHead(405, { allow: 'POST' })
|
|
1378
|
+
response.end()
|
|
1379
|
+
return
|
|
1380
|
+
}
|
|
1381
|
+
if (!sameOrigin(request)) {
|
|
1382
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin writes are refused' })
|
|
1383
|
+
return
|
|
1384
|
+
}
|
|
1385
|
+
// 手动重探 = 全部作废重来:运行期标记也清掉,否则「充值了却还藏着」。
|
|
1386
|
+
runtimeMarks.clear()
|
|
1387
|
+
successAt.clear()
|
|
1388
|
+
baseVerdicts.clear()
|
|
1389
|
+
lastProbeAt = 0
|
|
1390
|
+
try {
|
|
1391
|
+
await refreshAvailability(true)
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
sendJson(response, 502, { ok: false, error: describeError(error) })
|
|
1394
|
+
return
|
|
1395
|
+
}
|
|
1396
|
+
sendJson(response, 200, availabilityPayload())
|
|
1397
|
+
}
|
|
1398
|
+
}), 'dsh-llm-hub: availability recheck route')
|
|
1399
|
+
//#endregion
|
|
1400
|
+
|
|
1401
|
+
// settings.yaml 里 modelgo 那种 pi-ai provider 把 `models:` 留成空列表时,
|
|
1402
|
+
// 启动时主动去网关拉一份目录写回去 —— settings.yaml 里就不再留空列表。下次 reload /
|
|
1403
|
+
// 重启不需要重做这件事(pi-ai 自己的 onChange 会按 settings 重新注册 model 目录)。
|
|
1404
|
+
//
|
|
1405
|
+
// 写入前再 `get` 一次当前段,把期间用户已经填上的 provider 过滤掉 —— 避免
|
|
1406
|
+
// fetch 期间(几百毫秒到几秒)用户在外部编辑 settings.yaml 之后,被 fetch 前的
|
|
1407
|
+
// 快照结果覆盖。
|
|
1408
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
1409
|
+
void (async () => {
|
|
1410
|
+
try {
|
|
1411
|
+
// modelgo 这种 pi-ai 风格 provider 在 'llm-pi-ai' namespace 下;
|
|
1412
|
+
// dsh-llm-hub 自己的 NS 是 'llm-deepseek',但写回属于跨 namespace 兜底:
|
|
1413
|
+
// plugin 不是 pi-ai 的 owner,只是借用 settings 服务给 pi-ai 段补一份目录。
|
|
1414
|
+
const source = settingsCtx.settings.get(PIAI_NS)
|
|
1415
|
+
const updates = await populateEmptyProviderModels(source)
|
|
1416
|
+
if (Object.keys(updates).length === 0) return
|
|
1417
|
+
const current = settingsCtx.settings.get(PIAI_NS)
|
|
1418
|
+
for (const provider of Object.keys(updates)) {
|
|
1419
|
+
const live = current?.providers?.[provider]
|
|
1420
|
+
if (Array.isArray(live?.models) && live.models.length > 0) {
|
|
1421
|
+
delete updates[provider]
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
if (Object.keys(updates).length === 0) return
|
|
1425
|
+
await settingsCtx.settings.update(PIAI_NS, { providers: updates })
|
|
1426
|
+
ctx.logger.info(`llm-deepseek: wrote back discovered models for ${Object.keys(updates).join(', ')}`)
|
|
1427
|
+
} catch (err) {
|
|
1428
|
+
ctx.logger.warn(`llm-deepseek: settings patch on boot failed: ${err?.message ?? err}`)
|
|
1429
|
+
}
|
|
1430
|
+
})()
|
|
1431
|
+
})
|
|
1432
|
+
|
|
1433
|
+
// ── harness 子代理 ────────────────────────────────────────────────────────
|
|
1434
|
+
//
|
|
1435
|
+
// 把本机装好的外部 agent CLI(codex / claude / agy)注册成子代理提供方,详见
|
|
1436
|
+
// `./harness.js`。
|
|
1437
|
+
//
|
|
1438
|
+
// 为什么用**局部注入**而不是加进模块顶部的 `inject` 数组:`inject` 是硬依赖,
|
|
1439
|
+
// 少一个服务整个插件就不加载 —— 那会让「宿主没装子代理栈」的部署连余额和模型
|
|
1440
|
+
// 发现一起失去。局部注入只让 harness 这一段跟着服务走,缺了就不启用。
|
|
1441
|
+
ctx.inject(['subagents', 'subprocess'], (scope) => {
|
|
1442
|
+
applyHarness(scope).catch((error) => {
|
|
1443
|
+
scope.logger?.warn?.(`dsh-llm-hub: harness 探测失败:${error?.message ?? error}`)
|
|
1444
|
+
})
|
|
1445
|
+
})
|
|
1446
|
+
|
|
1447
|
+
// 自检路由:`GET /api/dsh-llm-hub/harness` 报告探到了什么、注册了几个。
|
|
1448
|
+
//
|
|
1449
|
+
// 为什么需要它:宿主的 `info` 级日志**不落盘**(实测 launchd.log 里只有
|
|
1450
|
+
// event-dispatch / mcp / web url 三行),所以「provider 到底注册上了没有」
|
|
1451
|
+
// 从外部无从确认,只能靠在会话里真跑一次委派 —— 那要花钱调模型。这条路由
|
|
1452
|
+
// 让同一份判定可读,也是 client 半渲染 harness 卡的数据源。
|
|
1453
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1454
|
+
kind: 'exact',
|
|
1455
|
+
path: HARNESS_PATH,
|
|
1456
|
+
handler: (request, response) => {
|
|
1457
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
1458
|
+
response.writeHead(405, { allow: 'GET, HEAD' })
|
|
1459
|
+
response.end()
|
|
1460
|
+
return
|
|
1461
|
+
}
|
|
1462
|
+
if (!sameOrigin(request)) {
|
|
1463
|
+
sendJson(response, 403, { ok: false, error: 'cross-origin reads are refused' })
|
|
1464
|
+
return
|
|
1465
|
+
}
|
|
1466
|
+
if (request.method === 'HEAD') {
|
|
1467
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
1468
|
+
response.end()
|
|
1469
|
+
return
|
|
1470
|
+
}
|
|
1471
|
+
const report = harnessReport()
|
|
1472
|
+
sendJson(response, 200, {
|
|
1473
|
+
ok: true,
|
|
1474
|
+
probedAt: report.probedAt,
|
|
1475
|
+
// 还没探完(启动竞态)时 entries 是空的 —— 明说,不要让读者以为「一个都没装」。
|
|
1476
|
+
probed: report.probedAt > 0,
|
|
1477
|
+
harnesses: report.entries
|
|
1478
|
+
})
|
|
1479
|
+
}
|
|
1480
|
+
}), 'dsh-llm-hub: harness 自检路由')
|
|
1481
|
+
}
|