@askalf/dario 3.11.0 → 3.12.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/dist/analytics.d.ts +39 -16
- package/dist/analytics.js +41 -0
- package/dist/cli.js +52 -0
- package/dist/proxy.js +8 -2
- package/dist/shim/host.d.ts +59 -0
- package/dist/shim/host.js +169 -0
- package/dist/shim/runtime.cjs +186 -0
- package/package.json +3 -3
package/dist/analytics.d.ts
CHANGED
|
@@ -23,6 +23,26 @@ export interface RequestRecord {
|
|
|
23
23
|
isStream: boolean;
|
|
24
24
|
isOpenAI: boolean;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* The four billing buckets a request can land in, derived from the
|
|
28
|
+
* `anthropic-ratelimit-unified-representative-claim` response header.
|
|
29
|
+
*
|
|
30
|
+
* - `subscription` — request billed against the user's 5h subscription window (Max/Pro)
|
|
31
|
+
* - `subscription_fallback` — server-side fallback subscription bucket (rare, still covered)
|
|
32
|
+
* - `extra_usage` — overage / pay-as-you-go, paid on top of subscription
|
|
33
|
+
* - `api` — pure API key billing, no subscription involved
|
|
34
|
+
* - `unknown` — header absent or unparseable (non-200 responses, stream aborts)
|
|
35
|
+
*
|
|
36
|
+
* Exposed in `/analytics` summaries and in verbose per-request logs so
|
|
37
|
+
* users can see at a glance which bucket their traffic is actually hitting.
|
|
38
|
+
* See #34 for background.
|
|
39
|
+
*/
|
|
40
|
+
export type BillingBucket = 'subscription' | 'subscription_fallback' | 'extra_usage' | 'api' | 'unknown';
|
|
41
|
+
/**
|
|
42
|
+
* Map the raw `representative-claim` header value to a human-friendly
|
|
43
|
+
* billing bucket. Pure function; no state; safe to call from any context.
|
|
44
|
+
*/
|
|
45
|
+
export declare function billingBucketFromClaim(claim: string | null | undefined): BillingBucket;
|
|
26
46
|
export declare class Analytics {
|
|
27
47
|
private records;
|
|
28
48
|
private maxRecords;
|
|
@@ -60,27 +80,30 @@ interface PerModelStat {
|
|
|
60
80
|
avgThinkingTokens: number;
|
|
61
81
|
estimatedCost: number;
|
|
62
82
|
}
|
|
83
|
+
interface WindowStats {
|
|
84
|
+
totalInputTokens: number;
|
|
85
|
+
totalOutputTokens: number;
|
|
86
|
+
totalThinkingTokens: number;
|
|
87
|
+
estimatedCost: number;
|
|
88
|
+
avgLatencyMs: number;
|
|
89
|
+
errorRate: number;
|
|
90
|
+
claimBreakdown: Record<string, number>;
|
|
91
|
+
/** Count of requests in each derived billing bucket. See #34. */
|
|
92
|
+
billingBucketBreakdown: Record<BillingBucket, number>;
|
|
93
|
+
/**
|
|
94
|
+
* Percentage of *classified* requests (non-unknown) that hit a
|
|
95
|
+
* subscription bucket. The headline number for "is dario routing me
|
|
96
|
+
* through my subscription?" — should be 100% for a clean setup. See #34.
|
|
97
|
+
*/
|
|
98
|
+
subscriptionPercent: number;
|
|
99
|
+
}
|
|
63
100
|
export interface AnalyticsSummary {
|
|
64
|
-
window: {
|
|
101
|
+
window: WindowStats & {
|
|
65
102
|
minutes: number;
|
|
66
103
|
requests: number;
|
|
67
|
-
totalInputTokens: number;
|
|
68
|
-
totalOutputTokens: number;
|
|
69
|
-
totalThinkingTokens: number;
|
|
70
|
-
estimatedCost: number;
|
|
71
|
-
avgLatencyMs: number;
|
|
72
|
-
errorRate: number;
|
|
73
|
-
claimBreakdown: Record<string, number>;
|
|
74
104
|
};
|
|
75
|
-
allTime: {
|
|
105
|
+
allTime: WindowStats & {
|
|
76
106
|
requests: number;
|
|
77
|
-
totalInputTokens: number;
|
|
78
|
-
totalOutputTokens: number;
|
|
79
|
-
totalThinkingTokens: number;
|
|
80
|
-
estimatedCost: number;
|
|
81
|
-
avgLatencyMs: number;
|
|
82
|
-
errorRate: number;
|
|
83
|
-
claimBreakdown: Record<string, number>;
|
|
84
107
|
};
|
|
85
108
|
perAccount: Record<string, PerAccountStat>;
|
|
86
109
|
perModel: Record<string, PerModelStat>;
|
package/dist/analytics.js
CHANGED
|
@@ -5,6 +5,24 @@
|
|
|
5
5
|
* In-memory rolling window; exposed via the /analytics endpoint when
|
|
6
6
|
* pool mode is active.
|
|
7
7
|
*/
|
|
8
|
+
/**
|
|
9
|
+
* Map the raw `representative-claim` header value to a human-friendly
|
|
10
|
+
* billing bucket. Pure function; no state; safe to call from any context.
|
|
11
|
+
*/
|
|
12
|
+
export function billingBucketFromClaim(claim) {
|
|
13
|
+
switch (claim) {
|
|
14
|
+
case 'five_hour':
|
|
15
|
+
return 'subscription';
|
|
16
|
+
case 'five_hour_fallback':
|
|
17
|
+
return 'subscription_fallback';
|
|
18
|
+
case 'overage':
|
|
19
|
+
return 'extra_usage';
|
|
20
|
+
case 'api':
|
|
21
|
+
return 'api';
|
|
22
|
+
default:
|
|
23
|
+
return 'unknown';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
8
26
|
// Anthropic pricing (per 1M tokens, USD). Not authoritative — used for
|
|
9
27
|
// rough burn-rate display in the /analytics summary.
|
|
10
28
|
const PRICING = {
|
|
@@ -74,6 +92,14 @@ export class Analytics {
|
|
|
74
92
|
totalInputTokens: 0, totalOutputTokens: 0, totalThinkingTokens: 0,
|
|
75
93
|
estimatedCost: 0, avgLatencyMs: 0, errorRate: 0,
|
|
76
94
|
claimBreakdown: {},
|
|
95
|
+
billingBucketBreakdown: {
|
|
96
|
+
subscription: 0,
|
|
97
|
+
subscription_fallback: 0,
|
|
98
|
+
extra_usage: 0,
|
|
99
|
+
api: 0,
|
|
100
|
+
unknown: 0,
|
|
101
|
+
},
|
|
102
|
+
subscriptionPercent: 0,
|
|
77
103
|
};
|
|
78
104
|
}
|
|
79
105
|
const totalInput = records.reduce((s, r) => s + r.inputTokens, 0);
|
|
@@ -83,9 +109,22 @@ export class Analytics {
|
|
|
83
109
|
const avgLatency = records.reduce((s, r) => s + r.latencyMs, 0) / records.length;
|
|
84
110
|
const errors = records.filter(r => r.status >= 400).length;
|
|
85
111
|
const claims = {};
|
|
112
|
+
const buckets = {
|
|
113
|
+
subscription: 0,
|
|
114
|
+
subscription_fallback: 0,
|
|
115
|
+
extra_usage: 0,
|
|
116
|
+
api: 0,
|
|
117
|
+
unknown: 0,
|
|
118
|
+
};
|
|
86
119
|
for (const r of records) {
|
|
87
120
|
claims[r.claim] = (claims[r.claim] ?? 0) + 1;
|
|
121
|
+
buckets[billingBucketFromClaim(r.claim)]++;
|
|
88
122
|
}
|
|
123
|
+
const subscriptionHits = buckets.subscription + buckets.subscription_fallback;
|
|
124
|
+
const billedRequests = records.length - buckets.unknown;
|
|
125
|
+
const subscriptionPct = billedRequests > 0
|
|
126
|
+
? Math.round((subscriptionHits / billedRequests) * 10000) / 100
|
|
127
|
+
: 0;
|
|
89
128
|
return {
|
|
90
129
|
totalInputTokens: totalInput,
|
|
91
130
|
totalOutputTokens: totalOutput,
|
|
@@ -94,6 +133,8 @@ export class Analytics {
|
|
|
94
133
|
avgLatencyMs: Math.round(avgLatency),
|
|
95
134
|
errorRate: Math.round((errors / records.length) * 10000) / 10000,
|
|
96
135
|
claimBreakdown: claims,
|
|
136
|
+
billingBucketBreakdown: buckets,
|
|
137
|
+
subscriptionPercent: subscriptionPct,
|
|
97
138
|
};
|
|
98
139
|
}
|
|
99
140
|
perAccountStats(records) {
|
package/dist/cli.js
CHANGED
|
@@ -366,6 +366,8 @@ async function help() {
|
|
|
366
366
|
dario backend add NAME --key=sk-... [--base-url=...]
|
|
367
367
|
Add an OpenAI-compat backend (OpenAI, OpenRouter, Groq, etc.)
|
|
368
368
|
dario backend remove N Remove an OpenAI-compat backend
|
|
369
|
+
dario shim -- CMD ARGS Run CMD inside the dario shim (experimental,
|
|
370
|
+
stealth fingerprint via in-process fetch patch)
|
|
369
371
|
|
|
370
372
|
Proxy options:
|
|
371
373
|
--model=MODEL Force a model for all requests
|
|
@@ -402,6 +404,55 @@ async function help() {
|
|
|
402
404
|
Tokens auto-refresh in the background — set it and forget it.
|
|
403
405
|
`);
|
|
404
406
|
}
|
|
407
|
+
async function shim() {
|
|
408
|
+
// dario shim -- <command> [args...]
|
|
409
|
+
// The `--` separator is conventional but optional; if the user omits it
|
|
410
|
+
// we just pass everything after `shim` through to the child.
|
|
411
|
+
const rest = args.slice(1);
|
|
412
|
+
const sepIdx = rest.indexOf('--');
|
|
413
|
+
let verbose = false;
|
|
414
|
+
let head;
|
|
415
|
+
let childArgs;
|
|
416
|
+
if (sepIdx >= 0) {
|
|
417
|
+
head = rest.slice(0, sepIdx);
|
|
418
|
+
childArgs = rest.slice(sepIdx + 1);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
head = [];
|
|
422
|
+
childArgs = rest;
|
|
423
|
+
}
|
|
424
|
+
for (const flag of head) {
|
|
425
|
+
if (flag === '-v' || flag === '--verbose')
|
|
426
|
+
verbose = true;
|
|
427
|
+
else {
|
|
428
|
+
console.error(`Unknown shim flag: ${flag}`);
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (childArgs.length === 0) {
|
|
433
|
+
console.error('Usage: dario shim [-v] -- <command> [args...]');
|
|
434
|
+
console.error('Example: dario shim -- claude --print -p "hi"');
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
const { runShim } = await import('./shim/host.js');
|
|
438
|
+
try {
|
|
439
|
+
const result = await runShim({
|
|
440
|
+
command: childArgs[0],
|
|
441
|
+
args: childArgs.slice(1),
|
|
442
|
+
verbose,
|
|
443
|
+
});
|
|
444
|
+
if (verbose) {
|
|
445
|
+
const summary = result.analytics.summary(60);
|
|
446
|
+
console.error(`[dario shim] ${result.events.length} relay events, ` +
|
|
447
|
+
`subscriptionPercent=${summary.window.subscriptionPercent}%`);
|
|
448
|
+
}
|
|
449
|
+
process.exit(result.exitCode);
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
console.error('shim failed:', sanitizeError(err));
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
405
456
|
async function version() {
|
|
406
457
|
try {
|
|
407
458
|
const { fileURLToPath } = await import('node:url');
|
|
@@ -423,6 +474,7 @@ const commands = {
|
|
|
423
474
|
logout,
|
|
424
475
|
accounts,
|
|
425
476
|
backend,
|
|
477
|
+
shim,
|
|
426
478
|
help,
|
|
427
479
|
version,
|
|
428
480
|
'--help': help,
|
package/dist/proxy.js
CHANGED
|
@@ -8,7 +8,7 @@ import { arch, platform } from 'node:process';
|
|
|
8
8
|
import { getAccessToken, getStatus } from './oauth.js';
|
|
9
9
|
import { buildCCRequest, reverseMapResponse, createStreamingReverseMapper } from './cc-template.js';
|
|
10
10
|
import { AccountPool, parseRateLimits } from './pool.js';
|
|
11
|
-
import { Analytics } from './analytics.js';
|
|
11
|
+
import { Analytics, billingBucketFromClaim } from './analytics.js';
|
|
12
12
|
import { loadAllAccounts, loadAccount, refreshAccountToken } from './accounts.js';
|
|
13
13
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
14
14
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
@@ -1039,7 +1039,13 @@ export async function startProxy(opts = {}) {
|
|
|
1039
1039
|
else {
|
|
1040
1040
|
overagePct = 'n/a';
|
|
1041
1041
|
}
|
|
1042
|
-
|
|
1042
|
+
// Show the derived billing bucket as the headline, with the raw
|
|
1043
|
+
// claim value in parens so power users still see the header as-is.
|
|
1044
|
+
// See #34 — users want "am I actually on subscription?" answered
|
|
1045
|
+
// at a glance instead of having to memorize that `five_hour` means
|
|
1046
|
+
// "yes, subscription."
|
|
1047
|
+
const bucket = billingBucketFromClaim(billingClaim);
|
|
1048
|
+
console.log(`[dario] #${requestCount} billing: ${bucket} (${billingClaim}, overage: ${overagePct})`);
|
|
1043
1049
|
}
|
|
1044
1050
|
else if (verbose) {
|
|
1045
1051
|
console.log(`[dario] #${requestCount} billing: headers absent (status=${upstream.status})`);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shim host — dario-side of the shim transport.
|
|
3
|
+
*
|
|
4
|
+
* Runs `dario shim -- <cmd> [args...]`. Spawns the child with NODE_OPTIONS
|
|
5
|
+
* pointing at the shim runtime, listens on a unix socket (or named pipe on
|
|
6
|
+
* Windows) for billing relay events from the runtime, feeds them into the
|
|
7
|
+
* Analytics class, and forwards the child's stdio to the host TTY so the
|
|
8
|
+
* user experience is identical to running the command directly.
|
|
9
|
+
*
|
|
10
|
+
* Why a socket and not a file or stdout: the runtime patches `globalThis.fetch`
|
|
11
|
+
* inside the *child*'s process — that child still owns its own stdout (the
|
|
12
|
+
* user's TTY), and we don't want shim relay traffic interleaved with CC's
|
|
13
|
+
* normal output. A unix socket gives us a clean side-channel, lets the host
|
|
14
|
+
* keep accumulating analytics across the child's lifetime, and stays open if
|
|
15
|
+
* the child re-execs (rare but possible with claude wrappers).
|
|
16
|
+
*
|
|
17
|
+
* See v3.12.0 CHANGELOG for the design rationale.
|
|
18
|
+
*/
|
|
19
|
+
import { Analytics } from './../analytics.js';
|
|
20
|
+
/**
|
|
21
|
+
* Locate the shim runtime CJS file. In the published package it lives at
|
|
22
|
+
* `dist/shim/runtime.cjs` next to this module's compiled output. In dev
|
|
23
|
+
* (running via tsx from src/) it lives at `src/shim/runtime.cjs`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function locateShimRuntime(): string;
|
|
26
|
+
interface RelayEvent {
|
|
27
|
+
kind: 'request' | 'response';
|
|
28
|
+
timestamp: number;
|
|
29
|
+
bytes?: number;
|
|
30
|
+
status?: number;
|
|
31
|
+
claim?: string | null;
|
|
32
|
+
overageUtil?: number | null;
|
|
33
|
+
}
|
|
34
|
+
export interface ShimHostOptions {
|
|
35
|
+
/** Command to spawn (the user's claude binary, or any node-based CC wrapper). */
|
|
36
|
+
command: string;
|
|
37
|
+
/** Args passed through to the child. */
|
|
38
|
+
args: string[];
|
|
39
|
+
/** Override the template path the runtime reads. Defaults to ~/.dario/cc-template.live.json. */
|
|
40
|
+
templatePath?: string;
|
|
41
|
+
/** Print per-event lines to stderr. */
|
|
42
|
+
verbose?: boolean;
|
|
43
|
+
/** Optional Analytics sink. If omitted, a fresh instance is created. */
|
|
44
|
+
analytics?: Analytics;
|
|
45
|
+
}
|
|
46
|
+
export interface ShimHostResult {
|
|
47
|
+
exitCode: number;
|
|
48
|
+
events: RelayEvent[];
|
|
49
|
+
analytics: Analytics;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Spawn the child command with the shim runtime injected, relay billing
|
|
53
|
+
* events to Analytics, return when the child exits.
|
|
54
|
+
*
|
|
55
|
+
* Stdio is inherited so the user sees the child's output exactly as if they
|
|
56
|
+
* had run it without the shim.
|
|
57
|
+
*/
|
|
58
|
+
export declare function runShim(opts: ShimHostOptions): Promise<ShimHostResult>;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shim host — dario-side of the shim transport.
|
|
3
|
+
*
|
|
4
|
+
* Runs `dario shim -- <cmd> [args...]`. Spawns the child with NODE_OPTIONS
|
|
5
|
+
* pointing at the shim runtime, listens on a unix socket (or named pipe on
|
|
6
|
+
* Windows) for billing relay events from the runtime, feeds them into the
|
|
7
|
+
* Analytics class, and forwards the child's stdio to the host TTY so the
|
|
8
|
+
* user experience is identical to running the command directly.
|
|
9
|
+
*
|
|
10
|
+
* Why a socket and not a file or stdout: the runtime patches `globalThis.fetch`
|
|
11
|
+
* inside the *child*'s process — that child still owns its own stdout (the
|
|
12
|
+
* user's TTY), and we don't want shim relay traffic interleaved with CC's
|
|
13
|
+
* normal output. A unix socket gives us a clean side-channel, lets the host
|
|
14
|
+
* keep accumulating analytics across the child's lifetime, and stays open if
|
|
15
|
+
* the child re-execs (rare but possible with claude wrappers).
|
|
16
|
+
*
|
|
17
|
+
* See v3.12.0 CHANGELOG for the design rationale.
|
|
18
|
+
*/
|
|
19
|
+
import { createServer } from 'node:net';
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { mkdtempSync, existsSync } from 'node:fs';
|
|
22
|
+
import { tmpdir, homedir } from 'node:os';
|
|
23
|
+
import { join, dirname } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { Analytics } from './../analytics.js';
|
|
26
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
27
|
+
const __dirname = dirname(__filename);
|
|
28
|
+
/**
|
|
29
|
+
* Locate the shim runtime CJS file. In the published package it lives at
|
|
30
|
+
* `dist/shim/runtime.cjs` next to this module's compiled output. In dev
|
|
31
|
+
* (running via tsx from src/) it lives at `src/shim/runtime.cjs`.
|
|
32
|
+
*/
|
|
33
|
+
export function locateShimRuntime() {
|
|
34
|
+
const candidates = [
|
|
35
|
+
join(__dirname, 'runtime.cjs'), // dist/shim/runtime.cjs (production)
|
|
36
|
+
join(__dirname, '..', '..', 'src', 'shim', 'runtime.cjs'), // dev: from dist/shim → ../../src/shim
|
|
37
|
+
join(__dirname, '..', 'src', 'shim', 'runtime.cjs'), // dev: from src/shim → ../src/shim (rare)
|
|
38
|
+
];
|
|
39
|
+
for (const c of candidates) {
|
|
40
|
+
if (existsSync(c))
|
|
41
|
+
return c;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`shim runtime not found; checked: ${candidates.join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Pick a socket path: unix domain socket on POSIX, named pipe on Windows.
|
|
47
|
+
* Both forms are accepted directly by net.createServer / net.connect.
|
|
48
|
+
*/
|
|
49
|
+
function makeSockPath() {
|
|
50
|
+
if (process.platform === 'win32') {
|
|
51
|
+
return `\\\\.\\pipe\\dario-shim-${process.pid}-${Date.now()}`;
|
|
52
|
+
}
|
|
53
|
+
const dir = mkdtempSync(join(tmpdir(), 'dario-shim-'));
|
|
54
|
+
return join(dir, 'sock');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build the child env. We *prepend* our --require to NODE_OPTIONS rather than
|
|
58
|
+
* overwrite, so existing user NODE_OPTIONS (debuggers, source maps, tracers)
|
|
59
|
+
* still apply. Quoting paths defends against spaces in the dario install dir.
|
|
60
|
+
*/
|
|
61
|
+
function buildChildEnv(parentEnv, runtimePath, sockPath, templatePath, verbose) {
|
|
62
|
+
const requireFlag = `--require=${JSON.stringify(runtimePath)}`;
|
|
63
|
+
const existing = parentEnv.NODE_OPTIONS ?? '';
|
|
64
|
+
const NODE_OPTIONS = existing ? `${requireFlag} ${existing}` : requireFlag;
|
|
65
|
+
return {
|
|
66
|
+
...parentEnv,
|
|
67
|
+
NODE_OPTIONS,
|
|
68
|
+
DARIO_SHIM: '1',
|
|
69
|
+
DARIO_SHIM_SOCK: sockPath,
|
|
70
|
+
DARIO_SHIM_TEMPLATE: templatePath,
|
|
71
|
+
...(verbose ? { DARIO_SHIM_VERBOSE: '1' } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Stream parser: relay events arrive as newline-delimited JSON over the socket. */
|
|
75
|
+
function makeSocketHandler(events, analytics, verbose) {
|
|
76
|
+
return (sock) => {
|
|
77
|
+
let buf = '';
|
|
78
|
+
sock.setEncoding('utf-8');
|
|
79
|
+
sock.on('data', (chunk) => {
|
|
80
|
+
buf += chunk;
|
|
81
|
+
let nl;
|
|
82
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
83
|
+
const line = buf.slice(0, nl);
|
|
84
|
+
buf = buf.slice(nl + 1);
|
|
85
|
+
if (!line)
|
|
86
|
+
continue;
|
|
87
|
+
try {
|
|
88
|
+
const event = JSON.parse(line);
|
|
89
|
+
events.push(event);
|
|
90
|
+
if (event.kind === 'response') {
|
|
91
|
+
// Synthesize a minimal RequestRecord so this surfaces in /analytics.
|
|
92
|
+
// Token counts aren't available from the shim transport — the runtime
|
|
93
|
+
// would need to parse the SSE stream to extract them, which we
|
|
94
|
+
// explicitly chose not to do (it's expensive and intrusive). So this
|
|
95
|
+
// is a request-count + claim-tracking record, not a token-cost record.
|
|
96
|
+
analytics.record({
|
|
97
|
+
timestamp: event.timestamp ?? Date.now(),
|
|
98
|
+
account: 'shim',
|
|
99
|
+
model: 'unknown',
|
|
100
|
+
inputTokens: 0, outputTokens: 0,
|
|
101
|
+
cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
102
|
+
claim: event.claim ?? '',
|
|
103
|
+
util5h: 0, util7d: 0,
|
|
104
|
+
overageUtil: event.overageUtil ?? 0,
|
|
105
|
+
latencyMs: 0,
|
|
106
|
+
status: event.status ?? 0,
|
|
107
|
+
isStream: false,
|
|
108
|
+
isOpenAI: false,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (verbose) {
|
|
112
|
+
process.stderr.write(`[dario shim] ${JSON.stringify(event)}\n`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Malformed line — drop silently. The runtime is best-effort.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** Internal: stand up the socket server and resolve when it's listening. */
|
|
123
|
+
function startSocketServer(sockPath, events, analytics, verbose) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const server = createServer(makeSocketHandler(events, analytics, verbose));
|
|
126
|
+
server.once('error', reject);
|
|
127
|
+
server.listen(sockPath, () => resolve(server));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Spawn the child command with the shim runtime injected, relay billing
|
|
132
|
+
* events to Analytics, return when the child exits.
|
|
133
|
+
*
|
|
134
|
+
* Stdio is inherited so the user sees the child's output exactly as if they
|
|
135
|
+
* had run it without the shim.
|
|
136
|
+
*/
|
|
137
|
+
export async function runShim(opts) {
|
|
138
|
+
const runtimePath = locateShimRuntime();
|
|
139
|
+
const sockPath = makeSockPath();
|
|
140
|
+
const templatePath = opts.templatePath ?? join(homedir(), '.dario', 'cc-template.live.json');
|
|
141
|
+
const verbose = opts.verbose ?? false;
|
|
142
|
+
const analytics = opts.analytics ?? new Analytics();
|
|
143
|
+
const events = [];
|
|
144
|
+
const server = await startSocketServer(sockPath, events, analytics, verbose);
|
|
145
|
+
let child;
|
|
146
|
+
try {
|
|
147
|
+
child = spawn(opts.command, opts.args, {
|
|
148
|
+
stdio: 'inherit',
|
|
149
|
+
env: buildChildEnv(process.env, runtimePath, sockPath, templatePath, verbose),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
server.close();
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
const exitCode = await new Promise((resolve) => {
|
|
157
|
+
child.on('exit', (code, signal) => {
|
|
158
|
+
if (signal)
|
|
159
|
+
resolve(128 + (signal === 'SIGTERM' ? 15 : 1));
|
|
160
|
+
else
|
|
161
|
+
resolve(code ?? 0);
|
|
162
|
+
});
|
|
163
|
+
child.on('error', () => resolve(1));
|
|
164
|
+
});
|
|
165
|
+
// Give any in-flight relay writes a brief window to land before tearing down.
|
|
166
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
167
|
+
server.close();
|
|
168
|
+
return { exitCode, events, analytics };
|
|
169
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
let template = null;
|
|
35
|
+
function loadTemplate() {
|
|
36
|
+
if (template) return template;
|
|
37
|
+
try {
|
|
38
|
+
const raw = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (parsed && parsed.agent_identity && parsed.system_prompt && Array.isArray(parsed.tools)) {
|
|
41
|
+
template = parsed;
|
|
42
|
+
log(`template loaded from ${TEMPLATE_PATH} (cc_version=${parsed.cc_version || 'unknown'})`);
|
|
43
|
+
return template;
|
|
44
|
+
}
|
|
45
|
+
log(`template at ${TEMPLATE_PATH} missing required fields — passthrough`);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
log(`template load failed: ${e.message} — passthrough`);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let relaySock = null;
|
|
53
|
+
function relay(event) {
|
|
54
|
+
if (!RELAY_SOCK) return;
|
|
55
|
+
try {
|
|
56
|
+
if (!relaySock) {
|
|
57
|
+
relaySock = net.createConnection(RELAY_SOCK);
|
|
58
|
+
relaySock.on('error', () => { relaySock = null; });
|
|
59
|
+
}
|
|
60
|
+
relaySock.write(JSON.stringify(event) + '\n');
|
|
61
|
+
} catch (_) { /* relay is best-effort */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isAnthropicMessages(url) {
|
|
65
|
+
try {
|
|
66
|
+
const u = typeof url === 'string' ? new URL(url) : url;
|
|
67
|
+
return /(^|\.)anthropic\.com$/.test(u.hostname) && u.pathname === '/v1/messages';
|
|
68
|
+
} catch (_) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rewriteBody(bodyText, tmpl) {
|
|
74
|
+
let body;
|
|
75
|
+
try { body = JSON.parse(bodyText); } catch (_) { return null; }
|
|
76
|
+
if (!body || typeof body !== 'object') return null;
|
|
77
|
+
|
|
78
|
+
// CC system shape: array of 3 blocks — billing tag, agent identity, system prompt.
|
|
79
|
+
// We replace blocks [1] and [2] with template values; block [0] (the billing tag)
|
|
80
|
+
// is left alone since the host process owns its OAuth context.
|
|
81
|
+
if (Array.isArray(body.system) && body.system.length >= 1) {
|
|
82
|
+
const billingTag = body.system[0];
|
|
83
|
+
body.system = [
|
|
84
|
+
billingTag,
|
|
85
|
+
{ type: 'text', text: tmpl.agent_identity, cache_control: { type: 'ephemeral', ttl: '1h' } },
|
|
86
|
+
{ type: 'text', text: tmpl.system_prompt, cache_control: { type: 'ephemeral', ttl: '1h' } },
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
body.tools = tmpl.tools;
|
|
90
|
+
return JSON.stringify(body);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function rewriteHeaders(headers, tmpl) {
|
|
94
|
+
// Headers in fetch() init can be Headers, plain object, or array of pairs.
|
|
95
|
+
// Normalize into a plain object so we can mutate, then return Headers.
|
|
96
|
+
const out = new Headers(headers || {});
|
|
97
|
+
if (tmpl.cc_version) {
|
|
98
|
+
out.set('user-agent', `claude-cli/${tmpl.cc_version} (external, cli)`);
|
|
99
|
+
out.set('x-anthropic-billing-header', `cc_version=${tmpl.cc_version}`);
|
|
100
|
+
}
|
|
101
|
+
out.set('anthropic-beta', tmpl.anthropic_beta || 'claude-code-20250219');
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function shouldIntercept(input, init) {
|
|
106
|
+
const method = (init && init.method) || (input && input.method) || 'GET';
|
|
107
|
+
if (String(method).toUpperCase() !== 'POST') return false;
|
|
108
|
+
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
|
109
|
+
return isAnthropicMessages(url);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const originalFetch = globalThis.fetch;
|
|
113
|
+
|
|
114
|
+
function installFetchPatch() {
|
|
115
|
+
if (typeof originalFetch !== 'function') {
|
|
116
|
+
log('globalThis.fetch is not a function — shim disabled');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
globalThis.fetch = darioShimFetch;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function darioShimFetch(input, init) {
|
|
123
|
+
try {
|
|
124
|
+
if (!shouldIntercept(input, init)) {
|
|
125
|
+
return originalFetch.call(this, input, init);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const tmpl = loadTemplate();
|
|
129
|
+
if (!tmpl) return originalFetch.call(this, input, init);
|
|
130
|
+
|
|
131
|
+
let bodyText;
|
|
132
|
+
if (init && typeof init.body === 'string') {
|
|
133
|
+
bodyText = init.body;
|
|
134
|
+
} else if (input && typeof input.text === 'function') {
|
|
135
|
+
bodyText = await input.clone().text();
|
|
136
|
+
} else {
|
|
137
|
+
log('unsupported body shape — passthrough');
|
|
138
|
+
return originalFetch.call(this, input, init);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const rewritten = rewriteBody(bodyText, tmpl);
|
|
142
|
+
if (!rewritten) {
|
|
143
|
+
log('body rewrite failed — passthrough');
|
|
144
|
+
return originalFetch.call(this, input, init);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const newInit = Object.assign({}, init || {}, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
body: rewritten,
|
|
150
|
+
headers: rewriteHeaders((init && init.headers) || (input && input.headers), tmpl),
|
|
151
|
+
});
|
|
152
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
153
|
+
|
|
154
|
+
relay({ kind: 'request', timestamp: Date.now(), bytes: rewritten.length });
|
|
155
|
+
const response = await originalFetch.call(this, url, newInit);
|
|
156
|
+
|
|
157
|
+
const claim = response.headers.get('anthropic-ratelimit-unified-representative-claim');
|
|
158
|
+
const overage = response.headers.get('anthropic-ratelimit-unified-overage-utilization');
|
|
159
|
+
relay({
|
|
160
|
+
kind: 'response',
|
|
161
|
+
timestamp: Date.now(),
|
|
162
|
+
status: response.status,
|
|
163
|
+
claim: claim || null,
|
|
164
|
+
overageUtil: overage ? parseFloat(overage) : null,
|
|
165
|
+
});
|
|
166
|
+
return response;
|
|
167
|
+
} catch (e) {
|
|
168
|
+
log(`shim fetch error: ${e.message} — passthrough`);
|
|
169
|
+
return originalFetch.call(this, input, init);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (process.env.DARIO_SHIM === '1') {
|
|
174
|
+
installFetchPatch();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Internal hooks for unit tests. Always exported so tests can require this
|
|
178
|
+
// file without setting DARIO_SHIM (which would patch the test process's fetch).
|
|
179
|
+
module.exports = {
|
|
180
|
+
_rewriteBody: rewriteBody,
|
|
181
|
+
_rewriteHeaders: rewriteHeaders,
|
|
182
|
+
_shouldIntercept: shouldIntercept,
|
|
183
|
+
_isAnthropicMessages: isAnthropicMessages,
|
|
184
|
+
_darioShimFetch: darioShimFetch,
|
|
185
|
+
_installFetchPatch: installFetchPatch,
|
|
186
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.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/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/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",
|