@askalf/dario 3.11.1 → 3.13.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.
@@ -0,0 +1,312 @@
1
+ // dario shim runtime — loaded into a CC child process via NODE_OPTIONS=--require
2
+ //
3
+ // CommonJS by necessity: --require only accepts CJS. Hand-written, no build step.
4
+ //
5
+ // Responsibilities, in order of importance:
6
+ // 1. Patch globalThis.fetch so outbound POSTs to *.anthropic.com/v1/messages
7
+ // are rewritten with the dario template (system blocks, tools, fingerprint headers).
8
+ // 2. Peek the response headers and relay billing markers
9
+ // (anthropic-ratelimit-unified-representative-claim and friends) to the
10
+ // dario host over a unix/named-pipe socket if DARIO_SHIM_SOCK is set.
11
+ // 3. Be invisible when DARIO_SHIM is unset — so dario can install the require
12
+ // globally without breaking unrelated Node processes.
13
+ // 4. Failsafe: any internal error falls through to the original fetch. The shim
14
+ // must never break the host process. CC's retry/auth/streaming logic stays intact.
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+ const net = require('net');
20
+ const path = require('path');
21
+ const os = require('os');
22
+
23
+ const TEMPLATE_PATH = process.env.DARIO_SHIM_TEMPLATE
24
+ || path.join(os.homedir(), '.dario', 'cc-template.live.json');
25
+ const RELAY_SOCK = process.env.DARIO_SHIM_SOCK || null;
26
+ const VERBOSE = process.env.DARIO_SHIM_VERBOSE === '1';
27
+
28
+ function log(msg) {
29
+ if (VERBOSE) {
30
+ try { process.stderr.write(`[dario-shim] ${msg}\n`); } catch (_) { /* noop */ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Detect the JS runtime we've been loaded into. Shim was designed for
36
+ * Node — Bun ships its own fetch + undici with slightly different
37
+ * internals, and Deno's fetch is a completely different implementation.
38
+ * Patching globalThis.fetch works in all three, but body/header semantics
39
+ * may drift. We log a warning for non-Node runtimes so surprising
40
+ * behavior is traceable to the root cause.
41
+ *
42
+ * When Anthropic eventually ships a Bun-compiled / single-binary CC,
43
+ * this detector is the canary — a user running `dario shim -- claude ...`
44
+ * against a Bun CC will see the warning and know to expect quirks.
45
+ */
46
+ function detectRuntime() {
47
+ if (typeof globalThis.Bun !== 'undefined') return 'bun';
48
+ if (typeof globalThis.Deno !== 'undefined') return 'deno';
49
+ if (typeof process !== 'undefined' && process.versions && process.versions.node) return 'node';
50
+ return 'unknown';
51
+ }
52
+
53
+ const RUNTIME = detectRuntime();
54
+ if (RUNTIME !== 'node') {
55
+ log(`running under ${RUNTIME} — shim was validated against Node. Body/header semantics may differ.`);
56
+ }
57
+
58
+ let template = null;
59
+ let templateMtime = 0;
60
+
61
+ /**
62
+ * Load the template, re-reading from disk if the file's mtime has changed.
63
+ * Auto-refresh matters for long-running shim sessions: dario's live
64
+ * fingerprint capture may update the template file mid-session (daily
65
+ * refresh), and we'd like the shim to pick up the new version without
66
+ * requiring a child restart.
67
+ *
68
+ * Cached in memory between calls so we don't stat on every intercept.
69
+ */
70
+ function loadTemplate() {
71
+ try {
72
+ const stat = fs.statSync(TEMPLATE_PATH);
73
+ if (template && stat.mtimeMs === templateMtime) return template;
74
+ const raw = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
75
+ const parsed = JSON.parse(raw);
76
+ if (parsed && parsed.agent_identity && parsed.system_prompt && Array.isArray(parsed.tools)) {
77
+ const prevVersion = template && template.cc_version;
78
+ template = parsed;
79
+ templateMtime = stat.mtimeMs;
80
+ if (prevVersion && prevVersion !== parsed.cc_version) {
81
+ log(`template reloaded: cc_version ${prevVersion} → ${parsed.cc_version}`);
82
+ } else {
83
+ log(`template loaded from ${TEMPLATE_PATH} (cc_version=${parsed.cc_version || 'unknown'}${
84
+ Array.isArray(parsed.header_order) ? `, header_order=${parsed.header_order.length}` : ''
85
+ })`);
86
+ }
87
+ return template;
88
+ }
89
+ log(`template at ${TEMPLATE_PATH} missing required fields — passthrough`);
90
+ } catch (e) {
91
+ if (e.code !== 'ENOENT') log(`template load failed: ${e.message} — passthrough`);
92
+ }
93
+ return null;
94
+ }
95
+
96
+ let relaySock = null;
97
+ function relay(event) {
98
+ if (!RELAY_SOCK) return;
99
+ try {
100
+ if (!relaySock) {
101
+ relaySock = net.createConnection(RELAY_SOCK);
102
+ relaySock.on('error', () => { relaySock = null; });
103
+ }
104
+ relaySock.write(JSON.stringify(event) + '\n');
105
+ } catch (_) { /* relay is best-effort */ }
106
+ }
107
+
108
+ function isAnthropicMessages(url) {
109
+ try {
110
+ const u = typeof url === 'string' ? new URL(url) : url;
111
+ return /(^|\.)anthropic\.com$/.test(u.hostname) && u.pathname === '/v1/messages';
112
+ } catch (_) {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function rewriteBody(bodyText, tmpl) {
118
+ let body;
119
+ try { body = JSON.parse(bodyText); } catch (_) { return null; }
120
+ if (!body || typeof body !== 'object') return null;
121
+
122
+ // Defensive shape check. Real CC sends:
123
+ // system: [billing_tag, agent_identity, system_prompt] (length 3, all text blocks)
124
+ // If we see anything else — a one-element system, a four-element system,
125
+ // an image block in system[0], CC shipping a restructured system array
126
+ // in a future release — passthrough instead of rewriting. Blindly
127
+ // replacing blocks we don't understand can corrupt the request in ways
128
+ // that break the child silently (think: 400 with "unexpected block type").
129
+ //
130
+ // The old logic accepted `length >= 1`, creating [1] and [2] out of thin
131
+ // air when they didn't exist. That's a recipe for template drift incidents
132
+ // when CC's shape changes. Strict check, log, passthrough on mismatch.
133
+ if (!Array.isArray(body.system) || body.system.length !== 3) {
134
+ log(`body rewrite skipped: system has ${Array.isArray(body.system) ? body.system.length : 'no'} blocks, expected 3`);
135
+ return null;
136
+ }
137
+ const allText = body.system.every((b) =>
138
+ b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string',
139
+ );
140
+ if (!allText) {
141
+ log('body rewrite skipped: system contains non-text blocks');
142
+ return null;
143
+ }
144
+
145
+ const billingTag = body.system[0];
146
+ body.system = [
147
+ billingTag,
148
+ { type: 'text', text: tmpl.agent_identity, cache_control: { type: 'ephemeral', ttl: '1h' } },
149
+ { type: 'text', text: tmpl.system_prompt, cache_control: { type: 'ephemeral', ttl: '1h' } },
150
+ ];
151
+ body.tools = tmpl.tools;
152
+ return JSON.stringify(body);
153
+ }
154
+
155
+ function rewriteHeaders(headers, tmpl) {
156
+ // Headers in fetch() init can be Headers, plain object, or array of pairs.
157
+ // We normalize into a Map (lowercased keys, insertion-order iteration),
158
+ // then return an array of [name, value] pairs — a valid HeadersInit —
159
+ // which fetch() will serialize on the wire in our exact order.
160
+ //
161
+ // A plain Headers object won't do: per the fetch spec, Headers iteration
162
+ // is sorted alphabetically, so building a Headers with sets in order
163
+ // would succeed internally but iteration (and any downstream code that
164
+ // reads via for...of) would see sorted order. Using an array bypasses
165
+ // that entirely — the HTTP layer writes pairs in array order.
166
+ //
167
+ // This is the v3.13 "hide in the population" hook: when the live capture
168
+ // has recorded CC's header sequence, we replay it on every outbound
169
+ // request so Anthropic sees the same shape we observed.
170
+ const src = new Headers(headers || {});
171
+ const snapshot = new Map();
172
+ for (const [name, value] of src) {
173
+ snapshot.set(name.toLowerCase(), value);
174
+ }
175
+ if (tmpl.cc_version) {
176
+ snapshot.set('user-agent', `claude-cli/${tmpl.cc_version} (external, cli)`);
177
+ snapshot.set('x-anthropic-billing-header', `cc_version=${tmpl.cc_version}`);
178
+ }
179
+ snapshot.set('anthropic-beta', tmpl.anthropic_beta || 'claude-code-20250219');
180
+
181
+ if (!Array.isArray(tmpl.header_order) || tmpl.header_order.length === 0) {
182
+ return [...snapshot.entries()];
183
+ }
184
+
185
+ // Rebuild in the captured order. Any header the caller supplied that
186
+ // wasn't in the captured order gets appended at the end so we don't
187
+ // silently drop host-added headers (content-type, content-length).
188
+ const ordered = [];
189
+ const seen = new Set();
190
+ for (const name of tmpl.header_order) {
191
+ const key = name.toLowerCase();
192
+ if (snapshot.has(key)) {
193
+ ordered.push([key, snapshot.get(key)]);
194
+ seen.add(key);
195
+ }
196
+ }
197
+ for (const [key, value] of snapshot) {
198
+ if (!seen.has(key)) {
199
+ ordered.push([key, value]);
200
+ }
201
+ }
202
+ return ordered;
203
+ }
204
+
205
+ /**
206
+ * Warn when the child's user-agent cc_version differs from the template's.
207
+ * Useful signal during a CC upgrade: the user installed a new CC but the
208
+ * live template cache is stale, so we're about to fingerprint as an older
209
+ * version than the actual CC binary. The shim still works — we overwrite
210
+ * the user-agent regardless — but logging the drift makes debugging
211
+ * easier when a user reports "Anthropic started seeing me as 2.1.200 even
212
+ * though I'm running 2.1.250".
213
+ */
214
+ function checkVersionDrift(headers, tmpl) {
215
+ if (!tmpl || !tmpl.cc_version) return;
216
+ try {
217
+ const h = new Headers(headers || {});
218
+ const ua = h.get('user-agent') || '';
219
+ const match = ua.match(/claude-cli\/(\d+\.\d+\.\d+)/);
220
+ if (match && match[1] && match[1] !== tmpl.cc_version) {
221
+ log(`version drift: child cc_version=${match[1]}, template cc_version=${tmpl.cc_version} — shim will impersonate template version`);
222
+ }
223
+ } catch (_) { /* noop */ }
224
+ }
225
+
226
+ function shouldIntercept(input, init) {
227
+ const method = (init && init.method) || (input && input.method) || 'GET';
228
+ if (String(method).toUpperCase() !== 'POST') return false;
229
+ const url = typeof input === 'string' ? input : (input && input.url) || '';
230
+ return isAnthropicMessages(url);
231
+ }
232
+
233
+ const originalFetch = globalThis.fetch;
234
+
235
+ function installFetchPatch() {
236
+ if (typeof originalFetch !== 'function') {
237
+ log('globalThis.fetch is not a function — shim disabled');
238
+ return;
239
+ }
240
+ globalThis.fetch = darioShimFetch;
241
+ }
242
+
243
+ async function darioShimFetch(input, init) {
244
+ try {
245
+ if (!shouldIntercept(input, init)) {
246
+ return originalFetch.call(this, input, init);
247
+ }
248
+
249
+ const tmpl = loadTemplate();
250
+ if (!tmpl) return originalFetch.call(this, input, init);
251
+
252
+ let bodyText;
253
+ if (init && typeof init.body === 'string') {
254
+ bodyText = init.body;
255
+ } else if (input && typeof input.text === 'function') {
256
+ bodyText = await input.clone().text();
257
+ } else {
258
+ log('unsupported body shape — passthrough');
259
+ return originalFetch.call(this, input, init);
260
+ }
261
+
262
+ const rewritten = rewriteBody(bodyText, tmpl);
263
+ if (!rewritten) {
264
+ log('body rewrite failed — passthrough');
265
+ return originalFetch.call(this, input, init);
266
+ }
267
+
268
+ const srcHeaders = (init && init.headers) || (input && input.headers);
269
+ checkVersionDrift(srcHeaders, tmpl);
270
+ const newInit = Object.assign({}, init || {}, {
271
+ method: 'POST',
272
+ body: rewritten,
273
+ headers: rewriteHeaders(srcHeaders, tmpl),
274
+ });
275
+ const url = typeof input === 'string' ? input : input.url;
276
+
277
+ relay({ kind: 'request', timestamp: Date.now(), bytes: rewritten.length });
278
+ const response = await originalFetch.call(this, url, newInit);
279
+
280
+ const claim = response.headers.get('anthropic-ratelimit-unified-representative-claim');
281
+ const overage = response.headers.get('anthropic-ratelimit-unified-overage-utilization');
282
+ relay({
283
+ kind: 'response',
284
+ timestamp: Date.now(),
285
+ status: response.status,
286
+ claim: claim || null,
287
+ overageUtil: overage ? parseFloat(overage) : null,
288
+ });
289
+ return response;
290
+ } catch (e) {
291
+ log(`shim fetch error: ${e.message} — passthrough`);
292
+ return originalFetch.call(this, input, init);
293
+ }
294
+ };
295
+
296
+ if (process.env.DARIO_SHIM === '1') {
297
+ installFetchPatch();
298
+ }
299
+
300
+ // Internal hooks for unit tests. Always exported so tests can require this
301
+ // file without setting DARIO_SHIM (which would patch the test process's fetch).
302
+ module.exports = {
303
+ _rewriteBody: rewriteBody,
304
+ _rewriteHeaders: rewriteHeaders,
305
+ _checkVersionDrift: checkVersionDrift,
306
+ _detectRuntime: detectRuntime,
307
+ _loadTemplate: loadTemplate,
308
+ _shouldIntercept: shouldIntercept,
309
+ _isAnthropicMessages: isAnthropicMessages,
310
+ _darioShimFetch: darioShimFetch,
311
+ _installFetchPatch: installFetchPatch,
312
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.11.1",
3
+ "version": "3.13.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": {
@@ -20,8 +20,8 @@
20
20
  "LICENSE"
21
21
  ],
22
22
  "scripts": {
23
- "build": "tsc && cp src/cc-template-data.json dist/",
24
- "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/live-fingerprint.mjs",
23
+ "build": "tsc && cp src/cc-template-data.json dist/ && node -e \"require('fs').mkdirSync('dist/shim',{recursive:true})\" && cp src/shim/runtime.cjs dist/shim/",
24
+ "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs",
25
25
  "audit": "npm audit --production --audit-level=high",
26
26
  "prepublishOnly": "npm run build",
27
27
  "start": "node dist/cli.js",