@askalf/dario 2.3.0 → 2.4.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.
Files changed (2) hide show
  1. package/dist/proxy.js +164 -8
  2. package/package.json +1 -1
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 or model override
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 && (isOpenAI || modelOverride)) {
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,11 +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
- // Merge client beta flags with defaults
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.
439
577
  const clientBeta = req.headers['anthropic-beta'];
440
- let beta = 'oauth-2025-04-20,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05,claude-code-20250219,context-management-2025-06-27';
441
- if (clientBeta)
442
- beta += ',' + clientBeta.split(',').map(f => f.trim()).filter(f => f.length > 0 && f.length < 100).join(',');
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
+ }
443
584
  const headers = {
444
585
  ...staticHeaders,
445
586
  'Authorization': `Bearer ${accessToken}`,
@@ -516,6 +657,21 @@ export async function startProxy(opts = {}) {
516
657
  else {
517
658
  // Buffer and forward
518
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
+ }
519
675
  if (isOpenAI && upstream.status >= 200 && upstream.status < 300) {
520
676
  // Translate Anthropic response → OpenAI format
521
677
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Use your Claude subscription as an API. No API key needed. Local proxy for Claude Max/Pro subscriptions.",
5
5
  "type": "module",
6
6
  "bin": {