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