@askalf/dario 2.3.1 → 2.4.1
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 +42 -7
- package/dist/proxy.js +164 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<h1 align="center">dario</h1>
|
|
3
|
-
<p align="center"><strong>Use your Claude subscription as an API.</strong></p>
|
|
3
|
+
<p align="center"><strong>Use your Claude subscription as an API. The only proxy that bills correctly.</strong></p>
|
|
4
4
|
<p align="center">
|
|
5
|
-
No API key needed. Your Claude Max/Pro subscription becomes a local API endpoint<br/>that any tool, SDK, or framework can use.
|
|
5
|
+
No API key needed. Your Claude Max/Pro subscription becomes a local API endpoint<br/>that any tool, SDK, or framework can use — with native billing classification,<br/>so your Max plan limits actually work.
|
|
6
6
|
</p>
|
|
7
7
|
</p>
|
|
8
8
|
|
|
@@ -66,6 +66,37 @@ Opus, Sonnet, Haiku — all models, streaming, tool use. Works with Cursor, Cont
|
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
69
|
+
## Why dario
|
|
70
|
+
|
|
71
|
+
Most Claude subscription proxies have a critical billing problem: **Anthropic classifies their requests as third-party and routes all usage to Extra Usage billing** — even when you have Max plan limits available. You're paying for your subscription twice.
|
|
72
|
+
|
|
73
|
+
dario is the only proxy that solves this. It injects native Claude Code device identity (`metadata.user_id`) into every request, so Anthropic's billing system treats your requests exactly like Claude Code itself. Your Max plan limits work correctly.
|
|
74
|
+
|
|
75
|
+
| | dario | Other proxies |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| **Billing classification** | Native Claude Code session | Third-party (Extra Usage) |
|
|
78
|
+
| **Max plan limits** | Used correctly | Bypassed — billed separately |
|
|
79
|
+
| **Device identity** | Injected automatically | Missing |
|
|
80
|
+
| **Beta flags** | Match Claude Code v2.1.98 | Outdated or missing |
|
|
81
|
+
| **Billable beta filtering** | Strips surprise charges | Passes everything through |
|
|
82
|
+
|
|
83
|
+
<details>
|
|
84
|
+
<summary><strong>vs competitors</strong></summary>
|
|
85
|
+
|
|
86
|
+
| Feature | dario | Meridian (710 stars) | CLIProxyAPI (24K stars) | claude-code-mux |
|
|
87
|
+
|---------|-------|---------|------------|-----------------|
|
|
88
|
+
| Native billing classification | **Yes** | No | Inherited (CLI-only) | No |
|
|
89
|
+
| Direct OAuth (streaming, tools) | **Yes** | Yes (SDK-based) | No | No |
|
|
90
|
+
| CLI fallback (rate limit bypass) | **Yes** | No | Yes (only mode) | No |
|
|
91
|
+
| OpenAI API compat | **Yes** | Yes | Yes | Yes |
|
|
92
|
+
| Orchestration sanitization | **Yes** | Yes | No | No |
|
|
93
|
+
| Token anomaly detection | **Yes** | Yes | No | No |
|
|
94
|
+
| Codebase size | ~1,200 lines | ~9,000 lines | Platform | Rust binary |
|
|
95
|
+
| Dependencies | 1 | Many | Many | Compiled |
|
|
96
|
+
| Setup | 2 commands | Config + build | Config + dashboard | Config |
|
|
97
|
+
|
|
98
|
+
</details>
|
|
99
|
+
|
|
69
100
|
## The Problem
|
|
70
101
|
|
|
71
102
|
You pay $100-200/mo for Claude Max or Pro. But that subscription only works on claude.ai and Claude Code. If you want to use Claude with **any other tool** — Cursor, Continue, Aider, your own scripts — you need a separate API key with separate billing.
|
|
@@ -349,13 +380,17 @@ Then run `hermes` normally — it routes through dario using your Claude subscri
|
|
|
349
380
|
## Supported Features
|
|
350
381
|
|
|
351
382
|
### Direct API Mode
|
|
352
|
-
- All Claude models (Opus 4.6, Sonnet 4.6, Haiku 4.5)
|
|
383
|
+
- All Claude models (Opus 4.6, Sonnet 4.6, Haiku 4.5) + 1M extended context aliases (`opus1m`, `sonnet1m`)
|
|
384
|
+
- **Native billing classification** — device identity metadata ensures Max plan limits work correctly
|
|
353
385
|
- **OpenAI-compatible** (`/v1/chat/completions`) — works with any OpenAI SDK or tool
|
|
354
|
-
- Streaming and non-streaming (both Anthropic and OpenAI SSE formats)
|
|
386
|
+
- Streaming and non-streaming (both Anthropic and OpenAI SSE formats, including tool_use streaming)
|
|
355
387
|
- Tool use / function calling
|
|
356
388
|
- System prompts and multi-turn conversations
|
|
357
389
|
- Prompt caching and extended thinking
|
|
358
|
-
-
|
|
390
|
+
- **Billable beta filtering** — strips `extended-cache-ttl`, `context-management`, `prompt-caching-scope` from client betas to prevent surprise Extra Usage charges
|
|
391
|
+
- **Orchestration tag sanitization** — strips agent-injected XML (`<system-reminder>`, `<env>`, `<task_metadata>`, etc.) before forwarding
|
|
392
|
+
- **Token anomaly detection** — warns on context spike (>60% input growth) or output explosion (>2x previous)
|
|
393
|
+
- Concurrency control (max 10 concurrent upstream requests)
|
|
359
394
|
- CORS enabled (works from browser apps on localhost)
|
|
360
395
|
|
|
361
396
|
### CLI Backend Mode
|
|
@@ -458,7 +493,7 @@ Dario handles your OAuth tokens. Here's why you can trust it:
|
|
|
458
493
|
|
|
459
494
|
| Signal | Status |
|
|
460
495
|
|--------|--------|
|
|
461
|
-
| **Source code** | ~
|
|
496
|
+
| **Source code** | ~1,300 lines of TypeScript — small enough to audit in one sitting |
|
|
462
497
|
| **Dependencies** | 1 production dep (`@anthropic-ai/sdk`). Verify: `npm ls --production` |
|
|
463
498
|
| **npm provenance** | Every release is [SLSA attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions |
|
|
464
499
|
| **Security scanning** | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) runs on every push and weekly |
|
|
@@ -480,7 +515,7 @@ cd $(npm root -g)/@askalf/dario && npm ls --production
|
|
|
480
515
|
|
|
481
516
|
## Contributing
|
|
482
517
|
|
|
483
|
-
PRs welcome. The codebase is ~
|
|
518
|
+
PRs welcome. The codebase is ~1,300 lines of TypeScript across 4 files:
|
|
484
519
|
|
|
485
520
|
| File | Purpose |
|
|
486
521
|
|------|---------|
|
package/dist/proxy.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { execSync, spawn } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
4
7
|
import { arch, platform, version as nodeVersion } from 'node:process';
|
|
5
8
|
import { getAccessToken, getStatus } from './oauth.js';
|
|
6
9
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
@@ -45,12 +48,120 @@ function detectClaudeVersion() {
|
|
|
45
48
|
}
|
|
46
49
|
const SESSION_ID = randomUUID();
|
|
47
50
|
const OS_NAME = platform === 'win32' ? 'Windows' : platform === 'darwin' ? 'MacOS' : 'Linux';
|
|
51
|
+
// Claude Code device identity — required for Max plan billing classification.
|
|
52
|
+
// Without metadata.user_id, Anthropic classifies requests as third-party and
|
|
53
|
+
// routes them to Extra Usage billing instead of the Max plan allocation.
|
|
54
|
+
function loadClaudeIdentity() {
|
|
55
|
+
const paths = [
|
|
56
|
+
join(homedir(), '.claude.json'), // Windows / Linux / macOS (live config)
|
|
57
|
+
join(homedir(), '.claude', '.claude.json'), // Alternative location
|
|
58
|
+
join(homedir(), '.claude', 'claude.json'),
|
|
59
|
+
];
|
|
60
|
+
// Also check backup files as fallback
|
|
61
|
+
try {
|
|
62
|
+
const backupDir = join(homedir(), '.claude', 'backups');
|
|
63
|
+
const files = require('fs').readdirSync(backupDir);
|
|
64
|
+
const backups = files
|
|
65
|
+
.filter((f) => f.startsWith('.claude.json.backup.'))
|
|
66
|
+
.sort()
|
|
67
|
+
.reverse();
|
|
68
|
+
for (const b of backups)
|
|
69
|
+
paths.push(join(backupDir, b));
|
|
70
|
+
}
|
|
71
|
+
catch { /* no backups dir */ }
|
|
72
|
+
for (const p of paths) {
|
|
73
|
+
try {
|
|
74
|
+
const data = JSON.parse(readFileSync(p, 'utf-8'));
|
|
75
|
+
if (data.userID) {
|
|
76
|
+
// accountUuid lives inside oauthAccount, not at root
|
|
77
|
+
const accountUuid = data.oauthAccount?.accountUuid ?? data.accountUuid ?? '';
|
|
78
|
+
return { deviceId: data.userID, accountUuid };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch { /* try next */ }
|
|
82
|
+
}
|
|
83
|
+
return { deviceId: '', accountUuid: '' };
|
|
84
|
+
}
|
|
48
85
|
// Model shortcuts — users can pass short names
|
|
49
86
|
const MODEL_ALIASES = {
|
|
50
87
|
'opus': 'claude-opus-4-6',
|
|
88
|
+
'opus1m': 'claude-opus-4-6[1m]',
|
|
51
89
|
'sonnet': 'claude-sonnet-4-6',
|
|
90
|
+
'sonnet1m': 'claude-sonnet-4-6[1m]',
|
|
52
91
|
'haiku': 'claude-haiku-4-5',
|
|
53
92
|
};
|
|
93
|
+
// Beta prefixes that trigger Extra Usage billing on Max subscriptions.
|
|
94
|
+
// Stripping these prevents surprise charges while keeping caching/thinking/1M active.
|
|
95
|
+
const BILLABLE_BETA_PREFIXES = [
|
|
96
|
+
'extended-cache-ttl-', // Extended cache TTLs beyond default
|
|
97
|
+
'context-management-', // Auto-compaction / context management
|
|
98
|
+
'prompt-caching-scope-', // Extended prompt caching scope
|
|
99
|
+
];
|
|
100
|
+
/** Filter out billable betas from client-provided beta header. */
|
|
101
|
+
function filterBillableBetas(betas) {
|
|
102
|
+
return betas.split(',').map(b => b.trim()).filter(b => b.length > 0 && !BILLABLE_BETA_PREFIXES.some(p => b.startsWith(p))).join(',');
|
|
103
|
+
}
|
|
104
|
+
// Orchestration tags injected by agents (Aider, Cursor, OpenCode, etc.)
|
|
105
|
+
// that confuse Claude when passed through. Strip before forwarding.
|
|
106
|
+
const ORCHESTRATION_TAG_NAMES = [
|
|
107
|
+
'system-reminder', 'env', 'system_information', 'current_working_directory',
|
|
108
|
+
'operating_system', 'default_shell', 'home_directory', 'task_metadata',
|
|
109
|
+
'tool_exec', 'tool_output', 'skill_content', 'skill_files',
|
|
110
|
+
'directories', 'available_skills', 'thinking',
|
|
111
|
+
];
|
|
112
|
+
const ORCHESTRATION_PATTERNS = ORCHESTRATION_TAG_NAMES.flatMap(tag => [
|
|
113
|
+
new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'gi'),
|
|
114
|
+
new RegExp(`<${tag}\\b[^>]*\\/>`, 'gi'),
|
|
115
|
+
]);
|
|
116
|
+
/** Strip orchestration wrapper tags from message content. */
|
|
117
|
+
function sanitizeContent(text) {
|
|
118
|
+
let result = text;
|
|
119
|
+
for (const pattern of ORCHESTRATION_PATTERNS) {
|
|
120
|
+
pattern.lastIndex = 0;
|
|
121
|
+
result = result.replace(pattern, '');
|
|
122
|
+
}
|
|
123
|
+
return result.replace(/\n{3,}/g, '\n\n').trim();
|
|
124
|
+
}
|
|
125
|
+
/** Strip orchestration tags from all messages in a request body. */
|
|
126
|
+
function sanitizeMessages(body) {
|
|
127
|
+
const messages = body.messages;
|
|
128
|
+
if (!messages)
|
|
129
|
+
return;
|
|
130
|
+
for (const msg of messages) {
|
|
131
|
+
if (typeof msg.content === 'string') {
|
|
132
|
+
msg.content = sanitizeContent(msg.content);
|
|
133
|
+
}
|
|
134
|
+
else if (Array.isArray(msg.content)) {
|
|
135
|
+
for (const block of msg.content) {
|
|
136
|
+
if (typeof block === 'object' && block && 'text' in block && typeof block.text === 'string') {
|
|
137
|
+
block.text = sanitizeContent(block.text);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let lastTokenSnapshot = null;
|
|
144
|
+
function checkTokenAnomalies(usage, requestId) {
|
|
145
|
+
const current = {
|
|
146
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
147
|
+
outputTokens: usage.output_tokens ?? 0,
|
|
148
|
+
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
149
|
+
};
|
|
150
|
+
if (lastTokenSnapshot && lastTokenSnapshot.inputTokens > 0) {
|
|
151
|
+
const growth = (current.inputTokens - lastTokenSnapshot.inputTokens) / lastTokenSnapshot.inputTokens;
|
|
152
|
+
if (growth > 0.6) {
|
|
153
|
+
const pct = Math.round(growth * 100);
|
|
154
|
+
console.warn(`[dario] TOKEN WARN ${requestId}: Input grew ${pct}% (${lastTokenSnapshot.inputTokens} → ${current.inputTokens}). Possible full replay.`);
|
|
155
|
+
}
|
|
156
|
+
if (current.outputTokens > lastTokenSnapshot.outputTokens * 2 && current.outputTokens > 2000) {
|
|
157
|
+
console.warn(`[dario] TOKEN WARN ${requestId}: Output explosion ${current.outputTokens} tokens (${Math.round(current.outputTokens / lastTokenSnapshot.outputTokens)}x previous).`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
lastTokenSnapshot = current;
|
|
161
|
+
}
|
|
162
|
+
// Extended context fallback — cooldown after 1M context failure
|
|
163
|
+
let extendedContextUnavailableAt = 0;
|
|
164
|
+
const EXTENDED_CONTEXT_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
|
|
54
165
|
// OpenAI model names → Anthropic (fallback if client sends GPT names)
|
|
55
166
|
const OPENAI_MODEL_MAP = {
|
|
56
167
|
'gpt-5.4': 'claude-opus-4-6',
|
|
@@ -255,6 +366,14 @@ export async function startProxy(opts = {}) {
|
|
|
255
366
|
}
|
|
256
367
|
const cliVersion = detectClaudeVersion();
|
|
257
368
|
const modelOverride = opts.model ? (MODEL_ALIASES[opts.model] ?? opts.model) : null;
|
|
369
|
+
const identity = loadClaudeIdentity();
|
|
370
|
+
if (identity.deviceId) {
|
|
371
|
+
console.log(' Device identity: detected');
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
console.warn('[dario] WARNING: No Claude Code device identity found. Requests may be billed as Extra Usage.');
|
|
375
|
+
console.warn('[dario] Run Claude Code at least once to generate ~/.claude/.claude.json');
|
|
376
|
+
}
|
|
258
377
|
// Pre-build static headers (only auth, version, beta, request-id change per request)
|
|
259
378
|
const staticHeaders = {
|
|
260
379
|
'accept': 'application/json',
|
|
@@ -352,11 +471,12 @@ export async function startProxy(opts = {}) {
|
|
|
352
471
|
// Detect OpenAI-format requests
|
|
353
472
|
const isOpenAI = urlPath === '/v1/chat/completions';
|
|
354
473
|
// Allowlisted API paths — only these are proxied (prevents SSRF)
|
|
474
|
+
// ?beta=true matches native Claude Code behavior for billing classification
|
|
355
475
|
const allowedPaths = {
|
|
356
|
-
'/v1/messages': `${ANTHROPIC_API}/v1/messages`,
|
|
476
|
+
'/v1/messages': `${ANTHROPIC_API}/v1/messages?beta=true`,
|
|
357
477
|
'/v1/complete': `${ANTHROPIC_API}/v1/complete`,
|
|
358
478
|
};
|
|
359
|
-
const targetBase = isOpenAI ? `${ANTHROPIC_API}/v1/messages` : allowedPaths[urlPath];
|
|
479
|
+
const targetBase = isOpenAI ? `${ANTHROPIC_API}/v1/messages?beta=true` : allowedPaths[urlPath];
|
|
360
480
|
if (!targetBase) {
|
|
361
481
|
res.writeHead(403, JSON_HEADERS);
|
|
362
482
|
res.end(ERR_FORBIDDEN);
|
|
@@ -421,12 +541,28 @@ export async function startProxy(opts = {}) {
|
|
|
421
541
|
res.end(cliResult.body);
|
|
422
542
|
return;
|
|
423
543
|
}
|
|
424
|
-
// Parse body once, apply OpenAI translation
|
|
544
|
+
// Parse body once, apply OpenAI translation, model override, and sanitization
|
|
425
545
|
let finalBody = body.length > 0 ? body : undefined;
|
|
426
|
-
if (body.length > 0
|
|
546
|
+
if (body.length > 0) {
|
|
427
547
|
try {
|
|
428
548
|
const parsed = JSON.parse(body.toString());
|
|
549
|
+
// Strip orchestration tags from messages (Aider, Cursor, etc.)
|
|
550
|
+
sanitizeMessages(parsed);
|
|
551
|
+
// Handle 1M context: strip [1m] suffix if in cooldown
|
|
552
|
+
if (modelOverride?.includes('[1m]') && extendedContextUnavailableAt > 0 && Date.now() - extendedContextUnavailableAt < EXTENDED_CONTEXT_COOLDOWN_MS) {
|
|
553
|
+
parsed.model = modelOverride.replace('[1m]', '');
|
|
554
|
+
}
|
|
429
555
|
const result = isOpenAI ? openaiToAnthropic(parsed, modelOverride) : (modelOverride ? { ...parsed, model: modelOverride } : parsed);
|
|
556
|
+
// Inject device identity metadata — required for Max plan billing classification
|
|
557
|
+
if (identity.deviceId && typeof result === 'object' && result !== null) {
|
|
558
|
+
result.metadata = {
|
|
559
|
+
user_id: JSON.stringify({
|
|
560
|
+
device_id: identity.deviceId,
|
|
561
|
+
account_uuid: identity.accountUuid,
|
|
562
|
+
session_id: SESSION_ID,
|
|
563
|
+
}),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
430
566
|
finalBody = Buffer.from(JSON.stringify(result));
|
|
431
567
|
}
|
|
432
568
|
catch { /* not JSON, send as-is */ }
|
|
@@ -435,14 +571,16 @@ export async function startProxy(opts = {}) {
|
|
|
435
571
|
const modelInfo = modelOverride ? ` (model: ${modelOverride})` : '';
|
|
436
572
|
console.log(`[dario] #${requestCount} ${req.method} ${urlPath}${modelInfo}`);
|
|
437
573
|
}
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
// Client-provided betas pass through — the client controls its own feature set.
|
|
574
|
+
// Beta flags matching native Claude Code v2.1.98.
|
|
575
|
+
// context-management and prompt-caching-scope are safe when metadata.user_id
|
|
576
|
+
// is present — billing classification depends on device identity, not betas.
|
|
442
577
|
const clientBeta = req.headers['anthropic-beta'];
|
|
443
|
-
let beta = 'oauth-2025-04-20,interleaved-thinking-2025-05-14,claude-code-20250219';
|
|
444
|
-
if (clientBeta)
|
|
445
|
-
|
|
578
|
+
let beta = 'oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219,advisor-tool-2026-03-01';
|
|
579
|
+
if (clientBeta) {
|
|
580
|
+
const filtered = filterBillableBetas(clientBeta);
|
|
581
|
+
if (filtered)
|
|
582
|
+
beta += ',' + filtered;
|
|
583
|
+
}
|
|
446
584
|
const headers = {
|
|
447
585
|
...staticHeaders,
|
|
448
586
|
'Authorization': `Bearer ${accessToken}`,
|
|
@@ -519,6 +657,21 @@ export async function startProxy(opts = {}) {
|
|
|
519
657
|
else {
|
|
520
658
|
// Buffer and forward
|
|
521
659
|
const responseBody = await upstream.text();
|
|
660
|
+
// Check for extended context failure — cooldown to avoid repeated failures
|
|
661
|
+
if (upstream.status === 400 && responseBody.includes('extra_usage') && modelOverride?.includes('[1m]')) {
|
|
662
|
+
extendedContextUnavailableAt = Date.now();
|
|
663
|
+
console.warn('[dario] 1M context requires Extra Usage — falling back to standard context for 1 hour');
|
|
664
|
+
}
|
|
665
|
+
// Token anomaly detection on non-streaming responses
|
|
666
|
+
if (upstream.status >= 200 && upstream.status < 300) {
|
|
667
|
+
try {
|
|
668
|
+
const parsed = JSON.parse(responseBody);
|
|
669
|
+
const usage = parsed.usage;
|
|
670
|
+
if (usage)
|
|
671
|
+
checkTokenAnomalies(usage, responseHeaders['request-id'] ?? '');
|
|
672
|
+
}
|
|
673
|
+
catch { /* ignore parse errors */ }
|
|
674
|
+
}
|
|
522
675
|
if (isOpenAI && upstream.status >= 200 && upstream.status < 300) {
|
|
523
676
|
// Translate Anthropic response → OpenAI format
|
|
524
677
|
try {
|