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/shim.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loopback shim.
|
|
3
|
+
*
|
|
4
|
+
* `PiAiAdapter` drives providers through pi-ai's OpenAI-completions API, but
|
|
5
|
+
* Qoder needs COSY signing, a permuted body encoding, and its own SSE envelope.
|
|
6
|
+
* Rather than teach pi-ai those three things, this module runs a private HTTP
|
|
7
|
+
* server on `127.0.0.1` that *speaks* OpenAI and translates to Qoder on the way
|
|
8
|
+
* out — the same shape the Trae and WorkBuddy bundles use.
|
|
9
|
+
*
|
|
10
|
+
* The server is bound to an ephemeral loopback port and requires a per-process
|
|
11
|
+
* random bearer token, so nothing outside this process can use it as a proxy.
|
|
12
|
+
* The real Qoder token never reaches pi-ai: it stays on this side of the shim.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-connect-qoder/shim
|
|
15
|
+
*/
|
|
16
|
+
import { createServer } from 'node:http'
|
|
17
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
18
|
+
import { streamChat, toQoderMessages, toQoderTools } from './upstream.js'
|
|
19
|
+
import { filterByEnabled } from './adapter.js'
|
|
20
|
+
|
|
21
|
+
/** Reject anything that is not addressed to the loopback interface. */
|
|
22
|
+
function hostIsLoopback(host) {
|
|
23
|
+
if (typeof host !== 'string') return false
|
|
24
|
+
const name = host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0]
|
|
25
|
+
return name === '127.0.0.1' || name === 'localhost' || name === '::1'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Reject any request that claims a non-loopback origin. */
|
|
29
|
+
function originIsLoopback(origin) {
|
|
30
|
+
if (origin === undefined) return true
|
|
31
|
+
if (typeof origin !== 'string') return false
|
|
32
|
+
try {
|
|
33
|
+
const host = new URL(origin).hostname
|
|
34
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Write one OpenAI-shaped error body. */
|
|
41
|
+
function writeError(res, status, code, message) {
|
|
42
|
+
const payload = JSON.stringify({ error: { message, type: code, code } })
|
|
43
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) })
|
|
44
|
+
res.end(payload)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Write one JSON body. */
|
|
48
|
+
function writeJson(res, status, value) {
|
|
49
|
+
const payload = JSON.stringify(value)
|
|
50
|
+
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) })
|
|
51
|
+
res.end(payload)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read a request body fully. */
|
|
55
|
+
function readBody(req) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const chunks = []
|
|
58
|
+
req.on('data', (chunk) => chunks.push(chunk))
|
|
59
|
+
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
60
|
+
req.on('error', reject)
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Start the shim.
|
|
66
|
+
*
|
|
67
|
+
* @param options.resolveCredential - async `() => credential`, called per
|
|
68
|
+
* request so a re-sign-in is picked up without a restart.
|
|
69
|
+
* @param options.resolveModels - `() => model[]`, the live catalog.
|
|
70
|
+
* @param options.resolveEnabledIds - `() => string[]`, the models the user has
|
|
71
|
+
* enabled for this region. Read per request so a change in settings reaches
|
|
72
|
+
* the picker on the next listing without restarting anything.
|
|
73
|
+
* @param options.logger - optional logger for upstream failures.
|
|
74
|
+
* @returns `{ ready, baseUrl, token, close }`.
|
|
75
|
+
*/
|
|
76
|
+
export function createQoderShim(options) {
|
|
77
|
+
const {
|
|
78
|
+
resolveCredential,
|
|
79
|
+
resolveModels,
|
|
80
|
+
resolveUpstreamKey,
|
|
81
|
+
resolveAlwaysThinking,
|
|
82
|
+
resolveEnabledIds,
|
|
83
|
+
region,
|
|
84
|
+
logger,
|
|
85
|
+
} = options
|
|
86
|
+
const SHARED_SECRET = randomBytes(32).toString('base64url')
|
|
87
|
+
|
|
88
|
+
/** Constant-time bearer check. */
|
|
89
|
+
function bearerOk(req) {
|
|
90
|
+
const header = req.headers.authorization
|
|
91
|
+
if (typeof header !== 'string') return false
|
|
92
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim())
|
|
93
|
+
if (match === null) return false
|
|
94
|
+
const presented = Buffer.from(match[1])
|
|
95
|
+
const expected = Buffer.from(SHARED_SECRET)
|
|
96
|
+
if (presented.length !== expected.length) return false
|
|
97
|
+
return timingSafeEqual(presented, expected)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const server = createServer((req, res) => {
|
|
101
|
+
handle(req, res).catch((error) => {
|
|
102
|
+
if (!res.headersSent) writeError(res, 500, 'internal', String(error))
|
|
103
|
+
else res.end()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const ready = new Promise((resolve, reject) => {
|
|
108
|
+
server.once('listening', () => resolve())
|
|
109
|
+
server.once('error', reject)
|
|
110
|
+
})
|
|
111
|
+
server.listen(0, '127.0.0.1')
|
|
112
|
+
|
|
113
|
+
const baseUrl = () => {
|
|
114
|
+
const address = server.address()
|
|
115
|
+
if (address === null || typeof address === 'string') throw new Error('qoder shim has no listening address')
|
|
116
|
+
return `http://127.0.0.1:${address.port}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function handle(req, res) {
|
|
120
|
+
if (!hostIsLoopback(req.headers.host)) {
|
|
121
|
+
writeError(res, 403, 'host_not_allowed', 'Host header must name the loopback interface')
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (!originIsLoopback(req.headers.origin)) {
|
|
125
|
+
writeError(res, 403, 'origin_not_allowed', 'Origin must be a loopback origin')
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (!bearerOk(req)) {
|
|
129
|
+
writeError(res, 401, 'unauthorized', 'missing or invalid Authorization bearer')
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
const url = req.url ?? '/'
|
|
133
|
+
if (req.method === 'GET' && (url === '/healthz' || url === '/healthz/')) {
|
|
134
|
+
writeJson(res, 200, { ok: true })
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (req.method === 'GET' && (url === '/v1/models' || url === '/v1/models/')) {
|
|
138
|
+
// The picker's discovery reads this endpoint, so it must narrow the
|
|
139
|
+
// catalog exactly the way the adapter does. Returning the raw catalog here
|
|
140
|
+
// is what let unchecked models keep appearing in the selector: the
|
|
141
|
+
// adapter hid them, this listing did not.
|
|
142
|
+
const enabled =
|
|
143
|
+
typeof resolveEnabledIds === 'function' ? resolveEnabledIds() : undefined
|
|
144
|
+
const data = filterByEnabled(resolveModels(), enabled).map((model) => ({
|
|
145
|
+
id: model.id,
|
|
146
|
+
object: 'model',
|
|
147
|
+
created: 0,
|
|
148
|
+
owned_by: region.id,
|
|
149
|
+
}))
|
|
150
|
+
writeJson(res, 200, { object: 'list', data })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
if (req.method === 'POST' && (url === '/v1/chat/completions' || url === '/v1/chat/completions/')) {
|
|
154
|
+
await chatCompletions(req, res)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
writeError(res, 404, 'not_found', `no such route: ${req.method} ${url}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function chatCompletions(req, res) {
|
|
161
|
+
let credential
|
|
162
|
+
try {
|
|
163
|
+
credential = await resolveCredential()
|
|
164
|
+
} catch (error) {
|
|
165
|
+
writeError(res, 401, 'not_signed_in', String(error))
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (credential === undefined) {
|
|
169
|
+
writeError(res, 401, 'not_signed_in', `${region.displayName} is not signed in on this machine`)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let body
|
|
174
|
+
try {
|
|
175
|
+
body = JSON.parse((await readBody(req)).toString('utf8'))
|
|
176
|
+
} catch (error) {
|
|
177
|
+
writeError(res, 400, 'invalid_request', `body is not JSON: ${String(error)}`)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const controller = new AbortController()
|
|
182
|
+
req.on('close', () => controller.abort())
|
|
183
|
+
|
|
184
|
+
const enableThinking = resolveThinking(body)
|
|
185
|
+
const displayModel = body.model
|
|
186
|
+
// The catalog is the authority on whether this model tolerates
|
|
187
|
+
// `enable_thinking: false`; the injected resolver only overrides it.
|
|
188
|
+
const catalogEntry = resolveModels().find((model) => model.id === displayModel)
|
|
189
|
+
const alwaysThinking = resolveAlwaysThinking?.(displayModel) ?? catalogEntry?.alwaysThinking === true
|
|
190
|
+
const request = {
|
|
191
|
+
// DSH sends the user-facing model id; the wire needs Qoder's own key.
|
|
192
|
+
model: resolveUpstreamKey(body.model) ?? body.model,
|
|
193
|
+
messages: toQoderMessages(body.messages ?? []),
|
|
194
|
+
tools: toQoderTools(body.tools),
|
|
195
|
+
maxTokens: typeof body.max_tokens === 'number' ? body.max_tokens : undefined,
|
|
196
|
+
enableThinking,
|
|
197
|
+
alwaysThinking,
|
|
198
|
+
reasoningEffort: enableThinking ? body.reasoning_effort : undefined,
|
|
199
|
+
sessionId: typeof body.user === 'string' ? body.user : undefined,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const wantStream = body.stream !== false
|
|
203
|
+
let iterator
|
|
204
|
+
try {
|
|
205
|
+
iterator = streamChat(region, credential, request, controller.signal)
|
|
206
|
+
// Pull the first chunk before committing to a status code, so an auth or
|
|
207
|
+
// quota failure surfaces as an HTTP error rather than a broken stream.
|
|
208
|
+
var first = await iterator.next()
|
|
209
|
+
} catch (error) {
|
|
210
|
+
logger?.warn?.(`dsh-connect-qoder: ${region.displayName} upstream failed`, error)
|
|
211
|
+
// Queueing is transient, so it must not look like a rejection. DSH
|
|
212
|
+
// retries a 503 (RATE_LIMIT/SERVER); it refuses to retry a 403, and the
|
|
213
|
+
// UI renders a 403 as "the provider rejected this request" — which is
|
|
214
|
+
// both untrue for a queued request and not actionable by the user.
|
|
215
|
+
if (error?.retryable === true) {
|
|
216
|
+
writeError(res, 503, 'rate_limit', String(error?.message ?? error))
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
writeError(res, 502, 'upstream_error', String(error?.message ?? error))
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!wantStream) {
|
|
224
|
+
const content = []
|
|
225
|
+
const toolCalls = new Map()
|
|
226
|
+
let finish = 'stop'
|
|
227
|
+
let usage
|
|
228
|
+
for (let step = first; !step.done; step = await iterator.next()) {
|
|
229
|
+
// The usage frame carries no choice, so it is collected separately.
|
|
230
|
+
if (step.value?.usage !== undefined && step.value.usage !== null) usage = step.value.usage
|
|
231
|
+
absorb(step.value, content, toolCalls, (f) => { finish = f })
|
|
232
|
+
}
|
|
233
|
+
const message = { role: 'assistant', content: content.join('') }
|
|
234
|
+
if (toolCalls.size > 0) message.tool_calls = [...toolCalls.values()]
|
|
235
|
+
writeJson(res, 200, {
|
|
236
|
+
id: `chatcmpl-${randomBytes(8).toString('hex')}`,
|
|
237
|
+
object: 'chat.completion',
|
|
238
|
+
created: Math.floor(Date.now() / 1000),
|
|
239
|
+
model: displayModel,
|
|
240
|
+
choices: [{ index: 0, message, finish_reason: finish }],
|
|
241
|
+
// Present only when upstream reported it; an absent field is better than
|
|
242
|
+
// a fabricated zero, which would read as "this turn cost nothing".
|
|
243
|
+
...(usage !== undefined ? { usage } : {}),
|
|
244
|
+
})
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
res.writeHead(200, {
|
|
249
|
+
'Content-Type': 'text/event-stream',
|
|
250
|
+
'Cache-Control': 'no-cache',
|
|
251
|
+
Connection: 'keep-alive',
|
|
252
|
+
'X-Accel-Buffering': 'no',
|
|
253
|
+
})
|
|
254
|
+
const id = `chatcmpl-${randomBytes(8).toString('hex')}`
|
|
255
|
+
const created = Math.floor(Date.now() / 1000)
|
|
256
|
+
let sentRole = false
|
|
257
|
+
try {
|
|
258
|
+
for (let step = first; !step.done; step = await iterator.next()) {
|
|
259
|
+
const chunk = step.value
|
|
260
|
+
|
|
261
|
+
// Token accounting arrives in its OWN frame, with `choices: []` and a
|
|
262
|
+
// top-level `usage` — the same shape OpenAI uses for
|
|
263
|
+
// `stream_options.include_usage`. It must be forwarded before the choice
|
|
264
|
+
// check below, which would otherwise drop it: an empty `choices` array is
|
|
265
|
+
// truthy, so `choices[0]` is simply `undefined`.
|
|
266
|
+
//
|
|
267
|
+
// pi-ai reads `chunk.usage` from the top level and turns it into the
|
|
268
|
+
// session's token counts, so losing this frame is exactly why Qoder turns
|
|
269
|
+
// reported no tokens. Qoder's usage block also carries `credits` and
|
|
270
|
+
// `prompt_tokens_details.cached_tokens`, which pi-ai understands.
|
|
271
|
+
if (chunk?.usage !== undefined && chunk.usage !== null) {
|
|
272
|
+
writeSse(res, {
|
|
273
|
+
id,
|
|
274
|
+
object: 'chat.completion.chunk',
|
|
275
|
+
created,
|
|
276
|
+
model: displayModel,
|
|
277
|
+
choices: [],
|
|
278
|
+
usage: chunk.usage,
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const choice = chunk?.choices?.[0]
|
|
283
|
+
if (choice === undefined) continue
|
|
284
|
+
const delta = choice.delta ?? choice.message ?? {}
|
|
285
|
+
const out = { role: 'assistant' }
|
|
286
|
+
if (typeof delta.content === 'string' && delta.content.length > 0) out.content = delta.content
|
|
287
|
+
// pi-ai reads reasoning from any of these fields.
|
|
288
|
+
const reasoning = delta.reasoning_content ?? delta.reasoning
|
|
289
|
+
if (typeof reasoning === 'string' && reasoning.length > 0) out.reasoning_content = reasoning
|
|
290
|
+
if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) out.tool_calls = delta.tool_calls
|
|
291
|
+
const finish = choice.finish_reason
|
|
292
|
+
if (!sentRole) {
|
|
293
|
+
sentRole = true
|
|
294
|
+
} else if (Object.keys(out).length === 1 && finish === undefined) {
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
writeSse(res, {
|
|
298
|
+
id,
|
|
299
|
+
object: 'chat.completion.chunk',
|
|
300
|
+
created,
|
|
301
|
+
model: displayModel,
|
|
302
|
+
choices: [{ index: 0, delta: out, finish_reason: finish ?? null }],
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// Never end a broken stream with [DONE]: that tells the client the
|
|
307
|
+
// response finished normally, so a half-written answer is shown as a
|
|
308
|
+
// complete turn. Emitting the failure lets DSH retry or report it.
|
|
309
|
+
logger?.warn?.(`dsh-connect-qoder: ${region.displayName} stream broke`, error)
|
|
310
|
+
const retryable = error?.retryable === true
|
|
311
|
+
const kind = retryable ? 'rate_limit' : 'upstream_error'
|
|
312
|
+
const message = String(error?.message ?? error)
|
|
313
|
+
|
|
314
|
+
if (retryable) {
|
|
315
|
+
logger?.warn?.(
|
|
316
|
+
`dsh-connect-qoder: ${region.displayName} queued by Qoder; reporting 503 so DSH retries: ${message}`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
// The response has already started, so the failure travels in-band as an
|
|
320
|
+
// SSE error frame rather than a status-code change.
|
|
321
|
+
res.write('event: error\n')
|
|
322
|
+
res.write(`data: ${JSON.stringify({ error: { message, type: kind, code: kind } })}\n\n`)
|
|
323
|
+
res.write('data: [DONE]\n\n')
|
|
324
|
+
res.end()
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
res.write('data: [DONE]\n\n')
|
|
328
|
+
res.end()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Decide whether the caller asked for reasoning output. */
|
|
332
|
+
function resolveThinking(body) {
|
|
333
|
+
if (typeof body.reasoning_effort === 'string' && body.reasoning_effort.length > 0) {
|
|
334
|
+
return body.reasoning_effort !== 'off' && body.reasoning_effort !== 'none'
|
|
335
|
+
}
|
|
336
|
+
// pi-ai sends `reasoning_effort` only for models it believes reason; a
|
|
337
|
+
// model with a thinking level map but no explicit request still wants
|
|
338
|
+
// thinking enabled, which shows up as `thinking` on the body.
|
|
339
|
+
if (body.thinking !== undefined) return body.thinking !== false && body.thinking !== 'off'
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
ready,
|
|
345
|
+
baseUrl,
|
|
346
|
+
token: () => SHARED_SECRET,
|
|
347
|
+
close: () =>
|
|
348
|
+
new Promise((resolve, reject) => {
|
|
349
|
+
server.close(() => resolve())
|
|
350
|
+
server.closeAllConnections()
|
|
351
|
+
server.once('error', reject)
|
|
352
|
+
}),
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Accumulate one non-streaming chunk into the final message. */
|
|
357
|
+
function absorb(chunk, content, toolCalls, setFinish) {
|
|
358
|
+
const choice = chunk?.choices?.[0]
|
|
359
|
+
if (choice === undefined) return
|
|
360
|
+
const delta = choice.delta ?? choice.message ?? {}
|
|
361
|
+
if (typeof delta.content === 'string') content.push(delta.content)
|
|
362
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
363
|
+
for (const call of delta.tool_calls) {
|
|
364
|
+
const index = call.index ?? 0
|
|
365
|
+
const current = toolCalls.get(index) ?? { id: '', type: 'function', function: { name: '', arguments: '' } }
|
|
366
|
+
if (call.id) current.id = call.id
|
|
367
|
+
if (call.function?.name) current.function.name = call.function.name
|
|
368
|
+
if (call.function?.arguments) current.function.arguments += call.function.arguments
|
|
369
|
+
toolCalls.set(index, current)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (choice.finish_reason) setFinish(choice.finish_reason)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Write one SSE frame. */
|
|
376
|
+
function writeSse(res, value) {
|
|
377
|
+
res.write(`data: ${JSON.stringify(value)}\n\n`)
|
|
378
|
+
}
|