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.
@@ -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,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
- const COOL_DOWN_MS = 60 * 1000 // 失败冷却 60s
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
- function stripAlias(model: string): string {
33
- const slash = model.lastIndexOf('/')
34
- return slash >= 0 ? model.slice(slash + 1) : model
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
- /** 失败冷却:uid → until(ms)。 */
46
- const cooling = new Map<string, number>()
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
- function isCooling(uid: string, now = Date.now()): boolean {
65
- return (cooling.get(uid) ?? 0) > now
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
- async function refreshModels(): Promise<ModelInfo[]> {
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
- return models
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 有效性探测:chat 接口无 key → 400,无效 key → 401,有效 → 200。 */
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
- const text = await probe.text().catch(() => '')
107
- return { ok: false, error: `key 无效: ${probe.status} ${text.slice(0, 200)}` }
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: (): SupplierStatus => {
116
- const now = Date.now()
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
- cooling: isCooling(uid, now),
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
- 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(点「添加链接」配置)' }
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
- return { ok: false, error: `unknown model ${JSON.stringify(mid)}` }
249
+ lastErr = `unknown model ${JSON.stringify(req.model)}`
250
+ return { ok: false, state: 'no_such_model', message: lastErr }
158
251
  }
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
- }
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 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
- }
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
- 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' } })
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
- // 所有 key 都失败:记录日志,返回 false 让路由器 fallback
252
- env.log(`nvidia chat failed: ${lastErr}`)
253
- return false
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 { 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
+ 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 m = model.trim()
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: (): SupplierStatus => ({ id, name, accounts: [] }),
83
+ status: (): SupplierStatusNow => ({ id, name, accounts: [] }),
74
84
  listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
75
85
  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 }
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
- const m = String(obj.model ?? req.model)
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
- writeJson(res, 502, { error: { message: `opencode upstream: ${(err as Error).message}`, type: 'api_error', code: 'upstream_error' } })
134
- return true
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
- 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()
135
+ lastErr = 'opencode upstream: empty stream body'
136
+ return { ok: false, state: 'transport', message: lastErr }
171
137
  }
172
- res.end()
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
- 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' } })
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
- }