@askalf/dario 4.8.120 → 4.8.122

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.
@@ -32,7 +32,7 @@
32
32
  * — the same ordering normalizeUpstreamIds() produces for live data.
33
33
  */
34
34
  export declare const BAKED_BASE_MODELS: readonly string[];
35
- /** The set of suspended families, resolved from env at call time. */
35
+ /** The set of suspended families, resolved from env (memoized by raw value). */
36
36
  export declare function suspendedFamilies(): Set<string>;
37
37
  /** True when `model` (any spelling) belongs to a suspended family. */
38
38
  export declare function isSuspendedModel(model: string): boolean;
@@ -67,14 +67,23 @@ export const BAKED_BASE_MODELS = [
67
67
  * to re-suspend if access is ever pulled again.
68
68
  */
69
69
  const DEFAULT_SUSPENDED_MODELS = '';
70
- /** The set of suspended families, resolved from env at call time. */
70
+ // Memoized by the raw env value (#642-audit): isSuspendedModel runs per request,
71
+ // and this rebuilt a Set from process.env every call. Keyed on the raw string so
72
+ // a runtime change to DARIO_SUSPENDED_MODELS still re-parses; the common (empty)
73
+ // value allocates the Set once.
74
+ let _suspendedCache = null;
75
+ /** The set of suspended families, resolved from env (memoized by raw value). */
71
76
  export function suspendedFamilies() {
72
77
  const raw = process.env.DARIO_SUSPENDED_MODELS ?? DEFAULT_SUSPENDED_MODELS;
73
- return new Set(raw
78
+ if (_suspendedCache && _suspendedCache.raw === raw)
79
+ return _suspendedCache.set;
80
+ const set = new Set(raw
74
81
  .split(',')
75
82
  .map((s) => s.trim())
76
83
  .filter((s) => s.length > 0)
77
84
  .map((s) => modelFamily(s) ?? s.toLowerCase()));
85
+ _suspendedCache = { raw, set };
86
+ return set;
78
87
  }
79
88
  /** True when `model` (any spelling) belongs to a suspended family. */
80
89
  export function isSuspendedModel(model) {
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
@@ -455,6 +455,21 @@ function sanitizeContent(text, patterns) {
455
455
  }
456
456
  return result.replace(/\n{3,}/g, '\n\n').trim();
457
457
  }
458
+ // Memoize the compiled preserve-tag pattern set by Set reference (#642-audit):
459
+ // opts.preserveOrchestrationTags is a single Set instance passed on every
460
+ // request, so recompile the ~28 patterns once instead of per request. The
461
+ // default (no-preserve) path already uses the precompiled constant.
462
+ let _orchPreserveKey;
463
+ let _orchPatterns;
464
+ function orchestrationPatternsFor(preserveTags) {
465
+ if (preserveTags === undefined)
466
+ return ORCHESTRATION_PATTERNS_DEFAULT;
467
+ if (preserveTags !== _orchPreserveKey) {
468
+ _orchPreserveKey = preserveTags;
469
+ _orchPatterns = buildOrchestrationPatterns(preserveTags);
470
+ }
471
+ return _orchPatterns;
472
+ }
458
473
  /**
459
474
  * Strip orchestration tags from all messages in a request body.
460
475
  *
@@ -465,7 +480,7 @@ export function sanitizeMessages(body, preserveTags) {
465
480
  const messages = body.messages;
466
481
  if (!messages)
467
482
  return;
468
- const patterns = preserveTags === undefined ? ORCHESTRATION_PATTERNS_DEFAULT : buildOrchestrationPatterns(preserveTags);
483
+ const patterns = orchestrationPatternsFor(preserveTags);
469
484
  for (const msg of messages) {
470
485
  if (typeof msg.content === 'string') {
471
486
  msg.content = sanitizeContent(msg.content, patterns);
@@ -544,48 +559,55 @@ function anthropicToOpenai(body) {
544
559
  usage: { prompt_tokens: u?.input_tokens ?? 0, completion_tokens: u?.output_tokens ?? 0, total_tokens: (u?.input_tokens ?? 0) + (u?.output_tokens ?? 0) },
545
560
  };
546
561
  }
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`;
562
+ /**
563
+ * Translate Anthropic SSE OpenAI SSE. Returns a stateful per-CALL closure so
564
+ * concurrent /v1/chat/completions streams don't share tool-call index/id state
565
+ * (#642-audit: the previous module-global counters cross-contaminated
566
+ * interleaved streams and emitted malformed tool_calls deltas). One translator
567
+ * per request, mirroring createStreamingReverseMapper.
568
+ */
569
+ export function createOpenAIStreamTranslator() {
570
+ let toolIndex = 0;
571
+ let toolId = '';
572
+ return function translateStreamChunk(line) {
573
+ if (!line.startsWith('data: '))
574
+ return null;
575
+ const json = line.slice(6).trim();
576
+ if (json === '[DONE]')
577
+ return 'data: [DONE]\n\n';
578
+ try {
579
+ const e = JSON.parse(json);
580
+ const ts = Math.floor(Date.now() / 1000);
581
+ if (e.type === 'content_block_start') {
582
+ const block = e.content_block;
583
+ if (block?.type === 'tool_use' && block.name) {
584
+ toolId = block.id ?? `call_${toolIndex}`;
585
+ 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`;
586
+ }
565
587
  }
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 = '';
588
+ if (e.type === 'content_block_delta') {
589
+ const d = e.delta;
590
+ if (d?.type === 'text_delta' && d.text)
591
+ 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`;
592
+ if (d?.type === 'input_json_delta' && d.partial_json)
593
+ 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`;
594
+ }
595
+ if (e.type === 'content_block_stop') {
596
+ if (toolId) {
597
+ toolIndex++;
598
+ toolId = '';
599
+ }
600
+ return null;
601
+ }
602
+ if (e.type === 'message_stop') {
603
+ toolIndex = 0;
604
+ toolId = '';
605
+ 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
606
  }
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
607
  }
586
- }
587
- catch { }
588
- return null;
608
+ catch { }
609
+ return null;
610
+ };
589
611
  }
590
612
  // Baked /v1/models payload — what the proxy advertises before (or without)
591
613
  // a successful upstream catalog fetch. The live route serves the
@@ -1751,9 +1773,15 @@ export async function startProxy(opts = {}) {
1751
1773
  // recognizes through its own Anthropic gateway, bypassing localhost).
1752
1774
  let forcedProvider = cliProviderOverride;
1753
1775
  let requestEffort; // dario#419 — per-request effort parsed from a model-name suffix (model:high / model-high)
1776
+ // Parsed body, shared between the provider-prefix detection below and the
1777
+ // template-build block further down so the same bytes are not JSON.parsed
1778
+ // twice per request (#642-audit). Mutations in the prefix block re-serialize
1779
+ // `body` FROM this object, so it always represents the current body.
1780
+ let parsedBody = null;
1754
1781
  if (body.length > 0) {
1755
1782
  try {
1756
1783
  const parsed = JSON.parse(body.toString());
1784
+ parsedBody = parsed;
1757
1785
  const rawModel = parsed.model ?? '';
1758
1786
  const prefix = parseProviderPrefix(rawModel);
1759
1787
  if (prefix) {
@@ -1859,7 +1887,11 @@ export async function startProxy(opts = {}) {
1859
1887
  } : undefined;
1860
1888
  if (body.length > 0) {
1861
1889
  try {
1862
- const parsed = JSON.parse(body.toString());
1890
+ // Reuse the object parsed for provider-prefix detection above — the same
1891
+ // bytes, so re-parsing is wasted work on large bodies (#642-audit). Falls
1892
+ // back to a fresh parse when the earlier block didn't run (e.g. the body
1893
+ // was not JSON, or an OpenAI-backend reroute skipped it).
1894
+ const parsed = parsedBody ?? JSON.parse(body.toString());
1863
1895
  // Strip orchestration tags from messages (Aider, Cursor, etc.)
1864
1896
  sanitizeMessages(parsed, opts.preserveOrchestrationTags);
1865
1897
  const result = isOpenAI ? openaiToAnthropic(parsed, modelOverride) : (modelOverride ? { ...parsed, model: modelOverride } : parsed);
@@ -2779,14 +2811,29 @@ export async function startProxy(opts = {}) {
2779
2811
  const streamMapper = ccToolMap && !isOpenAI
2780
2812
  ? createStreamingReverseMapper(ccToolMap, reqCtx)
2781
2813
  : null;
2814
+ // Per-request OpenAI SSE translator — isolated tool-call state so
2815
+ // concurrent /v1/chat/completions streams don't cross-contaminate (#642-audit).
2816
+ const openaiTranslate = isOpenAI ? createOpenAIStreamTranslator() : null;
2782
2817
  // Gated writer — a no-op once the downstream client has gone away
2783
2818
  // in drain-on-close mode. The read loop keeps consuming so the
2784
2819
  // upstream sees a full-length read; writes to a closed socket are
2785
2820
  // suppressed to avoid EPIPE/warnings and pointless work.
2821
+ // Honor downstream backpressure (#642-audit): when res.write() returns
2822
+ // false the client's socket buffer is full, so pause the read loop until
2823
+ // it drains instead of buffering the whole (fast) upstream in memory.
2824
+ let needsDrain = false;
2786
2825
  const writeToClient = (chunk) => {
2787
- if (!clientDisconnected)
2788
- res.write(chunk);
2826
+ if (clientDisconnected)
2827
+ return;
2828
+ if (res.write(chunk) === false)
2829
+ needsDrain = true;
2789
2830
  };
2831
+ // Resolves on 'close' too, so a vanished client can't wedge the loop.
2832
+ const waitForDrain = () => new Promise((resolve) => {
2833
+ const done = () => { res.off('drain', done); res.off('close', done); resolve(); };
2834
+ res.once('drain', done);
2835
+ res.once('close', done);
2836
+ });
2790
2837
  try {
2791
2838
  let buffer = '';
2792
2839
  const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
@@ -2860,7 +2907,7 @@ export async function startProxy(opts = {}) {
2860
2907
  const lines = buffer.split('\n');
2861
2908
  buffer = lines.pop() ?? '';
2862
2909
  for (const line of lines) {
2863
- const translated = translateStreamChunk(line);
2910
+ const translated = openaiTranslate(line);
2864
2911
  if (translated)
2865
2912
  writeToClient(translated);
2866
2913
  }
@@ -2873,10 +2920,16 @@ export async function startProxy(opts = {}) {
2873
2920
  else {
2874
2921
  writeToClient(value);
2875
2922
  }
2923
+ // Backpressure (#642-audit): if the last writes filled the client
2924
+ // buffer, pause reading upstream until it drains.
2925
+ if (needsDrain && !clientDisconnected) {
2926
+ needsDrain = false;
2927
+ await waitForDrain();
2928
+ }
2876
2929
  }
2877
2930
  // Flush remaining buffer
2878
2931
  if (isOpenAI && buffer.trim()) {
2879
- const translated = translateStreamChunk(buffer);
2932
+ const translated = openaiTranslate(buffer);
2880
2933
  if (translated)
2881
2934
  writeToClient(translated);
2882
2935
  }
@@ -2889,6 +2942,11 @@ export async function startProxy(opts = {}) {
2889
2942
  catch (err) {
2890
2943
  if (verbose)
2891
2944
  console.error('[dario] Stream error:', sanitizeError(err));
2945
+ // Tear down the upstream body reader deterministically on an abnormal
2946
+ // mid-stream error (e.g. a bare upstream socket reset) so the undici
2947
+ // socket is released now, not at GC (#642-audit). No-op if aborted.
2948
+ if (!upstreamAbort.signal.aborted)
2949
+ upstreamAbort.abort();
2892
2950
  }
2893
2951
  res.end();
2894
2952
  // 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.122",
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": {