dsh-endpoint 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/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +243 -2
- package/README.zh.md +225 -0
- package/SECURITY.md +35 -0
- package/cordis.patch.yml +9 -0
- package/docs/curl.png +0 -0
- package/docs/settings.png +0 -0
- package/examples/opencode.json +18 -0
- package/icon.svg +13 -0
- package/package.json +68 -4
- package/src/admin.js +178 -0
- package/src/client.js +517 -0
- package/src/config.js +89 -0
- package/src/dsh/catalog.js +97 -0
- package/src/index.js +28 -0
- package/src/mirror.js +286 -0
- package/src/openai/convert.js +197 -0
- package/src/openai/errors.js +68 -0
- package/src/openai/media.js +120 -0
- package/src/openai/stream.js +294 -0
- package/src/server.js +624 -0
- package/src/session/key.js +129 -0
- package/src/session/registry.js +209 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { createServer } from 'node:http'
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { mergeConfig, readOverlay, registerAdminRoutes } from './admin.js'
|
|
6
|
+
import { ModelCatalog } from './dsh/catalog.js'
|
|
7
|
+
import { SessionMirror, toolResultEvent, userMessageEvent } from './mirror.js'
|
|
8
|
+
import { MappingStore } from './session/registry.js'
|
|
9
|
+
import { deriveSessionKey, resolveExternalKey, sessionKeyHash } from './session/key.js'
|
|
10
|
+
import { firstUserText, systemText, toDshMessages, toDshTools } from './openai/convert.js'
|
|
11
|
+
import { ApiError, badRequest, fromLlmFailure, notFound, toOpenAiBody } from './openai/errors.js'
|
|
12
|
+
import {
|
|
13
|
+
CompletionCollector,
|
|
14
|
+
SseWriter,
|
|
15
|
+
StopMatcher,
|
|
16
|
+
newCompletionId,
|
|
17
|
+
toFinishReason,
|
|
18
|
+
toOpenAiUsage,
|
|
19
|
+
} from './openai/stream.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the OpenAI-compatible request handler.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately tolerant: unknown request fields are ignored (clients routinely
|
|
25
|
+
* send fields this build does not model), while the few that change semantics
|
|
26
|
+
* are rejected explicitly.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} deps
|
|
29
|
+
* @param {object} deps.config
|
|
30
|
+
* @param {{ stream: Function, listProviders: Function, listModels: Function }} deps.llm
|
|
31
|
+
* @param {ModelCatalog} deps.catalog
|
|
32
|
+
* @param {SessionMirror | undefined} deps.mirror
|
|
33
|
+
* @param {MappingStore | undefined} deps.registry
|
|
34
|
+
* @param {(level: string, message: string) => void} deps.log
|
|
35
|
+
*/
|
|
36
|
+
export function createHandler(deps) {
|
|
37
|
+
return async function handler(req, res) {
|
|
38
|
+
const requestId = `req_${randomUUID().replaceAll('-', '').slice(0, 24)}`
|
|
39
|
+
res.setHeader('x-request-id', requestId)
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
42
|
+
if (req.method === 'GET' && url.pathname === '/healthz') {
|
|
43
|
+
return sendJson(res, 200, {
|
|
44
|
+
ok: true,
|
|
45
|
+
object: 'dsh-endpoint.health',
|
|
46
|
+
providers: deps.catalog.providers().map((provider) => provider.id),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
if (req.method === 'GET' && url.pathname === '/v1/models') {
|
|
50
|
+
return sendJson(res, 200, { object: 'list', data: await deps.catalog.list() })
|
|
51
|
+
}
|
|
52
|
+
if (req.method === 'GET' && url.pathname.startsWith('/v1/models/')) {
|
|
53
|
+
const id = decodeURIComponent(url.pathname.slice('/v1/models/'.length))
|
|
54
|
+
const found = (await deps.catalog.list()).find((model) => model.id === id)
|
|
55
|
+
if (found === undefined) throw notFound(`model "${id}" not found`)
|
|
56
|
+
return sendJson(res, 200, found)
|
|
57
|
+
}
|
|
58
|
+
if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
|
|
59
|
+
const body = await readJson(req, deps.config.limits.maxBodyBytes)
|
|
60
|
+
return await handleChat(req, res, body, deps, requestId)
|
|
61
|
+
}
|
|
62
|
+
if (req.method === 'POST' && url.pathname === '/v1/completions') {
|
|
63
|
+
throw badRequest('the legacy /v1/completions endpoint is not supported; use /v1/chat/completions')
|
|
64
|
+
}
|
|
65
|
+
throw new ApiError(404, 'invalid_request_error', `unknown route ${req.method} ${url.pathname}`)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
const apiError = error instanceof ApiError ? error : new ApiError(500, 'api_error', String(error?.message ?? error))
|
|
68
|
+
if (res.headersSent) {
|
|
69
|
+
res.end()
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
sendJson(res, apiError.status, toOpenAiBody(apiError, requestId))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function handleChat(req, res, body, deps, requestId) {
|
|
78
|
+
const { config, catalog, mirror, registry, log } = deps
|
|
79
|
+
if (body === null || typeof body !== 'object') throw badRequest('request body must be a JSON object')
|
|
80
|
+
|
|
81
|
+
if (body.n !== undefined && body.n !== 1) throw badRequest('`n` values above 1 are not supported', 'n')
|
|
82
|
+
if (body.tool_choice !== undefined && body.tool_choice !== null && body.tool_choice !== 'auto') {
|
|
83
|
+
throw badRequest('only `tool_choice: "auto"` is supported', 'tool_choice')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const route = catalog.resolve(body.model)
|
|
87
|
+
const wantsStream = body.stream === true
|
|
88
|
+
const messages = await toDshMessages(body.messages, route, { saveImages: deps.saveImages })
|
|
89
|
+
const tools = toDshTools(body.tools)
|
|
90
|
+
|
|
91
|
+
const identity =
|
|
92
|
+
resolveExternalKey({ headers: req.headers, body, headerName: config.session.header }) ??
|
|
93
|
+
deriveSessionKey({
|
|
94
|
+
model: route.id,
|
|
95
|
+
systemText: systemText(body.messages),
|
|
96
|
+
firstUserText: firstUserSignal(body.messages),
|
|
97
|
+
deriveFrom: config.session.deriveFrom,
|
|
98
|
+
}) ?? { key: `auto:mint:${randomUUID()}`, source: 'minted' }
|
|
99
|
+
|
|
100
|
+
const hash = sessionKeyHash(identity.key)
|
|
101
|
+
const turn = await prepareTurn({ body, converted: messages, identity, hash, route, deps })
|
|
102
|
+
res.setHeader(config.session.header, turn.sessionId)
|
|
103
|
+
|
|
104
|
+
if (turn.state !== undefined) {
|
|
105
|
+
try {
|
|
106
|
+
mirror.beginTurn(turn.state, turn.events)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
log('warn', `could not open mirrored turn: ${String(error?.message ?? error)}`)
|
|
109
|
+
turn.state = undefined
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const collector = new CompletionCollector()
|
|
114
|
+
const stop = new StopMatcher(normaliseStop(body.stop))
|
|
115
|
+
const controller = new AbortController()
|
|
116
|
+
const abort = () => controller.abort()
|
|
117
|
+
req.on('close', abort)
|
|
118
|
+
|
|
119
|
+
const created = Math.floor(Date.now() / 1000)
|
|
120
|
+
const completionId = newCompletionId()
|
|
121
|
+
const writer = wantsStream
|
|
122
|
+
? beginSse(res, { id: completionId, created, model: route.id, sessionHeader: config.session.header, sessionId: turn.sessionId })
|
|
123
|
+
: undefined
|
|
124
|
+
|
|
125
|
+
const options = {
|
|
126
|
+
provider: route.provider,
|
|
127
|
+
model: route.model,
|
|
128
|
+
messages,
|
|
129
|
+
// Provider-side cache/routing affinity. Several gateways (opencode-go
|
|
130
|
+
// among them) reject requests that carry no session identity, and pi-ai
|
|
131
|
+
// forwards this value as their affinity header. It is derived from the
|
|
132
|
+
// conversation key, so it is stable across requests and restarts.
|
|
133
|
+
sessionId: affinityId(identity.key),
|
|
134
|
+
signal: controller.signal,
|
|
135
|
+
}
|
|
136
|
+
if (tools !== undefined) options.tools = tools
|
|
137
|
+
if (Number.isFinite(body.temperature)) options.temperature = body.temperature
|
|
138
|
+
const requestedMax = Number.isFinite(body.max_tokens) ? body.max_tokens : body.max_completion_tokens
|
|
139
|
+
const maxTokens = Number.isFinite(requestedMax)
|
|
140
|
+
? Math.max(1, Math.min(requestedMax, config.limits.maxOutputTokens))
|
|
141
|
+
: undefined
|
|
142
|
+
if (maxTokens !== undefined) options.maxTokens = maxTokens
|
|
143
|
+
|
|
144
|
+
let stopHit = false
|
|
145
|
+
let failure
|
|
146
|
+
try {
|
|
147
|
+
for await (const chunk of deps.llm.stream(options)) {
|
|
148
|
+
if (chunk?.type === 'text-delta' && stop.active) {
|
|
149
|
+
const emitted = stop.push(chunk.text)
|
|
150
|
+
if (emitted.text.length > 0) {
|
|
151
|
+
collector.push({ ...chunk, text: emitted.text })
|
|
152
|
+
writer?.chunk({ ...chunk, text: emitted.text })
|
|
153
|
+
}
|
|
154
|
+
if (emitted.hit) {
|
|
155
|
+
stopHit = true
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
collector.push(chunk)
|
|
161
|
+
writer?.chunk(chunk)
|
|
162
|
+
if (chunk?.type === 'finish' && chunk.reason?.kind === 'error') failure = chunk.reason.failure
|
|
163
|
+
}
|
|
164
|
+
if (!stopHit && stop.active) {
|
|
165
|
+
const tail = stop.flush()
|
|
166
|
+
if (tail.length > 0) {
|
|
167
|
+
collector.push({ type: 'text-delta', index: 0, text: tail })
|
|
168
|
+
writer?.chunk({ type: 'text-delta', index: 0, text: tail })
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
failure = { code: error?.code ?? 'INTERNAL', message: String(error?.message ?? error) }
|
|
173
|
+
} finally {
|
|
174
|
+
req.off('close', abort)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const usage = collector.usage
|
|
178
|
+
if (failure !== undefined) {
|
|
179
|
+
const apiError = fromLlmFailure(failure)
|
|
180
|
+
await finishTurn({ turn, hash, deps, route, collector, usage, failure: apiError })
|
|
181
|
+
log('warn', `model call failed: ${apiError.message}`)
|
|
182
|
+
if (writer !== undefined) {
|
|
183
|
+
writer.writeError(toOpenAiBody(apiError, requestId))
|
|
184
|
+
writer.end()
|
|
185
|
+
return undefined
|
|
186
|
+
}
|
|
187
|
+
throw apiError
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const finishReason = stopHit ? 'stop' : toFinishReason(collector.finishReason)
|
|
191
|
+
if (writer !== undefined) {
|
|
192
|
+
writer.finish(collector.finishReason)
|
|
193
|
+
if (body.stream_options?.include_usage === true) writer.usage(usage)
|
|
194
|
+
writer.end()
|
|
195
|
+
} else {
|
|
196
|
+
const message = { role: 'assistant', content: collector.text }
|
|
197
|
+
if (collector.reasoning.length > 0) message.reasoning_content = collector.reasoning
|
|
198
|
+
if (collector.toolCalls.length > 0) {
|
|
199
|
+
message.tool_calls = collector.toolCalls.map((call, index) => ({
|
|
200
|
+
index,
|
|
201
|
+
id: call.id,
|
|
202
|
+
type: 'function',
|
|
203
|
+
function: { name: call.name, arguments: call.arguments },
|
|
204
|
+
}))
|
|
205
|
+
message.content = collector.text.length > 0 ? collector.text : null
|
|
206
|
+
}
|
|
207
|
+
sendJson(res, 200, {
|
|
208
|
+
id: completionId,
|
|
209
|
+
object: 'chat.completion',
|
|
210
|
+
created,
|
|
211
|
+
model: route.id,
|
|
212
|
+
choices: [{ index: 0, message, finish_reason: finishReason }],
|
|
213
|
+
usage: toOpenAiUsage(usage),
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await finishTurn({ turn, hash, deps, route, collector, usage })
|
|
218
|
+
return undefined
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolve (or create) the mirror session for this conversation and work out
|
|
223
|
+
* which client messages are new since the last recorded turn.
|
|
224
|
+
*/
|
|
225
|
+
async function prepareTurn({ body, converted, identity, hash, route, deps }) {
|
|
226
|
+
const { mirror, registry, log } = deps
|
|
227
|
+
// Keep the raw message for the prefix hash (stable across re-uploads) and the
|
|
228
|
+
// converted one for the event, so images land in the mirror as attachment
|
|
229
|
+
// references instead of being flattened to text.
|
|
230
|
+
const sides = []
|
|
231
|
+
body.messages.forEach((message, index) => {
|
|
232
|
+
if (message?.role !== 'user' && message?.role !== 'tool') return
|
|
233
|
+
sides.push({ raw: message, converted: converted[index] })
|
|
234
|
+
})
|
|
235
|
+
const hashes = sides.map((side) => hashMessage(side.raw))
|
|
236
|
+
|
|
237
|
+
if (mirror === undefined || registry === undefined) {
|
|
238
|
+
return { sessionId: echoable(identity.key, hash), state: undefined, events: [], hashes }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const entry = registry.get(hash)
|
|
243
|
+
if (entry !== undefined) {
|
|
244
|
+
const recorded = entry.userHashes ?? []
|
|
245
|
+
const prefixMatches = recorded.every((recordedHash, index) => hashes[index] === recordedHash)
|
|
246
|
+
if (prefixMatches) {
|
|
247
|
+
let state
|
|
248
|
+
try {
|
|
249
|
+
state = await mirror.ensure(entry)
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (!isMissingSession(error)) throw error
|
|
252
|
+
log('warn', `mirrored session ${entry.dshSessionId} is gone; creating a replacement`)
|
|
253
|
+
const created = await mirror.create()
|
|
254
|
+
await registry.bind(hash, {
|
|
255
|
+
externalKey: identity.key,
|
|
256
|
+
source: identity.source,
|
|
257
|
+
dshSessionId: created.dshSessionId,
|
|
258
|
+
workspacePath: mirror.workspacePath,
|
|
259
|
+
model: route.id,
|
|
260
|
+
})
|
|
261
|
+
return {
|
|
262
|
+
sessionId: created.dshSessionId,
|
|
263
|
+
state: created.state,
|
|
264
|
+
events: sides.map((side) => toEvent(side.converted)),
|
|
265
|
+
hashes,
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
sessionId: entry.dshSessionId,
|
|
270
|
+
state,
|
|
271
|
+
events: sides.slice(recorded.length).map((side) => toEvent(side.converted)),
|
|
272
|
+
hashes,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
log('warn', `client history diverged for "${identity.key}"; starting a new DSH session`)
|
|
276
|
+
}
|
|
277
|
+
const created = await mirror.create()
|
|
278
|
+
await registry.bind(hash, {
|
|
279
|
+
externalKey: identity.key,
|
|
280
|
+
source: identity.source,
|
|
281
|
+
dshSessionId: created.dshSessionId,
|
|
282
|
+
workspacePath: mirror.workspacePath,
|
|
283
|
+
model: route.id,
|
|
284
|
+
})
|
|
285
|
+
return {
|
|
286
|
+
sessionId: created.dshSessionId,
|
|
287
|
+
state: created.state,
|
|
288
|
+
events: sides.map((side) => toEvent(side.converted)),
|
|
289
|
+
hashes,
|
|
290
|
+
}
|
|
291
|
+
} catch (error) {
|
|
292
|
+
log('warn', `session mirror unavailable for this request: ${String(error?.message ?? error)}`)
|
|
293
|
+
return { sessionId: echoable(identity.key, hash), state: undefined, events: [], hashes }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function finishTurn({ turn, hash, deps, route, collector, usage, failure }) {
|
|
298
|
+
const { mirror, registry, log } = deps
|
|
299
|
+
if (mirror === undefined || registry === undefined || turn.state === undefined) return
|
|
300
|
+
try {
|
|
301
|
+
if (failure === undefined) {
|
|
302
|
+
mirror.completeTurn(turn.state, {
|
|
303
|
+
blocks: collector.blocks,
|
|
304
|
+
chunks: collector.chunks,
|
|
305
|
+
usage,
|
|
306
|
+
provider: route.provider,
|
|
307
|
+
model: route.model,
|
|
308
|
+
})
|
|
309
|
+
} else {
|
|
310
|
+
mirror.failTurn(turn.state, { message: failure.message, code: failure.code ?? 'api_error' })
|
|
311
|
+
}
|
|
312
|
+
const entry = registry.get(hash)
|
|
313
|
+
if (entry !== undefined) {
|
|
314
|
+
await registry.update(hash, {
|
|
315
|
+
turns: turn.state.turn,
|
|
316
|
+
requests: entry.requests + 1,
|
|
317
|
+
inputTokens: entry.inputTokens + (usage?.inputTokens ?? 0),
|
|
318
|
+
outputTokens: entry.outputTokens + (usage?.outputTokens ?? 0),
|
|
319
|
+
userHashes: turn.hashes,
|
|
320
|
+
model: route.id,
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
void mirror.flush(turn.state)
|
|
324
|
+
} catch (error) {
|
|
325
|
+
log('warn', `could not record mirrored turn: ${String(error?.message ?? error)}`)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Deterministic UUID-shaped affinity id for one conversation. */
|
|
330
|
+
function affinityId(key) {
|
|
331
|
+
const h = createHash('sha256').update(`dsh-endpoint-affinity\u0000${key}`).digest('hex')
|
|
332
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Header values must be a single ASCII line; fall back to the opaque hash. */
|
|
336
|
+
function echoable(key, hash) {
|
|
337
|
+
return /^[\x20-\x7e]{1,200}$/.test(key) ? key : `unmirrored-${hash.slice(0, 8)}`
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The mirror records text only; media parts become a stable placeholder so an
|
|
342
|
+
* image-only message still produces a valid `user/message` event.
|
|
343
|
+
*/
|
|
344
|
+
/** Converted DSH message -> mirror surface event (keeps image/file references). */
|
|
345
|
+
function toEvent(message) {
|
|
346
|
+
if (message.role === 'tool') {
|
|
347
|
+
return toolResultEvent({
|
|
348
|
+
callId: message.toolCallId,
|
|
349
|
+
text: textOfBlocks(message.content),
|
|
350
|
+
isError: message.isError === true,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
return userMessageEvent(message.content)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function textOfBlocks(content) {
|
|
357
|
+
if (!Array.isArray(content)) return ''
|
|
358
|
+
return content
|
|
359
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
360
|
+
.map((block) => block.text)
|
|
361
|
+
.join('')
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Fingerprint input for the first user message. Text is preferred; an
|
|
366
|
+
* image-only opener falls back to a hash of its parts so the conversation key
|
|
367
|
+
* stays stable instead of minting a new session per request.
|
|
368
|
+
*/
|
|
369
|
+
function firstUserSignal(messages) {
|
|
370
|
+
const text = firstUserText(messages)
|
|
371
|
+
if (text.length > 0) return text
|
|
372
|
+
const first = messages.find((message) => message?.role === 'user')
|
|
373
|
+
if (first === undefined) return ''
|
|
374
|
+
const parts = JSON.stringify(first.content ?? '')
|
|
375
|
+
return `parts:${createHash('sha256').update(parts).digest('hex').slice(0, 32)}`
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
function hashMessage(message) {
|
|
380
|
+
return createHash('sha256')
|
|
381
|
+
.update(JSON.stringify([message.role, message.content ?? null, message.tool_call_id ?? null]))
|
|
382
|
+
.digest('hex')
|
|
383
|
+
.slice(0, 32)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function normaliseStop(value) {
|
|
387
|
+
if (typeof value === 'string') return [value]
|
|
388
|
+
if (Array.isArray(value)) return value.filter((item) => typeof item === 'string')
|
|
389
|
+
return []
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function beginSse(res, { id, created, model, sessionHeader, sessionId }) {
|
|
393
|
+
res.writeHead(200, {
|
|
394
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
395
|
+
'cache-control': 'no-cache, no-transform',
|
|
396
|
+
'x-accel-buffering': 'no',
|
|
397
|
+
[sessionHeader]: sessionId,
|
|
398
|
+
})
|
|
399
|
+
const writer = new SseWriter({ res, id, created, model })
|
|
400
|
+
writer.start()
|
|
401
|
+
return writer
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function sendJson(res, status, payload) {
|
|
405
|
+
const body = JSON.stringify(payload)
|
|
406
|
+
res.writeHead(status, {
|
|
407
|
+
'content-type': 'application/json; charset=utf-8',
|
|
408
|
+
'content-length': Buffer.byteLength(body),
|
|
409
|
+
})
|
|
410
|
+
res.end(body)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function readJson(req, limit) {
|
|
414
|
+
const chunks = []
|
|
415
|
+
let size = 0
|
|
416
|
+
for await (const chunk of req) {
|
|
417
|
+
size += chunk.length
|
|
418
|
+
if (size > limit) throw badRequest(`request body exceeds ${limit} bytes`)
|
|
419
|
+
chunks.push(chunk)
|
|
420
|
+
}
|
|
421
|
+
if (chunks.length === 0) throw badRequest('request body is empty')
|
|
422
|
+
try {
|
|
423
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
424
|
+
} catch {
|
|
425
|
+
throw badRequest('request body is not valid JSON')
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Start the listener and return its disposer.
|
|
431
|
+
*
|
|
432
|
+
* @param {object} ctx
|
|
433
|
+
* @param {object} config
|
|
434
|
+
* @param {{ log: (level: string, message: string) => void }} options
|
|
435
|
+
*/
|
|
436
|
+
export async function startEndpoint(ctx, config, options) {
|
|
437
|
+
const log = options.log
|
|
438
|
+
const llm = ctx.get('llm')
|
|
439
|
+
if (llm === undefined) throw new Error('dsh-endpoint: ctx.llm is not available in this profile')
|
|
440
|
+
|
|
441
|
+
const root = options.stateRoot ?? resolveStateRoot(ctx)
|
|
442
|
+
const overlay = await readOverlay(root)
|
|
443
|
+
const effective = mergeConfig(config, overlay)
|
|
444
|
+
|
|
445
|
+
const catalog = new ModelCatalog(llm, {
|
|
446
|
+
aliases: effective.modelAliases,
|
|
447
|
+
defaultModel: effective.defaultModel,
|
|
448
|
+
log,
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
let mirror
|
|
452
|
+
let registry
|
|
453
|
+
if (effective.session.mirror === true) {
|
|
454
|
+
const workspacePath = effective.session.workspacePath || join(root, 'conversations')
|
|
455
|
+
registry = new MappingStore(join(root, 'state.json'), { log })
|
|
456
|
+
await registry.open()
|
|
457
|
+
mirror = new SessionMirror(ctx, { workspacePath, log })
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let sweepTimer
|
|
461
|
+
if (mirror !== undefined && registry !== undefined) {
|
|
462
|
+
const sweep = () => void sweepExpired(ctx, registry, mirror, effective, log)
|
|
463
|
+
sweepTimer = setInterval(sweep, 60 * 60 * 1000)
|
|
464
|
+
sweepTimer.unref?.()
|
|
465
|
+
const first = setTimeout(sweep, 10_000)
|
|
466
|
+
first.unref?.()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const attachments = ctx.get('attachments')
|
|
470
|
+
const saveImages =
|
|
471
|
+
attachments === undefined ? undefined : (inputs) => attachments.saveImages(inputs)
|
|
472
|
+
const handler = createHandler({
|
|
473
|
+
config: effective,
|
|
474
|
+
llm,
|
|
475
|
+
catalog,
|
|
476
|
+
mirror,
|
|
477
|
+
registry,
|
|
478
|
+
log,
|
|
479
|
+
saveImages,
|
|
480
|
+
})
|
|
481
|
+
let current = await listenOn(handler, effective.host, effective.port)
|
|
482
|
+
logListening(effective, current.port, log)
|
|
483
|
+
|
|
484
|
+
// Serialized listener swaps: the settings page can move host/port without a
|
|
485
|
+
// plugin reload. A failed bind restores the previous address instead of
|
|
486
|
+
// leaving the endpoint down.
|
|
487
|
+
let rebindChain = Promise.resolve()
|
|
488
|
+
const rebind = (nextHost, nextPort) => {
|
|
489
|
+
rebindChain = rebindChain.then(async () => {
|
|
490
|
+
const previous = { host: effective.host, port: current.port }
|
|
491
|
+
await closeServer(current.server)
|
|
492
|
+
try {
|
|
493
|
+
current = await listenOn(handler, nextHost, nextPort)
|
|
494
|
+
} catch (error) {
|
|
495
|
+
try {
|
|
496
|
+
current = await listenOn(handler, previous.host, previous.port)
|
|
497
|
+
} catch {
|
|
498
|
+
current = { server: undefined, port: previous.port }
|
|
499
|
+
}
|
|
500
|
+
throw new Error(`cannot listen on ${formatHost(nextHost)}:${nextPort}: ${error?.message ?? error}`)
|
|
501
|
+
}
|
|
502
|
+
effective.host = nextHost
|
|
503
|
+
effective.port = nextPort
|
|
504
|
+
logListening(effective, current.port, log)
|
|
505
|
+
})
|
|
506
|
+
return rebindChain
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
registerAdminRoutes(ctx, {
|
|
510
|
+
root,
|
|
511
|
+
log,
|
|
512
|
+
getConfig: () => effective,
|
|
513
|
+
onSave: async (next, patch) => {
|
|
514
|
+
const hostChanged = patch.host !== undefined && patch.host !== effective.host
|
|
515
|
+
const portChanged = patch.port !== undefined && Number(patch.port) !== effective.port
|
|
516
|
+
if (hostChanged || portChanged) await rebind(next.host, next.port)
|
|
517
|
+
for (const key of Object.keys(next)) effective[key] = next[key]
|
|
518
|
+
catalog.aliases = next.modelAliases ?? {}
|
|
519
|
+
catalog.defaultModel = next.defaultModel ?? ''
|
|
520
|
+
},
|
|
521
|
+
getState: async () => ({
|
|
522
|
+
url: `http://${formatHost(effective.host)}:${current.port}`,
|
|
523
|
+
host: effective.host,
|
|
524
|
+
port: current.port,
|
|
525
|
+
providers: catalog.providers().map((provider) => provider.id),
|
|
526
|
+
sessions: registry?.list().length ?? 0,
|
|
527
|
+
mirror: effective.session.mirror,
|
|
528
|
+
stateRoot: root,
|
|
529
|
+
}),
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
const dispose = async () => {
|
|
533
|
+
if (sweepTimer !== undefined) clearInterval(sweepTimer)
|
|
534
|
+
await rebindChain.catch(() => {})
|
|
535
|
+
await closeServer(current.server)
|
|
536
|
+
await mirror?.close()
|
|
537
|
+
await registry?.close()
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return {
|
|
541
|
+
port: current.port,
|
|
542
|
+
url: `http://${formatHost(effective.host)}:${current.port}`,
|
|
543
|
+
dispose,
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function listenOn(handler, host, port) {
|
|
548
|
+
return new Promise((resolve, reject) => {
|
|
549
|
+
const server = createServer(handler)
|
|
550
|
+
server.keepAliveTimeout = 65_000
|
|
551
|
+
server.once('error', reject)
|
|
552
|
+
server.listen(port, host, () => {
|
|
553
|
+
server.off('error', reject)
|
|
554
|
+
const address = server.address()
|
|
555
|
+
resolve({ server, port: typeof address === 'object' && address !== null ? address.port : port })
|
|
556
|
+
})
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function closeServer(server) {
|
|
561
|
+
if (server === undefined) return
|
|
562
|
+
await new Promise((resolve) => {
|
|
563
|
+
server.close(() => resolve())
|
|
564
|
+
server.closeAllConnections?.()
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function logListening(config, port, log) {
|
|
569
|
+
log('info', `dsh-endpoint listening on http://${formatHost(config.host)}:${port}`)
|
|
570
|
+
if (config.host === '0.0.0.0') {
|
|
571
|
+
log(
|
|
572
|
+
'warn',
|
|
573
|
+
'dsh-endpoint is bound to 0.0.0.0 without authentication; anyone who can reach this port can spend your model quota',
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function sweepExpired(ctx, registry, mirror, config, log) {
|
|
579
|
+
try {
|
|
580
|
+
const expired = await registry.expire(config.session.mappingTtlDays)
|
|
581
|
+
for (const entry of expired) {
|
|
582
|
+
const state = mirror.states.get(entry.dshSessionId)
|
|
583
|
+
if (state !== undefined) {
|
|
584
|
+
await mirror.flush(state)
|
|
585
|
+
await state.handle?.close?.().catch(() => {})
|
|
586
|
+
mirror.states.delete(entry.dshSessionId)
|
|
587
|
+
}
|
|
588
|
+
if (config.session.archiveOnExpire === true) {
|
|
589
|
+
try {
|
|
590
|
+
await ctx.get('workspaceRegistry')?.archiveSession?.(entry.dshSessionId)
|
|
591
|
+
} catch (error) {
|
|
592
|
+
log('warn', `could not archive ${entry.dshSessionId}: ${String(error?.message ?? error)}`)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
log('info', `expired mapping ${entry.externalKey} -> ${entry.dshSessionId}`)
|
|
596
|
+
}
|
|
597
|
+
} catch (error) {
|
|
598
|
+
log('warn', `mapping sweep failed: ${String(error?.message ?? error)}`)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function isMissingSession(error) {
|
|
603
|
+
const name = String(error?.name ?? '')
|
|
604
|
+
const message = String(error?.message ?? '')
|
|
605
|
+
return name.includes('NotFound') || message.includes('not found')
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function resolveStateRoot(ctx) {
|
|
609
|
+
try {
|
|
610
|
+
const homePath = ctx.get('dshHomePath')
|
|
611
|
+
if (typeof homePath === 'function') {
|
|
612
|
+
const resolved = homePath('dsh-endpoint')
|
|
613
|
+
if (typeof resolved === 'string' && resolved.length > 0) return resolved
|
|
614
|
+
}
|
|
615
|
+
} catch {
|
|
616
|
+
// fall through to DSH_HOME
|
|
617
|
+
}
|
|
618
|
+
const base = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
619
|
+
return join(base, 'dsh-endpoint')
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function formatHost(host) {
|
|
623
|
+
return host.includes(':') ? `[${host}]` : host
|
|
624
|
+
}
|