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