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/.zcode-plugin/plugin.json +40 -0
- package/LICENSE +21 -0
- package/README.md +71 -2
- package/bin/cli.mjs +204 -0
- package/docs/validate-provider-config.mjs +203 -0
- package/hooks/service-ensure.mjs +115 -0
- package/package.json +50 -4
- package/skills/qoder-models/SKILL.md +68 -0
- package/src/catalog.js +58 -0
- package/src/config.js +81 -0
- package/src/credentials.js +185 -0
- package/src/loopback.js +40 -0
- package/src/server.js +799 -0
- package/src/upstream.js +581 -0
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
|
+
}
|