@kylindc/ccxray 1.2.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/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.ja.md +144 -0
- package/README.md +145 -0
- package/README.zh-TW.md +144 -0
- package/package.json +46 -0
- package/public/app.js +99 -0
- package/public/cost-budget-ui.js +305 -0
- package/public/entry-rendering.js +535 -0
- package/public/index.html +119 -0
- package/public/intercept-ui.js +335 -0
- package/public/keyboard-nav.js +208 -0
- package/public/messages.js +750 -0
- package/public/miller-columns.js +1686 -0
- package/public/quota-ticker.js +67 -0
- package/public/style.css +431 -0
- package/public/system-prompt-ui.js +327 -0
- package/server/auth.js +34 -0
- package/server/bedrock-credentials.js +141 -0
- package/server/config.js +190 -0
- package/server/cost-budget.js +220 -0
- package/server/cost-worker.js +110 -0
- package/server/eventstream.js +148 -0
- package/server/forward.js +683 -0
- package/server/helpers.js +393 -0
- package/server/hub.js +418 -0
- package/server/index.js +551 -0
- package/server/pricing.js +133 -0
- package/server/restore.js +141 -0
- package/server/routes/api.js +123 -0
- package/server/routes/costs.js +124 -0
- package/server/routes/intercept.js +89 -0
- package/server/routes/sse.js +44 -0
- package/server/sigv4.js +104 -0
- package/server/sse-broadcast.js +71 -0
- package/server/storage/index.js +36 -0
- package/server/storage/interface.js +26 -0
- package/server/storage/local.js +79 -0
- package/server/storage/s3.js +91 -0
- package/server/store.js +108 -0
- package/server/system-prompt.js +150 -0
package/server/config.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { createStorage } = require('./storage');
|
|
7
|
+
|
|
8
|
+
// ── Config ──────────────────────────────────────────────────────────
|
|
9
|
+
const PORT = parseInt(process.env.PROXY_PORT || '5577', 10);
|
|
10
|
+
const ANTHROPIC_HOST = process.env.ANTHROPIC_TEST_HOST || 'api.anthropic.com';
|
|
11
|
+
const ANTHROPIC_PORT = parseInt(process.env.ANTHROPIC_TEST_PORT || '443', 10);
|
|
12
|
+
const ANTHROPIC_PROTOCOL = process.env.ANTHROPIC_TEST_PROTOCOL || 'https';
|
|
13
|
+
const LOGS_DIR = path.join(os.homedir(), '.ccxray', 'logs');
|
|
14
|
+
const LEGACY_LOGS_DIR = path.join(__dirname, '..', 'logs');
|
|
15
|
+
const RESTORE_DAYS = parseInt(process.env.RESTORE_DAYS || '3', 10);
|
|
16
|
+
|
|
17
|
+
// ── Bedrock config ───────────────────────────────────────────────────
|
|
18
|
+
const BEDROCK_REGION = process.env.BEDROCK_REGION || '';
|
|
19
|
+
const BEDROCK_PROFILE_ARN = process.env.BEDROCK_PROFILE_ARN || '';
|
|
20
|
+
const BEDROCK_MODEL_ID = process.env.BEDROCK_MODEL_ID || '';
|
|
21
|
+
const BEDROCK_BEARER_TOKEN = process.env.BEDROCK_BEARER_TOKEN || '';
|
|
22
|
+
const _CLAUDE_CODE_USE_BEDROCK = process.env.CLAUDE_CODE_USE_BEDROCK || '';
|
|
23
|
+
|
|
24
|
+
// IS_BEDROCK_MODE is true when any activation trigger is set.
|
|
25
|
+
// index.js also sets this to true when --bedrock CLI flag is found.
|
|
26
|
+
let IS_BEDROCK_MODE = !!(BEDROCK_REGION || _CLAUDE_CODE_USE_BEDROCK === '1' || _CLAUDE_CODE_USE_BEDROCK === 'true');
|
|
27
|
+
|
|
28
|
+
// Region resolution: BEDROCK_REGION → AWS_REGION → AWS_DEFAULT_REGION → us-east-1
|
|
29
|
+
const BEDROCK_RESOLVED_REGION = BEDROCK_REGION
|
|
30
|
+
|| process.env.AWS_REGION
|
|
31
|
+
|| process.env.AWS_DEFAULT_REGION
|
|
32
|
+
|| 'us-east-1';
|
|
33
|
+
|
|
34
|
+
// Bedrock activation source label (for startup log)
|
|
35
|
+
const BEDROCK_ACTIVATION_SOURCE = BEDROCK_REGION ? 'BEDROCK_REGION'
|
|
36
|
+
: (_CLAUDE_CODE_USE_BEDROCK === '1' || _CLAUDE_CODE_USE_BEDROCK === 'true') ? 'CLAUDE_CODE_USE_BEDROCK'
|
|
37
|
+
: '--bedrock flag';
|
|
38
|
+
|
|
39
|
+
// Optional: override Bedrock endpoint host/port for testing
|
|
40
|
+
const BEDROCK_TEST_HOST = process.env.BEDROCK_TEST_HOST || '';
|
|
41
|
+
const BEDROCK_TEST_PORT = parseInt(process.env.BEDROCK_TEST_PORT || '443', 10);
|
|
42
|
+
const BEDROCK_TEST_PROTOCOL = process.env.BEDROCK_TEST_PROTOCOL || 'https';
|
|
43
|
+
|
|
44
|
+
// Resolved credentials (set by index.js at startup in Bedrock mode)
|
|
45
|
+
let BEDROCK_CREDENTIALS = null;
|
|
46
|
+
|
|
47
|
+
// ── Anthropic → Bedrock model ID mapping table ───────────────────────
|
|
48
|
+
// Keys are Anthropic model ID prefixes, longest match wins.
|
|
49
|
+
const BEDROCK_MODEL_MAP = {
|
|
50
|
+
// Claude 4 family
|
|
51
|
+
'claude-opus-4-20250514': 'anthropic.claude-opus-4-20250514-v1:0',
|
|
52
|
+
'claude-sonnet-4-20250514': 'anthropic.claude-sonnet-4-20250514-v1:0',
|
|
53
|
+
'claude-haiku-4-20250514': 'anthropic.claude-haiku-4-20250514-v1:0',
|
|
54
|
+
'claude-opus-4': 'anthropic.claude-opus-4-20250514-v1:0',
|
|
55
|
+
'claude-sonnet-4': 'anthropic.claude-sonnet-4-20250514-v1:0',
|
|
56
|
+
'claude-haiku-4': 'anthropic.claude-haiku-4-20250514-v1:0',
|
|
57
|
+
// Claude 3.7
|
|
58
|
+
'claude-3-7-sonnet-20250219': 'anthropic.claude-3-7-sonnet-20250219-v1:0',
|
|
59
|
+
'claude-3-7-sonnet': 'anthropic.claude-3-7-sonnet-20250219-v1:0',
|
|
60
|
+
// Claude 3.5
|
|
61
|
+
'claude-3-5-sonnet-20241022': 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
|
62
|
+
'claude-3-5-sonnet-20240620': 'anthropic.claude-3-5-sonnet-20240620-v1:0',
|
|
63
|
+
'claude-3-5-sonnet': 'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
|
64
|
+
'claude-3-5-haiku-20241022': 'anthropic.claude-3-5-haiku-20241022-v1:0',
|
|
65
|
+
'claude-3-5-haiku': 'anthropic.claude-3-5-haiku-20241022-v1:0',
|
|
66
|
+
// Claude 3
|
|
67
|
+
'claude-3-opus-20240229': 'anthropic.claude-3-opus-20240229-v1:0',
|
|
68
|
+
'claude-3-opus': 'anthropic.claude-3-opus-20240229-v1:0',
|
|
69
|
+
'claude-3-sonnet-20240229': 'anthropic.claude-3-sonnet-20240229-v1:0',
|
|
70
|
+
'claude-3-sonnet': 'anthropic.claude-3-sonnet-20240229-v1:0',
|
|
71
|
+
'claude-3-haiku-20240307': 'anthropic.claude-3-haiku-20240307-v1:0',
|
|
72
|
+
'claude-3-haiku': 'anthropic.claude-3-haiku-20240307-v1:0',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function resolveBedrockModelId(anthropicModelId) {
|
|
76
|
+
if (anthropicModelId) {
|
|
77
|
+
if (BEDROCK_MODEL_MAP[anthropicModelId]) return BEDROCK_MODEL_MAP[anthropicModelId];
|
|
78
|
+
const keys = Object.keys(BEDROCK_MODEL_MAP).sort((a, b) => b.length - a.length);
|
|
79
|
+
for (const key of keys) {
|
|
80
|
+
if (anthropicModelId.startsWith(key)) return BEDROCK_MODEL_MAP[key];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (BEDROCK_MODEL_ID) return BEDROCK_MODEL_ID;
|
|
84
|
+
throw new Error(`No Bedrock model ID for '${anthropicModelId}'. Set BEDROCK_MODEL_ID to override.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildBedrockUrl(region, modelId, profileArn) {
|
|
88
|
+
const segment = profileArn ? encodeURIComponent(profileArn) : encodeURIComponent(modelId);
|
|
89
|
+
if (BEDROCK_TEST_HOST) {
|
|
90
|
+
return `${BEDROCK_TEST_PROTOCOL}://${BEDROCK_TEST_HOST}:${BEDROCK_TEST_PORT}/model/${segment}/invoke-with-response-stream`;
|
|
91
|
+
}
|
|
92
|
+
return `https://bedrock-runtime.${region}.amazonaws.com/model/${segment}/invoke-with-response-stream`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Storage adapter (local by default, S3 via STORAGE_BACKEND=s3)
|
|
96
|
+
const storage = createStorage();
|
|
97
|
+
|
|
98
|
+
// Model → context window fallback mapping (used when LiteLLM data unavailable)
|
|
99
|
+
// https://docs.anthropic.com/en/docs/about-claude/models
|
|
100
|
+
const MODEL_CONTEXT_FALLBACK = {
|
|
101
|
+
'claude-opus-4': 200_000,
|
|
102
|
+
'claude-sonnet-4': 200_000,
|
|
103
|
+
'claude-haiku-4': 200_000,
|
|
104
|
+
'claude-3-5-sonnet': 200_000,
|
|
105
|
+
'claude-3-5-haiku': 200_000,
|
|
106
|
+
'claude-3-opus': 200_000,
|
|
107
|
+
'claude-3-sonnet': 200_000,
|
|
108
|
+
'claude-3-haiku': 200_000,
|
|
109
|
+
};
|
|
110
|
+
const DEFAULT_CONTEXT = 200_000;
|
|
111
|
+
|
|
112
|
+
// Extract effective model ID from system prompt (includes [1m] suffix if present).
|
|
113
|
+
// API request model field never includes [1m], but system prompt does:
|
|
114
|
+
// "The exact model ID is claude-opus-4-6[1m]."
|
|
115
|
+
function extractModelFromSystem(system) {
|
|
116
|
+
if (!Array.isArray(system)) return null;
|
|
117
|
+
for (const block of system) {
|
|
118
|
+
const text = typeof block === 'string' ? block : (block?.text || '');
|
|
119
|
+
const m = text.match(/exact model ID is (claude-[^\s.]+)/);
|
|
120
|
+
if (m) return m[1];
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getMaxContext(model, system) {
|
|
126
|
+
// Prefer model ID from system prompt (has [1m] suffix when applicable)
|
|
127
|
+
const effective = extractModelFromSystem(system) || model;
|
|
128
|
+
if (!effective) return DEFAULT_CONTEXT;
|
|
129
|
+
// 1) Explicit suffix: "claude-opus-4-6[1m]" → 1M
|
|
130
|
+
if (/\[1m\]/i.test(effective)) return 1_000_000;
|
|
131
|
+
// 2) Known Claude Code defaults (200K standard plan)
|
|
132
|
+
const stripped = effective.replace(/\[.*\]/, '');
|
|
133
|
+
const keys = Object.keys(MODEL_CONTEXT_FALLBACK).sort((a, b) => b.length - a.length);
|
|
134
|
+
for (const key of keys) {
|
|
135
|
+
if (stripped.startsWith(key)) return MODEL_CONTEXT_FALLBACK[key];
|
|
136
|
+
}
|
|
137
|
+
// 3) LiteLLM dynamic data — only for unknown models not in fallback table
|
|
138
|
+
const { getModelContext } = require('./pricing');
|
|
139
|
+
const dynamic = getModelContext(stripped);
|
|
140
|
+
if (dynamic) return dynamic;
|
|
141
|
+
return DEFAULT_CONTEXT;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Ensure logs dir exists; migrate from legacy location if needed
|
|
145
|
+
if (!fs.existsSync(LOGS_DIR)) {
|
|
146
|
+
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
|
147
|
+
// One-time migration from old package-relative logs/
|
|
148
|
+
const legacyIndex = path.join(LEGACY_LOGS_DIR, 'index.ndjson');
|
|
149
|
+
if (fs.existsSync(legacyIndex)) {
|
|
150
|
+
try {
|
|
151
|
+
const files = fs.readdirSync(LEGACY_LOGS_DIR);
|
|
152
|
+
for (const f of files) {
|
|
153
|
+
fs.renameSync(path.join(LEGACY_LOGS_DIR, f), path.join(LOGS_DIR, f));
|
|
154
|
+
}
|
|
155
|
+
console.log(`Migrated logs from ${LEGACY_LOGS_DIR} → ${LOGS_DIR}`);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error(`Log migration failed: ${e.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
PORT,
|
|
164
|
+
ANTHROPIC_HOST,
|
|
165
|
+
ANTHROPIC_PORT,
|
|
166
|
+
ANTHROPIC_PROTOCOL,
|
|
167
|
+
LOGS_DIR,
|
|
168
|
+
RESTORE_DAYS,
|
|
169
|
+
storage,
|
|
170
|
+
MODEL_CONTEXT_FALLBACK,
|
|
171
|
+
DEFAULT_CONTEXT,
|
|
172
|
+
getMaxContext,
|
|
173
|
+
// Bedrock
|
|
174
|
+
get IS_BEDROCK_MODE() { return IS_BEDROCK_MODE; },
|
|
175
|
+
set IS_BEDROCK_MODE(v) { IS_BEDROCK_MODE = v; },
|
|
176
|
+
BEDROCK_REGION,
|
|
177
|
+
BEDROCK_RESOLVED_REGION,
|
|
178
|
+
BEDROCK_ACTIVATION_SOURCE,
|
|
179
|
+
BEDROCK_PROFILE_ARN,
|
|
180
|
+
BEDROCK_MODEL_ID,
|
|
181
|
+
BEDROCK_BEARER_TOKEN,
|
|
182
|
+
BEDROCK_TEST_HOST,
|
|
183
|
+
BEDROCK_TEST_PORT,
|
|
184
|
+
BEDROCK_TEST_PROTOCOL,
|
|
185
|
+
get BEDROCK_CREDENTIALS() { return BEDROCK_CREDENTIALS; },
|
|
186
|
+
set BEDROCK_CREDENTIALS(v) { BEDROCK_CREDENTIALS = v; },
|
|
187
|
+
BEDROCK_MODEL_MAP,
|
|
188
|
+
resolveBedrockModelId,
|
|
189
|
+
buildBedrockUrl,
|
|
190
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { fork } = require('child_process');
|
|
5
|
+
|
|
6
|
+
// ── Cost Budget: JSONL reader via child process ─────────────────────
|
|
7
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
8
|
+
const TOKEN_LIMIT = 220_000;
|
|
9
|
+
const SUBSCRIPTION_USD = 200;
|
|
10
|
+
|
|
11
|
+
// 5-minute server-side cache
|
|
12
|
+
let costsCache = null;
|
|
13
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
function streamUsageEntries() {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const worker = fork(path.join(__dirname, 'cost-worker.js'), [], { silent: true });
|
|
18
|
+
const chunks = [];
|
|
19
|
+
let stderrBuf = '';
|
|
20
|
+
const timeout = setTimeout(() => {
|
|
21
|
+
worker.kill();
|
|
22
|
+
reject(new Error('Worker timeout (60s)'));
|
|
23
|
+
}, 60_000);
|
|
24
|
+
worker.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
25
|
+
worker.stderr.on('data', (chunk) => { stderrBuf += chunk; });
|
|
26
|
+
worker.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
27
|
+
worker.on('exit', (code) => {
|
|
28
|
+
clearTimeout(timeout);
|
|
29
|
+
if (code !== 0 && code !== null) {
|
|
30
|
+
reject(new Error(stderrBuf || `Worker exited with code ${code}`));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const raw = Buffer.concat(chunks).toString();
|
|
35
|
+
const data = JSON.parse(raw);
|
|
36
|
+
if (!Array.isArray(data)) {
|
|
37
|
+
reject(new Error(`Worker returned ${typeof data}, expected array`));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
resolve(data);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
reject(new Error(`Worker output parse error: ${e.message}`));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function floorToHour(tsMs) {
|
|
49
|
+
const d = new Date(tsMs);
|
|
50
|
+
d.setUTCMinutes(0, 0, 0);
|
|
51
|
+
return d.getTime();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function groupIntoBlocks(entries) {
|
|
55
|
+
if (!entries.length) return [];
|
|
56
|
+
const blocks = [];
|
|
57
|
+
let current = null;
|
|
58
|
+
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
const quotaTokens = (e.usage.input_tokens || 0) + (e.usage.output_tokens || 0);
|
|
61
|
+
const timeSinceBlockStart = current ? e.timestamp - current.startTime : Infinity;
|
|
62
|
+
const timeSinceLastEntry = current ? e.timestamp - current.lastTs : Infinity;
|
|
63
|
+
const needsNewBlock = !current || timeSinceBlockStart > FIVE_HOURS_MS || timeSinceLastEntry > FIVE_HOURS_MS;
|
|
64
|
+
|
|
65
|
+
if (needsNewBlock) {
|
|
66
|
+
const blockStart = floorToHour(e.timestamp);
|
|
67
|
+
current = {
|
|
68
|
+
startTime: blockStart,
|
|
69
|
+
endTime: blockStart + FIVE_HOURS_MS,
|
|
70
|
+
totalTokens: 0,
|
|
71
|
+
costUSD: 0,
|
|
72
|
+
models: new Set(),
|
|
73
|
+
firstTs: e.timestamp,
|
|
74
|
+
lastTs: e.timestamp,
|
|
75
|
+
};
|
|
76
|
+
blocks.push(current);
|
|
77
|
+
}
|
|
78
|
+
current.totalTokens += quotaTokens;
|
|
79
|
+
current.costUSD += e.costUSD || 0;
|
|
80
|
+
if (e.model) current.models.add(e.model);
|
|
81
|
+
current.lastTs = e.timestamp;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
return blocks.map(b => ({
|
|
86
|
+
startTime: new Date(b.startTime).toISOString(),
|
|
87
|
+
endTime: new Date(b.endTime).toISOString(),
|
|
88
|
+
totalTokens: b.totalTokens,
|
|
89
|
+
costUSD: b.costUSD,
|
|
90
|
+
models: [...b.models],
|
|
91
|
+
isActive: now < b.endTime && (now - b.lastTs) < FIVE_HOURS_MS,
|
|
92
|
+
_startMs: b.startTime,
|
|
93
|
+
_endMs: b.endTime,
|
|
94
|
+
_firstTs: b.firstTs,
|
|
95
|
+
_lastTs: b.lastTs,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function calculateBurnRate(block) {
|
|
100
|
+
if (!block.isActive) return null;
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const durationMin = (block._lastTs - block._firstTs) / 60_000;
|
|
103
|
+
if (durationMin <= 0) return null;
|
|
104
|
+
const tokensPerMinute = block.totalTokens / durationMin;
|
|
105
|
+
const costPerHour = (block.costUSD / durationMin) * 60;
|
|
106
|
+
const minutesRemaining = Math.max(0, (block._endMs - now) / 60_000);
|
|
107
|
+
const projectedAdditionalTokens = tokensPerMinute * minutesRemaining;
|
|
108
|
+
const projectedTotalTokens = block.totalTokens + projectedAdditionalTokens;
|
|
109
|
+
const projectedAdditionalCost = (costPerHour / 60) * minutesRemaining;
|
|
110
|
+
const projectedTotalCost = block.costUSD + projectedAdditionalCost;
|
|
111
|
+
return {
|
|
112
|
+
burnRate: { tokensPerMinute: Math.round(tokensPerMinute), costPerHour: Math.round(costPerHour * 100) / 100 },
|
|
113
|
+
projection: { totalTokens: Math.round(projectedTotalTokens), totalCost: Math.round(projectedTotalCost * 100) / 100 },
|
|
114
|
+
minutesRemaining: Math.round(minutesRemaining),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function groupByDay(entries) {
|
|
119
|
+
const days = {};
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
const date = new Date(e.timestamp).toLocaleDateString('sv-SE');
|
|
122
|
+
if (!days[date]) days[date] = { date, totalTokens: 0, costUSD: 0, models: new Set(), sessions: new Set() };
|
|
123
|
+
const d = days[date];
|
|
124
|
+
d.totalTokens +=
|
|
125
|
+
(e.usage.input_tokens || 0) +
|
|
126
|
+
(e.usage.output_tokens || 0) +
|
|
127
|
+
(e.usage.cache_creation_input_tokens || 0) +
|
|
128
|
+
(e.usage.cache_read_input_tokens || 0);
|
|
129
|
+
d.costUSD += e.costUSD || 0;
|
|
130
|
+
if (e.model) d.models.add(e.model);
|
|
131
|
+
if (e.sessionId) d.sessions.add(e.sessionId);
|
|
132
|
+
}
|
|
133
|
+
const result = [];
|
|
134
|
+
const now = new Date();
|
|
135
|
+
for (let i = 181; i >= 0; i--) {
|
|
136
|
+
const d = new Date(now);
|
|
137
|
+
d.setDate(d.getDate() - i);
|
|
138
|
+
const dateStr = d.toLocaleDateString('sv-SE');
|
|
139
|
+
const day = days[dateStr] || { date: dateStr, totalTokens: 0, costUSD: 0, models: new Set(), sessions: new Set() };
|
|
140
|
+
result.push({ date: day.date, totalTokens: day.totalTokens, costUSD: Math.round(day.costUSD * 100) / 100, models: [...day.models], sessionCount: day.sessions.size });
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function groupByMonth(entries) {
|
|
146
|
+
const months = {};
|
|
147
|
+
for (const e of entries) {
|
|
148
|
+
const d = new Date(e.timestamp);
|
|
149
|
+
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
150
|
+
if (!months[key]) months[key] = { month: key, totalTokens: 0, costUSD: 0, models: new Set() };
|
|
151
|
+
const m = months[key];
|
|
152
|
+
m.totalTokens +=
|
|
153
|
+
(e.usage.input_tokens || 0) +
|
|
154
|
+
(e.usage.output_tokens || 0) +
|
|
155
|
+
(e.usage.cache_creation_input_tokens || 0) +
|
|
156
|
+
(e.usage.cache_read_input_tokens || 0);
|
|
157
|
+
m.costUSD += e.costUSD || 0;
|
|
158
|
+
if (e.model) m.models.add(e.model);
|
|
159
|
+
}
|
|
160
|
+
return Object.values(months)
|
|
161
|
+
.map(m => ({ month: m.month, totalTokens: m.totalTokens, costUSD: Math.round(m.costUSD * 100) / 100, models: [...m.models] }))
|
|
162
|
+
.sort((a, b) => a.month.localeCompare(b.month));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let costsInflight = null;
|
|
166
|
+
|
|
167
|
+
function startComputation() {
|
|
168
|
+
if (costsInflight) return costsInflight;
|
|
169
|
+
costsInflight = (async () => {
|
|
170
|
+
try {
|
|
171
|
+
const usageEntries = await streamUsageEntries();
|
|
172
|
+
const blocks = groupIntoBlocks(usageEntries);
|
|
173
|
+
const daily = groupByDay(usageEntries);
|
|
174
|
+
const monthly = groupByMonth(usageEntries);
|
|
175
|
+
const data = { blocks, daily, monthly };
|
|
176
|
+
costsCache = { data, computedAt: Date.now() };
|
|
177
|
+
return data;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error('Cost computation failed:', err.message);
|
|
180
|
+
throw err;
|
|
181
|
+
} finally {
|
|
182
|
+
costsInflight = null;
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
return costsInflight;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function getOrComputeCosts() {
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
if (costsCache && (now - costsCache.computedAt) < CACHE_TTL_MS) {
|
|
191
|
+
return costsCache.data;
|
|
192
|
+
}
|
|
193
|
+
return startComputation();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Returns cached data immediately, or null if not ready yet.
|
|
197
|
+
// Triggers background computation if cache is stale/missing.
|
|
198
|
+
function getCostsCacheOrNull() {
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
if (costsCache && (now - costsCache.computedAt) < CACHE_TTL_MS) {
|
|
201
|
+
return costsCache.data;
|
|
202
|
+
}
|
|
203
|
+
// Kick off background computation but don't wait
|
|
204
|
+
startComputation().catch(() => {});
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Call at startup to begin warming the cache in the background
|
|
209
|
+
function warmUp() {
|
|
210
|
+
startComputation().catch(() => {});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
TOKEN_LIMIT,
|
|
215
|
+
SUBSCRIPTION_USD,
|
|
216
|
+
getOrComputeCosts,
|
|
217
|
+
getCostsCacheOrNull,
|
|
218
|
+
calculateBurnRate,
|
|
219
|
+
warmUp,
|
|
220
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Worker process for JSONL cost parsing — runs in a child process
|
|
4
|
+
// to avoid blocking the main event loop during heavy I/O + JSON.parse.
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const readline = require('readline');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
function calculateCostSimple(usage, model) {
|
|
12
|
+
// Simplified cost calc — doesn't need full pricing module
|
|
13
|
+
// Rates per token (not per million)
|
|
14
|
+
const rates = {
|
|
15
|
+
'claude-sonnet-4-5-20250514': { input: 3e-6, output: 15e-6, cache_read: 0.3e-6, cache_create: 3.75e-6 },
|
|
16
|
+
'claude-opus-4-5-20250514': { input: 15e-6, output: 75e-6, cache_read: 1.5e-6, cache_create: 18.75e-6 },
|
|
17
|
+
'claude-haiku-3-5-20241022': { input: 0.8e-6, output: 4e-6, cache_read: 0.08e-6, cache_create: 1e-6 },
|
|
18
|
+
};
|
|
19
|
+
// Find matching rate by prefix
|
|
20
|
+
let r = null;
|
|
21
|
+
for (const [k, v] of Object.entries(rates)) {
|
|
22
|
+
if (model && model.startsWith(k.split('-202')[0])) { r = v; break; }
|
|
23
|
+
}
|
|
24
|
+
if (!r) r = { input: 3e-6, output: 15e-6, cache_read: 0.3e-6, cache_create: 3.75e-6 }; // default sonnet
|
|
25
|
+
return (usage.input_tokens || 0) * r.input
|
|
26
|
+
+ (usage.output_tokens || 0) * r.output
|
|
27
|
+
+ (usage.cache_read_input_tokens || 0) * r.cache_read
|
|
28
|
+
+ (usage.cache_creation_input_tokens || 0) * r.cache_create;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function collectJsonlFiles(dir, results = []) {
|
|
32
|
+
let items;
|
|
33
|
+
try { items = await fs.promises.readdir(dir); } catch { return results; }
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
const fullPath = path.join(dir, item);
|
|
36
|
+
let stat;
|
|
37
|
+
try { stat = await fs.promises.stat(fullPath); } catch { continue; }
|
|
38
|
+
if (stat.isDirectory()) await collectJsonlFiles(fullPath, results);
|
|
39
|
+
else if (item.endsWith('.jsonl')) results.push(fullPath);
|
|
40
|
+
}
|
|
41
|
+
return results;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function processFile(filePath) {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const localEntries = [];
|
|
47
|
+
let rl;
|
|
48
|
+
try {
|
|
49
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
50
|
+
rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
51
|
+
} catch { resolve(localEntries); return; }
|
|
52
|
+
|
|
53
|
+
rl.on('line', (line) => {
|
|
54
|
+
if (!line.includes('"usage"')) return;
|
|
55
|
+
let obj;
|
|
56
|
+
try { obj = JSON.parse(line); } catch { return; }
|
|
57
|
+
const timestamp = obj.timestamp;
|
|
58
|
+
const msg = obj.message;
|
|
59
|
+
if (!timestamp || !msg || !msg.usage) return;
|
|
60
|
+
const usage = msg.usage;
|
|
61
|
+
const model = msg.model || obj.model || 'unknown';
|
|
62
|
+
const messageId = msg.id || null;
|
|
63
|
+
const totalTokens = (usage.input_tokens || 0) + (usage.output_tokens || 0)
|
|
64
|
+
+ (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);
|
|
65
|
+
if (totalTokens === 0) return;
|
|
66
|
+
const costUSD = calculateCostSimple(usage, model);
|
|
67
|
+
const sessionId = path.basename(filePath, '.jsonl');
|
|
68
|
+
localEntries.push({ timestamp: new Date(timestamp).getTime(), usage, costUSD, model, sessionId, messageId });
|
|
69
|
+
});
|
|
70
|
+
rl.on('close', () => resolve(localEntries));
|
|
71
|
+
rl.on('error', () => resolve(localEntries));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function run() {
|
|
76
|
+
const homedir = os.homedir();
|
|
77
|
+
const dirs = [
|
|
78
|
+
path.join(homedir, '.claude', 'projects'),
|
|
79
|
+
path.join(homedir, '.config', 'claude', 'projects'),
|
|
80
|
+
];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const entries = [];
|
|
83
|
+
|
|
84
|
+
for (const baseDir of dirs) {
|
|
85
|
+
try { await fs.promises.access(baseDir); } catch { continue; }
|
|
86
|
+
const jsonlFiles = await collectJsonlFiles(baseDir);
|
|
87
|
+
|
|
88
|
+
const BATCH_SIZE = 20;
|
|
89
|
+
for (let i = 0; i < jsonlFiles.length; i += BATCH_SIZE) {
|
|
90
|
+
const batch = jsonlFiles.slice(i, i + BATCH_SIZE);
|
|
91
|
+
const results = await Promise.all(batch.map(processFile));
|
|
92
|
+
for (const localEntries of results) {
|
|
93
|
+
for (const e of localEntries) {
|
|
94
|
+
if (e.messageId && seen.has(e.messageId)) continue;
|
|
95
|
+
if (e.messageId) seen.add(e.messageId);
|
|
96
|
+
entries.push(e);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
entries.sort((a, b) => a.timestamp - b.timestamp);
|
|
103
|
+
// Use stdout instead of IPC — process.send() can fail silently with large payloads
|
|
104
|
+
process.stdout.write(JSON.stringify(entries));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
run().catch(err => {
|
|
108
|
+
process.stderr.write(err.message);
|
|
109
|
+
process.exitCode = 1;
|
|
110
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ── CRC32 (IEEE 802.3 / zlib polynomial 0xEDB88320) ─────────────────
|
|
4
|
+
|
|
5
|
+
const CRC32_TABLE = (() => {
|
|
6
|
+
const table = new Uint32Array(256);
|
|
7
|
+
for (let i = 0; i < 256; i++) {
|
|
8
|
+
let c = i;
|
|
9
|
+
for (let j = 0; j < 8; j++) {
|
|
10
|
+
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
11
|
+
}
|
|
12
|
+
table[i] = c;
|
|
13
|
+
}
|
|
14
|
+
return table;
|
|
15
|
+
})();
|
|
16
|
+
|
|
17
|
+
function crc32(buf, offset, length) {
|
|
18
|
+
let crc = 0xFFFFFFFF;
|
|
19
|
+
const end = offset + length;
|
|
20
|
+
for (let i = offset; i < end; i++) {
|
|
21
|
+
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ buf[i]) & 0xFF];
|
|
22
|
+
}
|
|
23
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── AWS EventStream binary frame format ─────────────────────────────
|
|
27
|
+
//
|
|
28
|
+
// Prelude (12 bytes):
|
|
29
|
+
// [0-3] total_length uint32 BE — total bytes including prelude + headers + payload + CRCs
|
|
30
|
+
// [4-7] headers_length uint32 BE — byte length of headers section
|
|
31
|
+
// [8-11] prelude_crc uint32 BE — CRC32 of bytes [0..7]
|
|
32
|
+
//
|
|
33
|
+
// Headers (variable, headers_length bytes):
|
|
34
|
+
// Each header entry:
|
|
35
|
+
// [0] name_length uint8 — byte length of header name
|
|
36
|
+
// [1..n] name string — header name (name_length bytes)
|
|
37
|
+
// [n+1] value_type uint8 — 7 = string
|
|
38
|
+
// [n+2..n+3] value_length uint16 BE
|
|
39
|
+
// [n+4..] value string — value_length bytes
|
|
40
|
+
//
|
|
41
|
+
// Payload:
|
|
42
|
+
// total_length - 12 (prelude) - headers_length - 4 (message CRC) bytes
|
|
43
|
+
//
|
|
44
|
+
// Message CRC (4 bytes):
|
|
45
|
+
// CRC32 of all bytes from [0 .. total_length - 5]
|
|
46
|
+
|
|
47
|
+
class EventStreamDecoder {
|
|
48
|
+
constructor() {
|
|
49
|
+
this._buf = Buffer.alloc(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Push a chunk of binary data. Returns an array of result objects:
|
|
53
|
+
// { event: <parsed-event-object> } — success
|
|
54
|
+
// { error: 'crc_mismatch' } — prelude or message CRC failed
|
|
55
|
+
// { error: 'modelStreamErrorException', message } — Bedrock stream error event
|
|
56
|
+
// (empty frames / non-chunk events are silently skipped)
|
|
57
|
+
push(chunk) {
|
|
58
|
+
this._buf = Buffer.concat([this._buf, chunk]);
|
|
59
|
+
const results = [];
|
|
60
|
+
|
|
61
|
+
while (true) {
|
|
62
|
+
if (this._buf.length < 12) break; // need at least prelude
|
|
63
|
+
|
|
64
|
+
const totalLength = this._buf.readUInt32BE(0);
|
|
65
|
+
if (this._buf.length < totalLength) break; // incomplete frame
|
|
66
|
+
|
|
67
|
+
const headersLength = this._buf.readUInt32BE(4);
|
|
68
|
+
const preludeCrc = this._buf.readUInt32BE(8);
|
|
69
|
+
const expectedPreludeCrc = crc32(this._buf, 0, 8);
|
|
70
|
+
|
|
71
|
+
if (preludeCrc !== expectedPreludeCrc) {
|
|
72
|
+
results.push({ error: 'crc_mismatch' });
|
|
73
|
+
// Skip this frame and try to resync
|
|
74
|
+
this._buf = this._buf.slice(totalLength);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const messageCrc = this._buf.readUInt32BE(totalLength - 4);
|
|
79
|
+
const expectedMessageCrc = crc32(this._buf, 0, totalLength - 4);
|
|
80
|
+
|
|
81
|
+
if (messageCrc !== expectedMessageCrc) {
|
|
82
|
+
results.push({ error: 'crc_mismatch' });
|
|
83
|
+
this._buf = this._buf.slice(totalLength);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Parse headers
|
|
88
|
+
const headers = {};
|
|
89
|
+
let pos = 12;
|
|
90
|
+
const headersEnd = 12 + headersLength;
|
|
91
|
+
while (pos < headersEnd) {
|
|
92
|
+
const nameLen = this._buf[pos++];
|
|
93
|
+
const name = this._buf.slice(pos, pos + nameLen).toString('utf8');
|
|
94
|
+
pos += nameLen;
|
|
95
|
+
const valueType = this._buf[pos++];
|
|
96
|
+
if (valueType === 7) { // string
|
|
97
|
+
const valueLen = this._buf.readUInt16BE(pos);
|
|
98
|
+
pos += 2;
|
|
99
|
+
const value = this._buf.slice(pos, pos + valueLen).toString('utf8');
|
|
100
|
+
pos += valueLen;
|
|
101
|
+
headers[name] = value;
|
|
102
|
+
} else {
|
|
103
|
+
// Skip unknown value types gracefully
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Extract payload
|
|
109
|
+
const payloadStart = headersEnd;
|
|
110
|
+
const payloadEnd = totalLength - 4;
|
|
111
|
+
const payloadBuf = this._buf.slice(payloadStart, payloadEnd);
|
|
112
|
+
|
|
113
|
+
// Consume this frame from the buffer
|
|
114
|
+
this._buf = this._buf.slice(totalLength);
|
|
115
|
+
|
|
116
|
+
const eventType = headers[':event-type'] || '';
|
|
117
|
+
const messageType = headers[':message-type'] || '';
|
|
118
|
+
|
|
119
|
+
// Handle Bedrock stream error
|
|
120
|
+
if (eventType === 'modelStreamErrorException' || messageType === 'exception') {
|
|
121
|
+
let message = eventType;
|
|
122
|
+
try {
|
|
123
|
+
const body = JSON.parse(payloadBuf.toString('utf8'));
|
|
124
|
+
message = body.message || body.Message || eventType;
|
|
125
|
+
} catch {}
|
|
126
|
+
results.push({ error: 'modelStreamErrorException', message });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Only process 'chunk' events — others (initial-response, etc.) are skipped
|
|
131
|
+
if (eventType !== 'chunk' && payloadBuf.length === 0) continue;
|
|
132
|
+
|
|
133
|
+
// Decode payload: {"bytes":"<base64>"} → Anthropic SSE event JSON
|
|
134
|
+
let event = null;
|
|
135
|
+
try {
|
|
136
|
+
const payloadJson = JSON.parse(payloadBuf.toString('utf8'));
|
|
137
|
+
const decoded = Buffer.from(payloadJson.bytes || '', 'base64').toString('utf8');
|
|
138
|
+
if (decoded) event = JSON.parse(decoded);
|
|
139
|
+
} catch {}
|
|
140
|
+
|
|
141
|
+
if (event) results.push({ event });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return results;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { EventStreamDecoder, crc32 };
|