@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/lib/server-api.js CHANGED
@@ -1,9 +1,14 @@
1
1
  import { attributionHeaders } from '@deepseek-ai/dsh-llm';
2
2
  import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout';
3
+ import { JsonParseError, parseJsonValue } from './json-parse.js';
3
4
  /** Route prefix registered on ctx.webServer. */
4
5
  export const API_ROUTE = '/dsh-lemonade/api';
5
6
  /** Maximum accepted request body and proxied response body in bytes. */
6
7
  export const MAX_BODY_BYTES = 1_000_000;
8
+ /** Maximum JSON nesting depth accepted in a proxied request body. */
9
+ export const MAX_JSON_DEPTH = 64;
10
+ /** Maximum path length after the route prefix (op + args). */
11
+ export const MAX_SEGMENTS = 5;
7
12
  /** Fetch timeout for proxied Lemonade calls. */
8
13
  export const API_TIMEOUT_MS = 10_000;
9
14
  const TIMEOUT_CODE = 'LEMONADE_API_TIMEOUT';
@@ -117,6 +122,12 @@ function resolveTarget(op, args, query, body) {
117
122
  throw new RequestError('model required', 'INVALID_REQUEST', 400);
118
123
  return { method: 'POST', url: '/v1/delete', body: { model_name: mn } };
119
124
  }
125
+ case 'modelInfo': {
126
+ const id = args[0];
127
+ if (id === undefined || id.length === 0)
128
+ throw new RequestError('model id required', 'INVALID_REQUEST', 400);
129
+ return { method: 'GET', url: '/v1/models/' + encodeURIComponent(id) + '/info' };
130
+ }
120
131
  case 'checkUpdates': return { method: 'POST', url: '/v1/models/check-updates' };
121
132
  case 'registrySearch':
