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.
- package/.zcode-plugin/plugin.json +40 -0
- package/LICENSE +21 -0
- package/README.md +71 -2
- package/bin/cli.mjs +204 -0
- package/docs/validate-provider-config.mjs +203 -0
- package/hooks/service-ensure.mjs +115 -0
- package/package.json +50 -4
- package/skills/qoder-models/SKILL.md +68 -0
- package/src/catalog.js +58 -0
- package/src/config.js +81 -0
- package/src/credentials.js +185 -0
- package/src/loopback.js +40 -0
- package/src/server.js +799 -0
- package/src/upstream.js +581 -0
package/src/upstream.js
ADDED
|
@@ -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
|
+
}
|