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.
- package/README.md +100 -0
- package/cordis.patch.yml +6 -0
- package/lib/adapter.js +445 -0
- package/lib/client.js +992 -0
- package/lib/credentials.js +324 -0
- package/lib/errors.js +37 -0
- package/lib/index.js +691 -0
- package/lib/shim.js +378 -0
- package/lib/upstream.js +1232 -0
- package/package.json +71 -0
package/lib/upstream.js
ADDED
|
@@ -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
|
+
}
|