dsh-router-core 0.3.14 → 0.3.16
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/docs/suppliers.md +19 -8
- package/lib/client-registry.js +2 -6
- package/lib/client.js +2 -6
- package/lib/index.js +84 -72
- package/lib/suppliers/nvidia.js +2 -1
- package/lib/suppliers/opencode.js +2 -1
- package/lib/suppliers/openrouter.js +2 -1
- package/lib/types/router/account-pool.d.ts +11 -11
- package/lib/types/router/types.d.ts +0 -1
- package/lib/types/shared.d.ts +0 -1
- package/lib/types/suppliers/contract.d.ts +16 -6
- package/package.json +1 -1
- package/src/client/RouterView.tsx +1 -1
- package/src/client/SupplierDetail.tsx +1 -2
- package/src/llm/adapter.test.ts +106 -4
- package/src/llm/adapter.ts +57 -18
- package/src/router/account-pool.test.ts +27 -10
- package/src/router/account-pool.ts +49 -45
- package/src/router/index.ts +12 -5
- package/src/router/models-cache.test.ts +0 -1
- package/src/router/types.ts +0 -1
- package/src/shared.ts +0 -1
- package/src/suppliers/contract.ts +15 -5
- package/src/suppliers/http-state.test.ts +36 -0
- package/src/suppliers/http-state.ts +31 -0
- package/src/suppliers/loader.ts +1 -1
- package/src/suppliers/nvidia/plugin.ts +2 -6
- package/src/suppliers/opencode/plugin.ts +3 -4
- package/src/suppliers/openrouter/plugin.ts +3 -7
- package/src/suppliers/registry.test.ts +3 -4
- package/src/suppliers/registry.ts +1 -1
package/src/router/index.ts
CHANGED
|
@@ -71,6 +71,7 @@ function stripAlias(model: string, alias: string): string {
|
|
|
71
71
|
*
|
|
72
72
|
* 只对**瞬时**故障等:连接层失败 / 上游不可用(对应 9router 的 502/503/504)。
|
|
73
73
|
* 模型不属于本供应商(no_such_model)绝不能等 —— 它不是故障,等纯属浪费。
|
|
74
|
+
* 请求本身非法(bad_request)同理:换哪个模型都是同一个 400,等也是白等。
|
|
74
75
|
*/
|
|
75
76
|
const TRANSIENT_SETTLE_MS = 2_000
|
|
76
77
|
|
|
@@ -998,7 +999,8 @@ export class Router {
|
|
|
998
999
|
if (s.accounts !== undefined && s.accounts().length === 0) {
|
|
999
1000
|
const r = await s.chatOnce('', req.lv ?? 'auto', req)
|
|
1000
1001
|
if (!r.ok) {
|
|
1001
|
-
|
|
1002
|
+
// 模型不属于本供应商、或请求本身被上游拒收:换供应商,不记账
|
|
1003
|
+
if (r.state === 'no_such_model' || r.state === 'bad_request') return false
|
|
1002
1004
|
if (trace !== undefined) trace.lastState = r.state
|
|
1003
1005
|
pool.noteFailure('', req.model, r.state, r.message)
|
|
1004
1006
|
return false
|
|
@@ -1028,10 +1030,15 @@ export class Router {
|
|
|
1028
1030
|
tried.add(uid)
|
|
1029
1031
|
const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
|
|
1030
1032
|
if (!r.ok) {
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1033
|
+
// 模型不属于本供应商、或请求本身被上游拒收:整个供应商都跳过,
|
|
1034
|
+
// 换号重试没有意义,也不能记在账号头上(否则无关账号会被冷掉)。
|
|
1035
|
+
// bad_request(如 tool_call 配对断裂、图片格式不认)对每个号结果相同,
|
|
1036
|
+
// 冷号只会把一次「请求有问题」放大成「这个模型谁都别用」。
|
|
1037
|
+
if (r.state === 'no_such_model' || r.state === 'bad_request') {
|
|
1038
|
+
if (trace !== undefined) {
|
|
1039
|
+
trace.lastError = r.state
|
|
1040
|
+
trace.lastState = r.state
|
|
1041
|
+
}
|
|
1035
1042
|
return false
|
|
1036
1043
|
}
|
|
1037
1044
|
pool.noteFailure(uid, req.model, r.state, r.message)
|
|
@@ -613,7 +613,6 @@ test('组合请求不污染无关供应商的账号(no_such_model 不记账)
|
|
|
613
613
|
const acc = other.s.pool.decorate([{ uid: 'u1', credits: 0, state: 'ok' }])[0]
|
|
614
614
|
assert.equal(acc?.err_count, 0, '不该累计连续错误')
|
|
615
615
|
assert.equal(acc?.cooling, false, '不该被冷却')
|
|
616
|
-
assert.equal(acc?.disabled, false, '不该被禁用')
|
|
617
616
|
})
|
|
618
617
|
|
|
619
618
|
/**
|
package/src/router/types.ts
CHANGED
package/src/shared.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface SupplierEnv {
|
|
|
31
31
|
* 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
|
|
32
32
|
*
|
|
33
33
|
* 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
|
|
34
|
-
*
|
|
34
|
+
* 冷却。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
|
|
35
35
|
* 流内 `code=4008`,三个号里两个如此,round-robin 下 2/3 请求直接失败
|
|
36
36
|
* (2026-09-08 实测)——有了这条通道,坏号被冷一次就退出轮转。
|
|
37
37
|
*
|
|
@@ -46,7 +46,7 @@ export type SupplierFactory = (env: SupplierEnv) => SupplierModule
|
|
|
46
46
|
/**
|
|
47
47
|
* 账户此刻的状态 —— 插件**解读**上游信号后的语义状态。
|
|
48
48
|
*
|
|
49
|
-
*
|
|
49
|
+
* 插件只报「现在怎么了」,不说「该怎么办」:冷却多久、要不要换号
|
|
50
50
|
* 都是核心的策略,见 `AccountState` 各值的处置表(docs/suppliers.md)。
|
|
51
51
|
*
|
|
52
52
|
* 天花板:目前是固定枚举。若将来某供应商需要更细的语义,再加值而不是放宽成
|
|
@@ -67,8 +67,18 @@ export type AccountState =
|
|
|
67
67
|
* 把它冷却掉(历史上正是这个坑)。
|
|
68
68
|
*/
|
|
69
69
|
| 'no_such_model'
|
|
70
|
+
/**
|
|
71
|
+
* 请求本身不合法,被上游拒(参数错、图片格式不支持、tool_call 配对断裂…)。
|
|
72
|
+
*
|
|
73
|
+
* **不是账号的错**:同一个请求对池里每个号都会失败,冷号只会让好号白白
|
|
74
|
+
* 失效,把一次「这条请求有问题」放大成「这个模型 30 秒内谁都别用」——
|
|
75
|
+
* 组合里两条腿同时被冷就是这个机制(2026-09-15 读图 11148 事故)。
|
|
76
|
+
* 核心据此**不惩罚账号**,直接跳出本供应商换下一个模型/供应商。
|
|
77
|
+
* 与 `no_such_model` 的区别:那个是「模型不归我」,这个是「请求我不认」。
|
|
78
|
+
*/
|
|
79
|
+
| 'bad_request'
|
|
70
80
|
|
|
71
|
-
/**
|
|
81
|
+
/** 面板展示的账号此刻状态(插件只报它观察到的部分,冷却由核心叠加)。 */
|
|
72
82
|
export interface SupplierAccountNow {
|
|
73
83
|
uid: string
|
|
74
84
|
nickname?: string
|
|
@@ -98,7 +108,7 @@ export type ChatOnceResult =
|
|
|
98
108
|
| { ok: true; stream: ReadableStream<Uint8Array> }
|
|
99
109
|
/** 非流式:核心写 JSON。 */
|
|
100
110
|
| { ok: true; status: number; body: string }
|
|
101
|
-
/**
|
|
111
|
+
/** 失败:核心据此做冷却/换号。 */
|
|
102
112
|
| { ok: false; state: AccountState; message: string }
|
|
103
113
|
|
|
104
114
|
/** 供应商模块 —— 契约(核心必须,差异化可选)。 */
|
|
@@ -120,7 +130,7 @@ export interface SupplierModule {
|
|
|
120
130
|
readonly apiKeyHint?: string
|
|
121
131
|
|
|
122
132
|
// ---- 核心(必须) ----
|
|
123
|
-
/**
|
|
133
|
+
/** 报账号「现在状态」。冷却/错误累计由核心叠加,插件不算这些。 */
|
|
124
134
|
status(): SupplierStatusNow
|
|
125
135
|
/** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
|
|
126
136
|
listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 状态码 → AccountState 公共映射测试。
|
|
3
|
+
*
|
|
4
|
+
* 存在的原因:这条映射曾在内置三家供应商里各写一份,于是「哪个错误算账号的错」
|
|
5
|
+
* 有三份实现——bad_request 加固差点只补上 codebuddy,三家内置仍是 4xx→unknown
|
|
6
|
+
* →瞬冷 30s(同型事故)。收敛成一处后,这一份测试同时守住三家。
|
|
7
|
+
*/
|
|
8
|
+
import { test } from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { stateFromHttpStatus } from './http-state.ts'
|
|
11
|
+
|
|
12
|
+
test('映射:账号侧错误(换号会好的)归各自的冷却状态', () => {
|
|
13
|
+
assert.equal(stateFromHttpStatus(429), 'rate_limit', '限流 = 换号有用')
|
|
14
|
+
assert.equal(stateFromHttpStatus(401), 'session_dead', '凭证失效')
|
|
15
|
+
assert.equal(stateFromHttpStatus(403), 'session_dead', '403 分不清死活与风控,按连接级冷却')
|
|
16
|
+
assert.equal(stateFromHttpStatus(402), 'quota', '要钱/额度不足')
|
|
17
|
+
assert.equal(stateFromHttpStatus(404), 'unavailable', '上游没有这个端点/服务下线')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('映射:请求侧 4xx 归 bad_request —— 同一个请求对每个号都一样失败,不该冷号', () => {
|
|
21
|
+
for (const status of [400, 405, 409, 413, 422, 499]) {
|
|
22
|
+
assert.equal(stateFromHttpStatus(status), 'bad_request', `${status} 是请求形态问题,不是账号的错`)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('映射:5xx 与异常码仍归 unknown(上游故障侧,瞬冷换号是对的)', () => {
|
|
27
|
+
for (const status of [500, 502, 503, 504, 599]) {
|
|
28
|
+
assert.equal(stateFromHttpStatus(status), 'unknown', `${status} 是上游故障`)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('映射:绝不返回 ok —— 这个函数只在非 2xx 分支被调用', () => {
|
|
33
|
+
for (const status of [400, 401, 402, 403, 404, 429, 500]) {
|
|
34
|
+
assert.notEqual(stateFromHttpStatus(status), 'ok')
|
|
35
|
+
}
|
|
36
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP 状态码 → AccountState 的公共映射(内置三家供应商共用)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么收敛成一处:opencode / openrouter / nvidia 本来各写一份逐字相同的
|
|
5
|
+
* 三元表达式,于是「哪个状态算谁的错」这件事有三份实现,改一处就漏两处
|
|
6
|
+
* (bad_request 就是这么差点只补了 codebuddy)。
|
|
7
|
+
*
|
|
8
|
+
* 关键判据:**这个错误换一个账号会不会好?**
|
|
9
|
+
* - 会好(限流/额度/凭证)→ 是账号的错,交给核心冷却/换号
|
|
10
|
+
* - 不会好(请求参数、模型不存在)→ 不是账号的错,绝不能冷号
|
|
11
|
+
* 否则一次坏请求会把整个池冷掉(2026-09-15 codebuddy 读图事故的同型:
|
|
12
|
+
* 组合两条腿同时被冷,之后连正常文本请求也全灭 503)
|
|
13
|
+
*/
|
|
14
|
+
import type { AccountState } from './contract.ts'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 非 2xx 上游响应 → 语义状态。
|
|
18
|
+
*
|
|
19
|
+
* 4xx 里除了「限流/凭证/不存在」这三种明确归属账号的,剩下的都是**请求本身
|
|
20
|
+
* 有问题**(400 参数错、413 太大、422 语义错…)——同一个请求对池里每个号结果
|
|
21
|
+
* 都相同,归 `bad_request`(核心不惩罚账号,直接换下一个模型)。
|
|
22
|
+
* 5xx / 说不清的仍归 `unknown`(上游故障侧,瞬冷换号是对的)。
|
|
23
|
+
*/
|
|
24
|
+
export function stateFromHttpStatus(status: number): AccountState {
|
|
25
|
+
if (status === 429) return 'rate_limit'
|
|
26
|
+
if (status === 401 || status === 403) return 'session_dead'
|
|
27
|
+
if (status === 404) return 'unavailable'
|
|
28
|
+
if (status === 402) return 'quota'
|
|
29
|
+
if (status >= 400 && status < 500) return 'bad_request'
|
|
30
|
+
return 'unknown'
|
|
31
|
+
}
|
package/src/suppliers/loader.ts
CHANGED
|
@@ -124,7 +124,7 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
|
|
|
124
124
|
if (isFn((instance as unknown as Record<string, unknown>)[key])) capabilities.add(key)
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
//
|
|
127
|
+
// 账号池由核心持有(选号/冷却/错误累计),插件不参与。
|
|
128
128
|
// 挂在 supplier 上:status() 叠加状态与 Router 的 chat 循环共用同一实例。
|
|
129
129
|
// 冷却键带 supplierId(跨供应商同名模型不串),故构造时注入本供应商 id。
|
|
130
130
|
const pool = new AccountPool(instance.id)
|
|
@@ -18,6 +18,7 @@ import { join } from 'node:path'
|
|
|
18
18
|
import type { ChatRequest, ModelInfo } from '../../router/types.ts'
|
|
19
19
|
import type { AccountState, ChatOnceResult, SupplierStatusNow } from '../contract.ts'
|
|
20
20
|
import type { SupplierEnv, SupplierModule } from '../contract.ts'
|
|
21
|
+
import { stateFromHttpStatus } from '../http-state.ts'
|
|
21
22
|
|
|
22
23
|
export const id = 'nvidia'
|
|
23
24
|
export const name = 'NVIDIA NIM'
|
|
@@ -287,12 +288,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
287
288
|
if (upstream.status < 200 || upstream.status >= 300) {
|
|
288
289
|
const text = await upstream.text().catch(() => '')
|
|
289
290
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
290
|
-
|
|
291
|
-
upstream.status === 429 ? 'rate_limit'
|
|
292
|
-
: upstream.status === 401 || upstream.status === 403 ? 'session_dead'
|
|
293
|
-
: upstream.status === 404 ? 'unavailable'
|
|
294
|
-
: 'unknown'
|
|
295
|
-
return { ok: false, state, message: msg }
|
|
291
|
+
return { ok: false, state: stateFromHttpStatus(upstream.status), message: msg }
|
|
296
292
|
}
|
|
297
293
|
|
|
298
294
|
// 流式:上游已是 OpenAI SSE,原样交回核心写
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* 面板只显示模型卡片(连接池、签到由能力检测自动隐藏)。
|
|
10
10
|
*/
|
|
11
11
|
import type { ChatRequest, ModelInfo } from '../../router/types.ts'
|
|
12
|
-
import type {
|
|
12
|
+
import type { ChatOnceResult, SupplierEnv, SupplierModule, SupplierStatusNow } from '../contract.ts'
|
|
13
|
+
import { stateFromHttpStatus } from '../http-state.ts'
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
export const id = 'opencode'
|
|
@@ -125,9 +126,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
125
126
|
if (upstream.status < 200 || upstream.status >= 300) {
|
|
126
127
|
const text = await upstream.text().catch(() => '')
|
|
127
128
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 200)}`
|
|
128
|
-
|
|
129
|
-
upstream.status === 429 ? 'rate_limit' : upstream.status === 404 ? 'unavailable' : 'unknown'
|
|
130
|
-
return { ok: false, state, message: msg }
|
|
129
|
+
return { ok: false, state: stateFromHttpStatus(upstream.status), message: msg }
|
|
131
130
|
}
|
|
132
131
|
|
|
133
132
|
// 流式:上游已是 OpenAI SSE,原样交回核心(核心负责写响应)
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import type { ChatRequest, ModelInfo } from '../../router/types.ts'
|
|
13
13
|
import type { AccountState, ChatOnceResult, SupplierStatusNow } from '../contract.ts'
|
|
14
14
|
import type { SupplierEnv, SupplierModule } from '../contract.ts'
|
|
15
|
+
import { stateFromHttpStatus } from '../http-state.ts'
|
|
15
16
|
|
|
16
17
|
export const id = 'openrouter'
|
|
17
18
|
export const name = 'OpenRouter'
|
|
@@ -202,13 +203,8 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
202
203
|
if (upstream.status < 200 || upstream.status >= 300) {
|
|
203
204
|
const text = await upstream.text().catch(() => '')
|
|
204
205
|
const msg = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
205
|
-
// 401/403 = 这个 key 本身不可用;429 =
|
|
206
|
-
|
|
207
|
-
upstream.status === 429 ? 'rate_limit'
|
|
208
|
-
: upstream.status === 401 || upstream.status === 403 ? 'session_dead'
|
|
209
|
-
: upstream.status === 404 ? 'unavailable'
|
|
210
|
-
: 'unknown'
|
|
211
|
-
return { ok: false, state, message: msg }
|
|
206
|
+
// 401/403 = 这个 key 本身不可用;429 = 限流;其余见 stateFromHttpStatus
|
|
207
|
+
return { ok: false, state: stateFromHttpStatus(upstream.status), message: msg }
|
|
212
208
|
}
|
|
213
209
|
|
|
214
210
|
// 流式:上游已是 OpenAI SSE,原样交回核心写
|
|
@@ -30,7 +30,7 @@ function fakeRes(): { status: number; body: unknown; res: never } {
|
|
|
30
30
|
return { get status() { return out.status }, get body() { return out.body }, res: res as never }
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
type FakeAccount = { uid: string;
|
|
33
|
+
type FakeAccount = { uid: string; credits?: number }
|
|
34
34
|
|
|
35
35
|
/** 造一个只带 checkinNow 的假供应商:status() 返回账号列表,checkinNow 按 uid 应答。
|
|
36
36
|
* accounts 可传函数(每次 status() 重新求值,用来模拟插件后台异步刷积分)。 */
|
|
@@ -50,7 +50,6 @@ function fixture(
|
|
|
50
50
|
nickname: a.uid,
|
|
51
51
|
credits: a.credits ?? 0,
|
|
52
52
|
cooling: false,
|
|
53
|
-
disabled: a.disabled ?? false,
|
|
54
53
|
err_count: 0,
|
|
55
54
|
})),
|
|
56
55
|
}),
|
|
@@ -109,10 +108,10 @@ test('签到:遍历所有链接,逐个调 checkinNow(uid)', async () => {
|
|
|
109
108
|
assert.deepEqual(body.results?.map((r) => r.uid), ['a', 'b'])
|
|
110
109
|
})
|
|
111
110
|
|
|
112
|
-
test('
|
|
111
|
+
test('签到:插件自标 disabled 的链接照样签(筛不筛由插件自己判定,核心不筛)', async () => {
|
|
113
112
|
const seen: string[] = []
|
|
114
113
|
const { body } = await postCheckin(
|
|
115
|
-
fixture([{ uid: 'a'
|
|
114
|
+
fixture([{ uid: 'a' }, { uid: 'b' }], async (uid) => {
|
|
116
115
|
seen.push(uid)
|
|
117
116
|
// 插件(如 traework)自己会对禁用链接返回 status:'disabled'
|
|
118
117
|
return uid === 'a' ? { ok: false, status: 'disabled', message: '链接已禁用' } : { ok: true, status: 'ok' }
|
|
@@ -66,7 +66,7 @@ function cap(set: Set<string>, key: string): boolean {
|
|
|
66
66
|
|
|
67
67
|
/** 账号状态指纹(积分 + 健康),用于判断刷新是否落地。 */
|
|
68
68
|
function fingerprint(accounts: SupplierStatus['accounts']): string {
|
|
69
|
-
return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}
|
|
69
|
+
return accounts.map((a) => `${a.uid}:${a.credits}:${a.cooling}`).join('|')
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|