@askalf/dario 3.19.0 → 3.19.2

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
@@ -11,6 +11,10 @@
11
11
  <a href="https://www.npmjs.com/package/@askalf/dario"><img src="https://img.shields.io/npm/dm/@askalf/dario" alt="Downloads"></a>
12
12
  </p>
13
13
 
14
+ <p align="center">
15
+ <sub><strong>v4 is not a version bump.</strong> The router was the prerequisite. What comes next uses it as a substrate. — <a href="https://github.com/askalf/dario/discussions/categories/announcements">watch this space</a></sub>
16
+ </p>
17
+
14
18
  ```bash
15
19
  npm install -g @askalf/dario && dario proxy
16
20
  ```
@@ -88,7 +92,7 @@ Something broken? `dario doctor` prints a single aggregated health report — da
88
92
 
89
93
  **You want the proxy layer off the wire entirely.** **Shim mode** (v3.12, hardened in v3.13) is an in-process `globalThis.fetch` patch injected via `NODE_OPTIONS=--require`. No HTTP hop, no port to bind, no `BASE_URL` to set. `dario shim -- claude --print "hi"` and CC thinks it's talking directly to `api.anthropic.com`. See [Shim mode](#shim-mode).
90
94
 
91
- **You want to share capacity with a trusted group without surveilling each other.** The **sealed-sender overflow protocol** (v3.13) uses RSA blind signatures (Chaum 1983, implemented from scratch over Node's `crypto`) so members of a trust group can lend unused Claude capacity to each other with cryptographic unlinkability. A lender verifies "this is a valid group member" without learning *which* member. See [Sealed-sender overflow](#sealed-sender-overflow-protocol).
95
+ **You want to share capacity with a trusted group without surveilling each other.** The **sealed-sender overflow protocol** (v3.13) uses RSA blind signatures (Chaum 1983, implemented from scratch over Node's `crypto`) so members of a trust group can lend unused Claude capacity to each other with cryptographic unlinkability. A lender verifies "this is a valid group member" without learning *which* member. Dario ships the primitive; [mux](https://github.com/askalf/mux) is the dedicated product around it (group admin, key distribution, member workflow, borrower CLI). See [Sealed-sender overflow](#sealed-sender-overflow-protocol).
92
96
 
93
97
  **You want to actually audit the thing.** ~7,600 lines of TypeScript across ~15 files. Zero runtime dependencies (`npm ls --production` confirms). Credentials at `~/.dario/` with `0600` permissions. `127.0.0.1`-only by default. Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions. Nothing phones home. Small enough to read in a weekend.
94
98
 
@@ -232,6 +236,8 @@ Trust-group members can lend each other Claude capacity with **cryptographic unl
232
236
 
233
237
  Full feature-parity with `/v1/messages` (streaming, inside-request 429 failover, reverse tool mapping) for borrowed requests is intentionally a follow-up — the current release ships the cryptographic primitive and a working minimal endpoint; full integration layers on top.
234
238
 
239
+ **Dedicated product.** The sealed-sender protocol has a dedicated product around it: [mux](https://github.com/askalf/mux). mux carries the group admin tooling (key generation, member roster, batch signing), the member workflow (prepare / finalize / status), the borrower CLI, and the lender daemon as a coherent surface. It uses dario as its backend — a mux lender runs a dario pool and fronts it with `/v1/pool/borrow`. Dario keeps the primitive here for anyone who wants to embed it without running the full mux flow; for peer-to-peer capacity sharing as a product, use mux.
240
+
235
241
  ---
236
242
 
237
243
  ## Shim mode
@@ -232,7 +232,10 @@ const TOOL_MAP = {
232
232
  execute_command: {
233
233
  ccTool: 'Bash',
234
234
  translateArgs: (a) => ({ command: a.command || a.cmd || '', ...(a.description ? { description: a.description } : {}) }),
235
- translateBack: (a) => ({ command: a.command ?? '', ...(a.description ? { description: a.description } : { description: a.command ?? '' }) }),
235
+ // requires_approval is required by Cline's execute_command schema. Default
236
+ // to false — CC already gates Bash upstream through its own permission
237
+ // model, and the borrower controls their own auto-approval settings.
238
+ translateBack: (a) => ({ command: a.command ?? '', requires_approval: false, ...(a.description ? { description: a.description } : { description: a.command ?? '' }) }),
236
239
  },
237
240
  // Cursor
238
241
  run_terminal_cmd: {
@@ -336,7 +339,9 @@ const TOOL_MAP = {
336
339
  replace_in_file: {
337
340
  ccTool: 'Edit',
338
341
  translateArgs: (a) => ({ file_path: a.path || a.filePath || a.file_path || '', old_string: a.old_string || a.old || '', new_string: a.new_string || a.new || '' }),
339
- translateBack: (a) => ({ path: a.file_path ?? '', old_string: a.old_string ?? '', new_string: a.new_string ?? '' }),
342
+ // Cline's schema requires `diff`, not old_string/new_string formatted as
343
+ // one SEARCH/REPLACE block (see replace_in_file.ts in cline/cline).
344
+ translateBack: (a) => ({ path: a.file_path ?? '', diff: `------- SEARCH\n${a.old_string ?? ''}\n=======\n${a.new_string ?? ''}\n+++++++ REPLACE` }),
340
345
  },
341
346
  // Roo Code
342
347
  apply_diff: {
@@ -513,6 +513,14 @@ export function extractTemplate(captured) {
513
513
  const STATIC_HEADER_EXCLUDE = new Set([
514
514
  // Auth — never replay across identities
515
515
  'authorization',
516
+ // x-api-key is a CAPTURE ARTIFACT (dario#42). During capture we spawn CC
517
+ // with ANTHROPIC_API_KEY=sk-dario-fingerprint-capture pointing at a loopback
518
+ // MITM, so CC emits `x-api-key: sk-dario-fingerprint-capture`. Replaying
519
+ // that placeholder upstream alongside the real OAuth Bearer used to be a
520
+ // no-op because Anthropic ignored x-api-key when Authorization was present;
521
+ // as of 2026-04-17 some account tiers now 401 with "invalid x-api-key" when
522
+ // both are sent. Never capture it.
523
+ 'x-api-key',
516
524
  // Body-framing — computed per request
517
525
  'content-type', 'content-length', 'transfer-encoding',
518
526
  // Host / connection — managed by the HTTP stack
package/dist/proxy.js CHANGED
@@ -493,8 +493,17 @@ export async function startProxy(opts = {}) {
493
493
  // Excludes auth + body-framing + session-scoped keys by construction (see
494
494
  // extractStaticHeaderValues in live-fingerprint.ts). No-op when the loaded
495
495
  // template predates v2 or the bundled snapshot is in use.
496
+ //
497
+ // `x-api-key` is filtered defensively here too — pre-v3.19.2 captures still
498
+ // carry `x-api-key: sk-dario-fingerprint-capture` from the MITM spawn env.
499
+ // Replaying that placeholder alongside a real OAuth Bearer triggers a
500
+ // "invalid x-api-key" 401 on some account tiers as of 2026-04-17 (dario#42).
501
+ // The capture filter was updated in v3.19.2 to stop storing it, but the
502
+ // per-request skip below lets existing caches self-heal without a refresh.
496
503
  if (!passthrough && CC_TEMPLATE.header_values) {
497
504
  for (const [k, v] of Object.entries(CC_TEMPLATE.header_values)) {
505
+ if (k.toLowerCase() === 'x-api-key')
506
+ continue;
498
507
  staticHeaders[k] = v;
499
508
  }
500
509
  }
@@ -507,6 +516,14 @@ export async function startProxy(opts = {}) {
507
516
  // is the single-account slot. Reported by @boeingchoco in dario#36 — the
508
517
  // retry loop was firing on every POST with hybrid-tools + OC.
509
518
  const context1mUnavailable = new Set();
519
+ // Per-account cache of anthropic-beta flags the upstream has rejected as
520
+ // "Unexpected value(s)". The live-captured template lifts whatever CC emits
521
+ // verbatim — including flags gated to higher-tier accounts (e.g.
522
+ // `afk-mode-2026-01-31` is rejected on Max 5x as of 2026-04-17). On the
523
+ // first rejection we parse the flag out of the error message, strip it,
524
+ // retry once, and cache it so subsequent requests on the same account don't
525
+ // re-pay the 400 round-trip. Keyed by account alias (pool) or `__default__`.
526
+ const unavailableBetas = new Map();
510
527
  const ACCOUNT_KEY_SINGLE = '__default__';
511
528
  // Beta flag set — sourced from the live template when the capture recorded
512
529
  // one (schema v2+), else falls back to the v2.1.104 bundled default. Same
@@ -514,7 +531,18 @@ export async function startProxy(opts = {}) {
514
531
  // never diverge on the wire). Computed once per proxy because it's a
515
532
  // function of the loaded template, not of the request.
516
533
  const BETA_FALLBACK = 'claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,effort-2025-11-24';
517
- const betaBase = CC_TEMPLATE.anthropic_beta || BETA_FALLBACK;
534
+ let betaBase = CC_TEMPLATE.anthropic_beta || BETA_FALLBACK;
535
+ // `oauth-2025-04-20` is CC's OAuth-enablement beta flag. It is NOT present in
536
+ // the live-captured beta set because dario's fingerprint capture spawns CC
537
+ // with a placeholder `ANTHROPIC_API_KEY`, and CC only appends the oauth beta
538
+ // when it's actually using an OAuth bearer token. The proxy always uses
539
+ // OAuth upstream, so the flag is required — force it in if the captured
540
+ // template didn't carry it. As of 2026-04-17 some account tiers (Max 20x,
541
+ // Pro) return `authentication_error: invalid x-api-key` without this flag
542
+ // even when a valid Bearer is sent (dario#42).
543
+ if (!passthrough && !betaBase.split(',').includes('oauth-2025-04-20')) {
544
+ betaBase = betaBase ? `${betaBase},oauth-2025-04-20` : 'oauth-2025-04-20';
545
+ }
518
546
  const betaWithoutContext1m = betaBase.split(',').filter((t) => t !== 'context-1m-2025-08-07').join(',');
519
547
  // Rate governor — minimum 500ms between requests. Fast enough for agents,
520
548
  // slow enough to not look like a scripted flood of identical traffic.
@@ -985,6 +1013,14 @@ export async function startProxy(opts = {}) {
985
1013
  if (filtered)
986
1014
  beta += ',' + filtered;
987
1015
  }
1016
+ // Strip any beta flags the upstream has previously rejected on this
1017
+ // account so we don't re-pay the 400 round-trip (dario#42 afk-mode
1018
+ // fallout: captured templates carry tier-gated flags whose availability
1019
+ // we only learn at request time).
1020
+ const rejectedSet = unavailableBetas.get(acctKey);
1021
+ if (rejectedSet && rejectedSet.size > 0) {
1022
+ beta = beta.split(',').filter((t) => t.length > 0 && !rejectedSet.has(t)).join(',');
1023
+ }
988
1024
  }
989
1025
  // Rate governor — prevent inhuman request cadence
990
1026
  const now = Date.now();
@@ -1086,7 +1122,56 @@ export async function startProxy(opts = {}) {
1086
1122
  const isLongContextError = peekedBody.includes('long context')
1087
1123
  || peekedBody.includes('Extra usage is required')
1088
1124
  || peekedBody.includes('long_context');
1089
- if (isLongContextError) {
1125
+ // Detect "Unexpected value(s) `flag-name` for the `anthropic-beta` header"
1126
+ // — the upstream's way of saying this account tier doesn't have the
1127
+ // flag. Parse out the offending tokens (there can be more than one),
1128
+ // cache them, strip, and retry.
1129
+ const betaRejectedFlags = [];
1130
+ if (upstream.status === 400 && peekedBody.includes('anthropic-beta')) {
1131
+ const re = /Unexpected value\(s\)\s+((?:`[^`]+`(?:\s*,\s*)?)+)\s+for the `anthropic-beta` header/;
1132
+ const m = peekedBody.match(re);
1133
+ if (m) {
1134
+ for (const tok of m[1].matchAll(/`([^`]+)`/g))
1135
+ betaRejectedFlags.push(tok[1]);
1136
+ }
1137
+ }
1138
+ if (betaRejectedFlags.length > 0) {
1139
+ const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_SINGLE;
1140
+ let set = unavailableBetas.get(acctKey);
1141
+ if (!set) {
1142
+ set = new Set();
1143
+ unavailableBetas.set(acctKey, set);
1144
+ }
1145
+ const newFlags = [];
1146
+ for (const f of betaRejectedFlags) {
1147
+ if (!set.has(f)) {
1148
+ set.add(f);
1149
+ newFlags.push(f);
1150
+ }
1151
+ }
1152
+ if (verbose && newFlags.length > 0)
1153
+ console.log(`[dario] #${requestCount} anthropic-beta rejected (${newFlags.join(',')}) — retrying without (cached for session)`);
1154
+ const reducedBeta = beta.split(',').filter((t) => t.length > 0 && !set.has(t)).join(',');
1155
+ const retryHeaders = { ...headers, 'anthropic-beta': reducedBeta };
1156
+ const retry = await fetch(targetBase, {
1157
+ method: req.method ?? 'POST',
1158
+ headers: passthrough ? retryHeaders : orderHeadersForOutbound(retryHeaders),
1159
+ body: finalBody ? new Uint8Array(finalBody) : undefined,
1160
+ signal: upstreamAbort.signal,
1161
+ });
1162
+ upstream = retry;
1163
+ peekedBody = null;
1164
+ if (pool && poolAccount) {
1165
+ const retrySnapshot = parseRateLimits(upstream.headers);
1166
+ if (upstream.status === 429) {
1167
+ pool.markRejected(poolAccount.alias, retrySnapshot);
1168
+ }
1169
+ else {
1170
+ pool.updateRateLimits(poolAccount.alias, retrySnapshot);
1171
+ }
1172
+ }
1173
+ }
1174
+ else if (isLongContextError) {
1090
1175
  // Cache the rejection so future requests on this account skip
1091
1176
  // context-1m up front instead of re-paying the 400/429 round-trip.
1092
1177
  const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_SINGLE;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.19.0",
3
+ "version": "3.19.2",
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": {