@askalf/dario 3.31.20 → 3.32.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/dist/proxy.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomUUID, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
3
3
  import { execSync } from 'node:child_process';
4
- import { readFileSync, readdirSync } from 'node:fs';
4
+ import { readFileSync, readdirSync, createWriteStream } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
7
  import { arch, platform } from 'node:process';
@@ -345,6 +345,21 @@ function translateStreamChunk(line) {
345
345
  return null;
346
346
  }
347
347
  const OPENAI_MODELS_LIST = { object: 'list', data: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'].map(id => ({ id, object: 'model', created: 1700000000, owned_by: 'anthropic' })) };
348
+ /**
349
+ * Append a JSON-ND line to the proxy log file. No-op when stream is
350
+ * null (logFile not configured). Errors are swallowed — log writes
351
+ * must never break the request path.
352
+ */
353
+ export function writeLogLine(stream, entry) {
354
+ if (!stream)
355
+ return;
356
+ try {
357
+ stream.write(redactSecrets(JSON.stringify(entry)) + '\n');
358
+ }
359
+ catch {
360
+ // ignore — log mishaps must never affect requests
361
+ }
362
+ }
348
363
  export function sanitizeError(err) {
349
364
  // Pattern set lives in src/redact.ts so OAuth call sites can run the
350
365
  // same redaction directly on response-body strings without importing
@@ -440,12 +455,74 @@ export async function startProxy(opts = {}) {
440
455
  // Set once on first sighting per family so the startup log stays
441
456
  // short even under heavy traffic. dario#40.
442
457
  const detectedClientsLogged = new Set();
458
+ // Per-(client, mapping-mode) keys for which we've already emitted a
459
+ // tool-substitution warn line. Same de-dup contract as
460
+ // detectedClientsLogged so mixed-traffic proxies don't spam.
461
+ const toolSubLogged = new Set();
443
462
  // Body-dump mode: set via --verbose=2 / -vv or DARIO_LOG_BODIES=1.
444
463
  // When on, every request emits a redacted JSON body to stderr so
445
464
  // operators can see exactly what dario forwards upstream. Default
446
465
  // -v stays quiet because bodies can carry file content and tool
447
466
  // output. Reported in dario#40 by @ringge.
448
467
  const verboseBodies = Boolean(opts.verboseBodies) || process.env.DARIO_LOG_BODIES === '1';
468
+ // Operator-declared beta passthrough set. Sourced from CLI flag or env;
469
+ // both are CSV strings of beta-flag names. Trimmed, deduped, empty
470
+ // entries dropped. Stays a Set for fast membership checks in the per-
471
+ // request beta build below.
472
+ const passthroughBetas = new Set([
473
+ ...(opts.passthroughBetas ?? []),
474
+ ...((process.env.DARIO_PASSTHROUGH_BETAS ?? '').split(',')),
475
+ ]
476
+ .map((b) => b.trim())
477
+ .filter((b) => b.length > 0));
478
+ if (passthroughBetas.size > 0) {
479
+ console.log(` Beta passthrough: ${[...passthroughBetas].sort().join(', ')} (always forwarded; per-account rejection cache still applies)`);
480
+ }
481
+ // Tool-routing mode mutex. preserve / hybrid / merge each shape the
482
+ // outbound `tools` array differently; combining two would mean two
483
+ // different bodies. Refuse to start with a clear error rather than
484
+ // silently dropping a flag.
485
+ const toolModes = [
486
+ opts.preserveTools ? 'preserve-tools' : null,
487
+ opts.hybridTools ? 'hybrid-tools' : null,
488
+ opts.mergeTools ? 'merge-tools' : null,
489
+ ].filter((m) => m !== null);
490
+ if (toolModes.length > 1) {
491
+ console.error(`[dario] tool-routing flags are mutually exclusive — pick one: ${toolModes.join(', ')}.`);
492
+ process.exit(1);
493
+ }
494
+ if (opts.mergeTools) {
495
+ // Loud notice — this mode is experimental and operators need to
496
+ // verify their billing classification before relying on it. The
497
+ // wire-shape "tools[]" axis still has CC's array as a prefix, but
498
+ // the suffix is operator-supplied custom shapes. Anthropic's
499
+ // classifier may flip routing on the difference.
500
+ console.log(' Tool routing: merge (CC tools + client custom tools, deduped)');
501
+ console.log(' ⚠ EXPERIMENTAL: validate billing-bucket behavior on the first 1-2 requests with --verbose');
502
+ }
503
+ // Append-only structured request log. One JSON-ND line per completed
504
+ // request — secrets scrubbed via redactSecrets, no bodies. Off by
505
+ // default; opt in with `--log-file <path>` or DARIO_LOG_FILE. See the
506
+ // ProxyLogEntry interface for fields. Useful for backgrounded proxies
507
+ // where stdout is unobserved (`verbose` only helps in foreground).
508
+ // Errors during open are reported once and downgrade to no-op so a
509
+ // log mishap never blocks the proxy from booting.
510
+ const logFilePath = opts.logFile || process.env.DARIO_LOG_FILE || null;
511
+ let logFileStream = null;
512
+ if (logFilePath) {
513
+ try {
514
+ logFileStream = createWriteStream(logFilePath, { flags: 'a' });
515
+ logFileStream.on('error', (err) => {
516
+ console.error(`[dario] log-file write error: ${err.message} (logging disabled)`);
517
+ logFileStream = null;
518
+ });
519
+ console.log(` Request log: ${logFilePath}`);
520
+ }
521
+ catch (err) {
522
+ console.error(`[dario] log-file open failed: ${err instanceof Error ? err.message : err} (continuing without)`);
523
+ logFileStream = null;
524
+ }
525
+ }
449
526
  // Multi-provider backends (v3.6.0+). Loaded once at startup; the CLI
450
527
  // `dario backend add openai --key=…` writes to ~/.dario/backends/.
451
528
  // Routing: a GPT-family model arriving on /v1/chat/completions is
@@ -707,6 +784,10 @@ export async function startProxy(opts = {}) {
707
784
  // one-line reject log under -v so operators see auth misfires.
708
785
  console.error(`[dario] #${requestCount} 401 rejected (DARIO_API_KEY mismatch): ${describeAuthReject(req.headers)}`);
709
786
  }
787
+ writeLogLine(logFileStream, {
788
+ ts: new Date().toISOString(), req: requestCount,
789
+ method: req.method ?? '', path: urlPath, status: 401, reject: 'auth',
790
+ });
710
791
  res.writeHead(401, JSON_HEADERS);
711
792
  res.end(ERR_UNAUTH);
712
793
  return;
@@ -790,6 +871,10 @@ export async function startProxy(opts = {}) {
790
871
  }
791
872
  catch (err) {
792
873
  if (err instanceof QueueFullError) {
874
+ writeLogLine(logFileStream, {
875
+ ts: new Date().toISOString(), req: requestCount,
876
+ method: req.method ?? '', path: urlPath, status: 429, reject: 'queue-full',
877
+ });
793
878
  res.writeHead(429, JSON_HEADERS);
794
879
  res.end(JSON.stringify({
795
880
  type: 'error',
@@ -801,6 +886,10 @@ export async function startProxy(opts = {}) {
801
886
  return;
802
887
  }
803
888
  if (err instanceof QueueTimeoutError) {
889
+ writeLogLine(logFileStream, {
890
+ ts: new Date().toISOString(), req: requestCount,
891
+ method: req.method ?? '', path: urlPath, status: 504, reject: 'queue-timeout',
892
+ });
804
893
  res.writeHead(504, JSON_HEADERS);
805
894
  res.end(JSON.stringify({
806
895
  type: 'error',
@@ -817,6 +906,13 @@ export async function startProxy(opts = {}) {
817
906
  let upstreamTimeout = null;
818
907
  let onClientClose = null;
819
908
  let upstreamAbortReason = null;
909
+ // Hoisted so the catch can include them in the request log line. The
910
+ // body-parsing block below assigns these once the request is parsed;
911
+ // before that point they remain at their initial values, which is
912
+ // also exactly what we want to log on early-failure paths.
913
+ let requestModel = '';
914
+ let detectedClientForLog;
915
+ let preserveToolsEffective = Boolean(opts.preserveTools);
820
916
  try {
821
917
  // Pool mode: select an account by headroom. Single-account mode:
822
918
  // fall through to getAccessToken() exactly as before. Request-path
@@ -912,7 +1008,9 @@ export async function startProxy(opts = {}) {
912
1008
  // Parse body once, apply OpenAI translation, model override, and sanitization
913
1009
  let finalBody = body.length > 0 ? body : undefined;
914
1010
  let ccToolMap = null;
915
- let requestModel = '';
1011
+ // requestModel / detectedClientForLog / preserveToolsEffective are
1012
+ // declared at the outer try-scope above so the catch block can
1013
+ // include them in the request log line.
916
1014
  // Session stickiness key — hash of the first user message in this
917
1015
  // conversation. Populated inside the template-replay block below
918
1016
  // after the first user message is extracted for the build tag, then
@@ -1000,13 +1098,17 @@ export async function startProxy(opts = {}) {
1000
1098
  const bodyIdentity = poolAccount
1001
1099
  ? poolAccount.identity
1002
1100
  : { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: preBodySessionId };
1003
- const { body: ccBody, toolMap, detectedClient } = buildCCRequest(r, billingTag, CACHE_EPHEMERAL, bodyIdentity, {
1101
+ const { body: ccBody, toolMap, detectedClient, unmappedTools } = buildCCRequest(r, billingTag, CACHE_EPHEMERAL, bodyIdentity, {
1004
1102
  preserveTools: opts.preserveTools ?? false,
1005
1103
  hybridTools: opts.hybridTools ?? false,
1104
+ mergeTools: opts.mergeTools ?? false,
1006
1105
  noAutoDetect: opts.noAutoDetect ?? false,
1007
1106
  effort: opts.effort,
1008
1107
  maxTokens: opts.maxTokens,
1009
1108
  });
1109
+ detectedClientForLog = detectedClient;
1110
+ preserveToolsEffective = Boolean(opts.preserveTools)
1111
+ || (Boolean(detectedClient) && !opts.hybridTools && !opts.mergeTools);
1010
1112
  // Log the auto-preserve-tools switch once per text-tool
1011
1113
  // client family. Skip when the operator already opted into
1012
1114
  // --preserve-tools or --hybrid-tools — they know what they
@@ -1019,6 +1121,28 @@ export async function startProxy(opts = {}) {
1019
1121
  detectedClientsLogged.add(detectedClient);
1020
1122
  console.log(`[dario] detected ${detectedClient}-style text-tool protocol — auto-enabling preserve-tools for this client (pass --hybrid-tools to override, --preserve-tools to silence)`);
1021
1123
  }
1124
+ // Surface tool substitution. When a non-CC client routes
1125
+ // tools we don't have in TOOL_MAP and neither auto-detect
1126
+ // nor an explicit flag flipped us into preserve-tools, the
1127
+ // unmapped tools get distributed onto CC fallback slots —
1128
+ // the model upstream sees "Glob"/"Read"/etc. and the
1129
+ // client's own tool surface is silently rewritten on the
1130
+ // response path. That rewrite is correct for
1131
+ // schema-compatible cases but invisible to operators who
1132
+ // didn't expect it. One-line warn the first time we see a
1133
+ // non-empty unmapped set per (client family, mapping mode)
1134
+ // — same de-dupe key shape as the auto-detect line so a
1135
+ // mixed-traffic proxy doesn't spam.
1136
+ const subKey = `${detectedClient ?? 'unknown'}:${preserveToolsEffective ? 'preserve' : 'remap'}`;
1137
+ if (unmappedTools.length > 0
1138
+ && !preserveToolsEffective
1139
+ && !toolSubLogged.has(subKey)) {
1140
+ toolSubLogged.add(subKey);
1141
+ const totalTools = (Array.isArray(r.tools) ? r.tools.length : 0);
1142
+ const sample = unmappedTools.slice(0, 5).join(', ');
1143
+ const more = unmappedTools.length > 5 ? `, +${unmappedTools.length - 5} more` : '';
1144
+ console.log(`[dario] tool substitution: ${unmappedTools.length}/${totalTools} client tool${unmappedTools.length === 1 ? '' : 's'} not in TOOL_MAP — remapped onto CC fallback slots (${sample}${more}). Pass --preserve-tools to forward your schemas verbatim instead.`);
1145
+ }
1022
1146
  // Store tool map for response reverse-mapping
1023
1147
  ccToolMap = toolMap;
1024
1148
  // Replace request body entirely with CC template
@@ -1074,6 +1198,19 @@ export async function startProxy(opts = {}) {
1074
1198
  if (filtered)
1075
1199
  beta += ',' + filtered;
1076
1200
  }
1201
+ // Operator-pinned passthrough betas. Always forwarded — bypasses
1202
+ // the billable-beta filter, bypasses the "not in client's
1203
+ // request" gate. The per-account rejection cache below still
1204
+ // applies, so a pinned flag the upstream 400's gets dropped on
1205
+ // the retry rather than re-sent forever (the cache survives the
1206
+ // operator's pin because the upstream's no is final until a
1207
+ // tier change resets it).
1208
+ if (passthroughBetas.size > 0) {
1209
+ const baseSet = new Set(beta.split(','));
1210
+ const toAdd = [...passthroughBetas].filter((b) => !baseSet.has(b));
1211
+ if (toAdd.length > 0)
1212
+ beta += ',' + toAdd.join(',');
1213
+ }
1077
1214
  // Strip any beta flags the upstream has previously rejected on this
1078
1215
  // account so we don't re-pay the 400 round-trip (dario#42 afk-mode
1079
1216
  // fallout: captured templates carry tier-gated flags whose availability
@@ -1596,6 +1733,20 @@ export async function startProxy(opts = {}) {
1596
1733
  latencyMs: Date.now() - startTime, status: upstream.status, isStream: true, isOpenAI,
1597
1734
  });
1598
1735
  }
1736
+ writeLogLine(logFileStream, {
1737
+ ts: new Date().toISOString(), req: requestCount,
1738
+ method: req.method ?? '', path: urlPath,
1739
+ model: requestModel || undefined,
1740
+ status: upstream.status, latency_ms: Date.now() - startTime,
1741
+ in_tokens: streamInputTokens, out_tokens: streamOutputTokens,
1742
+ cache_read: streamCacheReadTokens, cache_create: streamCacheCreateTokens,
1743
+ claim: poolAccount?.rateLimit.claim,
1744
+ bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
1745
+ account: poolAccount?.alias,
1746
+ client: detectedClientForLog,
1747
+ preserve_tools: preserveToolsEffective,
1748
+ stream: true,
1749
+ });
1599
1750
  }
1600
1751
  else {
1601
1752
  // Buffer and forward
@@ -1615,16 +1766,20 @@ export async function startProxy(opts = {}) {
1615
1766
  else {
1616
1767
  res.end(responseBody);
1617
1768
  }
1618
- if (analytics && poolAccount) {
1769
+ let bufferedUsage = null;
1770
+ try {
1771
+ const parsed = JSON.parse(responseBody);
1772
+ bufferedUsage = Analytics.parseUsage(parsed);
1773
+ }
1774
+ catch { /* malformed body — log without usage */ }
1775
+ if (analytics && poolAccount && bufferedUsage) {
1619
1776
  try {
1620
- const parsed = JSON.parse(responseBody);
1621
- const usage = Analytics.parseUsage(parsed);
1622
1777
  analytics.record({
1623
1778
  timestamp: Date.now(), account: poolAccount.alias,
1624
- model: usage.model || requestModel,
1625
- inputTokens: usage.inputTokens, outputTokens: usage.outputTokens,
1626
- cacheReadTokens: usage.cacheReadTokens, cacheCreateTokens: usage.cacheCreateTokens,
1627
- thinkingTokens: usage.thinkingTokens,
1779
+ model: bufferedUsage.model || requestModel,
1780
+ inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
1781
+ cacheReadTokens: bufferedUsage.cacheReadTokens, cacheCreateTokens: bufferedUsage.cacheCreateTokens,
1782
+ thinkingTokens: bufferedUsage.thinkingTokens,
1628
1783
  claim: poolAccount.rateLimit.claim, util5h: poolAccount.rateLimit.util5h,
1629
1784
  util7d: poolAccount.rateLimit.util7d, overageUtil: poolAccount.rateLimit.overageUtil,
1630
1785
  latencyMs: Date.now() - startTime, status: upstream.status, isStream: false, isOpenAI,
@@ -1632,6 +1787,20 @@ export async function startProxy(opts = {}) {
1632
1787
  }
1633
1788
  catch { /* don't let analytics errors break responses */ }
1634
1789
  }
1790
+ writeLogLine(logFileStream, {
1791
+ ts: new Date().toISOString(), req: requestCount,
1792
+ method: req.method ?? '', path: urlPath,
1793
+ model: bufferedUsage?.model || requestModel || undefined,
1794
+ status: upstream.status, latency_ms: Date.now() - startTime,
1795
+ in_tokens: bufferedUsage?.inputTokens, out_tokens: bufferedUsage?.outputTokens,
1796
+ cache_read: bufferedUsage?.cacheReadTokens, cache_create: bufferedUsage?.cacheCreateTokens,
1797
+ claim: poolAccount?.rateLimit.claim,
1798
+ bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
1799
+ account: poolAccount?.alias,
1800
+ client: detectedClientForLog,
1801
+ preserve_tools: preserveToolsEffective,
1802
+ stream: false,
1803
+ });
1635
1804
  if (verbose)
1636
1805
  console.log(`[dario] #${requestCount} ${upstream.status}`);
1637
1806
  }
@@ -1639,9 +1808,17 @@ export async function startProxy(opts = {}) {
1639
1808
  catch (err) {
1640
1809
  // Differentiate the three failure modes so each gets the right
1641
1810
  // response (and so we don't spam logs when clients simply drop).
1811
+ const errLogBase = {
1812
+ ts: new Date().toISOString(), req: requestCount,
1813
+ method: req.method ?? '', path: urlPath,
1814
+ model: requestModel || undefined,
1815
+ client: detectedClientForLog,
1816
+ preserve_tools: preserveToolsEffective,
1817
+ };
1642
1818
  if (upstreamAbortReason === 'client_closed') {
1643
1819
  if (verbose)
1644
1820
  console.log(`[dario] #${requestCount} aborted (client disconnected)`);
1821
+ writeLogLine(logFileStream, { ...errLogBase, reject: 'client-closed' });
1645
1822
  }
1646
1823
  else if (upstreamAbortReason === 'timeout') {
1647
1824
  console.error(`[dario] #${requestCount} upstream timeout after ${UPSTREAM_TIMEOUT_MS / 1000}s`);
@@ -1652,6 +1829,7 @@ export async function startProxy(opts = {}) {
1652
1829
  else if (!res.writableEnded) {
1653
1830
  res.end();
1654
1831
  }
1832
+ writeLogLine(logFileStream, { ...errLogBase, status: 504, error: 'upstream-timeout' });
1655
1833
  }
1656
1834
  else {
1657
1835
  // Log full error server-side, return generic message to client
@@ -1663,6 +1841,7 @@ export async function startProxy(opts = {}) {
1663
1841
  else if (!res.writableEnded) {
1664
1842
  res.end();
1665
1843
  }
1844
+ writeLogLine(logFileStream, { ...errLogBase, status: 502, error: sanitizeError(err) });
1666
1845
  }
1667
1846
  }
1668
1847
  finally {
@@ -1823,6 +2002,8 @@ export async function startProxy(opts = {}) {
1823
2002
  console.log('\n[dario] Shutting down...');
1824
2003
  clearInterval(presenceInterval);
1825
2004
  clearInterval(refreshInterval);
2005
+ if (logFileStream)
2006
+ logFileStream.end();
1826
2007
  server.close(() => process.exit(0));
1827
2008
  // Force exit after 5s if connections don't close
1828
2009
  setTimeout(() => process.exit(0), 5000).unref();
@@ -75,3 +75,29 @@ export declare function classifyRuntimeFingerprint(runningUnderBun: boolean, ava
75
75
  * directly with synthetic inputs.
76
76
  */
77
77
  export declare function detectRuntimeFingerprint(): RuntimeFingerprint;
78
+ /**
79
+ * One-shot Bun installer. Used by `dario doctor --bun-bootstrap` to
80
+ * close the gap between "Bun warn surfaced" and "Bun on PATH" without
81
+ * making the user copy-paste an install line. Picks the platform-correct
82
+ * upstream installer:
83
+ *
84
+ * - Windows: `powershell -c "irm https://bun.sh/install.ps1 | iex"`
85
+ * - macOS / Linux: `curl -fsSL https://bun.sh/install | bash`
86
+ *
87
+ * Streams installer output to the parent stdio so the user sees what's
88
+ * happening (the install can take 10-30 s on a slow link). Returns the
89
+ * exit code; non-zero is surfaced by the caller as a fail row.
90
+ *
91
+ * Pure delegation to the upstream Bun installer — dario does not vendor
92
+ * or self-host the binary. If the user wants a pinned version or doesn't
93
+ * want to run a curl-to-shell installer, the doctor warn line still
94
+ * points at https://bun.sh for manual install.
95
+ *
96
+ * Pinned to bun.sh (not bun.com) because PowerShell's `irm` doesn't
97
+ * follow the bun.com → bun.sh 308 redirect; piping the redirect HTML
98
+ * to `iex` then fails parse. bun.sh serves the install script directly.
99
+ */
100
+ export declare function bunBootstrap(): Promise<{
101
+ exitCode: number;
102
+ runner: string;
103
+ }>;
@@ -115,3 +115,46 @@ export function detectRuntimeFingerprint() {
115
115
  const probed = probeBunVersion();
116
116
  return classifyRuntimeFingerprint(false, probed, process.env);
117
117
  }
118
+ /**
119
+ * One-shot Bun installer. Used by `dario doctor --bun-bootstrap` to
120
+ * close the gap between "Bun warn surfaced" and "Bun on PATH" without
121
+ * making the user copy-paste an install line. Picks the platform-correct
122
+ * upstream installer:
123
+ *
124
+ * - Windows: `powershell -c "irm https://bun.sh/install.ps1 | iex"`
125
+ * - macOS / Linux: `curl -fsSL https://bun.sh/install | bash`
126
+ *
127
+ * Streams installer output to the parent stdio so the user sees what's
128
+ * happening (the install can take 10-30 s on a slow link). Returns the
129
+ * exit code; non-zero is surfaced by the caller as a fail row.
130
+ *
131
+ * Pure delegation to the upstream Bun installer — dario does not vendor
132
+ * or self-host the binary. If the user wants a pinned version or doesn't
133
+ * want to run a curl-to-shell installer, the doctor warn line still
134
+ * points at https://bun.sh for manual install.
135
+ *
136
+ * Pinned to bun.sh (not bun.com) because PowerShell's `irm` doesn't
137
+ * follow the bun.com → bun.sh 308 redirect; piping the redirect HTML
138
+ * to `iex` then fails parse. bun.sh serves the install script directly.
139
+ */
140
+ export async function bunBootstrap() {
141
+ const { spawn } = await import('node:child_process');
142
+ const isWindows = process.platform === 'win32';
143
+ const runner = isWindows
144
+ ? 'powershell -NoProfile -ExecutionPolicy Bypass -c "irm https://bun.sh/install.ps1 | iex"'
145
+ : 'curl -fsSL https://bun.sh/install | bash';
146
+ return await new Promise((resolve) => {
147
+ // Single-shell invocation so the pipe stages execute the way the
148
+ // upstream installer expects. Avoids reimplementing the curl-pipe-bash
149
+ // sequencing in Node primitives.
150
+ const child = isWindows
151
+ ? spawn('powershell', [
152
+ '-NoProfile',
153
+ '-ExecutionPolicy', 'Bypass',
154
+ '-Command', 'irm https://bun.sh/install.ps1 | iex',
155
+ ], { stdio: 'inherit' })
156
+ : spawn('bash', ['-lc', 'curl -fsSL https://bun.sh/install | bash'], { stdio: 'inherit' });
157
+ child.on('error', () => resolve({ exitCode: 1, runner }));
158
+ child.on('exit', (code) => resolve({ exitCode: code ?? 1, runner }));
159
+ });
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.31.20",
3
+ "version": "3.32.0",
4
4
  "description": "A local LLM router. One endpoint, every provider — Claude subscriptions, OpenAI, OpenRouter, Groq, local LiteLLM, any OpenAI-compat endpoint — your tools don't need to change.",
5
5
  "type": "module",
6
6
  "bin": {