@askalf/dario 3.4.3 → 3.4.5

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
@@ -37,7 +37,7 @@ export ANTHROPIC_API_KEY=dario # or OPENAI_API_KEY=dario
37
37
 
38
38
  Opus, Sonnet, Haiku — all models, streaming, tool use. **Zero dependencies.** ~2,000 lines of TypeScript. Works with Cursor, Continue, Aider, LiteLLM, Hermes, OpenClaw, or any tool that speaks the Anthropic or OpenAI API. Auto-launches under [Bun](https://bun.sh) when available for TLS fingerprint fidelity. **Auto-detects OAuth config from your installed CC binary** so dario stays in sync forever — Anthropic can rotate client IDs and dario picks them up on the next run.
39
39
 
40
- dario is built and maintained by [askalf](https://askalf.org) the open-source foundation of the askalf agent platform. If you need more than a proxy, [see below](#askalf).
40
+ dario is the **per-request layer** one account, one workload, every request indistinguishable from CC on the wire. Session-level and account-level concerns (multi-account pooling, behavioral-classifier shaping, 24/7 fleets) live a layer above dario, in [askalf](https://askalf.org) — [see below](#askalf). Both are built by the same team on the same OAuth and billing infrastructure.
41
41
 
42
42
  <table>
43
43
  <tr>
@@ -91,7 +91,7 @@ dario is the only proxy that solves this. Instead of transforming your requests
91
91
  | **Approach** | Template replay — sends CC's actual request | Signal matching or none |
92
92
  | **Tools** | CC's exact tool definitions sent upstream | Client tools (detected) |
93
93
  | **Max plan limits** | Used correctly | Bypassed — billed separately |
94
- | **Detection resistance** | Undetectable without flagging CC itself | Detected by tool names, field order, effort level, etc. |
94
+ | **Detection resistance** | Undetectable at the per-request level without flagging CC itself | Detected by tool names, field order, effort level, etc. |
95
95
  | **Dependencies** | 0 | Many |
96
96
 
97
97
  <details>
@@ -408,13 +408,13 @@ Anthropic periodically rotates the OAuth `client_id`, authorize URL, token URL,
408
408
 
409
409
  Dario scans the installed CC binary at startup and extracts the current config directly:
410
410
 
411
- - **Anchor**: `OAUTH_FILE_SUFFIX:"-local-oauth"` — the config block CC uses for clients that run their own localhost callback.
412
- - **Extracted**: `CLIENT_ID`, `CLAUDE_AI_AUTHORIZE_URL`, `TOKEN_URL`, and the full `user:*` scope string.
413
- - **Cached**: Results stored at `~/.dario/cc-oauth-cache.json` keyed by binary fingerprint (first 64KB sha256 + size + mtime). Cold scan ~500ms, cache hit ~5ms. Re-scans only when CC is upgraded.
414
- - **Fallback**: If CC is not installed or scanning fails, dario uses known-good hardcoded values. No user action needed.
411
+ - **Anchor**: `BASE_API_URL:"https://api.anthropic.com"` — this literal appears only inside CC's live prod OAuth config block, so the scanner reliably lands in the right object even when the minifier reorders fields across CC releases.
412
+ - **Extracted**: `CLIENT_ID`, `CLAUDE_AI_AUTHORIZE_URL`, `TOKEN_URL`, and the full `user:*` scope string. A defensive check rejects any scan result that matches a known-dead internal client_id.
413
+ - **Cached**: Results stored at `~/.dario/cc-oauth-cache-v2.json` keyed by binary fingerprint (first 64KB sha256 + size + mtime). Cold scan ~500ms, cache hit ~5ms. Re-scans only when CC is upgraded.
414
+ - **Fallback**: If CC is not installed or scanning fails, dario uses the CC 2.1.104 prod config values hardcoded in-tree. No user action needed.
415
415
  - **Override**: Set `DARIO_CC_PATH=/path/to/claude` to point dario at a non-standard CC binary location.
416
416
 
417
- CC ships **two** OAuth client configurations in one binary a `-local-oauth` flow (localhost callback) and a platform-hosted flow (`platform.claude.com/oauth/code/callback`). Dario must use the former. The scanner anchors specifically on the local block.
417
+ CC ships three OAuth config factories (`local`, `staging`, `prod`) in one binary, selected at runtime by a function that is hardcoded to `prod` in shipped builds. Only the prod block is live; the other two are dead code paths CC uses when pointing at internal dev/staging infrastructure. The scanner targets the prod block specifically.
418
418
 
419
419
  End-to-end verification lives at [`test/oauth-detector.mjs`](test/oauth-detector.mjs).
420
420
 
@@ -439,8 +439,10 @@ End-to-end verification lives at [`test/oauth-detector.mjs`](test/oauth-detector
439
439
  | `--preserve-tools` / `--keep-tools` | Keep client tool schemas instead of remapping to CC tools | off |
440
440
  | `--model=MODEL` | Force a model (`opus`, `sonnet`, `haiku`, or full ID) | passthrough |
441
441
  | `--port=PORT` | Port to listen on | `3456` |
442
+ | `--host=ADDRESS` / `DARIO_HOST` | Bind address. Use `0.0.0.0` to accept LAN connections, or a specific IP to bind selectively (e.g. a Tailscale interface). When non-loopback, also set `DARIO_API_KEY`. | `127.0.0.1` |
442
443
  | `--verbose` / `-v` | Log every request | off |
443
- | `DARIO_API_KEY` | If set, all endpoints (except `/health`) require matching `x-api-key` or `Authorization: Bearer` | unset (open) |
444
+ | `DARIO_API_KEY` | If set, all endpoints (except `/health`) require matching `x-api-key` or `Authorization: Bearer`. **Required** when `--host` binds to anything other than loopback. | unset (open) |
445
+ | `DARIO_CORS_ORIGIN` | Override the browser CORS `Access-Control-Allow-Origin`. Useful for browser-based clients (open-webui, librechat) connecting over a mesh network. | `http://localhost:${port}` |
444
446
  | `DARIO_NO_BUN` | Disable automatic Bun relaunch (stay on Node.js) | unset |
445
447
  | `DARIO_MIN_INTERVAL_MS` | Minimum ms between requests (rate governor) | `500` |
446
448
  | `DARIO_CC_PATH` | Override path to Claude Code binary for OAuth detection | auto-detect |
@@ -506,9 +508,9 @@ curl http://localhost:3456/health
506
508
  |---------|---------------------|
507
509
  | Credential storage | Reads from Claude Code (`~/.claude/.credentials.json`) or its own store (`~/.dario/credentials.json`) with `0600` permissions |
508
510
  | OAuth flow | PKCE (Proof Key for Code Exchange) — no client secret needed |
509
- | OAuth config source | Auto-detected from local CC binary at runtime; cached at `~/.dario/cc-oauth-cache.json`. Detector reads binary in read-only mode, never modifies it. |
511
+ | OAuth config source | Auto-detected from local CC binary at runtime; cached at `~/.dario/cc-oauth-cache-v2.json`. Detector reads binary in read-only mode, never modifies it. |
510
512
  | Token exposure | Tokens never logged; redacted from all error messages. |
511
- | Network binding | Binds exclusively to `127.0.0.1`. Upstream traffic goes only to `api.anthropic.com` over HTTPS. |
513
+ | Network binding | Binds to `127.0.0.1` by default. Override with `--host` / `DARIO_HOST` for mesh/LAN use; dario refuses to treat non-loopback binds as safe and requires you to set `DARIO_API_KEY` to avoid an unauthenticated LAN-reachable proxy. Upstream traffic goes only to `api.anthropic.com` over HTTPS. |
512
514
  | Auth timing | `timingSafeEqual` used for `DARIO_API_KEY` comparison. |
513
515
  | SSRF protection | Only `/v1/messages` and `/v1/complete` are proxied upstream — hardcoded allowlist. |
514
516
  | Body size | 10MB hard cap per request. 30s read timeout prevents slow-loris. |
@@ -519,11 +521,11 @@ curl http://localhost:3456/health
519
521
 
520
522
  ## askalf
521
523
 
522
- dario solves the API access problem your $200/mo subscription, usable everywhere, billed correctly.
524
+ **dario and askalf solve different layers of the same problem.**
523
525
 
524
- But a proxy has a ceiling. Every request still runs on your single account, with your subscription's rate limits, on your machine. When you need to scale beyond that multiple accounts, persistent browser sessions, desktop control, scheduled workflows, a fleet of agents that can run while you sleep that's what [askalf](https://askalf.org) is built for.
526
+ dario is the per-request layer: one account, one workload, every request on the wire indistinguishable from Claude Code. It's what you reach for when you want your Max/Pro subscription usable from any tool that speaks the Anthropic or OpenAI API. It does not pool accounts, shape sessions, distribute load, or care about cumulative behavioral signals those are not per-request concerns, and solving them at the per-request layer is a category error.
525
527
 
526
- **askalf** is the agent platform built on top of the same OAuth and billing infrastructure that powers dario:
528
+ **askalf** is the layer above that: multi-account pooling behind one endpoint, session and workload shaping to stay under Anthropic's session-level classifiers, persistent browser and desktop sessions, scheduling, and a hosted fleet that runs 24/7. Built on the same OAuth and billing infrastructure as dario.
527
529
 
528
530
  | | dario | askalf |
529
531
  |---|---|---|
@@ -571,18 +573,21 @@ Optional but recommended. If [Bun](https://bun.sh) is installed, dario auto-rela
571
573
  Dario auto-refreshes tokens 30 minutes before expiry. You should never see an auth error in normal use. If something goes wrong, `dario refresh` forces an immediate refresh.
572
574
 
573
575
  **What happens when Anthropic rotates the OAuth client_id or URL?**
574
- Dario auto-detects OAuth config from your installed Claude Code binary. When CC ships a new version with rotated values, dario picks them up on the next startup — no dario release needed. The detector is cached at `~/.dario/cc-oauth-cache.json` and only re-scans when the binary fingerprint changes. If CC isn't installed, dario falls back to known-good hardcoded values.
576
+ Dario auto-detects OAuth config from your installed Claude Code binary. When CC ships a new version with rotated values, dario picks them up on the next startup — no dario release needed. The detector is cached at `~/.dario/cc-oauth-cache-v2.json` and only re-scans when the binary fingerprint changes. If CC isn't installed, dario falls back to hardcoded CC 2.1.104 prod values.
575
577
 
576
578
  **I'm hitting rate limits. What do I do?**
577
579
  Claude subscriptions have rolling 5-hour and 7-day usage windows. Check your utilization with Claude Code's `/usage` command or the [statusline](https://code.claude.com/docs/en/statusline). Rate limit errors from dario include utilization percentages and reset times so you can see exactly when capacity returns.
578
580
 
581
+ **My multi-agent workload is getting reclassified to overage even though dario template-replays per request. Why?**
582
+ Because reclassification at high agent volume is not a per-request problem. Anthropic's classifier operates on cumulative per-OAuth-session behavioral aggregates — token throughput, conversation depth, streaming duration, inter-arrival timing, thinking-block volume. Dario 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 per-request v3.4.3 hardening that landed as a result. For the session-layer shaping itself — multi-account pooling, session rotation, workload distribution that keeps any single account from concentrating the behavioral signal — that's what [askalf](https://askalf.org) is built for. Different layer, different tool.
583
+
579
584
  If you're running a multi-agent workload and consistently hitting limits, [askalf](https://askalf.org) distributes load across multiple accounts automatically.
580
585
 
581
586
  **What are the usage limits?**
582
587
  Claude subscriptions have rolling 5-hour and 7-day usage windows shared across claude.ai and Claude Code. See [Anthropic's docs](https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work) for details.
583
588
 
584
589
  **Can I run this on a server?**
585
- Dario binds to localhost by default. For server use, handle the initial login on a machine with a browser, then copy `~/.claude/.credentials.json` (or `~/.dario/credentials.json`) to your server. Auto-refresh will keep it alive from there.
590
+ Dario binds to localhost by default. For server use, handle the initial login on a machine with a browser, then copy `~/.claude/.credentials.json` (or `~/.dario/credentials.json`) to your server. Auto-refresh will keep it alive from there. If you want dario reachable from other machines on the same LAN or a Tailscale mesh, pass `--host=0.0.0.0` (or a specific interface IP) and set `DARIO_API_KEY` to gate access.
586
591
 
587
592
  **Why "dario"?**
588
593
  Named after [Dario Amodei](https://en.wikipedia.org/wiki/Dario_Amodei), CEO of Anthropic.
@@ -622,7 +627,7 @@ Dario handles your OAuth tokens. Here's why you can trust it:
622
627
  | **npm provenance** | Every release is [SLSA attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions |
623
628
  | **Security scanning** | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) runs on every push and weekly |
624
629
  | **Credential handling** | Tokens never logged, redacted from errors, stored with 0600 permissions |
625
- | **Network scope** | Binds to 127.0.0.1 only. Upstream traffic goes exclusively to `api.anthropic.com` over HTTPS |
630
+ | **Network scope** | Binds to 127.0.0.1 by default; `--host` / `DARIO_HOST` allows LAN/mesh exposure with `DARIO_API_KEY` gating. Upstream traffic goes exclusively to `api.anthropic.com` over HTTPS |
626
631
  | **No telemetry** | Zero analytics, tracking, or data collection of any kind |
627
632
  | **Audit trail** | [CHANGELOG.md](CHANGELOG.md) documents every release |
628
633
  | **Branch protection** | CI must pass before merge. CODEOWNERS enforces review |
@@ -673,7 +678,7 @@ npm run dev # runs with tsx (no build needed)
673
678
  | Who | Contributions |
674
679
  |-----|---------------|
675
680
  | [@GodsBoy](https://github.com/GodsBoy) | Proxy authentication, token redaction, error sanitization ([#2](https://github.com/askalf/dario/pull/2)) |
676
- | [@belangertrading](https://github.com/belangertrading) | Billing classification investigation ([#4](https://github.com/askalf/dario/issues/4)), billing reclassification root cause ([#7](https://github.com/askalf/dario/issues/7)) |
681
+ | [@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)) |
677
682
 
678
683
  ## License
679
684
 
@@ -44,12 +44,23 @@ const FALLBACK = {
44
44
  clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
45
45
  authorizeUrl: 'https://claude.com/cai/oauth/authorize',
46
46
  tokenUrl: 'https://platform.claude.com/v1/oauth/token',
47
- scopes: 'user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload',
47
+ // Scopes are the full `n36` union from the CC binary, which is the value
48
+ // sent during a normal `claude login` (non-setup-token) flow. In CC's
49
+ // source: `let D = f ? [TI] : n36` where `f = inferenceOnly` (true only
50
+ // for `claude setup-token`). Normal interactive login uses the 6-scope
51
+ // union including `org:create_api_key` — even though that scope is named
52
+ // "Console-only" by convention, CC's own login flow requests it up front.
53
+ // Earlier dario versions (3.2.7 through 3.4.3) dropped `org:create_api_key`
54
+ // from the list based on a misread of the name; the dev-only client_id
55
+ // was lenient enough to accept the shorter list, the prod client_id is not.
56
+ scopes: 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload',
48
57
  source: 'fallback',
49
58
  };
50
- // -v2 suffix invalidates the v3.4.0-v3.4.2 cache that pinned the wrong
51
- // (dev) client_id extracted from the dead-code -local-oauth block.
52
- const CACHE_PATH = join(homedir(), '.dario', 'cc-oauth-cache-v2.json');
59
+ // -v3 suffix invalidates v3.4.3 caches that were populated with the wrong
60
+ // 4-scope list (the scanner's regex matched a help-message string literal
61
+ // in the CC binary instead of the real scope array). See the scanner's
62
+ // scope handling below for why scope detection is no longer attempted.
63
+ const CACHE_PATH = join(homedir(), '.dario', 'cc-oauth-cache-v3.json');
53
64
  function candidatePaths() {
54
65
  const home = homedir();
55
66
  if (platform() === 'win32') {
@@ -139,19 +150,22 @@ export function scanBinaryForOAuthConfig(buf) {
139
150
  const tokenMatch = /TOKEN_URL\s*:\s*"(https:\/\/[^"]*\/oauth\/token[^"]*)"/.exec(prodBlock);
140
151
  if (tokenMatch && tokenMatch[1])
141
152
  tokenUrl = tokenMatch[1];
142
- // Scopes live in a separate array-of-strings block elsewhere in the
143
- // binary, not inside the prod config object itself. Search globally
144
- // for the first quoted `user:profile ...` run.
145
- let scopes = FALLBACK.scopes;
146
- const scopeAnchor = Buffer.from('"user:profile ');
147
- const scopeIdx = buf.indexOf(scopeAnchor);
148
- if (scopeIdx !== -1) {
149
- const w = buf.slice(scopeIdx, Math.min(buf.length, scopeIdx + 512)).toString('latin1');
150
- const m = /"(user:profile(?:\s+user:[a-z_:]+)+)"/.exec(w);
151
- if (m && m[1])
152
- scopes = m[1];
153
- }
154
- return { clientId, authorizeUrl, tokenUrl, scopes };
153
+ // Scopes are NOT detected from the binary. Previous versions of this
154
+ // scanner anchored on `"user:profile ` and regex-captured the first
155
+ // contiguous quoted run of scopes, but that anchor matches an error/help
156
+ // message string literal (used by `claude setup-token` error output) that
157
+ // contains only 4 of the 6 actual scopes. The real scope array is stored
158
+ // as a constant-reference array — `dY8 = [B9H, TI, "user:sessions:...", ...]`
159
+ // where the first two elements are minified variable references, not
160
+ // literal strings, so no regex can reliably extract the full list. And the
161
+ // runtime-computed union `n36` only exists after `Array.from(new Set(...))`
162
+ // executes, which we can't evaluate from a static scan.
163
+ //
164
+ // Given that scopes rarely change across CC releases (Anthropic adds or
165
+ // removes maybe one per major version), hardcoding them in FALLBACK is
166
+ // more reliable than scanning. If Anthropic changes the scope list, the
167
+ // fix is a one-line FALLBACK update in a dario release.
168
+ return { clientId, authorizeUrl, tokenUrl, scopes: FALLBACK.scopes };
155
169
  }
156
170
  async function loadCache() {
157
171
  try {
@@ -15,6 +15,7 @@ export declare const CC_TOOL_DEFINITIONS: {
15
15
  export declare const CC_SYSTEM_PROMPT: string;
16
16
  /** CC's agent identity string. */
17
17
  export declare const CC_AGENT_IDENTITY: string;
18
+ export declare function scrubFrameworkIdentifiers(text: string): string;
18
19
  /** Client tool name → CC tool mapping with parameter translation. */
19
20
  interface ToolMapping {
20
21
  ccTool: string;
@@ -17,6 +17,28 @@ export const CC_TOOL_DEFINITIONS = TEMPLATE.tools;
17
17
  export const CC_SYSTEM_PROMPT = TEMPLATE.system_prompt;
18
18
  /** CC's agent identity string. */
19
19
  export const CC_AGENT_IDENTITY = TEMPLATE.agent_identity;
20
+ // Framework identifiers that would flag non-CC usage. Stripped from the system
21
+ // prompt and from message content text blocks before the request goes upstream.
22
+ const FRAMEWORK_PATTERNS = [
23
+ // Compound/hyphenated patterns run first so their halves can't be eaten
24
+ // by the simpler word-level patterns below.
25
+ /\b(roo[- ]?cline|big[- ]?agi|claude[- ]?bridge)\b/gi,
26
+ /\b(openclaw|hermes|aider|cursor|windsurf|cline|continue|copilot|cody)\b/gi,
27
+ /\b(librechat|typingmind)\b/gi,
28
+ /\b(openai|gpt-4|gpt-3\.5)\b/gi,
29
+ /powered by [a-z]+/gi,
30
+ /\bgateway\b/gi,
31
+ // OC's sessions_* tool-name prefix — flagged as a fingerprint in dario#23.
32
+ /\bsessions_[a-z_]+\b/gi,
33
+ ];
34
+ export function scrubFrameworkIdentifiers(text) {
35
+ let result = text;
36
+ for (const pattern of FRAMEWORK_PATTERNS) {
37
+ pattern.lastIndex = 0;
38
+ result = result.replace(pattern, '');
39
+ }
40
+ return result;
41
+ }
20
42
  const TOOL_MAP = {
21
43
  // Direct maps
22
44
  bash: { ccTool: 'Bash', translateArgs: (a) => ({ command: a.cmd || a.command || a.c || '' }) },
@@ -194,15 +216,31 @@ export function buildCCRequest(clientBody, billingTag, cache1h, identity, opts =
194
216
  .map(b => b.text)
195
217
  .join('\n\n');
196
218
  }
197
- // Strip framework identifiers from system prompt that would flag non-CC usage
198
- const FRAMEWORK_PATTERNS = [
199
- /\b(openclaw|hermes|aider|cursor|windsurf|cline|continue|copilot|cody)\b/gi,
200
- /\b(openai|gpt-4|gpt-3\.5)\b/gi,
201
- /powered by [a-z]+/gi,
202
- /\bgateway\b/gi,
203
- ];
204
- for (const pattern of FRAMEWORK_PATTERNS) {
205
- systemText = systemText.replace(pattern, '');
219
+ systemText = scrubFrameworkIdentifiers(systemText);
220
+ // Also scrub framework identifiers from message content text blocks.
221
+ // Clients often inject their product name into user/tool messages as well,
222
+ // and the system-prompt-only scrub used to miss those.
223
+ for (const msg of messages) {
224
+ if (typeof msg.content === 'string') {
225
+ msg.content = scrubFrameworkIdentifiers(msg.content);
226
+ }
227
+ else if (Array.isArray(msg.content)) {
228
+ for (const block of msg.content) {
229
+ if (block.type === 'text' && typeof block.text === 'string') {
230
+ block.text = scrubFrameworkIdentifiers(block.text);
231
+ }
232
+ if (block.type === 'tool_result' && typeof block.content === 'string') {
233
+ block.content = scrubFrameworkIdentifiers(block.content);
234
+ }
235
+ if (block.type === 'tool_result' && Array.isArray(block.content)) {
236
+ for (const sub of block.content) {
237
+ if (sub.type === 'text' && typeof sub.text === 'string') {
238
+ sub.text = scrubFrameworkIdentifiers(sub.text);
239
+ }
240
+ }
241
+ }
242
+ }
243
+ }
206
244
  }
207
245
  // ── Build the CC request from template ──
208
246
  // Key order matches CC v2.1.104 exactly:
package/dist/proxy.js CHANGED
@@ -147,6 +147,7 @@ const ORCHESTRATION_TAG_NAMES = [
147
147
  'system-reminder', 'env', 'system_information', 'current_working_directory',
148
148
  'operating_system', 'default_shell', 'home_directory', 'task_metadata',
149
149
  'directories', 'thinking',
150
+ 'agent_persona', 'agent_context', 'tool_context', 'persona', 'tool_call',
150
151
  ];
151
152
  const ORCHESTRATION_PATTERNS = ORCHESTRATION_TAG_NAMES.flatMap(tag => [
152
153
  new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'),
@@ -459,6 +460,10 @@ export async function startProxy(opts = {}) {
459
460
  }
460
461
  // Proxy to Anthropic (with concurrency control)
461
462
  await semaphore.acquire();
463
+ // Hoisted so the finally block can clean up whatever was set.
464
+ let upstreamTimeout = null;
465
+ let onClientClose = null;
466
+ let upstreamAbortReason = null;
462
467
  try {
463
468
  const accessToken = await getAccessToken();
464
469
  // Read request body with size limit and timeout (prevents slow-loris)
@@ -562,11 +567,33 @@ export async function startProxy(opts = {}) {
562
567
  // CC sends 600 on first request per session. With rotation, every request is "first"
563
568
  'x-stainless-timeout': '600',
564
569
  };
570
+ // Client-disconnect abort: if the client drops the connection before
571
+ // we've finished sending the response, we abort the upstream fetch so
572
+ // Anthropic stops generating (and billing) a response nobody will
573
+ // read. Also carries the 5-minute upstream timeout via the same
574
+ // controller, so a single signal covers both cancellation reasons.
575
+ const upstreamAbort = new AbortController();
576
+ upstreamTimeout = setTimeout(() => {
577
+ if (!upstreamAbort.signal.aborted) {
578
+ upstreamAbortReason = 'timeout';
579
+ upstreamAbort.abort();
580
+ }
581
+ }, UPSTREAM_TIMEOUT_MS);
582
+ onClientClose = () => {
583
+ // 'close' fires on both normal teardown and client disconnect.
584
+ // We only want to abort if we haven't finished our response yet —
585
+ // normal teardown happens AFTER res.writableEnded becomes true.
586
+ if (!res.writableEnded && !upstreamAbort.signal.aborted) {
587
+ upstreamAbortReason = 'client_closed';
588
+ upstreamAbort.abort();
589
+ }
590
+ };
591
+ req.on('close', onClientClose);
565
592
  let upstream = await fetch(targetBase, {
566
593
  method: req.method ?? 'POST',
567
594
  headers,
568
595
  body: finalBody ? new Uint8Array(finalBody) : undefined,
569
- signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
596
+ signal: upstreamAbort.signal,
570
597
  });
571
598
  // Auto-retry without context-1m if it triggers a long-context billing error.
572
599
  // Anthropic returns this as either 400 ("long context beta is not yet available
@@ -590,7 +617,7 @@ export async function startProxy(opts = {}) {
590
617
  method: req.method ?? 'POST',
591
618
  headers: retryHeaders,
592
619
  body: finalBody ? new Uint8Array(finalBody) : undefined,
593
- signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
620
+ signal: upstreamAbort.signal,
594
621
  });
595
622
  // Use the retry response from here on — peeked body is now stale
596
623
  upstream = retry;
@@ -750,12 +777,42 @@ export async function startProxy(opts = {}) {
750
777
  }
751
778
  }
752
779
  catch (err) {
753
- // Log full error server-side, return generic message to client
754
- console.error('[dario] Proxy error:', sanitizeError(err));
755
- res.writeHead(502, JSON_HEADERS);
756
- res.end(JSON.stringify({ error: 'Proxy error', message: 'Failed to reach upstream API' }));
780
+ // Differentiate the three failure modes so each gets the right
781
+ // response (and so we don't spam logs when clients simply drop).
782
+ if (upstreamAbortReason === 'client_closed') {
783
+ if (verbose)
784
+ console.log(`[dario] #${requestCount} aborted (client disconnected)`);
785
+ }
786
+ else if (upstreamAbortReason === 'timeout') {
787
+ console.error(`[dario] #${requestCount} upstream timeout after ${UPSTREAM_TIMEOUT_MS / 1000}s`);
788
+ if (!res.headersSent) {
789
+ res.writeHead(504, JSON_HEADERS);
790
+ res.end(JSON.stringify({ error: 'Upstream timeout', message: `Anthropic did not respond within ${UPSTREAM_TIMEOUT_MS / 1000}s` }));
791
+ }
792
+ else if (!res.writableEnded) {
793
+ res.end();
794
+ }
795
+ }
796
+ else {
797
+ // Log full error server-side, return generic message to client
798
+ console.error('[dario] Proxy error:', sanitizeError(err));
799
+ if (!res.headersSent) {
800
+ res.writeHead(502, JSON_HEADERS);
801
+ res.end(JSON.stringify({ error: 'Proxy error', message: 'Failed to reach upstream API' }));
802
+ }
803
+ else if (!res.writableEnded) {
804
+ res.end();
805
+ }
806
+ }
757
807
  }
758
808
  finally {
809
+ // Always clean up the upstream-abort plumbing if it was set up. The
810
+ // setup happens after the body-read phase, so on fast-path errors
811
+ // (413, body read timeout) these may still be null — guard accordingly.
812
+ if (upstreamTimeout !== null)
813
+ clearTimeout(upstreamTimeout);
814
+ if (onClientClose !== null)
815
+ req.off('close', onClientClose);
759
816
  semaphore.release();
760
817
  }
761
818
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.4.3",
3
+ "version": "3.4.5",
4
4
  "description": "Use your Claude subscription as an API. No API key needed. Local proxy for Claude Max/Pro subscriptions.",
5
5
  "type": "module",
6
6
  "bin": {