dsh-connect-qoder 0.1.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,1232 @@
1
+ /**
2
+ * The Qoder wire protocol.
3
+ *
4
+ * Qoder is not an OpenAI-compatible endpoint, so three separate mechanisms have
5
+ * to be reproduced to talk to it:
6
+ *
7
+ * 1. **COSY signed headers.** Every gateway request carries a set of `Cosy-*`
8
+ * headers plus an `Authorization: Bearer COSY.<payload>.<sig>` value. The
9
+ * signature is an MD5 over the base64 payload, the RSA-wrapped AES key, the
10
+ * timestamp, the request body, and the signature path.
11
+ * 2. **A permuted base64 body.** The `Encode=1` query flag means the JSON body
12
+ * is base64-encoded, its alphabet is permuted, and the resulting string's
13
+ * characters are rotated by thirds.
14
+ * 3. **Doubly-wrapped SSE.** Each `data:` frame is an envelope object whose
15
+ * `body` field is *itself* a JSON string holding an OpenAI-style chunk.
16
+ *
17
+ * The constants below are protocol facts recovered from the client; they are
18
+ * not configuration.
19
+ *
20
+ * @module dsh-connect-qoder/upstream
21
+ */
22
+ import crypto from 'node:crypto'
23
+ import { classifyUpstreamError } from './errors.js'
24
+
25
+ /** Public key the gateway expects the per-request AES key to be wrapped with. */
26
+ const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
27
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
28
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
29
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
30
+ XcW+ML9FoCI6AOvOzwIDAQAB
31
+ -----END PUBLIC KEY-----`
32
+
33
+ /** COSY protocol revision the gateway is currently serving. */
34
+ const COSY_VERSION = '1.1.38'
35
+ /** Client type magic the gateway expects from a CLI client. */
36
+ const CLIENT_TYPE = '5'
37
+ /** Machine type magic the gateway expects. */
38
+ const MACHINE_TYPE = '5'
39
+ /** Data-policy value a non-consenting client sends. */
40
+ const DATA_POLICY = 'disagree'
41
+
42
+ /**
43
+ * How long one turn may spend waiting out Qoder's queue, in total.
44
+ *
45
+ * The gateway answers a queued request with `10605` plus a `retryAfterSeconds`
46
+ * hint, and the official client simply waits and tries again. Handing that wait
47
+ * to DSH instead does not work: DSH's retry policy is fixed (5 attempts, 500 ms
48
+ * doubling to a 10 s ceiling, ~40 s of total budget) and ignores the hint, so a
49
+ * queue that clears in 60 s exhausts the budget and the turn fails — after
50
+ * which the user has to send the message again, which restarts the turn from
51
+ * scratch rather than resuming the wait.
52
+ *
53
+ * This module therefore owns the wait. The budget stays well under the 300 s
54
+ * idle ceiling the adapter declares, so a queued turn is never killed by the
55
+ * stream watchdog while it waits.
56
+ */
57
+ const QUEUE_WAIT_BUDGET_MS = 120000
58
+
59
+ /** Longest single sleep, so one absurd hint cannot stall a turn indefinitely. */
60
+ const QUEUE_WAIT_MAX_SLEEP_MS = 30000
61
+
62
+ /** Sleep before the first retry when the gateway gives no hint at all. */
63
+ const QUEUE_WAIT_MIN_SLEEP_MS = 1000
64
+
65
+ /** Abortable sleep; resolves early (without throwing) when the signal aborts. */
66
+ function sleep(ms, signal) {
67
+ return new Promise((resolve) => {
68
+ if (signal?.aborted) {
69
+ resolve()
70
+ return
71
+ }
72
+ const timer = setTimeout(finish, Math.max(0, ms))
73
+ function finish() {
74
+ clearTimeout(timer)
75
+ signal?.removeEventListener('abort', finish)
76
+ resolve()
77
+ }
78
+ signal?.addEventListener('abort', finish, { once: true })
79
+ })
80
+ }
81
+
82
+ /**
83
+ * Classify one error frame into `{ kind, retryAfterSeconds, code, detail }`.
84
+ *
85
+ * Shared by the HTTP-status path and the in-band frame path so both agree on
86
+ * what a given payload means. A frame that carries no failure at all — no error
87
+ * code and no explicit `success: false` — returns `undefined`, which is how the
88
+ * frame reader tells a status frame apart from a chunk.
89
+ *
90
+ * @param chunk - one decoded frame, or a synthetic `{ code, message }` built
91
+ * from a non-2xx HTTP reply.
92
+ * @param fallbackCode - code to assume when the payload names none (the HTTP
93
+ * status, on the transport path).
94
+ */
95
+ function readFailure(chunk, fallbackCode = '') {
96
+ if (chunk === null || typeof chunk !== 'object') return undefined
97
+ if (chunk.success !== false && chunk.code === undefined && chunk.errorCode === undefined) {
98
+ if (fallbackCode === '') return undefined
99
+ }
100
+ const { code, detail } = unwrapFailure(chunk)
101
+ const effective = code !== '' ? code : fallbackCode
102
+ const kind = classifyUpstreamError(chunk, effective, detail)
103
+ return { kind: kind.kind, retryAfterSeconds: kind.retryAfterSeconds ?? 0, code: effective, detail }
104
+ }
105
+ /** Login protocol revision. */
106
+ const LOGIN_VERSION = 'v2'
107
+
108
+ /** Machine OS string, spelled the way the gateway expects. */
109
+ export const MACHINE_OS =
110
+ process.platform === 'win32'
111
+ ? process.arch === 'arm64'
112
+ ? 'aarch64_windows'
113
+ : 'x86_64_windows'
114
+ : process.arch === 'arm64'
115
+ ? 'aarch64_linux'
116
+ : 'x86_64_linux'
117
+
118
+ /** Permuted base64 alphabet used when `Encode=1` is in effect. */
119
+ const CUSTOM_ALPHABET = '_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!'
120
+ /** Standard base64 alphabet, positionally mapped onto the custom one. */
121
+ const STD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
122
+
123
+ /** Positional translation table from standard to custom alphabet. */
124
+ const ENCODE_TABLE = (() => {
125
+ const table = new Uint8Array(256)
126
+ for (let i = 0; i < table.length; i++) table[i] = i
127
+ for (let i = 0; i < STD_ALPHABET.length; i++) {
128
+ table[STD_ALPHABET.charCodeAt(i)] = CUSTOM_ALPHABET.charCodeAt(i)
129
+ }
130
+ table['='.charCodeAt(0)] = '$'.charCodeAt(0)
131
+ return table
132
+ })()
133
+
134
+ /**
135
+ * Encode a request body the way the `Encode=1` flag requires.
136
+ *
137
+ * The transform is: base64 the bytes, translate each character through the
138
+ * permuted alphabet, then rotate the string so the last third comes first and
139
+ * the first third goes last.
140
+ *
141
+ * @param plaintext - the JSON body bytes.
142
+ * @returns the encoded bytes to send as the request body.
143
+ */
144
+ export function encodeBody(plaintext) {
145
+ const bytes = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(plaintext)
146
+ const std = bytes.toString('base64')
147
+ const n = std.length
148
+ const third = Math.floor(n / 3)
149
+ const out = Buffer.allocUnsafe(n)
150
+ let dst = 0
151
+ for (let i = n - third; i < n; i++) out[dst++] = ENCODE_TABLE[std.charCodeAt(i)]
152
+ for (let i = third; i < n - third; i++) out[dst++] = ENCODE_TABLE[std.charCodeAt(i)]
153
+ for (let i = 0; i < third; i++) out[dst++] = ENCODE_TABLE[std.charCodeAt(i)]
154
+ return out
155
+ }
156
+
157
+ /** AES-128-CBC encrypt with the key doubling as the IV, base64-encoded. */
158
+ function aesEncryptCBCBase64(plaintext, keyString) {
159
+ const key = Buffer.from(keyString)
160
+ const cipher = crypto.createCipheriv('aes-128-cbc', key, key)
161
+ return cipher.update(plaintext, 'utf8', 'base64') + cipher.final('base64')
162
+ }
163
+
164
+ /**
165
+ * The path the signature covers: the request path with a leading `/algo`
166
+ * stripped, because the gateway routes that prefix away before verifying.
167
+ */
168
+ export function signaturePath(url) {
169
+ let path = new URL(url).pathname
170
+ if (path.startsWith('/algo')) path = path.slice('/algo'.length)
171
+ return path
172
+ }
173
+
174
+ /**
175
+ * Build the full authenticated header set for one gateway request.
176
+ *
177
+ * @param body - the exact bytes that will be sent (already encoded).
178
+ * @param url - the absolute request URL.
179
+ * @param credential - `{ userID, token, name, email, machineID }`.
180
+ * @returns the headers to merge into the request.
181
+ */
182
+ export function authHeaders(body, url, credential) {
183
+ const aesKey = crypto.randomUUID().replace(/-/g, '').slice(0, 16)
184
+ const infoB64 = aesEncryptCBCBase64(
185
+ JSON.stringify({
186
+ uid: credential.userID,
187
+ security_oauth_token: credential.token,
188
+ name: credential.name ?? '',
189
+ aid: '',
190
+ email: credential.email ?? '',
191
+ }),
192
+ aesKey,
193
+ )
194
+ const cosyKey = crypto
195
+ .publicEncrypt(
196
+ { key: QODER_RSA_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING },
197
+ Buffer.from(aesKey),
198
+ )
199
+ .toString('base64')
200
+
201
+ const timestamp = Math.floor(Date.now() / 1000).toString()
202
+ const payloadB64 = Buffer.from(
203
+ JSON.stringify({
204
+ version: 'v1',
205
+ requestId: crypto.randomUUID(),
206
+ info: infoB64,
207
+ cosyVersion: COSY_VERSION,
208
+ ideVersion: '',
209
+ }),
210
+ ).toString('base64')
211
+
212
+ const path = signaturePath(url)
213
+ const bodyBytes = body ?? Buffer.alloc(0)
214
+ const sig = crypto
215
+ .createHash('md5')
216
+ .update(payloadB64)
217
+ .update('\n')
218
+ .update(cosyKey)
219
+ .update('\n')
220
+ .update(timestamp)
221
+ .update('\n')
222
+ .update(bodyBytes)
223
+ .update('\n')
224
+ .update(path)
225
+ .digest('hex')
226
+
227
+ const machineID = credential.machineID
228
+ return {
229
+ Authorization: `Bearer COSY.${payloadB64}.${sig}`,
230
+ 'Cosy-Key': cosyKey,
231
+ 'Cosy-User': credential.userID,
232
+ 'Cosy-Date': timestamp,
233
+ 'Cosy-Version': COSY_VERSION,
234
+ 'Cosy-Machineid': machineID,
235
+ 'Cosy-Machinetoken': machineID,
236
+ 'Cosy-Machinetype': MACHINE_TYPE,
237
+ 'Cosy-Machineos': MACHINE_OS,
238
+ 'Cosy-Clienttype': CLIENT_TYPE,
239
+ 'Cosy-Clientip': '127.0.0.1',
240
+ 'Cosy-Bodyhash': crypto.createHash('md5').update(bodyBytes).digest('hex'),
241
+ 'Cosy-Bodylength': String(bodyBytes.length),
242
+ 'Cosy-Sigpath': path,
243
+ 'Cosy-Data-Policy': DATA_POLICY,
244
+ 'Cosy-Organization-Id': '',
245
+ 'Cosy-Organization-Tags': '',
246
+ 'Login-Version': LOGIN_VERSION,
247
+ 'X-Request-Id': crypto.randomUUID(),
248
+ }
249
+ }
250
+
251
+ /** URL listing the models this account may use. */
252
+ export function modelListUrl(region) {
253
+ return `${region.baseUrl}algo/api/v2/model/list?Encode=1`
254
+ }
255
+
256
+ /** URL of the streaming chat endpoint. */
257
+ export function chatUrl(region) {
258
+ return `${region.baseUrl}algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`
259
+ }
260
+
261
+ /** URL exchanging a personal access token for a job token. */
262
+ export function exchangeUrl(region) {
263
+ return `${region.openApiUrl}/api/v1/jobToken/exchange`
264
+ }
265
+
266
+ /** URL returning the signed-in account's profile. */
267
+ export function userInfoUrl(region) {
268
+ return `${region.openApiUrl}/api/v1/userinfo`
269
+ }
270
+
271
+ /** URL returning the account's quota usage. */
272
+ export function usageUrl(region) {
273
+ return `${region.openApiUrl}/api/v2/quota/usage`
274
+ }
275
+
276
+ /**
277
+ * URL the Qoder IDE's own "我的用量" panel reads.
278
+ *
279
+ * The `sash` route is the presentation layer: it answers with a `displayMode`
280
+ * wrapper and carries `dedicatedResourcePackages`, the per-model promotional
281
+ * allowances the panel lists beside the plan and add-on quotas. The plain
282
+ * `quota/usage` route returns only the plan and add-on halves, so this is the
283
+ * one to prefer and the other is the fallback.
284
+ */
285
+ export function usagePresentationUrl(region) {
286
+ return `${region.openApiUrl}/sash/api/v2/me/usage`
287
+ }
288
+
289
+ /**
290
+ * URL listing the account's active campaigns.
291
+ *
292
+ * The IDE's usage panel renders a campaign's `USAGE` placement as the extra
293
+ * promotional row under the quotas — the one carrying a "限时特惠" badge and an
294
+ * end date. The quota routes do not carry that text, so it is read separately
295
+ * and merged in.
296
+ */
297
+ export function campaignsUrl(region) {
298
+ return `${region.openApiUrl}/sash/api/v1/me/campaigns`
299
+ }
300
+
301
+ /**
302
+ * Headers the Qoder IDE itself sends to the OpenAPI host.
303
+ *
304
+ * `Cosy-ClientType: 10` identifies the desktop app. The quota and campaign
305
+ * routes answer without it, but sending what the real client sends keeps this
306
+ * from depending on a default the server may tighten later.
307
+ */
308
+ function openApiHeaders(credential) {
309
+ return {
310
+ Accept: 'application/json',
311
+ Authorization: `Bearer ${credential.token}`,
312
+ 'Cosy-ClientType': '10',
313
+ 'User-Agent': 'Qoder',
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Read the account's active promotional campaigns, keeping only the parts the
319
+ * usage panel can render.
320
+ *
321
+ * A campaign contributes a row only through its `USAGE` placement, and only the
322
+ * copy is taken — the amounts it may carry describe what was granted, not what
323
+ * is left, so they are not shown as a balance.
324
+ *
325
+ * @returns `[{ key, title, description, detailUrl, endsAt }]`, newest end first.
326
+ */
327
+ export async function fetchCampaigns(region, credential, signal) {
328
+ const response = await fetch(campaignsUrl(region), {
329
+ method: 'GET',
330
+ headers: openApiHeaders(credential),
331
+ signal,
332
+ })
333
+ if (!response.ok) throw new Error(`Qoder campaigns failed: HTTP ${response.status}`)
334
+ const payload = await response.json()
335
+ const list = Array.isArray(payload?.campaigns) ? payload.campaigns : []
336
+ const rows = []
337
+ for (const campaign of list) {
338
+ const placements = Array.isArray(campaign?.placements) ? campaign.placements : []
339
+ const usage = placements.find((entry) => entry?.type === 'USAGE')
340
+ if (usage === undefined) continue
341
+ // The panel is bilingual; prefer the Simplified Chinese copy to match the
342
+ // rest of this card, and fall back to English when only that exists.
343
+ const content = usage.content?.zh ?? usage.content?.['zh-CN'] ?? usage.content?.en ?? {}
344
+ const endsAt = Number(campaign.endAt)
345
+ rows.push({
346
+ key: String(campaign.campaignKey ?? campaign.campaignId ?? ''),
347
+ title: typeof content.title === 'string' ? content.title : '',
348
+ description: typeof content.description === 'string' ? content.description : '',
349
+ detailUrl: typeof content.detailUrl === 'string' ? content.detailUrl : '',
350
+ ...(Number.isFinite(endsAt) && endsAt > 0 ? { endsAt: endsAt * 1000 } : {}),
351
+ })
352
+ }
353
+ return rows
354
+ }
355
+
356
+ /** Coerce one quota bucket into `{ total, used, remaining, percentage, unit }`. */
357
+ function normalizeQuotaBucket(value) {
358
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
359
+ const total = Number(value.total)
360
+ const used = Number(value.used)
361
+ if (!Number.isFinite(total) || total <= 0) return undefined
362
+ const safeUsed = Number.isFinite(used) ? Math.max(0, used) : 0
363
+ const remainingRaw = Number(value.remaining)
364
+ const remaining = Number.isFinite(remainingRaw) ? Math.max(0, remainingRaw) : Math.max(0, total - safeUsed)
365
+ const percentageRaw = Number(value.percentage)
366
+ const percentage = Number.isFinite(percentageRaw)
367
+ ? percentageRaw > 1
368
+ ? percentageRaw / 100
369
+ : percentageRaw
370
+ : safeUsed / total
371
+ return {
372
+ total,
373
+ used: safeUsed,
374
+ remaining,
375
+ percentage: Math.min(1, Math.max(0, percentage)),
376
+ unit: typeof value.unit === 'string' && value.unit.length > 0 ? value.unit : 'credits',
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Normalize one dedicated (per-model) resource package.
382
+ *
383
+ * These are the promotional allowances the IDE panel shows as e.g.
384
+ * "Qwen3.8-Max 免费额度 1402 / 2000 次" — a package bound to a set of models
385
+ * rather than to the account as a whole. Packages that declare no usable total
386
+ * are dropped, matching the client's own filtering.
387
+ */
388
+ function normalizeDedicatedPackage(value) {
389
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
390
+ const id = typeof value.id === 'string' ? value.id.trim() : ''
391
+ const total = Number(value.total)
392
+ if (id === '' || !Number.isFinite(total) || total <= 0) return undefined
393
+ const used = Number(value.used)
394
+ const remainingRaw = Number(value.remaining)
395
+ const safeUsed = Number.isFinite(used) ? Math.max(0, used) : 0
396
+ const remaining = Number.isFinite(remainingRaw) ? Math.max(0, remainingRaw) : Math.max(0, total - safeUsed)
397
+ const percentageRaw = Number(value.percentage)
398
+ const percentage = Number.isFinite(percentageRaw)
399
+ ? percentageRaw > 1
400
+ ? percentageRaw / 100
401
+ : percentageRaw
402
+ : safeUsed / total
403
+ const expiresAt = Number(value.expiresAt)
404
+ return {
405
+ id,
406
+ name: typeof value.name === 'string' ? value.name : '',
407
+ description: typeof value.description === 'string' ? value.description : '',
408
+ total,
409
+ used: safeUsed,
410
+ remaining,
411
+ percentage: Math.min(1, Math.max(0, percentage)),
412
+ unit: typeof value.unit === 'string' && value.unit.length > 0 ? value.unit : 'credits',
413
+ ...(Number.isFinite(expiresAt) && expiresAt > 0 ? { expiresAt } : {}),
414
+ available: value.available !== false,
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Read the account's usage, shaped the way the IDE's panel presents it.
420
+ *
421
+ * Two routes are tried because they carry different halves of the picture: the
422
+ * `sash` presentation route includes the per-model dedicated packages, while
423
+ * `quota/usage` is the older, narrower shape. Either alone is enough to render
424
+ * something useful, so a failure of the first falls back to the second rather
425
+ * than failing the whole read.
426
+ *
427
+ * @returns `{ displayMode, userType, expiresAt, upgradeUrl, userQuota, addOnQuota,
428
+ * dedicatedPackages, isQuotaExceeded, source }`, or `undefined` when neither
429
+ * route answered.
430
+ */
431
+ export async function fetchUsage(region, credential, signal) {
432
+ const headers = openApiHeaders(credential)
433
+
434
+ const read = async (url) => {
435
+ const response = await fetch(url, { method: 'GET', headers, signal })
436
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
437
+ return response.json()
438
+ }
439
+
440
+ let payload
441
+ let source = 'presentation'
442
+ try {
443
+ payload = await read(usagePresentationUrl(region))
444
+ } catch (error) {
445
+ if (signal?.aborted) throw error
446
+ source = 'quota'
447
+ payload = await read(usageUrl(region))
448
+ }
449
+
450
+ // The presentation route wraps the same body under `qoderUsage`; the plain
451
+ // route returns it directly. Both spellings of each field are accepted.
452
+ const usage = payload?.qoderUsage ?? payload?.data ?? payload
453
+ if (usage === null || typeof usage !== 'object') return undefined
454
+
455
+ const userQuota = normalizeQuotaBucket(usage.user_quota ?? usage.userQuota)
456
+ const addOnQuota = normalizeQuotaBucket(usage.add_on_quota ?? usage.addOnQuota)
457
+ const rawPackages = usage.dedicated_resource_packages ?? usage.dedicatedResourcePackages
458
+ const dedicatedPackages = Array.isArray(rawPackages)
459
+ ? rawPackages.map(normalizeDedicatedPackage).filter((entry) => entry !== undefined)
460
+ : []
461
+
462
+ // Campaigns are supplementary: the panel is still useful without them, so a
463
+ // failure here must not cost the user their quota numbers.
464
+ let campaigns = []
465
+ try {
466
+ campaigns = await fetchCampaigns(region, credential, signal)
467
+ } catch (error) {
468
+ if (signal?.aborted) throw error
469
+ }
470
+
471
+ const expiresAt = Number(usage.expires_at ?? usage.expiresAt)
472
+ const isQuotaExceeded = usage.is_quota_exceeded ?? usage.isQuotaExceeded
473
+
474
+ // Nothing usable at all is reported as "no data" so the card can say so
475
+ // instead of rendering an empty panel.
476
+ if (userQuota === undefined && addOnQuota === undefined && dedicatedPackages.length === 0) return undefined
477
+
478
+ return {
479
+ displayMode: typeof payload?.displayMode === 'string' ? payload.displayMode : 'qoder',
480
+ userType: String(usage.user_type ?? usage.userType ?? ''),
481
+ ...(Number.isFinite(expiresAt) && expiresAt > 0 ? { expiresAt } : {}),
482
+ upgradeUrl: String(usage.upgrade_url ?? usage.upgradeUrl ?? ''),
483
+ ...(userQuota !== undefined ? { userQuota } : {}),
484
+ ...(addOnQuota !== undefined ? { addOnQuota } : {}),
485
+ dedicatedPackages,
486
+ campaigns,
487
+ isQuotaExceeded: isQuotaExceeded === true,
488
+ source,
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Fetch and normalize the account's model catalog.
494
+ *
495
+ * The response is grouped by product surface (`chat`, `developer`, `quest`,
496
+ * ...). The `chat` group is the one that answers on the `agent_common` route,
497
+ * so only it is used.
498
+ *
499
+ * @returns an array of `{ key, name, isVL, isReasoning, maxInputTokens, ... }`.
500
+ */
501
+ export async function fetchModels(region, credential, signal) {
502
+ const url = modelListUrl(region)
503
+ const headers = authHeaders(Buffer.alloc(0), url, credential)
504
+ const response = await fetch(url, {
505
+ method: 'GET',
506
+ headers: { Accept: 'application/json', ...headers },
507
+ signal,
508
+ })
509
+ if (!response.ok) {
510
+ throw new Error(`Qoder model list failed: HTTP ${response.status} ${(await response.text()).slice(0, 300)}`)
511
+ }
512
+ const data = await response.json()
513
+ const chat = data?.chat
514
+ if (chat === null || typeof chat !== 'object') return []
515
+ const models = []
516
+ for (const entry of Object.values(chat)) {
517
+ if (entry === null || typeof entry !== 'object') continue
518
+ if (typeof entry.key !== 'string' || entry.key.length === 0) continue
519
+ if (entry.enable === false) continue
520
+ if (typeof entry.display_name !== 'string' || entry.display_name.length === 0) continue
521
+ const config = entry.thinking_config
522
+ const efforts = config?.enabled?.efforts
523
+ // `efforts` is an object keyed by level (`{ high: {}, max: {} }`), not an
524
+ // array, and `disabled` is present only on models that permit turning
525
+ // thinking off. A model with `enabled` and no `disabled` block always
526
+ // thinks: it answers `enable_thinking: false` with provider_error 1210
527
+ // ("该模型始终思考,不支持关闭思考"), so the flag must be omitted for it.
528
+ const effortLevels = efforts !== null && typeof efforts === 'object' ? Object.keys(efforts) : []
529
+ const supportsEffort = effortLevels.length > 0
530
+ const canDisableThinking = config?.disabled !== undefined
531
+
532
+ // The catalog publishes its selectable context windows as `context_config`
533
+ // (`{ "1M": {...}, "200K": { is_default: true }, ... }`) and publishes no
534
+ // output ceiling anywhere. Both facts matter downstream:
535
+ //
536
+ // - `context_config` is the only place the offered window sizes exist, and
537
+ // the entry flagged `is_default` is the one the app itself starts on, so
538
+ // these become the choices the model picker can offer.
539
+ // - No output ceiling is reported on purpose. Declaring one makes
540
+ // dsh-llm-pi-ai record it as the model's *configured* max tokens, and a
541
+ // long reasoned reply then ends with `finish: max-tokens` — the visible
542
+ // text is cut off mid-sentence. Leaving it undeclared lets the harness
543
+ // use its own default instead.
544
+ const windows = entry.context_config
545
+ const contextOptions = []
546
+ let defaultContextWindow = 0
547
+ if (windows !== null && typeof windows === 'object') {
548
+ for (const value of Object.values(windows)) {
549
+ const tokens = Number(value?.token_count)
550
+ if (!Number.isFinite(tokens) || tokens <= 0) continue
551
+ contextOptions.push(tokens)
552
+ if (value?.is_default === true) defaultContextWindow = tokens
553
+ }
554
+ contextOptions.sort((left, right) => left - right)
555
+ }
556
+ if (defaultContextWindow === 0) defaultContextWindow = Number(entry.max_input_tokens) || 0
557
+
558
+ models.push({
559
+ key: entry.key,
560
+ name: entry.display_name,
561
+ isVL: entry.is_vl === true,
562
+ isReasoning: entry.is_reasoning === true || config !== undefined,
563
+ supportsEffort,
564
+ alwaysThinking: supportsEffort && !canDisableThinking,
565
+ effortLevels,
566
+ defaultContextWindow,
567
+ contextOptions,
568
+ maxInputTokens: Number(entry.max_input_tokens) || 0,
569
+ isDefault: entry.is_default === true,
570
+ priceFactor: Number(entry.price_factor) || 0,
571
+ isFree: entry.is_free === true,
572
+ promotion: normalizePromotion(entry.promotion),
573
+ })
574
+ }
575
+ return models
576
+ }
577
+
578
+ /**
579
+ * Normalize one model's time-of-day discount.
580
+ *
581
+ * Qoder discounts some models during an off-peak window (the catalog's own copy
582
+ * reads "错峰 4 折" — 22:00 to 08:00, Asia/Shanghai). The block carries the
583
+ * window, the discounted multiplier, and the multiplier that applies outside it,
584
+ * so the effective rate depends on **when** the model is used, not just on the
585
+ * catalog entry.
586
+ *
587
+ * @returns `{ active, windowStart, windowEnd, timezone, discountFactor,
588
+ * beforePromotionPriceFactor, badge, description }`, or `undefined` when the
589
+ * model carries no promotion.
590
+ */
591
+ function normalizePromotion(value) {
592
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
593
+ const discountFactor = Number(value.discount_factor)
594
+ const before = Number(value.before_promotion_price_factor)
595
+ const windowStart = typeof value.window_start === 'string' ? value.window_start : ''
596
+ const windowEnd = typeof value.window_end === 'string' ? value.window_end : ''
597
+ const hasWindow = /^\d{2}:\d{2}$/.test(windowStart) && /^\d{2}:\d{2}$/.test(windowEnd)
598
+ if (!Number.isFinite(discountFactor) && !Number.isFinite(before) && !hasWindow) return undefined
599
+ // The catalog is bilingual; prefer the Simplified Chinese copy to match the
600
+ // rest of the card, falling back to English when only that is present.
601
+ const pick = (field) => {
602
+ const source = value[field]
603
+ if (source === null || typeof source !== 'object') return ''
604
+ const text = source.zh ?? source['zh-CN'] ?? source.en
605
+ return typeof text === 'string' ? text : ''
606
+ }
607
+ return {
608
+ active: value.active === true,
609
+ windowStart: hasWindow ? windowStart : '',
610
+ windowEnd: hasWindow ? windowEnd : '',
611
+ timezone: typeof value.timezone === 'string' && value.timezone.length > 0 ? value.timezone : 'Asia/Shanghai',
612
+ ...(Number.isFinite(discountFactor) ? { discountFactor } : {}),
613
+ ...(Number.isFinite(before) ? { beforePromotionPriceFactor: before } : {}),
614
+ badge: pick('badge'),
615
+ description: pick('description'),
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Exchange a personal access token for a job token.
621
+ *
622
+ * @returns `{ token, refreshToken, expiresAt }`.
623
+ */
624
+ export async function exchangePat(region, pat, signal) {
625
+ const response = await fetch(exchangeUrl(region), {
626
+ method: 'POST',
627
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
628
+ body: JSON.stringify({ personal_access_token: pat }),
629
+ signal,
630
+ })
631
+ if (!response.ok) {
632
+ throw new Error(`Qoder PAT exchange failed: HTTP ${response.status} ${(await response.text()).slice(0, 300)}`)
633
+ }
634
+ const data = await response.json()
635
+ const token = data?.token ?? data?.job_token ?? data?.jobToken
636
+ if (typeof token !== 'string' || token.length === 0) {
637
+ throw new Error('Qoder PAT exchange returned no token')
638
+ }
639
+ return {
640
+ token,
641
+ refreshToken: typeof data?.refresh_token === 'string' ? data.refresh_token : '',
642
+ expiresAt: typeof data?.expires_at === 'string' ? Date.parse(data.expires_at) : Date.now() + 3600_000,
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Fetch the signed-in account's profile.
648
+ *
649
+ * @returns `{ userID, name, email }`.
650
+ */
651
+ export async function fetchUserInfo(region, credential, signal) {
652
+ const response = await fetch(userInfoUrl(region), {
653
+ method: 'GET',
654
+ headers: { Accept: 'application/json', Authorization: `Bearer ${credential.token}` },
655
+ signal,
656
+ })
657
+ if (!response.ok) {
658
+ throw new Error(`Qoder userinfo failed: HTTP ${response.status} ${(await response.text()).slice(0, 300)}`)
659
+ }
660
+ const data = await response.json()
661
+ const body = data?.data ?? data
662
+ return {
663
+ userID: String(body?.id ?? body?.user_id ?? ''),
664
+ name: String(body?.name ?? body?.nickname ?? ''),
665
+ email: String(body?.email ?? ''),
666
+ }
667
+ }
668
+
669
+ /**
670
+ * One chat turn, streamed.
671
+ *
672
+ * Yields plain objects in the OpenAI chunk vocabulary
673
+ * (`{ choices: [{ delta, finish_reason }] }`) so the caller can forward them
674
+ * without knowing about Qoder's envelope.
675
+ *
676
+ * @param region - the region descriptor.
677
+ * @param credential - `{ userID, token, name, email, machineID }`.
678
+ * @param request - `{ model, messages, tools, maxTokens, enableThinking, alwaysThinking, reasoningEffort, sessionId }`.
679
+ * `alwaysThinking` marks a model that rejects `enable_thinking: false`; for
680
+ * those the flag is omitted entirely rather than sent as `false`.
681
+ * @yields OpenAI-shaped chat completion chunks.
682
+ */
683
+ export async function* streamChat(region, credential, request, signal) {
684
+ const model = request.model
685
+ const recordID = crypto.randomUUID()
686
+ const lastUser = [...request.messages].reverse().find((m) => m.role === 'user')
687
+ const lastText = typeof lastUser?.content === 'string' ? lastUser.content : ''
688
+
689
+ // A model that always thinks answers `enable_thinking: false` with a
690
+ // `provider_error` (1210) instead of a completion, so the flag is left off
691
+ // for those and thinking is only ever requested positively.
692
+ //
693
+ // `max_tokens` is forwarded only when the caller actually specifies one. The
694
+ // harness sizes its request from the model's declared ceiling, and this
695
+ // plugin deliberately declares none, so inventing a number here would
696
+ // reintroduce the very truncation that omission avoids: reasoning and the
697
+ // answer share this budget upstream, so an over-large value is not harmless
698
+ // and an absent one lets Qoder apply its own.
699
+ const parameters = {}
700
+ if (Number.isSafeInteger(request.maxTokens) && request.maxTokens > 0) {
701
+ parameters.max_tokens = request.maxTokens
702
+ }
703
+ if (request.enableThinking === true) {
704
+ parameters.enable_thinking = true
705
+ if (typeof request.reasoningEffort === 'string' && request.reasoningEffort.length > 0) {
706
+ parameters.reasoning_effort = request.reasoningEffort
707
+ }
708
+ } else if (request.alwaysThinking !== true) {
709
+ parameters.enable_thinking = false
710
+ }
711
+
712
+ const body = {
713
+ request_id: crypto.randomUUID(),
714
+ request_set_id: recordID,
715
+ chat_record_id: recordID,
716
+ session_id: request.sessionId ?? `dsh-${crypto.randomUUID()}`,
717
+ stream: true,
718
+ chat_task: 'FREE_INPUT',
719
+ is_reply: true,
720
+ is_retry: false,
721
+ source: 1,
722
+ version: '3',
723
+ session_type: 'qodercli',
724
+ agent_id: 'agent_common',
725
+ task_id: 'common',
726
+ code_language: '',
727
+ chat_prompt: '',
728
+ image_urls: null,
729
+ aliyun_user_type: '',
730
+ // The upstream ignores a top-level `system` field; a leading role:system
731
+ // message is what it actually honours.
732
+ system: '',
733
+ messages: request.messages,
734
+ tools: request.tools ?? [],
735
+ parameters,
736
+ chat_context: {
737
+ chatPrompt: '',
738
+ imageUrls: null,
739
+ extra: {
740
+ context: [],
741
+ modelConfig: { key: model, is_reasoning: request.enableThinking === true },
742
+ originalContent: lastText,
743
+ },
744
+ features: [],
745
+ text: lastText,
746
+ },
747
+ model_config: { key: model, source: 'system', is_reasoning: request.enableThinking === true },
748
+ business: {
749
+ product: 'cli',
750
+ version: '1.0.0',
751
+ type: 'agent',
752
+ stage: 'start',
753
+ id: crypto.randomUUID(),
754
+ name: lastText.slice(0, 30),
755
+ begin_at: Date.now(),
756
+ },
757
+ }
758
+
759
+ const url = chatUrl(region)
760
+ const bodyBytes = encodeBody(Buffer.from(JSON.stringify(body)))
761
+
762
+ /**
763
+ * Open one attempt and hand back its frame stream.
764
+ *
765
+ * Nothing is yielded until the first frame arrives, so a queue rejection
766
+ * raised here is still safe to retry: the caller has seen no output yet.
767
+ */
768
+ async function* readFrames(response) {
769
+ const reader = response.body.getReader()
770
+ const decoder = new TextDecoder()
771
+ let buffer = ''
772
+ try {
773
+ while (true) {
774
+ const { done, value } = await reader.read()
775
+ if (done) break
776
+ buffer += decoder.decode(value, { stream: true })
777
+ let newline
778
+ while ((newline = buffer.indexOf('\n')) !== -1) {
779
+ const line = buffer.slice(0, newline).trim()
780
+ buffer = buffer.slice(newline + 1)
781
+ if (!line.startsWith('data:')) continue
782
+ const payload = line.slice(5).trim()
783
+ if (payload.length === 0 || payload === '[DONE]') continue
784
+
785
+ // The frame is an envelope whose `body` is itself JSON — but a few
786
+ // frames carry the chunk inline, so both shapes are accepted.
787
+ let envelope
788
+ try {
789
+ envelope = JSON.parse(payload)
790
+ } catch {
791
+ continue
792
+ }
793
+ let chunk = envelope
794
+ if (typeof envelope?.body === 'string') {
795
+ try {
796
+ chunk = JSON.parse(envelope.body)
797
+ } catch {
798
+ continue
799
+ }
800
+ } else if (envelope?.body !== undefined && typeof envelope.body === 'object') {
801
+ chunk = envelope.body
802
+ }
803
+ // A token-accounting frame carries `choices: []` (or omits the field)
804
+ // and a top-level `usage`. It must be forwarded, not treated as a
805
+ // failure: the shim turns it into the counts DSH records per turn.
806
+ if (chunk?.choices !== undefined || (chunk?.usage !== undefined && chunk.usage !== null)) {
807
+ yield chunk
808
+ continue
809
+ }
810
+
811
+ // A failure arrives as an ordinary 200 frame carrying an error object
812
+ // rather than a chunk. Silently dropping it would present the user
813
+ // with an empty assistant turn, so it is raised instead.
814
+ const failure = readFailure(chunk)
815
+ if (failure === undefined) continue
816
+ if (failure.kind === 'rate-limit') {
817
+ // Retryable: the caller waits it out and re-sends, so this is not
818
+ // reported as a rejection.
819
+ throw new QueueRejection(failure.retryAfterSeconds, failure.detail)
820
+ }
821
+ throw new Error(failureMessage(failure, region))
822
+ }
823
+ }
824
+ } finally {
825
+ // On a retryable rejection the body is abandoned mid-stream; cancelling
826
+ // releases the socket now instead of leaving it to the garbage collector,
827
+ // which matters because the retry loop may open several of these.
828
+ try {
829
+ await reader.cancel()
830
+ } catch {
831
+ // Already closed or errored — nothing left to release.
832
+ }
833
+ }
834
+ }
835
+
836
+ /** Send one request. Throws for terminal failures, returns the open stream. */
837
+ async function openAttempt() {
838
+ const response = await fetch(url, {
839
+ method: 'POST',
840
+ headers: {
841
+ 'Content-Type': 'application/json',
842
+ Accept: 'text/event-stream',
843
+ 'Cache-Control': 'no-cache',
844
+ 'Accept-Encoding': 'identity',
845
+ 'X-Model-Key': model,
846
+ 'X-Model-Source': 'system',
847
+ ...authHeaders(bodyBytes, url, credential),
848
+ },
849
+ body: bodyBytes,
850
+ signal,
851
+ })
852
+
853
+ if (!response.ok) {
854
+ const text = (await response.text()).slice(0, 500)
855
+ // A non-2xx reply carries the same nested envelope as an in-band error
856
+ // frame, so it is unwrapped the same way: the outer status is often a
857
+ // bare 403 whose real meaning (`10605`, queued) lives two JSON strings
858
+ // deeper.
859
+ const failure = readFailure({ code: String(response.status), message: text }, String(response.status))
860
+ if (failure === undefined) {
861
+ throw new Error(`Qoder chat failed: HTTP ${response.status} ${response.statusText} — ${text}`)
862
+ }
863
+ if (failure.kind === 'rate-limit') throw new QueueRejection(failure.retryAfterSeconds, failure.detail)
864
+ throw new Error(failureMessage(failure, region, response.status, response.statusText))
865
+ }
866
+ if (response.body === null) throw new Error('Qoder chat returned no body')
867
+ return response
868
+ }
869
+
870
+ // Qoder queues rather than rejecting, and tells us how long to wait. That wait
871
+ // belongs here, not in DSH's retry loop: DSH's policy is fixed (5 attempts,
872
+ // 500 ms doubling to 10 s, ignoring the hint) and its budget is often shorter
873
+ // than the queue. Waiting internally also keeps the turn alive, so the user
874
+ // does not have to send the message again and restart it.
875
+ let waitedMs = 0
876
+ for (let attempt = 1; ; attempt++) {
877
+ let stream
878
+ let first
879
+ try {
880
+ stream = readFrames(await openAttempt())
881
+ // Pull the first frame before committing to a response, so a queue
882
+ // rejection surfaces here rather than mid-stream where it could not be
883
+ // retried without duplicating output.
884
+ first = await stream.next()
885
+ } catch (error) {
886
+ const queueWaitMs = queueWaitFor(error, waitedMs, attempt)
887
+ if (queueWaitMs === undefined) throw error
888
+ // The caller aborted while we were deciding, so stop rather than sleep.
889
+ if (signal?.aborted) throw error
890
+ await sleep(queueWaitMs, signal)
891
+ waitedMs += queueWaitMs
892
+ continue
893
+ }
894
+
895
+ if (first.done === true) {
896
+ // The gateway accepted the request but sent nothing usable. Treat it as an
897
+ // empty turn rather than a queue rejection: it is not something waiting
898
+ // will fix.
899
+ throw new Error(`${region.displayName} returned an empty response`)
900
+ }
901
+
902
+ // Once the first chunk is out the turn is committed — there is no going
903
+ // back — so the remaining frames are forwarded as they arrive. The `finally`
904
+ // covers the case where the consumer stops early (the shim aborts on client
905
+ // disconnect): without it the upstream socket would stay open until the
906
+ // gateway closed it on its own.
907
+ try {
908
+ yield first.value
909
+ for (;;) {
910
+ const step = await stream.next()
911
+ if (step.done === true) break
912
+ yield step.value
913
+ }
914
+ } finally {
915
+ await stream.return(undefined).catch(() => {})
916
+ }
917
+ return
918
+ }
919
+ }
920
+
921
+ /**
922
+ * A queue rejection that can be waited out.
923
+ *
924
+ * `retryable` is what the shim reads to decide between a retryable status and a
925
+ * hard failure, so this stays a plain `Error` carrying the same flags the shim
926
+ * already understood.
927
+ */
928
+ class QueueRejection extends Error {
929
+ constructor(retryAfterSeconds, detail) {
930
+ super(
931
+ `Qoder is busy — the request was queued` +
932
+ `${retryAfterSeconds > 0 ? ` (retry in ~${retryAfterSeconds}s)` : ''}`,
933
+ )
934
+ this.name = 'QueueRejection'
935
+ this.retryable = true
936
+ this.retryAfterSeconds = retryAfterSeconds
937
+ this.upstreamDetail = detail
938
+ }
939
+ }
940
+
941
+ /**
942
+ * How long to wait before retrying a queue rejection, or `undefined` when it is
943
+ * not a queue rejection or the wait budget is spent.
944
+ *
945
+ * The gateway's own `retryAfterSeconds` is the primary signal and is honoured
946
+ * as given. It is not always accurate, though: a queue that keeps answering
947
+ * "retry in 2s" for a minute would otherwise be polled every two seconds for the
948
+ * whole budget, which is both rude and pointless. So once a few consecutive
949
+ * rejections have shown the hint is not converging, the pause escalates
950
+ * geometrically up to {@link QUEUE_WAIT_MAX_SLEEP_MS}.
951
+ *
952
+ * @param error - the rejection to judge.
953
+ * @param waitedMs - time already spent waiting in this turn.
954
+ * @param attempt - 1-based index of the attempt that was just rejected.
955
+ */
956
+ function queueWaitFor(error, waitedMs, attempt) {
957
+ if (error?.retryable !== true) return undefined
958
+ const remaining = QUEUE_WAIT_BUDGET_MS - waitedMs
959
+ if (remaining <= 0) return undefined
960
+
961
+ const hinted =
962
+ Number(error.retryAfterSeconds) > 0 ? Number(error.retryAfterSeconds) * 1000 : QUEUE_WAIT_MIN_SLEEP_MS
963
+
964
+ // Leave the first few attempts on the gateway's own advice; only start
965
+ // backing off further once it is clear the hint is not clearing the queue.
966
+ const GRACE_ATTEMPTS = 3
967
+ const escalated =
968
+ attempt > GRACE_ATTEMPTS
969
+ ? QUEUE_WAIT_MIN_SLEEP_MS * 2 ** Math.min(attempt - GRACE_ATTEMPTS, 8)
970
+ : 0
971
+
972
+ const target = Math.max(hinted, escalated)
973
+ return Math.max(QUEUE_WAIT_MIN_SLEEP_MS, Math.min(target, QUEUE_WAIT_MAX_SLEEP_MS, remaining))
974
+ }
975
+
976
+ /** Build the readable sentence for a non-queue failure. */
977
+ function failureMessage(failure, region, status, statusText) {
978
+ if (failure.kind === 'sign-in-expired') {
979
+ return (
980
+ `${region.displayName} sign-in is no longer valid — open the ${region.displayName} app ` +
981
+ `to sign in again, then restart DSH` +
982
+ `${status !== undefined ? ` (HTTP ${status}: ${failure.detail})` : ` (upstream ${failure.code || 'error'}: ${failure.detail})`}`
983
+ )
984
+ }
985
+ if (status === 401 || status === 403) {
986
+ return (
987
+ `${region.displayName} was refused by Qoder — check that this account can use this model ` +
988
+ `(HTTP ${status}: ${failure.detail})`
989
+ )
990
+ }
991
+ if (status !== undefined) {
992
+ return `Qoder chat failed: HTTP ${status} ${statusText ?? ''} — ${failure.detail}`
993
+ }
994
+ return `Qoder upstream error${failure.code !== '' ? ` ${failure.code}` : ''}${failure.detail.length > 0 ? `: ${failure.detail}` : ''}`
995
+ }
996
+
997
+ /**
998
+ * Parse a string that is expected to hold a JSON object, else `undefined`.
999
+ */
1000
+ function tryJsonObject(text) {
1001
+ const trimmed = text.trim()
1002
+ if (!trimmed.startsWith('{')) return undefined
1003
+ try {
1004
+ const parsed = JSON.parse(trimmed)
1005
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined
1006
+ } catch {
1007
+ return undefined
1008
+ }
1009
+ }
1010
+
1011
+ /** Whether a value is a plain (non-array) object. */
1012
+ function isPlainObject(value) {
1013
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
1014
+ }
1015
+
1016
+ /**
1017
+ * Unwrap the gateway's failure envelope to its deepest level.
1018
+ *
1019
+ * Qoder nests the real complaint instead of stating it once: an outer
1020
+ * transport code wraps a `message` that is itself a JSON *string*, which wraps
1021
+ * another code and message, and the queue descriptor sits at the bottom —
1022
+ *
1023
+ * ```
1024
+ * { code: "403",
1025
+ * message: "{\"code\":\"10605\",
1026
+ * \"message\":\"{\\\"isQueued\\\":false,\\\"retryAfterSeconds\\\":2,...}\"}" }
1027
+ * ```
1028
+ *
1029
+ * Reading only the first level therefore reports a bare `403` and hides both
1030
+ * the true code (`10605`) and the retry hint. The whole chain is walked here so
1031
+ * the deepest code — the one that names the actual problem — wins.
1032
+ *
1033
+ * @returns `{ code, detail }`, the most specific code and the most informative
1034
+ * detail text found.
1035
+ */
1036
+ function unwrapFailure(chunk) {
1037
+ let code = ''
1038
+ let detail = ''
1039
+ let node = chunk
1040
+ let descended = false
1041
+
1042
+ for (let depth = 0; depth < 8; depth++) {
1043
+ if (!isPlainObject(node)) break
1044
+ const levelCode = node.errorCode ?? node.code
1045
+ if (typeof levelCode === 'string' && levelCode.length > 0) code = levelCode
1046
+ const message = node.message ?? node.errorMessage
1047
+ if (typeof message === 'string' && message.length > 0) detail = message
1048
+
1049
+ // The next level sits under `details`/`error`, or inside a `message`
1050
+ // string that is itself a JSON document.
1051
+ const nested = isPlainObject(node.details) ? node.details : isPlainObject(node.error) ? node.error : undefined
1052
+ const next = nested ?? (typeof message === 'string' ? tryJsonObject(message) : undefined)
1053
+ if (next === undefined) break
1054
+ node = next
1055
+ descended = true
1056
+ }
1057
+
1058
+ // When the walk bottoms out on a payload object (the queue descriptor), that
1059
+ // object is far more useful than the JSON string that carried it.
1060
+ if (descended && isPlainObject(node) && typeof node.message !== 'string') {
1061
+ detail = JSON.stringify(node)
1062
+ }
1063
+ return { code, detail }
1064
+ }
1065
+
1066
+ /**
1067
+ * Normalize a message list into what the Qoder endpoint accepts.
1068
+ *
1069
+ * Two vocabularies can arrive here and both must survive the trip:
1070
+ *
1071
+ * - **OpenAI shape**, which is what pi-ai actually sends through the shim:
1072
+ * `tool_calls` on the assistant message and `role: "tool"` with a
1073
+ * `tool_call_id` for the result. Dropping either would break the harness's
1074
+ * tool loop on the second turn, which is the single most important thing
1075
+ * this translation has to get right.
1076
+ * - **DSH shape** (`toolCall` content blocks, `toolResult` role), accepted so
1077
+ * the function stays usable from a direct caller.
1078
+ *
1079
+ * Image parts become `image_url` parts carrying a data URL, which is the only
1080
+ * image form this endpoint accepts.
1081
+ *
1082
+ * @param messages - messages in either vocabulary.
1083
+ * @returns Qoder-shaped messages.
1084
+ */
1085
+ export function toQoderMessages(messages) {
1086
+ const out = []
1087
+ for (const message of messages) {
1088
+ if (message === null || typeof message !== 'object') continue
1089
+
1090
+ if (message.role === 'system' || message.role === 'developer') {
1091
+ // `developer` is OpenAI's newer name for the same thing, and pi-ai emits
1092
+ // it whenever a reasoning model's compat allows it. Qoder only knows
1093
+ // `system`, so the two collapse into one here. Dropping the role instead
1094
+ // (which is what an unhandled value used to do) left the request with no
1095
+ // system message at all, and the gateway answers that with a permanent
1096
+ // `403 {"code":"10605"}` on every retry.
1097
+ out.push({ role: 'system', content: textOf(message.content) })
1098
+ continue
1099
+ }
1100
+
1101
+ if (message.role === 'user') {
1102
+ const parts = []
1103
+ let hasImage = false
1104
+ if (Array.isArray(message.content)) {
1105
+ for (const block of message.content) {
1106
+ if (block?.type === 'text') parts.push({ type: 'text', text: block.text ?? '' })
1107
+ else if (block?.type === 'image_url' || block?.type === 'image') {
1108
+ // Two shapes reach this function and BOTH must survive:
1109
+ //
1110
+ // - `image_url` is what pi-ai's OpenAI-completions API actually puts
1111
+ // on the wire through the shim (it maps every image block to
1112
+ // `{ type: 'image_url', image_url: { url } }`), so this is the
1113
+ // shape the harness really sends. Matching only `image` here made
1114
+ // every attached image silently disappear on the way out.
1115
+ // - `image` with bytes is the DSH-native shape, kept so the function
1116
+ // stays usable from a direct caller.
1117
+ const url = block.image_url?.url ?? (typeof block.data === 'string'
1118
+ ? `data:${block.mimeType ?? 'image/png'};base64,${block.data}`
1119
+ : undefined)
1120
+ if (url !== undefined) {
1121
+ hasImage = true
1122
+ parts.push({ type: 'image_url', image_url: { url } })
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+ out.push({ role: 'user', content: hasImage ? parts : textOf(message.content) })
1128
+ continue
1129
+ }
1130
+
1131
+ if (message.role === 'assistant') {
1132
+ let text = ''
1133
+ const toolCalls = []
1134
+
1135
+ // OpenAI shape: tool_calls sit on the message itself.
1136
+ if (Array.isArray(message.tool_calls)) {
1137
+ for (const call of message.tool_calls) {
1138
+ toolCalls.push({
1139
+ id: call.id ?? `call_${toolCalls.length}`,
1140
+ type: 'function',
1141
+ function: {
1142
+ name: call.function?.name ?? call.name ?? '',
1143
+ arguments:
1144
+ typeof call.function?.arguments === 'string'
1145
+ ? call.function.arguments
1146
+ : JSON.stringify(call.function?.arguments ?? call.arguments ?? {}),
1147
+ },
1148
+ })
1149
+ }
1150
+ }
1151
+
1152
+ if (Array.isArray(message.content)) {
1153
+ for (const block of message.content) {
1154
+ if (block?.type === 'text') text += block.text ?? ''
1155
+ // DSH shape: tool calls are content blocks.
1156
+ else if (block?.type === 'toolCall') {
1157
+ toolCalls.push({
1158
+ id: block.id ?? `call_${toolCalls.length}`,
1159
+ type: 'function',
1160
+ function: { name: block.name ?? '', arguments: JSON.stringify(block.arguments ?? {}) },
1161
+ })
1162
+ }
1163
+ }
1164
+ } else {
1165
+ text = textOf(message.content)
1166
+ }
1167
+
1168
+ // An assistant turn that only called tools carries no content; the
1169
+ // endpoint rejects a null content field, so an empty string is sent.
1170
+ const entry = { role: 'assistant', content: text }
1171
+ if (toolCalls.length > 0) entry.tool_calls = toolCalls
1172
+ out.push(entry)
1173
+ continue
1174
+ }
1175
+
1176
+ if (message.role === 'toolResult' || message.role === 'tool') {
1177
+ out.push({
1178
+ role: 'tool',
1179
+ tool_call_id: message.toolCallId ?? message.tool_call_id ?? '',
1180
+ content: textOf(message.content),
1181
+ })
1182
+ }
1183
+ }
1184
+ return out
1185
+ }
1186
+
1187
+ /** Flatten a DSH content value into plain text. */
1188
+ function textOf(content) {
1189
+ if (typeof content === 'string') return content
1190
+ if (!Array.isArray(content)) return ''
1191
+ let text = ''
1192
+ for (const block of content) {
1193
+ if (block?.type === 'text') text += block.text ?? ''
1194
+ }
1195
+ return text
1196
+ }
1197
+
1198
+ /**
1199
+ * Normalize tool definitions into the endpoint's function schema.
1200
+ *
1201
+ * pi-ai hands the shim tools that are already OpenAI-shaped
1202
+ * (`{ type: "function", function: { name, description, parameters } }`), so the
1203
+ * common case is a pass-through. A bare DSH descriptor (`{ name, description,
1204
+ * parameters }`) is also accepted and wrapped, which keeps this usable from a
1205
+ * direct caller as well.
1206
+ *
1207
+ * @param tools - tool descriptors in either shape.
1208
+ * @returns OpenAI-shaped tool entries.
1209
+ */
1210
+ export function toQoderTools(tools) {
1211
+ if (!Array.isArray(tools)) return []
1212
+ return tools.map((tool) => {
1213
+ if (tool?.function !== undefined) {
1214
+ return {
1215
+ type: 'function',
1216
+ function: {
1217
+ name: tool.function.name,
1218
+ description: tool.function.description ?? '',
1219
+ parameters: tool.function.parameters ?? { type: 'object', properties: {} },
1220
+ },
1221
+ }
1222
+ }
1223
+ return {
1224
+ type: 'function',
1225
+ function: {
1226
+ name: tool.name,
1227
+ description: tool.description ?? '',
1228
+ parameters: tool.parameters ?? { type: 'object', properties: {} },
1229
+ },
1230
+ }
1231
+ })
1232
+ }