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
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
* 模型不内置、不缓存:listModels 每次从上游 /v1/models 拉取(全量,不过滤),
|
|
14
14
|
* 缓存由 dsh-router 核心统一管。
|
|
15
15
|
*/
|
|
16
|
-
import
|
|
17
|
-
import
|
|
16
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import type { ChatRequest, ModelInfo } from '../../router/types.ts'
|
|
19
|
+
import type { AccountState, ChatOnceResult, SupplierStatusNow } from '../contract.ts'
|
|
18
20
|
import type { SupplierEnv, SupplierModule } from '../contract.ts'
|
|
19
21
|
|
|
20
22
|
export const id = 'nvidia'
|
|
@@ -26,12 +28,16 @@ export const icon = 'http://localhost:20128/providers/nvidia.png'
|
|
|
26
28
|
const BASE = 'https://integrate.api.nvidia.com/v1'
|
|
27
29
|
const MODELS_URL = `${BASE}/models`
|
|
28
30
|
const CHAT_URL = `${BASE}/chat/completions`
|
|
29
|
-
|
|
31
|
+
/** 默认前缀(用户可在面板改;loader 包装会优先用 store 里的值)。 */
|
|
32
|
+
const DEFAULT_ALIAS = 'nv'
|
|
33
|
+
/** 非聊天用途的模型(护栏/翻译/解析/检测/图像生成等),聊天路由用不上,拉列表时过滤掉。 */
|
|
34
|
+
const NON_CHAT_MODEL = /(guard|safety|moderation|translate|transcription|parse|detector|embed|rerank|diffusion|tts|asr|calibration)/i
|
|
35
|
+
|
|
30
36
|
|
|
31
37
|
/** 剥 alias 前缀(nv/xxx → xxx)。 */
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return
|
|
38
|
+
/** 剥本供应商 alias 前缀(只剥自己的,模型 id 自带的斜杠保留)。 */
|
|
39
|
+
function stripAlias(model: string, alias: string): string {
|
|
40
|
+
return alias !== '' && model.startsWith(`${alias}/`) ? model.slice(alias.length + 1) : model
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
interface ApiKeyAccount {
|
|
@@ -42,9 +48,8 @@ interface ApiKeyAccount {
|
|
|
42
48
|
export default function factory(env: SupplierEnv): SupplierModule {
|
|
43
49
|
// 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
|
|
44
50
|
let modelsCache: ModelInfo[] | undefined
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
/** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
|
|
52
|
+
let lastErr: string | undefined
|
|
48
53
|
function listKeys(): string[] {
|
|
49
54
|
return env.credentials.list(id)
|
|
50
55
|
}
|
|
@@ -61,11 +66,94 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
61
66
|
return uids.map((uid) => ({ uid, acct: getKey(uid)! })).filter((x) => x.acct !== undefined)
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
/** 当前前缀(与 loader 包装一致:store 覆盖默认值)。 */
|
|
70
|
+
function currentAlias(): string {
|
|
71
|
+
return env.store.get(id).alias || DEFAULT_ALIAS
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- 模型可用性探测(NIM 的 /v1/models 无状态字段,大量模型已下线/未授权) ----
|
|
75
|
+
// 判定:200=可用;404/410=死(下线或未授权给该账号);401/403=key 无效;其余=未知(下次再试)。
|
|
76
|
+
// 结果持久化到 dataDir,force 刷新只探测「未探测过」的模型,避免每次全量探测。
|
|
77
|
+
|
|
78
|
+
/** 已确认不可用的模型(下线/未授权)。 */
|
|
79
|
+
const deadModels = new Set<string>()
|
|
80
|
+
/** 已确认可用的模型(避免重复探测)。 */
|
|
81
|
+
const okModels = new Set<string>()
|
|
82
|
+
const PROBE_CONCURRENCY = 8
|
|
83
|
+
|
|
84
|
+
function probeFile(): string {
|
|
85
|
+
return join(env.dataDir, 'nvidia-models.json')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function loadProbeState(): void {
|
|
89
|
+
try {
|
|
90
|
+
const j = JSON.parse(readFileSync(probeFile(), 'utf8')) as { dead?: string[]; ok?: string[] }
|
|
91
|
+
for (const m of j.dead ?? []) deadModels.add(m)
|
|
92
|
+
for (const m of j.ok ?? []) okModels.add(m)
|
|
93
|
+
} catch {
|
|
94
|
+
// 首次无文件
|
|
95
|
+
}
|
|
66
96
|
}
|
|
67
97
|
|
|
68
|
-
|
|
98
|
+
function saveProbeState(): void {
|
|
99
|
+
try {
|
|
100
|
+
mkdirSync(env.dataDir, { recursive: true })
|
|
101
|
+
const tmp = `${probeFile()}.tmp`
|
|
102
|
+
writeFileSync(tmp, JSON.stringify({ dead: [...deadModels], ok: [...okModels], at: Date.now() }))
|
|
103
|
+
renameSync(tmp, probeFile())
|
|
104
|
+
} catch {
|
|
105
|
+
// 持久化失败不影响主流程
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
loadProbeState()
|
|
110
|
+
|
|
111
|
+
/** 探测单个模型:'ok' | 'dead' | 'unauthorized' | 'unknown'。 */
|
|
112
|
+
async function probeModel(apiKey: string, model: string): Promise<'ok' | 'dead' | 'unauthorized' | 'unknown'> {
|
|
113
|
+
try {
|
|
114
|
+
const r = await fetch(CHAT_URL, {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, 'User-Agent': 'dsh-router' },
|
|
117
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
|
|
118
|
+
signal: AbortSignal.timeout(15000),
|
|
119
|
+
})
|
|
120
|
+
if (r.ok) return 'ok'
|
|
121
|
+
if (r.status === 401 || r.status === 403) return 'unauthorized'
|
|
122
|
+
if (r.status === 404 || r.status === 410) return 'dead'
|
|
123
|
+
return 'unknown'
|
|
124
|
+
} catch {
|
|
125
|
+
return 'unknown'
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 并发探测全部「未探测过」的模型,更新 dead/ok 并持久化。 */
|
|
130
|
+
async function probeAll(ids: string[]): Promise<void> {
|
|
131
|
+
// 探测是尽力而为:随便挑一个 key,冷却/健康由核心在真正请求时才判断
|
|
132
|
+
const acct = orderedKeys()[0]
|
|
133
|
+
if (acct === undefined) return // 无 key:不探测,保持原样
|
|
134
|
+
const todo = ids.filter((m) => !deadModels.has(m) && !okModels.has(m) && !NON_CHAT_MODEL.test(m))
|
|
135
|
+
if (todo.length === 0) return
|
|
136
|
+
let cursor = 0
|
|
137
|
+
let unauthorized = false
|
|
138
|
+
let changed = false
|
|
139
|
+
await Promise.all(
|
|
140
|
+
Array.from({ length: Math.min(PROBE_CONCURRENCY, todo.length) }, async () => {
|
|
141
|
+
for (;;) {
|
|
142
|
+
const i = cursor++
|
|
143
|
+
if (i >= todo.length || unauthorized) return
|
|
144
|
+
const model = todo[i]!
|
|
145
|
+
const r = await probeModel(acct.acct.apiKey, model)
|
|
146
|
+
if (r === 'unauthorized') unauthorized = true
|
|
147
|
+
else if (r === 'ok') { okModels.add(model); changed = true }
|
|
148
|
+
else if (r === 'dead') { deadModels.add(model); changed = true }
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
if (changed) saveProbeState()
|
|
153
|
+
env.log(`nvidia probe: ${okModels.size} ok / ${deadModels.size} dead`)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function refreshModels(force = false): Promise<ModelInfo[]> {
|
|
69
157
|
try {
|
|
70
158
|
const resp = await fetch(MODELS_URL, {
|
|
71
159
|
headers: { 'User-Agent': 'dsh-router' },
|
|
@@ -84,17 +172,20 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
84
172
|
models.push(entry)
|
|
85
173
|
}
|
|
86
174
|
if (models.length > 0) modelsCache = models
|
|
87
|
-
|
|
175
|
+
if (force && models.length > 0) await probeAll(models.map((m) => m.id))
|
|
176
|
+
return models.filter((m) => !deadModels.has(m.id) && !NON_CHAT_MODEL.test(m.id))
|
|
88
177
|
} catch {
|
|
89
|
-
return modelsCache ?? []
|
|
178
|
+
return (modelsCache ?? []).filter((m) => !deadModels.has(m.id) && !NON_CHAT_MODEL.test(m.id))
|
|
90
179
|
}
|
|
91
180
|
}
|
|
92
181
|
|
|
93
182
|
async function allModels(force: boolean): Promise<ModelInfo[]> {
|
|
94
|
-
return refreshModels()
|
|
183
|
+
return refreshModels(force)
|
|
95
184
|
}
|
|
96
185
|
|
|
97
|
-
/** key
|
|
186
|
+
/** key 有效性探测:只有 401/403 才算 key 无效。
|
|
187
|
+
* NIM 模型下线很快(410)或没授权给该账号(404),那是模型问题不是 key 问题——
|
|
188
|
+
* 固定探测某个模型迟早腐化,故 404/410 视为 key 有效。 */
|
|
98
189
|
async function probeKey(apiKey: string): Promise<{ ok: boolean; error?: string }> {
|
|
99
190
|
const probe = await fetch(CHAT_URL, {
|
|
100
191
|
method: 'POST',
|
|
@@ -103,8 +194,12 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
103
194
|
signal: AbortSignal.timeout(30000),
|
|
104
195
|
})
|
|
105
196
|
if (probe.ok) return { ok: true }
|
|
106
|
-
|
|
107
|
-
|
|
197
|
+
if (probe.status === 401 || probe.status === 403) {
|
|
198
|
+
const text = await probe.text().catch(() => '')
|
|
199
|
+
return { ok: false, error: `key 无效: ${probe.status} ${text.slice(0, 200)}` }
|
|
200
|
+
}
|
|
201
|
+
// 404/410 等:模型端问题(下线/未授权),key 本身已通过鉴权
|
|
202
|
+
return { ok: true }
|
|
108
203
|
}
|
|
109
204
|
|
|
110
205
|
return {
|
|
@@ -112,15 +207,13 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
112
207
|
name,
|
|
113
208
|
priority,
|
|
114
209
|
icon,
|
|
115
|
-
status: ():
|
|
116
|
-
|
|
210
|
+
status: (): SupplierStatusNow => {
|
|
211
|
+
// 只报「现在状态」:凭证是否存在。冷却/禁用/错误累计由核心叠加。
|
|
117
212
|
const accounts = orderedKeys().map(({ uid, acct }) => ({
|
|
118
213
|
uid,
|
|
119
214
|
nickname: acct.name || 'API Key',
|
|
120
215
|
credits: 0,
|
|
121
|
-
|
|
122
|
-
disabled: false,
|
|
123
|
-
err_count: 0,
|
|
216
|
+
state: 'ok' as AccountState,
|
|
124
217
|
}))
|
|
125
218
|
return { id, name, accounts }
|
|
126
219
|
},
|
|
@@ -146,38 +239,21 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
146
239
|
async removeLink(uid: string): Promise<boolean> {
|
|
147
240
|
if (getKey(uid) === undefined) return false
|
|
148
241
|
env.credentials.remove(id, uid)
|
|
149
|
-
cooling.delete(uid)
|
|
150
242
|
return true
|
|
151
243
|
},
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
244
|
+
lastError: (): string | undefined => lastErr,
|
|
245
|
+
/** 对单个 key 调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
|
|
246
|
+
async chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult> {
|
|
247
|
+
const base = stripAlias(req.model, currentAlias())
|
|
156
248
|
if (!(await allModels(false)).some((m) => m.id === base)) {
|
|
157
|
-
|
|
249
|
+
lastErr = `unknown model ${JSON.stringify(req.model)}`
|
|
250
|
+
return { ok: false, state: 'no_such_model', message: lastErr }
|
|
158
251
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${acct.apiKey}`, 'User-Agent': 'dsh-router' },
|
|
164
|
-
body: JSON.stringify({ model: base, messages: [{ role: 'user', content: 'ping' }], stream: false, max_tokens: 1 }),
|
|
165
|
-
signal: AbortSignal.timeout(30000),
|
|
166
|
-
})
|
|
167
|
-
if (resp.ok) return { ok: true }
|
|
168
|
-
const body = await resp.text().catch(() => '')
|
|
169
|
-
return { ok: false, error: `upstream ${resp.status}: ${body.slice(0, 200)}` }
|
|
170
|
-
} catch (err) {
|
|
171
|
-
return { ok: false, error: (err as Error).message }
|
|
172
|
-
}
|
|
252
|
+
const acct = getKey(uid)
|
|
253
|
+
if (acct === undefined) {
|
|
254
|
+
lastErr = `unknown account ${JSON.stringify(uid)}`
|
|
255
|
+
return { ok: false, state: 'no_such_model', message: lastErr }
|
|
173
256
|
}
|
|
174
|
-
return { ok: false, error: 'no api key' }
|
|
175
|
-
},
|
|
176
|
-
async chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean> {
|
|
177
|
-
const base = stripAlias(req.model)
|
|
178
|
-
if (!(await allModels(false)).some((m) => m.id === base)) return false
|
|
179
|
-
const keys = orderedKeys().filter(({ uid }) => !isCooling(uid))
|
|
180
|
-
if (keys.length === 0) return false // 无健康 key → 路由器 fallback
|
|
181
257
|
|
|
182
258
|
let body = req.rawBody
|
|
183
259
|
try {
|
|
@@ -188,69 +264,44 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
188
264
|
// 保持原样
|
|
189
265
|
}
|
|
190
266
|
|
|
191
|
-
let
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
// 非 2xx:冷却该 key,试下一个
|
|
207
|
-
if (upstream.status < 200 || upstream.status >= 300) {
|
|
208
|
-
cooling.set(uid, Date.now() + COOL_DOWN_MS)
|
|
209
|
-
const text = await upstream.text().catch(() => '')
|
|
210
|
-
lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
211
|
-
continue
|
|
212
|
-
}
|
|
213
|
-
// 成功:透传(流式 SSE 或 JSON)
|
|
214
|
-
if (req.stream) {
|
|
215
|
-
res.writeHead(upstream.status, {
|
|
216
|
-
'Content-Type': 'text/event-stream',
|
|
217
|
-
'Cache-Control': 'no-cache',
|
|
218
|
-
Connection: 'keep-alive',
|
|
219
|
-
'X-Accel-Buffering': 'no',
|
|
220
|
-
})
|
|
221
|
-
if (!upstream.body) {
|
|
222
|
-
res.end()
|
|
223
|
-
return true
|
|
224
|
-
}
|
|
225
|
-
const reader = upstream.body.getReader()
|
|
226
|
-
try {
|
|
227
|
-
for (;;) {
|
|
228
|
-
const { done, value } = await reader.read()
|
|
229
|
-
if (done) break
|
|
230
|
-
res.write(Buffer.from(value))
|
|
231
|
-
if (typeof (res as { flushHeaders?: () => void }).flushHeaders === 'function') {
|
|
232
|
-
;(res as { flushHeaders: () => void }).flushHeaders()
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
} finally {
|
|
236
|
-
reader.releaseLock()
|
|
237
|
-
}
|
|
238
|
-
res.end()
|
|
239
|
-
return true
|
|
240
|
-
}
|
|
267
|
+
let upstream: Response
|
|
268
|
+
try {
|
|
269
|
+
upstream = await fetch(CHAT_URL, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${acct.apiKey}`, 'User-Agent': 'dsh-router' },
|
|
272
|
+
body,
|
|
273
|
+
signal: AbortSignal.timeout(120000),
|
|
274
|
+
})
|
|
275
|
+
} catch (err) {
|
|
276
|
+
lastErr = (err as Error).message
|
|
277
|
+
return { ok: false, state: 'transport', message: lastErr }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (upstream.status < 200 || upstream.status >= 300) {
|
|
241
281
|
const text = await upstream.text().catch(() => '')
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
282
|
+
lastErr = `upstream ${upstream.status}: ${text.slice(0, 120)}`
|
|
283
|
+
const state: AccountState =
|
|
284
|
+
upstream.status === 429 ? 'rate_limit'
|
|
285
|
+
: upstream.status === 401 || upstream.status === 403 ? 'session_dead'
|
|
286
|
+
: upstream.status === 404 ? 'unavailable'
|
|
287
|
+
: 'unknown'
|
|
288
|
+
return { ok: false, state, message: lastErr }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 流式:上游已是 OpenAI SSE,原样交回核心写
|
|
292
|
+
if (req.stream) {
|
|
293
|
+
if (!upstream.body) {
|
|
294
|
+
lastErr = 'nvidia upstream: empty stream body'
|
|
295
|
+
return { ok: false, state: 'transport', message: lastErr }
|
|
248
296
|
}
|
|
249
|
-
return true
|
|
297
|
+
return { ok: true, stream: upstream.body }
|
|
250
298
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
299
|
+
const text = await upstream.text().catch(() => '')
|
|
300
|
+
if (text === '') {
|
|
301
|
+
lastErr = 'nvidia upstream: empty body'
|
|
302
|
+
return { ok: false, state: 'transport', message: lastErr }
|
|
303
|
+
}
|
|
304
|
+
return { ok: true, status: upstream.status, body: text }
|
|
254
305
|
},
|
|
255
306
|
dispose: (): void => {
|
|
256
307
|
modelsCache = undefined
|
|
@@ -258,11 +309,3 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
258
309
|
}
|
|
259
310
|
}
|
|
260
311
|
|
|
261
|
-
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
262
|
-
const payload = typeof body === 'string' ? body : JSON.stringify(body)
|
|
263
|
-
res.writeHead(status, {
|
|
264
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
265
|
-
'Cache-Control': 'no-store',
|
|
266
|
-
})
|
|
267
|
-
res.end(payload)
|
|
268
|
-
}
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* noAuth 直连:无账号/凭证/连接池/签到 —— 通用层对它是空的,
|
|
9
9
|
* 面板只显示模型卡片(连接池、签到由能力检测自动隐藏)。
|
|
10
10
|
*/
|
|
11
|
-
import type {
|
|
12
|
-
import type {
|
|
13
|
-
|
|
11
|
+
import type { ChatRequest, ModelInfo } from '../../router/types.ts'
|
|
12
|
+
import type { AccountState, ChatOnceResult, SupplierEnv, SupplierModule, SupplierStatusNow } from '../contract.ts'
|
|
13
|
+
|
|
14
|
+
const DEFAULT_ALIAS = 'oc'
|
|
14
15
|
|
|
15
16
|
export const id = 'opencode'
|
|
16
17
|
export const name = 'OpenCode Free'
|
|
@@ -29,6 +30,8 @@ const KNOWN_FREE = new Set(['big-pickle'])
|
|
|
29
30
|
export default function factory(env: SupplierEnv): SupplierModule {
|
|
30
31
|
// 模型缓存由 dsh-router 核心统一管;插件每次拉取,失败回退上次成功结果
|
|
31
32
|
let modelsCache: ModelInfo[] | undefined
|
|
33
|
+
/** 上次 chatOnce 失败原因(供核心测试模型汇总诊断)。 */
|
|
34
|
+
let lastErr: string | undefined
|
|
32
35
|
|
|
33
36
|
async function refreshModels(): Promise<ModelInfo[]> {
|
|
34
37
|
try {
|
|
@@ -56,12 +59,19 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
56
59
|
return refreshModels()
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/** 当前前缀(与 loader 包装一致:store 覆盖默认值)。 */
|
|
63
|
+
function currentAlias(): string {
|
|
64
|
+
return env.store.get(id).alias || DEFAULT_ALIAS
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 剥本供应商 alias 前缀(只剥自己的,模型 id 自带的斜杠保留)。 */
|
|
68
|
+
function stripAlias(model: string, alias: string): string {
|
|
69
|
+
return alias !== '' && model.startsWith(`${alias}/`) ? model.slice(alias.length + 1) : model
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
/** 剥 alias 前缀(oc/xxx → xxx)后判断是否 free 模型。 */
|
|
60
73
|
function isFreeModel(model: string): boolean {
|
|
61
|
-
const
|
|
62
|
-
let base = m
|
|
63
|
-
const slash = base.lastIndexOf('/')
|
|
64
|
-
if (slash >= 0) base = base.slice(slash + 1)
|
|
74
|
+
const base = stripAlias(model.trim(), currentAlias())
|
|
65
75
|
return base.endsWith('-free') || KNOWN_FREE.has(base)
|
|
66
76
|
}
|
|
67
77
|
|
|
@@ -70,47 +80,23 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
70
80
|
name,
|
|
71
81
|
priority,
|
|
72
82
|
icon,
|
|
73
|
-
status: ():
|
|
83
|
+
status: (): SupplierStatusNow => ({ id, name, accounts: [] }),
|
|
74
84
|
listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
|
|
75
85
|
getAlias: (): string => 'oc',
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
'User-Agent': UA,
|
|
85
|
-
'x-opencode-client': 'desktop',
|
|
86
|
-
},
|
|
87
|
-
body: JSON.stringify({
|
|
88
|
-
model: mid,
|
|
89
|
-
messages: [{ role: 'user', content: 'ping' }],
|
|
90
|
-
stream: false,
|
|
91
|
-
max_tokens: 1,
|
|
92
|
-
}),
|
|
93
|
-
signal: AbortSignal.timeout(30000),
|
|
94
|
-
})
|
|
95
|
-
if (!resp.ok) {
|
|
96
|
-
const body = await resp.text().catch(() => '')
|
|
97
|
-
return { ok: false, error: `upstream ${resp.status}: ${body.slice(0, 200)}` }
|
|
98
|
-
}
|
|
99
|
-
return { ok: true }
|
|
100
|
-
} catch (err) {
|
|
101
|
-
return { ok: false, error: (err as Error).message }
|
|
86
|
+
// testModel 由 dsh-router 核心统一走 chatOnce 路径(无账号,无需回退)
|
|
87
|
+
lastError: (): string | undefined => lastErr,
|
|
88
|
+
/** 无账号供应商:uid 恒为空,只认 free 模型。 */
|
|
89
|
+
async chatOnce(_uid: string, req: ChatRequest): Promise<ChatOnceResult> {
|
|
90
|
+
// 只认 free 模型,其他模型交给别的供应商
|
|
91
|
+
if (!isFreeModel(req.model)) {
|
|
92
|
+
lastErr = `unknown free model ${JSON.stringify(req.model)}`
|
|
93
|
+
return { ok: false, state: 'no_such_model', message: lastErr }
|
|
102
94
|
}
|
|
103
|
-
},
|
|
104
|
-
async chatCompletions(req: ChatRequest, res: ServerResponse): Promise<boolean> {
|
|
105
|
-
// 只认 free 模型,其他模型返回 false 交给别的供应商
|
|
106
|
-
if (!isFreeModel(req.model)) return false
|
|
107
95
|
// 模型名规范化:剥 alias 前缀,body.model 用裸 id
|
|
108
96
|
let body = req.rawBody
|
|
109
97
|
try {
|
|
110
98
|
const obj = JSON.parse(body) as Record<string, unknown>
|
|
111
|
-
|
|
112
|
-
const slash = m.lastIndexOf('/')
|
|
113
|
-
obj.model = slash >= 0 ? m.slice(slash + 1) : m
|
|
99
|
+
obj.model = stripAlias(String(obj.model ?? req.model), currentAlias())
|
|
114
100
|
body = JSON.stringify(obj)
|
|
115
101
|
} catch {
|
|
116
102
|
// 保持原样
|
|
@@ -130,71 +116,38 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
130
116
|
signal: AbortSignal.timeout(120000),
|
|
131
117
|
})
|
|
132
118
|
} catch (err) {
|
|
133
|
-
|
|
134
|
-
return
|
|
119
|
+
lastErr = `opencode upstream: ${(err as Error).message}`
|
|
120
|
+
return { ok: false, state: 'transport', message: lastErr }
|
|
135
121
|
}
|
|
136
122
|
|
|
123
|
+
// 非 2xx:按状态归类,核心据此冷却/换号(本供应商无号可换,直接放弃)
|
|
124
|
+
if (upstream.status < 200 || upstream.status >= 300) {
|
|
125
|
+
const text = await upstream.text().catch(() => '')
|
|
126
|
+
lastErr = `upstream ${upstream.status}: ${text.slice(0, 200)}`
|
|
127
|
+
const state: AccountState =
|
|
128
|
+
upstream.status === 429 ? 'rate_limit' : upstream.status === 404 ? 'unavailable' : 'unknown'
|
|
129
|
+
return { ok: false, state, message: lastErr }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 流式:上游已是 OpenAI SSE,原样交回核心(核心负责写响应)
|
|
137
133
|
if (req.stream) {
|
|
138
|
-
// 上游非 2xx:不是 SSE,返回 JSON 错误(透传状态)
|
|
139
|
-
if (upstream.status < 200 || upstream.status >= 300) {
|
|
140
|
-
const text = await upstream.text().catch(() => '')
|
|
141
|
-
try {
|
|
142
|
-
writeJson(res, upstream.status, JSON.parse(text))
|
|
143
|
-
} catch {
|
|
144
|
-
writeJson(res, upstream.status, { error: { message: text.slice(0, 500), type: 'api_error', code: 'upstream_error' } })
|
|
145
|
-
}
|
|
146
|
-
return true
|
|
147
|
-
}
|
|
148
|
-
// SSE 透传
|
|
149
|
-
res.writeHead(upstream.status, {
|
|
150
|
-
'Content-Type': 'text/event-stream',
|
|
151
|
-
'Cache-Control': 'no-cache',
|
|
152
|
-
Connection: 'keep-alive',
|
|
153
|
-
'X-Accel-Buffering': 'no',
|
|
154
|
-
})
|
|
155
134
|
if (!upstream.body) {
|
|
156
|
-
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
const reader = upstream.body.getReader()
|
|
160
|
-
try {
|
|
161
|
-
for (;;) {
|
|
162
|
-
const { done, value } = await reader.read()
|
|
163
|
-
if (done) break
|
|
164
|
-
res.write(Buffer.from(value))
|
|
165
|
-
if (typeof (res as { flushHeaders?: () => void }).flushHeaders === 'function') {
|
|
166
|
-
;(res as { flushHeaders: () => void }).flushHeaders()
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
} finally {
|
|
170
|
-
reader.releaseLock()
|
|
135
|
+
lastErr = 'opencode upstream: empty stream body'
|
|
136
|
+
return { ok: false, state: 'transport', message: lastErr }
|
|
171
137
|
}
|
|
172
|
-
|
|
173
|
-
return true
|
|
138
|
+
return { ok: true, stream: upstream.body }
|
|
174
139
|
}
|
|
175
140
|
|
|
176
|
-
// 非流式:透传 JSON
|
|
141
|
+
// 非流式:透传 JSON(空 body 视为上游异常)
|
|
177
142
|
const text = await upstream.text().catch(() => '')
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
writeJson(res, status, JSON.parse(text))
|
|
182
|
-
} catch {
|
|
183
|
-
writeJson(res, status, { error: { message: text.slice(0, 500), type: 'api_error', code: 'upstream_error' } })
|
|
143
|
+
if (text === '') {
|
|
144
|
+
lastErr = 'opencode upstream: empty body'
|
|
145
|
+
return { ok: false, state: 'transport', message: lastErr }
|
|
184
146
|
}
|
|
185
|
-
return true
|
|
147
|
+
return { ok: true, status: upstream.status, body: text }
|
|
186
148
|
},
|
|
187
149
|
dispose: (): void => {
|
|
188
150
|
modelsCache = undefined
|
|
189
151
|
},
|
|
190
152
|
}
|
|
191
153
|
}
|
|
192
|
-
|
|
193
|
-
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
194
|
-
const payload = typeof body === 'string' ? body : JSON.stringify(body)
|
|
195
|
-
res.writeHead(status, {
|
|
196
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
197
|
-
'Cache-Control': 'no-store',
|
|
198
|
-
})
|
|
199
|
-
res.end(payload)
|
|
200
|
-
}
|