zcode-qoder-connect 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.js ADDED
@@ -0,0 +1,799 @@
1
+ /**
2
+ * Loopback OpenAI-compatible endpoint for the Trae upstream.
3
+ *
4
+ * A client points an `openai-chat-completions` provider at this server; the
5
+ * server forwards to Trae's remote-session protocol through the per-edition
6
+ * `TraeUpstreamClient` and re-emits the pieces as clean OpenAI SSE. It binds
7
+ * 127.0.0.1 only and never serves another interface.
8
+ *
9
+ * Inbound hardening: the loopback bind alone is not a trust boundary (any
10
+ * local process, and a DNS-rebinding page, can reach 127.0.0.1), so every
11
+ * request must carry a loopback Host header, browser-sent Origins must be
12
+ * loopback, chat POSTs must be application/json, and the Authorization header
13
+ * must carry this server's configured bearer.
14
+ *
15
+ * Variants: the two Trae editions share model ids (glm-5.3, kimi-k3, …) but
16
+ * bill against separate accounts, so one id cannot stand for both. The
17
+ * edition therefore travels in the URL path rather than in the model id: the
18
+ * root serves 国内版 and `/intl/…` the international one, which keeps the ids
19
+ * a client displays clean. Each variant carries its own credential store,
20
+ * catalog and upstream client.
21
+ *
22
+ * @module zcode-trae-connect/server
23
+ */
24
+
25
+ import { timingSafeEqual } from 'node:crypto'
26
+ import { createServer } from 'node:http'
27
+ import { appendFileSync } from 'node:fs'
28
+ import { homedir } from 'node:os'
29
+ import { join } from 'node:path'
30
+ import { hostIsLoopback, originIsLoopback } from './loopback.js'
31
+
32
+ const REQUEST_BODY_LIMIT = 64 * 1024 * 1024
33
+ const CATALOG_REFRESH_MS = 30 * 60 * 1000
34
+ /** SSE comment cadence during upstream silence, well under any read timeout. */
35
+ const HEARTBEAT_MS = 10_000
36
+ /**
37
+ * Total upstream silence tolerated before the turn is given up.
38
+ *
39
+ * Heartbeats keep the client's read timer alive, which is exactly what hides a
40
+ * dead upstream: the socket stays open, the client keeps waiting, and nothing
41
+ * ever ends — the "model just stopped moving" report. A multi-minute silence
42
+ * while the stream is already open is a stall, not a slow model.
43
+ */
44
+ const STREAM_IDLE_TIMEOUT_MS = Number(process.env['ZC_STREAM_IDLE_TIMEOUT_MS'] ?? 180_000)
45
+
46
+ /** HTTP status each upstream failure class surfaces as. */
47
+ const KIND_STATUS = {
48
+ hard_credit: 402,
49
+ soft_rate: 429,
50
+ session_dead: 401,
51
+ not_found: 502,
52
+ server: 502,
53
+ client: 400,
54
+ }
55
+
56
+ function writeJson(res, status, body) {
57
+ const payload = JSON.stringify(body)
58
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) })
59
+ res.end(payload)
60
+ }
61
+
62
+ function writeOpenAIError(res, status, kind, message) {
63
+ writeJson(res, status, { error: { message, type: kind, code: kind } })
64
+ }
65
+
66
+ /** Read a request body with a size cap; over-limit bodies fail the request. */
67
+ function readBody(req) {
68
+ return new Promise((resolve, reject) => {
69
+ const chunks = []
70
+ let size = 0
71
+ req.on('data', chunk => {
72
+ size += chunk.length
73
+ if (size > REQUEST_BODY_LIMIT) {
74
+ reject(new Error('request body too large'))
75
+ req.destroy()
76
+ return
77
+ }
78
+ chunks.push(chunk)
79
+ })
80
+ req.on('end', () => resolve(Buffer.concat(chunks)))
81
+ req.on('error', reject)
82
+ })
83
+ }
84
+
85
+ function isJsonContentType(req) {
86
+ const type = req.headers['content-type']
87
+ return typeof type === 'string' && type.trim().toLowerCase().startsWith('application/json')
88
+ }
89
+
90
+ /** Constant-time bearer check; absent or mismatched bearers are rejected. */
91
+ function bearerOk(req, expected) {
92
+ const header = req.headers.authorization
93
+ if (typeof header !== 'string') return false
94
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim())
95
+ if (match === null) return false
96
+ const a = Buffer.from(match[1])
97
+ const b = Buffer.from(expected)
98
+ if (a.length !== b.length) return false
99
+ return timingSafeEqual(a, b)
100
+ }
101
+
102
+ /**
103
+ * Buffer an upstream SSE stream into one non-streaming completion.
104
+ *
105
+ * The Trae upstream only speaks streaming, so a client that asked for a
106
+ * single JSON answer is served by consuming the stream here and reassembling
107
+ * it. Content and reasoning are merged, matching the OpenAI chunking shape.
108
+ */
109
+ async function collectCompletion(body, model) {
110
+ const text = await new Response(body).text()
111
+ let content = ''
112
+ let reasoning = ''
113
+ let finishReason = 'stop'
114
+ let usage
115
+ const toolCalls = []
116
+ let id = `chatcmpl-${Date.now()}`
117
+
118
+ for (const line of text.split('\n')) {
119
+ const trimmed = line.trim()
120
+ if (!trimmed.startsWith('data:')) continue
121
+ const payload = trimmed.slice(5).trim()
122
+ if (payload === '' || payload === '[DONE]') continue
123
+ let chunk
124
+ try {
125
+ chunk = JSON.parse(payload)
126
+ } catch {
127
+ continue
128
+ }
129
+ if (typeof chunk['id'] === 'string' && chunk['id'] !== '') id = chunk['id']
130
+ if (chunk['usage'] !== undefined && chunk['usage'] !== null) usage = chunk['usage']
131
+ const choice = Array.isArray(chunk['choices']) ? chunk['choices'][0] : undefined
132
+ if (choice === undefined) continue
133
+ if (typeof choice['finish_reason'] === 'string' && choice['finish_reason'] !== '') {
134
+ finishReason = choice['finish_reason']
135
+ }
136
+ const delta = choice['delta']
137
+ if (typeof delta !== 'object' || delta === null) continue
138
+ if (typeof delta['content'] === 'string') content += delta['content']
139
+ if (typeof delta['reasoning_content'] === 'string') reasoning += delta['reasoning_content']
140
+ if (Array.isArray(delta['tool_calls'])) {
141
+ for (const call of delta['tool_calls']) {
142
+ const index = call['index'] ?? 0
143
+ const existing = toolCalls.find(c => c.index === index)
144
+ if (!existing) {
145
+ toolCalls.push({ index, id: call['id'] ?? '', name: call['function']?.['name'] ?? '', arguments: call['function']?.['arguments'] ?? '' })
146
+ } else {
147
+ if (call['id']) existing.id = call['id']
148
+ if (call['function']?.['name']) existing.name += call['function']['name']
149
+ if (call['function']?.['arguments']) existing.arguments += call['function']['arguments']
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ const message = { role: 'assistant', content: content === '' && toolCalls.length > 0 ? null : content }
156
+ if (reasoning !== '') message['reasoning_content'] = reasoning
157
+ if (toolCalls.length > 0) {
158
+ message['tool_calls'] = toolCalls.map((call, i) => ({ id: call.id || ('call_' + i), type: 'function', function: { name: call.name, arguments: call.arguments } }))
159
+ if (finishReason === 'stop') finishReason = 'tool_calls'
160
+ }
161
+ const completion = {
162
+ id,
163
+ object: 'chat.completion',
164
+ created: Math.floor(Date.now() / 1000),
165
+ model,
166
+ choices: [{ index: 0, message, finish_reason: finishReason }],
167
+ }
168
+ if (usage !== undefined) completion['usage'] = usage
169
+ return completion
170
+ }
171
+
172
+
173
+ /** Diagnostics: the service runs detached, so stdout goes nowhere. */
174
+ const STREAM_LOG_PATH = process.env['ZC_STREAM_LOG'] ?? join(homedir(), '.zcode-qoder-connect', 'stream.log')
175
+
176
+ /** Append one diagnostics line; logging must never break a request. */
177
+ function diag(line) {
178
+ try {
179
+ appendFileSync(STREAM_LOG_PATH, `${new Date().toISOString()} qoder ${line}
180
+ `)
181
+ } catch {
182
+ /* best effort */
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Drop assistant messages that carry neither text nor tool calls.
188
+ *
189
+ * Upstream validators reject such a message outright (Trae answers 4001 / HTTP 400
190
+ * on EVERY request carrying it), and the client stores the empty assistant message
191
+ * in its session and sends it back next turn — one interrupted turn therefore
192
+ * poisons the whole session. A real session accumulated 154 of them and could not
193
+ * send another message.
194
+ *
195
+ * @param {string} bodyJson raw request body
196
+ * @returns {{ body: string, dropped: number }}
197
+ */
198
+ function sanitizeHistory(bodyJson) {
199
+ let parsed
200
+ try {
201
+ parsed = JSON.parse(bodyJson)
202
+ } catch {
203
+ return { body: bodyJson, dropped: 0 }
204
+ }
205
+ if (!Array.isArray(parsed?.messages)) return { body: bodyJson, dropped: 0 }
206
+ const before = parsed.messages.length
207
+ const messages = parsed.messages.filter((message) => {
208
+ if (message?.role !== 'assistant') return true
209
+ const content = message.content
210
+ const hasText = typeof content === 'string'
211
+ ? content.trim() !== ''
212
+ : Array.isArray(content) && content.some((block) => String(block?.text ?? block?.content ?? '').length > 0)
213
+ const calls = Array.isArray(message.tool_calls)
214
+ ? message.tool_calls.length
215
+ : Array.isArray(message.function_call) ? message.function_call.length : 0
216
+ return hasText || calls > 0
217
+ })
218
+ const dropped = before - messages.length
219
+ if (dropped === 0) return { body: bodyJson, dropped: 0 }
220
+ parsed.messages = messages
221
+ return { body: JSON.stringify(parsed), dropped }
222
+ }
223
+
224
+ /**
225
+ * Proxy an upstream SSE stream into clean OpenAI chunks.
226
+ *
227
+ * Content and `reasoning_content` deltas are forwarded immediately so the
228
+ * caller streams token-by-token; usage and finish reasons pass through
229
+ * unchanged.
230
+ *
231
+ * Heartbeats: reasoning models can think for minutes before emitting a single
232
+ * token, and the upstream pads that silence with SSE comment lines. Comments
233
+ * are not forwarded (they carry nothing), but the silence must not be passed
234
+ * on either — a client whose read timeout is shorter than the thinking time
235
+ * would abort a request that is still working. An SSE comment is emitted
236
+ * locally every {@link HEARTBEAT_MS} of silence instead: every SSE client
237
+ * ignores comment lines, so this keeps intermediaries and read timers alive
238
+ * without inventing protocol events.
239
+ */
240
+ function createStreamNormalizer(res, fallbackModel, { logger, onStall } = {}) {
241
+ let model = fallbackModel
242
+ let id = `chatcmpl-${Date.now()}`
243
+ let created = Math.floor(Date.now() / 1000)
244
+ let done = false
245
+ let settled = false
246
+ /** Any tool_call delta reached the wire: the finish reason must say so. */
247
+ let toolCallsSent = false
248
+ let heartbeat
249
+ let idleTimer
250
+
251
+ const bumpHeartbeat = () => {
252
+ if (heartbeat !== undefined) clearInterval(heartbeat)
253
+ heartbeat = setInterval(() => {
254
+ if (res.writable && !done) res.write(': heartbeat\n\n')
255
+ }, HEARTBEAT_MS)
256
+ heartbeat.unref?.()
257
+ }
258
+
259
+ const write = payload => {
260
+ if (!res.writable || done) return
261
+ res.write(`data: ${JSON.stringify(payload)}\n\n`)
262
+ }
263
+
264
+ /**
265
+ * Upstream-liveness watchdog. Re-armed on every upstream byte, so only real
266
+ * silence counts (our own heartbeats deliberately do NOT re-arm it — that
267
+ * would keep masking a dead upstream forever). Firing closes the response
268
+ * with an error frame plus `network_error`, which a client retries, instead
269
+ * of an open socket nobody will ever answer.
270
+ */
271
+ const armIdle = () => {
272
+ if (idleTimer !== undefined) clearTimeout(idleTimer)
273
+ idleTimer = setTimeout(() => {
274
+ if (done || settled) return
275
+ logger?.warn?.(`[qoder] upstream silent for ${STREAM_IDLE_TIMEOUT_MS}ms — aborting the turn`)
276
+ write({
277
+ error: {
278
+ message: `upstream produced no data for ${STREAM_IDLE_TIMEOUT_MS}ms`,
279
+ type: 'upstream_stalled',
280
+ code: 'upstream_stalled',
281
+ },
282
+ })
283
+ emitFinish('network_error')
284
+ done = true
285
+ if (res.writable) res.end('data: [DONE]\n\n')
286
+ onStall?.()
287
+ }, STREAM_IDLE_TIMEOUT_MS)
288
+ idleTimer.unref?.()
289
+ }
290
+
291
+ bumpHeartbeat()
292
+ armIdle()
293
+
294
+ const emitFinish = finishReason => {
295
+ if (settled) return
296
+ settled = true
297
+ if (idleTimer !== undefined) clearTimeout(idleTimer)
298
+ // A turn that streamed tool calls must say `tool_calls` even when the
299
+ // upstream labelled it `stop`: clients gate their agent loop on this
300
+ // finish reason.
301
+ const wireFinish = toolCallsSent && (finishReason === 'stop' || finishReason === 'end')
302
+ ? 'tool_calls'
303
+ : finishReason
304
+ write({
305
+ id,
306
+ object: 'chat.completion.chunk',
307
+ created,
308
+ model,
309
+ choices: [{ index: 0, delta: {}, finish_reason: wireFinish }],
310
+ })
311
+ }
312
+
313
+ /** Handle one parsed upstream chunk. Returns true once the stream is spent. */
314
+ const onChunk = chunk => {
315
+ if (typeof chunk['id'] === 'string' && chunk['id'] !== '') id = chunk['id']
316
+ if (typeof chunk['model'] === 'string' && chunk['model'] !== '') model = chunk['model']
317
+ if (typeof chunk['created'] === 'number') created = chunk['created']
318
+
319
+ const choice = Array.isArray(chunk['choices']) ? chunk['choices'][0] : undefined
320
+ if (choice === undefined) {
321
+ // A usage-only chunk carries no choices; forward it as-is.
322
+ if (chunk['usage'] !== undefined && chunk['usage'] !== null) write(chunk)
323
+ return false
324
+ }
325
+ const delta = choice['delta']
326
+ const finishReason = choice['finish_reason']
327
+
328
+ if (typeof delta === 'object' && delta !== null) {
329
+ const carry = {}
330
+ if (typeof delta['content'] === 'string' && delta['content'] !== '') carry['content'] = delta['content']
331
+ if (typeof delta['reasoning_content'] === 'string' && delta['reasoning_content'] !== '') {
332
+ carry['reasoning_content'] = delta['reasoning_content']
333
+ }
334
+ // Pending tool calls must reach the caller. Dropping them here (the old
335
+ // behaviour: only content/reasoning were carried) left the client with a
336
+ // turn that asked for a tool it never received — the agent simply stops
337
+ // and the task stalls.
338
+ if (Array.isArray(delta['tool_calls']) && delta['tool_calls'].length > 0) {
339
+ carry['tool_calls'] = delta['tool_calls']
340
+ toolCallsSent = true
341
+ }
342
+ if (Object.keys(carry).length > 0) {
343
+ write({
344
+ id,
345
+ object: 'chat.completion.chunk',
346
+ created,
347
+ model,
348
+ choices: [{ index: 0, delta: { role: 'assistant', ...carry }, finish_reason: null }],
349
+ })
350
+ }
351
+ }
352
+
353
+ // The final chunk normally carries the usage totals; forward them so the
354
+ // client can account for the call.
355
+ if (chunk['usage'] !== undefined && chunk['usage'] !== null) {
356
+ write({ ...chunk, id, model })
357
+ }
358
+
359
+ if (typeof finishReason === 'string' && finishReason !== '') {
360
+ emitFinish(finishReason)
361
+ // Stop reading on the first finish event: everything after it is the
362
+ // upstream repeating itself, and holding the connection open while it
363
+ // does keeps the request alive for no benefit.
364
+ return true
365
+ }
366
+ return false
367
+ }
368
+
369
+ return {
370
+ push(text) {
371
+ armIdle()
372
+ let sawEnd = false
373
+ for (const line of text.split('\n')) {
374
+ const trimmed = line.trim()
375
+ if (!trimmed.startsWith('data:')) continue
376
+ const payload = trimmed.slice(5).trim()
377
+ if (payload === '') continue
378
+ if (payload === '[DONE]') {
379
+ sawEnd = true
380
+ continue
381
+ }
382
+ let chunk
383
+ try {
384
+ chunk = JSON.parse(payload)
385
+ } catch {
386
+ continue
387
+ }
388
+ if (onChunk(chunk)) sawEnd = true
389
+ }
390
+ return sawEnd
391
+ },
392
+ /** Finish the response: terminate with the OpenAI [DONE] sentinel. */
393
+ end() {
394
+ if (heartbeat !== undefined) clearInterval(heartbeat)
395
+ if (idleTimer !== undefined) clearTimeout(idleTimer)
396
+ if (done) return
397
+ if (!settled) {
398
+ // No finish frame from the upstream: this answer was cut, not
399
+ // finished. Saying nothing (or `stop`) makes the client believe the
400
+ // model chose to end the task here.
401
+ logger?.warn?.('[qoder] upstream stream ended without a finish frame — closing as a truncated answer')
402
+ emitFinish('length')
403
+ }
404
+ done = true
405
+ if (res.writable) res.end('data: [DONE]\n\n')
406
+ },
407
+ /** Stop the heartbeat without ending the response (client went away). */
408
+ dispose() {
409
+ if (heartbeat !== undefined) clearInterval(heartbeat)
410
+ if (idleTimer !== undefined) clearTimeout(idleTimer)
411
+ done = true
412
+ },
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Start the endpoint.
418
+ *
419
+ * @param {object} options
420
+ * @param {string} options.token bearer this endpoint requires
421
+ * @param {number} [options.port] fixed port; 0 (default) asks the OS
422
+ * @param {Array<{id: string, path: string, prefix: string, store: object, catalog: object, client: object}>} [options.variants]
423
+ * @param {object} [options.logger]
424
+ */
425
+ export function createQoderServer(options) {
426
+ const { token } = options
427
+ const port = options.port ?? 0
428
+ const logger = options.logger ?? console
429
+ /**
430
+ * Variant registry. Each entry: { id, path, prefix, store, catalog, client }.
431
+ *
432
+ * The two editions share model ids but bill against separate accounts, so
433
+ * the edition travels in the URL path: the root serves 国内版 and `/intl`
434
+ * the international one. `prefix` is a legacy disambiguator kept for
435
+ * configurations written before the path split.
436
+ */
437
+ const variants = options.variants ?? [{ id: 'cn', path: '', prefix: '', store: options.store, catalog: options.catalog, client: options.client }]
438
+
439
+ let lastCredits = new Map()
440
+ let lastCatalogError = new Map()
441
+ let lastRefreshAtMs = new Map()
442
+
443
+ /** The variant a request path is mounted under; the longest mount wins. */
444
+ function variantForPath(pathname) {
445
+ let best
446
+ for (const v of variants) {
447
+ const mount = v.path ?? ''
448
+ if (mount === '') continue
449
+ if (pathname !== mount && !pathname.startsWith(`${mount}/`)) continue
450
+ if (best === undefined || mount.length > (best.path ?? '').length) best = v
451
+ }
452
+ return best
453
+ }
454
+
455
+ /** The variant served at the root, which is what a bare `/v1` addresses. */
456
+ function defaultVariant() {
457
+ return variants.find(v => (v.path ?? '') === '') ?? variants[0]
458
+ }
459
+
460
+ /**
461
+ * Legacy route: a model id carrying a variant's old prefix names its
462
+ * edition outright. Returns undefined when the id carries no known prefix.
463
+ */
464
+ /**
465
+ * Public model ids hide Qoder's internal keys (dmodel, qfmodel …): a caller
466
+ * addressing e.g. 'deepseek-v4-pro' is routed to the internal key upstream.
467
+ * Round-trips: publicAliasFor(internal) for listings, internalKeyFor(public)
468
+ * for chat routing. Internal keys keep working when addressed directly.
469
+ */
470
+ const MODEL_ALIASES = {
471
+ 'auto': 'qoder-auto',
472
+ 'fast': 'qoder-fast',
473
+ 'qmodel_38max': 'qwen3.8-max',
474
+ 'qfmodel': 'qwen3.8-flash',
475
+ 'dmodel': 'deepseek-v4-pro',
476
+ 'dfmodel': 'deepseek-flash',
477
+ 'gmodel': 'glm-5.3',
478
+ 'gfmodel': 'glm-5.3-flash',
479
+ 'kmodel_latest': 'kimi-k3.1-pro',
480
+ 'kmodel': 'kimi-k2.8-preview',
481
+ 'mmodel': 'minimax-m3.1-flash',
482
+ }
483
+ function publicAliasFor(internalKey) {
484
+ return MODEL_ALIASES[internalKey] ?? internalKey
485
+ }
486
+ function internalKeyFor(publicId) {
487
+ for (const [internal, alias] of Object.entries(MODEL_ALIASES)) {
488
+ if (alias === publicId) return internal
489
+ }
490
+ return undefined
491
+ }
492
+
493
+ function variantForPrefix(modelId) {
494
+ for (const v of variants) {
495
+ const prefix = v.prefix ?? ''
496
+ if (prefix !== '' && modelId.startsWith(prefix)) {
497
+ return { variant: v, modelId: modelId.slice(prefix.length) }
498
+ }
499
+ }
500
+ return undefined
501
+ }
502
+
503
+ /**
504
+ * Pull the live roster for every variant and gate visibility on having a
505
+ * usable credential.
506
+ *
507
+ * The gate is load-bearing: a signed-out account must expose *no* models.
508
+ * Serving the fallback roster to a signed-out user offers models that can
509
+ * only fail, which is worse than showing nothing. A variant that already
510
+ * published a roster keeps it across a transient upstream failure — a
511
+ * momentary error must not blank out a working provider.
512
+ */
513
+ async function refreshCatalog() {
514
+ const results = []
515
+ for (const variant of variants) {
516
+ const { catalog } = variant
517
+ const client = variant.client ?? options.client
518
+ try {
519
+ const credential = await variant.store.resolve()
520
+ const models = await client.fetchModels(credential)
521
+ catalog.set(models, { source: client.lastCatalog?.source, fetchedAtMs: client.lastCatalog?.fetchedAtMs })
522
+ catalog.setVisible(true)
523
+ lastCatalogError.set(variant.id, undefined)
524
+ lastRefreshAtMs.set(variant.id, Date.now())
525
+ logger.log(`[qoder] ${variant.id} catalog refreshed: ${models.length} models (${catalog.source})`)
526
+ try {
527
+ lastCredits.set(variant.id, await client.fetchCredits(credential))
528
+ } catch (error) {
529
+ logger.warn(`[qoder] ${variant.id} credit lookup failed: ${String(error)}`)
530
+ }
531
+ results.push({ variant: variant.id, ok: true, count: models.length })
532
+ } catch (error) {
533
+ // No credential, or the roster pull failed.
534
+ if (!catalog.isVisible()) catalog.setVisible(false)
535
+ lastCatalogError.set(variant.id, error instanceof Error ? error.message : String(error))
536
+ logger.warn(`[qoder] ${variant.id} catalog unavailable: ${lastCatalogError.get(variant.id)}`)
537
+ results.push({ variant: variant.id, ok: false, error: lastCatalogError.get(variant.id) })
538
+ }
539
+ }
540
+ return { ok: results.some(r => r.ok), results }
541
+ }
542
+
543
+ const server = createServer((req, res) => {
544
+ void handle(req, res).catch(error => {
545
+ if (!res.headersSent) writeOpenAIError(res, 500, 'internal', String(error))
546
+ else res.end()
547
+ })
548
+ })
549
+
550
+ const ready = new Promise((resolve, reject) => {
551
+ server.once('listening', () => resolve())
552
+ server.once('error', reject)
553
+ })
554
+
555
+ async function handle(req, res) {
556
+ // Every request must name the loopback host, and browser-sent origins must
557
+ // be loopback too. A DNS-rebinding page satisfies neither.
558
+ if (!hostIsLoopback(req.headers.host)) {
559
+ writeOpenAIError(res, 403, 'host_not_allowed', 'Host header must name the loopback interface')
560
+ return
561
+ }
562
+ if (!originIsLoopback(req.headers.origin)) {
563
+ writeOpenAIError(res, 403, 'origin_not_allowed', 'Origin must be a loopback origin')
564
+ return
565
+ }
566
+ const url = (req.url ?? '/').split('?')[0]
567
+ // A variant mount is stripped before routing, so `/intl/v1/chat/completions`
568
+ // reaches the same handler as `/v1/chat/completions` with the international
569
+ // variant selected. Every route honours a mount, management routes included.
570
+ const scoped = variantForPath(url)
571
+ const stripped = url.slice(scoped === undefined ? 0 : (scoped.path ?? '').length)
572
+ const path = stripped === '' ? '/' : stripped
573
+
574
+ // Model discovery and health probes are exempt from the bearer: a client
575
+ // registering this endpoint (ZCode's provider-adding flow probes
576
+ // `/v1/models`, sometimes without credentials) must be able to see the
577
+ // roster, and it carries nothing sensitive — ids, windows, vision flags.
578
+ // Chat stays behind the bearer because it spends the account's quota.
579
+ const publicRead = req.method === 'GET'
580
+ && (path === '/v1/models' || path === '/v1/models/' || path === '/healthz' || path === '/healthz/')
581
+ if (!publicRead && !bearerOk(req, token)) {
582
+ // Diagnostics: a client that reaches this line either sent no
583
+ // Authorization header or a stale/mistyped bearer. Record the shape
584
+ // (never the full token) so a UI-added provider with an empty key
585
+ // field is distinguishable from a rotated one.
586
+ const auth = req.headers.authorization
587
+ logger.warn(`[qoder] 401 ${req.method} ${url} auth=${auth === undefined ? 'absent' : `${String(auth).slice(0, 16)}…(${String(auth).length} chars)`} ua=${(req.headers['user-agent'] ?? '?').slice(0, 90)}`)
588
+ writeOpenAIError(res, 401, 'unauthorized', 'missing or invalid Authorization bearer')
589
+ return
590
+ }
591
+
592
+ if (req.method === 'GET' && (path === '/healthz' || path === '/healthz/')) {
593
+ writeJson(res, 200, {
594
+ ok: true,
595
+ models: variants.reduce((sum, v) => sum + v.catalog.current().length, 0),
596
+ variants: variants.map(v => ({ id: v.id, models: v.catalog.current().length, source: v.catalog.source, visible: v.catalog.isVisible() })),
597
+ })
598
+ return
599
+ }
600
+ if (req.method === 'GET' && (path === '/v1/models' || path === '/v1/models/')) {
601
+ // Scoped to the mount: `/intl/v1/models` answers with the international
602
+ // roster under its own clean ids. The aggregate view lives at /healthz
603
+ // and /v1/status, which report every variant.
604
+ const listed = scoped === undefined ? [defaultVariant()] : [scoped]
605
+ writeJson(res, 200, {
606
+ object: 'list',
607
+ data: listed.flatMap(variant => variant.catalog.current().map(model => ({
608
+ id: publicAliasFor(model.id),
609
+ object: 'model',
610
+ created: 0,
611
+ owned_by: 'qoder',
612
+ // Non-standard extras: harmless to a strict client, and the only way
613
+ // a caller can learn the real window before it sends a message the
614
+ // upstream would reject.
615
+ context_window: model.contextWindow,
616
+ max_output_tokens: model.maxTokens,
617
+ supports_images: model.supportsImages,
618
+ display_name: model.name,
619
+ }))),
620
+ })
621
+ return
622
+ }
623
+ if (req.method === 'POST' && (path === '/v1/refresh' || path === '/v1/refresh/')) {
624
+ const result = await refreshCatalog()
625
+ writeJson(res, result.ok ? 200 : 503, result)
626
+ return
627
+ }
628
+ if (req.method === 'GET' && (path === '/v1/status' || path === '/v1/status/')) {
629
+ const statuses = await Promise.all(variants.map(async variant => ({
630
+ variant: variant.id,
631
+ auth: await variant.store.status(),
632
+ credits: lastCredits.get(variant.id),
633
+ catalog: {
634
+ source: variant.catalog.source,
635
+ visible: variant.catalog.isVisible(),
636
+ count: variant.catalog.current().length,
637
+ fetchedAtMs: variant.catalog.fetchedAtMs,
638
+ lastRefreshAtMs: lastRefreshAtMs.get(variant.id),
639
+ error: lastCatalogError.get(variant.id),
640
+ },
641
+ })))
642
+ writeJson(res, 200, { variants: statuses })
643
+ return
644
+ }
645
+ if (req.method === 'POST' && (path === '/v1/chat/completions' || path === '/v1/chat/completions/')) {
646
+ await chatCompletions(req, res, scoped)
647
+ return
648
+ }
649
+ // A 404 here is usually a client probing the wrong path (a provider-adding
650
+ // flow appending /v1 twice, say). Log it so the path can be read off the
651
+ // wire instead of guessed.
652
+ logger.warn(`[qoder] 404 ${req.method} ${url} host=${req.headers.host ?? '?'} ua=${(req.headers['user-agent'] ?? '?').slice(0, 80)}`)
653
+ writeOpenAIError(res, 404, 'not_found', `no such route: ${req.method} ${url}`)
654
+ }
655
+
656
+ async function chatCompletions(req, res, scoped) {
657
+ if (!isJsonContentType(req)) {
658
+ writeOpenAIError(res, 415, 'unsupported_media_type', 'Content-Type must be application/json')
659
+ return
660
+ }
661
+
662
+ const raw = (await readBody(req)).toString('utf8')
663
+ // 丢掉「既无正文也无工具调用」的 assistant 消息:留着会让上游之后每一轮都拒绝请求。
664
+ const sanitized = sanitizeHistory(raw)
665
+ if (sanitized.dropped > 0) {
666
+ logger.warn(`[qoder] dropped ${sanitized.dropped} empty assistant message(s) from the request history`)
667
+ }
668
+ let wantsStream = true
669
+ let model = 'auto'
670
+ try {
671
+ const parsed = JSON.parse(raw)
672
+ wantsStream = parsed?.['stream'] !== false
673
+ if (typeof parsed?.['model'] === 'string') model = parsed['model']
674
+ } catch {
675
+ // A non-JSON body fails in the upstream client below; treat it as
676
+ // asking to stream until then.
677
+ }
678
+
679
+ // The edition comes from the mount the request arrived on. A legacy
680
+ // prefix on the id still names it outright and wins, so configurations
681
+ // written before the path split keep routing correctly.
682
+ const aliasTarget = internalKeyFor(model)
683
+ const legacy = variantForPrefix(model)
684
+ const variant = legacy === undefined ? (scoped ?? defaultVariant()) : legacy.variant
685
+ const upstreamModel = aliasTarget ?? (legacy === undefined ? model : legacy.modelId)
686
+ const escaped = model.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
687
+ const routed = upstreamModel === model
688
+ ? sanitized.body
689
+ : sanitized.body.replace(new RegExp(`("model"\\s*:\\s*")${escaped}"`), `$1${upstreamModel}"`)
690
+
691
+ let credential
692
+ try {
693
+ credential = await variant.store.resolve()
694
+ } catch (error) {
695
+ writeOpenAIError(res, 401, 'not_signed_in', `${variant.id}: ${String(error)}`)
696
+ return
697
+ }
698
+ const client = variant.client ?? options.client
699
+
700
+ const controller = new AbortController()
701
+ // Disconnect detection: on Node >= 16 `req` 'close' fires when the request
702
+ // BODY has been received, not when the client goes away, so it can never
703
+ // be the disconnect signal (the listener here is attached after the event
704
+ // has already fired). `res` 'close' is the real socket close;
705
+ // `writableEnded` separates it from a response we finished ourselves.
706
+ res.on('close', () => {
707
+ if (res.writableEnded) return
708
+ logger.warn('[qoder] client disconnected mid-stream — releasing the upstream turn')
709
+ controller.abort('client disconnected')
710
+ })
711
+ const result = await client.chatStream(credential, routed, controller.signal)
712
+
713
+ if (!result.ok) {
714
+ const kind = KIND_STATUS[result.kind] === undefined ? 'server' : result.kind
715
+ const detail = String(result.message ?? '').trim()
716
+ const message = detail === ''
717
+ ? `qoder upstream returned http ${result.status} with an empty body (most often a rejected history: empty assistant message, orphan tool result, or an oversized payload)`
718
+ : `qoder upstream ${result.kind} (http ${result.status}): ${detail.slice(0, 400)}`
719
+ diag(`upstream-fail kind=${result.kind} status=${result.status} msg=${detail.slice(0, 120)}`)
720
+ writeOpenAIError(res, KIND_STATUS[kind], result.kind, message)
721
+ return
722
+ }
723
+
724
+ if (!wantsStream) {
725
+ // The upstream always streams; reassemble for a client that asked for one
726
+ // JSON answer. Errors are surfaced as a real 502 rather than a 200 with an
727
+ // empty body, because a silent empty completion is indistinguishable from
728
+ // a model that chose to say nothing.
729
+ try {
730
+ const completion = await collectCompletion(result.response.body, model)
731
+ writeJson(res, 200, completion)
732
+ } catch (error) {
733
+ writeOpenAIError(res, 502, 'upstream_stream_failed', String(error))
734
+ }
735
+ return
736
+ }
737
+
738
+ res.writeHead(200, {
739
+ 'Content-Type': 'text/event-stream',
740
+ 'Cache-Control': 'no-cache',
741
+ Connection: 'keep-alive',
742
+ 'X-Accel-Buffering': 'no',
743
+ })
744
+ const normalizer = createStreamNormalizer(res, model, {
745
+ logger,
746
+ onStall: () => controller.abort('upstream stalled'),
747
+ })
748
+ const decoder = new TextDecoder()
749
+ const reader = result.response.body.getReader()
750
+ try {
751
+ for (;;) {
752
+ const { done, value } = await reader.read()
753
+ if (done) break
754
+ if (normalizer.push(decoder.decode(value, { stream: true }))) break
755
+ }
756
+ normalizer.end()
757
+ } catch (error) {
758
+ logger.warn(`[qoder] upstream stream failed mid-flight: ${String(error)}`)
759
+ // Always close the SSE stream properly: a client left waiting for a
760
+ // terminator that never arrives hangs the whole conversation.
761
+ normalizer.end()
762
+ } finally {
763
+ normalizer.dispose()
764
+ void result.response.body.cancel?.().catch?.(() => {})
765
+ }
766
+ }
767
+
768
+ let refreshTimer
769
+
770
+ return {
771
+ ready,
772
+ port: () => {
773
+ const address = server.address()
774
+ if (address === null || typeof address === 'string') throw new Error('trae server has no listening address')
775
+ return address.port
776
+ },
777
+ baseUrl: () => `http://127.0.0.1:${(() => {
778
+ const address = server.address()
779
+ if (address === null || typeof address === 'string') throw new Error('trae server has no listening address')
780
+ return address.port
781
+ })()}`,
782
+ refreshCatalog,
783
+ credits: () => lastCredits,
784
+ start({ listen = true } = {}) {
785
+ if (listen) server.listen(port, '127.0.0.1')
786
+ void refreshCatalog().then(() => {
787
+ refreshTimer = setInterval(() => void refreshCatalog(), CATALOG_REFRESH_MS)
788
+ refreshTimer.unref?.()
789
+ })
790
+ return ready
791
+ },
792
+ close: () => new Promise((resolve, reject) => {
793
+ if (refreshTimer !== undefined) clearInterval(refreshTimer)
794
+ server.close(() => resolve())
795
+ server.closeAllConnections()
796
+ server.once('error', reject)
797
+ }),
798
+ }
799
+ }