@askalf/dario 4.8.120 → 4.8.121

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/dist/proxy.d.ts CHANGED
@@ -174,6 +174,14 @@ export declare function buildOrchestrationPatterns(preserveTags?: Set<string>):
174
174
  * opt any tag out of the scrub. dario#78.
175
175
  */
176
176
  export declare function sanitizeMessages(body: Record<string, unknown>, preserveTags?: Set<string>): void;
177
+ /**
178
+ * Translate Anthropic SSE → OpenAI SSE. Returns a stateful per-CALL closure so
179
+ * concurrent /v1/chat/completions streams don't share tool-call index/id state
180
+ * (#642-audit: the previous module-global counters cross-contaminated
181
+ * interleaved streams and emitted malformed tool_calls deltas). One translator
182
+ * per request, mirroring createStreamingReverseMapper.
183
+ */
184
+ export declare function createOpenAIStreamTranslator(): (line: string) => string | null;
177
185
  export declare const OPENAI_MODELS_LIST: {
178
186
  object: string;
179
187
  data: Array<{
package/dist/proxy.js CHANGED
@@ -544,48 +544,55 @@ function anthropicToOpenai(body) {
544
544
  usage: { prompt_tokens: u?.input_tokens ?? 0, completion_tokens: u?.output_tokens ?? 0, total_tokens: (u?.input_tokens ?? 0) + (u?.output_tokens ?? 0) },
545
545
  };
546
546
  }
547
- /** Translate Anthropic SSE → OpenAI SSE. */
548
- // Track tool call state across stream chunks
549
- let _streamToolIndex = 0;
550
- let _streamToolId = '';
551
- function translateStreamChunk(line) {
552
- if (!line.startsWith('data: '))
553
- return null;
554
- const json = line.slice(6).trim();
555
- if (json === '[DONE]')
556
- return 'data: [DONE]\n\n';
557
- try {
558
- const e = JSON.parse(json);
559
- const ts = Math.floor(Date.now() / 1000);
560
- if (e.type === 'content_block_start') {
561
- const block = e.content_block;
562
- if (block?.type === 'tool_use' && block.name) {
563
- _streamToolId = block.id ?? `call_${_streamToolIndex}`;
564
- return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { tool_calls: [{ index: _streamToolIndex, id: _streamToolId, type: 'function', function: { name: block.name, arguments: '' } }] }, finish_reason: null }] })}\n\n`;
547
+ /**
548
+ * Translate Anthropic SSE OpenAI SSE. Returns a stateful per-CALL closure so
549
+ * concurrent /v1/chat/completions streams don't share tool-call index/id state
550
+ * (#642-audit: the previous module-global counters cross-contaminated
551
+ * interleaved streams and emitted malformed tool_calls deltas). One translator
552
+ * per request, mirroring createStreamingReverseMapper.
553
+ */
554
+ export function createOpenAIStreamTranslator() {
555
+ let toolIndex = 0;
556
+ let toolId = '';
557
+ return function translateStreamChunk(line) {
558
+ if (!line.startsWith('data: '))
559
+ return null;
560
+ const json = line.slice(6).trim();
561
+ if (json === '[DONE]')
562
+ return 'data: [DONE]\n\n';
563
+ try {
564
+ const e = JSON.parse(json);
565
+ const ts = Math.floor(Date.now() / 1000);
566
+ if (e.type === 'content_block_start') {
567
+ const block = e.content_block;
568
+ if (block?.type === 'tool_use' && block.name) {
569
+ toolId = block.id ?? `call_${toolIndex}`;
570
+ return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { tool_calls: [{ index: toolIndex, id: toolId, type: 'function', function: { name: block.name, arguments: '' } }] }, finish_reason: null }] })}\n\n`;
571
+ }
565
572
  }
