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/.zcode-plugin/plugin.json +40 -0
- package/LICENSE +22 -0
- package/NOTICE.md +49 -0
- package/README.md +264 -2
- package/bin/cli.mjs +8 -0
- package/docs/adr/0001-loopback-provider-for-zcode.md +66 -0
- package/docs/adr/0002-widest-window-and-output-ceiling.md +98 -0
- package/docs/adr/0003-region-by-url-path.md +97 -0
- package/docs/adr/0004-workbuddy-56-at-rest-credentials.md +111 -0
- package/docs/validate-provider-config.mjs +203 -0
- package/docs/verify-endpoint.mjs +162 -0
- package/hooks/service-ensure.mjs +133 -0
- package/package.json +59 -4
- package/skills/workbuddy-models/SKILL.md +107 -0
- package/src/app-version.js +119 -0
- package/src/auth.js +441 -0
- package/src/catalog.js +100 -0
- package/src/cli.js +1010 -0
- package/src/client-identity.js +213 -0
- package/src/config.js +91 -0
- package/src/desktop-credential-protection.js +890 -0
- package/src/loopback.js +40 -0
- package/src/server.js +873 -0
- package/src/upstream.js +762 -0
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
|
+
}
|