@cyrilmarin/dsh-lemonade 0.2.6 → 0.4.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/README.md +15 -1
- package/lib/adapter.js +5 -4
- package/lib/client.js +200 -8
- package/lib/index.js +8 -1
- package/lib/json-parse.js +230 -0
- package/lib/server-api.js +320 -30
- package/lib/translate.js +67 -7
- package/lib/types/adapter.d.ts +7 -2
- package/lib/types/index.d.ts +1 -0
- package/lib/types/json-parse.d.ts +34 -0
- package/lib/types/server-api.d.ts +4 -10
- package/lib/types/translate.d.ts +19 -4
- package/package.json +2 -2
- package/src/adapter.ts +11 -4
- package/src/client/index.js +200 -8
- package/src/index.ts +9 -0
- package/src/json-parse.ts +175 -0
- package/src/server-api.ts +329 -30
- package/src/translate.ts +71 -5
package/src/server-api.ts
CHANGED
|
@@ -12,11 +12,16 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
12
12
|
import type { CredentialRef } from '@deepseek-ai/dsh-credentials';
|
|
13
13
|
import { attributionHeaders } from '@deepseek-ai/dsh-llm';
|
|
14
14
|
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout';
|
|
15
|
+
import { JsonParseError, parseJsonValue } from './json-parse.js';
|
|
15
16
|
|
|
16
17
|
/** Route prefix registered on ctx.webServer. */
|
|
17
18
|
export const API_ROUTE = '/dsh-lemonade/api';
|
|
18
19
|
/** Maximum accepted request body and proxied response body in bytes. */
|
|
19
20
|
export const MAX_BODY_BYTES = 1_000_000;
|
|
21
|
+
/** Maximum JSON nesting depth accepted in a proxied request body. */
|
|
22
|
+
export const MAX_JSON_DEPTH = 64;
|
|
23
|
+
/** Maximum path length after the route prefix (op + args). */
|
|
24
|
+
export const MAX_SEGMENTS = 5;
|
|
20
25
|
/** Fetch timeout for proxied Lemonade calls. */
|
|
21
26
|
export const API_TIMEOUT_MS = 10_000;
|
|
22
27
|
const TIMEOUT_CODE = 'LEMONADE_API_TIMEOUT';
|
|
@@ -157,6 +162,11 @@ function resolveTarget(
|
|
|
157
162
|
if (mn === undefined) throw new RequestError('model required', 'INVALID_REQUEST', 400);
|
|
158
163
|
return { method: 'POST', url: '/v1/delete', body: { model_name: mn } };
|
|
159
164
|
}
|
|
165
|
+
case 'modelInfo': {
|
|
166
|
+
const id = args[0];
|
|
167
|
+
if (id === undefined || id.length === 0) throw new RequestError('model id required', 'INVALID_REQUEST', 400);
|
|
168
|
+
return { method: 'GET', url: '/v1/models/' + encodeURIComponent(id) + '/info' };
|
|
169
|
+
}
|
|
160
170
|
case 'checkUpdates': return { method: 'POST', url: '/v1/models/check-updates' };
|
|
161
171
|
case 'registrySearch':
|
|
162
172
|
return {
|
|
@@ -183,6 +193,17 @@ function resolveTarget(
|
|
|
183
193
|
url: '/v1/pull',
|
|
184
194
|
body: pick(['model_name', 'recipe', 'checkpoint', 'checkpoints', 'reasoning', 'vision', 'embedding', 'reranking', 'mmproj', 'stream', 'subscribe']),
|
|
185
195
|
};
|
|
196
|
+
case 'loraList': return { method: 'GET', url: '/v1/extensions/lora/list' };
|
|
197
|
+
case 'loraLoad': {
|
|
198
|
+
const adapter = args[0];
|
|
199
|
+
if (adapter === undefined || adapter.length === 0) throw new RequestError('adapter id required', 'INVALID_REQUEST', 400);
|
|
200
|
+
return { method: 'POST', url: '/v1/extensions/lora/' + encodeURIComponent(adapter) };
|
|
201
|
+
}
|
|
202
|
+
case 'loraUnload': {
|
|
203
|
+
const adapter = args[0];
|
|
204
|
+
if (adapter === undefined || adapter.length === 0) throw new RequestError('adapter id required', 'INVALID_REQUEST', 400);
|
|
205
|
+
return { method: 'DELETE', url: '/v1/extensions/lora/' + encodeURIComponent(adapter) };
|
|
206
|
+
}
|
|
186
207
|
case 'downloads': return { method: 'GET', url: '/v1/downloads' };
|
|
187
208
|
case 'downloadsControl': return { method: 'POST', url: '/v1/downloads/control', body: pick(['id', 'action']) };
|
|
188
209
|
case 'stats': return { method: 'GET', url: '/v1/stats' };
|
|
@@ -237,6 +258,23 @@ export async function serveLemonadeApi(
|
|
|
237
258
|
body: unknown,
|
|
238
259
|
signal?: AbortSignal,
|
|
239
260
|
): Promise<LemonadeWireResult> {
|
|
261
|
+
// Batch operations are intercepted before resolveTarget: they are not a
|
|
262
|
+
// single Lemonade endpoint but a sequence of one dispatched through postLemonade.
|
|
263
|
+
if (op === 'batchLoad' || op === 'batchUnload' || op === 'batchDelete') {
|
|
264
|
+
const ids = collectBatchIds(body);
|
|
265
|
+
if (ids.length === 0) {
|
|
266
|
+
return errResult('batch ' + op + ' requires a non-empty "models" list', 'INVALID_REQUEST', 400);
|
|
267
|
+
}
|
|
268
|
+
const build = (id: string): { method: 'GET' | 'POST' | 'DELETE'; url: string; body?: unknown } => {
|
|
269
|
+
switch (op) {
|
|
270
|
+
case 'batchLoad': return { method: 'POST', url: '/v1/load', body: { model_name: id } };
|
|
271
|
+
case 'batchUnload': return { method: 'POST', url: '/v1/unload', body: { model_name: id } };
|
|
272
|
+
default: return { method: 'POST', url: '/v1/delete', body: { model_name: id } };
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
const kind = op === 'batchLoad' ? 'load' : op === 'batchUnload' ? 'unload' : 'delete';
|
|
276
|
+
return batchRun(cfg, kind, ids, build);
|
|
277
|
+
}
|
|
240
278
|
let target: { method: 'GET' | 'POST' | 'DELETE'; url: string; body?: unknown };
|
|
241
279
|
try {
|
|
242
280
|
target = resolveTarget(op, args, query, body);
|
|
@@ -251,11 +289,97 @@ export async function serveLemonadeApi(
|
|
|
251
289
|
405,
|
|
252
290
|
);
|
|
253
291
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
292
|
+
return postLemonade(cfg, target.url, target.method, target.body, isAdminOp(op), signal);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** One sub-call of a batch operation: its resolved target plus its id. */
|
|
296
|
+
interface BatchCall {
|
|
297
|
+
id: string;
|
|
298
|
+
target: { method: 'GET' | 'POST' | 'DELETE'; url: string; body?: unknown };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Extract the target model ids from a batch-operation body. Lemonade uses the
|
|
303
|
+
* `model_name` field per spec, so accept both `models` and `model_name` (single
|
|
304
|
+
* or list) and ignore any other keys.
|
|
305
|
+
*/
|
|
306
|
+
function collectBatchIds(body: unknown): string[] {
|
|
307
|
+
const record = asRecord(body);
|
|
308
|
+
const collect = (key: 'models' | 'model_name'): string[] => {
|
|
309
|
+
const raw = record[key];
|
|
310
|
+
if (Array.isArray(raw)) {
|
|
311
|
+
return raw.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
|
312
|
+
}
|
|
313
|
+
return typeof raw === 'string' && raw.length > 0 ? [raw] : [];
|
|
314
|
+
};
|
|
315
|
+
const ids = collect('models').concat(collect('model_name'));
|
|
316
|
+
// De-duplicate while preserving order.
|
|
317
|
+
const seen = new Set<string>();
|
|
318
|
+
return ids.filter((id) => {
|
|
319
|
+
if (seen.has(id)) return false;
|
|
320
|
+
seen.add(id);
|
|
321
|
+
return true;
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Run a batch of sub-calls against Lemonade, dispatching each sequentially
|
|
327
|
+
* through {@link postLemonade} (reusing per-endpoint key selection, the
|
|
328
|
+
* /api-vs-root rule, the shared timer, and status/body normalization). Each
|
|
329
|
+
* sub-call error is recorded alongside the successful ones rather than
|
|
330
|
+
* aborting the whole batch; the wire result carries `ok: true` with a
|
|
331
|
+
* structured payload listing every item and its status.
|
|
332
|
+
*
|
|
333
|
+
* @param cfg - connection facts (thunks resolved per call).
|
|
334
|
+
* @param kind - the operation class ("load", "unload", "delete").
|
|
335
|
+
* @param ids - the identifiers to apply the operation to.
|
|
336
|
+
* @param build - build one sub-call target for an id (path/body), or throw.
|
|
337
|
+
*/
|
|
338
|
+
async function batchRun(
|
|
339
|
+
cfg: LemonadeApiConfig,
|
|
340
|
+
kind: 'load' | 'unload' | 'delete',
|
|
341
|
+
ids: readonly string[],
|
|
342
|
+
build: (id: string) => { method: 'GET' | 'POST' | 'DELETE'; url: string; body?: unknown },
|
|
343
|
+
): Promise<LemonadeWireResult> {
|
|
344
|
+
const calls: BatchCall[] = [];
|
|
345
|
+
for (const id of ids) {
|
|
346
|
+
try {
|
|
347
|
+
calls.push({ id, target: build(id) });
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (error instanceof RequestError) return errResult(error.message, error.code, error.status);
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const results = [];
|
|
354
|
+
for (const call of calls) {
|
|
355
|
+
const wire = await postLemonade(cfg, call.target.url, call.target.method, call.target.body, false, undefined);
|
|
356
|
+
results.push({
|
|
357
|
+
id: call.id,
|
|
358
|
+
ok: wire.ok,
|
|
359
|
+
message: wire.ok ? undefined : (wire.error && (wire.error.message || wire.error.code)),
|
|
360
|
+
status: wire.ok ? undefined : wire.error.status,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
364
|
+
return okResult({ kind, total: results.length, failed, results });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Perform the HTTP round-trip to Lemonade and normalize the response into a
|
|
369
|
+
* wire result. Shared by {@link serveLemonadeApi} (single op) and the batch
|
|
370
|
+
* ops: it resolves the per-endpoint API key (from the target url), builds the
|
|
371
|
+
* fully-qualified URL (root vs /api prefix), runs the fetch under the shared
|
|
372
|
+
* timer, and applies the status/body normalization. `admin` selects the admin
|
|
373
|
+
* vs regular credential and drives the root-path rule.
|
|
374
|
+
*/
|
|
375
|
+
async function postLemonade(
|
|
376
|
+
cfg: LemonadeApiConfig,
|
|
377
|
+
url: string,
|
|
378
|
+
method: 'GET' | 'POST' | 'DELETE',
|
|
379
|
+
body: unknown,
|
|
380
|
+
admin: boolean,
|
|
381
|
+
signal?: AbortSignal,
|
|
382
|
+
): Promise<LemonadeWireResult> {
|
|
259
383
|
const regularKey = await cfg.resolveKey(cfg.apiKeyRef());
|
|
260
384
|
const adminKey = await cfg.resolveKey(cfg.adminApiKeyRef());
|
|
261
385
|
const apiKey = admin ? (adminKey ?? regularKey) : (regularKey ?? adminKey);
|
|
@@ -268,20 +392,20 @@ export async function serveLemonadeApi(
|
|
|
268
392
|
const configured = (cfg.baseURL() || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
|
|
269
393
|
// /internal/*, /live and /metrics are ROOT-level (no /api, no /v1) per spec;
|
|
270
394
|
// everything else is served under the /api prefix.
|
|
271
|
-
const isRootPath =
|
|
395
|
+
const isRootPath = url.startsWith('/internal/') || url === '/live' || url === '/metrics';
|
|
272
396
|
const base = isRootPath ? configured.replace(/\/api$/i, '') : configured;
|
|
273
|
-
const
|
|
397
|
+
const fullUrl = base + url;
|
|
274
398
|
const headers: Record<string, string> = { accept: 'application/json', ...attributionHeaders() };
|
|
275
399
|
if (apiKey !== undefined) headers.authorization = 'Bearer ' + apiKey;
|
|
276
|
-
if (
|
|
400
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
277
401
|
|
|
278
402
|
const timer = deadline(signal, API_TIMEOUT_MS, TIMEOUT_CODE);
|
|
279
403
|
let response: Response;
|
|
280
404
|
try {
|
|
281
|
-
response = await fetch(
|
|
282
|
-
method
|
|
405
|
+
response = await fetch(fullUrl, {
|
|
406
|
+
method,
|
|
283
407
|
headers,
|
|
284
|
-
...(
|
|
408
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
285
409
|
signal: timer.signal,
|
|
286
410
|
});
|
|
287
411
|
} catch (error) {
|
|
@@ -289,9 +413,15 @@ export async function serveLemonadeApi(
|
|
|
289
413
|
return errResult('Lemonade API timeout after ' + API_TIMEOUT_MS + 'ms', 'TIMEOUT');
|
|
290
414
|
}
|
|
291
415
|
if (signal !== undefined && signal.aborted) return errResult('Lemonade request aborted by caller', 'ABORTED');
|
|
292
|
-
return errResult('could not reach ' +
|
|
416
|
+
return errResult('could not reach ' + fullUrl, 'TRANSPORT');
|
|
293
417
|
} finally {
|
|
294
|
-
timer
|
|
418
|
+
// Wrap cleanup in try/catch so a timer teardown error (it should never
|
|
419
|
+
// throw) can't mask the original throw/return from the block above.
|
|
420
|
+
try {
|
|
421
|
+
timer[Symbol.dispose]();
|
|
422
|
+
} catch {
|
|
423
|
+
// ignore — the timer is disposable and teardown errors are non-fatal
|
|
424
|
+
}
|
|
295
425
|
}
|
|
296
426
|
|
|
297
427
|
const decoded = await readResponseText(response);
|
|
@@ -300,14 +430,14 @@ export async function serveLemonadeApi(
|
|
|
300
430
|
}
|
|
301
431
|
let value: unknown = null;
|
|
302
432
|
if (decoded.text.length > 0) {
|
|
303
|
-
if (
|
|
433
|
+
if (url === '/metrics') {
|
|
304
434
|
// Prometheus text exposition format, not JSON.
|
|
305
435
|
value = decoded.text;
|
|
306
436
|
} else {
|
|
307
437
|
try {
|
|
308
438
|
value = JSON.parse(decoded.text);
|
|
309
439
|
} catch {
|
|
310
|
-
return errResult('Lemonade answered with non-JSON at ' +
|
|
440
|
+
return errResult('Lemonade answered with non-JSON at ' + fullUrl, 'BAD_RESPONSE', 502);
|
|
311
441
|
}
|
|
312
442
|
}
|
|
313
443
|
}
|
|
@@ -332,6 +462,36 @@ export async function serveLemonadeApi(
|
|
|
332
462
|
return okResult(value);
|
|
333
463
|
}
|
|
334
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Parse one request body as JSON with a hard nesting-depth cap, so a hostile
|
|
467
|
+
* client cannot send a "JSON bomb" (a tree whose width is small but whose
|
|
468
|
+
* depth is enormous, whose naive JSON.parse stack would blow). Throws a
|
|
469
|
+
* RequestError (INVALID_REQUEST 400) on malformed input or on exceeding the
|
|
470
|
+
* depth cap, carrying the byte offset of the offending token so the client can
|
|
471
|
+
* point at the exact character.
|
|
472
|
+
*/
|
|
473
|
+
/**
|
|
474
|
+
* Parse one request body as JSON with a hard nesting-depth cap, so a hostile
|
|
475
|
+
* client cannot send a "JSON bomb" (a tree whose width is small but whose
|
|
476
|
+
* depth is enormous, whose naive JSON.parse stack would blow). A malformed or
|
|
477
|
+
* too-deep body becomes a {@link RequestError} (INVALID_REQUEST 400) carrying
|
|
478
|
+
* the byte offset of the offending token, so the client can point at the exact
|
|
479
|
+
* character; a body over {@link MAX_BODY_BYTES} stays a 413.
|
|
480
|
+
*/
|
|
481
|
+
function parseJsonRequest(text: string): unknown {
|
|
482
|
+
try {
|
|
483
|
+
return parseJsonValue(text, { maxDepth: MAX_JSON_DEPTH });
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (error instanceof JsonParseError) {
|
|
486
|
+
const detail = error.isDepthOverflow
|
|
487
|
+
? 'request body exceeds JSON depth ' + MAX_JSON_DEPTH
|
|
488
|
+
: 'request body is not valid JSON near offset ' + error.position;
|
|
489
|
+
throw new RequestError(detail, 'INVALID_REQUEST', 400);
|
|
490
|
+
}
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
335
495
|
async function readRequestBody(req: IncomingMessage): Promise<unknown> {
|
|
336
496
|
const chunks: Buffer[] = [];
|
|
337
497
|
let size = 0;
|
|
@@ -343,11 +503,7 @@ async function readRequestBody(req: IncomingMessage): Promise<unknown> {
|
|
|
343
503
|
if (chunks.length === 0) return undefined;
|
|
344
504
|
const text = Buffer.concat(chunks).toString('utf8');
|
|
345
505
|
if (text.trim().length === 0) return undefined;
|
|
346
|
-
|
|
347
|
-
return JSON.parse(text);
|
|
348
|
-
} catch {
|
|
349
|
-
throw new RequestError('request body is not valid JSON', 'INVALID_REQUEST', 400);
|
|
350
|
-
}
|
|
506
|
+
return parseJsonRequest(text);
|
|
351
507
|
}
|
|
352
508
|
|
|
353
509
|
function writeJson(res: ServerResponse, status: number, value: unknown): void {
|
|
@@ -369,6 +525,133 @@ function writeJson(res: ServerResponse, status: number, value: unknown): void {
|
|
|
369
525
|
* held open and closed when the browser disconnects.
|
|
370
526
|
*/
|
|
371
527
|
|
|
528
|
+
/** Write one Server-Sent-Event line (`event:` + `data:`), splitting multi-line payloads. */
|
|
529
|
+
function writeSse(res: ServerResponse, event: string, data: string): void {
|
|
530
|
+
res.write('event: ' + event + '\n');
|
|
531
|
+
const parts = data.replace(/\r\n/g, '\n').split('\n');
|
|
532
|
+
for (const line of parts) {
|
|
533
|
+
res.write('data: ' + line + '\n');
|
|
534
|
+
}
|
|
535
|
+
res.write('\n');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Serve the Lemonade server log stream to the browser as an SSE feed.
|
|
540
|
+
*
|
|
541
|
+
* The browser holds a plain HTTP/SSE connection to the host proxy (which owns
|
|
542
|
+
* the credentials); the proxy, in turn, opens a WebSocket *client* to the
|
|
543
|
+
* Lemonade log endpoint and re-emits every upstream message as an SSE event. A
|
|
544
|
+
* client→Lemonade relay is mandatory here: Node's runtime exposes no
|
|
545
|
+
* server-side WebSocket API and the `ws` package is not installable, so the
|
|
546
|
+
* relay can never accept an upgrade itself. The log port is discovered from
|
|
547
|
+
* /v1/health (`websocket_port`, which shares the Realtime Audio port and
|
|
548
|
+
* therefore differs from the main API port), the log message is authenticated
|
|
549
|
+
* through the regular API key, and the response is held open and closed when
|
|
550
|
+
* the browser disconnects.
|
|
551
|
+
*/
|
|
552
|
+
async function serveLogsStream(
|
|
553
|
+
cfg: LemonadeApiConfig,
|
|
554
|
+
res: ServerResponse,
|
|
555
|
+
signal?: AbortSignal,
|
|
556
|
+
): Promise<void> {
|
|
557
|
+
const key = await cfg.resolveKey(cfg.apiKeyRef());
|
|
558
|
+
const configured = (cfg.baseURL() || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
|
|
559
|
+
const healthUrl = configured + '/v1/health';
|
|
560
|
+
|
|
561
|
+
const openHeaders: Record<string, string> = {
|
|
562
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
563
|
+
'cache-control': 'no-store',
|
|
564
|
+
connection: 'close',
|
|
565
|
+
// Disable proxy buffering that would defeat the streaming contract.
|
|
566
|
+
'x-accel-buffering': 'no',
|
|
567
|
+
};
|
|
568
|
+
res.writeHead(200, openHeaders);
|
|
569
|
+
writeSse(res, 'comment', 'streaming logs');
|
|
570
|
+
|
|
571
|
+
// Discover the WebSocket port from health before touching the log endpoint.
|
|
572
|
+
let health: { status?: string; websocket_port?: number } | undefined;
|
|
573
|
+
try {
|
|
574
|
+
const resp = await fetch(healthUrl, { headers: { accept: 'application/json' } });
|
|
575
|
+
const text = await resp.text().catch(() => '');
|
|
576
|
+
if (text.trim().length > 0) {
|
|
577
|
+
const parsed = JSON.parse(text) as { status?: string; websocket_port?: number };
|
|
578
|
+
health = parsed;
|
|
579
|
+
}
|
|
580
|
+
} catch {
|
|
581
|
+
writeSse(res, 'error', 'could not reach ' + healthUrl);
|
|
582
|
+
res.end();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const port = health && typeof health.websocket_port === 'number' ? health.websocket_port : undefined;
|
|
586
|
+
if (!port || port <= 0) {
|
|
587
|
+
writeSse(res, 'error', 'Lemonade server did not advertise a websocket_port');
|
|
588
|
+
res.end();
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
// Reuse the host of the configured base URL, but speak ws over the log port.
|
|
592
|
+
const base = new URL(configured);
|
|
593
|
+
const wsUrl = 'ws://' + base.hostname + ':' + port + '/logs/stream';
|
|
594
|
+
|
|
595
|
+
let clientClosed = false;
|
|
596
|
+
const upstreamAbort = () => {
|
|
597
|
+
if (clientClosed) return;
|
|
598
|
+
try { ws.close(1001); } catch { /* noop */ }
|
|
599
|
+
};
|
|
600
|
+
if (signal) {
|
|
601
|
+
if (signal.aborted) upstreamAbort();
|
|
602
|
+
else signal.addEventListener?.('abort', upstreamAbort);
|
|
603
|
+
}
|
|
604
|
+
// When the browser drops the SSE, close the upstream WebSocket cleanly.
|
|
605
|
+
res.on('close', () => {
|
|
606
|
+
clientClosed = true;
|
|
607
|
+
if (signal) signal.removeEventListener?.('abort', upstreamAbort);
|
|
608
|
+
try { ws.close(1001); } catch { /* noop */ }
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const ws = new WebSocket(wsUrl);
|
|
612
|
+
const decode = (data: ArrayBuffer | Buffer | string): string => {
|
|
613
|
+
if (typeof data === 'string') return data;
|
|
614
|
+
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
|
|
615
|
+
return (data as Buffer).toString('utf8');
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
ws.addEventListener('open', () => {
|
|
619
|
+
if (clientClosed) return;
|
|
620
|
+
try {
|
|
621
|
+
ws.send(JSON.stringify({ type: 'logs.subscribe', after_seq: null, ...(key !== undefined ? { key } : {}) }));
|
|
622
|
+
} catch {
|
|
623
|
+
writeSse(res, 'error', 'failed to subscribe to the log stream');
|
|
624
|
+
res.end();
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
writeSse(res, 'comment', 'connected');
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
ws.addEventListener('message', (event) => {
|
|
631
|
+
if (clientClosed) return;
|
|
632
|
+
const raw = decode(event.data);
|
|
633
|
+
try {
|
|
634
|
+
JSON.parse(raw);
|
|
635
|
+
writeSse(res, 'data', raw);
|
|
636
|
+
} catch {
|
|
637
|
+
writeSse(res, 'data', raw);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
ws.addEventListener('close', (event) => {
|
|
642
|
+
if (clientClosed) return;
|
|
643
|
+
writeSse(res, 'error', 'log stream closed by Lemonade (code ' + event.code + (event.reason ? ' ' + event.reason : '') + ')');
|
|
644
|
+
res.end();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
ws.addEventListener('error', () => {
|
|
648
|
+
if (clientClosed) return;
|
|
649
|
+
writeSse(res, 'error', 'log stream error');
|
|
650
|
+
try { ws.close(1001); } catch { /* noop */ }
|
|
651
|
+
res.end();
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
372
655
|
/**
|
|
373
656
|
* Build the node:http handler mounting the Lemonade-specific API proxy at the
|
|
374
657
|
* /dsh-lemonade/api prefix route (ctx.webServer.register). Never throws out:
|
|
@@ -378,18 +661,34 @@ export function createLemonadeApiHandler(
|
|
|
378
661
|
cfg: LemonadeApiConfig,
|
|
379
662
|
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
|
380
663
|
return async (req, res) => {
|
|
664
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
665
|
+
const rest = url.pathname.startsWith(API_ROUTE) ? url.pathname.slice(API_ROUTE.length) : url.pathname;
|
|
666
|
+
const segments = rest.split('/').filter((part) => part.length > 0);
|
|
667
|
+
if (segments.length > MAX_SEGMENTS) {
|
|
668
|
+
throw new RequestError('request path is too deep (' + segments.length + '/' + MAX_SEGMENTS + ')', 'INVALID_REQUEST', 400);
|
|
669
|
+
}
|
|
670
|
+
const op = segments[0] ?? '';
|
|
671
|
+
const args = segments.slice(1);
|
|
672
|
+
const method = req.method ?? 'GET';
|
|
673
|
+
const body =
|
|
674
|
+
method === 'POST' || method === 'PUT' || method === 'PATCH'
|
|
675
|
+
? await readRequestBody(req)
|
|
676
|
+
: undefined;
|
|
677
|
+
|
|
678
|
+
// The log stream is a long-lived SSE relay, not a short wire result: the
|
|
679
|
+
// proxy owns the WebSocket to Lemonade and re-emits it as SSE to the browser.
|
|
680
|
+
if (op === 'logsStream') {
|
|
681
|
+
// IncomingMessage's type carries no request signal; derive one and fire
|
|
682
|
+
// it when the browser drops the underlying socket.
|
|
683
|
+
const controller = new AbortController();
|
|
684
|
+
req.socket?.on?.('close', () => controller.abort());
|
|
685
|
+
await serveLogsStream(cfg, res, controller.signal);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
381
689
|
let result: LemonadeWireResult;
|
|
382
690
|
try {
|
|
383
|
-
|
|
384
|
-
const rest = url.pathname.startsWith(API_ROUTE) ? url.pathname.slice(API_ROUTE.length) : url.pathname;
|
|
385
|
-
const segments = rest.split('/').filter((part) => part.length > 0);
|
|
386
|
-
const op = segments[0] ?? '';
|
|
387
|
-
const args = segments.slice(1);
|
|
388
|
-
const body =
|
|
389
|
-
req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH'
|
|
390
|
-
? await readRequestBody(req)
|
|
391
|
-
: undefined;
|
|
392
|
-
result = await serveLemonadeApi(cfg, req.method ?? 'GET', op, args, url.searchParams, body);
|
|
691
|
+
result = await serveLemonadeApi(cfg, method, op, args, url.searchParams, body);
|
|
393
692
|
} catch (error) {
|
|
394
693
|
result =
|
|
395
694
|
error instanceof RequestError
|
package/src/translate.ts
CHANGED
|
@@ -18,17 +18,56 @@ import { CallId, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm';
|
|
|
18
18
|
import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm';
|
|
19
19
|
import { EventSourceParserStream } from 'eventsource-parser/stream';
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A `[DONE]` sentinel in the *middle* of a payload stream (i.e. not the final
|
|
23
|
+
* event) is malformed: clean consumers normally emit it only once, at EOF.
|
|
24
|
+
* This is a soft warning — the caller ignores it rather than aborting — so a
|
|
25
|
+
* briefly misbehaving server never silently kills a generation.
|
|
26
|
+
*/
|
|
27
|
+
export const MID_STREAM_DONE_WARNING = 'lemonade-sse: mid-stream [DONE] detected; the stream may end prematurely';
|
|
28
|
+
|
|
29
|
+
/** Max consecutive malformed SSE payloads tolerated before aborting. */
|
|
30
|
+
const MAX_SKIP = 20;
|
|
31
|
+
|
|
21
32
|
/** Parse an SSE byte stream into its `data` payloads. */
|
|
22
33
|
export async function* parseSse(
|
|
23
34
|
stream: ReadableStream<Uint8Array>,
|
|
24
35
|
onComment?: (comment: string) => void,
|
|
36
|
+
onSkip?: (reason: string) => void,
|
|
25
37
|
): AsyncGenerator<string> {
|
|
26
38
|
const events = stream
|
|
27
39
|
.pipeThrough(new TextDecoderStream())
|
|
28
40
|
.pipeThrough(new EventSourceParserStream({ onComment }));
|
|
41
|
+
let consecutiveSkips = 0;
|
|
29
42
|
for await (const { data } of events) {
|
|
43
|
+
// The `[DONE]` sentinel is not valid JSON, so special-case it before the
|
|
44
|
+
// JSON.parse path (which would otherwise treat it as a malformed payload
|
|
45
|
+
// and drop it). It is yielded verbatim and translate() decides, from the
|
|
46
|
+
// payloads that follow it, whether it landed mid-stream.
|
|
47
|
+
if (data === '[DONE]') {
|
|
48
|
+
yield data;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
let parsed: unknown;
|
|
52
|
+
try {
|
|
53
|
+
parsed = data.length === 0 ? {} : JSON.parse(data);
|
|
54
|
+
} catch {
|
|
55
|
+
consecutiveSkips += 1;
|
|
56
|
+
if (consecutiveSkips > MAX_SKIP) {
|
|
57
|
+
onSkip?.('malformed SSE payloads exhausted tolerance (' + consecutiveSkips + ')');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
onSkip?.('skipping malformed SSE payload');
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
consecutiveSkips = 0;
|
|
64
|
+
// A non-object payload (e.g. a bare string or number) carries no usable
|
|
65
|
+
// choices/usage; skip it rather than treating it as an empty object.
|
|
66
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
67
|
+
onSkip?.('skipping non-object SSE payload');
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
30
70
|
yield data;
|
|
31
|
-
if (data === '[DONE]') return;
|
|
32
71
|
}
|
|
33
72
|
}
|
|
34
73
|
|
|
@@ -106,10 +145,21 @@ interface WireChunk {
|
|
|
106
145
|
/**
|
|
107
146
|
* Consume SSE data payloads (optionally ending with `[DONE]`) and yield
|
|
108
147
|
* harness StreamChunks. Malformed JSON payloads abort the stream with
|
|
109
|
-
* `MALFORMED_RESPONSE
|
|
110
|
-
*
|
|
148
|
+
* `MALFORMED_RESPONSE` — parseSse already skips transiently malformed payloads
|
|
149
|
+
* with a threshold, so a payload reaching this point is genuinely corrupt. A
|
|
150
|
+
* `stop` (or absent) finish with no opened blocks is a degenerate provider
|
|
151
|
+
* completion and maps to an `EMPTY_RESPONSE` error finish.
|
|
152
|
+
*
|
|
153
|
+
* `[DONE]` is skipped (not terminal here): it flows through parseSse and is
|
|
154
|
+
* only treated as a soft warning when a *further* payload follows it — a clean
|
|
155
|
+
* terminal `[DONE]` ends the loop without warning. A mid-stream `[DONE]` (or
|
|
156
|
+
* content after the sentinel) logs a soft warning and the loop continues
|
|
157
|
+
* rather than crashing.
|
|
111
158
|
*/
|
|
112
|
-
export async function* translate(
|
|
159
|
+
export async function* translate(
|
|
160
|
+
payloads: AsyncIterable<string>,
|
|
161
|
+
onSkip?: (reason: string) => void,
|
|
162
|
+
): AsyncGenerator<StreamChunk> {
|
|
113
163
|
let nextIndex = 0;
|
|
114
164
|
let textBlock: OpenBlock | undefined;
|
|
115
165
|
let reasoningBlock: OpenBlock | undefined;
|
|
@@ -117,6 +167,10 @@ export async function* translate(payloads: AsyncIterable<string>): AsyncGenerato
|
|
|
117
167
|
const order: OpenBlock[] = [];
|
|
118
168
|
let pendingFinish: FinishReason | undefined;
|
|
119
169
|
let pendingUsage: TokenUsage | undefined;
|
|
170
|
+
// True while a `[DONE]` sentinel has been seen without a following real
|
|
171
|
+
// payload: the next real payload (or a repeated sentinel) proves it was
|
|
172
|
+
// mid-stream. A clean terminal `[DONE]` simply leaves the loop ending here.
|
|
173
|
+
let doneSeen = false;
|
|
120
174
|
|
|
121
175
|
function open(kind: OpenBlock['kind']): OpenBlock {
|
|
122
176
|
const block: OpenBlock = { index: nextIndex++, kind, text: '' };
|
|
@@ -125,7 +179,19 @@ export async function* translate(payloads: AsyncIterable<string>): AsyncGenerato
|
|
|
125
179
|
}
|
|
126
180
|
|
|
127
181
|
for await (const payload of payloads) {
|
|
128
|
-
if (payload === '[DONE]')
|
|
182
|
+
if (payload === '[DONE]') {
|
|
183
|
+
// The sentinel is terminal when it is the last event. Only warn when a
|
|
184
|
+
// prior sentinel was already seen AND a further payload follows it — a
|
|
185
|
+
// misbehaving server re-emitting the sentinel, or emitting content after
|
|
186
|
+
// it. A clean terminal `[DONE]` (loop simply ends after it) warns nothing.
|
|
187
|
+
if (doneSeen) onSkip?.(MID_STREAM_DONE_WARNING);
|
|
188
|
+
doneSeen = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (doneSeen) {
|
|
192
|
+
onSkip?.(MID_STREAM_DONE_WARNING);
|
|
193
|
+
doneSeen = false;
|
|
194
|
+
}
|
|
129
195
|
let chunk: WireChunk;
|
|
130
196
|
try {
|
|
131
197
|
chunk = JSON.parse(payload) as WireChunk;
|