@askalf/dario 3.8.1 → 3.9.1

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/README.md CHANGED
@@ -246,6 +246,7 @@ curl http://localhost:3456/analytics # per-account / per-model stats, burn ra
246
246
  |---|---|---|
247
247
  | `--passthrough` / `--thin` | Thin proxy for the Claude backend — OAuth swap only, no template injection | off |
248
248
  | `--preserve-tools` / `--keep-tools` | Keep client tool schemas instead of remapping to CC's `Bash/Read/Grep/Glob/WebSearch/WebFetch`. Required for clients whose tools have fields CC doesn't (`sessionId`, custom ids, etc.) — see [Custom tool schemas](#custom-tool-schemas). Trade-off: drops the CC request fingerprint. | off |
249
+ | `--hybrid-tools` / `--context-inject` | Remap to CC tools **and** inject request-context values (`sessionId`, `requestId`, `channelId`, `userId`, `timestamp`) into client-declared fields CC's schema doesn't carry. Preserves the CC fingerprint while keeping custom schemas functional — see [Hybrid tool mode](#hybrid-tool-mode). Mutually exclusive with `--preserve-tools`. | off |
249
250
  | `--model=<name>` | Force a model (`opus`, `sonnet`, `haiku`, or full ID). Applies to the Claude backend. | passthrough |
250
251
  | `--port=<n>` | Port to listen on | `3456` |
251
252
  | `--host=<addr>` / `DARIO_HOST` | Bind address. Use `0.0.0.0` for LAN, or a specific IP (e.g. a Tailscale interface). When non-loopback, also set `DARIO_API_KEY`. | `127.0.0.1` |
@@ -361,10 +362,36 @@ Fix: run dario with `--preserve-tools` (or `--keep-tools`). That skips the CC to
361
362
  dario proxy --preserve-tools
362
363
  ```
363
364
 
364
- The cost: requests no longer look like CC on the wire, so the CC subscription fingerprint is gone. On a Max/Pro plan, that means the request may be counted against your API usage rather than your subscription quota. If you're on API-key billing already, `--preserve-tools` is free; if you're using dario specifically to route against a subscription, decide whether your custom-schema workload is worth the fingerprint loss on that endpoint. (A hybrid mode that keeps the fingerprint and also passes through unmapped client fields is on the roadmap.)
365
+ The cost: requests no longer look like CC on the wire, so the CC subscription fingerprint is gone. On a Max/Pro plan, that means the request may be counted against your API usage rather than your subscription quota. If you're on API-key billing already, `--preserve-tools` is free; if you're using dario specifically to route against a subscription, the [hybrid tool mode](#hybrid-tool-mode) below is the compromise that keeps both.
365
366
 
366
367
  The openai-compat backend (OpenRouter, OpenAI, Groq, local LiteLLM, etc.) is unaffected — it forwards tool definitions byte-for-byte and doesn't need this flag.
367
368
 
369
+ ### Hybrid tool mode
370
+
371
+ For the very common case where the "missing" fields on your client's tool are **request context** — `sessionId`, `requestId`, `channelId`, `userId`, `timestamp` — dario can remap to CC tools *and* inject those values on the reverse path. The fingerprint stays intact, the model still sees only CC's tools (so subscription billing still routes), and your validator still sees the fields it requires because dario fills them from request headers on the way back.
372
+
373
+ ```bash
374
+ dario proxy --hybrid-tools
375
+ ```
376
+
377
+ **How it works.** On each request, dario builds a `RequestContext` from headers (`x-session-id`, `x-request-id`, `x-channel-id`, `x-user-id`) plus its own generated ids and the current timestamp. After `translateBack` produces the client-shaped tool call on the response path, any field declared on the client's tool schema whose name matches a known context field (`sessionId`/`session_id`, `requestId`/`request_id`, `channelId`/`channel_id`, `userId`/`user_id`, `timestamp`/`created_at`/`createdAt`) and isn't already populated gets filled from the context. Fields the model genuinely populated via `translateBack` are never overwritten.
378
+
379
+ **When to use which flag.**
380
+
381
+ | Your situation | Flag | Why |
382
+ |---|---|---|
383
+ | Your custom fields are request context (session/request/channel/user ids, timestamps) | `--hybrid-tools` | Keeps the CC fingerprint *and* your validator is satisfied. |
384
+ | Your custom fields need the model's reasoning (e.g. `confidence`, `reasoning_trace`, `tool_selection_rationale`) | `--preserve-tools` | The model has to see the real schema to populate these. Accept the fingerprint loss. |
385
+ | Your client's tools are already a subset of CC's `Bash/Read/Grep/Glob/WebSearch/WebFetch` | *(neither)* | Default mode works as-is. |
386
+
387
+ **Limitations of hybrid mode.**
388
+
389
+ - Top-level fields only. If your custom field is nested (e.g. `meta: {sessionId: ...}`), v1 doesn't reach into the nested object. Tracked in [#33](https://github.com/askalf/dario/issues/33).
390
+ - The field-to-context mapping is a fixed list. If you need arbitrary fields (e.g. an internal `tenant_id`) pulled from headers, file an issue and we'll extend the map.
391
+ - No type coercion beyond string. If your schema requires a numeric `sessionId`, dario sends the string it got from headers — override at your client level or use `--preserve-tools`.
392
+
393
+ Hybrid mode was built to resolve [#29](https://github.com/askalf/dario/issues/29) cleanly for OpenClaw-style agents whose `process` tool declares `sessionId`, after the full provider-comparison diagnostic from [@boeingchoco](https://github.com/boeingchoco) made clear that the problem wasn't fixable in the translation layer alone.
394
+
368
395
  ### Library mode
369
396
 
370
397
  ```typescript
@@ -548,6 +575,8 @@ npm run dev # runs with tsx, no build step
548
575
  | [@GodsBoy](https://github.com/GodsBoy) | Proxy authentication, token redaction, error sanitization ([#2](https://github.com/askalf/dario/pull/2)) |
549
576
  | [@belangertrading](https://github.com/belangertrading) | Billing classification investigation ([#4](https://github.com/askalf/dario/issues/4)), cache_control fingerprinting ([#6](https://github.com/askalf/dario/issues/6)), billing reclassification root cause ([#7](https://github.com/askalf/dario/issues/7)), OAuth client_id discovery ([#12](https://github.com/askalf/dario/issues/12)), multi-agent session-level billing analysis ([#23](https://github.com/askalf/dario/issues/23)) |
550
577
  | [@nathan-widjaja](https://github.com/nathan-widjaja) | README positioning rewrite structure ([#21](https://github.com/askalf/dario/issues/21)) |
578
+ | [@iNicholasBE](https://github.com/iNicholasBE) | macOS keychain credential detection ([#30](https://github.com/askalf/dario/pull/30)) |
579
+ | [@boeingchoco](https://github.com/boeingchoco) | Reverse-direction tool parameter translation ([#29](https://github.com/askalf/dario/issues/29)), SSE event-group framing regression catch (v3.7.1), provider-comparison diagnostic that surfaced the `--preserve-tools` discoverability gap (v3.8.1), and the motivating case for hybrid tool mode ([#33](https://github.com/askalf/dario/issues/33), v3.9.0) |
551
580
 
552
581
  ---
553
582
 
@@ -37,6 +37,25 @@ export interface ToolMapping {
37
37
  ccTool: string;
38
38
  translateArgs?: (args: Record<string, unknown>) => Record<string, unknown>;
39
39
  translateBack?: (args: Record<string, unknown>) => Record<string, unknown>;
40
+ /**
41
+ * Top-level field names the client's original tool schema declared.
42
+ * Populated only in hybrid mode (`hybridTools: true`) so the reverse
43
+ * path can inject request-context values (sessionId, requestId, …)
44
+ * into fields CC's schema doesn't carry. Unset in default mode.
45
+ */
46
+ clientFields?: string[];
47
+ }
48
+ /**
49
+ * Request context extracted once per incoming request. Source for
50
+ * hybrid-mode field injection — fields declared on the client's tool
51
+ * but not on CC's get filled from here on the reverse path.
52
+ */
53
+ export interface RequestContext {
54
+ sessionId: string;
55
+ requestId: string;
56
+ channelId?: string;
57
+ userId?: string;
58
+ timestamp: string;
40
59
  }
41
60
  /**
42
61
  * Build a CC-template request from a client request.
@@ -52,6 +71,7 @@ export declare function buildCCRequest(clientBody: Record<string, unknown>, bill
52
71
  sessionId: string;
53
72
  }, opts?: {
54
73
  preserveTools?: boolean;
74
+ hybridTools?: boolean;
55
75
  }): {
56
76
  body: Record<string, unknown>;
57
77
  toolMap: Map<string, ToolMapping>;
@@ -64,7 +84,7 @@ export declare function buildCCRequest(clientBody: Record<string, unknown>, bill
64
84
  * body isn't valid JSON (e.g. an error response, a partial chunk),
65
85
  * returns it unchanged.
66
86
  */
67
- export declare function reverseMapResponse(responseBody: string, toolMap: Map<string, ToolMapping>): string;
87
+ export declare function reverseMapResponse(responseBody: string, toolMap: Map<string, ToolMapping>, ctx?: RequestContext): string;
68
88
  /**
69
89
  * Streaming reverse-mapper for SSE responses.
70
90
  *
@@ -111,4 +131,4 @@ export interface StreamingReverseMapper {
111
131
  feed(chunk: Uint8Array): Uint8Array;
112
132
  end(): Uint8Array;
113
133
  }
114
- export declare function createStreamingReverseMapper(toolMap: Map<string, ToolMapping>): StreamingReverseMapper;
134
+ export declare function createStreamingReverseMapper(toolMap: Map<string, ToolMapping>, ctx?: RequestContext): StreamingReverseMapper;
@@ -39,6 +39,49 @@ export function scrubFrameworkIdentifiers(text) {
39
39
  }
40
40
  return result;
41
41
  }
42
+ /**
43
+ * Map from client-declared field name (lowercase) to the RequestContext
44
+ * key that supplies its value. A field declared on the client's tool
45
+ * whose name matches one of these gets auto-filled in hybrid mode.
46
+ *
47
+ * Case-insensitive match on the client's declared field name. Both
48
+ * snake_case and camelCase variants map to the same source.
49
+ */
50
+ const CONTEXT_FIELD_SOURCES = {
51
+ sessionid: 'sessionId',
52
+ session_id: 'sessionId',
53
+ requestid: 'requestId',
54
+ request_id: 'requestId',
55
+ channelid: 'channelId',
56
+ channel_id: 'channelId',
57
+ userid: 'userId',
58
+ user_id: 'userId',
59
+ timestamp: 'timestamp',
60
+ createdat: 'timestamp',
61
+ created_at: 'timestamp',
62
+ };
63
+ /**
64
+ * Fill in fields declared on the client's tool schema that are still
65
+ * absent from the translated input, drawing values from the request
66
+ * context. Only runs when a mapping has `clientFields` populated
67
+ * (hybrid mode) and an input object is present. Fields already set
68
+ * by `translateBack` are never overwritten.
69
+ */
70
+ function injectContextFields(input, clientFields, ctx) {
71
+ if (!clientFields || !ctx)
72
+ return input;
73
+ for (const field of clientFields) {
74
+ if (field in input && input[field] !== undefined && input[field] !== null && input[field] !== '')
75
+ continue;
76
+ const sourceKey = CONTEXT_FIELD_SOURCES[field.toLowerCase()];
77
+ if (!sourceKey)
78
+ continue;
79
+ const value = ctx[sourceKey];
80
+ if (value !== undefined)
81
+ input[field] = value;
82
+ }
83
+ return input;
84
+ }
42
85
  const TOOL_MAP = {
43
86
  // Direct maps
44
87
  bash: {
@@ -233,7 +276,18 @@ export function buildCCRequest(clientBody, billingTag, cache1h, identity, opts =
233
276
  const name = (tool.name || '').toLowerCase();
234
277
  const mapping = TOOL_MAP[name];
235
278
  if (mapping) {
236
- activeToolMap.set(tool.name, mapping);
279
+ // In hybrid mode, clone the shared mapping and attach the
280
+ // client-declared top-level field names from input_schema.
281
+ // The reverse path uses these to inject request-context values
282
+ // into fields CC's schema doesn't carry.
283
+ if (opts.hybridTools) {
284
+ const schema = tool.input_schema;
285
+ const fields = schema?.properties ? Object.keys(schema.properties) : [];
286
+ activeToolMap.set(tool.name, { ...mapping, clientFields: fields });
287
+ }
288
+ else {
289
+ activeToolMap.set(tool.name, mapping);
290
+ }
237
291
  claimedCC.add(mapping.ccTool);
238
292
  }
239
293
  }
@@ -435,7 +489,7 @@ function buildReverseLookup(toolMap) {
435
489
  * leaving the input shape in CC's parameter names which the client's
436
490
  * own validator would reject.
437
491
  */
438
- function rewriteToolUseBlock(block, reverseMap) {
492
+ function rewriteToolUseBlock(block, reverseMap, ctx) {
439
493
  const ccName = block.name;
440
494
  if (typeof ccName !== 'string')
441
495
  return;
@@ -453,6 +507,13 @@ function rewriteToolUseBlock(block, reverseMap) {
453
507
  // the same broken input it would have seen pre-v3.7.0.
454
508
  }
455
509
  }
510
+ // Hybrid mode: inject request-context values into any client-declared
511
+ // fields still missing after translateBack. No-op unless the mapping
512
+ // was built with `clientFields` populated (hybridTools: true) and a
513
+ // context was passed in.
514
+ if (entry.mapping.clientFields && block.input && typeof block.input === 'object') {
515
+ injectContextFields(block.input, entry.mapping.clientFields, ctx);
516
+ }
456
517
  }
457
518
  /**
458
519
  * Reverse-map CC tool calls in a non-streaming response back to the
@@ -461,7 +522,7 @@ function rewriteToolUseBlock(block, reverseMap) {
461
522
  * body isn't valid JSON (e.g. an error response, a partial chunk),
462
523
  * returns it unchanged.
463
524
  */
464
- export function reverseMapResponse(responseBody, toolMap) {
525
+ export function reverseMapResponse(responseBody, toolMap, ctx) {
465
526
  if (toolMap.size === 0)
466
527
  return responseBody;
467
528
  const reverseMap = buildReverseLookup(toolMap);
@@ -477,12 +538,12 @@ export function reverseMapResponse(responseBody, toolMap) {
477
538
  return responseBody;
478
539
  for (const block of content) {
479
540
  if (block && typeof block === 'object' && block.type === 'tool_use') {
480
- rewriteToolUseBlock(block, reverseMap);
541
+ rewriteToolUseBlock(block, reverseMap, ctx);
481
542
  }
482
543
  }
483
544
  return JSON.stringify(parsed);
484
545
  }
485
- export function createStreamingReverseMapper(toolMap) {
546
+ export function createStreamingReverseMapper(toolMap, ctx) {
486
547
  const noop = {
487
548
  feed: (chunk) => chunk,
488
549
  end: () => new Uint8Array(0),
@@ -490,11 +551,13 @@ export function createStreamingReverseMapper(toolMap) {
490
551
  if (toolMap.size === 0)
491
552
  return noop;
492
553
  const reverseMap = buildReverseLookup(toolMap);
493
- // If no mapping needs translation, fall back to identity behavior
494
- // so we don't pay the SSE-parsing cost on every chunk.
554
+ // If no mapping needs translation OR context injection, fall back to
555
+ // identity behavior so we don't pay the SSE-parsing cost on every chunk.
556
+ // Hybrid mode with clientFields always needs the streaming path so the
557
+ // injection can run at content_block_stop.
495
558
  let anyNeedsTranslation = false;
496
559
  for (const { mapping } of reverseMap.values()) {
497
- if (mapping.translateBack) {
560
+ if (mapping.translateBack || (mapping.clientFields && mapping.clientFields.length > 0)) {
498
561
  anyNeedsTranslation = true;
499
562
  break;
500
563
  }
@@ -568,7 +631,9 @@ export function createStreamingReverseMapper(toolMap) {
568
631
  const block = event.content_block;
569
632
  if (block && block.type === 'tool_use' && typeof block.name === 'string') {
570
633
  const entry = reverseMap.get(block.name);
571
- if (entry && entry.mapping.translateBack && idx >= 0) {
634
+ const needsBuffering = entry && idx >= 0 && (entry.mapping.translateBack ||
635
+ (entry.mapping.clientFields && entry.mapping.clientFields.length > 0));
636
+ if (entry && needsBuffering) {
572
637
  // Stash the block so we can flush a translated version at
573
638
  // content_block_stop. Emit a rewritten start event now so
574
639
  // the client sees its own tool name immediately.
@@ -619,6 +684,9 @@ export function createStreamingReverseMapper(toolMap) {
619
684
  translatedInput = buf.mapping.translateBack
620
685
  ? buf.mapping.translateBack(parsedInput)
621
686
  : parsedInput;
687
+ if (buf.mapping.clientFields && buf.mapping.clientFields.length > 0) {
688
+ injectContextFields(translatedInput, buf.mapping.clientFields, ctx);
689
+ }
622
690
  }
623
691
  catch {
624
692
  parseOk = false;
package/dist/cli.js CHANGED
@@ -140,9 +140,14 @@ async function proxy() {
140
140
  const verbose = args.includes('--verbose') || args.includes('-v');
141
141
  const passthrough = args.includes('--passthrough') || args.includes('--thin');
142
142
  const preserveTools = args.includes('--preserve-tools') || args.includes('--keep-tools');
143
+ const hybridTools = args.includes('--hybrid-tools') || args.includes('--context-inject');
144
+ if (preserveTools && hybridTools) {
145
+ console.error('[dario] --preserve-tools and --hybrid-tools are mutually exclusive. Pick one.');
146
+ process.exit(1);
147
+ }
143
148
  const modelArg = args.find(a => a.startsWith('--model='));
144
149
  const model = modelArg ? modelArg.split('=')[1] : undefined;
145
- await startProxy({ port, host, verbose, model, passthrough, preserveTools });
150
+ await startProxy({ port, host, verbose, model, passthrough, preserveTools, hybridTools });
146
151
  }
147
152
  async function accounts() {
148
153
  const sub = args[1];
@@ -369,6 +374,10 @@ async function help() {
369
374
  Default: passthrough (client decides)
370
375
  --passthrough, --thin Thin proxy — OAuth swap only, no injection
371
376
  --preserve-tools Keep client tool schemas (for agents with custom tools)
377
+ --hybrid-tools Remap to CC tools AND inject request-context fields
378
+ (sessionId, requestId, channelId, userId, timestamp)
379
+ declared on the client's tool schema but missing
380
+ from CC's. Preserves CC fingerprint. See #33.
372
381
  --port=PORT Port to listen on (default: 3456)
373
382
  --host=ADDRESS Address to bind to (default: 127.0.0.1)
374
383
  Use 0.0.0.0 to accept connections from other machines.
package/dist/oauth.js CHANGED
@@ -52,8 +52,47 @@ function getClaudeCodeCredentialsPath() {
52
52
  * store instead of ~/.claude/.credentials.json:
53
53
  * - macOS: Keychain, service "Claude Code-credentials"
54
54
  * - Linux: libsecret / Secret Service D-Bus API via `secret-tool`
55
- * - Windows: Windows Credential Manager via `cmdkey` (not yet implemented)
55
+ * - Windows: Windows Credential Manager via PowerShell + Win32 CredEnumerate
56
56
  */
57
+ const WIN_CRED_SCRIPT = `
58
+ $ErrorActionPreference = 'Stop'
59
+ $sig = @"
60
+ using System;
61
+ using System.Runtime.InteropServices;
62
+ public class CM {
63
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
64
+ public struct CRED {
65
+ public uint Flags; public uint Type; public string TargetName;
66
+ public string Comment; public System.Runtime.InteropServices.ComTypes.FILETIME LW;
67
+ public uint BlobSize; public IntPtr Blob;
68
+ public uint Persist; public uint AC; public IntPtr Attrs;
69
+ public string Alias; public string UN;
70
+ }
71
+ [DllImport("advapi32.dll", EntryPoint="CredEnumerateW", CharSet=CharSet.Unicode, SetLastError=true)]
72
+ public static extern bool CredEnumerate(string filter, uint flag, out uint count, out IntPtr pCredentials);
73
+ [DllImport("advapi32.dll", EntryPoint="CredFree")]
74
+ public static extern void CredFree(IntPtr cred);
75
+ }
76
+ "@
77
+ Add-Type -TypeDefinition $sig
78
+ $count = 0
79
+ $ptr = [IntPtr]::Zero
80
+ if ([CM]::CredEnumerate('Claude Code-credentials*', 0, [ref]$count, [ref]$ptr)) {
81
+ try {
82
+ for ($i = 0; $i -lt $count; $i++) {
83
+ $credPtr = [System.Runtime.InteropServices.Marshal]::ReadIntPtr($ptr, $i * [IntPtr]::Size)
84
+ $cred = [System.Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [type][CM+CRED])
85
+ if ($cred.BlobSize -gt 0) {
86
+ $bytes = New-Object byte[] $cred.BlobSize
87
+ [System.Runtime.InteropServices.Marshal]::Copy($cred.Blob, $bytes, 0, $cred.BlobSize)
88
+ Write-Output ([System.Text.Encoding]::Unicode.GetString($bytes))
89
+ }
90
+ }
91
+ } finally {
92
+ [CM]::CredFree($ptr)
93
+ }
94
+ }
95
+ `;
57
96
  async function loadKeychainCredentials() {
58
97
  try {
59
98
  if (platform() === 'darwin') {
@@ -74,6 +113,35 @@ async function loadKeychainCredentials() {
74
113
  return parsed;
75
114
  }
76
115
  }
116
+ else if (platform() === 'win32') {
117
+ // Windows Credential Manager via PowerShell + Win32 CredEnumerate.
118
+ // Claude Code on Windows (via Node keytar) stores OAuth tokens as
119
+ // Generic credentials with target prefix "Claude Code-credentials".
120
+ // We enumerate matching credentials and return the first one that
121
+ // parses as a valid CC credentials blob. The password field is
122
+ // stored as UTF-16LE bytes (keytar convention on Windows).
123
+ //
124
+ // PowerShell CredEnumerate sets LastWin32Error=1168 (ERROR_NOT_FOUND)
125
+ // when the filter matches nothing — we catch the non-zero exit and
126
+ // return null so the caller falls back to the file-path checks.
127
+ const raw = await new Promise((resolve, reject) => {
128
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WIN_CRED_SCRIPT], { timeout: 5000, windowsHide: true }, (err, stdout) => (err ? reject(err) : resolve(stdout)));
129
+ });
130
+ // Script emits one JSON blob per matching credential, newline-separated.
131
+ // Return the first one that parses with the expected CC shape.
132
+ for (const line of raw.split(/\r?\n/)) {
133
+ const s = line.trim();
134
+ if (!s)
135
+ continue;
136
+ try {
137
+ const parsed = JSON.parse(s);
138
+ if (parsed?.claudeAiOauth?.accessToken && parsed?.claudeAiOauth?.refreshToken) {
139
+ return parsed;
140
+ }
141
+ }
142
+ catch { /* not a valid JSON credential blob — try next */ }
143
+ }
144
+ }
77
145
  }
78
146
  catch { /* keychain not available or no entry */ }
79
147
  return null;
package/dist/proxy.d.ts CHANGED
@@ -5,6 +5,7 @@ interface ProxyOptions {
5
5
  model?: string;
6
6
  passthrough?: boolean;
7
7
  preserveTools?: boolean;
8
+ hybridTools?: boolean;
8
9
  }
9
10
  export declare function sanitizeError(err: unknown): string;
10
11
  export declare function startProxy(opts?: ProxyOptions): Promise<void>;
package/dist/proxy.js CHANGED
@@ -626,6 +626,20 @@ export async function startProxy(opts = {}) {
626
626
  let finalBody = body.length > 0 ? body : undefined;
627
627
  let ccToolMap = null;
628
628
  let requestModel = '';
629
+ // Request context for hybrid-mode field injection (#33). Built once
630
+ // per request from incoming headers so the reverse mapper can fill
631
+ // client-declared fields like `sessionId` that CC's schema doesn't
632
+ // carry. Undefined when hybridTools is off — the reverse path then
633
+ // skips injection entirely.
634
+ const reqCtx = opts.hybridTools ? {
635
+ sessionId: req.headers['x-session-id']
636
+ ?? req.headers['x-client-session-id']
637
+ ?? SESSION_ID,
638
+ requestId: req.headers['x-request-id'] ?? randomUUID(),
639
+ channelId: req.headers['x-channel-id'],
640
+ userId: req.headers['x-user-id'],
641
+ timestamp: new Date().toISOString(),
642
+ } : undefined;
629
643
  if (body.length > 0) {
630
644
  try {
631
645
  const parsed = JSON.parse(body.toString());
@@ -649,7 +663,10 @@ export async function startProxy(opts = {}) {
649
663
  const bodyIdentity = poolAccount
650
664
  ? poolAccount.identity
651
665
  : { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: SESSION_ID };
652
- const { body: ccBody, toolMap } = buildCCRequest(r, billingTag, CACHE_1H, bodyIdentity, { preserveTools: opts.preserveTools ?? false });
666
+ const { body: ccBody, toolMap } = buildCCRequest(r, billingTag, CACHE_1H, bodyIdentity, {
667
+ preserveTools: opts.preserveTools ?? false,
668
+ hybridTools: opts.hybridTools ?? false,
669
+ });
653
670
  // Store tool map for response reverse-mapping
654
671
  ccToolMap = toolMap;
655
672
  // Replace request body entirely with CC template
@@ -944,7 +961,7 @@ export async function startProxy(opts = {}) {
944
961
  // path; the non-streaming reverseMapResponse covers buffered
945
962
  // responses below.
946
963
  const streamMapper = ccToolMap && !isOpenAI
947
- ? createStreamingReverseMapper(ccToolMap)
964
+ ? createStreamingReverseMapper(ccToolMap, reqCtx)
948
965
  : null;
949
966
  try {
950
967
  let buffer = '';
@@ -1039,7 +1056,7 @@ export async function startProxy(opts = {}) {
1039
1056
  let responseBody = await upstream.text();
1040
1057
  // Reverse tool name mapping so client sees original names
1041
1058
  if (ccToolMap)
1042
- responseBody = reverseMapResponse(responseBody, ccToolMap);
1059
+ responseBody = reverseMapResponse(responseBody, ccToolMap, reqCtx);
1043
1060
  if (isOpenAI && upstream.status >= 200 && upstream.status < 300) {
1044
1061
  try {
1045
1062
  const parsed = JSON.parse(responseBody);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.8.1",
3
+ "version": "3.9.1",
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": {
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "build": "tsc && cp src/cc-template-data.json dist/",
24
- "test": "node test/issue-29-tool-translation.mjs && node test/analytics-recording.mjs && node test/failover-429.mjs",
24
+ "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/analytics-recording.mjs && node test/failover-429.mjs",
25
25
  "audit": "npm audit --production --audit-level=high",
26
26
  "prepublishOnly": "npm run build",
27
27
  "start": "node dist/cli.js",