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/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
+ }