zcode-qoder-connect 0.0.0-stage → 1.0.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.
@@ -0,0 +1,581 @@
1
+ // upstream.js — Qoder (CN) model-gateway client for zcode-qoder-connect.
2
+ //
3
+ // Protocol ported from dsh-qoder-connect v0.2.9 (MIT) + live probing on
4
+ // 2026-10-25. Three layers:
5
+ //
6
+ // COSY signing — every gateway call carries a per-request envelope:
7
+ // random 16-hex AES-128-CBC key (key=IV) encrypts {uid, security_oauth_
8
+ // token, name, email}; RSA-PKCS1 wraps the AES key; MD5 signs
9
+ // payloadB64 \n cosyKey \n timestamp \n body \n sigPath. Full header set
10
+ // below in buildCosyHeaders.
11
+ //
12
+ // WAF body encoding — request bodies pass qoderEncodeBody(): base64 with
13
+ // thirds rearranged, then translated through a custom alphabet.
14
+ //
15
+ // Two-layer SSE — chat responses stream `data:` frames holding an outer
16
+ // JSON {statusCodeValue?, body:string}; body carries an inner OpenAI-
17
+ // style chunk (choices[].delta.{content, reasoning_content, tool_calls},
18
+ // usage, finish_reason).
19
+ //
20
+ // Endpoints (china): gateway https://gateway.qoder.com.cn, openapi
21
+ // https://openapi.qoder.com.cn. Chat POST {gateway}/algo/api/v2/service/pro/
22
+ // sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&
23
+ // Encode=1 ; models GET {gateway}/algo/api/v2/model/list?Encode=1 ; check-in
24
+ // {openapi}/sash/api/v1/me/campaigns (+ /{id}/claim) with plain Bearer jt-.
25
+ //
26
+ // chatStream contract (consumed by server.js): resolves
27
+ // {ok:true, response:{body:ReadableStream-of-OpenAI-SSE}} or
28
+ // {ok:false, kind, status, message}.
29
+
30
+ import crypto from 'node:crypto'
31
+
32
+ /** Placeholder roster before the first live fetch — intentionally empty: a
33
+ * signed-out account must expose no models rather than stale guesses. */
34
+ export const FALLBACK_MODELS = []
35
+
36
+ export const QODER_IDE_VERSION = '1.1.77'
37
+ /**
38
+ * 上游「响应头」到达的等待上限(毫秒)。只覆盖响应头阶段,拿到即清除;
39
+ * body 流由流内看门狗负责。可用 `ZC_UPSTREAM_HEADER_TIMEOUT_MS` 覆盖。
40
+ */
41
+ const UPSTREAM_HEADER_TIMEOUT_MS = Number(process.env['ZC_UPSTREAM_HEADER_TIMEOUT_MS'] ?? 120_000)
42
+ const USER_AGENT = `qoder/${QODER_IDE_VERSION}`
43
+ const LOGIN_VERSION = 'v2'
44
+ const MACHINE_OS = `${process.arch === 'arm64' ? 'aarch64' : process.arch === 'x64' ? 'x86_64' : process.arch}_${process.platform}`
45
+ const MACHINE_TYPE_MAGIC = '5'
46
+ const DATA_POLICY = 'disagree'
47
+
48
+ const GATEWAY_BASE = 'https://gateway.qoder.com.cn'
49
+ const OPENAPI_BASE = 'https://openapi.qoder.com.cn'
50
+
51
+ const RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
52
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
53
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
54
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
55
+ XcW+ML9FoCI6AOvOzwIDAQAB
56
+ -----END PUBLIC KEY-----`
57
+
58
+ // --- WAF body encoding ------------------------------------------------------
59
+
60
+ const CUSTOM_ALPHABET = '_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!'
61
+ const STD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
62
+ const ENCODE_TABLE = (() => {
63
+ const table = new Uint8Array(256)
64
+ for (let i = 0; i < 256; i++) table[i] = i
65
+ for (let i = 0; i < 64; i++) table[STD_ALPHABET.charCodeAt(i)] = CUSTOM_ALPHABET.charCodeAt(i)
66
+ table['='.charCodeAt(0)] = '$'.charCodeAt(0)
67
+ return table
68
+ })()
69
+
70
+ /** Qoder WAF encoding: rearranged base64 thirds through a custom alphabet. */
71
+ export function qoderEncodeBody(plaintext) {
72
+ const std = Buffer.from(plaintext).toString('base64')
73
+ const n = std.length
74
+ const a = Math.floor(n / 3)
75
+ const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a)
76
+ const source = Buffer.from(rearranged, 'latin1')
77
+ const target = Buffer.allocUnsafe(n)
78
+ for (let i = 0; i < n; i++) target[i] = ENCODE_TABLE[source[i] ?? 0] ?? 0
79
+ return target.toString('latin1')
80
+ }
81
+
82
+ // --- COSY headers ------------------------------------------------------------
83
+
84
+ function computeSigPath(urlStr) {
85
+ let sigPath = new URL(urlStr).pathname
86
+ if (sigPath.startsWith('/algo')) sigPath = sigPath.substring(5)
87
+ return sigPath
88
+ }
89
+
90
+ /** COSY-signed headers for one gateway request. `body` is the ENCODED wire
91
+ * body string ('' for GET) — it feeds both the MD5 signature and the
92
+ * body-hash headers, so it must be byte-identical to what is sent. */
93
+ export function buildCosyHeaders({ body, url, credential }) {
94
+ const aesKey = crypto.randomUUID().replace(/-/g, '').slice(0, 16)
95
+ const userInfo = {
96
+ uid: credential.userID,
97
+ security_oauth_token: credential.authToken,
98
+ name: credential.name ?? '',
99
+ aid: '',
100
+ email: credential.email ?? '',
101
+ }
102
+ const key = Buffer.from(aesKey)
103
+ const cipher = crypto.createCipheriv('aes-128-cbc', key, key)
104
+ const infoB64 = cipher.update(JSON.stringify(userInfo), 'utf8', 'base64') + cipher.final('base64')
105
+ const cosyKey = crypto.publicEncrypt(
106
+ { key: RSA_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING },
107
+ Buffer.from(aesKey),
108
+ ).toString('base64')
109
+ const timestamp = Math.floor(Date.now() / 1000).toString()
110
+ const payloadB64 = Buffer.from(JSON.stringify({
111
+ version: 'v1',
112
+ requestId: crypto.randomUUID(),
113
+ info: infoB64,
114
+ cosyVersion: QODER_IDE_VERSION,
115
+ ideVersion: '',
116
+ })).toString('base64')
117
+ const sigPath = computeSigPath(url)
118
+ const sig = crypto.createHash('md5')
119
+ .update(`${payloadB64}\n${cosyKey}\n${timestamp}\n${body ?? ''}\n${sigPath}`)
120
+ .digest('hex')
121
+ const bodyHash = crypto.createHash('md5').update(body ?? '').digest('hex')
122
+ return {
123
+ Authorization: `Bearer COSY.${payloadB64}.${sig}`,
124
+ 'Cosy-Key': cosyKey,
125
+ 'Cosy-User': credential.userID,
126
+ 'Cosy-Date': timestamp,
127
+ 'Cosy-Version': QODER_IDE_VERSION,
128
+ 'Cosy-Machineid': credential.machineID,
129
+ 'Cosy-Machinetoken': credential.machineID,
130
+ 'Cosy-Machinetype': MACHINE_TYPE_MAGIC,
131
+ 'Cosy-Machineos': MACHINE_OS,
132
+ 'Cosy-Clienttype': '5',
133
+ 'Cosy-Clientip': '127.0.0.1',
134
+ 'Cosy-Bodyhash': bodyHash,
135
+ 'Cosy-Bodylength': body ? Buffer.byteLength(body).toString() : '0',
136
+ 'Cosy-Sigpath': sigPath,
137
+ 'Cosy-Data-Policy': DATA_POLICY,
138
+ 'Cosy-Organization-Id': '',
139
+ 'Cosy-Organization-Tags': '',
140
+ 'Login-Version': LOGIN_VERSION,
141
+ 'X-Request-Id': crypto.randomUUID(),
142
+ }
143
+ }
144
+
145
+ // --- error taxonomy ----------------------------------------------------------
146
+
147
+ function classifyStatus(status, text) {
148
+ if (status === 401 || status === 403) return { kind: 'authentication', status: 401 }
149
+ if (status === 429) return { kind: 'soft_rate', status: 429 }
150
+ if (status === 402) return { kind: 'hard_credit', status: 402 }
151
+ return { kind: status >= 500 ? 'server' : 'client', status: status >= 500 ? 502 : status }
152
+ }
153
+
154
+ function safeJsonParse(text) {
155
+ try { return JSON.parse(text) } catch { return { type: 'object', properties: {} } }
156
+ }
157
+
158
+ // --- client ------------------------------------------------------------------
159
+
160
+ export class QoderUpstreamClient {
161
+ constructor({ logger = console } = {}) {
162
+ this.logger = logger
163
+ this.lastCatalog = { source: 'fallback', fetchedAtMs: undefined }
164
+ }
165
+
166
+ urls() {
167
+ return {
168
+ models: `${GATEWAY_BASE}/algo/api/v2/model/list?Encode=1`,
169
+ chat: `${GATEWAY_BASE}/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`,
170
+ campaigns: `${OPENAPI_BASE}/sash/api/v1/me/campaigns`,
171
+ usage: `${OPENAPI_BASE}/api/v2/quota/usage`,
172
+ }
173
+ }
174
+
175
+ /** Live roster from the gateway directory. */
176
+ async fetchModels(credential) {
177
+ const url = this.urls().models
178
+ const response = await fetch(url, {
179
+ method: 'GET',
180
+ headers: {
181
+ accept: 'application/json',
182
+ 'accept-encoding': 'identity',
183
+ 'user-agent': USER_AGENT,
184
+ ...buildCosyHeaders({ body: '', url, credential }),
185
+ },
186
+ signal: AbortSignal.timeout(25000),
187
+ })
188
+ const text = await response.text()
189
+ if (!response.ok) throw Object.assign(new Error(`qoder models [${response.status}]: ${text.slice(0, 200)}`), classifyStatus(response.status, text))
190
+ let payload
191
+ try { payload = JSON.parse(text) } catch { throw new Error('qoder models: 非 JSON 响应') }
192
+ const rows = Array.isArray(payload?.assistant) ? payload.assistant : []
193
+ const models = []
194
+ for (const raw of rows) {
195
+ if (raw?.enable !== true) continue
196
+ const id = typeof raw.key === 'string' ? raw.key.trim() : ''
197
+ if (!id || models.some(m => m.id === id)) continue
198
+ const contextOptions = {}
199
+ if (raw.context_config && typeof raw.context_config === 'object') {
200
+ for (const [key, value] of Object.entries(raw.context_config)) {
201
+ if (value && typeof value === 'object' && typeof value.token_count === 'number' && value.token_count > 0) {
202
+ contextOptions[key] = { tokenCount: value.token_count, isDefault: value.is_default === true }
203
+ }
204
+ }
205
+ }
206
+ const defaults = Object.values(contextOptions).filter(o => o.isDefault)
207
+ const contextWindow = (defaults.length === 1 ? defaults[0].tokenCount : undefined)
208
+ ?? (raw.max_input_tokens > 0 ? raw.max_input_tokens : undefined) ?? 180_000
209
+ const maxAll = Math.max(raw.max_input_tokens > 0 ? raw.max_input_tokens : 0, contextWindow,
210
+ ...Object.values(contextOptions).map(o => o.tokenCount))
211
+ const efforts = Object.keys(raw.thinking_config?.enabled?.efforts ?? {}).filter(e => e.trim())
212
+ models.push({
213
+ id,
214
+ name: typeof raw.display_name === 'string' && raw.display_name.trim() ? raw.display_name.trim() : id,
215
+ contextWindow: maxAll > contextWindow ? maxAll : contextWindow,
216
+ ...(raw.max_output_tokens > 0 ? { maxTokens: raw.max_output_tokens } : {}),
217
+ reasoning: { supports: raw.is_reasoning === true || efforts.length > 0, supportedEfforts: efforts },
218
+ supportsImages: raw.is_vl === true,
219
+ ...(typeof raw.price_factor === 'number' && raw.price_factor >= 0 ? { priceFactor: raw.price_factor } : {}),
220
+ })
221
+ }
222
+ this.lastCatalog = { source: 'qoder-directory', fetchedAtMs: Date.now() }
223
+ return models
224
+ }
225
+
226
+ /** Remaining credits (plain Bearer on openapi). */
227
+ async fetchCredits(credential) {
228
+ const response = await fetch(this.urls().usage, {
229
+ method: 'POST',
230
+ headers: { accept: 'application/json', 'content-type': 'application/json', Authorization: `Bearer ${credential.authToken}` },
231
+ body: '{}',
232
+ signal: AbortSignal.timeout(20000),
233
+ })
234
+ const text = await response.text()
235
+ if (!response.ok) throw new Error(`qoder usage [${response.status}]: ${text.slice(0, 160)}`)
236
+ const payload = JSON.parse(text)
237
+ return payload?.data?.userQuota ?? payload?.data ?? payload ?? {}
238
+ }
239
+
240
+ /** Daily check-in (+100 credits). */
241
+ async checkin(credential) {
242
+ const headers = { accept: 'application/json', Authorization: `Bearer ${credential.authToken}` }
243
+ const listR = await fetch(this.urls().campaigns, { headers, signal: AbortSignal.timeout(20000) })
244
+ const listT = await listR.text()
245
+ if (!listR.ok) throw new Error(`qoder campaigns [${listR.status}]: ${listT.slice(0, 160)}`)
246
+ let campaigns = []
247
+ try { campaigns = JSON.parse(listT).data?.campaigns ?? [] } catch {}
248
+ const benefit = campaigns.find(c => c.actionType === 'CLAIM_BENEFIT')
249
+ if (!benefit) return { status: 'no-campaign', message: '该区域当前没有可领取的签到活动' }
250
+ if (benefit.claimStatus === 'CLAIMED') return { status: 'already-claimed', amount: benefit.benefit?.amount ?? 100 }
251
+ const claimR = await fetch(`${this.urls().campaigns}/${encodeURIComponent(benefit.campaignId)}/claim`, {
252
+ method: 'POST',
253
+ headers: { ...headers, 'content-type': 'application/json' },
254
+ body: '{}',
255
+ signal: AbortSignal.timeout(20000),
256
+ })
257
+ const claimT = await claimR.text()
258
+ if (!claimR.ok) throw new Error(`qoder claim [${claimR.status}]: ${claimT.slice(0, 160)}`)
259
+ let claim = {}
260
+ try { claim = JSON.parse(claimT).data ?? {} } catch {}
261
+ if (claim.status === 'CLAIMED') {
262
+ const amount = claim.benefit?.amount ?? benefit.benefit?.amount ?? 100
263
+ return { status: claim.replayed ? 'already-claimed' : 'claimed', amount }
264
+ }
265
+ return { status: 'failed', message: claimT.slice(0, 200) }
266
+ }
267
+
268
+ /**
269
+ * One chat turn. `prepared` is the caller's raw OpenAI request body (JSON
270
+ * string). Resolves {ok:true, response:{body}} where body is a ReadableStream
271
+ * of OpenAI-shaped SSE, or {ok:false, kind, status, message} when the
272
+ * failure lands before the first output frame.
273
+ */
274
+ async chatStream(credential, prepared, signal) {
275
+ let input
276
+ try { input = JSON.parse(prepared) } catch {
277
+ return { ok: false, kind: 'client', status: 400, message: 'invalid JSON body' }
278
+ }
279
+ const modelKey = typeof input.model === 'string' && input.model !== '' ? input.model : 'cmodel'
280
+ const stablePart = crypto.createHash('md5').update(`qoder-session:${credential.userID}:${modelKey}`).digest('hex')
281
+ // Wire shapes (verified against dsh-qoder-connect's translator):
282
+ // system/user content is a STRING; assistant carries OpenAI-nested
283
+ // tool_calls + string content; tool rows are flat {role, tool_call_id,
284
+ // content}; tools themselves are FLAT {type, name, description,
285
+ // parameters-object} — NOT the OpenAI nested function shape.
286
+ const textOf = (content) => {
287
+ if (typeof content === 'string') return content
288
+ if (Array.isArray(content)) return content.map(p => (typeof p === 'string' ? p : p?.text ?? '')).join('')
289
+ return ''
290
+ }
291
+ let lastUserText = ''
292
+ const wireMessages = (Array.isArray(input.messages) ? input.messages : []).map((raw) => {
293
+ const role = raw?.role === 'developer' ? 'system' : (raw?.role ?? 'user')
294
+ if (role === 'assistant') {
295
+ return {
296
+ role: 'assistant',
297
+ content: textOf(raw.content) || ' ',
298
+ ...(Array.isArray(raw.tool_calls) && raw.tool_calls.length > 0
299
+ ? { tool_calls: raw.tool_calls.map(call => ({ id: String(call.id ?? ''), type: 'function', function: { name: call.function?.name ?? '', arguments: call.function?.arguments ?? '' } })) }
300
+ : {}),
301
+ }
302
+ }
303
+ if (role === 'tool') {
304
+ return { role: 'tool', tool_call_id: String(raw.tool_call_id ?? ''), content: textOf(raw.content) }
305
+ }
306
+ return { role, content: textOf(raw.content) }
307
+ })
308
+ for (let i = wireMessages.length - 1; i >= 0; i--) {
309
+ if (wireMessages[i]?.role === 'user') { lastUserText = String(wireMessages[i].content ?? ''); break }
310
+ }
311
+ // Wire tools keep the OpenAI NESTED shape ({type, function:{name,
312
+ // description, parameters-object}}) — the upstream model API demands
313
+ // tools.0.function as an object ("'function' is a required property",
314
+ // live-verified 2026-10-25); dsh's flat translator feeds an internal
315
+ // shape, not this wire.
316
+ const tools = Array.isArray(input.tools) ? input.tools.map((raw) => ({
317
+ type: 'function',
318
+ function: {
319
+ name: raw?.function?.name ?? raw?.name ?? '',
320
+ description: raw?.function?.description ?? raw?.description ?? '',
321
+ parameters: typeof raw?.function?.parameters === 'string' ? safeJsonParse(raw.function.parameters) : (raw?.function?.parameters ?? raw?.parameters ?? { type: 'object', properties: {} }),
322
+ },
323
+ })) : null
324
+ const buildBody = () => {
325
+ const requestId = crypto.randomUUID()
326
+ const requestSetId = crypto.randomUUID()
327
+ const maxTokens = input.max_tokens > 0 ? input.max_tokens : undefined
328
+ const isReasoning = input.reasoning_effort !== undefined
329
+ return qoderEncodeBody(JSON.stringify({
330
+ request_id: requestId,
331
+ request_set_id: requestSetId,
332
+ chat_record_id: requestId,
333
+ session_id: `${stablePart}-${crypto.randomUUID()}`,
334
+ stream: true,
335
+ chat_task: 'FREE_INPUT',
336
+ is_reply: true,
337
+ is_retry: false,
338
+ source: 1,
339
+ version: '3',
340
+ session_type: 'qoderclicn',
341
+ agent_id: 'agent_common',
342
+ task_id: 'common',
343
+ code_language: '',
344
+ chat_prompt: '',
345
+ image_urls: null,
346
+ aliyun_user_type: '',
347
+ system: '',
348
+ messages: wireMessages,
349
+ tools,
350
+ parameters: {
351
+ ...(maxTokens === undefined ? {} : { max_tokens: maxTokens }),
352
+ ...(typeof input.reasoning_effort === 'string' && input.reasoning_effort !== '' ? { reasoning_effort: input.reasoning_effort } : {}),
353
+ },
354
+ chat_context: {
355
+ chatPrompt: '',
356
+ imageUrls: null,
357
+ extra: {
358
+ context: [],
359
+ modelConfig: { key: modelKey, is_reasoning: isReasoning },
360
+ originalContent: lastUserText,
361
+ features: [],
362
+ },
363
+ text: lastUserText,
364
+ },
365
+ model_config: {
366
+ key: modelKey,
367
+ is_reasoning: isReasoning,
368
+ ...(maxTokens === undefined ? {} : { max_output_tokens: maxTokens }),
369
+ source: 'system',
370
+ },
371
+ business: {
372
+ product: 'cli',
373
+ version: '1.0.0',
374
+ type: 'agent',
375
+ stage: 'start',
376
+ id: requestSetId,
377
+ name: lastUserText.slice(0, 30),
378
+ begin_at: Date.now(),
379
+ },
380
+ }))
381
+ }
382
+ const chatUrl = this.urls().chat
383
+ // 10605 = "model request is queued": free-tier models answer HTTP 200
384
+ // whose FIRST SSE frame is a 403/queue envelope, or a plain HTTP 403.
385
+ // Either shape retries with a fresh request id (dsh rides a queue-status
386
+ // poll; a timed retry is the pragmatic equivalent).
387
+ const isQueueBody = (text) => /10605|isQueued|queueCount/.test(String(text))
388
+ {
389
+ // Two-layer SSE: data frames hold outer {statusCodeValue?, body:string};
390
+ // body carries the inner OpenAI chunk (choices[].delta.{content,
391
+ // reasoning_content, tool_calls}, usage). The 10605 queue envelope is a
392
+ // REPLAY signal: the gateway closes the SSE for a queued request and
393
+ // the client must wait and replay (dsh rides its queue-status poll; a
394
+ // timed replay is the pragmatic equivalent). The first real output
395
+ // chunk hands the response to the caller immediately, and the rest is
396
+ // pumped from inside the stream.
397
+ //
398
+ // NOTE: there used to be a second, identical fetch in an OUTER loop
399
+ // before this one whose response was never read. Every turn therefore
400
+ // opened the model twice: two billed generations, and one abandoned
401
+ // upstream stream per turn (which the account's concurrency limit counts
402
+ // against the next turn). One request per attempt, starting here.
403
+ const encoder = new TextEncoder()
404
+ const emitFrom = (controller, inner) => {
405
+ const choice = Array.isArray(inner.choices) ? inner.choices[0] : undefined
406
+ if (!choice) return null
407
+ const delta = choice.delta ?? {}
408
+ const carry = {}
409
+ if (typeof delta.content === 'string' && delta.content !== '') carry.content = delta.content
410
+ if (typeof delta.reasoning_content === 'string' && delta.reasoning_content !== '') carry.reasoning_content = delta.reasoning_content
411
+ if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) {
412
+ carry.tool_calls = delta.tool_calls.map((call, i) => ({
413
+ index: call.index ?? i,
414
+ ...(call.id ? { id: call.id } : {}),
415
+ type: 'function',
416
+ function: { ...(call.id ? { name: call.function?.name ?? '' } : {}), arguments: call.function?.arguments ?? '' },
417
+ }))
418
+ }
419
+ const finish = typeof choice.finish_reason === 'string' && choice.finish_reason !== '' ? choice.finish_reason : null
420
+ if (Object.keys(carry).length > 0 || finish) {
421
+ controller.enqueue(encoder.encode('data: ' + JSON.stringify({ choices: [{ index: 0, delta: carry, finish_reason: finish }] }) + '\n\n'))
422
+ }
423
+ return finish
424
+ }
425
+ for (let attempt = 0; attempt < 4; attempt++) {
426
+ if (attempt > 0) await new Promise(resolve => setTimeout(resolve, 6000))
427
+ const encoded = buildBody()
428
+ let response
429
+ // ⚠️ 可清除的 AbortController,而不是 AbortSignal.timeout:后者绑进 fetch 后
430
+ // 会被 undici 维持到整个响应生命周期(含 body 流),长回答会在第 N 秒被掐死。
431
+ // 这里只覆盖「响应头到达之前」——上游接单不回是最常见的挂起形态。
432
+ const headerAbort = new AbortController()
433
+ const headerTimer = setTimeout(() => {
434
+ headerAbort.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
435
+ }, UPSTREAM_HEADER_TIMEOUT_MS)
436
+ try {
437
+ response = await fetch(chatUrl, {
438
+ method: 'POST',
439
+ headers: {
440
+ 'content-type': 'application/json',
441
+ accept: 'text/event-stream',
442
+ 'cache-control': 'no-cache',
443
+ 'accept-encoding': 'identity',
444
+ 'x-model-key': modelKey,
445
+ 'x-model-source': 'system',
446
+ 'user-agent': USER_AGENT,
447
+ ...buildCosyHeaders({ body: encoded, url: chatUrl, credential }),
448
+ },
449
+ body: encoded,
450
+ signal: signal === undefined ? headerAbort.signal : AbortSignal.any([signal, headerAbort.signal]),
451
+ })
452
+ } catch (error) {
453
+ return { ok: false, kind: 'server', status: 0, message: `transport error: ${String(error).slice(0, 160)}` }
454
+ } finally {
455
+ clearTimeout(headerTimer)
456
+ }
457
+ if (!response.ok) {
458
+ const text = await response.text().catch(() => '')
459
+ if (isQueueBody(text) && attempt < 3) continue
460
+ return { ok: false, ...classifyStatus(response.status, text), message: `qoder chat [${response.status}]: ${text.slice(0, 300)}` }
461
+ }
462
+
463
+ async function* iterateFrames() {
464
+ const reader = response.body.getReader()
465
+ const decoder = new TextDecoder()
466
+ let buffer = ''
467
+ try {
468
+ for (;;) {
469
+ const { done, value } = await reader.read()
470
+ if (done) return
471
+ buffer += decoder.decode(value, { stream: true })
472
+ let index
473
+ while ((index = buffer.indexOf('\n')) >= 0) {
474
+ let line = buffer.substring(0, index)
475
+ buffer = buffer.substring(index + 1)
476
+ if (line.endsWith('\r')) line = line.slice(0, -1)
477
+ line = line.trim()
478
+ if (!line.startsWith('data:')) continue
479
+ const rawData = line.substring(5).trim()
480
+ if (!rawData) continue
481
+ if (rawData === '[DONE]') return
482
+ let outer
483
+ try { outer = JSON.parse(rawData) } catch { continue }
484
+ if (typeof outer.statusCodeValue === 'number' && outer.statusCodeValue !== 200) {
485
+ yield { kind: 'status', statusCodeValue: outer.statusCodeValue, bodyText: String(outer.body ?? '') }
486
+ continue
487
+ }
488
+ const bodyText = typeof outer.body === 'string' ? outer.body.trim() : ''
489
+ if (!bodyText) continue
490
+ if (bodyText === '[DONE]') return
491
+ let inner
492
+ try { inner = JSON.parse(bodyText) } catch { continue }
493
+ yield { kind: 'chunk', chunk: inner }
494
+ }
495
+ }
496
+ } finally {
497
+ // An abandoned frame generator must also release the socket: the
498
+ // gateway counts the open stream against the account's concurrency.
499
+ try { await reader.cancel() } catch {}
500
+ try { reader.releaseLock() } catch {}
501
+ }
502
+ }
503
+ const frames = iterateFrames()
504
+ let firstChunk = null
505
+ for (;;) {
506
+ const next = await frames.next()
507
+ if (next.done) break
508
+ const frame = next.value
509
+ if (frame.kind === 'status') {
510
+ if (frame.statusCodeValue !== 200) {
511
+ if (isQueueBody(frame.bodyText) && attempt < 3) break
512
+ frames.return?.()
513
+ return { ok: false, status: 502, kind: 'server', message: `qoder upstream ${frame.statusCodeValue}: ${frame.bodyText.slice(0, 200)}` }
514
+ }
515
+ continue
516
+ }
517
+ firstChunk = frame.chunk
518
+ break
519
+ }
520
+ if (!firstChunk) continue
521
+
522
+ const stream = new ReadableStream({
523
+ async start(controller) {
524
+ let sawFinishFrame = false
525
+ let sawOutput = false
526
+ const push = (inner) => {
527
+ const choice = Array.isArray(inner.choices) ? inner.choices[0] : undefined
528
+ const delta = choice?.delta ?? {}
529
+ if ((typeof delta.content === 'string' && delta.content !== '')
530
+ || (typeof delta.reasoning_content === 'string' && delta.reasoning_content !== '')
531
+ || (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0)) sawOutput = true
532
+ const finish = emitFrom(controller, inner)
533
+ if (finish !== null) sawFinishFrame = true
534
+ return finish !== null
535
+ }
536
+ try {
537
+ if (push(firstChunk) === false) {
538
+ for (;;) {
539
+ const next = await frames.next()
540
+ if (next.done) break
541
+ const frame = next.value
542
+ if (frame.kind === 'status' && frame.statusCodeValue !== 200) break
543
+ if (frame.kind !== 'chunk') continue
544
+ if (push(frame.chunk)) break
545
+ }
546
+ }
547
+ if (!sawFinishFrame) {
548
+ // The gateway closed the stream without a finish frame. Passing
549
+ // that off as a plain `stop` is what makes a cut answer look
550
+ // like a finished one; `length` says it was cut.
551
+ controller.enqueue(encoder.encode('data: ' + JSON.stringify({
552
+ choices: [{ index: 0, delta: {}, finish_reason: sawOutput ? 'length' : 'network_error' }],
553
+ }) + '\n\n'))
554
+ if (!sawOutput) {
555
+ controller.enqueue(encoder.encode('data: ' + JSON.stringify({
556
+ error: { message: 'upstream stream ended without any output', type: 'upstream_error' },
557
+ }) + '\n\n'))
558
+ }
559
+ }
560
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
561
+ controller.close()
562
+ } catch (error) {
563
+ try {
564
+ controller.enqueue(encoder.encode('data: ' + JSON.stringify({
565
+ error: { message: String(error?.message ?? error).slice(0, 300), type: 'upstream_error' },
566
+ }) + '\n\n'))
567
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
568
+ } catch {}
569
+ try { controller.close() } catch {}
570
+ }
571
+ },
572
+ cancel() {
573
+ try { frames.return() } catch {}
574
+ },
575
+ })
576
+ return { ok: true, response: { body: stream } }
577
+ }
578
+ return { ok: false, kind: 'soft_rate', status: 429, message: 'qoder kept queueing (10605) after 4 attempts' }
579
+ }
580
+ }
581
+ }