566
- }
567
- if (e.type === 'content_block_delta') {
568
- const d = e.delta;
569
- if (d?.type === 'text_delta' && d.text)
570
- return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { content: d.text }, finish_reason: null }] })}\n\n`;
571
- if (d?.type === 'input_json_delta' && d.partial_json)
572
- return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { tool_calls: [{ index: _streamToolIndex, function: { arguments: d.partial_json } }] }, finish_reason: null }] })}\n\n`;
573
- }
574
- if (e.type === 'content_block_stop') {
575
- if (_streamToolId) {
576
- _streamToolIndex++;
577
- _streamToolId = '';
573
+ if (e.type === 'content_block_delta') {
574
+ const d = e.delta;
575
+ if (d?.type === 'text_delta' && d.text)
576
+ return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { content: d.text }, finish_reason: null }] })}\n\n`;
577
+ if (d?.type === 'input_json_delta' && d.partial_json)
578
+ return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: { tool_calls: [{ index: toolIndex, function: { arguments: d.partial_json } }] }, finish_reason: null }] })}\n\n`;
579
+ }
580
+ if (e.type === 'content_block_stop') {
581
+ if (toolId) {
582
+ toolIndex++;
583
+ toolId = '';
584
+ }
585
+ return null;
586
+ }
587
+ if (e.type === 'message_stop') {
588
+ toolIndex = 0;
589
+ toolId = '';
590
+ return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`;
578
591
  }
579
- return null;
580
- }
581
- if (e.type === 'message_stop') {
582
- _streamToolIndex = 0;
583
- _streamToolId = '';
584
- return `data: ${JSON.stringify({ id: 'chatcmpl-dario', object: 'chat.completion.chunk', created: ts, model: 'claude', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n`;
585
592
  }
586
- }
587
- catch { }
588
- return null;
593
+ catch { }
594
+ return null;
595
+ };
589
596
  }
590
597
  // Baked /v1/models payload — what the proxy advertises before (or without)
591
598
  // a successful upstream catalog fetch. The live route serves the
@@ -2779,14 +2786,29 @@ export async function startProxy(opts = {}) {
2779
2786
  const streamMapper = ccToolMap && !isOpenAI
2780
2787
  ? createStreamingReverseMapper(ccToolMap, reqCtx)
2781
2788
  : null;
2789
+ // Per-request OpenAI SSE translator — isolated tool-call state so
2790
+ // concurrent /v1/chat/completions streams don't cross-contaminate (#642-audit).
2791
+ const openaiTranslate = isOpenAI ? createOpenAIStreamTranslator() : null;
2782
2792
  // Gated writer — a no-op once the downstream client has gone away
2783
2793
  // in drain-on-close mode. The read loop keeps consuming so the
2784
2794
  // upstream sees a full-length read; writes to a closed socket are
2785
2795
  // suppressed to avoid EPIPE/warnings and pointless work.
2796
+ // Honor downstream backpressure (#642-audit): when res.write() returns
2797
+ // false the client's socket buffer is full, so pause the read loop until
2798
+ // it drains instead of buffering the whole (fast) upstream in memory.
2799
+ let needsDrain = false;
2786
2800
  const writeToClient = (chunk) => {
2787
- if (!clientDisconnected)
2788
- res.write(chunk);
2801
+ if (clientDisconnected)
2802
+ return;
2803
+ if (res.write(chunk) === false)
2804
+ needsDrain = true;
2789
2805
  };
2806
+ // Resolves on 'close' too, so a vanished client can't wedge the loop.
2807
+ const waitForDrain = () => new Promise((resolve) => {
2808
+ const done = () => { res.off('drain', done); res.off('close', done); resolve(); };
2809
+ res.once('drain', done);
2810
+ res.once('close', done);
2811
+ });
2790
2812
  try {
2791
2813
  let buffer = '';
2792
2814
  const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
@@ -2860,7 +2882,7 @@ export async function startProxy(opts = {}) {
2860
2882
  const lines = buffer.split('\n');
2861
2883
  buffer = lines.pop() ?? '';
2862
2884
  for (const line of lines) {
2863
- const translated = translateStreamChunk(line);
2885
+ const translated = openaiTranslate(line);
2864
2886
  if (translated)
2865
2887
  writeToClient(translated);
2866
2888
  }
@@ -2873,10 +2895,16 @@ export async function startProxy(opts = {}) {
2873
2895
  else {
2874
2896
  writeToClient(value);
2875
2897
  }
2898
+ // Backpressure (#642-audit): if the last writes filled the client
2899
+ // buffer, pause reading upstream until it drains.
2900
+ if (needsDrain && !clientDisconnected) {
2901
+ needsDrain = false;
2902
+ await waitForDrain();
2903
+ }
2876
2904
  }
2877
2905
  // Flush remaining buffer
2878
2906
  if (isOpenAI && buffer.trim()) {
2879
- const translated = translateStreamChunk(buffer);
2907
+ const translated = openaiTranslate(buffer);
2880
2908
  if (translated)
2881
2909
  writeToClient(translated);
2882
2910
  }
@@ -2889,6 +2917,11 @@ export async function startProxy(opts = {}) {
2889
2917
  catch (err) {
2890
2918
  if (verbose)
2891
2919
  console.error('[dario] Stream error:', sanitizeError(err));
2920
+ // Tear down the upstream body reader deterministically on an abnormal
2921
+ // mid-stream error (e.g. a bare upstream socket reset) so the undici
2922
+ // socket is released now, not at GC (#642-audit). No-op if aborted.
2923
+ if (!upstreamAbort.signal.aborted)
2924
+ upstreamAbort.abort();
2892
2925
  }
2893
2926
  res.end();
2894
2927
  // Stamp the response-completion timestamp + token count so the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "4.8.120",
3
+ "version": "4.8.121",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {