dsh-router-core 0.1.0 → 0.2.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/README.md +36 -12
- package/lib/client-registry.js +92 -66
- package/lib/client.js +92 -66
- package/lib/index.js +567 -204
- package/lib/types/router/account-pool.d.ts +41 -0
- package/lib/types/router/index.d.ts +43 -4
- package/lib/types/router/types.d.ts +12 -10
- package/lib/types/supplier-config.d.ts +0 -3
- package/lib/types/suppliers/contract.d.ts +70 -21
- package/lib/types/suppliers/registry.d.ts +4 -3
- package/package.json +2 -1
- package/src/client/CombosTab.tsx +7 -5
- package/src/client/SupplierDetail.tsx +58 -34
- package/src/client/router.css +9 -0
- package/src/index.ts +2 -2
- package/src/router/account-pool.test.ts +120 -0
- package/src/router/account-pool.ts +161 -0
- package/src/router/index.ts +233 -19
- package/src/router/models-cache.test.ts +186 -0
- package/src/router/types.ts +12 -7
- package/src/supplier-config.ts +1 -12
- package/src/suppliers/contract.ts +71 -8
- package/src/suppliers/loader.ts +19 -12
- package/src/suppliers/nvidia/plugin.ts +161 -118
- package/src/suppliers/opencode/plugin.ts +47 -94
- package/src/suppliers/openrouter/plugin.ts +67 -121
- package/src/suppliers/registry.test.ts +194 -0
- package/src/suppliers/registry.ts +72 -57
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号池(核心策略)测试 —— 选号/冷却/禁用/错误累计/遍历回退。
|
|
3
|
+
* 用 node --test 跑(Node 原生 TS 剥离,零新依赖)。
|
|
4
|
+
*/
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { AccountPool } from './account-pool.ts'
|
|
8
|
+
import type { SupplierAccountNow } from '../suppliers/contract.ts'
|
|
9
|
+
|
|
10
|
+
const accs = (uids: string[]): SupplierAccountNow[] => uids.map((uid) => ({ uid, credits: 0, state: 'ok' }))
|
|
11
|
+
|
|
12
|
+
test('选号:无冷却时按 poolOrder 优先,未配置的追加在后', () => {
|
|
13
|
+
const p = new AccountPool()
|
|
14
|
+
const list = accs(['a', 'b', 'c'])
|
|
15
|
+
assert.equal(p.pick(list, ['c', 'a'], 'fallback'), 'c')
|
|
16
|
+
assert.equal(p.pick(list, ['b'], 'fallback'), 'b')
|
|
17
|
+
// 未配置顺序的按自然顺序补上
|
|
18
|
+
assert.equal(p.pick(list, [], 'fallback'), 'a')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('选号:冷却中的号跳过,全冷却返回 undefined', () => {
|
|
22
|
+
const p = new AccountPool()
|
|
23
|
+
const list = accs(['a', 'b'])
|
|
24
|
+
p.noteFailure('a', 'rate_limit', '429')
|
|
25
|
+
assert.equal(p.pick(list, [], 'fallback'), 'b')
|
|
26
|
+
p.noteFailure('b', 'rate_limit', '429')
|
|
27
|
+
assert.equal(p.pick(list, [], 'fallback'), undefined)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('选号:round-robin 在健康号间轮转', () => {
|
|
31
|
+
const p = new AccountPool()
|
|
32
|
+
const list = accs(['a', 'b'])
|
|
33
|
+
assert.equal(p.pick(list, [], 'round-robin'), 'a')
|
|
34
|
+
assert.equal(p.pick(list, [], 'round-robin'), 'b')
|
|
35
|
+
assert.equal(p.pick(list, [], 'round-robin'), 'a')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('处置:session_dead 永久禁用(不再被选中)', () => {
|
|
39
|
+
const p = new AccountPool()
|
|
40
|
+
const list = accs(['a'])
|
|
41
|
+
p.noteFailure('a', 'session_dead', '凭证失效')
|
|
42
|
+
assert.equal(p.pick(list, [], 'fallback'), undefined)
|
|
43
|
+
assert.equal(p.decorate(list)[0]?.disabled, true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('处置:连续错误攒够阈值才冷却(前两次仍可服务)', () => {
|
|
47
|
+
const p = new AccountPool()
|
|
48
|
+
const list = accs(['a'])
|
|
49
|
+
p.noteFailure('a', 'unknown', 'e1')
|
|
50
|
+
assert.equal(p.pick(list, [], 'fallback'), 'a')
|
|
51
|
+
p.noteFailure('a', 'unknown', 'e2')
|
|
52
|
+
assert.equal(p.pick(list, [], 'fallback'), 'a')
|
|
53
|
+
// 第三次触发冷却
|
|
54
|
+
p.noteFailure('a', 'unknown', 'e3')
|
|
55
|
+
assert.equal(p.pick(list, [], 'fallback'), undefined)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('处置:成功清零连续错误,避免之前累积的错误把好号冷却掉', () => {
|
|
59
|
+
const p = new AccountPool()
|
|
60
|
+
const list = accs(['a'])
|
|
61
|
+
p.noteFailure('a', 'unknown', 'e1')
|
|
62
|
+
p.noteFailure('a', 'unknown', 'e2')
|
|
63
|
+
p.noteSuccess('a')
|
|
64
|
+
p.noteFailure('a', 'unknown', 'e3')
|
|
65
|
+
assert.equal(p.pick(list, [], 'fallback'), 'a')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('decorate:把冷却/禁用/错误累计叠加到插件报的「现在状态」上', () => {
|
|
69
|
+
const p = new AccountPool()
|
|
70
|
+
const list: SupplierAccountNow[] = [
|
|
71
|
+
{ uid: 'ok', credits: 5, state: 'ok' },
|
|
72
|
+
{ uid: 'cool', credits: 5, state: 'ok' },
|
|
73
|
+
{ uid: 'dead', credits: 5, state: 'ok' },
|
|
74
|
+
]
|
|
75
|
+
p.noteFailure('cool', 'rate_limit', '429')
|
|
76
|
+
p.noteFailure('dead', 'session_dead', '凭证失效')
|
|
77
|
+
const out = p.decorate(list)
|
|
78
|
+
const by = new Map(out.map((a) => [a.uid, a]))
|
|
79
|
+
assert.equal(by.get('ok')?.cooling, false)
|
|
80
|
+
assert.equal(by.get('ok')?.disabled, false)
|
|
81
|
+
assert.equal(by.get('cool')?.cooling, true)
|
|
82
|
+
assert.equal(by.get('cool')?.until !== undefined, true)
|
|
83
|
+
assert.equal(by.get('cool')?.reason, '429')
|
|
84
|
+
assert.equal(by.get('dead')?.disabled, true)
|
|
85
|
+
// 插件报的字段要原样透出(不能被 decorate 吃掉)
|
|
86
|
+
assert.equal(by.get('ok')?.credits, 5)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('no_such_model 不惩罚账号:不冷却、不计数', () => {
|
|
90
|
+
const p = new AccountPool()
|
|
91
|
+
const list = accs(['a'])
|
|
92
|
+
// 攒够阈值次也不该冷却——模型不属于本供应商不是账号的错
|
|
93
|
+
for (let i = 0; i < 10; i++) p.noteFailure('a', 'no_such_model', 'unknown model "x"')
|
|
94
|
+
assert.equal(p.pick(list, [], 'fallback'), 'a')
|
|
95
|
+
const out = p.decorate(list)[0]
|
|
96
|
+
assert.equal(out?.cooling, false)
|
|
97
|
+
assert.equal(out?.disabled, false)
|
|
98
|
+
assert.equal(out?.err_count, 0)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('状态表:各 AccountState 的处置符合预期', () => {
|
|
102
|
+
const p = new AccountPool()
|
|
103
|
+
const list = accs(['rate', 'quota', 'dead', 'unavail'])
|
|
104
|
+
p.noteFailure('rate', 'rate_limit', 'x')
|
|
105
|
+
p.noteFailure('quota', 'quota', 'x')
|
|
106
|
+
p.noteFailure('dead', 'session_dead', 'x')
|
|
107
|
+
p.noteFailure('unavail', 'unavailable', 'x')
|
|
108
|
+
// rate_limit/quota 立刻冷却;session_dead 禁用;unavailable 只计错误不冷却
|
|
109
|
+
assert.equal(p.pick(list, [], 'fallback'), 'unavail')
|
|
110
|
+
const by = new Map(p.decorate(list).map((a) => [a.uid, a]))
|
|
111
|
+
assert.equal(by.get('rate')?.cooling, true)
|
|
112
|
+
assert.equal(by.get('quota')?.cooling, true)
|
|
113
|
+
assert.equal(by.get('dead')?.disabled, true)
|
|
114
|
+
assert.equal(by.get('unavail')?.cooling, false)
|
|
115
|
+
assert.equal(by.get('unavail')?.err_count, 1)
|
|
116
|
+
// 攒够阈值后才冷却(与 unknown/transport 同一条路径)
|
|
117
|
+
p.noteFailure('unavail', 'unavailable', 'x')
|
|
118
|
+
p.noteFailure('unavail', 'unavailable', 'x')
|
|
119
|
+
assert.equal(p.pick(list, [], 'fallback'), undefined)
|
|
120
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 账号池 —— dsh-router 核心策略:选号、冷却、禁用、错误累计、遍历回退。
|
|
3
|
+
*
|
|
4
|
+
* 供应商插件只管「对单个账号调通上游」,账号层面的策略全在这里:
|
|
5
|
+
* - 按 poolOrder 排序(未配置的按插件报告顺序追加)
|
|
6
|
+
* - 按 poolStrategy 选号:fallback = 取第一个健康;round-robin = 轮转健康号
|
|
7
|
+
* - 失败按插件报的 AccountState 处置(冷却/禁用/错误累计),攒够阈值自动冷却
|
|
8
|
+
*
|
|
9
|
+
* 天花板:冷却/禁用/错误计数都是**内存态**,重启归零(与旧插件行为一致,
|
|
10
|
+
* 插件的持久化只存凭证与积分,不存健康)。要跨重启保留得落盘,届时加一个
|
|
11
|
+
* stateFile 即可,接口不用变。
|
|
12
|
+
*/
|
|
13
|
+
import type { AccountState, SupplierAccountNow } from '../suppliers/contract.ts'
|
|
14
|
+
|
|
15
|
+
/** 各 AccountState 的处置:冷却时长 / 是否禁用 / 是否计入连续错误。 */
|
|
16
|
+
interface Rule {
|
|
17
|
+
/** 冷却时长(ms);0 = 不冷却。 */
|
|
18
|
+
cooldownMs: number
|
|
19
|
+
/** 是否永久禁用(需重新登录才能恢复)。 */
|
|
20
|
+
disable: boolean
|
|
21
|
+
/** 是否计入连续错误(攒够阈值自动冷却)。 */
|
|
22
|
+
counts: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MINUTE = 60_000
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 状态 → 处置表。**核心策略就在这一张表里**,插件只报状态不做决策。
|
|
29
|
+
* 时长参考 9router 的 errorConfig(限流类短冷却、额度类长冷却),
|
|
30
|
+
* 但 9router 按文本/状态匹配,这里按插件解读后的语义状态匹配,更准。
|
|
31
|
+
*/
|
|
32
|
+
const RULES: Record<AccountState, Rule> = {
|
|
33
|
+
ok: { cooldownMs: 0, disable: false, counts: false },
|
|
34
|
+
rate_limit: { cooldownMs: 1 * MINUTE, disable: false, counts: false },
|
|
35
|
+
quota: { cooldownMs: 10 * MINUTE, disable: false, counts: false },
|
|
36
|
+
session_dead: { cooldownMs: 0, disable: true, counts: false },
|
|
37
|
+
// 404/服务下线:只计错误不立刻冷却——偶发的自己恢复,真挂了攒够阈值再冷却
|
|
38
|
+
unavailable: { cooldownMs: 0, disable: false, counts: true },
|
|
39
|
+
transport: { cooldownMs: 0, disable: false, counts: true },
|
|
40
|
+
unknown: { cooldownMs: 0, disable: false, counts: true },
|
|
41
|
+
// 模型不属于本供应商:不是账号的错,不冷却也不计数(核心据此换下一个供应商)
|
|
42
|
+
no_such_model: { cooldownMs: 0, disable: false, counts: false },
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 连续错误攒够这么多次就自动冷却(防止坏号被反复重试)。 */
|
|
46
|
+
const ERR_THRESHOLD = 3
|
|
47
|
+
/** 攒够后的冷却时长。 */
|
|
48
|
+
const ERR_COOLDOWN_MS = 10 * MINUTE
|
|
49
|
+
|
|
50
|
+
interface Entry {
|
|
51
|
+
until: number
|
|
52
|
+
disabled: boolean
|
|
53
|
+
errCount: number
|
|
54
|
+
reason: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class AccountPool {
|
|
58
|
+
private entries = new Map<string, Entry>()
|
|
59
|
+
private rrCursor = 0
|
|
60
|
+
|
|
61
|
+
private entry(uid: string): Entry {
|
|
62
|
+
let e = this.entries.get(uid)
|
|
63
|
+
if (!e) {
|
|
64
|
+
e = { until: 0, disabled: false, errCount: 0, reason: '' }
|
|
65
|
+
this.entries.set(uid, e)
|
|
66
|
+
}
|
|
67
|
+
return e
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 是否可服务:未禁用且不在冷却中。 */
|
|
71
|
+
private healthy(uid: string, now: number): boolean {
|
|
72
|
+
const e = this.entries.get(uid)
|
|
73
|
+
if (!e) return true
|
|
74
|
+
if (e.disabled) return false
|
|
75
|
+
return e.until <= now
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 按策略选一个健康账号。
|
|
80
|
+
* @param accounts 插件报告的「现在状态」(顺序即插件的自然顺序)
|
|
81
|
+
* @param poolOrder 用户在面板拖出来的顺序(核心管)
|
|
82
|
+
* @returns 选中的 uid;无健康账号返回 undefined
|
|
83
|
+
*/
|
|
84
|
+
pick(accounts: SupplierAccountNow[], poolOrder: string[], strategy: string): string | undefined {
|
|
85
|
+
const now = Date.now()
|
|
86
|
+
const byUid = new Map(accounts.map((a) => [a.uid, a]))
|
|
87
|
+
// 池顺序优先,未配置的按插件自然顺序追加
|
|
88
|
+
const ordered = [
|
|
89
|
+
...poolOrder.filter((uid) => byUid.has(uid)),
|
|
90
|
+
...accounts.map((a) => a.uid).filter((uid) => !poolOrder.includes(uid)),
|
|
91
|
+
]
|
|
92
|
+
const healthy = ordered.filter((uid) => this.healthy(uid, now))
|
|
93
|
+
if (healthy.length === 0) return undefined
|
|
94
|
+
if (strategy !== 'round-robin') return healthy[0]
|
|
95
|
+
const uid = healthy[this.rrCursor % healthy.length]
|
|
96
|
+
this.rrCursor = (this.rrCursor + 1) % healthy.length
|
|
97
|
+
return uid
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 记录一次失败:按状态处置(冷却/禁用/错误累计)。 */
|
|
101
|
+
noteFailure(uid: string, state: AccountState, message: string): void {
|
|
102
|
+
const rule = RULES[state]
|
|
103
|
+
const e = this.entry(uid)
|
|
104
|
+
if (rule.disable) {
|
|
105
|
+
e.disabled = true
|
|
106
|
+
e.reason = message
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (rule.cooldownMs > 0) {
|
|
110
|
+
e.until = Math.max(e.until, Date.now() + rule.cooldownMs)
|
|
111
|
+
e.reason = message
|
|
112
|
+
e.errCount = 0
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (rule.counts) {
|
|
116
|
+
e.errCount += 1
|
|
117
|
+
e.reason = message
|
|
118
|
+
if (e.errCount >= ERR_THRESHOLD) {
|
|
119
|
+
e.until = Date.now() + ERR_COOLDOWN_MS
|
|
120
|
+
e.errCount = 0
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 记录一次成功:清零连续错误。 */
|
|
126
|
+
noteSuccess(uid: string): void {
|
|
127
|
+
const e = this.entries.get(uid)
|
|
128
|
+
if (e) e.errCount = 0
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 手动冷却(面板/管理用途)。 */
|
|
132
|
+
cooldown(uid: string, ms: number, reason: string): void {
|
|
133
|
+
const e = this.entry(uid)
|
|
134
|
+
e.until = Date.now() + ms
|
|
135
|
+
e.reason = reason
|
|
136
|
+
e.errCount = 0
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 把核心的冷却/禁用/错误累计叠加到插件报告的「现在状态」上,产出面板态。 */
|
|
140
|
+
decorate(accounts: SupplierAccountNow[]): Array<SupplierAccountNow & {
|
|
141
|
+
cooling: boolean
|
|
142
|
+
disabled: boolean
|
|
143
|
+
err_count: number
|
|
144
|
+
until?: string
|
|
145
|
+
reason?: string
|
|
146
|
+
}> {
|
|
147
|
+
const now = Date.now()
|
|
148
|
+
return accounts.map((a) => {
|
|
149
|
+
const e = this.entries.get(a.uid)
|
|
150
|
+
const cooling = e !== undefined && !e.disabled && e.until > now
|
|
151
|
+
return {
|
|
152
|
+
...a,
|
|
153
|
+
cooling,
|
|
154
|
+
disabled: e?.disabled ?? false,
|
|
155
|
+
err_count: e?.errCount ?? 0,
|
|
156
|
+
until: cooling && e ? new Date(e.until).toISOString() : undefined,
|
|
157
|
+
reason: e?.reason !== '' ? e?.reason : undefined,
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/router/index.ts
CHANGED
|
@@ -7,6 +7,12 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
|
7
7
|
import { dirname, join } from 'node:path'
|
|
8
8
|
import type { ServerResponse } from 'node:http'
|
|
9
9
|
import type { Supplier, ChatRequest, ModelInfo, ModelWithEnabled, SupplierStatus, Combo } from './types.ts'
|
|
10
|
+
import type { ChatOnceResult } from '../suppliers/contract.ts'
|
|
11
|
+
import { AccountPool } from './account-pool.ts'
|
|
12
|
+
import { SupplierConfigStore } from '../supplier-config.ts'
|
|
13
|
+
|
|
14
|
+
/** 模型列表缓存有效期。 */
|
|
15
|
+
const MODELS_TTL_MS = 60 * 1000
|
|
10
16
|
|
|
11
17
|
function openAIError(code: string, msg: string): Record<string, unknown> {
|
|
12
18
|
return { error: { message: msg, type: 'api_error', code } }
|
|
@@ -20,6 +26,88 @@ function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
|
20
26
|
res.end(JSON.stringify(body))
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
/** 丢弃响应的假 ServerResponse(测试模型用:记录状态+内容,用于判定成败)。 */
|
|
30
|
+
function sinkRes(): ServerResponse & { status(): number; body(): string } {
|
|
31
|
+
let status = 200
|
|
32
|
+
let text = ''
|
|
33
|
+
const self = {
|
|
34
|
+
headersSent: false,
|
|
35
|
+
writableEnded: false,
|
|
36
|
+
writeHead: (code: number): unknown => {
|
|
37
|
+
status = code
|
|
38
|
+
return self
|
|
39
|
+
},
|
|
40
|
+
write: (chunk?: unknown): boolean => {
|
|
41
|
+
if (chunk !== undefined) text += String(chunk)
|
|
42
|
+
return true
|
|
43
|
+
},
|
|
44
|
+
end: (chunk?: unknown): unknown => {
|
|
45
|
+
if (chunk !== undefined) text += String(chunk)
|
|
46
|
+
return self
|
|
47
|
+
},
|
|
48
|
+
flushHeaders: (): void => {},
|
|
49
|
+
status: (): number => status,
|
|
50
|
+
body: (): string => text,
|
|
51
|
+
}
|
|
52
|
+
return self as unknown as ServerResponse & { status(): number; body(): string }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 把 chatOnce 的结果写进 res(核心独占响应写入权)。
|
|
57
|
+
* 流式:SSE 头 + pipe 上游已转好的 OpenAI SSE;非流式:原样写 JSON。
|
|
58
|
+
*/
|
|
59
|
+
async function writeChatResult(res: ServerResponse, r: ChatOnceResult): Promise<void> {
|
|
60
|
+
if (!r.ok) return // 失败不该走到这里(核心先判 ok 才写)
|
|
61
|
+
if (!('stream' in r)) {
|
|
62
|
+
res.writeHead(r.status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
63
|
+
res.end(r.body)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
res.writeHead(200, {
|
|
67
|
+
'Content-Type': 'text/event-stream',
|
|
68
|
+
'Cache-Control': 'no-cache',
|
|
69
|
+
Connection: 'keep-alive',
|
|
70
|
+
'X-Accel-Buffering': 'no',
|
|
71
|
+
})
|
|
72
|
+
if (typeof (res as { flushHeaders?: () => void }).flushHeaders === 'function') {
|
|
73
|
+
;(res as { flushHeaders: () => void }).flushHeaders()
|
|
74
|
+
}
|
|
75
|
+
// 流中途出错(客户端断开/上游断流)也要结束响应,否则连接悬挂
|
|
76
|
+
const onError = (): void => {
|
|
77
|
+
res.destroy()
|
|
78
|
+
}
|
|
79
|
+
res.once('error', onError)
|
|
80
|
+
try {
|
|
81
|
+
await r.stream.pipeTo(
|
|
82
|
+
new WritableStream<Uint8Array>({
|
|
83
|
+
write: (chunk) =>
|
|
84
|
+
new Promise<void>((resolve) => {
|
|
85
|
+
if (res.write(chunk)) resolve()
|
|
86
|
+
else res.once('drain', resolve)
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
} catch {
|
|
91
|
+
// 尽力而为:流断了也要收尾
|
|
92
|
+
} finally {
|
|
93
|
+
res.removeListener('error', onError)
|
|
94
|
+
res.end()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 从上游响应体提取错误信息(OpenAI {error:{message}} 或 {code,msg}/{message})。 */
|
|
99
|
+
function extractUpstreamError(body: string): string {
|
|
100
|
+
try {
|
|
101
|
+
const j = JSON.parse(body) as { error?: { message?: string }; message?: string; msg?: string; code?: number }
|
|
102
|
+
if (typeof j.error?.message === 'string') return j.error.message
|
|
103
|
+
if (typeof j.message === 'string') return j.message
|
|
104
|
+
if (typeof j.msg === 'string') return j.msg
|
|
105
|
+
} catch {
|
|
106
|
+
// 非 JSON(可能是 SSE 文本)→ 走下面截断
|
|
107
|
+
}
|
|
108
|
+
return body.slice(0, 200)
|
|
109
|
+
}
|
|
110
|
+
|
|
23
111
|
/** 路由器。 */
|
|
24
112
|
export class Router {
|
|
25
113
|
private suppliers: Supplier[] = []
|
|
@@ -27,9 +115,14 @@ export class Router {
|
|
|
27
115
|
private customCombos: Combo[] = []
|
|
28
116
|
/** round-robin 轮转游标(按组合 id 记忆)。 */
|
|
29
117
|
private rrCursors = new Map<string, number>()
|
|
118
|
+
/** 通用供应商配置(连接池顺序/策略、模型启用、别名)。 */
|
|
119
|
+
private store: SupplierConfigStore
|
|
120
|
+
/** 模型列表缓存(supplierId → 模型 + 拉取时间)。插件只管拉,不缓存。 */
|
|
121
|
+
private modelsCache = new Map<string, { models: ModelWithEnabled[]; fetchedAt: number }>()
|
|
30
122
|
|
|
31
|
-
constructor(stateFile = '') {
|
|
123
|
+
constructor(stateFile = '', store?: SupplierConfigStore) {
|
|
32
124
|
this.combosFp = stateFile ? join(dirname(stateFile), 'combos.json') : ''
|
|
125
|
+
this.store = store ?? new SupplierConfigStore(stateFile)
|
|
33
126
|
this.loadCombos()
|
|
34
127
|
}
|
|
35
128
|
|
|
@@ -50,9 +143,11 @@ export class Router {
|
|
|
50
143
|
.map((s) => s.supplier)
|
|
51
144
|
.filter((m) => typeof m === 'string' && m !== '')
|
|
52
145
|
: []
|
|
53
|
-
// 兼容旧版存的全名 alias/id
|
|
146
|
+
// 兼容旧版存的全名 alias/id:剥掉第一段前缀存裸 id(前缀随供应商动态变)。
|
|
147
|
+
// 用 indexOf 而非 lastIndexOf:模型 id 本身可含斜杠(如 nvidia 的
|
|
148
|
+
// deepseek-ai/xxx),剥最后一段会把命名空间吃掉。
|
|
54
149
|
const models = raw.map((m) => {
|
|
55
|
-
const slash = m.
|
|
150
|
+
const slash = m.indexOf('/')
|
|
56
151
|
return slash >= 0 ? m.slice(slash + 1) : m
|
|
57
152
|
})
|
|
58
153
|
return {
|
|
@@ -93,6 +188,7 @@ export class Router {
|
|
|
93
188
|
const i = this.suppliers.findIndex((s) => s.id === id)
|
|
94
189
|
if (i < 0) return false
|
|
95
190
|
const [s] = this.suppliers.splice(i, 1)
|
|
191
|
+
this.modelsCache.delete(id)
|
|
96
192
|
s?.dispose()
|
|
97
193
|
return true
|
|
98
194
|
}
|
|
@@ -142,18 +238,61 @@ export class Router {
|
|
|
142
238
|
return [...this.customCombos]
|
|
143
239
|
}
|
|
144
240
|
|
|
145
|
-
/**
|
|
241
|
+
/**
|
|
242
|
+
* 取某供应商的模型(核心统一缓存)。
|
|
243
|
+
*
|
|
244
|
+
* 模型列表只在这里缓存一次:`/suppliers/:id/models`(供应商详情)和
|
|
245
|
+
* `/combos`(组合加模型)共用。组合面板**不主动打上游**——它只是读缓存,
|
|
246
|
+
* 冷启动(缓存还没建)时才拉一次;真正刷新由详情页打开或「获取模型」
|
|
247
|
+
* 按钮(force)触发。
|
|
248
|
+
*
|
|
249
|
+
* 天花板:TTL 60s 是拍的。若上游模型列表变更很频繁,可调小;要彻底实时
|
|
250
|
+
* 就得让供应商暴露 etag/版本号,目前没有这个需求。
|
|
251
|
+
*/
|
|
252
|
+
async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
|
|
253
|
+
const s = this.suppliers.find((x) => x.id === supplierId)
|
|
254
|
+
if (s === undefined) return []
|
|
255
|
+
const hit = this.modelsCache.get(supplierId)
|
|
256
|
+
if (!force && hit !== undefined && Date.now() - hit.fetchedAt < MODELS_TTL_MS) return hit.models
|
|
257
|
+
const cfg = this.store.get(supplierId)
|
|
258
|
+
const custom = new Set(cfg.custom)
|
|
259
|
+
const disabled = new Set(cfg.disabled)
|
|
260
|
+
const list = await Promise.resolve(s.listModels())
|
|
261
|
+
const models: ModelWithEnabled[] = list.map((mm) => ({
|
|
262
|
+
...mm,
|
|
263
|
+
enabled: !disabled.has(mm.id),
|
|
264
|
+
custom: custom.has(mm.id) ? true : undefined,
|
|
265
|
+
}))
|
|
266
|
+
// 自定义模型(listModels 之外的)并入显示
|
|
267
|
+
const seen = new Set(models.map((mm) => mm.id))
|
|
268
|
+
for (const id of custom) {
|
|
269
|
+
if (!seen.has(id)) {
|
|
270
|
+
seen.add(id)
|
|
271
|
+
models.push({ id, enabled: !disabled.has(id), custom: true })
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
this.modelsCache.set(supplierId, { models, fetchedAt: Date.now() })
|
|
275
|
+
return models
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 失效某供应商的模型缓存(增删改模型后调用)。 */
|
|
279
|
+
invalidateModels(supplierId: string): void {
|
|
280
|
+
this.modelsCache.delete(supplierId)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 可用模型(按供应商分组,仅启用),面板加模型用。
|
|
284
|
+
* 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
|
|
146
285
|
async supplierModels(): Promise<Array<{ supplier: { id: string; name: string; alias: string }; models: ModelWithEnabled[] }>> {
|
|
147
|
-
const
|
|
148
|
-
for (const s of this.suppliers) {
|
|
286
|
+
const groups = await Promise.all(this.suppliers.map(async (s) => {
|
|
149
287
|
try {
|
|
150
|
-
const models = (await s.
|
|
151
|
-
|
|
288
|
+
const models = (await this.modelsOf(s.id)).filter((m) => m.enabled)
|
|
289
|
+
return { supplier: { id: s.id, name: s.name, alias: s.getAlias() }, models }
|
|
152
290
|
} catch {
|
|
153
291
|
// 单供应商失败不影响其它
|
|
292
|
+
return undefined
|
|
154
293
|
}
|
|
155
|
-
}
|
|
156
|
-
return
|
|
294
|
+
}))
|
|
295
|
+
return groups.filter((g): g is NonNullable<typeof g> => g !== undefined)
|
|
157
296
|
}
|
|
158
297
|
|
|
159
298
|
private validModels(models: string[]): boolean {
|
|
@@ -165,11 +304,15 @@ export class Router {
|
|
|
165
304
|
return strategy === 'fallback' || strategy === 'round-robin'
|
|
166
305
|
}
|
|
167
306
|
|
|
168
|
-
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
307
|
+
/** 组合模型统一存裸 id:剥掉 alias/ 前缀(前缀随供应商动态变)。
|
|
308
|
+
* 只剥「已知 alias + /」开头的前缀——模型 id 本身可以含斜杠
|
|
309
|
+
* (如 nvidia 的 `deepseek-ai/deepseek-v4-flash-0731`),用 lastIndexOf 会把
|
|
310
|
+
* 命名空间一起吃掉,后面请求必然 404。 */
|
|
169
311
|
private normalizeModelIds(models: string[]): string[] {
|
|
312
|
+
const aliases = this.suppliers.map((s) => s.getAlias()).filter((a) => a !== '')
|
|
170
313
|
return models.map((m) => {
|
|
171
|
-
const
|
|
172
|
-
return
|
|
314
|
+
const prefix = aliases.find((a) => m.startsWith(`${a}/`))
|
|
315
|
+
return prefix === undefined ? m : m.slice(prefix.length + 1)
|
|
173
316
|
})
|
|
174
317
|
}
|
|
175
318
|
|
|
@@ -242,14 +385,12 @@ export class Router {
|
|
|
242
385
|
writeJson(res, 503, openAIError('no_healthy_supplier', `combo ${JSON.stringify(req.model)}: all models unavailable`))
|
|
243
386
|
return
|
|
244
387
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (served) return
|
|
248
|
-
}
|
|
388
|
+
const served = await this.chatWithModel(req, res, req.model)
|
|
389
|
+
if (served) return
|
|
249
390
|
writeJson(res, 503, openAIError('no_healthy_supplier', 'all suppliers unavailable'))
|
|
250
391
|
}
|
|
251
392
|
|
|
252
|
-
/** 把 model
|
|
393
|
+
/** 把 model 改写为组合选中的模型名后,遍历供应商 + 账号(策略全在核心)。 */
|
|
253
394
|
private async chatWithModel(req: ChatRequest, res: ServerResponse, model: string): Promise<boolean> {
|
|
254
395
|
const clone: ChatRequest = { ...req, model }
|
|
255
396
|
try {
|
|
@@ -260,12 +401,85 @@ export class Router {
|
|
|
260
401
|
clone.rawBody = req.rawBody
|
|
261
402
|
}
|
|
262
403
|
for (const s of this.suppliers) {
|
|
263
|
-
const served = await
|
|
404
|
+
const served = await this.chatWithSupplier(s, clone, res)
|
|
264
405
|
if (served) return true
|
|
265
406
|
}
|
|
266
407
|
return false
|
|
267
408
|
}
|
|
268
409
|
|
|
410
|
+
/**
|
|
411
|
+
* 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
|
|
412
|
+
* AccountState 处置 → 失败换号;写响应只在成功后发生。
|
|
413
|
+
*
|
|
414
|
+
* 流式一旦写出响应头就绑死(上游协议如此,9router 同样如此),所以
|
|
415
|
+
* 「拿到 ok:true 的流」即视为该账号成功,之后出错不再换号——要能回退
|
|
416
|
+
* 就得在写头前缓冲判定,代价是首字节延迟,目前未做。
|
|
417
|
+
*/
|
|
418
|
+
private async chatWithSupplier(s: Supplier, req: ChatRequest, res: ServerResponse): Promise<boolean> {
|
|
419
|
+
const pool = s.pool
|
|
420
|
+
const cfg = this.store.get(s.id)
|
|
421
|
+
// 无账号供应商:直接调一次(uid 传空,插件忽略)
|
|
422
|
+
if (s.accounts !== undefined && s.accounts().length === 0) {
|
|
423
|
+
const r = await s.chatOnce('', req)
|
|
424
|
+
if (!r.ok) {
|
|
425
|
+
if (r.state === 'no_such_model') return false // 不是我的模型:换供应商,不记账
|
|
426
|
+
pool.noteFailure('', r.state, r.message)
|
|
427
|
+
return false
|
|
428
|
+
}
|
|
429
|
+
await writeChatResult(res, r)
|
|
430
|
+
return true
|
|
431
|
+
}
|
|
432
|
+
// 试过的号不再选:某些失败状态既不冷却也不计数(如模型不属于本供应商),
|
|
433
|
+
// 不排除试过的就会原地打转——死循环等于整个服务挂住。
|
|
434
|
+
const tried = new Set<string>()
|
|
435
|
+
for (;;) {
|
|
436
|
+
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy)
|
|
437
|
+
if (uid === undefined) return false
|
|
438
|
+
tried.add(uid)
|
|
439
|
+
const r = await s.chatOnce(uid, req)
|
|
440
|
+
if (!r.ok) {
|
|
441
|
+
// 模型不属于本供应商:整个供应商都跳过,换号重试没有意义,
|
|
442
|
+
// 也不能记在账号头上(否则无关账号会被攒够错误冷却掉)
|
|
443
|
+
if (r.state === 'no_such_model') return false
|
|
444
|
+
pool.noteFailure(uid, r.state, r.message)
|
|
445
|
+
continue
|
|
446
|
+
}
|
|
447
|
+
pool.noteSuccess(uid)
|
|
448
|
+
await writeChatResult(res, r)
|
|
449
|
+
return true
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** 测试某供应商的某模型是否可用。
|
|
454
|
+
* 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
|
|
455
|
+
* 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。 */
|
|
456
|
+
async testModel(supplierId: string, model: string): Promise<{ ok: boolean; error?: string }> {
|
|
457
|
+
const s = this.suppliers.find((x) => x.id === supplierId)
|
|
458
|
+
if (s === undefined) return { ok: false, error: `unknown supplier ${JSON.stringify(supplierId)}` }
|
|
459
|
+
const sink = sinkRes()
|
|
460
|
+
const req: ChatRequest = {
|
|
461
|
+
model,
|
|
462
|
+
stream: false,
|
|
463
|
+
rawBody: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
|
|
464
|
+
}
|
|
465
|
+
let served = false
|
|
466
|
+
try {
|
|
467
|
+
served = await this.chatWithSupplier(s, req, sink)
|
|
468
|
+
} catch (err) {
|
|
469
|
+
return { ok: false, error: (err as Error).message }
|
|
470
|
+
}
|
|
471
|
+
// 供应商可能「服务了但写出错误响应」(如 opencode:唯一能处理该模型的供应商,错误自己上报)
|
|
472
|
+
if (served && sink.status() < 400) return { ok: true }
|
|
473
|
+
const fromSink = served && sink.status() >= 400 ? extractUpstreamError(sink.body()) : ''
|
|
474
|
+
const detail = fromSink !== '' ? fromSink : s.lastError?.()
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
error: detail !== undefined && detail !== ''
|
|
478
|
+
? `${detail}(账号/额度/限流问题,非模型问题)`
|
|
479
|
+
: '所有账号都失败或账号都在冷却中(稍后重试)',
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
269
483
|
dispose(): void {
|
|
270
484
|
for (const s of this.suppliers) s.dispose()
|
|
271
485
|
this.suppliers = []
|