@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/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
- // Per-endpoint key selection: internal/control endpoints (/internal/*, /metrics)
255
- // authenticate with the admin key (falling back to the regular key, which
256
- // lemonade accepts for /metrics); regular endpoints use the regular key (the
257
- // admin key is a superior credential and also works when it is the only one set).
258
- const admin = isAdminOp(op);
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 = target.url.startsWith('/internal/') || target.url === '/live' || target.url === '/metrics';
395
+ const isRootPath = url.startsWith('/internal/') || url === '/live' || url === '/metrics';
272
396
  const base = isRootPath ? configured.replace(/\/api$/i, '') : configured;
273
- const url = base + target.url;
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 (target.body !== undefined) headers['content-type'] = 'application/json';
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(url, {
282
- method: target.method,
405
+ response = await fetch(fullUrl, {
406
+ method,
283
407
  headers,
284
- ...(target.body === undefined ? {} : { body: JSON.stringify(target.body) }),
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 ' + url, 'TRANSPORT');
416
+ return errResult('could not reach ' + fullUrl, 'TRANSPORT');
293
417
  } finally {
294
- timer[Symbol.dispose]();
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 (op === 'metrics') {
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 ' + url, 'BAD_RESPONSE', 502);
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
- try {
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
- const url = new URL(req.url ?? '/', 'http://localhost');
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`. A `stop` (or absent) finish with no opened blocks is a
110
- * degenerate provider completion and maps to an `EMPTY_RESPONSE` error finish.
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(payloads: AsyncIterable<string>): AsyncGenerator<StreamChunk> {
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]') continue;
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;