@askalf/dario 3.6.1 → 3.7.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 +32 -0
- package/dist/cc-template.d.ts +70 -4
- package/dist/cc-template.js +417 -43
- package/dist/oauth.js +43 -2
- package/dist/proxy.js +21 -9
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -438,6 +438,38 @@ Dario auto-detects OAuth config from the installed Claude Code binary. When CC s
|
|
|
438
438
|
**I'm hitting rate limits on the Claude backend. What do I do?**
|
|
439
439
|
Claude subscriptions have rolling 5-hour and 7-day usage windows. Check utilization with Claude Code's `/usage` command or the [statusline](https://code.claude.com/docs/en/statusline). For multi-agent workloads, add more accounts and let pool mode distribute the load: `dario accounts add <alias>`.
|
|
440
440
|
|
|
441
|
+
**I'm seeing `representative-claim: seven_day` in my rate-limit headers instead of `five_hour`. Am I being downgraded to API billing?**
|
|
442
|
+
|
|
443
|
+
**No.** You're still on subscription billing. Both `five_hour` and `seven_day` are the same subscription billing mode — they're just two different accounting buckets inside it.
|
|
444
|
+
|
|
445
|
+
Here's the full picture. Every Claude Max and Pro subscription has **two rolling usage windows**:
|
|
446
|
+
|
|
447
|
+
- **5-hour window** — your short-term usage bucket. Refreshes on a rolling 5-hour schedule. It's the one you'll see most of the time if you use Claude casually.
|
|
448
|
+
- **7-day window** — your longer-term usage bucket. Refreshes on a rolling 7-day schedule. It's intentionally larger than the 5-hour one so you can keep working past brief bursts of heavy usage.
|
|
449
|
+
|
|
450
|
+
When Anthropic bills a request, it decides which bucket to charge it against based on your current utilization. That decision comes back to you in the `anthropic-ratelimit-unified-representative-claim` response header:
|
|
451
|
+
|
|
452
|
+
| Claim | What it means |
|
|
453
|
+
|---|---|
|
|
454
|
+
| `five_hour` | You're well inside your 5-hour window; billing against the short-term bucket. |
|
|
455
|
+
| `seven_day` | You've exhausted (or come close to exhausting) the 5-hour window for this rolling cycle, so Anthropic is now charging this request against the 7-day bucket. **Still subscription billing. Still your plan.** Not API pricing, not overage. |
|
|
456
|
+
| `overage` | Both subscription windows are effectively exhausted. *This* is where per-token Extra Usage charges kick in — if you've enabled Extra Usage on the account. If you haven't, you get 429'd instead. |
|
|
457
|
+
|
|
458
|
+
**Seeing `seven_day` is a healthy state.** It means your Max/Pro plan is doing exactly what it's supposed to do: letting you keep working past short bursts of heavy use by absorbing them into the larger 7-day bucket. Your subscription is not being "downgraded." You're not being charged API rates. Nothing has reclassified you to a worse billing tier. When your 5-hour window rolls forward enough, the claim on new requests will go back to `five_hour` on its own.
|
|
459
|
+
|
|
460
|
+
**What about `overage`?** That's the state to watch. It means both windows are saturated and Anthropic is either billing you per-token under Extra Usage (if enabled) or refusing the request (if disabled). If you see this on a Claude Max account under normal use, it usually means (a) you're running a multi-agent workload that's genuinely outgrowing one subscription, or (b) Anthropic's session-level classifier has reclassified your long-running OAuth session as agentic load — see the next FAQ entry for the mechanism.
|
|
461
|
+
|
|
462
|
+
**Checking where you stand.** You can inspect your current utilization three ways:
|
|
463
|
+
1. **Claude Code's built-in command** — run `/usage` inside a `claude` session. Shows both windows as percentages with reset times.
|
|
464
|
+
2. **The statusline** — see [Claude Code's statusline docs](https://code.claude.com/docs/en/statusline) for a per-prompt readout.
|
|
465
|
+
3. **Dario's pool endpoint** — `curl http://localhost:3456/accounts` when running pool mode. The returned snapshot includes `util5h`, `util7d`, and `claim` per account.
|
|
466
|
+
|
|
467
|
+
**Practical answer if `seven_day` is painful for your workload.** Add more Claude subscriptions to the pool. Each account has its own independent 5-hour and 7-day windows, and dario pool mode will route each request to the account with the most headroom (`1 - max(util5h, util7d)`). With 2-3 accounts, you almost never see the `seven_day` bucket get touched because the router steers traffic to whichever account still has `five_hour` headroom. `dario accounts add <alias>`.
|
|
468
|
+
|
|
469
|
+
**Dario's test suite asserts `five_hour` — what if I see failures saying `got: seven_day`?** Some of dario's stealth-test assertions use `representative-claim == "five_hour"` as a shorthand for "is subscription billing classification working?" That assertion is correct for a fresh account but noisy for an account that's been developed against heavily — exactly the situation our own CI hits after an afternoon of test runs. If you're running the stealth suite against an account that's been busy recently and you see failures of the form `Billing claim is five_hour` / `got: seven_day`, that's a test infrastructure limitation, not a dario bug. The request was still billed against your subscription, which is what matters. These assertions will be tightened in a follow-up so they accept both buckets.
|
|
470
|
+
|
|
471
|
+
Standalone writeup with more detail: [Discussion #32 — why you see `representative-claim: seven_day` and why it's not a downgrade](https://github.com/askalf/dario/discussions/32).
|
|
472
|
+
|
|
441
473
|
**My multi-agent workload is getting reclassified to overage even though dario template-replays per request. Why?**
|
|
442
474
|
Reclassification at high agent volume is not a per-request problem. Anthropic's classifier operates on cumulative per-OAuth-session aggregates — token throughput, conversation depth, streaming duration, inter-arrival timing, thinking-block volume. Dario's Claude backend can make each individual request indistinguishable from Claude Code and still hit this wall on a long-running agent session, because the wall isn't at the request level. Thorough diagnostic work on this was contributed by [@belangertrading](https://github.com/belangertrading) in [#23](https://github.com/askalf/dario/issues/23), including the v3.4.3/v3.4.5 hardening that landed as a result. The practical answer at the dario layer is **pool mode** — distribute load across multiple subscriptions so no single account accumulates enough signal to trip anything. See [Multi-Account Pool Mode](#multi-account-pool-mode).
|
|
443
475
|
|
package/dist/cc-template.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/dist/cc-template.js
CHANGED
|
@@ -41,42 +41,154 @@ export function scrubFrameworkIdentifiers(text) {
|
|
|
41
41
|
}
|
|
42
42
|
const TOOL_MAP = {
|
|
43
43
|
// Direct maps
|
|
44
|
-
bash: {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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: {
|
|
59
|
-
|
|
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: {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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: {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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: {
|
|
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
|
-
*
|
|
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
|
-
|
|
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,275 @@ 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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
+
// We process on SSE event-group boundaries, not line boundaries.
|
|
507
|
+
// Events are separated by a blank line (two consecutive newlines);
|
|
508
|
+
// within an event group there may be multiple header lines like
|
|
509
|
+
// `event: content_block_delta` and `data: {...}`. The old code
|
|
510
|
+
// processed one line at a time, which meant swallowed deltas left
|
|
511
|
+
// orphan `event:` lines and synthetic delta+stop emissions joined
|
|
512
|
+
// two `data:` lines without a blank-line separator — which SSE
|
|
513
|
+
// parsers concatenate into one malformed multi-line event that
|
|
514
|
+
// fails JSON.parse downstream. v3.7.1 fixes both by processing
|
|
515
|
+
// whole event groups.
|
|
516
|
+
let groupBuffer = '';
|
|
517
|
+
// index → BufferedToolBlock for tool_use content blocks currently
|
|
518
|
+
// being held for end-of-block translation.
|
|
519
|
+
const buffered = new Map();
|
|
520
|
+
/**
|
|
521
|
+
* Build a complete SSE event group string with an `event:` header
|
|
522
|
+
* and a `data:` line. Used when emitting rewritten or synthetic
|
|
523
|
+
* events so the wire format matches what upstream produces.
|
|
524
|
+
*/
|
|
525
|
+
function buildEvent(type, payload) {
|
|
526
|
+
return `event: ${type}\ndata: ${JSON.stringify(payload)}`;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Process one complete SSE event group. Returns:
|
|
530
|
+
* - a string with one or more rewritten event groups separated
|
|
531
|
+
* by "\n\n" (no trailing blank line — the caller adds that)
|
|
532
|
+
* - null to drop the event group entirely (swallow)
|
|
533
|
+
* - the original `eventText` to pass through unchanged
|
|
534
|
+
*
|
|
535
|
+
* An event group is the text between blank lines. It may contain
|
|
536
|
+
* lines like `event: <type>`, `data: <payload>`, `id:`, `retry:`
|
|
537
|
+
* in any order. We only look at the `data:` line (Anthropic never
|
|
538
|
+
* uses multi-line data payloads).
|
|
539
|
+
*/
|
|
540
|
+
function processEventGroup(eventText) {
|
|
541
|
+
if (eventText === '')
|
|
542
|
+
return eventText;
|
|
543
|
+
// Find the data: line. Anthropic's SSE uses one data: per event.
|
|
544
|
+
const lines = eventText.split('\n');
|
|
545
|
+
let dataLineIdx = -1;
|
|
546
|
+
let dataText = '';
|
|
547
|
+
for (let i = 0; i < lines.length; i++) {
|
|
548
|
+
const line = lines[i];
|
|
549
|
+
if (line.startsWith('data:')) {
|
|
550
|
+
dataLineIdx = i;
|
|
551
|
+
dataText = line.slice(5).trim();
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (dataLineIdx === -1 || dataText === '' || dataText === '[DONE]') {
|
|
556
|
+
return eventText;
|
|
557
|
+
}
|
|
558
|
+
let event;
|
|
559
|
+
try {
|
|
560
|
+
event = JSON.parse(dataText);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return eventText;
|
|
564
|
+
}
|
|
565
|
+
const type = event.type;
|
|
566
|
+
if (type === 'content_block_start') {
|
|
567
|
+
const idx = typeof event.index === 'number' ? event.index : -1;
|
|
568
|
+
const block = event.content_block;
|
|
569
|
+
if (block && block.type === 'tool_use' && typeof block.name === 'string') {
|
|
570
|
+
const entry = reverseMap.get(block.name);
|
|
571
|
+
if (entry && entry.mapping.translateBack && idx >= 0) {
|
|
572
|
+
// Stash the block so we can flush a translated version at
|
|
573
|
+
// content_block_stop. Emit a rewritten start event now so
|
|
574
|
+
// the client sees its own tool name immediately.
|
|
575
|
+
buffered.set(idx, {
|
|
576
|
+
ccName: block.name,
|
|
577
|
+
mapping: entry.mapping,
|
|
578
|
+
clientName: entry.clientName,
|
|
579
|
+
partial: '',
|
|
580
|
+
});
|
|
581
|
+
block.name = entry.clientName;
|
|
582
|
+
// Reset input to empty so the client doesn't see CC's empty
|
|
583
|
+
// placeholder before the translated full input arrives.
|
|
584
|
+
block.input = {};
|
|
585
|
+
return buildEvent('content_block_start', event);
|
|
586
|
+
}
|
|
587
|
+
// Tool we don't translate — just rewrite the name in place.
|
|
588
|
+
if (entry) {
|
|
589
|
+
block.name = entry.clientName;
|
|
590
|
+
return buildEvent('content_block_start', event);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return eventText;
|
|
594
|
+
}
|
|
595
|
+
if (type === 'content_block_delta') {
|
|
596
|
+
const idx = typeof event.index === 'number' ? event.index : -1;
|
|
597
|
+
const buf = idx >= 0 ? buffered.get(idx) : undefined;
|
|
598
|
+
if (!buf)
|
|
599
|
+
return eventText;
|
|
600
|
+
const delta = event.delta;
|
|
601
|
+
if (delta && delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
602
|
+
buf.partial += delta.partial_json;
|
|
603
|
+
// Swallow the whole event group — including any `event:`
|
|
604
|
+
// header line the upstream emitted for it — because we'll
|
|
605
|
+
// emit a synthetic combined delta at content_block_stop.
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
return eventText;
|
|
609
|
+
}
|
|
610
|
+
if (type === 'content_block_stop') {
|
|
611
|
+
const idx = typeof event.index === 'number' ? event.index : -1;
|
|
612
|
+
const buf = idx >= 0 ? buffered.get(idx) : undefined;
|
|
613
|
+
if (!buf)
|
|
614
|
+
return eventText;
|
|
615
|
+
let translatedInput = {};
|
|
616
|
+
let parseOk = true;
|
|
617
|
+
try {
|
|
618
|
+
const parsedInput = JSON.parse(buf.partial || '{}');
|
|
619
|
+
translatedInput = buf.mapping.translateBack
|
|
620
|
+
? buf.mapping.translateBack(parsedInput)
|
|
621
|
+
: parsedInput;
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
parseOk = false;
|
|
625
|
+
}
|
|
626
|
+
buffered.delete(idx);
|
|
627
|
+
if (!parseOk) {
|
|
628
|
+
// Fall back to passing the original partial through unchanged
|
|
629
|
+
// so the client at least sees whatever upstream actually sent.
|
|
630
|
+
// Emit as TWO separate SSE events with blank-line separators.
|
|
631
|
+
const passthroughDelta = {
|
|
632
|
+
type: 'content_block_delta',
|
|
633
|
+
index: idx,
|
|
634
|
+
delta: { type: 'input_json_delta', partial_json: buf.partial },
|
|
635
|
+
};
|
|
636
|
+
return (buildEvent('content_block_delta', passthroughDelta) +
|
|
637
|
+
'\n\n' +
|
|
638
|
+
buildEvent('content_block_stop', event));
|
|
639
|
+
}
|
|
640
|
+
const synthDelta = {
|
|
641
|
+
type: 'content_block_delta',
|
|
642
|
+
index: idx,
|
|
643
|
+
delta: { type: 'input_json_delta', partial_json: JSON.stringify(translatedInput) },
|
|
644
|
+
};
|
|
645
|
+
// Emit as TWO separate SSE events joined by a blank line so
|
|
646
|
+
// downstream parsers see them as distinct events. The outer
|
|
647
|
+
// processBuffer will append one more "\n\n" after the final
|
|
648
|
+
// event in this group, which is correct SSE framing.
|
|
649
|
+
return (buildEvent('content_block_delta', synthDelta) +
|
|
650
|
+
'\n\n' +
|
|
651
|
+
buildEvent('content_block_stop', event));
|
|
652
|
+
}
|
|
653
|
+
return eventText;
|
|
654
|
+
}
|
|
655
|
+
function processBuffer(flush) {
|
|
656
|
+
// Split the accumulated buffer on "\n\n" (SSE event separator).
|
|
657
|
+
// Every complete part is a full event group; the last part is
|
|
658
|
+
// either empty (the trailing blank after a completed event) or
|
|
659
|
+
// a partial event that needs to wait for more bytes.
|
|
660
|
+
const parts = groupBuffer.split('\n\n');
|
|
661
|
+
if (!flush) {
|
|
662
|
+
// Hold the last (potentially incomplete) part back.
|
|
663
|
+
groupBuffer = parts.pop() ?? '';
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
groupBuffer = '';
|
|
667
|
+
}
|
|
668
|
+
const out = [];
|
|
669
|
+
for (const part of parts) {
|
|
670
|
+
if (part === '')
|
|
671
|
+
continue;
|
|
672
|
+
const processed = processEventGroup(part);
|
|
673
|
+
if (processed !== null)
|
|
674
|
+
out.push(processed);
|
|
675
|
+
}
|
|
676
|
+
// Each emitted event (or multi-event group) needs a trailing
|
|
677
|
+
// blank line so the SSE framing is correct. We join with "\n\n"
|
|
678
|
+
// and append "\n\n" so both the inter-group and final
|
|
679
|
+
// separators are present.
|
|
680
|
+
return out.length > 0 ? out.join('\n\n') + '\n\n' : '';
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
feed(chunk) {
|
|
684
|
+
groupBuffer += decoder.decode(chunk, { stream: true });
|
|
685
|
+
const out = processBuffer(false);
|
|
686
|
+
return out.length > 0 ? encoder.encode(out) : new Uint8Array(0);
|
|
687
|
+
},
|
|
688
|
+
end() {
|
|
689
|
+
groupBuffer += decoder.decode();
|
|
690
|
+
const out = processBuffer(true);
|
|
691
|
+
return out.length > 0 ? encoder.encode(out) : new Uint8Array(0);
|
|
692
|
+
},
|
|
693
|
+
};
|
|
320
694
|
}
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "3.7.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,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",
|