dsh-router-core 0.4.4 → 0.4.6
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/LICENSE +0 -0
- package/README.md +0 -0
- package/cordis.patch.yml +0 -0
- package/docs/ext.md +0 -0
- package/docs/pool-sticky-block.md +59 -1
- package/docs/screenshot.png +0 -0
- package/docs/suppliers.md +22 -3
- package/lib/client-registry.js +24 -14
- package/lib/client.js +24 -14
- package/lib/ext-prompt/locale/en.json +0 -0
- package/lib/ext-prompt/locale/zh.json +0 -0
- package/lib/ext-prompt/package.json +0 -0
- package/lib/ext-test/locale/en.json +0 -0
- package/lib/ext-test/locale/zh.json +0 -0
- package/lib/ext-test/package.json +0 -0
- package/lib/index.js +101 -15
- package/lib/suppliers/nvidia/locale/en.json +0 -0
- package/lib/suppliers/nvidia/locale/zh.json +0 -0
- package/lib/suppliers/nvidia/package.json +0 -0
- package/lib/suppliers/opencode/index.js +58 -30
- package/lib/suppliers/opencode/locale/en.json +0 -0
- package/lib/suppliers/opencode/locale/zh.json +0 -0
- package/lib/suppliers/opencode/package.json +0 -0
- package/lib/suppliers/openrouter/locale/en.json +0 -0
- package/lib/suppliers/openrouter/locale/zh.json +0 -0
- package/lib/suppliers/openrouter/package.json +0 -0
- package/lib/types/router/account-pool.d.ts +30 -3
- package/lib/types/router/index.d.ts +25 -0
- package/lib/types/router/types.d.ts +16 -4
- package/lib/types/router/usage-store.d.ts +4 -2
- package/lib/types/shared.d.ts +19 -0
- package/lib/types/suppliers/contract.d.ts +7 -1
- package/package.json +1 -1
- package/src/client/CheckinCard.tsx +0 -0
- package/src/client/CombosTab.tsx +0 -0
- package/src/client/EndpointTab.tsx +0 -0
- package/src/client/ExtDetail.tsx +0 -0
- package/src/client/ExtTab.tsx +0 -0
- package/src/client/ExtTestPanel.tsx +0 -0
- package/src/client/LastHitDock.tsx +0 -0
- package/src/client/Modal.tsx +0 -0
- package/src/client/RouterComponentsSection.tsx +0 -0
- package/src/client/RouterView.tsx +0 -0
- package/src/client/StatsTab.tsx +0 -0
- package/src/client/SupplierDetail.tsx +1 -5
- package/src/client/account-badge.test.ts +37 -0
- package/src/client/account-badge.ts +24 -0
- package/src/client/comp-button-scope.test.ts +0 -0
- package/src/client/comp-panel-layout.test.ts +0 -0
- package/src/client/env.d.ts +0 -0
- package/src/client/ext-panels.test.ts +0 -0
- package/src/client/ext-panels.ts +0 -0
- package/src/client/index.tsx +0 -0
- package/src/client/last-hit-store.ts +0 -0
- package/src/client/model-choice.test.ts +0 -0
- package/src/client/model-choice.ts +0 -0
- package/src/client/model-hint-copy.test.ts +0 -0
- package/src/client/model-hint-copy.ts +0 -0
- package/src/client/router-components.test.ts +0 -0
- package/src/client/router-components.ts +0 -0
- package/src/client/router.css +0 -0
- package/src/client/settings-nav-icon.ts +0 -0
- package/src/client/settings-section.tsx +0 -0
- package/src/client/sidebar-entry.ts +0 -0
- package/src/client/supplier-visibility.test.ts +0 -0
- package/src/client/supplier-visibility.ts +0 -0
- package/src/client/workspace-controller.ts +0 -0
- package/src/client/workspace-mount.tsx +0 -0
- package/src/credential-store.ts +0 -0
- package/src/data-dir.test.ts +0 -0
- package/src/data-dir.ts +0 -0
- package/src/ext/contract.ts +0 -0
- package/src/ext/store.test.ts +0 -0
- package/src/ext/store.ts +0 -0
- package/src/ext-prompt/assembly.test.ts +0 -0
- package/src/ext-prompt/content.ts +0 -0
- package/src/ext-prompt/ext-prompt.test.ts +0 -0
- package/src/ext-prompt/index.ts +0 -0
- package/src/ext-prompt/locale/en.json +0 -0
- package/src/ext-prompt/locale/zh.json +0 -0
- package/src/ext-prompt/logo.svg +0 -0
- package/src/ext-prompt/logo.ts +0 -0
- package/src/ext-prompt/mount.test.ts +0 -0
- package/src/ext-prompt/mount.ts +0 -0
- package/src/ext-prompt/plugin.ts +0 -0
- package/src/ext-prompt/render.test.ts +0 -0
- package/src/ext-prompt/render.ts +0 -0
- package/src/ext-prompt/row.json +0 -0
- package/src/ext-route.test.ts +0 -0
- package/src/ext-test/ext-test.test.ts +0 -0
- package/src/ext-test/index.ts +0 -0
- package/src/ext-test/locale/en.json +0 -0
- package/src/ext-test/locale/zh.json +0 -0
- package/src/ext-test/plugin.ts +0 -0
- package/src/ext-test/row.json +0 -0
- package/src/host-base-url.test.ts +0 -0
- package/src/host-base-url.ts +0 -0
- package/src/host-version.test.ts +0 -0
- package/src/host-version.ts +0 -0
- package/src/index.ts +20 -6
- package/src/keys.ts +0 -0
- package/src/llm/adapter.test.ts +54 -0
- package/src/llm/adapter.ts +24 -2
- package/src/router/account-pool.test.ts +109 -0
- package/src/router/account-pool.ts +52 -9
- package/src/router/combo-change.test.ts +125 -0
- package/src/router/disconnect-leak.test.ts +0 -0
- package/src/router/first-byte-budget.test.ts +0 -0
- package/src/router/index.ts +44 -4
- package/src/router/models-cache.test.ts +0 -0
- package/src/router/precise-dispatch.test.ts +0 -0
- package/src/router/prefix-affinity.ts +0 -0
- package/src/router/supplier-switch.test.ts +91 -0
- package/src/router/types.ts +16 -4
- package/src/router/usage-store.ts +4 -2
- package/src/router/usage-tokens.ts +0 -0
- package/src/router/usage.test.ts +0 -0
- package/src/shared.test.ts +35 -0
- package/src/shared.ts +28 -0
- package/src/supplier-config.test.ts +0 -0
- package/src/supplier-config.ts +0 -0
- package/src/suppliers/contract.ts +7 -2
- package/src/suppliers/http-state.test.ts +0 -0
- package/src/suppliers/http-state.ts +0 -0
- package/src/suppliers/loader.test.ts +53 -0
- package/src/suppliers/loader.ts +0 -0
- package/src/suppliers/nvidia/index.ts +0 -0
- package/src/suppliers/nvidia/locale/en.json +0 -0
- package/src/suppliers/nvidia/locale/zh.json +0 -0
- package/src/suppliers/nvidia/plugin.ts +0 -0
- package/src/suppliers/nvidia/row.json +0 -0
- package/src/suppliers/opencode/index.ts +0 -0
- package/src/suppliers/opencode/locale/en.json +0 -0
- package/src/suppliers/opencode/locale/zh.json +0 -0
- package/src/suppliers/opencode/plugin.test.ts +79 -25
- package/src/suppliers/opencode/plugin.ts +60 -29
- package/src/suppliers/opencode/row.json +0 -0
- package/src/suppliers/openrouter/index.ts +0 -0
- package/src/suppliers/openrouter/locale/en.json +0 -0
- package/src/suppliers/openrouter/locale/zh.json +0 -0
- package/src/suppliers/openrouter/plugin.ts +0 -0
- package/src/suppliers/openrouter/row.json +0 -0
- package/src/suppliers/probe.test.ts +0 -0
- package/src/suppliers/probe.ts +0 -0
- package/src/suppliers/registry.test.ts +0 -0
- package/src/suppliers/registry.ts +0 -0
- package/src/types.d.ts +0 -0
package/src/router/index.ts
CHANGED
|
@@ -657,6 +657,21 @@ export class Router {
|
|
|
657
657
|
private log: (msg: string) => void
|
|
658
658
|
/** 每条降级腿的首字节预算(ms)。只给测试一个入口,生产恒为 FIRST_BYTE_BUDGET_MS。 */
|
|
659
659
|
private firstByteBudgetMs: number
|
|
660
|
+
/**
|
|
661
|
+
* 组合增删改**成功提交后**的通知出口。
|
|
662
|
+
*
|
|
663
|
+
* 为什么必须有:模型选择器(`ui-model-selection`)的目录是**客户端缓存**的,
|
|
664
|
+
* 只在宿主转发过来的事件上失效重载(`llm/adapters-updated` /
|
|
665
|
+
* `settings/document-updated` / 凭据事件 / 重连)。而组合只是本插件
|
|
666
|
+
* `combos.json` 里的一份状态:改完名字若不发通知,选择器会一直拿着旧快照 ——
|
|
667
|
+
* 用户能看到的症状是「面板里明明有这个名字,模型选择器里却找不到、切不过去」,
|
|
668
|
+
* 且新建的组合永远不出现(2026-10-14 实测)。
|
|
669
|
+
*
|
|
670
|
+
* 通知与落盘分开({@link saveCombos} 先 {@link commitCombos} 落盘、再发通知):
|
|
671
|
+
* 落盘失败、或 `stateFile` 为空(单测/独立运行时)时内存已经改了,客户端的
|
|
672
|
+
* 状态同样过期,所以这两条路径都必须照发。
|
|
673
|
+
*/
|
|
674
|
+
private onCombosChanged: (() => void) | undefined
|
|
660
675
|
/** 用量统计(概览看板)。 */
|
|
661
676
|
usage: UsageStore
|
|
662
677
|
|
|
@@ -664,12 +679,13 @@ export class Router {
|
|
|
664
679
|
stateFile = '',
|
|
665
680
|
store?: SupplierConfigStore,
|
|
666
681
|
log?: (msg: string) => void,
|
|
667
|
-
opts?: { firstByteBudgetMs?: number },
|
|
682
|
+
opts?: { firstByteBudgetMs?: number; onCombosChanged?: () => void },
|
|
668
683
|
) {
|
|
669
684
|
this.combosFp = stateFile ? join(dirname(stateFile), 'combos.json') : ''
|
|
670
685
|
this.store = store ?? new SupplierConfigStore(stateFile)
|
|
671
686
|
this.log = log ?? ((): void => {})
|
|
672
687
|
this.firstByteBudgetMs = opts?.firstByteBudgetMs ?? FIRST_BYTE_BUDGET_MS
|
|
688
|
+
this.onCombosChanged = opts?.onCombosChanged
|
|
673
689
|
this.usage = new UsageStore(stateFile)
|
|
674
690
|
this.loadCombos()
|
|
675
691
|
}
|
|
@@ -728,7 +744,24 @@ export class Router {
|
|
|
728
744
|
}
|
|
729
745
|
}
|
|
730
746
|
|
|
747
|
+
/**
|
|
748
|
+
* 提交组合变更:落盘 + 通知拓扑观察者。
|
|
749
|
+
*
|
|
750
|
+
* `onCombosChanged` 的失败必须被吞掉:它是**通知**不是事务参与者,
|
|
751
|
+
* 监听方(宿主事件总线)出问题不能把一次已经生效的增删改报成失败 ——
|
|
752
|
+
* 那会让用户重试一个其实已经成功的操作(同 `schedule/changed` 的口径)。
|
|
753
|
+
*/
|
|
731
754
|
private saveCombos(): void {
|
|
755
|
+
this.commitCombos()
|
|
756
|
+
try {
|
|
757
|
+
this.onCombosChanged?.()
|
|
758
|
+
} catch (err) {
|
|
759
|
+
this.log(`combo change notification failed: ${(err as Error).message}`)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** 组合落盘(通知由 {@link saveCombos} 单独发,落盘失败不阻断它)。 */
|
|
764
|
+
private commitCombos(): void {
|
|
732
765
|
if (this.combosFp === '') return
|
|
733
766
|
try {
|
|
734
767
|
const dir = dirname(this.combosFp)
|
|
@@ -1276,12 +1309,19 @@ export class Router {
|
|
|
1276
1309
|
for (;;) {
|
|
1277
1310
|
// 指定连接:只试它。选不中就直接失败并说明原因(连接已不存在/不在池里),
|
|
1278
1311
|
// **不回退到别的号** —— 否则「测这个连接」测出来的是另一个号的结果,
|
|
1279
|
-
//
|
|
1312
|
+
// 诊断就变成了假话。注意「选不中」要区分「不在池里」和「这一轮试过了」:
|
|
1313
|
+
// 后者是失败重试的收尾,必须保留上游给的真实错误(见下方 uid === undefined)。
|
|
1280
1314
|
const uid = onlyUid !== undefined
|
|
1281
1315
|
? (s.accounts().some((a) => a.uid === onlyUid) && !tried.has(onlyUid) ? onlyUid : undefined)
|
|
1282
1316
|
: pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session)
|
|
1283
1317
|
if (uid === undefined) {
|
|
1284
|
-
|
|
1318
|
+
// 指定连接却选不出来,有两种完全不同的原因,混为一谈就是假诊断:
|
|
1319
|
+
// - 这个 uid 根本不在账号池里 → 真「连接不存在(可能已被删除)」
|
|
1320
|
+
// - uid 在池里,只是这一轮被 `tried` 挡掉 → 它**刚才已经试过并失败**了,
|
|
1321
|
+
// trace 里正躺着上游给的真实原因(如 quota / 4008「额度已用完」)。
|
|
1322
|
+
// 此处若照旧覆盖,会把「积分用完」说成「连接不存在」——2026-10-14
|
|
1323
|
+
// 实测:0 积分号测出「连接不存在(可能已被删除)」,而它明明在面板上。
|
|
1324
|
+
if (onlyUid !== undefined && !tried.has(onlyUid) && trace !== undefined) {
|
|
1285
1325
|
// 不给 AccountState 加「连接不存在」这个值:那是给**账号的失败**分类用的
|
|
1286
1326
|
// (决定要不要冷却),而链接不存在不是任何账号的错,混进去会让所有分类逻辑
|
|
1287
1327
|
// 都要认识它。具体原因放 lastError 就够。
|
|
@@ -1315,7 +1355,7 @@ export class Router {
|
|
|
1315
1355
|
}
|
|
1316
1356
|
return false
|
|
1317
1357
|
}
|
|
1318
|
-
pool.noteFailure(uid, req.model, r.state, r.message)
|
|
1358
|
+
pool.noteFailure(uid, req.model, r.state, r.message, r.resetAt)
|
|
1319
1359
|
// 故障切走:下次选号会换号,旧块的驻留计数不再适用(前缀归属变了)
|
|
1320
1360
|
pool.dropBlock(req.model)
|
|
1321
1361
|
if (trace !== undefined) {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -218,3 +218,94 @@ test('指定不存在的连接:明确报「连接不存在」,而不是静
|
|
|
218
218
|
assert.equal(a.calls, 0, '一个号都不该被试')
|
|
219
219
|
assert.match(r.error ?? '', /不存在/)
|
|
220
220
|
})
|
|
221
|
+
|
|
222
|
+
test('指定连接失败:报上游真实原因,不许说成「连接不存在」', async () => {
|
|
223
|
+
// 2026-10-14 实测:0 积分号(uid 在池里、面板上看得见)测出来是
|
|
224
|
+
// 「连接 "4135677434275728" 不存在(可能已被删除)」——真话是额度用完。
|
|
225
|
+
// 机制:失败后 `continue`,下一轮 onlyUid 被 `tried` 挡掉 → uid undefined,
|
|
226
|
+
// 旧代码在那里无条件覆盖 lastError,把上游原因冲掉。假诊断比没有诊断更坏:
|
|
227
|
+
// 用户会去删链接、重新登录,而正解是充值。
|
|
228
|
+
const { router } = rig()
|
|
229
|
+
const a = spy('supA', 'a-model')
|
|
230
|
+
a.s.accounts = () => [
|
|
231
|
+
{ uid: 'broke', credits: 0, state: 'ok' },
|
|
232
|
+
{ uid: 'rich', credits: 100, state: 'ok' },
|
|
233
|
+
]
|
|
234
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
235
|
+
const seen: string[] = []
|
|
236
|
+
a.s.chatOnce = async (uid) => {
|
|
237
|
+
seen.push(uid)
|
|
238
|
+
return { ok: false, state: 'quota', message: 'Your requests have exceeded the quota.' }
|
|
239
|
+
}
|
|
240
|
+
add(router, a.s)
|
|
241
|
+
|
|
242
|
+
const r = await router.testModel('supA', 'a-model', 'broke')
|
|
243
|
+
assert.equal(r.ok, false)
|
|
244
|
+
assert.deepEqual(seen, ['broke'], '钉死单连接,失败也不换号')
|
|
245
|
+
assert.match(r.error ?? '', /quota|exceeded/, `要报上游真因,实际: ${r.error}`)
|
|
246
|
+
assert.doesNotMatch(r.error ?? '', /不存在/, 'uid 在池里,绝不是「不存在」')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// ---- 积分用完的号必须退出路由(插件报 quota → 核心不选它) ----
|
|
250
|
+
// 端到端串起来:traework 的 pool.list() 在 credits===0 时报 quota,核心 pick()
|
|
251
|
+
// 据此跳过该号。这条走真实入口 chatCompletions,钉住「报出的事实真的改变了路由」,
|
|
252
|
+
// 而不只是「状态字符串好看」。
|
|
253
|
+
|
|
254
|
+
test('插件报 quota 的号:请求直接落到还有额度的号,不去撞死号', async () => {
|
|
255
|
+
const { router } = rig()
|
|
256
|
+
const a = spy('supA', 'a-model')
|
|
257
|
+
const tried: string[] = []
|
|
258
|
+
a.s.accounts = () => [
|
|
259
|
+
{ uid: 'broke', credits: 0, state: 'quota' },
|
|
260
|
+
{ uid: 'rich', credits: 500, state: 'ok' },
|
|
261
|
+
]
|
|
262
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
263
|
+
a.s.chatOnce = async (uid) => {
|
|
264
|
+
tried.push(uid)
|
|
265
|
+
return { ok: true, status: 200, body: '{"ok":1}' }
|
|
266
|
+
}
|
|
267
|
+
add(router, a.s)
|
|
268
|
+
|
|
269
|
+
await router.chatCompletions(reqWith('supA/a-model'), fakeRes())
|
|
270
|
+
|
|
271
|
+
assert.deepEqual(tried, ['rich'], '一次都不该撞到积分用完的号')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('积分用完的号恢复(充值后报 ok):立刻重新参与路由,无需重启', async () => {
|
|
275
|
+
const { router } = rig()
|
|
276
|
+
const a = spy('supA', 'a-model')
|
|
277
|
+
let broke = true
|
|
278
|
+
const tried: string[] = []
|
|
279
|
+
a.s.accounts = () => (broke
|
|
280
|
+
? [{ uid: 'was-broke', credits: 0, state: 'quota' }, { uid: 'other', credits: 10, state: 'ok' }]
|
|
281
|
+
: [{ uid: 'was-broke', credits: 900, state: 'ok' }])
|
|
282
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
283
|
+
a.s.chatOnce = async (uid) => {
|
|
284
|
+
tried.push(uid)
|
|
285
|
+
return { ok: true, status: 200, body: '{"ok":1}' }
|
|
286
|
+
}
|
|
287
|
+
add(router, a.s)
|
|
288
|
+
|
|
289
|
+
await router.chatCompletions(reqWith('supA/a-model'), fakeRes())
|
|
290
|
+
assert.deepEqual(tried, ['other'], '没额度时不选它')
|
|
291
|
+
|
|
292
|
+
broke = false
|
|
293
|
+
await router.chatCompletions(reqWith('supA/a-model'), fakeRes())
|
|
294
|
+
assert.equal(tried[1], 'was-broke', '充值后立刻回到候选')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('全池都没额度:如实失败,不假装成功也不无限转圈', async () => {
|
|
298
|
+
const { router } = rig()
|
|
299
|
+
const a = spy('supA', 'a-model')
|
|
300
|
+
a.s.accounts = () => [
|
|
301
|
+
{ uid: 'b1', credits: 0, state: 'quota' },
|
|
302
|
+
{ uid: 'b2', credits: 0, state: 'quota' },
|
|
303
|
+
]
|
|
304
|
+
a.s.status = () => ({ id: 'supA', name: 'supA', accounts: a.s.accounts() })
|
|
305
|
+
a.s.chatOnce = async () => ({ ok: true, status: 200, body: '{"ok":1}' })
|
|
306
|
+
add(router, a.s)
|
|
307
|
+
|
|
308
|
+
const r = await router.testModel('supA', 'a-model')
|
|
309
|
+
assert.equal(r.ok, false, '没号可选就是失败')
|
|
310
|
+
assert.equal(a.calls, 0, '不该去撞任何一个已知没额度的号')
|
|
311
|
+
})
|
package/src/router/types.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* 每个供应商提供 OpenAI 兼容的 chat/models,并暴露面板状态。
|
|
4
4
|
* 仿 9router:供应商 = 一种"免费/付费"通道;dsh-router 在其上做路由。
|
|
5
5
|
*/
|
|
6
|
-
import type { ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts'
|
|
6
|
+
import type { AccountState, ChatOnceResult, SupplierAccountNow } from '../suppliers/contract.ts'
|
|
7
7
|
import type { AccountPool } from './account-pool.ts'
|
|
8
8
|
|
|
9
9
|
/** 面板展示的账号状态(脱敏)。 */
|
|
@@ -11,6 +11,14 @@ export interface SupplierAccount {
|
|
|
11
11
|
uid: string
|
|
12
12
|
nickname?: string
|
|
13
13
|
credits: number
|
|
14
|
+
/**
|
|
15
|
+
* 插件解读上游信号后的语义状态(`quota` = 额度/积分用完,`session_dead` = 凭证失效)。
|
|
16
|
+
*
|
|
17
|
+
* `decorate()` 一直原样带出插件报的状态,核心选号也读它(`quota`/`session_dead`
|
|
18
|
+
* 的号不进候选)——面板类型里缺了这一格就会自相矛盾:号明明已被排除在路由之外,
|
|
19
|
+
* 界面上却只看得到「0 积分」,看不出它为什么不参与。
|
|
20
|
+
*/
|
|
21
|
+
state?: AccountState
|
|
14
22
|
cooling: boolean
|
|
15
23
|
until?: string
|
|
16
24
|
reason?: string
|
|
@@ -53,9 +61,13 @@ export interface ChatRequest {
|
|
|
53
61
|
/** 推理等级(reasoning effort),供应商各自决定怎么映射到上游。缺省 = 'auto'。 */
|
|
54
62
|
lv?: string
|
|
55
63
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
64
|
+
* 调用方身份(归因键)。来源两种:
|
|
65
|
+
* 1. 宿主会话身份(dsh-llm `GenerateOptions.sessionId`,adapter 转成内部头
|
|
66
|
+
* `x-dsh-router-session`)——形式 `session-<uuid>`;
|
|
67
|
+
* 2. 外部 OpenAI 兼容客户端的 `user` 字段(Zed/IDE 等多客户端场景)——形式
|
|
68
|
+
* `ext:<user>`,前缀标明「这不是宿主会话」。
|
|
69
|
+
* 用途:把用量记录**按调用方归因**(「最近命中」徽章按当前会话显示;出事时
|
|
70
|
+
* 能反查是哪个客户端在打)。缺省 = 匿名外部客户端。
|
|
59
71
|
*/
|
|
60
72
|
session?: string
|
|
61
73
|
}
|
|
@@ -55,8 +55,10 @@ export interface UsageRecord {
|
|
|
55
55
|
/** 失败原因(ok=false 时)。 */
|
|
56
56
|
error?: string
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
58
|
+
* 调用方身份(有则记):宿主会话 id(`session-<uuid>`)或外部 OpenAI 客户端
|
|
59
|
+
* 标签(`ext/…`,见 shared.ts 的 clientIdentityOf)。用于「最近命中」按当前
|
|
60
|
+
* 会话归因(徽章要显示当前会话的命中,而不是全局最近),出事时也能反查
|
|
61
|
+
* 是哪个客户端在打。
|
|
60
62
|
*/
|
|
61
63
|
session?: string
|
|
62
64
|
}
|
|
File without changes
|
package/src/router/usage.test.ts
CHANGED
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 外部 OpenAI 客户端归因标签(clientIdentityOf)的闸门。
|
|
3
|
+
*
|
|
4
|
+
* 2026-10-15 事故反查暴露的盲区:`/v1` 没有宿主内部头的请求全落成匿名
|
|
5
|
+
* usage 记录(296 条对不上任何会话)。这里锁住三级兜底的优先级与边界。
|
|
6
|
+
* 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { clientIdentityOf } from './shared.ts'
|
|
11
|
+
|
|
12
|
+
test('归因优先级:user > prompt_cache_key > User-Agent', () => {
|
|
13
|
+
assert.equal(clientIdentityOf({ user: 'zed-thread-1', prompt_cache_key: 'k' }, 'Zed/0.2'), 'ext/user:zed-thread-1')
|
|
14
|
+
assert.equal(clientIdentityOf({ prompt_cache_key: 'k1' }, 'Zed/0.2'), 'ext/cache:k1')
|
|
15
|
+
assert.equal(clientIdentityOf({}, 'Zed/0.2'), 'ext/ua:Zed/0.2')
|
|
16
|
+
assert.equal(clientIdentityOf({}, undefined), undefined, '什么都没有 → 保持匿名')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('空串/非字符串的 user 与 prompt_cache_key 当没有,落 UA 兜底', () => {
|
|
20
|
+
assert.equal(clientIdentityOf({ user: '' }, 'Zed/0.2'), 'ext/ua:Zed/0.2')
|
|
21
|
+
assert.equal(clientIdentityOf({ user: 123 }, 'Zed/0.2'), 'ext/ua:Zed/0.2')
|
|
22
|
+
assert.equal(clientIdentityOf({ user: '', prompt_cache_key: '' }, ''), undefined)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('值截断到 64 字符(防超长标识灌进 usage 记录)', () => {
|
|
26
|
+
const long = 'x'.repeat(100)
|
|
27
|
+
assert.equal(clientIdentityOf({ user: long }, undefined), `ext/user:${'x'.repeat(64)}`)
|
|
28
|
+
assert.equal(clientIdentityOf({}, long), `ext/ua:${'x'.repeat(64)}`)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('宿主会话 id 形态与外部标签形态不撞(lastHit 过滤互不误命中)', () => {
|
|
32
|
+
const host = 'session-72847baa-e309-42ad-aa6b-15107cdc6518'
|
|
33
|
+
const ext = clientIdentityOf({ user: '72847baa-e309-42ad-aa6b-15107cdc6518' }, undefined)
|
|
34
|
+
assert.notEqual(ext, host, '前缀区分宿主/外部,精确匹配过滤不会串')
|
|
35
|
+
})
|
package/src/shared.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface RouterAccount {
|
|
|
25
25
|
uid: string
|
|
26
26
|
nickname?: string
|
|
27
27
|
credits: number
|
|
28
|
+
/**
|
|
29
|
+
* 插件解读上游信号后的语义状态(`quota` = 额度/积分用完,`session_dead` = 凭证失效)。
|
|
30
|
+
* 核心选号会跳过这些号,所以面板必须显示出来——否则界面写着「健康」,而路由
|
|
31
|
+
* 永远不选它,用户无从知道为什么。
|
|
32
|
+
*/
|
|
33
|
+
state?: string
|
|
28
34
|
cooling: boolean
|
|
29
35
|
until?: string
|
|
30
36
|
reason?: string
|
|
@@ -315,3 +321,25 @@ export interface RouterChartResponse {
|
|
|
315
321
|
error?: string
|
|
316
322
|
chart?: RouterChartBucket[]
|
|
317
323
|
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 外部 OpenAI 兼容客户端的归因标签(`/v1` 没有宿主内部头 `x-dsh-router-session`
|
|
327
|
+
* 时的兜底)——否则 usage 记录里全是匿名流量,出事反查不出是谁在打
|
|
328
|
+
* (2026-10-15 实测:296 条匿名记录对不上任何会话)。
|
|
329
|
+
*
|
|
330
|
+
* 取值优先级:`user`(OpenAI 标准终端用户字段)→ `prompt_cache_key`
|
|
331
|
+
* (Zed 等客户端的缓存键,常带线程粒度)→ `User-Agent`(保底到客户端粒度)。
|
|
332
|
+
* 值截断到 64 字符;三者皆无返回 undefined(保持匿名)。
|
|
333
|
+
*/
|
|
334
|
+
export function clientIdentityOf(
|
|
335
|
+
body: { user?: unknown; prompt_cache_key?: unknown },
|
|
336
|
+
userAgent: string | undefined,
|
|
337
|
+
): string | undefined {
|
|
338
|
+
const tag = (kind: string, v: unknown): string | undefined =>
|
|
339
|
+
typeof v === 'string' && v !== '' ? `ext/${kind}:${v.slice(0, 64)}` : undefined
|
|
340
|
+
return (
|
|
341
|
+
tag('user', body.user)
|
|
342
|
+
?? tag('cache', body.prompt_cache_key)
|
|
343
|
+
?? (userAgent !== undefined && userAgent !== '' ? `ext/ua:${userAgent.slice(0, 64)}` : undefined)
|
|
344
|
+
)
|
|
345
|
+
}
|
|
File without changes
|
package/src/supplier-config.ts
CHANGED
|
File without changes
|
|
@@ -117,8 +117,13 @@ export type ChatOnceResult =
|
|
|
117
117
|
| { ok: true; stream: ReadableStream<Uint8Array> }
|
|
118
118
|
/** 非流式:核心写 JSON。 */
|
|
119
119
|
| { ok: true; status: number; body: string }
|
|
120
|
-
/**
|
|
121
|
-
|
|
120
|
+
/**
|
|
121
|
+
* 失败:核心据此做冷却/换号。
|
|
122
|
+
* `resetAt` = 上游自己声明的重置时刻(epoch ms,可选):限流类错误带它时
|
|
123
|
+
* 核心冷却到该点,而不是指数退避——频率配额的重置是小时级的,5min 上限的
|
|
124
|
+
* 退避只会到点回来再撞(2026-10-15 的 6004 事故:11 个号轮流被打冷)。
|
|
125
|
+
*/
|
|
126
|
+
| { ok: false; state: AccountState; message: string; resetAt?: number }
|
|
122
127
|
|
|
123
128
|
/** 供应商模块 —— 契约(核心必须,差异化可选)。 */
|
|
124
129
|
export interface SupplierModule {
|
|
File without changes
|
|
File without changes
|
|
@@ -215,3 +215,56 @@ test('用户目录只加载 *.js,且同 id 后者覆盖前者(按文件名
|
|
|
215
215
|
assert.equal(suppliers[0]?.supplier.name, 'Second')
|
|
216
216
|
assert.equal(suppliers[0]?.source, 'user')
|
|
217
217
|
})
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// 插件报的「额度用完」要真的进路由(end-to-end 的那一段线)
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 造一个报 quota 的插件(模拟 traework 的 pool.list():credits===0 → state quota)。
|
|
225
|
+
* 这条钉住 loader 的 `accounts()` 是**原样透传**插件状态(不带 decorate/hydrate):
|
|
226
|
+
* 核心 pick() 读的就是它。哪天有人给它加一层加工,把 quota 抹成 ok,路由又会
|
|
227
|
+
* 去撞没额度的号,而面板还显示正常——所以这里必须红。
|
|
228
|
+
*/
|
|
229
|
+
function quotaPlugin(id: string): SupplierModule {
|
|
230
|
+
const accounts: SupplierAccountNow[] = [
|
|
231
|
+
{ uid: 'u-broke', credits: 0, state: 'quota' },
|
|
232
|
+
{ uid: 'u-rich', credits: 500, state: 'ok' },
|
|
233
|
+
]
|
|
234
|
+
return {
|
|
235
|
+
id,
|
|
236
|
+
name: id,
|
|
237
|
+
status: () => ({ id, name: id, accounts }),
|
|
238
|
+
listModels: () => [],
|
|
239
|
+
getAlias: () => id,
|
|
240
|
+
chatOnce: async (_uid: string, _req: ChatRequest): Promise<ChatOnceResult> => ({ ok: true, status: 200, body: '{}' }),
|
|
241
|
+
dispose: () => {},
|
|
242
|
+
} as unknown as SupplierModule
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
test('插件报的 quota 原样到达核心 → 没额度的号不进选号', () => {
|
|
246
|
+
const { env: e } = env()
|
|
247
|
+
const { supplier } = wrapModule(quotaPlugin('traework'), e, 'test')
|
|
248
|
+
assert.equal(
|
|
249
|
+
supplier.accounts().find((a) => a.uid === 'u-broke')?.state,
|
|
250
|
+
'quota',
|
|
251
|
+
'accounts() 必须原样带出插件状态(核心 pick 读的就是它)',
|
|
252
|
+
)
|
|
253
|
+
assert.equal(supplier.pool.pick(supplier.accounts(), [], 'm1'), 'u-rich', '额度用完的号被跳过')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('额度用完的号在面板上也能看出来(decorate 不吞掉插件报的 state)', () => {
|
|
257
|
+
const { env: e } = env()
|
|
258
|
+
const { supplier } = wrapModule(quotaPlugin('traework'), e, 'test')
|
|
259
|
+
const shown = supplier.status().accounts.find((a) => a.uid === 'u-broke')
|
|
260
|
+
assert.equal(shown?.state, 'quota', '面板要能区分「没额度」与「正常」')
|
|
261
|
+
assert.equal(shown?.cooling, false, '还没发生过失败,就不该标成冷却')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('额度用完 ≠ -1:water 后积分真值 0 照旧落进核心缓存(不冲值、不误判)', () => {
|
|
265
|
+
const { env: e, store } = env()
|
|
266
|
+
const { supplier } = wrapModule(quotaPlugin('traework'), e, 'test')
|
|
267
|
+
supplier.status() // 触发 hydrateCredits
|
|
268
|
+
assert.equal(store.get('traework').credits['u-broke'], 0, '0 是真值,要落盘')
|
|
269
|
+
assert.equal(store.get('traework').credits['u-rich'], 500)
|
|
270
|
+
})
|
package/src/suppliers/loader.ts
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* opencode(Zen 免费档)握手契约测试 —— 锁住 2026-
|
|
2
|
+
* opencode(OpenCode Zen 免费档)握手契约测试 —— 锁住 2026-10-15 实测的那组硬门。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 为什么值得固化:这些**都不是可选优化,缺一即 403**
|
|
5
5
|
* `FreeTierError: OpenCode's free tier can only be used from within OpenCode`。
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* 它们全是「看不出来」的那种约定——代码删掉一行、或把端点/工具名改回去,
|
|
7
|
+
* 单测本该立刻变红,否则下次只能在真机对话里发现整个供应商死了。
|
|
8
8
|
*
|
|
9
|
-
* 实测依据(2026-
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* 实测依据(2026-10-15,逐项消融 + 单因子隔离):
|
|
10
|
+
* - 端点必须是 `/inference/v1/chat/completions`:`/zen/v1` 已废弃,
|
|
11
|
+
* 对任何头部/工具组合一律 403(旧实现死在这里)
|
|
12
|
+
* - `tools` 里必须存在**小写精确名为 `read`** 的工具:
|
|
13
|
+
* `read` 单独→200;`bash`/`glob`/`write`/`shell` 单独→403;
|
|
14
|
+
* `read` 改名 `READ`→403;但 `read` 的 `parameters` 完全不看
|
|
15
|
+
* (`properties:{}`、无 `parameters`、`parameters:null` 全 200)
|
|
16
|
+
* - session 的 12 位 hex 前缀必须是倒序时间戳且低 12 位 ∈ [0,63]:
|
|
17
|
+
* 纯随机 hex→403、sha256 派生→403、counter=64/300→403
|
|
18
|
+
* - 非流式(`stream:false`)即使其余齐全也 403 → 必须恒发 stream:true
|
|
19
|
+
*
|
|
20
|
+
* 已实测**不敏感**(故不作为断言,改回旧值也不算错):UA 具体版本、
|
|
21
|
+
* `x-opencode-client` 的 cli/desktop、工具 description。
|
|
15
22
|
*
|
|
16
23
|
* 用 node --test 跑(Node 原生剥 TS 类型):
|
|
17
24
|
* node --test src/suppliers/opencode/plugin.test.ts
|
|
@@ -28,6 +35,19 @@ import type { ChatRequest } from '../../router/types.ts'
|
|
|
28
35
|
const SES_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/
|
|
29
36
|
const MSG_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/
|
|
30
37
|
|
|
38
|
+
/** 上游免费档的推理端点。`/zen/v1` 已废弃,出现即回归。 */
|
|
39
|
+
const EXPECTED_CHAT_URL = 'https://opencode.ai/inference/v1/chat/completions'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 从 session id 还原出 counter:12 位 hex 是 `~(ts * 0x1000 + counter)`,
|
|
43
|
+
* 故先取反还原 `ts * 0x1000 + counter`,再取低 12 位即 counter
|
|
44
|
+
* (`ts * 0x1000` 的低 12 位恒为 0)。
|
|
45
|
+
*/
|
|
46
|
+
function invertedCounter(id: string): bigint {
|
|
47
|
+
const time = BigInt('0x' + id.slice(4, 16))
|
|
48
|
+
return ~time & 0xffffffffffffn & 0xfffn
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
interface Captured {
|
|
32
52
|
url: string
|
|
33
53
|
headers: Record<string, string>
|
|
@@ -70,25 +90,38 @@ function request(over: Partial<ChatRequest> = {}): ChatRequest {
|
|
|
70
90
|
}
|
|
71
91
|
}
|
|
72
92
|
|
|
73
|
-
test('
|
|
93
|
+
test('chat 必须打 /inference/v1(/zen/v1 已废弃,全组合 403)', async () => {
|
|
74
94
|
const stub = stubFetch()
|
|
75
95
|
try {
|
|
76
96
|
const r = await makePlugin().chatOnce('', 'auto', request())
|
|
77
97
|
assert.equal(r.ok, true, '握手齐全时应当成功')
|
|
78
|
-
|
|
98
|
+
assert.equal(stub.captured[0]!.url, EXPECTED_CHAT_URL, '/zen/v1 是本次故障根因,不得回退')
|
|
99
|
+
} finally {
|
|
100
|
+
stub.restore()
|
|
101
|
+
}
|
|
102
|
+
})
|
|
79
103
|
|
|
80
|
-
|
|
81
|
-
|
|
104
|
+
test('session 的 12 位 hex 必须是倒序时间戳,低 12 位 counter ∈ [0,63]', async () => {
|
|
105
|
+
const stub = stubFetch()
|
|
106
|
+
try {
|
|
107
|
+
await makePlugin().chatOnce('', 'auto', request())
|
|
108
|
+
const h = stub.captured[0]!.headers
|
|
82
109
|
|
|
83
|
-
//
|
|
110
|
+
// 形状不对上游是**拒绝**而不是忽略 —— 形状必须严格
|
|
84
111
|
assert.match(h['x-opencode-session'] ?? '', SES_RE, 'x-opencode-session 形状不对会被 403')
|
|
85
112
|
assert.match(h['x-opencode-request'] ?? '', MSG_RE, 'x-opencode-request 形状不对会被 403')
|
|
113
|
+
|
|
114
|
+
// 纯随机 hex 与 sha256 派生 hex 都落不进 [0,63] 窗口 → 必然 403
|
|
115
|
+
for (const key of ['x-opencode-session', 'x-opencode-request'] as const) {
|
|
116
|
+
const counter = invertedCounter(h[key]!)
|
|
117
|
+
assert.ok(counter <= 63n, `${key} 的 counter 必须 ∈ [0,63](实测 64/300 会 403),得到 ${counter}`)
|
|
118
|
+
}
|
|
86
119
|
} finally {
|
|
87
120
|
stub.restore()
|
|
88
121
|
}
|
|
89
122
|
})
|
|
90
123
|
|
|
91
|
-
test('
|
|
124
|
+
test('tools 必须含小写 read;bash 不是门,且不得被注入(旧实现的回归点)', async () => {
|
|
92
125
|
const stub = stubFetch()
|
|
93
126
|
try {
|
|
94
127
|
// 调用方明确要非流式 —— 上游免费档对非流式一律 403,插件必须翻成流式
|
|
@@ -96,32 +129,53 @@ test('请求体恒为流式,且 tools 同时含 bash 与 read', async () => {
|
|
|
96
129
|
const body = stub.captured[0]!.body
|
|
97
130
|
assert.equal(body.stream, true, '免费档只支持流式,非流式必须翻成 stream:true')
|
|
98
131
|
|
|
99
|
-
const
|
|
100
|
-
|
|
132
|
+
const tools = body.tools as Array<{ function?: { name?: string } }>
|
|
133
|
+
const names = tools.map((t) => t.function?.name)
|
|
101
134
|
assert.ok(names.includes('read'), 'tools 缺 read 会被 403')
|
|
135
|
+
// 实测:bash 不是门(bash 单独 → 403),注入它只会污染调用方的工具表
|
|
136
|
+
assert.equal(names.includes('bash'), false, 'bash 不是免费档的门,不该被注入')
|
|
137
|
+
assert.equal(tools.length, 1, '无调用方工具时只应补 read 一个占位')
|
|
138
|
+
} finally {
|
|
139
|
+
stub.restore()
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('read 的 parameters 不被检查(空 schema 即可通过)', async () => {
|
|
144
|
+
const stub = stubFetch()
|
|
145
|
+
try {
|
|
146
|
+
await makePlugin().chatOnce('', 'auto', request())
|
|
147
|
+
const tools = stub.captured[0]!.body.tools as Array<{ function: { name: string; parameters?: unknown } }>
|
|
148
|
+
const read = tools.find((t) => t.function.name === 'read')!
|
|
149
|
+
// 上游只看名字,故占位用空 properties 是对的(别"补"成真 schema 增加噪音)
|
|
150
|
+
assert.deepEqual(read.function.parameters, { type: 'object', properties: {} })
|
|
102
151
|
} finally {
|
|
103
152
|
stub.restore()
|
|
104
153
|
}
|
|
105
154
|
})
|
|
106
155
|
|
|
107
|
-
test('调用方自带的同名工具不被占位工具覆盖(
|
|
156
|
+
test('调用方自带的同名工具不被占位工具覆盖(read 只补缺)', async () => {
|
|
108
157
|
const stub = stubFetch()
|
|
109
158
|
try {
|
|
159
|
+
const realRead = {
|
|
160
|
+
type: 'function',
|
|
161
|
+
function: { name: 'read', description: 'REAL READ', parameters: { type: 'object', properties: { filePath: { type: 'string' } } } },
|
|
162
|
+
}
|
|
110
163
|
const realBash = {
|
|
111
164
|
type: 'function',
|
|
112
165
|
function: { name: 'bash', description: 'REAL BASH', parameters: { type: 'object', properties: { command: { type: 'string' } } } },
|
|
113
166
|
}
|
|
114
167
|
await makePlugin().chatOnce('', 'auto', request({
|
|
115
|
-
rawBody: JSON.stringify({ model: 'big-pickle', messages: [], stream: true, tools: [realBash] }),
|
|
168
|
+
rawBody: JSON.stringify({ model: 'big-pickle', messages: [], stream: true, tools: [realRead, realBash] }),
|
|
116
169
|
}))
|
|
117
170
|
const tools = stub.captured[0]!.body.tools as Array<{ function: { name: string; description?: string } }>
|
|
118
171
|
|
|
119
|
-
const
|
|
120
|
-
assert.equal(
|
|
121
|
-
assert.equal(
|
|
172
|
+
const read = tools.filter((t) => t.function.name === 'read')
|
|
173
|
+
assert.equal(read.length, 1, 'read 不能被注入成两条(否则真实工具签名被挤掉)')
|
|
174
|
+
assert.equal(read[0]!.function.description, 'REAL READ', '调用方自己的定义必须原样保留')
|
|
175
|
+
assert.equal(tools.length, 2, '已有 read 时不该再补任何占位')
|
|
122
176
|
|
|
123
|
-
//
|
|
124
|
-
assert.
|
|
177
|
+
// 调用方的 bash 必须原样留着(它不是门,但也绝不能被删)
|
|
178
|
+
assert.equal(tools.filter((t) => t.function.name === 'bash').length, 1, '调用方的 bash 不得被动')
|
|
125
179
|
} finally {
|
|
126
180
|
stub.restore()
|
|
127
181
|
}
|