@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.
- package/README.md +44 -4
- package/dist/cc-template.js +15 -0
- package/dist/cli.js +52 -0
- package/dist/live-fingerprint.d.ts +82 -0
- package/dist/live-fingerprint.js +94 -0
- package/dist/pool.d.ts +48 -0
- package/dist/pool.js +99 -1
- package/dist/proxy.js +168 -2
- package/dist/sealed-pool.d.ts +202 -0
- package/dist/sealed-pool.js +416 -0
- package/dist/shim/host.d.ts +59 -0
- package/dist/shim/host.js +169 -0
- package/dist/shim/runtime.cjs +312 -0
- package/package.json +3 -3
|
@@ -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.
|
|
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",
|