122
133
  return {
@@ -143,6 +154,19 @@ function resolveTarget(op, args, query, body) {
143
154
  url: '/v1/pull',
144
155
  body: pick(['model_name', 'recipe', 'checkpoint', 'checkpoints', 'reasoning', 'vision', 'embedding', 'reranking', 'mmproj', 'stream', 'subscribe']),
145
156
  };
157
+ case 'loraList': return { method: 'GET', url: '/v1/extensions/lora/list' };
158
+ case 'loraLoad': {
159
+ const adapter = args[0];
160
+ if (adapter === undefined || adapter.length === 0)
161
+ throw new RequestError('adapter id required', 'INVALID_REQUEST', 400);
162
+ return { method: 'POST', url: '/v1/extensions/lora/' + encodeURIComponent(adapter) };
163
+ }
164
+ case 'loraUnload': {
165
+ const adapter = args[0];
166
+ if (adapter === undefined || adapter.length === 0)
167
+ throw new RequestError('adapter id required', 'INVALID_REQUEST', 400);
168
+ return { method: 'DELETE', url: '/v1/extensions/lora/' + encodeURIComponent(adapter) };
169
+ }
146
170
  case 'downloads': return { method: 'GET', url: '/v1/downloads' };
147
171
  case 'downloadsControl': return { method: 'POST', url: '/v1/downloads/control', body: pick(['id', 'action']) };
148
172
  case 'stats': return { method: 'GET', url: '/v1/stats' };
@@ -190,6 +214,23 @@ async function readResponseText(response) {
190
214
  * @param signal - optional caller cancellation.
191
215
  */
192
216
  export async function serveLemonadeApi(cfg, method, op, args, query, body, signal) {
217
+ // Batch operations are intercepted before resolveTarget: they are not a
218
+ // single Lemonade endpoint but a sequence of one dispatched through postLemonade.
219
+ if (op === 'batchLoad' || op === 'batchUnload' || op === 'batchDelete') {
220
+ const ids = collectBatchIds(body);
221
+ if (ids.length === 0) {
222
+ return errResult('batch ' + op + ' requires a non-empty "models" list', 'INVALID_REQUEST', 400);
223
+ }
224
+ const build = (id) => {
225
+ switch (op) {
226
+ case 'batchLoad': return { method: 'POST', url: '/v1/load', body: { model_name: id } };
227
+ case 'batchUnload': return { method: 'POST', url: '/v1/unload', body: { model_name: id } };
228
+ default: return { method: 'POST', url: '/v1/delete', body: { model_name: id } };
229
+ }
230
+ };
231
+ const kind = op === 'batchLoad' ? 'load' : op === 'batchUnload' ? 'unload' : 'delete';
232
+ return batchRun(cfg, kind, ids, build);
233
+ }
193
234
  let target;
194
235
  try {
195
236
  target = resolveTarget(op, args, query, body);
@@ -202,11 +243,79 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
202
243
  if (method !== target.method) {
203
244
  return errResult('method ' + method + ' not allowed for ' + op + ' (expected ' + target.method + ')', 'METHOD_NOT_ALLOWED', 405);
204
245
  }
205
- // Per-endpoint key selection: internal/control endpoints (/internal/*, /metrics)
206
- // authenticate with the admin key (falling back to the regular key, which
207
- // lemonade accepts for /metrics); regular endpoints use the regular key (the
208
- // admin key is a superior credential and also works when it is the only one set).
209
- const admin = isAdminOp(op);
246
+ return postLemonade(cfg, target.url, target.method, target.body, isAdminOp(op), signal);
247
+ }
248
+ /**
249
+ * Extract the target model ids from a batch-operation body. Lemonade uses the
250
+ * `model_name` field per spec, so accept both `models` and `model_name` (single
251
+ * or list) and ignore any other keys.
252
+ */
253
+ function collectBatchIds(body) {
254
+ const record = asRecord(body);
255
+ const collect = (key) => {
256
+ const raw = record[key];
257
+ if (Array.isArray(raw)) {
258
+ return raw.filter((v) => typeof v === 'string' && v.length > 0);
259
+ }
260
+ return typeof raw === 'string' && raw.length > 0 ? [raw] : [];
261
+ };
262
+ const ids = collect('models').concat(collect('model_name'));
263
+ // De-duplicate while preserving order.
264
+ const seen = new Set();
265
+ return ids.filter((id) => {
266
+ if (seen.has(id))
267
+ return false;
268
+ seen.add(id);
269
+ return true;
270
+ });
271
+ }
272
+ /**
273
+ * Run a batch of sub-calls against Lemonade, dispatching each sequentially
274
+ * through {@link postLemonade} (reusing per-endpoint key selection, the
275
+ * /api-vs-root rule, the shared timer, and status/body normalization). Each
276
+ * sub-call error is recorded alongside the successful ones rather than
277
+ * aborting the whole batch; the wire result carries `ok: true` with a
278
+ * structured payload listing every item and its status.
279
+ *
280
+ * @param cfg - connection facts (thunks resolved per call).
281
+ * @param kind - the operation class ("load", "unload", "delete").
282
+ * @param ids - the identifiers to apply the operation to.
283
+ * @param build - build one sub-call target for an id (path/body), or throw.
284
+ */
285
+ async function batchRun(cfg, kind, ids, build) {
286
+ const calls = [];
287
+ for (const id of ids) {
288
+ try {
289
+ calls.push({ id, target: build(id) });
290
+ }
291
+ catch (error) {
292
+ if (error instanceof RequestError)
293
+ return errResult(error.message, error.code, error.status);
294
+ throw error;
295
+ }
296
+ }
297
+ const results = [];
298
+ for (const call of calls) {
299
+ const wire = await postLemonade(cfg, call.target.url, call.target.method, call.target.body, false, undefined);
300
+ results.push({
301
+ id: call.id,
302
+ ok: wire.ok,
303
+ message: wire.ok ? undefined : (wire.error && (wire.error.message || wire.error.code)),
304
+ status: wire.ok ? undefined : wire.error.status,
305
+ });
306
+ }
307
+ const failed = results.filter((r) => !r.ok).length;
308
+ return okResult({ kind, total: results.length, failed, results });
309
+ }
310
+ /**
311
+ * Perform the HTTP round-trip to Lemonade and normalize the response into a
312
+ * wire result. Shared by {@link serveLemonadeApi} (single op) and the batch
313
+ * ops: it resolves the per-endpoint API key (from the target url), builds the
314
+ * fully-qualified URL (root vs /api prefix), runs the fetch under the shared
315
+ * timer, and applies the status/body normalization. `admin` selects the admin
316
+ * vs regular credential and drives the root-path rule.
317
+ */
318
+ async function postLemonade(cfg, url, method, body, admin, signal) {
210
319
  const regularKey = await cfg.resolveKey(cfg.apiKeyRef());
211
320
  const adminKey = await cfg.resolveKey(cfg.adminApiKeyRef());
212
321
  const apiKey = admin ? (adminKey ?? regularKey) : (regularKey ?? adminKey);
@@ -216,21 +325,21 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
216
325
  const configured = (cfg.baseURL() || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
217
326
  // /internal/*, /live and /metrics are ROOT-level (no /api, no /v1) per spec;
218
327
  // everything else is served under the /api prefix.
219
- const isRootPath = target.url.startsWith('/internal/') || target.url === '/live' || target.url === '/metrics';
328
+ const isRootPath = url.startsWith('/internal/') || url === '/live' || url === '/metrics';
220
329
  const base = isRootPath ? configured.replace(/\/api$/i, '') : configured;
221
- const url = base + target.url;
330
+ const fullUrl = base + url;
222
331
  const headers = { accept: 'application/json', ...attributionHeaders() };
223
332
  if (apiKey !== undefined)
224
333
  headers.authorization = 'Bearer ' + apiKey;
225
- if (target.body !== undefined)
334
+ if (body !== undefined)
226
335
  headers['content-type'] = 'application/json';
227
336
  const timer = deadline(signal, API_TIMEOUT_MS, TIMEOUT_CODE);
228
337
  let response;
229
338
  try {
230
- response = await fetch(url, {
231
- method: target.method,
339
+ response = await fetch(fullUrl, {
340
+ method,
232
341
  headers,
233
- ...(target.body === undefined ? {} : { body: JSON.stringify(target.body) }),
342
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
234
343
  signal: timer.signal,
235
344
  });
236
345
  }
@@ -240,10 +349,17 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
240
349
  }
241
350
  if (signal !== undefined && signal.aborted)
242
351
  return errResult('Lemonade request aborted by caller', 'ABORTED');
243
- return errResult('could not reach ' + url, 'TRANSPORT');
352
+ return errResult('could not reach ' + fullUrl, 'TRANSPORT');
244
353
  }
245
354
  finally {
246
- timer[Symbol.dispose]();
355
+ // Wrap cleanup in try/catch so a timer teardown error (it should never
356
+ // throw) can't mask the original throw/return from the block above.
357
+ try {
358
+ timer[Symbol.dispose]();
359
+ }
360
+ catch {
361
+ // ignore — the timer is disposable and teardown errors are non-fatal
362
+ }
247
363
  }
248
364
  const decoded = await readResponseText(response);
249
365
  if (decoded.tooLarge) {
@@ -251,7 +367,7 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
251
367
  }
252
368
  let value = null;
253
369
  if (decoded.text.length > 0) {
254
- if (op === 'metrics') {
370
+ if (url === '/metrics') {
255
371
  // Prometheus text exposition format, not JSON.
256
372
  value = decoded.text;
257
373
  }
@@ -260,7 +376,7 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
260
376
  value = JSON.parse(decoded.text);
261
377
  }
262
378
  catch {
263
- return errResult('Lemonade answered with non-JSON at ' + url, 'BAD_RESPONSE', 502);
379
+ return errResult('Lemonade answered with non-JSON at ' + fullUrl, 'BAD_RESPONSE', 502);
264
380
  }
265
381
  }
266
382
  }
@@ -286,6 +402,36 @@ export async function serveLemonadeApi(cfg, method, op, args, query, body, signa
286
402
  }
287
403
  return okResult(value);
288
404
  }
405
+ /**
406
+ * Parse one request body as JSON with a hard nesting-depth cap, so a hostile
407
+ * client cannot send a "JSON bomb" (a tree whose width is small but whose
408
+ * depth is enormous, whose naive JSON.parse stack would blow). Throws a
409
+ * RequestError (INVALID_REQUEST 400) on malformed input or on exceeding the
410
+ * depth cap, carrying the byte offset of the offending token so the client can
411
+ * point at the exact character.
412
+ */
413
+ /**
414
+ * Parse one request body as JSON with a hard nesting-depth cap, so a hostile
415
+ * client cannot send a "JSON bomb" (a tree whose width is small but whose
416
+ * depth is enormous, whose naive JSON.parse stack would blow). A malformed or
417
+ * too-deep body becomes a {@link RequestError} (INVALID_REQUEST 400) carrying
418
+ * the byte offset of the offending token, so the client can point at the exact
419
+ * character; a body over {@link MAX_BODY_BYTES} stays a 413.
420
+ */
421
+ function parseJsonRequest(text) {
422
+ try {
423
+ return parseJsonValue(text, { maxDepth: MAX_JSON_DEPTH });
424
+ }
425
+ catch (error) {
426
+ if (error instanceof JsonParseError) {
427
+ const detail = error.isDepthOverflow
428
+ ? 'request body exceeds JSON depth ' + MAX_JSON_DEPTH
429
+ : 'request body is not valid JSON near offset ' + error.position;
430
+ throw new RequestError(detail, 'INVALID_REQUEST', 400);
431
+ }
432
+ throw error;
433
+ }
434
+ }
289
435
  async function readRequestBody(req) {
290
436
  const chunks = [];
291
437
  let size = 0;
@@ -300,12 +446,7 @@ async function readRequestBody(req) {
300
446
  const text = Buffer.concat(chunks).toString('utf8');
301
447
  if (text.trim().length === 0)
302
448
  return undefined;
303
- try {
304
- return JSON.parse(text);
305
- }
306
- catch {
307
- throw new RequestError('request body is not valid JSON', 'INVALID_REQUEST', 400);
308
- }
449
+ return parseJsonRequest(text);
309
450
  }
310
451
  function writeJson(res, status, value) {
311
452
  res.writeHead(status, {
@@ -324,6 +465,141 @@ function writeJson(res, status, value) {
324
465
  * (`{ type: 'logs.snapshot' | 'logs.entry' | 'error', ... }`); the response is
325
466
  * held open and closed when the browser disconnects.
326
467
  */
468
+ /** Write one Server-Sent-Event line (`event:` + `data:`), splitting multi-line payloads. */
469
+ function writeSse(res, event, data) {
470
+ res.write('event: ' + event + '\n');
471
+ const parts = data.replace(/\r\n/g, '\n').split('\n');
472
+ for (const line of parts) {
473
+ res.write('data: ' + line + '\n');
474
+ }
475
+ res.write('\n');
476
+ }
477
+ /**
478
+ * Serve the Lemonade server log stream to the browser as an SSE feed.
479
+ *
480
+ * The browser holds a plain HTTP/SSE connection to the host proxy (which owns
481
+ * the credentials); the proxy, in turn, opens a WebSocket *client* to the
482
+ * Lemonade log endpoint and re-emits every upstream message as an SSE event. A
483
+ * client→Lemonade relay is mandatory here: Node's runtime exposes no
484
+ * server-side WebSocket API and the `ws` package is not installable, so the
485
+ * relay can never accept an upgrade itself. The log port is discovered from
486
+ * /v1/health (`websocket_port`, which shares the Realtime Audio port and
487
+ * therefore differs from the main API port), the log message is authenticated
488
+ * through the regular API key, and the response is held open and closed when
489
+ * the browser disconnects.
490
+ */
491
+ async function serveLogsStream(cfg, res, signal) {
492
+ const key = await cfg.resolveKey(cfg.apiKeyRef());
493
+ const configured = (cfg.baseURL() || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
494
+ const healthUrl = configured + '/v1/health';
495
+ const openHeaders = {
496
+ 'content-type': 'text/event-stream; charset=utf-8',
497
+ 'cache-control': 'no-store',
498
+ connection: 'close',
499
+ // Disable proxy buffering that would defeat the streaming contract.
500
+ 'x-accel-buffering': 'no',
501
+ };
502
+ res.writeHead(200, openHeaders);
503
+ writeSse(res, 'comment', 'streaming logs');
504
+ // Discover the WebSocket port from health before touching the log endpoint.
505
+ let health;
506
+ try {
507
+ const resp = await fetch(healthUrl, { headers: { accept: 'application/json' } });
508
+ const text = await resp.text().catch(() => '');
509
+ if (text.trim().length > 0) {
510
+ const parsed = JSON.parse(text);
511
+ health = parsed;
512
+ }
513
+ }
514
+ catch {
515
+ writeSse(res, 'error', 'could not reach ' + healthUrl);
516
+ res.end();
517
+ return;
518
+ }
519
+ const port = health && typeof health.websocket_port === 'number' ? health.websocket_port : undefined;
520
+ if (!port || port <= 0) {
521
+ writeSse(res, 'error', 'Lemonade server did not advertise a websocket_port');
522
+ res.end();
523
+ return;
524
+ }
525
+ // Reuse the host of the configured base URL, but speak ws over the log port.
526
+ const base = new URL(configured);
527
+ const wsUrl = 'ws://' + base.hostname + ':' + port + '/logs/stream';
528
+ let clientClosed = false;
529
+ const upstreamAbort = () => {
530
+ if (clientClosed)
531
+ return;
532
+ try {
533
+ ws.close(1001);
534
+ }
535
+ catch { /* noop */ }
536
+ };
537
+ if (signal) {
538
+ if (signal.aborted)
539
+ upstreamAbort();
540
+ else
541
+ signal.addEventListener?.('abort', upstreamAbort);
542
+ }
543
+ // When the browser drops the SSE, close the upstream WebSocket cleanly.
544
+ res.on('close', () => {
545
+ clientClosed = true;
546
+ if (signal)
547
+ signal.removeEventListener?.('abort', upstreamAbort);
548
+ try {
549
+ ws.close(1001);
550
+ }
551
+ catch { /* noop */ }
552
+ });
553
+ const ws = new WebSocket(wsUrl);
554
+ const decode = (data) => {
555
+ if (typeof data === 'string')
556
+ return data;
557
+ if (data instanceof ArrayBuffer)
558
+ return new TextDecoder().decode(data);
559
+ return data.toString('utf8');
560
+ };
561
+ ws.addEventListener('open', () => {
562
+ if (clientClosed)
563
+ return;
564
+ try {
565
+ ws.send(JSON.stringify({ type: 'logs.subscribe', after_seq: null, ...(key !== undefined ? { key } : {}) }));
566
+ }
567
+ catch {
568
+ writeSse(res, 'error', 'failed to subscribe to the log stream');
569
+ res.end();
570
+ return;
571
+ }
572
+ writeSse(res, 'comment', 'connected');
573
+ });
574
+ ws.addEventListener('message', (event) => {
575
+ if (clientClosed)
576
+ return;
577
+ const raw = decode(event.data);
578
+ try {
579
+ JSON.parse(raw);
580
+ writeSse(res, 'data', raw);
581
+ }
582
+ catch {
583
+ writeSse(res, 'data', raw);
584
+ }
585
+ });
586
+ ws.addEventListener('close', (event) => {
587
+ if (clientClosed)
588
+ return;
589
+ writeSse(res, 'error', 'log stream closed by Lemonade (code ' + event.code + (event.reason ? ' ' + event.reason : '') + ')');
590
+ res.end();
591
+ });
592
+ ws.addEventListener('error', () => {
593
+ if (clientClosed)
594
+ return;
595
+ writeSse(res, 'error', 'log stream error');
596
+ try {
597
+ ws.close(1001);
598
+ }
599
+ catch { /* noop */ }
600
+ res.end();
601
+ });
602
+ }
327
603
  /**
328
604
  * Build the node:http handler mounting the Lemonade-specific API proxy at the
329
605
  * /dsh-lemonade/api prefix route (ctx.webServer.register). Never throws out:
@@ -331,17 +607,31 @@ function writeJson(res, status, value) {
331
607
  */
332
608
  export function createLemonadeApiHandler(cfg) {
333
609
  return async (req, res) => {
610
+ const url = new URL(req.url ?? '/', 'http://localhost');
611
+ const rest = url.pathname.startsWith(API_ROUTE) ? url.pathname.slice(API_ROUTE.length) : url.pathname;
612
+ const segments = rest.split('/').filter((part) => part.length > 0);
613
+ if (segments.length > MAX_SEGMENTS) {
614
+ throw new RequestError('request path is too deep (' + segments.length + '/' + MAX_SEGMENTS + ')', 'INVALID_REQUEST', 400);
615
+ }
616
+ const op = segments[0] ?? '';
617
+ const args = segments.slice(1);
618
+ const method = req.method ?? 'GET';
619
+ const body = method === 'POST' || method === 'PUT' || method === 'PATCH'
620
+ ? await readRequestBody(req)
621
+ : undefined;
622
+ // The log stream is a long-lived SSE relay, not a short wire result: the
623
+ // proxy owns the WebSocket to Lemonade and re-emits it as SSE to the browser.
624
+ if (op === 'logsStream') {
625
+ // IncomingMessage's type carries no request signal; derive one and fire
626
+ // it when the browser drops the underlying socket.
627
+ const controller = new AbortController();
628
+ req.socket?.on?.('close', () => controller.abort());
629
+ await serveLogsStream(cfg, res, controller.signal);
630
+ return;
631
+ }
334
632
  let result;
335
633
  try {
336
- const url = new URL(req.url ?? '/', 'http://localhost');
337
- const rest = url.pathname.startsWith(API_ROUTE) ? url.pathname.slice(API_ROUTE.length) : url.pathname;
338
- const segments = rest.split('/').filter((part) => part.length > 0);
339
- const op = segments[0] ?? '';
340
- const args = segments.slice(1);
341
- const body = req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH'
342
- ? await readRequestBody(req)
343
- : undefined;
344
- result = await serveLemonadeApi(cfg, req.method ?? 'GET', op, args, url.searchParams, body);
634
+ result = await serveLemonadeApi(cfg, method, op, args, url.searchParams, body);
345
635
  }
346
636
  catch (error) {
347
637
  result =
package/lib/translate.js CHANGED
@@ -16,15 +16,51 @@
16
16
  */
17
17
  import { CallId, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm';
18
18
  import { EventSourceParserStream } from 'eventsource-parser/stream';
19
+ /**
20
+ * A `[DONE]` sentinel in the *middle* of a payload stream (i.e. not the final
21
+ * event) is malformed: clean consumers normally emit it only once, at EOF.
22
+ * This is a soft warning — the caller ignores it rather than aborting — so a
23
+ * briefly misbehaving server never silently kills a generation.
24
+ */
25
+ export const MID_STREAM_DONE_WARNING = 'lemonade-sse: mid-stream [DONE] detected; the stream may end prematurely';
26
+ /** Max consecutive malformed SSE payloads tolerated before aborting. */
27
+ const MAX_SKIP = 20;
19
28
  /** Parse an SSE byte stream into its `data` payloads. */
20
- export async function* parseSse(stream, onComment) {
29
+ export async function* parseSse(stream, onComment, onSkip) {
21
30
  const events = stream
22
31
  .pipeThrough(new TextDecoderStream())
23
32
  .pipeThrough(new EventSourceParserStream({ onComment }));
33
+ let consecutiveSkips = 0;
24
34
  for await (const { data } of events) {
35
+ // The `[DONE]` sentinel is not valid JSON, so special-case it before the
36
+ // JSON.parse path (which would otherwise treat it as a malformed payload
37
+ // and drop it). It is yielded verbatim and translate() decides, from the
38
+ // payloads that follow it, whether it landed mid-stream.
39
+ if (data === '[DONE]') {
40
+ yield data;
41
+ continue;
42
+ }
43
+ let parsed;
44
+ try {
45
+ parsed = data.length === 0 ? {} : JSON.parse(data);
46
+ }
47
+ catch {
48
+ consecutiveSkips += 1;
49
+ if (consecutiveSkips > MAX_SKIP) {
50
+ onSkip?.('malformed SSE payloads exhausted tolerance (' + consecutiveSkips + ')');
51
+ return;
52
+ }
53
+ onSkip?.('skipping malformed SSE payload');
54
+ continue;
55
+ }
56
+ consecutiveSkips = 0;
57
+ // A non-object payload (e.g. a bare string or number) carries no usable
58
+ // choices/usage; skip it rather than treating it as an empty object.
59
+ if (parsed === null || typeof parsed !== 'object') {
60
+ onSkip?.('skipping non-object SSE payload');
61
+ continue;
62
+ }
25
63
  yield data;
26
- if (data === '[DONE]')
27
- return;
28
64
  }
29
65
  }
30
66
  /**
@@ -73,10 +109,18 @@ function closeBlock(block) {
73
109
  /**
74
110
  * Consume SSE data payloads (optionally ending with `[DONE]`) and yield
75
111
  * harness StreamChunks. Malformed JSON payloads abort the stream with
76
- * `MALFORMED_RESPONSE`. A `stop` (or absent) finish with no opened blocks is a
77
- * degenerate provider completion and maps to an `EMPTY_RESPONSE` error finish.
112
+ * `MALFORMED_RESPONSE` parseSse already skips transiently malformed payloads
113
+ * with a threshold, so a payload reaching this point is genuinely corrupt. A
114
+ * `stop` (or absent) finish with no opened blocks is a degenerate provider
115
+ * completion and maps to an `EMPTY_RESPONSE` error finish.
116
+ *
117
+ * `[DONE]` is skipped (not terminal here): it flows through parseSse and is
118
+ * only treated as a soft warning when a *further* payload follows it — a clean
119
+ * terminal `[DONE]` ends the loop without warning. A mid-stream `[DONE]` (or
120
+ * content after the sentinel) logs a soft warning and the loop continues
121
+ * rather than crashing.
78
122
  */
79
- export async function* translate(payloads) {
123
+ export async function* translate(payloads, onSkip) {
80
124
  let nextIndex = 0;
81
125
  let textBlock;
82
126
  let reasoningBlock;
@@ -84,14 +128,30 @@ export async function* translate(payloads) {
84
128
  const order = [];
85
129
  let pendingFinish;
86
130
  let pendingUsage;
131
+ // True while a `[DONE]` sentinel has been seen without a following real
132
+ // payload: the next real payload (or a repeated sentinel) proves it was
133
+ // mid-stream. A clean terminal `[DONE]` simply leaves the loop ending here.
134
+ let doneSeen = false;
87
135
  function open(kind) {
88
136
  const block = { index: nextIndex++, kind, text: '' };
89
137
  order.push(block);
90
138
  return block;
91
139
  }
92
140
  for await (const payload of payloads) {
93
- if (payload === '[DONE]')
141
+ if (payload === '[DONE]') {
142
+ // The sentinel is terminal when it is the last event. Only warn when a
143
+ // prior sentinel was already seen AND a further payload follows it — a
144
+ // misbehaving server re-emitting the sentinel, or emitting content after
145
+ // it. A clean terminal `[DONE]` (loop simply ends after it) warns nothing.
146
+ if (doneSeen)
147
+ onSkip?.(MID_STREAM_DONE_WARNING);
148
+ doneSeen = true;
94
149
  continue;
150
+ }
151
+ if (doneSeen) {
152
+ onSkip?.(MID_STREAM_DONE_WARNING);
153
+ doneSeen = false;
154
+ }
95
155
  let chunk;
96
156
  try {
97
157
  chunk = JSON.parse(payload);
@@ -21,8 +21,8 @@ export declare const DEFAULT_CONTEXT_WINDOW = 32768;
21
21
  export declare const DEFAULT_MAX_TOKENS = 8192;
22
22
  /** Default maximum idle interval while an adapter stream read is outstanding. */
23
23
  export declare const DEFAULT_STREAM_IDLE_TIMEOUT_MS: number;
24
- /** Maximum time one live model-listing query may take. */
25
- export declare const LISTING_TIMEOUT_MS = 5000;
24
+ /** Default maximum time one live model-listing query may take. */
25
+ export declare const DEFAULT_LISTING_TIMEOUT_MS = 5000;
26
26
  /** One entry of the user-pinned advisory model catalog. */
27
27
  export interface LemonadeCatalogModel {
28
28
  id: string;
@@ -42,6 +42,7 @@ export interface LemonadeOptions {
42
42
  maxTokens: number;
43
43
  models: LemonadeCatalogModel[];
44
44
  streamIdleTimeoutMs: number;
45
+ listingTimeoutMs: number;
45
46
  retryPolicy: ResolvedRetryPolicy;
46
47
  }
47
48
  /** The adapter's dependency thunks, owned by the registering plugin. */
@@ -52,6 +53,10 @@ export interface LemonadeAdapterConfig {
52
53
  resolveApiKey(): Promise<string | undefined>;
53
54
  /** The attachment service, when one is mounted (needed to send images). */
54
55
  resolveAttachments(): AttachmentStore | undefined;
56
+ /** Optional sink for soft, non-fatal stream warnings (mid-stream `[DONE]`, skipped payloads). */
57
+ logger?: () => {
58
+ warn(...args: unknown[]): void;
59
+ };
55
60
  }
56
61
  /** One Lemonade model entry as read from `GET /v1/models`. */
57
62
  export interface LemonadeModelEntry {
@@ -65,6 +65,7 @@ export interface LemonadeResolvedConfig {
65
65
  maxTokens: number;
66
66
  models: LemonadeCatalogModel[];
67
67
  streamIdleTimeoutMs: number;
68
+ listingTimeoutMs: number;
68
69
  retryPolicy?: RetryPolicyConfig;
69
70
  }
70
71
  /** Raw composition entry: every field optional (schema defaults apply on resolution). */
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Depth-bounded JSON parser for proxied Lemonade request bodies.
3
+ *
4
+ * A hostile client can send a "JSON bomb": a tree whose width is small but
5
+ * whose depth is enormous. A naive `JSON.parse` walks such input with a stack
6
+ * proportional to the depth, which can blow the V8 stack. This parser walks it
7
+ * with an explicit recursion depth cap (`maxDepth`) and rejects deeper input
8
+ * with a {@link JsonParseError} carrying the byte offset of the offending
9
+ * token, so the caller can surface a precise message without the request ever
10
+ * reaching a downstream consumer.
11
+ *
12
+ * Only what the Lemonade proxy needs is supported: objects, arrays, strings
13
+ * (with escapes), numbers, and the `true`/`false`/`null` literals. Whitespace
14
+ * between tokens is skipped. Trailing characters after the value are rejected.
15
+ *
16
+ * @module dsh-lemonade-provider/json-parse
17
+ */
18
+ /** Error thrown by {@link parseJsonValue} on malformed input or depth overflow. */
19
+ export declare class JsonParseError extends Error {
20
+ /** Byte offset of the offending token (or where parsing ended). */
21
+ readonly position: number;
22
+ /** True when the cap on nesting depth was exceeded rather than the text being malformed. */
23
+ readonly isDepthOverflow: boolean;
24
+ constructor(message: string, position: number, isDepthOverflow?: boolean);
25
+ }
26
+ /** Parse one UTF-8 JSON value from `text`.
27
+ * @param text - the raw request body.
28
+ * @param options - parser options; only `maxDepth` is honoured today.
29
+ * @returns the parsed value (never `undefined`; use `parseJsonValue` for that).
30
+ * @throws {JsonParseError} when the text is not a single valid JSON value or exceeds `maxDepth`.
31
+ */
32
+ export declare function parseJsonValue(text: string, options?: {
33
+ maxDepth?: number;
34
+ }): unknown;
@@ -14,6 +14,10 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials';
14
14
  export declare const API_ROUTE = "/dsh-lemonade/api";
15
15
  /** Maximum accepted request body and proxied response body in bytes. */
16
16
  export declare const MAX_BODY_BYTES = 1000000;
17
+ /** Maximum JSON nesting depth accepted in a proxied request body. */
18
+ export declare const MAX_JSON_DEPTH = 64;
19
+ /** Maximum path length after the route prefix (op + args). */
20
+ export declare const MAX_SEGMENTS = 5;
17
21
  /** Fetch timeout for proxied Lemonade calls. */
18
22
  export declare const API_TIMEOUT_MS = 10000;
19
23
  /** Host-side connection facts the proxy resolves per request. */
@@ -59,16 +63,6 @@ export declare function mapLemonadeStatus(status: number): string;
59
63
  * @param signal - optional caller cancellation.
60
64
  */
61
65
  export declare function serveLemonadeApi(cfg: LemonadeApiConfig, method: string, op: string, args: readonly string[], query: URLSearchParams, body: unknown, signal?: AbortSignal): Promise<LemonadeWireResult>;
62
- /**
63
- * Stream the Lemonade server logs (WS /logs/stream) as newline-delimited JSON
64
- * to the browser. The spec: the log WebSocket shares the Realtime Audio port,
65
- * discovered via /v1/health (websocket_port) — not the main HTTP port — then
66
- * `ws://<host>:<port>/logs/stream`, subscribe with `{ type: 'logs.subscribe',
67
- * after_seq: <int|null> }`, and the server answers `logs.snapshot` (up to
68
- * 5000 retained entries) then `logs.entry` lines. Messages are relayed as-is
69
- * (`{ type: 'logs.snapshot' | 'logs.entry' | 'error', ... }`); the response is
70
- * held open and closed when the browser disconnects.
71
- */
72
66
  /**
73
67
  * Build the node:http handler mounting the Lemonade-specific API proxy at the
74
68
  * /dsh-lemonade/api prefix route (ctx.webServer.register). Never throws out: