@askalf/dario 3.6.1 → 3.7.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.
@@ -16,8 +16,24 @@ export declare const CC_SYSTEM_PROMPT: string;
16
16
  /** CC's agent identity string. */
17
17
  export declare const CC_AGENT_IDENTITY: string;
18
18
  export declare function scrubFrameworkIdentifiers(text: string): string;
19
- /** Client tool name → CC tool mapping with parameter translation. */
20
- interface ToolMapping {
19
+ /**
20
+ * Client tool name → CC tool mapping with parameter translation.
21
+ *
22
+ * `translateArgs` runs forward (client → CC) when building the upstream
23
+ * request. `translateBack` runs reverse (CC → client) when rewriting
24
+ * the upstream response so the client receives tool_use input in the
25
+ * shape its own validator expects. The forward direction is lossy
26
+ * (multiple client field names may collapse to one CC field), so the
27
+ * reverse picks the *primary* client field name — the first one in
28
+ * the forward function's `||` chain. That's the field the client's
29
+ * own schema defines, which is the one its validator will accept.
30
+ *
31
+ * Issue #29 (boeingchoco) is the bug this layer fixes: prior to v3.7.0,
32
+ * dario rewrote the tool name on response (Bash → process) but left
33
+ * the input shape alone, so the client saw `{command: ...}` against a
34
+ * schema that wanted `{action: ...}` and rejected the call.
35
+ */
36
+ export interface ToolMapping {
21
37
  ccTool: string;
22
38
  translateArgs?: (args: Record<string, unknown>) => Record<string, unknown>;
23
39
  translateBack?: (args: Record<string, unknown>) => Record<string, unknown>;
@@ -42,7 +58,57 @@ export declare function buildCCRequest(clientBody: Record<string, unknown>, bill
42
58
  unmappedTools: string[];
43
59
  };
44
60
  /**
45
- * Reverse-map CC tool calls in a response back to client tool names.
61
+ * Reverse-map CC tool calls in a non-streaming response back to the
62
+ * client's original tool names AND parameter shapes. Walks the parsed
63
+ * JSON `content` array and rewrites every `tool_use` block. If the
64
+ * body isn't valid JSON (e.g. an error response, a partial chunk),
65
+ * returns it unchanged.
46
66
  */
47
67
  export declare function reverseMapResponse(responseBody: string, toolMap: Map<string, ToolMapping>): string;
48
- export {};
68
+ /**
69
+ * Streaming reverse-mapper for SSE responses.
70
+ *
71
+ * The non-streaming reverse-map can rewrite tool_use input in one pass
72
+ * because it sees the whole `input` object. SSE streaming arrives in
73
+ * three phases per tool_use block:
74
+ *
75
+ * content_block_start → carries `tool_use.name` and `tool_use.input: {}`
76
+ * content_block_delta → carries `input_json_delta.partial_json` chunks
77
+ * that, concatenated, form the full input JSON
78
+ * content_block_stop → end of the block
79
+ *
80
+ * To rewrite the parameter shape we need the FULL input, which only
81
+ * exists at content_block_stop. So for tool_use blocks that need
82
+ * translation, we:
83
+ *
84
+ * 1. Forward content_block_start with the rewritten name (so clients
85
+ * see their own tool name immediately and can start tracking it)
86
+ * 2. Swallow content_block_delta events for that block, accumulating
87
+ * partial_json into a per-block buffer
88
+ * 3. On content_block_stop, parse the accumulated input, apply
89
+ * translateBack, and emit ONE synthetic content_block_delta with
90
+ * the full translated input as a single partial_json string,
91
+ * followed by the original content_block_stop event
92
+ *
93
+ * Trade-off: clients that consume tool_use input as it streams (rare
94
+ * but possible) will see the input arrive as a single chunk at the
95
+ * end of the block instead of streaming character-by-character. For
96
+ * tool_use that's acceptable — input is usually small (<1KB) and the
97
+ * alternative is parameter-shape mismatch causing validation errors.
98
+ *
99
+ * For tool_use blocks that DON'T have a translateBack mapping (or
100
+ * aren't in the reverseMap at all), the streaming mapper passes the
101
+ * original SSE bytes through unchanged.
102
+ *
103
+ * Usage:
104
+ *
105
+ * const mapper = createStreamingReverseMapper(toolMap);
106
+ * for await (const chunk of upstream) res.write(mapper.feed(chunk));
107
+ * const tail = mapper.end();
108
+ * if (tail.length) res.write(tail);
109
+ */
110
+ export interface StreamingReverseMapper {
111
+ feed(chunk: Uint8Array): Uint8Array;
112
+ end(): Uint8Array;
113
+ }
114
+ export declare function createStreamingReverseMapper(toolMap: Map<string, ToolMapping>): StreamingReverseMapper;
@@ -41,42 +41,154 @@ export function scrubFrameworkIdentifiers(text) {
41
41
  }
42
42
  const TOOL_MAP = {
43
43
  // Direct maps
44
- bash: { ccTool: 'Bash', translateArgs: (a) => ({ command: a.cmd || a.command || a.c || '' }) },
45
- exec: { ccTool: 'Bash', translateArgs: (a) => ({ command: a.cmd || a.command || a.c || '' }) },
46
- shell: { ccTool: 'Bash', translateArgs: (a) => ({ command: a.cmd || a.command || a.c || '' }) },
47
- run: { ccTool: 'Bash', translateArgs: (a) => ({ command: a.cmd || a.command || '' }) },
48
- command: { ccTool: 'Bash', translateArgs: (a) => ({ command: a.cmd || a.command || '' }) },
49
- terminal: { ccTool: 'Bash', translateArgs: (a) => ({ command: a.cmd || a.command || '' }) },
50
- process: { ccTool: 'Bash', translateArgs: (a) => ({ command: a.action || a.cmd || '' }) },
51
- read: { ccTool: 'Read', translateArgs: (a) => ({ file_path: a.path || a.file_path || '' }) },
52
- read_file: { ccTool: 'Read', translateArgs: (a) => ({ file_path: a.path || a.file_path || '' }) },
53
- write: { ccTool: 'Write', translateArgs: (a) => ({ file_path: a.path || a.file_path || '', content: a.content || '' }) },
54
- write_file: { ccTool: 'Write', translateArgs: (a) => ({ file_path: a.path || a.file_path || '', content: a.content || '' }) },
55
- edit: { ccTool: 'Edit', translateArgs: (a) => ({ file_path: a.path || a.file_path || '', old_string: a.old || a.old_string || '', new_string: a.new || a.new_string || '' }) },
44
+ bash: {
45
+ ccTool: 'Bash',
46
+ translateArgs: (a) => ({ command: a.cmd || a.command || a.c || '' }),
47
+ translateBack: (a) => ({ cmd: a.command ?? '' }),
48
+ },
49
+ exec: {
50
+ ccTool: 'Bash',
51
+ translateArgs: (a) => ({ command: a.cmd || a.command || a.c || '' }),
52
+ translateBack: (a) => ({ cmd: a.command ?? '' }),
53
+ },
54
+ shell: {
55
+ ccTool: 'Bash',
56
+ translateArgs: (a) => ({ command: a.cmd || a.command || a.c || '' }),
57
+ translateBack: (a) => ({ cmd: a.command ?? '' }),
58
+ },
59
+ run: {
60
+ ccTool: 'Bash',
61
+ translateArgs: (a) => ({ command: a.cmd || a.command || '' }),
62
+ translateBack: (a) => ({ cmd: a.command ?? '' }),
63
+ },
64
+ command: {
65
+ ccTool: 'Bash',
66
+ translateArgs: (a) => ({ command: a.cmd || a.command || '' }),
67
+ translateBack: (a) => ({ cmd: a.command ?? '' }),
68
+ },
69
+ terminal: {
70
+ ccTool: 'Bash',
71
+ translateArgs: (a) => ({ command: a.cmd || a.command || '' }),
72
+ translateBack: (a) => ({ cmd: a.command ?? '' }),
73
+ },
74
+ process: {
75
+ ccTool: 'Bash',
76
+ translateArgs: (a) => ({ command: a.action || a.cmd || '' }),
77
+ translateBack: (a) => ({ action: a.command ?? '' }),
78
+ },
79
+ read: {
80
+ ccTool: 'Read',
81
+ translateArgs: (a) => ({ file_path: a.path || a.file_path || '' }),
82
+ translateBack: (a) => ({ path: a.file_path ?? '' }),
83
+ },
84
+ read_file: {
85
+ ccTool: 'Read',
86
+ translateArgs: (a) => ({ file_path: a.path || a.file_path || '' }),
87
+ translateBack: (a) => ({ path: a.file_path ?? '' }),
88
+ },
89
+ write: {
90
+ ccTool: 'Write',
91
+ translateArgs: (a) => ({ file_path: a.path || a.file_path || '', content: a.content || '' }),
92
+ translateBack: (a) => ({ path: a.file_path ?? '', content: a.content ?? '' }),
93
+ },
94
+ write_file: {
95
+ ccTool: 'Write',
96
+ translateArgs: (a) => ({ file_path: a.path || a.file_path || '', content: a.content || '' }),
97
+ translateBack: (a) => ({ path: a.file_path ?? '', content: a.content ?? '' }),
98
+ },
99
+ edit: {
100
+ ccTool: 'Edit',
101
+ translateArgs: (a) => ({ file_path: a.path || a.file_path || '', old_string: a.old || a.old_string || '', new_string: a.new || a.new_string || '' }),
102
+ translateBack: (a) => ({ path: a.file_path ?? '', old: a.old_string ?? '', new: a.new_string ?? '' }),
103
+ },
56
104
  edit_file: { ccTool: 'Edit' },
57
105
  glob: { ccTool: 'Glob' },
58
- find_files: { ccTool: 'Glob', translateArgs: (a) => ({ pattern: a.pattern || a.query || '' }) },
59
- list_files: { ccTool: 'Glob', translateArgs: (a) => ({ pattern: a.pattern || '*' }) },
106
+ find_files: {
107
+ ccTool: 'Glob',
108
+ translateArgs: (a) => ({ pattern: a.pattern || a.query || '' }),
109
+ translateBack: (a) => ({ pattern: a.pattern ?? '' }),
110
+ },
111
+ list_files: {
112
+ ccTool: 'Glob',
113
+ translateArgs: (a) => ({ pattern: a.pattern || '*' }),
114
+ translateBack: (a) => ({ pattern: a.pattern ?? '' }),
115
+ },
60
116
  grep: { ccTool: 'Grep' },
61
- search: { ccTool: 'Grep', translateArgs: (a) => ({ pattern: a.query || a.pattern || '' }) },
62
- search_files: { ccTool: 'Grep', translateArgs: (a) => ({ pattern: a.query || a.pattern || '' }) },
63
- web_search: { ccTool: 'WebSearch', translateArgs: (a) => ({ query: a.query || a.q || '' }) },
64
- websearch: { ccTool: 'WebSearch', translateArgs: (a) => ({ query: a.query || a.q || '' }) },
65
- web_fetch: { ccTool: 'WebFetch', translateArgs: (a) => ({ url: a.url || a.u || '' }) },
66
- webfetch: { ccTool: 'WebFetch', translateArgs: (a) => ({ url: a.url || a.u || '' }) },
67
- fetch: { ccTool: 'WebFetch', translateArgs: (a) => ({ url: a.url || '' }) },
68
- browse: { ccTool: 'WebFetch', translateArgs: (a) => ({ url: a.url || '' }) },
117
+ search: {
118
+ ccTool: 'Grep',
119
+ translateArgs: (a) => ({ pattern: a.query || a.pattern || '' }),
120
+ translateBack: (a) => ({ query: a.pattern ?? '' }),
121
+ },
122
+ search_files: {
123
+ ccTool: 'Grep',
124
+ translateArgs: (a) => ({ pattern: a.query || a.pattern || '' }),
125
+ translateBack: (a) => ({ query: a.pattern ?? '' }),
126
+ },
127
+ web_search: {
128
+ ccTool: 'WebSearch',
129
+ translateArgs: (a) => ({ query: a.query || a.q || '' }),
130
+ translateBack: (a) => ({ query: a.query ?? '' }),
131
+ },
132
+ websearch: {
133
+ ccTool: 'WebSearch',
134
+ translateArgs: (a) => ({ query: a.query || a.q || '' }),
135
+ translateBack: (a) => ({ query: a.query ?? '' }),
136
+ },
137
+ web_fetch: {
138
+ ccTool: 'WebFetch',
139
+ translateArgs: (a) => ({ url: a.url || a.u || '' }),
140
+ translateBack: (a) => ({ url: a.url ?? '' }),
141
+ },
142
+ webfetch: {
143
+ ccTool: 'WebFetch',
144
+ translateArgs: (a) => ({ url: a.url || a.u || '' }),
145
+ translateBack: (a) => ({ url: a.url ?? '' }),
146
+ },
147
+ fetch: {
148
+ ccTool: 'WebFetch',
149
+ translateArgs: (a) => ({ url: a.url || '' }),
150
+ translateBack: (a) => ({ url: a.url ?? '' }),
151
+ },
152
+ browse: {
153
+ ccTool: 'WebFetch',
154
+ translateArgs: (a) => ({ url: a.url || '' }),
155
+ translateBack: (a) => ({ url: a.url ?? '' }),
156
+ },
69
157
  notebook: { ccTool: 'NotebookEdit' },
70
158
  notebook_edit: { ccTool: 'NotebookEdit' },
71
159
  // Additional client tool mappings
72
- browser: { ccTool: 'WebFetch', translateArgs: (a) => ({ url: String(a.url || '') }) },
73
- message: { ccTool: 'AskUserQuestion', translateArgs: (a) => ({ question: String(a.message || a.content || '') }) },
74
- todo_read: { ccTool: 'TodoWrite', translateArgs: () => ({ todos: [] }) },
75
- todo_write: { ccTool: 'TodoWrite', translateArgs: (a) => ({ todos: a.todos || [] }) },
76
- notebook_read: { ccTool: 'NotebookEdit', translateArgs: (a) => ({ notebook_path: String(a.notebook_path || a.path || '') }) },
160
+ browser: {
161
+ ccTool: 'WebFetch',
162
+ translateArgs: (a) => ({ url: String(a.url || '') }),
163
+ translateBack: (a) => ({ url: a.url ?? '' }),
164
+ },
165
+ message: {
166
+ ccTool: 'AskUserQuestion',
167
+ translateArgs: (a) => ({ question: String(a.message || a.content || '') }),
168
+ translateBack: (a) => ({ message: a.question ?? '' }),
169
+ },
170
+ todo_read: {
171
+ ccTool: 'TodoWrite',
172
+ translateArgs: () => ({ todos: [] }),
173
+ translateBack: () => ({}),
174
+ },
175
+ todo_write: {
176
+ ccTool: 'TodoWrite',
177
+ translateArgs: (a) => ({ todos: a.todos || [] }),
178
+ translateBack: (a) => ({ todos: a.todos ?? [] }),
179
+ },
180
+ notebook_read: {
181
+ ccTool: 'NotebookEdit',
182
+ translateArgs: (a) => ({ notebook_path: String(a.notebook_path || a.path || '') }),
183
+ translateBack: (a) => ({ notebook_path: a.notebook_path ?? '' }),
184
+ },
77
185
  enter_plan_mode: { ccTool: 'EnterPlanMode' },
78
186
  exit_plan_mode: { ccTool: 'ExitPlanMode' },
79
- enter_worktree: { ccTool: 'EnterWorktree', translateArgs: (a) => ({ path: a.path }) },
187
+ enter_worktree: {
188
+ ccTool: 'EnterWorktree',
189
+ translateArgs: (a) => ({ path: a.path }),
190
+ translateBack: (a) => ({ path: a.path ?? '' }),
191
+ },
80
192
  exit_worktree: { ccTool: 'ExitWorktree' },
81
193
  };
82
194
  /**
@@ -287,23 +399,20 @@ export function buildCCRequest(clientBody, billingTag, cache1h, identity, opts =
287
399
  return { body: ccRequest, toolMap: activeToolMap, unmappedTools };
288
400
  }
289
401
  /**
290
- * Reverse-map CC tool calls in a response back to client tool names.
402
+ * Build the CC-name {clientName, mapping} reverse lookup used by both
403
+ * the non-streaming and streaming reverse-mappers. Two-pass construction
404
+ * preserves the original identity-protection rule: when a client sent a
405
+ * tool with the literal CC name (e.g. `WebSearch`), that pairing claims
406
+ * the CC slot first so a later unmapped-tool fallback that also lands
407
+ * on `WebSearch` can't overwrite it.
291
408
  */
292
- export function reverseMapResponse(responseBody, toolMap) {
293
- if (toolMap.size === 0)
294
- return responseBody;
295
- let result = responseBody;
296
- // Build reverse map: CC tool name → original client tool name.
297
- // Two passes so identity mappings (client sent a tool with the real CC
298
- // name) claim their CC slot first and can never be overwritten by a
299
- // non-identity entry. Without this, a collision between a direct
300
- // `WebSearch` and an unmapped-tool fallback landing on `WebSearch` could
301
- // rewrite the real search response to the wrong client name.
409
+ function buildReverseLookup(toolMap) {
302
410
  const reverseMap = new Map();
303
411
  const identityClaimed = new Set();
304
412
  for (const [clientName, mapping] of toolMap) {
305
413
  if (clientName.toLowerCase() === mapping.ccTool.toLowerCase()) {
306
414
  identityClaimed.add(mapping.ccTool);
415
+ reverseMap.set(mapping.ccTool, { clientName, mapping });
307
416
  }
308
417
  }
309
418
  for (const [clientName, mapping] of toolMap) {
@@ -311,10 +420,221 @@ export function reverseMapResponse(responseBody, toolMap) {
311
420
  continue;
312
421
  if (identityClaimed.has(mapping.ccTool))
313
422
  continue;
314
- reverseMap.set(mapping.ccTool, clientName);
423
+ reverseMap.set(mapping.ccTool, { clientName, mapping });
315
424
  }
316
- for (const [ccName, clientName] of reverseMap) {
317
- result = result.replace(new RegExp(`"name"\\s*:\\s*"${ccName}"`, 'g'), `"name":"${clientName}"`);
425
+ return reverseMap;
426
+ }
427
+ /**
428
+ * Apply the reverse mapping to a single tool_use block in place.
429
+ * Mutates `block.name` (CC name → client name) and `block.input`
430
+ * (CC parameter shape → client parameter shape) when the mapping
431
+ * has a `translateBack`. Identity mappings and mappings with no
432
+ * `translateBack` defined leave the input unchanged.
433
+ *
434
+ * Issue #29 fix lives here: previously only the name was rewritten,
435
+ * leaving the input shape in CC's parameter names which the client's
436
+ * own validator would reject.
437
+ */
438
+ function rewriteToolUseBlock(block, reverseMap) {
439
+ const ccName = block.name;
440
+ if (typeof ccName !== 'string')
441
+ return;
442
+ const entry = reverseMap.get(ccName);
443
+ if (!entry)
444
+ return;
445
+ block.name = entry.clientName;
446
+ if (entry.mapping.translateBack && block.input && typeof block.input === 'object') {
447
+ try {
448
+ block.input = entry.mapping.translateBack(block.input);
449
+ }
450
+ catch {
451
+ // If the translateBack throws on unexpected shape, leave input
452
+ // alone rather than crashing the response. The client will see
453
+ // the same broken input it would have seen pre-v3.7.0.
454
+ }
318
455
  }
319
- return result;
456
+ }
457
+ /**
458
+ * Reverse-map CC tool calls in a non-streaming response back to the
459
+ * client's original tool names AND parameter shapes. Walks the parsed
460
+ * JSON `content` array and rewrites every `tool_use` block. If the
461
+ * body isn't valid JSON (e.g. an error response, a partial chunk),
462
+ * returns it unchanged.
463
+ */
464
+ export function reverseMapResponse(responseBody, toolMap) {
465
+ if (toolMap.size === 0)
466
+ return responseBody;
467
+ const reverseMap = buildReverseLookup(toolMap);
468
+ let parsed;
469
+ try {
470
+ parsed = JSON.parse(responseBody);
471
+ }
472
+ catch {
473
+ return responseBody;
474
+ }
475
+ const content = parsed.content;
476
+ if (!Array.isArray(content))
477
+ return responseBody;
478
+ for (const block of content) {
479
+ if (block && typeof block === 'object' && block.type === 'tool_use') {
480
+ rewriteToolUseBlock(block, reverseMap);
481
+ }
482
+ }
483
+ return JSON.stringify(parsed);
484
+ }
485
+ export function createStreamingReverseMapper(toolMap) {
486
+ const noop = {
487
+ feed: (chunk) => chunk,
488
+ end: () => new Uint8Array(0),
489
+ };
490
+ if (toolMap.size === 0)
491
+ return noop;
492
+ 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.
495
+ let anyNeedsTranslation = false;
496
+ for (const { mapping } of reverseMap.values()) {
497
+ if (mapping.translateBack) {
498
+ anyNeedsTranslation = true;
499
+ break;
500
+ }
501
+ }
502
+ if (!anyNeedsTranslation)
503
+ return noop;
504
+ const decoder = new TextDecoder();
505
+ const encoder = new TextEncoder();
506
+ let lineBuffer = '';
507
+ // index → BufferedToolBlock for content blocks currently being held
508
+ // for end-of-block translation.
509
+ const buffered = new Map();
510
+ function processSseLine(line) {
511
+ // Pass through empty lines and event: prefix lines unchanged.
512
+ if (!line.startsWith('data:'))
513
+ return line;
514
+ const jsonText = line.slice(5).trim();
515
+ if (jsonText === '[DONE]' || jsonText === '')
516
+ return line;
517
+ let event;
518
+ try {
519
+ event = JSON.parse(jsonText);
520
+ }
521
+ catch {
522
+ return line;
523
+ }
524
+ const type = event.type;
525
+ if (type === 'content_block_start') {
526
+ const idx = typeof event.index === 'number' ? event.index : -1;
527
+ const block = event.content_block;
528
+ if (block && block.type === 'tool_use' && typeof block.name === 'string') {
529
+ const entry = reverseMap.get(block.name);
530
+ if (entry && entry.mapping.translateBack && idx >= 0) {
531
+ // Stash the block so we can flush a translated version at
532
+ // content_block_stop. Emit a rewritten start event NOW so
533
+ // the client sees its own tool name immediately and can
534
+ // associate subsequent events with the right call.
535
+ buffered.set(idx, {
536
+ ccName: block.name,
537
+ mapping: entry.mapping,
538
+ clientName: entry.clientName,
539
+ partial: '',
540
+ startEventLines: [],
541
+ });
542
+ block.name = entry.clientName;
543
+ // Reset input to empty so the client doesn't see CC's empty
544
+ // placeholder before we emit the translated full input.
545
+ block.input = {};
546
+ return `data: ${JSON.stringify(event)}`;
547
+ }
548
+ // Tool we don't translate — just rewrite the name in place
549
+ // (matches the old non-streaming-rewrite behavior for these).
550
+ if (entry) {
551
+ block.name = entry.clientName;
552
+ return `data: ${JSON.stringify(event)}`;
553
+ }
554
+ }
555
+ return line;
556
+ }
557
+ if (type === 'content_block_delta') {
558
+ const idx = typeof event.index === 'number' ? event.index : -1;
559
+ const buf = idx >= 0 ? buffered.get(idx) : undefined;
560
+ if (!buf)
561
+ return line;
562
+ const delta = event.delta;
563
+ if (delta && delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
564
+ buf.partial += delta.partial_json;
565
+ // Swallow this delta — we'll emit a synthetic combined one at stop.
566
+ return null;
567
+ }
568
+ // Some other delta type for a tool_use block (shouldn't happen,
569
+ // but pass through if it does).
570
+ return line;
571
+ }
572
+ if (type === 'content_block_stop') {
573
+ const idx = typeof event.index === 'number' ? event.index : -1;
574
+ const buf = idx >= 0 ? buffered.get(idx) : undefined;
575
+ if (!buf)
576
+ return line;
577
+ // Parse the accumulated input JSON, apply translateBack, and
578
+ // emit a single synthetic delta carrying the full translated
579
+ // input followed by the original stop event.
580
+ let translatedInput = {};
581
+ try {
582
+ const parsedInput = JSON.parse(buf.partial || '{}');
583
+ translatedInput = buf.mapping.translateBack
584
+ ? buf.mapping.translateBack(parsedInput)
585
+ : parsedInput;
586
+ }
587
+ catch {
588
+ // If we couldn't assemble valid JSON from the deltas, fall
589
+ // back to passing the original partial through unchanged so
590
+ // the client at least sees what Anthropic sent.
591
+ buffered.delete(idx);
592
+ const passthroughDelta = {
593
+ type: 'content_block_delta',
594
+ index: idx,
595
+ delta: { type: 'input_json_delta', partial_json: buf.partial },
596
+ };
597
+ return `data: ${JSON.stringify(passthroughDelta)}\ndata: ${JSON.stringify(event)}`;
598
+ }
599
+ buffered.delete(idx);
600
+ const synthDelta = {
601
+ type: 'content_block_delta',
602
+ index: idx,
603
+ delta: { type: 'input_json_delta', partial_json: JSON.stringify(translatedInput) },
604
+ };
605
+ return `data: ${JSON.stringify(synthDelta)}\ndata: ${JSON.stringify(event)}`;
606
+ }
607
+ return line;
608
+ }
609
+ function processBuffer(flush) {
610
+ // Split on newlines; keep the trailing partial line in the buffer
611
+ // unless we're flushing at end-of-stream.
612
+ const lines = lineBuffer.split('\n');
613
+ if (!flush) {
614
+ lineBuffer = lines.pop() ?? '';
615
+ }
616
+ else {
617
+ lineBuffer = '';
618
+ }
619
+ const out = [];
620
+ for (const line of lines) {
621
+ const processed = processSseLine(line);
622
+ if (processed !== null)
623
+ out.push(processed);
624
+ }
625
+ return out.length > 0 ? out.join('\n') + '\n' : '';
626
+ }
627
+ return {
628
+ feed(chunk) {
629
+ lineBuffer += decoder.decode(chunk, { stream: true });
630
+ const out = processBuffer(false);
631
+ return out.length > 0 ? encoder.encode(out) : new Uint8Array(0);
632
+ },
633
+ end() {
634
+ // Flush any decoder state and remaining buffer.
635
+ lineBuffer += decoder.decode();
636
+ const out = processBuffer(true);
637
+ return out.length > 0 ? encoder.encode(out) : new Uint8Array(0);
638
+ },
639
+ };
320
640
  }
package/dist/oauth.js CHANGED
@@ -6,8 +6,9 @@
6
6
  */
7
7
  import { randomBytes, createHash } from 'node:crypto';
8
8
  import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
9
+ import { execFile } from 'node:child_process';
9
10
  import { dirname, join } from 'node:path';
10
- import { homedir } from 'node:os';
11
+ import { homedir, platform } from 'node:os';
11
12
  import { detectCCOAuthConfig } from './cc-oauth-detect.js';
12
13
  // OAuth config is auto-detected at runtime from the installed Claude Code
13
14
  // binary. This eliminates the "Anthropic rotated the client_id again" class
@@ -44,12 +45,45 @@ function getDarioCredentialsPath() {
44
45
  function getClaudeCodeCredentialsPath() {
45
46
  return join(homedir(), '.claude', '.credentials.json');
46
47
  }
48
+ /**
49
+ * Read Claude Code credentials from the OS keychain.
50
+ *
51
+ * Modern CC versions (since ~1.0.17) store OAuth tokens in the OS credential
52
+ * store instead of ~/.claude/.credentials.json:
53
+ * - macOS: Keychain, service "Claude Code-credentials"
54
+ * - Linux: libsecret / Secret Service D-Bus API via `secret-tool`
55
+ * - Windows: Windows Credential Manager via `cmdkey` (not yet implemented)
56
+ */
57
+ async function loadKeychainCredentials() {
58
+ try {
59
+ if (platform() === 'darwin') {
60
+ const raw = await new Promise((resolve, reject) => {
61
+ execFile('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], { timeout: 5000 }, (err, stdout) => (err ? reject(err) : resolve(stdout.trim())));
62
+ });
63
+ const parsed = JSON.parse(raw);
64
+ if (parsed?.claudeAiOauth?.accessToken && parsed?.claudeAiOauth?.refreshToken) {
65
+ return parsed;
66
+ }
67
+ }
68
+ else if (platform() === 'linux') {
69
+ const raw = await new Promise((resolve, reject) => {
70
+ execFile('secret-tool', ['lookup', 'service', 'Claude Code-credentials'], { timeout: 5000 }, (err, stdout) => (err ? reject(err) : resolve(stdout.trim())));
71
+ });
72
+ const parsed = JSON.parse(raw);
73
+ if (parsed?.claudeAiOauth?.accessToken && parsed?.claudeAiOauth?.refreshToken) {
74
+ return parsed;
75
+ }
76
+ }
77
+ }
78
+ catch { /* keychain not available or no entry */ }
79
+ return null;
80
+ }
47
81
  export async function loadCredentials() {
48
82
  // Return cached if fresh
49
83
  if (credentialsCache && Date.now() - credentialsCacheTime < CACHE_TTL_MS) {
50
84
  return credentialsCache;
51
85
  }
52
- // Try dario's own credentials first, then fall back to Claude Code's
86
+ // Try dario's own credentials first, then fall back to Claude Code's file
53
87
  for (const path of [getDarioCredentialsPath(), getClaudeCodeCredentialsPath()]) {
54
88
  try {
55
89
  const raw = await readFile(path, 'utf-8');
@@ -62,6 +96,13 @@ export async function loadCredentials() {
62
96
  }
63
97
  catch { /* try next */ }
64
98
  }
99
+ // Fall back to OS keychain (modern CC stores credentials here, not on disk)
100
+ const keychainCreds = await loadKeychainCredentials();
101
+ if (keychainCreds) {
102
+ credentialsCache = keychainCreds;
103
+ credentialsCacheTime = Date.now();
104
+ return credentialsCache;
105
+ }
65
106
  return null;
66
107
  }
67
108
  async function saveCredentials(creds) {
package/dist/proxy.js CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
7
  import { arch, platform } from 'node:process';
8
8
  import { getAccessToken, getStatus } from './oauth.js';
9
- import { buildCCRequest, reverseMapResponse } from './cc-template.js';
9
+ import { buildCCRequest, reverseMapResponse, createStreamingReverseMapper } from './cc-template.js';
10
10
  import { AccountPool, parseRateLimits } from './pool.js';
11
11
  import { Analytics } from './analytics.js';
12
12
  import { loadAllAccounts, loadAccount, refreshAccountToken } from './accounts.js';
@@ -872,6 +872,15 @@ export async function startProxy(opts = {}) {
872
872
  // Stream SSE chunks through
873
873
  const reader = upstream.body.getReader();
874
874
  const decoder = new TextDecoder();
875
+ // Stateful streaming reverse-mapper for tool_use blocks. Buffers
876
+ // input_json_delta chunks per content block and emits a single
877
+ // synthetic delta with the translated parameter shape on
878
+ // content_block_stop. Issue #29 fix lives here for the streaming
879
+ // path; the non-streaming reverseMapResponse covers buffered
880
+ // responses below.
881
+ const streamMapper = ccToolMap && !isOpenAI
882
+ ? createStreamingReverseMapper(ccToolMap)
883
+ : null;
875
884
  try {
876
885
  let buffer = '';
877
886
  const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
@@ -894,15 +903,13 @@ export async function startProxy(opts = {}) {
894
903
  res.write(translated);
895
904
  }
896
905
  }
906
+ else if (streamMapper) {
907
+ const out = streamMapper.feed(value);
908
+ if (out.length > 0)
909
+ res.write(out);
910
+ }
897
911
  else {
898
- // Reverse tool names in streaming chunks
899
- if (ccToolMap && ccToolMap.size > 0) {
900
- const text = new TextDecoder().decode(value);
901
- res.write(reverseMapResponse(text, ccToolMap));
902
- }
903
- else {
904
- res.write(value);
905
- }
912
+ res.write(value);
906
913
  }
907
914
  }
908
915
  // Flush remaining buffer
@@ -911,6 +918,11 @@ export async function startProxy(opts = {}) {
911
918
  if (translated)
912
919
  res.write(translated);
913
920
  }
921
+ if (streamMapper) {
922
+ const tail = streamMapper.end();
923
+ if (tail.length > 0)
924
+ res.write(tail);
925
+ }
914
926
  }
915
927
  catch (err) {
916
928
  if (verbose)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.6.1",
3
+ "version": "3.7.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": {
@@ -21,6 +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",
24
25
  "audit": "npm audit --production --audit-level=high",
25
26
  "prepublishOnly": "npm run build",
26
27
  "start": "node dist/cli.js",